107 lines
7.5 KiB
Markdown
107 lines
7.5 KiB
Markdown
---
|
||
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
|
||
``` |