feat: 修改星河,星榜的样式
This commit is contained in:
parent
714d71e2d8
commit
ddb3620eb1
@ -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) |
|
||||
|
||||
@ -89,13 +89,13 @@
|
||||
lazy-load
|
||||
/>
|
||||
<!-- 前 3 名专属:左上角奖牌装饰 -->
|
||||
<image
|
||||
<!-- <image
|
||||
v-if="index < 3"
|
||||
class="card-medal"
|
||||
:src="MEDAL_MAP[index]"
|
||||
mode="aspectFit"
|
||||
lazy-load
|
||||
/>
|
||||
/> -->
|
||||
</view>
|
||||
<view
|
||||
class="like-info"
|
||||
@ -198,32 +198,32 @@ const tabs = [
|
||||
key: "hot",
|
||||
label: "点赞榜",
|
||||
icon: "/static/square/galaxy/dianzanbang.png",
|
||||
iconWidth: 64,
|
||||
iconHeight: 72,
|
||||
iconWidth: 96,
|
||||
iconHeight: 88,
|
||||
fetch: (page) => 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: "";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
>
|
||||
<!-- 相框(中间层) -->
|
||||
<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
|
||||
class="cover-image"
|
||||
:class="{ [`cover-image-${i}`]: true }"
|
||||
@ -152,8 +152,15 @@ function handleClick(item) {
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
padding: 8rpx 0 16rpx;
|
||||
padding: 16rpx 0 24rpx;
|
||||
box-sizing: border-box;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.ring-frame-1{
|
||||
/* padding: 8rpx 0 16rpx;
|
||||
transform: scale(1); */
|
||||
left: -4rpx;
|
||||
}
|
||||
|
||||
.top-label {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user