228 lines
16 KiB
Markdown
228 lines
16 KiB
Markdown
---
|
||
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`)做定义/引用/调用层次追踪,验证幂等与回退链路没有遗漏调用点或破坏现有引用。
|
||
- 预期产出:关键函数完整调用链,确认修复后无断链、无重复发放/回退缺口。 |