From a2a28a9f5664ede6678fe2f7778fce1ecc17d81b Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sat, 20 Jun 2026 14:53:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CachedUserRepository=20=E2=80=94=20refr?= =?UTF-8?q?esh=20token=20=E7=9A=84=20Redis=20=E7=BC=93=E5=AD=98=E8=A3=85?= =?UTF-8?q?=E9=A5=B0=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 装饰 UserRepository,仅缓存 refresh token 相关操作 - SaveRefreshToken: Write-Through,先写 DB 再写 Redis(SET + SADD) - FindRefreshToken: Read-Through,Redis miss 时查 DB 并回填 - DeleteRefreshToken: 双删 DB + Redis - DeleteUserRefreshTokens: 通过 Redis Set 批量清理缓存后删 DB - Redis 操作失败时降级到纯 DB,不阻断主流程 --- backend/internal/store/cached_user.go | 168 ++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 backend/internal/store/cached_user.go diff --git a/backend/internal/store/cached_user.go b/backend/internal/store/cached_user.go new file mode 100644 index 0000000..dfc2e9e --- /dev/null +++ b/backend/internal/store/cached_user.go @@ -0,0 +1,168 @@ +package store + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/hhs/camtalk/internal/logger" +) + +// Redis key 前缀。 +const ( + refreshTokenPrefix = "auth:refresh:" // auth:refresh:{token_hash} → user_id + userRefreshPrefix = "auth:user_refresh:" // auth:user_refresh:{user_id} → Set of token_hash +) + +// CachedUserRepository 装饰器,为 UserRepository 的 refresh token 操作增加 Redis 缓存。 +// 读路径:Redis miss → DB → 回填 Redis。 +// 写路径:同步双写 Redis + DB。 +// 删路径:同步双删 Redis + DB。 +// Redis 操作失败时降级到纯 DB,不阻断主流程。 +type CachedUserRepository struct { + inner UserRepository + rdb *redis.Client + backfillTTL time.Duration // DB 回填 Redis 时使用的默认 TTL +} + +// NewCachedUserRepository 创建带 Redis 缓存的 UserRepository 装饰器。 +// backfillTTL: 从 DB 回填 Redis 时使用的 TTL(因 DB 接口不返回 expiresAt)。 +func NewCachedUserRepository(inner UserRepository, rdb *redis.Client, backfillTTL time.Duration) *CachedUserRepository { + if backfillTTL <= 0 { + backfillTTL = 24 * time.Hour + } + return &CachedUserRepository{ + inner: inner, + rdb: rdb, + backfillTTL: backfillTTL, + } +} + +// refreshTokenKey 生成 refresh token 的 Redis key。 +func refreshTokenKey(tokenHash string) string { + return refreshTokenPrefix + tokenHash +} + +// userRefreshKey 生成用户 refresh token 集合的 Redis key。 +func userRefreshKey(userID string) string { + return userRefreshPrefix + userID +} + +// --- 委托方法(不做缓存) --- + +func (r *CachedUserRepository) Create(ctx context.Context, username, passwordHash string) (string, error) { + return r.inner.Create(ctx, username, passwordHash) +} + +func (r *CachedUserRepository) FindByUsername(ctx context.Context, username string) (*User, error) { + return r.inner.FindByUsername(ctx, username) +} + +func (r *CachedUserRepository) FindByID(ctx context.Context, id string) (*User, error) { + return r.inner.FindByID(ctx, id) +} + +// --- 缓存方法 --- + +// SaveRefreshToken Write-Through:先写 DB,再写 Redis。 +func (r *CachedUserRepository) SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { + // 先写 DB + if err := r.inner.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil { + return err + } + + // 写 Redis(SET + SADD),设置 TTL 为 token 剩余有效期 + ttl := time.Until(expiresAt) + if ttl <= 0 { + return nil + } + + key := refreshTokenKey(tokenHash) + pipe := r.rdb.Pipeline() + pipe.Set(ctx, key, userID, ttl) + pipe.SAdd(ctx, userRefreshKey(userID), tokenHash) + if _, err := pipe.Exec(ctx); err != nil { + logger.Log.Warnw("Redis cache write failed for refresh token", "error", err) + // 降级:DB 已写入成功,Redis 失败不影响正确性 + } + return nil +} + +// FindRefreshToken Read-Through:先查 Redis,miss 时查 DB 并回填。 +func (r *CachedUserRepository) FindRefreshToken(ctx context.Context, tokenHash string) (string, error) { + key := refreshTokenKey(tokenHash) + + // 查 Redis + userID, err := r.rdb.Get(ctx, key).Result() + if err == nil { + return userID, nil + } + // redis.Nil 表示 key 不存在,其他错误记录日志后降级到 DB + if err != redis.Nil { + logger.Log.Warnw("Redis cache read failed for refresh token", "error", err) + } + + // 降级到 DB + userID, err = r.inner.FindRefreshToken(ctx, tokenHash) + if err != nil { + return "", err + } + + // 回填 Redis(SET + SADD),TTL 使用保守默认值 + go func() { + bgCtx := context.Background() + pipe := r.rdb.Pipeline() + pipe.Set(bgCtx, key, userID, r.backfillTTL) + pipe.SAdd(bgCtx, userRefreshKey(userID), tokenHash) + _, _ = pipe.Exec(bgCtx) + }() + + return userID, nil +} + +// DeleteRefreshToken 双删:先删 DB,再删 Redis。 +func (r *CachedUserRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error { + // 先从 Redis 获取 user_id(用于从集合中移除) + userID, _ := r.rdb.Get(ctx, refreshTokenKey(tokenHash)).Result() + + // 删 DB + if err := r.inner.DeleteRefreshToken(ctx, tokenHash); err != nil { + return err + } + + // 删 Redis + key := refreshTokenKey(tokenHash) + pipe := r.rdb.Pipeline() + pipe.Del(ctx, key) + if userID != "" { + pipe.SRem(ctx, userRefreshKey(userID), tokenHash) + } + if _, err := pipe.Exec(ctx); err != nil { + logger.Log.Warnw("Redis cache delete failed for refresh token", "error", err) + } + return nil +} + +// DeleteUserRefreshTokens 批量清理:先从 Redis 获取集合,逐个删缓存,再删 DB。 +func (r *CachedUserRepository) DeleteUserRefreshTokens(ctx context.Context, userID string) error { + userKey := userRefreshKey(userID) + + // 从 Redis 获取该用户所有 token hash + hashes, _ := r.rdb.SMembers(ctx, userKey).Result() + + // 批量删除 Redis 缓存 + if len(hashes) > 0 { + keys := make([]string, 0, len(hashes)+1) + for _, h := range hashes { + keys = append(keys, refreshTokenKey(h)) + } + keys = append(keys, userKey) + if err := r.rdb.Del(ctx, keys...).Err(); err != nil { + logger.Log.Warnw("Redis cache batch delete failed for user refresh tokens", "error", err, "userID", userID) + } + } + + // 删 DB(无论 Redis 是否成功都执行) + return r.inner.DeleteUserRefreshTokens(ctx, userID) +}