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

328 lines
11 KiB
Go
Raw Permalink 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 (
"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()
}