first commit

This commit is contained in:
2026-09-11 01:41:52 +08:00
commit 9f22e6ccae
263 changed files with 28935 additions and 0 deletions
@@ -0,0 +1,117 @@
---
name: 修复中等与轻微问题8项
overview: 按二次审查报告修复 8 个问题:M1(退款推送 RefundStatus 空串误回收)、M2(发货校验失败返回成功应答吞资损线索)、M5(补偿任务 Failed 被回调重置)、M14(衣物失败回退与删除并发漏退)、M10(refresh-token access_token 文档矛盾)、M11(公开接口无限流)、M12(上传接口无 body 大小限制)、M13(Outfit.Public 并发语义冲突)。
todos:
- id: fix-xpay-notify
content: 修复虚拟支付推送链路:新增 XPayRefundStatusSuccess 常量,handleRefundNotify 改白名单判断,handleGoodsDeliver 五个校验失败分支返回 70009 触发微信重试
status: pending
- id: fix-benefit-reset
content: 修复补偿任务重置:BenefitGrantTaskReset 保留 retry_count,失败任务增加 5 分钟冷却期后才允许回调重新触发
status: pending
- id: fix-clothes-refund-tx
content: 修复衣物回退漏退:markProcessFailed 重写为行锁单事务(置失败+回退+流水),删除 refundIfExists 与 ClothesMarkProcessFailedIfNotFailed
status: pending
- id: fix-outfit-public-lock
content: 修复穿搭公开竞态:redismodel 新增 KeyLockOutfitPublic,OutfitService.Public 全流程加 Redis 锁串行化
status: pending
- id: add-ratelimit-upload-guard
content: 新增登录/刷新限流中间件(Redis 固定窗口 fail-open)并接入路由,上传接口加 MaxBytesReader 请求体限制
status: pending
- id: sync-docs
content: 同步文档:README 修正 refresh-token access_token 为必填,审查报告 M1/M2/M5/M10/M11/M12/M13/M14 标注已修复并验证编译测试
status: pending
dependencies:
- fix-xpay-notify
- fix-benefit-reset
- fix-clothes-refund-tx
- fix-outfit-public-lock
- add-ratelimit-upload-guard
---
## 产品概述
基于二次审查报告,修复用户确认的 8 个问题:虚拟支付退款误回收(M1)、发货校验失败静默(M2)、补偿任务重试上限失效(M5)、衣物额度漏退窗口(M14),以及限流缺失(M11)、上传接口无请求体限制(M12)、refresh-token 文档矛盾(M10)、穿搭公开并发语义冲突(M13)。全部为后端 Go 代码修复,不涉及 UI。
## 核心功能
- **M1 退款推送白名单**:仅当 `RefundStatus == "SUCCESS"` 才回收权益,空串/未知状态仅记录日志,杜绝误回收
- **M2 发货校验失败应答**:发货推送本地校验失败(订单不存在/渠道不符/道具不符/金额不符/环境不符)返回失败应答触发微信重试,不再静默吞掉资损线索;并发锁竞争分支保持成功应答防重试风暴
- **M5 补偿任务冷却保护**:任务重置不再清零重试计数,且失败任务 5 分钟冷却期外才允许被回调重新触发,重试上限与人工介入机制恢复有效
- **M14 衣物回退事务化**:处理失败标记与额度回退合并为单事务(行锁互斥),消除与删除并发时的漏退窗口,保证恰好回退一次
- **M11 登录/刷新限流**:新增基于 Redis 固定窗口的限流中间件(按 IP,Redis 故障 fail-open),仅应用于登录与刷新两个公开接口,回调接口不限流
- **M12 上传请求体限制**:上传接口在 multipart 解析前用 MaxBytesReader 按配置上限拦截超大请求体
- **M13 穿搭公开串行化**:公开/取消公开流程加 per-outfit Redis 锁,消除并发下「回退公开但另一请求误报成功」的语义冲突
- **M10 文档修正**:README 中 refresh-token 的 access_token 改为必填,与代码实现一致
## 技术方案
### 1. M1 + M2:虚拟支付推送链路修复
- `pkg/wechat/virtualpay.go`:在退款推送结构附近新增常量 `XPayRefundStatusSuccess = "SUCCESS"`
- `internal/service/virtualpay.go` `handleRefundNotify`:条件改为 `if refund.RefundStatus != wechat.XPayRefundStatusSuccess { 记录日志; return nil }`,仅成功状态走 `settleRefundSuccess`;订单不存在分支保持 return nil(退款重试无意义)
- `handleGoodsDeliver`:order not found / pay_channel 不符 / product mismatch / amount mismatch / env mismatch 五个分支由 `return nil` 改为 `return errcode.OrderRealityCheckFailed`(70009 已存在),使 `XPayNotify` 回 `ErrCode:1` 让微信重试;锁竞争失败分支保持 `return nil`(防并发回调重试风暴)
### 2. M5:补偿任务重置保护
- `internal/mysqldao/benefit_grant_task.go` `BenefitGrantTaskReset`:删除 `retry_count: 0`,保留原重试计数
- `internal/service/order.go` `grantBenefitIdempotent` 的 Failed 分支:新增冷却常量 `benefitRetryCooldownSeconds = 300`;当 `nowUnix() - existing.UpdatedAt < 300` 时仅打告警日志跳过,冷却期外才 Reset + executeGrant,防止微信回调重试风暴无限重置任务
### 3. M14:衣物处理失败回退事务化
- `internal/service/clothes.go` 重写 `markProcessFailed` 为单事务(与 `DeleteByUUID` 同模式,已 import gorm/clause):
1. `tx.Clauses(clause.Locking{Strength: "UPDATE"})` 按 clothes_uuid 查衣物;不存在/已软删(RecordNotFound)或已是失败态则 return nil(删除路径已回退或已处理)
2. 置 `image_process_status=5` + `fail_reason`
3. 同事务回退额度:条件更新 `used - 1`(used>=1),回读真实值,写回退流水(Reason="衣物处理失败回退",OrderNo=流水 UUID 兜底)
- 删除 `refundIfExists` 方法与 `internal/mysqldao/clothes.go` 的 `ClothesMarkProcessFailedIfNotFailed`(已确认仅此一处调用)
- 删除路径 `DeleteByUUID` 保持不变:行锁读到失败态即跳过回退,与新事务通过行锁互斥,两路径恰好回退一次
### 4. M13:穿搭公开加 Redis 锁
- `internal/redismodel/lock.go` 新增 `KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s"`(outfit_uuid)
- `internal/service/outfit.go` `Public`:进入后先 `redisdao.TryLock(fmtLockKey(KeyLockOutfitPublic, outfitUUID), 10s)`,失败返回 `errcode.TooManyRequests`;成功后 defer 解锁;锁内保留现有「条件公开 → 扣额 → 失败条件回退」流程,串行化后并发请求语义正确
### 5. M11:限流中间件
- 新增 `internal/middleware/ratelimit.go`:`RateLimit(limit int, window time.Duration) gin.HandlerFunc`
- Redis 固定窗口:key = `redismodel.KeyRateLimit(IP)`,首次 `INCR` 后设 `EXPIRE window`,超过 limit 返回 `errcode.TooManyRequests`(10003,已映射 HTTP 429)
- Redis 异常时 fail-open 放行(与项目降级策略一致),仅告警
- `internal/redismodel/` 新增 key 常量(如 `KeyRateLimit = RedisPrefix + "ratelimit:%s"`)
- `internal/routers/router.go`:对 `/user/wxlogin`、`/user/refresh-token` 两个路由应用,默认阈值常量 `每 IP 每 60 秒 30 次`;微信支付/xpay 回调路由不接限流(避免误伤真实回调)
### 6. M12:上传请求体限制
- `internal/routers/api/v1/clothes.go` `Upload`:在 `c.FormFile` 前设置 `c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(global.UploadSetting.MaxSizeMB+2)*1024*1024)`(留 multipart 边界余量)
- FormFile 失败时:错误信息含 "request body too large" 返回 `errcode.UploadFileTooLarge`(30002),否则保持 `errcode.UploadFileEmpty`
### 7. M10 + 文档同步
- `README.md` 686 行:access_token 由「否(可选,向前兼容)」改为「是(必填)」,说明缺少返回 10000
- `docs/代码审查报告-二次check.md`:M1/M2/M5/M10/M11/M12/M13/M14 标题后标注「已修复」及一行修复说明
### 验证
- `go build ./...` 全量编译通过
- `go test ./pkg/wechat/` 通过
- read_lints 检查修改文件无新增告警
## 关键代码结构
限流中间件签名:
```
// RateLimit 基于 Redis 固定窗口的按 IP 限流中间件(Redis 故障 fail-open)。
func RateLimit(limit int, window time.Duration) gin.HandlerFunc
```
衣物失败回退事务核心流程:
```text
tx 开始
SELECT ... FOR UPDATE clothes(不存在/已删/已失败 → 提交并跳过)
UPDATE clothes SET image_process_status=5, fail_reason=?
UPDATE quota SET used=used-1 WHERE used>=1(条件失败 → 跳过流水)
回读 quota → 写回退流水(唯一索引兜底)
tx 提交
```