Files
zt-closet-backend/.codebuddy/plans/修复全部_39_项代码缺陷_f3b9019e.md
2026-09-11 01:41:52 +08:00

228 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: 修复全部 39 项代码缺陷
overview: 根据代码审查报告,修复全部 39 项业务逻辑与技术逻辑 bug。核心是资损链路(退款回退权益、取消后付款自动退款、权益发放幂等化)、安全修复(密钥环境变量化、JWT 校验、错误码映射)与正确性修复(VIP 续费 CASE 求值顺序、Redis 超时单位、配额唯一约束等)。鉴权降级按用户决策维持 fail-open 现状,不改代码。
todos:
- id: fix-dal
content: 修复数据访问层:VIP 续费 CASE 求值顺序、QuotaAddTotal 命中检查、新增 QuotaReduceTotal/UserReduceVIP、配额与用户唯一索引 tag
status: completed
- id: fix-redis
content: 修复 Redis 数据层:点赞/收藏未命中回源语义、计数 TTL、未读数对账、token 集合 TTL、清理 QuotaRemain 死缓存
status: completed
- id: fix-benefit
content: 修复权益发放幂等化:发放与流水同事务、订单快照、Paid 后凭证恢复、retry_count 原子自增、唯一冲突区分
status: completed
dependencies:
- fix-dal
- id: fix-refund
content: 修复退款链路:退款成功回退权益、取消后付款自动微信退款、退款申请与流水幂等、grantQuota 并发建行与流水失真
status: completed
dependencies:
- fix-dal
- id: fix-business
content: 修复其他业务逻辑:登录并发与封禁、点赞通知去重、穿搭公开锁、衣物回退竞态、衣柜分享明细、通知已读与 AI 锁错误
status: completed
dependencies:
- fix-redis
- id: fix-infra
content: 修复基础设施与安全:Redis 超时单位、密钥环境变量化、CORS、错误码映射、端口绑定、优雅关闭、panic 恢复、JWT 校验、COS 与日志等
status: completed
- id: fix-dto-migrate-verify
content: 补充 DTO 校验、新增唯一索引迁移 SQL,并执行 go build 与 go vet 验证全部修复
status: completed
dependencies:
- fix-dal
- fix-redis
- fix-benefit
- fix-refund
- fix-business
- fix-infra
---
## 用户需求
修复此前全量代码审查发现的 **39 项 bug**(业务逻辑 15 项 + 技术逻辑 24 项),不引入新的架构模式,保持现有 Gin + GORM + Redis 分层风格,修复后项目可正常编译。
## 关键决策(已确认)
1. **修复范围**:全部 39 项(含低危的边界校验、日志、代码质量问题)。
2. **退款权益**:订单全额退款成功后,**回退已发放的会员天数与各类额度**。
- 额度回退:仅回退未使用部分,已用部分不回退(`total = max(used, total - n)`)。
- VIP 回退:扣减会员天数,但到期时间不倒退到过去(`vip_expire = max(now, vip_expire - n*86400)`)。
3. **取消后付款**:订单本地已取消但用户实际已付款时,**自动发起微信退款**(调用已存在的 `wechat.Refund`)。
4. **鉴权降级**:Redis 故障时 access token 会话校验**维持 fail-open 现状,不改代码**(仅在注释中记录该决策)。
## 修复原则
- 优先修复资损与安全高危项(退款回退、权益重复发放、自动退款、密钥、JWT)。
- 幂等性修复统一采用「唯一索引兜底 + 流水先写 + 累加同事务」的成熟模式。
- 每个模块的修复保持小步、可编译,不跨模块做无关重构。
- 新增数据库迁移 SQL 放入 `migrations/`,不动已有迁移文件。
## 技术栈
- Go 1.27、Gin v1.12、GORM v1.31(MySQL + soft_delete 插件)、go-redis v9、golang-jwt/v5、OpenTelemetry、微信支付 apiv3(`wechatpay-go` 官方 SDK)、腾讯云 COS、viper。
## 实现方法
采用「分层分模块」修复策略,自底向上:数据访问层 → 资损链路 → 业务逻辑 → 基础设施 → DTO 校验与迁移。核心思路:
### 1. 退款回退权益(A1)
- 新增 `QuotaDao.QuotaReduceTotal`:`UPDATE user_quotas SET total = GREATEST(used, total - ?) WHERE user_id = ? AND quota_type = ?`,检查 `RowsAffected` 防静默丢失。
- 新增 `UserDao.UserReduceVIP`:`UPDATE users SET vip_expire = GREATEST(?, vip_expire - ?) WHERE id = ?`(now, days*86400)。
- 幂等:仅在退款回调 `SUCCESS` 且流水从非成功态流转到成功态时执行回退;回退写 `QuotaFlowLog`/`VIPFlowLog`,新增 `ChangeType` 常量(退款回收),并以 `order_no + 回收类型` 唯一索引兜底防重复回退。
### 2. 取消后付款自动退款(A2)
- `PayNotify` 的 `OrderStatusCancelled` 分支:生成 `refundNo` → 创建 `RefundFlowLog(status=Processing)` → 调用 `wechat.Refund(orderNo, refundNo, payAmount, payAmount, reason)` → 记录微信退款单号。
- 幂等:创建退款流水前按 `order_no` 查重(已有非失败退款则跳过),并为 `refund_flow_logs` 增加 `order_no` 唯一约束(部分退款场景暂不支持,本项目为全额退款)。
### 3. 权益发放幂等化(A3/A4/A6/B11/B12)
- 将「不可逆累加」与「幂等凭证写入」放入**同一 DB 事务**:
- VIP:事务内先写 `VIPFlowLog`(唯一索引 `order_no + change_type`),冲突即视为已发放;成功后再 `UserRenewVIPAtomic`。
- 额度:事务内先写 `QuotaFlowLog`(唯一索引 `order_no + quota_type + change_type`),冲突即视为已发放;成功后再 `QuotaAddTotal`。
- `grantOrderBenefits` 改用**订单快照字段**(`order.MemberDays`)而非实时读 `goods`(A5);订单需在快照中补充各类额度字段或从 `benefit_grant_tasks` 的 `Amount` 取值。
- 崩溃恢复(A6):`PayNotify` 的 `OrderStatusPaid` 幂等分支不再直接 return,而是检查该订单是否已存在 `benefit_grant_tasks` 凭证,若无则补建并发起发放(幂等)。
- `retry_count` 改为 `retry_count = retry_count + 1` 原子自增(B11)。
- `grantBenefitIdempotent` 用错误类型区分「唯一键冲突」与「其他 DB 错误」(B12)。
### 4. VIP 续费 CASE 修复(B2)
- `UserRenewVIPAtomic` 改为原生 SQL(`Exec`)显式控制 SET 顺序,使 `vip_start` 先于 `vip_expire` 求值,读到旧 `vip_expire`,恢复「过期续费重置 vip_start」语义。
### 5. 配额与 Redis 修复(A7/B5/B6/B7/B13/B29/B30/B31)
- `QuotaAddTotal` 补 `RowsAffected` 检查;`user_quotas` 模型加 `(user_id, quota_type)` 唯一索引 tag,并新增迁移 SQL。
- `grantQuota` 创建分支改为「先插入(唯一索引冲突则重查后走累加)」;流水 `oldValue/newValue` 改为在原子更新后回读真实值写入。
- 点赞/收藏 `SIsMember` 在 key 不存在时返回 `redis.Nil` 语义(或 DAO 返回 `(false, redis.Nil)`),使 service 层正确回落 MySQL。
- 访问计数 `INCR` 配套首次设置 TTL;未读数 key 改用 DB 对账回源;token 集合 TTL 只在首次 SAdd 时设置;删除或补全只写不读的 `QuotaRemain`。
### 6. 基础设施与安全(B3/B4/B8/B9/B14-B19/B22-B28/B36)
- Redis 超时统一单位:代码改 `* time.Second`,与配置注释「单位s」对齐(B3)。
- 密钥环境变量化:`setting` 增加 `AutomaticEnv` + `SetEnvKeyReplacer`;`config.yaml` 敏感值改为占位/移除,保留 `config.example.yaml` 模板(B4/B25)。
- CORS 收窄来源白名单(B8);错误码补充 `Forbidden→403`、`TooManyRequests→429`、`ClassifyFailed→500` 等映射(B9/B24)。
- `ListenAndServe` 立即错误触发退出(B14);优雅关闭拆分 HTTP 与 OTel context(B15);COS nil 保护(B16);4 个后台 goroutine 加 `defer recover()`(B17);配置段 nil 校验(B18);JWT 校验 issuer 且拒绝空密钥(B19);日志目录 `MkdirAll`(B23);`url.Parse` 错误处理(B22);OTel 失败回滚全局 provider(B26);清理重复 `UserDefaultQuota` 配置段(B27);时间单位避免 duration 双重乘算(B28);访问日志 query 脱敏(B36)。
### 7. 其余业务逻辑(A8-A13/B10/B20/B35/B37/B38/B39)
- 登录并发注册:`wx_open_id` 唯一索引 + duplicate 冲突后重查继续登录;登录拦截临时/永久封禁(A8/A13)。
- 点赞通知去重(A9);穿搭公开加分布式锁 + 条件更新(A10);衣物删除回退改原子判定(A11);衣柜分享明细失败返回错误(A12)。
- 刷新令牌强制携带旧 access token 并吊销(B10);通知已读条件更新防重复扣减(B20);登录与鉴权降级策略统一为 fail-open 并注释(B35);AI 锁错误区分处理(B37);穿搭明细更新包事务(B38);通知未读数递增错误处理(B39)。
## 架构设计
保持现有分层(routers → service → mysqldao/redisdao → mysqlmodel/redismodel),不新增架构层。核心修复后的资金/权益流转如下:
```mermaid
flowchart TD
A[微信支付回调 PayNotify] --> B{订单状态}
B -->|Pending| C[条件更新 Pending->Paid]
C --> D[创建权益发放凭证 benefit_grant_tasks]
D --> E[事务: 写流水唯一索引 + 累加权益]
E --> F[权益到账]
B -->|Cancelled| G[创建退款流水 + 调 wechat.Refund]
G --> H[退款回调 RefundNotify]
B -->|Paid 但无凭证| D
H -->|SUCCESS| I[事务: 回退额度 GREATEST + 回退VIP天数]
I --> J[写回退流水 幂等防重]
J --> K[订单置 Refunded]
```
## 目录结构
以下为本次修复涉及的文件(MODIFY 表示修改,NEW 表示新增):
```
/root/zt-closet-backend
├── main.go # [MODIFY] B14/B15/B16/B17/B18/B23/B28 端口绑定/优雅关闭/panic恢复/配置nil/日志目录/时间单位
├── configs/
│ ├── config.yaml # [MODIFY] B3/B4/B27 超时单位、密钥占位、清理重复段
│ └── config.example.yaml # [MODIFY] 同步模板更新
├── global/ # 视 B18 需要调整设置读取
├── migrations/
│ └── 20260907_bugfix_constraints.sql # [NEW] 唯一索引:user_quotas、quota_flow_logs、vip_flow_logs、refund_flow_logs、users.wx_open_id
├── internal/
│ ├── mysqlmodel/
│ │ ├── quota.go # [MODIFY] A7 唯一索引 tag
│ │ ├── user.go # [MODIFY] A8 wx_open_id 唯一索引 tag
│ │ ├── quotaflowlog.go # [MODIFY] A4 唯一索引 tag
│ │ ├── vipflowlog.go # [MODIFY] A3 唯一索引 tag
│ │ └── refundflowlog.go # [MODIFY] A14 唯一索引 tag
│ ├── mysqldao/
│ │ ├── user.go # [MODIFY] B2 VIP CASE、新增 UserReduceVIP
│ │ └── quota.go # [MODIFY] B5/A7、新增 QuotaReduceTotal
│ ├── redisdao/
│ │ ├── like.go / collect.go # [MODIFY] B6 未命中语义
│ │ ├── closet.go / outfit.go # [MODIFY] B7 计数 TTL
│ │ ├── quota.go # [MODIFY] B29 死缓存清理
│ │ ├── notification.go # [MODIFY] B30 未读数对账
│ │ └── auth.go # [MODIFY] B31 token 集合 TTL
│ ├── redismodel/
│ │ └── keys.go # [MODIFY] B3 超时单位
│ ├── service/
│ │ ├── order.go # [MODIFY] A1/A2/A5/A6/A14/A15/B12/B21 退款回退/自动退款/快照/退款幂等
│ │ ├── benefit_compensation.go # [MODIFY] A4/B11 幂等发放/retry_count
│ │ ├── service.go # [MODIFY] A7/B13 grantQuota 并发与流水
│ │ ├── constants.go # [MODIFY] 新增退款回收 ChangeType 常量
│ │ ├── user.go # [MODIFY] A8/A13/B10/B35 登录/封禁/刷新/降级
│ │ ├── like.go # [MODIFY] A9 点赞通知去重
│ │ ├── outfit.go # [MODIFY] A10/B38 公开锁/明细事务
│ │ ├── clothes.go # [MODIFY] A11 删除回退竞态
│ │ ├── closet.go # [MODIFY] A12 明细失败处理
│ │ ├── notification.go # [MODIFY] B20/B39 已读幂等/未读数
│ │ └── airecommend.go # [MODIFY] B37 锁错误处理
│ ├── dto/
│ │ ├── closet.go # [MODIFY] B32 过期时间校验
│ │ ├── user.go # [MODIFY] B33 binding tag
│ │ └── outfit.go / clothes.go # [MODIFY] B34 文本长度上限
│ └── middleware/
│ ├── cors.go # [MODIFY] B8 来源白名单
│ ├── logger.go # [MODIFY] B36 query 脱敏
│ └── lzcwyjwt.go # [MODIFY] B1 仅注释说明 fail-open 决策
├── pkg/
│ ├── setting/setting.go # [MODIFY] B25 环境变量支持
│ ├── lzcwyjwt/lzcwyjwt.go # [MODIFY] B19 issuer/密钥校验
│ ├── errcode/errcode.go # [MODIFY] B9/B24 状态码映射
│ ├── cos/cosclient.go # [MODIFY] B22 url.Parse 错误
│ ├── tracing/provider.go # [MODIFY] B26 失败回滚
│ └── upload/upload.go # [MODIFY] B16 COS nil 保护
└── pkg/tokenhub/scheduler.go / fortune.go # [MODIFY] B17 panic 恢复
```
## 关键代码结构
新增 DAO 方法(接口级定义,实现时遵循现有 DAO 风格):
```
// QuotaReduceTotal 原子扣减总配额(退款回收),保证 total 不低于 used,返回是否命中。
func (d *QuotaDao) QuotaReduceTotal(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error)
// UserReduceVIP 原子回退会员到期时间,保证不早于 now,返回是否命中。
func (d *UserDao) UserReduceVIP(ctx context.Context, userID int64, now int64, memberDays int64) (bool, error)
```
## 实施注意事项
- **兼容性**:新增唯一索引前需评估存量数据是否已有重复(`user_quotas`、`quota_flow_logs`、`vip_flow_logs`、`refund_flow_logs`),迁移 SQL 需先清理/合并重复再建索引。
- **事务边界**:GORM 事务使用 `db.Transaction(func(tx *gorm.DB) error {...})`,避免跨 service 的裸事务。
- **回退幂等**:退款回退与权益发放的所有「写流水 + 累加/回退」必须在同一事务内,唯一索引冲突通过错误类型识别并视为已处理。
- **日志与安全**:环境变量化后,禁止在日志打印 openid/金额/密钥;`config.yaml` 敏感值改为 `${ENV_VAR}` 占位。
- **验证**:修复完成后执行 `go build ./...` 与 `go vet ./...`,确保可编译且无新增告警;不强制写测试,但不破坏现有逻辑。
## 使用的扩展
### SubAgent
- **code-explorer**
- 用途:在执行各模块修复前,快速确认相关文件的完整实现、字段定义与调用点(如 `grantQuota`/`doGrantBenefit`/`RefundNotify` 的全部调用方),确保修改不遗漏、不引入冲突。
- 预期产出:每个修复模块的精确修改点清单与调用关系,支撑小步、可编译的改动。
### Skill
- **lsp-code-analysis**
- 用途:修复后对关键函数(`consumeQuota`、`grantQuota`、`doGrantBenefit`、`UserRenewVIPAtomic`、`QuotaReduceTotal`、`UserReduceVIP`)做定义/引用/调用层次追踪,验证幂等与回退链路没有遗漏调用点或破坏现有引用。
- 预期产出:关键函数完整调用链,确认修复后无断链、无重复发放/回退缺口。