topfans/backend/services/userService/service/user_service.go
zerosaturation 8a767fb400 fix(backend): service stability — batch 3 accumulated (bcrypt off-txn / login anti-enum / MQ stub / aiChat / event reliability / gateway aggregate)
- 3.1 bcrypt 移出事务 (Register): repository.HashPassword 前移到 db.Transaction 之前。
- 3.2 Login 消除用户枚举 + 限流 + timing 抹平: pkg/errors 加 ErrInvalidCredential / ErrTooManyLoginAttempts;
  user-not-found 跑 dummy bcrypt 抹平 ~100ms 时序差; mobile 5次/ip 20次 per 15min 限流 (Redis, fail-open 降级)。
- 3.3 MQ streams adapter 停用 → stub: 0 业务调用方, noop EventProducer.Publish; Init 不再装配 streams;
  11 处硬编码 'gallery'/'default' 抽常量到 pkg/queue/consts (值不变, 消漂移)。
- 3.5 JWT 密钥治理: pkg/jwt MustInit fail-fast + atomic.Value, 50-goroutine race_test 零告警;
  MustInit 调用点 gateway main + auth_provider + loadgen 同步更新。
- 3.6 aiChat 健壮性: SaveContext 用 persona.ID(非 req.PersonaId); Redis/memory 错误 记 WARN 不静默;
  Dify err 映射稳定用户文案。
- 3.7 statistic.Client 重构: TrackEvent 改 buffered channel (cap 1024) + dispatchLoop worker。
- 3.8 网关聚合: StarCache (60s TTL, singleflight) 替换 GetFanIdentities 链式调用;
  DeleteAccount 改网关直调 userService.DeleteAccount(避免改 hand-written triple.go);
  铸造双写改异步 channel+consumer (3 retry)。
- 大量单测: 各子项 TDD (RED→GREEN), 关键并发 race_test (50 goroutine)。
- .env.example JWT_SECRET 改为 ≥32 字节 base64 示例(原为空, 被 MustInit 立即拒)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 14:03:21 +08:00

1104 lines
33 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"
"net/url"
"strconv"
"strings"
"time"
eventPb "github.com/topfans/backend/pkg/proto/event"
appErrors "github.com/topfans/backend/pkg/errors"
"github.com/topfans/backend/pkg/logger"
pbCommon "github.com/topfans/backend/pkg/proto/common"
pb "github.com/topfans/backend/pkg/proto/user"
"github.com/topfans/backend/pkg/statistic"
"github.com/topfans/backend/pkg/validator"
"github.com/topfans/backend/services/userService/repository"
"go.uber.org/zap"
"gorm.io/gorm"
"google.golang.org/grpc/codes"
)
// UserService 用户信息Service接口
type UserService interface {
// GetUser 获取用户信息
GetUser(req *pb.GetUserRequest) (*pb.GetUserResponse, error)
// GetFanProfile 获取粉丝档案
GetFanProfile(req *pb.GetFanProfileRequest) (*pb.GetFanProfileResponse, error)
// GetMyProfile 获取个人信息页
GetMyProfile(req *pb.GetMyProfileRequest, userID, starID int64) (*pb.GetMyProfileResponse, error)
// CheckNickname 检查昵称是否已被注册
CheckNickname(req *pb.CheckNicknameRequest) (*pb.CheckNicknameResponse, error)
// CheckMobile 检查手机号是否已被注册
CheckMobile(req *pb.CheckMobileRequest) (*pb.CheckMobileResponse, error)
// UpdateNickname 修改昵称
UpdateNickname(req *pb.UpdateNicknameRequest, userID, starID int64) (*pb.UpdateNicknameResponse, error)
// UpdatePassword 修改密码
UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error)
// ResetPassword 匿名重置密码(忘记密码场景)
ResetPassword(ctx context.Context, req *pb.ResetPasswordRequest) (*pb.ResetPasswordResponse, error)
// UpdateFanProfileSocial 更新粉丝档案的好友数量内部RPC调用
UpdateFanProfileSocial(req *pb.UpdateFanProfileSocialRequest) (*pb.UpdateFanProfileSocialResponse, error)
// UpdateCrystalBalance 更新水晶余额内部RPC调用
UpdateCrystalBalance(req *pb.UpdateCrystalBalanceRequest) (*pb.UpdateCrystalBalanceResponse, error)
// UpdateAssetsCount 更新资产数量内部RPC调用
UpdateAssetsCount(req *pb.UpdateAssetsCountRequest) (*pb.UpdateAssetsCountResponse, error)
// AddExhibitionHours 增加用户累计上架时长内部RPC调用用于galleryService展品下架时
AddExhibitionHours(req *pb.AddExhibitionHoursRequest) (*pb.AddExhibitionHoursResponse, error)
// UpdateAvatar 更新用户头像
UpdateAvatar(req *pb.UpdateAvatarRequest, userID, starID int64) (*pb.UpdateAvatarResponse, error)
}
// userService 用户信息Service实现
type userService struct {
userRepo repository.UserRepository
fanProfileRepo repository.FanProfileRepository
db *gorm.DB
}
// NewUserService 创建用户信息Service实例
func NewUserService(
userRepo repository.UserRepository,
fanProfileRepo repository.FanProfileRepository,
db *gorm.DB,
) UserService {
return &userService{
userRepo: userRepo,
fanProfileRepo: fanProfileRepo,
db: db,
}
}
// GetUser 获取用户信息
func (s *userService) GetUser(req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(req.UserId) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", req.UserId),
)
return nil, fmt.Errorf("invalid user_id: %d", req.UserId)
}
// 2. 查询用户
user, err := s.userRepo.GetByID(req.UserId)
if err != nil {
if errors.Is(err, appErrors.ErrUserNotFound) {
logger.Logger.Warn("User not found",
zap.Int64("user_id", req.UserId),
)
return &pb.GetUserResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrUserNotFound),
}, nil // 返回响应对象,不返回 error
}
logger.Logger.Error("Failed to get user",
zap.Int64("user_id", req.UserId),
zap.Error(err),
)
return &pb.GetUserResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil // 返回响应对象,不返回 error
}
// 3. 构建响应
response := &pb.GetUserResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
User: &pb.User{
Id: user.ID,
Mobile: user.Mobile,
AvatarUrl: getStringValue(user.AvatarURL),
GlobalWalletAddress: getStringValue(user.GlobalWalletAddr),
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
},
}
logger.Logger.Debug("Get user successful",
zap.Int64("user_id", req.UserId),
)
return response, nil
}
// GetFanProfile 获取粉丝档案
func (s *userService) GetFanProfile(req *pb.GetFanProfileRequest) (*pb.GetFanProfileResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(req.UserId) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", req.UserId),
)
return &pb.GetFanProfileResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidUserID),
}, nil
}
if !validator.ValidateStarID(req.StarId) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", req.StarId),
)
return &pb.GetFanProfileResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidStarID),
}, nil
}
// 2. 查询粉丝档案
fanProfile, err := s.fanProfileRepo.GetByUserAndStar(req.UserId, req.StarId)
if err != nil {
if errors.Is(err, appErrors.ErrFanProfileNotFound) {
logger.Logger.Warn("Fan profile not found",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
)
return &pb.GetFanProfileResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrFanProfileNotFound),
}, nil // 返回响应对象,不返回 error
}
logger.Logger.Error("Failed to get fan profile",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
zap.Error(err),
)
return &pb.GetFanProfileResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil // 返回响应对象,不返回 error
}
// 4. 构建响应
response := &pb.GetFanProfileResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Profile: ModelToProtoFanProfile(fanProfile),
}
logger.Logger.Debug("Get fan profile successful",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
)
return response, nil
}
// GetMyProfile 获取个人信息页(聚合用户信息和粉丝档案)
func (s *userService) GetMyProfile(req *pb.GetMyProfileRequest, userID, starID int64) (*pb.GetMyProfileResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, fmt.Errorf("invalid user_id: %d", userID)
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
// 2. 查询用户信息
user, err := s.userRepo.GetByID(userID)
if err != nil {
if errors.Is(err, appErrors.ErrUserNotFound) {
logger.Logger.Warn("User not found",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrUserNotFound
}
logger.Logger.Error("Failed to get user",
zap.Int64("user_id", userID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get user: %w", err)
}
// 3. 查询当前粉丝档案
currentFanProfile, err := s.fanProfileRepo.GetByUserAndStar(userID, starID)
if err != nil {
if errors.Is(err, appErrors.ErrFanProfileNotFound) {
logger.Logger.Warn("Fan profile not found",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrFanProfileNotFound
}
logger.Logger.Error("Failed to get fan profile",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get fan profile: %w", err)
}
// 5. 查询用户的所有粉丝身份
fanProfiles, err := s.fanProfileRepo.GetByUserID(userID)
if err != nil {
logger.Logger.Error("Failed to get fan profiles",
zap.Int64("user_id", userID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get fan profiles: %w", err)
}
// 5. 构建响应
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.GetMyProfileResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
User: &pb.User{
Id: user.ID,
Mobile: user.Mobile,
AvatarUrl: getStringValue(user.AvatarURL),
GlobalWalletAddress: getStringValue(user.GlobalWalletAddr),
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
},
FanProfile: ModelToProtoFanProfile(currentFanProfile),
FanProfiles: pbFanProfiles,
}
logger.Logger.Debug("Get my profile successful",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
return response, nil
}
// CheckNickname 检查昵称是否已被注册
func (s *userService) CheckNickname(req *pb.CheckNicknameRequest) (*pb.CheckNicknameResponse, error) {
// 1. 参数验证
if req.Nickname == "" {
logger.Logger.Warn("Nickname is empty")
return &pb.CheckNicknameResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidNickname),
}, nil
}
// 2. 验证昵称格式
valid, msg := validator.ValidateNickname(req.Nickname)
if !valid {
logger.Logger.Warn("Invalid nickname format",
zap.String("nickname", req.Nickname),
zap.String("error", msg),
)
return &pb.CheckNicknameResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidNickname),
}, nil
}
// 3. 查询昵称是否已存在
exists, err := s.fanProfileRepo.ExistsByNickname(req.Nickname)
if err != nil {
logger.Logger.Error("Failed to check nickname",
zap.String("nickname", req.Nickname),
zap.Error(err),
)
return &pb.CheckNicknameResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil
}
logger.Logger.Info("Check nickname result",
zap.String("nickname", req.Nickname),
zap.Bool("exists", exists),
)
// 4. 构建响应
return &pb.CheckNicknameResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Exists: exists,
}, nil
}
// CheckMobile 检查手机号是否已被注册
func (s *userService) CheckMobile(req *pb.CheckMobileRequest) (*pb.CheckMobileResponse, error) {
// 1. 参数验证
if req.Mobile == "" {
logger.Logger.Warn("Mobile is empty")
return &pb.CheckMobileResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidMobile),
}, nil
}
// 2. 验证手机号格式
if !validator.ValidateMobile(req.Mobile) {
logger.Logger.Warn("Invalid mobile format",
zap.String("mobile", maskMobile(req.Mobile)),
)
return &pb.CheckMobileResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidMobile),
}, nil
}
// 3. 查询手机号是否已存在
exists, err := s.userRepo.ExistsByMobile(req.Mobile)
if err != nil {
logger.Logger.Error("Failed to check mobile",
zap.String("mobile", maskMobile(req.Mobile)),
zap.Error(err),
)
return &pb.CheckMobileResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil
}
logger.Logger.Info("Check mobile result",
zap.String("mobile", maskMobile(req.Mobile)),
zap.Bool("exists", exists),
)
// 4. 构建响应
return &pb.CheckMobileResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Exists: exists,
}, nil
}
// UpdateNickname 修改昵称
func (s *userService) UpdateNickname(req *pb.UpdateNicknameRequest, userID, starID int64) (*pb.UpdateNicknameResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, fmt.Errorf("invalid user_id: %d", userID)
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
valid, msg := validator.ValidateNickname(req.Nickname)
if !valid {
logger.Logger.Warn("Invalid nickname",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("error", msg),
)
return nil, fmt.Errorf("invalid nickname: %s", msg)
}
// 2. 验证粉丝档案是否存在
_, err := s.fanProfileRepo.GetByUserAndStar(userID, starID)
if err != nil {
if errors.Is(err, appErrors.ErrFanProfileNotFound) {
logger.Logger.Warn("Fan profile not found",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrFanProfileNotFound
}
logger.Logger.Error("Failed to get fan profile",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get fan profile: %w", err)
}
// 3. 更新昵称
if err := s.fanProfileRepo.UpdateNickname(userID, starID, req.Nickname); err != nil {
logger.Logger.Error("Failed to update nickname",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to update nickname: %w", err)
}
// 4. 查询更新后的粉丝档案
fanProfile, err := s.fanProfileRepo.GetByUserAndStar(userID, starID)
if err != nil {
logger.Logger.Error("Failed to get updated fan profile",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get updated fan profile: %w", err)
}
// 5. 构建响应
response := &pb.UpdateNicknameResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
FanProfile: ModelToProtoFanProfile(fanProfile),
}
logger.Logger.Info("Update nickname successful",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("new_nickname", req.Nickname),
)
return response, nil
}
// UpdatePassword 修改密码
func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error) {
// 0. 参数基础验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, fmt.Errorf("invalid user_id: %d", userID)
}
if req.OldPassword == "" {
logger.Logger.Warn("Old password is empty",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidOldPassword
}
if req.VerifyToken == "" {
logger.Logger.Warn("Verify token is empty",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidVerifyToken
}
valid, msg := validator.ValidatePassword(req.NewPassword)
if !valid {
logger.Logger.Warn("Invalid new password",
zap.Int64("user_id", userID),
zap.String("error", msg),
)
if msg == "password too short" {
return nil, appErrors.ErrPasswordTooShort
}
return nil, fmt.Errorf("invalid password: %s", msg)
}
// 0.5 (新增) 查询用户(后续需 user.Mobile 校验 verify_token)
user, err := s.userRepo.GetByID(userID)
if err != nil {
if errors.Is(err, appErrors.ErrUserNotFound) {
logger.Logger.Warn("User not found",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrUserNotFound
}
logger.Logger.Error("Failed to get user",
zap.Int64("user_id", userID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get user: %w", err)
}
// 0.6 (Plan A) 仅校验 verify_token,不删除
if err := VerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil {
logger.Logger.Warn("Verify token validation failed",
zap.Int64("user_id", userID),
zap.String("mobile", user.Mobile),
zap.Error(err))
return nil, appErrors.ErrInvalidVerifyToken
}
// 1. (新增) 新旧密码一致性
if req.OldPassword == req.NewPassword {
logger.Logger.Warn("New password is same as old",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrSameAsOldPassword
}
// 2. 验证旧密码(Plan A: 此处失败 token 仍保留,前端可重试无需重新发短信)
if !s.userRepo.VerifyPassword(user, req.OldPassword) {
logger.Logger.Warn("Invalid old password",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidOldPassword
}
// 3. 加密新密码
newPasswordHash, err := repository.HashPassword(req.NewPassword)
if err != nil {
logger.Logger.Error("Failed to hash new password",
zap.Int64("user_id", userID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// 4. 更新密码和updated_at清除Token使用事务
err = s.db.Transaction(func(tx *gorm.DB) error {
// 更新密码
user.PasswordHash = newPasswordHash
user.UpdatedAt = time.Now().UnixMilli()
// 清除Token设为nil
user.AccessToken = nil
user.TokenExpiresAt = nil
if err := tx.Model(user).Updates(map[string]interface{}{
"password_hash": user.PasswordHash,
"updated_at": user.UpdatedAt,
"access_token": nil,
"token_expires_at": nil,
}).Error; err != nil {
logger.Logger.Error("Failed to update password in transaction",
zap.Int64("user_id", userID),
zap.Error(err),
)
return fmt.Errorf("failed to update password: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
// 5. (Plan A) 业务成功后原子消费 verify_token(Lua GET+COMPARE+DEL)
// Consume 失败(Redis 抖动)仅记日志,不返回错误:
// - 业务已成功,密码已更新,access_token 已清空,符合预期
// - token 未删,用户重试时 VerifyToken 仍能通过、自愈
if err := ConsumeVerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil {
logger.Logger.Error("failed to consume verify token after password update (self-healing on retry)",
zap.Int64("user_id", userID),
zap.Error(err))
}
logger.Logger.Info("Update password successful",
zap.Int64("user_id", userID),
)
return &pb.UpdatePasswordResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
}, nil
}
// ResetPassword 匿名重置密码(忘记密码场景)
// 流程:
// 1. 通过 mobile 查 user必须存在
// 2. 校验 verify_tokenscene=password
// 3. 校验 new_password 格式
// 4. 事务内更新 password_hash + 清空 access_token强制重登
// 5. ConsumeVerifyTokenPlan A业务成功后原子消费
//
// 与 UpdatePassword 的区别:
// - 无 userID匿名
// - 无 OldPassword 校验(忘记密码场景不知道旧密码)
// - 无 AuthMiddleware 依赖
func (s *userService) ResetPassword(ctx context.Context, req *pb.ResetPasswordRequest) (*pb.ResetPasswordResponse, error) {
// 0. 参数基础验证
if req.Mobile == "" {
return nil, appErrors.ErrInvalidMobile
}
if req.VerifyToken == "" {
return nil, appErrors.ErrInvalidVerifyToken
}
valid, msg := validator.ValidatePassword(req.NewPassword)
if !valid {
if msg == "password too short" {
return nil, appErrors.ErrPasswordTooShort
}
return nil, fmt.Errorf("invalid password: %s", msg)
}
// 1. 通过 mobile 查 user
user, err := s.userRepo.GetByMobile(req.Mobile)
if err != nil {
if errors.Is(err, appErrors.ErrUserNotFound) {
return nil, appErrors.ErrUserNotFound
}
return nil, fmt.Errorf("failed to get user by mobile: %w", err)
}
// 2. 校验 verify_tokenscene=password
if err := VerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil {
return nil, appErrors.ErrInvalidVerifyToken
}
// 3. 加密新密码
newPasswordHash, err := repository.HashPassword(req.NewPassword)
if err != nil {
return nil, fmt.Errorf("failed to hash new password: %w", err)
}
// 4. 事务内更新密码 + 清 token
// 关键语义:覆写 password_hash 后,旧密码的 bcrypt 哈希物理上不再存在。
// 任何用旧密码明文登录的请求都会因 bcrypt 校验失败被拒 → 旧密码立即失效,
// 无需额外的「密码历史」机制。
//
// NOTE: 这里把 access_token 置 nil 只阻止「后续发新 token 时复用旧 token 字段」。
// gateway/middleware/auth_middleware.go 当前不把 JWT 与 users.access_token 做比对,
// 所以已签发的 JWT 在过期前仍可访问 → 完整 JWT 失效留待后续 ticket 处理。
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(user).Updates(map[string]interface{}{
"password_hash": newPasswordHash,
"updated_at": time.Now().UnixMilli(),
"access_token": nil,
"token_expires_at": nil,
}).Error; err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
// 5. (Plan A) 业务成功后原子消费 verify_token
if err := ConsumeVerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil {
logger.Logger.Error("failed to consume verify token after password reset (self-healing on retry)",
zap.String("mobile", maskMobile(user.Mobile)),
zap.Error(err))
}
logger.Logger.Info("Reset password successful",
zap.String("mobile", maskMobile(user.Mobile)),
)
return &pb.ResetPasswordResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
}, nil
}
// UpdateFanProfileSocial 更新粉丝档案的好友数量内部RPC调用
func (s *userService) UpdateFanProfileSocial(req *pb.UpdateFanProfileSocialRequest) (*pb.UpdateFanProfileSocialResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(req.UserId) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", req.UserId),
)
return &pb.UpdateFanProfileSocialResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidUserID),
}, nil
}
if !validator.ValidateStarID(req.StarId) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", req.StarId),
)
return &pb.UpdateFanProfileSocialResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidStarID),
}, nil
}
// 2. 更新 social 字段
newSocial, err := s.fanProfileRepo.UpdateSocial(req.UserId, req.StarId, req.Delta)
if err != nil {
logger.Logger.Error("Failed to update fan profile social",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
zap.Int32("delta", req.Delta),
zap.Error(err),
)
// 判断错误类型
if errors.Is(err, appErrors.ErrFanProfileNotFound) {
return &pb.UpdateFanProfileSocialResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrFanProfileNotFound),
}, nil
}
return &pb.UpdateFanProfileSocialResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil
}
logger.Logger.Info("Update fan profile social successful",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
zap.Int32("delta", req.Delta),
zap.Int32("new_social", newSocial),
)
return &pb.UpdateFanProfileSocialResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
NewSocial: newSocial,
}, nil
}
// UpdateCrystalBalance 更新水晶余额内部RPC调用
func (s *userService) UpdateCrystalBalance(req *pb.UpdateCrystalBalanceRequest) (*pb.UpdateCrystalBalanceResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(req.UserId) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", req.UserId),
)
return &pb.UpdateCrystalBalanceResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidUserID),
}, nil
}
if !validator.ValidateStarID(req.StarId) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", req.StarId),
)
return &pb.UpdateCrystalBalanceResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidStarID),
}, nil
}
// 2. 更新水晶余额
newBalance, err := s.fanProfileRepo.UpdateCrystalBalance(req.UserId, req.StarId, req.Delta, req.ChangeType, req.SourceId, req.Description)
if err != nil {
logger.Logger.Error("Failed to update crystal balance",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
zap.Int64("delta", req.Delta),
zap.Error(err),
)
// 判断错误类型
if errors.Is(err, appErrors.ErrFanProfileNotFound) {
return &pb.UpdateCrystalBalanceResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrFanProfileNotFound),
}, nil
}
return &pb.UpdateCrystalBalanceResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil
}
logger.Logger.Info("Update crystal balance successful",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
zap.Int64("delta", req.Delta),
zap.Int64("new_balance", newBalance),
)
// 事件埋点crystal.changefire-and-forget
fireCrystalChangeEvent(req.UserId, req.StarId, req.Delta, req.ChangeType)
return &pb.UpdateCrystalBalanceResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
NewBalance: newBalance,
}, nil
}
// 事件埋点crystal.changefire-and-forget余额变动成功后
// 调用 UpdateCrystalBalance 末尾
// 在 UpdateCrystalBalance 末尾加 TrackEvent此处为 wrapper 用于实际埋点接入)
func fireCrystalChangeEvent(userID, starID int64, delta int64, reason string) {
if c := statistic.Get(); c != nil {
c.TrackEvent(context.Background(), &eventPb.Event{
EventType: "crystal.change",
UserId: userID,
StarId: starID,
OccurredAt: time.Now().UnixMilli(),
Properties: map[string]string{
"amount": strconv.FormatInt(delta, 10),
"reason": reason,
},
})
}
}
// UpdateAssetsCount 更新资产数量内部RPC调用
func (s *userService) UpdateAssetsCount(req *pb.UpdateAssetsCountRequest) (*pb.UpdateAssetsCountResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(req.UserId) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", req.UserId),
)
return &pb.UpdateAssetsCountResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidUserID),
}, nil
}
if !validator.ValidateStarID(req.StarId) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", req.StarId),
)
return &pb.UpdateAssetsCountResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidStarID),
}, nil
}
// 2. 根据 delta 的正负调用不同的方法
var err error
var newCount int32
if req.Delta > 0 {
// 增加资产数量
err = s.fanProfileRepo.IncrementAssetsCount(req.UserId, req.StarId, req.Delta)
if err == nil {
// 查询更新后的值
profile, getErr := s.fanProfileRepo.GetByUserAndStar(req.UserId, req.StarId)
if getErr == nil {
newCount = profile.AssetsCount
}
}
} else if req.Delta < 0 {
// 减少资产数量
err = s.fanProfileRepo.DecrementAssetsCount(req.UserId, req.StarId, -req.Delta)
if err == nil {
// 查询更新后的值
profile, getErr := s.fanProfileRepo.GetByUserAndStar(req.UserId, req.StarId)
if getErr == nil {
newCount = profile.AssetsCount
}
}
} else {
// delta == 0直接查询当前值
profile, getErr := s.fanProfileRepo.GetByUserAndStar(req.UserId, req.StarId)
if getErr != nil {
if errors.Is(getErr, appErrors.ErrFanProfileNotFound) {
return &pb.UpdateAssetsCountResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrFanProfileNotFound),
}, nil
}
return &pb.UpdateAssetsCountResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil
}
newCount = profile.AssetsCount
}
if err != nil {
logger.Logger.Error("Failed to update assets count",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
zap.Int32("delta", req.Delta),
zap.Error(err),
)
// 判断错误类型
if errors.Is(err, appErrors.ErrFanProfileNotFound) {
return &pb.UpdateAssetsCountResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrFanProfileNotFound),
}, nil
}
return &pb.UpdateAssetsCountResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil
}
logger.Logger.Info("Update assets count successful",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
zap.Int32("delta", req.Delta),
zap.Int32("new_count", newCount),
)
return &pb.UpdateAssetsCountResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
NewCount: newCount,
}, nil
}
// AddExhibitionHours 增加用户累计上架时长并触发升级检查内部RPC调用
func (s *userService) AddExhibitionHours(req *pb.AddExhibitionHoursRequest) (*pb.AddExhibitionHoursResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(req.UserId) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", req.UserId),
)
return &pb.AddExhibitionHoursResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidUserID),
}, nil
}
if !validator.ValidateStarID(req.StarId) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", req.StarId),
)
return &pb.AddExhibitionHoursResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidStarID),
}, nil
}
if req.ExhibitionHours <= 0 {
logger.Logger.Warn("Invalid exhibition_hours",
zap.Int64("exhibition_hours", req.ExhibitionHours),
)
return &pb.AddExhibitionHoursResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.InvalidArgument),
Message: "exhibition_hours must be positive",
},
}, nil
}
// 2. 增加累计上架时长并同步等级
newLevel, levelDelta, crystalReward, err := s.fanProfileRepo.AddExhibitionHours(req.UserId, req.StarId, req.ExhibitionHours, req.SourceId)
if err != nil {
logger.Logger.Error("AddExhibitionHours failed",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
zap.Int64("exhibition_hours", req.ExhibitionHours),
zap.Error(err))
return &pb.AddExhibitionHoursResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, err
}
logger.Logger.Info("AddExhibitionHours successful",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
zap.Int64("exhibition_hours", req.ExhibitionHours),
zap.Int32("new_level", newLevel),
zap.Int32("level_delta", levelDelta),
zap.Int64("crystal_reward", crystalReward))
return &pb.AddExhibitionHoursResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
NewLevel: newLevel,
LevelDelta: levelDelta,
CrystalReward: crystalReward,
}, nil
}
// UpdateAvatar 更新用户头像
func (s *userService) UpdateAvatar(req *pb.UpdateAvatarRequest, userID, starID int64) (*pb.UpdateAvatarResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, fmt.Errorf("invalid user_id: %d", userID)
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
if req.AvatarUrl == "" {
logger.Logger.Warn("Avatar URL is empty",
zap.Int64("user_id", userID),
)
return nil, fmt.Errorf("avatar_url cannot be empty")
}
// 2. 验证URL格式简单验证
if len(req.AvatarUrl) > 500 {
logger.Logger.Warn("Avatar URL too long",
zap.Int64("user_id", userID),
zap.Int("length", len(req.AvatarUrl)),
)
return nil, fmt.Errorf("avatar_url too long, max length is 500")
}
// 2.1 同源校验(防 SSRF / 头像被指向任意公网 URL
// 仅允许 *.aliyuncs.com 子域,与 OSS 公网读域名约定。
// 与 gateway/controller/user_controller.go 的 OSS key 提取是两道独立检查:
// 这里兜底防止 service 被绕过;gateway 是公共入口。同源白名单比黑名单更稳。
if u, parseErr := url.Parse(req.AvatarUrl); parseErr == nil && u.Host != "" {
if !strings.Contains(u.Host, "aliyuncs.com") {
logger.Logger.Warn("Avatar URL not from aliyuncs.com",
zap.Int64("user_id", userID),
zap.String("host", u.Host),
)
return nil, appErrors.ErrInvalidAvatarURL
}
}
// 3. 更新头像
if err := s.fanProfileRepo.UpdateAvatar(userID, starID, req.AvatarUrl); err != nil {
if errors.Is(err, appErrors.ErrFanProfileNotFound) {
logger.Logger.Warn("Fan profile not found",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrFanProfileNotFound
}
logger.Logger.Error("Failed to update avatar",
zap.Int64("user_id", userID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to update avatar: %w", err)
}
logger.Logger.Info("Update avatar successful",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.String("avatar_url", req.AvatarUrl),
)
// 4. 构建响应
return &pb.UpdateAvatarResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
AvatarUrl: req.AvatarUrl,
}, nil
}