Files
bj_power/bj_power_wms/internal/handler/attachment.go
T
SunYF 62e45b7379 feat: 完成附件存储重构与磁盘监控功能,同步物料档案与入库单规则更新
1.  新增跨平台磁盘空间监控能力,每日7点自动检测附件目录剩余空间,触发阈值告警
2.  重构附件存储方案为年/月/日/文件类型分层结构,统一MES与WMS的附件管理逻辑
3.  对齐物料档案与入库单的质量状态校验规则,仅合格品计入库存与出库
4.  实现入库单作废功能与区域库位的多级父子结构管理
5.  删除物料简称字段,补充规格型号与单位必填项,统一系统数据口径
6.  新增附件中心与磁盘状态查询接口,完善权限控制与操作日志
2026-09-19 10:54:15 +08:00

596 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
// 文件类型(大写枚举):决定存储目录 <根目录>/年/月/日/<文件类型>/<uuid>.<ext>。
// 与 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(可选,大写枚举)。
// 落盘:<根目录>/年/月/日/<文件类型>/<uuid>.<ext>;库内只存相对路径。
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 提供,免鉴权便于 <img>/新窗口预览;支持 Range 断点续传)。
// GET /uploads/:namename = 附件 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)
}
}