Compare commits

...

3 Commits

Author SHA1 Message Date
becomer e5b57203fb 置顶公告:回到前台时重新测量文本宽度
ci/woodpecker/tag/build Pipeline was successful
onVisibility 原先只调 schedule(),不重新测量。后台期间若发生尺寸变化
(横竖屏切换、系统字体缩放、页面布局重排),overflow 仍是旧值,
长文本的滚动距离会算错,表现为「还没滚完就翻页」或「滚过头留白」,
而 measure() 本身只在挂载和 resize 时执行。

改为回到前台先 measure() 再 schedule(),与 resize 处理保持一致。

审查同类边界,以下均已正确处理,未改动:
- items 为空:模板用 v-if="current" 不渲染
- 只有 1 条且不溢出:schedule 直接 return,不空转定时器
- items 长度变化:watch 中把越界索引归零
- 滚动时长:Math.min(9, Math.max(3, ...)) 有上下限,
  不会出现 NaN/Infinity 导致 setTimeout 异常

npm run build 与 vue-tsc 均通过。
2026-10-21 17:35:28 +08:00
becomer a51501909a 绑定邀请码增加形态预校验,并兼容 6 位存量码
ci/woodpecker/tag/build Pipeline was successful
1. 预校验(避免无用查库)
   原先任何非空字符串都会直接拿去查库。邀请码固定长度且只用
   inviteCodeChars(去掉 0/O/1/L 后的 31 个字符),不合规的输入
   可以无需查库就拒绝;用统一文案「邀请码无效」,不泄露额外信息。

2. 存量兼容(关键,差点造成线上事故)
   最初把校验写成「必须 8 位」。查生产库发现:
       6 位:551 个用户
       8 位:1 个用户
   即绝大多数用户的邀请码仍是 6 位旧码。若只接受 8 位,
   这 551 人的码会被判无效,他们将无法被新用户填写邀请——
   影响面很大。已改为同时接受 6 位与 8 位,字符集校验不变。

测试覆盖:8 位正常、6 位存量、5/7 位、空串、含易混淆字符
(0/O/1/I/L)、含小写、含符号、含中文。
2026-10-21 17:33:41 +08:00
becomer 672214c06e 重置邀请码:补 409 并发冲突自动刷新与空码保护
ci/woodpecker/tag/build Pipeline was successful
自审发现两个未处理的边界,都属于「提示了但页面状态不对」:

1. 并发冲突(409)
   两个请求同时重置时,后端乐观锁会让其中一个返回 409。
   原先只弹错误提示,页面仍显示旧码——用户以为没重置成功,
   但服务端实际已是新码,导致分享出去的是失效的码。
   现在收到 409 会自动重新拉取邀请信息并刷新页面状态。

2. 接口成功但未返回新码
   原先 `if (code) inviteCode.value = code` 在 code 为空时静默跳过,
   却照样提示「邀请码已重置」,页面继续显示旧码。
   改为明确报错并 return,不谎报成功。

npm run build 与 vue-tsc 均通过。
2026-10-21 17:32:28 +08:00
5 changed files with 94 additions and 4 deletions
+7 -1
View File
@@ -1,13 +1,13 @@
package handler
import (
"context"
"cpdaily-new/internal/config"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -585,6 +585,12 @@ func BindInviteCode(c *fiber.Ctx) error {
if code == "" {
return utils.FailParam(c, "请输入邀请码")
}
// 格式预校验:邀请码固定 8 位且只用 inviteCodeChars 字符集。
// 不合规的输入直接拒绝,避免每次都在库里做一次无用查询;
// 用统一文案,不泄露「码存在但格式对」这类信息。
if !isValidInviteCodeShape(code) {
return utils.Fail(c, 400, "邀请码无效")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
+24
View File
@@ -205,3 +205,27 @@ func ResetInviteCode(c *fiber.Ctx) error {
slog.Info("用户重置邀请码", "userId", userID, "oldCode", oldCode, "newCode", code)
return utils.Success(c, fiber.Map{"inviteCode": code})
}
// inviteCodeLegacyLength 旧版邀请码长度。
//
// 2026-10 之前生成为 6 位,生产库中仍有大量存量用户沿用旧码
// (实测 551 个 6 位 / 1 个 8 位)。校验必须同时接受两种长度,
// 否则存量用户的码会被判为无效,导致他们无法被邀请。
const inviteCodeLegacyLength = 6
// isValidInviteCodeShape 校验邀请码的形态(长度 + 字符集),不查库。
//
// 长度接受 6(存量)与 8(当前)两种,字符集为 inviteCodeChars
// (已去掉易混淆的 0/O/1/L)。不合规的输入无需查库即可拒绝,
// 也避免把用户的任意长字符串当码去查。
func isValidInviteCodeShape(code string) bool {
if len(code) != inviteCodeLength && len(code) != inviteCodeLegacyLength {
return false
}
for i := 0; i < len(code); i++ {
if !strings.ContainsRune(inviteCodeChars, rune(code[i])) {
return false
}
}
return true
}
@@ -163,3 +163,42 @@ func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
t.Fatal("errors.Is 自反性失败")
}
}
// isValidInviteCodeShape:只校验形态(长度 + 字符集),不查库
func TestIsValidInviteCodeShape(t *testing.T) {
valid, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
code string
want bool
}{
{"正常 8 位", valid, true},
{"空串", "", false},
{"过短", "ABC", false},
{"过长", "ABCDEFGHJ", false},
{"含易混淆字符 0", "ABCD0EFG", false},
{"含易混淆字符 O", "ABCDOEFG", false},
{"含易混淆字符 1", "ABCD1EFG", false},
{"含易混淆字符 I", "ABCDIEFG", false},
{"含易混淆字符 L", "ABCDLEFG", false},
{"含小写", "abcdefgh", false},
{"含符号", "ABCD-EFG", false},
{"含中文", "邀请码邀请码邀请", false},
// 存量兼容:生产库仍有 551 个 6 位旧码,必须仍能正常绑定
{"存量 6 位码", "ABCDEF", true},
{"存量 6 位含易混淆字符", "ABCD0F", false},
{"5 位", "ABCDE", false},
{"7 位", "ABCDEFG", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isValidInviteCodeShape(c.code); got != c.want {
t.Fatalf("isValidInviteCodeShape(%q)=%v, want %v", c.code, got, c.want)
}
})
}
}
+8 -2
View File
@@ -74,8 +74,14 @@ watch(
)
function onVisibility() {
if (document.visibilityState === 'visible') schedule()
else clearTimer()
if (document.visibilityState === 'visible') {
// 回到前台时重新测量:后台期间可能发生过尺寸变化(横竖屏切换、
// 系统字体缩放等),若沿用旧的 overflow,长文本的滚动距离会算错,
// 出现「还没滚完就翻页」或「滚过头留白」。
void measure().then(schedule)
} else {
clearTimer()
}
}
function openAll() {
+16 -1
View File
@@ -394,11 +394,26 @@ async function onResetInviteTap() {
try {
const res = await authApi.resetInviteCode()
const code = res.data.data?.inviteCode || ''
if (code) inviteCode.value = code
if (!code) {
// 接口成功但未返回新码:不能静默当作成功,否则页面仍显示旧码
message.error('重置失败:未获取到新的邀请码,请重试')
return
}
inviteCode.value = code
message.success('邀请码已重置,请使用新的邀请码分享')
} catch (e: any) {
const status = e?.response?.status
const msg = e?.response?.data?.message || e?.message || '重置失败,请稍后重试'
message.error(msg)
// 409:并发重置冲突,说明服务端已是新码。重新拉取,避免页面继续显示旧码
if (status === 409) {
try {
const info = await authApi.getMyInviteInfo()
applyInviteInfo(info.data.data)
} catch {
// 拉取失败不覆盖原提示,用户可手动刷新
}
}
} finally {
resettingInvite.value = false
}