- 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>
888 lines
28 KiB
Go
888 lines
28 KiB
Go
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 刷新Token(userID 和 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_token(Plan 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_at(updated_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
|
||
}
|