feat: 修改星河,星榜的样式

This commit is contained in:
zerosaturation 2026-06-15 12:07:40 +08:00
parent 714d71e2d8
commit ddb3620eb1
4 changed files with 116 additions and 29 deletions

View File

@ -88,12 +88,13 @@
│ ├─────────────────────────→│ ─Dubbo→ │ │ │ ├─────────────────────────→│ ─Dubbo→ │ │
│ │ │ UpdatePassword │ │ │ │ │ UpdatePassword │ │
│ │ │ - GetByID(uid) │ │ │ │ │ - GetByID(uid) │ │
│ │ │ - VerifyToken() │ Get+DeleteVerifyTok│ │ │ │ - VerifyToken() │ GetVerifyToken ← 仅校验(Plan A)
│ │ │ ├───────────────────→│ │ │ │ ├───────────────────→│
│ │ │ - bcrypt 比对 │ │ │ │ │ - bcrypt 比对 │ │
│ │ │ - 事务内更新 │ │ │ │ │ - 事务内更新 │ │
│ │ │ password_hash │ │ │ │ │ password_hash │ │
│ │ │ access_token=nil│ │ │ │ │ access_token=nil│ │
│ │ │ - ConsumeToken()│ DeleteVerifyToken │ ← 业务成功后原子消费(Lua)
│ │ 200 OK │ │ │ │ │ 200 OK │ │ │
│ │←─────────────────────────┤←─────────────────┤ │ │ │←─────────────────────────┤←─────────────────┤ │
│ │ 2s 后清本地+跳登录页 │ │ │ │ │ 2s 后清本地+跳登录页 │ │ │
@ -159,11 +160,70 @@ func verifyTokenKey(scene, mobile string) string {
`SaveVerifyToken / GetVerifyToken / DeleteVerifyToken` 函数签名增加 `scene string` 参数,内部用新 key 函数。 `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` 参数 - `sms_service.go``SendCode / VerifyCode``scene` 参数;`VerifyToken` 加 `scene` 参数但**语义改为只校验**(原 Get+Delete 拆为 VerifyToken + ConsumeVerifyToken)
- `auth_service.go``Register``VerifyToken(ctx, req.Mobile, req.VerifyToken)``VerifyToken(ctx, "register", req.Mobile, req.VerifyToken)` - `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 互不污染 - Register 仍用 `scene="register"`,改密用 `scene="password"`,两个 scene 的 Redis key 互不污染
- 老数据兼容:老的 `sms:register:*` / `verify:register:*` 在过渡期可保留(只要还有未过期的 token),过渡期后自然过期(60s/300s TTL) - 老数据兼容:老的 `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 == "" { if req.VerifyToken == "" {
return nil, appErrors.ErrInvalidVerifyToken return nil, appErrors.ErrInvalidVerifyToken
} }
// (Plan A) 仅校验 token,不删除。业务失败时 token 保留可重试,用户无需重新发短信
// scene="password" 必须与前端 verifyCodeApi 调用时传的一致 // scene="password" 必须与前端 verifyCodeApi 调用时传的一致
// 注意:user_service.go 本身在 service 包,直接调 VerifyToken,不要加 service. 前缀 // 注意:user_service.go 本身在 service 包,直接调 VerifyToken,不要加 service. 前缀
if err := VerifyToken(ctx, "password", user.Mobile, req.VerifyToken); err != nil { 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 return nil, appErrors.ErrSameAsOldPassword
} }
// 3. 验证旧密码 // 3. 验证旧密码(Plan A: 此处失败 token 仍保留,前端可重试无需重新发短信)
if !s.userRepo.VerifyPassword(user, req.OldPassword) { if !s.userRepo.VerifyPassword(user, req.OldPassword) {
return nil, appErrors.ErrInvalidOldPassword return nil, appErrors.ErrInvalidOldPassword
} }
@ -242,7 +303,18 @@ func (s *userService) UpdatePassword(ctx context.Context, req *pb.UpdatePassword
} }
return nil 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` | | 2 | verify_token 缺失 | `""` | `ErrInvalidVerifyToken` |
| 3 | verify_token 错误 | 任意非法字符串 | `ErrInvalidVerifyToken` | | 3 | verify_token 错误 | 任意非法字符串 | `ErrInvalidVerifyToken` |
| 4 | verify_token 过期 | mock `VerifyToken` 返回 expired 错误 | `ErrInvalidVerifyToken` | | 4 | verify_token 过期 | mock `VerifyToken` 返回 expired 错误 | `ErrInvalidVerifyToken` |
@ -350,6 +422,9 @@ func ToStatusCode(err error) pb.StatusCode {
| 8 | 用户不存在 | 不存在的 userID | `ErrUserNotFound` | | 8 | 用户不存在 | 不存在的 userID | `ErrUserNotFound` |
| 9 | Redis 故障 | mock `VerifyToken` error | wrap 后抛出 | | 9 | Redis 故障 | mock `VerifyToken` error | wrap 后抛出 |
| 10 | 事务回滚 | mock `tx.Updates` 失败 | 整事务回滚 | | 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 方案: Mock 方案:
- `UserRepository`:用 `testify/mock` 包装,实现 `UserRepository` 接口,注入到 `userService` - `UserRepository`:用 `testify/mock` 包装,实现 `UserRepository` 接口,注入到 `userService`
@ -785,7 +860,8 @@ export function updatePasswordApi(oldPassword, newPassword, verifyToken) {
| Redis 不可用 | `VerifyToken` 报错 → 改密接口返回 500 → 前端 toast,弹窗保留 | | Redis 不可用 | `VerifyToken` 报错 → 改密接口返回 500 → 前端 toast,弹窗保留 |
| 短信发送失败(>10次/小时) | 后端返回 429 → 前端 toast "发送过于频繁" | | 短信发送失败(>10次/小时) | 后端返回 429 → 前端 toast "发送过于频繁" |
| 用户 token 已过期 | AuthMiddleware 拦截 → 前端拦截器跳登录(合理) | | 用户 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 来源 | | 鉴权强制 | 路由 `/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 一致) | | 短信 token TTL | 5 分钟(与 Register 一致) |
| 限流 | 复用 `sms:limit:mobile:` 计数器,scene=password | | 限流 | 复用 `sms:limit:mobile:` 计数器,scene=password |
| 旧密码明文只传不存 | bcrypt(成本因子 10) | | 旧密码明文只传不存 | bcrypt(成本因子 10) |

View File

@ -89,13 +89,13 @@
lazy-load lazy-load
/> />
<!-- 3 名专属左上角奖牌装饰 --> <!-- 3 名专属左上角奖牌装饰 -->
<image <!-- <image
v-if="index < 3" v-if="index < 3"
class="card-medal" class="card-medal"
:src="MEDAL_MAP[index]" :src="MEDAL_MAP[index]"
mode="aspectFit" mode="aspectFit"
lazy-load lazy-load
/> /> -->
</view> </view>
<view <view
class="like-info" class="like-info"
@ -198,32 +198,32 @@ const tabs = [
key: "hot", key: "hot",
label: "点赞榜", label: "点赞榜",
icon: "/static/square/galaxy/dianzanbang.png", icon: "/static/square/galaxy/dianzanbang.png",
iconWidth: 64, iconWidth: 96,
iconHeight: 72, iconHeight: 88,
fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE), fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE),
}, },
{ {
key: "new", key: "new",
label: "活跃榜", label: "活跃榜",
icon: "/static/square/galaxy/huoyuebang.png", icon: "/static/square/galaxy/huoyuebang.png",
iconWidth: 64, iconWidth: 96,
iconHeight: 72, iconHeight: 88,
fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE), fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE),
}, },
{ {
key: "trending", key: "trending",
label: "曝光榜", label: "曝光榜",
icon: "/static/square/galaxy/baoguangbang.png", icon: "/static/square/galaxy/baoguangbang.png",
iconWidth: 64, iconWidth: 96,
iconHeight: 72, iconHeight: 88,
fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE), fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE),
}, },
{ {
key: "tongcheng", key: "tongcheng",
label: "同城榜", label: "同城榜",
icon: "/static/square/galaxy/tongchengbang.png", icon: "/static/square/galaxy/tongchengbang.png",
iconWidth: 64, iconWidth: 96,
iconHeight: 72, iconHeight: 88,
fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE), fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE),
}, },
]; ];
@ -478,11 +478,11 @@ onUnmounted(() => {
rgba(255, 90, 93, 0.47) -36.55%, rgba(255, 90, 93, 0.47) -36.55%,
rgba(194, 235, 255, 0.47) 121.2% rgba(194, 235, 255, 0.47) 121.2%
); );
filter: blur(5.9px); filter: blur(2.5px);
// opacity: 0.8; // opacity: 0.8;
// Figma filter: blur(), backdrop-filter() // Figma filter: blur(), backdrop-filter()
// filter: blur(3.7px); // filter: blur(3.7px);
-webkit-filter: blur(5.9px); -webkit-filter: blur(2.5px);
border-top-left-radius: 14px; border-top-left-radius: 14px;
border-top-right-radius: 13px; border-top-right-radius: 13px;
border-bottom-right-radius: 8px; border-bottom-right-radius: 8px;
@ -522,7 +522,8 @@ onUnmounted(() => {
} }
.ranking-tab-item.active .ranking-tab-icon { .ranking-tab-item.active .ranking-tab-icon {
top: -16rpx; top: -8rpx;
height: 96rpx !important;
} }
.ranking-tab-label { .ranking-tab-label {
@ -634,7 +635,7 @@ onUnmounted(() => {
/* background: rgba(255, 255, 255, 0.15); */ /* background: rgba(255, 255, 255, 0.15); */
/* box-shadow: 2px 2px 4.5px 0px #f04b4b40; */ /* box-shadow: 2px 2px 4.5px 0px #f04b4b40; */
box-shadow: 2px 4px 4px 0px #c92f2f5c; box-shadow: 2px 4px 4px 0px #c92f2f5c;
margin-bottom: 48rpx; margin-bottom: 36.8rpx;
} }
/* 单行布局:藏品图片 + 头像 + 点赞信息 + TOP 标签 */ /* 单行布局:藏品图片 + 头像 + 点赞信息 + TOP 标签 */
@ -691,7 +692,7 @@ onUnmounted(() => {
} }
.grid-card-top-4 { .grid-card-top-4 {
position: relative; position: relative;
overflow: hidden; // overflow: hidden;
// [3] bj.png opacity // [3] bj.png opacity
&::before { &::before {
content: ""; content: "";
@ -706,7 +707,7 @@ onUnmounted(() => {
} }
.grid-card-top-5 { .grid-card-top-5 {
position: relative; position: relative;
overflow: hidden; // overflow: hidden;
&::before { &::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -721,7 +722,7 @@ onUnmounted(() => {
.grid-card-top-other { .grid-card-top-other {
position: relative; position: relative;
overflow: hidden; // overflow: hidden;
&::before { &::before {
content: ""; content: "";

View File

@ -69,6 +69,7 @@ function handleClick() {
.podium-frame { .podium-frame {
width: 100%; width: 100%;
height: 100%; height: 100%;
top: -24rpx;
} }
} }
.top-label { .top-label {
@ -101,6 +102,7 @@ function handleClick() {
.podium-frame { .podium-frame {
width: 100%; width: 100%;
height: 100%; height: 100%;
transform: scale(1.6);
} }
} }
} }
@ -125,10 +127,11 @@ function handleClick() {
.cover-wrap { .cover-wrap {
width: 96rpx; width: 96rpx;
height: 152rpx; height: 152rpx;
bottom: 104rpx; bottom: 88rpx;
.podium-frame { .podium-frame {
width: 100%; width: 100%;
height: 100%; height: 100%;
top: -16rpx;
} }
} }
} }

View File

@ -29,7 +29,7 @@
> >
<!-- 相框中间层 --> <!-- 相框中间层 -->
<view class="top-label">{{ formatLabel(p.rank) }}</view> <view class="top-label">{{ formatLabel(p.rank) }}</view>
<image class="ring-frame" :src="ringFrameSrc(p.rank)" mode="aspectFit" /> <image class="ring-frame" :class="{ [`ring-frame-${i}`]: true }" :src="ringFrameSrc(p.rank)" mode="aspectFit" />
<image <image
class="cover-image" class="cover-image"
:class="{ [`cover-image-${i}`]: true }" :class="{ [`cover-image-${i}`]: true }"
@ -152,8 +152,15 @@ function handleClick(item) {
height: 100%; height: 100%;
z-index: 2; z-index: 2;
pointer-events: none; pointer-events: none;
padding: 8rpx 0 16rpx; padding: 16rpx 0 24rpx;
box-sizing: border-box; box-sizing: border-box;
transform: scale(1.1);
}
.ring-frame-1{
/* padding: 8rpx 0 16rpx;
transform: scale(1); */
left: -4rpx;
} }
.top-label { .top-label {