package handlers import ( "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "qr-scanner/config" "qr-scanner/models" "qr-scanner/services" "qr-scanner/utils" ) type UploadHandler struct { cfg config.Config store *services.TaskStore } func NewUploadHandler(cfg config.Config, store *services.TaskStore) *UploadHandler { return &UploadHandler{cfg: cfg, store: store} } func (h *UploadHandler) HandleUpload(c *gin.Context) { /* 上传处理策略: - 支持多文件上传:图片与 ZIP 可混合(ZIP 会自动解压并提取图片) - 自动过滤非图片文件 - 不对“重名图片”强制覆盖:单独上传的图片会在文件名后追加 _N;ZIP 内则保留相对路径区分 - 不暴露服务器绝对路径:后续结果只输出压缩包内相对路径/上传文件名 */ form, err := c.MultipartForm() if err != nil { fail(c, http.StatusBadRequest, "无法解析上传表单") return } files := form.File["files"] if len(files) == 0 { fail(c, http.StatusBadRequest, "未选择文件") return } taskID := utils.NewTaskID(time.Now()) taskDir := filepath.Join(h.cfg.TempDir, taskID) uploadDir := filepath.Join(taskDir, "upload") imageDir := filepath.Join(taskDir, "images") if err := os.MkdirAll(uploadDir, 0o755); err != nil { fail(c, http.StatusInternalServerError, "创建任务目录失败") return } if err := os.MkdirAll(imageDir, 0o755); err != nil { fail(c, http.StatusInternalServerError, "创建任务目录失败") return } allowedExt := map[string]struct{}{ ".png": {}, ".jpg": {}, ".jpeg": {}, ".bmp": {}, } var extracted []utils.ExtractedFile used := map[string]int{} for _, fh := range files { if fh == nil { continue } // 上传大小限制(单文件):避免单个文件过大导致磁盘/内存压力。 if h.cfg.MaxUploadMB > 0 && fh.Size > h.cfg.MaxUploadMB*1024*1024 { _ = os.RemoveAll(taskDir) fail(c, http.StatusBadRequest, "文件大小超出限制") return } name := strings.ReplaceAll(fh.Filename, "\\", "/") base := filepath.Base(name) ext := strings.ToLower(filepath.Ext(base)) if ext == ".zip" { // ZIP:落盘后再解压。解压过程会做路径穿越防护、总展开大小限制、加密包拒绝等安全校验。 zipPath := filepath.Join(uploadDir, "upload.zip") if err := c.SaveUploadedFile(fh, zipPath); err != nil { _ = os.RemoveAll(taskDir) fail(c, http.StatusInternalServerError, "保存压缩包失败") return } limits := utils.ZipLimits{ MaxFiles: h.cfg.MaxFiles, MaxTotalBytes: h.cfg.MaxZipTotalMB * 1024 * 1024, MaxFileBytes: h.cfg.MaxZipFileMB * 1024 * 1024, } out, err := utils.ExtractZip(zipPath, imageDir, allowedExt, limits) if err != nil { _ = os.RemoveAll(taskDir) fail(c, http.StatusBadRequest, err.Error()) return } extracted = append(extracted, out...) continue } if _, ok := allowedExt[ext]; !ok { // 非图片:直接忽略(符合 PRD 的“自动过滤非图片文件”规则)。 continue } rel := base if n := used[rel]; n > 0 { // 同名冲突:仅针对“直接上传的图片”,通过追加 _N 避免覆盖。 rel = strings.TrimSuffix(base, ext) + "_" + strconv.Itoa(n) + ext } used[base]++ dst := filepath.Join(imageDir, rel) if err := c.SaveUploadedFile(fh, dst); err != nil { _ = os.RemoveAll(taskDir) fail(c, http.StatusInternalServerError, "保存图片失败") return } extracted = append(extracted, utils.ExtractedFile{RelPath: rel, AbsPath: dst}) } if len(extracted) == 0 { _ = os.RemoveAll(taskDir) fail(c, http.StatusBadRequest, "未发现可识别的图片文件") return } if h.cfg.MaxFiles > 0 && len(extracted) > h.cfg.MaxFiles { _ = os.RemoveAll(taskDir) fail(c, http.StatusBadRequest, "文件数量超出限制") return } taskFiles := make([]services.TaskFile, 0, len(extracted)) for i, ef := range extracted { taskFiles = append(taskFiles, services.TaskFile{ Index: i + 1, RelPath: filepath.ToSlash(ef.RelPath), AbsPath: ef.AbsPath, }) } task := &services.Task{ ID: taskID, TempDir: taskDir, CreatedAt: time.Now(), UpdatedAt: time.Now(), Status: services.TaskUploaded, Files: taskFiles, } h.store.Put(task) respondOK(c, models.UploadResponse{TaskID: taskID, TotalFiles: len(taskFiles)}) }