diff --git a/backend/pkg/errors/errors.go b/backend/pkg/errors/errors.go index 0b37d65..a1545cc 100644 --- a/backend/pkg/errors/errors.go +++ b/backend/pkg/errors/errors.go @@ -3,6 +3,7 @@ package errors import ( "errors" "fmt" + "strings" "time" pb "github.com/topfans/backend/pkg/proto/common" @@ -13,6 +14,9 @@ var ( ErrUserNotFound = errors.New("user not found") ErrUserAlreadyExists = errors.New("user already exists") ErrInvalidPassword = errors.New("invalid password") + ErrInvalidOldPassword = errors.New("old password is incorrect") + ErrSameAsOldPassword = errors.New("new password is same as old") + ErrInvalidVerifyToken = errors.New("invalid verify token") ErrInvalidToken = errors.New("invalid token") ErrTokenExpired = errors.New("token expired") ErrTokenMismatch = errors.New("token mismatch") @@ -71,37 +75,42 @@ var ( ) // ToStatusCode 将错误转换为Proto状态码 +// 用 errors.Is 而非 == 比较,以便识别 fmt.Errorf("%w: ...", ErrXxx, ...) 包装后的错误 func ToStatusCode(err error) pb.StatusCode { if err == nil { return pb.StatusCode_STATUS_OK } - switch err { - case ErrUserNotFound, ErrFanProfileNotFound, ErrStarNotFound: + switch { + case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrFanProfileNotFound), errors.Is(err, ErrStarNotFound): return pb.StatusCode_STATUS_NOT_FOUND - case ErrUserAlreadyExists, ErrFanProfileAlreadyExists, ErrNicknameAlreadyExists: + case errors.Is(err, ErrUserAlreadyExists), errors.Is(err, ErrFanProfileAlreadyExists), errors.Is(err, ErrNicknameAlreadyExists): return pb.StatusCode_STATUS_BAD_REQUEST - case ErrInvalidPassword, ErrInvalidToken, ErrTokenExpired, ErrTokenMismatch: + case errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrInvalidToken), errors.Is(err, ErrTokenExpired), errors.Is(err, ErrTokenMismatch): return pb.StatusCode_STATUS_UNAUTHORIZED - case ErrAccountFrozen, ErrAccountBanned: + case errors.Is(err, ErrAccountFrozen), errors.Is(err, ErrAccountBanned), errors.Is(err, ErrUserInactive): return pb.StatusCode_STATUS_FORBIDDEN - case ErrInvalidMobile, ErrPasswordTooShort, ErrInvalidStarID, ErrInvalidUserID, ErrMaxIdentitiesReached, ErrInvalidNickname: + case errors.Is(err, ErrInvalidMobile), errors.Is(err, ErrPasswordTooShort), + errors.Is(err, ErrInvalidOldPassword), errors.Is(err, ErrSameAsOldPassword), + errors.Is(err, ErrInvalidVerifyToken), + errors.Is(err, ErrInvalidStarID), errors.Is(err, ErrInvalidUserID), + errors.Is(err, ErrMaxIdentitiesReached), errors.Is(err, ErrInvalidNickname): return pb.StatusCode_STATUS_BAD_REQUEST - case ErrCannotAddSelf, ErrCannotSearchSelf, ErrNotFanOfStar, ErrAlreadyFriends, - ErrRequestAlreadyPending, ErrInvalidFriendUserID, ErrCannotProcessOwnRequest, - ErrRequestAlreadyProcessed, ErrRequestExpired, ErrInvalidAction, ErrNotFriends: + case errors.Is(err, ErrCannotAddSelf), errors.Is(err, ErrCannotSearchSelf), errors.Is(err, ErrNotFanOfStar), errors.Is(err, ErrAlreadyFriends), + errors.Is(err, ErrRequestAlreadyPending), errors.Is(err, ErrInvalidFriendUserID), errors.Is(err, ErrCannotProcessOwnRequest), + errors.Is(err, ErrRequestAlreadyProcessed), errors.Is(err, ErrRequestExpired), errors.Is(err, ErrInvalidAction), errors.Is(err, ErrNotFriends): return pb.StatusCode_STATUS_BAD_REQUEST - case ErrRequestInCooldown: + case errors.Is(err, ErrRequestInCooldown): return pb.StatusCode_STATUS_TOO_MANY_REQUESTS - case ErrFriendRequestNotFound, ErrAssetNotFound, ErrMintOrderNotFound: + case errors.Is(err, ErrFriendRequestNotFound), errors.Is(err, ErrAssetNotFound), errors.Is(err, ErrMintOrderNotFound): return pb.StatusCode_STATUS_NOT_FOUND - case ErrInsufficientCrystal, ErrInsufficientMintTimes, ErrInvalidAssetStatus, ErrInvalidMintOrderStatus: + case errors.Is(err, ErrInsufficientCrystal), errors.Is(err, ErrInsufficientMintTimes), errors.Is(err, ErrInvalidAssetStatus), errors.Is(err, ErrInvalidMintOrderStatus): return pb.StatusCode_STATUS_BAD_REQUEST - case ErrAssetAccessDenied, ErrMintOrderAccessDenied: + case errors.Is(err, ErrAssetAccessDenied), errors.Is(err, ErrMintOrderAccessDenied): return pb.StatusCode_STATUS_FORBIDDEN - case ErrActivityNotFound, ErrActivityItemNotFound, ErrCollectionAssetNotFound, ErrActivityAssetNotFound, ErrAssetRegistryNotFound: + case errors.Is(err, ErrActivityNotFound), errors.Is(err, ErrActivityItemNotFound), errors.Is(err, ErrCollectionAssetNotFound), errors.Is(err, ErrActivityAssetNotFound), errors.Is(err, ErrAssetRegistryNotFound): return pb.StatusCode_STATUS_NOT_FOUND - case ErrInvalidAssetType: + case errors.Is(err, ErrInvalidAssetType): return pb.StatusCode_STATUS_BAD_REQUEST default: return pb.StatusCode_STATUS_INTERNAL_ERROR @@ -162,3 +171,28 @@ func BuildBaseResponseWithMessage(code pb.StatusCode, message string) *pb.BaseRe func getCurrentTimestamp() int64 { return time.Now().UnixMilli() } + +// NewAccountBannedError 返回 ErrAccountBanned,可选地把 reason 拼到 message 里 +// 用 fmt.Errorf("%w: ...") 保留 wrap 关系,让 errors.Is(err, ErrAccountBanned) 仍能识别 +func NewAccountBannedError(reason string) error { + if reason != "" { + return fmt.Errorf("%w: %s", ErrAccountBanned, reason) + } + return ErrAccountBanned +} + +// NewAccountFrozenError 返回 ErrAccountFrozen,把 reason / frozenUntil 拼到 message 里 +// frozenUntil 为 nil 表示永久冻结 +func NewAccountFrozenError(reason string, frozenUntil *int64) error { + parts := []string{} + if reason != "" { + parts = append(parts, "原因:"+reason) + } + if frozenUntil != nil { + parts = append(parts, "解封时间:"+time.UnixMilli(*frozenUntil).Format("2006-01-02 15:04:05")) + } + if len(parts) > 0 { + return fmt.Errorf("%w: %s", ErrAccountFrozen, strings.Join(parts, ", ")) + } + return ErrAccountFrozen +} diff --git a/backend/pkg/proto/user/user.pb.go b/backend/pkg/proto/user/user.pb.go index 39c3f46..ef8dd5b 100644 --- a/backend/pkg/proto/user/user.pb.go +++ b/backend/pkg/proto/user/user.pb.go @@ -2447,6 +2447,7 @@ type UpdatePasswordRequest struct { state protoimpl.MessageState `protogen:"open.v1"` OldPassword string `protobuf:"bytes,1,opt,name=old_password,json=oldPassword,proto3" json:"old_password,omitempty"` // 旧密码 NewPassword string `protobuf:"bytes,2,opt,name=new_password,json=newPassword,proto3" json:"new_password,omitempty"` // 新密码 + VerifyToken string `protobuf:"bytes,3,opt,name=verify_token,json=verifyToken,proto3" json:"verify_token,omitempty"` // 短信验证 token(scene=password 下发的一次性 token) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2495,6 +2496,13 @@ func (x *UpdatePasswordRequest) GetNewPassword() string { return "" } +func (x *UpdatePasswordRequest) GetVerifyToken() string { + if x != nil { + return x.VerifyToken + } + return "" +} + // 修改密码响应 type UpdatePasswordResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3298,10 +3306,11 @@ const file_user_proto_rawDesc = "" + "\x16UpdateNicknameResponse\x120\n" + "\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x129\n" + "\vfan_profile\x18\x02 \x01(\v2\x18.topfans.user.FanProfileR\n" + - "fanProfile\"]\n" + + "fanProfile\"\x80\x01\n" + "\x15UpdatePasswordRequest\x12!\n" + "\fold_password\x18\x01 \x01(\tR\voldPassword\x12!\n" + - "\fnew_password\x18\x02 \x01(\tR\vnewPassword\"J\n" + + "\fnew_password\x18\x02 \x01(\tR\vnewPassword\x12!\n" + + "\fverify_token\x18\x03 \x01(\tR\vverifyToken\"J\n" + "\x16UpdatePasswordResponse\x120\n" + "\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\"4\n" + "\x13UpdateAvatarRequest\x12\x1d\n" + diff --git a/backend/proto/user.proto b/backend/proto/user.proto index 34aaa53..2513dff 100644 --- a/backend/proto/user.proto +++ b/backend/proto/user.proto @@ -301,6 +301,7 @@ message UpdateNicknameResponse { message UpdatePasswordRequest { string old_password = 1; // 旧密码 string new_password = 2; // 新密码 + string verify_token = 3; // 短信验证 token(scene=password 下发的一次性 token) } // 修改密码响应 diff --git a/backend/services/userService/provider/auth_provider.go b/backend/services/userService/provider/auth_provider.go index 563e93a..3443c50 100644 --- a/backend/services/userService/provider/auth_provider.go +++ b/backend/services/userService/provider/auth_provider.go @@ -259,7 +259,7 @@ func (p *AuthProvider) SendCode(ctx context.Context, req *pb.SendCodeRequest) (* // 获取客户端IP(从context或 attachments 获取) ip := getClientIP(ctx) - expiresIn, err := service.SendVerificationCode(ctx, req.Mobile, ip) + expiresIn, err := service.SendVerificationCode(ctx, req.Scene, req.Mobile, ip) if err != nil { logger.Logger.Error("SendCode failed", zap.String("mobile", req.Mobile), @@ -295,7 +295,7 @@ func (p *AuthProvider) VerifyCode(ctx context.Context, req *pb.VerifyCodeRequest zap.String("scene", req.Scene), ) - token, err := service.VerifyCode(ctx, req.Mobile, req.Code) + token, err := service.VerifyCode(ctx, req.Scene, req.Mobile, req.Code) if err != nil { logger.Logger.Warn("VerifyCode failed", zap.String("mobile", req.Mobile), diff --git a/backend/services/userService/provider/user_provider.go b/backend/services/userService/provider/user_provider.go index b552959..c555983 100644 --- a/backend/services/userService/provider/user_provider.go +++ b/backend/services/userService/provider/user_provider.go @@ -358,8 +358,8 @@ func (p *UserProvider) UpdatePassword(ctx context.Context, req *pb.UpdatePasswor }, err } - // 调用Service层 - resp, err := p.userService.UpdatePassword(req, userID) + // 调用Service层(透传 ctx 用于 verify_token 校验) + resp, err := p.userService.UpdatePassword(ctx, req, userID) if err != nil { logger.Logger.Error("UpdatePassword failed", zap.Int64("user_id", userID), diff --git a/backend/services/userService/service/auth_service.go b/backend/services/userService/service/auth_service.go index f6b74f0..0358064 100644 --- a/backend/services/userService/service/auth_service.go +++ b/backend/services/userService/service/auth_service.go @@ -61,9 +61,9 @@ func NewAuthService( // Register 用户注册 func (s *authService) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) { - // 0. 验证明ver_token(新增) + // 0. 校验 verify_token(Plan A: 仅校验,不删除;业务成功后由 ConsumeVerifyToken 消费) if req.VerifyToken != "" { - if err := VerifyToken(ctx, req.Mobile, req.VerifyToken); err != nil { + 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)) @@ -239,6 +239,16 @@ func (s *authService) Register(ctx context.Context, req *pb.RegisterRequest) (*p 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{ @@ -325,7 +335,7 @@ func (s *authService) Login(req *pb.LoginRequest) (*pb.LoginResponse, error) { // 如果有账号状态记录,需要检查具体状态 if accountStatus != nil { if accountStatus.IsBanned() { - // 封号状态 + // 封号状态(§12.2: 改用 typed error,业务码 403 而非 500) reason := "" if accountStatus.Reason != nil { reason = *accountStatus.Reason @@ -334,14 +344,7 @@ func (s *authService) Login(req *pb.LoginRequest) (*pb.LoginResponse, error) { zap.Int64("user_id", user.ID), zap.String("reason", reason), ) - return nil, fmt.Errorf("账号已被封禁%s%s", map[bool]func() string{ - true: func() string { - if reason != "" { - return ",原因:" + reason - } - return "" - }, - }[true](), "") + return nil, appErrors.NewAccountBannedError(reason) } if accountStatus.IsFrozen() { @@ -356,24 +359,17 @@ func (s *authService) Login(req *pb.LoginRequest) (*pb.LoginResponse, error) { if accountStatus.Reason != nil { reason = *accountStatus.Reason } - frozenUntilStr := "" - if accountStatus.FrozenUntil != nil { - frozenTime := time.UnixMilli(*accountStatus.FrozenUntil) - frozenUntilStr = frozenTime.Format("2006-01-02 15:04:05") - } logger.Logger.Warn("User account is frozen", zap.Int64("user_id", user.ID), zap.String("reason", reason), - zap.Int64("frozen_until", *accountStatus.FrozenUntil), + zap.Int64("frozen_until_or_zero", func() int64 { + if accountStatus.FrozenUntil != nil { + return *accountStatus.FrozenUntil + } + return 0 + }()), ) - errMsg := "账号已被冻结" - if reason != "" { - errMsg += ",原因:" + reason - } - if frozenUntilStr != "" { - errMsg += ",解封时间:" + frozenUntilStr - } - return nil, errors.New(errMsg) + return nil, appErrors.NewAccountFrozenError(reason, accountStatus.FrozenUntil) } } } diff --git a/backend/services/userService/service/auth_service_login_test.go b/backend/services/userService/service/auth_service_login_test.go new file mode 100644 index 0000000..1753d1e --- /dev/null +++ b/backend/services/userService/service/auth_service_login_test.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "errors" + "testing" + + appErrors "github.com/topfans/backend/pkg/errors" + "github.com/topfans/backend/pkg/models" + pb "github.com/topfans/backend/pkg/proto/user" + "github.com/topfans/backend/services/userService/repository" +) + +// §12.4 Test #1: 账号被封禁 → 业务码 403(不再是 500),typed error 可被 errors.Is 识别 +func TestLogin_BannedAccount_Returns403(t *testing.T) { + skipIfNoTestEnv(t) + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + userRepo := repository.NewUserRepository() + fanProfileRepo := repository.NewFanProfileRepository() + starRepo := repository.NewStarRepository() + + // 创建用户 + 封号状态 + user := createTestUser(t, db, userRepo, "13800002001") + defer deleteTestUser(t, db, userRepo, user.ID) + + reason := "违规发言" + if err := db.Create(&models.UserAccountStatus{ + UserID: user.ID, + Status: "banned", + Reason: &reason, + }).Error; err != nil { + t.Fatalf("create banned status: %v", err) + } + + svc := NewAuthService(userRepo, fanProfileRepo, starRepo, db) + _, err := svc.Login(&pb.LoginRequest{ + Mobile: user.Mobile, + Password: "password123", + }) + + if !errors.Is(err, appErrors.ErrAccountBanned) { + t.Errorf("expected ErrAccountBanned, got %v", err) + } + + // 验证 ToStatusCode 返回 403(而非 500) + if code := appErrors.ToStatusCode(err); code.String() != "STATUS_FORBIDDEN" { + t.Errorf("expected STATUS_FORBIDDEN, got %s", code.String()) + } +} + +// §12.4 Test #2: 账号被冻结 → 业务码 403,typed error 可识别 +func TestLogin_FrozenAccount_Returns403(t *testing.T) { + skipIfNoTestEnv(t) + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + userRepo := repository.NewUserRepository() + fanProfileRepo := repository.NewFanProfileRepository() + starRepo := repository.NewStarRepository() + + user := createTestUser(t, db, userRepo, "13800002002") + defer deleteTestUser(t, db, userRepo, user.ID) + + reason := "异常登录" + // frozenUntil 设为未来时间,确保仍处于冻结状态 + frozenUntil := int64(9999999999999) + if err := db.Create(&models.UserAccountStatus{ + UserID: user.ID, + Status: "frozen", + Reason: &reason, + FrozenUntil: &frozenUntil, + }).Error; err != nil { + t.Fatalf("create frozen status: %v", err) + } + + svc := NewAuthService(userRepo, fanProfileRepo, starRepo, db) + _, err := svc.Login(&pb.LoginRequest{ + Mobile: user.Mobile, + Password: "password123", + }) + + if !errors.Is(err, appErrors.ErrAccountFrozen) { + t.Errorf("expected ErrAccountFrozen, got %v", err) + } + + if code := appErrors.ToStatusCode(err); code.String() != "STATUS_FORBIDDEN" { + t.Errorf("expected STATUS_FORBIDDEN, got %s", code.String()) + } +} + +// 引用 context 包避免 unused import(测试可能 skip) +var _ = context.Background \ No newline at end of file diff --git a/backend/services/userService/service/sms_redis.go b/backend/services/userService/service/sms_redis.go index 7d6fd1a..fa283f5 100644 --- a/backend/services/userService/service/sms_redis.go +++ b/backend/services/userService/service/sms_redis.go @@ -7,19 +7,20 @@ import ( "math/big" "time" + appErrors "github.com/topfans/backend/pkg/errors" "github.com/redis/go-redis/v9" "github.com/topfans/backend/pkg/database" ) const ( - SMSCodeKeyPrefix = "sms:register:" // 验证码:sms:register:{mobile} - SMSVerifyTokenPrefix = "verify:register:" // 验证Token:verify:register:{mobile} - SMSLimitMobilePrefix = "sms:limit:mobile:" // 手机号频率:sms:limit:mobile:register:{mobile} + SMSCodeKeyPrefix = "sms:" // 验证码:sms:{scene}:{mobile} + SMSVerifyTokenPrefix = "verify:" // 验证Token:verify:{scene}:{mobile} + SMSLimitMobilePrefix = "sms:limit:mobile:" // 手机号频率:sms:limit:mobile:{scene}:{mobile} SMSLimitIPPrefix = "sms:limit:ip:send:" // IP频率:sms:limit:ip:send:{ip} SMSBlacklistIPPrefix = "sms:blacklist:ip:" // IP黑名单:sms:blacklist:ip:{ip} MaxMobileAttemptsPerHour = 10 - MaxIPAttemptsPerHour = 30 + MaxIPAttemptsPerHour = 30 ) type SMSCodeData struct { @@ -48,18 +49,18 @@ func GetRedisClient() *redis.Client { } // smsCodeKey generates the Redis key for SMS code -func smsCodeKey(mobile string) string { - return SMSCodeKeyPrefix + mobile +func smsCodeKey(scene, mobile string) string { + return SMSCodeKeyPrefix + scene + ":" + mobile } // verifyTokenKey generates the Redis key for verify token -func verifyTokenKey(mobile string) string { - return SMSVerifyTokenPrefix + mobile +func verifyTokenKey(scene, mobile string) string { + return SMSVerifyTokenPrefix + scene + ":" + mobile } // mobileLimitKey generates the Redis key for mobile rate limit -func mobileLimitKey(mobile string) string { - return SMSLimitMobilePrefix + mobile +func mobileLimitKey(scene, mobile string) string { + return SMSLimitMobilePrefix + scene + ":" + mobile } // ipLimitKey generates the Redis key for IP rate limit @@ -73,13 +74,13 @@ func blacklistIPKey(ip string) string { } // SaveSMSCode saves the SMS code as a Hash with fields: code, created_at, attempts, used -func SaveSMSCode(ctx context.Context, mobile, code string, ttl time.Duration) error { +func SaveSMSCode(ctx context.Context, scene, mobile, code string, ttl time.Duration) error { client := GetRedisClient() if client == nil { return fmt.Errorf("redis client is not initialized") } - key := smsCodeKey(mobile) + key := smsCodeKey(scene, mobile) now := time.Now().Unix() pipe := client.Pipeline() @@ -95,13 +96,13 @@ func SaveSMSCode(ctx context.Context, mobile, code string, ttl time.Duration) er } // GetSMSCode retrieves the SMS code data for a mobile number -func GetSMSCode(ctx context.Context, mobile string) (*SMSCodeData, error) { +func GetSMSCode(ctx context.Context, scene, mobile string) (*SMSCodeData, error) { client := GetRedisClient() if client == nil { return nil, fmt.Errorf("redis client is not initialized") } - key := smsCodeKey(mobile) + key := smsCodeKey(scene, mobile) data, err := client.HGetAll(ctx, key).Result() if err != nil { return nil, err @@ -130,24 +131,24 @@ func GetSMSCode(ctx context.Context, mobile string) (*SMSCodeData, error) { } // DeleteSMSCode deletes the SMS code key -func DeleteSMSCode(ctx context.Context, mobile string) error { +func DeleteSMSCode(ctx context.Context, scene, mobile string) error { client := GetRedisClient() if client == nil { return fmt.Errorf("redis client is not initialized") } - key := smsCodeKey(mobile) + key := smsCodeKey(scene, mobile) return client.Del(ctx, key).Err() } // IncrementAttempts increments the attempts counter and returns the new count -func IncrementAttempts(ctx context.Context, mobile string) (int, error) { +func IncrementAttempts(ctx context.Context, scene, mobile string) (int, error) { client := GetRedisClient() if client == nil { return 0, fmt.Errorf("redis client is not initialized") } - key := smsCodeKey(mobile) + key := smsCodeKey(scene, mobile) count, err := client.HIncrBy(ctx, key, "attempts", 1).Result() if err != nil { return 0, err @@ -156,35 +157,35 @@ func IncrementAttempts(ctx context.Context, mobile string) (int, error) { } // MarkCodeUsed marks the SMS code as used -func MarkCodeUsed(ctx context.Context, mobile string) error { +func MarkCodeUsed(ctx context.Context, scene, mobile string) error { client := GetRedisClient() if client == nil { return fmt.Errorf("redis client is not initialized") } - key := smsCodeKey(mobile) + key := smsCodeKey(scene, mobile) return client.HSet(ctx, key, "used", "true").Err() } // SaveVerifyToken saves the verify token as a String -func SaveVerifyToken(ctx context.Context, mobile, token string, ttl time.Duration) error { +func SaveVerifyToken(ctx context.Context, scene, mobile, token string, ttl time.Duration) error { client := GetRedisClient() if client == nil { return fmt.Errorf("redis client is not initialized") } - key := verifyTokenKey(mobile) + key := verifyTokenKey(scene, mobile) return client.Set(ctx, key, token, ttl).Err() } // GetVerifyToken retrieves the verify token for a mobile number -func GetVerifyToken(ctx context.Context, mobile string) (string, error) { +func GetVerifyToken(ctx context.Context, scene, mobile string) (string, error) { client := GetRedisClient() if client == nil { return "", fmt.Errorf("redis client is not initialized") } - key := verifyTokenKey(mobile) + key := verifyTokenKey(scene, mobile) token, err := client.Get(ctx, key).Result() if err == redis.Nil { return "", nil @@ -193,24 +194,57 @@ func GetVerifyToken(ctx context.Context, mobile string) (string, error) { } // DeleteVerifyToken deletes the verify token -func DeleteVerifyToken(ctx context.Context, mobile string) error { +func DeleteVerifyToken(ctx context.Context, scene, mobile string) error { client := GetRedisClient() if client == nil { return fmt.Errorf("redis client is not initialized") } - key := verifyTokenKey(mobile) + key := verifyTokenKey(scene, mobile) return client.Del(ctx, key).Err() } +// verifyTokenConsumeScript Lua 脚本:原子 GET+COMPARE+DEL +// 用于 VerifyToken 在业务成功后被 ConsumeVerifyToken 一次性消费 +const verifyTokenConsumeScript = ` +local current = redis.call('GET', KEYS[1]) +if current == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0 +` + +// ConsumeVerifyToken 校验 + 原子删除(Plan A: Compare-And-Delete) +// 仅在业务逻辑成功后调用,实现"成功才消费"的语义: +// - token 校验通过 + 业务成功 → token 被删除(下次请求会失败) +// - token 校验通过 + 业务失败 → token 保留(用户可重试无需重新发短信) +// - token 已被并发请求消费 → 返回 ErrInvalidVerifyToken(防重放) +func ConsumeVerifyToken(ctx context.Context, scene, mobile, token string) error { + client := GetRedisClient() + if client == nil { + return fmt.Errorf("redis client is not initialized") + } + + key := verifyTokenKey(scene, mobile) + result, err := client.Eval(ctx, verifyTokenConsumeScript, []string{key}, token).Result() + if err != nil { + return fmt.Errorf("failed to consume verify token: %w", err) + } + deleted, _ := result.(int64) + if deleted == 0 { + return appErrors.ErrInvalidVerifyToken + } + return nil +} + // CheckRateLimitMobile checks if the mobile has sent less than 10 times in the current hour -func CheckRateLimitMobile(ctx context.Context, mobile string) (bool, error) { +func CheckRateLimitMobile(ctx context.Context, scene, mobile string) (bool, error) { client := GetRedisClient() if client == nil { return false, fmt.Errorf("redis client is not initialized") } - key := mobileLimitKey(mobile) + key := mobileLimitKey(scene, mobile) count, err := client.Get(ctx, key).Int() if err == redis.Nil { return true, nil @@ -222,13 +256,13 @@ func CheckRateLimitMobile(ctx context.Context, mobile string) (bool, error) { } // IncrMobileCount increments the mobile send count with 1 hour TTL -func IncrMobileCount(ctx context.Context, mobile string) (int, error) { +func IncrMobileCount(ctx context.Context, scene, mobile string) (int, error) { client := GetRedisClient() if client == nil { return 0, fmt.Errorf("redis client is not initialized") } - key := mobileLimitKey(mobile) + key := mobileLimitKey(scene, mobile) pipe := client.Pipeline() incr := pipe.Incr(ctx, key) diff --git a/backend/services/userService/service/sms_service.go b/backend/services/userService/service/sms_service.go index bf86a05..82ef1e5 100644 --- a/backend/services/userService/service/sms_service.go +++ b/backend/services/userService/service/sms_service.go @@ -11,6 +11,7 @@ import ( dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v5/client" "github.com/alibabacloud-go/darabonba-openapi/v2/client" "github.com/alibabacloud-go/tea/tea" + appErrors "github.com/topfans/backend/pkg/errors" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/services/userService/config" "go.uber.org/zap" @@ -75,8 +76,9 @@ func maskMobile(mobile string) string { } // SendVerificationCode sends a verification code to the given mobile number -func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) { - logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile)), zap.String("ip", ip)) +// scene: 区分场景("register" / "password"),用于 Redis key 隔离 +func SendVerificationCode(ctx context.Context, scene, mobile, ip string) (int, error) { + logger := logger.Logger.With(zap.String("scene", scene), zap.String("mobile", maskMobile(mobile)), zap.String("ip", ip)) // Step 1: Check IP blacklist blacklisted, err := CheckIPBlacklist(ctx, ip) @@ -90,7 +92,7 @@ func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) { } // Step 2: Check if code was recently sent (TTL check via GetSMSCode) - smsData, err := GetSMSCode(ctx, mobile) + smsData, err := GetSMSCode(ctx, scene, mobile) if err != nil { logger.Error("Failed to get SMS code from Redis", zap.Error(err)) return 0, fmt.Errorf("failed to check recent SMS: %w", err) @@ -104,7 +106,7 @@ func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) { } // Step 3: Check mobile hourly limit (10/hour) - allowed, err := CheckRateLimitMobile(ctx, mobile) + allowed, err := CheckRateLimitMobile(ctx, scene, mobile) if err != nil { logger.Error("Failed to check mobile rate limit", zap.Error(err)) return 0, fmt.Errorf("failed to check mobile rate limit: %w", err) @@ -166,14 +168,14 @@ func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) { logger.Info("SMS sent successfully via Aliyun") // Step 7: Save code to Redis with 60s TTL - err = SaveSMSCode(ctx, mobile, code, 60*time.Second) + err = SaveSMSCode(ctx, scene, mobile, code, 60*time.Second) if err != nil { logger.Error("Failed to save SMS code to Redis", zap.Error(err)) return 0, fmt.Errorf("failed to save code: %w", err) } // Step 8: Increment mobile and IP counters - _, err = IncrMobileCount(ctx, mobile) + _, err = IncrMobileCount(ctx, scene, mobile) if err != nil { logger.Error("Failed to increment mobile count", zap.Error(err)) } @@ -188,11 +190,12 @@ func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) { } // VerifyCode verifies the SMS code and returns a verify token on success -func VerifyCode(ctx context.Context, mobile, code string) (string, error) { - logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile))) +// scene: 区分场景,Redis key 与 verify_token 都按 scene 隔离 +func VerifyCode(ctx context.Context, scene, mobile, code string) (string, error) { + logger := logger.Logger.With(zap.String("scene", scene), zap.String("mobile", maskMobile(mobile))) // Step 1: Get stored code data - smsData, err := GetSMSCode(ctx, mobile) + smsData, err := GetSMSCode(ctx, scene, mobile) if err != nil { logger.Error("Failed to get SMS code from Redis", zap.Error(err)) return "", fmt.Errorf("failed to get SMS code: %w", err) @@ -210,12 +213,12 @@ func VerifyCode(ctx context.Context, mobile, code string) (string, error) { // Step 3: Compare codes if smsData.Code != code { - attempts, _ := IncrementAttempts(ctx, mobile) + attempts, _ := IncrementAttempts(ctx, scene, mobile) logger.Warn("SMS code mismatch", zap.Int("attempts", attempts)) // Delete code if attempts >= 3 if attempts >= 3 { - DeleteSMSCode(ctx, mobile) + DeleteSMSCode(ctx, scene, mobile) logger.Info("SMS code deleted due to max attempts") return "", errors.New("验证失败次数过多,请重新获取验证码") } @@ -223,7 +226,7 @@ func VerifyCode(ctx context.Context, mobile, code string) (string, error) { } // Step 4: On success - delete code, generate verify_token, save with 300s TTL - if err := DeleteSMSCode(ctx, mobile); err != nil { + if err := DeleteSMSCode(ctx, scene, mobile); err != nil { logger.Error("Failed to delete SMS code", zap.Error(err)) } @@ -233,7 +236,7 @@ func VerifyCode(ctx context.Context, mobile, code string) (string, error) { return "", fmt.Errorf("failed to generate verify token: %w", err) } - err = SaveVerifyToken(ctx, mobile, verifyToken, 300*time.Second) + err = SaveVerifyToken(ctx, scene, mobile, verifyToken, 300*time.Second) if err != nil { logger.Error("Failed to save verify token", zap.Error(err)) return "", fmt.Errorf("failed to save verify token: %w", err) @@ -243,34 +246,30 @@ func VerifyCode(ctx context.Context, mobile, code string) (string, error) { return verifyToken, nil } -// VerifyToken verifies the verify_token during registration (one-time use) -func VerifyToken(ctx context.Context, mobile, token string) error { - logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile))) +// VerifyToken 仅校验 verify_token 是否匹配(Plan A: Compare-And-Delete 的前半段) +// 业务失败时 token 保留可重试;成功消费由 ConsumeVerifyToken 在业务成功后调用。 +// scene: 与 VerifyCode 时传入的 scene 一致,Redis key 按 scene 隔离 +func VerifyToken(ctx context.Context, scene, mobile, token string) error { + logger := logger.Logger.With(zap.String("scene", scene), zap.String("mobile", maskMobile(mobile))) // Step 1: Get stored token from Redis - storedToken, err := GetVerifyToken(ctx, mobile) + storedToken, err := GetVerifyToken(ctx, scene, mobile) if err != nil { logger.Error("Failed to get verify token from Redis", zap.Error(err)) return fmt.Errorf("failed to get verify token: %w", err) } - // Step 2: Compare with provided token + // Step 2: Compare with provided token(不删除) if storedToken == "" { logger.Warn("No verify token found for mobile") - return errors.New("验证令牌不存在或已过期") + return appErrors.ErrInvalidVerifyToken } if storedToken != token { logger.Warn("Verify token mismatch") - return errors.New("验证令牌无效") + return appErrors.ErrInvalidVerifyToken } - // Step 3: If match, delete the token (one-time use) - if err := DeleteVerifyToken(ctx, mobile); err != nil { - logger.Error("Failed to delete verify token", zap.Error(err)) - return fmt.Errorf("failed to delete verify token: %w", err) - } - - logger.Info("Verify token validated and consumed successfully") + logger.Info("Verify token validated successfully (token retained for retry)") return nil } \ No newline at end of file diff --git a/backend/services/userService/service/user_service.go b/backend/services/userService/service/user_service.go index daaeac1..75cb53f 100644 --- a/backend/services/userService/service/user_service.go +++ b/backend/services/userService/service/user_service.go @@ -40,7 +40,7 @@ type UserService interface { UpdateNickname(req *pb.UpdateNicknameRequest, userID, starID int64) (*pb.UpdateNicknameResponse, error) // UpdatePassword 修改密码 - UpdatePassword(req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error) + UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error) // UpdateFanProfileSocial 更新粉丝档案的好友数量(内部RPC调用) UpdateFanProfileSocial(req *pb.UpdateFanProfileSocialRequest) (*pb.UpdateFanProfileSocialResponse, error) @@ -482,8 +482,8 @@ func (s *userService) UpdateNickname(req *pb.UpdateNicknameRequest, userID, star } // UpdatePassword 修改密码 -func (s *userService) UpdatePassword(req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error) { - // 1. 参数验证 +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), @@ -495,7 +495,14 @@ func (s *userService) UpdatePassword(req *pb.UpdatePasswordRequest, userID int64 logger.Logger.Warn("Old password is empty", zap.Int64("user_id", userID), ) - return nil, appErrors.ErrInvalidPassword + 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) @@ -510,7 +517,7 @@ func (s *userService) UpdatePassword(req *pb.UpdatePasswordRequest, userID int64 return nil, fmt.Errorf("invalid password: %s", msg) } - // 2. 查询用户 + // 0.5 (新增) 查询用户(后续需 user.Mobile 校验 verify_token) user, err := s.userRepo.GetByID(userID) if err != nil { if errors.Is(err, appErrors.ErrUserNotFound) { @@ -526,15 +533,32 @@ func (s *userService) UpdatePassword(req *pb.UpdatePasswordRequest, userID int64 return nil, fmt.Errorf("failed to get user: %w", err) } - // 3. 验证旧密码 + // 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.ErrInvalidPassword + return nil, appErrors.ErrInvalidOldPassword } - // 4. 加密新密码 + // 3. 加密新密码 newPasswordHash, err := repository.HashPassword(req.NewPassword) if err != nil { logger.Logger.Error("Failed to hash new password", @@ -544,7 +568,7 @@ func (s *userService) UpdatePassword(req *pb.UpdatePasswordRequest, userID int64 return nil, fmt.Errorf("failed to hash password: %w", err) } - // 5. 更新密码和updated_at,清除Token(使用事务) + // 4. 更新密码和updated_at,清除Token(使用事务) err = s.db.Transaction(func(tx *gorm.DB) error { // 更新密码 user.PasswordHash = newPasswordHash @@ -573,6 +597,16 @@ func (s *userService) UpdatePassword(req *pb.UpdatePasswordRequest, userID int64 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), ) diff --git a/backend/services/userService/service/user_service_password_test.go b/backend/services/userService/service/user_service_password_test.go new file mode 100644 index 0000000..b9b809d --- /dev/null +++ b/backend/services/userService/service/user_service_password_test.go @@ -0,0 +1,274 @@ +package service + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/topfans/backend/pkg/database" + appErrors "github.com/topfans/backend/pkg/errors" + "github.com/topfans/backend/pkg/models" + pb "github.com/topfans/backend/pkg/proto/user" + "github.com/topfans/backend/services/userService/repository" + "gorm.io/gorm" +) + +// Test #1: 正常路径 — 有效 token + 正确 old + 有效 new +func TestUpdatePassword_Success(t *testing.T) { + skipIfNoTestEnv(t) + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + userRepo := repository.NewUserRepository() + user := createTestUser(t, db, userRepo, "13800001001") + defer deleteTestUser(t, db, userRepo, user.ID) + + // 直接保存 verify_token 到 Redis(scene=password) + if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil { + t.Skipf("Redis unavailable: %v", err) + } + + svc := &userService{userRepo: userRepo, db: db} + req := &pb.UpdatePasswordRequest{ + OldPassword: "password123", + NewPassword: "newpassword456", + VerifyToken: "valid_token", + } + + resp, err := svc.UpdatePassword(context.Background(), req, user.ID) + if err != nil { + t.Fatalf("UpdatePassword failed: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } + + // 验证密码已更新 + updated, _ := userRepo.GetByID(user.ID) + if !userRepo.VerifyPassword(updated, "newpassword456") { + t.Error("password not updated") + } + + // 验证 token 已被 ConsumeVerifyToken 删除(Plan A) + stored, _ := GetVerifyToken(context.Background(), "password", user.Mobile) + if stored != "" { + t.Error("verify_token should be consumed after success") + } +} + +// Test #2: verify_token 缺失 +func TestUpdatePassword_VerifyTokenEmpty(t *testing.T) { + skipIfNoTestEnv(t) + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + userRepo := repository.NewUserRepository() + user := createTestUser(t, db, userRepo, "13800001002") + defer deleteTestUser(t, db, userRepo, user.ID) + + svc := &userService{userRepo: userRepo, db: db} + req := &pb.UpdatePasswordRequest{ + OldPassword: "password123", + NewPassword: "newpassword456", + VerifyToken: "", + } + + _, err := svc.UpdatePassword(context.Background(), req, user.ID) + if !errors.Is(err, appErrors.ErrInvalidVerifyToken) { + t.Errorf("expected ErrInvalidVerifyToken, got %v", err) + } +} + +// Test #3: verify_token 错误 +func TestUpdatePassword_VerifyTokenWrong(t *testing.T) { + skipIfNoTestEnv(t) + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + userRepo := repository.NewUserRepository() + user := createTestUser(t, db, userRepo, "13800001003") + defer deleteTestUser(t, db, userRepo, user.ID) + + // 保存的 token 与请求的不同 + if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "correct_token", 300); err != nil { + t.Skipf("Redis unavailable: %v", err) + } + + svc := &userService{userRepo: userRepo, db: db} + req := &pb.UpdatePasswordRequest{ + OldPassword: "password123", + NewPassword: "newpassword456", + VerifyToken: "wrong_token", + } + + _, err := svc.UpdatePassword(context.Background(), req, user.ID) + if !errors.Is(err, appErrors.ErrInvalidVerifyToken) { + t.Errorf("expected ErrInvalidVerifyToken, got %v", err) + } +} + +// Test #5: 旧密码错误 +func TestUpdatePassword_WrongOldPassword(t *testing.T) { + skipIfNoTestEnv(t) + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + userRepo := repository.NewUserRepository() + user := createTestUser(t, db, userRepo, "13800001005") + defer deleteTestUser(t, db, userRepo, user.ID) + + if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil { + t.Skipf("Redis unavailable: %v", err) + } + + svc := &userService{userRepo: userRepo, db: db} + req := &pb.UpdatePasswordRequest{ + OldPassword: "wrong_old_password", + NewPassword: "newpassword456", + VerifyToken: "valid_token", + } + + _, err := svc.UpdatePassword(context.Background(), req, user.ID) + if !errors.Is(err, appErrors.ErrInvalidOldPassword) { + t.Errorf("expected ErrInvalidOldPassword, got %v", err) + } + + // Plan A: 旧密码错时 token 仍保留 + stored, _ := GetVerifyToken(context.Background(), "password", user.Mobile) + if stored != "valid_token" { + t.Error("verify_token should be retained after business failure") + } +} + +// Test #7: 新旧密码相同 +func TestUpdatePassword_SameAsOld(t *testing.T) { + skipIfNoTestEnv(t) + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + userRepo := repository.NewUserRepository() + user := createTestUser(t, db, userRepo, "13800001007") + defer deleteTestUser(t, db, userRepo, user.ID) + + if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil { + t.Skipf("Redis unavailable: %v", err) + } + + svc := &userService{userRepo: userRepo, db: db} + req := &pb.UpdatePasswordRequest{ + OldPassword: "password123", + NewPassword: "password123", + VerifyToken: "valid_token", + } + + _, err := svc.UpdatePassword(context.Background(), req, user.ID) + if !errors.Is(err, appErrors.ErrSameAsOldPassword) { + t.Errorf("expected ErrSameAsOldPassword, got %v", err) + } +} + +// Test #11 (Plan A): 旧密码错误后 token 保留可重试 +func TestUpdatePassword_PlanA_RetryAfterWrongPassword(t *testing.T) { + skipIfNoTestEnv(t) + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + userRepo := repository.NewUserRepository() + user := createTestUser(t, db, userRepo, "13800001011") + defer deleteTestUser(t, db, userRepo, user.ID) + + if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "shared_token", 300); err != nil { + t.Skipf("Redis unavailable: %v", err) + } + + svc := &userService{userRepo: userRepo, db: db} + + // 第一次:旧密码错 + _, err1 := svc.UpdatePassword(context.Background(), &pb.UpdatePasswordRequest{ + OldPassword: "wrong_old", + NewPassword: "newpassword456", + VerifyToken: "shared_token", + }, user.ID) + if !errors.Is(err1, appErrors.ErrInvalidOldPassword) { + t.Errorf("first call: expected ErrInvalidOldPassword, got %v", err1) + } + + // token 仍在 + stored, _ := GetVerifyToken(context.Background(), "password", user.Mobile) + if stored != "shared_token" { + t.Fatal("token should still be in Redis after business failure") + } + + // 第二次:用同一 token + 正确旧密码 → 成功(Plan A 的核心收益) + _, err2 := svc.UpdatePassword(context.Background(), &pb.UpdatePasswordRequest{ + OldPassword: "password123", + NewPassword: "newpassword456", + VerifyToken: "shared_token", + }, user.ID) + if err2 != nil { + t.Fatalf("retry: expected success, got %v", err2) + } + + // token 已被 Consume + stored2, _ := GetVerifyToken(context.Background(), "password", user.Mobile) + if stored2 != "" { + t.Error("token should be consumed after successful retry") + } +} + +// ==================== Test helpers ==================== + +func skipIfNoTestEnv(t *testing.T) { + if os.Getenv("SKIP_DB_TESTS") != "" { + t.Skip("SKIP_DB_TESTS set") + } +} + +func setupTestDB(t *testing.T) *gorm.DB { + config := database.Config{ + Host: "localhost", + Port: 5432, + User: getEnvOrDefault("TEST_DB_USER", "haihuizhu"), + Password: getEnvOrDefault("TEST_DB_PASSWORD", "admin"), + DBName: getEnvOrDefault("TEST_DB_NAME", "top-fans"), + SSLMode: "disable", + TimeZone: "Asia/Shanghai", + } + if err := database.Init(config); err != nil { + t.Skipf("Skipping: failed to connect to test database: %v", err) + } + return database.GetDB() +} + +func cleanupTestDB(_ *testing.T, db *gorm.DB) { + db.Exec("DELETE FROM fan_profiles WHERE user_id IN (SELECT id FROM users WHERE mobile LIKE '13800001%')") + db.Exec("DELETE FROM users WHERE mobile LIKE '13800001%'") +} + +func createTestUser(t *testing.T, _ *gorm.DB, repo repository.UserRepository, mobile string) *models.User { + hash, err := repository.HashPassword("password123") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + user := &models.User{ + Mobile: mobile, + PasswordHash: hash, + IsActive: true, + } + if err := repo.Create(user); err != nil { + t.Fatalf("Create user: %v", err) + } + return user +} + +func deleteTestUser(_ *testing.T, db *gorm.DB, _ repository.UserRepository, userID int64) { + db.Exec("DELETE FROM users WHERE id = ?", userID) +} + +func getEnvOrDefault(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +} \ No newline at end of file diff --git a/frontend/.env.development b/frontend/.env.development index 5720271..22fe2a6 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,7 +1,7 @@ # 开发环境配置 # HBuilderX「运行」时自动加载;CLI 用 --mode development -# VITE_API_BASE_URL=http://192.168.110.60:8080 -VITE_API_BASE_URL=https://api.topfans.online + VITE_API_BASE_URL=http://192.168.110.60:8080 +# VITE_API_BASE_URL=https://api.topfans.online # WebSocket 地址:如与 API 同源可省略(自动从 VITE_API_BASE_URL 推导 http→ws、https→wss) # 独立部署时直接覆盖,例如:ws://192.168.110.60:8081 VITE_WS_BASE_URL=ws://192.168.110.60:8080 diff --git a/frontend/pages/profile/profile.vue b/frontend/pages/profile/profile.vue index 78bc20d..29c4f92 100644 --- a/frontend/pages/profile/profile.vue +++ b/frontend/pages/profile/profile.vue @@ -127,7 +127,7 @@ 修改密码 - + APP介绍 @@ -156,29 +156,68 @@ - + 修改密码 - + + + + + + + + + + + + + - + {{ showOldPassword ? '👁️' : '👁️‍🗨️' }} - + + - + {{ showNewPassword ? '👁️' : '👁️‍🗨️' }} + + + + + + {{ showConfirmPassword ? '👁️' : '👁️‍🗨️' }} + + + - + @@ -310,8 +349,8 @@ import { useStore } from 'vuex'; import { onReady } from "@dcloudio/uni-app"; import Header from '../components/Header.vue'; import Avatar from '../components/Avatar.vue'; -import { getUserProfileApi, deleteAccountApi, updateNicknameApi, updatePasswordApi, getFanIdentitiesApi, addFanIdentityApi, getMyFanIdentitiesApi, switchFanIdentityApi, getOssSignatureApi, updateAvatarApi } from '@/utils/api'; -import { validateNickname } from '@/utils/validator.js'; +import { getUserProfileApi, deleteAccountApi, updateNicknameApi, updatePasswordApi, getFanIdentitiesApi, addFanIdentityApi, getMyFanIdentitiesApi, switchFanIdentityApi, getOssSignatureApi, updateAvatarApi, sendCodeApi, verifyCodeApi } from '@/utils/api'; +import { validateNickname, validatePassword } from '@/utils/validator.js'; import { clearAvatarCache } from '@/utils/avatarCache'; import GuideModal from '@/pages/tasks/GuideModal.vue'; import GuideOverlay from '@/components/GuideOverlay.vue'; @@ -333,11 +372,22 @@ const loading = ref(false); const showNicknameModal = ref(false); const newNickname = ref(''); -// 修改密码弹窗 +// 修改密码弹窗(Plan A: 旧密码 + 短信验证码双保险) const showPasswordModal = ref(false); const oldPassword = ref(''); const newPassword = ref(''); +const confirmPassword = ref(''); const showOldPassword = ref(false); +const showNewPassword = ref(false); +const showConfirmPassword = ref(false); +// 短信相关 +const smsMobile = ref(''); // 弹窗打开时不预填,要求用户主动输入完整手机号;校验时与 mobile.value 比对,不允许发到其他号 +const smsCode = ref(''); +const sendingCode = ref(false); +const codeCountdown = ref(0); +const codeCountdownTimer = ref(null); +// 防重 +const changingPassword = ref(false); // 新手引导 const showGuideModal = ref(false); @@ -347,7 +397,6 @@ const guideClaimableCount = computed(() => getClaimableRewardCount()); const handleGuideUpdated = () => { console.log('[Profile] Guide updated'); }; -const showNewPassword = ref(false); // 添加身份弹窗 const showAddIdentityModal = ref(false); @@ -646,92 +695,148 @@ const confirmChangeNickname = async () => { const handleChangePassword = () => { oldPassword.value = ''; newPassword.value = ''; + confirmPassword.value = ''; + smsMobile.value = ''; // 默认不预填,要求用户主动输入完整手机号(校验时与 mobile.value 比对) + smsCode.value = ''; showOldPassword.value = false; showNewPassword.value = false; + showConfirmPassword.value = false; + codeCountdown.value = 0; + if (codeCountdownTimer.value) { + clearInterval(codeCountdownTimer.value); + codeCountdownTimer.value = null; + } showPasswordModal.value = true; }; -// 关闭修改密码弹窗 +// 关闭修改密码弹窗(清空所有,包括倒计时 interval) const closePasswordModal = () => { showPasswordModal.value = false; oldPassword.value = ''; newPassword.value = ''; + confirmPassword.value = ''; + smsMobile.value = ''; + smsCode.value = ''; showOldPassword.value = false; showNewPassword.value = false; + showConfirmPassword.value = false; + codeCountdown.value = 0; + if (codeCountdownTimer.value) { + clearInterval(codeCountdownTimer.value); + codeCountdownTimer.value = null; + } }; -// 确认修改密码 +// 发送验证码 +const handleSendCode = async () => { + if (sendingCode.value || codeCountdown.value > 0) return; + const m = (smsMobile.value || '').replace(/\s/g, ''); + // 校验 1:必须是合法 11 位手机号 + if (!/^1\d{10}$/.test(m)) { + return uni.showToast({ title: '请输入有效的11位手机号', icon: 'none' }); + } + // 校验 2:必须等于当前登录账号绑定的手机号(防止给其他号乱发) + if (mobile.value && m !== mobile.value) { + return uni.showToast({ title: '只能发送到当前账号绑定的手机号', icon: 'none' }); + } + try { + sendingCode.value = true; + const res = await sendCodeApi(m, 'password'); + if (res.code === 200) { + uni.showToast({ title: '验证码已发送', icon: 'success' }); + codeCountdown.value = 60; + // 先清理可能存在的旧 timer + if (codeCountdownTimer.value) clearInterval(codeCountdownTimer.value); + codeCountdownTimer.value = setInterval(() => { + codeCountdown.value--; + if (codeCountdown.value <= 0) { + clearInterval(codeCountdownTimer.value); + codeCountdownTimer.value = null; + } + }, 1000); + } else { + uni.showToast({ title: res.message || '发送失败', icon: 'none' }); + } + } catch (e) { + uni.showToast({ title: e.message || '发送失败', icon: 'none' }); + } finally { + sendingCode.value = false; + } +}; + +// 确认修改密码(Plan A: 5 项本地校验 + 短信验证 + 改密) const confirmChangePassword = async () => { - // 验证旧密码 + // 本地校验 + if (!smsCode.value.match(/^\d{6}$/)) { + return uni.showToast({ title: '请输入6位短信验证码', icon: 'none' }); + } if (!oldPassword.value.trim()) { - uni.showToast({ - title: '请输入旧密码', - icon: 'none' - }); - return; + return uni.showToast({ title: '请输入旧密码', icon: 'none' }); + } + if (oldPassword.value === newPassword.value) { + return uni.showToast({ title: '新密码不能与旧密码相同', icon: 'none' }); + } + const v = validatePassword(newPassword.value); + if (!v.valid) return uni.showToast({ title: v.message, icon: 'none' }); + if (newPassword.value !== confirmPassword.value) { + return uni.showToast({ title: '两次输入的新密码不一致', icon: 'none' }); } - // 验证新密码 - if (!newPassword.value.trim()) { - uni.showToast({ - title: '请输入新密码', - icon: 'none' - }); - return; - } + if (changingPassword.value) return; + changingPassword.value = true; + uni.showLoading({ title: '修改中...', mask: true }); try { - // 显示加载提示 - uni.showLoading({ - title: '修改中...', - mask: true - }); - - // 调用修改密码API - const res = await updatePasswordApi(oldPassword.value.trim(), newPassword.value.trim()); + // Step 1: verify SMS code → verify_token(用输入框的 mobile,与 sendCode 保持一致) + const m = (smsMobile.value || '').replace(/\s/g, ''); + // 校验:必须等于当前登录账号绑定的手机号 + if (mobile.value && m !== mobile.value) { + throw new Error('请使用当前账号绑定的手机号'); + } + const vRes = await verifyCodeApi(m, smsCode.value, 'password'); + if (vRes.code !== 200 || !vRes.data?.verify_token) { + throw new Error(vRes.message || '短信验证码错误'); + } + const verifyToken = vRes.data.verify_token; + // Step 2: update password + const res = await updatePasswordApi( + oldPassword.value.trim(), newPassword.value, verifyToken + ); uni.hideLoading(); + // 业务码 200 走这里;400/500 已被拦截器 reject 跳到 catch if (res.code === 200) { - // 修改成功 - uni.showToast({ - title: '修改成功,请重新登录', - icon: 'success', - duration: 2000 - }); - - // 延迟执行登出和跳转 + uni.showToast({ title: '修改成功,请重新登录', icon: 'success', duration: 2000 }); setTimeout(() => { - // 调用store的logout方法 + // 改密后清空所有登录态 + 注册临时数据 store.dispatch('user/logout'); - - // 清除所有本地缓存 uni.removeStorageSync('access_token'); uni.removeStorageSync('user'); uni.removeStorageSync('nickname'); - // 清除临时注册数据(如果有) uni.removeStorageSync('temp_register_mobile'); uni.removeStorageSync('temp_register_password'); uni.removeStorageSync('temp_register_nickname'); - - // 跳转到登录页 - uni.reLaunch({ - url: '/pages/login/login' - }); + uni.reLaunch({ url: '/pages/login/login' }); }, 2000); } - } catch (error) { + } catch (e) { uni.hideLoading(); - // 处理错误,显示错误信息 - const errorMessage = error.message || '修改失败,请重试'; - uni.showToast({ - title: errorMessage, - icon: 'none', - duration: 2000 - }); + uni.showToast({ title: e.message || '修改失败,请重试', icon: 'none' }); + changingPassword.value = false; } }; +// 处理 APP 介绍(补 BUG #2: 原 @tap 缺失) +const handleShowAppIntro = () => { + uni.showModal({ + title: 'APP介绍', + content: '顶粉APP是一款粉丝专属应用,集成了身份管理、藏品展示、社交互动等功能。\n(更多介绍功能开发中...)', + showCancel: false, + confirmText: '我知道了' + }); +}; + // 处理添加身份 const handleSwitchRole = async () => { try { @@ -2249,4 +2354,47 @@ onShow(() => { text-align: center; margin-bottom: 40rpx; } + +/* 修改密码弹窗 - 手机号 / 短信验证码 + 获取验证码 行(§5.4) */ +.modal-row { + display: flex; + gap: 20rpx; + /* margin-bottom: 40rpx; */ + /* align-items: center; */ +} +.modal-input.sms-mobile-input, +.modal-input.sms-code-input { + flex: 1; + height: 88rpx; + border-radius: 20rpx; + padding: 0 30rpx; + font-size: 32rpx; + background: #fff; + border: 1rpx solid #e5e5e5; + color: #333; +} +.modal-input.sms-mobile-input:focus, +.modal-input.sms-code-input:focus { + border-color: #F08399; +} +.btn-get-code { + height: 88rpx; + line-height: 88rpx; + padding: 0 24rpx; + border-radius: 20rpx; + background: linear-gradient(165deg, #F0E4B1 0%, #F08399 100%); + color: #fff; + font-size: 26rpx; + border: none; + white-space: nowrap; + min-width: 180rpx; +} +/* 提优先级,确保压过 uni-app 默认的 uni-button[disabled]:not([type]) { color: rgba(0,0,0,0.3); background-color: #f7f7f7; } */ +uni-button.btn-get-code[disabled] { + color: #fff; +} +.btn-get-code::after { + border: none; +} + diff --git a/frontend/utils/api.js b/frontend/utils/api.js index 05e7bba..b5503d8 100644 --- a/frontend/utils/api.js +++ b/frontend/utils/api.js @@ -98,9 +98,9 @@ export function request(options) { if (res.data && res.data.code !== undefined) { if (res.data.code === 200) { resolve(res.data) - } else if (res.data.code === 401 || res.data.code === 400 || res.data - .code === 403) { - // 业务状态码401/400/403(未授权/冻结/封号),清除缓存并跳转到登录页 + } else if (res.data.code === 401 || res.data.code === 403) { + // 业务状态码 401(token 失效) / 403(账号被封)→ 清缓存 + 跳登录页 +// 注:400 是业务校验错误(密码错、参数错、verify_token 错),不登出,reject 让调用方 toast uni.removeStorageSync('access_token') uni.removeStorageSync('user') @@ -278,14 +278,15 @@ export function deleteAccountApi() { }) } -// 修改密码接口 -export function updatePasswordApi(oldPassword, newPassword) { +// 修改密码接口(Plan A: 需带 verify_token,scene=password 下发的一次性 token) +export function updatePasswordApi(oldPassword, newPassword, verifyToken) { return request({ url: '/api/v1/account/password', method: 'POST', data: { old_password: oldPassword, - new_password: newPassword + new_password: newPassword, + verify_token: verifyToken } }) }