fix: 前后台 Token Redis 存储隔离,修复同账号互相覆盖问题

核心变更:
- Redis key 加入 clientType 前缀:echo:auth:token:{frontend|admin}:{user_id}
- JWT Claims 新增 client_type 字段区分前台/管理端
- 新增 constants/client_type.go 定义 ClientTypeFrontend / ClientTypeAdmin
- 全链路传递 clientType:Controller → Service → TokenStore → Redis
- 中间件从 JWT Claims 提取 clientType 做 Redis 校验
- 登出只删除当前端 Token,不影响另一端

前端变更:
- 管理端 login API 改为调用 POST /api/v1/admin/auth/login(之前错误调用了前台接口)

文档更新:
- 系统设计文档、实施计划、API文档、开发规范、项目进度全部同步

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-02 14:28:45 +08:00
parent cfb1651480
commit c3e8600b24
13 changed files with 206 additions and 76 deletions

View File

@@ -11,10 +11,12 @@ import (
"go.uber.org/zap"
)
// Redis Key 前缀,遵循设计方案中 echo:auth:* 的命名规范
// Redis Key 前缀,按 client_type 隔离前台和管理端的 Token
// 格式echo:auth:token:{client_type}:{user_id}
// 例如echo:auth:token:frontend:1 / echo:auth:token:admin:1
const (
keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{user_id}
keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{user_id}
keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{client_type}:{user_id}
keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{client_type}:{user_id}
)
// TokenStore 管理 Token 在 Redis 中的存取
@@ -33,12 +35,13 @@ func NewTokenStore(redisClient *redis.Client, jwtCfg *config.JWTConfig) *TokenSt
}
// SaveTokens 将 Access Token 和 Refresh Token 存入 Redis
// 每次登录/注册时调用,覆盖旧 Token(实现单设备登录)
func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken, refreshToken string) error {
// 每次登录/注册时调用,覆盖同一 clientType 下的旧 Token
// clientType 用于隔离前台frontend和管理端admin的 Token
func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, clientType, accessToken, refreshToken string) error {
funcName := "service.token_store.SaveTokens"
accessKey := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID)
refreshKey := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID)
accessKey := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
refreshKey := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
accessTTL := time.Duration(s.jwtCfg.AccessExpireMin) * time.Minute
refreshTTL := time.Duration(s.jwtCfg.RefreshExpireDay) * 24 * time.Hour
@@ -50,6 +53,7 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken,
if _, err := pipe.Exec(ctx); err != nil {
logs.Error(ctx, funcName, "保存 Token 到 Redis 失败",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
zap.Error(err),
)
return err
@@ -57,6 +61,7 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken,
logs.Debug(ctx, funcName, "Token 已存入 Redis",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
zap.Duration("access_ttl", accessTTL),
zap.Duration("refresh_ttl", refreshTTL),
)
@@ -64,21 +69,23 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, accessToken,
}
// ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致
// 返回 true 表示有效false 表示已被登出/覆盖
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, token string) bool {
// clientType 用于定位正确的 Redis key前台 vs 管理端)
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool {
funcName := "service.token_store.ValidateAccessToken"
key := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID)
key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
stored, err := s.redis.Get(ctx, key).Result()
if err == redis.Nil {
logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
)
return false
}
if err != nil {
logs.Error(ctx, funcName, "Redis 查询 Token 失败",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
zap.Error(err),
)
return false
@@ -88,8 +95,8 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, toke
}
// ValidateRefreshToken 校验 Refresh Token 是否与 Redis 中存储的一致
func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, token string) bool {
key := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID)
func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, clientType, token string) bool {
key := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
stored, err := s.redis.Get(ctx, key).Result()
if err != nil {
return false
@@ -97,16 +104,17 @@ func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, tok
return stored == token
}
// RemoveTokens 从 Redis 删除用户的所有 Token登出时调用
func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64) error {
// RemoveTokens 从 Redis 删除指定 clientType 下用户的 Token登出时调用
func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64, clientType string) error {
funcName := "service.token_store.RemoveTokens"
accessKey := fmt.Sprintf("%s%d", keyPrefixAccessToken, userID)
refreshKey := fmt.Sprintf("%s%d", keyPrefixRefreshToken, userID)
accessKey := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
refreshKey := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
if err := s.redis.Del(ctx, accessKey, refreshKey).Err(); err != nil {
logs.Error(ctx, funcName, "删除 Token 失败",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
zap.Error(err),
)
return err
@@ -114,6 +122,7 @@ func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64) error {
logs.Info(ctx, funcName, "Token 已从 Redis 删除",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
)
return nil
}