first commit
This commit is contained in:
commit
c0680a6ae4
|
|
@ -0,0 +1,11 @@
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.trae
|
||||||
|
deploy
|
||||||
|
**/*_test.go
|
||||||
|
**/*.log
|
||||||
|
**/*.tmp
|
||||||
|
**/*.swp
|
||||||
|
**/.DS_Store
|
||||||
|
**/node_modules
|
||||||
|
**/.vscode
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
.trae
|
||||||
|
.vscode
|
||||||
|
__debug*
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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})
|
||||||
|
}
|
||||||
|
|
@ -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})
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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)})
|
||||||
|
}
|
||||||
|
|
@ -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)})
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type ScanRequest struct {
|
||||||
|
TaskID string `json:"taskID"`
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
Timeout int `json:"timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func ensureDir(dir string) error {
|
||||||
|
if dir == "" || dir == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.MkdirAll(dir, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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()
|
||||||
|
})()
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 != ""
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue