Files
cpdaily_xiaozhushou/backed/internal/handler/invite_code_test.go
T
becomer a51501909a
ci/woodpecker/tag/build Pipeline was successful
绑定邀请码增加形态预校验,并兼容 6 位存量码
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

205 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"errors"
"strings"
"testing"
)
// TestNewInviteCodeFormat 邀请码长度、字符集(不含易混淆的 0/O/1/I/L)
func TestNewInviteCodeFormat(t *testing.T) {
for i := 0; i < 2000; i++ {
code, err := newInviteCode()
if err != nil {
t.Fatalf("newInviteCode error: %v", err)
}
if len(code) != inviteCodeLength {
t.Fatalf("长度应为 %d,得到 %q", inviteCodeLength, code)
}
for _, ch := range code {
if !strings.ContainsRune(inviteCodeChars, ch) {
t.Fatalf("出现字符集外字符 %q(code=%q)", ch, code)
}
if strings.ContainsRune("0O1IL", ch) {
t.Fatalf("出现易混淆字符 %q(code=%q)", ch, code)
}
}
}
}
// TestNewInviteCodeRandomness 连续生成不应大量重复(旧实现按纳秒取模,快速连续调用会高度重复)
func TestNewInviteCodeRandomness(t *testing.T) {
const n = 5000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
code, _ := newInviteCode()
seen[code] = struct{}{}
}
// 31^6 ≈ 8.9 亿空间,5000 个样本的期望碰撞数 ≈ 0.014;允许极少量
if dup := n - len(seen); dup > 2 {
t.Fatalf("5000 次生成出现 %d 个重复,随机性异常", dup)
}
}
func dupErr(key string) error {
return errors.New("Error 1062 (23000): Duplicate entry 'ABC123' for key '" + key + "'")
}
// TestWithUniqueInviteCodeRetriesOnConflict 邀请码唯一索引冲突时换码重试,成功返回最终写入的码
func TestWithUniqueInviteCodeRetriesOnConflict(t *testing.T) {
calls := 0
var tried []string
code, err := withUniqueInviteCode(func(c string) error {
calls++
tried = append(tried, c)
if calls < 3 {
return dupErr("sys_user.idx_sys_user_invite_code")
}
return nil
})
if err != nil {
t.Fatalf("应重试成功,得到错误 %v", err)
}
if calls != 3 {
t.Fatalf("应调用 3 次,实际 %d", calls)
}
if code != tried[len(tried)-1] {
t.Fatalf("返回的码应为最后一次写入的码:got %q, want %q", code, tried[len(tried)-1])
}
}
// TestWithUniqueInviteCodeNoRetryOnOtherErrors 用户名冲突等其他错误不重试,原样返回
func TestWithUniqueInviteCodeNoRetryOnOtherErrors(t *testing.T) {
cases := []error{
dupErr("sys_user.idx_sys_user_username"),
errors.New("connection refused"),
}
for _, want := range cases {
calls := 0
_, err := withUniqueInviteCode(func(string) error { calls++; return want })
if calls != 1 {
t.Fatalf("非邀请码冲突不应重试(%v),实际调用 %d 次", want, calls)
}
if !errors.Is(err, want) && err.Error() != want.Error() {
t.Fatalf("应原样返回错误 %v,得到 %v", want, err)
}
}
}
// TestWithUniqueInviteCodeGivesUp 持续冲突时有上限,不会死循环
func TestWithUniqueInviteCodeGivesUp(t *testing.T) {
calls := 0
_, err := withUniqueInviteCode(func(string) error {
calls++
return dupErr("sys_user.idx_sys_user_invite_code")
})
if err == nil {
t.Fatal("持续冲突应返回错误")
}
if calls != inviteCodeAttempts {
t.Fatalf("应尝试 %d 次,实际 %d", inviteCodeAttempts, calls)
}
}
// TestGenerateRandomCode 短信登录占位密码等仍使用该函数:长度正确且不重复
func TestGenerateRandomCode(t *testing.T) {
a, b := generateRandomCode(32), generateRandomCode(32)
if len(a) != 32 || len(b) != 32 {
t.Fatalf("长度应为 32:%q %q", a, b)
}
if a == b {
t.Fatal("两次生成不应相同")
}
}
// 重置邀请码:新旧码必须不同,且新码格式合规。
func TestResetInviteCodeProducesNewCode(t *testing.T) {
old, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{old: true}
for i := 0; i < 100; i++ {
code, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
if len(code) != inviteCodeLength {
t.Fatalf("新码长度应为 %d,实际 %q", inviteCodeLength, code)
}
if seen[code] {
t.Fatalf("生成重复码 %q", code)
}
seen[code] = true
}
}
// 8 位邀请码空间足够大:5000 次生成不应出现重复。
func TestInviteCodeLengthIsEight(t *testing.T) {
if inviteCodeLength != 8 {
t.Fatalf("邀请码长度应为 8,实际 %d", inviteCodeLength)
}
const n = 5000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
code, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
seen[code] = struct{}{}
}
// 31^8 ≈ 8.5e11,5000 个样本碰撞期望 ≈ 0
if dup := n - len(seen); dup > 0 {
t.Fatalf("8 位下 5000 次生成出现 %d 个重复", dup)
}
}
// resetInviteCode 的乐观锁:并发改动时返回 errInviteCodeChanged
func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
if errors.Is(errInviteCodeChanged, errInviteCodeAlreadySet) {
t.Fatal("两个哨兵错误不应互相匹配")
}
if !errors.Is(errInviteCodeChanged, errInviteCodeChanged) {
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)
}
})
}
}