16 KiB
16 KiB
name, overview, todos
| name | overview | todos | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 修复全部 39 项代码缺陷 | 根据代码审查报告,修复全部 39 项业务逻辑与技术逻辑 bug。核心是资损链路(退款回退权益、取消后付款自动退款、权益发放幂等化)、安全修复(密钥环境变量化、JWT 校验、错误码映射)与正确性修复(VIP 续费 CASE 求值顺序、Redis 超时单位、配额唯一约束等)。鉴权降级按用户决策维持 fail-open 现状,不改代码。 |
|
用户需求
修复此前全量代码审查发现的 39 项 bug(业务逻辑 15 项 + 技术逻辑 24 项),不引入新的架构模式,保持现有 Gin + GORM + Redis 分层风格,修复后项目可正常编译。
关键决策(已确认)
- 修复范围:全部 39 项(含低危的边界校验、日志、代码质量问题)。
- 退款权益:订单全额退款成功后,回退已发放的会员天数与各类额度。
- 额度回退:仅回退未使用部分,已用部分不回退(
total = max(used, total - n))。 - VIP 回退:扣减会员天数,但到期时间不倒退到过去(
vip_expire = max(now, vip_expire - n*86400))。
- 取消后付款:订单本地已取消但用户实际已付款时,自动发起微信退款(调用已存在的
wechat.Refund)。 - 鉴权降级: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),不新增架构层。核心修复后的资金/权益流转如下:
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)做定义/引用/调用层次追踪,验证幂等与回退链路没有遗漏调用点或破坏现有引用。 - 预期产出:关键函数完整调用链,确认修复后无断链、无重复发放/回退缺口。