topfans/backend/services/userService/service/auth_service.go
zerosaturation e1326acaf9 fix(backend): service stability — bcrypt off-txn / login anti-enum / MQ stub / aiChat / event reliability / gateway aggregate (batch 3)
- 3.1 bcrypt 移出事务 (Register): repository.HashPassword 前移到 db.Transaction 之前,消除连接池占用。
- 3.2 Login 消除用户枚举 + 限流 + timing 抹平: pkg/errors 加 ErrInvalidCredential
  /ErrTooManyLoginAttempts; 用户不存在/密码错/密码空 三路径统一返回同一错误;
  mobile 5次/ip 20次 per 15min 限流 (Redis, fail-open 降级); user-not-found 走
  dummy bcrypt 抹平 ~100ms 时序差,完全消除枚举侧信道;空密码分支已核实无时序 leak。
- 3.3 MQ streams adapter 停用 → stub: 0 业务调用方, 新 stub EventProducer.Publish no-op;
  pkg/mq/mq.go Init 不再装配 streams; 全仓 grep 验证 11 处硬编码
  'gallery'/'default' 集中到 pkg/queue/consts (值不变, 仅消漂移)。
- 3.5 JWT 密钥治理: pkg/jwt MustInit fail-fast + atomic.Value (见上一个 commit 293c7b1)。
- 3.6 aiChat 健壮性: SaveContext 用 persona.ID(非 req.PersonaId); Redis/memory 错误
  记 WARN 不静默; Dify err 映射稳定用户文案,原始 err 仅服务端日志。
- 3.7 statistic.Client 重构: TrackEvent 改 buffered channel (cap 1024) + dispatchLoop
  worker; 失败 ERROR 日志带字段; drop 记 WARN; Close 可重复调用。
- 3.8 网关聚合: StarCache (60s TTL, singleflight) 替换 5+ 处 GetFanIdentities 链式调用;
  DeleteAccount 改网关直调 userService.DeleteAccount(避免改 hand-written triple.go
  风险,见报告 §5 proto 风险复盘); 铸造双写改异步 channel+consumer (3 retry)。
- 大量单测: 各子项 TDD (RED→GREEN), 关键并发 race_test (50 goroutine)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 18:50:40 +08:00

888 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"errors"
"fmt"
"time"
appErrors "github.com/topfans/backend/pkg/errors"
"github.com/topfans/backend/pkg/jwt"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
pbCommon "github.com/topfans/backend/pkg/proto/common"
pb "github.com/topfans/backend/pkg/proto/user"
"github.com/topfans/backend/pkg/validator"
"github.com/topfans/backend/services/userService/repository"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"google.golang.org/grpc/codes"
)
// dummyBcryptHash 用于"用户不存在"分支的常量时间 bcrypt 比对,抹平
// "GetByMobile 命中 + VerifyPassword(~100ms)" vs "GetByMobile 未命中 + 直接返回"的
// 时序差异。攻击者侧:采样多个 mobile 的 Login 响应耗时,在修复前两类 mobile 的
// 平均响应时间差 ~100ms,足以脚本化枚举;修复后两条路径都跑一次
// bcrypt.CompareHashAndPassword,误差范围变小到网络/RPC 开销量级,枚举不再可行。
//
// bcrypt cost 与 repository.HashPassword(bcrypt.DefaultCost=10) 保持一致;
// 该 hash 来自 cost=10、口令 "dummy-password-for-timing-equalization" 的预生成结果,
// 任何人都不应能用它反算出真实口令。
//
// 选用 hardcoded 而非 init-time GenerateFromPassword:
// 1) init 运行就消耗 ~100ms,拖累启动
// 2) 重启后 hash 唯一标识会变化(每次 salt 不同),给日志/审计带来干扰
const dummyBcryptHash = "$2a$10$024Ruwb8pTfRm9GKprx9AOmyvpBTJaWkcyYdl0JsFzrVtqAhD1HTq"
// equalizeTimingToRealBcrypt 在"用户不存在"分支调用一次 bcrypt 比对,
// 使该分支的 CPU 耗时与"密码错"分支(走真实 VerifyPassword)对齐。
// 比对结果有意丢弃 —— 我们不在乎密码匹不匹配,只在耗时上对齐。
func equalizeTimingToRealBcrypt(password string) {
// _ = bcrypt.CompareHashAndPassword(...) 不行,需要真调用
// CompareHashAndPassword 在 hash 错误格式时 panic;用 recover 兜底
// (hardcoded 不会发生,但理论防御)
defer func() { _ = recover() }()
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
}
type AuthService interface {
// Register 注册ctx用于验证verify_token
Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error)
// Login 登录
Login(req *pb.LoginRequest) (*pb.LoginResponse, error)
// Logout 登出userID 从网关的 attachments 中获取)
Logout(userID int64) (*pb.LogoutResponse, error)
// RefreshToken 刷新TokenuserID 和 starID 从网关的 attachments 中获取)
RefreshToken(userID, starID int64) (*pb.RefreshTokenResponse, error)
// ValidateToken 验证Token用于中间件
ValidateToken(req *pb.ValidateTokenRequest) (*pb.ValidateTokenResponse, error)
// DeleteAccount 注销账号软删:软删 user + 停用 fan_profiles + 释放 mobile/nickname
// userID 从网关的 attachments 中获取。
// 返回: deactivated_profile_count + error
// 注意:JWT 黑名单是网关职责(本 gateway 已经先 AddToBlacklist 再调本 RPC),
// 本方法只负责"用户生命周期"语义,不接触 token。
DeleteAccount(userID int64) (int32, error)
}
// authService 认证Service实现
type authService struct {
userRepo repository.UserRepository
fanProfileRepo repository.FanProfileRepository
starRepo repository.StarRepository
db *gorm.DB
}
// NewAuthService 创建认证Service实例
func NewAuthService(
userRepo repository.UserRepository,
fanProfileRepo repository.FanProfileRepository,
starRepo repository.StarRepository,
db *gorm.DB,
) AuthService {
return &authService{
userRepo: userRepo,
fanProfileRepo: fanProfileRepo,
starRepo: starRepo,
db: db,
}
}
// Register 用户注册
func (s *authService) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
// 0. 校验 verify_tokenPlan A: 仅校验,不删除;业务成功后由 ConsumeVerifyToken 消费)
if req.VerifyToken != "" {
if err := VerifyToken(ctx, "register", req.Mobile, req.VerifyToken); err != nil {
logger.Logger.Warn("Verify token validation failed",
zap.String("mobile", req.Mobile),
zap.Error(err))
return nil, fmt.Errorf("invalid verify_token: %w", err)
}
}
// 1. 参数验证
if !validator.ValidateMobile(req.Mobile) {
logger.Logger.Warn("Invalid mobile format",
zap.String("mobile", req.Mobile),
)
return nil, appErrors.ErrInvalidMobile
}
if valid, msg := validator.ValidatePassword(req.Password); !valid {
logger.Logger.Warn("Invalid password",
zap.String("mobile", req.Mobile),
zap.String("error", msg),
)
if msg == "password too short" {
return nil, appErrors.ErrPasswordTooShort
}
return nil, fmt.Errorf("invalid password: %s", msg)
}
if valid, msg := validator.ValidateNickname(req.Nickname); !valid {
logger.Logger.Warn("Invalid nickname",
zap.String("mobile", req.Mobile),
zap.String("error", msg),
)
return nil, fmt.Errorf("invalid nickname: %s", msg)
}
if !validator.ValidateStarID(req.StarId) {
logger.Logger.Warn("Invalid star_id",
zap.String("mobile", req.Mobile),
zap.Int64("star_id", req.StarId),
)
return nil, appErrors.ErrInvalidStarID
}
// 2. 验证手机号是否已存在
existingUser, err := s.userRepo.GetByMobile(req.Mobile)
if err != nil && !errors.Is(err, appErrors.ErrUserNotFound) {
logger.Logger.Error("Failed to check mobile existence",
zap.String("mobile", req.Mobile),
zap.Error(err),
)
return nil, fmt.Errorf("failed to check mobile: %w", err)
}
if existingUser != nil {
logger.Logger.Warn("Mobile already exists",
zap.String("mobile", req.Mobile),
)
return nil, appErrors.ErrUserAlreadyExists
}
// 3. 验证明星是否存在
_, err = s.starRepo.GetByID(req.StarId)
if err != nil {
logger.Logger.Error("Failed to get star",
zap.Int64("star_id", req.StarId),
zap.Error(err),
)
if errors.Is(err, appErrors.ErrStarNotFound) {
return nil, appErrors.ErrStarNotFound
}
return nil, fmt.Errorf("failed to get star: %w", err)
}
// 4. 使用事务创建用户和粉丝档案
var user *models.User
var fanProfile *models.FanProfile
// 4.0 事务【之前】完成密码哈希
// bcrypt 是 CPU 密集操作cost≈100ms。若放在 s.db.Transaction(...) 内执行,
// 会在整段哈希耗时里持有 DB 连接,注册高峰下极易耗尽连接池。
// 这里前移到事务外:哈希值与后续 DB 写入逻辑完全不变(行为等价),
// 事务内只做纯 DB 写Create user / Create fanProfile / UpdateColumns token
hashedPassword, err := repository.HashPassword(req.Password)
if err != nil {
logger.Logger.Error("Failed to hash password",
zap.String("mobile", req.Mobile),
zap.Error(err),
)
return nil, fmt.Errorf("failed to hash password: %w", err)
}
err = s.db.Transaction(func(tx *gorm.DB) error {
// 4.1 创建用户
now := time.Now().UnixMilli()
user = &models.User{
Mobile: req.Mobile,
PasswordHash: hashedPassword, // 已在事务外计算完成
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}
// 如果前端传入了头像URL则使用,否则保持空前端会基于userId渲染默认头像
if req.AvatarUrl != "" {
avatarURL := req.AvatarUrl
user.AvatarURL = &avatarURL
}
// 在事务中创建用户
if err := tx.Create(user).Error; err != nil {
logger.Logger.Error("Failed to create user in transaction",
zap.String("mobile", req.Mobile),
zap.Error(err),
)
return fmt.Errorf("failed to create user: %w", err)
}
// 4.2 创建第一个粉丝档案
fanProfile = &models.FanProfile{
UserID: user.ID,
StarID: req.StarId,
Nickname: req.Nickname,
AvatarURL: user.AvatarURL,
Level: 1,
Times: 1,
Social: 0,
CoinBalance: 0,
CrystalBalance: 0,
Tags: models.StringArray{},
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(fanProfile).Error; err != nil {
logger.Logger.Error("Failed to create fan profile in transaction",
zap.Int64("user_id", user.ID),
zap.Int64("star_id", req.StarId),
zap.Error(err),
)
return fmt.Errorf("failed to create fan profile: %w", err)
}
// 4.3 生成JWT Token
token, err := jwt.GenerateToken(user.ID, req.StarId, user.UpdatedAt)
if err != nil {
logger.Logger.Error("Failed to generate token",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
return fmt.Errorf("failed to generate token: %w", err)
}
// 4.4 更新用户Token
// 注意:使用 UpdateColumns 而不是 Updates避免触发 BeforeUpdate 钩子
// 因为更新 Token 不应该改变 updated_atupdated_at 用于验证 Token 有效性)
tokenExpiresAt := jwt.GetExpiresAt()
if err := tx.Model(user).UpdateColumns(map[string]interface{}{
"access_token": token,
"token_expires_at": tokenExpiresAt,
}).Error; err != nil {
logger.Logger.Error("Failed to update token in transaction",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
return fmt.Errorf("failed to update token: %w", err)
}
// 更新user对象以便返回
user.AccessToken = &token
expiresAt := tokenExpiresAt
user.TokenExpiresAt = &expiresAt
return nil
})
if err != nil {
// 检查是否是唯一约束错误
errStr := err.Error()
if contains(errStr, "uk_fan_profiles_star_nickname") || contains(errStr, "该昵称已被注册") {
return nil, appErrors.ErrNicknameAlreadyExists
}
return nil, err
}
// 4.5 (Plan A) 业务成功后原子消费 verify_token
// Consume 失败(Redis 抖动)仅记日志,不返回错误——业务已成功,token 保留可自愈
if req.VerifyToken != "" {
if err := ConsumeVerifyToken(ctx, "register", req.Mobile, req.VerifyToken); err != nil {
logger.Logger.Error("failed to consume verify token after register (self-healing on retry)",
zap.String("mobile", req.Mobile),
zap.Error(err))
}
}
// 5. 构建响应
response := &pb.RegisterResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
AccessToken: *user.AccessToken,
ExpiresIn: jwt.GetExpiresIn(),
User: ModelToProtoUser(user),
FanProfile: ModelToProtoFanProfile(fanProfile),
}
logger.Logger.Info("User registered successfully",
zap.Int64("user_id", user.ID),
zap.String("mobile", user.Mobile),
zap.Int64("star_id", req.StarId),
)
return response, nil
}
// Login 用户登录
//
// 安全特性(service-stability plan §3.2):
// - 统一错误码:无论"用户不存在"还是"密码错",对外返回 ErrInvalidCredential,
// 防止攻击者通过差异化的错误码/消息枚举有效账号。
// - 限流:在 GetByMobile 之前先查 mobile/IP 维度失败计数;超过阈值返回
// ErrTooManyLoginAttempts(gRPC ResourceExhausted),避免:
//
// a) 暴力破解
// b) "用户不存在"vs"密码错"在 DB 行为差异上的计时侧信道放大(限流检查比 DB 命中快得多,
// 路径长度差异反而成为信号,因此把限流检查统一前置,让攻击者无法利用查询时长区分)。
//
// - 失败计数:账号不匹配或密码错都会 IncrLoginFailure(mobile, ip),登录成功时清零。
// - IP 维度:当前 Login 签名不带 client IP,统一以 "0.0.0.0" 占位 → 被 login_ratelimit.isSkippedIP
// 跳过,不参与限流(避免全用户共享同一 IP 桶)。批次 5 把 client IP 透传后可自动启用。
func (s *authService) Login(req *pb.LoginRequest) (*pb.LoginResponse, error) {
ctx := context.Background()
// 占位 IP:Login proto 未透传 client IP;批次 5 透传前 IP 维度被 isSkippedIP 跳过。
const clientIP = "0.0.0.0"
// 1. 参数验证
if !validator.ValidateMobile(req.Mobile) {
logger.Logger.Warn("Invalid mobile format",
zap.String("mobile", req.Mobile),
)
return nil, appErrors.ErrInvalidMobile
}
if req.Password == "" {
// 空密码也归并到 ErrInvalidCredential,避免成为"账号是否存在"的旁路信号。
logger.Logger.Warn("Password is empty",
zap.String("mobile", req.Mobile),
)
return nil, appErrors.ErrInvalidCredential
}
// 1.5 限流检查(在任何 DB / bcrypt 操作之前)
if err := CheckLoginRateLimit(ctx, req.Mobile, clientIP); err != nil {
logger.Logger.Warn("Login rate limited",
zap.String("mobile", req.Mobile),
zap.Error(err),
)
return nil, err
}
// 2. 根据手机号查询用户
user, err := s.userRepo.GetByMobile(req.Mobile)
if err != nil {
if errors.Is(err, appErrors.ErrUserNotFound) {
// 统一对外错误:不再泄露"用户是否存在"。
logger.Logger.Warn("User not found",
zap.String("mobile", req.Mobile),
)
_, _ = IncrLoginFailure(ctx, req.Mobile, clientIP)
// ★ 时序对齐:抹平"用户不存在"分支(无 bcrypt)与"密码错"分支(~100ms bcrypt)的耗时差,
// 防止攻击者用响应耗时脚本化枚举有效 mobile。
// dummyBcryptHash 用 cost=10(与 repository.HashPassword 的 bcrypt.DefaultCost 一致),
// 真实 VerifyPassword 也走 cost=10,两条路径 CPU 量级对齐。
equalizeTimingToRealBcrypt(req.Password)
return nil, appErrors.ErrInvalidCredential
}
logger.Logger.Error("Failed to get user by mobile",
zap.String("mobile", req.Mobile),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get user: %w", err)
}
// 3. 验证密码
if !s.userRepo.VerifyPassword(user, req.Password) {
// 统一对外错误:与"用户不存在"返回相同的 ErrInvalidCredential,
// 攻击者无法通过错误码/消息区分有效账号。
logger.Logger.Warn("Invalid password",
zap.String("mobile", req.Mobile),
zap.Int64("user_id", user.ID),
)
_, _ = IncrLoginFailure(ctx, req.Mobile, clientIP)
return nil, appErrors.ErrInvalidCredential
}
// 4. 验证用户是否激活
if !user.IsActive {
logger.Logger.Warn("User is inactive",
zap.Int64("user_id", user.ID),
zap.String("mobile", req.Mobile),
)
return nil, appErrors.ErrUserInactive
}
// 4.1 检查账号状态(冻结/封号)
accountStatus, err := s.userRepo.GetAccountStatus(user.ID)
if err != nil {
logger.Logger.Error("Failed to get account status",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get account status: %w", err)
}
// 如果有账号状态记录,需要检查具体状态
if accountStatus != nil {
if accountStatus.IsBanned() {
// 封号状态(§12.2: 改用 typed error,业务码 403 而非 500)
reason := ""
if accountStatus.Reason != nil {
reason = *accountStatus.Reason
}
logger.Logger.Warn("User account is banned",
zap.Int64("user_id", user.ID),
zap.String("reason", reason),
)
return nil, appErrors.NewAccountBannedError(reason)
}
if accountStatus.IsFrozen() {
// 冻结状态,检查是否已过解冻时间
if accountStatus.FrozenUntil != nil && time.Now().UnixMilli() > *accountStatus.FrozenUntil {
// 冻结已过期,理论上应该更新状态,但这里先放行让用户登录
logger.Logger.Info("User account frozen but expired",
zap.Int64("user_id", user.ID),
)
} else {
reason := ""
if accountStatus.Reason != nil {
reason = *accountStatus.Reason
}
logger.Logger.Warn("User account is frozen",
zap.Int64("user_id", user.ID),
zap.String("reason", reason),
zap.Int64("frozen_until_or_zero", func() int64 {
if accountStatus.FrozenUntil != nil {
return *accountStatus.FrozenUntil
}
return 0
}()),
)
return nil, appErrors.NewAccountFrozenError(reason, accountStatus.FrozenUntil)
}
}
}
// 5. 获取用户的粉丝档案列表
fanProfiles, err := s.fanProfileRepo.GetByUserID(user.ID)
if err != nil {
logger.Logger.Error("Failed to get fan profiles",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get fan profiles: %w", err)
}
if len(fanProfiles) == 0 {
logger.Logger.Error("User has no fan profiles",
zap.Int64("user_id", user.ID),
)
return nil, fmt.Errorf("user has no fan profiles")
}
// 选择当前身份
var currentProfile *models.FanProfile
if req.StarId > 0 {
// 如果指定了 star_id查找对应的粉丝档案
specifiedStarID := req.StarId
found := false
for _, profile := range fanProfiles {
if profile.StarID == specifiedStarID {
currentProfile = profile
found = true
break
}
}
if !found {
logger.Logger.Warn("Specified star_id not found in user's fan profiles",
zap.Int64("user_id", user.ID),
zap.Int64("specified_star_id", specifiedStarID),
)
return nil, fmt.Errorf("你还不是该明星的粉丝,无法切换到该身份")
}
logger.Logger.Info("Using specified star_id for login",
zap.Int64("user_id", user.ID),
zap.Int64("star_id", specifiedStarID),
)
} else {
// 没有指定 star_id使用第一个最早创建的粉丝档案
currentProfile = fanProfiles[0]
logger.Logger.Info("Using first fan profile for login",
zap.Int64("user_id", user.ID),
zap.Int64("star_id", currentProfile.StarID),
)
}
// 6. 生成JWT Token包含user_id和当前star_id
token, err := jwt.GenerateToken(user.ID, currentProfile.StarID, user.UpdatedAt)
if err != nil {
logger.Logger.Error("Failed to generate token",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to generate token: %w", err)
}
// 7. 更新用户Token
tokenExpiresAt := jwt.GetExpiresAt()
if err := s.userRepo.UpdateToken(user.ID, token, tokenExpiresAt); err != nil {
logger.Logger.Error("Failed to update token",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to update token: %w", err)
}
// 7.1 登录成功,清空 mobile/IP 维度的失败计数(避免历史失败拖入限流)。
// Redis 不可用时 ClearLoginAttempts 内部静默跳过,不影响业务成功路径。
ClearLoginAttempts(ctx, req.Mobile, clientIP)
// 8. 构建响应
pbFanProfiles := make([]*pb.FanProfile, 0, len(fanProfiles))
for _, profile := range fanProfiles {
pbFanProfiles = append(pbFanProfiles, &pb.FanProfile{
Id: profile.ID,
UserId: profile.UserID,
StarId: profile.StarID,
Nickname: profile.Nickname,
Level: int32(profile.Level),
Times: int32(profile.Times),
Social: int32(profile.Social),
CoinBalance: profile.CoinBalance,
CrystalBalance: profile.CrystalBalance,
Tags: []string(profile.Tags),
CreatedAt: profile.CreatedAt,
})
}
response := &pb.LoginResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
AccessToken: token,
ExpiresIn: jwt.GetExpiresIn(),
User: ModelToProtoUser(user),
FanProfile: ModelToProtoFanProfile(currentProfile),
FanProfiles: pbFanProfiles,
}
logger.Logger.Info("User login successful",
zap.Int64("user_id", user.ID),
zap.String("mobile", user.Mobile),
zap.Int64("star_id", currentProfile.StarID),
)
return response, nil
}
// Logout 用户登出
func (s *authService) Logout(userID int64) (*pb.LogoutResponse, error) {
// 清除用户Token
if err := s.userRepo.ClearToken(userID); err != nil {
logger.Logger.Error("Failed to clear token",
zap.Int64("user_id", userID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to clear token: %w", err)
}
logger.Logger.Info("User logout successful",
zap.Int64("user_id", userID),
)
return &pb.LogoutResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
}, nil
}
// RefreshToken 刷新Token
func (s *authService) RefreshToken(userID, starID int64) (*pb.RefreshTokenResponse, error) {
// 1. 查询用户
user, err := s.userRepo.GetByID(userID)
if err != nil {
if errors.Is(err, appErrors.ErrUserNotFound) {
logger.Logger.Warn("User not found during token refresh",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrUserNotFound
}
logger.Logger.Error("Failed to get user during token refresh",
zap.Int64("user_id", userID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get user: %w", err)
}
// 2. 验证用户是否激活
if !user.IsActive {
logger.Logger.Warn("User is inactive during token refresh",
zap.Int64("user_id", user.ID),
)
return nil, appErrors.ErrUserInactive
}
// 3. 生成新Token
newToken, err := jwt.GenerateToken(user.ID, starID, user.UpdatedAt)
if err != nil {
logger.Logger.Error("Failed to generate new token",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to generate token: %w", err)
}
// 4. 更新数据库中的Token
tokenExpiresAt := jwt.GetExpiresAt()
if err := s.userRepo.UpdateToken(user.ID, newToken, tokenExpiresAt); err != nil {
logger.Logger.Error("Failed to update token",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to update token: %w", err)
}
logger.Logger.Info("Token refreshed successfully",
zap.Int64("user_id", user.ID),
zap.Int64("star_id", starID),
)
return &pb.RefreshTokenResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
AccessToken: newToken,
ExpiresIn: jwt.GetExpiresIn(),
}, nil
}
// ValidateToken 验证Token用于中间件
func (s *authService) ValidateToken(req *pb.ValidateTokenRequest) (*pb.ValidateTokenResponse, error) {
// 1. 解析和验证Token检查签名和过期时间
claims, err := jwt.ValidateToken(req.AccessToken)
if err != nil {
logger.Logger.Warn("Token validation failed",
zap.Error(err),
)
return &pb.ValidateTokenResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.Unauthenticated),
Message: err.Error(),
Timestamp: time.Now().UnixMilli(),
},
UserId: 0,
StarId: 0,
IsValid: false,
ExpiresAt: 0,
}, nil
}
// 2. 查询用户验证Token是否匹配
user, err := s.userRepo.GetByID(claims.UserID)
if err != nil {
logger.Logger.Warn("User not found during token validation",
zap.Int64("user_id", claims.UserID),
zap.Error(err),
)
return &pb.ValidateTokenResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.Unauthenticated),
Message: "user not found",
Timestamp: time.Now().UnixMilli(),
},
UserId: 0,
StarId: 0,
IsValid: false,
ExpiresAt: 0,
}, nil
}
// 3. 验证用户是否激活
if !user.IsActive {
logger.Logger.Warn("User is inactive during token validation",
zap.Int64("user_id", user.ID),
)
return &pb.ValidateTokenResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.PermissionDenied),
Message: "user is inactive",
Timestamp: time.Now().UnixMilli(),
},
UserId: claims.UserID,
StarId: claims.StarID,
IsValid: false,
ExpiresAt: 0,
}, nil
}
// 4. 验证Token是否匹配数据库中的Token
if user.AccessToken == nil || *user.AccessToken != req.AccessToken {
logger.Logger.Warn("Token mismatch during validation",
zap.Int64("user_id", user.ID),
)
return &pb.ValidateTokenResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.Unauthenticated),
Message: "token mismatch",
Timestamp: time.Now().UnixMilli(),
},
UserId: claims.UserID,
StarId: claims.StarID,
IsValid: false,
ExpiresAt: 0,
}, nil
}
// 5. 验证updated_at是否匹配
if user.UpdatedAt != claims.UpdatedAt {
logger.Logger.Warn("Token invalidated due to user info update",
zap.Int64("user_id", user.ID),
)
return &pb.ValidateTokenResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.Unauthenticated),
Message: "token expired due to user info update",
Timestamp: time.Now().UnixMilli(),
},
UserId: claims.UserID,
StarId: claims.StarID,
IsValid: false,
ExpiresAt: 0,
}, nil
}
// 6. 获取过期时间
var expiresAt int64
if claims.RegisteredClaims.ExpiresAt != nil {
expiresAt = claims.RegisteredClaims.ExpiresAt.Time.UnixMilli()
}
logger.Logger.Debug("Token validated successfully",
zap.Int64("user_id", claims.UserID),
zap.Int64("star_id", claims.StarID),
)
return &pb.ValidateTokenResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
UserId: claims.UserID,
StarId: claims.StarID,
IsValid: true,
ExpiresAt: expiresAt,
}, nil
}
// min 返回两个整数的最小值
func min(a, b int) int {
if a < b {
return a
}
return b
}
// DeleteAccount 注销账号软删(用户生命周期,userService 是 owner):
//
// 1. 软删 user:is_active=false, mobile 改成 D{id:010d} 释放,
//
// access_token 置空, deleted_at=now, updated_at=now。
// 2. 停用所有 active 的 fan_profiles:is_active=false, nickname 改成 D{id:010d}
//
// 释放(解除 uniqueIndex 占用)。
// 3. 计数:返回此次停用的 fan_profiles 数量(便于运维/审计)。
//
// 失败语义:任意一步失败 → 整体回滚 → 返回 error → 网关向用户报"注销失败"。
//
// 安全:
// - 不会触碰 token:JWT 黑名单由 gateway 在调本 RPC 之前保证。
// - 不会触碰 access_token 列以外的敏感字段;biometric / 设备指纹等不在本表。
// - 用 fmt.Sprintf("D%010d", id) 而不是纯数字,避免与新注册 mobile
// 1xxxxxxxxxx(mysql varchar(11))形式冲突(同上)。
func (s *authService) DeleteAccount(userID int64) (int32, error) {
if userID <= 0 {
return 0, fmt.Errorf("invalid user_id for DeleteAccount: %d", userID)
}
var deactivatedCount int32
err := s.db.Transaction(func(tx *gorm.DB) error {
now := time.Now().UnixMilli()
// 1. 软删 user
releasedMobile := fmt.Sprintf("D%010d", userID)
result := tx.Model(&struct {
ID int64 `gorm:"column:id"`
IsActive bool `gorm:"column:is_active"`
Mobile string `gorm:"column:mobile"`
AccessToken *string `gorm:"column:access_token"`
DeletedAt *int64 `gorm:"column:deleted_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
}{}).
Table("users").
Where("id = ?", userID).
Updates(map[string]interface{}{
"is_active": false,
"mobile": releasedMobile,
"access_token": nil,
"deleted_at": now,
"updated_at": now,
})
if result.Error != nil {
return fmt.Errorf("soft-delete user: %w", result.Error)
}
if result.RowsAffected == 0 {
// 用户不存在或已注销 — 仍继续停用 fan_profiles,保持幂等
logger.Logger.Warn("DeleteAccount: user already deleted/missing, still deactivating fan profiles",
zap.Int64("user_id", userID),
)
}
// 2. 软删该 user 的 active fan_profiles
var profiles []struct {
ID int64 `gorm:"column:id"`
Nickname string `gorm:"column:nickname"`
}
if err := tx.Table("fan_profiles").
Where("user_id = ? AND is_active = ?", userID, true).
Find(&profiles).Error; err != nil {
return fmt.Errorf("query fan_profiles: %w", err)
}
for _, p := range profiles {
releasedNickname := fmt.Sprintf("D%010d", p.ID)
if err := tx.Table("fan_profiles").
Where("id = ?", p.ID).
Updates(map[string]interface{}{
"is_active": false,
"nickname": releasedNickname,
"updated_at": now,
}).Error; err != nil {
return fmt.Errorf("deactivate fan_profile %d: %w", p.ID, err)
}
}
deactivatedCount = int32(len(profiles))
logger.Logger.Info("DeleteAccount: fan profiles deactivated",
zap.Int64("user_id", userID),
zap.Int32("count", deactivatedCount),
)
return nil
})
if err != nil {
logger.Logger.Error("DeleteAccount transaction failed",
zap.Int64("user_id", userID),
zap.Error(err),
)
return 0, err
}
logger.Logger.Info("DeleteAccount successful",
zap.Int64("user_id", userID),
zap.Int32("deactivated_profile_count", deactivatedCount),
)
return deactivatedCount, nil
}