10 KiB
10 KiB
name, overview, todos
| name | overview | todos | |||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| fix-second-review-bugs | 修复第二轮全量代码审查确认的 8 项问题:RefreshToken 未吊销旧 access token(安全)、fortune 定时任务无锁/锁误删、收藏点赞并发重复、衣物删除额度泄漏、资料无校验、AI 明细落库失败被吞、缓存 key 无 TTL。 |
|
产品概述
对全量代码审查确认的 8 项业务/技术漏洞进行修复,覆盖安全(token 吊销)、并发(定时任务锁、收藏点赞幂等)、业务一致性(额度回退、明细落库)、数据校验与缓存健壮性。所有修复基于当前代码真实状态,不触碰前几轮已完成的修复(支付回调竞态、VIP 原子续费、补偿任务锁、中间件 Redis 回落等)。
核心功能
- RefreshToken 轮换吊销旧 access token:刷新令牌时同步删除旧 access token 会话,使旧 token 立即失效,不再等到自然过期。
- 每日运势生成加分布式锁:多实例部署时每天 0 点仅一个实例生成运势,且锁释放改为校验持有者,杜绝误删。
- 收藏/点赞并发幂等:通过数据库唯一索引 + upsert,消除并发双击导致的重复记录与计数虚高。
- 衣物额度回退闭环:统一「失败即回退」,处理中删除也回退额度,并防止异步失败路径重复回退。
- 用户资料字段校验:昵称长度、性别枚举、生日范围、头像长度校验,拦截非法输入。
- AI 推荐明细落库失败处理:明细批量创建失败时删除已建穿搭并回退额度,避免空推荐。
- 缓存 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.goUserRefreshTokenRequest新增可选字段AccessToken string json:"access_token"(不加binding:"required",向前兼容)。internal/service/user.goRefreshToken:若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 规则可 importinternal/redisdao;新增 importgithub.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.goCollect的ActionDo分支与internal/service/like.goLikeCreate的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.goProfileUpdate增加校验:Username:trim 后按 rune 长度 ≤ 32,超长返回InvalidParams。Avatar:长度 ≤ 500。Sex:非 0 时必须为SexMale(1)或SexFemale(2)。BirthDay:必须> 0且<= nowUnix()。- 校验失败返回
errcode.InvalidParams.WithDetails(...);新增 importstrings。
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.goCollectAdd:SADD后Expire(key, 7*24h)(best-effort)。internal/redisdao/like.goLikeAdd:同上加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);