From c0680a6ae4c4d9980f2455c1834494d753c847cb Mon Sep 17 00:00:00 2001
From: fuzhongyun <15339891972@163.com>
Date: Sat, 28 Feb 2026 18:01:53 +0800
Subject: [PATCH] first commit
---
.dockerignore | 11 ++
.gitignore | 3 +
Dockerfile | 43 +++++
README.md | 73 ++++++++
config/config.go | 76 ++++++++
go.mod | 45 +++++
go.sum | 106 +++++++++++
handlers/cancel.go | 49 ++++++
handlers/common.go | 17 ++
handlers/export.go | 57 ++++++
handlers/progress.go | 97 ++++++++++
handlers/results.go | 50 ++++++
handlers/scan.go | 71 ++++++++
handlers/upload.go | 163 +++++++++++++++++
main.go | 65 +++++++
models/request.go | 8 +
models/response.go | 34 ++++
models/result.go | 14 ++
services/exporter.go | 82 +++++++++
services/fs.go | 11 ++
services/progress.go | 138 +++++++++++++++
services/scanner.go | 235 +++++++++++++++++++++++++
services/scanner_test.go | 64 +++++++
services/store.go | 59 +++++++
services/task.go | 80 +++++++++
static/app.js | 369 +++++++++++++++++++++++++++++++++++++++
static/index.html | 156 +++++++++++++++++
static/style.css | 60 +++++++
utils/archive.go | 163 +++++++++++++++++
utils/archive_test.go | 34 ++++
utils/id.go | 15 ++
utils/qrcode.go | 115 ++++++++++++
utils/url.go | 15 ++
33 files changed, 2578 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 README.md
create mode 100644 config/config.go
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 handlers/cancel.go
create mode 100644 handlers/common.go
create mode 100644 handlers/export.go
create mode 100644 handlers/progress.go
create mode 100644 handlers/results.go
create mode 100644 handlers/scan.go
create mode 100644 handlers/upload.go
create mode 100644 main.go
create mode 100644 models/request.go
create mode 100644 models/response.go
create mode 100644 models/result.go
create mode 100644 services/exporter.go
create mode 100644 services/fs.go
create mode 100644 services/progress.go
create mode 100644 services/scanner.go
create mode 100644 services/scanner_test.go
create mode 100644 services/store.go
create mode 100644 services/task.go
create mode 100644 static/app.js
create mode 100644 static/index.html
create mode 100644 static/style.css
create mode 100644 utils/archive.go
create mode 100644 utils/archive_test.go
create mode 100644 utils/id.go
create mode 100644 utils/qrcode.go
create mode 100644 utils/url.go
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5c3fc41
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+.git
+.github
+.trae
+deploy
+**/*_test.go
+**/*.log
+**/*.tmp
+**/*.swp
+**/.DS_Store
+**/node_modules
+**/.vscode
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..660b06e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.trae
+.vscode
+__debug*
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..9e16b9c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,43 @@
+FROM golang:1.21-alpine AS builder
+
+ARG APK_MIRROR=https://mirrors.aliyun.com/alpine
+ARG GOPROXY=https://goproxy.cn,direct
+
+RUN set -eux; \
+ printf '%s\n' \
+ "${APK_MIRROR}/v3.20/main" \
+ "${APK_MIRROR}/v3.20/community" > /etc/apk/repositories; \
+ apk add --no-cache ca-certificates tzdata git
+
+WORKDIR /src
+
+COPY go.mod go.sum ./
+ENV GOPROXY=${GOPROXY}
+
+RUN go mod download
+
+COPY . .
+
+RUN set -eux; \
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
+ go build -trimpath -ldflags="-s -w" -o /out/qr-scanner .
+
+FROM alpine:3.20
+
+ARG APK_MIRROR=https://mirrors.aliyun.com/alpine
+
+RUN set -eux; \
+ printf '%s\n' \
+ "${APK_MIRROR}/v3.20/main" \
+ "${APK_MIRROR}/v3.20/community" > /etc/apk/repositories; \
+ apk add --no-cache ca-certificates tzdata; \
+ addgroup -S app && adduser -S -G app app
+
+WORKDIR /app
+
+COPY --from=builder /out/qr-scanner /app/qr-scanner
+COPY --from=builder /src/static /app/static
+
+USER app
+EXPOSE 8080
+ENTRYPOINT ["/app/qr-scanner"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..adb992b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,73 @@
+# 二维码批量识别工具(qr-scanner)
+
+一个轻量级的二维码批量识别工具:上传 ZIP 或图片,批量识别二维码内容,实时显示进度,并导出 Excel 报告。
+
+## 功能
+
+- 上传 ZIP(自动解压并提取图片)
+- 上传单张/多张图片
+- QR Code 识别(单图多码输出多条内容)
+- 进度查询与 SSE 实时推送
+- Excel 导出(图片相对路径、识别内容、状态、错误信息、处理时间)
+
+## 运行
+
+环境变量:
+
+- `QR_SCANNER_PORT`:端口(默认 8080)
+- `QR_SCANNER_TEMP_DIR`:临时目录(默认 /tmp/qr-scanner)
+- `QR_SCANNER_RETENTION_MINUTES`:结果保留分钟数(默认 30)
+- `QR_SCANNER_DEFAULT_WORKERS`:默认并发(默认 4)
+- `QR_SCANNER_DEFAULT_TIMEOUT_S`:默认单张超时秒(默认 30)
+- `QR_SCANNER_DEBUG_DELAY_MS`:调试延时毫秒(默认 0,每处理一张额外 sleep)
+
+启动服务:
+
+```bash
+go run .
+```
+
+浏览器打开:
+
+- `http://localhost:8080/`
+
+## API(v1.0)
+
+- `POST /api/upload`:multipart/form-data,字段 `files`(可多文件)
+- `POST /api/scan`:`{"taskID":"...","concurrency":4,"timeout":30}`:推送、处理任务
+- `GET /api/progress/{taskID}`:查询进度
+- `GET /api/progress/{taskID}/stream`:SSE 推送进度
+- `GET /api/results/{taskID}`:查询结果
+- `GET /api/export/{taskID}`:下载 Excel
+- `POST /api/cancel/{taskID}`:取消任务
+
+## Docker
+
+构建镜像(默认从 docker.io 拉取基础镜像):
+
+```bash
+docker build -t qr-scanner:local .
+```
+
+国内网络可切换基础镜像仓库(示例):
+
+```bash
+docker build --build-arg BASE_IMAGE_REGISTRY=docker.m.daocloud.io -t qr-scanner:local .
+```
+
+运行:
+
+```bash
+docker run -d --name qr-scanner -p 8080:8080 qr-scanner:local
+docker logs -f --tail=200 qr-scanner
+```
+
+## 部署脚本
+
+脚本路径:`deploy/deploy.sh`
+
+示例:
+
+```bash
+REPO_URL=git@github.com:xxx/qr-scanner.git BRANCH=main HOST_PORT=8080 ./deploy/deploy.sh
+```
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..c23bd06
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,76 @@
+package config
+
+import (
+ "os"
+ "strconv"
+)
+
+type Config struct {
+ Host string
+ Port int
+ TempDir string
+ MaxUploadMB int64
+ MaxFiles int
+ DefaultWorkers int
+ MaxWorkers int
+ DefaultTimeoutS int
+ MaxTimeoutS int
+ RetentionMinutes int
+ MaxZipTotalMB int64
+ MaxZipFileMB int64
+ DebugDelayMS int
+}
+
+func Default() Config {
+ return Config{
+ Host: "0.0.0.0",
+ Port: 8080,
+ TempDir: "/tmp/qr-scanner",
+ MaxUploadMB: 100,
+ MaxFiles: 10000,
+ DefaultWorkers: 4,
+ MaxWorkers: 32,
+ DefaultTimeoutS: 30,
+ MaxTimeoutS: 60,
+ RetentionMinutes: 30,
+ MaxZipTotalMB: 1024,
+ MaxZipFileMB: 50,
+ DebugDelayMS: 0,
+ }
+}
+
+func (c Config) WithEnv() Config {
+ /*
+ 环境变量说明(用于本地开发/部署时覆盖默认配置):
+
+ - QR_SCANNER_PORT:服务监听端口
+ - QR_SCANNER_TEMP_DIR:任务临时目录根路径(任务结束后按保留期清理)
+ - QR_SCANNER_RETENTION_MINUTES:终态任务保留分钟数(completed/canceled/failed)
+ - QR_SCANNER_DEFAULT_WORKERS:默认并发数(scan 请求未传 concurrency 时使用)
+ - QR_SCANNER_DEFAULT_TIMEOUT_S:默认单张图片超时秒数(scan 请求未传 timeout 时使用)
+ - QR_SCANNER_DEBUG_DELAY_MS:调试延时(每处理完一张图片额外 sleep,用于观察进度条/SSE)
+ */
+ c.Port = envInt("QR_SCANNER_PORT", c.Port)
+ c.TempDir = envString("QR_SCANNER_TEMP_DIR", c.TempDir)
+ c.RetentionMinutes = envInt("QR_SCANNER_RETENTION_MINUTES", c.RetentionMinutes)
+ c.DefaultWorkers = envInt("QR_SCANNER_DEFAULT_WORKERS", c.DefaultWorkers)
+ c.DefaultTimeoutS = envInt("QR_SCANNER_DEFAULT_TIMEOUT_S", c.DefaultTimeoutS)
+ c.DebugDelayMS = envInt("QR_SCANNER_DEBUG_DELAY_MS", c.DebugDelayMS)
+ return c
+}
+
+func envString(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
+
+func envInt(key string, fallback int) int {
+ if v := os.Getenv(key); v != "" {
+ if n, err := strconv.Atoi(v); err == nil {
+ return n
+ }
+ }
+ return fallback
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f26a46f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,45 @@
+module qr-scanner
+
+go 1.21
+
+require (
+ github.com/gin-gonic/gin v1.10.0
+ github.com/makiuchi-d/gozxing v0.1.1
+ github.com/xuri/excelize/v2 v2.8.1
+ golang.org/x/image v0.14.0
+)
+
+require (
+ github.com/bytedance/sonic v1.11.6 // indirect
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
+ github.com/cloudwego/base64x v0.1.4 // indirect
+ github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/richardlehane/mscfb v1.0.4 // indirect
+ github.com/richardlehane/msoleps v1.0.3 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
+ github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect
+ github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect
+ golang.org/x/arch v0.8.0 // indirect
+ golang.org/x/crypto v0.23.0 // indirect
+ golang.org/x/net v0.25.0 // indirect
+ golang.org/x/sys v0.20.0 // indirect
+ golang.org/x/text v0.15.0 // indirect
+ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
+ google.golang.org/protobuf v1.34.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..6165493
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,106 @@
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
+github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
+github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
+github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
+github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0=
+github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ=
+github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE=
+github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4=
+github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
+golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/handlers/cancel.go b/handlers/cancel.go
new file mode 100644
index 0000000..82d8b90
--- /dev/null
+++ b/handlers/cancel.go
@@ -0,0 +1,49 @@
+package handlers
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "qr-scanner/services"
+)
+
+type CancelHandler struct {
+ store *services.TaskStore
+}
+
+func NewCancelHandler(store *services.TaskStore) *CancelHandler {
+ return &CancelHandler{store: store}
+}
+
+func (h *CancelHandler) Cancel(c *gin.Context) {
+ taskID := c.Param("taskID")
+ task, ok := h.store.Get(taskID)
+ if !ok {
+ fail(c, http.StatusNotFound, "任务不存在")
+ return
+ }
+ if task.Status == services.TaskExpired {
+ fail(c, http.StatusGone, "任务已过期")
+ return
+ }
+
+ if task.Status == services.TaskProcessing {
+ task.Cancel()
+ respondOK(c, gin.H{"taskID": task.ID, "status": task.Status})
+ return
+ }
+
+ if task.Status == services.TaskUploaded {
+ task.Status = services.TaskCanceled
+ task.EndedAt = time.Now()
+ task.UpdatedAt = time.Now()
+ if task.Progress != nil {
+ task.Progress.Complete(string(task.Status))
+ }
+ h.store.Put(task)
+ }
+
+ respondOK(c, gin.H{"taskID": task.ID, "status": task.Status})
+}
diff --git a/handlers/common.go b/handlers/common.go
new file mode 100644
index 0000000..20c35f3
--- /dev/null
+++ b/handlers/common.go
@@ -0,0 +1,17 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "qr-scanner/models"
+)
+
+func respondOK[T any](c *gin.Context, data T) {
+ c.JSON(http.StatusOK, models.APIResponse[T]{Code: 200, Message: "OK", Data: data})
+}
+
+func fail(c *gin.Context, status int, msg string) {
+ c.JSON(status, models.APIResponse[any]{Code: status, Message: msg})
+}
diff --git a/handlers/export.go b/handlers/export.go
new file mode 100644
index 0000000..8fd329a
--- /dev/null
+++ b/handlers/export.go
@@ -0,0 +1,57 @@
+package handlers
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "github.com/gin-gonic/gin"
+
+ "qr-scanner/services"
+)
+
+type ExportHandler struct {
+ store *services.TaskStore
+ exporter *services.Exporter
+}
+
+func NewExportHandler(store *services.TaskStore, exporter *services.Exporter) *ExportHandler {
+ return &ExportHandler{store: store, exporter: exporter}
+}
+
+func (h *ExportHandler) Download(c *gin.Context) {
+ taskID := c.Param("taskID")
+ task, ok := h.store.Get(taskID)
+ if !ok {
+ fail(c, http.StatusNotFound, "任务不存在")
+ return
+ }
+ if task.Status == services.TaskExpired {
+ fail(c, http.StatusGone, "任务已过期")
+ return
+ }
+
+ if task.ExcelPath != "" {
+ if _, err := os.Stat(task.ExcelPath); err == nil {
+ c.FileAttachment(task.ExcelPath, filepath.Base(task.ExcelPath))
+ return
+ }
+ }
+
+ results := task.Results()
+ if len(results) == 0 {
+ fail(c, http.StatusConflict, "结果尚未生成")
+ return
+ }
+
+ out := task.ExportFilename()
+ if err := h.exporter.Export(results, out); err != nil {
+ fail(c, http.StatusInternalServerError, "生成Excel失败")
+ return
+ }
+ task.ExcelPath = out
+ h.store.Put(task)
+
+ c.FileAttachment(out, filepath.Base(out))
+}
+
diff --git a/handlers/progress.go b/handlers/progress.go
new file mode 100644
index 0000000..5235676
--- /dev/null
+++ b/handlers/progress.go
@@ -0,0 +1,97 @@
+package handlers
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "qr-scanner/models"
+ "qr-scanner/services"
+)
+
+type ProgressHandler struct {
+ store *services.TaskStore
+}
+
+func NewProgressHandler(store *services.TaskStore) *ProgressHandler {
+ return &ProgressHandler{store: store}
+}
+
+func (h *ProgressHandler) GetProgress(c *gin.Context) {
+ taskID := c.Param("taskID")
+ task, ok := h.store.Get(taskID)
+ if !ok {
+ fail(c, http.StatusNotFound, "任务不存在")
+ return
+ }
+ if task.Status == services.TaskExpired {
+ fail(c, http.StatusGone, "任务已过期")
+ return
+ }
+
+ var update models.ProgressUpdate
+ if task.Progress != nil {
+ update = task.Progress.Snapshot(string(task.Status))
+ } else {
+ update = models.ProgressUpdate{Total: len(task.Files), Status: string(task.Status), UpdatedAt: time.Now()}
+ }
+ respondOK(c, update)
+}
+
+func (h *ProgressHandler) StreamProgress(c *gin.Context) {
+ /*
+ SSE 进度推送:
+ - 连接建立后先发送一次快照,避免前端空白
+ - progress 内部用带缓冲 channel 做“最新进度覆盖”,避免慢客户端拖垮服务
+ - 任务达到终态(completed/canceled/failed)后主动结束 SSE
+ */
+ taskID := c.Param("taskID")
+ task, ok := h.store.Get(taskID)
+ if !ok {
+ fail(c, http.StatusNotFound, "任务不存在")
+ return
+ }
+ if task.Status == services.TaskExpired {
+ fail(c, http.StatusGone, "任务已过期")
+ return
+ }
+ if task.Progress == nil {
+ fail(c, http.StatusConflict, "任务尚未开始")
+ return
+ }
+
+ c.Writer.Header().Set("Content-Type", "text/event-stream")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+
+ flusher, ok := c.Writer.(http.Flusher)
+ if !ok {
+ fail(c, http.StatusInternalServerError, "SSE不支持")
+ return
+ }
+
+ updateCh := make(chan models.ProgressUpdate, 16)
+ task.Progress.AddListener(updateCh)
+ defer task.Progress.RemoveListener(updateCh)
+
+ initial := task.Progress.Snapshot(string(task.Status))
+ _ = services.SSEWrite(c.Writer, initial)
+ flusher.Flush()
+
+ for {
+ select {
+ case <-c.Request.Context().Done():
+ return
+ case update, ok := <-updateCh:
+ if !ok {
+ return
+ }
+ _ = services.SSEWrite(c.Writer, update)
+ flusher.Flush()
+ if update.Status == string(services.TaskCompleted) || update.Status == string(services.TaskCanceled) || update.Status == string(services.TaskFailed) {
+ return
+ }
+ }
+ }
+}
diff --git a/handlers/results.go b/handlers/results.go
new file mode 100644
index 0000000..b90707d
--- /dev/null
+++ b/handlers/results.go
@@ -0,0 +1,50 @@
+package handlers
+
+import (
+ "net/http"
+ "sort"
+
+ "github.com/gin-gonic/gin"
+
+ "qr-scanner/models"
+ "qr-scanner/services"
+)
+
+type ResultsHandler struct {
+ store *services.TaskStore
+}
+
+func NewResultsHandler(store *services.TaskStore) *ResultsHandler {
+ return &ResultsHandler{store: store}
+}
+
+func (h *ResultsHandler) GetResults(c *gin.Context) {
+ taskID := c.Param("taskID")
+ task, ok := h.store.Get(taskID)
+ if !ok {
+ fail(c, http.StatusNotFound, "任务不存在")
+ return
+ }
+ if task.Status == services.TaskExpired {
+ fail(c, http.StatusGone, "任务已过期")
+ return
+ }
+
+ resultsMap := task.Results()
+ indexes := make([]int, 0, len(resultsMap))
+ for i := range resultsMap {
+ indexes = append(indexes, i)
+ }
+ sort.Ints(indexes)
+
+ items := make([]models.ScanResult, 0, len(indexes))
+ for _, i := range indexes {
+ items = append(items, resultsMap[i])
+ }
+
+ respondOK(c, gin.H{
+ "taskID": task.ID,
+ "status": task.Status,
+ "items": items,
+ })
+}
diff --git a/handlers/scan.go b/handlers/scan.go
new file mode 100644
index 0000000..f32d328
--- /dev/null
+++ b/handlers/scan.go
@@ -0,0 +1,71 @@
+package handlers
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "qr-scanner/config"
+ "qr-scanner/models"
+ "qr-scanner/services"
+)
+
+type ScanHandler struct {
+ cfg config.Config
+ store *services.TaskStore
+ scanner *services.Scanner
+}
+
+func NewScanHandler(cfg config.Config, store *services.TaskStore, scanner *services.Scanner) *ScanHandler {
+ return &ScanHandler{cfg: cfg, store: store, scanner: scanner}
+}
+
+func (h *ScanHandler) StartScan(c *gin.Context) {
+ var req models.ScanRequest
+ if err := c.ShouldBindJSON(&req); err != nil || req.TaskID == "" {
+ fail(c, http.StatusBadRequest, "请求参数错误")
+ return
+ }
+
+ task, ok := h.store.Get(req.TaskID)
+ if !ok {
+ fail(c, http.StatusNotFound, "任务不存在")
+ return
+ }
+
+ if task.Status == services.TaskExpired {
+ fail(c, http.StatusGone, "任务已过期")
+ return
+ }
+
+ concurrency := req.Concurrency
+ if concurrency <= 0 {
+ concurrency = h.cfg.DefaultWorkers
+ }
+ if concurrency <= 0 {
+ concurrency = 1
+ }
+ if h.cfg.MaxWorkers > 0 && concurrency > h.cfg.MaxWorkers {
+ concurrency = h.cfg.MaxWorkers
+ }
+
+ timeout := req.Timeout
+ if timeout <= 0 {
+ timeout = h.cfg.DefaultTimeoutS
+ }
+ if timeout <= 0 {
+ timeout = 30
+ }
+ if h.cfg.MaxTimeoutS > 0 && timeout > h.cfg.MaxTimeoutS {
+ timeout = h.cfg.MaxTimeoutS
+ }
+
+ task.Concurrency = concurrency
+ task.TimeoutS = timeout
+ task.UpdatedAt = time.Now()
+ h.store.Put(task)
+
+ _ = h.scanner.Start(task)
+ respondOK(c, models.ScanStartResponse{TaskID: task.ID, Status: string(task.Status)})
+}
diff --git a/handlers/upload.go b/handlers/upload.go
new file mode 100644
index 0000000..024fe05
--- /dev/null
+++ b/handlers/upload.go
@@ -0,0 +1,163 @@
+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)})
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d827d6e
--- /dev/null
+++ b/main.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "qr-scanner/config"
+ "qr-scanner/handlers"
+ "qr-scanner/services"
+)
+
+func main() {
+ cfg := config.Default().WithEnv()
+
+ if err := os.MkdirAll(cfg.TempDir, 0o755); err != nil {
+ panic(err)
+ }
+
+ store := services.NewTaskStore()
+ scanner := services.NewScanner(store, time.Duration(cfg.DebugDelayMS)*time.Millisecond)
+ exporter := services.NewExporter()
+
+ uploadHandler := handlers.NewUploadHandler(cfg, store)
+ scanHandler := handlers.NewScanHandler(cfg, store, scanner)
+ progressHandler := handlers.NewProgressHandler(store)
+ resultsHandler := handlers.NewResultsHandler(store)
+ exportHandler := handlers.NewExportHandler(store, exporter)
+ cancelHandler := handlers.NewCancelHandler(store)
+
+ r := gin.Default()
+ if cfg.MaxUploadMB > 0 {
+ r.MaxMultipartMemory = cfg.MaxUploadMB * 1024 * 1024
+ }
+
+ r.GET("/", func(c *gin.Context) { c.File("./static/index.html") })
+ r.Static("/static", "./static")
+
+ api := r.Group("/api")
+ {
+ api.POST("/upload", uploadHandler.HandleUpload)
+ api.POST("/scan", scanHandler.StartScan)
+ api.GET("/progress/:taskID", progressHandler.GetProgress)
+ api.GET("/progress/:taskID/stream", progressHandler.StreamProgress)
+ api.GET("/results/:taskID", resultsHandler.GetResults)
+ api.GET("/export/:taskID", exportHandler.Download)
+ api.POST("/cancel/:taskID", cancelHandler.Cancel)
+ }
+
+ go func() {
+ retention := time.Duration(cfg.RetentionMinutes) * time.Minute
+ ticker := time.NewTicker(time.Minute)
+ defer ticker.Stop()
+ for range ticker.C {
+ store.Cleanup(retention)
+ }
+ }()
+
+ addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
+ if err := r.Run(addr); err != nil {
+ panic(err)
+ }
+}
diff --git a/models/request.go b/models/request.go
new file mode 100644
index 0000000..2d1d50b
--- /dev/null
+++ b/models/request.go
@@ -0,0 +1,8 @@
+package models
+
+type ScanRequest struct {
+ TaskID string `json:"taskID"`
+ Concurrency int `json:"concurrency"`
+ Timeout int `json:"timeout"`
+}
+
diff --git a/models/response.go b/models/response.go
new file mode 100644
index 0000000..fff6ab9
--- /dev/null
+++ b/models/response.go
@@ -0,0 +1,34 @@
+package models
+
+import "time"
+
+type APIResponse[T any] struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data T `json:"data,omitempty"`
+}
+
+type UploadResponse struct {
+ TaskID string `json:"taskID"`
+ TotalFiles int `json:"totalFiles"`
+}
+
+type ScanStartResponse struct {
+ TaskID string `json:"taskID"`
+ Status string `json:"status"`
+ Message string `json:"message,omitempty"`
+}
+
+type ProgressUpdate struct {
+ Total int `json:"total"`
+ Processed int `json:"processed"`
+ Success int `json:"success"`
+ Failed int `json:"failed"`
+ Speed float64 `json:"speed"`
+ Remaining string `json:"remaining"`
+ Current string `json:"current,omitempty"`
+ Status string `json:"status,omitempty"`
+ Result *ScanResult `json:"result,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
diff --git a/models/result.go b/models/result.go
new file mode 100644
index 0000000..103de43
--- /dev/null
+++ b/models/result.go
@@ -0,0 +1,14 @@
+package models
+
+import "time"
+
+type ScanResult struct {
+ Index int `json:"index"`
+ FilePath string `json:"file_path"`
+ Contents []string `json:"contents"`
+ Success bool `json:"success"`
+ ErrorCode string `json:"error_code,omitempty"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ ProcessedAt time.Time `json:"processed_at"`
+}
+
diff --git a/services/exporter.go b/services/exporter.go
new file mode 100644
index 0000000..ebdb8bb
--- /dev/null
+++ b/services/exporter.go
@@ -0,0 +1,82 @@
+package services
+
+import (
+ "fmt"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/xuri/excelize/v2"
+
+ "qr-scanner/models"
+)
+
+type Exporter struct{}
+
+func NewExporter() *Exporter {
+ return &Exporter{}
+}
+
+func (e *Exporter) Export(results map[int]models.ScanResult, outputPath string) error {
+ f := excelize.NewFile()
+
+ sheet := "Sheet1"
+ headers := []string{"序号", "图片路径", "识别内容", "处理状态", "错误信息", "处理时间"}
+ for i, header := range headers {
+ cell, _ := excelize.CoordinatesToCellName(i+1, 1)
+ _ = f.SetCellValue(sheet, cell, header)
+ }
+
+ indexes := make([]int, 0, len(results))
+ for i := range results {
+ indexes = append(indexes, i)
+ }
+ sort.Ints(indexes)
+
+ row := 2
+ for _, i := range indexes {
+ r := results[i]
+ status := "成功"
+ if !r.Success {
+ status = "失败"
+ }
+ content := strings.Join(r.Contents, "\n")
+ values := []any{
+ r.Index,
+ r.FilePath,
+ content,
+ status,
+ r.ErrorMessage,
+ r.ProcessedAt.Format("2006-01-02 15:04:05"),
+ }
+ for col, v := range values {
+ cell, _ := excelize.CoordinatesToCellName(col+1, row)
+ _ = f.SetCellValue(sheet, cell, v)
+ }
+ row++
+ }
+
+ if err := f.SetColWidth(sheet, "A", "A", 8); err != nil {
+ return err
+ }
+ if err := f.SetColWidth(sheet, "B", "B", 50); err != nil {
+ return err
+ }
+ if err := f.SetColWidth(sheet, "C", "C", 60); err != nil {
+ return err
+ }
+ if err := f.SetColWidth(sheet, "D", "F", 18); err != nil {
+ return err
+ }
+
+ if err := ensureDir(filepath.Dir(outputPath)); err != nil {
+ return err
+ }
+ return f.SaveAs(outputPath)
+}
+
+func ExportFilename(now time.Time) string {
+ return fmt.Sprintf("二维码识别结果_%s.xlsx", now.Format("20060102_150405"))
+}
+
diff --git a/services/fs.go b/services/fs.go
new file mode 100644
index 0000000..3fdffbd
--- /dev/null
+++ b/services/fs.go
@@ -0,0 +1,11 @@
+package services
+
+import "os"
+
+func ensureDir(dir string) error {
+ if dir == "" || dir == "." {
+ return nil
+ }
+ return os.MkdirAll(dir, 0o755)
+}
+
diff --git a/services/progress.go b/services/progress.go
new file mode 100644
index 0000000..d072ec6
--- /dev/null
+++ b/services/progress.go
@@ -0,0 +1,138 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+ "sync"
+ "time"
+
+ "qr-scanner/models"
+)
+
+type Progress struct {
+ mu sync.RWMutex
+ Total int
+ StartTime time.Time
+ EndTime time.Time
+ Current string
+
+ Processed int
+ Success int
+ Failed int
+
+ Results map[int]models.ScanResult
+ listeners map[chan models.ProgressUpdate]struct{}
+}
+
+func NewProgress(total int) *Progress {
+ return &Progress{
+ Total: total,
+ StartTime: time.Now(),
+ Results: make(map[int]models.ScanResult, total),
+ listeners: make(map[chan models.ProgressUpdate]struct{}),
+ }
+}
+
+func (p *Progress) AddListener(ch chan models.ProgressUpdate) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.listeners[ch] = struct{}{}
+}
+
+func (p *Progress) RemoveListener(ch chan models.ProgressUpdate) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ delete(p.listeners, ch)
+ close(ch)
+}
+
+func (p *Progress) SetCurrent(current string) {
+ p.mu.Lock()
+ p.Current = current
+ p.mu.Unlock()
+}
+
+func (p *Progress) Update(result models.ScanResult, status string) {
+ p.mu.Lock()
+ p.Processed++
+ if result.Success {
+ p.Success++
+ } else {
+ p.Failed++
+ }
+ p.Results[result.Index] = result
+ update := p.buildUpdateLocked(status, &result)
+ for ch := range p.listeners {
+ select {
+ case ch <- update:
+ default:
+ }
+ }
+ p.mu.Unlock()
+}
+
+func (p *Progress) Complete(status string) {
+ p.mu.Lock()
+ p.EndTime = time.Now()
+ update := p.buildUpdateLocked(status, nil)
+ for ch := range p.listeners {
+ select {
+ case ch <- update:
+ default:
+ }
+ }
+ p.mu.Unlock()
+}
+
+func (p *Progress) Snapshot(status string) models.ProgressUpdate {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ return p.buildUpdateLocked(status, nil)
+}
+
+func (p *Progress) ResultsSnapshot() map[int]models.ScanResult {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ out := make(map[int]models.ScanResult, len(p.Results))
+ for k, v := range p.Results {
+ out[k] = v
+ }
+ return out
+}
+
+func (p *Progress) buildUpdateLocked(status string, result *models.ScanResult) models.ProgressUpdate {
+ elapsed := time.Since(p.StartTime).Seconds()
+ speed := 0.0
+ if elapsed > 0 {
+ speed = float64(p.Processed) / elapsed
+ }
+ remaining := ""
+ if speed > 0 && p.Total > p.Processed {
+ sec := float64(p.Total-p.Processed) / speed
+ remaining = (time.Duration(sec * float64(time.Second))).Truncate(time.Second).String()
+ }
+ return models.ProgressUpdate{
+ Total: p.Total,
+ Processed: p.Processed,
+ Success: p.Success,
+ Failed: p.Failed,
+ Speed: speed,
+ Remaining: remaining,
+ Current: p.Current,
+ Status: status,
+ Result: result,
+ UpdatedAt: time.Now(),
+ }
+}
+
+func SSEWrite(w interface {
+ Write([]byte) (int, error)
+}, update models.ProgressUpdate) error {
+ b, err := json.Marshal(update)
+ if err != nil {
+ return err
+ }
+ _, err = w.Write([]byte(fmt.Sprintf("data: %s\n\n", b)))
+ return err
+}
+
diff --git a/services/scanner.go b/services/scanner.go
new file mode 100644
index 0000000..48e2607
--- /dev/null
+++ b/services/scanner.go
@@ -0,0 +1,235 @@
+package services
+
+import (
+ "context"
+ "errors"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "qr-scanner/models"
+ "qr-scanner/utils"
+)
+
+type Scanner struct {
+ store *TaskStore
+ debugDelay time.Duration
+}
+
+func NewScanner(store *TaskStore, debugDelay time.Duration) *Scanner {
+ return &Scanner{store: store, debugDelay: debugDelay}
+}
+
+func (s *Scanner) Start(task *Task) error {
+ /*
+ Start 只负责把任务从 uploaded 推进到 processing,并异步启动 worker。
+ 为了让 HTTP handler 迅速返回,真正的 CPU/IO 工作放在 run() 里执行。
+ */
+ if task == nil {
+ return errors.New("task is nil")
+ }
+ if task.Status == TaskProcessing {
+ return nil
+ }
+ if task.Status == TaskCompleted || task.Status == TaskCanceled || task.Status == TaskFailed {
+ return nil
+ }
+
+ if task.Progress == nil {
+ task.Progress = NewProgress(len(task.Files))
+ }
+
+ task.Status = TaskProcessing
+ task.UpdatedAt = time.Now()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ task.SetCancel(cancel)
+ s.store.Put(task)
+
+ /*
+ 并发数:优先使用 scan 请求传入的 Concurrency(由 handler 写入 task),
+ 兜底为 runtime.NumCPU(),并且不会超过任务文件数,避免空转 goroutine。
+ */
+ workers := task.Concurrency
+ if workers <= 0 {
+ workers = runtime.NumCPU()
+ }
+ if workers > len(task.Files) && len(task.Files) > 0 {
+ workers = len(task.Files)
+ }
+ if workers <= 0 {
+ workers = 1
+ }
+
+ /*
+ 单张超时:每张图片的解码被包在 context timeout 里,
+ 避免极端图片导致任务整体卡死。
+ */
+ timeout := time.Duration(task.TimeoutS) * time.Second
+ if task.TimeoutS <= 0 {
+ timeout = 0
+ }
+
+ go func() {
+ s.run(ctx, task, workers, timeout)
+ }()
+
+ return nil
+}
+
+func (s *Scanner) run(ctx context.Context, task *Task, workers int, timeout time.Duration) {
+ /*
+ run 使用“任务内 worker 池”处理图片列表:
+ - jobs:按顺序投递 TaskFile
+ - workers:并发消费 jobs,识别二维码并回写 progress
+ - ctx:用于取消任务;取消后尽快停止投递/停止 worker
+ */
+ jobs := make(chan TaskFile)
+ var wg sync.WaitGroup
+
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for tf := range jobs {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ /*
+ Current 字段用于 UI 显示“当前处理文件”。
+ 这里写入的是“压缩包内相对路径”或“上传文件名”,不会暴露服务器绝对路径。
+ */
+ task.Progress.SetCurrent(tf.RelPath)
+
+ report := func(r models.ScanResult) {
+ if s.debugDelay > 0 {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(s.debugDelay):
+ }
+ }
+ task.Progress.Update(r, string(task.Status))
+ }
+
+ contents, err := utils.DecodeFile(ctx, tf.AbsPath, timeout)
+ res := models.ScanResult{
+ Index: tf.Index,
+ FilePath: tf.RelPath,
+ Contents: nil,
+ Success: false,
+ ProcessedAt: time.Now(),
+ }
+
+ if err == nil && len(contents) > 0 {
+ /*
+ 业务规则:单张图片可能含多个二维码,DecodeFile 返回 []string。
+ 这里做两件事:
+ 1) 去空、去首尾空白
+ 2) 如果识别到 http/https,则做 URL 基本格式校验
+ */
+ validated := make([]string, 0, len(contents))
+ hasInvalidURL := false
+ for _, c := range contents {
+ c = strings.TrimSpace(c)
+ if c == "" {
+ continue
+ }
+ if strings.HasPrefix(c, "http://") || strings.HasPrefix(c, "https://") {
+ if !utils.IsHTTPURL(c) {
+ hasInvalidURL = true
+ continue
+ }
+ }
+ validated = append(validated, c)
+ }
+ if len(validated) > 0 {
+ res.Contents = validated
+ if hasInvalidURL {
+ /*
+ URL 校验策略(更严格):
+ - 如果识别结果包含非法 URL,则该图片标记为失败(便于前端/导出明确提示)。
+ - 同时保留 validated 中的有效内容,便于后续人工处理(Excel 中可看到)。
+ */
+ res.Success = false
+ res.ErrorCode = "E_URL_INVALID"
+ res.ErrorMessage = "识别结果包含非法URL"
+ report(res)
+ continue
+ }
+ res.Success = true
+ report(res)
+ continue
+ }
+ if hasInvalidURL {
+ res.ErrorCode = "E_URL_INVALID"
+ res.ErrorMessage = "识别到疑似URL但格式不合法"
+ report(res)
+ continue
+ }
+ }
+
+ if err != nil {
+ /*
+ 错误码归一化:便于前端展示“失败原因”并导出到 Excel。
+ 不要把底层库的原始错误信息直接暴露给用户(信息噪音大且不稳定)。
+ */
+ if errors.Is(err, context.DeadlineExceeded) {
+ res.ErrorCode = "E_TIMEOUT"
+ res.ErrorMessage = "处理超时"
+ } else if errors.Is(err, utils.ErrImageDecode) {
+ res.ErrorCode = "E_IMG_DECODE"
+ res.ErrorMessage = "图片无法解析"
+ } else if errors.Is(err, utils.ErrQRCodeNotFound) {
+ res.ErrorCode = "E_QR_NOT_FOUND"
+ res.ErrorMessage = "未检测到二维码"
+ } else {
+ res.ErrorCode = "E_QR_DECODE_FAIL"
+ res.ErrorMessage = "二维码解码失败"
+ }
+ } else if res.ErrorMessage == "" {
+ res.ErrorCode = "E_QR_NOT_FOUND"
+ res.ErrorMessage = "未检测到二维码"
+ }
+ report(res)
+ }
+ }()
+ }
+
+ go func() {
+ /*
+ 投递线程:按 files 的顺序把任务文件发到 jobs。
+ 一旦取消任务,停止继续投递。
+ */
+ defer close(jobs)
+ for _, tf := range task.Files {
+ select {
+ case <-ctx.Done():
+ return
+ case jobs <- tf:
+ }
+ }
+ }()
+
+ wg.Wait()
+
+ /*
+ 任务结束状态:worker 全部退出后,根据 ctx 是否被取消决定 completed/canceled。
+ 此处完成最终一次 progress 推送,便于 SSE 客户端收敛到终态并跳转结果页。
+ */
+ select {
+ case <-ctx.Done():
+ task.Status = TaskCanceled
+ default:
+ task.Status = TaskCompleted
+ }
+
+ task.EndedAt = time.Now()
+ task.UpdatedAt = time.Now()
+ task.Progress.Complete(string(task.Status))
+ s.store.Put(task)
+}
diff --git a/services/scanner_test.go b/services/scanner_test.go
new file mode 100644
index 0000000..c239ab5
--- /dev/null
+++ b/services/scanner_test.go
@@ -0,0 +1,64 @@
+package services
+
+import (
+ "image"
+ "image/png"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestScanner_EndToEnd_NoQRCode(t *testing.T) {
+ dir := t.TempDir()
+ imgPath := filepath.Join(dir, "blank.png")
+
+ f, err := os.Create(imgPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ img := image.NewRGBA(image.Rect(0, 0, 120, 120))
+ if err := png.Encode(f, img); err != nil {
+ t.Fatal(err)
+ }
+ _ = f.Close()
+
+ store := NewTaskStore()
+ scanner := NewScanner(store, 0)
+
+ task := &Task{
+ ID: "t1",
+ TempDir: dir,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ Status: TaskUploaded,
+ Files: []TaskFile{
+ {Index: 1, RelPath: "blank.png", AbsPath: imgPath},
+ },
+ Concurrency: 1,
+ TimeoutS: 5,
+ }
+ store.Put(task)
+
+ if err := scanner.Start(task); err != nil {
+ t.Fatal(err)
+ }
+
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ cur, _ := store.Get("t1")
+ if cur.Status == TaskCompleted || cur.Status == TaskCanceled || cur.Status == TaskFailed {
+ results := cur.Results()
+ if len(results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(results))
+ }
+ if results[1].Success {
+ t.Fatalf("expected failure")
+ }
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+
+ t.Fatalf("timeout waiting for task completion")
+}
diff --git a/services/store.go b/services/store.go
new file mode 100644
index 0000000..771e8e7
--- /dev/null
+++ b/services/store.go
@@ -0,0 +1,59 @@
+package services
+
+import (
+ "sync"
+ "time"
+)
+
+type TaskStore struct {
+ mu sync.RWMutex
+ tasks map[string]*Task
+}
+
+func NewTaskStore() *TaskStore {
+ return &TaskStore{tasks: map[string]*Task{}}
+}
+
+func (s *TaskStore) Put(t *Task) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.tasks[t.ID] = t
+}
+
+func (s *TaskStore) Get(id string) (*Task, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ t, ok := s.tasks[id]
+ return t, ok
+}
+
+func (s *TaskStore) Delete(id string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.tasks, id)
+}
+
+func (s *TaskStore) Cleanup(retention time.Duration) {
+ now := time.Now()
+ var expired []*Task
+
+ s.mu.RLock()
+ for _, t := range s.tasks {
+ if t.Status == TaskCompleted || t.Status == TaskCanceled || t.Status == TaskFailed {
+ if !t.EndedAt.IsZero() && now.Sub(t.EndedAt) > retention {
+ expired = append(expired, t)
+ }
+ }
+ }
+ s.mu.RUnlock()
+
+ for _, t := range expired {
+ t.Cleanup()
+ s.mu.Lock()
+ if cur, ok := s.tasks[t.ID]; ok && cur == t {
+ delete(s.tasks, t.ID)
+ }
+ s.mu.Unlock()
+ }
+}
+
diff --git a/services/task.go b/services/task.go
new file mode 100644
index 0000000..183c4a6
--- /dev/null
+++ b/services/task.go
@@ -0,0 +1,80 @@
+package services
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "qr-scanner/models"
+)
+
+type TaskStatus string
+
+const (
+ TaskUploaded TaskStatus = "uploaded" // 上传完成
+ TaskProcessing TaskStatus = "processing" // 处理中
+ TaskCompleted TaskStatus = "completed" // 处理完成
+ TaskCanceled TaskStatus = "canceled" // 已取消
+ TaskFailed TaskStatus = "failed" // 处理失败
+ TaskExpired TaskStatus = "expired" // 已过期
+)
+
+type TaskFile struct {
+ Index int // 文件索引
+ RelPath string // 相对路径
+ AbsPath string // 绝对路径
+}
+
+type Task struct {
+ ID string // 任务 ID
+ TempDir string // 临时目录
+ CreatedAt time.Time // 创建时间
+ UpdatedAt time.Time // 更新时间
+ EndedAt time.Time // 结束时间
+ Status TaskStatus // 任务状态
+ Files []TaskFile // 任务文件列表
+
+ Concurrency int // 并发数
+ TimeoutS int // 超时时间(秒)
+
+ Progress *Progress // 进度信息
+ ExcelPath string // 导出 Excel 路径
+
+ cancelMu sync.Mutex // 取消锁
+ cancel context.CancelFunc // 取消函数
+}
+
+func (t *Task) SetCancel(cancel context.CancelFunc) {
+ t.cancelMu.Lock()
+ t.cancel = cancel
+ t.cancelMu.Unlock()
+}
+
+func (t *Task) Cancel() {
+ t.cancelMu.Lock()
+ cancel := t.cancel
+ t.cancelMu.Unlock()
+ if cancel != nil {
+ cancel()
+ }
+}
+
+func (t *Task) Cleanup() error {
+ if t.TempDir == "" {
+ return nil
+ }
+ return os.RemoveAll(t.TempDir)
+}
+
+func (t *Task) ExportFilename() string {
+ return filepath.Join(t.TempDir, "export", ExportFilename(time.Now()))
+}
+
+func (t *Task) Results() map[int]models.ScanResult {
+ if t.Progress == nil {
+ return map[int]models.ScanResult{}
+ }
+ return t.Progress.ResultsSnapshot()
+}
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..c8e9619
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,369 @@
+(function () {
+ const el = {
+ statusText: document.getElementById('statusText'),
+ viewUpload: document.getElementById('viewUpload'),
+ viewProgress: document.getElementById('viewProgress'),
+ viewResult: document.getElementById('viewResult'),
+ dropzone: document.getElementById('dropzone'),
+ fileInput: document.getElementById('fileInput'),
+ fileList: document.getElementById('fileList'),
+ btnClear: document.getElementById('btnClear'),
+ btnStart: document.getElementById('btnStart'),
+ concurrencyInput: document.getElementById('concurrencyInput'),
+ timeoutInput: document.getElementById('timeoutInput'),
+ progressPercent: document.getElementById('progressPercent'),
+ progressBar: document.getElementById('progressBar'),
+ statTotal: document.getElementById('statTotal'),
+ statProcessed: document.getElementById('statProcessed'),
+ statSuccess: document.getElementById('statSuccess'),
+ statFailed: document.getElementById('statFailed'),
+ currentFile: document.getElementById('currentFile'),
+ speed: document.getElementById('speed'),
+ remaining: document.getElementById('remaining'),
+ btnCancel: document.getElementById('btnCancel'),
+ resultTitle: document.getElementById('resultTitle'),
+ taskMeta: document.getElementById('taskMeta'),
+ resultSuccess: document.getElementById('resultSuccess'),
+ resultFailed: document.getElementById('resultFailed'),
+ btnDownloadExcel: document.getElementById('btnDownloadExcel'),
+ btnBack: document.getElementById('btnBack'),
+ failuresWrap: document.getElementById('failuresWrap'),
+ failuresBody: document.getElementById('failuresBody'),
+ errorAlert: document.getElementById('errorAlert'),
+ }
+
+ const state = {
+ files: [],
+ taskID: null,
+ sse: null,
+ lastProgress: null,
+ status: 'idle',
+ }
+
+ function setStatus(text, kind) {
+ el.statusText.textContent = text
+ if (!kind) return
+ }
+
+ function showError(msg) {
+ el.errorAlert.textContent = msg
+ el.errorAlert.classList.remove('d-none')
+ }
+
+ function clearError() {
+ el.errorAlert.classList.add('d-none')
+ el.errorAlert.textContent = ''
+ }
+
+ function setView(name) {
+ el.viewUpload.classList.toggle('d-none', name !== 'upload')
+ el.viewProgress.classList.toggle('d-none', name !== 'progress')
+ el.viewResult.classList.toggle('d-none', name !== 'result')
+ }
+
+ function renderFileList() {
+ if (!state.files.length) {
+ el.fileList.classList.add('text-muted')
+ el.fileList.textContent = '暂无文件'
+ el.btnStart.disabled = true
+ return
+ }
+
+ el.fileList.classList.remove('text-muted')
+ el.fileList.innerHTML = state.files.map((f, idx) => {
+ const size = formatBytes(f.size)
+ return `
+
+
${escapeHtml(f.name)}
+
+
+ `
+ }).join('')
+
+ el.btnStart.disabled = false
+ }
+
+ function bindFileListActions() {
+ el.fileList.addEventListener('click', (e) => {
+ const btn = e.target.closest('button[data-remove]')
+ if (!btn) return
+ const idx = parseInt(btn.getAttribute('data-remove'), 10)
+ if (Number.isNaN(idx)) return
+ state.files.splice(idx, 1)
+ renderFileList()
+ })
+ }
+
+ function setProgress(p) {
+ const total = p.total || 0
+ const processed = p.processed || 0
+ const percent = total > 0 ? Math.min(100, Math.floor((processed / total) * 100)) : 0
+ el.progressPercent.textContent = percent + '%'
+ el.progressBar.style.width = percent + '%'
+ el.statTotal.textContent = String(total)
+ el.statProcessed.textContent = String(processed)
+ el.statSuccess.textContent = String(p.success || 0)
+ el.statFailed.textContent = String(p.failed || 0)
+ el.currentFile.textContent = p.current || '-'
+ el.speed.textContent = (typeof p.speed === 'number' ? p.speed.toFixed(1) : '-')
+ el.remaining.textContent = p.remaining || '-'
+ }
+
+ async function apiUpload(files) {
+ const fd = new FormData()
+ for (const f of files) fd.append('files', f)
+ const res = await fetch('/api/upload', { method: 'POST', body: fd })
+ const body = await safeJson(res)
+ if (!res.ok || !body || body.code !== 200) {
+ throw new Error((body && body.message) || '上传失败')
+ }
+ return body.data
+ }
+
+ async function apiScan(taskID, concurrency, timeout) {
+ const res = await fetch('/api/scan', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ taskID, concurrency, timeout }),
+ })
+ const body = await safeJson(res)
+ if (!res.ok || !body || body.code !== 200) {
+ throw new Error((body && body.message) || '启动处理失败')
+ }
+ return body.data
+ }
+
+ async function apiResults(taskID) {
+ const res = await fetch('/api/results/' + encodeURIComponent(taskID))
+ const body = await safeJson(res)
+ if (!res.ok || !body || body.code !== 200) {
+ throw new Error((body && body.message) || '获取结果失败')
+ }
+ return body.data
+ }
+
+ async function apiCancel(taskID) {
+ const res = await fetch('/api/cancel/' + encodeURIComponent(taskID), { method: 'POST' })
+ const body = await safeJson(res)
+ if (!res.ok || !body || body.code !== 200) {
+ throw new Error((body && body.message) || '取消失败')
+ }
+ return body.data
+ }
+
+ function openSSE(taskID) {
+ closeSSE()
+ const url = '/api/progress/' + encodeURIComponent(taskID) + '/stream'
+ const es = new EventSource(url)
+ state.sse = es
+ es.onmessage = (ev) => {
+ try {
+ const p = JSON.parse(ev.data)
+ state.lastProgress = p
+ setProgress(p)
+ if (p.status === 'completed' || p.status === 'canceled' || p.status === 'failed') {
+ closeSSE()
+ void loadResultsAndShow()
+ }
+ } catch (_) {}
+ }
+ es.onerror = () => {
+ closeSSE()
+ showError('进度连接中断(SSE)')
+ setStatus('出错')
+ }
+ }
+
+ function closeSSE() {
+ if (state.sse) {
+ state.sse.close()
+ state.sse = null
+ }
+ }
+
+ async function loadResultsAndShow() {
+ try {
+ setStatus('整理结果')
+ const data = await apiResults(state.taskID)
+ renderResults(data)
+ setView('result')
+ clearError()
+ if (data.status === 'canceled') {
+ el.resultTitle.textContent = '已取消'
+ } else if (data.status === 'failed') {
+ el.resultTitle.textContent = '任务失败'
+ } else {
+ el.resultTitle.textContent = '处理完成!'
+ }
+ setStatus('就绪')
+ } catch (e) {
+ showError(e.message || '获取结果失败')
+ }
+ }
+
+ function renderResults(data) {
+ el.taskMeta.textContent = data.taskID || '-'
+ const items = Array.isArray(data.items) ? data.items : []
+ let success = 0
+ let failed = 0
+ const failures = []
+
+ for (const it of items) {
+ if (it.success) {
+ success++
+ } else {
+ failed++
+ failures.push(it)
+ }
+ }
+
+ el.resultSuccess.textContent = String(success)
+ el.resultFailed.textContent = String(failed)
+
+ if (!failures.length) {
+ el.failuresWrap.classList.add('d-none')
+ el.failuresBody.innerHTML = ''
+ return
+ }
+
+ el.failuresWrap.classList.remove('d-none')
+ el.failuresBody.innerHTML = failures.map((it, idx) => {
+ return `
+
+ | ${idx + 1} |
+ ${escapeHtml(it.file_path || '')} |
+ ${escapeHtml(it.error_message || '')} |
+
+ `
+ }).join('')
+ }
+
+ function resetAll() {
+ closeSSE()
+ state.files = []
+ state.taskID = null
+ state.lastProgress = null
+ state.status = 'idle'
+ el.fileInput.value = ''
+ renderFileList()
+ clearError()
+ setProgress({ total: 0, processed: 0, success: 0, failed: 0, speed: 0, remaining: '' })
+ setView('upload')
+ setStatus('就绪')
+ }
+
+ function setupDropzone() {
+ el.dropzone.addEventListener('click', () => el.fileInput.click())
+
+ el.dropzone.addEventListener('dragover', (e) => {
+ e.preventDefault()
+ el.dropzone.classList.add('dragover')
+ })
+ el.dropzone.addEventListener('dragleave', () => el.dropzone.classList.remove('dragover'))
+ el.dropzone.addEventListener('drop', (e) => {
+ e.preventDefault()
+ el.dropzone.classList.remove('dragover')
+ const files = Array.from(e.dataTransfer.files || [])
+ if (!files.length) return
+ state.files.push(...files)
+ renderFileList()
+ })
+ }
+
+ el.fileInput.addEventListener('change', () => {
+ const files = Array.from(el.fileInput.files || [])
+ state.files.push(...files)
+ renderFileList()
+ })
+
+ el.btnClear.addEventListener('click', () => {
+ state.files = []
+ el.fileInput.value = ''
+ renderFileList()
+ })
+
+ el.btnStart.addEventListener('click', async () => {
+ clearError()
+ if (!state.files.length) return
+
+ try {
+ setStatus('上传中')
+ el.btnStart.disabled = true
+ const upload = await apiUpload(state.files)
+ state.taskID = upload.taskID
+
+ const concurrency = clampInt(parseInt(el.concurrencyInput.value, 10), 1, 32, 4)
+ const timeout = clampInt(parseInt(el.timeoutInput.value, 10), 1, 60, 30)
+
+ setStatus('启动处理中')
+ await apiScan(state.taskID, concurrency, timeout)
+
+ setView('progress')
+ setStatus('处理中')
+ openSSE(state.taskID)
+ } catch (e) {
+ showError(e.message || '处理失败')
+ setStatus('出错')
+ el.btnStart.disabled = false
+ }
+ })
+
+ el.btnCancel.addEventListener('click', async () => {
+ clearError()
+ if (!state.taskID) return
+ try {
+ setStatus('取消中')
+ await apiCancel(state.taskID)
+ closeSSE()
+ await loadResultsAndShow()
+ } catch (e) {
+ showError(e.message || '取消失败')
+ }
+ })
+
+ el.btnDownloadExcel.addEventListener('click', () => {
+ if (!state.taskID) return
+ window.location.href = '/api/export/' + encodeURIComponent(state.taskID)
+ })
+
+ el.btnBack.addEventListener('click', () => resetAll())
+
+ function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
+ }
+
+ function formatBytes(bytes) {
+ if (!bytes || bytes < 0) return '0 B'
+ const units = ['B', 'KB', 'MB', 'GB']
+ let n = bytes
+ let i = 0
+ while (n >= 1024 && i < units.length - 1) {
+ n /= 1024
+ i++
+ }
+ return n.toFixed(i === 0 ? 0 : 1) + ' ' + units[i]
+ }
+
+ async function safeJson(res) {
+ try {
+ return await res.json()
+ } catch (_) {
+ return null
+ }
+ }
+
+ function clampInt(v, min, max, fallback) {
+ if (!Number.isFinite(v)) return fallback
+ if (v < min) return min
+ if (v > max) return max
+ return v
+ }
+
+ bindFileListActions()
+ setupDropzone()
+ resetAll()
+})()
+
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..de03955
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,156 @@
+
+
+
+
+
+ 二维码批量识别工具
+
+
+
+
+
+
+
+
+
+
+
+
拖拽压缩包或图片到此处
+
或点击选择文件(ZIP / PNG / JPG / JPEG / BMP)
+
+
+
+
+
+
+
+
+
参数
+
+
+
+
+
+
+
+
+
ZIP≤100MB;图片≤10MB/张;非图片会被忽略
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
当前处理:-
+
速度:- 张/秒 | 预计剩余:-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
失败文件列表
+
+
+
+
+ | 序号 |
+ 图片路径 |
+ 失败原因 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..b74ce71
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,60 @@
+:root {
+ --app-primary: #2563eb;
+ --app-border: #e5e7eb;
+ --app-bg: #f9fafb;
+}
+
+.dropzone {
+ border: 2px dashed var(--app-border);
+ border-radius: 12px;
+ padding: 22px 16px;
+ background: #fff;
+ text-align: center;
+ cursor: pointer;
+ user-select: none;
+}
+
+.dropzone.dragover {
+ border-color: #60a5fa;
+ background: #eff6ff;
+}
+
+.file-list .file-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 0;
+ border-top: 1px solid var(--app-border);
+}
+
+.file-list .file-item:first-child {
+ border-top: 0;
+}
+
+.file-list .file-name {
+ min-width: 0;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.stat {
+ border: 1px solid var(--app-border);
+ border-radius: 12px;
+ padding: 12px;
+ background: #fff;
+}
+
+.stat-label {
+ font-size: 12px;
+ color: #6b7280;
+}
+
+.stat-value {
+ font-size: 24px;
+ font-weight: 700;
+ line-height: 32px;
+ font-variant-numeric: tabular-nums;
+ color: #111827;
+}
diff --git a/utils/archive.go b/utils/archive.go
new file mode 100644
index 0000000..eea0038
--- /dev/null
+++ b/utils/archive.go
@@ -0,0 +1,163 @@
+package utils
+
+import (
+ "archive/zip"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+)
+
+type ExtractedFile struct {
+ RelPath string
+ AbsPath string
+}
+
+type ZipLimits struct {
+ MaxFiles int
+ MaxTotalBytes int64
+ MaxFileBytes int64
+}
+
+func ExtractZip(zipPath string, destDir string, allowedExt map[string]struct{}, limits ZipLimits) ([]ExtractedFile, error) {
+ /*
+ ZIP 解压安全策略(默认拒绝风险输入):
+ - 路径穿越防护(ZipSlip):拒绝包含 “..” 的条目,并校验最终落盘路径必须在 destDir 内
+ - 总展开大小限制:防止解压炸弹
+ - 单文件展开大小限制:防止单文件巨量占用磁盘
+ - 文件数量限制:防止过多条目导致资源耗尽
+ - 加密压缩包:不支持,发现加密条目直接失败(避免交互式密码输入/不确定性)
+ */
+ r, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return nil, err
+ }
+ defer r.Close()
+
+ if err := os.MkdirAll(destDir, 0o755); err != nil {
+ return nil, err
+ }
+
+ var (
+ out []ExtractedFile
+ totalWritten int64
+ )
+
+ for _, f := range r.File {
+ if f.FileInfo().IsDir() {
+ continue
+ }
+
+ if limits.MaxFiles > 0 && len(out) >= limits.MaxFiles {
+ return nil, fmt.Errorf("文件数量超出限制")
+ }
+
+ rel, err := cleanZipPath(f.Name)
+ if err != nil {
+ return nil, err
+ }
+
+ ext := strings.ToLower(filepath.Ext(rel))
+ if _, ok := allowedExt[ext]; !ok {
+ continue
+ }
+
+ dst := filepath.Join(destDir, filepath.FromSlash(rel))
+ if !isWithinDir(destDir, dst) {
+ return nil, fmt.Errorf("压缩包存在不安全路径")
+ }
+
+ if limits.MaxFileBytes > 0 && int64(f.UncompressedSize64) > limits.MaxFileBytes {
+ return nil, fmt.Errorf("压缩包内文件过大")
+ }
+
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ return nil, err
+ }
+
+ rc, err := f.Open()
+ if err != nil {
+ if strings.Contains(strings.ToLower(err.Error()), "password") || strings.Contains(strings.ToLower(err.Error()), "encrypted") {
+ return nil, fmt.Errorf("不支持加密压缩包")
+ }
+ return nil, err
+ }
+
+ written, err := writeFileLimited(dst, rc, limits.MaxFileBytes)
+ _ = rc.Close()
+ if err != nil {
+ if errors.Is(err, errFileTooLarge) {
+ return nil, fmt.Errorf("压缩包内文件过大")
+ }
+ return nil, err
+ }
+
+ totalWritten += written
+ if limits.MaxTotalBytes > 0 && totalWritten > limits.MaxTotalBytes {
+ return nil, fmt.Errorf("解压总大小超出限制")
+ }
+
+ out = append(out, ExtractedFile{RelPath: rel, AbsPath: dst})
+ }
+
+ if len(out) == 0 {
+ return nil, fmt.Errorf("压缩包中未发现可识别的图片文件")
+ }
+ return out, nil
+}
+
+func cleanZipPath(name string) (string, error) {
+ // ZIP 条目路径清洗:把 Windows 分隔符统一成 “/”,并拒绝任何包含 “..” 的段。
+ s := strings.ReplaceAll(name, "\\", "/")
+ for _, part := range strings.Split(s, "/") {
+ if part == ".." {
+ return "", fmt.Errorf("压缩包存在不安全路径")
+ }
+ }
+ clean := path.Clean("/" + s)
+ clean = strings.TrimPrefix(clean, "/")
+ if clean == "" || clean == "." {
+ return "", fmt.Errorf("压缩包路径非法")
+ }
+ if strings.Contains(clean, ":") {
+ return "", fmt.Errorf("压缩包存在不安全路径")
+ }
+ return clean, nil
+}
+
+func isWithinDir(root, target string) bool {
+ // 再次兜底校验:target 必须位于 root 下(防止 clean 规则被绕过)。
+ rootClean := filepath.Clean(root)
+ targetClean := filepath.Clean(target)
+ rel, err := filepath.Rel(rootClean, targetClean)
+ if err != nil {
+ return false
+ }
+ return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
+var errFileTooLarge = errors.New("file too large")
+
+func writeFileLimited(dst string, r io.Reader, maxBytes int64) (int64, error) {
+ f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
+ if err != nil {
+ return 0, err
+ }
+ defer f.Close()
+
+ if maxBytes > 0 {
+ n, err := io.CopyN(f, r, maxBytes+1)
+ if err == nil {
+ return n, errFileTooLarge
+ }
+ if errors.Is(err, io.EOF) {
+ return n, nil
+ }
+ return n, err
+ }
+
+ return io.Copy(f, r)
+}
diff --git a/utils/archive_test.go b/utils/archive_test.go
new file mode 100644
index 0000000..9ee6b90
--- /dev/null
+++ b/utils/archive_test.go
@@ -0,0 +1,34 @@
+package utils
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestExtractZip_PathTraversal(t *testing.T) {
+ dir := t.TempDir()
+ zipPath := filepath.Join(dir, "a.zip")
+ dest := filepath.Join(dir, "out")
+
+ f, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ zw := zip.NewWriter(f)
+ w, err := zw.Create("../evil.png")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = w.Write([]byte("x"))
+ _ = zw.Close()
+ _ = f.Close()
+
+ allowed := map[string]struct{}{".png": {}}
+ _, err = ExtractZip(zipPath, dest, allowed, ZipLimits{MaxFiles: 10, MaxTotalBytes: 1024, MaxFileBytes: 1024})
+ if err == nil {
+ t.Fatalf("expected error")
+ }
+}
+
diff --git a/utils/id.go b/utils/id.go
new file mode 100644
index 0000000..5beadaf
--- /dev/null
+++ b/utils/id.go
@@ -0,0 +1,15 @@
+package utils
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "time"
+)
+
+func NewTaskID(now time.Time) string {
+ b := make([]byte, 4)
+ _, _ = rand.Read(b)
+ return fmt.Sprintf("task_%s_%s", now.Format("20060102_150405"), hex.EncodeToString(b))
+}
+
diff --git a/utils/qrcode.go b/utils/qrcode.go
new file mode 100644
index 0000000..f35f185
--- /dev/null
+++ b/utils/qrcode.go
@@ -0,0 +1,115 @@
+package utils
+
+import (
+ "context"
+ "errors"
+ "image"
+ _ "image/jpeg"
+ _ "image/png"
+ "os"
+ "time"
+
+ "github.com/makiuchi-d/gozxing"
+ multiqrcode "github.com/makiuchi-d/gozxing/multi/qrcode"
+ "github.com/makiuchi-d/gozxing/qrcode"
+ _ "golang.org/x/image/bmp"
+)
+
+var (
+ ErrQRCodeNotFound = errors.New("未检测到二维码")
+ ErrQRCodeDecodeFail = errors.New("二维码解码失败")
+ ErrImageDecode = errors.New("图片无法解析")
+)
+
+func DecodeFile(ctx context.Context, filePath string, timeout time.Duration) ([]string, error) {
+ /*
+ 解码封装:
+ - 统一在此处施加“单张超时”,避免上层 caller 需要重复处理
+ - 使用 goroutine 执行实际解码,让 ctx 超时/取消可以及时返回
+ - 返回值为 []string:支持“单图多码”
+ */
+ if timeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, timeout)
+ defer cancel()
+ }
+
+ type result struct {
+ contents []string
+ err error
+ }
+
+ ch := make(chan result, 1)
+ go func() {
+ contents, err := decodeFile(filePath)
+ ch <- result{contents: contents, err: err}
+ }()
+
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case r := <-ch:
+ return r.contents, r.err
+ }
+}
+
+func decodeFile(filePath string) ([]string, error) {
+ /*
+ 识别策略:
+ 1) 先尝试多码识别(QRCodeMultiReader)
+ 2) 如果多码失败,再回退到单码识别(QRCodeReader)
+ 失败时返回稳定的业务错误(ErrImageDecode/ErrQRCodeNotFound/ErrQRCodeDecodeFail)
+ */
+ f, err := os.Open(filePath)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ img, _, err := image.Decode(f)
+ if err != nil {
+ return nil, ErrImageDecode
+ }
+
+ bmp, err := gozxing.NewBinaryBitmapFromImage(img)
+ if err != nil {
+ return nil, ErrImageDecode
+ }
+
+ mr := multiqrcode.NewQRCodeMultiReader()
+ rs, err := mr.DecodeMultiple(bmp, nil)
+ if err == nil && len(rs) > 0 {
+ return uniqueTexts(rs), nil
+ }
+
+ r := qrcode.NewQRCodeReader()
+ single, err2 := r.Decode(bmp, nil)
+ if err2 == nil && single != nil {
+ return []string{single.GetText()}, nil
+ }
+
+ if err2 != nil {
+ return nil, ErrQRCodeDecodeFail
+ }
+ return nil, ErrQRCodeNotFound
+}
+
+func uniqueTexts(rs []*gozxing.Result) []string {
+ seen := map[string]struct{}{}
+ out := make([]string, 0, len(rs))
+ for _, r := range rs {
+ if r == nil {
+ continue
+ }
+ t := r.GetText()
+ if t == "" {
+ continue
+ }
+ if _, ok := seen[t]; ok {
+ continue
+ }
+ seen[t] = struct{}{}
+ out = append(out, t)
+ }
+ return out
+}
diff --git a/utils/url.go b/utils/url.go
new file mode 100644
index 0000000..603e095
--- /dev/null
+++ b/utils/url.go
@@ -0,0 +1,15 @@
+package utils
+
+import "net/url"
+
+func IsHTTPURL(s string) bool {
+ u, err := url.Parse(s)
+ if err != nil {
+ return false
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return false
+ }
+ return u.Host != ""
+}
+