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 }