qr-scanner/handlers/upload.go

164 lines
4.3 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 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 会自动解压并提取图片)
- 自动过滤非图片文件
- 不对“重名图片”强制覆盖:单独上传的图片会在文件名后追加 _NZIP 内则保留相对路径区分
- 不暴露服务器绝对路径:后续结果只输出压缩包内相对路径/上传文件名
*/
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)})
}