Files
SproutGate/sproutgate-backend/internal/storage/registration.go

390 lines
11 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 storage
import (
"crypto/rand"
"errors"
"regexp"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sproutgate-backend/internal/models"
)
const (
defaultForbiddenAccountsCSV = "sb,mail,nmsl,cnmb,smy,shumengya"
defaultInviteRegisterRewardCoins = 10
// MinSelfServiceAccountLen / MaxSelfServiceAccountLen 自助注册账号长度(仅小写与数字)。
MinSelfServiceAccountLen = 3
MaxSelfServiceAccountLen = 32
)
func effectiveInviteRegisterRewardCoins(stored *int) int {
if stored == nil {
return defaultInviteRegisterRewardCoins
}
if *stored < 0 {
return 0
}
return *stored
}
var selfServiceAccountPattern = regexp.MustCompile(`^[a-z0-9]+$`)
// NormalizeSelfServiceAccount 自助注册账号:去空白并转为小写。
func NormalizeSelfServiceAccount(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func effectiveForbiddenAccountsCSV(raw string) string {
if strings.TrimSpace(raw) == "" {
return defaultForbiddenAccountsCSV
}
return strings.TrimSpace(raw)
}
func parseForbiddenAccountSet(csv string) map[string]struct{} {
out := make(map[string]struct{})
for _, part := range strings.Split(csv, ",") {
t := strings.ToLower(strings.TrimSpace(part))
if t == "" {
continue
}
out[t] = struct{}{}
}
return out
}
// InviteEntry 管理员发放的注册邀请码。
type InviteEntry struct {
Code string `json:"code"`
Note string `json:"note,omitempty"`
MaxUses int `json:"maxUses"` // 0 表示不限次数
Uses int `json:"uses"`
ExpiresAt string `json:"expiresAt,omitempty"` // RFC3339空表示不过期
CreatedAt string `json:"createdAt"`
}
// RegistrationConfig 注册策略(不含邀请码列表,邀请码单独存入 invite_codes 表)。
type RegistrationConfig struct {
RequireInviteCode bool `json:"requireInviteCode"`
ForbiddenAccounts string `json:"forbiddenAccounts"` // 逗号分隔;空串表示使用内置默认禁注列表
InviteRegisterRewardCoins int `json:"inviteRegisterRewardCoins"` // 使用邀请码完成注册时赠送(生效值,默认 10
Invites []InviteEntry `json:"invites"` // 内存缓存,非 DB 字段
}
func normalizeInviteCode(raw string) string {
return strings.ToUpper(strings.TrimSpace(raw))
}
type dbRegistrationPolicy struct {
RequireInviteCode bool `json:"requireInviteCode"`
ForbiddenAccounts string `json:"forbiddenAccounts"`
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins,omitempty"` // nil 表示未配置,按内置默认 10
}
func (s *Store) loadOrCreateRegistrationConfig() error {
var policy dbRegistrationPolicy
found, err := s.getConfig("registration", &policy)
if err != nil {
return err
}
if !found {
policy = dbRegistrationPolicy{RequireInviteCode: false, ForbiddenAccounts: ""}
if err := s.setConfig("registration", policy); err != nil {
return err
}
}
// 从 invite_codes 表加载所有邀请码
invites, err := s.loadAllInvites()
if err != nil {
return err
}
rewardEff := effectiveInviteRegisterRewardCoins(policy.InviteRegisterRewardCoins)
s.mu.Lock()
s.registrationConfig = RegistrationConfig{
RequireInviteCode: policy.RequireInviteCode,
ForbiddenAccounts: policy.ForbiddenAccounts,
InviteRegisterRewardCoins: rewardEff,
Invites: invites,
}
s.mu.Unlock()
return nil
}
func (s *Store) loadAllInvites() ([]InviteEntry, error) {
var rows []DBInviteCode
if err := s.db.Order("created_at ASC").Find(&rows).Error; err != nil {
return nil, err
}
entries := make([]InviteEntry, 0, len(rows))
for _, r := range rows {
entries = append(entries, r.toEntry())
}
return entries, nil
}
// RegistrationRequireInvite 是否强制邀请码。
func (s *Store) RegistrationRequireInvite() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.registrationConfig.RequireInviteCode
}
// RegistrationInviteRegisterRewardCoins 使用邀请码完成邮箱验证注册时赠送的萌芽币(未配置时为 10
func (s *Store) RegistrationInviteRegisterRewardCoins() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.registrationConfig.InviteRegisterRewardCoins
}
// GetRegistrationConfig 返回配置副本(含邀请码列表)。
func (s *Store) GetRegistrationConfig() RegistrationConfig {
s.mu.RLock()
defer s.mu.RUnlock()
out := effectiveRegistrationConfigForAPI(s.registrationConfig)
return out
}
// effectiveRegistrationConfigForAPI 管理端展示用:禁注列表空时透出当前生效的内置默认文案。
func effectiveRegistrationConfigForAPI(in RegistrationConfig) RegistrationConfig {
out := RegistrationConfig{
RequireInviteCode: in.RequireInviteCode,
ForbiddenAccounts: in.ForbiddenAccounts,
InviteRegisterRewardCoins: in.InviteRegisterRewardCoins,
Invites: append([]InviteEntry(nil), in.Invites...),
}
if strings.TrimSpace(out.ForbiddenAccounts) == "" {
out.ForbiddenAccounts = defaultForbiddenAccountsCSV
}
return out
}
// ValidateSelfServiceAccount 自助注册账号:仅小写字母与数字,长度与禁注表(可配置,空则用内置默认)。
func (s *Store) ValidateSelfServiceAccount(account string) error {
acc := NormalizeSelfServiceAccount(account)
if acc == "" {
return errors.New("account is required")
}
if len(acc) < MinSelfServiceAccountLen || len(acc) > MaxSelfServiceAccountLen {
return errors.New("account must be 3-32 characters")
}
if !selfServiceAccountPattern.MatchString(acc) {
return errors.New("account may only contain lowercase letters and digits")
}
s.mu.RLock()
csv := s.registrationConfig.ForbiddenAccounts
s.mu.RUnlock()
blocked := parseForbiddenAccountSet(effectiveForbiddenAccountsCSV(csv))
if _, ok := blocked[acc]; ok {
return errors.New("this account name is not allowed")
}
return nil
}
// UpdateRegistrationPolicy 更新注册策略inviteRewardCoins 为 nil 时不改库中该项(兼容旧客户端)。
func (s *Store) UpdateRegistrationPolicy(require bool, forbiddenAccounts string, inviteRewardCoins *int) error {
forbiddenAccounts = strings.TrimSpace(forbiddenAccounts)
var policy dbRegistrationPolicy
found, err := s.getConfig("registration", &policy)
if err != nil {
return err
}
if !found {
policy = dbRegistrationPolicy{}
}
policy.RequireInviteCode = require
policy.ForbiddenAccounts = forbiddenAccounts
if inviteRewardCoins != nil {
v := *inviteRewardCoins
if v < 0 {
v = 0
}
policy.InviteRegisterRewardCoins = &v
}
if err := s.setConfig("registration", policy); err != nil {
return err
}
rewardEff := effectiveInviteRegisterRewardCoins(policy.InviteRegisterRewardCoins)
s.mu.Lock()
s.registrationConfig.RequireInviteCode = require
s.registrationConfig.ForbiddenAccounts = forbiddenAccounts
s.registrationConfig.InviteRegisterRewardCoins = rewardEff
s.mu.Unlock()
return nil
}
func inviteEntryValid(e *InviteEntry) error {
if strings.TrimSpace(e.ExpiresAt) != "" {
t, err := time.Parse(time.RFC3339, e.ExpiresAt)
if err == nil && time.Now().After(t) {
return errors.New("invite code expired")
}
}
if e.MaxUses > 0 && e.Uses >= e.MaxUses {
return errors.New("invite code has been fully used")
}
return nil
}
// ValidateInviteForRegister 校验邀请码是否可用(不扣次)。
func (s *Store) ValidateInviteForRegister(code string) error {
n := normalizeInviteCode(code)
if n == "" {
return errors.New("invite code is required")
}
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.registrationConfig.Invites {
e := &s.registrationConfig.Invites[i]
if strings.EqualFold(e.Code, n) {
return inviteEntryValid(e)
}
}
return errors.New("invalid invite code")
}
// RedeemInvite 邮箱验证通过后扣减邀请码使用次数。
func (s *Store) RedeemInvite(code string) error {
n := normalizeInviteCode(code)
if n == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.registrationConfig.Invites {
e := &s.registrationConfig.Invites[i]
if strings.EqualFold(e.Code, n) {
if err := inviteEntryValid(e); err != nil {
return err
}
e.Uses++
// 写回数据库
return s.db.Model(&DBInviteCode{}).
Where("code = ?", e.Code).
Update("uses", e.Uses).Error
}
}
return errors.New("invalid invite code")
}
const inviteCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
func randomInviteToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
var sb strings.Builder
sb.Grow(n)
for i := 0; i < n; i++ {
sb.WriteByte(inviteCodeAlphabet[int(b[i])%len(inviteCodeAlphabet)])
}
return sb.String(), nil
}
// AddInviteEntry 生成新邀请码并写入数据库。
func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (InviteEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
var code string
for attempt := 0; attempt < 24; attempt++ {
c, err := randomInviteToken(8)
if err != nil {
return InviteEntry{}, err
}
dup := false
for _, ex := range s.registrationConfig.Invites {
if strings.EqualFold(ex.Code, c) {
dup = true
break
}
}
if !dup {
code = c
break
}
}
if code == "" {
return InviteEntry{}, errors.New("failed to generate unique invite code")
}
expiresAt = strings.TrimSpace(expiresAt)
if expiresAt != "" {
if _, err := time.Parse(time.RFC3339, expiresAt); err != nil {
return InviteEntry{}, errors.New("invalid expiresAt (use RFC3339)")
}
}
if maxUses < 0 {
maxUses = 0
}
entry := InviteEntry{
Code: code,
Note: strings.TrimSpace(note),
MaxUses: maxUses,
Uses: 0,
ExpiresAt: expiresAt,
CreatedAt: models.NowISO(),
}
row := dbInviteFromEntry(entry)
if err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return InviteEntry{}, err
}
s.registrationConfig.Invites = append(s.registrationConfig.Invites, entry)
return entry, nil
}
// DeleteInviteEntry 按码删除(大小写不敏感)。
func (s *Store) DeleteInviteEntry(code string) error {
n := normalizeInviteCode(code)
if n == "" {
return errors.New("code is required")
}
result := s.db.Where("UPPER(code) = ?", n).Delete(&DBInviteCode{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("invite not found")
}
s.mu.Lock()
for i, e := range s.registrationConfig.Invites {
if strings.EqualFold(e.Code, n) {
s.registrationConfig.Invites = append(
s.registrationConfig.Invites[:i],
s.registrationConfig.Invites[i+1:]...,
)
break
}
}
s.mu.Unlock()
return nil
}
// refreshInviteCache 重新从数据库加载邀请码列表(内部用)。
func (s *Store) refreshInviteCache() error {
invites, err := s.loadAllInvites()
if err != nil {
return err
}
s.mu.Lock()
s.registrationConfig.Invites = invites
s.mu.Unlock()
return nil
}
// 确保 gorm 包被使用
var _ = gorm.ErrRecordNotFound