# 修改密码功能设计文档 - **创建日期**: 2026-06-12 - **状态**: 待评审 - **目标**: 修复"修改密码"功能中已发现的 3 个 BUG,并升级为"旧密码 + 短信验证码"双保险的安全流程 --- ## 1. 背景与需求 ### 1.1 现状 修改密码功能后端全链路(proto → service → provider → gateway)、前端 API、入口、弹窗均已实现。但经代码审查发现: **🔴 BUG #1(严重):业务码 400 被前端误判为 token 过期,强制登出** [api.js:101-115](frontend/utils/api.js#L101-L115) 的统一拦截器把业务码 401/400/403 **一律视为登录过期,清 token + 跳登录页**。但 400 应是"业务参数错误"(密码错、参数错),不应触发登出。配合 §4.5 把"旧密码错 / verify_token 错"从 401 改为 400 后,前端拦截器也必须同步从条件中移除 400,否则 401 业务码仍会触发登出(因为后端对"旧密码错"原本就返回 401)。用户体验事故:输错旧密码即被踢出。 **🟡 BUG #2(中):"APP介绍" 入口是空壳** [profile.vue:130-134](frontend/pages/profile/profile.vue#L130-L134) 缺少 `@tap` 处理器,点击无任何反应。 **🟡 BUG #3(中):前端缺失密码规则校验** `confirmChangePassword` 只检查了非空,没有调用 `validator.js` 已有的 `validatePassword`,没有"旧=新"校验,没有"确认新密码"输入框。 ### 1.2 安全升级需求 将改密流程从"只校验旧密码"升级为"**旧密码 + 短信验证码**"双保险,符合用户对改密类操作的强安全预期(类比银行/支付 App)。 --- ## 2. 方案选型 ### 候选方案对比 | 方案 | 流程 | 后端改动 | 前端改动 | 评价 | |---|---|---|---|---| | **A. verify_token 模式(选用 ⭐)** | sendCode → verifyCode → updatePassword(old, new, verify_token) | proto 新增 verify_token 字段 + Service 头部校验 | 新增 4 个输入区 + 倒计时 | 与 Register 流程一致,复用 `VerifyToken` 一次性消费 | | B. 单接口自带 SMS 码 | sendCode → updatePassword(mobile, sms_code, old, new) | proto 新增 mobile/sms_code 字段 + Service 内部校验 | 同上 | 偏离现有模式,可扩展性差 | | C. 复用 register scene | 同 A | scene=register | 同上 | ❌ Redis key 污染,不可取 | **最终方案**: A + BUG 修复 + 单元测试。 --- ## 3. 整体流程 ### 3.1 序列图 ``` 用户 前端(profile.vue) Gateway userService Redis │ │ │ │ │ │ 1.点击"修改密码" │ │ │ │ ├───────────────────────────→│ │ │ │ │ │ 弹窗打开(5个UI区) │ │ │ │ │ │ │ │ │ 2.点"获取验证码" │ │ │ │ ├───────────────────────────→│ │ │ │ │ │ POST /auth/send-code │ │ │ │ │ {mobile, scene:"password"}│ │ │ │ ├─────────────────────────→│ ─Dubbo→ │ │ │ │ │ SendCode │ │ │ │ │ │ SaveSMSCode(60s) │ │ │ │ ├───────────────────→│ │ │ 200 OK │ │ │ │ │←─────────────────────────┤←─────────────────┤ │ │ │ 启动 60s 倒计时 │ │ │ │ │ │ │ │ │ 3.输入验证码+旧/新/确认密码 │ │ │ │ ├───────────────────────────→│ │ │ │ │ │ │ │ │ │ 4.点"确认" │ │ │ │ │ │ (本地校验5项) │ │ │ │ │ │ │ │ │ │ POST /auth/verify-code │ │ │ │ │ {mobile, code, scene} │ │ │ │ ├─────────────────────────→│ ─Dubbo→ │ │ │ │ │ VerifyCode │ DeleteSMSCode │ │ │ │ ├───────────────────→│ │ │ │ │ SaveVerifyToken(5m)│ │ │ │ ├───────────────────→│ │ │ 200 {verify_token} │ │ │ │ │←─────────────────────────┤←─────────────────┤ │ │ │ │ │ │ │ │ POST /account/password │ │ │ │ │ {old, new, verify_token} │ │ │ │ ├─────────────────────────→│ ─Dubbo→ │ │ │ │ │ UpdatePassword │ │ │ │ │ - GetByID(uid) │ │ │ │ │ - VerifyToken() │ GetVerifyToken │ ← 仅校验(Plan A) │ │ │ ├───────────────────→│ │ │ │ - bcrypt 比对 │ │ │ │ │ - 事务内更新 │ │ │ │ │ password_hash │ │ │ │ │ access_token=nil│ │ │ │ │ - ConsumeToken()│ DeleteVerifyToken │ ← 业务成功后原子消费(Lua) │ │ 200 OK │ │ │ │ │←─────────────────────────┤←─────────────────┤ │ │ │ 2s 后清本地+跳登录页 │ │ │ │ │ │ │ │ │ 5.重新登录 │ │ │ │ ├───────────────────────────→│ │ │ │ ``` ### 3.2 前端弹窗 UI 结构 ``` ┌──────────────────────────────────┐ │ 修改密码 │ ├──────────────────────────────────┤ │ [138****1234 ] [获取验证码 60s] │ ← 手机号只读 + 倒计时按钮 │ [短信验证码 ______ ] │ ← 6位数字 │ [旧密码 ____________ 👁] │ ← 小眼睛切换 │ [新密码 ____________ 👁] │ ← 至少6位 │ [确认新密码 ____________ 👁] │ ← 二次输入(防误输) │ │ │ [ 取消 ] [ 确认 ] │ └──────────────────────────────────┘ ``` --- ## 4. 后端改动 ### 4.1 proto 定义 **修改** [backend/proto/user.proto](backend/proto/user.proto) ```protobuf message UpdatePasswordRequest { string old_password = 1; // 旧密码 string new_password = 2; // 新密码 string verify_token = 3; // 短信验证 token(scene=password 下发的一次性 token) } ``` 重新生成 `user.pb.go` / `user.triple.go`(用 `gen-swagger.sh`)。 ### 4.2 SMS Redis key 重构 **修改** [backend/services/userService/service/sms_redis.go](backend/services/userService/service/sms_redis.go) 将硬编码的 `register` 改为基于 `scene` 动态拼: ```go const ( SMSCodeKeyPrefix = "sms:" // 后跟 scene SMSVerifyTokenPrefix = "verify:" // 后跟 scene SMSLimitMobilePrefix = "sms:limit:mobile:" ) func smsCodeKey(scene, mobile string) string { return SMSCodeKeyPrefix + scene + ":" + mobile } func verifyTokenKey(scene, mobile string) string { return SMSVerifyTokenPrefix + scene + ":" + mobile } ``` `SaveVerifyToken / GetVerifyToken / DeleteVerifyToken` 函数签名增加 `scene string` 参数,内部用新 key 函数。 `VerifyToken(ctx, mobile, token)`(定义在 [sms_service.go:247](backend/services/userService/service/sms_service.go#L247))同步改造为 `VerifyToken(ctx, scene, mobile, token)`,内部按 `verify:{scene}:{mobile}` 拼 key 做 **Get 校验**(不再做 Delete,删除逻辑下沉到新增的 `ConsumeVerifyToken`,详见 §4.2.1)。 ### 4.2.1 VerifyToken 语义重构(Plan A: Compare-And-Delete) **问题**:原 `VerifyToken` 是"调用即消费",业务失败(输错旧密码、密码太短、新旧相同等)也会消耗 token,用户每次都得重新发短信,UX 差。 **方案**:把 `VerifyToken` 拆成两个职责单一的函数: ```go // VerifyToken 仅校验 token 是否匹配(只读,无副作用),业务失败时 token 保留可重试 func VerifyToken(ctx context.Context, scene, mobile, token string) error { stored, err := GetVerifyToken(ctx, scene, mobile) if err != nil { return fmt.Errorf("failed to get verify token: %w", err) } if stored != token { return appErrors.ErrInvalidVerifyToken } return nil } // ConsumeVerifyToken 校验 + 原子删除(Lua 脚本保证 GET+COMPARE+DEL 原子性), // 仅在业务成功后调用,实现"成功才消费"的语义。 func ConsumeVerifyToken(ctx context.Context, scene, mobile, token string) error { // KEYS[1] = verify token key, ARGV[1] = expected token const luaScript = ` local current = redis.call('GET', KEYS[1]) if current == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0 ` key := verifyTokenKey(scene, mobile) result, err := redisClient.Eval(ctx, luaScript, []string{key}, token).Result() if err != nil { return fmt.Errorf("failed to consume verify token: %w", err) } if deleted, _ := result.(int64); deleted == 0 { return appErrors.ErrInvalidVerifyToken } return nil } ``` **为什么安全**: - **反重放仍然成立**:即使两个请求同时校验通过,Lua 脚本的 GET+COMPARE+DEL 是原子的,只有一个能成功 DEL,另一个收到 `ErrInvalidVerifyToken`(详见 §8 多标签页行)。 - **攻击门槛未降低**:攻击者必须**同时**拿到 verify_token(5 分钟 TTL)和旧密码(bcrypt)才能改密;业务校验仍由旧密码把关。 - **幂等性自愈**:若业务成功 + Consume 失败(Redis 抖动),密码已更新但 token 未删;前端重试时 `VerifyToken` 仍能通过、再次跑业务、再次 Consume —— 自愈 ✅。 **为什么 UX 更好**: - 旧密码输错 → 用户在弹窗内修正 → 直接点确认,**无需重新发短信**(`VerifyToken` 通过 → 业务重跑 → 成功 → Consume)。 - 新旧密码相同 / 新密码太短等本地已拦截,但若服务端校验失败亦同理可重试。 **Register 流程同步改造**:`auth_service.go` 的 `Register` 中原 `VerifyToken(ctx, req.Mobile, req.VerifyToken)` 拆为两步: 1. `VerifyToken(ctx, "register", mobile, token)` —— 校验(用户记录创建之前) 2. 用户记录 commit 成功后,调用 `ConsumeVerifyToken(ctx, "register", mobile, token)` —— 消费 这样 `Register` 在用户已存在 / 昵称冲突等竞态场景下,token 也能保留可重试,与改密行为一致。 **影响面**: - `sms_service.go` 中 `SendCode / VerifyCode` 加 `scene` 参数;`VerifyToken` 加 `scene` 参数但**语义改为只校验**(原 Get+Delete 拆为 VerifyToken + ConsumeVerifyToken) - `sms_redis.go` 新增 `ConsumeVerifyToken`(Lua 原子 GET+COMPARE+DEL);`VerifyToken` 改为只读 - `auth_service.go` 的 `Register` 中原 `VerifyToken(ctx, req.Mobile, req.VerifyToken)` 拆为两步:① 校验 `VerifyToken(ctx, "register", mobile, token)`;② 用户 commit 成功后 `ConsumeVerifyToken(ctx, "register", mobile, token)` - `user_service.go` 的 `UpdatePassword` 同样拆为两步(详见 §4.3):① 校验;② 事务成功后消费 - Register 仍用 `scene="register"`,改密用 `scene="password"`,两个 scene 的 Redis key 互不污染 - 老数据兼容:老的 `sms:register:*` / `verify:register:*` 在过渡期可保留(只要还有未过期的 token),过渡期后自然过期(60s/300s TTL) **Call-site 审计(实施前必跑)**: ```bash grep -rn "SaveVerifyToken\|GetVerifyToken\|DeleteVerifyToken\|VerifyToken(\|SaveSMSCode\|GetSMSCode\|VerifyCode(\|SendCode(" \ --include="*.go" backend/ ``` 涉及文件(已通过 grep 确认):9 个 —— `auth_service.go / sms_service.go / user.proto / user.pb.go / auth_provider.go / unified_provider.go / sms_redis.go / auth_controller.go / user.triple.go`。其中 `*.proto` / `*.pb.go` / `*.triple.go` 是 regen 自动产物,人工改动集中在 `auth_service.go / sms_service.go / auth_provider.go / auth_controller.go / unified_provider.go`。 ### 4.3 Service 层 UpdatePassword **修改** [backend/services/userService/service/user_service.go](backend/services/userService/service/user_service.go#L484-L587) ```go func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest, userID int64) (*pb.UpdatePasswordResponse, error) { // 0. (新增) 查询用户(后续需 user.Mobile 校验 verify_token) user, err := s.userRepo.GetByID(userID) if err != nil { if errors.Is(err, appErrors.ErrUserNotFound) { return nil, appErrors.ErrUserNotFound } return nil, fmt.Errorf("failed to get user: %w", err) } if req.VerifyToken == "" { return nil, appErrors.ErrInvalidVerifyToken } // (Plan A) 仅校验 token,不删除。业务失败时 token 保留可重试,用户无需重新发短信 // scene="password" 必须与前端 verifyCodeApi 调用时传的一致 // 注意:user_service.go 本身在 service 包,直接调 VerifyToken,不要加 service. 前缀 if err := VerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil { return nil, appErrors.ErrInvalidVerifyToken } // 1. 参数验证 if !validator.ValidateUserID(userID) { ... } if req.OldPassword == "" { return nil, appErrors.ErrInvalidOldPassword } valid, msg := validator.ValidatePassword(req.NewPassword) if !valid { if msg == "password too short" { return nil, appErrors.ErrPasswordTooShort } return nil, fmt.Errorf("invalid password: %s", msg) } // 2. (新增) 新旧密码一致性 if req.OldPassword == req.NewPassword { return nil, appErrors.ErrSameAsOldPassword } // 3. 验证旧密码(Plan A: 此处失败 token 仍保留,前端可重试无需重新发短信) if !s.userRepo.VerifyPassword(user, req.OldPassword) { return nil, appErrors.ErrInvalidOldPassword } // 4. 加密新密码 newPasswordHash, err := repository.HashPassword(req.NewPassword) if err != nil { ... } // 5. 事务内更新 err = s.db.Transaction(func(tx *gorm.DB) error { // 5.1 更新密码 hash 和 updated_at if err := tx.Model(user).Updates(map[string]interface{}{ "password_hash": newPasswordHash, "updated_at": time.Now().UnixMilli(), }).Error; err != nil { return fmt.Errorf("failed to update password: %w", err) } // 5.2 清除 access_token(强制重新登录,旧 token 失效) if err := tx.Model(user).Updates(map[string]interface{}{ "access_token": nil, "token_expires_at": nil, }).Error; err != nil { return fmt.Errorf("failed to clear token: %w", err) } return nil }) if err != nil { return nil, err } // 6. (Plan A) 业务成功后原子消费 verify_token(Lua GET+COMPARE+DEL) // Consume 失败(Redis 抖动)仅记日志,不返回错误: // - 业务已成功,密码已更新,access_token 已清空,符合预期 // - token 未删,用户重试时 VerifyToken 仍能通过、自愈(详见 §4.2.1) 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)", "user_id", userID, "err", err.Error()) } return &pb.UpdatePasswordResponse{}, nil } ``` **注意**:`GetByID` 提到第 0 步(原本在第 2 步),因为新逻辑需要 `user.Mobile` 来校验 verify_token。 ### 4.4 Provider 透传 ctx **修改** [backend/services/userService/provider/user_provider.go](backend/services/userService/provider/user_provider.go#L341-L388) ```go func (p *UserProvider) UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest) (*pb.UpdatePasswordResponse, error) { // ... (略) resp, err := p.userService.UpdatePassword(ctx, req, userID) // 传 ctx // ... } ``` ### 4.5 新增错误码 + ToStatusCode 改造 **修改** [backend/pkg/errors/errors.go](backend/pkg/errors/errors.go) **4.5.1 新增错误变量** ```go var ( ErrInvalidVerifyToken = errors.New("invalid verify token") // 新增 ErrSameAsOldPassword = errors.New("new password is same as old") // 新增 ErrInvalidOldPassword = errors.New("old password is incorrect") // 新增 ) ``` **4.5.2 `ToStatusCode()` 改造**:用 `errors.Is` 全面识别 wrapped error(为 §12.2 修复铺路),同步加 `ErrInvalidOldPassword` / `ErrUserInactive` / `ErrAccountFrozen` / `ErrAccountBanned` 等 case: ```go // 改造后(关键 case 完整列出,其他 case 沿用) func ToStatusCode(err error) pb.StatusCode { if err == nil { return pb.StatusCode_STATUS_OK } switch { case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrFanProfileNotFound), errors.Is(err, ErrStarNotFound): return pb.StatusCode_STATUS_NOT_FOUND case errors.Is(err, ErrUserAlreadyExists), errors.Is(err, ErrFanProfileAlreadyExists), errors.Is(err, ErrNicknameAlreadyExists), errors.Is(err, ErrInvalidMobile), errors.Is(err, ErrPasswordTooShort), errors.Is(err, ErrInvalidOldPassword), // ← 新增 ErrInvalidOldPassword errors.Is(err, ErrInvalidStarID), errors.Is(err, ErrInvalidUserID), errors.Is(err, ErrMaxIdentitiesReached), errors.Is(err, ErrInvalidNickname), errors.Is(err, ErrInvalidVerifyToken), // ← 新增:ErrInvalidVerifyToken -> 400 errors.Is(err, ErrSameAsOldPassword), // ← 新增 errors.Is(err, ErrCannotAddSelf), errors.Is(err, ErrCannotSearchSelf), // ... 其他 400 业务错误同前 return pb.StatusCode_STATUS_BAD_REQUEST case errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrInvalidToken), errors.Is(err, ErrTokenExpired), errors.Is(err, ErrTokenMismatch): return pb.StatusCode_STATUS_UNAUTHORIZED case errors.Is(err, ErrAccountFrozen), errors.Is(err, ErrAccountBanned), errors.Is(err, ErrUserInactive), // ← 新增 ErrUserInactive 修复(此前 default 500) errors.Is(err, ErrAssetAccessDenied), errors.Is(err, ErrMintOrderAccessDenied): return pb.StatusCode_STATUS_FORBIDDEN case errors.Is(err, ErrRequestInCooldown): return pb.StatusCode_STATUS_TOO_MANY_REQUESTS default: return pb.StatusCode_STATUS_INTERNAL_ERROR } } ``` **关键变化**: - `switch err` → `switch { case errors.Is(...) }`:所有 case 改用 `errors.Is`,使 `fmt.Errorf("%w: ...", ErrXxx, "extra")` 这种 wrapped error 也能正确分类 - 显式加入 `ErrInvalidOldPassword` (400) 和 `ErrInvalidVerifyToken` (400) 两个新 case - 显式加入 `ErrUserInactive` (403) 修复(此前 default 500) - `ErrAccountFrozen` / `ErrAccountBanned` 在 wrapped 形式下也能被识别(为 §12 修复铺路) **错误映射表(实施后)**: | 错误 | 旧映射 | 新映射 | 说明 | |---|---|---|---| | `ErrInvalidPassword`(Login 用) | 401 | 401 | 不变:登录场景下密码错 = 鉴权失败 | | `ErrInvalidOldPassword`(新,ChangePassword 用) | — | **400** | 改密场景下旧密码错 = 业务校验(用户已通过 AuthMiddleware 鉴权) | | `ErrPasswordTooShort` | 400 | 400 | 不变 | | `ErrInvalidVerifyToken`(新) | (旧方案 401) | **400** | 改密场景下短信 token 错 = 业务校验(用户已通过 AuthMiddleware 鉴权) | | `ErrSameAsOldPassword`(新) | 400 | 400 | 不变 | | `ErrUserInactive` | 500(实际落 default) | **403** | 修复:此前 ToStatusCode 没有 case 落到 default 500,本次显式映射 403;配合 §12 Login 流程一起修复 | | `ErrInvalidToken` / `ErrTokenExpired` / `ErrTokenMismatch` | 401 | 401 | 不变:真鉴权问题 | **关键设计原则**: - **401 业务码只用于"token 鉴权失败"**(用户登录态有问题,应登出) - **业务校验类错误(密码错、参数错)** 统一走 400 业务码(应 toast 让用户重试) - **403 业务码只用于"账号被禁用/冻结"**(应登出 + 显示封禁原因) - 用户已通过 AuthMiddleware 鉴权后,在已登录态下做的所有业务校验都应走 400,不踢出登录 ### 4.6 单元测试 **新增** `backend/services/userService/service/user_service_password_test.go` | # | 测试用例 | 输入 | 期望 | |---|---|---|---| | 1 | 正常路径 | 有效 token + 正确 old + 有效 new | 200,密码更新,token 通过 ConsumeVerifyToken 清空 | | 2 | verify_token 缺失 | `""` | `ErrInvalidVerifyToken` | | 3 | verify_token 错误 | 任意非法字符串 | `ErrInvalidVerifyToken` | | 4 | verify_token 过期 | mock `VerifyToken` 返回 expired 错误 | `ErrInvalidVerifyToken` | | 5 | 旧密码错误 | bcrypt 比对失败 | `ErrInvalidOldPassword` | | 6 | 新密码太短 | "abcde" (5位) | `ErrPasswordTooShort` | | 7 | 新旧密码相同 | old == new | `ErrSameAsOldPassword` | | 8 | 用户不存在 | 不存在的 userID | `ErrUserNotFound` | | 9 | Redis 故障 | mock `VerifyToken` error | wrap 后抛出 | | 10 | 事务回滚 | mock `tx.Updates` 失败 | 整事务回滚 | | 11 | **(Plan A) 旧密码错误后 token 保留可重试** | 第一次请求:有效 token + **错误** old + 有效 new;第二次请求:**同一 token** + 正确 old + 有效 new | 第一次返回 `ErrInvalidOldPassword` 且 token 仍在 Redis;第二次成功(无需重新发短信) | | 12 | **(Plan A) 业务失败时 token 未消费** | mock 事务提交失败 | 返回 wrap error,verify_token 仍在 Redis(下次重试可成功) | | 13 | **(Plan A) ConsumeVerifyToken 失败自愈** | mock `ConsumeVerifyToken` 返回 Redis error,业务前序步骤均成功 | 接口返回 200(业务已成功),日志记录 ERROR,token 保留 | Mock 方案: - `UserRepository`:用 `testify/mock` 包装,实现 `UserRepository` 接口,注入到 `userService` - `VerifyToken`(包级函数):用 package-level `var` 注入(标准 Go test 模式) ```go // sms_service.go(运行时) var VerifyTokenFn func(ctx context.Context, scene, mobile, token string) error = realVerifyToken func VerifyToken(ctx context.Context, scene, mobile, token string) error { return VerifyTokenFn(ctx, scene, mobile, token) } func realVerifyToken(ctx context.Context, scene, mobile, token string) error { // 实际 Get+DeleteVerifyToken 逻辑 } // user_service_password_test.go(测试时) func TestUpdatePassword_VerifyTokenExpired(t *testing.T) { orig := VerifyTokenFn defer func() { VerifyTokenFn = orig }() VerifyTokenFn = func(ctx context.Context, scene, mobile, token string) error { return errors.New("verify token expired") } // ... 断言 } ``` **Test #9 错误类型规约**:当 `VerifyToken` 自身出错(Redis 故障、key 拼错等)时: - `VerifyToken` 返回的原始 error → wrap 为 `fmt.Errorf("failed to verify token: %w", err)` → 在 Provider 层映射为 500 - 业务上的"token 不存在 / 已过期 / scene 不匹配" → 仍返回 `ErrInvalidVerifyToken` → **400**(已修订) - §7 错误码表中"其他 5xx"行即对应此场景,前端 toast 通用提示 --- ## 5. 前端改动 ### 5.1 BUG #1 修复:拦截器按状态码语义区分 **问题根因**:`api.js` 把业务码 400 也当成了"鉴权/封号"错误一并踢出登录,但 400 应是"业务参数错误"(密码错、参数错),不应登出。 **修改** [frontend/utils/api.js](frontend/utils/api.js#L101-L115) 把"业务码 400 也踢出登录"从条件里去掉: ```js // 改前(老代码,有问题) else if (res.data.code === 401 || res.data.code === 400 || res.data.code === 403) { // 清 token + 跳登录页 } // 改后 else if (res.data.code === 401 || res.data.code === 403) { // 只有 401(token 失效)或 403(账号被封)才清 token + 跳登录页 // 400 是业务校验错误(密码错、参数错、verify_token 错),reject 让调用方 toast uni.removeStorageSync('access_token') uni.removeStorageSync('user') const errorMsg = res.data.message || '登录已过期,请重新登录' uni.reLaunch({ url: '/pages/login/login?error=' + encodeURIComponent(errorMsg) }) reject(new Error(errorMsg)) return } ``` **为什么这个修复是根本性的(对比白名单方案)**: | 维度 | 白名单方案(已弃) | 状态码语义方案(采用 ✅) | |---|---|---| | 修复位置 | 前端拦截器 | 前端拦截器 + 后端错误码映射 | | 修复点数量 | 仅前端 1 处 | 前端 1 处 + 后端 1 处(`ToStatusCode` 改映射) | | 健壮性 | 任何新增的"业务 400"接口都要加白名单 | 任何业务 400 错误都自动正确处理 | | 状态码语义 | 401/400/403 都混合为"踢出登录" | 401=鉴权、403=封号、400=业务校验,语义清晰 | | 维护成本 | 高(新接口需加白名单) | 低(新接口自动正确) | | 与 REST 规范契合度 | 差 | 高 | **与后端协同**:本修复依赖后端 §4.5 的错误码映射修订 —— 旧密码错、verify_token 错必须返回业务码 400(而不是 401)。如果后端不改,前端单独改会导致:用户输错旧密码时,后端返回 401,前端仍然踢出登录,BUG #1 没修好。所以 §4.5 和 §5.1 必须**同步修改**。 ### 5.2 BUG #2 修复:补 APP介绍 handler **修改** [frontend/pages/profile/profile.vue](frontend/pages/profile/profile.vue#L130-L134) ```vue APP介绍 ``` ```js const handleShowAppIntro = () => { uni.showModal({ title: 'APP介绍', content: '顶粉APP是一款粉丝专属应用,集成了身份管理、藏品展示、社交互动等功能。\n(更多介绍功能开发中...)', showCancel: false, confirmText: '我知道了' }) } ``` ### 5.3 BUG #3 修复 + 弹窗结构升级 **修改** [frontend/pages/profile/profile.vue](frontend/pages/profile/profile.vue) #### 5.3.1 弹窗模板 替换原 [profile.vue:159-184](frontend/pages/profile/profile.vue#L159-L184) 的 password-modal: ```vue 修改密码 {{ showOldPassword ? '👁️' : '👁️‍🗨️' }} {{ showNewPassword ? '👁️' : '👁️‍🗨️' }} {{ showConfirmPassword ? '👁️' : '👁️‍🗨️' }} ``` #### 5.3.2 状态变量(替换原 refs) ```js // 旧密码 const oldPassword = ref('') const showOldPassword = ref(false) // 新密码 + 确认 const newPassword = ref('') const confirmPassword = ref('') const showNewPassword = ref(false) const showConfirmPassword = ref(false) // 短信 const smsCode = ref('') const sendingCode = ref(false) const codeCountdown = ref(0) const codeCountdownTimer = ref(null) // 新增,用于清理 setInterval // 防重 const changingPassword = ref(false) ``` #### 5.3.3 三个核心 handler ```js // 1. 发送验证码 const handleSendCode = async () => { if (sendingCode.value || codeCountdown.value > 0) return if (!mobile.value) { return uni.showToast({ title: '未获取到手机号', icon: 'none' }) } try { sendingCode.value = true const res = await sendCodeApi(mobile.value, '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 } } // 2. 关闭弹窗(清空所有,包括倒计时 interval) const closePasswordModal = () => { showPasswordModal.value = false oldPassword.value = '' newPassword.value = '' confirmPassword.value = '' smsCode.value = '' showOldPassword.value = false showNewPassword.value = false showConfirmPassword.value = false codeCountdown.value = 0 if (codeCountdownTimer.value) { clearInterval(codeCountdownTimer.value) codeCountdownTimer.value = null } } // 3. 确认改密 const confirmChangePassword = async () => { // 本地校验 if (!smsCode.value.match(/^\d{6}$/)) { return uni.showToast({ title: '请输入6位短信验证码', icon: 'none' }) } if (!oldPassword.value.trim()) { 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 (changingPassword.value) return changingPassword.value = true uni.showLoading({ title: '修改中...', mask: true }) try { // Step 1: verify SMS code → verify_token const vRes = await verifyCodeApi(mobile.value, 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 // 业务码 400(密码错、verify token 错等)会由 api.js 拦截器 reject, // 跳到 catch 分支;成功(业务码 200)进入此分支 const res = await updatePasswordApi( oldPassword.value, newPassword.value, verifyToken ) uni.hideLoading() // res 此时已是 res.data(被 request() resolve 过的) if (res.code === 200) { uni.showToast({ title: '修改成功,请重新登录', icon: 'success' }) setTimeout(() => { // 改密后清空所有登录态 + 注册临时数据 // 备注:store 的 'user/logout' 实际只 commit CLEAR_AUTH(清 access_token/user/star_id/login_mobile/gallery_owner_id), // 不会清 temp_register_*,所以这里需要手动清;后续若 store 升级,本处可同步收敛 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' }) }, 2000) } // 业务码 400/500 已被 api.js 拦截器 reject 跳到 catch 块,这里无 else } catch (e) { uni.hideLoading() uni.showToast({ title: e.message || '修改失败,请重试', icon: 'none' }) changingPassword.value = false } } ``` #### 5.3.4 新增 import ```js // 已有 sendCodeApi(mobile, scene) / verifyCodeApi(mobile, code, scene),支持任意 scene 字符串 // 已有 updatePasswordApi,本节中扩展为 3 参数(老调用点需同步更新) import { sendCodeApi, verifyCodeApi, updatePasswordApi, ... } from '@/utils/api' import { validatePassword } from '@/utils/validator.js' // 已有 ``` **API 函数兼容性确认**: - `sendCodeApi(mobile, scene)`:见 [api.js:187-196](frontend/utils/api.js#L187-L196),已支持任意 `scene` 字符串(后端在 SendCode 中按 scene 路由),无需改动 - `verifyCodeApi(mobile, code, scene)`:见 [api.js:199-209](frontend/utils/api.js#L199-L209),已支持任意 `scene`,无需改动 - `updatePasswordApi(oldPassword, newPassword, verifyToken)`:**新签名**,§5.3.5 中给出实现 **`updatePasswordApi` 调用点审计**(实施前必跑): ```bash grep -rn "updatePasswordApi" --include="*.vue" --include="*.js" frontend/ ``` 经 grep 确认:仅 2 个调用点 —— `frontend/utils/api.js`(定义处)+ `frontend/pages/profile/profile.vue`(使用处)。本 PR 内一并更新即可,无连锁影响。 #### 5.3.5 API 函数扩展 [frontend/utils/api.js](frontend/utils/api.js#L281-L291) ```js export function updatePasswordApi(oldPassword, newPassword, verifyToken) { return request({ url: '/api/v1/account/password', method: 'POST', data: { old_password: oldPassword, new_password: newPassword, verify_token: verifyToken, } }) } ``` ### 5.4 新增 CSS ```css .modal-row { display: flex; gap: 20rpx; margin-bottom: 40rpx; } .modal-input.readonly { background: #f5f5f5; color: #999; flex: 1; height: 88rpx; border-radius: 20rpx; padding: 0 30rpx; font-size: 32rpx; } .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; } .btn-get-code:disabled { opacity: 0.6; } .btn-get-code::after { border: none; } ``` --- ## 6. 测试策略 ### 6.1 后端单测(新增 `user_service_password_test.go`) 见 §4.6 表格。 ### 6.2 前端手动验证 checklist | # | 场景 | 预期 | |---|---|---| | 1 | 弹窗打开,字段全空,点确认 | 提示"请输入6位短信验证码" | | 2 | 输错旧密码(其他字段都对) | 后端 400(§4.5 修订后),弹 toast "旧密码错误",**不跳登录** | | 3 | 新密码 5 位 | 本地拒绝,提示"密码至少为6位" | | 4 | 新密码 ≠ 确认密码 | 提示"两次输入不一致" | | 5 | 短信码输错 | 后端拒绝,弹 toast,不跳登录 | | 6 | 60s 内连点"获取验证码" | 第二次按钮 disabled,显示倒计时 | | 7 | 改密成功 | toast + 2s 后清 token + 跳登录页 | | 8 | 弱网超时 | toast "网络异常",弹窗保留 | | 9 | 关闭弹窗再开 | 所有字段已清空,倒计时已重置 | | 10 | APP介绍 入口 | 弹模态框 "功能开发中" 提示 | --- ## 7. 错误码映射 | 后端错误 | proto 状态码 | 前端 toast | 是否触发自动登出 | |---|---|---|---| | `ErrInvalidOldPassword`(新,旧密码错) | **400** | 旧密码错误 | ❌ 否(toast 让用户重输) | | `ErrInvalidVerifyToken`(新) | **400** | 短信验证码无效或已过期 | ❌ 否 | | `ErrPasswordTooShort` | 400 | 新密码至少6位 | ❌ 否 | | `ErrSameAsOldPassword`(新) | 400 | 新密码不能与旧密码相同 | ❌ 否 | | `ErrUserNotFound` | 404 | 用户不存在 | ❌ 否 | | `ErrUserInactive` | 403 | 账号已被禁用 | ✅ **是** | | `ErrAccountFrozen` | 403 | 账号已被冻结 | ✅ **是** | | `ErrAccountBanned` | 403 | 账号已被封禁 | ✅ **是** | | `ErrInvalidToken` / `ErrTokenExpired` | 401 | 登录已过期 | ✅ **是**(从 AuthMiddleware 返回) | | 其他 5xx | 500 | 修改失败,请稍后重试 | ❌ 否 | **统一规则**: - 业务码 401 / 403 → 自动登出(分别:token 失效 / 账号被封) - 业务码 400 / 404 / 5xx → toast 提示,不登出 - 实现阶段必须保证后端 `err.Error()` 文案稳定并与本表格一致,前端只信任 `res.message` 字段做 toast --- ## 8. 异常路径与降级 | 场景 | 行为 | |---|---| | Redis 不可用 | `VerifyToken` 报错 → 改密接口返回 500 → 前端 toast,弹窗保留 | | 短信发送失败(>10次/小时) | 后端返回 429 → 前端 toast "发送过于频繁" | | 用户 token 已过期 | AuthMiddleware 拦截 → 前端拦截器跳登录(合理) | | 多标签页同时改密 | 第一个标签业务成功后 `ConsumeVerifyToken` 删 token;第二个标签的 `VerifyToken` 仍能通过,但 `ConsumeVerifyToken` 发现 token 已被删 → 失败(防重放 ✅) | | 旧密码输错 → 修正重试 | **Plan A**:`VerifyToken` 只校验不删,token 保留在 Redis;用户修正旧密码再次提交,`VerifyToken` 仍能通过、业务重跑、最终成功,**无需重新发短信** | --- ## 9. 安全考量 | 项 | 措施 | |---|---| | 鉴权强制 | 路由 `/api/v1/account/password` 已挂 `AuthMiddleware`(见 [router.go:185-189](backend/gateway/router/router.go#L185-L189)),token 无效直接 401,无需在 Service 重复校验 userID 来源 | | 短信 token 一次性(Plan A) | `VerifyToken` 仅校验不删除;`ConsumeVerifyToken`(Lua 原子 GET+COMPARE+DEL)在业务成功后调用;**业务失败时 token 保留**,用户可重试无需重新发短信;**Consume 失败(Redis 抖动)自愈**:业务已成功,密码已更新,token 未删,下次重试仍可成功 | | 短信 token TTL | 5 分钟(与 Register 一致) | | 限流 | 复用 `sms:limit:mobile:` 计数器,scene=password | | 旧密码明文只传不存 | bcrypt(成本因子 10) | | 改密后清 token | 事务内 `access_token = nil` | | updated_at 同步更新 | 与 token 校验机制一致,旧 token 失效 | | 不暴露 mobile 是否存在 | 短信发送成功/失败统一 200(继承 Register 的 anti-enumeration 行为) | --- ## 10. 文件变更总览 | 层 | 变更 | 文件 | |---|---|---| | Proto | 修改 | `backend/proto/user.proto` | | Proto | regen | `backend/pkg/proto/user/user.pb.go` | | Proto | regen | `backend/pkg/proto/user/user.triple.go` | | Service | 修改 | `backend/services/userService/service/sms_redis.go` | | Service | 修改 | `backend/services/userService/service/sms_service.go` | | Service | 修改 | `backend/services/userService/service/user_service.go` | | Service | 修改 | `backend/services/userService/service/auth_service.go`(Register 中 `VerifyToken(ctx, mobile, token)` → `VerifyToken(ctx, "register", mobile, token)`,共 1 处;§12 修复 Login 流程 2 处 `return` 改用 typed error) | | Service | 新增 | `backend/services/userService/service/user_service_password_test.go`(§4.6 10 个用例) | | Service | 新增 | `backend/services/userService/service/auth_service_login_test.go`(§12.4 2 个用例,banned/frozen 返 403) | | Provider | 修改 | `backend/services/userService/provider/user_provider.go`(传 ctx) | | Errors | 修改 | `backend/pkg/errors/errors.go`(新增 3 个错误码 `ErrInvalidVerifyToken`/`ErrSameAsOldPassword`/`ErrInvalidOldPassword`;`ToStatusCode` 中 `ErrInvalidVerifyToken` 改映射 400;`ToStatusCode` 改用 `errors.Is` 全面识别 wrapped error;新增 `NewAccountBannedError` / `NewAccountFrozenError` 2 个 helper) | | Frontend utils | 修改 | `frontend/utils/api.js`(拦截器去掉 400 自动登出,保留 401/403 + `updatePasswordApi` 增 `verify_token` 字段) | | Frontend pages | 修改 | `frontend/pages/profile/profile.vue`(弹窗 + 状态 + handler + CSS) | --- ## 11. 部署/上线检查清单 - [ ] proto 重新生成后,所有引用 `UpdatePasswordRequest` 的代码编译通过 - [ ] Redis key 升级:老的 `sms:register:*` / `verify:register:*` 数据保留(Register 仍用 scene=register);新加的 `sms:password:*` / `verify:password:*` 独立 - [ ] **API 接口回归**: Login / Register / RefreshToken / Logout / GetMyProfile / UpdateNickname 等所有走 `ToStatusCode` 的接口,在 `errors.Is` 改造后行为不变(单测 + 灰度验证) - [ ] **登录态回归**: 在修复 BUG #1(拦截器去掉 400 自动登出)后,跑一遍以下场景确认无回归: - 输错密码登录(Login 接口返 401)→ 前端拦截器跳登录 ✅ - 账号被冻结登录(§12 修复后应返 403,而非 500)→ 弹封禁原因 + 跳登录 ✅ - 业务校验错(改密输错旧密码,本次返 400)→ toast 提示,不跳登录 ✅ - [ ] 后端单测全绿(含 §4.6 10 个 + §12.4 2 个) - [ ] 前端手动验证 10 条 checklist 通过 - [ ] Swagger 文档重新生成 - [ ] 灰度发布,先开 10% 流量观察错误率,重点监控 401/403/500 比例(应:401 不变,403 上升,500 下降) --- ## 12. 关联修复:Login 流程账号状态码 BUG(顺带修) ### 12.1 问题 §7 设计的统一规则是"`ErrAccountFrozen` / `ErrAccountBanned` → 403 → 自动登出"。但实际 Login 流程并未使用这两个 typed error,导致: [auth_service.go:327-377](backend/services/userService/service/auth_service.go#L327-L377) 在 banned / frozen 情况下: ```go if accountStatus.IsBanned() { return nil, fmt.Errorf("账号已被封禁%s%s", ...) // ← 不是 ErrAccountBanned } if accountStatus.IsFrozen() { return nil, errors.New(errMsg) // ← 不是 ErrAccountFrozen } ``` 这两个 error 都是 generic 的,**不匹配 `ToStatusCode()` 中 `case ErrAccountFrozen, ErrAccountBanned:` 分支**,落到 `default` → `STATUS_INTERNAL_ERROR` (500)。 **实际行为**:用户账号被冻结/封禁后尝试登录,后端返回 500(而非 403),前端按 §5.1 规则不会自动登出,只 toast 一个"修改失败"类的提示,体验不一致。 ### 12.2 修复 **修改** [backend/services/userService/service/auth_service.go](backend/services/userService/service/auth_service.go#L327-L377) 引入两个新的辅助函数,把 reason / frozenUntil 信息塞进 error message 但保留 typed error 身份: ```go // 新增到 errors.go // NewAccountBannedError returns ErrAccountBanned with reason wrapped into message func NewAccountBannedError(reason string) error { if reason != "" { return fmt.Errorf("%w: %s", ErrAccountBanned, reason) } return ErrAccountBanned } // NewAccountFrozenError returns ErrAccountFrozen with reason + frozenUntil wrapped into message 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 } ``` **关键技巧**:`fmt.Errorf("%w: ...", ErrAccountBanned, ...)` 用 `%w` 保留 wrap 关系,使 `errors.Is(err, ErrAccountBanned)` 仍能识别。同时 `ToStatusCode()` 的 `switch err` 不识别 wrapped error,所以需要扩展 `ToStatusCode()` 用 `errors.Is`: ```go // errors.go func ToStatusCode(err error) pb.StatusCode { if err == nil { return pb.StatusCode_STATUS_OK } switch { case errors.Is(err, ErrUserNotFound), errors.Is(err, ErrFanProfileNotFound), errors.Is(err, ErrStarNotFound): return pb.StatusCode_STATUS_NOT_FOUND case errors.Is(err, ErrUserAlreadyExists), errors.Is(err, ErrFanProfileAlreadyExists), errors.Is(err, ErrNicknameAlreadyExists): return pb.StatusCode_STATUS_BAD_REQUEST case errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrInvalidToken), errors.Is(err, ErrTokenExpired), errors.Is(err, ErrTokenMismatch): return pb.StatusCode_STATUS_UNAUTHORIZED case errors.Is(err, ErrAccountFrozen), errors.Is(err, ErrAccountBanned): return pb.StatusCode_STATUS_FORBIDDEN // ... 后续同样改用 errors.Is default: return pb.StatusCode_STATUS_INTERNAL_ERROR } } ``` **auth_service.go Login 流程**改造: ```go if accountStatus.IsBanned() { reason := "" if accountStatus.Reason != nil { reason = *accountStatus.Reason } logger.Logger.Warn("User account is banned", ...) return nil, appErrors.NewAccountBannedError(reason) // ← 改用 typed error } if accountStatus.IsFrozen() { if accountStatus.FrozenUntil != nil && time.Now().UnixMilli() > *accountStatus.FrozenUntil { // 冻结已过期,放行 logger.Logger.Info("User account frozen but expired", ...) } else { reason := "" if accountStatus.Reason != nil { reason = *accountStatus.Reason } logger.Logger.Warn("User account is frozen", ...) return nil, appErrors.NewAccountFrozenError(reason, accountStatus.FrozenUntil) // ← 改用 typed error } } ``` ### 12.3 影响面 | 项 | 变化 | |---|---| | `errors.go` | 新增 `NewAccountBannedError` / `NewAccountFrozenError` 2 个 helper;`ToStatusCode` 改用 `errors.Is` 全面识别 wrapped error(1 处 switch 大改造) | | `auth_service.go` | Login 流程的 2 处 `return` 改用 helper | | 其他服务 | `ToStatusCode` 改用 `errors.Is` 后,其他 service 中已有的 `fmt.Errorf("%w", ErrXxx)` 自动受益,**无需逐个改动** | | 前端 | 无需改动:403 业务码已在 §5.1 拦截器白名单中被正确处理(自动登出 + 跳登录页) | ### 12.4 单测追加 在 `auth_service_login_test.go`(新建,或追加到现有 test)加 2 个用例: | # | 场景 | 期望 | |---|---|---| | 1 | 账号被封禁 | 业务码 403(不再是 500),`errors.Is(err, ErrAccountBanned)` 为 true | | 2 | 账号被冻结 | 业务码 403(不再是 500),`errors.Is(err, ErrAccountFrozen)` 为 true | ### 12.5 与"状态码重构"的关系 本次修复是**最小变更**,仅让 Login 流程复用现有的 `ErrAccountFrozen` / `ErrAccountBanned` 语义,业务码到 HTTP 码的映射规则不变。 配套的状态码体系重构(已 Approved)见 [2026-06-12-status-code-refactor-design.md](../specs/2026-06-12-status-code-refactor-design.md),本次修复的 `errors.Is` 改造会作为重构的前置工作存在,可以平滑迁移(重构的 `ToStatusCode` 函数直接受益于 `errors.Is` 模式)。