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

157 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: fix-second-review-bugs
overview: 修复第二轮全量代码审查确认的 8 项问题:RefreshToken 未吊销旧 access token(安全)、fortune 定时任务无锁/锁误删、收藏点赞并发重复、衣物删除额度泄漏、资料无校验、AI 明细落库失败被吞、缓存 key 无 TTL。
todos:
- id: fix-refresh-token-revocation
content: 修复 RefreshToken 未吊销旧 access token:DTO 加字段并在服务层精确删除旧会话
status: completed
- id: fix-fortune-scheduler-lock
content: 统一运势生成分布式锁为 TryLock/Unlock,修复每日无锁与 Ensure 锁误删
status: completed
- id: fix-collect-like-idempotent
content: 新增 collect/like 唯一索引迁移并用 upsert 改造收藏点赞写入
status: completed
- id: fix-clothes-quota-refund
content: 修复衣物删除与处理失败的额度回退闭环,防止泄漏与双重回退
status: completed
- id: fix-profile-validation
content: 为 ProfileUpdate 增加昵称、性别、生日、头像字段校验
status: completed
- id: fix-ai-items-error
content: AI 推荐明细落库失败时回滚穿搭并回退额度
status: completed
- id: fix-cache-ttl
content: 为通知未读数与收藏点赞 Redis 集合增加 TTL
status: completed
---
## 产品概述
对全量代码审查确认的 8 项业务/技术漏洞进行修复,覆盖安全(token 吊销)、并发(定时任务锁、收藏点赞幂等)、业务一致性(额度回退、明细落库)、数据校验与缓存健壮性。所有修复基于当前代码真实状态,不触碰前几轮已完成的修复(支付回调竞态、VIP 原子续费、补偿任务锁、中间件 Redis 回落等)。
## 核心功能
1. **RefreshToken 轮换吊销旧 access token**:刷新令牌时同步删除旧 access token 会话,使旧 token 立即失效,不再等到自然过期。
2. **每日运势生成加分布式锁**:多实例部署时每天 0 点仅一个实例生成运势,且锁释放改为校验持有者,杜绝误删。
3. **收藏/点赞并发幂等**:通过数据库唯一索引 + upsert,消除并发双击导致的重复记录与计数虚高。
4. **衣物额度回退闭环**:统一「失败即回退」,处理中删除也回退额度,并防止异步失败路径重复回退。
5. **用户资料字段校验**:昵称长度、性别枚举、生日范围、头像长度校验,拦截非法输入。
6. **AI 推荐明细落库失败处理**:明细批量创建失败时删除已建穿搭并回退额度,避免空推荐。
7. **缓存 key 增加 TTL**:通知未读数与收藏/点赞集合设置过期时间,避免弱一致长期漂移。
## 非目标(本次不修改)
前几轮已完成修复的支付回调竞态、VIP 并发续费、benefit_compensation 锁、中间件 Redis 回落、权益补偿幂等、订单过期、退款人工审核、图片 magic bytes、prompt 注入清洗等,均保持不动。微信登录并发建用户、订单孤儿预支付单、空衣橱快照仍扣额度、回调报文未截断等历史已知项本次仍不处理。
## 技术栈
- 语言与框架:Go、Gin、GORM(gorm.io/gorm v1.31.2 + gorm.io/gorm/clause)、go-redis、log/slog
- 复用现有约定:Service 返回 `(*dto.X, *errcode.Error)`、DAO 返回底层 error、分布式锁统一 `redisdao.TryLock/Unlock`、SQL 一律 gorm 占位符值绑定、日志用 `slog`
## 实现方案
### H1 RefreshToken 吊销旧 access token
- `internal/dto/user.go` `UserRefreshTokenRequest` 新增可选字段 `AccessToken string json:"access_token"`(不加 `binding:"required"`,向前兼容)。
- `internal/service/user.go` `RefreshToken`:若 `request.AccessToken != ""`,先用 `redisdao.AuthUserIDByAccess` 校验其归属 `uid == claims.UserID`,不匹配返回 `UnauthorizedTokenError`;随后 `AuthTokenDel(ctx, claims.UserID, request.AccessToken, request.RefreshToken)` 精确删除旧 access + refresh 会话。为空时保持现状仅删 refresh(记录 warn)。
### M1/M2 运势生成分布式锁(合并于 scheduler.go)
- `pkg/tokenhub/scheduler.go` 新增辅助函数 `runFortuneGenerationWithLock`:内部生成 `lockKey = "ztcloset:lock:fortune:daily:" + today`、`lockVal = uuid.NewString()`,`redisdao.TryLock(ctx, lockKey, lockVal, 5*time.Minute)` 失败即返回,`defer redisdao.Unlock(context.Background(), lockKey, lockVal)`,再调 `runFortuneGeneration`。
- `StartFortuneScheduler` 每日循环与 `EnsureTodayFortunes` 均改调该带锁辅助函数;删除 `EnsureTodayFortunes` 中现有的 `SetNX + 无条件 Del`。
- 该文件已 import `internal/mysqldao`,按 Go internal 规则可 import `internal/redisdao`;新增 import `github.com/google/uuid`。
### M3 收藏/点赞并发幂等
- 新增 `migrations/collect_like_unique.sql`:`collects` 加 `UNIQUE KEY uk_collect (collect_type, target_id, user_id)`,`likes` 加 `UNIQUE KEY uk_like (like_type, target_id, user_id)`。DDL 前先清理历史重复数据(保留最小 id)。
- `internal/mysqldao/collect.go` 新增 `CollectUpsertOn`:用 `clause.OnConflict{Columns: [collect_type,target_id,user_id], DoUpdates: AssignmentColumns(["status"])}` 的 `Create`。
- `internal/mysqldao/like.go` 新增 `LikeUpsertOn`,语义同上。
- `internal/service/collect.go` `Collect` 的 `ActionDo` 分支与 `internal/service/like.go` `LikeCreate` 的 `ActionDo` 分支改为直接调用 upsert(去掉 read-then-write),`ActionCancel` 保持现有查询后更新(幂等无害)。
### L1 衣物额度回退闭环
- `internal/service/clothes.go`:
- 分类失败分支(原仅置 Failed 不回退)补 `refundQuota`,统一「失败即回退」。
- 新增辅助 `refundIfExists(ctx, userID, clothesUUID, reason)`:先 `ClothesGetByUUID`(软删除自动过滤),仅当衣物仍存在时才 `refundQuota`,已删除则跳过。
- `markProcessFailed` 改用 `refundIfExists` 替代无条件 `refundQuota`,防止「处理中删除已回退 + 异步失败再回退」双重回退。
- `DeleteByUUID` 回退条件由 `== ImageProcessSuccess` 改为 `!= ImageProcessFailed`(成功与排队/抠图/分类中等处理中状态均回退;失败状态因已回退而不回退)。
### L2 ProfileUpdate 字段校验
- `internal/service/user.go` `ProfileUpdate` 增加校验:
- `Username`:trim 后按 rune 长度 ≤ 32,超长返回 `InvalidParams`。
- `Avatar`:长度 ≤ 500。
- `Sex`:非 0 时必须为 `SexMale(1)` 或 `SexFemale(2)`。
- `BirthDay`:必须 `> 0` 且 `<= nowUnix()`。
- 校验失败返回 `errcode.InvalidParams.WithDetails(...)`;新增 import `strings`。
### L3 AI 推荐明细落库失败处理
- `internal/service/airecommend.go` 第 119 行附近:`OutfitItemCreateBatch` 返回 error 时,先 `OutfitDeleteByUUID` 软删除已建穿搭,再 `return nil, fail(errcode.OutfitCreateFailed, "穿搭明细落库失败")`(`fail` 闭包会回退 AI 额度并写失败流水)。
### L4 缓存 key 加 TTL
- `internal/redisdao/notification.go`:新增 `notificationUnreadIncrScript`(INCR 后首次 EXPIRE),`NotificationUnreadIncr` 改用脚本,TTL 常量 `7 * 24 * time.Hour`。
- `internal/redisdao/collect.go` `CollectAdd`:`SADD` 后 `Expire(key, 7*24h)`(best-effort)。
- `internal/redisdao/like.go` `LikeAdd`:同上加 `Expire`。`Remove` 系列不刷新 TTL。
## 实现注意事项
- 安全:M3 的 upsert 依赖唯一索引,DDL 需部署时手动执行;若 `collects/likes` 已存在重复数据,必须先清理再建索引(SQL 中提供 `DELETE` 保留最小 id 的清理语句)。
- 一致性:L1 的核心是「失败即回退 + 删除回退 + 已删除不回退」三段式闭环,`refundIfExists` 是防止双重回退的关键守卫,不可省略。
- 向后兼容:H1 的 `AccessToken` 为可选字段,老客户端不传不报错,仅保留原行为;新客户端传则立即吊销旧 token。
- 影响范围:所有改动为增量修改,不引入事务封装或新依赖;不改动既有的支付/订单/额度/补偿相关已修复逻辑。
- 日志:沿用 `slog`,H1 旧 token 未携带时用 `WarnContext` 提示,不打印 token 明文与密钥。
## 目录结构
```
zt-closet-backend/
├── internal/
│ ├── dto/
│ │ └── user.go # [MODIFY] UserRefreshTokenRequest 新增 AccessToken 字段
│ ├── service/
│ │ ├── user.go # [MODIFY] RefreshToken 吊销旧 access;ProfileUpdate 字段校验
│ │ ├── collect.go # [MODIFY] ActionDo 改 upsert 防并发重复
│ │ ├── like.go # [MODIFY] ActionDo 改 upsert 防并发重复
│ │ ├── clothes.go # [MODIFY] 删除/失败额度回退闭环
│ │ └── airecommend.go # [MODIFY] 明细落库失败回滚处理
│ ├── mysqldao/
│ │ ├── collect.go # [MODIFY] 新增 CollectUpsertOn
│ │ └── like.go # [MODIFY] 新增 LikeUpsertOn
│ └── redisdao/
│ ├── notification.go # [MODIFY] 未读数 key 加 TTL
│ ├── collect.go # [MODIFY] 收藏集合加 TTL
│ └── like.go # [MODIFY] 点赞集合加 TTL
├── pkg/
│ └── tokenhub/
│ └── scheduler.go # [MODIFY] 运势生成统一 TryLock/Unlock 锁
└── migrations/
└── collect_like_unique.sql # [NEW] collects/likes 唯一索引 + 历史重复清理
```
## 关键代码结构
```
// internal/mysqldao/collect.go
// CollectUpsertOn 收藏幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。
func (d *CollectDao) CollectUpsertOn(ctx context.Context, collect *mysqlmodel.Collect) error
// internal/mysqldao/like.go
// LikeUpsertOn 点赞幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。
func (d *LikeDao) LikeUpsertOn(ctx context.Context, like *mysqlmodel.Like) error
```
```sql
-- migrations/collect_like_unique.sql
-- 1) 清理历史重复(保留最小 id)
DELETE c1 FROM collects c1 JOIN collects c2
ON c1.collect_type=c2.collect_type AND c1.target_id=c2.target_id AND c1.user_id=c2.user_id
AND c1.id > c2.id;
ALTER TABLE collects ADD UNIQUE KEY uk_collect (collect_type, target_id, user_id);
DELETE l1 FROM likes l1 JOIN likes l2
ON l1.like_type=l2.like_type AND l1.target_id=l2.target_id AND l1.user_id=l2.user_id
AND l1.id > l2.id;
ALTER TABLE likes ADD UNIQUE KEY uk_like (like_type, target_id, user_id);
```