feat: 完成附件存储重构与磁盘监控功能,同步物料档案与入库单规则更新
1. 新增跨平台磁盘空间监控能力,每日7点自动检测附件目录剩余空间,触发阈值告警 2. 重构附件存储方案为年/月/日/文件类型分层结构,统一MES与WMS的附件管理逻辑 3. 对齐物料档案与入库单的质量状态校验规则,仅合格品计入库存与出库 4. 实现入库单作废功能与区域库位的多级父子结构管理 5. 删除物料简称字段,补充规格型号与单位必填项,统一系统数据口径 6. 新增附件中心与磁盘状态查询接口,完善权限控制与操作日志
This commit is contained in:
@@ -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 UUID(crypto/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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user