feat: 完成附件存储重构与磁盘监控功能,同步物料档案与入库单规则更新

1.  新增跨平台磁盘空间监控能力,每日7点自动检测附件目录剩余空间,触发阈值告警
2.  重构附件存储方案为年/月/日/文件类型分层结构,统一MES与WMS的附件管理逻辑
3.  对齐物料档案与入库单的质量状态校验规则,仅合格品计入库存与出库
4.  实现入库单作废功能与区域库位的多级父子结构管理
5.  删除物料简称字段,补充规格型号与单位必填项,统一系统数据口径
6.  新增附件中心与磁盘状态查询接口,完善权限控制与操作日志
This commit is contained in:
SunYF
2026-09-19 10:54:15 +08:00
parent 9002d9a642
commit 62e45b7379
95 changed files with 10467 additions and 2677 deletions
+269 -26
View File
@@ -1,43 +1,123 @@
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"
)
// AttachmentUploadHandler POST /attachment/upload 上传并登记(multipart: bizType,bizId,file
// ---------- 附件(统一存储方案) ----------
//
// 落盘:<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(32 << 20); err != nil {
httpx.BadRequest(w, "表单解析失败")
if err := r.ParseMultipartForm(4 << 20); err != nil && err != http.ErrNotMultipart {
httpx.BadRequest(w, "表单解析失败"+err.Error())
return
}
bizType := r.FormValue("bizType")
bizId := r.FormValue("bizId")
bizType := strings.TrimSpace(r.FormValue("bizType"))
bizId := strings.TrimSpace(r.FormValue("bizId"))
if bizType == "" || bizId == "" {
httpx.BadRequest(w, "bizType 与 bizId 必填")
return
}
rel, fname, size, err := saveUploadFileFull(r, "file", svcCtx.Config.Upload.Dir)
if err != nil {
httpx.Fail(w, 3311, "文件保存失败:"+err.Error())
return
fileType := strings.ToUpper(strings.TrimSpace(r.FormValue("fileType")))
dir := svcCtx.Config.Upload.Dir
// 先解析扩展名推断文件类型,再取对应大小上限
if fileType == "" || !validFileTypes[fileType] {
// 由 saveTypedUpload 内部按扩展名推断(此处先给空值)
fileType = ""
}
rec, err := logic.New(svcCtx).AddAttachment(r.Context(), bizType, bizId, fname, rel, int(size), operator(r, ""))
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
}
httpx.Ok(w, rec)
// 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= 附件列表
// AttachmentListHandler GET /attachments?bizType=&bizId= 某业务对象的附件列表
func AttachmentListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
@@ -46,39 +126,202 @@ func AttachmentListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
httpx.Fail(w, 3312, err.Error())
return
}
httpx.Ok(w, data)
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)})
}
}
// AttachmentDownloadHandler GET /attachment/download?name= 下载
func AttachmentDownloadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
// 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) {
name := r.URL.Query().Get("name")
if name == "" {
httpx.BadRequest(w, "缺少 name")
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
}
serveUploadFile(w, r, svcCtx.Config.Upload.Dir, name)
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),
})
}
}
// AttachmentDeleteHandler POST /attachment/delete {id} 删除(记录+物理文件
// 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 {
httpx.BadRequest(w, "请求体解析失败")
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)
att, err := logic.New(svcCtx).DeleteAttachment(r.Context(), req.Id, operator(r, ""))
if err != nil {
httpx.Fail(w, 3313, err.Error())
httpx.Fail(w, 3313, "删除附件失败:"+err.Error())
return
}
full := filepath.Join(svcCtx.Config.Upload.Dir, filepath.FromSlash(att.FilePath))
_ = os.Remove(full)
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()
}
@@ -0,0 +1,105 @@
package handler
import (
"context"
"fmt"
"net/http"
"os"
"time"
"bj_power_mes/common/httpx"
"bj_power_mes/internal/svc"
"github.com/zeromicro/go-zero/core/logx"
)
// 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 / unknown
Message string `json:"message"`
CheckedAt int64 `json:"checkedAt"`
}
// diskStatus 读取附件根目录所在磁盘剩余空间并给出告警级别。
// 剩余 < DiskCriticalPercent → critical(提示执行备份归档清理);< DiskWarnPercent → warn。
func diskStatus(svcCtx *svc.ServiceContext) diskStatusView {
cfg := svcCtx.Config.Upload
root := cfg.Dir
if root == "" {
root = "uploads"
}
v := diskStatusView{
RootDir: root, WarnPercent: cfg.DiskWarnPercent, CritPercent: cfg.DiskCriticalPercent,
Level: "ok", Message: "磁盘空间正常", CheckedAt: time.Now().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 /attachment/disk-status
func DiskStatusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
httpx.Ok(w, diskStatus(svcCtx))
}
}
// StartDiskMonitor 启动磁盘空间监控:每天 DiskCheckHour(默认 7 点) 检测一次,
// 剩余 <20% 告警、<10% 提示清理(写服务日志 + 操作日志,附件中心顶部同步展示)。
func StartDiskMonitor(svcCtx *svc.ServiceContext) {
go func() {
hour := svcCtx.Config.Upload.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(svcCtx)
}
}()
}
func checkDiskOnce(svcCtx *svc.ServiceContext) {
st := diskStatus(svcCtx)
switch st.Level {
case "critical":
logx.Errorf("[磁盘监控] %s(已用 %d%%,剩余 %d%%", st.Message, st.UsedPercent, 100-st.UsedPercent)
svcCtx.EventLog.Write(context.Background(), "system.disk", "", "system", "attachment", "disk", st.Message,
map[string]any{"usedPercent": st.UsedPercent, "freeBytes": st.FreeBytes})
case "warn":
logx.Alert("[磁盘监控] " + st.Message)
svcCtx.EventLog.Write(context.Background(), "system.disk", "", "system", "attachment", "disk", st.Message,
map[string]any{"usedPercent": st.UsedPercent, "freeBytes": st.FreeBytes})
default:
logx.Infof("[磁盘监控] 正常:剩余 %d%%%s", 100-st.UsedPercent, st.RootDir)
}
}
@@ -0,0 +1,19 @@
//go:build !windows
package handler
import "golang.org/x/sys/unix"
// diskUsage 返回指定目录所在磁盘的已用百分比(0-100)、可用字节、总字节。
func diskUsage(path string) (usedPercent int, freeBytes, totalBytes uint64, err error) {
var st unix.Statfs_t
if err := unix.Statfs(path, &st); err != nil {
return 0, 0, 0, err
}
total := st.Blocks * uint64(st.Bsize)
free := st.Bavail * uint64(st.Bsize)
if total == 0 {
return 0, free, total, nil
}
return int((total - free) * 100 / total), free, total, nil
}
@@ -0,0 +1,22 @@
//go:build windows
package handler
import "golang.org/x/sys/windows"
// diskUsage 返回指定目录所在磁盘的已用百分比(0-100)、可用字节、总字节。
func diskUsage(path string) (usedPercent int, freeBytes, totalBytes uint64, err error) {
p, e := windows.UTF16PtrFromString(path)
if e != nil {
return 0, 0, 0, e
}
var freeAvail, total, totalFree uint64
if e := windows.GetDiskFreeSpaceEx(p, &freeAvail, &total, &totalFree); e != nil {
return 0, 0, 0, e
}
if total == 0 {
return 0, totalFree, total, nil
}
used := total - totalFree
return int(used * 100 / total), totalFree, total, nil
}
+5 -3
View File
@@ -71,19 +71,21 @@ func ListInspectionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
// UploadPhotoHandler POST /inspections/upload(巡检拍照上传,返回文件名
// UploadPhotoHandler POST /inspections/upload(巡检/异常拍照上传)
// 统一存储:<Upload.Dir>/年/月/日/INSPECTION_PHOTO/<uuid>.<ext>;返回相对路径 + 免鉴权直链。
func UploadPhotoHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(svcCtx.Config.Upload.MaxMB << 20); err != nil {
httpx.BadRequest(w, "上传文件过大或格式错误")
return
}
name, err := saveUploadFile(r, "file", svcCtx.Config.Upload.Dir)
res, err := saveTypedUpload(r, "file", svcCtx.Config.Upload.Dir, "inspection", FileTypeInspectionPhoto,
uploadMaxBytes(svcCtx.Config.Upload.MaxSize, svcCtx.Config.Upload.MaxMB, FileTypeInspectionPhoto))
if err != nil {
httpx.Fail(w, 2303, "照片保存失败:"+err.Error())
return
}
httpx.Ok(w, map[string]any{"filename": name, "url": "/api/v1/files/" + name})
httpx.Ok(w, map[string]any{"filename": res.RelPath, "url": "/api/v1/files/" + res.RelPath})
}
}
+13 -2
View File
@@ -59,12 +59,13 @@ func UploadPdfHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
httpx.BadRequest(w, "上传文件过大或格式错误")
return
}
name, err := saveUploadFile(r, "file", svcCtx.Config.Upload.Dir)
res, err := saveTypedUpload(r, "file", svcCtx.Config.Upload.Dir, "process_flow", FileTypeProcessPDF,
uploadMaxBytes(svcCtx.Config.Upload.MaxSize, svcCtx.Config.Upload.MaxMB, FileTypeProcessPDF))
if err != nil {
httpx.Fail(w, 2204, "文件保存失败:"+err.Error())
return
}
httpx.Ok(w, map[string]any{"filename": name, "url": "/api/v1/files/" + name})
httpx.Ok(w, map[string]any{"filename": res.RelPath, "url": "/api/v1/files/" + res.RelPath})
}
}
@@ -84,6 +85,16 @@ func FilePathHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
// FileDeepPathHandler GET /files/:y/:m/:d/:t/:f —— 统一存储格式的路径式 URL
// (年/月/日/文件类型/uuid.ext)。go-zero 路由不支持多段通配,故按固定 5 段显式声明。
func FileDeepPathHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := pathvar.Vars(r)
rel := vars["y"] + "/" + vars["m"] + "/" + vars["d"] + "/" + vars["t"] + "/" + vars["f"]
serveUploadFile(w, r, svcCtx.Config.Upload.Dir, rel)
}
}
// ListStationsHandler GET /stations
func ListStationsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -212,10 +212,13 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
{Method: http.MethodGet, Path: "/alerts", Handler: production.ListAlertsHandler(serverCtx)},
{Method: http.MethodPost, Path: "/alert/read", Handler: production.MarkAlertReadHandler(serverCtx)},
// ---------- 附件中心(M10 ----------
// ---------- 附件中心(统一存储方案 2026-09-19 ----------
{Method: http.MethodPost, Path: "/attachment/upload", Handler: AttachmentUploadHandler(serverCtx)},
{Method: http.MethodGet, Path: "/attachments", Handler: AttachmentListHandler(serverCtx)},
{Method: http.MethodGet, Path: "/attachment/download", Handler: AttachmentDownloadHandler(serverCtx)},
{Method: http.MethodGet, Path: "/attachment/all", Handler: AttachmentCenterHandler(serverCtx)},
{Method: http.MethodGet, Path: "/attachment/file/:id", Handler: AttachmentFileHandler(serverCtx)},
{Method: http.MethodGet, Path: "/attachment/disk-status", Handler: DiskStatusHandler(serverCtx)},
{Method: http.MethodPost, Path: "/attachment/archive", Handler: AttachmentArchiveHandler(serverCtx)},
{Method: http.MethodPost, Path: "/attachment/delete", Handler: AttachmentDeleteHandler(serverCtx)},
// ---------- 工艺流程/工位(块3 ----------
@@ -266,8 +269,9 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
rest.WithMiddlewares([]rest.Middleware{permissionGuard(serverCtx)}, jwtRoutes...),
rest.WithPrefix("/api/v1"),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
// 请求体上限跟随 Upload.MaxMB(默认20MB,否则 go-zero 默认 1MB 会 413PDF/照片上传在此分组)
rest.WithMaxBytes(serverCtx.Config.Upload.MaxMB<<20),
// 请求体上限取「Upload.MaxMB 与各文件类型 MaxSize 的最大值」,否则 go-zero 默认 1MB 会 413
// (PDF/照片上传在此分组;大写枚举若配了 50MB,此处必须放宽到 50MB 才不会被网关先拦)
rest.WithMaxBytes(maxUploadBytes(serverCtx.Config.Upload.MaxMB, serverCtx.Config.Upload.MaxSize)),
)
// ---------- 公开文件访问(免 JWT ----------
+221 -54
View File
@@ -1,69 +1,251 @@
package handler
import (
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// saveUploadFile 保存上传文件(工艺图纸PDF / 巡检照片),返回生成的相对文件名(含日期子目录)
func saveUploadFile(r *http.Request, field, dir string) (string, error) {
sub := time.Now().Format("2006-01-02")
full := filepath.Join(dir, sub)
if err := os.MkdirAll(full, 0o755); err != nil {
return "", err
// ---------------- 附件统一存储方案(2026-09-19 ----------------
//
// 目录结构:<Upload.Dir>/年/月/日/<文件类型>/<uuid>.<ext>
// - 年月日按当前日期自动生成(月/日补零)
// - 业务类型目录全部大写(见下方枚举)
// - 文件名统一 UUID,扩展名小写,不含中文/空格/特殊字符
// - 数据库只存相对路径(如 2026/09/19/PROCESS_PDF/xxx.pdf),不存绝对路径
// - 根目录从 etc/*.yaml 的 Upload.Dir 读取,不硬编码
// 文件类型枚举(全部大写)
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,
}
// allowedExt 允许的扩展名白名单(图纸/报告/图片/表格/压缩包)。白名单而非黑名单,避免上传可执行文件。
var allowedExt = 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,
}
// 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 == "work_order":
if ext == ".pdf" {
return FileTypeProcessCard
}
case bizType == "process_flow" && 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
}
// newUUID 生成 RFC4122 v4 UUIDcrypto/rand,不引第三方依赖)。
func newUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// UploadResult 上传落盘结果。
type UploadResult struct {
RelPath string // 相对路径:年/月/日/文件类型/uuid.ext
FileName string // 原始文件名(含扩展名)
Size int64
MD5 string
Ext string // 小写,不含点
MimeType string
}
// RelPath2FileType 从相对路径反推文件类型目录名(年/月/日/类型/uuid.ext → 类型)。
// 供处理器在落盘后回填库内 fileType(避免再解析一次文件名)。
func (u *UploadResult) RelPath2FileType() string {
parts := strings.Split(u.RelPath, "/")
if len(parts) >= 2 {
if ft := parts[len(parts)-2]; validFileTypes[ft] {
return ft
}
}
return FileTypeOther
}
// saveTypedUpload 流式保存上传文件(边读边算 MD5,内存占用恒定;绝不 ReadAll)。
// fileType 为空或非法时按 bizType+扩展名推断;maxBytes<=0 时用 Upload.MaxMB。
// 返回相对路径(年/月/日/文件类型/uuid.ext)与原始文件名/大小/MD5。
func saveTypedUpload(r *http.Request, field, rootDir, bizType, fileType string, maxBytes int64) (*UploadResult, error) {
file, header, err := r.FormFile(field)
if err != nil {
return "", err
return nil, err
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" {
ext = ".bin"
if ext == "" || !allowedExt[ext] {
return nil, fmt.Errorf("不支持的文件类型(仅支持 PDF/图片/Office/图纸/压缩包)")
}
name := time.Now().Format("20060102150405") + "_" + randHex(6) + ext
dst, err := os.Create(filepath.Join(full, name))
if fileType == "" || !validFileTypes[strings.ToUpper(fileType)] {
fileType = deriveFileType(bizType, ext)
} else {
fileType = strings.ToUpper(fileType)
}
if maxBytes > 0 && header.Size > 0 && header.Size > maxBytes {
return nil, fmt.Errorf("文件超过 %dMB 上限,请压缩后重传", maxBytes>>20)
}
relDir := buildRelDir(fileType, time.Now())
absDir, ok := safeAbsPath(rootDir, relDir)
if !ok {
return nil, fmt.Errorf("非法存储路径")
}
if err := os.MkdirAll(absDir, 0o755); err != nil {
return nil, err
}
storedName := newUUID() + ext
relPath := relDir + "/" + storedName
dst := filepath.Join(absDir, storedName)
out, err := os.Create(dst)
if err != nil {
return "", err
return nil, err
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
return "", err
hash := md5.New()
var written int64
if maxBytes > 0 {
written, err = io.Copy(io.MultiWriter(out, hash), io.LimitReader(file, maxBytes+1))
} else {
written, err = io.Copy(io.MultiWriter(out, hash), file)
}
return filepath.ToSlash(filepath.Join(sub, name)), nil
_ = out.Close()
if err != nil {
_ = os.Remove(dst)
return nil, err
}
if maxBytes > 0 && written > maxBytes {
_ = os.Remove(dst)
return nil, fmt.Errorf("文件超过 %dMB 上限,请压缩后重传", maxBytes>>20)
}
return &UploadResult{
RelPath: relPath,
FileName: header.Filename,
Size: written,
MD5: hex.EncodeToString(hash.Sum(nil)),
Ext: strings.TrimPrefix(ext, "."),
MimeType: header.Header.Get("Content-Type"),
}, nil
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)[:n*2]
// uploadMaxBytes 单文件上限:按文件类型取 Upload.MaxSize 配置,未配置用 Upload.MaxMB。
func uploadMaxBytes(maxSizes map[string]int64, maxMB int64, fileType string) int64 {
if maxSizes != nil {
if v, ok := maxSizes[strings.ToUpper(fileType)]; ok && v > 0 {
return v
}
}
if maxMB > 0 {
return maxMB << 20
}
return 20 << 20
}
// serveUploadFile 下发上传目录中的文件(仅允许 单层日期子目录/文件名,防路径穿越)
// maxUploadBytes 计算路由组请求体上限:取 MaxMB 与各文件类型 MaxSize 的最大值(兜底 20MB)。
func maxUploadBytes(maxMB int64, maxSizes map[string]int64) int64 {
limit := maxMB << 20
if limit <= 0 {
limit = 20 << 20
}
for _, v := range maxSizes {
if v > limit {
limit = v
}
}
return limit
}
// fileURL 由相对路径生成免鉴权直链(query 传名,天然支持多级目录)。
// 注:go-zero 路由不支持多段通配,故统一走 /api/v1/files?name=<urlencoded 相对路径>。
func fileURL(relPath string) string {
return "/api/v1/files?name=" + url.QueryEscape(relPath)
}
// serveUploadFile 下发上传目录中的文件(相对路径可为多级「年/月/日/类型/文件名」,防路径穿越)。
func serveUploadFile(w http.ResponseWriter, r *http.Request, dir, name string) {
name = strings.TrimSpace(name)
clean := filepath.Clean(filepath.FromSlash(name))
if clean == "." || clean == ".." ||
strings.HasPrefix(clean, ".."+string(filepath.Separator)) ||
filepath.IsAbs(clean) || strings.ContainsRune(name, '\x00') {
if name == "" {
http.Error(w, "非法文件名", http.StatusBadRequest)
return
}
f, err := os.Open(filepath.Join(dir, clean))
abs, ok := safeAbsPath(dir, name)
if !ok {
http.Error(w, "非法文件名", http.StatusBadRequest)
return
}
f, err := os.Open(abs)
if err != nil {
http.Error(w, "文件不存在", http.StatusNotFound)
return
}
defer f.Close()
ct := "application/octet-stream"
base := strings.ToLower(clean)
base := strings.ToLower(abs)
switch {
case strings.HasSuffix(base, ".pdf"):
ct = "application/pdf"
@@ -71,35 +253,20 @@ func serveUploadFile(w http.ResponseWriter, r *http.Request, dir, name string) {
ct = "image/jpeg"
case strings.HasSuffix(base, ".png"):
ct = "image/png"
case strings.HasSuffix(base, ".gif"):
ct = "image/gif"
case strings.HasSuffix(base, ".xlsx"):
ct = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case strings.HasSuffix(base, ".xls"):
ct = "application/vnd.ms-excel"
}
w.Header().Set("Content-Type", ct)
http.ServeContent(w, r, name, time.Time{}, f)
http.ServeContent(w, r, filepath.Base(abs), time.Time{}, f)
}
// saveUploadFileFull 同 saveUploadFile,但额外返回原始文件名与字节大小(附件登记用)
func saveUploadFileFull(r *http.Request, field, dir string) (string, string, int64, error) {
sub := time.Now().Format("2006-01-02")
full := filepath.Join(dir, sub)
if err := os.MkdirAll(full, 0o755); err != nil {
return "", "", 0, err
}
file, header, err := r.FormFile(field)
if err != nil {
return "", "", 0, err
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" {
ext = ".bin"
}
name := time.Now().Format("20060102150405") + "_" + randHex(6) + ext
dst, err := os.Create(filepath.Join(full, name))
if err != nil {
return "", "", 0, err
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
return "", "", 0, err
}
return filepath.ToSlash(filepath.Join(sub, name)), header.Filename, header.Size, nil
// randHex 随机十六进制串(保留给其他场景的短随机名使用)
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)[:n*2]
}