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 }