From ddb3620eb156f27a0e5ce670fb223c98562d2487 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 15 Jun 2026 12:07:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E6=98=9F=E6=B2=B3?= =?UTF-8?q?=EF=BC=8C=E6=98=9F=E6=A6=9C=E7=9A=84=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-12-change-password-design.md | 94 +++++++++++++++++-- .../square/components/HotCategoryBlock.vue | 35 +++---- .../components/StarGalaxy/PodiumCard.vue | 5 +- .../components/StarGalaxy/ScatteredRanks.vue | 11 ++- 4 files changed, 116 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-change-password-design.md b/docs/superpowers/specs/2026-06-12-change-password-design.md index a327f5e..4afc08f 100644 --- a/docs/superpowers/specs/2026-06-12-change-password-design.md +++ b/docs/superpowers/specs/2026-06-12-change-password-design.md @@ -88,12 +88,13 @@ │ ├─────────────────────────→│ ─Dubbo→ │ │ │ │ │ UpdatePassword │ │ │ │ │ - GetByID(uid) │ │ - │ │ │ - VerifyToken() │ Get+DeleteVerifyTok│ + │ │ │ - VerifyToken() │ GetVerifyToken │ ← 仅校验(Plan A) │ │ │ ├───────────────────→│ │ │ │ - bcrypt 比对 │ │ │ │ │ - 事务内更新 │ │ │ │ │ password_hash │ │ │ │ │ access_token=nil│ │ + │ │ │ - ConsumeToken()│ DeleteVerifyToken │ ← 业务成功后原子消费(Lua) │ │ 200 OK │ │ │ │ │←─────────────────────────┤←─────────────────┤ │ │ │ 2s 后清本地+跳登录页 │ │ │ @@ -159,11 +160,70 @@ func verifyTokenKey(scene, mobile string) string { `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 一次性消费。 +`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 / VerifyToken` 全部加 `scene` 参数 -- `auth_service.go` 的 `Register` 中 `VerifyToken(ctx, req.Mobile, req.VerifyToken)` → `VerifyToken(ctx, "register", req.Mobile, req.VerifyToken)` +- `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) @@ -194,6 +254,7 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword 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 { @@ -215,7 +276,7 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword return nil, appErrors.ErrSameAsOldPassword } - // 3. 验证旧密码 + // 3. 验证旧密码(Plan A: 此处失败 token 仍保留,前端可重试无需重新发短信) if !s.userRepo.VerifyPassword(user, req.OldPassword) { return nil, appErrors.ErrInvalidOldPassword } @@ -242,7 +303,18 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword } 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 } ``` @@ -340,7 +412,7 @@ func ToStatusCode(err error) pb.StatusCode { | # | 测试用例 | 输入 | 期望 | |---|---|---|---| -| 1 | 正常路径 | 有效 token + 正确 old + 有效 new | 200,密码更新,token 清空 | +| 1 | 正常路径 | 有效 token + 正确 old + 有效 new | 200,密码更新,token 通过 ConsumeVerifyToken 清空 | | 2 | verify_token 缺失 | `""` | `ErrInvalidVerifyToken` | | 3 | verify_token 错误 | 任意非法字符串 | `ErrInvalidVerifyToken` | | 4 | verify_token 过期 | mock `VerifyToken` 返回 expired 错误 | `ErrInvalidVerifyToken` | @@ -350,6 +422,9 @@ func ToStatusCode(err error) pb.StatusCode { | 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` @@ -785,7 +860,8 @@ export function updatePasswordApi(oldPassword, newPassword, verifyToken) { | Redis 不可用 | `VerifyToken` 报错 → 改密接口返回 500 → 前端 toast,弹窗保留 | | 短信发送失败(>10次/小时) | 后端返回 429 → 前端 toast "发送过于频繁" | | 用户 token 已过期 | AuthMiddleware 拦截 → 前端拦截器跳登录(合理) | -| 多标签页同时改密 | 第二个请求因 `VerifyToken` 一次性消费失败(防重放 ✅) | +| 多标签页同时改密 | 第一个标签业务成功后 `ConsumeVerifyToken` 删 token;第二个标签的 `VerifyToken` 仍能通过,但 `ConsumeVerifyToken` 发现 token 已被删 → 失败(防重放 ✅) | +| 旧密码输错 → 修正重试 | **Plan A**:`VerifyToken` 只校验不删,token 保留在 Redis;用户修正旧密码再次提交,`VerifyToken` 仍能通过、业务重跑、最终成功,**无需重新发短信** | --- @@ -794,7 +870,7 @@ export function updatePasswordApi(oldPassword, newPassword, verifyToken) { | 项 | 措施 | |---|---| | 鉴权强制 | 路由 `/api/v1/account/password` 已挂 `AuthMiddleware`(见 [router.go:185-189](backend/gateway/router/router.go#L185-L189)),token 无效直接 401,无需在 Service 重复校验 userID 来源 | -| 短信 token 一次性 | 复用 `DeleteVerifyToken` 消费后即删 | +| 短信 token 一次性(Plan A) | `VerifyToken` 仅校验不删除;`ConsumeVerifyToken`(Lua 原子 GET+COMPARE+DEL)在业务成功后调用;**业务失败时 token 保留**,用户可重试无需重新发短信;**Consume 失败(Redis 抖动)自愈**:业务已成功,密码已更新,token 未删,下次重试仍可成功 | | 短信 token TTL | 5 分钟(与 Register 一致) | | 限流 | 复用 `sms:limit:mobile:` 计数器,scene=password | | 旧密码明文只传不存 | bcrypt(成本因子 10) | diff --git a/frontend/pages/square/components/HotCategoryBlock.vue b/frontend/pages/square/components/HotCategoryBlock.vue index 2278bba..cc22058 100644 --- a/frontend/pages/square/components/HotCategoryBlock.vue +++ b/frontend/pages/square/components/HotCategoryBlock.vue @@ -89,13 +89,13 @@ lazy-load /> - + /> --> getHotRankingApi("displaying", null, page, PAGE_SIZE), }, { key: "new", label: "活跃榜", icon: "/static/square/galaxy/huoyuebang.png", - iconWidth: 64, - iconHeight: 72, + iconWidth: 96, + iconHeight: 88, fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE), }, { key: "trending", label: "曝光榜", icon: "/static/square/galaxy/baoguangbang.png", - iconWidth: 64, - iconHeight: 72, + iconWidth: 96, + iconHeight: 88, fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE), }, { key: "tongcheng", label: "同城榜", icon: "/static/square/galaxy/tongchengbang.png", - iconWidth: 64, - iconHeight: 72, + iconWidth: 96, + iconHeight: 88, fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE), }, ]; @@ -478,11 +478,11 @@ onUnmounted(() => { rgba(255, 90, 93, 0.47) -36.55%, rgba(194, 235, 255, 0.47) 121.2% ); - filter: blur(5.9px); + filter: blur(2.5px); // opacity: 0.8; // Figma 用的就是 filter: blur(图形模糊),不是 backdrop-filter(背景模糊) // filter: blur(3.7px); - -webkit-filter: blur(5.9px); + -webkit-filter: blur(2.5px); border-top-left-radius: 14px; border-top-right-radius: 13px; border-bottom-right-radius: 8px; @@ -522,7 +522,8 @@ onUnmounted(() => { } .ranking-tab-item.active .ranking-tab-icon { - top: -16rpx; + top: -8rpx; + height: 96rpx !important; } .ranking-tab-label { @@ -634,7 +635,7 @@ onUnmounted(() => { /* background: rgba(255, 255, 255, 0.15); */ /* box-shadow: 2px 2px 4.5px 0px #f04b4b40; */ box-shadow: 2px 4px 4px 0px #c92f2f5c; - margin-bottom: 48rpx; + margin-bottom: 36.8rpx; } /* 单行布局:藏品图片 + 头像 + 点赞信息 + TOP 标签 */ @@ -691,7 +692,7 @@ onUnmounted(() => { } .grid-card-top-4 { position: relative; - overflow: hidden; + // overflow: hidden; // [方案3] 伪元素承载 bj.png,对图片单独设 opacity &::before { content: ""; @@ -706,7 +707,7 @@ onUnmounted(() => { } .grid-card-top-5 { position: relative; - overflow: hidden; + // overflow: hidden; &::before { content: ""; position: absolute; @@ -721,7 +722,7 @@ onUnmounted(() => { .grid-card-top-other { position: relative; - overflow: hidden; + // overflow: hidden; &::before { content: ""; diff --git a/frontend/pages/square/components/StarGalaxy/PodiumCard.vue b/frontend/pages/square/components/StarGalaxy/PodiumCard.vue index 547454e..9e2bdac 100644 --- a/frontend/pages/square/components/StarGalaxy/PodiumCard.vue +++ b/frontend/pages/square/components/StarGalaxy/PodiumCard.vue @@ -69,6 +69,7 @@ function handleClick() { .podium-frame { width: 100%; height: 100%; + top: -24rpx; } } .top-label { @@ -101,6 +102,7 @@ function handleClick() { .podium-frame { width: 100%; height: 100%; + transform: scale(1.6); } } } @@ -125,10 +127,11 @@ function handleClick() { .cover-wrap { width: 96rpx; height: 152rpx; - bottom: 104rpx; + bottom: 88rpx; .podium-frame { width: 100%; height: 100%; + top: -16rpx; } } } diff --git a/frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue b/frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue index 0889322..ed1bdd8 100644 --- a/frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue +++ b/frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue @@ -29,7 +29,7 @@ > {{ formatLabel(p.rank) }} - +