Files

273 lines
8.7 KiB
Go
Raw Permalink Normal View History

package handler
import (
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// ---------------- 附件统一存储方案(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 nil, err
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" || !allowedExt[ext] {
return nil, fmt.Errorf("不支持的文件类型(仅支持 PDF/图片/Office/图纸/压缩包)")
}
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 nil, 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)
}
_ = 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
}
// 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
}
// 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)
if name == "" {
http.Error(w, "非法文件名", http.StatusBadRequest)
return
}
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(abs)
switch {
case strings.HasSuffix(base, ".pdf"):
ct = "application/pdf"
case strings.HasSuffix(base, ".jpg"), strings.HasSuffix(base, ".jpeg"):
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, filepath.Base(abs), time.Time{}, f)
}
// randHex 随机十六进制串(保留给其他场景的短随机名使用)。
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)[:n*2]
}