546 lines
16 KiB
Go
546 lines
16 KiB
Go
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
|
||
}
|