first commit
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
---
|
||||
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);
|
||||
```
|
||||
Reference in New Issue
Block a user