feat:修改密码增加手机号+短信验证

This commit is contained in:
zerosaturation 2026-06-15 14:15:24 +08:00
parent ddb3620eb1
commit 56fc2f6beb
14 changed files with 806 additions and 182 deletions

View File

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

View File

@ -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"` // 短信验证 tokenscene=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" +

View File

@ -301,6 +301,7 @@ message UpdateNicknameResponse {
message UpdatePasswordRequest {
string old_password = 1; //
string new_password = 2; //
string verify_token = 3; // tokenscene=password token
}
//

View File

@ -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),

View File

@ -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),

View File

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

View File

@ -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

View File

@ -7,14 +7,15 @@ 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:" // 验证Tokenverify:register:{mobile}
SMSLimitMobilePrefix = "sms:limit:mobile:" // 手机号频率sms:limit:mobile:register:{mobile}
SMSCodeKeyPrefix = "sms:" // 验证码sms:{scene}:{mobile}
SMSVerifyTokenPrefix = "verify:" // 验证Tokenverify:{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}
@ -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)

View File

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

View File

@ -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),
)

View File

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

View File

@ -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

View File

@ -127,7 +127,7 @@
</image>
<text class="service-text">修改密码</text>
</view>
<view class="service-button">
<view class="service-button" @tap="handleShowAppIntro">
<image class="service-icon" src="/static/icon/edit-password.png" mode="aspectFit">
</image>
<text class="service-text">APP介绍</text>
@ -156,29 +156,68 @@
</view>
</view>
<!-- 修改密码弹窗 -->
<!-- 修改密码弹窗(Plan A: 旧密码 + 短信验证码双保险) -->
<view class="password-modal" v-if="showPasswordModal" @tap="closePasswordModal">
<view class="modal-content" @tap.stop>
<view class="modal-title">修改密码</view>
<!-- 旧密码输入框 -->
<!-- 1. 手机号(默认空,要求用户输入完整 11 ;校验必须等于当前登录账号的 mobile,否则拦截) -->
<view class="modal-row">
<input class="modal-input sms-mobile-input" type="number" maxlength="11"
v-model="smsMobile" placeholder="请输入完整手机号"
placeholder-class="input-placeholder" />
</view>
<!-- 2. 短信验证码 + 获取验证码按钮(同行) -->
<view class="modal-row">
<input class="modal-input sms-code-input" type="number" maxlength="6"
v-model="smsCode" placeholder="请输入短信验证码"
placeholder-class="input-placeholder" />
<button class="btn-get-code" :disabled="codeCountdown > 0 || sendingCode"
@tap="handleSendCode">
{{ codeCountdown > 0 ? `${codeCountdown}s 后重发` : '获取验证码' }}
</button>
</view>
<!-- 3. 旧密码 -->
<view class="modal-password-wrapper">
<input class="modal-password-input" :type="showOldPassword ? 'text' : 'password'"
v-model="oldPassword" placeholder="请输入旧密码" placeholder-class="input-placeholder" />
<input class="modal-password-input"
:type="showOldPassword ? 'text' : 'password'"
v-model="oldPassword" placeholder="请输入旧密码"
placeholder-class="input-placeholder" />
<view class="modal-eye-icon" @click="showOldPassword = !showOldPassword">
<text class="eye-text">{{ showOldPassword ? '👁️' : '👁️‍🗨️' }}</text>
</view>
</view>
<!-- 新密码输入框 -->
<!-- 4. 新密码 -->
<view class="modal-password-wrapper">
<input class="modal-password-input" :type="showNewPassword ? 'text' : 'password'"
v-model="newPassword" placeholder="请输入新密码" placeholder-class="input-placeholder" />
<input class="modal-password-input"
:type="showNewPassword ? 'text' : 'password'"
v-model="newPassword" placeholder="请输入新密码(至少6位)"
placeholder-class="input-placeholder" />
<view class="modal-eye-icon" @click="showNewPassword = !showNewPassword">
<text class="eye-text">{{ showNewPassword ? '👁️' : '👁️‍🗨️' }}</text>
</view>
</view>
<!-- 5. 确认新密码 -->
<view class="modal-password-wrapper">
<input class="modal-password-input"
:type="showConfirmPassword ? 'text' : 'password'"
v-model="confirmPassword" placeholder="请再次输入新密码"
placeholder-class="input-placeholder" />
<view class="modal-eye-icon" @click="showConfirmPassword = !showConfirmPassword">
<text class="eye-text">{{ showConfirmPassword ? '👁️' : '👁️‍🗨️' }}</text>
</view>
</view>
<view class="modal-buttons">
<button class="modal-btn-cancel" @tap="closePasswordModal">取消</button>
<button class="modal-btn-confirm" @tap="confirmChangePassword">确认</button>
<button class="modal-btn-confirm" @tap="confirmChangePassword"
:disabled="changingPassword">
{{ changingPassword ? '修改中...' : '确认' }}
</button>
</view>
</view>
</view>
@ -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(() => {
// storelogout
// +
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;
}
</style>

View File

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