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