Author SHA1 Message Date
yml2213 7b156b5abf 计划 2026-05-24 11:17:10 +08:00
yml2213 cdee93c7c5 修复鉴权:401拦截器加refresh重试 + admin refresh接口 + 路由守卫完善
根因:access_token每2小时过期,前端收到401直接清token跳登录,没有用refresh_token续期

后端修复:
- adminauth模块新增 POST /admin/auth/refresh 接口
- Service 注入 JWTManager,支持 admin refresh token 换新 token pair
- Refresh 方法验证 subjectType=admin + tokenType=refresh

前端修复:
- 401 拦截器核心改造:收到401先调 refresh 接口续期
- 加 isRefreshing 锁 + pendingRequests 队列防止并发刷新
- refresh 用原生 axios.post 避免拦截器递归
- 成功则更新 localStorage + 重试原请求,失败才清 token 跳登录
- 排除 /auth/refresh 自身避免死循环
- 支持 /admin/ 请求独立 token 管理
- auth.ts/adminAuth.ts 新增手动 refreshUserToken/refreshAdminSession
- 路由守卫给所有需登录路由添加 meta.requiresAuth
- 守卫同时支持 PC 端 /login 和移动端 /m/login
2026-05-24 07:00:13 +08:00
yml2213 3631e70321 实现结账流程:替换旧归还逻辑为完整结账-修正-争议流程
- 新增 order_checkouts 表,支持结账明细(消耗/押金扣除/退回/号主入账)
- 租客发起结账 → 号主确认 → 已完成(正常路径)
- 号主修改结账 → 租客确认修正 → 已完成(修正路径)
- 结账阶段任一方发起争议 → 后台仲裁 → 已完成/已关闭/异常(争议路径)
- 争议模块适配结账争议类型,仲裁结果新增 mark_abnormal
- 超时任务适配新的 pending_checkout_confirm 状态
- 前端结账明细面板、发起结账/确认/修正/拒绝表单全部实现
- 移除旧的 SubmitReturn/ConfirmReturn 接口
2026-05-24 02:17:36 +08:00
yml2213 25e5599e0a 优化了后台管理界面, 2026-05-24 01:01:04 +08:00
782 changed files with 21003 additions and 157597 deletions
-34
View File
@@ -1,34 +0,0 @@
# caddy 构建上下文是整个仓库根,但 Dockerfile 只需要 frontend/ 源码。
# 排除所有运行时产物与无关目录,避免把数 GB 数据传进构建上下文。
# 后端运行时产物与二进制(caddy 构建完全用不到 backend
backend/tmp/
backend/data/
backend/bin/
backend/api
backend/logs/
backend/.git-cache/
# 前端依赖与产物(镜像内会重新 npm install / build
frontend/node_modules/
frontend/dist/
frontend/.cache/
# 版本库与备份
.git/
.git-cache/
backups/
.idea/
.snow/
.claude/
# 密钥与环境文件
**/.env
**/.env.*
# 文档
docs/
**/*.md
# 杂项
**/.DS_Store
-18
View File
@@ -1,18 +1,14 @@
.snow .snow
.vscode .vscode
.idea .idea
.claude
*.log *.log
backend/logs/
*.tmp *.tmp
*.bak *.bak
.Ds_Store
# Environment # Environment
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.prod.example
# Go # Go
bin/ bin/
@@ -23,20 +19,6 @@ node_modules/
dist/ dist/
.vite/ .vite/
*.tsbuildinfo *.tsbuildinfo
# unplugin-vue-components 自动生成,随组件使用情况变动,不纳入版本控制
frontend/components.d.ts
# Docker / local data # Docker / local data
.docker-data/ .docker-data/
backups/
.DS_Store
backend/api
# 选号网构建产物(由 deploy-prod.sh 生成)
deploy/caddy/show-dist/**
!deploy/caddy/show-dist/.gitkeep
!deploy/caddy/show-dist/index.html
deploy/caddy/conf.d/show.caddy
logs
.gocache
+16 -31
View File
@@ -18,21 +18,19 @@
脚本会自动启动 MySQL、Redis、MinIO,首次初始化数据库结构,并同时启动后端和前端。 脚本会自动启动 MySQL、Redis、MinIO,首次初始化数据库结构,并同时启动后端和前端。
前端地址:`http://localhost:5189` 前端地址:`http://localhost:5173`
后台地址默认`http://localhost:5189/admin/login`。可通过 `VITE_ADMIN_BASE_PATH` 调整后台页面入口 后台地址:`http://localhost:5173/admin/login`,开发态默认管理员为 `admin / admin123456`
如需拆分调试,可手动执行: 如需拆分调试,可手动执行:
```bash ```bash
docker compose -f deploy/docker-compose.dev.yml up -d docker compose -f deploy/docker-compose.dev.yml up -d
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < backend/migrations/000001_init.sql
cd backend cd backend
cp .env.example .env cp .env.example .env
go run github.com/pressly/goose/v3/cmd/goose@v3.27.1 \
-dir migrations \
mysql 'hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true' \
up
go run ./cmd/api go run ./cmd/api
cd ../frontend cd ../frontend
@@ -40,21 +38,9 @@ npm install
npm run dev npm run dev
``` ```
## 代码门禁
提交前或部署前在本地执行全量检查,覆盖后端 `go vet` / `go build` / `go test` 与前端 `prettier` / `eslint` / `typecheck` / `vite build`
```bash
./scripts/check.sh
```
`./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go``npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend``--skip-frontend``--skip-tests` 做局部调试。
## 开发态短信与实名 ## 开发态短信与实名
- 后端日志使用单行可读文本。开发环境同时输出控制台和 `backend/logs/app-YYYY-MM-DD.log`,生产示例只输出文件以避免重复存储;可通过 `LOG_LEVEL=debug|info|warn|error` 调整级别 - 短信验证码使用 mock 适配器,验证码会打印在后端日志中
- 短信验证码默认使用 mock 适配器,本地验证码会以 INFO 日志输出,手机号保持脱敏。生产环境强制使用 `SMS_PROVIDER=aliyun`
- 阿里云短信需要配置 `ALIYUN_ACCESS_KEY_ID``ALIYUN_ACCESS_KEY_SECRET``ALIYUN_SMS_SIGN_NAME``ALIYUN_SMS_LOGIN_TEMPLATE_CODE`,模板变量名默认为 `code`
- 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。 - 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。
- 实名状态可通过 `GET /api/realname/status` 查询。 - 实名状态可通过 `GET /api/realname/status` 查询。
- 租号发布需要登录并完成实名认证;`POST /api/listings/{id}/submit-review` 会进入待审核,后台通过后才上架。 - 租号发布需要登录并完成实名认证;`POST /api/listings/{id}/submit-review` 会进入待审核,后台通过后才上架。
@@ -63,21 +49,20 @@ npm run dev
- 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。 - 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。
- 后端已接入开发态订单超时扫描任务,会按系统配置处理交接超时、确认收号超时、逾期未归还和确认归还超时。 - 后端已接入开发态订单超时扫描任务,会按系统配置处理交接超时、确认收号超时、逾期未归还和确认归还超时。
- 文件上传已接入 MinIO,发布账号资产截图和申诉证据可上传 JPG、PNG、WebP 或 PDF。 - 文件上传已接入 MinIO,发布账号资产截图和申诉证据可上传 JPG、PNG、WebP 或 PDF。
- MinIO 开发地址默认使用 `http://localhost:19090`,控制台默认使用 `http://localhost:19091` - 如果本机有代理工具占用 `127.0.0.1:9000`MinIO 开发地址使用 `http://localhost:9000`
- 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance``GET /api/wallet/ledger` 查看。 - 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance``GET /api/wallet/ledger` 查看。
- 后台资金流水已接入,支持按用户、订单和业务类型查询。 - 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`支持按用户、订单和业务类型查询。
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。 - 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
- 订单群聊已支持支付成功后自动创建,租客、号主和客服可在移动端消息页进入会话 - 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理,后台页面为 `http://localhost:5173/admin/disputes`
- 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理 - 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志
- 系统配置已支持默认配置初始化和后台编辑,更新会写入审计日志 - 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`
- 后台已使用独立登录、图形验证码和独立管理 UI,页面入口由 `VITE_ADMIN_BASE_PATH` 控制 - 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`
- 后台仪表盘已接入真实统计数据 - 用户管理后台已接入,页面为 `http://localhost:5173/admin/users`,支持冻结和解冻用户
- 用户管理后台已接入,支持冻结和解冻用户 - 订单管理后台已接入,页面为 `http://localhost:5173/admin/orders`,支持查看全量订单和交接记录
- 订单管理后台已接入,支持查看全量订单和交接记录 - 商品管理后台已接入,页面为 `http://localhost:5173/admin/listings`,支持查看全量商品、商品详情、强制下架和标记异常
- 商品管理后台已接入,支持查看全量商品、商品详情、强制下架和标记异常。
- 订单详情后台已支持客服关闭和标记异常,相关操作会写入审计日志并通知双方。 - 订单详情后台已支持客服关闭和标记异常,相关操作会写入审计日志并通知双方。
- 商品审核后台已接入,审核员可查看账号资产截图后通过或拒绝。 - 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`审核员可查看账号资产截图后通过或拒绝。
- 审计日志后台已接入,支持查看高风险操作明细。 - 审计日志后台已接入,页面为 `http://localhost:5173/admin/audit-logs`支持查看高风险操作明细。
## 文档 ## 文档
-32
View File
@@ -1,32 +0,0 @@
# 构建只需要 Go 源码(cmd / internal / pkg / migrations / go.mod / go.sum
# 以下均为运行时产物、二进制、密钥与文档,排除后可把构建上下文从 ~1GB 降到几 MB
# 运行时上传文件与临时目录(最大头,~980MB)
tmp/
data/
# 编译产物
bin/
api
*.exe
# 日志
logs/
*.log
# 本地缓存
.git-cache/
# 密钥与环境文件(避免打进构建上下文)
.env
.env.*
# 脚本与文档(构建不需要)
# 注意:backend/docs 是被 router.go import 的 Swagger Go 包,不能排除
scripts/
*.md
# 其它
.DS_Store
Dockerfile
.dockerignore
+4 -78
View File
@@ -1,87 +1,13 @@
APP_ENV=development APP_ENV=development
APP_ADDR=:18088 APP_ADDR=:8080
# MySQL 容器初始化变量,同时供后端 DSN 使用。 MYSQL_DSN=hfb:secret@tcp(127.0.0.1:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
MYSQL_ROOT_PASSWORD=rootsecret REDIS_ADDR=127.0.0.1:6379
MYSQL_DATABASE=hfb_sys
MYSQL_USER=hfb
MYSQL_PASSWORD=secret
MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
REDIS_ADDR=127.0.0.1:16379
REDIS_PASSWORD= REDIS_PASSWORD=
REDIS_DB=0 REDIS_DB=0
JWT_SECRET=change-me JWT_SECRET=change-me
STORAGE_ENDPOINT=http://localhost:9000
# 前端后台页面入口;本地默认 /admin,生产建议改成不公开的单段路径。
VITE_ADMIN_BASE_PATH=/admin
# 首次启动且管理员表为空时,按以下变量创建首个超级管理员。
# 本地开发可自行填写,生产环境请使用随机强密码,创建后建议从 .env 移除。
ADMIN_BOOTSTRAP_USERNAME=
ADMIN_BOOTSTRAP_PASSWORD=
ADMIN_BOOTSTRAP_NICKNAME=超级管理员
# API 限流:默认开启,每个 IP 每分钟 300 次。
RATE_LIMIT_ENABLED=true
RATE_LIMIT_REQUESTS_PER_MINUTE=300
LOG_LEVEL=info
LOG_DIR=logs
LOG_ENABLE_CONSOLE=true
LOG_ENABLE_FILE=true
LOG_RETAIN_DAYS=14
# MinIO 容器初始化变量,同时供后端对象存储使用。
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
STORAGE_ENDPOINT=http://localhost:19090
STORAGE_BUCKET=hfb-sys STORAGE_BUCKET=hfb-sys
STORAGE_ACCESS_KEY_ID=minioadmin STORAGE_ACCESS_KEY_ID=minioadmin
STORAGE_SECRET_ACCESS_KEY=minioadmin STORAGE_SECRET_ACCESS_KEY=minioadmin
STORAGE_REGION=
STORAGE_BUCKET_LOOKUP=auto
# 对象存储迁移期间的镜像端;本地开发默认关闭。
STORAGE_MIRROR_ENDPOINT=
STORAGE_MIRROR_BUCKET=
STORAGE_MIRROR_ACCESS_KEY_ID=
STORAGE_MIRROR_SECRET_ACCESS_KEY=
STORAGE_MIRROR_REGION=
STORAGE_MIRROR_BUCKET_LOOKUP=auto
# 短信服务:本地开发使用 mock。
SMS_PROVIDER=mock
ALIYUN_ACCESS_KEY_ID=
ALIYUN_ACCESS_KEY_SECRET=
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_LOGIN_TEMPLATE_CODE=
# 实名认证:本地开发使用 mock;生产可改为 cloudmarket 并填写云市场 AppCode。
REALNAME_PROVIDER=mock
REALNAME_CLOUDMARKET_URL=https://sinocheck2.market.alicloudapi.com/fortest/ttttt
REALNAME_CLOUDMARKET_APPCODE=
# 支付商户、网关和回调等业务配置通过后台支付配置页维护。
# 支付配置加密密钥(必须为 16、24 或 32 字节;生产环境不要留空)
# 用于加密存储支付商户配置中的敏感信息(sign_key、notify_key
# 生成方式:openssl rand -hex 16
PAYMENT_CONFIG_ENCRYPTION_KEY=
# 业务字段加密密钥(必须为 16、24 或 32 字节;生产环境必填且与下方 legacy 不同)
# 用于加密实名信息(姓名、身份证)和收款账号(银行卡/支付宝号)
# 生成方式:openssl rand -hex 16
FIELD_ENCRYPTION_KEY=
# 业务字段旧密钥(可选;密钥轮换期间用于透明解出旧密文)
# 留空时使用内置默认值,兼容历史硬编码密钥加密的开发/测试数据。
# 生产环境轮换流程:设新 FIELD_ENCRYPTION_KEY,把旧密钥填到这里,
# 待所有存量密文被读出后(或跑重加密脚本),删除此变量。
FIELD_ENCRYPTION_LEGACY_KEY=
# 开放导入接口可选签名密钥;留空或请求未带签名时按旧方式导入。
# 若调用方携带 X-HFB-Timestamp 和 X-HFB-Signature,则会校验 HMAC 签名。
EXTERNAL_UPLOAD_SECRET=
# 可选:逗号分隔的 IP 或 CIDR 白名单,例如 127.0.0.1,10.0.0.0/8。
EXTERNAL_UPLOAD_ALLOWED_IPS=
-104
View File
@@ -1,104 +0,0 @@
# 生产环境示例:复制为 backend/.env 后再替换所有 change-* 占位值。
APP_ENV=production
APP_ADDR=:8080
# Caddy 自动申请和续签 HTTPS 证书使用。
CADDY_DOMAIN=hfb.221329.cc.cd
CADDY_EMAIL=admin@example.com
# 选号网(hfb_show)独立域名,与主站同机;不部署选号网可留空。
# CADDY_SHOW_DOMAIN=show.example.com
# 选号网源码目录(相对 hfb_sys 根或绝对路径),默认 ../hfb_show
# HFB_SHOW_DIR=../hfb_show
# 选号网 npm 源(国内服务器默认用 npmmirror,避免 registry.npmjs.org 超时)
# NPM_REGISTRY=https://registry.npmmirror.com
# MySQL 容器初始化变量,同时供后端 DSN 使用。
MYSQL_ROOT_PASSWORD=change-root-password
MYSQL_DATABASE=hfb_sys
MYSQL_USER=hfb
MYSQL_PASSWORD=change-hfb-password
MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
REDIS_ADDR=redis:6379
REDIS_PASSWORD=
REDIS_DB=0
JWT_SECRET=change-to-a-long-random-secret
# 前端后台页面入口;生产请改成不公开的单段路径,不要使用 /admin。
VITE_ADMIN_BASE_PATH=/manage-7x9k2p
# 首次启动且管理员表为空时创建首个超级管理员;生产密码至少 12 位且包含字母和数字。
# 创建成功后建议从 .env 移除,后续管理员通过后台维护。
ADMIN_BOOTSTRAP_USERNAME=change-admin-username
ADMIN_BOOTSTRAP_PASSWORD=change-admin-password-123
ADMIN_BOOTSTRAP_NICKNAME=超级管理员
# API 限流:默认开启,每个 IP 每分钟 300 次。
RATE_LIMIT_ENABLED=true
RATE_LIMIT_REQUESTS_PER_MINUTE=300
# 后端容器使用宿主机业务用户的 UID/GID,服务器上可用 id -u yml、id -g yml 查询。
BACKEND_UID=1000
BACKEND_GID=1000
LOG_LEVEL=info
LOG_DIR=/app/logs
LOG_ENABLE_CONSOLE=false
LOG_ENABLE_FILE=true
LOG_RETAIN_DAYS=14
# MinIO 容器初始化变量。迁移完成前仍保留,用于回退与校验。
MINIO_ROOT_USER=change-minio-user
MINIO_ROOT_PASSWORD=change-minio-password
# 当前主对象存储。使用内置 MinIO 时,STORAGE_* 密钥必须和 MINIO_ROOT_* 保持一致。
STORAGE_ENDPOINT=http://minio:9000
STORAGE_BUCKET=hfb-sys
STORAGE_ACCESS_KEY_ID=change-minio-user
STORAGE_SECRET_ACCESS_KEY=change-minio-password
STORAGE_REGION=
STORAGE_BUCKET_LOOKUP=auto
# OSS 迁移镜像端。迁移期间设置后,新上传文件会同时写入 MinIO 与 OSS。
# 杭州 ECS 应使用内网 Endpointhttps://oss-cn-hangzhou-internal.aliyuncs.com
# OSS 的 S3 兼容访问使用 cn-hangzhou 区域和 DNS Bucket 寻址。
STORAGE_MIRROR_ENDPOINT=
STORAGE_MIRROR_BUCKET=hfb-sys-assets
STORAGE_MIRROR_ACCESS_KEY_ID=
STORAGE_MIRROR_SECRET_ACCESS_KEY=
STORAGE_MIRROR_REGION=cn-hangzhou
STORAGE_MIRROR_BUCKET_LOOKUP=dns
# 生产环境建议接入真实短信服务;未配置时不要使用 mock 对外运营。
SMS_PROVIDER=aliyun
ALIYUN_ACCESS_KEY_ID=
ALIYUN_ACCESS_KEY_SECRET=
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_LOGIN_TEMPLATE_CODE=
# 生产环境建议接入真实实名服务;未配置时不要使用 mock 对外运营。
REALNAME_PROVIDER=cloudmarket
REALNAME_CLOUDMARKET_URL=https://sinocheck2.market.alicloudapi.com/fortest/ttttt
REALNAME_CLOUDMARKET_APPCODE=
# 支付商户、网关和回调等业务配置通过后台支付配置页维护。
# 支付配置加密密钥(必须为 16、24 或 32 字节,生产环境必填)
# 用于加密存储支付商户配置中的敏感信息(sign_key、notify_key
# 生成方式:openssl rand -hex 16
# 警告:此密钥一旦设置不要更改,否则已有配置无法解密
PAYMENT_CONFIG_ENCRYPTION_KEY=change-to-32-byte-encryption-key
# 业务字段加密密钥(必须为 16、24 或 32 字节,生产环境必填且与 legacy 不同)
# 用于加密实名信息(姓名、身份证)和收款账号(银行卡/支付宝号)
# 生成方式:openssl rand -hex 16
FIELD_ENCRYPTION_KEY=change-to-32-byte-field-encryption-key
# 业务字段旧密钥(密钥轮换期间用于透明解出旧密文,验证全部密文轮换完后删除)
# 首次部署若数据库已有用旧硬编码密钥加密的存量数据,填 hfb-sys-2024-secret-key-32bytes!
FIELD_ENCRYPTION_LEGACY_KEY=
# 开放导入接口可选签名密钥;内部调用方暂不签名时可留空。
# 若启用签名,生成方式:openssl rand -hex 32
EXTERNAL_UPLOAD_SECRET=
# 可选:逗号分隔的 IP 或 CIDR 白名单,例如 203.0.113.10,10.0.0.0/8。
EXTERNAL_UPLOAD_ALLOWED_IPS=
+2 -29
View File
@@ -1,41 +1,14 @@
FROM golang:1.26-alpine AS build FROM golang:1.26-alpine AS build
WORKDIR /src WORKDIR /src
ARG GOOSE_VERSION=v3.27.1
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
ENV GOPROXY=${GOPROXY} \
GOSUMDB=${GOSUMDB}
COPY go.mod go.sum* ./ COPY go.mod go.sum* ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download RUN go mod download
COPY . . COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \ RUN CGO_ENABLED=0 GOOS=linux go build -o /out/hfb-api ./cmd/api
--mount=type=cache,target=/root/.cache/go-build \
go install github.com/pressly/goose/v3/cmd/goose@${GOOSE_VERSION}
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -o /out/hfb-api ./cmd/api
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -o /out/hfb-storage-migrate ./cmd/storage-migrate
FROM alpine:3.22 FROM alpine:3.22
WORKDIR /app WORKDIR /app
ENV TZ=Asia/Shanghai
# 国内服务器访问 dl-cdn.alpinelinux.org 常极慢,改用阿里云镜像
RUN sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.aliyun.com/alpine#g' \
/etc/apk/repositories \
&& apk add --no-cache tzdata \
&& addgroup -S -g 10001 app \
&& adduser -S -D -H -u 10001 -G app app \
&& mkdir -p /app/logs \
&& chown app:app /app/logs
COPY --from=build /out/hfb-api /app/hfb-api COPY --from=build /out/hfb-api /app/hfb-api
COPY --from=build /out/hfb-storage-migrate /app/hfb-storage-migrate
COPY --from=build /go/bin/goose /app/goose
COPY --from=build /src/migrations /app/migrations
USER app:app
EXPOSE 8080 EXPOSE 8080
CMD ["/app/hfb-api"] CMD ["/app/hfb-api"]
+12 -86
View File
@@ -2,8 +2,6 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -12,66 +10,31 @@ import (
"hfb_sys/backend/internal/config" "hfb_sys/backend/internal/config"
"hfb_sys/backend/internal/database" "hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
"hfb_sys/backend/internal/jobs/ordertimeout" "hfb_sys/backend/internal/jobs/ordertimeout"
"hfb_sys/backend/internal/jobs/refundretry"
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/internal/modules/adminauth"
"hfb_sys/backend/internal/modules/payment"
"hfb_sys/backend/internal/modules/paymentconfig"
"hfb_sys/backend/internal/router" "hfb_sys/backend/internal/router"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
) )
// @title HFB Sys API
// @version 1.0
// @description 哈夫币租号平台 API 文档
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.email support@hfb.com
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /api
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
func main() { func main() {
cfg := config.Load() cfg := config.Load()
logger, err := logging.New(cfg.Log) logger, err := zap.NewProduction()
if cfg.AppEnv == "development" {
logger, err = zap.NewDevelopment()
}
if err != nil { if err != nil {
panic(err) panic(err)
} }
defer func() { defer func() {
_ = logger.Sync() _ = logger.Sync()
}() }()
if err := cfg.ValidateProductionSecurity(); err != nil {
logger.Fatal("生产安全配置校验失败", zap.Error(err))
}
logAuthRuntimeIdentity(logger, cfg)
var deps router.Dependencies var deps router.Dependencies
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level, logger) db, err := database.OpenMySQL(cfg.MySQLDSN)
if err != nil { if err != nil {
logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err)) logger.Warn("mysql unavailable; database-backed APIs will return 503", zap.Error(err))
} else { } else {
deps.DB = db deps.DB = db
if err := adminauth.BootstrapAdmin(context.Background(), db, adminauth.BootstrapConfig{
AppEnv: cfg.AppEnv,
Username: cfg.BootstrapAdminUsername,
Password: cfg.BootstrapAdminPassword,
Nickname: cfg.BootstrapAdminNickname,
}); err != nil {
logger.Fatal("初始管理员创建失败", zap.Error(err))
}
} }
redisClient, err := database.OpenRedis(context.Background(), database.RedisConfig{ redisClient, err := database.OpenRedis(context.Background(), database.RedisConfig{
Addr: cfg.RedisAddr, Addr: cfg.RedisAddr,
@@ -79,7 +42,7 @@ func main() {
DB: cfg.RedisDB, DB: cfg.RedisDB,
}) })
if err != nil { if err != nil {
logger.Warn("Redis 不可用,相关接口将返回 503", zap.Error(err)) logger.Warn("redis unavailable; redis-backed APIs will return 503", zap.Error(err))
} else { } else {
deps.Redis = redisClient deps.Redis = redisClient
defer func() { defer func() {
@@ -91,12 +54,7 @@ func main() {
jobCtx, stopJobs := context.WithCancel(context.Background()) jobCtx, stopJobs := context.WithCancel(context.Background())
defer stopJobs() defer stopJobs()
if deps.DB != nil { if deps.DB != nil {
ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx) ordertimeout.New(deps.DB, logger).Start(jobCtx)
fileuploadcleanup.New(deps.DB, deps.Redis, logger).Start(jobCtx)
if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil {
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
}
} }
server := &http.Server{ server := &http.Server{
Addr: cfg.AppAddr, Addr: cfg.AppAddr,
@@ -105,9 +63,9 @@ func main() {
} }
go func() { go func() {
logger.Info("API 服务启动", zap.String("addr", cfg.AppAddr)) logger.Info("api server starting", zap.String("addr", cfg.AppAddr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal("API 服务异常退出", zap.Error(err)) logger.Fatal("api server failed", zap.Error(err))
} }
}() }()
@@ -119,39 +77,7 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := server.Shutdown(ctx); err != nil { if err := server.Shutdown(ctx); err != nil {
logger.Fatal("API 服务关闭失败", zap.Error(err)) logger.Fatal("api server shutdown failed", zap.Error(err))
} }
logger.Info("API 服务已停止") logger.Info("api server stopped")
}
// logAuthRuntimeIdentity 记录可用于排查多实例或部署配置漂移的非敏感身份信息。
func logAuthRuntimeIdentity(logger *zap.Logger, cfg config.Config) {
instanceID, err := os.Hostname()
if err != nil || instanceID == "" {
instanceID = "unknown"
}
sum := sha256.Sum256([]byte(cfg.JWTSecret))
logger.Debug("认证运行实例",
zap.String("instance_id", instanceID),
zap.String("app_env", cfg.AppEnv),
zap.String("jwt_secret_fingerprint", hex.EncodeToString(sum[:])[:12]),
)
}
func newPaymentConfigRepositoryForJobs(cfg config.Config, db *gorm.DB, logger *zap.Logger) *paymentconfig.Repository {
encryptionKey := cfg.PaymentConfigEncryptionKey
var encryptor paymentconfig.Encryptor
if encryptionKey != "" {
if aesEncryptor, err := paymentconfig.NewAESEncryptor(encryptionKey); err == nil {
encryptor = aesEncryptor
}
}
if encryptor == nil {
if config.IsProductionEnv(cfg.AppEnv) {
logger.Fatal("支付配置加密密钥未设置或无效")
}
encryptor = &paymentconfig.MockEncryptor{}
logger.Debug("定时任务使用模拟支付配置加密器")
}
return paymentconfig.NewRepository(db, encryptor)
} }
-228
View File
@@ -1,228 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"strings"
"hfb_sys/backend/internal/config"
filemodule "hfb_sys/backend/internal/modules/file"
"github.com/minio/minio-go/v7"
)
type migrationConfig struct {
source config.StorageConfig
target config.StorageConfig
}
type summary struct {
objectsCopied int64
bytesCopied int64
objectsSkipped int64
objectsFailed int64
}
func main() {
verifyOnly := flag.Bool("verify", false, "仅校验目标对象,不上传文件")
dryRun := flag.Bool("dry-run", false, "只输出待迁移对象,不上传文件")
prefix := flag.String("prefix", "", "仅迁移指定前缀")
flag.Parse()
if *verifyOnly && *dryRun {
log.Fatal("--verify 与 --dry-run 不能同时使用")
}
cfg, err := loadMigrationConfig()
if err != nil {
log.Fatal(err)
}
if sameStorage(cfg.source, cfg.target) {
log.Fatal("源存储和目标存储相同,已拒绝执行")
}
ctx := context.Background()
source, err := filemodule.NewStorageClient(cfg.source)
if err != nil {
log.Fatalf("创建 MinIO 源客户端失败: %v", err)
}
target, err := filemodule.NewStorageClient(cfg.target)
if err != nil {
log.Fatalf("创建 OSS 目标客户端失败: %v", err)
}
if err := requireBucket(ctx, source, cfg.source.Bucket, "源"); err != nil {
log.Fatal(err)
}
if err := requireBucket(ctx, target, cfg.target.Bucket, "目标"); err != nil {
log.Fatal(err)
}
mode := "同步"
if *verifyOnly {
mode = "校验"
} else if *dryRun {
mode = "预演"
}
log.Printf("开始%s%s/%s -> %s/%s,前缀=%q", mode, cfg.source.Endpoint, cfg.source.Bucket, cfg.target.Endpoint, cfg.target.Bucket, *prefix)
result, err := migrate(ctx, source, cfg.source.Bucket, target, cfg.target.Bucket, *prefix, *verifyOnly, *dryRun)
log.Printf("结束:已复制 %d 个对象(%d 字节),已跳过 %d 个对象,失败 %d 个对象", result.objectsCopied, result.bytesCopied, result.objectsSkipped, result.objectsFailed)
if err != nil {
log.Fatal(err)
}
}
func loadMigrationConfig() (migrationConfig, error) {
source := config.StorageConfig{
Endpoint: os.Getenv("STORAGE_ENDPOINT"),
Bucket: os.Getenv("STORAGE_BUCKET"),
AccessKeyID: os.Getenv("STORAGE_ACCESS_KEY_ID"),
SecretAccessKey: os.Getenv("STORAGE_SECRET_ACCESS_KEY"),
Region: os.Getenv("STORAGE_REGION"),
BucketLookup: valueOrDefault("STORAGE_BUCKET_LOOKUP", "auto"),
}
target := config.StorageConfig{
Endpoint: os.Getenv("STORAGE_MIRROR_ENDPOINT"),
Bucket: os.Getenv("STORAGE_MIRROR_BUCKET"),
AccessKeyID: os.Getenv("STORAGE_MIRROR_ACCESS_KEY_ID"),
SecretAccessKey: os.Getenv("STORAGE_MIRROR_SECRET_ACCESS_KEY"),
Region: os.Getenv("STORAGE_MIRROR_REGION"),
BucketLookup: valueOrDefault("STORAGE_MIRROR_BUCKET_LOOKUP", "auto"),
}
if err := validateStorageConfig("源 MinIO", source); err != nil {
return migrationConfig{}, err
}
if err := validateStorageConfig("目标 OSS", target); err != nil {
return migrationConfig{}, err
}
return migrationConfig{source: source, target: target}, nil
}
func valueOrDefault(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func validateStorageConfig(name string, cfg config.StorageConfig) error {
if strings.TrimSpace(cfg.Endpoint) == "" || strings.TrimSpace(cfg.Bucket) == "" || strings.TrimSpace(cfg.AccessKeyID) == "" || strings.TrimSpace(cfg.SecretAccessKey) == "" {
return fmt.Errorf("%s 配置不完整,请检查 endpoint、bucket 和访问密钥", name)
}
return nil
}
func sameStorage(source, target config.StorageConfig) bool {
return strings.TrimRight(strings.ToLower(source.Endpoint), "/") == strings.TrimRight(strings.ToLower(target.Endpoint), "/") && source.Bucket == target.Bucket
}
func requireBucket(ctx context.Context, client *minio.Client, bucket, label string) error {
exists, err := client.BucketExists(ctx, bucket)
if err != nil {
return fmt.Errorf("检查%s Bucket 失败: %w", label, err)
}
if !exists {
return fmt.Errorf("%s Bucket 不存在: %s", label, bucket)
}
return nil
}
func migrate(ctx context.Context, source *minio.Client, sourceBucket string, target *minio.Client, targetBucket, prefix string, verifyOnly, dryRun bool) (summary, error) {
var result summary
for item := range source.ListObjects(ctx, sourceBucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) {
if item.Err != nil {
return result, fmt.Errorf("列出源对象失败: %w", item.Err)
}
sourceInfo, err := source.StatObject(ctx, sourceBucket, item.Key, minio.StatObjectOptions{})
if err != nil {
result.objectsFailed++
log.Printf("跳过对象 %s:读取源对象元数据失败: %v", item.Key, err)
continue
}
matches, err := targetMatches(ctx, target, targetBucket, item.Key, sourceInfo)
if err != nil {
result.objectsFailed++
log.Printf("跳过对象 %s:检查目标对象失败: %v", item.Key, err)
continue
}
if matches {
result.objectsSkipped++
continue
}
if verifyOnly {
result.objectsFailed++
continue
}
if dryRun {
log.Printf("待复制:%s%d 字节)", item.Key, sourceInfo.Size)
continue
}
if err := copyObject(ctx, source, sourceBucket, target, targetBucket, item.Key, sourceInfo); err != nil {
result.objectsFailed++
log.Printf("复制对象 %s 失败: %v", item.Key, err)
continue
}
result.objectsCopied++
result.bytesCopied += sourceInfo.Size
}
if result.objectsFailed > 0 {
if verifyOnly {
return result, fmt.Errorf("校验失败:%d 个对象缺失、内容不匹配或无法检查", result.objectsFailed)
}
return result, fmt.Errorf("同步未完成:%d 个对象处理失败", result.objectsFailed)
}
return result, nil
}
func targetMatches(ctx context.Context, target *minio.Client, bucket, key string, source minio.ObjectInfo) (bool, error) {
targetInfo, err := target.StatObject(ctx, bucket, key, minio.StatObjectOptions{})
if err != nil {
if isNotFound(err) {
return false, nil
}
return false, fmt.Errorf("读取目标对象 %s 元数据失败: %w", key, err)
}
if targetInfo.Size != source.Size {
return false, nil
}
return sameETag(source.ETag, targetInfo.ETag), nil
}
func copyObject(ctx context.Context, source *minio.Client, sourceBucket string, target *minio.Client, targetBucket, key string, info minio.ObjectInfo) error {
object, err := source.GetObject(ctx, sourceBucket, key, minio.GetObjectOptions{})
if err != nil {
return fmt.Errorf("读取源对象 %s 失败: %w", key, err)
}
defer func() { _ = object.Close() }()
_, err = target.PutObject(ctx, targetBucket, key, object, info.Size, minio.PutObjectOptions{
ContentType: info.ContentType,
UserMetadata: info.UserMetadata,
})
if err != nil {
return fmt.Errorf("复制对象 %s 到目标 OSS 失败: %w", key, err)
}
verified, err := targetMatches(ctx, target, targetBucket, key, info)
if err != nil {
return err
}
if !verified {
return fmt.Errorf("复制对象 %s 后校验失败", key)
}
return nil
}
func sameETag(left, right string) bool {
return strings.Trim(strings.ToLower(left), "\"") == strings.Trim(strings.ToLower(right), "\"")
}
func isNotFound(err error) bool {
response := minio.ToErrorResponse(err)
switch response.Code {
case "NoSuchKey", "NoSuchObject", "NoSuchBucket", "NotFound":
return true
default:
return false
}
}
-276
View File
@@ -1,276 +0,0 @@
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"termsOfService": "http://swagger.io/terms/",
"contact": {
"name": "API Support",
"email": "support@hfb.com"
},
"license": {
"name": "MIT",
"url": "https://opensource.org/licenses/MIT"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/auth/logout": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "用户登出(客户端需清除本地 token)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"认证"
],
"summary": "登出",
"responses": {
"200": {
"description": "登出成功",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
}
}
}
},
"/auth/refresh": {
"post": {
"description": "使用 refresh_token 获取新的 access_token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"认证"
],
"summary": "刷新访问令牌",
"parameters": [
{
"description": "刷新令牌",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_modules_auth.RefreshRequest"
}
}
],
"responses": {
"200": {
"description": "刷新成功,返回新的 token",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"400": {
"description": "请求参数错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"401": {
"description": "refresh_token 无效",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
}
}
}
},
"/auth/sms/login": {
"post": {
"description": "使用手机号和验证码登录",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"认证"
],
"summary": "短信验证码登录",
"parameters": [
{
"description": "登录信息",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_modules_auth.LoginRequest"
}
}
],
"responses": {
"200": {
"description": "登录成功,返回用户信息和 token",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"400": {
"description": "请求参数错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"401": {
"description": "验证码错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"500": {
"description": "服务器错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
}
}
}
},
"/auth/sms/send": {
"post": {
"description": "发送登录验证码到指定手机号",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"认证"
],
"summary": "发送短信验证码",
"parameters": [
{
"description": "手机号",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_modules_auth.SendSMSRequest"
}
}
],
"responses": {
"200": {
"description": "发送成功",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"400": {
"description": "请求参数错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"500": {
"description": "服务器错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
}
}
}
}
},
"definitions": {
"hfb_sys_backend_pkg_response.Body": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"data": {},
"message": {
"type": "string"
}
}
},
"internal_modules_auth.LoginRequest": {
"type": "object",
"required": [
"code",
"phone"
],
"properties": {
"code": {
"type": "string"
},
"phone": {
"type": "string"
}
}
},
"internal_modules_auth.RefreshRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string"
}
}
},
"internal_modules_auth.SendSMSRequest": {
"type": "object",
"required": [
"phone"
],
"properties": {
"phone": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"description": "Type \"Bearer\" followed by a space and JWT token.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "localhost:8080",
BasePath: "/api",
Schemes: []string{},
Title: "HFB Sys API",
Description: "哈夫币租号平台 API 文档",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}
-252
View File
@@ -1,252 +0,0 @@
{
"swagger": "2.0",
"info": {
"description": "哈夫币租号平台 API 文档",
"title": "HFB Sys API",
"termsOfService": "http://swagger.io/terms/",
"contact": {
"name": "API Support",
"email": "support@hfb.com"
},
"license": {
"name": "MIT",
"url": "https://opensource.org/licenses/MIT"
},
"version": "1.0"
},
"host": "localhost:8080",
"basePath": "/api",
"paths": {
"/auth/logout": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "用户登出(客户端需清除本地 token)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"认证"
],
"summary": "登出",
"responses": {
"200": {
"description": "登出成功",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
}
}
}
},
"/auth/refresh": {
"post": {
"description": "使用 refresh_token 获取新的 access_token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"认证"
],
"summary": "刷新访问令牌",
"parameters": [
{
"description": "刷新令牌",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_modules_auth.RefreshRequest"
}
}
],
"responses": {
"200": {
"description": "刷新成功,返回新的 token",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"400": {
"description": "请求参数错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"401": {
"description": "refresh_token 无效",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
}
}
}
},
"/auth/sms/login": {
"post": {
"description": "使用手机号和验证码登录",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"认证"
],
"summary": "短信验证码登录",
"parameters": [
{
"description": "登录信息",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_modules_auth.LoginRequest"
}
}
],
"responses": {
"200": {
"description": "登录成功,返回用户信息和 token",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"400": {
"description": "请求参数错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"401": {
"description": "验证码错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"500": {
"description": "服务器错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
}
}
}
},
"/auth/sms/send": {
"post": {
"description": "发送登录验证码到指定手机号",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"认证"
],
"summary": "发送短信验证码",
"parameters": [
{
"description": "手机号",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_modules_auth.SendSMSRequest"
}
}
],
"responses": {
"200": {
"description": "发送成功",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"400": {
"description": "请求参数错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
},
"500": {
"description": "服务器错误",
"schema": {
"$ref": "#/definitions/hfb_sys_backend_pkg_response.Body"
}
}
}
}
}
},
"definitions": {
"hfb_sys_backend_pkg_response.Body": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"data": {},
"message": {
"type": "string"
}
}
},
"internal_modules_auth.LoginRequest": {
"type": "object",
"required": [
"code",
"phone"
],
"properties": {
"code": {
"type": "string"
},
"phone": {
"type": "string"
}
}
},
"internal_modules_auth.RefreshRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string"
}
}
},
"internal_modules_auth.SendSMSRequest": {
"type": "object",
"required": [
"phone"
],
"properties": {
"phone": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"description": "Type \"Bearer\" followed by a space and JWT token.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}
-165
View File
@@ -1,165 +0,0 @@
basePath: /api
definitions:
hfb_sys_backend_pkg_response.Body:
properties:
code:
type: string
data: {}
message:
type: string
type: object
internal_modules_auth.LoginRequest:
properties:
code:
type: string
phone:
type: string
required:
- code
- phone
type: object
internal_modules_auth.RefreshRequest:
properties:
refresh_token:
type: string
required:
- refresh_token
type: object
internal_modules_auth.SendSMSRequest:
properties:
phone:
type: string
required:
- phone
type: object
host: localhost:8080
info:
contact:
email: support@hfb.com
name: API Support
description: 哈夫币租号平台 API 文档
license:
name: MIT
url: https://opensource.org/licenses/MIT
termsOfService: http://swagger.io/terms/
title: HFB Sys API
version: "1.0"
paths:
/auth/logout:
post:
consumes:
- application/json
description: 用户登出(客户端需清除本地 token)
produces:
- application/json
responses:
"200":
description: 登出成功
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
security:
- BearerAuth: []
summary: 登出
tags:
- 认证
/auth/refresh:
post:
consumes:
- application/json
description: 使用 refresh_token 获取新的 access_token
parameters:
- description: 刷新令牌
in: body
name: request
required: true
schema:
$ref: '#/definitions/internal_modules_auth.RefreshRequest'
produces:
- application/json
responses:
"200":
description: 刷新成功,返回新的 token
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
"400":
description: 请求参数错误
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
"401":
description: refresh_token 无效
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
summary: 刷新访问令牌
tags:
- 认证
/auth/sms/login:
post:
consumes:
- application/json
description: 使用手机号和验证码登录
parameters:
- description: 登录信息
in: body
name: request
required: true
schema:
$ref: '#/definitions/internal_modules_auth.LoginRequest'
produces:
- application/json
responses:
"200":
description: 登录成功,返回用户信息和 token
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
"400":
description: 请求参数错误
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
"401":
description: 验证码错误
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
"500":
description: 服务器错误
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
summary: 短信验证码登录
tags:
- 认证
/auth/sms/send:
post:
consumes:
- application/json
description: 发送登录验证码到指定手机号
parameters:
- description: 手机号
in: body
name: request
required: true
schema:
$ref: '#/definitions/internal_modules_auth.SendSMSRequest'
produces:
- application/json
responses:
"200":
description: 发送成功
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
"400":
description: 请求参数错误
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
"500":
description: 服务器错误
schema:
$ref: '#/definitions/hfb_sys_backend_pkg_response.Body'
summary: 发送短信验证码
tags:
- 认证
securityDefinitions:
BearerAuth:
description: Type "Bearer" followed by a space and JWT token.
in: header
name: Authorization
type: apiKey
swagger: "2.0"
+27 -57
View File
@@ -3,62 +3,34 @@ module hfb_sys/backend
go 1.26 go 1.26
require ( require (
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1 github.com/gin-gonic/gin v1.11.0
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.5.1
github.com/alibabacloud-go/tea v1.5.0
github.com/gin-gonic/gin v1.12.0
github.com/go-sql-driver/mysql v1.8.1
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/minio/minio-go/v7 v7.1.0
github.com/redis/go-redis/v9 v9.17.0 github.com/redis/go-redis/v9 v9.17.0
github.com/sony/gobreaker/v2 v2.4.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.52.0 golang.org/x/crypto v0.46.0
golang.org/x/image v0.32.0
gorm.io/datatypes v1.2.7 gorm.io/datatypes v1.2.7
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect github.com/go-ini/ini v1.67.0 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.6 // indirect
github.com/go-openapi/spec v0.22.5 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
@@ -66,32 +38,30 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/minio/minio-go/v7 v7.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect github.com/tinylib/msgp v1.6.1 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.27.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.36.0 // indirect golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.45.0 // indirect golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
) )
+65 -340
View File
@@ -1,77 +1,17 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.14/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1 h1:R8b55YFS4K9x5P5IdgA+QWinVfVmulzqaJG/tr6HhxM=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1/go.mod h1:OCFim1kMbp2m+V8WS5IBnnVrk6nXaJiDwZpg3uqw8Po=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.5.1 h1:CyJ1adk5jlg7acrbG1sgdZ+EXTZNZHwhNQAf6VFfySo=
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.5.1/go.mod h1:J1zab9/VxVJGdZ5pSK/BbUot7CkaSkRXdaLKAXXRLoY=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea v1.5.0 h1:8pUo8WzMChtJT+jpLIcN1vzMi6SW3rihzzBoihPGUvs=
github.com/alibabacloud-go/tea v1.5.0/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -79,91 +19,39 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y=
github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY=
github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo=
github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8=
github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk=
github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo=
github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 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/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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
@@ -176,10 +64,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -187,16 +73,10 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 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/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
@@ -207,238 +87,85 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA= github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 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/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM= github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 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.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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.1/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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 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/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -454,5 +181,3 @@ gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
BIN
View File
Binary file not shown.
-56
View File
@@ -1,56 +0,0 @@
package auditlog
import (
"encoding/json"
"hfb_sys/backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type Meta struct {
IP string
UserAgent string
RequestID string
}
type Entry struct {
ActorType string
ActorID uint64
Action string
BizType string
BizID *uint64
Meta Meta
Detail map[string]any
}
// Append 在业务事务内写入审计日志,确保审计和业务状态一起提交或回滚。
func Append(tx *gorm.DB, entry Entry) error {
raw, err := marshalDetail(entry.Detail, entry.Meta.RequestID)
if err != nil {
return err
}
row := model.AuditLog{
ActorType: entry.ActorType,
ActorID: entry.ActorID,
Action: entry.Action,
BizType: entry.BizType,
BizID: entry.BizID,
IP: entry.Meta.IP,
UserAgent: entry.Meta.UserAgent,
Detail: datatypes.JSON(raw),
}
return tx.Create(&row).Error
}
func marshalDetail(detail map[string]any, requestID string) ([]byte, error) {
copied := make(map[string]any, len(detail)+1)
for key, value := range detail {
copied[key] = value
}
if requestID != "" {
copied["request_id"] = requestID
}
return json.Marshal(copied)
}
-113
View File
@@ -1,113 +0,0 @@
package captcha
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"html"
"math/big"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
var (
ErrDependencyUnavailable = errors.New("captcha dependency unavailable")
ErrInvalid = errors.New("captcha invalid")
)
type Item struct {
CaptchaID string `json:"captcha_id"`
Image string `json:"image"`
ExpiresIn int64 `json:"expires_in"`
}
func Generate(ctx context.Context, redisClient *redis.Client, namespace string, ttl time.Duration) (*Item, error) {
if redisClient == nil {
return nil, ErrDependencyUnavailable
}
captchaID, err := randomToken(16)
if err != nil {
return nil, err
}
code, err := randomCaptchaCode(4)
if err != nil {
return nil, err
}
if err := redisClient.Set(ctx, key(namespace, captchaID), strings.ToUpper(code), ttl).Err(); err != nil {
return nil, err
}
return &Item{
CaptchaID: captchaID,
Image: imageDataURL(code),
ExpiresIn: int64(ttl.Seconds()),
}, nil
}
func Verify(ctx context.Context, redisClient *redis.Client, namespace string, captchaID string, captchaCode string) error {
if redisClient == nil {
return ErrDependencyUnavailable
}
captchaID = strings.TrimSpace(captchaID)
captchaCode = strings.TrimSpace(captchaCode)
if captchaID == "" || captchaCode == "" {
return ErrInvalid
}
redisKey := key(namespace, captchaID)
stored, err := redisClient.Get(ctx, redisKey).Result()
if errors.Is(err, redis.Nil) {
return ErrInvalid
}
if err != nil {
return err
}
_ = redisClient.Del(ctx, redisKey).Err()
if strings.ToUpper(captchaCode) != stored {
return ErrInvalid
}
return nil
}
func key(namespace string, id string) string {
namespace = strings.Trim(strings.ToLower(strings.TrimSpace(namespace)), ":")
if namespace == "" {
namespace = "default"
}
return "captcha:" + namespace + ":" + id
}
func randomToken(length int) (string, error) {
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func randomCaptchaCode(length int) (string, error) {
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
result := make([]byte, length)
for i := range result {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
return "", err
}
result[i] = alphabet[n.Int64()]
}
return string(result), nil
}
func imageDataURL(code string) string {
safeCode := html.EscapeString(strings.ToUpper(code))
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="132" height="44" viewBox="0 0 132 44">
<rect width="132" height="44" rx="8" fill="#eef5f7"/>
<path d="M8 32 C32 2, 62 52, 124 12" stroke="#0f766e" stroke-width="2" fill="none" opacity=".28"/>
<path d="M10 13 C42 42, 86 0, 122 31" stroke="#2563eb" stroke-width="2" fill="none" opacity=".22"/>
<text x="66" y="29" text-anchor="middle" font-family="Menlo,Consolas,monospace" font-size="24" font-weight="700" letter-spacing="4" fill="#111827">%s</text>
</svg>`, safeCode)
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
}
+16 -199
View File
@@ -1,34 +1,19 @@
package config package config
import ( import (
"errors"
"os" "os"
"strconv" "strconv"
"strings"
) )
type Config struct { type Config struct {
AppEnv string AppEnv string
AppAddr string AppAddr string
MySQLDSN string MySQLDSN string
RedisAddr string RedisAddr string
RedisPassword string RedisPassword string
RedisDB int RedisDB int
JWTSecret string JWTSecret string
PaymentConfigEncryptionKey string Storage StorageConfig
FieldEncryptionKey string
FieldEncryptionLegacyKey string
ExternalUploadSecret string
ExternalUploadAllowedIPs []string
BootstrapAdminUsername string
BootstrapAdminPassword string
BootstrapAdminNickname string
Storage StorageConfig
StorageMirror StorageConfig
SMS SMSConfig
Realname RealnameConfig
Log LogConfig
RateLimit RateLimitConfig
} }
type StorageConfig struct { type StorageConfig struct {
@@ -36,138 +21,26 @@ type StorageConfig struct {
Bucket string Bucket string
AccessKeyID string AccessKeyID string
SecretAccessKey string SecretAccessKey string
Region string
BucketLookup string
}
type SMSConfig struct {
Provider string
AliyunAccessKeyID string
AliyunAccessKeySecret string
AliyunEndpoint string
AliyunSignName string
AliyunLoginTemplateCode string
}
type RealnameConfig struct {
Provider string
CloudMarketURL string
CloudMarketAppCode string
}
type LogConfig struct {
Level string
Dir string
EnableConsole bool
EnableFile bool
// RetainDays 日志保留天数(含当天);过期的 .log / .log.gz 会被清理。0 表示不自动清理。
RetainDays int
}
type RateLimitConfig struct {
Enabled bool
RequestsPerMinute int
} }
func Load() Config { func Load() Config {
return Config{ return Config{
AppEnv: getEnv("APP_ENV", "development"), AppEnv: getEnv("APP_ENV", "development"),
AppAddr: getEnv("APP_ADDR", ":18088"), AppAddr: getEnv("APP_ADDR", ":8080"),
MySQLDSN: getEnv("MYSQL_DSN", "hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"), MySQLDSN: getEnv("MYSQL_DSN", "hfb:secret@tcp(127.0.0.1:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"),
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:16379"), RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""), RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisDB: getEnvInt("REDIS_DB", 0), RedisDB: getEnvInt("REDIS_DB", 0),
JWTSecret: getEnv("JWT_SECRET", "change-me"), JWTSecret: getEnv("JWT_SECRET", "change-me"),
PaymentConfigEncryptionKey: getEnv("PAYMENT_CONFIG_ENCRYPTION_KEY", ""),
FieldEncryptionKey: getEnv("FIELD_ENCRYPTION_KEY", ""),
// 旧密钥回退:生产环境必须显式设置(空 = 禁用回退,用于密钥轮换收敛);
// 非生产默认填历史硬编码密钥,兼容开发/测试库的存量密文。不能用 getEnv 的 fallback——
// 那样生产删掉 env 会重新注入已泄露的硬编码密钥,破坏轮换闭环。
FieldEncryptionLegacyKey: fieldEncryptionLegacyKey(getEnv("APP_ENV", "development")),
ExternalUploadSecret: getEnv("EXTERNAL_UPLOAD_SECRET", ""),
ExternalUploadAllowedIPs: getEnvList("EXTERNAL_UPLOAD_ALLOWED_IPS"),
BootstrapAdminUsername: getEnv("ADMIN_BOOTSTRAP_USERNAME", ""),
BootstrapAdminPassword: getEnv("ADMIN_BOOTSTRAP_PASSWORD", ""),
BootstrapAdminNickname: getEnv("ADMIN_BOOTSTRAP_NICKNAME", "超级管理员"),
Storage: StorageConfig{ Storage: StorageConfig{
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:19090"), Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"),
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"), Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
AccessKeyID: getEnv("STORAGE_ACCESS_KEY_ID", "minioadmin"), AccessKeyID: getEnv("STORAGE_ACCESS_KEY_ID", "minioadmin"),
SecretAccessKey: getEnv("STORAGE_SECRET_ACCESS_KEY", "minioadmin"), SecretAccessKey: getEnv("STORAGE_SECRET_ACCESS_KEY", "minioadmin"),
Region: getEnv("STORAGE_REGION", ""),
BucketLookup: getEnv("STORAGE_BUCKET_LOOKUP", "auto"),
},
// StorageMirror 仅在对象存储迁移期间使用。镜像端配置完整时,新上传文件会同步写入两个存储端。
StorageMirror: StorageConfig{
Endpoint: getEnv("STORAGE_MIRROR_ENDPOINT", ""),
Bucket: getEnv("STORAGE_MIRROR_BUCKET", ""),
AccessKeyID: getEnv("STORAGE_MIRROR_ACCESS_KEY_ID", ""),
SecretAccessKey: getEnv("STORAGE_MIRROR_SECRET_ACCESS_KEY", ""),
Region: getEnv("STORAGE_MIRROR_REGION", ""),
BucketLookup: getEnv("STORAGE_MIRROR_BUCKET_LOOKUP", "auto"),
},
SMS: SMSConfig{
Provider: getEnv("SMS_PROVIDER", "mock"),
AliyunAccessKeyID: getEnv("ALIYUN_ACCESS_KEY_ID", ""),
AliyunAccessKeySecret: getEnv("ALIYUN_ACCESS_KEY_SECRET", ""),
AliyunEndpoint: getEnv("ALIYUN_SMS_ENDPOINT", "dysmsapi.aliyuncs.com"),
AliyunSignName: getEnv("ALIYUN_SMS_SIGN_NAME", ""),
AliyunLoginTemplateCode: getEnv("ALIYUN_SMS_LOGIN_TEMPLATE_CODE", ""),
},
Realname: RealnameConfig{
Provider: getEnv("REALNAME_PROVIDER", "mock"),
CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://sinocheck2.market.alicloudapi.com/fortest/ttttt"),
CloudMarketAppCode: getEnv("REALNAME_CLOUDMARKET_APPCODE", ""),
},
Log: LogConfig{
Level: getEnv("LOG_LEVEL", "info"),
Dir: getEnv("LOG_DIR", "logs"),
EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true),
EnableFile: getEnvBool("LOG_ENABLE_FILE", true),
RetainDays: getEnvInt("LOG_RETAIN_DAYS", 14),
},
RateLimit: RateLimitConfig{
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300),
}, },
} }
} }
func (c Config) ValidateProductionSecurity() error {
if !IsProductionEnv(c.AppEnv) {
return nil
}
if !strings.EqualFold(strings.TrimSpace(c.SMS.Provider), "aliyun") {
return errors.New("SMS_PROVIDER must be aliyun in production")
}
if strings.TrimSpace(c.SMS.AliyunAccessKeyID) == "" || strings.TrimSpace(c.SMS.AliyunAccessKeySecret) == "" ||
strings.TrimSpace(c.SMS.AliyunSignName) == "" || strings.TrimSpace(c.SMS.AliyunLoginTemplateCode) == "" {
return errors.New("aliyun SMS credentials, sign name, and template code are required in production")
}
if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 {
return errors.New("JWT_SECRET must be a non-default random value of at least 32 bytes in production")
}
keyLen := len([]byte(c.PaymentConfigEncryptionKey))
if isPlaceholder(c.PaymentConfigEncryptionKey) || (keyLen != 16 && keyLen != 24 && keyLen != 32) {
return errors.New("PAYMENT_CONFIG_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production")
}
fieldKeyLen := len([]byte(c.FieldEncryptionKey))
if c.FieldEncryptionKey == "" || isPlaceholder(c.FieldEncryptionKey) || (fieldKeyLen != 16 && fieldKeyLen != 24 && fieldKeyLen != 32) {
return errors.New("FIELD_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production")
}
legacyKeyLen := len([]byte(c.FieldEncryptionLegacyKey))
if c.FieldEncryptionLegacyKey != "" && (isPlaceholder(c.FieldEncryptionLegacyKey) || (legacyKeyLen != 16 && legacyKeyLen != 24 && legacyKeyLen != 32)) {
return errors.New("FIELD_ENCRYPTION_LEGACY_KEY must be empty or 16, 24, or 32 bytes in production")
}
if c.FieldEncryptionKey == c.FieldEncryptionLegacyKey {
return errors.New("FIELD_ENCRYPTION_KEY must differ from FIELD_ENCRYPTION_LEGACY_KEY in production (set a new primary key to rotate)")
}
if c.BootstrapAdminPassword != "" && isPlaceholder(c.BootstrapAdminPassword) {
return errors.New("ADMIN_BOOTSTRAP_PASSWORD must not use the example placeholder in production")
}
return nil
}
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {
value := os.Getenv(key) value := os.Getenv(key)
if value == "" { if value == "" {
@@ -176,24 +49,6 @@ func getEnv(key, fallback string) string {
return value return value
} }
// historicFieldEncryptionKey 是迁移前 pkg/crypto 的硬编码密钥,仅用于解密历史存量密文。
const historicFieldEncryptionKey = "hfb-sys-2024-secret-key-32bytes!"
// fieldEncryptionLegacyKey 解析 FIELD_ENCRYPTION_LEGACY_KEY
// - 生产环境:必须显式设置,空表示禁用回退(密钥轮换收敛后删除 env 即关闭旧密钥)。
// 不能用硬编码默认值,否则轮换闭环不成立。
// - 非生产环境:未设置时回退到历史硬编码密钥,兼容开发/测试库的存量密文(零配置)。
func fieldEncryptionLegacyKey(appEnv string) string {
if IsProductionEnv(appEnv) {
// 生产显式空 = 禁用;未设置也视为禁用。
return os.Getenv("FIELD_ENCRYPTION_LEGACY_KEY")
}
if v := os.Getenv("FIELD_ENCRYPTION_LEGACY_KEY"); v != "" {
return v
}
return historicFieldEncryptionKey
}
func getEnvInt(key string, fallback int) int { func getEnvInt(key string, fallback int) int {
value := os.Getenv(key) value := os.Getenv(key)
if value == "" { if value == "" {
@@ -205,41 +60,3 @@ func getEnvInt(key string, fallback int) int {
} }
return parsed return parsed
} }
func getEnvBool(key string, fallback bool) bool {
value := os.Getenv(key)
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func getEnvList(key string) []string {
raw := os.Getenv(key)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value != "" {
values = append(values, value)
}
}
return values
}
func isPlaceholder(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "change-me" || strings.HasPrefix(normalized, "change-") || strings.Contains(normalized, "change-to-")
}
// IsProductionEnv 判断 AppEnv 是否为生产环境(与 ValidateProductionSecurity 同口径)。
func IsProductionEnv(appEnv string) bool {
return strings.ToLower(strings.TrimSpace(appEnv)) == "production"
}
-104
View File
@@ -1,104 +0,0 @@
package config
import (
"os"
"testing"
)
const historicKey = "hfb-sys-2024-secret-key-32bytes!"
func TestIsProductionEnv(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"production", true},
{"PRODUCTION", true},
{" production ", true},
{"development", false},
{"", false},
{"staging", false},
}
for _, c := range cases {
if got := IsProductionEnv(c.in); got != c.want {
t.Fatalf("IsProductionEnv(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时
// 回退到历史硬编码密钥(开发态零配置兼容旧密文)。
func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) {
unsetEnv(t, "FIELD_ENCRYPTION_LEGACY_KEY")
if got := fieldEncryptionLegacyKey("development"); got != historicKey {
t.Fatalf("non-production default legacy = %q, want %q", got, historicKey)
}
}
// TestFieldEncryptionLegacyKeyNonProductionExplicitOverride 验证非生产环境显式设置 legacy 时用该值。
func TestFieldEncryptionLegacyKeyNonProductionExplicitOverride(t *testing.T) {
t.Setenv("FIELD_ENCRYPTION_LEGACY_KEY", "explicit-legacy-16bytes!")
if got := fieldEncryptionLegacyKey("development"); got != "explicit-legacy-16bytes!" {
t.Fatalf("non-production explicit legacy = %q, want explicit-legacy-16bytes!", got)
}
}
// TestFieldEncryptionLegacyKeyProductionEmptyDisablesFallback 验证生产环境未设置/留空 legacy
// 返回空(禁用回退),而不是注入历史硬编码密钥。
// 这是密钥轮换闭环的关键:删除 env 必须真正关闭旧密钥。
func TestFieldEncryptionLegacyKeyProductionEmptyDisablesFallback(t *testing.T) {
t.Setenv("FIELD_ENCRYPTION_LEGACY_KEY", "")
if got := fieldEncryptionLegacyKey("production"); got != "" {
t.Fatalf("production empty legacy = %q, want empty (fallback disabled); injecting historic key breaks rotation closure", got)
}
}
// TestFieldEncryptionLegacyKeyProductionExplicitValue 验证生产环境显式设置 legacy 时用该值
// (轮换期间保留旧密钥解密存量)。
func TestFieldEncryptionLegacyKeyProductionExplicitValue(t *testing.T) {
t.Setenv("FIELD_ENCRYPTION_LEGACY_KEY", "prod-old-key-32bytes-0123456789")
if got := fieldEncryptionLegacyKey("production"); got != "prod-old-key-32bytes-0123456789" {
t.Fatalf("production explicit legacy = %q, want prod-old-key-32bytes-0123456789", got)
}
}
// TestValidateProductionSecurityRejectsPrimaryEqualsLegacy 验证生产环境 primary 等于 legacy 时报错。
func TestValidateProductionSecurityRejectsPrimaryEqualsLegacy(t *testing.T) {
t.Setenv("APP_ENV", "production")
t.Setenv("JWT_SECRET", "a-very-long-random-jwt-secret-at-least-32-bytes!!")
t.Setenv("PAYMENT_CONFIG_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef")
t.Setenv("FIELD_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef")
t.Setenv("FIELD_ENCRYPTION_LEGACY_KEY", "0123456789abcdef0123456789abcdef")
cfg := Load()
if err := cfg.ValidateProductionSecurity(); err == nil {
t.Fatal("ValidateProductionSecurity should reject FIELD_ENCRYPTION_KEY == LEGACY_KEY")
}
}
// TestValidateProductionSecurityRejectsInvalidLegacyKey 验证生产环境显式设置 legacy 时也校验长度。
func TestValidateProductionSecurityRejectsInvalidLegacyKey(t *testing.T) {
t.Setenv("APP_ENV", "PRODUCTION")
t.Setenv("JWT_SECRET", "a-very-long-random-jwt-secret-at-least-32-bytes!!")
t.Setenv("PAYMENT_CONFIG_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef")
t.Setenv("FIELD_ENCRYPTION_KEY", "abcdef0123456789abcdef0123456789")
t.Setenv("FIELD_ENCRYPTION_LEGACY_KEY", "too-short")
cfg := Load()
if err := cfg.ValidateProductionSecurity(); err == nil {
t.Fatal("ValidateProductionSecurity should reject invalid FIELD_ENCRYPTION_LEGACY_KEY")
}
}
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, ok := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
t.Cleanup(func() {
if ok {
_ = os.Setenv(key, old)
return
}
_ = os.Unsetenv(key)
})
}
-106
View File
@@ -1,106 +0,0 @@
package database
import (
"context"
"errors"
"fmt"
"strings"
"time"
appLogging "hfb_sys/backend/internal/logging"
"go.uber.org/zap"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
)
type structuredGormLogger struct {
logger *zap.Logger
level gormLogger.LogLevel
slowThreshold time.Duration
}
func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
level := gormLogger.Warn
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
level = gormLogger.Info
}
if logger == nil {
logger = zap.L()
}
return &structuredGormLogger{
logger: logger.With(zap.String("module", "database")),
level: level,
slowThreshold: 500 * time.Millisecond,
}
}
func (l *structuredGormLogger) LogMode(level gormLogger.LogLevel) gormLogger.Interface {
cloned := *l
cloned.level = level
return &cloned
}
func (l *structuredGormLogger) Info(ctx context.Context, message string, args ...any) {
if l.level >= gormLogger.Info {
l.withContext(ctx).Debug("数据库信息", zap.String("detail", fmt.Sprintf(message, args...)))
}
}
func (l *structuredGormLogger) Warn(ctx context.Context, message string, args ...any) {
if l.level >= gormLogger.Warn {
l.withContext(ctx).Warn("数据库警告", zap.String("detail", fmt.Sprintf(message, args...)))
}
}
func (l *structuredGormLogger) Error(ctx context.Context, message string, args ...any) {
if l.level >= gormLogger.Error {
l.withContext(ctx).Error("数据库错误", zap.String("detail", fmt.Sprintf(message, args...)))
}
}
func (l *structuredGormLogger) Trace(ctx context.Context, begin time.Time, query func() (string, int64), err error) {
if l.level == gormLogger.Silent {
return
}
elapsed := time.Since(begin)
switch {
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.level >= gormLogger.Error:
sql, rows := query()
l.withContext(ctx).Error("数据库查询失败", queryFields(sql, rows, elapsed, err)...)
case elapsed >= l.slowThreshold && l.level >= gormLogger.Warn:
sql, rows := query()
l.withContext(ctx).Warn("数据库慢查询", queryFields(sql, rows, elapsed, nil)...)
case l.level == gormLogger.Info:
sql, rows := query()
l.withContext(ctx).Debug("数据库查询", queryFields(sql, rows, elapsed, nil)...)
}
}
// ParamsFilter 让 GORM 保留 SQL 占位符,避免查询参数进入日志。
func (l *structuredGormLogger) ParamsFilter(_ context.Context, sql string, _ ...any) (string, []any) {
return sql, nil
}
func (l *structuredGormLogger) withContext(ctx context.Context) *zap.Logger {
fields := make([]zap.Field, 0, 2)
if requestID := appLogging.RequestIDFromContext(ctx); requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
if adminID := appLogging.AdminIDFromContext(ctx); adminID != 0 {
fields = append(fields, zap.Uint64("admin_id", adminID))
}
return l.logger.With(fields...)
}
func queryFields(sql string, rows int64, elapsed time.Duration, err error) []zap.Field {
fields := []zap.Field{
zap.Float64("duration_ms", float64(elapsed.Microseconds())/1000),
zap.Int64("rows", rows),
zap.String("sql", sql),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
return fields
}
@@ -1,48 +0,0 @@
package database
import (
"strings"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestGormLoggerDoesNotLogQueryParameters(t *testing.T) {
core, observed := observer.New(zap.DebugLevel)
logger := zap.New(core)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: newGormLogger("debug", logger),
})
if err != nil {
t.Fatal(err)
}
type secretRecord struct {
ID uint64
Phone string
}
if err := db.AutoMigrate(&secretRecord{}); err != nil {
t.Fatal(err)
}
const secretPhone = "13812345678"
if err := db.Create(&secretRecord{Phone: secretPhone}).Error; err != nil {
t.Fatal(err)
}
for _, entry := range observed.All() {
for _, value := range entry.ContextMap() {
if strings.Contains(valueString(value), secretPhone) {
t.Fatalf("query parameter leaked into log: %v", entry.ContextMap())
}
}
}
}
func valueString(value any) string {
if text, ok := value.(string); ok {
return text
}
return ""
}
+2 -22
View File
@@ -1,30 +1,10 @@
package database package database
import ( import (
"time"
"go.uber.org/zap"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
) )
func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) { func OpenMySQL(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ return gorm.Open(mysql.Open(dsn), &gorm.Config{})
Logger: newGormLogger(logLevel, appLogger),
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
// 限制连接池,避免本地压测瞬间打满 MySQL max_connections。
sqlDB.SetMaxOpenConns(50)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
return db, nil
} }
-76
View File
@@ -1,76 +0,0 @@
package database
import (
"fmt"
"log"
"hfb_sys/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// NewTestDB 创建用于测试的内存数据库
func NewTestDB() *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("无法创建测试数据库: %v", err)
}
return db
}
// NewTestDBWithName 创建用于测试的命名内存数据库(支持多连接共享)
func NewTestDBWithName(name string) *gorm.DB {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", name)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("无法创建测试数据库 %s: %v", name, err)
}
return db
}
// MigrateListingLifecycleTestSchema 创建商品生命周期测试所需的共享表结构。
func MigrateListingLifecycleTestSchema(db *gorm.DB) error {
return db.AutoMigrate(
&model.User{},
&model.GameAccount{},
&model.RentalListing{},
&model.ListingStatusEvent{},
)
}
// MigrateRentalTransactionTestSchema 在商品生命周期基础上创建订单、支付和交接测试所需的共享表结构。
func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
if err := MigrateListingLifecycleTestSchema(db); err != nil {
return err
}
return db.AutoMigrate(
&model.RentalOrder{},
&model.SystemConfig{},
&model.PaymentOrder{},
&model.Notification{},
&model.AdminNotification{},
&model.HandoffRecord{},
&model.OrderCheckout{},
&model.WalletAccount{},
&model.WalletLedger{},
&model.RenterGrowthLedger{},
&model.AuditLog{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatMessage{},
)
}
// MigrateRentalDisputeTestSchema 在交易链路基础上创建申诉测试所需的表结构。
func MigrateRentalDisputeTestSchema(db *gorm.DB) error {
if err := MigrateRentalTransactionTestSchema(db); err != nil {
return err
}
return db.AutoMigrate(&model.Dispute{})
}
-562
View File
@@ -1,562 +0,0 @@
package e2e
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"testing"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/adminfinance"
"hfb_sys/backend/internal/modules/dispute"
"hfb_sys/backend/internal/modules/listing"
"hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/modules/payment"
"hfb_sys/backend/internal/modules/paymentaccount"
"hfb_sys/backend/internal/modules/paymentconfig"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/internal/modules/withdrawal"
"hfb_sys/backend/pkg/crypto"
)
func TestRentalFullFlowWithMockPayment(t *testing.T) {
db := openE2EDB(t)
owner, renter, adminID := seedUsers(t, db)
services := newFlowServices(db)
seedMockPaymentConfig(t, services.paymentConfig, adminID)
account := createOwnerPaymentAccount(t, services.paymentAccount, owner.ID)
listingDTO := createListingUnderReview(t, services.listing, owner.ID)
adjusted := adjustAndApproveListing(t, services.listing, adminID, listingDTO.ID)
if adjusted.PriceCent != 38300 {
t.Fatalf("调价后商品价格 = %d, want 38300", adjusted.PriceCent)
}
orderDTO := mustCreateOrder(t, services.order, renter.ID, adjusted.ID)
paymentDTO, err := services.payment.Start(t.Context(), renter.ID, orderDTO.ID, payment.StartPaymentRequest{}, "127.0.0.1")
if err != nil {
t.Fatalf("启动 mock 支付失败: %v", err)
}
assertEqual(t, "支付状态", paymentDTO.Status, "paid")
assertEqual(t, "支付金额", paymentDTO.AmountCent, int64(53300))
paidOrder, err := services.order.FindAdmin(t.Context(), orderDTO.ID)
if err != nil {
t.Fatalf("读取已支付订单失败: %v", err)
}
assertEqual(t, "支付后订单状态", paidOrder.Status, "pending_handoff")
assertEqual(t, "支付后交接状态", paidOrder.HandoffStatus, "pending_owner")
if _, err := services.order.SubmitHandoff(t.Context(), owner.ID, orderDTO.ID, order.SubmitHandoffRequest{Content: "账号:demo,密码:demo-pass"}); err != nil {
t.Fatalf("号主交接失败: %v", err)
}
if err := services.order.ConfirmReceive(t.Context(), renter.ID, orderDTO.ID); err != nil {
t.Fatalf("租客确认收号失败: %v", err)
}
if _, err := services.order.SubmitCheckout(t.Context(), renter.ID, orderDTO.ID, order.SubmitCheckoutRequest{
Content: "租客发起结账",
ConsumableAmountCent: 700,
CoinConsumedM: 90,
}); err != nil {
t.Fatalf("租客发起结账失败: %v", err)
}
countered, err := services.order.CounterCheckout(t.Context(), owner.ID, orderDTO.ID, order.CounterCheckoutRequest{
ConsumableAmountCent: 1200,
CoinConsumedM: 100,
DepositDeductAmountCent: 3000,
Reason: "补扣消耗和押金赔付",
})
if err != nil {
t.Fatalf("号主反价失败: %v", err)
}
assertEqual(t, "反价状态", countered.Status, "countered")
if err := services.order.AcceptCheckout(t.Context(), renter.ID, orderDTO.ID); err != nil {
t.Fatalf("租客接受反价失败: %v", err)
}
completed, err := services.order.FindAdmin(t.Context(), orderDTO.ID)
if err != nil {
t.Fatalf("读取完成订单失败: %v", err)
}
assertEqual(t, "完成后订单状态", completed.Status, "completed")
assertEqual(t, "完成后结算状态", completed.SettlementStatus, "settled")
if completed.Checkout == nil {
t.Fatal("完成订单缺少结账记录")
}
assertEqual(t, "结账状态", completed.Checkout.Status, "accepted")
assertEqual(t, "实际结账租金", *completed.Checkout.RentAmountCent, int64(27500))
assertEqual(t, "号主结算收入", *completed.Checkout.OwnerIncomeAmountCent, int64(27500))
assertEqual(t, "平台收入", *completed.Checkout.PlatformFeeCent, int64(3000))
assertFinanceDashboard(t, services.finance, orderDTO.OrderNo)
assertWalletAndWithdrawal(t, services.wallet, services.withdrawal, owner.ID, adminID, account.ID)
}
func TestArbitrationReleaseDepositDoesNotRepublishListing(t *testing.T) {
db := openE2EDB(t)
owner, renter, adminID := seedUsers(t, db)
services := newFlowServices(db)
seedMockPaymentConfig(t, services.paymentConfig, adminID)
listingDTO := createListingUnderReview(t, services.listing, owner.ID)
approved := adjustAndApproveListing(t, services.listing, adminID, listingDTO.ID)
orderDTO := mustCreateOrder(t, services.order, renter.ID, approved.ID)
if _, err := services.payment.Start(t.Context(), renter.ID, orderDTO.ID, payment.StartPaymentRequest{}, "127.0.0.1"); err != nil {
t.Fatalf("启动 mock 支付失败: %v", err)
}
if _, err := services.order.SubmitHandoff(t.Context(), owner.ID, orderDTO.ID, order.SubmitHandoffRequest{Content: "账号:demo,密码:demo-pass"}); err != nil {
t.Fatalf("号主交接失败: %v", err)
}
if err := services.order.ConfirmReceive(t.Context(), renter.ID, orderDTO.ID); err != nil {
t.Fatalf("租客确认收号失败: %v", err)
}
if _, err := services.order.SubmitCheckout(t.Context(), renter.ID, orderDTO.ID, order.SubmitCheckoutRequest{
Content: "租客发起结账",
ConsumableAmountCent: 700,
CoinConsumedM: 90,
}); err != nil {
t.Fatalf("租客发起结账失败: %v", err)
}
if _, err := services.order.CounterCheckout(t.Context(), owner.ID, orderDTO.ID, order.CounterCheckoutRequest{
ConsumableAmountCent: 1200,
CoinConsumedM: 100,
DepositDeductAmountCent: 3000,
Reason: "补扣消耗和押金赔付",
}); err != nil {
t.Fatalf("号主反价失败: %v", err)
}
disputeDTO, err := services.dispute.Create(t.Context(), renter.ID, orderDTO.ID, dispute.CreateRequest{
Type: "checkout_amount",
Description: "不同意扣押金,申请客服仲裁",
})
if err != nil {
t.Fatalf("创建结账争议失败: %v", err)
}
assertEqual(t, "争议类型", disputeDTO.Type, "checkout_dispute")
resolved, err := services.dispute.Arbitrate(t.Context(), adminID, disputeDTO.ID, dispute.ArbitrateRequest{
Result: "release_deposit",
Remark: "确认无押金扣除,退回押金",
}, auditlog.Meta{IP: "127.0.0.1", RequestID: "e2e-arbitrate"})
if err != nil {
t.Fatalf("仲裁失败: %v", err)
}
assertEqual(t, "争议状态", resolved.Status, "resolved")
completed, err := services.order.FindAdmin(t.Context(), orderDTO.ID)
if err != nil {
t.Fatalf("读取仲裁后订单失败: %v", err)
}
assertEqual(t, "仲裁后订单状态", completed.Status, "completed")
assertEqual(t, "仲裁后结算状态", completed.SettlementStatus, "arbitrated")
var orderRow model.RentalOrder
if err := db.First(&orderRow, orderDTO.ID).Error; err != nil {
t.Fatalf("读取仲裁后订单模型失败: %v", err)
}
assertEqual(t, "仲裁退款状态", orderRow.RefundStatus, "refunded")
assertEqual(t, "仲裁退款金额", orderRow.RefundAmountCent, int64(15000))
walletAccount, err := services.wallet.Account(t.Context(), owner.ID)
if err != nil {
t.Fatalf("读取号主钱包失败: %v", err)
}
assertEqual(t, "仲裁后号主可用余额", walletAccount.AvailableBalanceCent, int64(35300))
var listingRow model.RentalListing
if err := db.First(&listingRow, approved.ID).Error; err != nil {
t.Fatalf("读取仲裁后商品失败: %v", err)
}
var accountRow model.GameAccount
if err := db.First(&accountRow, approved.AccountID).Error; err != nil {
t.Fatalf("读取仲裁后账号失败: %v", err)
}
assertEqual(t, "仲裁后商品状态", listingRow.Status, "offline")
assertEqual(t, "仲裁后账号状态", accountRow.Status, "offline")
}
type flowServices struct {
listing *listing.Service
order *order.Service
payment *payment.Service
paymentConfig *paymentconfig.Service
paymentAccount *paymentaccount.Service
dispute *dispute.Service
wallet *wallet.Service
withdrawal *withdrawal.Service
finance *adminfinance.Service
}
func newFlowServices(db *gorm.DB) flowServices {
listingRepo := listing.NewRepository(db, nil)
walletRepo := wallet.NewRepository(db)
configRepo := paymentconfig.NewRepository(db, &paymentconfig.MockEncryptor{})
var paymentRepo *payment.Repository
orderRepo := order.NewRepository(db, order.Dependencies{
RefundStarter: order.RefundStarterFunc(func(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (string, error) {
refund, err := paymentRepo.StartRefund(ctx, orderID, refundAmountCent, bizType, remark)
if err != nil {
return "", err
}
return refund.Status, nil
}),
})
paymentRepo = payment.NewRepository(db, configRepo, orderRepo)
disputeRepo := dispute.NewRepository(db, dispute.Dependencies{
RefundStarter: dispute.RefundStarterFunc(func(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (string, error) {
refund, err := paymentRepo.StartRefund(ctx, orderID, refundAmountCent, bizType, remark)
if err != nil {
return "", err
}
return refund.Status, nil
}),
})
return flowServices{
listing: listing.NewService(listingRepo, fixedConfig{"listing.review_required": "true"}),
order: order.NewService(orderRepo),
payment: payment.NewService(paymentRepo),
paymentConfig: paymentconfig.NewService(configRepo),
paymentAccount: paymentaccount.NewService(paymentaccount.NewRepository(db, &crypto.MockEncryptor{})),
dispute: dispute.NewService(disputeRepo),
wallet: wallet.NewService(walletRepo),
withdrawal: withdrawal.NewService(withdrawal.NewRepository(db, walletRepo, &crypto.MockEncryptor{})),
finance: adminfinance.NewService(adminfinance.NewRepository(db)),
}
}
type fixedConfig map[string]string
func (c fixedConfig) FindValue(ctx context.Context, key string) (string, error) {
return c[key], nil
}
func openE2EDB(t *testing.T) *gorm.DB {
t.Helper()
rawDSN := os.Getenv("HFB_E2E_DSN")
if rawDSN == "" {
t.Skip("设置 HFB_E2E_DSN 后运行完整租号链路 e2e 测试")
}
cfg, err := mysqldriver.ParseDSN(rawDSN)
if err != nil {
t.Fatalf("解析 HFB_E2E_DSN 失败: %v", err)
}
dbName := cfg.DBName
assertSafeTestDBName(t, dbName)
serverCfg := cfg.Clone()
serverCfg.DBName = ""
serverDB, err := sql.Open("mysql", serverCfg.FormatDSN())
if err != nil {
t.Fatalf("连接 MySQL server 失败: %v", err)
}
defer serverDB.Close()
if _, err := serverDB.Exec("DROP DATABASE IF EXISTS `" + dbName + "`"); err != nil {
t.Fatalf("删除测试库失败: %v", err)
}
if _, err := serverDB.Exec("CREATE DATABASE `" + dbName + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
t.Fatalf("创建测试库失败: %v", err)
}
cfg.ParseTime = true
cfg.MultiStatements = true
sqlDB, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
t.Fatalf("连接测试库失败: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
applyMigrations(t, sqlDB)
gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("打开 gorm 连接失败: %v", err)
}
return gormDB
}
func assertSafeTestDBName(t *testing.T, dbName string) {
t.Helper()
if dbName == "" {
t.Fatal("HFB_E2E_DSN 必须包含数据库名")
}
if !regexp.MustCompile(`^[A-Za-z0-9_]+$`).MatchString(dbName) {
t.Fatalf("测试库名只允许字母、数字、下划线: %q", dbName)
}
lower := strings.ToLower(dbName)
if !strings.Contains(lower, "test") && !strings.Contains(lower, "e2e") {
t.Fatalf("拒绝重置非测试库 %q,库名需包含 test 或 e2e", dbName)
}
}
func applyMigrations(t *testing.T, db *sql.DB) {
t.Helper()
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("无法定位当前测试文件")
}
migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations")
for _, name := range []string{"000001_init.sql", "000002_dispute_cancel_snapshot.sql"} {
migrationPath := filepath.Join(migrationDir, name)
raw, err := os.ReadFile(migrationPath)
if err != nil {
t.Fatalf("读取迁移 %s 失败: %v", name, err)
}
sqlText := gooseUpSQL(string(raw))
if _, err := db.Exec(sqlText); err != nil {
t.Fatalf("执行迁移 %s 失败: %v", name, err)
}
}
}
func gooseUpSQL(raw string) string {
upIndex := strings.Index(raw, "-- +goose Up")
if upIndex >= 0 {
raw = raw[upIndex+len("-- +goose Up"):]
}
downIndex := strings.Index(raw, "-- +goose Down")
if downIndex >= 0 {
raw = raw[:downIndex]
}
return raw
}
func seedUsers(t *testing.T, db *gorm.DB) (model.User, model.User, uint64) {
t.Helper()
now := time.Now()
ownerName, renterName := "张三", "李四"
owner := model.User{Phone: "13900000001", Nickname: "测试号主", RealnameStatus: "verified", RiskStatus: "normal", CreditScore: 100, Status: "active"}
renter := model.User{Phone: "13900000002", Nickname: "测试租客", RealnameStatus: "verified", RiskStatus: "normal", CreditScore: 100, Status: "active"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("创建号主失败: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("创建租客失败: %v", err)
}
seedRealname(t, db, owner.ID, ownerName, now)
seedRealname(t, db, renter.ID, renterName, now)
admin := model.AdminUser{Username: "e2e_admin", PasswordHash: "noop", Nickname: "测试管理员", Status: "active"}
if err := db.Create(&admin).Error; err != nil {
t.Fatalf("创建管理员失败: %v", err)
}
return owner, renter, admin.ID
}
func seedRealname(t *testing.T, db *gorm.DB, userID uint64, name string, verifiedAt time.Time) {
t.Helper()
encryptedName, err := (&crypto.MockEncryptor{}).Encrypt(name)
if err != nil {
t.Fatalf("加密实名姓名失败: %v", err)
}
row := model.UserRealname{
UserID: userID,
Provider: "e2e",
ProviderOrderNo: fmt.Sprintf("RN%d", userID),
Status: "verified",
MaskedName: string([]rune(name)[0]) + "*",
EncryptedName: encryptedName,
MaskedIDNo: "110***********001",
VerifiedAt: &verifiedAt,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("创建实名记录失败: %v", err)
}
}
func seedMockPaymentConfig(t *testing.T, service *paymentconfig.Service, adminID uint64) {
t.Helper()
_, err := service.Create(t.Context(), paymentconfig.CreateRequest{
Name: "E2E Mock 支付",
Provider: "mock",
MerchantID: "mock-e2e",
IsDefault: true,
Status: "active",
Environment: "sandbox",
BusinessTags: []string{"order_pay", "wallet_recharge"},
}, adminID, auditlog.Meta{IP: "127.0.0.1", RequestID: "e2e-payment-config"})
if err != nil {
t.Fatalf("创建 mock 支付配置失败: %v", err)
}
}
func createOwnerPaymentAccount(t *testing.T, service *paymentaccount.Service, ownerID uint64) *paymentaccount.PaymentAccountDTO {
t.Helper()
account, err := service.Create(t.Context(), ownerID, paymentaccount.CreatePaymentAccountRequest{
AccountType: "alipay",
AccountName: "张三",
AccountNo: "owner@example.com",
})
if err != nil {
t.Fatalf("创建号主收款账号失败: %v", err)
}
return account
}
func createListingUnderReview(t *testing.T, service *listing.Service, ownerID uint64) *listing.ListingDTO {
t.Helper()
dto, err := service.Create(t.Context(), ownerID, listing.CreateRequest{
Title: "E2E 烽火地带账号",
Description: "用于完整链路测试",
ServerRegion: "烽火地带",
LoginPlatform: "QQ",
RankLevel: "钻石",
HafCoinAmount: 100000000,
PriceCent: 35300,
DepositAmountCent: 15000,
ScreenshotURLS: []string{"https://example.com/e2e-account.png"},
AssetSummary: map[string]any{
"fire_level": 50,
"price_breakdown": map[string]any{
"seller_coin_base_price": 233,
"seller_total_price": 353,
"seller_ratio": 42.9,
"consumable_price": 120,
},
},
AgreedVirtualAssetSale: true,
AgreedSellerAgreement: true,
})
if err != nil {
t.Fatalf("创建待审核商品失败: %v", err)
}
assertEqual(t, "商品审核状态", dto.ReviewStatus, "pending")
return dto
}
func adjustAndApproveListing(t *testing.T, service *listing.Service, adminID uint64, listingID uint64) *listing.ListingDTO {
t.Helper()
adjusted, err := service.AdjustReviewPrice(t.Context(), adminID, listingID, listing.AdminPriceAdjustRequest{
BuyerTotalPriceCent: 38300,
Reason: "E2E 审核调价",
}, auditlog.Meta{IP: "127.0.0.1", RequestID: "e2e-adjust-price"})
if err != nil {
t.Fatalf("审核调价失败: %v", err)
}
approved, err := service.Approve(t.Context(), adjusted.ID)
if err != nil {
t.Fatalf("审核通过失败: %v", err)
}
assertEqual(t, "审核通过商品状态", approved.Status, "published")
return approved
}
func mustCreateOrder(t *testing.T, service *order.Service, renterID uint64, listingID uint64) *order.OrderDTO {
t.Helper()
dto, err := service.Create(t.Context(), renterID, order.CreateRequest{ListingID: listingID})
if err != nil {
t.Fatalf("创建订单失败: %v", err)
}
assertEqual(t, "新订单状态", dto.Status, "pending_payment")
assertEqual(t, "订单租金", *dto.RentAmountCent, int64(38300))
assertEqual(t, "订单押金", dto.DepositAmountCent, int64(15000))
return dto
}
func assertFinanceDashboard(t *testing.T, service *adminfinance.Service, orderNo string) {
t.Helper()
now := time.Now()
query := adminfinance.DashboardQuery{
StartDate: now.Add(-time.Hour),
EndDate: now.Add(time.Hour),
}
dashboard, err := service.Dashboard(t.Context(), query)
if err != nil {
t.Fatalf("读取财务看板失败: %v", err)
}
assertEqual(t, "财务总流水", dashboard.Summary.TotalFlowAmountCent, int64(53300))
assertEqual(t, "财务已退款", dashboard.Summary.TotalRefundAmountCent, int64(22800))
assertEqual(t, "财务平台收入", dashboard.Summary.PlatformIncomeAmountCent, int64(3000))
assertEqual(t, "财务号主应收", dashboard.Summary.OwnerShouldIncomeAmountCent, int64(27500))
assertEqual(t, "财务号主钱包入账", dashboard.Summary.OwnerWalletIncomeAmountCent, int64(27500))
assertEqual(t, "财务结算订单数", dashboard.Summary.SettledOrderCount, int64(1))
details, err := service.Details(t.Context(), adminfinance.DetailQuery{
OrderNo: orderNo,
DateType: "settled",
StartDate: query.StartDate,
EndDate: query.EndDate,
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("读取财务明细失败: %v", err)
}
assertEqual(t, "财务明细数量", details.Total, int64(1))
items, ok := details.Items.([]adminfinance.FinanceDetailDTO)
if !ok || len(items) != 1 {
t.Fatalf("财务明细类型异常: %#v", details.Items)
}
assertEqual(t, "明细订单号", items[0].OrderNo, orderNo)
assertEqual(t, "明细平台净收入", items[0].PlatformNetAmountCent, int64(3000))
}
func assertWalletAndWithdrawal(t *testing.T, walletService *wallet.Service, withdrawalService *withdrawal.Service, ownerID uint64, adminID uint64, paymentAccountID uint64) {
t.Helper()
accountBefore, err := walletService.Account(t.Context(), ownerID)
if err != nil {
t.Fatalf("读取号主钱包失败: %v", err)
}
assertEqual(t, "提现前可用余额", accountBefore.AvailableBalanceCent, int64(27500))
req, err := withdrawalService.Create(t.Context(), ownerID, withdrawal.CreateWithdrawalRequest{
PaymentAccountID: paymentAccountID,
AmountCent: 10000,
})
if err != nil {
t.Fatalf("创建提现失败: %v", err)
}
assertEqual(t, "提现申请金额", req.AmountCent, int64(10000))
assertEqual(t, "提现申请状态", req.Status, "pending")
approved := true
reviewed, err := withdrawalService.Review(t.Context(), adminID, req.ID, withdrawal.ReviewWithdrawalRequest{
Approved: &approved,
Remark: "E2E 审核通过",
})
if err != nil {
t.Fatalf("审核提现失败: %v", err)
}
assertEqual(t, "提现审核后状态", reviewed.Status, "processing")
paid, err := withdrawalService.ConfirmPayment(t.Context(), adminID, req.ID, withdrawal.ConfirmPaymentRequest{
PaymentProofURL: "https://example.com/proof.png",
Remark: "E2E 已打款",
})
if err != nil {
t.Fatalf("确认提现打款失败: %v", err)
}
assertEqual(t, "提现完成状态", paid.Status, "completed")
assertEqual(t, "提现到账金额", paid.ActualAmountCent, int64(10000))
accountAfter, err := walletService.Account(t.Context(), ownerID)
if err != nil {
t.Fatalf("读取提现后钱包失败: %v", err)
}
assertEqual(t, "提现后可用余额", accountAfter.AvailableBalanceCent, int64(17500))
assertEqual(t, "提现后冻结余额", accountAfter.FrozenBalanceCent, int64(0))
}
func assertEqual[T comparable](t *testing.T, name string, got T, want T) {
t.Helper()
if got != want {
t.Fatalf("%s = %v, want %v", name, got, want)
}
}
@@ -1,785 +0,0 @@
package lakala
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
"hfb_sys/backend/internal/timeutil"
)
var (
ErrConfigIncomplete = errors.New("lakala payment config incomplete")
ErrSignFailed = errors.New("lakala sign failed")
ErrVerifyFailed = errors.New("lakala verify failed")
)
const (
endpointCounterCreate = "/api/v3/ccss/counter/order/special_create"
endpointCounterQuery = "/api/v3/ccss/counter/order/query"
endpointRefund = "/api/v3/labs/relation/refund"
endpointRefundQuery = "/api/v3/labs/query/idmrefundquery"
)
type Config struct {
GatewayURL string
AppID string
SerialNo string
MerchantID string
TermNo string
PrivateKey string
LakalaCert string
NotifyCert string
NotifyURL string
JumpURL string
PayWay string
JSPayFlag string
PayMode string
OrderExpireMinutes int
}
type Client struct {
cfg Config
httpClient *http.Client
privateKey *rsa.PrivateKey
lakalaCert *x509.Certificate
notifyCert *x509.Certificate
}
type CreatePaymentRequest struct {
ThirdOrderID string
AmountCent int64
PayWay string
JSPayFlag string
NotifyURL string
JumpURL string
ClientIP string
Body string
Attach string
}
type CreatePaymentResponse struct {
OK bool
ErrorMessage string
MerchantID string
ThirdOrderID string
ProviderOrderID string
PayWay string
Status string
PayTime string
TDCode string
JSPayURL string
JSPayInfo string
Raw map[string]string
RawRequest map[string]string
}
type QueryPaymentResponse struct {
OK bool
ErrorMessage string
MerchantID string
ThirdOrderID string
ProviderOrderID string
Status string
Amount string
PayWay string
PayTime string
Raw map[string]string
}
type CreateRefundRequest struct {
ThirdOrderID string
ProviderOrderID string
MerchantRefundID string
RefundAmountCent int64
NotifyURL string
Attach string
RefundReason string
ClientIP string
}
type CreateRefundResponse struct {
OK bool
ErrorMessage string
MerchantID string
ThirdOrderID string
ProviderOrderID string
MerchantRefundID string
ProviderRefundID string
Status string
RefundAmount string
Raw map[string]string
RawRequest map[string]string
}
type QueryRefundRequest struct {
ThirdOrderID string
ProviderOrderID string
MerchantRefundID string
ProviderRefundID string
}
type QueryRefundResponse struct {
OK bool
ErrorMessage string
MerchantID string
ThirdOrderID string
ProviderOrderID string
MerchantRefundID string
ProviderRefundID string
Status string
RefundAmount string
RefundTime string
Raw map[string]string
RawRequest map[string]string
}
type VerifyNotifyResult struct {
OK bool
MatchedKey string
Got string
Expected map[string]string
BaseString map[string]string
ParamKeys []string
}
func NewClient(cfg Config) *Client {
return &Client{
cfg: cfg,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*CreatePaymentResponse, error) {
if err := c.initCrypto(); err != nil {
return nil, err
}
payWay := firstNonEmpty(req.PayWay, c.cfg.PayWay, "ZFBZF")
payMode := normalizeCounterPayMode(c.cfg.PayMode)
expireMinutes := c.cfg.OrderExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 15
}
now := timeutil.ShanghaiNow()
reqData := map[string]any{
"out_order_no": req.ThirdOrderID,
"merchant_no": c.cfg.MerchantID,
"total_amount": req.AmountCent,
"order_efficient_time": now.Add(time.Duration(expireMinutes) * time.Minute).Format("20060102150405"),
"notify_url": firstNonEmpty(req.NotifyURL, c.cfg.NotifyURL),
"support_refund": 1,
"support_repeat_pay": 1,
"support_cancel": 0,
"order_info": sanitizeText(req.Body, 128),
}
if payMode != "" {
reqData["counter_param"] = fmt.Sprintf(`{"pay_mode":"%s"}`, payMode)
}
if c.cfg.TermNo != "" {
reqData["term_no"] = c.cfg.TermNo
}
if jumpURL := firstNonEmpty(req.JumpURL, c.cfg.JumpURL); jumpURL != "" {
reqData["callback_url"] = jumpURL
}
if req.Attach != "" {
reqData["counter_remark"] = sanitizeText(req.Attach, 128)
}
rawReq := stringifyMap(reqData)
raw, err := c.post(ctx, endpointCounterCreate, reqData)
if err != nil {
return nil, err
}
return &CreatePaymentResponse{
OK: responseOK(raw),
ErrorMessage: responseMessage(raw),
MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID),
ThirdOrderID: firstNonEmpty(raw["out_order_no"], req.ThirdOrderID),
ProviderOrderID: firstNonEmpty(raw["pay_order_no"], raw["trade_no"], raw["log_no"]),
PayWay: payWay,
Status: normalizePaymentStatus(raw),
PayTime: firstNonEmpty(raw["pay_time"], raw["trade_time"], raw["finish_time"]),
TDCode: firstNonEmpty(raw["qr_code"], raw["code_url"], raw["pay_url"], raw["counter_url"]),
JSPayURL: firstNonEmpty(raw["counter_url"], raw["pay_url"], raw["qr_code"], raw["code_url"]),
JSPayInfo: firstNonEmpty(raw["pay_info"], raw["credential"], raw["credential_json"], raw["req_data"]),
Raw: raw,
RawRequest: rawReq,
}, nil
}
func (c *Client) QueryPayment(ctx context.Context, thirdOrderID, providerOrderID string) (*QueryPaymentResponse, error) {
if err := c.initCrypto(); err != nil {
return nil, err
}
reqData := map[string]any{
"merchant_no": c.cfg.MerchantID,
}
if providerOrderID != "" {
reqData["pay_order_no"] = providerOrderID
} else {
reqData["out_order_no"] = thirdOrderID
}
raw, err := c.post(ctx, endpointCounterQuery, reqData)
if err != nil {
return nil, err
}
return &QueryPaymentResponse{
OK: responseOK(raw),
ErrorMessage: responseMessage(raw),
MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID),
ThirdOrderID: firstNonEmpty(raw["out_order_no"], thirdOrderID),
ProviderOrderID: firstNonEmpty(raw["pay_order_no"], raw["trade_no"], providerOrderID),
Status: normalizePaymentStatus(raw),
Amount: firstNonEmpty(raw["total_amount"], raw["amount"], raw["trade_amount"]),
PayWay: firstNonEmpty(raw["pay_mode"], raw["account_type"]),
PayTime: firstNonEmpty(raw["pay_time"], raw["trade_time"], raw["finish_time"]),
Raw: raw,
}, nil
}
func (c *Client) CreateRefund(ctx context.Context, req CreateRefundRequest) (*CreateRefundResponse, error) {
if err := c.initCrypto(); err != nil {
return nil, err
}
reqData := map[string]any{
"merchant_no": c.cfg.MerchantID,
"term_no": c.cfg.TermNo,
"out_trade_no": req.MerchantRefundID,
"refund_amount": strconv.FormatInt(req.RefundAmountCent, 10),
"refund_reason": firstNonEmpty(req.RefundReason, "订单退款"),
"origin_out_trade_no": req.ThirdOrderID,
"origin_trade_no": req.ProviderOrderID,
"location_info": defaultLocationInfo(req.ClientIP),
}
if c.cfg.TermNo == "" {
delete(reqData, "term_no")
}
if req.ProviderOrderID == "" {
delete(reqData, "origin_trade_no")
}
rawReq := stringifyMap(reqData)
raw, err := c.post(ctx, endpointRefund, reqData)
if err != nil {
return &CreateRefundResponse{
MerchantID: c.cfg.MerchantID,
ThirdOrderID: req.ThirdOrderID,
ProviderOrderID: req.ProviderOrderID,
MerchantRefundID: req.MerchantRefundID,
RefundAmount: strconv.FormatInt(req.RefundAmountCent, 10),
RawRequest: rawReq,
}, err
}
return &CreateRefundResponse{
OK: responseOK(raw),
ErrorMessage: responseMessage(raw),
MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID),
ThirdOrderID: firstNonEmpty(raw["origin_out_trade_no"], req.ThirdOrderID),
ProviderOrderID: firstNonEmpty(raw["origin_trade_no"], req.ProviderOrderID),
MerchantRefundID: firstNonEmpty(raw["out_trade_no"], req.MerchantRefundID),
ProviderRefundID: firstNonEmpty(raw["refund_trade_no"], raw["trade_no"], raw["log_no"]),
Status: normalizeRefundStatus(raw),
RefundAmount: firstNonEmpty(raw["refund_amount"], strconv.FormatInt(req.RefundAmountCent, 10)),
Raw: raw,
RawRequest: rawReq,
}, nil
}
func (c *Client) QueryRefund(ctx context.Context, req QueryRefundRequest) (*QueryRefundResponse, error) {
if err := c.initCrypto(); err != nil {
return nil, err
}
reqData := map[string]any{
"merchant_no": c.cfg.MerchantID,
"term_no": c.cfg.TermNo,
}
if req.MerchantRefundID != "" {
reqData["out_trade_no"] = req.MerchantRefundID
}
if req.ThirdOrderID != "" {
reqData["origin_out_trade_no"] = req.ThirdOrderID
}
if req.ProviderOrderID != "" {
reqData["origin_trade_no"] = req.ProviderOrderID
}
if c.cfg.TermNo == "" {
delete(reqData, "term_no")
}
rawReq := stringifyMap(reqData)
raw, err := c.post(ctx, endpointRefundQuery, reqData)
if err != nil {
return &QueryRefundResponse{
MerchantID: c.cfg.MerchantID,
ThirdOrderID: req.ThirdOrderID,
ProviderOrderID: req.ProviderOrderID,
MerchantRefundID: req.MerchantRefundID,
ProviderRefundID: req.ProviderRefundID,
RawRequest: rawReq,
}, err
}
return &QueryRefundResponse{
OK: responseOK(raw),
ErrorMessage: responseMessage(raw),
MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID),
ThirdOrderID: firstNonEmpty(raw["origin_out_trade_no"], req.ThirdOrderID),
ProviderOrderID: firstNonEmpty(raw["origin_trade_no"], req.ProviderOrderID),
MerchantRefundID: firstNonEmpty(raw["out_trade_no"], req.MerchantRefundID),
ProviderRefundID: firstNonEmpty(raw["refund_trade_no"], raw["trade_no"], req.ProviderRefundID),
Status: normalizeRefundStatus(raw),
RefundAmount: raw["refund_amount"],
RefundTime: firstNonEmpty(raw["refund_time"], raw["trade_time"], raw["finish_time"]),
Raw: raw,
RawRequest: rawReq,
}, nil
}
func (c *Client) VerifyNotifyDetail(rawPayload string, authorization string) VerifyNotifyResult {
result := VerifyNotifyResult{
Expected: map[string]string{},
BaseString: map[string]string{},
}
if err := c.initCrypto(); err != nil {
result.Expected["error"] = err.Error()
return result
}
signature, base, err := parseAuthorization(authorization)
result.Got = signature
result.BaseString["notify_cert"] = base.message(rawPayload)
if err != nil {
result.Expected["error"] = err.Error()
return result
}
ok := verifyRSA(c.notifyCert, []byte(base.message(rawPayload)), signature)
result.OK = ok
if ok {
result.MatchedKey = "notify_cert"
}
return result
}
func ParsePayload(body []byte) (map[string]string, error) {
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
raw := flattenJSON(payload)
normalizeNotifyAliases(raw)
return raw, nil
}
func (c *Client) post(ctx context.Context, endpoint string, reqData map[string]any) (map[string]string, error) {
body, err := json.Marshal(map[string]any{
"req_time": timeutil.ShanghaiNow().Format("20060102150405"),
"version": "3.0",
"req_data": reqData,
})
if err != nil {
return nil, err
}
url := strings.TrimRight(strings.TrimSpace(c.cfg.GatewayURL), "/") + endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", c.authorization(string(body)))
req.Header.Set("lkl-op-sdk", "hfb-go-lakala")
req.Header.Set("lkl-op-flowgroup", "NORMAL")
req.Header.Set("lkl-op-appid", c.cfg.AppID)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("lakala http status %d: %s", resp.StatusCode, string(respBody))
}
if err := c.verifyResponse(resp.Header, string(respBody)); err != nil {
return nil, err
}
var payload any
if len(respBody) == 0 {
return map[string]string{}, nil
}
if err := json.Unmarshal(respBody, &payload); err != nil {
return nil, err
}
raw := flattenJSON(payload)
normalizeNotifyAliases(raw)
return raw, nil
}
func (c *Client) authorization(body string) string {
nonce := Nonce(32)
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
message := strings.Join([]string{c.cfg.AppID, c.cfg.SerialNo, timestamp, nonce, body, ""}, "\n")
signature, err := signRSA(c.privateKey, []byte(message))
if err != nil {
return ""
}
return fmt.Sprintf(
`LKLAPI-SHA256withRSA appid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"`,
c.cfg.AppID,
nonce,
timestamp,
c.cfg.SerialNo,
signature,
)
}
func (c *Client) verifyResponse(header http.Header, body string) error {
signature := header.Get("Lklapi-Signature")
if signature == "" {
return nil
}
appID := header.Get("Lklapi-Appid")
if appID == "" {
appID = c.cfg.AppID
}
serial := header.Get("Lklapi-Serial")
timestamp := header.Get("Lklapi-Timestamp")
nonce := header.Get("Lklapi-Nonce")
if serial == "" || timestamp == "" || nonce == "" {
return ErrVerifyFailed
}
message := strings.Join([]string{appID, serial, timestamp, nonce, body, ""}, "\n")
if !verifyRSA(c.lakalaCert, []byte(message), signature) {
return ErrVerifyFailed
}
return nil
}
func (c *Client) initCrypto() error {
if strings.TrimSpace(c.cfg.GatewayURL) == "" ||
strings.TrimSpace(c.cfg.AppID) == "" ||
strings.TrimSpace(c.cfg.SerialNo) == "" ||
strings.TrimSpace(c.cfg.MerchantID) == "" ||
strings.TrimSpace(c.cfg.PrivateKey) == "" ||
strings.TrimSpace(c.cfg.NotifyCert) == "" {
return ErrConfigIncomplete
}
if c.privateKey == nil {
key, err := parsePrivateKey(c.cfg.PrivateKey)
if err != nil {
return err
}
c.privateKey = key
}
if c.notifyCert == nil {
cert, err := parseCertificate(c.cfg.NotifyCert)
if err != nil {
return err
}
c.notifyCert = cert
}
if c.lakalaCert == nil && c.cfg.LakalaCert != "" {
cert, err := parseCertificate(c.cfg.LakalaCert)
if err != nil {
return err
}
c.lakalaCert = cert
}
if c.lakalaCert == nil {
c.lakalaCert = c.notifyCert
}
return nil
}
type authorizationBase struct {
timestamp string
nonce string
}
func (b authorizationBase) message(body string) string {
return strings.Join([]string{b.timestamp, b.nonce, body, ""}, "\n")
}
func parseAuthorization(value string) (string, authorizationBase, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", authorizationBase{}, errors.New("empty authorization")
}
space := strings.Index(value, " ")
if space < 0 {
return "", authorizationBase{}, errors.New("invalid authorization")
}
items := strings.Split(value[space+1:], ",")
params := map[string]string{}
for _, item := range items {
parts := strings.SplitN(strings.TrimSpace(item), "=", 2)
if len(parts) != 2 {
continue
}
params[parts[0]] = strings.Trim(parts[1], `"`)
}
signature := params["signature"]
base := authorizationBase{
timestamp: params["timestamp"],
nonce: params["nonce_str"],
}
if signature == "" || base.timestamp == "" || base.nonce == "" {
return signature, base, errors.New("authorization missing signature fields")
}
return signature, base, nil
}
func parsePrivateKey(value string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(value))
if block == nil {
return nil, errors.New("invalid private key pem")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
key, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("private key is not rsa")
}
return key, nil
}
func parseCertificate(value string) (*x509.Certificate, error) {
block, _ := pem.Decode([]byte(value))
if block == nil {
return nil, errors.New("invalid certificate pem")
}
return x509.ParseCertificate(block.Bytes)
}
func signRSA(key *rsa.PrivateKey, message []byte) (string, error) {
if key == nil {
return "", ErrSignFailed
}
digest := sha256.Sum256(message)
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(signature), nil
}
func verifyRSA(cert *x509.Certificate, message []byte, signature string) bool {
if cert == nil || signature == "" {
return false
}
sig, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return false
}
pub, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
return false
}
digest := sha256.Sum256(message)
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig) == nil
}
func normalizeNotifyAliases(raw map[string]string) {
raw["third_order_id"] = firstNonEmpty(raw["third_order_id"], raw["out_order_no"], raw["out_trade_no"])
raw["provider_order_id"] = firstNonEmpty(raw["provider_order_id"], raw["pay_order_no"], raw["trade_no"], raw["log_no"])
raw["amount"] = firstNonEmpty(raw["amount"], raw["total_amount"], raw["trade_amount"])
raw["status"] = normalizePaymentStatus(raw)
raw["pay_time"] = firstNonEmpty(raw["pay_time"], raw["trade_time"], raw["finish_time"])
if raw["refund_amount"] != "" || raw["origin_out_trade_no"] != "" {
raw["merchant_refund_id"] = firstNonEmpty(raw["merchant_refund_id"], raw["out_trade_no"])
raw["provider_refund_id"] = firstNonEmpty(raw["provider_refund_id"], raw["refund_trade_no"], raw["trade_no"], raw["log_no"])
raw["status"] = normalizeRefundStatus(raw)
}
}
func normalizePaymentStatus(raw map[string]string) string {
value := strings.ToUpper(firstNonEmpty(raw["trade_state"], raw["order_status"], raw["trade_status"], raw["status"], raw["pay_status"]))
switch value {
case "SUCCESS", "PAY_SUCCESS", "TRADE_SUCCESS", "PAID", "S", "2", "30":
return "paid"
case "CLOSED", "CLOSE", "CANCEL", "CANCELED", "CANCELLED", "6":
return "closed"
case "FAIL", "FAILED", "PAY_FAIL", "TRADE_FAIL", "F", "8":
return "failed"
case "REFUND", "REFUNDED":
return "refunded"
default:
return "paying"
}
}
func normalizeRefundStatus(raw map[string]string) string {
value := strings.ToUpper(firstNonEmpty(raw["refund_status"], raw["trade_state"], raw["trade_status"], raw["status"]))
switch value {
case "SUCCESS", "REFUND_SUCCESS", "TRADE_SUCCESS", "REFUNDED", "S", "11":
return "refunded"
case "FAIL", "FAILED", "REFUND_FAIL", "TRADE_FAIL", "F", "12":
return "failed"
}
if hasSuccessResponseCode(raw) {
return "refunded"
}
return "refunding"
}
func hasSuccessResponseCode(raw map[string]string) bool {
code := firstNonEmpty(raw["code"], raw["resp_code"], raw["result_code"], raw["return_code"])
return code != "" && responseOK(raw)
}
func responseOK(raw map[string]string) bool {
code := strings.ToUpper(firstNonEmpty(raw["code"], raw["resp_code"], raw["result_code"], raw["return_code"]))
if code == "" {
return true
}
return code == "0" || code == "000000" || code == "SUCCESS" || code == "BBS00000"
}
func responseMessage(raw map[string]string) string {
return firstNonEmpty(raw["message"], raw["msg"], raw["error_msg"], raw["resp_msg"], raw["return_msg"])
}
func flattenJSON(value any) map[string]string {
out := map[string]string{}
var walk func(prefix string, v any)
walk = func(prefix string, v any) {
switch item := v.(type) {
case map[string]any:
for key, child := range item {
if key == "req_data" || key == "resp_data" || key == "data" {
walk("", child)
continue
}
walk(key, child)
}
case []any:
raw, _ := json.Marshal(item)
if prefix != "" {
out[prefix] = string(raw)
}
case nil:
if prefix != "" {
out[prefix] = ""
}
case string:
if prefix != "" {
out[prefix] = item
}
case float64:
if prefix != "" {
if math.Trunc(item) == item {
out[prefix] = strconv.FormatInt(int64(item), 10)
} else {
out[prefix] = strconv.FormatFloat(item, 'f', -1, 64)
}
}
case bool:
if prefix != "" {
out[prefix] = strconv.FormatBool(item)
}
default:
if prefix != "" {
raw, _ := json.Marshal(item)
out[prefix] = string(raw)
}
}
}
walk("", value)
return out
}
func stringifyMap(value map[string]any) map[string]string {
out := map[string]string{}
for key, item := range value {
switch typed := item.(type) {
case string:
out[key] = typed
default:
raw, _ := json.Marshal(typed)
out[key] = string(raw)
}
}
return out
}
func defaultLocationInfo(clientIP string) map[string]string {
info := map[string]string{
"request_ip": firstNonEmpty(clientIP, "127.0.0.1"),
}
return info
}
func payModeFromPayWay(payWay string) string {
switch strings.ToUpper(payWay) {
case "WXZF", "WECHAT", "WECHATPAY":
return "WECHAT"
case "UNIONPAY", "UQRCODEPAY":
return "UQRCODEPAY"
default:
return "ALIPAY"
}
}
func normalizeCounterPayMode(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "", "AUTO", "ALL", "NONE", "UNLIMITED":
return ""
default:
return strings.ToUpper(strings.TrimSpace(value))
}
}
func sanitizeText(value string, maxRunes int) string {
value = strings.TrimSpace(value)
if value == "" || maxRunes <= 0 {
return value
}
runes := []rune(value)
if len(runes) <= maxRunes {
return value
}
return string(runes[:maxRunes])
}
func Nonce(length int) string {
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
if length <= 0 {
length = 32
}
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
for i := range buf {
buf[i] = chars[int(buf[i])%len(chars)]
}
return string(buf)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
@@ -1,280 +0,0 @@
package lakala
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"hfb_sys/backend/internal/timeutil"
)
func TestVerifyNotifyDetail(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
privatePEM, certPEM := testKeyPairPEM(t, key)
client := NewClient(Config{
GatewayURL: "https://example.com",
AppID: "app-1",
SerialNo: "serial-1",
MerchantID: "merchant-1",
PrivateKey: privatePEM,
NotifyCert: certPEM,
})
body := `{"req_data":{"out_order_no":"NO1","total_amount":100,"trade_state":"SUCCESS"}}`
message := "1710000000\nabc123\n" + body + "\n"
signature, err := signRSA(key, []byte(message))
if err != nil {
t.Fatalf("signRSA: %v", err)
}
authorization := `LKLAPI-SHA256withRSA timestamp="1710000000",nonce_str="abc123",signature="` + signature + `"`
result := client.VerifyNotifyDetail(body, authorization)
if !result.OK {
t.Fatalf("VerifyNotifyDetail().OK = false, got=%s expected=%v base=%v", result.Got, result.Expected, result.BaseString)
}
tampered := client.VerifyNotifyDetail(strings.Replace(body, "100", "101", 1), authorization)
if tampered.OK {
t.Fatal("VerifyNotifyDetail() = true after body changed, want false")
}
}
func TestParsePayloadNormalizesAliases(t *testing.T) {
params, err := ParsePayload([]byte(`{
"req_data": {
"out_order_no": "ORDER1",
"pay_order_no": "PAY1",
"total_amount": 88,
"trade_state": "SUCCESS",
"trade_time": "20240521101010"
}
}`))
if err != nil {
t.Fatalf("ParsePayload error = %v", err)
}
if params["third_order_id"] != "ORDER1" {
t.Fatalf("third_order_id = %q, want ORDER1", params["third_order_id"])
}
if params["provider_order_id"] != "PAY1" {
t.Fatalf("provider_order_id = %q, want PAY1", params["provider_order_id"])
}
if params["amount"] != "88" {
t.Fatalf("amount = %q, want 88", params["amount"])
}
if params["status"] != "paid" {
t.Fatalf("status = %q, want paid", params["status"])
}
}
func TestNormalizeRefundStatusTreatsSuccessCodeAsRefunded(t *testing.T) {
status := normalizeRefundStatus(map[string]string{
"code": "BBS00000",
"msg": "成功",
})
if status != "refunded" {
t.Fatalf("normalizeRefundStatus() = %q, want refunded", status)
}
}
func TestNormalizeRefundStatusTreatsSuccessCodeWithRefundingAsRefunded(t *testing.T) {
status := normalizeRefundStatus(map[string]string{
"code": "BBS00000",
"msg": "成功",
"status": "refunding",
"refund_amount": "960",
"trade_no": "20260610110110001231120132051564",
})
if status != "refunded" {
t.Fatalf("normalizeRefundStatus() = %q, want refunded", status)
}
}
func TestNormalizeRefundStatusKeepsUnknownWithoutCodeAsRefunding(t *testing.T) {
status := normalizeRefundStatus(map[string]string{
"status": "processing",
})
if status != "refunding" {
t.Fatalf("normalizeRefundStatus() = %q, want refunding", status)
}
}
func TestCreatePaymentOmitsCounterParamWhenPayModeBlank(t *testing.T) {
body := captureCreatePaymentBody(t, "")
reqData := body["req_data"].(map[string]any)
if _, ok := reqData["counter_param"]; ok {
t.Fatalf("counter_param exists when pay_mode is blank: %#v", reqData["counter_param"])
}
}
func TestCreatePaymentSetsCounterParamWhenPayModeConfigured(t *testing.T) {
body := captureCreatePaymentBody(t, "WECHAT")
reqData := body["req_data"].(map[string]any)
if reqData["counter_param"] != `{"pay_mode":"WECHAT"}` {
t.Fatalf("counter_param = %#v, want WECHAT", reqData["counter_param"])
}
}
func TestCreatePaymentTrimsGatewayURLWhitespace(t *testing.T) {
body := captureCreatePaymentBody(t, "", "\t")
reqData := body["req_data"].(map[string]any)
if reqData["out_order_no"] != "ORDER1" {
t.Fatalf("out_order_no = %#v, want ORDER1", reqData["out_order_no"])
}
}
func TestCreatePaymentUsesShanghaiTimeWhenLocalIsUTC(t *testing.T) {
oldLocal := time.Local
time.Local = time.UTC
defer func() {
time.Local = oldLocal
}()
loc := timeutil.ShanghaiLocation()
before := time.Now().In(loc)
body := captureCreatePaymentBody(t, "")
after := time.Now().In(loc)
reqData := body["req_data"].(map[string]any)
reqTime := parseLakalaTestTime(t, body["req_time"].(string))
if reqTime.Before(before.Add(-2*time.Second)) || reqTime.After(after.Add(2*time.Second)) {
t.Fatalf("req_time = %s, want between %s and %s", reqTime, before, after)
}
efficientTime := parseLakalaTestTime(t, reqData["order_efficient_time"].(string))
wantMin := before.Add(15*time.Minute - 2*time.Second)
wantMax := after.Add(15*time.Minute + 2*time.Second)
if efficientTime.Before(wantMin) || efficientTime.After(wantMax) {
t.Fatalf("order_efficient_time = %s, want between %s and %s", efficientTime, wantMin, wantMax)
}
}
func TestCreateRefundReturnsRawRequestOnHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != endpointRefund {
t.Fatalf("path = %s, want %s", r.URL.Path, endpointRefund)
}
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":"OP90001","msg":"请求服务失败"}`))
}))
defer server.Close()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
privatePEM, certPEM := testKeyPairPEM(t, key)
client := NewClient(Config{
GatewayURL: server.URL,
AppID: "app-1",
SerialNo: "serial-1",
MerchantID: "merchant-1",
TermNo: "term-1",
PrivateKey: privatePEM,
NotifyCert: certPEM,
})
resp, err := client.CreateRefund(context.Background(), CreateRefundRequest{
ThirdOrderID: "PAY123",
ProviderOrderID: "TRADE123",
MerchantRefundID: "REF123",
RefundAmountCent: 99,
RefundReason: "结账退款",
})
if err == nil {
t.Fatal("CreateRefund error = nil, want HTTP error")
}
if resp == nil {
t.Fatal("CreateRefund response = nil, want raw request")
}
if resp.RawRequest["merchant_no"] != "merchant-1" ||
resp.RawRequest["term_no"] != "term-1" ||
resp.RawRequest["out_trade_no"] != "REF123" ||
resp.RawRequest["refund_amount"] != "99" ||
resp.RawRequest["origin_out_trade_no"] != "PAY123" ||
resp.RawRequest["origin_trade_no"] != "TRADE123" {
t.Fatalf("raw request = %#v", resp.RawRequest)
}
}
func parseLakalaTestTime(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.ParseInLocation("20060102150405", value, timeutil.ShanghaiLocation())
if err != nil {
t.Fatalf("parse lakala time %q: %v", value, err)
}
return parsed
}
func testKeyPairPEM(t *testing.T, key *rsa.PrivateKey) (string, string) {
t.Helper()
privateDER := x509.MarshalPKCS1PrivateKey(key)
privatePEM := string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privateDER,
}))
template := &x509.Certificate{}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}))
return privatePEM, certPEM
}
func captureCreatePaymentBody(t *testing.T, payMode string, gatewaySuffix ...string) map[string]any {
t.Helper()
var captured map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != endpointCounterCreate {
t.Fatalf("path = %s, want %s", r.URL.Path, endpointCounterCreate)
}
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"resp_code":"0","result_code":"0","pay_order_no":"LK1"}`))
}))
defer server.Close()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
privatePEM, certPEM := testKeyPairPEM(t, key)
gatewayURL := server.URL
if len(gatewaySuffix) > 0 {
gatewayURL += gatewaySuffix[0]
}
client := NewClient(Config{
GatewayURL: gatewayURL,
AppID: "app-1",
SerialNo: "serial-1",
MerchantID: "merchant-1",
PrivateKey: privatePEM,
NotifyCert: certPEM,
PayMode: payMode,
})
_, err = client.CreatePayment(context.Background(), CreatePaymentRequest{
ThirdOrderID: "ORDER1",
AmountCent: 100,
NotifyURL: "https://example.com/notify",
Body: "测试订单",
})
if err != nil {
t.Fatalf("CreatePayment error = %v", err)
}
return captured
}
@@ -1,583 +0,0 @@
package leshua
import (
"bytes"
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
var (
ErrConfigIncomplete = errors.New("leshua payment config incomplete")
ErrUnsupportedSign = errors.New("unsupported leshua sign type")
)
type Client struct {
cfg Config
httpClient *http.Client
}
type Config struct {
GatewayURL string
MerchantID string
SignKey string
NotifyKey string
NotifyURL string
JumpURL string
PayWay string
JSPayFlag string
SignType string
}
type CreatePaymentRequest struct {
ThirdOrderID string
AmountCent int64
PayWay string
JSPayFlag string
NotifyURL string
JumpURL string
ClientIP string
Body string
Attach string
}
type CreatePaymentResponse struct {
RespCode string
ResultCode string
ErrorCode string
ErrorMessage string
MerchantID string
ThirdOrderID string
ProviderOrderID string
PayWay string
TDCode string
JSPayURL string
JSPayInfo string
Raw map[string]string
}
type QueryPaymentResponse struct {
RespCode string
ResultCode string
ErrorCode string
ErrorMessage string
MerchantID string
ThirdOrderID string
ProviderOrderID string
Status string
Amount string
PayWay string
PayTime string
Raw map[string]string
}
type CreateRefundRequest struct {
ThirdOrderID string // 原支付商户订单号
LeshuaOrderID string // 原支付乐刷订单号(优先使用)
MerchantRefundID string // 商户退款单号(唯一)
RefundAmountCent int64 // 退款金额(分)
NotifyURL string
Attach string
}
type CreateRefundResponse struct {
RespCode string
ResultCode string
ErrorCode string
ErrorMessage string
MerchantID string
ThirdOrderID string
LeshuaOrderID string
MerchantRefundID string
LeshuaRefundID string
RefundAmount string
TotalAmount string
OrderBalance string
Status string
Raw map[string]string
}
type QueryRefundRequest struct {
ThirdOrderID string
LeshuaOrderID string
MerchantRefundID string
LeshuaRefundID string
}
type QueryRefundResponse struct {
RespCode string
ResultCode string
ErrorCode string
ErrorMessage string
MerchantID string
ThirdOrderID string
LeshuaOrderID string
MerchantRefundID string
LeshuaRefundID string
Status string
RefundAmount string
TotalAmount string
RefundTime string
Raw map[string]string
}
type VerifyNotifyResult struct {
OK bool
MatchedKey string
Got string
Expected map[string]string
BaseString map[string]string
ParamKeys []string
}
func NewClient(cfg Config) *Client {
return &Client{
cfg: cfg,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*CreatePaymentResponse, map[string]string, error) {
if err := c.validate(); err != nil {
return nil, nil, err
}
payWay := firstNonEmpty(req.PayWay, c.cfg.PayWay, "ZFBZF")
jsPayFlag := firstNonEmpty(req.JSPayFlag, c.cfg.JSPayFlag, "2")
params := map[string]string{
"service": "get_tdcode",
"merchant_id": c.cfg.MerchantID,
"third_order_id": req.ThirdOrderID,
"amount": fmt.Sprintf("%d", req.AmountCent),
"pay_way": payWay,
"jspay_flag": jsPayFlag,
"nonce_str": Nonce(32),
"body": sanitizeText(req.Body, 128),
"attach": sanitizeText(req.Attach, 64),
}
if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") {
params["sign_type"] = c.cfg.SignType
}
if req.NotifyURL != "" {
params["notify_url"] = req.NotifyURL
}
if req.JumpURL != "" {
params["jump_url"] = req.JumpURL
}
if req.ClientIP != "" {
params["client_ip"] = req.ClientIP
}
params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{})
raw, err := c.post(ctx, params)
if err != nil {
return nil, params, err
}
resp := &CreatePaymentResponse{
RespCode: raw["resp_code"],
ResultCode: raw["result_code"],
ErrorCode: raw["error_code"],
ErrorMessage: firstNonEmpty(raw["error_msg"], raw["resp_msg"]),
MerchantID: raw["merchant_id"],
ThirdOrderID: raw["third_order_id"],
ProviderOrderID: raw["leshua_order_id"],
PayWay: raw["pay_way"],
TDCode: raw["td_code"],
JSPayURL: raw["jspay_url"],
JSPayInfo: raw["jspay_info"],
Raw: raw,
}
return resp, params, nil
}
func (c *Client) QueryPayment(ctx context.Context, thirdOrderID, providerOrderID string) (*QueryPaymentResponse, error) {
if err := c.validate(); err != nil {
return nil, err
}
params := map[string]string{
"service": "query_status",
"merchant_id": c.cfg.MerchantID,
"nonce_str": Nonce(32),
}
if providerOrderID != "" {
params["leshua_order_id"] = providerOrderID
} else {
params["third_order_id"] = thirdOrderID
}
if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") {
params["sign_type"] = c.cfg.SignType
}
params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{})
raw, err := c.post(ctx, params)
if err != nil {
return nil, err
}
return &QueryPaymentResponse{
RespCode: raw["resp_code"],
ResultCode: raw["result_code"],
ErrorCode: raw["error_code"],
ErrorMessage: firstNonEmpty(raw["error_msg"], raw["resp_msg"]),
MerchantID: raw["merchant_id"],
ThirdOrderID: raw["third_order_id"],
ProviderOrderID: raw["leshua_order_id"],
Status: raw["status"],
Amount: raw["amount"],
PayWay: raw["pay_way"],
PayTime: raw["pay_time"],
Raw: raw,
}, nil
}
func (c *Client) CreateRefund(ctx context.Context, req CreateRefundRequest) (*CreateRefundResponse, map[string]string, error) {
if err := c.validate(); err != nil {
return nil, nil, err
}
params := map[string]string{
"service": "unified_refund",
"merchant_id": c.cfg.MerchantID,
"merchant_refund_id": req.MerchantRefundID,
"refund_amount": fmt.Sprintf("%d", req.RefundAmountCent),
"nonce_str": Nonce(32),
}
if req.LeshuaOrderID != "" {
params["leshua_order_id"] = req.LeshuaOrderID
} else if req.ThirdOrderID != "" {
params["third_order_id"] = req.ThirdOrderID
}
if req.Attach != "" {
params["attach"] = sanitizeText(req.Attach, 64)
}
if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") {
params["sign_type"] = c.cfg.SignType
}
if req.NotifyURL != "" {
params["notify_url"] = req.NotifyURL
}
params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{})
raw, err := c.post(ctx, params)
if err != nil {
return nil, params, err
}
resp := &CreateRefundResponse{
RespCode: raw["resp_code"],
ResultCode: raw["result_code"],
ErrorCode: raw["error_code"],
ErrorMessage: firstNonEmpty(raw["error_msg"], raw["resp_msg"]),
MerchantID: raw["merchant_id"],
ThirdOrderID: raw["third_order_id"],
LeshuaOrderID: raw["leshua_order_id"],
MerchantRefundID: raw["merchant_refund_id"],
LeshuaRefundID: raw["leshua_refund_id"],
RefundAmount: raw["refund_amount"],
TotalAmount: raw["total_amount"],
OrderBalance: raw["order_balance"],
Status: raw["status"],
Raw: raw,
}
return resp, params, nil
}
func (c *Client) QueryRefund(ctx context.Context, req QueryRefundRequest) (*QueryRefundResponse, error) {
if err := c.validate(); err != nil {
return nil, err
}
params := map[string]string{
"service": "unified_query_refund",
"merchant_id": c.cfg.MerchantID,
"nonce_str": Nonce(32),
}
if req.LeshuaOrderID != "" {
params["leshua_order_id"] = req.LeshuaOrderID
} else if req.ThirdOrderID != "" {
params["third_order_id"] = req.ThirdOrderID
}
if req.LeshuaRefundID != "" {
params["leshua_refund_id"] = req.LeshuaRefundID
} else if req.MerchantRefundID != "" {
params["merchant_refund_id"] = req.MerchantRefundID
}
if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") {
params["sign_type"] = c.cfg.SignType
}
params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{})
raw, err := c.post(ctx, params)
if err != nil {
return nil, err
}
return &QueryRefundResponse{
RespCode: raw["resp_code"],
ResultCode: raw["result_code"],
ErrorCode: raw["error_code"],
ErrorMessage: firstNonEmpty(raw["error_msg"], raw["resp_msg"]),
MerchantID: raw["merchant_id"],
ThirdOrderID: raw["third_order_id"],
LeshuaOrderID: raw["leshua_order_id"],
MerchantRefundID: raw["merchant_refund_id"],
LeshuaRefundID: raw["leshua_refund_id"],
Status: raw["status"],
RefundAmount: raw["refund_amount"],
TotalAmount: raw["total_amount"],
RefundTime: raw["refund_time"],
Raw: raw,
}, nil
}
func (c *Client) VerifyNotify(params map[string]string) bool {
return c.VerifyNotifyDetail(params).OK
}
func (c *Client) VerifyNotifyDetail(params map[string]string) VerifyNotifyResult {
got := strings.ToUpper(params["sign"])
result := VerifyNotifyResult{
Got: got,
Expected: map[string]string{},
BaseString: map[string]string{},
ParamKeys: notifyParamKeys(params),
}
if got == "" {
return result
}
for _, item := range c.notifyKeyCandidates() {
expected := Sign(params, item.key, notifySignOptions())
baseString := SignBaseString(params, notifySignOptions())
result.Expected[item.name] = expected
result.BaseString[item.name] = baseString
if got == expected {
result.OK = true
result.MatchedKey = item.name
return result
}
}
return result
}
func notifySignOptions() SignOptions {
return SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "leshua", "sign"},
}
}
type notifyKeyCandidate struct {
name string
key string
}
func (c *Client) notifyKeyCandidates() []notifyKeyCandidate {
candidates := []notifyKeyCandidate{}
if c.cfg.NotifyKey != "" {
candidates = append(candidates, notifyKeyCandidate{name: "notify_key", key: c.cfg.NotifyKey})
}
return candidates
}
func notifyParamKeys(params map[string]string) []string {
keys := make([]string, 0, len(params))
for key := range params {
if key == "sign" || key == "error_code" || key == "leshua" {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func (c *Client) validate() error {
if strings.TrimSpace(c.cfg.GatewayURL) == "" || c.cfg.MerchantID == "" || c.cfg.SignKey == "" {
return ErrConfigIncomplete
}
if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") {
return ErrUnsupportedSign
}
return nil
}
func (c *Client) post(ctx context.Context, params map[string]string) (map[string]string, error) {
values := url.Values{}
for key, value := range params {
values.Set(key, value)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(c.cfg.GatewayURL), strings.NewReader(values.Encode()))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpResp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
body, err := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
if err != nil {
return nil, err
}
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("leshua http status %d: %s", httpResp.StatusCode, string(body))
}
return ParsePayload(body)
}
type SignOptions struct {
IncludeEmpty bool
ExcludeKeys []string
}
func Sign(params map[string]string, key string, opts SignOptions) string {
baseString := SignBaseString(params, opts)
stringSignTemp := "key=" + key
if baseString != "" {
stringSignTemp = baseString + "&key=" + key
}
sum := md5.Sum([]byte(stringSignTemp))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func SignBaseString(params map[string]string, opts SignOptions) string {
excluded := map[string]bool{}
for _, item := range opts.ExcludeKeys {
excluded[item] = true
}
if len(opts.ExcludeKeys) == 0 {
excluded["sign"] = true
}
keys := make([]string, 0, len(params))
for name, value := range params {
if excluded[name] {
continue
}
if !opts.IncludeEmpty && value == "" {
continue
}
keys = append(keys, name)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys)+1)
for _, name := range keys {
parts = append(parts, name+"="+params[name])
}
return strings.Join(parts, "&")
}
func ParsePayload(body []byte) (map[string]string, error) {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return map[string]string{}, nil
}
if trimmed[0] == '<' {
return parseXMLPayload(trimmed)
}
if trimmed[0] == '{' {
var raw map[string]any
if err := json.Unmarshal(trimmed, &raw); err != nil {
return nil, err
}
out := map[string]string{}
for key, value := range raw {
out[key] = fmt.Sprint(value)
}
return out, nil
}
values, err := url.ParseQuery(string(trimmed))
if err != nil {
return nil, err
}
out := map[string]string{}
for key, item := range values {
if len(item) > 0 {
out[key] = item[0]
}
}
if len(out) == 0 {
return nil, fmt.Errorf("unsupported leshua payload: %s", string(trimmed))
}
return out, nil
}
func parseXMLPayload(body []byte) (map[string]string, error) {
decoder := xml.NewDecoder(bytes.NewReader(body))
out := map[string]string{}
var current string
depth := 0
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
switch item := token.(type) {
case xml.StartElement:
depth++
if depth > 1 {
current = item.Name.Local
if _, exists := out[current]; !exists {
out[current] = ""
}
}
case xml.CharData:
value := strings.TrimSpace(string(item))
if current != "" && value != "" {
out[current] = value
}
case xml.EndElement:
if current == item.Name.Local {
current = ""
}
if depth > 0 {
depth--
}
}
}
return out, nil
}
func Nonce(length int) string {
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
if length <= 0 {
length = 32
}
buf := make([]byte, length)
random := make([]byte, length)
if _, err := rand.Read(random); err != nil {
for i := range buf {
buf[i] = alphabet[int(time.Now().UnixNano())%len(alphabet)]
}
return string(buf)
}
for i, item := range random {
buf[i] = alphabet[int(item)%len(alphabet)]
}
return string(buf)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func sanitizeText(value string, max int) string {
value = strings.ReplaceAll(value, "\n", " ")
value = strings.ReplaceAll(value, "\r", " ")
value = strings.TrimSpace(value)
if max > 0 && len([]rune(value)) > max {
return string([]rune(value)[:max])
}
return value
}
@@ -1,216 +0,0 @@
package leshua
import (
"strings"
"testing"
)
func TestSignUsesASCIISortedNonEmptyParams(t *testing.T) {
params := map[string]string{
"service": "get_tdcode",
"merchant_id": "1234567890",
"third_order_id": "NO1",
"amount": "100",
"nonce_str": "abc",
"empty": "",
"sign": "ignored",
}
got := Sign(params, "secret", SignOptions{})
want := "1E7892034AA77899E8DD609C4FA09E76"
if got != want {
t.Fatalf("Sign() = %s, want %s", got, want)
}
baseString := SignBaseString(params, SignOptions{})
wantBaseString := "amount=100&merchant_id=1234567890&nonce_str=abc&service=get_tdcode&third_order_id=NO1"
if baseString != wantBaseString {
t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString)
}
}
func TestVerifyNotifyIncludesEmptyAndExcludesErrorCode(t *testing.T) {
client := NewClient(Config{NotifyKey: "notify-secret"})
params := map[string]string{
"merchant_id": "1234567890",
"third_order_id": "NO1",
"leshua_order_id": "LS1",
"amount": "100",
"status": "2",
"attach": "",
"leshua": "",
"error_code": "-20001",
}
params["sign"] = Sign(params, "notify-secret", SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "leshua", "sign"},
})
if !client.VerifyNotify(params) {
t.Fatal("VerifyNotify() = false, want true")
}
params["amount"] = "101"
if client.VerifyNotify(params) {
t.Fatal("VerifyNotify() = true after amount changed, want false")
}
}
func TestVerifyNotifyDoesNotFallBackToSignKey(t *testing.T) {
client := NewClient(Config{
NotifyKey: "wrong-notify-secret",
SignKey: "sign-secret",
})
params := map[string]string{
"merchant_id": "1234567890",
"third_order_id": "NO1",
"leshua_order_id": "LS1",
"amount": "100",
"status": "2",
}
params["sign"] = Sign(params, "sign-secret", SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "sign"},
})
result := client.VerifyNotifyDetail(params)
if result.OK {
t.Fatal("VerifyNotifyDetail().OK = true, want false")
}
if _, ok := result.Expected["sign_key"]; ok {
t.Fatal("VerifyNotifyDetail() unexpectedly used sign_key fallback")
}
}
func TestVerifyNotifyUsesDocumentedNotifySignature(t *testing.T) {
client := NewClient(Config{NotifyKey: "notify-secret"})
params := map[string]string{
"merchant_id": "1234567890",
"third_order_id": "NO1",
"leshua_order_id": "LS1",
"amount": "100",
"status": "2",
"sign_type": "MD5",
}
params["sign"] = Sign(params, "notify-secret", SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "leshua", "sign"},
})
result := client.VerifyNotifyDetail(params)
if !result.OK {
t.Fatal("VerifyNotifyDetail().OK = false, want true")
}
if result.MatchedKey != "notify_key" {
t.Fatalf("MatchedKey = %s, want notify_key", result.MatchedKey)
}
}
func TestVerifyNotifyKeepsEmptyXMLFieldsInSignature(t *testing.T) {
client := NewClient(Config{NotifyKey: "notify-secret"})
params, err := ParsePayload([]byte(`<leshua>
<amount>100</amount>
<goods_tag></goods_tag>
<merchant_id>1234567890</merchant_id>
<sign_type>MD5</sign_type>
<status>2</status>
<third_order_id>NO1</third_order_id>
</leshua>`))
if err != nil {
t.Fatalf("ParsePayload(xml) error = %v", err)
}
params["sign"] = Sign(params, "notify-secret", SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "leshua", "sign"},
})
result := client.VerifyNotifyDetail(params)
if !result.OK {
t.Fatal("VerifyNotifyDetail().OK = false, want true")
}
baseString := result.BaseString[result.MatchedKey]
if !strings.Contains(baseString, "goods_tag=") {
t.Fatalf("baseString = %s, want goods_tag included", baseString)
}
}
func TestParsePayloadSupportsFormAndXML(t *testing.T) {
form, err := ParsePayload([]byte("third_order_id=NO1&status=2&amount=100"))
if err != nil {
t.Fatalf("ParsePayload(form) error = %v", err)
}
if form["third_order_id"] != "NO1" || form["status"] != "2" || form["amount"] != "100" {
t.Fatalf("ParsePayload(form) = %#v", form)
}
xml, err := ParsePayload([]byte("<xml><third_order_id>NO2</third_order_id><status>6</status><goods_tag></goods_tag><coupon/></xml>"))
if err != nil {
t.Fatalf("ParsePayload(xml) error = %v", err)
}
if xml["third_order_id"] != "NO2" || xml["status"] != "6" {
t.Fatalf("ParsePayload(xml) = %#v", xml)
}
if value, ok := xml["goods_tag"]; !ok || value != "" {
t.Fatalf("ParsePayload(xml).goods_tag = %q, exists=%v; want empty value", value, ok)
}
if value, ok := xml["coupon"]; !ok || value != "" {
t.Fatalf("ParsePayload(xml).coupon = %q, exists=%v; want empty value", value, ok)
}
}
func TestSignRefundUsesSameAlgorithm(t *testing.T) {
params := map[string]string{
"service": "unified_refund",
"merchant_id": "1234567890",
"merchant_refund_id": "REF001",
"refund_amount": "100",
"leshua_order_id": "LS1",
"nonce_str": "abc",
"sign": "ignored",
}
got := Sign(params, "secret", SignOptions{})
baseString := SignBaseString(params, SignOptions{})
wantBaseString := "leshua_order_id=LS1&merchant_id=1234567890&merchant_refund_id=REF001&nonce_str=abc&refund_amount=100&service=unified_refund"
if baseString != wantBaseString {
t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString)
}
want := Sign(map[string]string{
"service": "unified_refund",
"merchant_id": "1234567890",
"merchant_refund_id": "REF001",
"refund_amount": "100",
"leshua_order_id": "LS1",
"nonce_str": "abc",
}, "secret", SignOptions{})
if got != want {
t.Fatalf("Sign() = %s, want %s", got, want)
}
}
func TestVerifyRefundNotify(t *testing.T) {
client := NewClient(Config{NotifyKey: "notify-secret"})
params := map[string]string{
"merchant_id": "1234567890",
"third_order_id": "NO1",
"leshua_order_id": "LS1",
"merchant_refund_id": "REF001",
"leshua_refund_id": "LREF001",
"refund_amount": "100",
"total_amount": "200",
"status": "11",
"attach": "",
}
params["sign"] = Sign(params, "notify-secret", SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "leshua", "sign"},
})
if !client.VerifyNotify(params) {
t.Fatal("VerifyNotify() = false, want true for refund notify")
}
params["status"] = "12"
if client.VerifyNotify(params) {
t.Fatal("VerifyNotify() = true after status changed without re-sign, want false")
}
}
File diff suppressed because it is too large Load Diff
@@ -1,266 +0,0 @@
package shuncheng
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)
func TestSignUsesDocumentedBase64LowerMD5(t *testing.T) {
params := map[string]string{
"service": "get_tdcode",
"merchant_id": "1234567890",
"third_order_id": "NO1",
"amount": "100",
"nonce_str": "abc",
"req_serial_no": "req001",
"empty": "",
"sign": "ignored",
}
got := SignStrings(params, "secret")
want := "ZTM5YTQ1NDk4YmRhODJlYTYyOWQzMGI2N2M5YzFjYjQ="
if got != want {
t.Fatalf("SignStrings() = %s, want %s", got, want)
}
baseString := SignBaseString(stringMapToAny(params))
wantBaseString := "amount=100&merchant_id=1234567890&nonce_str=abc&req_serial_no=req001&service=get_tdcode&third_order_id=NO1"
if baseString != wantBaseString {
t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString)
}
}
func TestSignSortsNestedObjectLikeDocs(t *testing.T) {
params := map[string]any{
"reqSerialNo": "20260318110810747",
"data": map[string]any{
"merchantId": "M1",
"applyAmount": 10000,
"reqId": "R1",
"ignored": nil,
},
}
baseString := SignBaseString(params)
wantBaseString := `data={"applyAmount":10000,"merchantId":"M1","reqId":"R1"}&reqSerialNo=20260318110810747`
if baseString != wantBaseString {
t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString)
}
got := Sign(params, "secret")
want := "N2VlOWY3OWJlZmU4YThiNjEwN2Y1NWYxNzI0ZjY0NTY="
if got != want {
t.Fatalf("Sign() = %s, want %s", got, want)
}
}
func TestParsePayloadNormalizesXMLAliases(t *testing.T) {
params, err := ParsePayload([]byte(`<schc>
<resp_code><![CDATA[200]]></resp_code>
<result_code><![CDATA[0]]></result_code>
<third_order_id><![CDATA[PAY1]]></third_order_id>
<schc_order_id><![CDATA[SC1]]></schc_order_id>
<schc_refund_id><![CDATA[RF1]]></schc_refund_id>
<refund_amount><![CDATA[100]]></refund_amount>
<status><![CDATA[11]]></status>
</schc>`))
if err != nil {
t.Fatalf("ParsePayload() error = %v", err)
}
if params["provider_order_id"] != "SC1" {
t.Fatalf("provider_order_id = %q, want SC1", params["provider_order_id"])
}
if params["provider_refund_id"] != "RF1" {
t.Fatalf("provider_refund_id = %q, want RF1", params["provider_refund_id"])
}
}
func TestParsePayloadExtractsEscapedXMLFromHTML(t *testing.T) {
params, err := ParsePayload([]byte(`<html><body>&lt;schc&gt;&lt;resp_code&gt;200&lt;/resp_code&gt;&lt;result_code&gt;0&lt;/result_code&gt;&lt;third_order_id&gt;PAY1&lt;/third_order_id&gt;&lt;schc_order_id&gt;SC1&lt;/schc_order_id&gt;&lt;jspay_url&gt;https://pay.example/sc1&lt;/jspay_url&gt;&lt;/schc&gt;</body></html>`))
if err != nil {
t.Fatalf("ParsePayload() error = %v", err)
}
if params["provider_order_id"] != "SC1" || params["jspay_url"] != "https://pay.example/sc1" {
t.Fatalf("ParsePayload() = %#v, want extracted shuncheng xml", params)
}
}
func TestParsePayloadAcceptsEscapedURLQueryInHTMLXML(t *testing.T) {
params, err := ParsePayload([]byte(`<html><body>&lt;schc&gt;&lt;resp_code&gt;200&lt;/resp_code&gt;&lt;result_code&gt;0&lt;/result_code&gt;&lt;third_order_id&gt;PAY1&lt;/third_order_id&gt;&lt;schc_order_id&gt;SC1&lt;/schc_order_id&gt;&lt;jspay_url&gt;https://pay.example/sc1?mid=1&amp;pu=2&lt;/jspay_url&gt;&lt;/schc&gt;</body></html>`))
if err != nil {
t.Fatalf("ParsePayload() error = %v", err)
}
if params["jspay_url"] != "https://pay.example/sc1?mid=1&pu=2" {
t.Fatalf("jspay_url = %q, want query decoded", params["jspay_url"])
}
}
func TestParsePayloadAcceptsJSONStringXML(t *testing.T) {
xmlText := `<?xml version="1.0" encoding="utf-16"?><schc xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"><resp_code>200</resp_code><merchant_id>5169118450</merchant_id><sign>00EFB11258D694634F727A8FCCF92290</sign><cost_time>4</cost_time><sign_type>MD5</sign_type><third_order_id>PAY1</third_order_id><jspay_url>https://pay.example/sc1?mid=1&amp;pu=2</jspay_url><result_code>0</result_code><schc_order_id>SC1</schc_order_id></schc>`
body, err := json.Marshal(xmlText)
if err != nil {
t.Fatalf("json marshal xml text: %v", err)
}
params, err := ParsePayload(body)
if err != nil {
t.Fatalf("ParsePayload() error = %v", err)
}
if params["provider_order_id"] != "SC1" || params["jspay_url"] != "https://pay.example/sc1?mid=1&pu=2" {
t.Fatalf("ParsePayload() = %#v, want xml string decoded", params)
}
}
func TestParsePayloadTrimsBOMBeforeXML(t *testing.T) {
params, err := ParsePayload([]byte("\xef\xbb\xbf<schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id></schc>"))
if err != nil {
t.Fatalf("ParsePayload() error = %v", err)
}
if params["provider_order_id"] != "SC1" {
t.Fatalf("provider_order_id = %q, want SC1", params["provider_order_id"])
}
}
func TestParsePayloadRejectsPlainHTML(t *testing.T) {
if _, err := ParsePayload([]byte(`<html><body>invalid semicolon; separator</body></html>`)); err == nil {
t.Fatal("ParsePayload() error = nil, want unsupported html error")
}
}
func TestVerifyNotifyAcceptsDocumentedAndLegacySignatures(t *testing.T) {
client := NewClient(Config{NotifyKey: "notify-secret"})
params := map[string]string{
"merchant_id": "1234567890",
"third_order_id": "NO1",
"schc_order_id": "SC1",
"amount": "100",
"status": "2",
"sign_type": "MD5",
}
params["sign"] = SignStrings(params, "notify-secret")
if result := client.VerifyNotifyDetail(params); !result.OK {
t.Fatalf("VerifyNotifyDetail().OK = false, got=%s expected=%v", result.Got, result.Expected)
}
params["sign"] = SignStringsUpperMD5(params, "notify-secret")
if result := client.VerifyNotifyDetail(params); !result.OK {
t.Fatalf("VerifyNotifyDetail() legacy upper md5 OK = false, got=%s expected=%v", result.Got, result.Expected)
}
params["amount"] = "101"
if client.VerifyNotify(params) {
t.Fatal("VerifyNotify() = true after amount changed, want false")
}
}
func TestCreatePaymentFetchesTokenOnceAndPostsSignedJSON(t *testing.T) {
resetTokenCacheForTest()
var tokenCount int
var createCount int
var captured map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case endpointToken:
tokenCount++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"respCode":"200","data":"token-1"}`))
case endpointSimplePay:
createCount++
if r.Header.Get("Authorization") != "Bearer token-1" {
t.Fatalf("Authorization = %q, want Bearer token-1", r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatalf("decode create payment request: %v", err)
}
if captured["sign"] != SignStrings(captured, "secret-key") {
t.Fatalf("sign = %q, want recalculated %q", captured["sign"], SignStrings(captured, "secret-key"))
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id><jspay_url>https://pay.example/sc1</jspay_url></schc>`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(Config{
GatewayURL: server.URL,
MerchantID: "merchant-1",
SecretID: "secret-id",
SecretKey: "secret-key",
NotifyURL: "https://example.com/notify",
})
for i := 0; i < 2; i++ {
resp, err := client.CreatePayment(context.Background(), CreatePaymentRequest{
ThirdOrderID: "PAY1",
AmountCent: 100,
Body: "租号订单",
})
if err != nil {
t.Fatalf("CreatePayment() error = %v", err)
}
if !resp.OK || resp.ProviderOrderID != "SC1" || resp.JSPayURL == "" {
t.Fatalf("CreatePayment() response = %+v", resp)
}
}
if tokenCount != 1 {
t.Fatalf("tokenCount = %d, want 1", tokenCount)
}
if createCount != 2 {
t.Fatalf("createCount = %d, want 2", createCount)
}
if captured["notify_url"] != "https://example.com/notify" {
t.Fatalf("notify_url = %q, want configured notify url", captured["notify_url"])
}
}
func TestUnauthorizedRefreshesTokenOnce(t *testing.T) {
resetTokenCacheForTest()
var tokenCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case endpointToken:
tokenCount++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"respCode":"200","data":"token-` + strconv.Itoa(tokenCount) + `"}`))
case endpointPaymentQuery:
if r.Header.Get("Authorization") == "Bearer token-1" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.Header.Get("Authorization") != "Bearer token-2" {
t.Fatalf("Authorization = %q, want Bearer token-2", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id><status>2</status></schc>`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(Config{
GatewayURL: server.URL,
MerchantID: "merchant-1",
SecretID: "secret-id",
SecretKey: "secret-key",
})
resp, err := client.QueryPayment(context.Background(), "PAY1", "")
if err != nil {
t.Fatalf("QueryPayment() error = %v", err)
}
if !resp.OK || resp.Status != "paid" || resp.ProviderOrderID != "SC1" {
t.Fatalf("QueryPayment() response = %+v", resp)
}
if tokenCount != 2 {
t.Fatalf("tokenCount = %d, want 2", tokenCount)
}
}
func resetTokenCacheForTest() {
tokenCache.Lock()
tokenCache.items = map[string]tokenEntry{}
tokenCache.Unlock()
}
@@ -1,69 +0,0 @@
package push
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const defaultBarkServer = "https://api.day.app"
// BarkConfig 是 iOS Bark 推送的配置。
type BarkConfig struct {
DeviceKey string
Server string // 可选,默认 https://api.day.app
}
// BarkProvider 实现了通过 Bark 发送 iOS 推送。
type BarkProvider struct {
deviceKey string
server string
client *http.Client
}
// NewBarkProvider 创建 Bark 推送 Provider。
// DeviceKey 为空时返回 ErrProviderConfigInvalid。
func NewBarkProvider(cfg BarkConfig) (*BarkProvider, error) {
if strings.TrimSpace(cfg.DeviceKey) == "" {
return nil, ErrProviderConfigInvalid
}
server := strings.TrimSpace(cfg.Server)
if server == "" {
server = defaultBarkServer
}
server = strings.TrimRight(server, "/")
return &BarkProvider{
deviceKey: strings.TrimSpace(cfg.DeviceKey),
server: server,
client: &http.Client{Timeout: 10 * time.Second},
}, nil
}
func (p *BarkProvider) Name() string { return "bark" }
func (p *BarkProvider) Send(ctx context.Context, msg Message) error {
title := url.PathEscape(msg.Title)
body := url.PathEscape(msg.Content)
reqURL := fmt.Sprintf("%s/%s/%s/%s", p.server, p.deviceKey, title, body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil)
if err != nil {
return fmt.Errorf("bark: create request: %w", err)
}
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("bark: send failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("bark: http %d: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
@@ -1,12 +0,0 @@
package push
import "context"
// NoopProvider 是一个空实现,未配置任何推送渠道时使用。
type NoopProvider struct{}
func NewNoopProvider() *NoopProvider { return &NoopProvider{} }
func (p *NoopProvider) Name() string { return "noop" }
func (p *NoopProvider) Send(_ context.Context, _ Message) error { return nil }
@@ -1,23 +0,0 @@
package push
import (
"context"
"errors"
)
var ErrProviderConfigInvalid = errors.New("push provider config invalid")
// Message 描述一条待发送的推送消息。
type Message struct {
Title string
Content string
}
// Provider 是站外推送渠道的统一接口。
// 实现方负责具体的 HTTP 调用(Bark、WPush 等)。
type Provider interface {
// Name 返回渠道标识,用于日志。
Name() string
// Send 发送一条推送消息。失败时返回 error。
Send(ctx context.Context, msg Message) error
}
@@ -1,73 +0,0 @@
package push
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const wpushAPIURL = "https://api.wpush.cn/api/v1/send"
// WPushConfig 是 WPush 推送的配置。
type WPushConfig struct {
APIKey string
}
// WPushProvider 实现了通过 WPush 发送推送。
// 支持微信公众号、飞书、钉钉、企业微信等多种渠道,取决于用户在 WPush 侧的配置。
type WPushProvider struct {
apiKey string
client *http.Client
}
// NewWPushProvider 创建 WPush 推送 Provider。
// APIKey 为空时返回 ErrProviderConfigInvalid。
func NewWPushProvider(cfg WPushConfig) (*WPushProvider, error) {
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, ErrProviderConfigInvalid
}
return &WPushProvider{
apiKey: strings.TrimSpace(cfg.APIKey),
client: &http.Client{Timeout: 10 * time.Second},
}, nil
}
func (p *WPushProvider) Name() string { return "wpush" }
func (p *WPushProvider) Send(ctx context.Context, msg Message) error {
form := url.Values{}
form.Set("apikey", p.apiKey)
form.Set("title", msg.Title)
form.Set("content", msg.Content)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, wpushAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("wpush: create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("wpush: send failed: %w", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return fmt.Errorf("wpush: parse response: %w (body: %s)", err, string(bodyBytes))
}
if result.Code != 0 {
return fmt.Errorf("wpush: api error code=%d: %s", result.Code, result.Message)
}
return nil
}
-104
View File
@@ -1,104 +0,0 @@
package sms
import (
"context"
"encoding/json"
"fmt"
"strings"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
dysms "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
"github.com/alibabacloud-go/tea/dara"
"go.uber.org/zap"
)
type AliyunConfig struct {
AccessKeyID string
AccessKeySecret string
Endpoint string
SignName string
LoginTemplateCode string
}
type AliyunProvider struct {
client *dysms.Client
signName string
loginTemplateCode string
log *zap.Logger
}
func NewAliyunProvider(cfg AliyunConfig, log *zap.Logger) (*AliyunProvider, error) {
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
if cfg.Endpoint == "" {
cfg.Endpoint = "dysmsapi.aliyuncs.com"
}
if strings.TrimSpace(cfg.AccessKeyID) == "" ||
strings.TrimSpace(cfg.AccessKeySecret) == "" ||
strings.TrimSpace(cfg.SignName) == "" ||
strings.TrimSpace(cfg.LoginTemplateCode) == "" {
return nil, ErrProviderConfigInvalid
}
client, err := dysms.NewClient(&openapi.Config{
AccessKeyId: dara.String(strings.TrimSpace(cfg.AccessKeyID)),
AccessKeySecret: dara.String(strings.TrimSpace(cfg.AccessKeySecret)),
Endpoint: dara.String(cfg.Endpoint),
})
if err != nil {
return nil, err
}
return &AliyunProvider{
client: client,
signName: strings.TrimSpace(cfg.SignName),
loginTemplateCode: strings.TrimSpace(cfg.LoginTemplateCode),
log: log,
}, nil
}
func (p *AliyunProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
params, err := json.Marshal(map[string]string{"code": code})
if err != nil {
return err
}
req := &dysms.SendSmsRequest{}
req.SetPhoneNumbers(phone)
req.SetSignName(p.signName)
req.SetTemplateCode(p.loginTemplateCode)
req.SetTemplateParam(string(params))
resp, err := p.client.SendSmsWithContext(ctx, req, &dara.RuntimeOptions{})
if err != nil {
return fmt.Errorf("aliyun sms send failed: %w", err)
}
if resp == nil || resp.Body == nil {
return fmt.Errorf("aliyun sms empty response")
}
resultCode := dara.StringValue(resp.Body.Code)
if resultCode != "OK" {
resultMessage := dara.StringValue(resp.Body.Message)
providerErr := &ProviderError{Code: resultCode, Message: resultMessage}
if resultCode == "isv.BUSINESS_LIMIT_CONTROL" {
providerErr.Err = ErrProviderRateLimited
}
return providerErr
}
if p.log != nil {
p.log.Debug(
"短信发送成功",
zap.String("phone", maskPhone(phone)),
zap.String("provider_request_id", dara.StringValue(resp.Body.RequestId)),
)
}
return nil
}
func maskPhone(phone string) string {
if len(phone) < 7 {
return phone
}
return phone[:3] + "****" + phone[len(phone)-4:]
}
-25
View File
@@ -1,25 +0,0 @@
package sms
import (
"context"
"go.uber.org/zap"
)
type MockProvider struct {
log *zap.Logger
}
func NewMockProvider(log *zap.Logger) *MockProvider {
return &MockProvider{log: log}
}
func (p *MockProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
if err := ctx.Err(); err != nil {
return err
}
if p.log != nil {
p.log.Info("本地短信验证码", zap.String("phone", maskPhone(phone)), zap.String("code", code))
}
return nil
}
@@ -1,40 +0,0 @@
package sms
import (
"context"
"errors"
"fmt"
)
var (
ErrProviderConfigInvalid = errors.New("sms provider config invalid")
ErrProviderRateLimited = errors.New("sms provider rate limited")
)
type Provider interface {
SendLoginCode(ctx context.Context, phone string, code string) error
}
type ProviderError struct {
Code string
Message string
Err error
}
func (e *ProviderError) Error() string {
if e.Message != "" {
return fmt.Sprintf("sms provider rejected: %s: %s", e.Code, e.Message)
}
if e.Code != "" {
return fmt.Sprintf("sms provider rejected: %s", e.Code)
}
return "sms provider rejected"
}
func (e *ProviderError) Unwrap() error {
return e.Err
}
func IsProviderRateLimited(err error) bool {
return errors.Is(err, ErrProviderRateLimited)
}
@@ -1,29 +0,0 @@
package sms
import (
"errors"
"testing"
)
func TestIsProviderRateLimited(t *testing.T) {
err := &ProviderError{
Code: "isv.BUSINESS_LIMIT_CONTROL",
Message: "触发小时级流控Permits:5",
Err: ErrProviderRateLimited,
}
if !IsProviderRateLimited(err) {
t.Fatalf("expected provider rate limit error")
}
if !errors.Is(err, ErrProviderRateLimited) {
t.Fatalf("expected errors.Is to match provider rate limit")
}
}
func TestProviderErrorMessage(t *testing.T) {
err := &ProviderError{Code: "isv.INVALID_PARAMETERS", Message: "参数错误"}
if got := err.Error(); got != "sms provider rejected: isv.INVALID_PARAMETERS: 参数错误" {
t.Fatalf("unexpected error message: %s", got)
}
}
@@ -1,21 +0,0 @@
package sms
import "context"
type UnavailableProvider struct {
err error
}
func NewUnavailableProvider(err error) *UnavailableProvider {
return &UnavailableProvider{err: err}
}
func (p *UnavailableProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
if err := ctx.Err(); err != nil {
return err
}
if p.err != nil {
return p.err
}
return ErrProviderConfigInvalid
}
@@ -1,167 +0,0 @@
package fileuploadcleanup
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"time"
"hfb_sys/backend/internal/model"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
const (
cleanupLockKey = "hfb:job:file-upload-cleanup:lock"
cleanupInterval = 24 * time.Hour
cleanupRetention = 30 * 24 * time.Hour
cleanupBatchSize = 200
)
// Job 清理长期未关联业务记录的上传归属,避免临时草稿记录无限增长。
type Job struct {
db *gorm.DB
redis *redis.Client
logger *zap.Logger
instanceID string
}
func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job {
if logger == nil {
logger = zap.NewNop()
}
return &Job{db: db, redis: redisClient, logger: logger, instanceID: newInstanceID()}
}
func (j *Job) Start(ctx context.Context) {
if j == nil || j.db == nil {
return
}
go j.loop(ctx)
}
func (j *Job) loop(ctx context.Context) {
j.run(ctx, time.Now())
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
j.logger.Debug("临时文件归属清理任务已停止")
return
case now := <-ticker.C:
j.run(ctx, now)
}
}
}
func (j *Job) run(ctx context.Context, now time.Time) {
release, ok := j.acquireLock(ctx)
if !ok {
return
}
defer release()
deleted, err := j.cleanup(ctx, now)
if err != nil {
j.logger.Warn("临时文件归属清理失败", zap.Error(err))
return
}
if deleted > 0 {
j.logger.Info("已清理未关联临时文件归属", zap.Int("count", deleted))
}
}
func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
if j.redis == nil {
return func() {}, true
}
ok, err := j.redis.SetNX(ctx, cleanupLockKey, j.instanceID, 10*time.Minute).Result()
if err != nil {
j.logger.Warn("临时文件归属清理任务获取锁失败", zap.Error(err))
return func() {}, true
}
if !ok {
return nil, false
}
return func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
if err := script.Run(releaseCtx, j.redis, []string{cleanupLockKey}, j.instanceID).Err(); err != nil {
j.logger.Warn("临时文件归属清理任务释放锁失败", zap.Error(err))
}
}, true
}
func (j *Job) cleanup(ctx context.Context, now time.Time) (int, error) {
cutoff := now.Add(-cleanupRetention)
lastID := uint64(0)
deleted := 0
for {
var records []model.FileUploadOwner
if err := j.db.WithContext(ctx).
Where("id > ? AND created_at < ?", lastID, cutoff).
Order("id ASC").
Limit(cleanupBatchSize).
Find(&records).Error; err != nil {
return deleted, err
}
if len(records) == 0 {
return deleted, nil
}
for _, record := range records {
lastID = record.ID
referenced, err := j.isReferenced(ctx, record.ObjectKey)
if err != nil {
return deleted, err
}
if referenced {
continue
}
result := j.db.WithContext(ctx).
Where("id = ? AND created_at < ?", record.ID, cutoff).
Delete(&model.FileUploadOwner{})
if result.Error != nil {
return deleted, result.Error
}
deleted += int(result.RowsAffected)
}
}
}
func (j *Job) isReferenced(ctx context.Context, key string) (bool, error) {
encodedKey := url.QueryEscape(key)
queries := []struct {
sql string
args []any
}{
{sql: "SELECT COUNT(1) FROM game_accounts WHERE INSTR(screenshot_urls, ?) > 0 OR INSTR(screenshot_urls, ?) > 0", args: []any{key, encodedKey}},
{sql: "SELECT COUNT(1) FROM order_checkouts WHERE INSTR(evidence_urls, ?) > 0 OR INSTR(evidence_urls, ?) > 0", args: []any{key, encodedKey}},
{sql: "SELECT COUNT(1) FROM disputes WHERE INSTR(evidence_urls, ?) > 0 OR INSTR(evidence_urls, ?) > 0", args: []any{key, encodedKey}},
{sql: "SELECT COUNT(1) FROM handoff_records WHERE INSTR(attachment_urls, ?) > 0 OR INSTR(attachment_urls, ?) > 0", args: []any{key, encodedKey}},
{sql: "SELECT COUNT(1) FROM chat_messages WHERE INSTR(attachment_urls, ?) > 0 OR INSTR(attachment_urls, ?) > 0", args: []any{key, encodedKey}},
{sql: "SELECT COUNT(1) FROM user_payment_accounts WHERE INSTR(certificate_urls, ?) > 0 OR INSTR(certificate_urls, ?) > 0", args: []any{key, encodedKey}},
}
for _, query := range queries {
var count int64
if err := j.db.WithContext(ctx).Raw(query.sql, query.args...).Scan(&count).Error; err != nil {
return false, err
}
if count > 0 {
return true, nil
}
}
return false, nil
}
func newInstanceID() string {
value := make([]byte, 8)
if _, err := rand.Read(value); err != nil {
return fmt.Sprintf("file-cleanup-%d", time.Now().UnixNano())
}
return hex.EncodeToString(value)
}
@@ -1,77 +0,0 @@
package fileuploadcleanup
import (
"context"
"testing"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestCleanupOnlyDeletesExpiredUnreferencedUploads(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&model.FileUploadOwner{}); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
createReferenceTables(t, db)
now := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC)
old := now.Add(-31 * 24 * time.Hour)
recent := now.Add(-29 * 24 * time.Hour)
records := []model.FileUploadOwner{
{UserID: 1, ObjectKey: "listing/orphan.jpg", CreatedAt: old},
{UserID: 1, ObjectKey: "listing/referenced.jpg", CreatedAt: old},
{UserID: 1, ObjectKey: "listing/recent.jpg", CreatedAt: recent},
}
if err := db.Create(&records).Error; err != nil {
t.Fatalf("create upload owners error = %v", err)
}
if err := db.Exec("INSERT INTO game_accounts (id, screenshot_urls) VALUES (1, ?)", `["/api/files/object?key=listing%2Freferenced.jpg"]`).Error; err != nil {
t.Fatalf("create referenced account error = %v", err)
}
job := New(db, nil, nil)
deleted, err := job.cleanup(context.Background(), now)
if err != nil {
t.Fatalf("cleanup() error = %v", err)
}
if deleted != 1 {
t.Fatalf("cleanup() deleted = %d, want 1", deleted)
}
var remaining []model.FileUploadOwner
if err := db.Order("id ASC").Find(&remaining).Error; err != nil {
t.Fatalf("load remaining uploads error = %v", err)
}
if len(remaining) != 2 || remaining[0].ObjectKey != "listing/referenced.jpg" || remaining[1].ObjectKey != "listing/recent.jpg" {
t.Fatalf("unexpected remaining uploads: %#v", remaining)
}
}
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open sqlite error = %v", err)
}
return db
}
func createReferenceTables(t *testing.T, db *gorm.DB) {
t.Helper()
statements := []string{
"CREATE TABLE game_accounts (id INTEGER PRIMARY KEY, screenshot_urls TEXT)",
"CREATE TABLE order_checkouts (id INTEGER PRIMARY KEY, evidence_urls TEXT)",
"CREATE TABLE disputes (id INTEGER PRIMARY KEY, evidence_urls TEXT)",
"CREATE TABLE handoff_records (id INTEGER PRIMARY KEY, attachment_urls TEXT)",
"CREATE TABLE chat_messages (id INTEGER PRIMARY KEY, attachment_urls TEXT)",
"CREATE TABLE user_payment_accounts (id INTEGER PRIMARY KEY, certificate_urls TEXT)",
}
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("create reference table error = %v", err)
}
}
}
+22 -366
View File
@@ -2,121 +2,40 @@ package ordertimeout
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"strconv" "strconv"
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/adminnotification"
"hfb_sys/backend/internal/modules/chat"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
const orderTimeoutLockKey = "hfb:job:ordertimeout:lock"
const (
defaultRenterRetentionDaysAfterOrderEnd = 5
minRenterRetentionDaysAfterOrderEnd = 3
maxRenterRetentionDaysAfterOrderEnd = 7
)
type Job struct { type Job struct {
db *gorm.DB db *gorm.DB
redis *redis.Client logger *zap.Logger
logger *zap.Logger interval time.Duration
interval time.Duration
instanceID string
} }
type thresholds struct { type thresholds struct {
PendingPaymentTimeoutMinutes int
OwnerSubmitTimeoutMinutes int OwnerSubmitTimeoutMinutes int
RenterConfirmTimeoutMinutes int RenterConfirmTimeoutMinutes int
ReturnOverdueGraceMinutes int ReturnOverdueGraceMinutes int
OwnerReturnConfirmTimeoutMinutes int OwnerReturnConfirmTimeoutMinutes int
RenterRetentionDaysAfterOrderEnd int
} }
func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job { func New(db *gorm.DB, logger *zap.Logger) *Job {
return &Job{ return &Job{
db: db, db: db,
redis: redisClient, logger: logger,
logger: logger, interval: time.Minute,
interval: time.Minute,
instanceID: newInstanceID(),
} }
} }
func isPlatformManagedOrder(order *model.RentalOrder) bool {
if order == nil {
return false
}
return order.HandoffMode == "platform" || order.SettlementMode == "platform_managed"
}
func appendManagedTimeoutNotification(tx *gorm.DB, order *model.RentalOrder, title string, content string) error {
if order == nil || order.ManagedAdminID == nil || *order.ManagedAdminID == 0 {
return nil
}
return adminnotification.Append(tx, adminnotification.Entry{
AdminUserID: *order.ManagedAdminID,
Type: "timeout",
Title: title,
Content: content,
})
}
// acquireLock 通过 Redis 分布式锁确保同一时刻只有一个实例执行超时扫描。
// 未配置 Redis 时直接执行;Redis 出错时降级执行(事务内行锁与状态二次校验可兜底,不会写坏数据)。
func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
if j.redis == nil {
return func() {}, true
}
ok, err := j.redis.SetNX(ctx, orderTimeoutLockKey, j.instanceID, j.lockTTL()).Result()
if err != nil {
j.logger.Warn("订单超时任务获取锁失败,将以单实例模式执行", zap.Error(err))
return func() {}, true
}
if !ok {
return nil, false
}
return j.releaseLock, true
}
// releaseLock 仅在锁仍归本实例时释放,避免误删其他实例已续上的锁。
func (j *Job) releaseLock() {
if j.redis == nil {
return
}
relCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
if err := script.Run(relCtx, j.redis, []string{orderTimeoutLockKey}, j.instanceID).Err(); err != nil {
j.logger.Warn("订单超时任务释放锁失败", zap.Error(err))
}
}
func (j *Job) lockTTL() time.Duration {
return 2 * j.interval
}
func newInstanceID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("inst-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func (j *Job) Start(ctx context.Context) { func (j *Job) Start(ctx context.Context) {
if j == nil || j.db == nil { if j == nil || j.db == nil {
return return
@@ -131,7 +50,7 @@ func (j *Job) loop(ctx context.Context) {
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
j.logger.Debug("订单超时任务已停止") j.logger.Info("order timeout job stopped")
return return
case <-ticker.C: case <-ticker.C:
j.run(ctx) j.run(ctx)
@@ -140,58 +59,46 @@ func (j *Job) loop(ctx context.Context) {
} }
func (j *Job) run(ctx context.Context) { func (j *Job) run(ctx context.Context) {
release, ok := j.acquireLock(ctx)
if !ok {
return
}
defer release()
cfg, err := j.loadThresholds(ctx) cfg, err := j.loadThresholds(ctx)
if err != nil { if err != nil {
j.logger.Warn("订单超时任务加载配置失败", zap.Error(err)) j.logger.Warn("order timeout job config load failed", zap.Error(err))
return return
} }
now := time.Now() now := time.Now()
handlers := []func(context.Context, time.Time, thresholds) (int, error){ handlers := []func(context.Context, time.Time, thresholds) (int, error){
j.handlePendingPaymentTimeout,
j.handleOwnerSubmitTimeout, j.handleOwnerSubmitTimeout,
j.handleRenterConfirmTimeout, j.handleRenterConfirmTimeout,
j.handleReturnOverdue, j.handleReturnOverdue,
j.handleOwnerReturnConfirmTimeout, j.handleOwnerReturnConfirmTimeout,
j.handleEndedOrderRenterRetention,
} }
total := 0 total := 0
for _, handler := range handlers { for _, handler := range handlers {
count, err := handler(ctx, now, cfg) count, err := handler(ctx, now, cfg)
if err != nil { if err != nil {
j.logger.Warn("订单超时处理失败", zap.Error(err)) j.logger.Warn("order timeout handler failed", zap.Error(err))
continue continue
} }
total += count total += count
} }
if total > 0 { if total > 0 {
j.logger.Info("订单超时处理完成", zap.Int("count", total)) j.logger.Info("order timeout job processed orders", zap.Int("count", total))
} }
} }
func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) { func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
cfg := thresholds{ cfg := thresholds{
PendingPaymentTimeoutMinutes: 15,
OwnerSubmitTimeoutMinutes: 30, OwnerSubmitTimeoutMinutes: 30,
RenterConfirmTimeoutMinutes: 30, RenterConfirmTimeoutMinutes: 30,
ReturnOverdueGraceMinutes: 10, ReturnOverdueGraceMinutes: 10,
OwnerReturnConfirmTimeoutMinutes: 120, OwnerReturnConfirmTimeoutMinutes: 120,
RenterRetentionDaysAfterOrderEnd: defaultRenterRetentionDaysAfterOrderEnd,
} }
var rows []model.SystemConfig var rows []model.SystemConfig
err := j.db.WithContext(ctx). err := j.db.WithContext(ctx).
Where("`key` IN ?", []string{ Where("`key` IN ?", []string{
"handoff.owner_submit_timeout_minutes", "handoff.owner_submit_timeout_minutes",
"handoff.renter_confirm_timeout_minutes", "handoff.renter_confirm_timeout_minutes",
"order.pending_payment_timeout_minutes",
"order.return_overdue_grace_minutes", "order.return_overdue_grace_minutes",
"handoff.owner_return_confirm_timeout_minutes", "handoff.owner_return_confirm_timeout_minutes",
"chat.renter_retention_days_after_order_end",
}). }).
Find(&rows).Error Find(&rows).Error
if err != nil { if err != nil {
@@ -199,7 +106,7 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
} }
for _, row := range rows { for _, row := range rows {
value, err := strconv.Atoi(row.Value) value, err := strconv.Atoi(row.Value)
if err != nil || value < 0 { if err != nil || value <= 0 {
continue continue
} }
switch row.Key { switch row.Key {
@@ -207,137 +114,19 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
cfg.OwnerSubmitTimeoutMinutes = value cfg.OwnerSubmitTimeoutMinutes = value
case "handoff.renter_confirm_timeout_minutes": case "handoff.renter_confirm_timeout_minutes":
cfg.RenterConfirmTimeoutMinutes = value cfg.RenterConfirmTimeoutMinutes = value
case "order.pending_payment_timeout_minutes":
cfg.PendingPaymentTimeoutMinutes = value
case "order.return_overdue_grace_minutes": case "order.return_overdue_grace_minutes":
cfg.ReturnOverdueGraceMinutes = value cfg.ReturnOverdueGraceMinutes = value
case "handoff.owner_return_confirm_timeout_minutes": case "handoff.owner_return_confirm_timeout_minutes":
cfg.OwnerReturnConfirmTimeoutMinutes = value cfg.OwnerReturnConfirmTimeoutMinutes = value
case "chat.renter_retention_days_after_order_end":
cfg.RenterRetentionDaysAfterOrderEnd = clampRenterRetentionDays(value)
} }
} }
return cfg, nil return cfg, nil
} }
func clampRenterRetentionDays(value int) int {
if value < minRenterRetentionDaysAfterOrderEnd {
return minRenterRetentionDaysAfterOrderEnd
}
if value > maxRenterRetentionDaysAfterOrderEnd {
return maxRenterRetentionDaysAfterOrderEnd
}
return value
}
func hasActiveSiblingOrder(tx *gorm.DB, order model.RentalOrder) (bool, error) {
var count int64
err := tx.Model(&model.RentalOrder{}).
Where("id <> ? AND (listing_id = ? OR account_id = ?) AND status NOT IN ?",
order.ID,
order.ListingID,
order.AccountID,
[]string{"completed", "cancelled", "closed"},
).
Count(&count).Error
return count > 0, err
}
func (j *Job) handlePendingPaymentTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
if cfg.PendingPaymentTimeoutMinutes <= 0 {
return 0, nil
}
var rows []model.RentalOrder
err := j.db.WithContext(ctx).
Where("status = ? AND created_at <= ?", "pending_payment", now.Add(-time.Duration(cfg.PendingPaymentTimeoutMinutes)*time.Minute)).
Order("id ASC").
Limit(100).
Find(&rows).Error
if err != nil {
return 0, err
}
count := 0
for _, row := range rows {
if err := j.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, row.ID).Error; err != nil {
return err
}
if order.Status != "pending_payment" {
return nil
}
before := snapshot(&order)
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
return err
}
order.Status = "cancelled"
order.HandoffStatus = "cancelled"
active, err := hasActiveSiblingOrder(tx, order)
if err != nil {
return err
}
if !active {
listing.InTransaction = false
}
orderID := order.ID
if err := closePendingOrderPayments(tx, order.ID, "order_timeout"); err != nil {
return err
}
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "timeout",
Title: "订单支付超时",
Content: "订单未在规定时间内完成支付,已自动取消。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return err
}
if err := tx.Save(&order).Error; err != nil {
return err
}
if err := tx.Save(&listing).Error; err != nil {
return err
}
return appendAuditLog(tx, "order.timeout.pending_payment", order.ID, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"before": before,
"after": snapshot(&order),
})
}); err != nil {
return count, err
}
count++
}
return count, nil
}
// closePendingOrderPayments 在系统自动取消订单时同步关闭未完成支付单,保持订单和支付流水状态一致。
func closePendingOrderPayments(tx *gorm.DB, orderID uint64, source string) error {
raw, err := json.Marshal(map[string]string{
"source": source,
"reason": "订单已取消,关闭未完成支付单",
})
if err != nil {
return err
}
return tx.Model(&model.PaymentOrder{}).
Where("order_id = ? AND biz_type = ? AND status IN ?", orderID, "order_pay", []string{"created", "paying"}).
Updates(map[string]any{
"status": "closed",
"raw_response": datatypes.JSON(raw),
}).Error
}
func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
if cfg.OwnerSubmitTimeoutMinutes <= 0 {
return 0, nil
}
var rows []model.RentalOrder var rows []model.RentalOrder
err := j.db.WithContext(ctx). err := j.db.WithContext(ctx).
Where("status = ? AND handoff_status = ? AND COALESCE(handoff_started_at, created_at) <= ?", "pending_handoff", "pending_owner", now.Add(-time.Duration(cfg.OwnerSubmitTimeoutMinutes)*time.Minute)). Where("status = ? AND handoff_status = ? AND created_at <= ?", "pending_handoff", "pending_owner", now.Add(-time.Duration(cfg.OwnerSubmitTimeoutMinutes)*time.Minute)).
Order("id ASC"). Order("id ASC").
Limit(100). Limit(100).
Find(&rows).Error Find(&rows).Error
@@ -353,22 +142,6 @@ func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg t
before := snapshot(order) before := snapshot(order)
order.HandoffStatus = "owner_timeout" order.HandoffStatus = "owner_timeout"
orderID := order.ID orderID := order.ID
if isPlatformManagedOrder(order) {
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "timeout",
Title: "客服代交接超时",
Content: "客服未在规定时间内提交交接说明,你可以取消订单或发起申诉。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return "", err
}
if err := appendManagedTimeoutNotification(tx, order, "代管订单交接超时", "订单已超过交接时限,请尽快进入订单详情处理。"); err != nil {
return "", err
}
return before, nil
}
if err := notification.Append(tx, if err := notification.Append(tx,
notification.Entry{ notification.Entry{
UserID: order.RenterID, UserID: order.RenterID,
@@ -399,11 +172,6 @@ func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg t
} }
func (j *Job) handleRenterConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { func (j *Job) handleRenterConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
if cfg.RenterConfirmTimeoutMinutes <= 0 {
return 0, nil
}
deadline := now.Add(-time.Duration(cfg.RenterConfirmTimeoutMinutes) * time.Minute)
var rows []model.RentalOrder var rows []model.RentalOrder
err := j.db.WithContext(ctx). err := j.db.WithContext(ctx).
Where("status = ? AND handoff_status = ?", "pending_handoff", "pending_renter_confirm"). Where("status = ? AND handoff_status = ?", "pending_handoff", "pending_renter_confirm").
@@ -413,42 +181,15 @@ func (j *Job) handleRenterConfirmTimeout(ctx context.Context, now time.Time, cfg
if err != nil { if err != nil {
return 0, err return 0, err
} }
if len(rows) == 0 {
return 0, nil
}
// 批量查询所有订单的最新交接记录,减少 N+1 查询
orderIDs := make([]uint64, len(rows))
for i, row := range rows {
orderIDs[i] = row.ID
}
var handoffs []model.HandoffRecord
if err := j.db.WithContext(ctx).
Where("order_id IN ? AND type = ?", orderIDs, "owner_handoff").
Order("order_id ASC, id DESC").
Find(&handoffs).Error; err != nil {
return 0, err
}
// 构建 order_id -> 最新交接记录的映射
latestHandoff := make(map[uint64]model.HandoffRecord, len(handoffs))
for _, h := range handoffs {
if _, exists := latestHandoff[h.OrderID]; !exists {
latestHandoff[h.OrderID] = h
}
}
count := 0 count := 0
deadline := now.Add(-time.Duration(cfg.RenterConfirmTimeoutMinutes) * time.Minute)
for _, row := range rows { for _, row := range rows {
handoff, exists := latestHandoff[row.ID] var handoff model.HandoffRecord
if !exists { err := j.db.WithContext(ctx).
continue Where("order_id = ? AND type = ?", row.ID, "owner_handoff").
} Order("id DESC").
// 判断阶段开始时间:优先使用 HandoffStartedAt(管理员重置后刷新的时间) First(&handoff).Error
stageStart := handoff.CreatedAt if err != nil || handoff.CreatedAt.After(deadline) {
if row.HandoffStartedAt != nil && row.HandoffStartedAt.After(handoff.CreatedAt) {
stageStart = *row.HandoffStartedAt
}
if stageStart.After(deadline) {
continue continue
} }
if err := j.updateOrder(ctx, row.ID, "order.timeout.renter_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) { if err := j.updateOrder(ctx, row.ID, "order.timeout.renter_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) {
@@ -490,9 +231,8 @@ func (j *Job) handleRenterConfirmTimeout(ctx context.Context, now time.Time, cfg
func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresholds) (int, error) { func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
var rows []model.RentalOrder var rows []model.RentalOrder
overdueBefore := now.Add(-time.Duration(cfg.ReturnOverdueGraceMinutes) * time.Minute)
err := j.db.WithContext(ctx). err := j.db.WithContext(ctx).
Where(`status = ? AND rented_at IS NOT NULL AND TIMESTAMPADD(HOUR, COALESCE(NULLIF(estimated_duration_hours, 0), 24), rented_at) <= ?`, "renting", overdueBefore). Where("status = ? AND rent_end_at IS NOT NULL AND rent_end_at <= ?", "renting", now.Add(-time.Duration(cfg.ReturnOverdueGraceMinutes)*time.Minute)).
Order("id ASC"). Order("id ASC").
Limit(100). Limit(100).
Find(&rows).Error Find(&rows).Error
@@ -539,12 +279,9 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh
} }
func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
if cfg.OwnerReturnConfirmTimeoutMinutes <= 0 {
return 0, nil
}
var rows []model.RentalOrder var rows []model.RentalOrder
err := j.db.WithContext(ctx). err := j.db.WithContext(ctx).
Where("status = ? AND handoff_status = ? AND COALESCE(handoff_started_at, updated_at) <= ?", "pending_checkout_confirm", "pending_owner_checkout", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)). Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_checkout_confirm", "pending_owner_checkout", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)).
Order("id ASC"). Order("id ASC").
Limit(100). Limit(100).
Find(&rows).Error Find(&rows).Error
@@ -561,22 +298,6 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
order.Status = "abnormal" order.Status = "abnormal"
order.HandoffStatus = "owner_checkout_confirm_timeout" order.HandoffStatus = "owner_checkout_confirm_timeout"
orderID := order.ID orderID := order.ID
if isPlatformManagedOrder(order) {
if err := notification.Append(tx, notification.Entry{
UserID: order.RenterID,
Type: "timeout",
Title: "客服确认结账超时",
Content: "客服未在规定时间内确认结账,订单已进入客服复核状态。",
BizType: "order",
BizID: &orderID,
}); err != nil {
return "", err
}
if err := appendManagedTimeoutNotification(tx, order, "代管订单确认结账超时", "订单已超过确认结账时限,请尽快进入订单详情处理。"); err != nil {
return "", err
}
return before, nil
}
if err := notification.Append(tx, if err := notification.Append(tx,
notification.Entry{ notification.Entry{
UserID: order.RenterID, UserID: order.RenterID,
@@ -606,71 +327,6 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
return count, nil return count, nil
} }
func (j *Job) handleEndedOrderRenterRetention(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
retentionDays := clampRenterRetentionDays(cfg.RenterRetentionDaysAfterOrderEnd)
deadline := now.AddDate(0, 0, -retentionDays)
var rows []model.RentalOrder
err := j.db.WithContext(ctx).
Table("rental_orders AS o").
Select("o.*").
Joins("JOIN chat_conversations AS c ON c.listing_id = o.listing_id AND c.type = ?", chat.ConversationTypeListingGroup).
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id AND cp.participant_type = ? AND cp.participant_id = o.renter_id AND cp.role = ?", "user", "renter").
Where("o.status IN ?", []string{"completed", "cancelled", "closed"}).
Where("o.settled_at IS NOT NULL AND o.settled_at <= ?", deadline).
Order("o.id ASC").
Limit(100).
Find(&rows).Error
if err != nil {
return 0, err
}
count := 0
for _, row := range rows {
removed := false
if err := j.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, row.ID).Error; err != nil {
return err
}
if !isEndedOrderStatus(order.Status) {
return nil
}
if order.SettledAt == nil || order.SettledAt.After(deadline) {
return nil
}
var err error
removed, err = chat.RemoveRenterFromListingConversation(tx, order.ListingID, order.RenterID)
if err != nil {
return err
}
if !removed {
return nil
}
return appendAuditLog(tx, "chat.renter_retention.remove", order.ID, map[string]any{
"order_id": order.ID,
"order_no": order.OrderNo,
"listing_id": order.ListingID,
"renter_id": order.RenterID,
"retention_days": retentionDays,
})
}); err != nil {
return count, err
}
if removed {
count++
}
}
return count, nil
}
func isEndedOrderStatus(status string) bool {
switch status {
case "completed", "cancelled", "closed":
return true
default:
return false
}
}
func (j *Job) updateOrder(ctx context.Context, orderID uint64, action string, fn func(*gorm.DB, *model.RentalOrder) (string, error)) error { func (j *Job) updateOrder(ctx context.Context, orderID uint64, action string, fn func(*gorm.DB, *model.RentalOrder) (string, error)) error {
return j.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return j.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder var order model.RentalOrder
@@ -1,267 +0,0 @@
package ordertimeout
import (
"testing"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chat"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupOrderTimeoutTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("无法创建测试数据库: %v", err)
}
if err := db.AutoMigrate(
&model.RentalOrder{},
&model.HandoffRecord{},
&model.Notification{},
&model.AdminNotification{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatMessage{},
&model.SystemConfig{},
&model.AuditLog{},
); err != nil {
t.Fatalf("数据库迁移失败: %v", err)
}
return db
}
func TestHandleEndedOrderRenterRetention(t *testing.T) {
db := setupOrderTimeoutTestDB(t)
job := New(db, nil, zap.NewNop())
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
oldSettledAt := now.AddDate(0, 0, -6)
recentSettledAt := now.AddDate(0, 0, -2)
oldOrder := model.RentalOrder{
OrderNo: "ORDER-OLD",
ListingID: 101,
AccountID: 201,
OwnerID: 301,
RenterID: 401,
Status: "completed",
SettledAt: &oldSettledAt,
}
recentOrder := model.RentalOrder{
OrderNo: "ORDER-RECENT",
ListingID: 102,
AccountID: 202,
OwnerID: 302,
RenterID: 402,
Status: "completed",
SettledAt: &recentSettledAt,
}
if err := db.Create(&oldOrder).Error; err != nil {
t.Fatalf("创建旧订单失败: %v", err)
}
if err := db.Create(&recentOrder).Error; err != nil {
t.Fatalf("创建新订单失败: %v", err)
}
oldConv := model.ChatConversation{
ListingID: &oldOrder.ListingID,
Type: chat.ConversationTypeListingGroup,
Title: "旧发布群",
Status: "active",
}
recentConv := model.ChatConversation{
ListingID: &recentOrder.ListingID,
Type: chat.ConversationTypeListingGroup,
Title: "新发布群",
Status: "active",
}
if err := db.Create(&oldConv).Error; err != nil {
t.Fatalf("创建旧会话失败: %v", err)
}
if err := db.Create(&recentConv).Error; err != nil {
t.Fatalf("创建新会话失败: %v", err)
}
participants := []model.ChatParticipant{
{ConversationID: oldConv.ID, ParticipantType: "user", ParticipantID: oldOrder.RenterID, Role: "renter", JoinedAt: oldSettledAt},
{ConversationID: recentConv.ID, ParticipantType: "user", ParticipantID: recentOrder.RenterID, Role: "renter", JoinedAt: recentSettledAt},
}
if err := db.Create(&participants).Error; err != nil {
t.Fatalf("创建成员失败: %v", err)
}
count, err := job.handleEndedOrderRenterRetention(t.Context(), now, thresholds{
RenterRetentionDaysAfterOrderEnd: 5,
})
if err != nil {
t.Fatalf("清退任务失败: %v", err)
}
if count != 1 {
t.Fatalf("清退数量 = %d, want 1", count)
}
var oldCount int64
if err := db.Model(&model.ChatParticipant{}).
Where("conversation_id = ? AND participant_id = ? AND role = ?", oldConv.ID, oldOrder.RenterID, "renter").
Count(&oldCount).Error; err != nil {
t.Fatalf("统计旧租客失败: %v", err)
}
if oldCount != 0 {
t.Fatalf("旧租客成员数 = %d, want 0", oldCount)
}
var recentCount int64
if err := db.Model(&model.ChatParticipant{}).
Where("conversation_id = ? AND participant_id = ? AND role = ?", recentConv.ID, recentOrder.RenterID, "renter").
Count(&recentCount).Error; err != nil {
t.Fatalf("统计新租客失败: %v", err)
}
if recentCount != 1 {
t.Fatalf("新租客成员数 = %d, want 1", recentCount)
}
var auditCount int64
if err := db.Model(&model.AuditLog{}).
Where("action = ?", "chat.renter_retention.remove").
Count(&auditCount).Error; err != nil {
t.Fatalf("统计清退审计失败: %v", err)
}
if auditCount != 1 {
t.Fatalf("清退审计数 = %d, want 1", auditCount)
}
count, err = job.handleEndedOrderRenterRetention(t.Context(), now, thresholds{
RenterRetentionDaysAfterOrderEnd: 5,
})
if err != nil {
t.Fatalf("重复清退任务失败: %v", err)
}
if count != 0 {
t.Fatalf("重复清退数量 = %d, want 0", count)
}
if err := db.Model(&model.AuditLog{}).
Where("action = ?", "chat.renter_retention.remove").
Count(&auditCount).Error; err != nil {
t.Fatalf("重复统计清退审计失败: %v", err)
}
if auditCount != 1 {
t.Fatalf("重复清退审计数 = %d, want 1", auditCount)
}
}
func TestHandleRenterConfirmTimeoutUsesRefreshedStageTime(t *testing.T) {
db := setupOrderTimeoutTestDB(t)
job := New(db, nil, zap.NewNop())
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
oldHandoffAt := now.Add(-2 * time.Hour)
refreshedAt := now.Add(-5 * time.Minute)
order := model.RentalOrder{
OrderNo: "ORDER-RENTER-CONFIRM-RESET",
ListingID: 101,
AccountID: 201,
OwnerID: 301,
RenterID: 401,
Status: "pending_handoff",
HandoffStatus: "pending_renter_confirm",
HandoffStartedAt: &refreshedAt,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
record := model.HandoffRecord{
OrderID: order.ID,
FromUserID: order.OwnerID,
ToUserID: order.RenterID,
Type: "owner_handoff",
Content: "交接说明",
CreatedAt: oldHandoffAt,
}
if err := db.Create(&record).Error; err != nil {
t.Fatalf("创建交接记录失败: %v", err)
}
count, err := job.handleRenterConfirmTimeout(t.Context(), now, thresholds{
RenterConfirmTimeoutMinutes: 30,
})
if err != nil {
t.Fatalf("确认收号超时任务失败: %v", err)
}
if count != 0 {
t.Fatalf("处理数量 = %d, want 0", count)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if saved.Status != "pending_handoff" || saved.HandoffStatus != "pending_renter_confirm" {
t.Fatalf("订单状态 = %s/%s, want pending_handoff/pending_renter_confirm", saved.Status, saved.HandoffStatus)
}
}
func TestHandleOwnerReturnConfirmTimeoutUsesStageTime(t *testing.T) {
db := setupOrderTimeoutTestDB(t)
job := New(db, nil, zap.NewNop())
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
oldUpdatedAt := now.Add(-2 * time.Hour)
refreshedAt := now.Add(-5 * time.Minute)
order := model.RentalOrder{
OrderNo: "ORDER-CHECKOUT-CONFIRM-RESET",
ListingID: 102,
AccountID: 202,
OwnerID: 302,
RenterID: 402,
Status: "pending_checkout_confirm",
HandoffStatus: "pending_owner_checkout",
HandoffStartedAt: &refreshedAt,
UpdatedAt: oldUpdatedAt,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
if err := db.Model(&model.RentalOrder{}).Where("id = ?", order.ID).Update("updated_at", oldUpdatedAt).Error; err != nil {
t.Fatalf("回写旧更新时间失败: %v", err)
}
count, err := job.handleOwnerReturnConfirmTimeout(t.Context(), now, thresholds{
OwnerReturnConfirmTimeoutMinutes: 30,
})
if err != nil {
t.Fatalf("号主确认结账超时任务失败: %v", err)
}
if count != 0 {
t.Fatalf("处理数量 = %d, want 0", count)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if saved.Status != "pending_checkout_confirm" || saved.HandoffStatus != "pending_owner_checkout" {
t.Fatalf("订单状态 = %s/%s, want pending_checkout_confirm/pending_owner_checkout", saved.Status, saved.HandoffStatus)
}
}
func TestClampRenterRetentionDays(t *testing.T) {
tests := []struct {
value int
want int
}{
{value: 1, want: 3},
{value: 5, want: 5},
{value: 9, want: 7},
}
for _, tt := range tests {
if got := clampRenterRetentionDays(tt.value); got != tt.want {
t.Fatalf("clampRenterRetentionDays(%d) = %d, want %d", tt.value, got, tt.want)
}
}
}
-326
View File
@@ -1,326 +0,0 @@
package refundretry
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/payment"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
const (
refundRetryLockKey = "hfb:job:refundretry:lock"
maxRetryCount = 10
baseRetryBackoff = 5 * time.Minute
maxRetryBackoff = time.Hour
manualWarnThreshold = 24 * time.Hour
)
type Job struct {
db *gorm.DB
redis *redis.Client
logger *zap.Logger
payments *payment.Repository
interval time.Duration
instanceID string
}
func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger, payments *payment.Repository) *Job {
return &Job{
db: db,
redis: redisClient,
logger: logger,
payments: payments,
interval: 2 * time.Minute,
instanceID: newInstanceID(),
}
}
// acquireLock 通过 Redis 分布式锁确保同一时刻只有一个实例执行退款补偿。
// 未配置 Redis 时直接执行;退款同步本身按退款单精确处理,可容忍短时间重复扫描。
func (j *Job) acquireLock(ctx context.Context) (func(), bool) {
if j.redis == nil {
return func() {}, true
}
ok, err := j.redis.SetNX(ctx, refundRetryLockKey, j.instanceID, j.lockTTL()).Result()
if err != nil {
j.logger.Warn("退款重试任务获取锁失败,将以单实例模式执行", zap.Error(err))
return func() {}, true
}
if !ok {
return nil, false
}
return j.releaseLock, true
}
// releaseLock 仅在锁仍归本实例时释放,避免误删其他实例的锁。
func (j *Job) releaseLock() {
if j.redis == nil {
return
}
relCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
if err := script.Run(relCtx, j.redis, []string{refundRetryLockKey}, j.instanceID).Err(); err != nil {
j.logger.Warn("退款重试任务释放锁失败", zap.Error(err))
}
}
func (j *Job) lockTTL() time.Duration {
return 2 * j.interval
}
func newInstanceID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("inst-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func (j *Job) Start(ctx context.Context) {
if j == nil || j.db == nil || j.payments == nil {
return
}
go j.loop(ctx)
}
func (j *Job) loop(ctx context.Context) {
j.run(ctx)
ticker := time.NewTicker(j.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
j.logger.Debug("退款重试任务已停止")
return
case <-ticker.C:
j.run(ctx)
}
}
}
func (j *Job) run(ctx context.Context) {
release, ok := j.acquireLock(ctx)
if !ok {
return
}
defer release()
now := time.Now()
processed, err := j.syncRefundPayments(ctx, now)
if err != nil {
j.logger.Warn("退款重试任务同步失败", zap.Error(err))
}
missing, rebuilt, err := j.warnMissingRefundOrders(ctx, now)
if err != nil {
j.logger.Warn("退款重试任务扫描缺失退款单失败", zap.Error(err))
}
if processed > 0 || missing > 0 {
j.logger.Info("退款重试任务处理完成",
zap.Int("processed", processed),
zap.Int("missing_refund_orders", missing),
zap.Int("rebuilt_arbitration_refunds", rebuilt),
)
}
}
func (j *Job) syncRefundPayments(ctx context.Context, now time.Time) (int, error) {
var rows []model.PaymentOrder
err := j.db.WithContext(ctx).
Where("biz_type IN ? AND status IN ? AND updated_at <= ? AND retry_count < ? AND (next_retry_at IS NULL OR next_retry_at <= ?)",
payment.RefundBizTypes(), []string{"refunding", "failed"}, now.Add(-5*time.Minute), maxRetryCount, now).
Order("id ASC").
Limit(100).
Find(&rows).Error
if err != nil {
return 0, err
}
processed := 0
for _, row := range rows {
if _, err := j.payments.SyncRefundStatusByPaymentID(ctx, row.ID); err != nil {
if markErr := j.markRetryFailed(ctx, row, now); markErr != nil {
j.logger.Warn("退款重试失败状态保存失败",
zap.Uint64("payment_id", row.ID),
zap.Uint64("order_id", row.OrderID),
zap.Error(markErr),
)
}
j.logger.Warn("退款状态同步失败",
zap.Uint64("payment_id", row.ID),
zap.Uint64("order_id", row.OrderID),
zap.String("biz_type", row.BizType),
zap.String("status", row.Status),
zap.Int("retry_count", row.RetryCount+1),
zap.Error(err),
)
continue
}
if err := j.resetRetry(ctx, row.ID); err != nil {
j.logger.Warn("退款重试计数重置失败",
zap.Uint64("payment_id", row.ID),
zap.Uint64("order_id", row.OrderID),
zap.Error(err),
)
}
processed++
}
j.warnMaxRetryRefunds(ctx, now)
return processed, nil
}
func (j *Job) markRetryFailed(ctx context.Context, row model.PaymentOrder, now time.Time) error {
nextCount := row.RetryCount + 1
backoff := retryBackoff(nextCount)
nextRetryAt := now.Add(backoff)
updates := map[string]any{
"retry_count": nextCount,
"last_retry_at": now,
"next_retry_at": nextRetryAt,
}
if nextCount >= maxRetryCount {
updates["next_retry_at"] = nil
}
return j.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", row.ID).Updates(updates).Error
}
func (j *Job) resetRetry(ctx context.Context, paymentID uint64) error {
return j.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{
"retry_count": 0,
"last_retry_at": nil,
"next_retry_at": nil,
}).Error
}
func retryBackoff(retryCount int) time.Duration {
if retryCount <= 1 {
return baseRetryBackoff
}
backoff := baseRetryBackoff
for i := 1; i < retryCount; i++ {
backoff *= 2
if backoff >= maxRetryBackoff {
return maxRetryBackoff
}
}
return backoff
}
func (j *Job) warnMaxRetryRefunds(ctx context.Context, now time.Time) {
var rows []model.PaymentOrder
err := j.db.WithContext(ctx).
Where("biz_type IN ? AND status IN ? AND retry_count >= ? AND (last_retry_at IS NULL OR last_retry_at <= ?)",
payment.RefundBizTypes(), []string{"refunding", "failed"}, maxRetryCount, now.Add(-manualWarnThreshold)).
Order("id ASC").
Limit(50).
Find(&rows).Error
if err != nil {
j.logger.Warn("退款最大重试次数扫描失败", zap.Error(err))
return
}
if len(rows) == 0 {
return
}
// 汇总一条告警,避免每 2 分钟对同一批单刷屏;更新 last_retry_at 实现 24h 节流
ids := make([]uint64, 0, len(rows))
orderIDs := make([]uint64, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
orderIDs = append(orderIDs, row.OrderID)
}
j.logger.Warn("退款已达最大重试次数,需要人工处理",
zap.Int("count", len(rows)),
zap.Uint64s("payment_ids", ids),
zap.Uint64s("order_ids", orderIDs),
)
if err := j.db.WithContext(ctx).Model(&model.PaymentOrder{}).
Where("id IN ?", ids).
Update("last_retry_at", now).Error; err != nil {
j.logger.Warn("退款重试时间更新失败", zap.Error(err))
}
}
func (j *Job) warnMissingRefundOrders(ctx context.Context, now time.Time) (int, int, error) {
var rows []model.RentalOrder
err := j.db.WithContext(ctx).
Where("refund_status IN ? AND refund_amount_cent > 0 AND updated_at <= ?", []string{"pending", "refunding"}, now.Add(-10*time.Minute)).
Order("id ASC").
Limit(100).
Find(&rows).Error
if err != nil {
return 0, 0, err
}
missing := 0
rebuilt := 0
var missingOrderIDs []uint64
var rebuildFailOrderIDs []uint64
for _, row := range rows {
var count int64
if err := j.db.WithContext(ctx).Model(&model.PaymentOrder{}).
Where("order_id = ? AND biz_type IN ?", row.ID, payment.RefundBizTypes()).
Count(&count).Error; err != nil {
return missing, rebuilt, err
}
if count > 0 {
continue
}
missing++
// 仲裁孤儿:settlement_status="arbitrated" 是仲裁事务无条件写入的标志
// (arbitration.go:61)biz_type 可确定还原为 arbitration_refund,自动补建。
// 非仲裁孤儿 biz_type 无法从订单状态可靠区分,维持只告警。
if row.SettlementStatus == "arbitrated" {
if j.payments == nil {
rebuildFailOrderIDs = append(rebuildFailOrderIDs, row.ID)
continue
}
if _, err := j.payments.StartRefund(ctx, row.ID, row.RefundAmountCent, "arbitration_refund", "仲裁退款补偿补建"); err != nil {
rebuildFailOrderIDs = append(rebuildFailOrderIDs, row.ID)
} else {
rebuilt++
j.logger.Info("仲裁退款单已自动补建",
zap.Uint64("order_id", row.ID),
zap.Int64("refund_amount_cent", row.RefundAmountCent),
)
}
continue
}
missingOrderIDs = append(missingOrderIDs, row.ID)
}
// 汇总告警 + Redis 24h 去重,避免每轮扫描刷屏
if len(missingOrderIDs) > 0 && j.shouldWarn(ctx, "missing_payment", 0) {
j.logger.Warn("退款订单缺少支付记录,需要人工处理",
zap.Int("count", len(missingOrderIDs)),
zap.Uint64s("order_ids", missingOrderIDs),
)
}
if len(rebuildFailOrderIDs) > 0 && j.shouldWarn(ctx, "rebuild_fail", 0) {
j.logger.Warn("仲裁退款单自动补建失败,需要人工处理",
zap.Int("count", len(rebuildFailOrderIDs)),
zap.Uint64s("order_ids", rebuildFailOrderIDs),
)
}
return missing, rebuilt, nil
}
// shouldWarn 用 Redis 做 24h 节流;无 Redis 时每个 job 周期最多打一次同类汇总(由调用方聚合)。
func (j *Job) shouldWarn(ctx context.Context, kind string, id uint64) bool {
if j.redis == nil {
return true
}
key := fmt.Sprintf("hfb:job:refundretry:warn:%s", kind)
if id > 0 {
key = fmt.Sprintf("%s:%d", key, id)
}
ok, err := j.redis.SetNX(ctx, key, "1", manualWarnThreshold).Result()
if err != nil {
return true
}
return ok
}
@@ -1,301 +0,0 @@
package refundretry
import (
"testing"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/payment"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupRefundRetryTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("创建测试数据库失败: %v", err)
}
if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}); err != nil {
t.Fatalf("数据库迁移失败: %v", err)
}
return db
}
func TestRetryBackoffCapsAtOneHour(t *testing.T) {
cases := []struct {
retryCount int
want time.Duration
}{
{retryCount: 1, want: 5 * time.Minute},
{retryCount: 2, want: 10 * time.Minute},
{retryCount: 3, want: 20 * time.Minute},
{retryCount: 4, want: 40 * time.Minute},
{retryCount: 5, want: time.Hour},
{retryCount: 10, want: time.Hour},
}
for _, tc := range cases {
if got := retryBackoff(tc.retryCount); got != tc.want {
t.Fatalf("retryBackoff(%d) = %s, want %s", tc.retryCount, got, tc.want)
}
}
}
func TestWarnMaxRetryRefundsThrottlesByLastRetryAt(t *testing.T) {
db := setupRefundRetryTestDB(t)
job := New(db, nil, zap.NewNop(), nil)
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
old := now.Add(-25 * time.Hour)
row := model.PaymentOrder{
PaymentNo: "PAY202607110001",
OrderID: 9,
OrderNo: "ORD202607110001",
UserID: 2,
Provider: "mock",
ThirdOrderID: "REF202607110001",
BizType: "admin_refund",
Status: "failed",
RetryCount: maxRetryCount,
LastRetryAt: &old,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("创建退款单失败: %v", err)
}
job.warnMaxRetryRefunds(t.Context(), now)
var latest model.PaymentOrder
if err := db.First(&latest, row.ID).Error; err != nil {
t.Fatalf("查询退款单失败: %v", err)
}
if latest.LastRetryAt == nil || !latest.LastRetryAt.Equal(now) {
t.Fatalf("LastRetryAt 应更新为 now 以节流,got %v", latest.LastRetryAt)
}
// 立即再扫不应匹配(last_retry_at 未超过 24h
var count int64
if err := db.Model(&model.PaymentOrder{}).
Where("retry_count >= ? AND (last_retry_at IS NULL OR last_retry_at <= ?)",
maxRetryCount, now.Add(-manualWarnThreshold)).
Count(&count).Error; err != nil {
t.Fatalf("count: %v", err)
}
if count != 0 {
t.Fatalf("throttled rows count = %d, want 0", count)
}
}
func TestMarkRetryFailedStopsAtMaxCount(t *testing.T) {
db := setupRefundRetryTestDB(t)
job := New(db, nil, zap.NewNop(), nil)
now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
row := model.PaymentOrder{
PaymentNo: "PAY202606140101",
OrderID: 1,
OrderNo: "ORD202606140101",
UserID: 2,
Provider: "mock",
ThirdOrderID: "REF202606140101",
BizType: "admin_refund",
Status: "failed",
RetryCount: maxRetryCount - 1,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("创建退款单失败: %v", err)
}
if err := job.markRetryFailed(t.Context(), row, now); err != nil {
t.Fatalf("markRetryFailed() error = %v", err)
}
var latest model.PaymentOrder
if err := db.First(&latest, row.ID).Error; err != nil {
t.Fatalf("查询退款单失败: %v", err)
}
if latest.RetryCount != maxRetryCount {
t.Fatalf("RetryCount = %d, want %d", latest.RetryCount, maxRetryCount)
}
if latest.LastRetryAt == nil || !latest.LastRetryAt.Equal(now) {
t.Fatalf("LastRetryAt = %v, want %v", latest.LastRetryAt, now)
}
if latest.NextRetryAt != nil {
t.Fatalf("NextRetryAt = %v, want nil after max retry", latest.NextRetryAt)
}
}
func TestResetRetryClearsRetryFields(t *testing.T) {
db := setupRefundRetryTestDB(t)
job := New(db, nil, zap.NewNop(), nil)
now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
next := now.Add(time.Hour)
row := model.PaymentOrder{
PaymentNo: "PAY202606140102",
OrderID: 1,
OrderNo: "ORD202606140102",
UserID: 2,
Provider: "mock",
ThirdOrderID: "REF202606140102",
BizType: "admin_refund",
Status: "refunded",
RetryCount: 3,
LastRetryAt: &now,
NextRetryAt: &next,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("创建退款单失败: %v", err)
}
if err := job.resetRetry(t.Context(), row.ID); err != nil {
t.Fatalf("resetRetry() error = %v", err)
}
var latest model.PaymentOrder
if err := db.First(&latest, row.ID).Error; err != nil {
t.Fatalf("查询退款单失败: %v", err)
}
if latest.RetryCount != 0 || latest.LastRetryAt != nil || latest.NextRetryAt != nil {
t.Fatalf("retry fields = count:%d last:%v next:%v, want zero/nil", latest.RetryCount, latest.LastRetryAt, latest.NextRetryAt)
}
}
// createArbitrationOrphanFixture 构造一个仲裁孤儿订单:已支付原单 + 仲裁已结案但退款单缺失。
func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder {
t.Helper()
order := model.RentalOrder{
OrderNo: "ORD" + suffix,
ListingID: 1,
AccountID: 1,
OwnerID: 1,
RenterID: 2,
RentAmountCent: 1000,
DepositAmountCent: 500,
Status: "closed",
HandoffStatus: "arbitrated",
SettlementStatus: settlementStatus,
RefundStatus: "pending",
RefundAmountCent: 800,
UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
originalPayment := model.PaymentOrder{
PaymentNo: "PAY" + suffix,
OrderID: order.ID,
OrderNo: order.OrderNo,
UserID: 2,
Provider: "mock",
ThirdOrderID: "TPAY" + suffix,
AmountCent: 1500,
BizType: "order_pay",
Status: "paid",
}
if err := db.Create(&originalPayment).Error; err != nil {
t.Fatalf("create original payment failed: %v", err)
}
return order
}
// TestAutoRebuildArbitrationOrphan 验证仲裁孤儿订单被自动补建退款单。
func TestAutoRebuildArbitrationOrphan(t *testing.T) {
db := setupRefundRetryTestDB(t)
repo := payment.NewRepository(db, nil, nil)
job := New(db, nil, zap.NewNop(), repo)
order := createArbitrationOrphanFixture(t, db, "ARB001", "arbitrated")
now := time.Now()
missing, rebuilt, err := job.warnMissingRefundOrders(t.Context(), now)
if err != nil {
t.Fatalf("warnMissingRefundOrders() error = %v", err)
}
if missing != 1 {
t.Fatalf("missing = %d, want 1", missing)
}
if rebuilt != 1 {
t.Fatalf("rebuilt = %d, want 1", rebuilt)
}
// 断言:payment_orders 新增一条 arbitration_refund 退款单
var refunds []model.PaymentOrder
if err := db.Where("order_id = ? AND biz_type = ?", order.ID, "arbitration_refund").Find(&refunds).Error; err != nil {
t.Fatalf("query refund failed: %v", err)
}
if len(refunds) != 1 {
t.Fatalf("arbitration_refund records = %d, want 1", len(refunds))
}
if refunds[0].AmountCent != 800 {
t.Fatalf("refund amount = %d, want 800", refunds[0].AmountCent)
}
}
// TestNoRebuildForNonArbitrationOrphan 验证非仲裁孤儿(settlement_status 非 arbitrated)不补建,仅告警。
func TestNoRebuildForNonArbitrationOrphan(t *testing.T) {
db := setupRefundRetryTestDB(t)
repo := payment.NewRepository(db, nil, nil)
job := New(db, nil, zap.NewNop(), repo)
order := createArbitrationOrphanFixture(t, db, "ARB002", "settled") // 非仲裁结算
now := time.Now()
missing, rebuilt, err := job.warnMissingRefundOrders(t.Context(), now)
if err != nil {
t.Fatalf("warnMissingRefundOrders() error = %v", err)
}
if missing != 1 {
t.Fatalf("missing = %d, want 1", missing)
}
if rebuilt != 0 {
t.Fatalf("rebuilt = %d, want 0 (non-arbitration orphan should not rebuild)", rebuilt)
}
var refunds []model.PaymentOrder
if err := db.Where("order_id = ? AND biz_type = ?", order.ID, "arbitration_refund").Find(&refunds).Error; err != nil {
t.Fatalf("query refund failed: %v", err)
}
if len(refunds) != 0 {
t.Fatalf("arbitration_refund records = %d, want 0 (no rebuild)", len(refunds))
}
}
// TestRebuildArbitrationOrphanIdempotent 验证连续两次扫描的幂等性。
// mock 模式下首次补建直接置 refunded,订单 refund_status 变更后第二次扫描不再命中,
// 退款单始终只有一条。真实非 mock 场景同理:首次补建留下 payment 记录,
// 第二次扫描 count>0 直接跳过,不会重复建单。
func TestRebuildArbitrationOrphanIdempotent(t *testing.T) {
db := setupRefundRetryTestDB(t)
repo := payment.NewRepository(db, nil, nil)
job := New(db, nil, zap.NewNop(), repo)
order := createArbitrationOrphanFixture(t, db, "ARB003", "arbitrated")
now := time.Now()
// 第一次:补建成功
if _, rebuilt, err := job.warnMissingRefundOrders(t.Context(), now); err != nil || rebuilt != 1 {
t.Fatalf("first run: rebuilt=%d err=%v, want rebuilt=1", rebuilt, err)
}
// 第二次:mock 模式下第一次补建已置 refunded,订单 refund_status 变更,
// warnMissingRefundOrders 的扫描条件(refund_status IN pending/refunding)不再命中 → missing=0
missing, rebuilt, err := job.warnMissingRefundOrders(t.Context(), now)
if err != nil {
t.Fatalf("second run error = %v", err)
}
if missing != 0 {
t.Fatalf("second run missing = %d, want 0 (order no longer orphan after rebuild)", missing)
}
if rebuilt != 0 {
t.Fatalf("second run rebuilt = %d, want 0", rebuilt)
}
// 断言:退款单仍只有一条 arbitration_refund
var count int64
if err := db.Model(&model.PaymentOrder{}).Where("order_id = ? AND biz_type = ?", order.ID, "arbitration_refund").Count(&count).Error; err != nil {
t.Fatalf("count refund failed: %v", err)
}
if count != 1 {
t.Fatalf("arbitration_refund count = %d, want 1 (idempotent)", count)
}
}
-128
View File
@@ -1,128 +0,0 @@
package listingstatus
import (
"strings"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/timeutil"
"gorm.io/gorm"
)
// 统计用的事件类型(与 to_status 对齐时优先用这些标准值)。
const (
EventPublished = "published"
EventOffline = "offline"
EventRented = "rented"
EventCompleted = "completed"
EventSealed = "sealed"
EventAbnormal = "abnormal"
)
// 来源
const (
SourceSeller = "seller"
SourceAdmin = "admin"
SourceReview = "review"
SourceOrder = "order"
SourcePickup = "pickup"
SourceDispute = "dispute"
SourceSystem = "system"
)
// 操作者类型
const (
ActorUser = "user"
ActorAdmin = "admin"
ActorSystem = "system"
)
// Entry 单次状态变更事件。
type Entry struct {
ListingID uint64
OwnerID uint64
EventType string
FromStatus string
ToStatus string
Source string
ActorType string
ActorID uint64
Remark string
// CreatedAt 为空时使用上海时间当前时刻。
CreatedAt time.Time
}
// Append 写入一条状态事件。from==to 或 event 无法识别时跳过。
func Append(tx *gorm.DB, entry Entry) error {
if tx == nil {
return nil
}
from := strings.TrimSpace(entry.FromStatus)
to := strings.TrimSpace(entry.ToStatus)
if to == "" {
to = strings.TrimSpace(entry.EventType)
}
if to == "" || from == to {
return nil
}
eventType := strings.TrimSpace(entry.EventType)
if eventType == "" {
eventType = normalizeEventType(to)
}
if eventType == "" {
return nil
}
createdAt := entry.CreatedAt
if createdAt.IsZero() {
createdAt = timeutil.ShanghaiNow()
}
row := model.ListingStatusEvent{
ListingID: entry.ListingID,
OwnerID: entry.OwnerID,
EventType: eventType,
FromStatus: from,
ToStatus: to,
Source: strings.TrimSpace(entry.Source),
ActorType: strings.TrimSpace(entry.ActorType),
ActorID: entry.ActorID,
Remark: strings.TrimSpace(entry.Remark),
CreatedAt: createdAt,
}
return tx.Create(&row).Error
}
// AppendTransition 根据 listing 变更前后状态写事件。
func AppendTransition(
tx *gorm.DB,
listing *model.RentalListing,
fromStatus string,
source string,
actorType string,
actorID uint64,
remark string,
) error {
if listing == nil {
return nil
}
return Append(tx, Entry{
ListingID: listing.ID,
OwnerID: listing.OwnerID,
EventType: normalizeEventType(listing.Status),
FromStatus: fromStatus,
ToStatus: listing.Status,
Source: source,
ActorType: actorType,
ActorID: actorID,
Remark: remark,
})
}
func normalizeEventType(status string) string {
switch strings.TrimSpace(status) {
case EventPublished, EventOffline, EventRented, EventCompleted, EventSealed, EventAbnormal:
return status
default:
return ""
}
}
@@ -1,73 +0,0 @@
package listingstatus
import (
"testing"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.ListingStatusEvent{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestAppendSkipsUnchangedOrUnknown(t *testing.T) {
db := setupTestDB(t)
if err := Append(db, Entry{ListingID: 1, FromStatus: "published", ToStatus: "published", EventType: EventPublished}); err != nil {
t.Fatalf("same status should skip: %v", err)
}
if err := Append(db, Entry{ListingID: 1, FromStatus: "draft", ToStatus: "draft", EventType: ""}); err != nil {
t.Fatalf("unknown should skip: %v", err)
}
var count int64
if err := db.Model(&model.ListingStatusEvent{}).Count(&count).Error; err != nil {
t.Fatalf("count: %v", err)
}
if count != 0 {
t.Fatalf("expected 0 events, got %d", count)
}
}
func TestAppendTransitionWritesPublishedAndOffline(t *testing.T) {
db := setupTestDB(t)
listing := &model.RentalListing{ID: 9, OwnerID: 3, Status: EventPublished}
if err := AppendTransition(db, listing, "draft", SourceSeller, ActorUser, 3, "发布"); err != nil {
t.Fatalf("append published: %v", err)
}
listing.Status = EventOffline
if err := AppendTransition(db, listing, EventPublished, SourceAdmin, ActorAdmin, 1, "后台下架"); err != nil {
t.Fatalf("append offline: %v", err)
}
var rows []model.ListingStatusEvent
if err := db.Order("id asc").Find(&rows).Error; err != nil {
t.Fatalf("find: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 events, got %d", len(rows))
}
if rows[0].EventType != EventPublished || rows[0].Source != SourceSeller {
t.Fatalf("unexpected first event: %#v", rows[0])
}
if rows[1].EventType != EventOffline || rows[1].Source != SourceAdmin {
t.Fatalf("unexpected second event: %#v", rows[1])
}
if rows[0].CreatedAt.IsZero() || rows[1].CreatedAt.After(time.Now().Add(time.Minute)) {
t.Fatalf("unexpected timestamps: %v %v", rows[0].CreatedAt, rows[1].CreatedAt)
}
}
-63
View File
@@ -1,63 +0,0 @@
package logging
import (
"context"
"go.uber.org/zap"
)
type requestIDContextKey struct{}
type adminIDContextKey struct{}
// WithRequestID 把请求 ID 写入标准 context,供非 HTTP 层日志关联请求链路。
func WithRequestID(ctx context.Context, requestID string) context.Context {
if ctx == nil || requestID == "" {
return ctx
}
return context.WithValue(ctx, requestIDContextKey{}, requestID)
}
// RequestIDFromContext 从标准 context 读取请求 ID。
func RequestIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
value, ok := ctx.Value(requestIDContextKey{}).(string)
if !ok {
return ""
}
return value
}
// WithAdminID 把已完成鉴权的管理员 ID 写入标准 context,供仓储审计和日志关联。
func WithAdminID(ctx context.Context, adminID uint64) context.Context {
if ctx == nil || adminID == 0 {
return ctx
}
return context.WithValue(ctx, adminIDContextKey{}, adminID)
}
// AdminIDFromContext 从标准 context 读取已完成鉴权的管理员 ID。
func AdminIDFromContext(ctx context.Context) uint64 {
if ctx == nil {
return 0
}
adminID, _ := ctx.Value(adminIDContextKey{}).(uint64)
return adminID
}
// FromContext 返回自动携带请求和管理员上下文的日志器。
func FromContext(ctx context.Context) *zap.Logger {
logger := zap.L()
fields := make([]zap.Field, 0, 2)
if requestID := RequestIDFromContext(ctx); requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
if adminID := AdminIDFromContext(ctx); adminID != 0 {
fields = append(fields, zap.Uint64("admin_id", adminID))
}
if len(fields) == 0 {
return logger
}
return logger.With(fields...)
}
-248
View File
@@ -1,248 +0,0 @@
package logging
import (
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type dailyWriter struct {
mu sync.Mutex
dir string
prefix string
location *time.Location
retainDays int
day string
file *os.File
}
func (w *dailyWriter) Open() error {
w.mu.Lock()
defer w.mu.Unlock()
return w.rotateIfNeeded(time.Now().In(w.location))
}
func newDailyWriter(dir string, prefix string, location *time.Location, retainDays int) *dailyWriter {
if retainDays < 0 {
retainDays = 0
}
return &dailyWriter{
dir: dir,
prefix: prefix,
location: location,
retainDays: retainDays,
}
}
func (w *dailyWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if err := w.rotateIfNeeded(time.Now().In(w.location)); err != nil {
return 0, err
}
return w.file.Write(p)
}
func (w *dailyWriter) Sync() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
return w.file.Sync()
}
func (w *dailyWriter) rotateIfNeeded(now time.Time) error {
day := now.Format("2006-01-02")
if w.file != nil && w.day == day {
return nil
}
if err := os.MkdirAll(w.dir, 0o700); err != nil {
return err
}
if err := os.Chmod(w.dir, 0o700); err != nil {
return err
}
if w.file != nil {
if err := w.file.Close(); err != nil {
return err
}
w.file = nil
}
path := filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, day))
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
if err := file.Chmod(0o600); err != nil {
_ = file.Close()
return err
}
w.file = file
w.day = day
// 异步补压缩全部历史日志并清理过期文件,避免漏掉停机期间的日期。
go w.maintain(now)
return nil
}
func (w *dailyWriter) maintain(now time.Time) {
if err := w.compressHistorical(now); err != nil {
writeMaintenanceError(err)
}
if w.retainDays > 0 {
if err := w.purgeOlderThan(now); err != nil {
writeMaintenanceError(err)
}
}
}
func (w *dailyWriter) compressHistorical(now time.Time) error {
entries, err := os.ReadDir(w.dir)
if err != nil {
return err
}
today := now.In(w.location).Format("2006-01-02")
for _, entry := range entries {
day, ok := w.dayFromFilename(entry.Name(), ".log")
if !ok || day >= today {
continue
}
if err := w.compressDay(day); err != nil {
return fmt.Errorf("压缩 %s 日志失败: %w", day, err)
}
}
return nil
}
func (w *dailyWriter) compressDay(day string) error {
srcPath := filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, day))
dstPath := srcPath + ".gz"
if _, err := os.Stat(dstPath); err == nil {
if err := validateGzip(dstPath); err == nil {
if err := os.Remove(srcPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
if err := os.Remove(dstPath); err != nil {
return err
}
}
src, err := os.Open(srcPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer src.Close()
dst, err := os.CreateTemp(w.dir, "."+filepath.Base(dstPath)+".tmp-*")
if err != nil {
return err
}
tmpPath := dst.Name()
defer func() { _ = os.Remove(tmpPath) }()
if err := dst.Chmod(0o600); err != nil {
_ = dst.Close()
return err
}
gz := gzip.NewWriter(dst)
if _, err := io.Copy(gz, src); err != nil {
_ = gz.Close()
_ = dst.Close()
return err
}
if err := gz.Close(); err != nil {
_ = dst.Close()
return err
}
if err := dst.Sync(); err != nil {
_ = dst.Close()
return err
}
if err := dst.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, dstPath); err != nil {
return err
}
return os.Remove(srcPath)
}
func validateGzip(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
reader, err := gzip.NewReader(file)
if err != nil {
return err
}
_, copyErr := io.Copy(io.Discard, reader)
closeErr := reader.Close()
return errors.Join(copyErr, closeErr)
}
func (w *dailyWriter) purgeOlderThan(now time.Time) error {
// 按「日历天」比较:保留最近 retainDays 天(含当天),更早的 .log / .log.gz 删除。
today := time.Date(now.In(w.location).Year(), now.In(w.location).Month(), now.In(w.location).Day(), 0, 0, 0, 0, w.location)
cutoff := today.AddDate(0, 0, -(w.retainDays - 1))
entries, err := os.ReadDir(w.dir)
if err != nil {
return err
}
prefix := w.prefix + "-"
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, prefix) ||
(!strings.HasSuffix(name, ".log") && !strings.HasSuffix(name, ".log.gz")) {
continue
}
rest := strings.TrimPrefix(name, prefix)
rest = strings.TrimSuffix(rest, ".gz")
rest = strings.TrimSuffix(rest, ".log")
day, err := time.ParseInLocation("2006-01-02", rest, w.location)
if err != nil {
continue
}
if day.Before(cutoff) {
if err := os.Remove(filepath.Join(w.dir, name)); err != nil && !os.IsNotExist(err) {
return err
}
}
}
return nil
}
func (w *dailyWriter) dayFromFilename(name string, suffix string) (string, bool) {
prefix := w.prefix + "-"
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, suffix) {
return "", false
}
day := strings.TrimSuffix(strings.TrimPrefix(name, prefix), suffix)
if _, err := time.ParseInLocation("2006-01-02", day, w.location); err != nil {
return "", false
}
return day, true
}
func writeMaintenanceError(err error) {
_, _ = fmt.Fprintf(os.Stderr, "%s | ERROR | 日志维护失败 | error=%q\n", time.Now().Format("2006-01-02 15:04:05.000"), err.Error())
}
@@ -1,89 +0,0 @@
package logging
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestCompressDayAndPurge(t *testing.T) {
dir := t.TempDir()
loc := time.FixedZone("Asia/Shanghai", 8*60*60)
w := newDailyWriter(dir, "app", loc, 2)
// 准备三天明文日志
days := []string{"2026-07-01", "2026-07-10", "2026-07-11"}
for _, day := range days {
path := filepath.Join(dir, "app-"+day+".log")
if err := os.WriteFile(path, []byte(`{"msg":"hello `+day+`"}`+"\n"), 0o644); err != nil {
t.Fatalf("write log: %v", err)
}
}
// 压缩 7-10
if err := w.compressDay("2026-07-10"); err != nil {
t.Fatalf("compressDay: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-10.log")); !os.IsNotExist(err) {
t.Fatalf("plain log should be removed after gzip, err=%v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-10.log.gz")); err != nil {
t.Fatalf("gzip missing: %v", err)
}
// 以 7-12 为「现在」,保留 2 天(7-11、7-12),删除 7-11 之前。
now := time.Date(2026, 7, 12, 12, 0, 0, 0, loc)
if err := w.purgeOlderThan(now); err != nil {
t.Fatalf("purgeOlderThan: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-01.log")); !os.IsNotExist(err) {
t.Fatal("expired log should be purged")
}
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-10.log.gz")); !os.IsNotExist(err) {
t.Fatalf("expired gzip should be purged, err=%v", err)
}
// 7-11 仍应存在(cutoff 当天起保留)
if _, err := os.Stat(filepath.Join(dir, "app-2026-07-11.log")); err != nil {
t.Fatalf("recent plain should remain: %v", err)
}
}
func TestCompressDayReplacesCorruptedGzip(t *testing.T) {
dir := t.TempDir()
w := newDailyWriter(dir, "app", time.UTC, 0)
sourcePath := filepath.Join(dir, "app-2026-07-11.log")
gzipPath := sourcePath + ".gz"
if err := os.WriteFile(sourcePath, []byte("important log\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(gzipPath, []byte("broken"), 0o600); err != nil {
t.Fatal(err)
}
if err := w.compressDay("2026-07-11"); err != nil {
t.Fatal(err)
}
if err := validateGzip(gzipPath); err != nil {
t.Fatalf("gzip should be valid: %v", err)
}
if _, err := os.Stat(sourcePath); !os.IsNotExist(err) {
t.Fatalf("source should be removed after successful compression, err=%v", err)
}
}
func TestCompressDayIdempotent(t *testing.T) {
dir := t.TempDir()
loc := time.UTC
w := newDailyWriter(dir, "app", loc, 0)
path := filepath.Join(dir, "app-2026-07-11.log")
if err := os.WriteFile(path, []byte("line\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := w.compressDay("2026-07-11"); err != nil {
t.Fatal(err)
}
if err := w.compressDay("2026-07-11"); err != nil {
t.Fatalf("second compress should be no-op: %v", err)
}
}
-70
View File
@@ -1,70 +0,0 @@
package logging
import (
"fmt"
"os"
"strings"
"time"
"hfb_sys/backend/internal/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func New(cfg config.LogConfig) (*zap.Logger, error) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
location = time.FixedZone("Asia/Shanghai", 8*60*60)
}
level := zap.NewAtomicLevelAt(parseLevel(cfg.Level))
cores := make([]zapcore.Core, 0, 2)
if cfg.EnableConsole {
cores = append(cores, zapcore.NewCore(
newTextEncoder(location),
zapcore.Lock(os.Stdout),
level,
))
}
if cfg.EnableFile {
writer := newDailyWriter(cfg.Dir, "app", location, cfg.RetainDays)
if err := writer.Open(); err != nil {
return nil, fmt.Errorf("初始化日志文件失败: %w", err)
}
cores = append(cores, zapcore.NewCore(
newTextEncoder(location),
writer,
level,
))
}
if len(cores) == 0 {
cores = append(cores, zapcore.NewCore(
newTextEncoder(location),
zapcore.Lock(os.Stdout),
level,
))
}
logger := zap.New(
zapcore.NewTee(cores...),
zap.AddCaller(),
zap.AddStacktrace(zapcore.PanicLevel),
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
)
zap.ReplaceGlobals(logger)
return logger, nil
}
func parseLevel(value string) zapcore.Level {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
return zapcore.DebugLevel
case "warn", "warning":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}
-128
View File
@@ -1,128 +0,0 @@
package logging
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"go.uber.org/zap/buffer"
"go.uber.org/zap/zapcore"
)
var textBufferPool = buffer.NewPool()
// textEncoder 把结构化字段输出为便于直接阅读和 grep 的单行文本。
type textEncoder struct {
*zapcore.MapObjectEncoder
location *time.Location
}
func newTextEncoder(location *time.Location) zapcore.Encoder {
return &textEncoder{
MapObjectEncoder: zapcore.NewMapObjectEncoder(),
location: location,
}
}
func (e *textEncoder) Clone() zapcore.Encoder {
cloned := &textEncoder{
MapObjectEncoder: zapcore.NewMapObjectEncoder(),
location: e.location,
}
for key, value := range e.Fields {
cloned.Fields[key] = value
}
return cloned
}
func (e *textEncoder) EncodeEntry(entry zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) {
ctx := e.Clone().(*textEncoder)
for i := range fields {
fields[i].AddTo(ctx)
}
buf := textBufferPool.Get()
buf.AppendString(entry.Time.In(e.location).Format("2006-01-02 15:04:05.000"))
buf.AppendString(" | ")
buf.AppendString(fmt.Sprintf("%-5s", strings.ToUpper(entry.Level.String())))
buf.AppendString(" | ")
buf.AppendString(cleanText(entry.Message))
if entry.Level >= zapcore.WarnLevel && entry.Caller.Defined {
buf.AppendString(" | caller=")
buf.AppendString(entry.Caller.TrimmedPath())
}
keys := orderedKeys(ctx.Fields)
for _, key := range keys {
value := ctx.Fields[key]
if isEmptyValue(value) {
continue
}
buf.AppendString(" | ")
buf.AppendString(key)
buf.AppendByte('=')
buf.AppendString(formatValue(value))
}
if entry.Stack != "" {
buf.AppendString(" | stack=")
buf.AppendString(strconv.Quote(entry.Stack))
}
buf.AppendByte('\n')
return buf, nil
}
func orderedKeys(fields map[string]any) []string {
priority := []string{
"module", "request_id", "method", "route", "path", "status", "code",
"duration_ms", "rows", "user_id", "admin_id", "client_ip", "error",
}
keys := make([]string, 0, len(fields))
seen := make(map[string]bool, len(fields))
for _, key := range priority {
if _, ok := fields[key]; ok {
keys = append(keys, key)
seen[key] = true
}
}
rest := make([]string, 0, len(fields)-len(keys))
for key := range fields {
if !seen[key] {
rest = append(rest, key)
}
}
sort.Strings(rest)
return append(keys, rest...)
}
func formatValue(value any) string {
switch typed := value.(type) {
case string:
if typed != "" && !strings.ContainsAny(typed, " \t\r\n|=\"") {
return typed
}
return strconv.Quote(typed)
case []byte:
return strconv.Quote(string(typed))
case time.Time:
return typed.Format(time.RFC3339)
case time.Duration:
return typed.String()
default:
return cleanText(fmt.Sprint(typed))
}
}
func cleanText(value string) string {
return strings.NewReplacer("\r", `\r`, "\n", `\n`, "\t", `\t`).Replace(value)
}
func isEmptyValue(value any) bool {
if value == nil {
return true
}
text, ok := value.(string)
return ok && strings.TrimSpace(text) == ""
}
@@ -1,36 +0,0 @@
package logging
import (
"bytes"
"strings"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func TestTextEncoderProducesReadableSingleLine(t *testing.T) {
var output bytes.Buffer
core := zapcore.NewCore(newTextEncoder(time.UTC), zapcore.AddSync(&output), zapcore.DebugLevel)
logger := zap.New(core)
logger.Info("服务启动",
zap.String("request_id", "req-1"),
zap.String("empty", ""),
zap.String("detail", "line1\nline2"),
)
got := output.String()
if strings.ContainsAny(got, "{}") {
t.Fatalf("output should not use JSON object syntax: %s", got)
}
if !strings.Contains(got, "INFO | 服务启动 | request_id=req-1") {
t.Fatalf("unexpected text output: %s", got)
}
if strings.Contains(got, "empty=") {
t.Fatalf("empty fields should be omitted: %s", got)
}
if strings.Count(got, "\n") != 1 || !strings.Contains(got, `detail="line1\nline2"`) {
t.Fatalf("output should remain one physical line: %q", got)
}
}
+14 -169
View File
@@ -1,12 +1,8 @@
package middleware package middleware
import ( import (
"context"
"errors"
"net/http"
"strings" "strings"
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/pkg/response" "hfb_sys/backend/pkg/response"
@@ -14,51 +10,17 @@ import (
) )
const ( const (
ContextUserID = "user_id" ContextUserID = "user_id"
ContextPhone = "phone" ContextPhone = "phone"
ContextAdminID = "admin_id" ContextAdminID = "admin_id"
ContextUsername = "username" ContextUsername = "username"
ContextPasswordMustChange = "password_must_change"
ContextAuthFailureReason = "auth_failure_reason"
ContextAuthTokenSource = "auth_token_source"
ContextAuthTokenVersion = "auth_token_version"
ContextAuthCurrentVersion = "auth_current_token_version"
ContextAuthFailureDetail = "auth_failure_detail"
AdminAccessCookieName = "hfb_admin_access"
UserAccessCookieName = "hfb_user_access"
) )
type AdminTokenContext struct { func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
Username string
PasswordMustChange bool
}
type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error)
func extractBearerToken(c *gin.Context) string {
header := c.GetHeader("Authorization")
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if tokenText != "" && tokenText != header {
return tokenText
}
return ""
}
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin.HandlerFunc {
var validate UserTokenValidatorFunc
if len(validators) > 0 {
validate = validators[0]
}
return func(c *gin.Context) { return func(c *gin.Context) {
tokenText := extractBearerToken(c) header := c.GetHeader("Authorization")
if tokenText == "" { tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil { if tokenText == "" || tokenText == header {
tokenText = strings.TrimSpace(cookieToken)
}
}
if tokenText == "" {
response.Unauthorized(c, "缺少访问令牌") response.Unauthorized(c, "缺少访问令牌")
c.Abort() c.Abort()
return return
@@ -70,13 +32,6 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
c.Abort() c.Abort()
return return
} }
if validate != nil {
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
response.Unauthorized(c, "访问令牌无效或已过期")
c.Abort()
return
}
}
c.Set(ContextUserID, claims.UserID) c.Set(ContextUserID, claims.UserID)
c.Set(ContextPhone, claims.Phone) c.Set(ContextPhone, claims.Phone)
@@ -84,18 +39,11 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
} }
} }
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc { func AdminAuth(jwtManager *auth.JWTManager) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
tokenText := extractBearerToken(c) header := c.GetHeader("Authorization")
tokenSource := "bearer" tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if tokenText == "" { if tokenText == "" || tokenText == header {
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
tokenSource = "cookie"
}
}
if tokenText == "" {
RecordAdminAuthFailure(c, "missing", "none", 0, 0)
response.Unauthorized(c, "缺少后台访问令牌") response.Unauthorized(c, "缺少后台访问令牌")
c.Abort() c.Abort()
return return
@@ -103,116 +51,13 @@ func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gi
claims, err := jwtManager.ParseSubject(tokenText, "access", "admin") claims, err := jwtManager.ParseSubject(tokenText, "access", "admin")
if err != nil { if err != nil {
RecordAdminAuthFailure(c, auth.TokenFailureReason(err), tokenSource, 0, 0)
response.Unauthorized(c, "后台访问令牌无效或已过期") response.Unauthorized(c, "后台访问令牌无效或已过期")
c.Abort() c.Abort()
return return
} }
c.Set(ContextAdminID, claims.UserID)
c.Request = c.Request.WithContext(logging.WithAdminID(c.Request.Context(), claims.UserID))
username := claims.Phone
passwordMustChange := false
if validate != nil {
tokenContext, err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion)
if err != nil {
reason, tokenVersion, currentVersion := AdminValidationFailure(err)
RecordAdminAuthFailure(c, reason, tokenSource, tokenVersion, currentVersion, AdminValidationFailureDetail(err))
if AdminValidationUnavailable(err) {
response.ServiceUnavailable(c, "后台认证服务暂时不可用")
c.Abort()
return
}
response.Unauthorized(c, "后台访问令牌无效或已过期")
c.Abort()
return
}
username = tokenContext.Username
passwordMustChange = tokenContext.PasswordMustChange
}
c.Set(ContextUsername, username) c.Set(ContextAdminID, claims.UserID)
c.Set(ContextPasswordMustChange, passwordMustChange) c.Set(ContextUsername, claims.Phone)
c.Next() c.Next()
} }
} }
type authFailureReasonCarrier interface {
AuthFailureReason() string
}
type authFailureVersionCarrier interface {
AuthFailureVersions() (int64, int64)
}
type authFailureDetailCarrier interface {
AuthFailureDetail() string
}
type authFailureUnavailableCarrier interface {
AuthFailureUnavailable() bool
}
// AdminValidationFailure 将认证包内部错误转换为安全、可观测的失败类别。
func AdminValidationFailure(err error) (string, int64, int64) {
reason := "admin_validation_failed"
var reasonCarrier authFailureReasonCarrier
if errors.As(err, &reasonCarrier) && reasonCarrier.AuthFailureReason() != "" {
reason = reasonCarrier.AuthFailureReason()
}
var versionCarrier authFailureVersionCarrier
if errors.As(err, &versionCarrier) {
tokenVersion, currentVersion := versionCarrier.AuthFailureVersions()
return reason, tokenVersion, currentVersion
}
return reason, 0, 0
}
// AdminValidationFailureDetail 返回仅供服务端日志记录的安全错误摘要。
func AdminValidationFailureDetail(err error) string {
var detailCarrier authFailureDetailCarrier
if errors.As(err, &detailCarrier) {
return detailCarrier.AuthFailureDetail()
}
return ""
}
// AdminValidationUnavailable 判断失败是否由临时依赖故障引起。
func AdminValidationUnavailable(err error) bool {
var unavailableCarrier authFailureUnavailableCarrier
return errors.As(err, &unavailableCarrier) && unavailableCarrier.AuthFailureUnavailable()
}
// RecordAdminAuthFailure 把认证失败诊断字段写入请求上下文,供访问日志统一输出。
func RecordAdminAuthFailure(c *gin.Context, reason, source string, tokenVersion, currentVersion int64, detail ...string) {
c.Set(ContextAuthFailureReason, reason)
c.Set(ContextAuthTokenSource, source)
if tokenVersion != 0 || currentVersion != 0 {
c.Set(ContextAuthTokenVersion, tokenVersion)
c.Set(ContextAuthCurrentVersion, currentVersion)
}
if len(detail) > 0 && detail[0] != "" {
c.Set(ContextAuthFailureDetail, detail[0])
}
}
func RequireAdminPasswordChanged() gin.HandlerFunc {
allowed := map[string]bool{
"/api/admin/me": true,
"/api/admin/auth/logout": true,
"/api/admin/admin-users/me/password": true,
}
return func(c *gin.Context) {
value, ok := c.Get(ContextPasswordMustChange)
if !ok {
c.Next()
return
}
mustChange, ok := value.(bool)
if !ok || !mustChange || allowed[c.FullPath()] {
c.Next()
return
}
response.Error(c, http.StatusForbidden, "password_must_change", "请先修改初始密码")
c.Abort()
}
}
-40
View File
@@ -1,40 +0,0 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/internal/modules/auth"
"github.com/gin-gonic/gin"
)
func TestUserAuthRejectsQueryToken(t *testing.T) {
gin.SetMode(gin.TestMode)
manager := auth.NewJWTManager("test-jwt-secret-for-query-token-rejection")
pair, err := manager.GenerateSubjectPairWithVersion(1, "13800000000", "user", 1)
if err != nil {
t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err)
}
engine := gin.New()
engine.GET("/protected", Auth(manager), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
queryRequest := httptest.NewRequest(http.MethodGet, "/protected?token="+pair.AccessToken, nil)
queryResponse := httptest.NewRecorder()
engine.ServeHTTP(queryResponse, queryRequest)
if queryResponse.Code != http.StatusUnauthorized {
t.Fatalf("query token status = %d, want %d", queryResponse.Code, http.StatusUnauthorized)
}
bearerRequest := httptest.NewRequest(http.MethodGet, "/protected", nil)
bearerRequest.Header.Set("Authorization", "Bearer "+pair.AccessToken)
bearerResponse := httptest.NewRecorder()
engine.ServeHTTP(bearerResponse, bearerRequest)
if bearerResponse.Code != http.StatusNoContent {
t.Fatalf("bearer token status = %d, want %d", bearerResponse.Code, http.StatusNoContent)
}
}
-86
View File
@@ -1,86 +0,0 @@
package middleware
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// RequirePermission 检查当前管理员是否拥有指定权限。
// 超级管理员(拥有 super_admin 角色的管理员)自动放行。
func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
if checkPermission(c, permCode, rdb) {
c.Next()
}
}
}
func RequirePermissionIf(permCode string, rdb *redis.Client, predicate func(*gin.Context) bool) gin.HandlerFunc {
return func(c *gin.Context) {
if predicate == nil || !predicate(c) {
c.Next()
return
}
if checkPermission(c, permCode, rdb) {
c.Next()
}
}
}
func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
value, ok := c.Get(ContextAdminID)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
c.Abort()
return false
}
adminID, ok := value.(uint64)
if !ok {
response.Unauthorized(c, "管理员上下文无效")
c.Abort()
return false
}
codes, err := getPermCodes(c, rdb, adminID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "perm_check_failed", "权限校验服务暂时不可用")
c.Abort()
return false
}
for _, code := range codes {
if code == permCode || code == "*" {
return true
}
}
response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限")
c.Abort()
return false
}
func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) {
if rdb == nil {
return nil, errors.New("redis unavailable")
}
key := fmt.Sprintf("admin:perms:%d", adminID)
raw, err := rdb.Get(c.Request.Context(), key).Result()
if errors.Is(err, redis.Nil) {
return nil, nil // 缓存未命中,视为无权限
}
if err != nil {
return nil, err
}
var codes []string
if err := json.Unmarshal([]byte(raw), &codes); err != nil {
return nil, err
}
return codes, nil
}
-133
View File
@@ -1,133 +0,0 @@
package middleware
import (
"context"
"net/http"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
type rateLimitBucket struct {
count int
resetAt time.Time
lastSeen time.Time
}
type rateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
buckets map[string]rateLimitBucket
}
type redisRateLimiter struct {
redis *redis.Client
fallback *rateLimiter
limit int
window time.Duration
}
func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc {
if limit <= 0 {
return func(c *gin.Context) {
c.Next()
}
}
limiter := &rateLimiter{
limit: limit,
window: time.Minute,
buckets: make(map[string]rateLimitBucket),
}
if rdb != nil {
return (&redisRateLimiter{
redis: rdb,
fallback: limiter,
limit: limit,
window: time.Minute,
}).handle
}
return limiter.handle
}
func (l *redisRateLimiter) handle(c *gin.Context) {
now := time.Now()
key := c.ClientIP()
allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
if err != nil {
allowed, resetAt = l.fallback.allow(key, now)
}
if !allowed {
writeRateLimited(c, now, resetAt)
return
}
c.Next()
}
func (l *rateLimiter) handle(c *gin.Context) {
now := time.Now()
key := c.ClientIP()
allowed, resetAt := l.allow(key, now)
if !allowed {
writeRateLimited(c, now, resetAt)
return
}
c.Next()
}
func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) {
windowSeconds := int64(l.window / time.Second)
windowID := now.Unix() / windowSeconds
redisKey := "rate_limit:" + key + ":" + strconv.FormatInt(windowID, 10)
count, err := l.redis.Incr(ctx, redisKey).Result()
if err != nil {
return false, time.Time{}, err
}
if count == 1 {
_ = l.redis.Expire(ctx, redisKey, 2*l.window).Err()
}
resetAt := time.Unix((windowID+1)*windowSeconds, 0)
return count <= int64(l.limit), resetAt, nil
}
func (l *rateLimiter) allow(key string, now time.Time) (bool, time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
l.cleanup(now)
bucket := l.buckets[key]
if bucket.resetAt.IsZero() || !now.Before(bucket.resetAt) {
bucket = rateLimitBucket{resetAt: now.Add(l.window)}
}
bucket.count++
bucket.lastSeen = now
l.buckets[key] = bucket
return bucket.count <= l.limit, bucket.resetAt
}
func (l *rateLimiter) cleanup(now time.Time) {
for key, bucket := range l.buckets {
if now.Sub(bucket.lastSeen) > 2*l.window {
delete(l.buckets, key)
}
}
}
func retryAfterSeconds(now time.Time, resetAt time.Time) string {
seconds := int(resetAt.Sub(now).Seconds())
if seconds < 1 {
seconds = 1
}
return strconv.Itoa(seconds)
}
func writeRateLimited(c *gin.Context, now time.Time, resetAt time.Time) {
c.Header("Retry-After", retryAfterSeconds(now, resetAt))
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"code": "rate_limited",
"message": "请求过于频繁,请稍后再试",
})
}
+1 -1
View File
@@ -23,7 +23,7 @@ func RequireRealname(users *auth.UserRepository) gin.HandlerFunc {
c.Abort() c.Abort()
return return
} }
user, err := users.FindByID(c.Request.Context(), value.(uint64)) user, err := users.FindByID(value.(uint64))
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
response.Unauthorized(c, "用户不存在") response.Unauthorized(c, "用户不存在")
c.Abort() c.Abort()
-31
View File
@@ -1,31 +0,0 @@
package middleware
import (
"fmt"
"net/http"
"runtime/debug"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
const contextPanicStack = "panic_stack"
func Recovery(_ *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if recovered := recover(); recovered != nil {
err := fmt.Errorf("panic: %v", recovered)
_ = c.Error(err)
c.Set(contextPanicStack, string(debug.Stack()))
if !c.Writer.Written() {
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
}
c.Abort()
}
}()
c.Next()
}
}
-66
View File
@@ -1,66 +0,0 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"sync/atomic"
"time"
"hfb_sys/backend/internal/logging"
"github.com/gin-gonic/gin"
)
var requestIDFallbackCounter atomic.Uint64
const (
RequestIDHeader = "X-Request-ID"
ContextRequestID = "request_id"
)
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader(RequestIDHeader)
if !validRequestID(requestID) {
requestID = newRequestID()
}
c.Set(ContextRequestID, requestID)
c.Writer.Header().Set(RequestIDHeader, requestID)
c.Request = c.Request.WithContext(logging.WithRequestID(c.Request.Context(), requestID))
c.Next()
}
}
func GetRequestID(c *gin.Context) string {
value, ok := c.Get(ContextRequestID)
if !ok {
return ""
}
requestID, ok := value.(string)
if !ok {
return ""
}
return requestID
}
func newRequestID() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("%x-%x", time.Now().UnixNano(), requestIDFallbackCounter.Add(1))
}
return hex.EncodeToString(buf)
}
func validRequestID(value string) bool {
if value == "" || len(value) > 64 {
return false
}
return strings.IndexFunc(value, func(char rune) bool {
return !((char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char == '-' || char == '_' || char == '.')
}) == -1
}
@@ -1,24 +0,0 @@
package middleware
import "testing"
func TestValidRequestID(t *testing.T) {
tests := []struct {
name string
value string
want bool
}{
{name: "标准 ID", value: "req-20260729_ab.cd", want: true},
{name: "空值", value: "", want: false},
{name: "包含空格", value: "bad id", want: false},
{name: "包含换行", value: "bad\nid", want: false},
{name: "超长", value: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validRequestID(tt.value); got != tt.want {
t.Fatalf("validRequestID() = %v, want %v", got, tt.want)
}
})
}
}
+5 -110
View File
@@ -1,128 +1,23 @@
package middleware package middleware
import ( import (
"net/http"
"strings"
"time" "time"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
) )
// 查询请求默认静默;成功写操作、慢请求、服务端错误、限流和有诊断价值的认证失败会输出。
const slowRequestThresholdMs = 500
func RequestLogger(logger *zap.Logger) gin.HandlerFunc { func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
start := time.Now() start := time.Now()
c.Next() c.Next()
latencyMs := float64(time.Since(start).Microseconds()) / 1000 logger.Info("http request",
status := c.Writer.Status()
path := c.Request.URL.Path
route := c.FullPath()
authFailure := meaningfulAuthFailure(c)
operation := meaningfulOperation(c.Request.Method, path, status)
if shouldSkipHTTPLog(path, route, status, latencyMs) && !authFailure && !operation {
return
}
fields := []zap.Field{
zap.String("request_id", GetRequestID(c)),
zap.String("method", c.Request.Method), zap.String("method", c.Request.Method),
zap.String("path", path), zap.String("path", c.Request.URL.Path),
zap.String("route", route), zap.Int("status", c.Writer.Status()),
zap.Int("status", status), zap.Duration("latency", time.Since(start)),
zap.String("code", response.CodeFromContext(c)),
zap.Float64("duration_ms", latencyMs),
zap.String("client_ip", c.ClientIP()), zap.String("client_ip", c.ClientIP()),
} )
if userID, ok := c.Get(ContextUserID); ok {
fields = append(fields, zap.Any("user_id", userID))
}
if adminID, ok := c.Get(ContextAdminID); ok {
fields = append(fields, zap.Any("admin_id", adminID))
}
if reason, ok := c.Get(ContextAuthFailureReason); ok {
fields = append(fields, zap.Any("auth_failure_reason", reason))
}
if source, ok := c.Get(ContextAuthTokenSource); ok {
fields = append(fields, zap.Any("auth_token_source", source))
}
if detail, ok := c.Get(ContextAuthFailureDetail); ok {
fields = append(fields, zap.Any("auth_failure_detail", detail))
}
if tokenVersion, ok := c.Get(ContextAuthTokenVersion); ok {
fields = append(fields, zap.Any("auth_token_version", tokenVersion))
}
if currentVersion, ok := c.Get(ContextAuthCurrentVersion); ok {
fields = append(fields, zap.Any("auth_current_token_version", currentVersion))
}
if len(c.Errors) > 0 {
fields = append(fields,
zap.String("error", c.Errors.Last().Err.Error()),
zap.Int("error_count", len(c.Errors)),
)
}
if stack, ok := c.Get(contextPanicStack); ok {
fields = append(fields, zap.Any("stack", stack))
}
switch {
case status >= 500:
logger.Error("HTTP 请求失败", fields...)
case status == http.StatusTooManyRequests:
logger.Warn("HTTP 请求被限流", fields...)
case authFailure:
logger.Warn("后台认证失败", fields...)
case latencyMs >= slowRequestThresholdMs:
logger.Warn("HTTP 慢请求", fields...)
case operation:
logger.Info("HTTP 操作完成", fields...)
}
} }
} }
func meaningfulOperation(method, path string, status int) bool {
if status < 200 || status >= 400 {
return false
}
// 支付回调已有更完整的业务结果日志,不再重复记录一条访问日志。
if isPaymentNotifyPath(path) {
return false
}
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func isPaymentNotifyPath(path string) bool {
return (strings.Contains(path, "/payment/") || strings.Contains(path, "/payments/")) &&
strings.HasSuffix(strings.TrimSuffix(path, "/"), "/notify")
}
// shouldSkipHTTPLog 判断是否为无需记录的普通请求。
func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool {
if status >= 500 || status == http.StatusTooManyRequests {
return false
}
if latencyMs >= slowRequestThresholdMs {
return false
}
return true
}
func meaningfulAuthFailure(c *gin.Context) bool {
value, ok := c.Get(ContextAuthFailureReason)
if !ok {
return false
}
reason, _ := value.(string)
return reason != "" && reason != "missing"
}
@@ -1,52 +0,0 @@
package middleware
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestRequestLoggerRecordsServerErrorCause(t *testing.T) {
gin.SetMode(gin.TestMode)
core, observed := observer.New(zap.DebugLevel)
engine := gin.New()
engine.Use(RequestID(), RequestLogger(zap.New(core)))
engine.GET("/failed", func(c *gin.Context) {
response.RecordError(c, errors.New("database unavailable"))
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
})
request := httptest.NewRequest(http.MethodGet, "/failed", nil)
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
entries := observed.FilterMessage("HTTP 请求失败").All()
if len(entries) != 1 {
t.Fatalf("server error logs = %d, want 1", len(entries))
}
fields := entries[0].ContextMap()
if fields["error"] != "database unavailable" || fields["code"] != "internal_error" {
t.Fatalf("unexpected fields: %v", fields)
}
}
func TestRequestLoggerSkipsOrdinaryNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
core, observed := observer.New(zap.DebugLevel)
engine := gin.New()
engine.Use(RequestID(), RequestLogger(zap.New(core)))
request := httptest.NewRequest(http.MethodGet, "/missing", nil)
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if observed.Len() != 0 {
t.Fatalf("ordinary 404 should not produce logs: %v", observed.All())
}
}
@@ -1,56 +0,0 @@
package middleware
import "testing"
func TestShouldSkipHTTPLog(t *testing.T) {
tests := []struct {
name string
path string
route string
status int
latencyMs float64
wantSkip bool
}{
{name: "普通成功请求跳过", path: "/api/orders", status: 200, latencyMs: 20, wantSkip: true},
{name: "创建成功请求跳过", path: "/api/orders", status: 201, latencyMs: 30, wantSkip: true},
{name: "普通 400 跳过", path: "/api/orders", status: 400, latencyMs: 2, wantSkip: true},
{name: "普通 401 跳过", path: "/api/orders", status: 401, latencyMs: 2, wantSkip: true},
{name: "普通 404 跳过", path: "/unknown", status: 404, latencyMs: 2, wantSkip: true},
{name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false},
{name: "限流请求不跳过", path: "/api/auth/sms", status: 429, latencyMs: 1, wantSkip: false},
{name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, wantSkip: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldSkipHTTPLog(tt.path, tt.route, tt.status, tt.latencyMs)
if got != tt.wantSkip {
t.Fatalf("shouldSkipHTTPLog() = %v, want %v", got, tt.wantSkip)
}
})
}
}
func TestMeaningfulOperation(t *testing.T) {
tests := []struct {
name string
method string
path string
status int
want bool
}{
{name: "创建成功", method: "POST", path: "/api/orders", status: 201, want: true},
{name: "更新成功", method: "PUT", path: "/api/orders/1", status: 200, want: true},
{name: "删除成功", method: "DELETE", path: "/api/orders/1", status: 204, want: true},
{name: "普通查询", method: "GET", path: "/api/orders", status: 200, want: false},
{name: "失败写操作", method: "POST", path: "/api/orders", status: 400, want: false},
{name: "支付回调已有业务日志", method: "POST", path: "/api/payments/lakala/notify", status: 200, want: false},
{name: "支付回调尾部斜杠", method: "POST", path: "/api/payments/leshua/notify/", status: 200, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := meaningfulOperation(tt.method, tt.path, tt.status); got != tt.want {
t.Fatalf("meaningfulOperation() = %v, want %v", got, tt.want)
}
})
}
}
+8 -11
View File
@@ -3,17 +3,14 @@ package model
import "time" import "time"
type AdminUser struct { type AdminUser struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;not null;uniqueIndex" json:"username"` Username string `gorm:"size:64;not null;uniqueIndex" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"` PasswordHash string `gorm:"size:255;not null" json:"-"`
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"` Status string `gorm:"size:32;not null;default:'active'" json:"status"`
SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"` LastLoginAt *time.Time `json:"last_login_at"`
TokenVersion int64 `gorm:"not null;default:1" json:"-"` CreatedAt time.Time `json:"created_at"`
PasswordMustChange bool `gorm:"not null;default:false" json:"password_must_change"` UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
func (AdminUser) TableName() string { func (AdminUser) TableName() string {
-23
View File
@@ -1,23 +0,0 @@
package model
import "time"
type Announcement struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:255;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Category string `gorm:"size:32;not null;default:'notice'" json:"category"`
Priority int `gorm:"not null;default:0" json:"priority"`
IsPinned bool `gorm:"not null;default:false" json:"is_pinned"`
IsImportant bool `gorm:"not null;default:false" json:"is_important"`
ViewCount int `gorm:"not null;default:0" json:"view_count"`
Status string `gorm:"size:32;not null;default:'draft'" json:"status"`
PublishedAt *time.Time `json:"published_at"`
CreatedBy *uint64 `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Announcement) TableName() string {
return "announcements"
}
-119
View File
@@ -1,119 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
)
type ChatConversation struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
ListingID *uint64 `gorm:"index" json:"listing_id"`
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"`
Title string `gorm:"size:128;not null" json:"title"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
LastMessageID *uint64 `json:"last_message_id"`
LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"`
LastMessageAt *time.Time `json:"last_message_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatConversation) TableName() string {
return "chat_conversations"
}
type ChatParticipant struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
ParticipantType string `gorm:"size:16;not null;index" json:"participant_type"`
ParticipantID uint64 `gorm:"not null;index" json:"participant_id"`
Role string `gorm:"size:32;not null" json:"role"`
Remark string `gorm:"size:128;not null;default:''" json:"remark"`
LastReadAt *time.Time `json:"last_read_at"`
JoinedAt time.Time `json:"joined_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatParticipant) TableName() string {
return "chat_participants"
}
type ChatMessage struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
SenderType string `gorm:"size:16;not null" json:"sender_type"`
SenderID uint64 `gorm:"not null;default:0" json:"sender_id"`
SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"`
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
Content string `json:"content"`
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
CreatedAt time.Time `json:"created_at"`
}
func (ChatMessage) TableName() string {
return "chat_messages"
}
type ChatQrCode struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ImageURL string `gorm:"size:512;not null" json:"image_url"`
GroupName string `gorm:"size:128;not null;default:''" json:"group_name"`
Status string `gorm:"size:16;not null;default:'unused'" json:"status"`
ConversationID *uint64 `gorm:"index" json:"conversation_id"`
UsedAt *time.Time `json:"used_at"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedBy uint64 `gorm:"not null" json:"created_by"`
Note string `gorm:"size:255;not null;default:''" json:"note"`
WecomRenamed bool `gorm:"not null;default:false" json:"wecom_renamed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatQrCode) TableName() string {
return "chat_qrcode_pool"
}
type ChatQrCodeDeliveryTask struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"column:conversation_id;not null;uniqueIndex" json:"conversation_id"`
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"`
QrCodeID *uint64 `gorm:"column:qrcode_id;index" json:"qrcode_id"`
ErrorMessage string `gorm:"column:error_message;size:255;not null;default:''" json:"error_message"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SentAt *time.Time `gorm:"column:sent_at" json:"sent_at"`
}
func (ChatQrCodeDeliveryTask) TableName() string {
return "chat_qrcode_delivery_tasks"
}
type ChatSupportGroup struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:64;not null;uniqueIndex" json:"code"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"size:255;not null;default:''" json:"description"`
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatSupportGroup) TableName() string {
return "chat_support_groups"
}
type ChatSupportGroupMember struct {
ID uint64 `gorm:"primaryKey" json:"id"`
GroupID uint64 `gorm:"not null;uniqueIndex:uk_support_group_member" json:"group_id"`
AdminID uint64 `gorm:"column:admin_user_id;not null;uniqueIndex:uk_support_group_member;index" json:"admin_id"`
CreatedAt time.Time `json:"created_at"`
}
func (ChatSupportGroupMember) TableName() string {
return "chat_support_group_members"
}
@@ -1,17 +0,0 @@
package model
import "time"
type ChatQuickReply struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
AdminUserID uint64 `gorm:"not null;default:0;index" json:"admin_user_id"` // 0=全局, >0=个人
Title string `gorm:"size:64;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
CreatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
}
func (ChatQuickReply) TableName() string {
return "chat_quick_replies"
}
+14 -21
View File
@@ -7,27 +7,20 @@ import (
) )
type Dispute struct { type Dispute struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
OrderID uint64 `gorm:"not null;index" json:"order_id"` OrderID uint64 `gorm:"not null;index" json:"order_id"`
InitiatorID uint64 `gorm:"not null" json:"initiator_id"` InitiatorID uint64 `gorm:"not null" json:"initiator_id"`
InitiatorType string `gorm:"size:16;not null;default:'user';index" json:"initiator_type"` TargetUserID uint64 `gorm:"not null" json:"target_user_id"`
InitiatorAdminID *uint64 `gorm:"index" json:"initiator_admin_id"` Type string `gorm:"size:32;not null" json:"type"`
TargetUserID uint64 `gorm:"not null" json:"target_user_id"` Status string `gorm:"size:32;not null;default:'open'" json:"status"`
Type string `gorm:"size:32;not null" json:"type"` Description string `json:"description"`
Status string `gorm:"size:32;not null;default:'open'" json:"status"` EvidenceURLS datatypes.JSON `json:"evidence_urls"`
Description string `json:"description"` ArbitrationResult string `gorm:"size:32;not null;default:''" json:"arbitration_result"`
EvidenceURLS datatypes.JSON `gorm:"column:evidence_urls" json:"evidence_urls"` ArbitrationRemark string `json:"arbitration_remark"`
PreviousOrderStatus string `gorm:"size:32;not null;default:''" json:"previous_order_status"` HandledBy *uint64 `json:"handled_by"`
PreviousHandoffStatus string `gorm:"size:32;not null;default:''" json:"previous_handoff_status"` HandledAt *time.Time `json:"handled_at"`
PreviousSettlementStatus string `gorm:"size:32;not null;default:''" json:"previous_settlement_status"` CreatedAt time.Time `json:"created_at"`
CheckoutID *uint64 `json:"checkout_id"` UpdatedAt time.Time `json:"updated_at"`
PreviousCheckoutStatus string `gorm:"size:32;not null;default:''" json:"previous_checkout_status"`
ArbitrationResult string `gorm:"size:32;not null;default:''" json:"arbitration_result"`
ArbitrationRemark string `json:"arbitration_remark"`
HandledBy *uint64 `json:"handled_by"`
HandledAt *time.Time `json:"handled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
func (Dispute) TableName() string { func (Dispute) TableName() string {
-15
View File
@@ -1,15 +0,0 @@
package model
import "time"
// FileUploadOwner 记录用户上传的私有文件归属,用于业务关联创建前的临时访问授权。
type FileUploadOwner struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"not null;index"`
ObjectKey string `gorm:"size:512;not null;uniqueIndex"`
CreatedAt time.Time `json:"created_at"`
}
func (FileUploadOwner) TableName() string {
return "file_upload_owners"
}
+13 -37
View File
@@ -29,45 +29,21 @@ func (GameAccount) TableName() string {
} }
type RentalListing struct { type RentalListing struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
ListingNo string `gorm:"column:listing_no;size:20;not null;uniqueIndex" json:"listing_no"` AccountID uint64 `gorm:"not null;index" json:"account_id"`
AccountID uint64 `gorm:"not null;index" json:"account_id"` OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
OwnerID uint64 `gorm:"not null;index" json:"owner_id"` PriceHourly float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_hourly"`
PriceCent int64 `gorm:"not null;default:0" json:"-"` PriceDaily float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_daily"`
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"` PriceWeekly float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_weekly"`
InTransaction bool `gorm:"not null;default:false" json:"in_transaction"` DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"`
Status string `gorm:"size:32;not null;default:'draft'" json:"status"` Status string `gorm:"size:32;not null;default:'draft'" json:"status"`
ReviewStatus string `gorm:"size:32;not null;default:'none'" json:"review_status"` ReviewStatus string `gorm:"size:32;not null;default:'none'" json:"review_status"`
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"` ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"`
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"` PublishedAt *time.Time `json:"published_at"`
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"` CreatedAt time.Time `json:"created_at"`
ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"` UpdatedAt time.Time `json:"updated_at"`
PublishedAt *time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
func (RentalListing) TableName() string { func (RentalListing) TableName() string {
return "rental_listings" return "rental_listings"
} }
type ListingUpload struct {
ID uint64 `gorm:"primaryKey" json:"id"`
UploaderName string `gorm:"size:64;not null;index" json:"uploader_name"`
SourceChannel string `gorm:"size:32;not null;default:'';index" json:"source_channel"`
MatchedAdminID *uint64 `gorm:"index" json:"matched_admin_id"`
OwnerID *uint64 `gorm:"index" json:"owner_id"`
ClientUploadTime *time.Time `json:"client_upload_time"`
ClientIP string `gorm:"size:64;not null;default:''" json:"client_ip"`
RawPayload datatypes.JSON `json:"raw_payload"`
ParsedPayload datatypes.JSON `json:"parsed_payload"`
ListingID *uint64 `gorm:"index" json:"listing_id"`
Status string `gorm:"size:32;not null;default:'draft_created'" json:"status"`
ErrorMessage string `gorm:"size:255;not null;default:''" json:"error_message"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ListingUpload) TableName() string {
return "listing_uploads"
}
@@ -1,22 +0,0 @@
package model
import "time"
// ListingStatusEvent 记录商品上架/主动下架/交易离架等状态变更,供后台日统计使用。
type ListingStatusEvent struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
OwnerID uint64 `gorm:"not null;default:0" json:"owner_id"`
EventType string `gorm:"size:32;not null" json:"event_type"`
FromStatus string `gorm:"size:32;not null;default:''" json:"from_status"`
ToStatus string `gorm:"size:32;not null;default:''" json:"to_status"`
Source string `gorm:"size:32;not null;default:''" json:"source"`
ActorType string `gorm:"size:16;not null;default:''" json:"actor_type"`
ActorID uint64 `gorm:"not null;default:0" json:"actor_id"`
Remark string `gorm:"size:255;not null;default:''" json:"remark"`
CreatedAt time.Time `json:"created_at"`
}
func (ListingStatusEvent) TableName() string {
return "listing_status_events"
}
-99
View File
@@ -1,99 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// MohongCategory 撞车商品分类。
type MohongCategory struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null;uniqueIndex" json:"name"`
Code string `gorm:"size:64;not null;default:''" json:"code"`
SortOrder int `gorm:"column:sort_order;not null;default:0" json:"sort_order"`
Status string `gorm:"size:16;not null;default:'enabled'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (MohongCategory) TableName() string {
return "mohong_categories"
}
// MohongProductCategory 商品与分类多对多(同一商品可出现在大红/九格等)。
type MohongProductCategory struct {
ProductID uint64 `gorm:"column:product_id;primaryKey" json:"product_id"`
CategoryID uint64 `gorm:"column:category_id;primaryKey;index" json:"category_id"`
}
func (MohongProductCategory) TableName() string {
return "mohong_product_categories"
}
// MohongProduct 撞车商品。
type MohongProduct struct {
ID uint64 `gorm:"primaryKey" json:"id"`
CategoryID *uint64 `gorm:"column:category_id;index" json:"category_id"` // 主分类(展示用)
Title string `gorm:"size:128;not null" json:"title"`
CoverURL string `gorm:"column:cover_url;size:512;not null;default:''" json:"cover_url"`
ImageURLs datatypes.JSON `gorm:"column:image_urls" json:"image_urls"`
Description string `gorm:"type:text;not null" json:"description"`
PriceCent int64 `gorm:"column:price_cent;not null;default:0" json:"-"`
OriginalPriceCent int64 `gorm:"column:original_price_cent;not null;default:0" json:"-"`
Unit string `gorm:"size:32;not null;default:'份'" json:"unit"`
Stock int `gorm:"not null;default:-1" json:"stock"`
SortOrder int `gorm:"column:sort_order;not null;default:0" json:"sort_order"`
Status string `gorm:"size:16;not null;default:'draft'" json:"status"`
QrcodeImageURL string `gorm:"column:qrcode_image_url;size:512;not null;default:''" json:"qrcode_image_url"`
CreatedBy *uint64 `gorm:"column:created_by" json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (MohongProduct) TableName() string {
return "mohong_products"
}
// MohongOrder 撞车订单。
type MohongOrder struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_no"`
UserID uint64 `gorm:"not null;index" json:"user_id"`
ProductID uint64 `gorm:"not null;index" json:"product_id"`
Quantity int `gorm:"not null;default:1" json:"quantity"`
UnitPriceCent int64 `gorm:"column:unit_price_cent;not null;default:0" json:"-"`
AmountCent int64 `gorm:"column:amount_cent;not null;default:0" json:"-"`
Status string `gorm:"size:32;not null;default:'pending_payment';index" json:"status"`
ProductSnapshot datatypes.JSON `gorm:"column:product_snapshot" json:"product_snapshot"`
ItemsSnapshot datatypes.JSON `gorm:"column:items_snapshot" json:"items_snapshot"`
QrcodeURLSnapshot string `gorm:"column:qrcode_url_snapshot;size:512;not null;default:''" json:"qrcode_url_snapshot"`
CopyText string `gorm:"type:text;not null" json:"copy_text"`
PaidAt *time.Time `json:"paid_at"`
CompletedAt *time.Time `json:"completed_at"`
CancelledAt *time.Time `json:"cancelled_at"`
CancelReason string `gorm:"size:255;not null;default:''" json:"cancel_reason"`
AdminRemark string `gorm:"size:255;not null;default:''" json:"admin_remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (MohongOrder) TableName() string {
return "mohong_orders"
}
const (
MohongCategoryStatusEnabled = "enabled"
MohongCategoryStatusDisabled = "disabled"
MohongProductStatusDraft = "draft"
MohongProductStatusOnSale = "on_sale"
MohongProductStatusOffSale = "off_sale"
MohongOrderStatusPendingPayment = "pending_payment"
MohongOrderStatusPaid = "paid"
MohongOrderStatusReceiving = "receiving" // 接待中:已有人接待买家
MohongOrderStatusCompleted = "completed"
MohongOrderStatusCancelled = "cancelled"
MohongOrderStatusRefunded = "refunded"
)
-15
View File
@@ -17,18 +17,3 @@ type Notification struct {
func (Notification) TableName() string { func (Notification) TableName() string {
return "notifications" return "notifications"
} }
type AdminNotification struct {
ID uint64 `gorm:"primaryKey" json:"id"`
AdminUserID uint64 `gorm:"not null;index" json:"admin_user_id"`
Type string `gorm:"size:32;not null" json:"type"`
Title string `gorm:"size:128;not null" json:"title"`
Content string `json:"content"`
IsRead bool `gorm:"not null;default:false" json:"is_read"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AdminNotification) TableName() string {
return "admin_notifications"
}
+20 -100
View File
@@ -7,108 +7,28 @@ import (
) )
type RentalOrder struct { type RentalOrder struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_no"` OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_no"`
ListingID uint64 `gorm:"not null;index" json:"listing_id"` ListingID uint64 `gorm:"not null;index" json:"listing_id"`
AccountID uint64 `gorm:"not null;index" json:"account_id"` AccountID uint64 `gorm:"not null;index" json:"account_id"`
OwnerID uint64 `gorm:"not null;index" json:"owner_id"` OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
RenterID uint64 `gorm:"not null;index" json:"renter_id"` RenterID uint64 `gorm:"not null;index" json:"renter_id"`
RentedAt *time.Time `json:"rented_at"` RentStartAt *time.Time `json:"rent_start_at"`
HandoffStartedAt *time.Time `json:"handoff_started_at"` RentEndAt *time.Time `json:"rent_end_at"`
EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"` RentHours int `gorm:"not null" json:"rent_hours"`
RentAmountCent int64 `gorm:"not null;default:0" json:"-"` RentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"rent_amount"`
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"`
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"` PlatformFee float64 `gorm:"type:decimal(12,2);not null;default:0" json:"platform_fee"`
DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` AccountSnapshot datatypes.JSON `json:"account_snapshot"`
DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"` Status string `gorm:"size:32;not null;default:'pending_confirm'" json:"status"`
DepositFreeLevelQuotaCent int64 `gorm:"not null;default:0" json:"deposit_free_level_quota_cent"` HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
DepositFreeManualQuotaCent int64 `gorm:"not null;default:0" json:"deposit_free_manual_quota_cent"` SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"`
DepositFreeUsedBeforeCent int64 `gorm:"not null;default:0" json:"deposit_free_used_before_cent"` OwnerSettledAt *time.Time `json:"owner_settled_at"`
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` SettledAt *time.Time `json:"settled_at"`
RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` CreatedAt time.Time `json:"created_at"`
RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"` UpdatedAt time.Time `json:"updated_at"`
PureCoinOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
ExtraItemOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
ActualCoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"actual_coin_consumed_m"`
ActualPureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
ActualPureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
ActualExtraItemAmountCent int64 `gorm:"not null;default:0" json:"-"`
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal'" json:"renter_growth_level"`
RenterGrowthLevelName string `gorm:"size:32;not null;default:'普通'" json:"renter_growth_level_name"`
RenterDiscountBps int `gorm:"not null;default:10000" json:"renter_discount_bps"`
GrowthPointsBasisCent int64 `gorm:"not null;default:0" json:"growth_points_basis_cent"`
GrowthPointsPerYuan int64 `gorm:"not null;default:0" json:"growth_points_per_yuan"`
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"`
OfflineSettlementStatus string `gorm:"size:16;not null;default:'none';index" json:"offline_settlement_status"`
OfflineSettlementAmountCent int64 `gorm:"not null;default:0" json:"offline_settlement_amount_cent"`
OfflineSettlementRemark string `gorm:"size:255;not null;default:''" json:"offline_settlement_remark"`
OfflineSettledBy *uint64 `json:"offline_settled_by"`
OfflineSettledAt *time.Time `json:"offline_settled_at"`
RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"`
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
RefundedAt *time.Time `json:"refunded_at"`
DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"`
DepositHoldAmountCent int64 `gorm:"not null;default:0" json:"deposit_hold_amount_cent"`
DepositHoldReason string `gorm:"size:255;not null;default:''" json:"deposit_hold_reason"`
DepositHeldBy *uint64 `json:"deposit_held_by"`
DepositHeldAt *time.Time `json:"deposit_held_at"`
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"`
OwnerSettledAt *time.Time `json:"owner_settled_at"`
SettledAt *time.Time `json:"settled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
func (RentalOrder) TableName() string { func (RentalOrder) TableName() string {
return "rental_orders" return "rental_orders"
} }
// 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算,
// 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。
const (
DepositHoldStatusNone = "none" // 未暂扣
DepositHoldStatusHeld = "held" // 已暂扣(押金退款被拦截/挂起)
DepositHoldStatusReleased = "released" // 已归还
)
// IsDepositHeld 判断订单当前是否处于押金暂扣状态。
func (o *RentalOrder) IsDepositHeld() bool {
return o.DepositHoldStatus == DepositHoldStatusHeld
}
// ApplyDepositHold 在任一结算退款路径中拦截押金退款。
// totalRefundCent 是本次本应原路退还给租客的总金额(含租金退款和押金退款),
// depositPortionCent 是其中属于押金退款的部分。
// 若订单已暂扣,则把押金部分(不超过本次退款总额)从退款中扣除,
// 并把尚未记录过的部分累加到暂扣金额(累计不超过实付押金),返回扣除后实际发起原路退款的金额;
// 未暂扣时原样返回 totalRefundCent。
// 注意:本函数只做“少退、挂起”,绝不多退,因此在任何路径下都不会造成租客损失或平台多付。
func (o *RentalOrder) ApplyDepositHold(totalRefundCent, depositPortionCent int64) int64 {
if !o.IsDepositHeld() {
return totalRefundCent
}
withheld := depositPortionCent
if withheld > totalRefundCent {
withheld = totalRefundCent
}
if withheld <= 0 {
return totalRefundCent
}
remainingDepositHoldCent := o.DepositAmountCent - o.DepositHoldAmountCent
if remainingDepositHoldCent > 0 {
newHold := withheld
if newHold > remainingDepositHoldCent {
newHold = remainingDepositHoldCent
}
o.DepositHoldAmountCent += newHold
}
return totalRefundCent - withheld
}
+20 -30
View File
@@ -7,36 +7,26 @@ import (
) )
type OrderCheckout struct { type OrderCheckout struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
OrderID uint64 `gorm:"not null;index" json:"order_id"` OrderID uint64 `gorm:"not null;index" json:"order_id"`
InitiatedBy uint64 `gorm:"not null" json:"initiated_by"` InitiatedBy uint64 `gorm:"not null" json:"initiated_by"`
Status string `gorm:"size:32;not null;default:'submitted'" json:"status"` Status string `gorm:"size:32;not null;default:'submitted'" json:"status"`
RoundCount int `gorm:"not null;default:1" json:"round_count"` RentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"rent_amount"`
Turn string `gorm:"size:16;not null;default:'owner'" json:"turn"` DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"`
ProposedBy uint64 `gorm:"not null;default:0" json:"proposed_by"` ConsumableAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"consumable_amount"`
RentAmountCent int64 `gorm:"not null;default:0" json:"-"` CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"`
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` OtherAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"other_amount"`
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` DepositDeductAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_deduct_amount"`
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"` RenterRefundAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"renter_refund_amount"`
PureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"` OwnerIncomeAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"owner_income_amount"`
PureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"` Content string `json:"content"`
PureCoinPayableCent int64 `gorm:"not null;default:0" json:"-"` EvidenceURLS datatypes.JSON `json:"evidence_urls"`
ConsumableAmountCent int64 `gorm:"not null;default:0" json:"-"` OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"` OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
OtherAmountCent int64 `gorm:"not null;default:0" json:"-"` RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
DepositDeductAmountCent int64 `gorm:"not null;default:0" json:"-"` RenterRejectedAt *time.Time `json:"renter_rejected_at"`
RenterRefundAmountCent int64 `gorm:"not null;default:0" json:"-"` CreatedAt time.Time `json:"created_at"`
OwnerIncomeAmountCent int64 `gorm:"not null;default:0" json:"-"` UpdatedAt time.Time `json:"updated_at"`
ShortfallCent int64 `gorm:"not null;default:0" json:"-"`
OvershootAmountCent int64 `gorm:"not null;default:0" json:"-"`
Content string `json:"content"`
EvidenceURLS datatypes.JSON `gorm:"column:evidence_urls" json:"evidence_urls"`
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
RenterRejectedAt *time.Time `json:"renter_rejected_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
func (OrderCheckout) TableName() string { func (OrderCheckout) TableName() string {
-49
View File
@@ -1,49 +0,0 @@
package model
import "testing"
func TestRentalOrderApplyDepositHold(t *testing.T) {
order := RentalOrder{
DepositAmountCent: 1000,
DepositHoldStatus: DepositHoldStatusHeld,
}
actualRefundCent := order.ApplyDepositHold(1500, 1000)
if actualRefundCent != 500 {
t.Fatalf("actual refund = %d, want 500", actualRefundCent)
}
if order.DepositHoldAmountCent != 1000 {
t.Fatalf("hold amount = %d, want 1000", order.DepositHoldAmountCent)
}
}
func TestRentalOrderApplyDepositHoldKeepsWithholdingAfterAmountRecorded(t *testing.T) {
order := RentalOrder{
DepositAmountCent: 1000,
DepositHoldStatus: DepositHoldStatusHeld,
DepositHoldAmountCent: 1000,
}
actualRefundCent := order.ApplyDepositHold(1500, 1000)
if actualRefundCent != 500 {
t.Fatalf("actual refund = %d, want 500", actualRefundCent)
}
if order.DepositHoldAmountCent != 1000 {
t.Fatalf("hold amount = %d, want 1000", order.DepositHoldAmountCent)
}
}
func TestRentalOrderApplyDepositHoldSkipsWhenNotHeld(t *testing.T) {
order := RentalOrder{
DepositAmountCent: 1000,
DepositHoldStatus: DepositHoldStatusNone,
}
actualRefundCent := order.ApplyDepositHold(1500, 1000)
if actualRefundCent != 1500 {
t.Fatalf("actual refund = %d, want 1500", actualRefundCent)
}
if order.DepositHoldAmountCent != 0 {
t.Fatalf("hold amount = %d, want 0", order.DepositHoldAmountCent)
}
}
-41
View File
@@ -1,41 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
)
type PaymentOrder struct {
ID uint64 `gorm:"primaryKey" json:"id"`
PaymentNo string `gorm:"size:64;not null;uniqueIndex" json:"payment_no"`
OrderID uint64 `gorm:"not null;index" json:"order_id"`
OrderNo string `gorm:"size:64;not null;index" json:"order_no"`
UserID uint64 `gorm:"not null;index" json:"user_id"`
PaymentConfigID uint64 `gorm:"not null;default:0;index" json:"payment_config_id"`
Provider string `gorm:"size:32;not null" json:"provider"`
MerchantID string `gorm:"size:32;not null;default:''" json:"merchant_id"`
ThirdOrderID string `gorm:"size:64;not null;uniqueIndex" json:"third_order_id"`
ProviderOrderID string `gorm:"size:64;not null;default:'';index" json:"provider_order_id"`
PayWay string `gorm:"size:16;not null;default:''" json:"pay_way"`
JSPayFlag string `gorm:"column:jspay_flag;size:8;not null;default:''" json:"jspay_flag"`
AmountCent int64 `gorm:"not null;default:0" json:"amount_cent"`
BizType string `gorm:"size:32;not null;default:'order_pay';index" json:"biz_type"`
Status string `gorm:"size:32;not null;default:'created';index" json:"status"`
TDCode string `gorm:"size:512;not null;default:''" json:"td_code"`
JSPayURL string `gorm:"column:jspay_url;size:512;not null;default:''" json:"jspay_url"`
JSPayInfo string `gorm:"column:jspay_info;type:text" json:"jspay_info"`
RawRequest datatypes.JSON `json:"raw_request"`
RawResponse datatypes.JSON `json:"raw_response"`
RetryCount int `gorm:"not null;default:0" json:"retry_count"`
LastRetryAt *time.Time `json:"last_retry_at"`
NextRetryAt *time.Time `json:"next_retry_at"`
PaidAt *time.Time `json:"paid_at"`
NotifiedAt *time.Time `json:"notified_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (PaymentOrder) TableName() string {
return "payment_orders"
}
-26
View File
@@ -1,26 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
)
type UserPaymentAccount struct {
ID uint64 `gorm:"primaryKey" json:"id"`
UserID uint64 `gorm:"not null;index" json:"user_id"`
AccountType string `gorm:"size:32;not null" json:"account_type"`
AccountName string `gorm:"size:128;not null" json:"account_name"`
AccountNo string `gorm:"size:255;not null" json:"account_no"`
BankName string `gorm:"size:128;not null;default:''" json:"bank_name"`
BankBranch string `gorm:"size:255;not null;default:''" json:"bank_branch"`
CertificateURLs datatypes.JSON `json:"certificate_urls"`
IsDefault bool `gorm:"not null;default:0" json:"is_default"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (UserPaymentAccount) TableName() string {
return "user_payment_accounts"
}
@@ -1,103 +0,0 @@
package model
import (
"database/sql/driver"
"encoding/json"
"time"
)
// PaymentMerchantConfig 支付商户配置
type PaymentMerchantConfig struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
Provider string `gorm:"size:32;not null;index:idx_payment_merchant_configs_provider" json:"provider"`
MerchantID string `gorm:"size:128;not null;index:idx_payment_merchant_configs_merchant_id" json:"merchant_id"`
GatewayURL string `gorm:"size:512;not null;default:''" json:"gateway_url"`
SignKey string `gorm:"type:text" json:"sign_key"` // 加密存储,拉卡拉使用商户私钥 PEM
NotifyKey string `gorm:"type:text" json:"notify_key"` // 加密存储,拉卡拉使用通知验签证书 PEM
NotifyURL string `gorm:"size:512;not null;default:''" json:"notify_url"`
JumpURL string `gorm:"size:512;not null;default:''" json:"jump_url"`
PayWay string `gorm:"size:32;not null;default:'ZFBZF';index:idx_payment_merchant_configs_pay_way" json:"pay_way"`
JSPayFlag string `gorm:"column:jspay_flag;size:8;not null;default:'2'" json:"jspay_flag"`
SignType string `gorm:"size:32;not null;default:'MD5'" json:"sign_type"`
ExtraConfig JSONMap `gorm:"type:json" json:"extra_config"`
IsDefault bool `gorm:"not null;default:0;index:idx_payment_merchant_configs_default" json:"is_default"`
Status string `gorm:"size:32;not null;default:'active';index:idx_payment_merchant_configs_status" json:"status"`
Environment string `gorm:"size:16;not null;default:'production'" json:"environment"`
BusinessTags JSONArray `gorm:"type:json" json:"business_tags"`
// 统计信息
TotalTransactions int64 `gorm:"not null;default:0" json:"total_transactions"`
TotalAmountCent int64 `gorm:"not null;default:0" json:"total_amount_cent"`
LastUsedAt *time.Time `json:"last_used_at"`
// 审计信息
CreatedBy *uint64 `json:"created_by"`
UpdatedBy *uint64 `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (PaymentMerchantConfig) TableName() string {
return "payment_merchant_configs"
}
// JSONMap 用于 extra_config 字段
type JSONMap map[string]any
func (j JSONMap) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
return json.Marshal(j)
}
func (j *JSONMap) Scan(value any) error {
if value == nil {
*j = nil
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, j)
}
// JSONArray 用于 business_tags 字段
type JSONArray []string
func (j JSONArray) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
return json.Marshal(j)
}
func (j *JSONArray) Scan(value any) error {
if value == nil {
*j = nil
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, j)
}
// PaymentConfigUsageLog 支付配置使用日志
type PaymentConfigUsageLog struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConfigID uint64 `gorm:"not null;index:idx_payment_config_usage_logs_config" json:"config_id"`
PaymentOrderID uint64 `gorm:"not null;index:idx_payment_config_usage_logs_payment" json:"payment_order_id"`
Provider string `gorm:"size:32;not null" json:"provider"`
MerchantID string `gorm:"size:128;not null" json:"merchant_id"`
AmountCent int64 `gorm:"not null" json:"amount_cent"`
BizType string `gorm:"size:32;not null" json:"biz_type"`
CreatedAt time.Time `json:"created_at"`
}
func (PaymentConfigUsageLog) TableName() string {
return "payment_config_usage_logs"
}
-46
View File
@@ -1,46 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// AdminPickup 管理员线下提号记录。
// 独立于 rental_orders,不进入正常订单状态机与财务统计口径。
// 站内上传完成时入卖家钱包;平台代管完成后进入线下结算。
type AdminPickup struct {
ID uint64 `gorm:"primaryKey" json:"id"`
PickupNo string `gorm:"size:64;not null;uniqueIndex" json:"pickup_no"`
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
AccountID uint64 `gorm:"not null;index" json:"account_id"`
OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
AdminID uint64 `gorm:"not null" json:"admin_id"`
Platform string `gorm:"size:32;not null;default:''" json:"platform"`
ShopName string `gorm:"size:100;not null;default:'';index" json:"shop_name"`
AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"`
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_cent"`
OwnerPriceCent int64 `gorm:"not null;default:0" json:"owner_price_cent"`
WebsiteProfitCent int64 `gorm:"not null;default:0" json:"website_profit_cent"`
ProfitAmountCent int64 `gorm:"not null;default:0" json:"profit_amount_cent"`
SellerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"seller_ratio"`
BuyerRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"buyer_ratio"`
AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"`
SettleAmountCent int64 `gorm:"not null;default:0" json:"settle_amount_cent"`
Status string `gorm:"size:20;not null;default:'picking_up';index" json:"status"`
OfflineSettlementStatus string `gorm:"size:16;not null;default:'none';index" json:"offline_settlement_status"`
OfflineSettlementRemark string `gorm:"size:255;not null;default:''" json:"offline_settlement_remark"`
OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"`
OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"`
Remark string `gorm:"size:255;not null;default:''" json:"remark"`
CompleteRemark string `gorm:"size:255;not null;default:''" json:"complete_remark"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CancelledAt *time.Time `json:"cancelled_at,omitempty"`
}
func (AdminPickup) TableName() string {
return "admin_pickups"
}
@@ -1,23 +0,0 @@
package model
import "time"
// AdminPickupFinancialAdjustment 保存已完成提号的财务调整,原始结算快照不可修改。
type AdminPickupFinancialAdjustment struct {
ID uint64 `gorm:"primaryKey" json:"id"`
PickupID uint64 `gorm:"not null;index" json:"pickup_id"`
ProfitDeltaCent int64 `gorm:"not null;default:0" json:"profit_delta_cent"`
SettleDeltaCent int64 `gorm:"not null;default:0" json:"settle_delta_cent"`
SettlementMode string `gorm:"size:32;not null" json:"settlement_mode"`
Status string `gorm:"size:24;not null;index" json:"status"`
Reason string `gorm:"size:255;not null" json:"reason"`
CreatedBy uint64 `gorm:"not null" json:"created_by"`
SettledBy *uint64 `json:"settled_by,omitempty"`
SettledAt *time.Time `json:"settled_at,omitempty"`
SettlementRemark string `gorm:"size:255;not null;default:''" json:"settlement_remark"`
CreatedAt time.Time `json:"created_at"`
}
func (AdminPickupFinancialAdjustment) TableName() string {
return "admin_pickup_financial_adjustments"
}
-25
View File
@@ -1,25 +0,0 @@
package model
import "time"
// RenterGrowthLedger 记录租客成长积分变动流水,便于排查订单完成后的积分发放。
type RenterGrowthLedger struct {
ID uint64 `gorm:"primaryKey" json:"id"`
UserID uint64 `gorm:"not null;index" json:"user_id"`
OrderID *uint64 `gorm:"index;uniqueIndex:uk_renter_growth_order_source,priority:1" json:"order_id,omitempty"`
Points int64 `gorm:"not null;default:0" json:"points"`
BasisAmountCent int64 `gorm:"not null;default:0" json:"basis_amount_cent"`
PointsPerYuan int64 `gorm:"not null;default:0" json:"points_per_yuan"`
BeforePoints int64 `gorm:"not null;default:0" json:"before_points"`
AfterPoints int64 `gorm:"not null;default:0" json:"after_points"`
BeforeLevel string `gorm:"size:32;not null;default:'normal'" json:"before_level"`
AfterLevel string `gorm:"size:32;not null;default:'normal'" json:"after_level"`
Source string `gorm:"size:32;not null;default:'order_completed';uniqueIndex:uk_renter_growth_order_source,priority:2" json:"source"`
OperatorAdminID *uint64 `gorm:"index" json:"operator_admin_id,omitempty"`
Remark string `gorm:"size:255;not null;default:''" json:"remark"`
CreatedAt time.Time `json:"created_at"`
}
func (RenterGrowthLedger) TableName() string {
return "renter_growth_ledger"
}
-44
View File
@@ -1,44 +0,0 @@
package model
import "time"
type Role struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:64;not null;uniqueIndex" json:"code"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"size:255;not null;default:''" json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Role) TableName() string { return "roles" }
type Permission struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:128;not null;uniqueIndex" json:"code"`
Name string `gorm:"size:64;not null" json:"name"`
Resource string `gorm:"size:128;not null" json:"resource"`
Action string `gorm:"size:64;not null" json:"action"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Permission) TableName() string { return "permissions" }
type AdminUserRole struct {
ID uint64 `gorm:"primaryKey" json:"id"`
AdminID uint64 `gorm:"column:admin_user_id;not null;uniqueIndex:uk_admin_user_roles_user_role" json:"admin_id"`
RoleID uint64 `gorm:"column:role_id;not null;uniqueIndex:uk_admin_user_roles_user_role" json:"role_id"`
CreatedAt time.Time `json:"created_at"`
}
func (AdminUserRole) TableName() string { return "admin_user_roles" }
type RolePermission struct {
ID uint64 `gorm:"primaryKey" json:"id"`
RoleID uint64 `gorm:"column:role_id;not null;uniqueIndex:uk_role_permissions_role_permission" json:"role_id"`
PermissionID uint64 `gorm:"column:permission_id;not null;uniqueIndex:uk_role_permissions_role_permission" json:"permission_id"`
CreatedAt time.Time `json:"created_at"`
}
func (RolePermission) TableName() string { return "role_permissions" }
+11 -16
View File
@@ -3,22 +3,17 @@ package model
import "time" import "time"
type User struct { type User struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"` Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"`
PasswordHash string `gorm:"size:255;not null;default:''" json:"-"` Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"` RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"` RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"` CreditScore int `gorm:"not null;default:100" json:"credit_score"`
CreditScore int `gorm:"not null;default:100" json:"credit_score"` Status string `gorm:"size:32;not null;default:'active'" json:"status"`
DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"` LastLoginAt *time.Time `json:"last_login_at"`
RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"` CreatedAt time.Time `json:"created_at"`
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"` UpdatedAt time.Time `json:"updated_at"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
TokenVersion int64 `gorm:"not null;default:1" json:"-"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
func (User) TableName() string { func (User) TableName() string {
-2
View File
@@ -9,9 +9,7 @@ type UserRealname struct {
ProviderOrderNo string `gorm:"size:128;not null;default:''" json:"provider_order_no"` ProviderOrderNo string `gorm:"size:128;not null;default:''" json:"provider_order_no"`
Status string `gorm:"size:32;not null;default:'pending'" json:"status"` Status string `gorm:"size:32;not null;default:'pending'" json:"status"`
MaskedName string `gorm:"size:64;not null;default:''" json:"masked_name"` MaskedName string `gorm:"size:64;not null;default:''" json:"masked_name"`
EncryptedName string `gorm:"size:255;not null;default:''" json:"encrypted_name"`
MaskedIDNo string `gorm:"size:64;not null;default:''" json:"masked_id_no"` MaskedIDNo string `gorm:"size:64;not null;default:''" json:"masked_id_no"`
EncryptedIDNo string `gorm:"size:255;not null;default:''" json:"encrypted_id_no"`
VerifiedAt *time.Time `json:"verified_at"` VerifiedAt *time.Time `json:"verified_at"`
FailReason string `gorm:"size:255;not null;default:''" json:"fail_reason"` FailReason string `gorm:"size:255;not null;default:''" json:"fail_reason"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
+19 -19
View File
@@ -3,13 +3,13 @@ package model
import "time" import "time"
type WalletAccount struct { type WalletAccount struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
UserID uint64 `gorm:"not null;uniqueIndex" json:"user_id"` UserID uint64 `gorm:"not null;uniqueIndex" json:"user_id"`
AvailableBalanceCent int64 `gorm:"not null;default:0" json:"-"` AvailableBalance float64 `gorm:"type:decimal(12,2);not null;default:0" json:"available_balance"`
FrozenBalanceCent int64 `gorm:"not null;default:0" json:"-"` FrozenBalance float64 `gorm:"type:decimal(12,2);not null;default:0" json:"frozen_balance"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"` Status string `gorm:"size:32;not null;default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
func (WalletAccount) TableName() string { func (WalletAccount) TableName() string {
@@ -17,18 +17,18 @@ func (WalletAccount) TableName() string {
} }
type WalletLedger struct { type WalletLedger struct {
ID uint64 `gorm:"primaryKey" json:"id"` ID uint64 `gorm:"primaryKey" json:"id"`
LedgerNo string `gorm:"size:64;not null;uniqueIndex" json:"ledger_no"` LedgerNo string `gorm:"size:64;not null;uniqueIndex" json:"ledger_no"`
UserID uint64 `gorm:"not null;index" json:"user_id"` UserID uint64 `gorm:"not null;index" json:"user_id"`
OrderID *uint64 `json:"order_id"` OrderID *uint64 `json:"order_id"`
Direction string `gorm:"size:16;not null" json:"direction"` Direction string `gorm:"size:16;not null" json:"direction"`
AmountCent int64 `gorm:"not null;default:0" json:"-"` Amount float64 `gorm:"type:decimal(12,2);not null" json:"amount"`
BalanceAfterCent int64 `gorm:"not null;default:0" json:"-"` BalanceAfter float64 `gorm:"type:decimal(12,2);not null" json:"balance_after"`
BalanceType string `gorm:"size:32;not null" json:"balance_type"` BalanceType string `gorm:"size:32;not null" json:"balance_type"`
BizType string `gorm:"size:32;not null" json:"biz_type"` BizType string `gorm:"size:32;not null" json:"biz_type"`
BizNo string `gorm:"size:64;not null" json:"biz_no"` BizNo string `gorm:"size:64;not null" json:"biz_no"`
Remark string `gorm:"size:255;not null;default:''" json:"remark"` Remark string `gorm:"size:255;not null;default:''" json:"remark"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
func (WalletLedger) TableName() string { func (WalletLedger) TableName() string {
-32
View File
@@ -1,32 +0,0 @@
package model
import "time"
type WithdrawalRequest struct {
ID uint64 `gorm:"primaryKey" json:"id"`
WithdrawNo string `gorm:"size:64;not null;uniqueIndex" json:"withdraw_no"`
UserID uint64 `gorm:"not null;index" json:"user_id"`
AmountCent int64 `gorm:"not null;default:0" json:"-"`
FeeCent int64 `gorm:"not null;default:0" json:"-"`
ActualAmountCent int64 `gorm:"not null;default:0" json:"-"`
PaymentAccountID *uint64 `json:"payment_account_id"`
AccountType string `gorm:"size:32;not null" json:"account_type"`
AccountName string `gorm:"size:128;not null" json:"account_name"`
AccountNo string `gorm:"size:128;not null" json:"account_no"`
BankName string `gorm:"size:128;not null;default:''" json:"bank_name"`
BankBranch string `gorm:"size:255;not null;default:''" json:"bank_branch"`
Status string `gorm:"size:32;not null;default:'pending'" json:"status"`
ReviewedBy *uint64 `json:"reviewed_by"`
ReviewedAt *time.Time `json:"reviewed_at"`
ReviewRemark string `gorm:"size:255;not null;default:''" json:"review_remark"`
PaidBy *uint64 `json:"paid_by"`
PaidAt *time.Time `json:"paid_at"`
PaymentProofURL string `gorm:"size:512;not null;default:''" json:"payment_proof_url"`
PaymentRemark string `gorm:"size:255;not null;default:''" json:"payment_remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (WithdrawalRequest) TableName() string {
return "withdrawal_requests"
}
@@ -38,7 +38,7 @@ func (h *Handler) List(c *gin.Context) {
if !ok { if !ok {
return return
} }
result, err := h.service.List(c.Request.Context(), query) result, err := h.service.List(query)
if err != nil { if err != nil {
writeAuditError(c, err) writeAuditError(c, err)
return return
@@ -65,7 +65,6 @@ func parseQuery(c *gin.Context) (Query, bool) {
} }
func writeAuditError(c *gin.Context, err error) { func writeAuditError(c *gin.Context, err error) {
response.RecordError(c, err)
switch { switch {
case errors.Is(err, ErrDependencyUnavailable): case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接") response.ServiceUnavailable(c, "数据库未连接")
@@ -1,7 +1,6 @@
package adminaudit package adminaudit
import ( import (
"context"
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
@@ -18,14 +17,14 @@ func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db} return &Repository{db: db}
} }
func (r *Repository) List(ctx context.Context, query Query) (*PaginatedResult, error) { func (r *Repository) List(query Query) (*PaginatedResult, error) {
db := r.db.WithContext(ctx).Table("audit_logs AS al"). db := r.db.Table("audit_logs AS al").
Select(`al.id, al.actor_type, al.actor_id, COALESCE(au.username, '') AS actor_username, Select(`al.id, al.actor_type, al.actor_id, COALESCE(au.username, '') AS actor_username,
COALESCE(au.nickname, '') AS actor_nickname, al.action, al.biz_type, al.biz_id, COALESCE(au.nickname, '') AS actor_nickname, al.action, al.biz_type, al.biz_id,
al.ip, al.user_agent, al.detail, al.created_at`). al.ip, al.user_agent, al.detail, al.created_at`).
Joins("LEFT JOIN admin_users AS au ON au.id = al.actor_id AND al.actor_type = ?", "admin") Joins("LEFT JOIN admin_users AS au ON au.id = al.actor_id AND al.actor_type = ?", "admin")
countDB := r.db.WithContext(ctx).Model(&model.AuditLog{}) countDB := r.db.Model(&model.AuditLog{})
if query.ActorID > 0 { if query.ActorID > 0 {
db = db.Where("al.actor_id = ?", query.ActorID) db = db.Where("al.actor_id = ?", query.ActorID)
countDB = countDB.Where("actor_id = ?", query.ActorID) countDB = countDB.Where("actor_id = ?", query.ActorID)
@@ -1,9 +1,6 @@
package adminaudit package adminaudit
import ( import "errors"
"context"
"errors"
)
var ErrDependencyUnavailable = errors.New("dependency unavailable") var ErrDependencyUnavailable = errors.New("dependency unavailable")
@@ -15,9 +12,9 @@ func NewService(repo *Repository) *Service {
return &Service{repo: repo} return &Service{repo: repo}
} }
func (s *Service) List(ctx context.Context, query Query) (*PaginatedResult, error) { func (s *Service) List(query Query) (*PaginatedResult, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
return s.repo.List(ctx, query) return s.repo.List(query)
} }
@@ -1,101 +0,0 @@
package adminauth
import (
"context"
"errors"
"strings"
"unicode"
"hfb_sys/backend/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type BootstrapConfig struct {
AppEnv string
Username string
Password string
Nickname string
}
// BootstrapAdmin 在管理员表为空时按部署配置创建首个超级管理员。
func BootstrapAdmin(ctx context.Context, db *gorm.DB, cfg BootstrapConfig) error {
if db == nil {
return nil
}
var count int64
if err := db.WithContext(ctx).Model(&model.AdminUser{}).Count(&count).Error; err != nil {
if isAdminTableMissing(err) {
return nil
}
return err
}
if count > 0 {
return nil
}
username := strings.TrimSpace(cfg.Username)
password := cfg.Password
if username == "" || password == "" {
if strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") {
return errors.New("admin bootstrap credentials are required when no admin exists in production")
}
return nil
}
if strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") && !bootstrapPasswordStrong(password) {
return errors.New("ADMIN_BOOTSTRAP_PASSWORD must be at least 12 chars and include letters and digits in production")
}
nickname := strings.TrimSpace(cfg.Nickname)
if nickname == "" {
nickname = username
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
admin := model.AdminUser{
Username: username,
PasswordHash: string(hash),
Nickname: nickname,
Status: "active",
TokenVersion: 1,
PasswordMustChange: true,
}
if err := tx.Create(&admin).Error; err != nil {
return err
}
var superAdminRole model.Role
if err := tx.Where("code = ?", "super_admin").First(&superAdminRole).Error; err != nil {
return err
}
return tx.Create(&model.AdminUserRole{
AdminID: admin.ID,
RoleID: superAdminRole.ID,
}).Error
})
}
func bootstrapPasswordStrong(value string) bool {
if len([]rune(value)) < 12 {
return false
}
hasLetter := false
hasDigit := false
for _, r := range value {
if unicode.IsLetter(r) {
hasLetter = true
}
if unicode.IsDigit(r) {
hasDigit = true
}
}
return hasLetter && hasDigit
}
func isAdminTableMissing(err error) bool {
message := strings.ToLower(err.Error())
return strings.Contains(message, "admin_users") && (strings.Contains(message, "doesn't exist") || strings.Contains(message, "no such table"))
}
+5 -19
View File
@@ -7,21 +7,11 @@ import (
) )
type AdminDTO struct { type AdminDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Status string `json:"status"` Status string `json:"status"`
SupportStatus string `json:"support_status"` LastLoginAt *time.Time `json:"last_login_at"`
PasswordMustChange bool `json:"password_must_change"`
Roles []RoleDTO `json:"roles"`
Permissions []string `json:"permissions"`
LastLoginAt *time.Time `json:"last_login_at"`
}
type RoleDTO struct {
ID uint64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
} }
type LoginRequest struct { type LoginRequest struct {
@@ -41,7 +31,3 @@ type CaptchaDTO struct {
Image string `json:"image"` Image string `json:"image"`
ExpiresIn int64 `json:"expires_in"` ExpiresIn int64 `json:"expires_in"`
} }
type UpdateSupportStatusRequest struct {
Status string `json:"status" binding:"required,oneof=online offline busy"`
}
+8 -97
View File
@@ -2,23 +2,15 @@ package adminauth
import ( import (
"errors" "errors"
"io"
"net/http" "net/http"
"strings" "strings"
"hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/pkg/response" "hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
const (
adminAccessCookieName = middleware.AdminAccessCookieName
adminRefreshCookieName = "hfb_admin_refresh"
adminRefreshCookieMaxAge = 14 * 24 * 60 * 60
)
type Handler struct { type Handler struct {
service *Service service *Service
} }
@@ -28,7 +20,7 @@ func NewHandler(service *Service) *Handler {
} }
func (h *Handler) Captcha(c *gin.Context) { func (h *Handler) Captcha(c *gin.Context) {
item, err := h.service.Captcha(c.Request.Context()) item, err := h.service.Captcha()
if err != nil { if err != nil {
writeAdminAuthError(c, err) writeAdminAuthError(c, err)
return return
@@ -42,13 +34,12 @@ func (h *Handler) Login(c *gin.Context) {
response.BadRequest(c, "用户名、密码和验证码不能为空") response.BadRequest(c, "用户名、密码和验证码不能为空")
return return
} }
result, err := h.service.Login(c.Request.Context(), strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode), c.ClientIP()) result, err := h.service.Login(strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode))
if err != nil { if err != nil {
writeAdminAuthError(c, err) writeAdminAuthError(c, err)
return return
} }
setAdminAuthCookies(c, result.Tokens) response.OK(c, result)
response.OK(c, gin.H{"admin": result.Admin})
} }
func (h *Handler) Me(c *gin.Context) { func (h *Handler) Me(c *gin.Context) {
@@ -62,7 +53,7 @@ func (h *Handler) Me(c *gin.Context) {
response.Unauthorized(c, "管理员上下文无效") response.Unauthorized(c, "管理员上下文无效")
return return
} }
admin, err := h.service.Me(c.Request.Context(), adminID) admin, err := h.service.Me(adminID)
if err != nil { if err != nil {
writeAdminAuthError(c, err) writeAdminAuthError(c, err)
return return
@@ -71,106 +62,28 @@ func (h *Handler) Me(c *gin.Context) {
} }
func (h *Handler) Logout(c *gin.Context) { func (h *Handler) Logout(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
if err := h.service.Logout(c.Request.Context(), adminID); err != nil {
writeAdminAuthError(c, err)
return
}
clearAdminAuthCookies(c)
response.OK(c, gin.H{"logged_out": true}) response.OK(c, gin.H{"logged_out": true})
} }
func (h *Handler) UpdateSupportStatus(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req UpdateSupportStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "状态值无效,必须是 online、offline 或 busy")
return
}
if err := h.service.UpdateSupportStatus(c.Request.Context(), adminID, req.Status); err != nil {
writeAdminAuthError(c, err)
return
}
response.OK(c, gin.H{"updated": true, "support_status": req.Status})
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
adminID, ok := value.(uint64)
return adminID, ok
}
type AdminRefreshRequest struct { type AdminRefreshRequest struct {
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token" binding:"required"`
} }
func (h *Handler) Refresh(c *gin.Context) { func (h *Handler) Refresh(c *gin.Context) {
var req AdminRefreshRequest var req AdminRefreshRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 { if err := c.ShouldBindJSON(&req); err != nil {
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.BadRequest(c, "refresh_token 格式不正确")
return
}
}
refreshToken := strings.TrimSpace(req.RefreshToken)
refreshTokenSource := "body"
if refreshToken == "" {
if cookieValue, err := c.Cookie(adminRefreshCookieName); err == nil {
refreshToken = strings.TrimSpace(cookieValue)
refreshTokenSource = "cookie"
}
}
if refreshToken == "" {
response.BadRequest(c, "refresh_token 不能为空") response.BadRequest(c, "refresh_token 不能为空")
return return
} }
tokens, err := h.service.Refresh(c.Request.Context(), refreshToken) tokens, err := h.service.Refresh(req.RefreshToken)
if err != nil { if err != nil {
reason, tokenVersion, currentVersion := middleware.AdminValidationFailure(err)
middleware.RecordAdminAuthFailure(c, reason, refreshTokenSource, tokenVersion, currentVersion, middleware.AdminValidationFailureDetail(err))
writeAdminAuthError(c, err) writeAdminAuthError(c, err)
return return
} }
setAdminAuthCookies(c, *tokens) response.OK(c, tokens)
response.OK(c, gin.H{"refreshed": true, "expires_in": tokens.ExpiresInSeconds})
}
func setAdminAuthCookies(c *gin.Context, tokens auth.TokenPair) {
secure := isHTTPSRequest(c)
httpOnly := true
sameSite := http.SameSiteStrictMode
c.SetSameSite(sameSite)
c.SetCookie(adminAccessCookieName, tokens.AccessToken, int(tokens.ExpiresInSeconds), "/api/admin", "", secure, httpOnly)
c.SetCookie(adminRefreshCookieName, tokens.RefreshToken, adminRefreshCookieMaxAge, "/api/admin/auth/refresh", "", secure, httpOnly)
}
func clearAdminAuthCookies(c *gin.Context) {
secure := isHTTPSRequest(c)
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(adminAccessCookieName, "", -1, "/api/admin", "", secure, true)
c.SetCookie(adminRefreshCookieName, "", -1, "/api/admin/auth/refresh", "", secure, true)
}
func isHTTPSRequest(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
} }
func writeAdminAuthError(c *gin.Context, err error) { func writeAdminAuthError(c *gin.Context, err error) {
response.RecordError(c, err)
switch { switch {
case errors.Is(err, ErrDependencyUnavailable): case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接") response.ServiceUnavailable(c, "数据库未连接")
@@ -180,8 +93,6 @@ func writeAdminAuthError(c *gin.Context, err error) {
response.BadRequest(c, "用户名或密码错误") response.BadRequest(c, "用户名或密码错误")
case errors.Is(err, ErrCaptchaInvalid): case errors.Is(err, ErrCaptchaInvalid):
response.BadRequest(c, "验证码错误或已过期") response.BadRequest(c, "验证码错误或已过期")
case errors.Is(err, ErrLoginLocked):
response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请稍后再试")
case errors.Is(err, ErrAdminDisabled): case errors.Is(err, ErrAdminDisabled):
response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用") response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用")
default: default:
+93 -211
View File
@@ -2,15 +2,15 @@ package adminauth
import ( import (
"context" "context"
"encoding/json" "crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"html"
"math/big"
"strings" "strings"
"time" "time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/captcha"
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/auth"
@@ -20,10 +20,9 @@ import (
) )
const ( const (
defaultAdminUsername = "admin"
defaultAdminPassword = "admin123456"
captchaTTL = 3 * time.Minute captchaTTL = 3 * time.Minute
loginFailureTTL = 15 * time.Minute
loginLockTTL = 15 * time.Minute
loginMaxFailureCount = 5
) )
type Repository struct { type Repository struct {
@@ -36,32 +35,39 @@ func NewRepository(db *gorm.DB, redis *redis.Client, jwt *auth.JWTManager) *Repo
return &Repository{db: db, redis: redis, jwt: jwt} return &Repository{db: db, redis: redis, jwt: jwt}
} }
func (r *Repository) Captcha(ctx context.Context) (*CaptchaDTO, error) { func (r *Repository) Captcha() (*CaptchaDTO, error) {
item, err := captcha.Generate(ctx, r.redis, "admin", captchaTTL) if r.redis == nil {
if errors.Is(err, captcha.ErrDependencyUnavailable) {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
captchaID, err := randomToken(16)
if err != nil { if err != nil {
return nil, err return nil, err
} }
code, err := randomCaptchaCode(4)
if err != nil {
return nil, err
}
ctx := context.Background()
if err := r.redis.Set(ctx, captchaKey(captchaID), strings.ToUpper(code), captchaTTL).Err(); err != nil {
return nil, err
}
return &CaptchaDTO{ return &CaptchaDTO{
CaptchaID: item.CaptchaID, CaptchaID: captchaID,
Image: item.Image, Image: captchaImageDataURL(code),
ExpiresIn: item.ExpiresIn, ExpiresIn: int64(captchaTTL.Seconds()),
}, nil }, nil
} }
func (r *Repository) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) { func (r *Repository) Login(username string, password string, captchaID string, captchaCode string) (LoginResult, error) {
if err := r.verifyCaptcha(ctx, captchaID, captchaCode); err != nil { if err := r.verifyCaptcha(captchaID, captchaCode); err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
if err := r.ensureLoginNotLocked(ctx, username, clientIP); err != nil { if err := r.ensureDefaultAdmin(); err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
var admin model.AdminUser var admin model.AdminUser
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil { if err := r.db.Where("username = ?", username).First(&admin).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
r.recordLoginFailure(ctx, username, clientIP)
return LoginResult{}, ErrInvalidCredential return LoginResult{}, ErrInvalidCredential
} }
return LoginResult{}, err return LoginResult{}, err
@@ -70,239 +76,115 @@ func (r *Repository) Login(ctx context.Context, username string, password string
return LoginResult{}, ErrAdminDisabled return LoginResult{}, ErrAdminDisabled
} }
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
r.recordLoginFailure(ctx, username, clientIP)
return LoginResult{}, ErrInvalidCredential return LoginResult{}, ErrInvalidCredential
} }
now := time.Now() now := time.Now()
admin.LastLoginAt = &now admin.LastLoginAt = &now
if admin.TokenVersion <= 0 { if err := r.db.Save(&admin).Error; err != nil {
admin.TokenVersion = 1
}
if err := r.db.WithContext(ctx).Save(&admin).Error; err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
r.clearLoginFailures(ctx, username, clientIP) tokens, err := r.jwt.GenerateSubjectPair(admin.ID, admin.Username, "admin")
tokens, err := r.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
dto := toDTO(admin) return LoginResult{Admin: toDTO(admin), Tokens: tokens}, nil
r.loadRolesAndPerms(ctx, &dto)
return LoginResult{Admin: dto, Tokens: tokens}, nil
} }
func (r *Repository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.AdminUser, error) { func (r *Repository) verifyCaptcha(captchaID string, captchaCode string) error {
if r.db == nil { if r.redis == nil {
return nil, newTokenDependencyError(ErrDependencyUnavailable)
}
var admin model.AdminUser
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, newTokenValidationError("admin_not_found", ErrAdminNotFound)
}
return nil, newTokenDependencyError(err)
}
if admin.Status != "active" {
return nil, newTokenValidationError("admin_disabled", ErrAdminDisabled)
}
if admin.TokenVersion <= 0 || admin.TokenVersion != tokenVersion {
return nil, newTokenVersionMismatchError(tokenVersion, admin.TokenVersion)
}
return &admin, nil
}
func (r *Repository) RevokeTokens(ctx context.Context, adminID uint64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.AdminUser{}).
Where("id = ?", adminID).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil {
return err
}
return appendTokenVersionAudit(tx, ctx, adminID, "logout")
})
}
func appendTokenVersionAudit(tx *gorm.DB, ctx context.Context, adminID uint64, reason string) error {
bizID := adminID
return auditlog.Append(tx, auditlog.Entry{
ActorType: "admin",
ActorID: logging.AdminIDFromContext(ctx),
Action: "auth.token_version.bump",
BizType: "admin_user",
BizID: &bizID,
Meta: auditlog.Meta{RequestID: logging.RequestIDFromContext(ctx)},
Detail: map[string]any{
"target_admin_id": adminID,
"reason": reason,
},
})
}
func (r *Repository) verifyCaptcha(ctx context.Context, captchaID string, captchaCode string) error {
err := captcha.Verify(ctx, r.redis, "admin", captchaID, captchaCode)
if errors.Is(err, captcha.ErrDependencyUnavailable) {
return ErrDependencyUnavailable return ErrDependencyUnavailable
} }
if errors.Is(err, captcha.ErrInvalid) { ctx := context.Background()
key := captchaKey(captchaID)
stored, err := r.redis.Get(ctx, key).Result()
if errors.Is(err, redis.Nil) {
return ErrCaptchaInvalid return ErrCaptchaInvalid
} }
return err if err != nil {
return err
}
_ = r.redis.Del(ctx, key).Err()
if strings.ToUpper(strings.TrimSpace(captchaCode)) != stored {
return ErrCaptchaInvalid
}
return nil
} }
func (r *Repository) FindByID(ctx context.Context, id uint64) (*AdminDTO, error) { func (r *Repository) FindByID(id uint64) (*AdminDTO, error) {
var admin model.AdminUser var admin model.AdminUser
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil { if err := r.db.First(&admin, id).Error; err != nil {
return nil, err return nil, err
} }
if admin.Status != "active" { if admin.Status != "active" {
return nil, ErrAdminDisabled return nil, ErrAdminDisabled
} }
dto := toDTO(admin) dto := toDTO(admin)
r.loadRolesAndPerms(ctx, &dto)
return &dto, nil return &dto, nil
} }
func (r *Repository) FindActiveForPasswordGate(ctx context.Context, id uint64, tokenVersion int64) (*AdminDTO, error) { func (r *Repository) ensureDefaultAdmin() error {
admin, err := r.FindActiveForToken(ctx, id, tokenVersion) var count int64
if err := r.db.Model(&model.AdminUser{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(defaultAdminPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, err return err
} }
// 认证只需要用户名和强制改密标识;复用已校验的查询结果,避免每个后台请求再查询一次数据库。 admin := model.AdminUser{
return &AdminDTO{ Username: defaultAdminUsername,
Username: admin.Username, PasswordHash: string(hash),
PasswordMustChange: admin.PasswordMustChange, Nickname: "超级管理员",
}, nil Status: "active",
}
func (r *Repository) UpdateSupportStatus(ctx context.Context, adminID uint64, status string) error {
if r.db == nil {
return ErrDependencyUnavailable
} }
if status != "online" && status != "offline" && status != "busy" { return r.db.Create(&admin).Error
return errors.New("invalid support status")
}
return r.db.WithContext(ctx).Model(&model.AdminUser{}).
Where("id = ?", adminID).
Update("support_status", status).Error
} }
func toDTO(admin model.AdminUser) AdminDTO { func toDTO(admin model.AdminUser) AdminDTO {
return AdminDTO{ return AdminDTO{
ID: admin.ID, ID: admin.ID,
Username: admin.Username, Username: admin.Username,
Nickname: admin.Nickname, Nickname: admin.Nickname,
Status: admin.Status, Status: admin.Status,
SupportStatus: admin.SupportStatus, LastLoginAt: admin.LastLoginAt,
PasswordMustChange: admin.PasswordMustChange,
LastLoginAt: admin.LastLoginAt,
} }
} }
func (r *Repository) loadRolesAndPerms(ctx context.Context, dto *AdminDTO) { func captchaKey(id string) string {
if r.db == nil { return "admin:captcha:" + id
return }
}
// 加载角色
var roles []RoleDTO
r.db.WithContext(ctx).Table("roles").
Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id").
Where("aur.admin_user_id = ?", dto.ID).
Find(&roles)
dto.Roles = roles
dto.PasswordMustChange = dto.PasswordMustChange && rolesRequireInitialPasswordChange(roles)
// 加载权限 func randomToken(length int) (string, error) {
for _, role := range roles { buf := make([]byte, length)
if role.Code == "super_admin" { if _, err := rand.Read(buf); err != nil {
dto.Permissions = []string{"*"} return "", err
cachePermissions(ctx, r, dto.ID, dto.Permissions) }
return return base64.RawURLEncoding.EncodeToString(buf), nil
}
func randomCaptchaCode(length int) (string, error) {
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
result := make([]byte, length)
for i := range result {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
return "", err
} }
result[i] = alphabet[n.Int64()]
} }
return string(result), nil
var permCodes []string
r.db.WithContext(ctx).Table("permissions").
Select("DISTINCT permissions.code").
Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id").
Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id").
Where("aur.admin_user_id = ?", dto.ID).
Pluck("code", &permCodes)
dto.Permissions = permCodes
// 缓存权限到 Redis
cachePermissions(ctx, r, dto.ID, permCodes)
} }
func rolesRequireInitialPasswordChange(roles []RoleDTO) bool { func captchaImageDataURL(code string) string {
for _, role := range roles { safeCode := html.EscapeString(strings.ToUpper(code))
if role.Code == "super_admin" { svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="132" height="44" viewBox="0 0 132 44">
return true <rect width="132" height="44" rx="8" fill="#eef5f7"/>
} <path d="M8 32 C32 2, 62 52, 124 12" stroke="#0f766e" stroke-width="2" fill="none" opacity=".28"/>
} <path d="M10 13 C42 42, 86 0, 122 31" stroke="#2563eb" stroke-width="2" fill="none" opacity=".22"/>
return false <text x="66" y="29" text-anchor="middle" font-family="Menlo,Consolas,monospace" font-size="24" font-weight="700" letter-spacing="4" fill="#111827">%s</text>
} </svg>`, safeCode)
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
func cachePermissions(ctx context.Context, r *Repository, adminID uint64, permCodes []string) {
if r.redis == nil || len(permCodes) == 0 {
return
}
key := fmt.Sprintf("admin:perms:%d", adminID)
raw, _ := json.Marshal(permCodes)
r.redis.Set(ctx, key, string(raw), 2*time.Hour)
}
func (r *Repository) ensureLoginNotLocked(ctx context.Context, username string, clientIP string) error {
if r.redis == nil {
return nil
}
locked, err := r.redis.Exists(ctx, loginLockKey(username, clientIP)).Result()
if err != nil {
return err
}
if locked > 0 {
return ErrLoginLocked
}
return nil
}
func (r *Repository) recordLoginFailure(ctx context.Context, username string, clientIP string) {
if r.redis == nil {
return
}
key := loginFailureKey(username, clientIP)
count, err := r.redis.Incr(ctx, key).Result()
if err != nil {
return
}
if count == 1 {
_ = r.redis.Expire(ctx, key, loginFailureTTL).Err()
}
if count >= loginMaxFailureCount {
_ = r.redis.Set(ctx, loginLockKey(username, clientIP), "1", loginLockTTL).Err()
}
}
func (r *Repository) clearLoginFailures(ctx context.Context, username string, clientIP string) {
if r.redis == nil {
return
}
_ = r.redis.Del(ctx, loginFailureKey(username, clientIP), loginLockKey(username, clientIP)).Err()
}
func loginFailureKey(username string, clientIP string) string {
return "admin:login:fail:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username)
}
func loginLockKey(username string, clientIP string) string {
return "admin:login:lock:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username)
}
func loginKeyPart(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, ":", "_")
value = strings.ReplaceAll(value, "/", "_")
if value == "" {
return "_"
}
return value
} }
@@ -1,35 +0,0 @@
package adminauth
import "testing"
func TestRolesRequireInitialPasswordChange(t *testing.T) {
tests := []struct {
name string
roles []RoleDTO
want bool
}{
{
name: "超级管理员需要修改初始密码",
roles: []RoleDTO{{Code: "super_admin", Name: "超级管理员"}},
want: true,
},
{
name: "客服不强制修改初始密码",
roles: []RoleDTO{{Code: "cs", Name: "客服"}},
want: false,
},
{
name: "运营不强制修改初始密码",
roles: []RoleDTO{{Code: "ops", Name: "运营"}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := rolesRequireInitialPasswordChange(tt.roles); got != tt.want {
t.Fatalf("rolesRequireInitialPasswordChange() = %v, want %v", got, tt.want)
}
})
}
}
+9 -121
View File
@@ -1,9 +1,7 @@
package adminauth package adminauth
import ( import (
"context"
"errors" "errors"
"strings"
"hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/auth"
) )
@@ -14,161 +12,51 @@ var (
ErrCaptchaInvalid = errors.New("captcha invalid") ErrCaptchaInvalid = errors.New("captcha invalid")
ErrAdminDisabled = errors.New("admin disabled") ErrAdminDisabled = errors.New("admin disabled")
ErrInvalidRefreshToken = errors.New("invalid refresh token") ErrInvalidRefreshToken = errors.New("invalid refresh token")
ErrLoginLocked = errors.New("login locked")
ErrAdminNotFound = errors.New("admin not found")
ErrTokenVersionMismatch = errors.New("admin token version mismatch")
) )
// TokenValidationError 为后台会话失败保留安全的诊断类别,响应仍使用统一文案。
type TokenValidationError struct {
Reason string
TokenVersion int64
CurrentVersion int64
Detail string
Unavailable bool
err error
}
func (e *TokenValidationError) Error() string {
return "admin token validation failed: " + e.Reason
}
func (e *TokenValidationError) Unwrap() error {
return e.err
}
// AuthFailureReason 供中间件读取失败类别并写入结构化日志。
func (e *TokenValidationError) AuthFailureReason() string {
return e.Reason
}
// AuthFailureVersions 返回令牌声明版本和数据库当前版本;非版本不匹配时均为 0。
func (e *TokenValidationError) AuthFailureVersions() (int64, int64) {
return e.TokenVersion, e.CurrentVersion
}
// AuthFailureDetail 返回仅供服务端日志使用的依赖错误摘要,不包含令牌内容。
func (e *TokenValidationError) AuthFailureDetail() string {
return e.Detail
}
// AuthFailureUnavailable 标识当前失败是否由临时依赖故障引起。
func (e *TokenValidationError) AuthFailureUnavailable() bool {
return e.Unavailable
}
func newTokenValidationError(reason string, err error) error {
return &TokenValidationError{Reason: reason, err: err}
}
func newTokenDependencyError(err error) error {
detail := strings.Join(strings.Fields(err.Error()), " ")
if len(detail) > 240 {
detail = detail[:240]
}
return &TokenValidationError{
Reason: "admin_db_query_failed",
Detail: detail,
Unavailable: true,
err: errors.Join(ErrDependencyUnavailable, err),
}
}
func newTokenVersionMismatchError(tokenVersion, currentVersion int64) error {
return &TokenValidationError{
Reason: "token_version_mismatch",
TokenVersion: tokenVersion,
CurrentVersion: currentVersion,
err: ErrTokenVersionMismatch,
}
}
type Service struct { type Service struct {
repo *Repository repo *Repository
jwt *auth.JWTManager jwt *auth.JWTManager
} }
func NewService(repo *Repository, jwt *auth.JWTManager) *Service { func NewService(repo *Repository, jwt *auth.JWTManager) *Service {
return &Service{repo: repo, jwt: jwt} return &Service{repo: repo, jwt: jwt}
} }
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.TokenPair, error) { func (s *Service) Refresh(refreshToken string) (*auth.TokenPair, error) {
if s.jwt == nil { if s.jwt == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
claims, err := s.jwt.ParseSubject(refreshToken, "refresh", "admin") claims, err := s.jwt.ParseSubject(refreshToken, "refresh", "admin")
if err != nil { if err != nil {
return nil, newTokenValidationError(auth.TokenFailureReason(err), ErrInvalidRefreshToken) return nil, ErrInvalidRefreshToken
} }
if s.repo == nil { pair, err := s.jwt.GenerateSubjectPair(claims.UserID, claims.Phone, "admin")
return nil, ErrDependencyUnavailable
}
admin, err := s.repo.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion)
if err != nil {
if errors.Is(err, ErrDependencyUnavailable) {
return nil, err
}
if errors.Is(err, ErrAdminDisabled) {
return nil, ErrAdminDisabled
}
return nil, invalidRefreshTokenError(err)
}
pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &pair, nil return &pair, nil
} }
func invalidRefreshTokenError(err error) error { func (s *Service) Captcha() (*CaptchaDTO, error) {
reason := "invalid"
var tokenErr *TokenValidationError
if errors.As(err, &tokenErr) {
reason = tokenErr.Reason
return &TokenValidationError{
Reason: reason,
TokenVersion: tokenErr.TokenVersion,
CurrentVersion: tokenErr.CurrentVersion,
err: ErrInvalidRefreshToken,
}
}
return newTokenValidationError(reason, ErrInvalidRefreshToken)
}
func (s *Service) Captcha(ctx context.Context) (*CaptchaDTO, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
return s.repo.Captcha(ctx) return s.repo.Captcha()
} }
func (s *Service) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) { func (s *Service) Login(username string, password string, captchaID string, captchaCode string) (LoginResult, error) {
if s.repo == nil { if s.repo == nil {
return LoginResult{}, ErrDependencyUnavailable return LoginResult{}, ErrDependencyUnavailable
} }
if username == "" || password == "" || captchaID == "" || captchaCode == "" { if username == "" || password == "" || captchaID == "" || captchaCode == "" {
return LoginResult{}, ErrInvalidCredential return LoginResult{}, ErrInvalidCredential
} }
return s.repo.Login(ctx, username, password, captchaID, captchaCode, clientIP) return s.repo.Login(username, password, captchaID, captchaCode)
} }
func (s *Service) Me(ctx context.Context, adminID uint64) (*AdminDTO, error) { func (s *Service) Me(adminID uint64) (*AdminDTO, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
return s.repo.FindByID(ctx, adminID) return s.repo.FindByID(adminID)
}
func (s *Service) UpdateSupportStatus(ctx context.Context, adminID uint64, status string) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.UpdateSupportStatus(ctx, adminID, status)
}
func (s *Service) Logout(ctx context.Context, adminID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.RevokeTokens(ctx, adminID)
} }
+22 -48
View File
@@ -3,48 +3,22 @@ package admindashboard
import "time" import "time"
type DashboardDTO struct { type DashboardDTO struct {
Metrics MetricsDTO `json:"metrics"` Metrics MetricsDTO `json:"metrics"`
Pending PendingDTO `json:"pending"` Pending PendingDTO `json:"pending"`
ListingDaily ListingDailyOverviewDTO `json:"listing_daily"` RecentOrders []RecentOrderDTO `json:"recent_orders"`
RecentOrders []RecentOrderDTO `json:"recent_orders"` RecentDisputes []RecentDisputeDTO `json:"recent_disputes"`
RecentDisputes []RecentDisputeDTO `json:"recent_disputes"` GeneratedAt time.Time `json:"generated_at"`
GeneratedAt time.Time `json:"generated_at"`
} }
type MetricsDTO struct { type MetricsDTO struct {
TotalUsers int64 `json:"total_users"` TotalUsers int64 `json:"total_users"`
VerifiedUsers int64 `json:"verified_users"` VerifiedUsers int64 `json:"verified_users"`
TotalListings int64 `json:"total_listings"` TotalListings int64 `json:"total_listings"`
PublishedListings int64 `json:"published_listings"` PublishedListings int64 `json:"published_listings"`
TotalOrders int64 `json:"total_orders"` TotalOrders int64 `json:"total_orders"`
RentingOrders int64 `json:"renting_orders"` RentingOrders int64 `json:"renting_orders"`
TodayOrders int64 `json:"today_orders"` TodayOrders int64 `json:"today_orders"`
TodayLedgerAmountCent int64 `json:"today_ledger_amount_cent"` TodayLedgerAmount float64 `json:"today_ledger_amount"`
}
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
type ListingDailyOverviewDTO struct {
Today ListingDayStatsDTO `json:"today"`
Trend []ListingDayStatsDTO `json:"trend"`
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
Days int `json:"days"`
Timezone string `json:"timezone"`
}
type ListingDayStatsDTO struct {
Date string `json:"date"` // YYYY-MM-DD(上海时区)
PublishedCount int64 `json:"published_count"`
ActiveOfflineCount int64 `json:"active_offline_count"` // 号主 + 后台主动下架
TradeLeaveCount int64 `json:"trade_leave_count"` // rented / completed / sealed / 交易侧归档下架
}
type ListingChannelDayStatsDTO struct {
Date string `json:"date"` // YYYY-MM-DD(上海时区)
SourceChannel string `json:"source_channel"`
PublishedCount int64 `json:"published_count"`
ActiveOfflineCount int64 `json:"active_offline_count"`
TradeLeaveCount int64 `json:"trade_leave_count"`
} }
type PendingDTO struct { type PendingDTO struct {
@@ -55,15 +29,15 @@ type PendingDTO struct {
} }
type RecentOrderDTO struct { type RecentOrderDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
OrderNo string `json:"order_no"` OrderNo string `json:"order_no"`
Title string `json:"title"` Title string `json:"title"`
RenterID uint64 `json:"renter_id"` RenterID uint64 `json:"renter_id"`
OwnerID uint64 `json:"owner_id"` OwnerID uint64 `json:"owner_id"`
Status string `json:"status"` Status string `json:"status"`
RentAmountCent int64 `json:"rent_amount_cent"` RentAmount float64 `json:"rent_amount"`
DepositAmountCent int64 `json:"deposit_amount_cent"` DepositAmount float64 `json:"deposit_amount"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type RecentDisputeDTO struct { type RecentDisputeDTO struct {
@@ -18,7 +18,7 @@ func NewHandler(service *Service) *Handler {
} }
func (h *Handler) Summary(c *gin.Context) { func (h *Handler) Summary(c *gin.Context) {
item, err := h.service.Summary(c.Request.Context()) item, err := h.service.Summary()
if err != nil { if err != nil {
writeDashboardError(c, err) writeDashboardError(c, err)
return return
@@ -27,7 +27,6 @@ func (h *Handler) Summary(c *gin.Context) {
} }
func writeDashboardError(c *gin.Context, err error) { func writeDashboardError(c *gin.Context, err error) {
response.RecordError(c, err)
switch { switch {
case errors.Is(err, ErrDependencyUnavailable): case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接") response.ServiceUnavailable(c, "数据库未连接")
@@ -1,23 +1,13 @@
package admindashboard package admindashboard
import ( import (
"context"
"sort"
"time" "time"
"hfb_sys/backend/internal/listingstatus"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/timeutil"
"gorm.io/gorm" "gorm.io/gorm"
) )
const (
listingDailyTrendDays = 7
listingChannelWebsite = "站内发布"
listingChannelExternalUnknown = "未填写"
)
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
} }
@@ -26,268 +16,72 @@ func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db} return &Repository{db: db}
} }
func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) { func (r *Repository) Summary() (*DashboardDTO, error) {
db := r.db.WithContext(ctx) now := time.Now()
loc := timeutil.ShanghaiLocation() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
now := timeutil.ShanghaiNow()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
metrics := MetricsDTO{} metrics := MetricsDTO{}
pending := PendingDTO{} pending := PendingDTO{}
if err := db.Model(&model.User{}).Count(&metrics.TotalUsers).Error; err != nil { if err := r.db.Model(&model.User{}).Count(&metrics.TotalUsers).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.User{}).Where("realname_status = ?", "verified").Count(&metrics.VerifiedUsers).Error; err != nil { if err := r.db.Model(&model.User{}).Where("realname_status = ?", "verified").Count(&metrics.VerifiedUsers).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.RentalListing{}).Count(&metrics.TotalListings).Error; err != nil { if err := r.db.Model(&model.RentalListing{}).Count(&metrics.TotalListings).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.RentalListing{}).Where("status = ? AND review_status = ?", "published", "approved").Count(&metrics.PublishedListings).Error; err != nil { if err := r.db.Model(&model.RentalListing{}).Where("status = ? AND review_status = ?", "published", "approved").Count(&metrics.PublishedListings).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.RentalOrder{}).Count(&metrics.TotalOrders).Error; err != nil { if err := r.db.Model(&model.RentalOrder{}).Count(&metrics.TotalOrders).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.RentalOrder{}).Where("status = ?", "renting").Count(&metrics.RentingOrders).Error; err != nil { if err := r.db.Model(&model.RentalOrder{}).Where("status = ?", "renting").Count(&metrics.RentingOrders).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.RentalOrder{}).Where("created_at >= ?", today).Count(&metrics.TodayOrders).Error; err != nil { if err := r.db.Model(&model.RentalOrder{}).Where("created_at >= ?", today).Count(&metrics.TodayOrders).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.WalletLedger{}). if err := r.db.Model(&model.WalletLedger{}).
Select("COALESCE(SUM(amount_cent), 0)"). Select("COALESCE(SUM(amount), 0)").
Where("created_at >= ?", today). Where("created_at >= ?", today).
Scan(&metrics.TodayLedgerAmountCent).Error; err != nil { Scan(&metrics.TodayLedgerAmount).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.RentalListing{}).Where("review_status = ?", "pending").Count(&pending.ListingReviews).Error; err != nil { if err := r.db.Model(&model.RentalListing{}).Where("review_status = ?", "pending").Count(&pending.ListingReviews).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.Dispute{}).Where("status IN ?", []string{"open", "processing"}).Count(&pending.Disputes).Error; err != nil { if err := r.db.Model(&model.Dispute{}).Where("status IN ?", []string{"open", "processing"}).Count(&pending.Disputes).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil { if err := r.db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil {
return nil, err return nil, err
} }
if err := db.Model(&model.RentalOrder{}).Where("status IN ?", []string{"pending_checkout_confirm", "pending_checkout_accept"}).Count(&pending.PendingReturnConfirms).Error; err != nil { if err := r.db.Model(&model.RentalOrder{}).Where("status IN ?", []string{"pending_checkout_confirm", "pending_checkout_accept"}).Count(&pending.PendingReturnConfirms).Error; err != nil {
return nil, err return nil, err
} }
recentOrders, err := r.recentOrders()
listingDaily, err := r.listingDailyOverview(ctx, now, listingDailyTrendDays)
if err != nil { if err != nil {
return nil, err return nil, err
} }
recentOrders, err := r.recentOrders(ctx) recentDisputes, err := r.recentDisputes()
if err != nil {
return nil, err
}
recentDisputes, err := r.recentDisputes(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &DashboardDTO{ return &DashboardDTO{
Metrics: metrics, Metrics: metrics,
Pending: pending, Pending: pending,
ListingDaily: listingDaily,
RecentOrders: recentOrders, RecentOrders: recentOrders,
RecentDisputes: recentDisputes, RecentDisputes: recentDisputes,
GeneratedAt: now, GeneratedAt: now,
}, nil }, nil
} }
func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, days int) (ListingDailyOverviewDTO, error) { func (r *Repository) recentOrders() ([]RecentOrderDTO, error) {
if days <= 0 { var rows []RecentOrderDTO
days = listingDailyTrendDays err := r.db.Table("rental_orders AS o").
} Select("o.id, o.order_no, a.title, o.renter_id, o.owner_id, o.status, o.rent_amount, o.deposit_amount, o.created_at").
loc := timeutil.ShanghaiLocation()
now = now.In(loc)
// 右开区间 [start, end)
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
start := end.AddDate(0, 0, -days)
type eventRow struct {
EventType string
Source string
SourceChannel string
CreatedAt time.Time
}
events := make([]eventRow, 0)
db := r.db.WithContext(ctx)
uploadSource := db.Table("listing_uploads").
Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel").
Where("listing_id IS NOT NULL").
Group("listing_id")
if err := db.Table("listing_status_events AS e").
Select(`e.event_type, e.source, e.created_at,
CASE
WHEN lu.upload_id IS NULL THEN ?
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
ELSE lu.source_channel
END AS source_channel`, listingChannelWebsite, listingChannelExternalUnknown).
Joins("LEFT JOIN (?) AS lu ON lu.listing_id = e.listing_id", uploadSource).
Where("e.created_at >= ? AND e.created_at < ?", start, end).
Scan(&events).Error; err != nil {
return ListingDailyOverviewDTO{}, err
}
byDay := make(map[string]*listingDailyBucket, days)
byChannel := make(map[listingChannelKey]*listingDailyBucket)
for _, ev := range events {
key := ev.CreatedAt.In(loc).Format("2006-01-02")
b := byDay[key]
if b == nil {
b = &listingDailyBucket{}
byDay[key] = b
}
if !addListingDailyEvent(b, ev.EventType, ev.Source) {
continue
}
channel := ev.SourceChannel
channelBucketKey := listingChannelKey{date: key, sourceChannel: channel}
cb := byChannel[channelBucketKey]
if cb == nil {
cb = &listingDailyBucket{}
byChannel[channelBucketKey] = cb
}
addListingDailyEvent(cb, ev.EventType, ev.Source)
}
trend := make([]ListingDayStatsDTO, 0, days)
for i := days - 1; i >= 0; i-- {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -i)
key := day.Format("2006-01-02")
item := ListingDayStatsDTO{Date: key}
if b := byDay[key]; b != nil {
item.PublishedCount = b.published
item.ActiveOfflineCount = b.activeOffline
item.TradeLeaveCount = b.tradeLeave
}
trend = append(trend, item)
}
todayStats := ListingDayStatsDTO{Date: now.Format("2006-01-02")}
if len(trend) > 0 {
todayStats = trend[len(trend)-1]
}
channelTrend := listingChannelTrend(byChannel)
todayChannels := make([]ListingChannelDayStatsDTO, 0)
for _, item := range channelTrend {
if item.Date == todayStats.Date {
todayChannels = append(todayChannels, item)
}
}
return ListingDailyOverviewDTO{
Today: todayStats,
Trend: trend,
TodayChannels: todayChannels,
ChannelTrend: channelTrend,
Days: days,
Timezone: "Asia/Shanghai",
}, nil
}
type listingChannelKey struct {
date string
sourceChannel string
}
type listingDailyBucket struct {
published int64
activeOffline int64
tradeLeave int64
}
func addListingDailyEvent(b *listingDailyBucket, eventType, source string) bool {
switch {
case eventType == listingstatus.EventPublished:
b.published++
return true
case isActiveOfflineEvent(eventType, source):
b.activeOffline++
return true
case isTradeLeaveEvent(eventType, source):
b.tradeLeave++
return true
default:
return false
}
}
func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []ListingChannelDayStatsDTO {
keys := make([]listingChannelKey, 0, len(rows))
for key := range rows {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].date != keys[j].date {
return keys[i].date < keys[j].date
}
leftRank := listingChannelRank(keys[i].sourceChannel)
rightRank := listingChannelRank(keys[j].sourceChannel)
if leftRank != rightRank {
return leftRank < rightRank
}
return keys[i].sourceChannel < keys[j].sourceChannel
})
items := make([]ListingChannelDayStatsDTO, 0, len(keys))
for _, key := range keys {
b := rows[key]
items = append(items, ListingChannelDayStatsDTO{
Date: key.date,
SourceChannel: key.sourceChannel,
PublishedCount: b.published,
ActiveOfflineCount: b.activeOffline,
TradeLeaveCount: b.tradeLeave,
})
}
return items
}
func listingChannelRank(channel string) int {
switch channel {
case "咸鱼":
return 10
case "淘宝":
return 20
case "京东":
return 30
case "QQ":
return 40
case "微信":
return 50
case listingChannelExternalUnknown:
return 90
case listingChannelWebsite:
return 100
default:
return 80
}
}
func isActiveOfflineEvent(eventType, source string) bool {
return eventType == listingstatus.EventOffline &&
(source == listingstatus.SourceSeller || source == listingstatus.SourceAdmin)
}
func isTradeLeaveEvent(eventType, source string) bool {
switch eventType {
case listingstatus.EventRented, listingstatus.EventCompleted, listingstatus.EventSealed:
return true
case listingstatus.EventOffline:
return source == listingstatus.SourceOrder ||
source == listingstatus.SourceDispute ||
source == listingstatus.SourcePickup ||
source == listingstatus.SourceSystem
default:
return false
}
}
func (r *Repository) recentOrders(ctx context.Context) ([]RecentOrderDTO, error) {
rows := make([]RecentOrderDTO, 0)
err := r.db.WithContext(ctx).Table("rental_orders AS o").
Select("o.id, o.order_no, a.title, o.renter_id, o.owner_id, o.status, o.rent_amount_cent, o.deposit_amount_cent, o.created_at").
Joins("JOIN game_accounts AS a ON a.id = o.account_id"). Joins("JOIN game_accounts AS a ON a.id = o.account_id").
Order("o.id DESC"). Order("o.id DESC").
Limit(8). Limit(8).
@@ -295,9 +89,9 @@ func (r *Repository) recentOrders(ctx context.Context) ([]RecentOrderDTO, error)
return rows, err return rows, err
} }
func (r *Repository) recentDisputes(ctx context.Context) ([]RecentDisputeDTO, error) { func (r *Repository) recentDisputes() ([]RecentDisputeDTO, error) {
rows := make([]RecentDisputeDTO, 0) var rows []RecentDisputeDTO
err := r.db.WithContext(ctx).Table("disputes AS d"). err := r.db.Table("disputes AS d").
Select("d.id, d.order_id, o.order_no, a.title, d.type, d.status, d.initiator_id, d.created_at"). Select("d.id, d.order_id, o.order_no, a.title, d.type, d.status, d.initiator_id, d.created_at").
Joins("JOIN rental_orders AS o ON o.id = d.order_id"). Joins("JOIN rental_orders AS o ON o.id = d.order_id").
Joins("JOIN game_accounts AS a ON a.id = o.account_id"). Joins("JOIN game_accounts AS a ON a.id = o.account_id").

Some files were not shown because too many files have changed in this diff Show More