feat: 更新SproutGate前后端代码
This commit is contained in:
@@ -6,13 +6,17 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
// ─── 配置结构体(公开 API 不变)────────────────────────────────────────────────
|
||||
|
||||
type AdminConfig struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@@ -36,66 +40,36 @@ type CheckInConfig struct {
|
||||
RewardCoins int `json:"rewardCoins"`
|
||||
}
|
||||
|
||||
// ─── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store struct {
|
||||
dataDir string
|
||||
usersDir string
|
||||
pendingDir string
|
||||
resetDir string
|
||||
secondaryDir string
|
||||
adminConfigPath string
|
||||
authConfigPath string
|
||||
emailConfigPath string
|
||||
checkInPath string
|
||||
registrationPath string
|
||||
registrationConfig RegistrationConfig
|
||||
db *gorm.DB
|
||||
adminToken string
|
||||
jwtSecret []byte
|
||||
issuer string
|
||||
emailConfig EmailConfig
|
||||
checkInConfig CheckInConfig
|
||||
mu sync.Mutex
|
||||
registrationConfig RegistrationConfig
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewStore(dataDir string) (*Store, error) {
|
||||
if dataDir == "" {
|
||||
dataDir = "./data"
|
||||
}
|
||||
absDir, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
// 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
|
||||
}
|
||||
usersDir := filepath.Join(absDir, "users")
|
||||
pendingDir := filepath.Join(absDir, "pending")
|
||||
resetDir := filepath.Join(absDir, "reset")
|
||||
secondaryDir := filepath.Join(absDir, "secondary")
|
||||
configDir := filepath.Join(absDir, "config")
|
||||
if err := os.MkdirAll(usersDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(pendingDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(resetDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(secondaryDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{
|
||||
dataDir: absDir,
|
||||
usersDir: usersDir,
|
||||
pendingDir: pendingDir,
|
||||
resetDir: resetDir,
|
||||
secondaryDir: secondaryDir,
|
||||
adminConfigPath: filepath.Join(configDir, "admin.json"),
|
||||
authConfigPath: filepath.Join(configDir, "auth.json"),
|
||||
emailConfigPath: filepath.Join(configDir, "email.json"),
|
||||
checkInPath: filepath.Join(configDir, "checkin.json"),
|
||||
registrationPath: filepath.Join(configDir, "registration.json"),
|
||||
}
|
||||
|
||||
store := &Store{db: db}
|
||||
|
||||
if err := store.loadOrCreateAdminConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -111,32 +85,136 @@ func NewStore(dataDir string) (*Store, error) {
|
||||
if err := store.loadOrCreateRegistrationConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) DataDir() string {
|
||||
return s.dataDir
|
||||
// ─── 配置辅助 ────────────────────────────────────────────────────────────────
|
||||
|
||||
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) AdminToken() string {
|
||||
return s.adminToken
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Store) JWTSecret() []byte {
|
||||
return s.jwtSecret
|
||||
// ─── 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
|
||||
}
|
||||
|
||||
func (s *Store) JWTIssuer() string {
|
||||
return s.issuer
|
||||
// ─── 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
|
||||
}
|
||||
|
||||
func (s *Store) EmailConfig() EmailConfig {
|
||||
return s.emailConfig
|
||||
// ─── 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.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cfg := s.checkInConfig
|
||||
if cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
@@ -145,160 +223,27 @@ func (s *Store) CheckInConfig() CheckInConfig {
|
||||
}
|
||||
|
||||
func (s *Store) UpdateCheckInConfig(cfg CheckInConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
}
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
if err := s.setConfig("checkin", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.checkInConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateAdminConfig() error {
|
||||
if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := AdminConfig{Token: token}
|
||||
if err := writeJSONFile(s.adminConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.adminToken = cfg.Token
|
||||
return nil
|
||||
}
|
||||
var cfg AdminConfig
|
||||
if err := readJSONFile(s.adminConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Token) == "" {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = token
|
||||
if err := writeJSONFile(s.adminConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.adminToken = cfg.Token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateAuthConfig() error {
|
||||
if _, err := os.Stat(s.authConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
secret, err := generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := AuthConfig{
|
||||
JWTSecret: base64.StdEncoding.EncodeToString(secret),
|
||||
Issuer: "sproutgate",
|
||||
}
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.jwtSecret = secret
|
||||
s.issuer = cfg.Issuer
|
||||
return nil
|
||||
}
|
||||
var cfg AuthConfig
|
||||
if err := readJSONFile(s.authConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
secretBytes, err := base64.StdEncoding.DecodeString(cfg.JWTSecret)
|
||||
if err != 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 := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.Issuer) == "" {
|
||||
cfg.Issuer = "sproutgate"
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.jwtSecret = secretBytes
|
||||
s.issuer = cfg.Issuer
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateEmailConfig() error {
|
||||
if _, err := os.Stat(s.emailConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := EmailConfig{
|
||||
FromName: "萌芽账户认证中心",
|
||||
FromAddress: "notice@smyhub.com",
|
||||
Username: "",
|
||||
Password: "",
|
||||
SMTPHost: "smtp.qiye.aliyun.com",
|
||||
SMTPPort: 465,
|
||||
Encryption: "SSL",
|
||||
}
|
||||
if err := writeJSONFile(s.emailConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Username == "" {
|
||||
cfg.Username = cfg.FromAddress
|
||||
}
|
||||
s.emailConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg EmailConfig
|
||||
if err := readJSONFile(s.emailConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.FromName) == "" {
|
||||
cfg.FromName = "萌芽账户认证中心"
|
||||
}
|
||||
if strings.TrimSpace(cfg.FromAddress) == "" {
|
||||
cfg.FromAddress = "notice@smyhub.com"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" {
|
||||
cfg.Username = cfg.FromAddress
|
||||
}
|
||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||
cfg.SMTPHost = "smtp.qiye.aliyun.com"
|
||||
}
|
||||
if cfg.SMTPPort == 0 {
|
||||
cfg.SMTPPort = 465
|
||||
}
|
||||
if strings.TrimSpace(cfg.Encryption) == "" {
|
||||
cfg.Encryption = "SSL"
|
||||
}
|
||||
if err := writeJSONFile(s.emailConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.emailConfig = cfg
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateCheckInConfig() error {
|
||||
if _, err := os.Stat(s.checkInPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := CheckInConfig{RewardCoins: 1}
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.checkInConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg CheckInConfig
|
||||
if err := readJSONFile(s.checkInPath, &cfg); err != nil {
|
||||
found, err := s.getConfig("checkin", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.RewardCoins <= 0 {
|
||||
if !found || cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
if err := s.setConfig("checkin", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -306,158 +251,143 @@ func (s *Store) loadOrCreateCheckInConfig() error {
|
||||
return 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
|
||||
}
|
||||
// ─── 用户 CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListUsers() ([]models.UserRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entries, err := os.ReadDir(s.usersDir)
|
||||
if err != nil {
|
||||
var rows []DBUser
|
||||
if err := s.db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := make([]models.UserRecord, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
var record models.UserRecord
|
||||
path := filepath.Join(s.usersDir, entry.Name())
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, record)
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, false, nil
|
||||
}
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toRecord(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(record models.UserRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(record.Account)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return errors.New("account already exists")
|
||||
}
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = record.CreatedAt
|
||||
return writeJSONFile(path, record)
|
||||
|
||||
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 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(record.Account)
|
||||
record.UpdatedAt = models.NowISO()
|
||||
return writeJSONFile(path, record)
|
||||
row := dbUserFromRecord(record)
|
||||
return s.db.Save(&row).Error
|
||||
}
|
||||
|
||||
// RecordAuthClient 在成功认证后记录第三方应用标识(clientID 须已规范化)。
|
||||
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")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
return models.UserRecord{}, err
|
||||
|
||||
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 record.AuthClients {
|
||||
if record.AuthClients[i].ClientID == clientID {
|
||||
record.AuthClients[i].LastSeenAt = now
|
||||
for i := range clients {
|
||||
if clients[i].ClientID == clientID {
|
||||
clients[i].LastSeenAt = now
|
||||
if displayName != "" {
|
||||
record.AuthClients[i].DisplayName = displayName
|
||||
clients[i].DisplayName = displayName
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
record.AuthClients = append(record.AuthClients, models.AuthClientEntry{
|
||||
clients = append(clients, models.AuthClientEntry{
|
||||
ClientID: clientID,
|
||||
DisplayName: displayName,
|
||||
FirstSeenAt: now,
|
||||
LastSeenAt: now,
|
||||
})
|
||||
}
|
||||
record.UpdatedAt = now
|
||||
if err := writeJSONFile(path, &record); err != nil {
|
||||
row.AuthClients = AuthClientSlice(clients)
|
||||
row.UpdatedAt = now
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
return row.toRecord(), nil
|
||||
}
|
||||
|
||||
func (s *Store) RecordVisit(account string, today string, at string) (models.UserRecord, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// ─── RecordVisit ──────────────────────────────────────────────────────────────
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
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
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, false, result.Error
|
||||
}
|
||||
|
||||
if record.LastVisitDate == today || models.HasActivityDate(record.VisitTimes, today) {
|
||||
return record, false, nil
|
||||
visitTimes := []string(row.VisitTimes)
|
||||
if row.LastVisitDate == today || models.HasActivityDate(visitTimes, today) {
|
||||
return row.toRecord(), false, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(at) == "" {
|
||||
at = models.CurrentActivityTime()
|
||||
}
|
||||
record.LastVisitDate = today
|
||||
record.LastVisitAt = at
|
||||
record.VisitTimes = append(record.VisitTimes, at)
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
row.LastVisitDate = today
|
||||
row.LastVisitAt = at
|
||||
row.VisitTimes = StringSlice(append(visitTimes, at))
|
||||
if row.CreatedAt == "" {
|
||||
row.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
row.UpdatedAt = models.NowISO()
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toRecord(), true, nil
|
||||
}
|
||||
|
||||
// ─── UpdateLastVisitMeta ─────────────────────────────────────────────────────
|
||||
|
||||
const maxLastVisitIPLen = 45
|
||||
const maxLastVisitDisplayLocationLen = 512
|
||||
|
||||
@@ -473,7 +403,6 @@ func clampVisitMeta(ip, displayLocation string) (string, string) {
|
||||
return ip, displayLocation
|
||||
}
|
||||
|
||||
// UpdateLastVisitMeta 更新用户最近一次访问的客户端 IP 与展示用地理位置(由前端调用地理接口后传入)。
|
||||
func (s *Store) UpdateLastVisitMeta(account string, ip string, displayLocation string) (models.UserRecord, error) {
|
||||
ip, displayLocation = clampVisitMeta(ip, displayLocation)
|
||||
if ip == "" && displayLocation == "" {
|
||||
@@ -487,101 +416,83 @@ func (s *Store) UpdateLastVisitMeta(account string, ip string, displayLocation s
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, result.Error
|
||||
}
|
||||
|
||||
if ip != "" {
|
||||
record.LastVisitIP = ip
|
||||
row.LastVisitIP = ip
|
||||
}
|
||||
if displayLocation != "" {
|
||||
record.LastVisitDisplayLocation = displayLocation
|
||||
row.LastVisitDisplayLocation = displayLocation
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
row.UpdatedAt = models.NowISO()
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
return row.toRecord(), nil
|
||||
}
|
||||
|
||||
func (s *Store) CheckIn(account string, today string, at string) (models.UserRecord, int, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// ─── CheckIn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
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
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, 0, false, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, 0, false, result.Error
|
||||
}
|
||||
|
||||
if record.LastCheckInDate == today || models.HasActivityDate(record.CheckInTimes, today) {
|
||||
return record, 0, true, nil
|
||||
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
|
||||
}
|
||||
record.SproutCoins += reward
|
||||
record.LastCheckInDate = today
|
||||
|
||||
row.SproutCoins += reward
|
||||
row.LastCheckInDate = today
|
||||
if strings.TrimSpace(at) == "" {
|
||||
at = models.CurrentActivityTime()
|
||||
}
|
||||
record.LastCheckInAt = at
|
||||
record.CheckInTimes = append(record.CheckInTimes, at)
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
row.LastCheckInAt = at
|
||||
row.CheckInTimes = StringSlice(append(checkInTimes, at))
|
||||
if row.CreatedAt == "" {
|
||||
row.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
row.UpdatedAt = models.NowISO()
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, 0, false, err
|
||||
}
|
||||
return record, reward, false, nil
|
||||
return row.toRecord(), reward, false, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func generateSecret() ([]byte, error) {
|
||||
secret := make([]byte, 32)
|
||||
_, err := rand.Read(secret)
|
||||
return secret, err
|
||||
}
|
||||
|
||||
func (s *Store) userFilePath(account string) string {
|
||||
return filepath.Join(s.usersDir, userFileName(account))
|
||||
}
|
||||
|
||||
func userFileName(account string) string {
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(account))
|
||||
return encoded + ".json"
|
||||
}
|
||||
|
||||
func readJSONFile(path string, target any) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
func generateToken() (string, error) {
|
||||
secret, err := generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, raw, 0644)
|
||||
return base64.RawURLEncoding.EncodeToString(secret), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user