first commit

This commit is contained in:
fuzhongyun 2026-02-28 18:01:53 +08:00
commit c0680a6ae4
33 changed files with 2578 additions and 0 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
.git
.github
.trae
deploy
**/*_test.go
**/*.log
**/*.tmp
**/*.swp
**/.DS_Store
**/node_modules
**/.vscode

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.trae
.vscode
__debug*

43
Dockerfile Normal file
View File

@ -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"]

73
README.md Normal file
View File

@ -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/`
## APIv1.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
```

76
config/config.go Normal file
View File

@ -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
}

45
go.mod Normal file
View File

@ -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
)

106
go.sum Normal file
View File

@ -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=

49
handlers/cancel.go Normal file
View File

@ -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})
}

17
handlers/common.go Normal file
View File

@ -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})
}

57
handlers/export.go Normal file
View File

@ -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))
}

97
handlers/progress.go Normal file
View File

@ -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
}
}
}
}

50
handlers/results.go Normal file
View File

@ -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,
})
}

71
handlers/scan.go Normal file
View File

@ -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)})
}

163
handlers/upload.go Normal file
View File

@ -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 会自动解压并提取图片
- 自动过滤非图片文件
- 不对重名图片强制覆盖单独上传的图片会在文件名后追加 _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)})
}

65
main.go Normal file
View File

@ -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)
}
}

8
models/request.go Normal file
View File

@ -0,0 +1,8 @@
package models
type ScanRequest struct {
TaskID string `json:"taskID"`
Concurrency int `json:"concurrency"`
Timeout int `json:"timeout"`
}

34
models/response.go Normal file
View File

@ -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"`
}

14
models/result.go Normal file
View File

@ -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"`
}

82
services/exporter.go Normal file
View File

@ -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"))
}

11
services/fs.go Normal file
View File

@ -0,0 +1,11 @@
package services
import "os"
func ensureDir(dir string) error {
if dir == "" || dir == "." {
return nil
}
return os.MkdirAll(dir, 0o755)
}

138
services/progress.go Normal file
View File

@ -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
}

235
services/scanner.go Normal file
View File

@ -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)
}

64
services/scanner_test.go Normal file
View File

@ -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")
}

59
services/store.go Normal file
View File

@ -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()
}
}

80
services/task.go Normal file
View File

@ -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()
}

369
static/app.js Normal file
View File

@ -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 `
<div class="file-item">
<div class="file-name">${escapeHtml(f.name)}</div>
<div class="d-flex align-items-center gap-2">
<div class="text-muted small">${size}</div>
<button class="btn btn-sm btn-outline-danger" data-remove="${idx}">移除</button>
</div>
</div>
`
}).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 `
<tr>
<td>${idx + 1}</td>
<td class="text-break">${escapeHtml(it.file_path || '')}</td>
<td class="text-break">${escapeHtml(it.error_message || '')}</td>
</tr>
`
}).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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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()
})()

156
static/index.html Normal file
View File

@ -0,0 +1,156 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>二维码批量识别工具</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="/static/style.css" rel="stylesheet" />
</head>
<body class="bg-light">
<main class="container py-4" id="app">
<header class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 mb-0">二维码批量识别工具 v1.0</h1>
<div class="text-muted small" id="statusText">就绪</div>
</header>
<section class="card mb-3" id="viewUpload">
<div class="card-body">
<div class="mb-3">
<div id="dropzone" class="dropzone">
<div class="fw-semibold">拖拽压缩包或图片到此处</div>
<div class="text-muted small">或点击选择文件ZIP / PNG / JPG / JPEG / BMP</div>
<input id="fileInput" type="file" class="d-none" multiple accept=".zip,.png,.jpg,.jpeg,.bmp,image/png,image/jpeg,image/bmp" />
</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-8">
<div class="border rounded-3 p-3 bg-white">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="fw-semibold">上传的文件</div>
<button class="btn btn-sm btn-outline-secondary" id="btnClear">清空列表</button>
</div>
<div id="fileList" class="file-list text-muted">暂无文件</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="border rounded-3 p-3 bg-white">
<div class="fw-semibold mb-2">参数</div>
<div class="mb-2">
<label class="form-label small text-muted mb-1" for="concurrencyInput">并发数</label>
<input class="form-control form-control-sm" id="concurrencyInput" type="number" min="1" max="32" value="4" />
</div>
<div class="mb-3">
<label class="form-label small text-muted mb-1" for="timeoutInput">单张超时(秒)</label>
<input class="form-control form-control-sm" id="timeoutInput" type="number" min="1" max="60" value="30" />
</div>
<div class="text-muted small">ZIP≤100MB图片≤10MB/张;非图片会被忽略</div>
</div>
</div>
</div>
</div>
<div class="card-footer d-flex gap-2 justify-content-end">
<button class="btn btn-primary" id="btnStart" disabled>开始处理</button>
</div>
</section>
<section class="card mb-3 d-none" id="viewProgress">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div class="fw-semibold">处理中…</div>
<div class="text-muted small" id="progressPercent">0%</div>
</div>
<div class="progress mt-2" role="progressbar" aria-label="处理进度">
<div class="progress-bar" id="progressBar" style="width:0%"></div>
</div>
<div class="row mt-3 g-3">
<div class="col-6 col-lg-3">
<div class="stat">
<div class="stat-label">总计</div>
<div class="stat-value" id="statTotal">0</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat">
<div class="stat-label">已处理</div>
<div class="stat-value" id="statProcessed">0</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat">
<div class="stat-label">成功</div>
<div class="stat-value text-success" id="statSuccess">0</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat">
<div class="stat-label">失败</div>
<div class="stat-value text-danger" id="statFailed">0</div>
</div>
</div>
</div>
<div class="mt-3 text-muted small">
<div>当前处理:<span id="currentFile">-</span></div>
<div>速度:<span id="speed">-</span> 张/秒 预计剩余:<span id="remaining">-</span></div>
</div>
</div>
<div class="card-footer d-flex gap-2 justify-content-end">
<button class="btn btn-outline-danger" id="btnCancel">取消</button>
</div>
</section>
<section class="card mb-3 d-none" id="viewResult">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div class="fw-semibold" id="resultTitle">处理完成!</div>
<div class="text-muted small" id="taskMeta">-</div>
</div>
<div class="row mt-3 g-3">
<div class="col-6">
<div class="stat">
<div class="stat-label">成功</div>
<div class="stat-value text-success" id="resultSuccess">0</div>
</div>
</div>
<div class="col-6">
<div class="stat">
<div class="stat-label">失败</div>
<div class="stat-value text-danger" id="resultFailed">0</div>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-3 flex-wrap">
<button class="btn btn-primary" id="btnDownloadExcel">下载Excel结果</button>
<button class="btn btn-outline-secondary" id="btnBack">返回首页</button>
</div>
<div class="mt-4 d-none" id="failuresWrap">
<div class="fw-semibold mb-2">失败文件列表</div>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th style="width:72px">序号</th>
<th>图片路径</th>
<th style="width:260px">失败原因</th>
</tr>
</thead>
<tbody id="failuresBody"></tbody>
</table>
</div>
</div>
</div>
</section>
<div class="alert alert-danger d-none" id="errorAlert" role="alert"></div>
</main>
<script src="/static/app.js"></script>
</body>
</html>

60
static/style.css Normal file
View File

@ -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;
}

163
utils/archive.go Normal file
View File

@ -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)
}

34
utils/archive_test.go Normal file
View File

@ -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")
}
}

15
utils/id.go Normal file
View File

@ -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))
}

115
utils/qrcode.go Normal file
View File

@ -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
}

15
utils/url.go Normal file
View File

@ -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 != ""
}