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
}
})
}