绑定邀请码增加形态预校验,并兼容 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)、含小写、含符号、含中文。
This commit is contained in:
becomer
2026-10-21 17:33:41 +08:00
parent 672214c06e
commit a51501909a
3 changed files with 70 additions and 1 deletions
+7 -1
View File
@@ -1,13 +1,13 @@
package handler package handler
import ( import (
"context"
"cpdaily-new/internal/config" "cpdaily-new/internal/config"
"cpdaily-new/internal/database" "cpdaily-new/internal/database"
"cpdaily-new/internal/model" "cpdaily-new/internal/model"
"cpdaily-new/internal/service" "cpdaily-new/internal/service"
cpd "cpdaily-new/internal/service/cpdaily" cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils" "cpdaily-new/internal/utils"
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -585,6 +585,12 @@ func BindInviteCode(c *fiber.Ctx) error {
if code == "" { if code == "" {
return utils.FailParam(c, "请输入邀请码") return utils.FailParam(c, "请输入邀请码")
} }
// 格式预校验:邀请码固定 8 位且只用 inviteCodeChars 字符集。
// 不合规的输入直接拒绝,避免每次都在库里做一次无用查询;
// 用统一文案,不泄露「码存在但格式对」这类信息。
if !isValidInviteCodeShape(code) {
return utils.Fail(c, 400, "邀请码无效")
}
var user model.SysUser var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil { 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) slog.Info("用户重置邀请码", "userId", userID, "oldCode", oldCode, "newCode", code)
return utils.Success(c, fiber.Map{"inviteCode": 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 自反性失败") 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)
}
})
}
}