Files
SproutGate/sproutgate-backend/internal/storage/storage.go
2026-05-13 12:19:36 +08:00

546 lines
16 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"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"sync"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sproutgate-backend/internal/models"
)
// ─── 配置结构体(公开 API 不变)────────────────────────────────────────────────
type AdminConfig struct {
Token string `json:"token"`
}
type AuthConfig struct {
JWTSecret string `json:"jwtSecret"`
Issuer string `json:"issuer"`
}
type EmailConfig struct {
FromName string `json:"fromName"`
FromAddress string `json:"fromAddress"`
Username string `json:"username"`
Password string `json:"password"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
Encryption string `json:"encryption"`
}
type CheckInConfig struct {
RewardCoins int `json:"rewardCoins"`
}
// OAuthConfig 第三方登录GitHub / Gitea / Google / Microsoft / Linux.do密钥也可由环境变量覆盖。
type OAuthConfig struct {
GitHubEnabled bool `json:"githubEnabled"`
GiteaEnabled bool `json:"giteaEnabled"`
GoogleEnabled bool `json:"googleEnabled"`
MicrosoftEnabled bool `json:"microsoftEnabled"`
LinuxdoEnabled bool `json:"linuxdoEnabled"`
GitHubClientID string `json:"githubClientId"`
GitHubClientSecret string `json:"githubClientSecret"`
GiteaBaseURL string `json:"giteaBaseUrl"`
GiteaClientID string `json:"giteaClientId"`
GiteaClientSecret string `json:"giteaClientSecret"`
GoogleClientID string `json:"googleClientId"`
GoogleClientSecret string `json:"googleClientSecret"`
MicrosoftClientID string `json:"microsoftClientId"`
MicrosoftClientSecret string `json:"microsoftClientSecret"`
MicrosoftTenant string `json:"microsoftTenant"`
LinuxdoConnectBaseURL string `json:"linuxdoConnectBaseUrl"`
LinuxdoClientID string `json:"linuxdoClientId"`
LinuxdoClientSecret string `json:"linuxdoClientSecret"`
GitHubLogoURL string `json:"githubLogoUrl"`
GiteaLogoURL string `json:"giteaLogoUrl"`
GoogleLogoURL string `json:"googleLogoUrl"`
MicrosoftLogoURL string `json:"microsoftLogoUrl"`
LinuxdoLogoURL string `json:"linuxdoLogoUrl"`
AllowedReturnPrefixes []string `json:"allowedReturnPrefixes"`
AllowOAuthSignUpWhenInviteRequired bool `json:"allowOAuthSignUpWhenInviteRequired"`
}
// TurnstileConfig Cloudflare Turnstile 人机验证配置。
type TurnstileConfig struct {
Enabled bool `json:"enabled"`
SiteKey string `json:"siteKey"`
SecretKey string `json:"secretKey"`
}
// ─── Store ─────────────────────────────────────────────────────────────────────
type Store struct {
db *gorm.DB
adminToken string
jwtSecret []byte
issuer string
emailConfig EmailConfig
checkInConfig CheckInConfig
registrationConfig RegistrationConfig
oauthConfig OAuthConfig
turnstileConfig TurnstileConfig
mu sync.RWMutex
hotReloadMu sync.Mutex
lastHotReload time.Time
}
// NewStore 接收已建立连接的 *gorm.DB自动迁移表结构并加载配置。
func NewStore(db *gorm.DB) (*Store, error) {
if err := db.AutoMigrate(
&DBUser{},
&DBPendingUser{},
&DBResetPassword{},
&DBSecondaryEmailVerification{},
&DBAppConfig{},
&DBInviteCode{},
&DBProfileLike{},
&DBProfileLikeDailyQuota{},
); err != nil {
return nil, err
}
store := &Store{db: db}
if err := store.loadOrCreateAdminConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateAuthConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateEmailConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateCheckInConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateRegistrationConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateOAuthConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateTurnstileConfig(); err != nil {
return nil, err
}
return store, nil
}
// ─── 配置辅助 ────────────────────────────────────────────────────────────────
func (s *Store) getConfig(key string, target any) (bool, error) {
var row DBAppConfig
result := s.db.First(&row, "config_key = ?", key)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return false, nil
}
if result.Error != nil {
return false, result.Error
}
return true, json.Unmarshal([]byte(row.ConfigValue), target)
}
func (s *Store) setConfig(key string, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
row := DBAppConfig{ConfigKey: key, ConfigValue: string(raw)}
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
}
// ─── Admin 配置 ───────────────────────────────────────────────────────────────
func (s *Store) AdminToken() string { return s.adminToken }
func (s *Store) loadOrCreateAdminConfig() error {
var cfg AdminConfig
found, err := s.getConfig("admin", &cfg)
if err != nil {
return err
}
if !found || strings.TrimSpace(cfg.Token) == "" {
token, err := generateToken()
if err != nil {
return err
}
cfg.Token = token
if err := s.setConfig("admin", cfg); err != nil {
return err
}
}
s.adminToken = cfg.Token
return nil
}
// ─── Auth 配置 ────────────────────────────────────────────────────────────────
func (s *Store) JWTSecret() []byte { return s.jwtSecret }
func (s *Store) JWTIssuer() string { return s.issuer }
func (s *Store) loadOrCreateAuthConfig() error {
var cfg AuthConfig
found, err := s.getConfig("auth", &cfg)
if err != nil {
return err
}
secretBytes, decodeErr := base64.StdEncoding.DecodeString(cfg.JWTSecret)
if !found || decodeErr != nil || len(secretBytes) == 0 {
secretBytes, err = generateSecret()
if err != nil {
return err
}
cfg.JWTSecret = base64.StdEncoding.EncodeToString(secretBytes)
}
if strings.TrimSpace(cfg.Issuer) == "" {
cfg.Issuer = "sproutgate"
}
if err := s.setConfig("auth", cfg); err != nil {
return err
}
s.jwtSecret = secretBytes
s.issuer = cfg.Issuer
return nil
}
// ─── Email 配置 ───────────────────────────────────────────────────────────────
func (s *Store) EmailConfig() EmailConfig { return s.emailConfig }
func (s *Store) loadOrCreateEmailConfig() error {
var cfg EmailConfig
found, err := s.getConfig("email", &cfg)
if err != nil {
return err
}
changed := !found
if strings.TrimSpace(cfg.FromName) == "" {
cfg.FromName = "萌芽账户认证中心"
changed = true
}
if strings.TrimSpace(cfg.FromAddress) == "" {
cfg.FromAddress = "notice@smyhub.com"
changed = true
}
if strings.TrimSpace(cfg.Username) == "" {
cfg.Username = cfg.FromAddress
changed = true
}
if strings.TrimSpace(cfg.SMTPHost) == "" {
cfg.SMTPHost = "smtp.qiye.aliyun.com"
changed = true
}
if cfg.SMTPPort == 0 {
cfg.SMTPPort = 465
changed = true
}
if strings.TrimSpace(cfg.Encryption) == "" {
cfg.Encryption = "SSL"
changed = true
}
if changed {
if err := s.setConfig("email", cfg); err != nil {
return err
}
}
s.emailConfig = cfg
return nil
}
// ─── CheckIn 配置 ─────────────────────────────────────────────────────────────
func (s *Store) CheckInConfig() CheckInConfig {
s.mu.RLock()
defer s.mu.RUnlock()
cfg := s.checkInConfig
if cfg.RewardCoins <= 0 {
cfg.RewardCoins = 1
}
return cfg
}
func (s *Store) UpdateCheckInConfig(cfg CheckInConfig) error {
if cfg.RewardCoins <= 0 {
cfg.RewardCoins = 1
}
if err := s.setConfig("checkin", cfg); err != nil {
return err
}
s.mu.Lock()
s.checkInConfig = cfg
s.mu.Unlock()
return nil
}
func (s *Store) loadOrCreateCheckInConfig() error {
var cfg CheckInConfig
found, err := s.getConfig("checkin", &cfg)
if err != nil {
return err
}
if !found || cfg.RewardCoins <= 0 {
cfg.RewardCoins = 1
if err := s.setConfig("checkin", cfg); err != nil {
return err
}
}
s.checkInConfig = cfg
return nil
}
// ─── 用户 CRUD ────────────────────────────────────────────────────────────────
func (s *Store) ListUsers() ([]models.UserRecord, error) {
var rows []DBUser
if err := s.db.Find(&rows).Error; err != nil {
return nil, err
}
users := make([]models.UserRecord, 0, len(rows))
for _, row := range rows {
users = append(users, row.toRecord())
}
return users, nil
}
func (s *Store) GetUser(account string) (models.UserRecord, bool, error) {
var row DBUser
result := s.db.First(&row, "account = ?", account)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, false, nil
}
if result.Error != nil {
return models.UserRecord{}, false, result.Error
}
return row.toRecord(), true, nil
}
func (s *Store) CreateUser(record models.UserRecord) error {
if record.CreatedAt == "" {
record.CreatedAt = models.NowISO()
}
record.UpdatedAt = record.CreatedAt
row := dbUserFromRecord(record)
result := s.db.Create(&row)
if result.Error != nil {
if strings.Contains(result.Error.Error(), "Duplicate entry") ||
strings.Contains(result.Error.Error(), "duplicate key") {
return errors.New("account already exists")
}
return result.Error
}
return nil
}
func (s *Store) SaveUser(record models.UserRecord) error {
record.UpdatedAt = models.NowISO()
row := dbUserFromRecord(record)
return s.db.Save(&row).Error
}
func (s *Store) DeleteUser(account string) error {
return s.db.Delete(&DBUser{}, "account = ?", account).Error
}
// ─── RecordAuthClient ─────────────────────────────────────────────────────────
func (s *Store) RecordAuthClient(account string, clientID string, displayName string) (models.UserRecord, error) {
if clientID == "" {
return models.UserRecord{}, errors.New("client id required")
}
var row DBUser
result := s.db.First(&row, "account = ?", account)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, os.ErrNotExist
}
if result.Error != nil {
return models.UserRecord{}, result.Error
}
now := models.NowISO()
displayName = models.ClampAuthClientName(displayName)
clients := []models.AuthClientEntry(row.AuthClients)
found := false
for i := range clients {
if clients[i].ClientID == clientID {
clients[i].LastSeenAt = now
if displayName != "" {
clients[i].DisplayName = displayName
}
found = true
break
}
}
if !found {
clients = append(clients, models.AuthClientEntry{
ClientID: clientID,
DisplayName: displayName,
FirstSeenAt: now,
LastSeenAt: now,
})
}
row.AuthClients = AuthClientSlice(clients)
row.UpdatedAt = now
if err := s.db.Save(&row).Error; err != nil {
return models.UserRecord{}, err
}
return row.toRecord(), nil
}
// ─── RecordVisit ──────────────────────────────────────────────────────────────
func (s *Store) RecordVisit(account string, today string, at string) (models.UserRecord, bool, error) {
var row DBUser
result := s.db.First(&row, "account = ?", account)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, false, os.ErrNotExist
}
if result.Error != nil {
return models.UserRecord{}, false, result.Error
}
visitTimes := []string(row.VisitTimes)
if row.LastVisitDate == today || models.HasActivityDate(visitTimes, today) {
return row.toRecord(), false, nil
}
if strings.TrimSpace(at) == "" {
at = models.CurrentActivityTime()
}
row.LastVisitDate = today
row.LastVisitAt = at
row.VisitTimes = StringSlice(append(visitTimes, at))
if row.CreatedAt == "" {
row.CreatedAt = models.NowISO()
}
row.UpdatedAt = models.NowISO()
if err := s.db.Save(&row).Error; err != nil {
return models.UserRecord{}, false, err
}
return row.toRecord(), true, nil
}
// ─── UpdateLastVisitMeta ─────────────────────────────────────────────────────
const maxLastVisitIPLen = 45
const maxLastVisitDisplayLocationLen = 512
func clampVisitMeta(ip, displayLocation string) (string, string) {
ip = strings.TrimSpace(ip)
displayLocation = strings.TrimSpace(displayLocation)
if len(ip) > maxLastVisitIPLen {
ip = ip[:maxLastVisitIPLen]
}
if len(displayLocation) > maxLastVisitDisplayLocationLen {
displayLocation = displayLocation[:maxLastVisitDisplayLocationLen]
}
return ip, displayLocation
}
func (s *Store) UpdateLastVisitMeta(account string, ip string, displayLocation string) (models.UserRecord, error) {
ip, displayLocation = clampVisitMeta(ip, displayLocation)
if ip == "" && displayLocation == "" {
rec, found, err := s.GetUser(account)
if err != nil {
return models.UserRecord{}, err
}
if !found {
return models.UserRecord{}, os.ErrNotExist
}
return rec, nil
}
var row DBUser
result := s.db.First(&row, "account = ?", account)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, os.ErrNotExist
}
if result.Error != nil {
return models.UserRecord{}, result.Error
}
if ip != "" {
row.LastVisitIP = ip
}
if displayLocation != "" {
row.LastVisitDisplayLocation = displayLocation
}
row.UpdatedAt = models.NowISO()
if err := s.db.Save(&row).Error; err != nil {
return models.UserRecord{}, err
}
return row.toRecord(), nil
}
// ─── CheckIn ─────────────────────────────────────────────────────────────────
func (s *Store) CheckIn(account string, today string, at string) (models.UserRecord, int, bool, error) {
var row DBUser
result := s.db.First(&row, "account = ?", account)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, 0, false, os.ErrNotExist
}
if result.Error != nil {
return models.UserRecord{}, 0, false, result.Error
}
checkInTimes := []string(row.CheckInTimes)
if row.LastCheckInDate == today || models.HasActivityDate(checkInTimes, today) {
return row.toRecord(), 0, true, nil
}
s.mu.RLock()
reward := s.checkInConfig.RewardCoins
s.mu.RUnlock()
if reward <= 0 {
reward = 1
}
row.SproutCoins += reward
row.LastCheckInDate = today
if strings.TrimSpace(at) == "" {
at = models.CurrentActivityTime()
}
row.LastCheckInAt = at
row.CheckInTimes = StringSlice(append(checkInTimes, at))
if row.CreatedAt == "" {
row.CreatedAt = models.NowISO()
}
row.UpdatedAt = models.NowISO()
if err := s.db.Save(&row).Error; err != nil {
return models.UserRecord{}, 0, false, err
}
return row.toRecord(), reward, false, nil
}
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
func generateSecret() ([]byte, error) {
secret := make([]byte, 32)
_, err := rand.Read(secret)
return secret, err
}
func generateToken() (string, error) {
secret, err := generateSecret()
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(secret), nil
}