Files
SproutGate/sproutgate-backend/internal/storage/storage.go

499 lines
14 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"
"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"`
}
// ─── Store ─────────────────────────────────────────────────────────────────────
type Store struct {
db *gorm.DB
adminToken string
jwtSecret []byte
issuer string
emailConfig EmailConfig
checkInConfig CheckInConfig
registrationConfig RegistrationConfig
mu sync.RWMutex
}
// 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
}
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
}