Files
cpdaily_xiaozhushou/backed/internal/handler/invite_code.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

232 lines
7.8 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 (
"crypto/rand"
"errors"
"fmt"
"log/slog"
"math/big"
"strings"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
// - 长度 8,字符集 31 个 → 约 8.5 万亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
const (
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
inviteCodeLength = 8
inviteCodeAttempts = 8
)
// newInviteCode 生成一个随机邀请码(不保证唯一,唯一性由调用方的写库重试保证)
func newInviteCode() (string, error) {
return randomFromCharset(inviteCodeChars, inviteCodeLength)
}
// randomFromCharset 用 crypto/rand 从字符集中均匀取 n 个字符
func randomFromCharset(charset string, n int) (string, error) {
max := big.NewInt(int64(len(charset)))
var sb strings.Builder
sb.Grow(n)
for i := 0; i < n; i++ {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
sb.WriteByte(charset[idx.Int64()])
}
return sb.String(), nil
}
// isInviteCodeConflict 判断是否为「邀请码」唯一索引冲突。
// 只认 invite_code 索引:用户名等其他唯一键冲突也会报 Duplicate entry,换邀请码重试毫无意义,
// 必须原样返回给调用方。MySQL 报错形如:
//
// Error 1062 (23000): Duplicate entry 'ABC123' for key 'sys_user.idx_sys_user_invite_code'
func isInviteCodeConflict(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "Duplicate entry") && strings.Contains(msg, "invite_code")
}
// withUniqueInviteCode 生成邀请码并执行写库操作 write(code);
// 若写库因唯一索引冲突失败,则换一个新码重试,最多 inviteCodeAttempts 次。
// 冲突判定交给数据库唯一索引(而非「先查后写」),并发下依然正确。
func withUniqueInviteCode(write func(code string) error) (string, error) {
var lastErr error
for i := 0; i < inviteCodeAttempts; i++ {
code, err := newInviteCode()
if err != nil {
return "", fmt.Errorf("生成邀请码失败: %w", err)
}
err = write(code)
if err == nil {
return code, nil
}
if !isInviteCodeConflict(err) {
return "", err
}
lastErr = err
}
return "", fmt.Errorf("邀请码重复次数过多: %w", lastErr)
}
// ensureInviteCode 确保用户拥有邀请码:已有则直接返回;没有则生成并写库。
// 写库条件带 invite_code 为空,避免并发登录时把已生成的码覆盖掉;
// 若条件未命中(被并发请求抢先写入),重新读取最新值。
func ensureInviteCode(user *model.SysUser) error {
if strings.TrimSpace(user.InviteCode) != "" {
return nil
}
code, err := withUniqueInviteCode(func(code string) error {
res := database.DB.Model(&model.SysUser{}).
Where("id = ? AND (invite_code IS NULL OR invite_code = '')", user.ID).
Update("invite_code", code)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errInviteCodeAlreadySet
}
return nil
})
if errors.Is(err, errInviteCodeAlreadySet) {
var fresh model.SysUser
if e := database.DB.Select("id", "invite_code").First(&fresh, user.ID).Error; e != nil {
return e
}
user.InviteCode = fresh.InviteCode
return nil
}
if err != nil {
return err
}
user.InviteCode = code
slog.Info("已为用户补发邀请码", "userId", user.ID, "inviteCode", code)
return nil
}
var errInviteCodeAlreadySet = errors.New("invite code already set")
// BackfillInviteCodes 启动时为所有缺少邀请码的存量用户补齐邀请码(幂等,无缺失时只执行一次计数查询)。
func BackfillInviteCodes() {
var users []model.SysUser
if err := database.DB.Select("id", "invite_code").
Where("invite_code IS NULL OR invite_code = ''").
Find(&users).Error; err != nil {
slog.Error("查询缺少邀请码的用户失败", "error", err)
return
}
if len(users) == 0 {
return
}
ok := 0
for i := range users {
if err := ensureInviteCode(&users[i]); err != nil {
slog.Error("补发邀请码失败", "userId", users[i].ID, "error", err)
continue
}
ok++
}
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
}
// resetInviteCode 为指定用户重新生成邀请码(旧码立即失效)。
//
// 唯一性由 invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑)。
// 用带条件的方式写库:仅当当前值仍是调用方读到的旧码时才更新,
// 避免两个并发重置请求互相覆盖、导致其中一个返回的码实际已失效。
func resetInviteCode(user *model.SysUser) (string, error) {
if user == nil {
return "", errors.New("用户不存在")
}
oldCode := strings.TrimSpace(user.InviteCode)
code, err := withUniqueInviteCode(func(code string) error {
q := database.DB.Model(&model.SysUser{}).Where("id = ?", user.ID)
if oldCode != "" {
// 乐观锁:旧码未被他人改动才允许替换
q = q.Where("invite_code = ?", oldCode)
}
res := q.Update("invite_code", code)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errInviteCodeChanged
}
return nil
})
if err != nil {
return "", err
}
user.InviteCode = code
return code, nil
}
var errInviteCodeChanged = errors.New("invite code changed concurrently")
// ResetInviteCode 用户主动重置自己的邀请码。
//
// 影响:旧邀请码立即失效——已用旧码注册的邀请关系不受影响(关系已落库),
// 但旧码不能再被新用户使用。前端需在操作前明确提示这一点。
func ResetInviteCode(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 没有码时先补齐,再重置(否则 oldCode 为空,乐观锁条件失效)
if strings.TrimSpace(user.InviteCode) == "" {
if err := ensureInviteCode(&user); err != nil {
return utils.FailServer(c, "生成邀请码失败,请稍后重试")
}
}
oldCode := user.InviteCode
code, err := resetInviteCode(&user)
if err != nil {
if errors.Is(err, errInviteCodeChanged) {
return utils.Fail(c, 409, "邀请码已被重置,请刷新页面后重试")
}
slog.Error("重置邀请码失败", "userId", userID, "error", err)
return utils.FailServer(c, "重置邀请码失败,请稍后重试")
}
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
}