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

10 KiB
Raw Permalink Blame History

name, overview, todos
name overview todos
fix-second-review-bugs 修复第二轮全量代码审查确认的 8 项问题:RefreshToken 未吊销旧 access token(安全)、fortune 定时任务无锁/锁误删、收藏点赞并发重复、衣物删除额度泄漏、资料无校验、AI 明细落库失败被吞、缓存 key 无 TTL。
id content status
fix-refresh-token-revocation 修复 RefreshToken 未吊销旧 access token:DTO 加字段并在服务层精确删除旧会话 completed
id content status
fix-fortune-scheduler-lock 统一运势生成分布式锁为 TryLock/Unlock,修复每日无锁与 Ensure 锁误删 completed
id content status
fix-collect-like-idempotent 新增 collect/like 唯一索引迁移并用 upsert 改造收藏点赞写入 completed
id content status
fix-clothes-quota-refund 修复衣物删除与处理失败的额度回退闭环,防止泄漏与双重回退 completed
id content status
fix-profile-validation 为 ProfileUpdate 增加昵称、性别、生日、头像字段校验 completed
id content status
fix-ai-items-error AI 推荐明细落库失败时回滚穿搭并回退额度 completed
id content status
fix-cache-ttl 为通知未读数与收藏点赞 Redis 集合增加 TTL 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
-- 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);