390 lines
11 KiB
Go
390 lines
11 KiB
Go
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
|