1. 新增跨平台磁盘空间监控能力,每日7点自动检测附件目录剩余空间,触发阈值告警 2. 重构附件存储方案为年/月/日/文件类型分层结构,统一MES与WMS的附件管理逻辑 3. 对齐物料档案与入库单的质量状态校验规则,仅合格品计入库存与出库 4. 实现入库单作废功能与区域库位的多级父子结构管理 5. 删除物料简称字段,补充规格型号与单位必填项,统一系统数据口径 6. 新增附件中心与磁盘状态查询接口,完善权限控制与操作日志
328 lines
11 KiB
Go
328 lines
11 KiB
Go
package handler
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"bj_power_mes/common/httpx"
|
||
"bj_power_mes/ent"
|
||
"bj_power_mes/internal/logic"
|
||
"bj_power_mes/internal/svc"
|
||
|
||
"github.com/zeromicro/go-zero/rest/pathvar"
|
||
)
|
||
|
||
// ---------- 附件(统一存储方案) ----------
|
||
//
|
||
// 落盘:<Upload.Dir>/年/月/日/<文件类型大写枚举>/<uuid>.<ext>
|
||
// 库内:filePath 只存相对路径;下载/预览按 id 提供,避免中文与多级路径问题。
|
||
|
||
// attachmentRow 附件返回视图(前端友好:附可直接预览/下载的 URL)。
|
||
type attachmentRow struct {
|
||
ID int `json:"id"`
|
||
BizType string `json:"bizType"`
|
||
BizID string `json:"bizId"`
|
||
FileType string `json:"fileType"`
|
||
FileName string `json:"fileName"`
|
||
FilePath string `json:"filePath"`
|
||
FileSize int `json:"fileSize"`
|
||
FileExt string `json:"fileExt"`
|
||
MimeType string `json:"mimeType"`
|
||
Uploader string `json:"uploader"`
|
||
Archived bool `json:"archived"`
|
||
Deleted bool `json:"deleted"`
|
||
DeletedBy string `json:"deletedBy"`
|
||
CreatedAt int64 `json:"createdAt"`
|
||
URL string `json:"url"`
|
||
PreviewURL string `json:"previewUrl"`
|
||
}
|
||
|
||
func toAttachmentRow(a *ent.Attachment) *attachmentRow {
|
||
// 预览走公开直链(文件名不可枚举:UUID + 日期目录),<img>/<iframe> 无法携带 JWT;
|
||
// 下载走带鉴权的 id 路由,由服务端给出 RFC5987 中文文件名与 Content-Disposition。
|
||
return &attachmentRow{
|
||
ID: a.ID, BizType: a.BizType, BizID: a.BizId, FileType: a.FileType,
|
||
FileName: a.FileName, FilePath: a.FilePath, FileSize: a.FileSize,
|
||
FileExt: a.FileExt, MimeType: a.MimeType, Uploader: a.Uploader,
|
||
Archived: a.Archived, Deleted: a.Deleted, DeletedBy: a.DeletedBy,
|
||
CreatedAt: a.CreatedAt.Unix(),
|
||
URL: "/api/v1/attachment/file/" + strconv.Itoa(a.ID) + "?dl=1",
|
||
PreviewURL: previewURL(a.FilePath),
|
||
}
|
||
}
|
||
|
||
// previewURL 相对路径 → 公开免鉴权直链(复用 /files 路由,天然支持多级目录)。
|
||
func previewURL(relPath string) string {
|
||
if relPath == "" {
|
||
return ""
|
||
}
|
||
return fileURL(relPath)
|
||
}
|
||
|
||
// AttachmentUploadHandler POST /attachment/upload
|
||
// multipart 字段:bizType(必填)、bizId(必填)、file(必填)、fileType(可选,大写枚举)
|
||
// 流式落盘 + MD5 秒传(同业务对象同 MD5 直接返回既有记录,不重复存文件)。
|
||
func AttachmentUploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseMultipartForm(4 << 20); err != nil && err != http.ErrNotMultipart {
|
||
httpx.BadRequest(w, "表单解析失败:"+err.Error())
|
||
return
|
||
}
|
||
bizType := strings.TrimSpace(r.FormValue("bizType"))
|
||
bizId := strings.TrimSpace(r.FormValue("bizId"))
|
||
if bizType == "" || bizId == "" {
|
||
httpx.BadRequest(w, "bizType 与 bizId 必填")
|
||
return
|
||
}
|
||
fileType := strings.ToUpper(strings.TrimSpace(r.FormValue("fileType")))
|
||
dir := svcCtx.Config.Upload.Dir
|
||
|
||
// 先解析扩展名推断文件类型,再取对应大小上限
|
||
if fileType == "" || !validFileTypes[fileType] {
|
||
// 由 saveTypedUpload 内部按扩展名推断(此处先给空值)
|
||
fileType = ""
|
||
}
|
||
maxBytes := uploadMaxBytes(svcCtx.Config.Upload.MaxSize, svcCtx.Config.Upload.MaxMB, fileType)
|
||
res, err := saveTypedUpload(r, "file", dir, bizType, fileType, maxBytes)
|
||
if err != nil {
|
||
httpx.Fail(w, 3311, err.Error())
|
||
return
|
||
}
|
||
|
||
// MD5 秒传:同业务对象 + 同 MD5 已存在 → 直接返回既有记录(删除刚落盘文件)
|
||
if exist, e := logic.New(svcCtx).FindByMD5(r.Context(), bizType, bizId, res.MD5); e == nil && exist != nil {
|
||
if abs, ok := safeAbsPath(dir, res.RelPath); ok {
|
||
_ = os.Remove(abs)
|
||
}
|
||
httpx.Ok(w, toAttachmentRow(exist))
|
||
return
|
||
}
|
||
|
||
uploader := operator(r, "")
|
||
rec, err := logic.New(svcCtx).AddAttachment(r.Context(), bizType, bizId,
|
||
res.RelPath2FileType(), res.FileName, res.RelPath, res.Ext, res.MD5, res.MimeType, int(res.Size), uploader)
|
||
if err != nil {
|
||
httpx.Fail(w, 3311, "附件登记失败:"+err.Error())
|
||
return
|
||
}
|
||
svcCtx.EventLog.Write(r.Context(), "attachment.upload", "", uploader, "attachment", bizId,
|
||
"上传附件 "+res.FileName, map[string]any{"bizType": bizType, "fileType": res.RelPath2FileType(), "size": res.Size})
|
||
httpx.Ok(w, toAttachmentRow(rec))
|
||
}
|
||
}
|
||
|
||
// AttachmentListHandler GET /attachments?bizType=&bizId= 某业务对象的附件列表
|
||
func AttachmentListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
data, err := logic.New(svcCtx).ListAttachments(r.Context(), q.Get("bizType"), q.Get("bizId"))
|
||
if err != nil {
|
||
httpx.Fail(w, 3312, err.Error())
|
||
return
|
||
}
|
||
rows := make([]*attachmentRow, 0, len(data))
|
||
for _, a := range data {
|
||
rows = append(rows, toAttachmentRow(a))
|
||
}
|
||
httpx.Ok(w, map[string]any{"list": rows, "total": len(rows)})
|
||
}
|
||
}
|
||
|
||
// AttachmentCenterHandler GET /attachment/all 附件中心(全量分页 + 磁盘状态)
|
||
// 参数:fileType / bizType / bizId / fileName / startDate / endDate / includeDeleted / page / pageSize
|
||
func AttachmentCenterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
page := atoiDefault(q.Get("page"), 1)
|
||
pageSize := atoiDefault(q.Get("pageSize"), 20)
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 200 {
|
||
pageSize = 20
|
||
}
|
||
var start, end time.Time
|
||
if v := strings.TrimSpace(q.Get("startDate")); v != "" {
|
||
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
|
||
start = t
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(q.Get("endDate")); v != "" {
|
||
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
|
||
end = t.Add(24*time.Hour - time.Second)
|
||
}
|
||
}
|
||
total, list, err := logic.New(svcCtx).ListAttachmentPage(r.Context(),
|
||
strings.ToUpper(strings.TrimSpace(q.Get("fileType"))), strings.TrimSpace(q.Get("bizType")),
|
||
strings.TrimSpace(q.Get("bizId")), strings.TrimSpace(q.Get("fileName")),
|
||
start, end, q.Get("includeDeleted") == "true", page, pageSize)
|
||
if err != nil {
|
||
httpx.Fail(w, 3312, err.Error())
|
||
return
|
||
}
|
||
rows := make([]*attachmentRow, 0, len(list))
|
||
for _, a := range list {
|
||
rows = append(rows, toAttachmentRow(a))
|
||
}
|
||
httpx.Ok(w, map[string]any{
|
||
"total": total, "list": rows, "page": page, "pageSize": pageSize,
|
||
"disk": diskStatus(svcCtx),
|
||
})
|
||
}
|
||
}
|
||
|
||
// AttachmentFileHandler GET /attachment/file/:id 下载/预览(支持 Range 断点续传;?dl=1 强制下载)
|
||
func AttachmentFileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
id := atoiDefault(pathvar.Vars(r)["id"], 0)
|
||
if id <= 0 {
|
||
httpx.BadRequest(w, "id 非法")
|
||
return
|
||
}
|
||
rec, err := logic.New(svcCtx).GetAttachment(r.Context(), id)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
abs, ok := safeAbsPath(svcCtx.Config.Upload.Dir, rec.FilePath)
|
||
if !ok {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if _, err := os.Stat(abs); err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
// 中文文件名走 RFC 5987;下载(dl=1)才强制 attachment,否则内联预览
|
||
disp := "inline"
|
||
if r.URL.Query().Get("dl") == "1" {
|
||
disp = "attachment"
|
||
}
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename*=UTF-8''%s`, disp, url.QueryEscape(rec.FileName)))
|
||
http.ServeFile(w, r, abs) // ServeFile 自带 Range 支持
|
||
}
|
||
}
|
||
|
||
// AttachmentDeleteHandler POST /attachment/delete {id} 逻辑删除(库内标记,物理文件交由归档任务处理)
|
||
func AttachmentDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Id int `json:"id"`
|
||
}
|
||
if err := httpx.ParseJSON(r, &req); err != nil || req.Id <= 0 {
|
||
httpx.BadRequest(w, "参数错误:id 必填")
|
||
return
|
||
}
|
||
att, err := logic.New(svcCtx).DeleteAttachment(r.Context(), req.Id, operator(r, ""))
|
||
if err != nil {
|
||
httpx.Fail(w, 3313, "删除附件失败:"+err.Error())
|
||
return
|
||
}
|
||
svcCtx.EventLog.Write(r.Context(), "attachment.delete", "", operator(r, ""), "attachment", att.BizId,
|
||
"删除附件 "+att.FileName, nil)
|
||
httpx.OkMessage(w, "删除成功", nil)
|
||
}
|
||
}
|
||
|
||
// AttachmentDiskStatusHandler GET /attachment/disk-status 磁盘剩余空间状态
|
||
func AttachmentDiskStatusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
httpx.Ok(w, diskStatus(svcCtx))
|
||
}
|
||
}
|
||
|
||
// AttachmentArchiveHandler POST /attachment/archive {dryRun} 归档
|
||
// 把「早于本地保留年数」的附件移动到 ArchiveDir 并标记 archived=true(保留索引可追溯)。
|
||
// ArchiveDir 为空时只标记不移动;dryRun=true 时仅返回将被归档的数量。
|
||
func AttachmentArchiveHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
DryRun bool `json:"dryRun"`
|
||
}
|
||
_ = httpx.ParseJSON(r, &req)
|
||
cfg := svcCtx.Config.Upload
|
||
years := cfg.LocalRetentionYears
|
||
if years <= 0 {
|
||
years = 1
|
||
}
|
||
cutYear := time.Now().Year() - years // 早于该年份的附件可归档
|
||
svc := logic.New(svcCtx)
|
||
recs, err := svc.ListArchivableAttachments(r.Context())
|
||
if err != nil {
|
||
httpx.Fail(w, 3314, err.Error())
|
||
return
|
||
}
|
||
moved, marked, failed := 0, 0, 0
|
||
for _, rec := range recs {
|
||
if rec.CreatedAt.Year() > cutYear-1 {
|
||
continue
|
||
}
|
||
if req.DryRun {
|
||
marked++
|
||
continue
|
||
}
|
||
if cfg.ArchiveDir != "" {
|
||
src, ok1 := safeAbsPath(cfg.Dir, rec.FilePath)
|
||
dst, ok2 := safeAbsPath(cfg.ArchiveDir, rec.FilePath)
|
||
if !ok1 || !ok2 {
|
||
failed++
|
||
continue
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||
failed++
|
||
continue
|
||
}
|
||
if err := os.Rename(src, dst); err != nil {
|
||
if err2 := copyFile(src, dst); err2 != nil {
|
||
failed++
|
||
continue
|
||
}
|
||
_ = os.Remove(src)
|
||
}
|
||
moved++
|
||
}
|
||
if err := svc.MarkArchived(r.Context(), rec.ID); err != nil {
|
||
failed++
|
||
continue
|
||
}
|
||
marked++
|
||
}
|
||
if !req.DryRun {
|
||
svcCtx.EventLog.Write(r.Context(), "attachment.archive", "", operator(r, ""), "attachment", "archive",
|
||
fmt.Sprintf("附件归档:移动%d 标记%d 失败%d(早于 %d 年)", moved, marked, failed, cutYear),
|
||
map[string]any{"moved": moved, "marked": marked, "failed": failed})
|
||
}
|
||
httpx.Ok(w, map[string]any{
|
||
"moved": moved, "marked": marked, "failed": failed,
|
||
"cutYear": cutYear, "archiveDir": cfg.ArchiveDir, "dryRun": req.DryRun,
|
||
})
|
||
}
|
||
}
|
||
|
||
// copyFile 兜底复制(跨盘符 Rename 失败时用)。
|
||
func copyFile(src, dst string) error {
|
||
in, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer in.Close()
|
||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||
return err
|
||
}
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := out.ReadFrom(in); err != nil {
|
||
_ = out.Close()
|
||
return err
|
||
}
|
||
return out.Close()
|
||
}
|