Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b156b5abf | ||
|
|
cdee93c7c5 | ||
|
|
3631e70321 | ||
|
|
25e5599e0a |
@@ -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
@@ -1,18 +1,14 @@
|
||||
.snow
|
||||
.vscode
|
||||
.idea
|
||||
.claude
|
||||
*.log
|
||||
backend/logs/
|
||||
*.tmp
|
||||
*.bak
|
||||
.Ds_Store
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.prod.example
|
||||
|
||||
# Go
|
||||
bin/
|
||||
@@ -23,20 +19,6 @@ node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
# unplugin-vue-components 自动生成,随组件使用情况变动,不纳入版本控制
|
||||
frontend/components.d.ts
|
||||
|
||||
# Docker / local 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
|
||||
|
||||
@@ -18,21 +18,19 @@
|
||||
|
||||
脚本会自动启动 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
|
||||
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
|
||||
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
|
||||
|
||||
cd ../frontend
|
||||
@@ -40,21 +38,9 @@ npm install
|
||||
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 适配器,本地验证码会以 INFO 日志输出,手机号保持脱敏。生产环境强制使用 `SMS_PROVIDER=aliyun`。
|
||||
- 阿里云短信需要配置 `ALIYUN_ACCESS_KEY_ID`、`ALIYUN_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME` 和 `ALIYUN_SMS_LOGIN_TEMPLATE_CODE`,模板变量名默认为 `code`。
|
||||
- 短信验证码使用 mock 适配器,验证码会打印在后端日志中。
|
||||
- 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。
|
||||
- 实名状态可通过 `GET /api/realname/status` 查询。
|
||||
- 租号发布需要登录并完成实名认证;`POST /api/listings/{id}/submit-review` 会进入待审核,后台通过后才上架。
|
||||
@@ -63,21 +49,20 @@ npm run dev
|
||||
- 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。
|
||||
- 后端已接入开发态订单超时扫描任务,会按系统配置处理交接超时、确认收号超时、逾期未归还和确认归还超时。
|
||||
- 文件上传已接入 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` 查看。
|
||||
- 后台资金流水已接入,支持按用户、订单和业务类型查询。
|
||||
- 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。
|
||||
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
|
||||
- 订单群聊已支持支付成功后自动创建,租客、号主和客服可在移动端消息页进入会话。
|
||||
- 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理。
|
||||
- 系统配置已支持默认配置初始化和后台编辑,更新会写入审计日志。
|
||||
- 后台已使用独立登录、图形验证码和独立管理 UI,页面入口由 `VITE_ADMIN_BASE_PATH` 控制。
|
||||
- 后台仪表盘已接入真实统计数据。
|
||||
- 用户管理后台已接入,支持冻结和解冻用户。
|
||||
- 订单管理后台已接入,支持查看全量订单和交接记录。
|
||||
- 商品管理后台已接入,支持查看全量商品、商品详情、强制下架和标记异常。
|
||||
- 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理,后台页面为 `http://localhost:5173/admin/disputes`。
|
||||
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
|
||||
- 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。
|
||||
- 后台仪表盘已接入真实统计数据,页面为 `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`,支持查看高风险操作明细。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -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
@@ -1,87 +1,13 @@
|
||||
APP_ENV=development
|
||||
APP_ADDR=:18088
|
||||
APP_ADDR=:8080
|
||||
|
||||
# MySQL 容器初始化变量,同时供后端 DSN 使用。
|
||||
MYSQL_ROOT_PASSWORD=rootsecret
|
||||
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
|
||||
MYSQL_DSN=hfb:secret@tcp(127.0.0.1:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
|
||||
REDIS_ADDR=127.0.0.1:6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
JWT_SECRET=change-me
|
||||
|
||||
# 前端后台页面入口;本地默认 /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_ENDPOINT=http://localhost:9000
|
||||
STORAGE_BUCKET=hfb-sys
|
||||
STORAGE_ACCESS_KEY_ID=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=
|
||||
|
||||
@@ -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 应使用内网 Endpoint:https://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
@@ -1,41 +1,14 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
|
||||
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* ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--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
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/hfb-api ./cmd/api
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
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-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
|
||||
CMD ["/app/hfb-api"]
|
||||
|
||||
+12
-86
@@ -2,8 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -12,66 +10,31 @@ import (
|
||||
|
||||
"hfb_sys/backend/internal/config"
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
|
||||
"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"
|
||||
|
||||
"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() {
|
||||
cfg := config.Load()
|
||||
logger, err := logging.New(cfg.Log)
|
||||
logger, err := zap.NewProduction()
|
||||
if cfg.AppEnv == "development" {
|
||||
logger, err = zap.NewDevelopment()
|
||||
}
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = logger.Sync()
|
||||
}()
|
||||
if err := cfg.ValidateProductionSecurity(); err != nil {
|
||||
logger.Fatal("生产安全配置校验失败", zap.Error(err))
|
||||
}
|
||||
logAuthRuntimeIdentity(logger, cfg)
|
||||
|
||||
var deps router.Dependencies
|
||||
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level, logger)
|
||||
db, err := database.OpenMySQL(cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err))
|
||||
logger.Warn("mysql unavailable; database-backed APIs will return 503", zap.Error(err))
|
||||
} else {
|
||||
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{
|
||||
Addr: cfg.RedisAddr,
|
||||
@@ -79,7 +42,7 @@ func main() {
|
||||
DB: cfg.RedisDB,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("Redis 不可用,相关接口将返回 503", zap.Error(err))
|
||||
logger.Warn("redis unavailable; redis-backed APIs will return 503", zap.Error(err))
|
||||
} else {
|
||||
deps.Redis = redisClient
|
||||
defer func() {
|
||||
@@ -91,12 +54,7 @@ func main() {
|
||||
jobCtx, stopJobs := context.WithCancel(context.Background())
|
||||
defer stopJobs()
|
||||
if deps.DB != nil {
|
||||
ordertimeout.New(deps.DB, deps.Redis, 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)
|
||||
}
|
||||
ordertimeout.New(deps.DB, logger).Start(jobCtx)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: cfg.AppAddr,
|
||||
@@ -105,9 +63,9 @@ func main() {
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
defer cancel()
|
||||
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 服务已停止")
|
||||
}
|
||||
|
||||
// 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)
|
||||
logger.Info("api server stopped")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -3,62 +3,34 @@ module hfb_sys/backend
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1
|
||||
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/gin-gonic/gin v1.11.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/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
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/image v0.32.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
gorm.io/datatypes v1.2.7
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // 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/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.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.7 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/gin-contrib/sse v1.1.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/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // 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/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // 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/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // 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/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/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // 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/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.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.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.27.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
|
||||
+65
-340
@@ -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/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/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
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/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
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/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
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/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -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/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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
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/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
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-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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
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/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.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
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/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/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
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/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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
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/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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
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/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
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/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/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
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/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
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/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.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/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/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/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/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-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/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.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
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/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/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.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.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.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.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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/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/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/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/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/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
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=
|
||||
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/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
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/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
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/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
||||
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
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=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
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=
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -15,20 +13,7 @@ type Config struct {
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
JWTSecret string
|
||||
PaymentConfigEncryptionKey string
|
||||
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 {
|
||||
@@ -36,138 +21,26 @@ type StorageConfig struct {
|
||||
Bucket string
|
||||
AccessKeyID 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 {
|
||||
return Config{
|
||||
AppEnv: getEnv("APP_ENV", "development"),
|
||||
AppAddr: getEnv("APP_ADDR", ":18088"),
|
||||
MySQLDSN: getEnv("MYSQL_DSN", "hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"),
|
||||
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:16379"),
|
||||
AppAddr: getEnv("APP_ADDR", ":8080"),
|
||||
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:6379"),
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
RedisDB: getEnvInt("REDIS_DB", 0),
|
||||
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{
|
||||
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:19090"),
|
||||
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"),
|
||||
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
|
||||
AccessKeyID: getEnv("STORAGE_ACCESS_KEY_ID", "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 {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
@@ -176,24 +49,6 @@ func getEnv(key, fallback string) string {
|
||||
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 {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
@@ -205,41 +60,3 @@ func getEnvInt(key string, fallback int) int {
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -1,30 +1,10 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) {
|
||||
db, err := 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
|
||||
func OpenMySQL(dsn string) (*gorm.DB, error) {
|
||||
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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><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></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><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?mid=1&pu=2</jspay_url></schc></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&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
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,121 +2,40 @@ package ordertimeout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/adminnotification"
|
||||
"hfb_sys/backend/internal/modules/chat"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const orderTimeoutLockKey = "hfb:job:ordertimeout:lock"
|
||||
|
||||
const (
|
||||
defaultRenterRetentionDaysAfterOrderEnd = 5
|
||||
minRenterRetentionDaysAfterOrderEnd = 3
|
||||
maxRenterRetentionDaysAfterOrderEnd = 7
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
interval time.Duration
|
||||
instanceID string
|
||||
}
|
||||
|
||||
type thresholds struct {
|
||||
PendingPaymentTimeoutMinutes int
|
||||
OwnerSubmitTimeoutMinutes int
|
||||
RenterConfirmTimeoutMinutes int
|
||||
ReturnOverdueGraceMinutes 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{
|
||||
db: db,
|
||||
redis: redisClient,
|
||||
logger: logger,
|
||||
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) {
|
||||
if j == nil || j.db == nil {
|
||||
return
|
||||
@@ -131,7 +50,7 @@ func (j *Job) loop(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
j.logger.Debug("订单超时任务已停止")
|
||||
j.logger.Info("order timeout job stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
j.run(ctx)
|
||||
@@ -140,58 +59,46 @@ func (j *Job) loop(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)
|
||||
if err != nil {
|
||||
j.logger.Warn("订单超时任务加载配置失败", zap.Error(err))
|
||||
j.logger.Warn("order timeout job config load failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
handlers := []func(context.Context, time.Time, thresholds) (int, error){
|
||||
j.handlePendingPaymentTimeout,
|
||||
j.handleOwnerSubmitTimeout,
|
||||
j.handleRenterConfirmTimeout,
|
||||
j.handleReturnOverdue,
|
||||
j.handleOwnerReturnConfirmTimeout,
|
||||
j.handleEndedOrderRenterRetention,
|
||||
}
|
||||
total := 0
|
||||
for _, handler := range handlers {
|
||||
count, err := handler(ctx, now, cfg)
|
||||
if err != nil {
|
||||
j.logger.Warn("订单超时处理失败", zap.Error(err))
|
||||
j.logger.Warn("order timeout handler failed", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
total += count
|
||||
}
|
||||
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) {
|
||||
cfg := thresholds{
|
||||
PendingPaymentTimeoutMinutes: 15,
|
||||
OwnerSubmitTimeoutMinutes: 30,
|
||||
RenterConfirmTimeoutMinutes: 30,
|
||||
ReturnOverdueGraceMinutes: 10,
|
||||
OwnerReturnConfirmTimeoutMinutes: 120,
|
||||
RenterRetentionDaysAfterOrderEnd: defaultRenterRetentionDaysAfterOrderEnd,
|
||||
}
|
||||
var rows []model.SystemConfig
|
||||
err := j.db.WithContext(ctx).
|
||||
Where("`key` IN ?", []string{
|
||||
"handoff.owner_submit_timeout_minutes",
|
||||
"handoff.renter_confirm_timeout_minutes",
|
||||
"order.pending_payment_timeout_minutes",
|
||||
"order.return_overdue_grace_minutes",
|
||||
"handoff.owner_return_confirm_timeout_minutes",
|
||||
"chat.renter_retention_days_after_order_end",
|
||||
}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
@@ -199,7 +106,7 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
|
||||
}
|
||||
for _, row := range rows {
|
||||
value, err := strconv.Atoi(row.Value)
|
||||
if err != nil || value < 0 {
|
||||
if err != nil || value <= 0 {
|
||||
continue
|
||||
}
|
||||
switch row.Key {
|
||||
@@ -207,137 +114,19 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
|
||||
cfg.OwnerSubmitTimeoutMinutes = value
|
||||
case "handoff.renter_confirm_timeout_minutes":
|
||||
cfg.RenterConfirmTimeoutMinutes = value
|
||||
case "order.pending_payment_timeout_minutes":
|
||||
cfg.PendingPaymentTimeoutMinutes = value
|
||||
case "order.return_overdue_grace_minutes":
|
||||
cfg.ReturnOverdueGraceMinutes = value
|
||||
case "handoff.owner_return_confirm_timeout_minutes":
|
||||
cfg.OwnerReturnConfirmTimeoutMinutes = value
|
||||
case "chat.renter_retention_days_after_order_end":
|
||||
cfg.RenterRetentionDaysAfterOrderEnd = clampRenterRetentionDays(value)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if cfg.OwnerSubmitTimeoutMinutes <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var rows []model.RentalOrder
|
||||
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").
|
||||
Limit(100).
|
||||
Find(&rows).Error
|
||||
@@ -353,22 +142,6 @@ func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg t
|
||||
before := snapshot(order)
|
||||
order.HandoffStatus = "owner_timeout"
|
||||
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,
|
||||
notification.Entry{
|
||||
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) {
|
||||
if cfg.RenterConfirmTimeoutMinutes <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
deadline := now.Add(-time.Duration(cfg.RenterConfirmTimeoutMinutes) * time.Minute)
|
||||
|
||||
var rows []model.RentalOrder
|
||||
err := j.db.WithContext(ctx).
|
||||
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 {
|
||||
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
|
||||
deadline := now.Add(-time.Duration(cfg.RenterConfirmTimeoutMinutes) * time.Minute)
|
||||
for _, row := range rows {
|
||||
handoff, exists := latestHandoff[row.ID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
// 判断阶段开始时间:优先使用 HandoffStartedAt(管理员重置后刷新的时间)
|
||||
stageStart := handoff.CreatedAt
|
||||
if row.HandoffStartedAt != nil && row.HandoffStartedAt.After(handoff.CreatedAt) {
|
||||
stageStart = *row.HandoffStartedAt
|
||||
}
|
||||
if stageStart.After(deadline) {
|
||||
var handoff model.HandoffRecord
|
||||
err := j.db.WithContext(ctx).
|
||||
Where("order_id = ? AND type = ?", row.ID, "owner_handoff").
|
||||
Order("id DESC").
|
||||
First(&handoff).Error
|
||||
if err != nil || handoff.CreatedAt.After(deadline) {
|
||||
continue
|
||||
}
|
||||
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) {
|
||||
var rows []model.RentalOrder
|
||||
overdueBefore := now.Add(-time.Duration(cfg.ReturnOverdueGraceMinutes) * time.Minute)
|
||||
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").
|
||||
Limit(100).
|
||||
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) {
|
||||
if cfg.OwnerReturnConfirmTimeoutMinutes <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var rows []model.RentalOrder
|
||||
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").
|
||||
Limit(100).
|
||||
Find(&rows).Error
|
||||
@@ -561,22 +298,6 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
|
||||
order.Status = "abnormal"
|
||||
order.HandoffStatus = "owner_checkout_confirm_timeout"
|
||||
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,
|
||||
notification.Entry{
|
||||
UserID: order.RenterID,
|
||||
@@ -606,71 +327,6 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
|
||||
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 {
|
||||
return j.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/logging"
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
@@ -18,47 +14,13 @@ const (
|
||||
ContextPhone = "phone"
|
||||
ContextAdminID = "admin_id"
|
||||
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 {
|
||||
Username string
|
||||
PasswordMustChange bool
|
||||
}
|
||||
|
||||
type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error)
|
||||
|
||||
func extractBearerToken(c *gin.Context) string {
|
||||
func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
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) {
|
||||
tokenText := extractBearerToken(c)
|
||||
if tokenText == "" {
|
||||
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil {
|
||||
tokenText = strings.TrimSpace(cookieToken)
|
||||
}
|
||||
}
|
||||
if tokenText == "" {
|
||||
if tokenText == "" || tokenText == header {
|
||||
response.Unauthorized(c, "缺少访问令牌")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -70,13 +32,6 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
|
||||
c.Abort()
|
||||
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(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) {
|
||||
tokenText := extractBearerToken(c)
|
||||
tokenSource := "bearer"
|
||||
if tokenText == "" {
|
||||
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
|
||||
tokenText = strings.TrimSpace(cookieToken)
|
||||
tokenSource = "cookie"
|
||||
}
|
||||
}
|
||||
if tokenText == "" {
|
||||
RecordAdminAuthFailure(c, "missing", "none", 0, 0)
|
||||
header := c.GetHeader("Authorization")
|
||||
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
if tokenText == "" || tokenText == header {
|
||||
response.Unauthorized(c, "缺少后台访问令牌")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -103,116 +51,13 @@ func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gi
|
||||
|
||||
claims, err := jwtManager.ParseSubject(tokenText, "access", "admin")
|
||||
if err != nil {
|
||||
RecordAdminAuthFailure(c, auth.TokenFailureReason(err), tokenSource, 0, 0)
|
||||
response.Unauthorized(c, "后台访问令牌无效或已过期")
|
||||
c.Abort()
|
||||
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(ContextPasswordMustChange, passwordMustChange)
|
||||
c.Set(ContextUsername, claims.Phone)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "请求过于频繁,请稍后再试",
|
||||
})
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func RequireRealname(users *auth.UserRepository) gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
user, err := users.FindByID(c.Request.Context(), value.(uint64))
|
||||
user, err := users.FindByID(value.(uint64))
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Unauthorized(c, "用户不存在")
|
||||
c.Abort()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,128 +1,23 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 查询请求默认静默;成功写操作、慢请求、服务端错误、限流和有诊断价值的认证失败会输出。
|
||||
const slowRequestThresholdMs = 500
|
||||
|
||||
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
latencyMs := float64(time.Since(start).Microseconds()) / 1000
|
||||
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)),
|
||||
logger.Info("http request",
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
zap.String("route", route),
|
||||
zap.Int("status", status),
|
||||
zap.String("code", response.CodeFromContext(c)),
|
||||
zap.Float64("duration_ms", latencyMs),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.Duration("latency", time.Since(start)),
|
||||
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,9 +8,6 @@ type AdminUser struct {
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||
SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"`
|
||||
TokenVersion int64 `gorm:"not null;default:1" json:"-"`
|
||||
PasswordMustChange bool `gorm:"not null;default:false" json:"password_must_change"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -10,18 +10,11 @@ type Dispute struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||
InitiatorID uint64 `gorm:"not null" json:"initiator_id"`
|
||||
InitiatorType string `gorm:"size:16;not null;default:'user';index" json:"initiator_type"`
|
||||
InitiatorAdminID *uint64 `gorm:"index" json:"initiator_admin_id"`
|
||||
TargetUserID uint64 `gorm:"not null" json:"target_user_id"`
|
||||
Type string `gorm:"size:32;not null" json:"type"`
|
||||
Status string `gorm:"size:32;not null;default:'open'" json:"status"`
|
||||
Description string `json:"description"`
|
||||
EvidenceURLS datatypes.JSON `gorm:"column:evidence_urls" json:"evidence_urls"`
|
||||
PreviousOrderStatus string `gorm:"size:32;not null;default:''" json:"previous_order_status"`
|
||||
PreviousHandoffStatus string `gorm:"size:32;not null;default:''" json:"previous_handoff_status"`
|
||||
PreviousSettlementStatus string `gorm:"size:32;not null;default:''" json:"previous_settlement_status"`
|
||||
CheckoutID *uint64 `json:"checkout_id"`
|
||||
PreviousCheckoutStatus string `gorm:"size:32;not null;default:''" json:"previous_checkout_status"`
|
||||
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
|
||||
ArbitrationResult string `gorm:"size:32;not null;default:''" json:"arbitration_result"`
|
||||
ArbitrationRemark string `json:"arbitration_remark"`
|
||||
HandledBy *uint64 `json:"handled_by"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -30,17 +30,14 @@ func (GameAccount) TableName() string {
|
||||
|
||||
type RentalListing struct {
|
||||
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"`
|
||||
OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
|
||||
PriceCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
InTransaction bool `gorm:"not null;default:false" json:"in_transaction"`
|
||||
PriceHourly float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_hourly"`
|
||||
PriceDaily float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_daily"`
|
||||
PriceWeekly float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_weekly"`
|
||||
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"`
|
||||
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"`
|
||||
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
|
||||
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
|
||||
ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -50,24 +47,3 @@ type RentalListing struct {
|
||||
func (RentalListing) TableName() string {
|
||||
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"
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -17,18 +17,3 @@ type Notification struct {
|
||||
func (Notification) TableName() string {
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -13,54 +13,16 @@ type RentalOrder struct {
|
||||
AccountID uint64 `gorm:"not null;index" json:"account_id"`
|
||||
OwnerID uint64 `gorm:"not null;index" json:"owner_id"`
|
||||
RenterID uint64 `gorm:"not null;index" json:"renter_id"`
|
||||
RentedAt *time.Time `json:"rented_at"`
|
||||
HandoffStartedAt *time.Time `json:"handoff_started_at"`
|
||||
EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"`
|
||||
RentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
DepositFreeLevelQuotaCent int64 `gorm:"not null;default:0" json:"deposit_free_level_quota_cent"`
|
||||
DepositFreeManualQuotaCent int64 `gorm:"not null;default:0" json:"deposit_free_manual_quota_cent"`
|
||||
DepositFreeUsedBeforeCent int64 `gorm:"not null;default:0" json:"deposit_free_used_before_cent"`
|
||||
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
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"`
|
||||
RentStartAt *time.Time `json:"rent_start_at"`
|
||||
RentEndAt *time.Time `json:"rent_end_at"`
|
||||
RentHours int `gorm:"not null" json:"rent_hours"`
|
||||
RentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"rent_amount"`
|
||||
DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"`
|
||||
PlatformFee float64 `gorm:"type:decimal(12,2);not null;default:0" json:"platform_fee"`
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
||||
Status string `gorm:"size:32;not null;default:'pending_confirm'" 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"`
|
||||
@@ -70,45 +32,3 @@ type RentalOrder struct {
|
||||
func (RentalOrder) TableName() string {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,26 +11,16 @@ type OrderCheckout struct {
|
||||
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||
InitiatedBy uint64 `gorm:"not null" json:"initiated_by"`
|
||||
Status string `gorm:"size:32;not null;default:'submitted'" json:"status"`
|
||||
RoundCount int `gorm:"not null;default:1" json:"round_count"`
|
||||
Turn string `gorm:"size:16;not null;default:'owner'" json:"turn"`
|
||||
ProposedBy uint64 `gorm:"not null;default:0" json:"proposed_by"`
|
||||
RentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PureCoinPayableCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
ConsumableAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"rent_amount"`
|
||||
DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"`
|
||||
ConsumableAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"consumable_amount"`
|
||||
CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"`
|
||||
OtherAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
DepositDeductAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RenterRefundAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
OwnerIncomeAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
ShortfallCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
OvershootAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
OtherAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"other_amount"`
|
||||
DepositDeductAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_deduct_amount"`
|
||||
RenterRefundAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"renter_refund_amount"`
|
||||
OwnerIncomeAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"owner_income_amount"`
|
||||
Content string `json:"content"`
|
||||
EvidenceURLS datatypes.JSON `gorm:"column:evidence_urls" json:"evidence_urls"`
|
||||
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
|
||||
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
|
||||
OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
|
||||
RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -5,17 +5,12 @@ import "time"
|
||||
type User struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
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"`
|
||||
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
|
||||
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
|
||||
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
||||
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
||||
DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"`
|
||||
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"`
|
||||
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"`
|
||||
|
||||
@@ -9,9 +9,7 @@ type UserRealname struct {
|
||||
ProviderOrderNo string `gorm:"size:128;not null;default:''" json:"provider_order_no"`
|
||||
Status string `gorm:"size:32;not null;default:'pending'" json:"status"`
|
||||
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"`
|
||||
EncryptedIDNo string `gorm:"size:255;not null;default:''" json:"encrypted_id_no"`
|
||||
VerifiedAt *time.Time `json:"verified_at"`
|
||||
FailReason string `gorm:"size:255;not null;default:''" json:"fail_reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -5,8 +5,8 @@ import "time"
|
||||
type WalletAccount struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
UserID uint64 `gorm:"not null;uniqueIndex" json:"user_id"`
|
||||
AvailableBalanceCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
FrozenBalanceCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
AvailableBalance float64 `gorm:"type:decimal(12,2);not null;default:0" json:"available_balance"`
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -22,8 +22,8 @@ type WalletLedger struct {
|
||||
UserID uint64 `gorm:"not null;index" json:"user_id"`
|
||||
OrderID *uint64 `json:"order_id"`
|
||||
Direction string `gorm:"size:16;not null" json:"direction"`
|
||||
AmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
BalanceAfterCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
Amount float64 `gorm:"type:decimal(12,2);not null" json:"amount"`
|
||||
BalanceAfter float64 `gorm:"type:decimal(12,2);not null" json:"balance_after"`
|
||||
BalanceType string `gorm:"size:32;not null" json:"balance_type"`
|
||||
BizType string `gorm:"size:32;not null" json:"biz_type"`
|
||||
BizNo string `gorm:"size:64;not null" json:"biz_no"`
|
||||
|
||||
@@ -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 {
|
||||
return
|
||||
}
|
||||
result, err := h.service.List(c.Request.Context(), query)
|
||||
result, err := h.service.List(query)
|
||||
if err != nil {
|
||||
writeAuditError(c, err)
|
||||
return
|
||||
@@ -65,7 +65,6 @@ func parseQuery(c *gin.Context) (Query, bool) {
|
||||
}
|
||||
|
||||
func writeAuditError(c *gin.Context, err error) {
|
||||
response.RecordError(c, err)
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package adminaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
@@ -18,14 +17,14 @@ func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) List(ctx context.Context, query Query) (*PaginatedResult, error) {
|
||||
db := r.db.WithContext(ctx).Table("audit_logs AS al").
|
||||
func (r *Repository) List(query Query) (*PaginatedResult, error) {
|
||||
db := r.db.Table("audit_logs AS al").
|
||||
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,
|
||||
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")
|
||||
|
||||
countDB := r.db.WithContext(ctx).Model(&model.AuditLog{})
|
||||
countDB := r.db.Model(&model.AuditLog{})
|
||||
if query.ActorID > 0 {
|
||||
db = db.Where("al.actor_id = ?", query.ActorID)
|
||||
countDB = countDB.Where("actor_id = ?", query.ActorID)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package adminaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
import "errors"
|
||||
|
||||
var ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
|
||||
@@ -15,9 +12,9 @@ func NewService(repo *Repository) *Service {
|
||||
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 {
|
||||
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"))
|
||||
}
|
||||
@@ -11,19 +11,9 @@ type AdminDTO struct {
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Status string `json:"status"`
|
||||
SupportStatus string `json:"support_status"`
|
||||
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 {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
@@ -41,7 +31,3 @@ type CaptchaDTO struct {
|
||||
Image string `json:"image"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
type UpdateSupportStatusRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=online offline busy"`
|
||||
}
|
||||
|
||||
@@ -2,23 +2,15 @@ package adminauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
adminAccessCookieName = middleware.AdminAccessCookieName
|
||||
adminRefreshCookieName = "hfb_admin_refresh"
|
||||
adminRefreshCookieMaxAge = 14 * 24 * 60 * 60
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
@@ -28,7 +20,7 @@ func NewHandler(service *Service) *Handler {
|
||||
}
|
||||
|
||||
func (h *Handler) Captcha(c *gin.Context) {
|
||||
item, err := h.service.Captcha(c.Request.Context())
|
||||
item, err := h.service.Captcha()
|
||||
if err != nil {
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
@@ -42,13 +34,12 @@ func (h *Handler) Login(c *gin.Context) {
|
||||
response.BadRequest(c, "用户名、密码和验证码不能为空")
|
||||
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 {
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
}
|
||||
setAdminAuthCookies(c, result.Tokens)
|
||||
response.OK(c, gin.H{"admin": result.Admin})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
@@ -62,7 +53,7 @@ func (h *Handler) Me(c *gin.Context) {
|
||||
response.Unauthorized(c, "管理员上下文无效")
|
||||
return
|
||||
}
|
||||
admin, err := h.service.Me(c.Request.Context(), adminID)
|
||||
admin, err := h.service.Me(adminID)
|
||||
if err != nil {
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
@@ -71,106 +62,28 @@ func (h *Handler) Me(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})
|
||||
}
|
||||
|
||||
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 {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(c *gin.Context) {
|
||||
var req AdminRefreshRequest
|
||||
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
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 == "" {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "refresh_token 不能为空")
|
||||
return
|
||||
}
|
||||
tokens, err := h.service.Refresh(c.Request.Context(), refreshToken)
|
||||
tokens, err := h.service.Refresh(req.RefreshToken)
|
||||
if err != nil {
|
||||
reason, tokenVersion, currentVersion := middleware.AdminValidationFailure(err)
|
||||
middleware.RecordAdminAuthFailure(c, reason, refreshTokenSource, tokenVersion, currentVersion, middleware.AdminValidationFailureDetail(err))
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
}
|
||||
setAdminAuthCookies(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")
|
||||
response.OK(c, tokens)
|
||||
}
|
||||
|
||||
func writeAdminAuthError(c *gin.Context, err error) {
|
||||
response.RecordError(c, err)
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
@@ -180,8 +93,6 @@ func writeAdminAuthError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "用户名或密码错误")
|
||||
case errors.Is(err, ErrCaptchaInvalid):
|
||||
response.BadRequest(c, "验证码错误或已过期")
|
||||
case errors.Is(err, ErrLoginLocked):
|
||||
response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请稍后再试")
|
||||
case errors.Is(err, ErrAdminDisabled):
|
||||
response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用")
|
||||
default:
|
||||
|
||||
@@ -2,15 +2,15 @@ package adminauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/big"
|
||||
"strings"
|
||||
"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/modules/auth"
|
||||
|
||||
@@ -20,10 +20,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAdminUsername = "admin"
|
||||
defaultAdminPassword = "admin123456"
|
||||
captchaTTL = 3 * time.Minute
|
||||
loginFailureTTL = 15 * time.Minute
|
||||
loginLockTTL = 15 * time.Minute
|
||||
loginMaxFailureCount = 5
|
||||
)
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
func (r *Repository) Captcha(ctx context.Context) (*CaptchaDTO, error) {
|
||||
item, err := captcha.Generate(ctx, r.redis, "admin", captchaTTL)
|
||||
if errors.Is(err, captcha.ErrDependencyUnavailable) {
|
||||
func (r *Repository) Captcha() (*CaptchaDTO, error) {
|
||||
if r.redis == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
captchaID, err := randomToken(16)
|
||||
if err != nil {
|
||||
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{
|
||||
CaptchaID: item.CaptchaID,
|
||||
Image: item.Image,
|
||||
ExpiresIn: item.ExpiresIn,
|
||||
CaptchaID: captchaID,
|
||||
Image: captchaImageDataURL(code),
|
||||
ExpiresIn: int64(captchaTTL.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) {
|
||||
if err := r.verifyCaptcha(ctx, captchaID, captchaCode); err != nil {
|
||||
func (r *Repository) Login(username string, password string, captchaID string, captchaCode string) (LoginResult, error) {
|
||||
if err := r.verifyCaptcha(captchaID, captchaCode); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if err := r.ensureLoginNotLocked(ctx, username, clientIP); err != nil {
|
||||
if err := r.ensureDefaultAdmin(); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
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) {
|
||||
r.recordLoginFailure(ctx, username, clientIP)
|
||||
return LoginResult{}, ErrInvalidCredential
|
||||
}
|
||||
return LoginResult{}, err
|
||||
@@ -70,120 +76,71 @@ func (r *Repository) Login(ctx context.Context, username string, password string
|
||||
return LoginResult{}, ErrAdminDisabled
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
|
||||
r.recordLoginFailure(ctx, username, clientIP)
|
||||
return LoginResult{}, ErrInvalidCredential
|
||||
}
|
||||
now := time.Now()
|
||||
admin.LastLoginAt = &now
|
||||
if admin.TokenVersion <= 0 {
|
||||
admin.TokenVersion = 1
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Save(&admin).Error; err != nil {
|
||||
if err := r.db.Save(&admin).Error; err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
r.clearLoginFailures(ctx, username, clientIP)
|
||||
tokens, err := r.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
|
||||
tokens, err := r.jwt.GenerateSubjectPair(admin.ID, admin.Username, "admin")
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
dto := toDTO(admin)
|
||||
r.loadRolesAndPerms(ctx, &dto)
|
||||
return LoginResult{Admin: dto, Tokens: tokens}, nil
|
||||
return LoginResult{Admin: toDTO(admin), Tokens: tokens}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.AdminUser, error) {
|
||||
if r.db == 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) {
|
||||
func (r *Repository) verifyCaptcha(captchaID string, captchaCode string) error {
|
||||
if r.redis == nil {
|
||||
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
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
if admin.Status != "active" {
|
||||
return nil, ErrAdminDisabled
|
||||
}
|
||||
dto := toDTO(admin)
|
||||
r.loadRolesAndPerms(ctx, &dto)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindActiveForPasswordGate(ctx context.Context, id uint64, tokenVersion int64) (*AdminDTO, error) {
|
||||
admin, err := r.FindActiveForToken(ctx, id, tokenVersion)
|
||||
func (r *Repository) ensureDefaultAdmin() error {
|
||||
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 {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
// 认证只需要用户名和强制改密标识;复用已校验的查询结果,避免每个后台请求再查询一次数据库。
|
||||
return &AdminDTO{
|
||||
Username: admin.Username,
|
||||
PasswordMustChange: admin.PasswordMustChange,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateSupportStatus(ctx context.Context, adminID uint64, status string) error {
|
||||
if r.db == nil {
|
||||
return ErrDependencyUnavailable
|
||||
admin := model.AdminUser{
|
||||
Username: defaultAdminUsername,
|
||||
PasswordHash: string(hash),
|
||||
Nickname: "超级管理员",
|
||||
Status: "active",
|
||||
}
|
||||
if status != "online" && status != "offline" && status != "busy" {
|
||||
return errors.New("invalid support status")
|
||||
}
|
||||
return r.db.WithContext(ctx).Model(&model.AdminUser{}).
|
||||
Where("id = ?", adminID).
|
||||
Update("support_status", status).Error
|
||||
return r.db.Create(&admin).Error
|
||||
}
|
||||
|
||||
func toDTO(admin model.AdminUser) AdminDTO {
|
||||
@@ -192,117 +149,42 @@ func toDTO(admin model.AdminUser) AdminDTO {
|
||||
Username: admin.Username,
|
||||
Nickname: admin.Nickname,
|
||||
Status: admin.Status,
|
||||
SupportStatus: admin.SupportStatus,
|
||||
PasswordMustChange: admin.PasswordMustChange,
|
||||
LastLoginAt: admin.LastLoginAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) loadRolesAndPerms(ctx context.Context, dto *AdminDTO) {
|
||||
if r.db == nil {
|
||||
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)
|
||||
|
||||
// 加载权限
|
||||
for _, role := range roles {
|
||||
if role.Code == "super_admin" {
|
||||
dto.Permissions = []string{"*"}
|
||||
cachePermissions(ctx, r, dto.ID, dto.Permissions)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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 captchaKey(id string) string {
|
||||
return "admin:captcha:" + id
|
||||
}
|
||||
|
||||
func rolesRequireInitialPasswordChange(roles []RoleDTO) bool {
|
||||
for _, role := range roles {
|
||||
if role.Code == "super_admin" {
|
||||
return true
|
||||
func randomToken(length int) (string, error) {
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return false
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
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()
|
||||
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
|
||||
return "", err
|
||||
}
|
||||
if locked > 0 {
|
||||
return ErrLoginLocked
|
||||
result[i] = alphabet[n.Int64()]
|
||||
}
|
||||
return nil
|
||||
return string(result), 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
|
||||
func captchaImageDataURL(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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
)
|
||||
@@ -14,161 +12,51 @@ var (
|
||||
ErrCaptchaInvalid = errors.New("captcha invalid")
|
||||
ErrAdminDisabled = errors.New("admin disabled")
|
||||
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 {
|
||||
repo *Repository
|
||||
jwt *auth.JWTManager
|
||||
}
|
||||
|
||||
func NewService(repo *Repository, jwt *auth.JWTManager) *Service {
|
||||
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 {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
claims, err := s.jwt.ParseSubject(refreshToken, "refresh", "admin")
|
||||
if err != nil {
|
||||
return nil, newTokenValidationError(auth.TokenFailureReason(err), ErrInvalidRefreshToken)
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
if s.repo == nil {
|
||||
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)
|
||||
pair, err := s.jwt.GenerateSubjectPair(claims.UserID, claims.Phone, "admin")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pair, nil
|
||||
}
|
||||
|
||||
func invalidRefreshTokenError(err error) 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) {
|
||||
func (s *Service) Captcha() (*CaptchaDTO, error) {
|
||||
if s.repo == nil {
|
||||
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 {
|
||||
return LoginResult{}, ErrDependencyUnavailable
|
||||
}
|
||||
if username == "" || password == "" || captchaID == "" || captchaCode == "" {
|
||||
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 {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.FindByID(ctx, 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)
|
||||
return s.repo.FindByID(adminID)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import "time"
|
||||
type DashboardDTO struct {
|
||||
Metrics MetricsDTO `json:"metrics"`
|
||||
Pending PendingDTO `json:"pending"`
|
||||
ListingDaily ListingDailyOverviewDTO `json:"listing_daily"`
|
||||
RecentOrders []RecentOrderDTO `json:"recent_orders"`
|
||||
RecentDisputes []RecentDisputeDTO `json:"recent_disputes"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
@@ -19,32 +18,7 @@ type MetricsDTO struct {
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
RentingOrders int64 `json:"renting_orders"`
|
||||
TodayOrders int64 `json:"today_orders"`
|
||||
TodayLedgerAmountCent int64 `json:"today_ledger_amount_cent"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
TodayLedgerAmount float64 `json:"today_ledger_amount"`
|
||||
}
|
||||
|
||||
type PendingDTO struct {
|
||||
@@ -61,8 +35,8 @@ type RecentOrderDTO struct {
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
RentAmountCent int64 `json:"rent_amount_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
RentAmount float64 `json:"rent_amount"`
|
||||
DepositAmount float64 `json:"deposit_amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func NewHandler(service *Service) *Handler {
|
||||
}
|
||||
|
||||
func (h *Handler) Summary(c *gin.Context) {
|
||||
item, err := h.service.Summary(c.Request.Context())
|
||||
item, err := h.service.Summary()
|
||||
if err != nil {
|
||||
writeDashboardError(c, err)
|
||||
return
|
||||
@@ -27,7 +27,6 @@ func (h *Handler) Summary(c *gin.Context) {
|
||||
}
|
||||
|
||||
func writeDashboardError(c *gin.Context, err error) {
|
||||
response.RecordError(c, err)
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
package admindashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
listingDailyTrendDays = 7
|
||||
listingChannelWebsite = "站内发布"
|
||||
listingChannelExternalUnknown = "未填写"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -26,268 +16,72 @@ func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) {
|
||||
db := r.db.WithContext(ctx)
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
now := timeutil.ShanghaiNow()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
func (r *Repository) Summary() (*DashboardDTO, error) {
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
metrics := MetricsDTO{}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := db.Model(&model.WalletLedger{}).
|
||||
Select("COALESCE(SUM(amount_cent), 0)").
|
||||
if err := r.db.Model(&model.WalletLedger{}).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Where("created_at >= ?", today).
|
||||
Scan(&metrics.TodayLedgerAmountCent).Error; err != nil {
|
||||
Scan(&metrics.TodayLedgerAmount).Error; err != nil {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
listingDaily, err := r.listingDailyOverview(ctx, now, listingDailyTrendDays)
|
||||
recentOrders, err := r.recentOrders()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recentOrders, err := r.recentOrders(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recentDisputes, err := r.recentDisputes(ctx)
|
||||
recentDisputes, err := r.recentDisputes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DashboardDTO{
|
||||
Metrics: metrics,
|
||||
Pending: pending,
|
||||
ListingDaily: listingDaily,
|
||||
RecentOrders: recentOrders,
|
||||
RecentDisputes: recentDisputes,
|
||||
GeneratedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, days int) (ListingDailyOverviewDTO, error) {
|
||||
if days <= 0 {
|
||||
days = listingDailyTrendDays
|
||||
}
|
||||
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").
|
||||
func (r *Repository) recentOrders() ([]RecentOrderDTO, error) {
|
||||
var rows []RecentOrderDTO
|
||||
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").
|
||||
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
|
||||
Order("o.id DESC").
|
||||
Limit(8).
|
||||
@@ -295,9 +89,9 @@ func (r *Repository) recentOrders(ctx context.Context) ([]RecentOrderDTO, error)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *Repository) recentDisputes(ctx context.Context) ([]RecentDisputeDTO, error) {
|
||||
rows := make([]RecentDisputeDTO, 0)
|
||||
err := r.db.WithContext(ctx).Table("disputes AS d").
|
||||
func (r *Repository) recentDisputes() ([]RecentDisputeDTO, error) {
|
||||
var rows []RecentDisputeDTO
|
||||
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").
|
||||
Joins("JOIN rental_orders AS o ON o.id = d.order_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
Reference in New Issue
Block a user