Files
zt-closet-backend/.codebuddy/plans/fix-code-review-bugs_a7c936c2.md
T
2026-09-11 01:41:52 +08:00

7.5 KiB
Raw Blame History

name, overview, todos
name overview todos
fix-code-review-bugs 修复全量代码审查确认的 5 项高/中优先级业务与技术 bug:支付回调与订单过期/取消竞态、grantVIP 并发续费丢失天数、补偿任务 failed 重置后死循环、补偿定时任务分布式锁误删、中间件 Redis 错误误拒服务。
id content status
fix-pay-notify-race 修复支付回调竞态:新增微信 CloseOrder 并在 Cancel 关单、PayNotify 支持过期订单补发货 completed
id content status
atomic-vip-renewal 新增 UserRenewVIPAtomic 原子续费并改造 grantVIP 防并发丢天数 completed
id content status
fix-compensation-scheduler 修复补偿任务 failed 重置死循环与定时任务分布式锁误删 completed
id content status
middleware-redis-fallback 改造 JWTAuth 中间件 UserInfoGet 错误回落到数据库避免误拒 completed

产品概述

对现有后端进行全量代码审查后的 5 项高/中优先级缺陷修复,核心目标是堵住两个资损漏洞(支付回调竞态、VIP 并发续费丢失),并修复补偿任务死循环、分布式锁误删、鉴权中间件可用性三个技术逻辑缺陷。

核心功能

  1. 支付回调与订单过期/取消竞态修复:取消订单时同步调用微信关单;支付回调在订单被系统过期后仍能流转为已支付并发放权益,避免用户付款但权益不到账。
  2. VIP 并发续费原子化:会员续费从「读-改-写」改为单条原子 SQL(CASE 表达式),避免两笔订单并发支付时后写覆盖、丢失续费天数。
  3. 补偿任务重试恢复:失败补偿任务被重置为待发放时同步清零重试次数,避免因重试次数仍超限而陷入「立即置失败」的死循环。
  4. 补偿定时任务锁安全:分布式锁释放改为校验持有者 value 后删除,避免任务执行超时后误删其它实例的锁。
  5. 鉴权中间件容错:Redis 读取用户信息出错时回落到数据库查询,避免 Redis 抖动导致全部鉴权接口被拒绝。

非目标(本次不修改)

RefreshToken 轮换未失效旧 access token、微信登录并发可能重复建用户、创建订单落库失败产生孤儿预支付单、空衣橱快照分享仍扣额度、回调报文未截断长度,上述 5 项作为低优先级建议记录,不在本次修复范围内。

技术栈

  • 语言与框架:Go 1.27、Gin、GORM(gorm.io/gorm v1.31.2)、go-redis、log/slog、OpenTelemetry
  • 微信支付:官方 github.com/wechatpay-apiv3/wechatpay-go v0.2.21(已确认 jsapi.JsapiApiService.CloseOrder 与 CloseOrderRequest 结构)
  • 复用现有约定:Service 返回 (*dto.X, *errcode.Error)、DAO 返回底层 error、Redis 锁用 redisdao.TryLock/Unlock、日志用 slog + global.Tracer

实现方案

Bug1 支付回调竞态(资损)

  • pkg/wechat/pay.go 新增 CloseOrder(ctx, orderNo string) error:调用 jsapi.JsapiApiService{Client: c.client}.CloseOrder(ctx, jsapi.CloseOrderRequest{OutTradeNo: core.String(orderNo), Mchid: core.String(c.mchID)}),直接返回 err。
  • internal/service/order.go Cancel:本地条件流转 pending->cancelled 成功后,best-effort 调用 wechat.CloseOrder,失败用 slog.WarnContext 记录(不阻断用户取消响应)。
  • internal/service/order.go PayNotify:将「写支付信息 + 发放权益 + 发订单通知」抽取为可复用私有方法 finalizePaidOrder(ctx, order, data);在 pending->paid 流转返回 false 后重新 OrderGetByOrderNo 读取订单:若 OrderStatusExpired 则改为 expired->paid 流转并调用 finalizePaidOrder(用户已付款应发货);若 OrderStatusCancelled 则记录 slog.ErrorContext 告警(用户主动取消却仍支付,人工介入)。

Bug2 grantVIP 原子续费(资损)

  • internal/mysqldao/user.go 新增原子方法 UserRenewVIPAtomic(ctx, userID int64, now int64, memberDays int64, vipType int8) (int64, error),用一条 UPDATE + gorm.Expr 原子更新,返回 RowsAffected:
  • vip_expire = (CASE WHEN vip_expire > ? THEN vip_expire ELSE ? END) + ?(参数 now, now, memberDays*86400)
  • vip_type = ?(vipType)
  • vip_start = CASE WHEN vip_type = 1 OR vip_expire <= ? THEN ? ELSE vip_start END(参数 now, now)
  • internal/service/order.go grantVIP 改造:先 UserGetByID 读旧值用于判定 changeType/newVIPType/vipStart 与流水 OldVIPExpire,再调用 UserRenewVIPAtomic,随后 UserGetByID 读回真实 newExpire 写 VIP 流水(OldVIPExpire=旧值、NewVIPExpire=读回值),最后失效用户缓存。核心收益:vip_expire 的累加在数据库层原子完成,并发不丢天数。

Bug3 补偿任务死循环

  • internal/mysqldao/benefit_grant_task.go BenefitGrantTaskReset 的 Updates 增加 "retry_count": 0,使 failed 任务重置后真正进入可重试状态。

Bug4 补偿定时任务锁误删

  • internal/service/benefit_compensation.go runBenefitCompensation 改用 redisdao.TryLock(ctx, lockKey, lockVal, time.Minute) + defer redisdao.Unlock(context.Background(), lockKey, lockVal),lockVal := genUUID(),复用现有 lua 校验 value 后才 DEL 的 Unlock 语义。

Bug5 中间件 Redis 错误回落 DB

  • internal/middleware/lzcwyjwt.go JWTAuth 中 UserInfoGet 返回任何错误(redis.Nil 或其它)都回落到 mysqldao.NewUserDao().UserGetByID;仅 DB 查询也失败时才拒绝,并保留「DB 用户不存在 → UnauthorizedTokenError」「状态非正常 → UserDisabled」逻辑。

实现注意事项

  • SQL 一律值绑定,gorm.Expr 仅用于表达式片段、参数通过占位符绑定,禁止字符串拼接 SQL。
  • 所有改动保持向后兼容,不引入事务封装或新依赖;Cancel 关单与 finalizePaidOrder 为增量修改,不影响现有 RefundNotify 流程。
  • 日志复用现有 slog,回调金额校验、订单归属校验保持不变;不打印 token、支付密钥等敏感信息。
  • vip_start/vip_type 的 CASE 更新与 vip_expire 在同一 UPDATE 中原子完成,避免字段间短暂不一致;vip_type 的续费/开通判定沿用现有 memberDays >= 365 规则。

目录结构

zt-closet-backend/
├── pkg/
│   └── wechat/
│       └── pay.go                        # [MODIFY] 新增 CloseOrder 关单方法
├── internal/
│   ├── service/
│   │   ├── order.go                      # [MODIFY] Cancel 关单、PayNotify 过期/取消竞态处理、grantVIP 原子续费、finalizePaidOrder 抽取
│   │   └── benefit_compensation.go       # [MODIFY] 定时任务锁改用 TryLock/Unlock
│   ├── mysqldao/
│   │   ├── user.go                       # [MODIFY] 新增 UserRenewVIPAtomic 原子续费方法
│   │   └── benefit_grant_task.go         # [MODIFY] Reset 增加 retry_count 清零
│   └── middleware/
│       └── lzcwyjwt.go                   # [MODIFY] UserInfoGet 错误回落 DB

关键代码结构

// internal/mysqldao/user.go
// UserRenewVIPAtomic 原子续费会员:vip_expire 使用 CASE 表达式累加避免并发丢失,
// 同时原子维护 vip_type 与 vip_start,返回受影响行数。
func (d *UserDao) UserRenewVIPAtomic(ctx context.Context, userID int64, now int64, memberDays int64, vipType int8) (int64, error)
// pkg/wechat/pay.go
// CloseOrder 关闭微信侧待支付订单,防止本地取消后用户仍完成支付。
func CloseOrder(ctx context.Context, orderNo string) error