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