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