feat: 更新SproutGate前后端代码

This commit is contained in:
2026-04-01 22:04:01 +08:00
parent 90590c7cb0
commit 650e1c7707
49 changed files with 3609 additions and 768 deletions

View File

@@ -3,13 +3,60 @@ package storage
import (
"crypto/rand"
"errors"
"os"
"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"`
@@ -20,64 +67,155 @@ type InviteEntry struct {
CreatedAt string `json:"createdAt"`
}
// RegistrationConfig 注册策略邀请码列表data/config/registration.json)。
// RegistrationConfig 注册策略(不含邀请码列表,邀请码单独存入 invite_codes 表)。
type RegistrationConfig struct {
RequireInviteCode bool `json:"requireInviteCode"`
Invites []InviteEntry `json:"invites"`
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 {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.registrationPath); errors.Is(err, os.ErrNotExist) {
cfg := RegistrationConfig{RequireInviteCode: false, Invites: []InviteEntry{}}
if err := writeJSONFile(s.registrationPath, cfg); err != nil {
return err
}
s.registrationConfig = cfg
return nil
}
var cfg RegistrationConfig
if err := readJSONFile(s.registrationPath, &cfg); err != nil {
var policy dbRegistrationPolicy
found, err := s.getConfig("registration", &policy)
if err != nil {
return err
}
if cfg.Invites == nil {
cfg.Invites = []InviteEntry{}
if !found {
policy = dbRegistrationPolicy{RequireInviteCode: false, ForbiddenAccounts: ""}
if err := s.setConfig("registration", policy); err != nil {
return err
}
}
s.registrationConfig = cfg
// 从 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) persistRegistrationConfigLocked() error {
return writeJSONFile(s.registrationPath, s.registrationConfig)
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 是否强制要求邀请码才能发起注册(发邮件验证码)
// RegistrationRequireInvite 是否强制邀请码。
func (s *Store) RegistrationRequireInvite() bool {
s.mu.Lock()
defer s.mu.Unlock()
s.mu.RLock()
defer s.mu.RUnlock()
return s.registrationConfig.RequireInviteCode
}
// GetRegistrationConfig 返回配置副本(管理端)。
// 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.Lock()
defer s.mu.Unlock()
out := s.registrationConfig
out.Invites = append([]InviteEntry(nil), s.registrationConfig.Invites...)
s.mu.RLock()
defer s.mu.RUnlock()
out := effectiveRegistrationConfigForAPI(s.registrationConfig)
return out
}
// SetRegistrationRequireInvite 更新是否强制邀请码
func (s *Store) SetRegistrationRequireInvite(require bool) error {
// 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()
defer s.mu.Unlock()
s.registrationConfig.RequireInviteCode = require
return s.persistRegistrationConfigLocked()
s.registrationConfig.ForbiddenAccounts = forbiddenAccounts
s.registrationConfig.InviteRegisterRewardCoins = rewardEff
s.mu.Unlock()
return nil
}
func inviteEntryValid(e *InviteEntry) error {
@@ -93,14 +231,14 @@ func inviteEntryValid(e *InviteEntry) error {
return nil
}
// ValidateInviteForRegister 校验邀请码是否可用(发验证码前,不扣次)。
// ValidateInviteForRegister 校验邀请码是否可用(不扣次)。
func (s *Store) ValidateInviteForRegister(code string) error {
n := normalizeInviteCode(code)
if n == "" {
return errors.New("invite code is required")
}
s.mu.Lock()
defer s.mu.Unlock()
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.registrationConfig.Invites {
e := &s.registrationConfig.Invites[i]
if strings.EqualFold(e.Code, n) {
@@ -110,14 +248,16 @@ func (s *Store) ValidateInviteForRegister(code string) error {
return errors.New("invalid invite code")
}
// RedeemInvite 邮箱验证通过创建用户后扣减邀请码使用次数。
// 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) {
@@ -125,7 +265,10 @@ func (s *Store) RedeemInvite(code string) error {
return err
}
e.Uses++
return s.persistRegistrationConfigLocked()
// 写回数据库
return s.db.Model(&DBInviteCode{}).
Where("code = ?", e.Code).
Update("uses", e.Uses).Error
}
}
return errors.New("invalid invite code")
@@ -146,10 +289,11 @@ func randomInviteToken(n int) (string, error) {
return sb.String(), nil
}
// AddInviteEntry 生成新邀请码并写入配置
// 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)
@@ -171,6 +315,7 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
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 {
@@ -180,6 +325,7 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
if maxUses < 0 {
maxUses = 0
}
entry := InviteEntry{
Code: code,
Note: strings.TrimSpace(note),
@@ -188,11 +334,13 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
ExpiresAt: expiresAt,
CreatedAt: models.NowISO(),
}
s.registrationConfig.Invites = append(s.registrationConfig.Invites, entry)
if err := s.persistRegistrationConfigLocked(); err != nil {
s.registrationConfig.Invites = s.registrationConfig.Invites[:len(s.registrationConfig.Invites)-1]
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
}
@@ -202,13 +350,40 @@ func (s *Store) DeleteInviteEntry(code string) error {
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()
defer s.mu.Unlock()
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:]...)
return s.persistRegistrationConfigLocked()
s.registrationConfig.Invites = append(
s.registrationConfig.Invites[:i],
s.registrationConfig.Invites[i+1:]...,
)
break
}
}
return errors.New("invite not found")
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