package handler import ( "crypto/md5" "encoding/hex" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" "bj_power_wms/ent" "bj_power_wms/ent/attachment" "bj_power_wms/internal/svc" "github.com/google/uuid" "github.com/zeromicro/go-zero/core/logx" ) // 文件类型(大写枚举):决定存储目录 <根目录>/年/月/日/<文件类型>/.。 // 与 biz_type(业务对象:inbound/material/inspection/semi/outbound)正交—— // 目录按"文件类型"分层,业务归属仍由 biz_type+biz_id 表达。 const ( FileTypeProcessPDF = "PROCESS_PDF" // 工艺流程作业指导书 FileTypeInspectionPhoto = "INSPECTION_PHOTO" // 检验照片 FileTypeInspectionPDF = "INSPECTION_PDF" // 检验报告 FileTypeDrawing = "DRAWING" // 图纸 FileTypeProcessCard = "PROCESS_CARD" // 流程卡 FileTypePackagePhoto = "PACKAGE_PHOTO" // 包装照片 FileTypeExcelImport = "EXCEL_IMPORT" // Excel 导入 FileTypeOther = "OTHER" // 其他 ) var validFileTypes = map[string]bool{ FileTypeProcessPDF: true, FileTypeInspectionPhoto: true, FileTypeInspectionPDF: true, FileTypeDrawing: true, FileTypeProcessCard: true, FileTypePackagePhoto: true, FileTypeExcelImport: true, FileTypeOther: true, } // allowedAttachmentExt 允许的附件扩展名白名单(图纸/报告/图片/表格)。白名单而非黑名单:避免上传可执行文件。 var allowedAttachmentExt = map[string]bool{ ".pdf": true, ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".xlsx": true, ".xls": true, ".doc": true, ".docx": true, ".dwg": true, ".dxf": true, ".zip": true, ".txt": true, ".csv": true, } // attRoot 附件根目录(etc/*.yaml 的 Attachment.RootDir;缺省 attachments,不硬编码绝对路径)。 func attRoot(ctx *svc.ServiceContext) string { if ctx != nil && strings.TrimSpace(ctx.Config.Attachment.RootDir) != "" { return strings.TrimSpace(ctx.Config.Attachment.RootDir) } return "attachments" } // maxSizeFor 单文件上限:按文件类型取配置,未配置用 DefaultMaxSize。 func maxSizeFor(ctx *svc.ServiceContext, fileType string) int64 { cfg := ctx.Config.Attachment if cfg.MaxSize != nil { if v, ok := cfg.MaxSize[fileType]; ok && v > 0 { return v } } if cfg.DefaultMaxSize > 0 { return cfg.DefaultMaxSize } return 20 << 20 } // deriveFileType 未显式指定文件类型时,按业务类型 + 扩展名推断,兜底 OTHER。 func deriveFileType(bizType, ext string) string { ext = strings.ToLower(ext) switch { case ext == ".xlsx" || ext == ".xls" || ext == ".csv": return FileTypeExcelImport case bizType == "inspection": if ext == ".pdf" { return FileTypeInspectionPDF } if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" { return FileTypeInspectionPhoto } case bizType == "material" && (ext == ".pdf" || ext == ".dwg" || ext == ".dxf"): return FileTypeDrawing case bizType == "outbound": if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" { return FileTypePackagePhoto } if ext == ".pdf" { return FileTypeProcessPDF } } return FileTypeOther } // buildRelDir 相对目录 年/月/日/文件类型(月/日补零)。 func buildRelDir(fileType string, t time.Time) string { return fmt.Sprintf("%d/%02d/%02d/%s", t.Year(), int(t.Month()), t.Day(), fileType) } // safeAbsPath 把库内相对路径安全解析为根目录内的绝对路径(阻断 ../ 穿越)。 func safeAbsPath(root, rel string) (string, bool) { rel = strings.ReplaceAll(rel, "\\", "/") clean := filepath.Clean(filepath.Join(root, rel)) rootAbs, err := filepath.Abs(root) if err != nil { return "", false } abs, err := filepath.Abs(clean) if err != nil { return "", false } if abs != rootAbs && !strings.HasPrefix(abs, rootAbs+string(os.PathSeparator)) { return "", false } return abs, true } // uploadAttachmentHandler 上传附件(multipart/form-data,流式写入)。 // 表单字段:bizType、bizId、file、fileType(可选,大写枚举)。 // 落盘:<根目录>/年/月/日/<文件类型>/.;库内只存相对路径。 func uploadAttachmentHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(4 << 20); err != nil && err != http.ErrNotMultipart { // 允许继续(大文件会经 MultipartReader 流式读取;此处仅先解析小字段) } bizType := strings.TrimSpace(r.FormValue("bizType")) bizId := strings.TrimSpace(r.FormValue("bizId")) fileTypeReq := strings.ToUpper(strings.TrimSpace(r.FormValue("fileType"))) if bizType == "" || bizId == "" { fail(w, http.StatusBadRequest, "bizType 与 bizId 必填") return } file, header, err := r.FormFile("file") if err != nil { fail(w, http.StatusBadRequest, "未取到上传文件: "+err.Error()) return } defer file.Close() ext := strings.ToLower(filepath.Ext(header.Filename)) if ext == "" || !allowedAttachmentExt[ext] { fail(w, http.StatusBadRequest, "不支持的文件类型(仅支持 PDF/图片/Office/图纸/压缩包)") return } fileType := fileTypeReq if fileType == "" || !validFileTypes[fileType] { fileType = deriveFileType(bizType, ext) } maxSize := maxSizeFor(ctx, fileType) if header.Size > 0 && header.Size > maxSize { fail(w, http.StatusBadRequest, fmt.Sprintf("文件超过 %dMB 上限,请压缩后重传", maxSize>>20)) return } // 流式落盘:边写边算 MD5,内存占用恒定(绝不 ReadAll) root := attRoot(ctx) relDir := buildRelDir(fileType, timeNow()) absDir, okPath := safeAbsPath(root, relDir) if !okPath { fail(w, http.StatusBadRequest, "非法存储路径") return } if err := os.MkdirAll(absDir, 0o755); err != nil { fail(w, http.StatusInternalServerError, "创建附件目录失败: "+err.Error()) return } storedName := uuid.NewString() + ext relPath := relDir + "/" + storedName dst := filepath.Join(absDir, storedName) out, err := os.Create(dst) if err != nil { fail(w, http.StatusInternalServerError, "写入附件失败: "+err.Error()) return } hash := md5.New() written, err := io.Copy(io.MultiWriter(out, hash), io.LimitReader(file, maxSize+1)) _ = out.Close() if err != nil { _ = os.Remove(dst) fail(w, http.StatusInternalServerError, "保存附件失败: "+err.Error()) return } if written > maxSize { _ = os.Remove(dst) fail(w, http.StatusBadRequest, fmt.Sprintf("文件超过 %dMB 上限,请压缩后重传", maxSize>>20)) return } fileMD5 := hex.EncodeToString(hash.Sum(nil)) // MD5 秒传:同业务对象 + 同 MD5 已存在 → 直接返回既有记录(不重复落盘) if exist, e := ctx.EntClient.Attachment.Query(). Where(attachment.BizTypeEQ(bizType), attachment.BizIDEQ(bizId), attachment.FileMd5EQ(fileMD5), attachment.DeletedEQ(false)). Only(ctx0()); e == nil && exist != nil { _ = os.Remove(dst) ok(w, attachmentView(ctx, exist)) return } rec, err := ctx.EntClient.Attachment.Create(). SetBizType(bizType). SetBizID(bizId). SetFileName(header.Filename). SetFilePath(relPath). SetFileType(fileType). SetFileExt(strings.TrimPrefix(ext, ".")). SetFileMd5(fileMD5). SetFileSize(written). SetNillableMimeType(strPtr(header.Header.Get("Content-Type"))). SetNillableUploadedBy(strPtr(r.Header.Get("X-Username"))). Save(ctx0()) if err != nil { _ = os.Remove(dst) fail(w, http.StatusInternalServerError, "登记附件失败: "+err.Error()) return } ctx.EventLog.Write(ctx0(), "attachment.upload", r.Header.Get("X-Username"), bizType, bizId, "上传附件 "+header.Filename, map[string]any{"fileSize": written, "fileType": fileType}) ok(w, attachmentView(ctx, rec)) } } // attachmentViewRow 附件返回视图:附带可直接预览/下载的 URL(按 id 提供,避免中文/多级路径问题)。 type attachmentViewRow struct { ID int64 `json:"id"` BizType string `json:"bizType"` BizID string `json:"bizId"` FileType string `json:"fileType"` FileName string `json:"fileName"` FileSize int64 `json:"fileSize"` FileExt string `json:"fileExt"` MimeType string `json:"mimeType"` UploadedBy string `json:"uploadedBy"` CreatedAt int64 `json:"createdAt"` Archived bool `json:"archived"` Deleted bool `json:"deleted"` URL string `json:"url"` } func attachmentView(ctx *svc.ServiceContext, rec *ent.Attachment) *attachmentViewRow { return &attachmentViewRow{ ID: int64(rec.ID), BizType: rec.BizType, BizID: rec.BizID, FileType: rec.FileType, FileName: rec.FileName, FileSize: rec.FileSize, FileExt: rec.FileExt, MimeType: rec.MimeType, UploadedBy: rec.UploadedBy, CreatedAt: rec.CreatedAt, Archived: rec.Archived, Deleted: rec.Deleted, URL: "/uploads/" + strconv.FormatInt(int64(rec.ID), 10), } } // listAttachmentsHandler 查询某业务对象的附件列表(排除逻辑删除) // GET /api/attachments?bizType=&bizId= func listAttachmentsHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { bizType := r.URL.Query().Get("bizType") bizId := r.URL.Query().Get("bizId") if bizType == "" || bizId == "" { fail(w, http.StatusBadRequest, "bizType 与 bizId 必填") return } list, err := ctx.EntClient.Attachment.Query(). Where(attachment.BizTypeEQ(bizType), attachment.BizIDEQ(bizId), attachment.DeletedEQ(false)). Order(ent.Desc("created_at"), ent.Desc("id")). All(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } rows := make([]*attachmentViewRow, 0, len(list)) for _, rec := range list { rows = append(rows, attachmentView(ctx, rec)) } ok(w, map[string]any{"list": rows, "total": len(rows)}) } } // listAllAttachmentsHandler 附件中心:全量分页查询(按文件类型/业务类型/业务标识/文件名/时间筛选) // GET /api/attachments/all?fileType=&bizType=&bizId=&fileName=&startDate=&endDate=&includeDeleted=&page=&pageSize= func listAllAttachmentsHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { page := atoi(r.URL.Query().Get("page"), 1) pageSize := atoi(r.URL.Query().Get("pageSize"), 20) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 200 { pageSize = 20 } q := ctx.EntClient.Attachment.Query() if v := strings.TrimSpace(r.URL.Query().Get("fileType")); v != "" { q = q.Where(attachment.FileTypeEQ(strings.ToUpper(v))) } if v := strings.TrimSpace(r.URL.Query().Get("bizType")); v != "" { q = q.Where(attachment.BizTypeEQ(v)) } if v := strings.TrimSpace(r.URL.Query().Get("bizId")); v != "" { q = q.Where(attachment.BizIDContainsFold(v)) } if v := strings.TrimSpace(r.URL.Query().Get("fileName")); v != "" { q = q.Where(attachment.FileNameContainsFold(v)) } if v := strings.TrimSpace(r.URL.Query().Get("startDate")); v != "" { if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil { q = q.Where(attachment.CreatedAtGTE(t.Unix())) } } if v := strings.TrimSpace(r.URL.Query().Get("endDate")); v != "" { if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil { q = q.Where(attachment.CreatedAtLTE(t.Add(24 * time.Hour).Unix())) } } if r.URL.Query().Get("includeDeleted") != "true" { q = q.Where(attachment.DeletedEQ(false)) } total, err := q.Count(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")). Offset((page - 1) * pageSize).Limit(pageSize).All(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } rows := make([]*attachmentViewRow, 0, len(list)) for _, rec := range list { rows = append(rows, attachmentView(ctx, rec)) } // 磁盘状态一并返回,供页面顶部告警条展示 ok(w, map[string]any{"total": total, "list": rows, "page": page, "pageSize": pageSize, "disk": diskStatus(ctx)}) } } // deleteAttachmentHandler 删除附件(逻辑删除:DB 标记 deleted,物理文件由归档任务统一处理) // POST /api/attachments/delete body: { id } func deleteAttachmentHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { ID int64 `json:"id"` } if err := parseJSON(r, &req); err != nil || req.ID <= 0 { fail(w, http.StatusBadRequest, "参数错误:id 必填") return } rec, err := ctx.EntClient.Attachment.Get(ctx0(), int(req.ID)) if err != nil { fail(w, http.StatusNotFound, "附件不存在") return } if _, err := ctx.EntClient.Attachment.UpdateOneID(int(req.ID)). SetDeleted(true).SetDeletedAt(timeNow().Unix()). SetNillableDeletedBy(strPtr(r.Header.Get("X-Username"))). Save(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "删除附件失败: "+err.Error()) return } ctx.EventLog.Write(ctx0(), "attachment.delete", r.Header.Get("X-Username"), rec.BizType, rec.BizID, "删除附件 "+rec.FileName, nil) ok(w, map[string]any{"id": req.ID}) } } // serveUploadFileHandler 附件文件服务(按 id 提供,免鉴权便于 /新窗口预览;支持 Range 断点续传)。 // GET /uploads/:name(name = 附件 id,或历史扁平文件名兜底) func serveUploadFileHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { name := filepath.Base(r.URL.Path) if name == "" || name == "." || name == "/" { http.NotFound(w, r) return } root := attRoot(ctx) var absPath string var fileName string if id, e := strconv.ParseInt(name, 10, 64); e == nil && id > 0 { rec, err := ctx.EntClient.Attachment.Get(ctx0(), int(id)) if err != nil { http.NotFound(w, r) return } p, ok := safeAbsPath(root, rec.FilePath) if !ok { http.NotFound(w, r) return } absPath, fileName = p, rec.FileName } else { // 历史扁平文件(uuid.ext)兜底 p, ok := safeAbsPath(root, name) if !ok { http.NotFound(w, r) return } absPath, fileName = p, name } if _, err := os.Stat(absPath); err != nil { http.NotFound(w, r) return } if fileName != "" { // 中文文件名走 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(fileName))) } http.ServeFile(w, r, absPath) // ServeFile 自带 Range 支持 } } // diskStatusView 磁盘状态视图(供接口/页面告警条使用)。 type diskStatusView struct { RootDir string `json:"rootDir"` UsedPercent int `json:"usedPercent"` FreeBytes uint64 `json:"freeBytes"` TotalBytes uint64 `json:"totalBytes"` WarnPercent int `json:"warnPercent"` CritPercent int `json:"critPercent"` Level string `json:"level"` // ok / warn / critical Message string `json:"message"` CheckedUnix int64 `json:"checkedAt"` } // diskStatus 读取附件根目录所在磁盘剩余空间并给出告警级别。 func diskStatus(ctx *svc.ServiceContext) diskStatusView { cfg := ctx.Config.Attachment root := attRoot(ctx) v := diskStatusView{RootDir: root, WarnPercent: cfg.DiskWarnPercent, CritPercent: cfg.DiskCriticalPercent, Level: "ok", Message: "磁盘空间正常", CheckedUnix: timeNow().Unix()} used, free, total, err := diskUsage(root) if err != nil { // 目录尚不存在时退化为父目录统计 _ = os.MkdirAll(root, 0o755) used, free, total, err = diskUsage(root) } if err != nil { v.Level = "unknown" v.Message = "磁盘空间检测失败:" + err.Error() return v } v.UsedPercent, v.FreeBytes, v.TotalBytes = used, free, total freePercent := 100 - used switch { case freePercent < cfg.DiskCriticalPercent: v.Level = "critical" v.Message = fmt.Sprintf("磁盘剩余空间仅 %d%%,低于 %d%%,请立即执行备份归档清理(附件中心可一键归档)", freePercent, cfg.DiskCriticalPercent) case freePercent < cfg.DiskWarnPercent: v.Level = "warn" v.Message = fmt.Sprintf("磁盘剩余空间 %d%%,低于 %d%% 告警线,请及时清理", freePercent, cfg.DiskWarnPercent) } return v } // diskStatusHandler GET /api/attachments/disk-status func diskStatusHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ok(w, diskStatus(ctx)) } } // archiveAttachmentsHandler 归档:把早于「本地保留年数」的附件移动到外部备份目录(ArchiveDir) // 并标记 archived=TRUE(保留索引,可追溯)。ArchiveDir 为空时仅标记不移动。 // POST /api/attachments/archive body: { dryRun } func archiveAttachmentsHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { DryRun bool `json:"dryRun"` } _ = parseJSON(r, &req) cfg := ctx.Config.Attachment root := attRoot(ctx) years := cfg.LocalRetentionYears if years <= 0 { years = 1 } cutYear := timeNow().Year() - years // 早于该年份的附件可归档 recs, err := ctx.EntClient.Attachment.Query(). Where(attachment.ArchivedEQ(false), attachment.DeletedEQ(false)). All(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } moved, marked, failed := 0, 0, 0 for _, rec := range recs { if int(time.Unix(rec.CreatedAt, 0).Year()) > cutYear-1 { continue } if req.DryRun { marked++ continue } if cfg.ArchiveDir != "" { src, ok1 := safeAbsPath(root, rec.FilePath) dst, ok2 := safeAbsPath(cfg.ArchiveDir, rec.FilePath) if ok1 && ok2 { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil { if err := os.Rename(src, dst); err == nil { moved++ } else if err := copyFile(src, dst); err == nil { _ = os.Remove(src) moved++ } else { failed++ continue } } else { failed++ continue } } else { failed++ continue } } if _, err := ctx.EntClient.Attachment.UpdateOneID(rec.ID). SetArchived(true).SetArchivedAt(timeNow().Unix()).Save(ctx0()); err != nil { failed++ continue } marked++ } if !req.DryRun { ctx.EventLog.Write(ctx0(), "attachment.archive", r.Header.Get("X-Username"), "attachment", "archive", fmt.Sprintf("附件归档:移动%d 标记%d 失败%d(早于 %d 年)", moved, marked, failed, cutYear), map[string]any{"moved": moved, "marked": marked, "failed": failed}) } 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 := io.Copy(out, in); err != nil { _ = out.Close() return err } return out.Close() } // StartDiskMonitor 启动磁盘空间监控:每天 DiskCheckHour(默认7点) 检测一次, // 剩余空间 <20% 告警、<10% 提示清理(写日志 + 操作日志,页面顶部同步提示)。 func StartDiskMonitor(ctx *svc.ServiceContext) { go func() { hour := ctx.Config.Attachment.DiskCheckHour if hour < 0 || hour > 23 { hour = 7 } for { now := time.Now() next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.Local) if !next.After(now) { next = next.Add(24 * time.Hour) } time.Sleep(time.Until(next)) checkDiskOnce(ctx) } }() } func checkDiskOnce(ctx *svc.ServiceContext) { st := diskStatus(ctx) switch st.Level { case "critical": logx.Errorf("[磁盘监控] %s(已用 %d%%,剩余 %d%%)", st.Message, st.UsedPercent, 100-st.UsedPercent) ctx.EventLog.Write(ctx0(), "system.disk", "system", "disk", "attachment", st.Message, map[string]any{"usedPercent": st.UsedPercent, "freeBytes": st.FreeBytes}) case "warn": logx.Alert("[磁盘监控] " + st.Message) ctx.EventLog.Write(ctx0(), "system.disk", "system", "disk", "attachment", st.Message, map[string]any{"usedPercent": st.UsedPercent, "freeBytes": st.FreeBytes}) default: logx.Infof("[磁盘监控] 正常:剩余 %d%%(%s)", 100-st.UsedPercent, st.RootDir) } }