commit c0680a6ae4c4d9980f2455c1834494d753c847cb Author: fuzhongyun <15339891972@163.com> Date: Sat Feb 28 18:01:53 2026 +0800 first commit 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)}
+
+
${size}
+ +
+
+ ` + }).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 @@ + + + + + + 二维码批量识别工具 + + + + +
+
+

二维码批量识别工具 v1.0

+
就绪
+
+ +
+
+
+
+
拖拽压缩包或图片到此处
+
或点击选择文件(ZIP / PNG / JPG / JPEG / BMP)
+ +
+
+ +
+
+
+
+
上传的文件
+ +
+
暂无文件
+
+
+
+
+
参数
+
+ + +
+
+ + +
+
ZIP≤100MB;图片≤10MB/张;非图片会被忽略
+
+
+
+
+ + +
+ +
+
+
+
处理中…
+
0%
+
+
+
+
+ +
+
+
+
总计
+
0
+
+
+
+
+
已处理
+
0
+
+
+
+
+
成功
+
0
+
+
+
+
+
失败
+
0
+
+
+
+ +
+
当前处理:-
+
速度:- 张/秒 | 预计剩余:-
+
+
+ +
+ +
+
+
+
处理完成!
+
-
+
+ +
+
+
+
成功
+
0
+
+
+
+
+
失败
+
0
+
+
+
+ +
+ + +
+ +
+
失败文件列表
+
+ + + + + + + + + +
序号图片路径失败原因
+
+
+
+
+ + +
+ + + + 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 != "" +} +