164 lines
4.3 KiB
Go
164 lines
4.3 KiB
Go
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)})
|
||
}
|