feat:修改密码增加手机号+短信验证
This commit is contained in:
parent
ddb3620eb1
commit
56fc2f6beb
@ -3,6 +3,7 @@ package errors
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "github.com/topfans/backend/pkg/proto/common"
|
pb "github.com/topfans/backend/pkg/proto/common"
|
||||||
@ -13,6 +14,9 @@ var (
|
|||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
ErrUserAlreadyExists = errors.New("user already exists")
|
ErrUserAlreadyExists = errors.New("user already exists")
|
||||||
ErrInvalidPassword = errors.New("invalid password")
|
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")
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
ErrTokenExpired = errors.New("token expired")
|
ErrTokenExpired = errors.New("token expired")
|
||||||
ErrTokenMismatch = errors.New("token mismatch")
|
ErrTokenMismatch = errors.New("token mismatch")
|
||||||
@ -71,37 +75,42 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ToStatusCode 将错误转换为Proto状态码
|
// ToStatusCode 将错误转换为Proto状态码
|
||||||
|
// 用 errors.Is 而非 == 比较,以便识别 fmt.Errorf("%w: ...", ErrXxx, ...) 包装后的错误
|
||||||
func ToStatusCode(err error) pb.StatusCode {
|
func ToStatusCode(err error) pb.StatusCode {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return pb.StatusCode_STATUS_OK
|
return pb.StatusCode_STATUS_OK
|
||||||
}
|
}
|
||||||
|
|
||||||
switch err {
|
switch {
|
||||||
case ErrUserNotFound, ErrFanProfileNotFound, ErrStarNotFound:
|
case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrFanProfileNotFound), errors.Is(err, ErrStarNotFound):
|
||||||
return pb.StatusCode_STATUS_NOT_FOUND
|
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
|
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
|
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
|
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
|
return pb.StatusCode_STATUS_BAD_REQUEST
|
||||||
case ErrCannotAddSelf, ErrCannotSearchSelf, ErrNotFanOfStar, ErrAlreadyFriends,
|
case errors.Is(err, ErrCannotAddSelf), errors.Is(err, ErrCannotSearchSelf), errors.Is(err, ErrNotFanOfStar), errors.Is(err, ErrAlreadyFriends),
|
||||||
ErrRequestAlreadyPending, ErrInvalidFriendUserID, ErrCannotProcessOwnRequest,
|
errors.Is(err, ErrRequestAlreadyPending), errors.Is(err, ErrInvalidFriendUserID), errors.Is(err, ErrCannotProcessOwnRequest),
|
||||||
ErrRequestAlreadyProcessed, ErrRequestExpired, ErrInvalidAction, ErrNotFriends:
|
errors.Is(err, ErrRequestAlreadyProcessed), errors.Is(err, ErrRequestExpired), errors.Is(err, ErrInvalidAction), errors.Is(err, ErrNotFriends):
|
||||||
return pb.StatusCode_STATUS_BAD_REQUEST
|
return pb.StatusCode_STATUS_BAD_REQUEST
|
||||||
case ErrRequestInCooldown:
|
case errors.Is(err, ErrRequestInCooldown):
|
||||||
return pb.StatusCode_STATUS_TOO_MANY_REQUESTS
|
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
|
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
|
return pb.StatusCode_STATUS_BAD_REQUEST
|
||||||
case ErrAssetAccessDenied, ErrMintOrderAccessDenied:
|
case errors.Is(err, ErrAssetAccessDenied), errors.Is(err, ErrMintOrderAccessDenied):
|
||||||
return pb.StatusCode_STATUS_FORBIDDEN
|
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
|
return pb.StatusCode_STATUS_NOT_FOUND
|
||||||
case ErrInvalidAssetType:
|
case errors.Is(err, ErrInvalidAssetType):
|
||||||
return pb.StatusCode_STATUS_BAD_REQUEST
|
return pb.StatusCode_STATUS_BAD_REQUEST
|
||||||
default:
|
default:
|
||||||
return pb.StatusCode_STATUS_INTERNAL_ERROR
|
return pb.StatusCode_STATUS_INTERNAL_ERROR
|
||||||
@ -162,3 +171,28 @@ func BuildBaseResponseWithMessage(code pb.StatusCode, message string) *pb.BaseRe
|
|||||||
func getCurrentTimestamp() int64 {
|
func getCurrentTimestamp() int64 {
|
||||||
return time.Now().UnixMilli()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -2447,6 +2447,7 @@ type UpdatePasswordRequest struct {
|
|||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
OldPassword string `protobuf:"bytes,1,opt,name=old_password,json=oldPassword,proto3" json:"old_password,omitempty"` // 旧密码
|
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"` // 新密码
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -2495,6 +2496,13 @@ func (x *UpdatePasswordRequest) GetNewPassword() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *UpdatePasswordRequest) GetVerifyToken() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.VerifyToken
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// 修改密码响应
|
// 修改密码响应
|
||||||
type UpdatePasswordResponse struct {
|
type UpdatePasswordResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -3298,10 +3306,11 @@ const file_user_proto_rawDesc = "" +
|
|||||||
"\x16UpdateNicknameResponse\x120\n" +
|
"\x16UpdateNicknameResponse\x120\n" +
|
||||||
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x129\n" +
|
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x129\n" +
|
||||||
"\vfan_profile\x18\x02 \x01(\v2\x18.topfans.user.FanProfileR\n" +
|
"\vfan_profile\x18\x02 \x01(\v2\x18.topfans.user.FanProfileR\n" +
|
||||||
"fanProfile\"]\n" +
|
"fanProfile\"\x80\x01\n" +
|
||||||
"\x15UpdatePasswordRequest\x12!\n" +
|
"\x15UpdatePasswordRequest\x12!\n" +
|
||||||
"\fold_password\x18\x01 \x01(\tR\voldPassword\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" +
|
"\x16UpdatePasswordResponse\x120\n" +
|
||||||
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\"4\n" +
|
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\"4\n" +
|
||||||
"\x13UpdateAvatarRequest\x12\x1d\n" +
|
"\x13UpdateAvatarRequest\x12\x1d\n" +
|
||||||
|
|||||||
@ -301,6 +301,7 @@ message UpdateNicknameResponse {
|
|||||||
message UpdatePasswordRequest {
|
message UpdatePasswordRequest {
|
||||||
string old_password = 1; // 旧密码
|
string old_password = 1; // 旧密码
|
||||||
string new_password = 2; // 新密码
|
string new_password = 2; // 新密码
|
||||||
|
string verify_token = 3; // 短信验证 token(scene=password 下发的一次性 token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改密码响应
|
// 修改密码响应
|
||||||
|
|||||||
@ -259,7 +259,7 @@ func (p *AuthProvider) SendCode(ctx context.Context, req *pb.SendCodeRequest) (*
|
|||||||
// 获取客户端IP(从context或 attachments 获取)
|
// 获取客户端IP(从context或 attachments 获取)
|
||||||
ip := getClientIP(ctx)
|
ip := getClientIP(ctx)
|
||||||
|
|
||||||
expiresIn, err := service.SendVerificationCode(ctx, req.Mobile, ip)
|
expiresIn, err := service.SendVerificationCode(ctx, req.Scene, req.Mobile, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Logger.Error("SendCode failed",
|
logger.Logger.Error("SendCode failed",
|
||||||
zap.String("mobile", req.Mobile),
|
zap.String("mobile", req.Mobile),
|
||||||
@ -295,7 +295,7 @@ func (p *AuthProvider) VerifyCode(ctx context.Context, req *pb.VerifyCodeRequest
|
|||||||
zap.String("scene", req.Scene),
|
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 {
|
if err != nil {
|
||||||
logger.Logger.Warn("VerifyCode failed",
|
logger.Logger.Warn("VerifyCode failed",
|
||||||
zap.String("mobile", req.Mobile),
|
zap.String("mobile", req.Mobile),
|
||||||
|
|||||||
@ -358,8 +358,8 @@ func (p *UserProvider) UpdatePassword(ctx context.Context, req *pb.UpdatePasswor
|
|||||||
}, err
|
}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用Service层
|
// 调用Service层(透传 ctx 用于 verify_token 校验)
|
||||||
resp, err := p.userService.UpdatePassword(req, userID)
|
resp, err := p.userService.UpdatePassword(ctx, req, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Logger.Error("UpdatePassword failed",
|
logger.Logger.Error("UpdatePassword failed",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
|
|||||||
@ -61,9 +61,9 @@ func NewAuthService(
|
|||||||
|
|
||||||
// Register 用户注册
|
// Register 用户注册
|
||||||
func (s *authService) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
|
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 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",
|
logger.Logger.Warn("Verify token validation failed",
|
||||||
zap.String("mobile", req.Mobile),
|
zap.String("mobile", req.Mobile),
|
||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
@ -239,6 +239,16 @@ func (s *authService) Register(ctx context.Context, req *pb.RegisterRequest) (*p
|
|||||||
return nil, err
|
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. 构建响应
|
// 5. 构建响应
|
||||||
response := &pb.RegisterResponse{
|
response := &pb.RegisterResponse{
|
||||||
Base: &pbCommon.BaseResponse{
|
Base: &pbCommon.BaseResponse{
|
||||||
@ -325,7 +335,7 @@ func (s *authService) Login(req *pb.LoginRequest) (*pb.LoginResponse, error) {
|
|||||||
// 如果有账号状态记录,需要检查具体状态
|
// 如果有账号状态记录,需要检查具体状态
|
||||||
if accountStatus != nil {
|
if accountStatus != nil {
|
||||||
if accountStatus.IsBanned() {
|
if accountStatus.IsBanned() {
|
||||||
// 封号状态
|
// 封号状态(§12.2: 改用 typed error,业务码 403 而非 500)
|
||||||
reason := ""
|
reason := ""
|
||||||
if accountStatus.Reason != nil {
|
if accountStatus.Reason != nil {
|
||||||
reason = *accountStatus.Reason
|
reason = *accountStatus.Reason
|
||||||
@ -334,14 +344,7 @@ func (s *authService) Login(req *pb.LoginRequest) (*pb.LoginResponse, error) {
|
|||||||
zap.Int64("user_id", user.ID),
|
zap.Int64("user_id", user.ID),
|
||||||
zap.String("reason", reason),
|
zap.String("reason", reason),
|
||||||
)
|
)
|
||||||
return nil, fmt.Errorf("账号已被封禁%s%s", map[bool]func() string{
|
return nil, appErrors.NewAccountBannedError(reason)
|
||||||
true: func() string {
|
|
||||||
if reason != "" {
|
|
||||||
return ",原因:" + reason
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
}[true](), "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if accountStatus.IsFrozen() {
|
if accountStatus.IsFrozen() {
|
||||||
@ -356,24 +359,17 @@ func (s *authService) Login(req *pb.LoginRequest) (*pb.LoginResponse, error) {
|
|||||||
if accountStatus.Reason != nil {
|
if accountStatus.Reason != nil {
|
||||||
reason = *accountStatus.Reason
|
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",
|
logger.Logger.Warn("User account is frozen",
|
||||||
zap.Int64("user_id", user.ID),
|
zap.Int64("user_id", user.ID),
|
||||||
zap.String("reason", reason),
|
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 := "账号已被冻结"
|
return nil, appErrors.NewAccountFrozenError(reason, accountStatus.FrozenUntil)
|
||||||
if reason != "" {
|
|
||||||
errMsg += ",原因:" + reason
|
|
||||||
}
|
|
||||||
if frozenUntilStr != "" {
|
|
||||||
errMsg += ",解封时间:" + frozenUntilStr
|
|
||||||
}
|
|
||||||
return nil, errors.New(errMsg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
@ -7,19 +7,20 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
appErrors "github.com/topfans/backend/pkg/errors"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"github.com/topfans/backend/pkg/database"
|
"github.com/topfans/backend/pkg/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SMSCodeKeyPrefix = "sms:register:" // 验证码:sms:register:{mobile}
|
SMSCodeKeyPrefix = "sms:" // 验证码:sms:{scene}:{mobile}
|
||||||
SMSVerifyTokenPrefix = "verify:register:" // 验证Token:verify:register:{mobile}
|
SMSVerifyTokenPrefix = "verify:" // 验证Token:verify:{scene}:{mobile}
|
||||||
SMSLimitMobilePrefix = "sms:limit:mobile:" // 手机号频率:sms:limit:mobile:register:{mobile}
|
SMSLimitMobilePrefix = "sms:limit:mobile:" // 手机号频率:sms:limit:mobile:{scene}:{mobile}
|
||||||
SMSLimitIPPrefix = "sms:limit:ip:send:" // IP频率:sms:limit:ip:send:{ip}
|
SMSLimitIPPrefix = "sms:limit:ip:send:" // IP频率:sms:limit:ip:send:{ip}
|
||||||
SMSBlacklistIPPrefix = "sms:blacklist:ip:" // IP黑名单:sms:blacklist:ip:{ip}
|
SMSBlacklistIPPrefix = "sms:blacklist:ip:" // IP黑名单:sms:blacklist:ip:{ip}
|
||||||
|
|
||||||
MaxMobileAttemptsPerHour = 10
|
MaxMobileAttemptsPerHour = 10
|
||||||
MaxIPAttemptsPerHour = 30
|
MaxIPAttemptsPerHour = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
type SMSCodeData struct {
|
type SMSCodeData struct {
|
||||||
@ -48,18 +49,18 @@ func GetRedisClient() *redis.Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// smsCodeKey generates the Redis key for SMS code
|
// smsCodeKey generates the Redis key for SMS code
|
||||||
func smsCodeKey(mobile string) string {
|
func smsCodeKey(scene, mobile string) string {
|
||||||
return SMSCodeKeyPrefix + mobile
|
return SMSCodeKeyPrefix + scene + ":" + mobile
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyTokenKey generates the Redis key for verify token
|
// verifyTokenKey generates the Redis key for verify token
|
||||||
func verifyTokenKey(mobile string) string {
|
func verifyTokenKey(scene, mobile string) string {
|
||||||
return SMSVerifyTokenPrefix + mobile
|
return SMSVerifyTokenPrefix + scene + ":" + mobile
|
||||||
}
|
}
|
||||||
|
|
||||||
// mobileLimitKey generates the Redis key for mobile rate limit
|
// mobileLimitKey generates the Redis key for mobile rate limit
|
||||||
func mobileLimitKey(mobile string) string {
|
func mobileLimitKey(scene, mobile string) string {
|
||||||
return SMSLimitMobilePrefix + mobile
|
return SMSLimitMobilePrefix + scene + ":" + mobile
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipLimitKey generates the Redis key for IP rate limit
|
// 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
|
// 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()
|
client := GetRedisClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return fmt.Errorf("redis client is not initialized")
|
return fmt.Errorf("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := smsCodeKey(mobile)
|
key := smsCodeKey(scene, mobile)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
|
||||||
pipe := client.Pipeline()
|
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
|
// 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()
|
client := GetRedisClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return nil, fmt.Errorf("redis client is not initialized")
|
return nil, fmt.Errorf("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := smsCodeKey(mobile)
|
key := smsCodeKey(scene, mobile)
|
||||||
data, err := client.HGetAll(ctx, key).Result()
|
data, err := client.HGetAll(ctx, key).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -130,24 +131,24 @@ func GetSMSCode(ctx context.Context, mobile string) (*SMSCodeData, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSMSCode deletes the SMS code key
|
// 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()
|
client := GetRedisClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return fmt.Errorf("redis client is not initialized")
|
return fmt.Errorf("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := smsCodeKey(mobile)
|
key := smsCodeKey(scene, mobile)
|
||||||
return client.Del(ctx, key).Err()
|
return client.Del(ctx, key).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementAttempts increments the attempts counter and returns the new count
|
// 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()
|
client := GetRedisClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return 0, fmt.Errorf("redis client is not initialized")
|
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()
|
count, err := client.HIncrBy(ctx, key, "attempts", 1).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@ -156,35 +157,35 @@ func IncrementAttempts(ctx context.Context, mobile string) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MarkCodeUsed marks the SMS code as used
|
// 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()
|
client := GetRedisClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return fmt.Errorf("redis client is not initialized")
|
return fmt.Errorf("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := smsCodeKey(mobile)
|
key := smsCodeKey(scene, mobile)
|
||||||
return client.HSet(ctx, key, "used", "true").Err()
|
return client.HSet(ctx, key, "used", "true").Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveVerifyToken saves the verify token as a String
|
// 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()
|
client := GetRedisClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return fmt.Errorf("redis client is not initialized")
|
return fmt.Errorf("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := verifyTokenKey(mobile)
|
key := verifyTokenKey(scene, mobile)
|
||||||
return client.Set(ctx, key, token, ttl).Err()
|
return client.Set(ctx, key, token, ttl).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVerifyToken retrieves the verify token for a mobile number
|
// 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()
|
client := GetRedisClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return "", fmt.Errorf("redis client is not initialized")
|
return "", fmt.Errorf("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := verifyTokenKey(mobile)
|
key := verifyTokenKey(scene, mobile)
|
||||||
token, err := client.Get(ctx, key).Result()
|
token, err := client.Get(ctx, key).Result()
|
||||||
if err == redis.Nil {
|
if err == redis.Nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
@ -193,24 +194,57 @@ func GetVerifyToken(ctx context.Context, mobile string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteVerifyToken deletes the verify token
|
// DeleteVerifyToken deletes the verify token
|
||||||
func DeleteVerifyToken(ctx context.Context, mobile string) error {
|
func DeleteVerifyToken(ctx context.Context, scene, mobile string) error {
|
||||||
client := GetRedisClient()
|
client := GetRedisClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return fmt.Errorf("redis client is not initialized")
|
return fmt.Errorf("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := verifyTokenKey(mobile)
|
key := verifyTokenKey(scene, mobile)
|
||||||
return client.Del(ctx, key).Err()
|
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
|
// 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()
|
client := GetRedisClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return false, fmt.Errorf("redis client is not initialized")
|
return false, fmt.Errorf("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := mobileLimitKey(mobile)
|
key := mobileLimitKey(scene, mobile)
|
||||||
count, err := client.Get(ctx, key).Int()
|
count, err := client.Get(ctx, key).Int()
|
||||||
if err == redis.Nil {
|
if err == redis.Nil {
|
||||||
return true, 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
|
// 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()
|
client := GetRedisClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return 0, fmt.Errorf("redis client is not initialized")
|
return 0, fmt.Errorf("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := mobileLimitKey(mobile)
|
key := mobileLimitKey(scene, mobile)
|
||||||
pipe := client.Pipeline()
|
pipe := client.Pipeline()
|
||||||
|
|
||||||
incr := pipe.Incr(ctx, key)
|
incr := pipe.Incr(ctx, key)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
|
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
|
||||||
"github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
"github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||||
"github.com/alibabacloud-go/tea/tea"
|
"github.com/alibabacloud-go/tea/tea"
|
||||||
|
appErrors "github.com/topfans/backend/pkg/errors"
|
||||||
"github.com/topfans/backend/pkg/logger"
|
"github.com/topfans/backend/pkg/logger"
|
||||||
"github.com/topfans/backend/services/userService/config"
|
"github.com/topfans/backend/services/userService/config"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@ -75,8 +76,9 @@ func maskMobile(mobile string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendVerificationCode sends a verification code to the given mobile number
|
// SendVerificationCode sends a verification code to the given mobile number
|
||||||
func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) {
|
// scene: 区分场景("register" / "password"),用于 Redis key 隔离
|
||||||
logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile)), zap.String("ip", ip))
|
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
|
// Step 1: Check IP blacklist
|
||||||
blacklisted, err := CheckIPBlacklist(ctx, ip)
|
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)
|
// 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 {
|
if err != nil {
|
||||||
logger.Error("Failed to get SMS code from Redis", zap.Error(err))
|
logger.Error("Failed to get SMS code from Redis", zap.Error(err))
|
||||||
return 0, fmt.Errorf("failed to check recent SMS: %w", 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)
|
// Step 3: Check mobile hourly limit (10/hour)
|
||||||
allowed, err := CheckRateLimitMobile(ctx, mobile)
|
allowed, err := CheckRateLimitMobile(ctx, scene, mobile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to check mobile rate limit", zap.Error(err))
|
logger.Error("Failed to check mobile rate limit", zap.Error(err))
|
||||||
return 0, fmt.Errorf("failed to check mobile rate limit: %w", 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")
|
logger.Info("SMS sent successfully via Aliyun")
|
||||||
|
|
||||||
// Step 7: Save code to Redis with 60s TTL
|
// 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 {
|
if err != nil {
|
||||||
logger.Error("Failed to save SMS code to Redis", zap.Error(err))
|
logger.Error("Failed to save SMS code to Redis", zap.Error(err))
|
||||||
return 0, fmt.Errorf("failed to save code: %w", err)
|
return 0, fmt.Errorf("failed to save code: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Increment mobile and IP counters
|
// Step 8: Increment mobile and IP counters
|
||||||
_, err = IncrMobileCount(ctx, mobile)
|
_, err = IncrMobileCount(ctx, scene, mobile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to increment mobile count", zap.Error(err))
|
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
|
// VerifyCode verifies the SMS code and returns a verify token on success
|
||||||
func VerifyCode(ctx context.Context, mobile, code string) (string, error) {
|
// scene: 区分场景,Redis key 与 verify_token 都按 scene 隔离
|
||||||
logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile)))
|
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
|
// Step 1: Get stored code data
|
||||||
smsData, err := GetSMSCode(ctx, mobile)
|
smsData, err := GetSMSCode(ctx, scene, mobile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to get SMS code from Redis", zap.Error(err))
|
logger.Error("Failed to get SMS code from Redis", zap.Error(err))
|
||||||
return "", fmt.Errorf("failed to get SMS code: %w", 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
|
// Step 3: Compare codes
|
||||||
if smsData.Code != code {
|
if smsData.Code != code {
|
||||||
attempts, _ := IncrementAttempts(ctx, mobile)
|
attempts, _ := IncrementAttempts(ctx, scene, mobile)
|
||||||
logger.Warn("SMS code mismatch", zap.Int("attempts", attempts))
|
logger.Warn("SMS code mismatch", zap.Int("attempts", attempts))
|
||||||
|
|
||||||
// Delete code if attempts >= 3
|
// Delete code if attempts >= 3
|
||||||
if attempts >= 3 {
|
if attempts >= 3 {
|
||||||
DeleteSMSCode(ctx, mobile)
|
DeleteSMSCode(ctx, scene, mobile)
|
||||||
logger.Info("SMS code deleted due to max attempts")
|
logger.Info("SMS code deleted due to max attempts")
|
||||||
return "", errors.New("验证失败次数过多,请重新获取验证码")
|
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
|
// 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))
|
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)
|
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 {
|
if err != nil {
|
||||||
logger.Error("Failed to save verify token", zap.Error(err))
|
logger.Error("Failed to save verify token", zap.Error(err))
|
||||||
return "", fmt.Errorf("failed to save verify token: %w", 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
|
return verifyToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyToken verifies the verify_token during registration (one-time use)
|
// VerifyToken 仅校验 verify_token 是否匹配(Plan A: Compare-And-Delete 的前半段)
|
||||||
func VerifyToken(ctx context.Context, mobile, token string) error {
|
// 业务失败时 token 保留可重试;成功消费由 ConsumeVerifyToken 在业务成功后调用。
|
||||||
logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile)))
|
// 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
|
// Step 1: Get stored token from Redis
|
||||||
storedToken, err := GetVerifyToken(ctx, mobile)
|
storedToken, err := GetVerifyToken(ctx, scene, mobile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to get verify token from Redis", zap.Error(err))
|
logger.Error("Failed to get verify token from Redis", zap.Error(err))
|
||||||
return fmt.Errorf("failed to get verify token: %w", 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 == "" {
|
if storedToken == "" {
|
||||||
logger.Warn("No verify token found for mobile")
|
logger.Warn("No verify token found for mobile")
|
||||||
return errors.New("验证令牌不存在或已过期")
|
return appErrors.ErrInvalidVerifyToken
|
||||||
}
|
}
|
||||||
|
|
||||||
if storedToken != token {
|
if storedToken != token {
|
||||||
logger.Warn("Verify token mismatch")
|
logger.Warn("Verify token mismatch")
|
||||||
return errors.New("验证令牌无效")
|
return appErrors.ErrInvalidVerifyToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: If match, delete the token (one-time use)
|
logger.Info("Verify token validated successfully (token retained for retry)")
|
||||||
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")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -40,7 +40,7 @@ type UserService interface {
|
|||||||
UpdateNickname(req *pb.UpdateNicknameRequest, userID, starID int64) (*pb.UpdateNicknameResponse, error)
|
UpdateNickname(req *pb.UpdateNicknameRequest, userID, starID int64) (*pb.UpdateNicknameResponse, error)
|
||||||
|
|
||||||
// UpdatePassword 修改密码
|
// 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 更新粉丝档案的好友数量(内部RPC调用)
|
||||||
UpdateFanProfileSocial(req *pb.UpdateFanProfileSocialRequest) (*pb.UpdateFanProfileSocialResponse, error)
|
UpdateFanProfileSocial(req *pb.UpdateFanProfileSocialRequest) (*pb.UpdateFanProfileSocialResponse, error)
|
||||||
@ -482,8 +482,8 @@ func (s *userService) UpdateNickname(req *pb.UpdateNicknameRequest, userID, star
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePassword 修改密码
|
// UpdatePassword 修改密码
|
||||||
func (s *userService) UpdatePassword(req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error) {
|
func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error) {
|
||||||
// 1. 参数验证
|
// 0. 参数基础验证
|
||||||
if !validator.ValidateUserID(userID) {
|
if !validator.ValidateUserID(userID) {
|
||||||
logger.Logger.Warn("Invalid user_id",
|
logger.Logger.Warn("Invalid user_id",
|
||||||
zap.Int64("user_id", userID),
|
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",
|
logger.Logger.Warn("Old password is empty",
|
||||||
zap.Int64("user_id", userID),
|
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)
|
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)
|
return nil, fmt.Errorf("invalid password: %s", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 查询用户
|
// 0.5 (新增) 查询用户(后续需 user.Mobile 校验 verify_token)
|
||||||
user, err := s.userRepo.GetByID(userID)
|
user, err := s.userRepo.GetByID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, appErrors.ErrUserNotFound) {
|
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)
|
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) {
|
if !s.userRepo.VerifyPassword(user, req.OldPassword) {
|
||||||
logger.Logger.Warn("Invalid old password",
|
logger.Logger.Warn("Invalid old password",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
)
|
)
|
||||||
return nil, appErrors.ErrInvalidPassword
|
return nil, appErrors.ErrInvalidOldPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 加密新密码
|
// 3. 加密新密码
|
||||||
newPasswordHash, err := repository.HashPassword(req.NewPassword)
|
newPasswordHash, err := repository.HashPassword(req.NewPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Logger.Error("Failed to hash new password",
|
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)
|
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 {
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
// 更新密码
|
// 更新密码
|
||||||
user.PasswordHash = newPasswordHash
|
user.PasswordHash = newPasswordHash
|
||||||
@ -573,6 +597,16 @@ func (s *userService) UpdatePassword(req *pb.UpdatePasswordRequest, userID int64
|
|||||||
return nil, err
|
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",
|
logger.Logger.Info("Update password successful",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
# 开发环境配置
|
# 开发环境配置
|
||||||
# HBuilderX「运行」时自动加载;CLI 用 --mode development
|
# HBuilderX「运行」时自动加载;CLI 用 --mode development
|
||||||
# VITE_API_BASE_URL=http://192.168.110.60:8080
|
VITE_API_BASE_URL=http://192.168.110.60:8080
|
||||||
VITE_API_BASE_URL=https://api.topfans.online
|
# VITE_API_BASE_URL=https://api.topfans.online
|
||||||
# WebSocket 地址:如与 API 同源可省略(自动从 VITE_API_BASE_URL 推导 http→ws、https→wss)
|
# WebSocket 地址:如与 API 同源可省略(自动从 VITE_API_BASE_URL 推导 http→ws、https→wss)
|
||||||
# 独立部署时直接覆盖,例如:ws://192.168.110.60:8081
|
# 独立部署时直接覆盖,例如:ws://192.168.110.60:8081
|
||||||
VITE_WS_BASE_URL=ws://192.168.110.60:8080
|
VITE_WS_BASE_URL=ws://192.168.110.60:8080
|
||||||
|
|||||||
@ -127,7 +127,7 @@
|
|||||||
</image>
|
</image>
|
||||||
<text class="service-text">修改密码</text>
|
<text class="service-text">修改密码</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="service-button">
|
<view class="service-button" @tap="handleShowAppIntro">
|
||||||
<image class="service-icon" src="/static/icon/edit-password.png" mode="aspectFit">
|
<image class="service-icon" src="/static/icon/edit-password.png" mode="aspectFit">
|
||||||
</image>
|
</image>
|
||||||
<text class="service-text">APP介绍</text>
|
<text class="service-text">APP介绍</text>
|
||||||
@ -156,29 +156,68 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 修改密码弹窗 -->
|
<!-- 修改密码弹窗(Plan A: 旧密码 + 短信验证码双保险) -->
|
||||||
<view class="password-modal" v-if="showPasswordModal" @tap="closePasswordModal">
|
<view class="password-modal" v-if="showPasswordModal" @tap="closePasswordModal">
|
||||||
<view class="modal-content" @tap.stop>
|
<view class="modal-content" @tap.stop>
|
||||||
<view class="modal-title">修改密码</view>
|
<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">
|
<view class="modal-password-wrapper">
|
||||||
<input class="modal-password-input" :type="showOldPassword ? 'text' : 'password'"
|
<input class="modal-password-input"
|
||||||
v-model="oldPassword" placeholder="请输入旧密码" placeholder-class="input-placeholder" />
|
:type="showOldPassword ? 'text' : 'password'"
|
||||||
|
v-model="oldPassword" placeholder="请输入旧密码"
|
||||||
|
placeholder-class="input-placeholder" />
|
||||||
<view class="modal-eye-icon" @click="showOldPassword = !showOldPassword">
|
<view class="modal-eye-icon" @click="showOldPassword = !showOldPassword">
|
||||||
<text class="eye-text">{{ showOldPassword ? '👁️' : '👁️🗨️' }}</text>
|
<text class="eye-text">{{ showOldPassword ? '👁️' : '👁️🗨️' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 新密码输入框 -->
|
|
||||||
|
<!-- 4. 新密码 -->
|
||||||
<view class="modal-password-wrapper">
|
<view class="modal-password-wrapper">
|
||||||
<input class="modal-password-input" :type="showNewPassword ? 'text' : 'password'"
|
<input class="modal-password-input"
|
||||||
v-model="newPassword" placeholder="请输入新密码" placeholder-class="input-placeholder" />
|
:type="showNewPassword ? 'text' : 'password'"
|
||||||
|
v-model="newPassword" placeholder="请输入新密码(至少6位)"
|
||||||
|
placeholder-class="input-placeholder" />
|
||||||
<view class="modal-eye-icon" @click="showNewPassword = !showNewPassword">
|
<view class="modal-eye-icon" @click="showNewPassword = !showNewPassword">
|
||||||
<text class="eye-text">{{ showNewPassword ? '👁️' : '👁️🗨️' }}</text>
|
<text class="eye-text">{{ showNewPassword ? '👁️' : '👁️🗨️' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</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">
|
<view class="modal-buttons">
|
||||||
<button class="modal-btn-cancel" @tap="closePasswordModal">取消</button>
|
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -310,8 +349,8 @@ import { useStore } from 'vuex';
|
|||||||
import { onReady } from "@dcloudio/uni-app";
|
import { onReady } from "@dcloudio/uni-app";
|
||||||
import Header from '../components/Header.vue';
|
import Header from '../components/Header.vue';
|
||||||
import Avatar from '../components/Avatar.vue';
|
import Avatar from '../components/Avatar.vue';
|
||||||
import { getUserProfileApi, deleteAccountApi, updateNicknameApi, updatePasswordApi, getFanIdentitiesApi, addFanIdentityApi, getMyFanIdentitiesApi, switchFanIdentityApi, getOssSignatureApi, updateAvatarApi } from '@/utils/api';
|
import { getUserProfileApi, deleteAccountApi, updateNicknameApi, updatePasswordApi, getFanIdentitiesApi, addFanIdentityApi, getMyFanIdentitiesApi, switchFanIdentityApi, getOssSignatureApi, updateAvatarApi, sendCodeApi, verifyCodeApi } from '@/utils/api';
|
||||||
import { validateNickname } from '@/utils/validator.js';
|
import { validateNickname, validatePassword } from '@/utils/validator.js';
|
||||||
import { clearAvatarCache } from '@/utils/avatarCache';
|
import { clearAvatarCache } from '@/utils/avatarCache';
|
||||||
import GuideModal from '@/pages/tasks/GuideModal.vue';
|
import GuideModal from '@/pages/tasks/GuideModal.vue';
|
||||||
import GuideOverlay from '@/components/GuideOverlay.vue';
|
import GuideOverlay from '@/components/GuideOverlay.vue';
|
||||||
@ -333,11 +372,22 @@ const loading = ref(false);
|
|||||||
const showNicknameModal = ref(false);
|
const showNicknameModal = ref(false);
|
||||||
const newNickname = ref('');
|
const newNickname = ref('');
|
||||||
|
|
||||||
// 修改密码弹窗
|
// 修改密码弹窗(Plan A: 旧密码 + 短信验证码双保险)
|
||||||
const showPasswordModal = ref(false);
|
const showPasswordModal = ref(false);
|
||||||
const oldPassword = ref('');
|
const oldPassword = ref('');
|
||||||
const newPassword = ref('');
|
const newPassword = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
const showOldPassword = ref(false);
|
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);
|
const showGuideModal = ref(false);
|
||||||
@ -347,7 +397,6 @@ const guideClaimableCount = computed(() => getClaimableRewardCount());
|
|||||||
const handleGuideUpdated = () => {
|
const handleGuideUpdated = () => {
|
||||||
console.log('[Profile] Guide updated');
|
console.log('[Profile] Guide updated');
|
||||||
};
|
};
|
||||||
const showNewPassword = ref(false);
|
|
||||||
|
|
||||||
// 添加身份弹窗
|
// 添加身份弹窗
|
||||||
const showAddIdentityModal = ref(false);
|
const showAddIdentityModal = ref(false);
|
||||||
@ -646,92 +695,148 @@ const confirmChangeNickname = async () => {
|
|||||||
const handleChangePassword = () => {
|
const handleChangePassword = () => {
|
||||||
oldPassword.value = '';
|
oldPassword.value = '';
|
||||||
newPassword.value = '';
|
newPassword.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
smsMobile.value = ''; // 默认不预填,要求用户主动输入完整手机号(校验时与 mobile.value 比对)
|
||||||
|
smsCode.value = '';
|
||||||
showOldPassword.value = false;
|
showOldPassword.value = false;
|
||||||
showNewPassword.value = false;
|
showNewPassword.value = false;
|
||||||
|
showConfirmPassword.value = false;
|
||||||
|
codeCountdown.value = 0;
|
||||||
|
if (codeCountdownTimer.value) {
|
||||||
|
clearInterval(codeCountdownTimer.value);
|
||||||
|
codeCountdownTimer.value = null;
|
||||||
|
}
|
||||||
showPasswordModal.value = true;
|
showPasswordModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 关闭修改密码弹窗
|
// 关闭修改密码弹窗(清空所有,包括倒计时 interval)
|
||||||
const closePasswordModal = () => {
|
const closePasswordModal = () => {
|
||||||
showPasswordModal.value = false;
|
showPasswordModal.value = false;
|
||||||
oldPassword.value = '';
|
oldPassword.value = '';
|
||||||
newPassword.value = '';
|
newPassword.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
smsMobile.value = '';
|
||||||
|
smsCode.value = '';
|
||||||
showOldPassword.value = false;
|
showOldPassword.value = false;
|
||||||
showNewPassword.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 () => {
|
const confirmChangePassword = async () => {
|
||||||
// 验证旧密码
|
// 本地校验
|
||||||
|
if (!smsCode.value.match(/^\d{6}$/)) {
|
||||||
|
return uni.showToast({ title: '请输入6位短信验证码', icon: 'none' });
|
||||||
|
}
|
||||||
if (!oldPassword.value.trim()) {
|
if (!oldPassword.value.trim()) {
|
||||||
uni.showToast({
|
return uni.showToast({ title: '请输入旧密码', icon: 'none' });
|
||||||
title: '请输入旧密码',
|
}
|
||||||
icon: 'none'
|
if (oldPassword.value === newPassword.value) {
|
||||||
});
|
return uni.showToast({ title: '新密码不能与旧密码相同', icon: 'none' });
|
||||||
return;
|
}
|
||||||
|
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 (changingPassword.value) return;
|
||||||
if (!newPassword.value.trim()) {
|
changingPassword.value = true;
|
||||||
uni.showToast({
|
uni.showLoading({ title: '修改中...', mask: true });
|
||||||
title: '请输入新密码',
|
|
||||||
icon: 'none'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 显示加载提示
|
// Step 1: verify SMS code → verify_token(用输入框的 mobile,与 sendCode 保持一致)
|
||||||
uni.showLoading({
|
const m = (smsMobile.value || '').replace(/\s/g, '');
|
||||||
title: '修改中...',
|
// 校验:必须等于当前登录账号绑定的手机号
|
||||||
mask: true
|
if (mobile.value && m !== mobile.value) {
|
||||||
});
|
throw new Error('请使用当前账号绑定的手机号');
|
||||||
|
}
|
||||||
// 调用修改密码API
|
const vRes = await verifyCodeApi(m, smsCode.value, 'password');
|
||||||
const res = await updatePasswordApi(oldPassword.value.trim(), newPassword.value.trim());
|
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();
|
uni.hideLoading();
|
||||||
|
|
||||||
|
// 业务码 200 走这里;400/500 已被拦截器 reject 跳到 catch
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
// 修改成功
|
uni.showToast({ title: '修改成功,请重新登录', icon: 'success', duration: 2000 });
|
||||||
uni.showToast({
|
|
||||||
title: '修改成功,请重新登录',
|
|
||||||
icon: 'success',
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
|
|
||||||
// 延迟执行登出和跳转
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 调用store的logout方法
|
// 改密后清空所有登录态 + 注册临时数据
|
||||||
store.dispatch('user/logout');
|
store.dispatch('user/logout');
|
||||||
|
|
||||||
// 清除所有本地缓存
|
|
||||||
uni.removeStorageSync('access_token');
|
uni.removeStorageSync('access_token');
|
||||||
uni.removeStorageSync('user');
|
uni.removeStorageSync('user');
|
||||||
uni.removeStorageSync('nickname');
|
uni.removeStorageSync('nickname');
|
||||||
// 清除临时注册数据(如果有)
|
|
||||||
uni.removeStorageSync('temp_register_mobile');
|
uni.removeStorageSync('temp_register_mobile');
|
||||||
uni.removeStorageSync('temp_register_password');
|
uni.removeStorageSync('temp_register_password');
|
||||||
uni.removeStorageSync('temp_register_nickname');
|
uni.removeStorageSync('temp_register_nickname');
|
||||||
|
uni.reLaunch({ url: '/pages/login/login' });
|
||||||
// 跳转到登录页
|
|
||||||
uni.reLaunch({
|
|
||||||
url: '/pages/login/login'
|
|
||||||
});
|
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
// 处理错误,显示错误信息
|
uni.showToast({ title: e.message || '修改失败,请重试', icon: 'none' });
|
||||||
const errorMessage = error.message || '修改失败,请重试';
|
changingPassword.value = false;
|
||||||
uni.showToast({
|
|
||||||
title: errorMessage,
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理 APP 介绍(补 BUG #2: 原 @tap 缺失)
|
||||||
|
const handleShowAppIntro = () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: 'APP介绍',
|
||||||
|
content: '顶粉APP是一款粉丝专属应用,集成了身份管理、藏品展示、社交互动等功能。\n(更多介绍功能开发中...)',
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '我知道了'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 处理添加身份
|
// 处理添加身份
|
||||||
const handleSwitchRole = async () => {
|
const handleSwitchRole = async () => {
|
||||||
try {
|
try {
|
||||||
@ -2249,4 +2354,47 @@ onShow(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 40rpx;
|
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>
|
</style>
|
||||||
|
|||||||
@ -98,9 +98,9 @@ export function request(options) {
|
|||||||
if (res.data && res.data.code !== undefined) {
|
if (res.data && res.data.code !== undefined) {
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
resolve(res.data)
|
resolve(res.data)
|
||||||
} else if (res.data.code === 401 || res.data.code === 400 || res.data
|
} else if (res.data.code === 401 || res.data.code === 403) {
|
||||||
.code === 403) {
|
// 业务状态码 401(token 失效) / 403(账号被封)→ 清缓存 + 跳登录页
|
||||||
// 业务状态码401/400/403(未授权/冻结/封号),清除缓存并跳转到登录页
|
// 注:400 是业务校验错误(密码错、参数错、verify_token 错),不登出,reject 让调用方 toast
|
||||||
uni.removeStorageSync('access_token')
|
uni.removeStorageSync('access_token')
|
||||||
uni.removeStorageSync('user')
|
uni.removeStorageSync('user')
|
||||||
|
|
||||||
@ -278,14 +278,15 @@ export function deleteAccountApi() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改密码接口
|
// 修改密码接口(Plan A: 需带 verify_token,scene=password 下发的一次性 token)
|
||||||
export function updatePasswordApi(oldPassword, newPassword) {
|
export function updatePasswordApi(oldPassword, newPassword, verifyToken) {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/v1/account/password',
|
url: '/api/v1/account/password',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
old_password: oldPassword,
|
old_password: oldPassword,
|
||||||
new_password: newPassword
|
new_password: newPassword,
|
||||||
|
verify_token: verifyToken
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user