feat: 更新SproutGate前后端代码
This commit is contained in:
321
sproutgate-backend/internal/storage/dbmodels.go
Normal file
321
sproutgate-backend/internal/storage/dbmodels.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
// ─── JSON 序列化辅助类型 ────────────────────────────────────────────────────────
|
||||
|
||||
// StringSlice 将 []string 序列化为 JSON 字符串存入 TEXT 列。
|
||||
type StringSlice []string
|
||||
|
||||
func (s StringSlice) Value() (driver.Value, error) {
|
||||
if s == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
b, err := json.Marshal([]string(s))
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func (s *StringSlice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = StringSlice{}
|
||||
return nil
|
||||
}
|
||||
var raw []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
raw = v
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("StringSlice.Scan: unsupported type %T", value)
|
||||
}
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
*s = StringSlice{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, (*[]string)(s))
|
||||
}
|
||||
|
||||
// AuthClientSlice 将 []models.AuthClientEntry 序列化为 JSON 存入 TEXT 列。
|
||||
type AuthClientSlice []models.AuthClientEntry
|
||||
|
||||
func (a AuthClientSlice) Value() (driver.Value, error) {
|
||||
if a == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
b, err := json.Marshal([]models.AuthClientEntry(a))
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func (a *AuthClientSlice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*a = AuthClientSlice{}
|
||||
return nil
|
||||
}
|
||||
var raw []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
raw = v
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("AuthClientSlice.Scan: unsupported type %T", value)
|
||||
}
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
*a = AuthClientSlice{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, (*[]models.AuthClientEntry)(a))
|
||||
}
|
||||
|
||||
// ─── GORM DB 模型 ─────────────────────────────────────────────────────────────
|
||||
|
||||
// DBUser 对应数据库 users 表。
|
||||
type DBUser struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null"`
|
||||
Username string `gorm:"column:username;not null;size:255"`
|
||||
Email string `gorm:"column:email;not null;size:255;index"`
|
||||
Level int `gorm:"column:level;default:0"`
|
||||
SproutCoins int `gorm:"column:sprout_coins;default:0"`
|
||||
LastCheckInDate string `gorm:"column:last_check_in_date;size:20"`
|
||||
LastCheckInAt string `gorm:"column:last_check_in_at;size:128"`
|
||||
LastVisitDate string `gorm:"column:last_visit_date;size:20"`
|
||||
LastVisitAt string `gorm:"column:last_visit_at;size:128"`
|
||||
LastVisitIP string `gorm:"column:last_visit_ip;size:45"`
|
||||
LastVisitDisplayLocation string `gorm:"column:last_visit_display_location;size:512"`
|
||||
CheckInTimes StringSlice `gorm:"column:check_in_times;type:mediumtext"`
|
||||
VisitTimes StringSlice `gorm:"column:visit_times;type:mediumtext"`
|
||||
SecondaryEmails StringSlice `gorm:"column:secondary_emails;type:text"`
|
||||
Phone string `gorm:"column:phone;size:50"`
|
||||
AvatarURL string `gorm:"column:avatar_url;size:1024"`
|
||||
WebsiteURL string `gorm:"column:website_url;size:1024"`
|
||||
Bio string `gorm:"column:bio;type:text"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
UpdatedAt string `gorm:"column:updated_at;size:50;not null"`
|
||||
Banned bool `gorm:"column:banned;default:false"`
|
||||
BanReason string `gorm:"column:ban_reason;type:text"`
|
||||
BannedAt string `gorm:"column:banned_at;size:50"`
|
||||
TokenEpoch int64 `gorm:"column:token_epoch;default:0"`
|
||||
AuthClients AuthClientSlice `gorm:"column:auth_clients;type:mediumtext"`
|
||||
}
|
||||
|
||||
func (DBUser) TableName() string { return "users" }
|
||||
|
||||
// DBUserFromRecord 将领域模型转换为 GORM 模型(导出供 migrate 工具使用)。
|
||||
func DBUserFromRecord(r models.UserRecord) DBUser {
|
||||
return dbUserFromRecord(r)
|
||||
}
|
||||
|
||||
func dbUserFromRecord(r models.UserRecord) DBUser {
|
||||
return DBUser{
|
||||
Account: r.Account,
|
||||
PasswordHash: r.PasswordHash,
|
||||
Username: r.Username,
|
||||
Email: r.Email,
|
||||
Level: r.Level,
|
||||
SproutCoins: r.SproutCoins,
|
||||
LastCheckInDate: r.LastCheckInDate,
|
||||
LastCheckInAt: r.LastCheckInAt,
|
||||
LastVisitDate: r.LastVisitDate,
|
||||
LastVisitAt: r.LastVisitAt,
|
||||
LastVisitIP: r.LastVisitIP,
|
||||
LastVisitDisplayLocation: r.LastVisitDisplayLocation,
|
||||
CheckInTimes: StringSlice(r.CheckInTimes),
|
||||
VisitTimes: StringSlice(r.VisitTimes),
|
||||
SecondaryEmails: StringSlice(r.SecondaryEmails),
|
||||
Phone: r.Phone,
|
||||
AvatarURL: r.AvatarURL,
|
||||
WebsiteURL: r.WebsiteURL,
|
||||
Bio: r.Bio,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
Banned: r.Banned,
|
||||
BanReason: r.BanReason,
|
||||
BannedAt: r.BannedAt,
|
||||
TokenEpoch: r.TokenEpoch,
|
||||
AuthClients: AuthClientSlice(r.AuthClients),
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBUser) toRecord() models.UserRecord {
|
||||
return models.UserRecord{
|
||||
Account: d.Account,
|
||||
PasswordHash: d.PasswordHash,
|
||||
Username: d.Username,
|
||||
Email: d.Email,
|
||||
Level: d.Level,
|
||||
SproutCoins: d.SproutCoins,
|
||||
LastCheckInDate: d.LastCheckInDate,
|
||||
LastCheckInAt: d.LastCheckInAt,
|
||||
LastVisitDate: d.LastVisitDate,
|
||||
LastVisitAt: d.LastVisitAt,
|
||||
LastVisitIP: d.LastVisitIP,
|
||||
LastVisitDisplayLocation: d.LastVisitDisplayLocation,
|
||||
CheckInTimes: []string(d.CheckInTimes),
|
||||
VisitTimes: []string(d.VisitTimes),
|
||||
SecondaryEmails: []string(d.SecondaryEmails),
|
||||
Phone: d.Phone,
|
||||
AvatarURL: d.AvatarURL,
|
||||
WebsiteURL: d.WebsiteURL,
|
||||
Bio: d.Bio,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
Banned: d.Banned,
|
||||
BanReason: d.BanReason,
|
||||
BannedAt: d.BannedAt,
|
||||
TokenEpoch: d.TokenEpoch,
|
||||
AuthClients: []models.AuthClientEntry(d.AuthClients),
|
||||
}
|
||||
}
|
||||
|
||||
// DBPendingUser 对应 pending_users 表。
|
||||
type DBPendingUser struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null"`
|
||||
Username string `gorm:"column:username;not null;size:255"`
|
||||
Email string `gorm:"column:email;not null;size:255;index"`
|
||||
CodeHash string `gorm:"column:code_hash;not null"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50;not null"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
InviteCode string `gorm:"column:invite_code;size:32"`
|
||||
}
|
||||
|
||||
func (DBPendingUser) TableName() string { return "pending_users" }
|
||||
|
||||
func dbPendingFromModel(p models.PendingUser) DBPendingUser {
|
||||
return DBPendingUser{
|
||||
Account: p.Account,
|
||||
PasswordHash: p.PasswordHash,
|
||||
Username: p.Username,
|
||||
Email: p.Email,
|
||||
CodeHash: p.CodeHash,
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
CreatedAt: p.CreatedAt,
|
||||
InviteCode: p.InviteCode,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBPendingUser) toModel() models.PendingUser {
|
||||
return models.PendingUser{
|
||||
Account: d.Account,
|
||||
PasswordHash: d.PasswordHash,
|
||||
Username: d.Username,
|
||||
Email: d.Email,
|
||||
CodeHash: d.CodeHash,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
InviteCode: d.InviteCode,
|
||||
}
|
||||
}
|
||||
|
||||
// DBResetPassword 对应 password_resets 表。
|
||||
type DBResetPassword struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
Email string `gorm:"column:email;not null;size:255"`
|
||||
CodeHash string `gorm:"column:code_hash;not null"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50;not null"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
}
|
||||
|
||||
func (DBResetPassword) TableName() string { return "password_resets" }
|
||||
|
||||
func dbResetFromModel(r models.ResetPassword) DBResetPassword {
|
||||
return DBResetPassword{
|
||||
Account: r.Account,
|
||||
Email: r.Email,
|
||||
CodeHash: r.CodeHash,
|
||||
ExpiresAt: r.ExpiresAt,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBResetPassword) toModel() models.ResetPassword {
|
||||
return models.ResetPassword{
|
||||
Account: d.Account,
|
||||
Email: d.Email,
|
||||
CodeHash: d.CodeHash,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// DBSecondaryEmailVerification 对应 secondary_email_verifications 表。
|
||||
// 联合主键 (account, email)。
|
||||
type DBSecondaryEmailVerification struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
Email string `gorm:"primaryKey;column:email;size:255"`
|
||||
CodeHash string `gorm:"column:code_hash;not null"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50;not null"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
}
|
||||
|
||||
func (DBSecondaryEmailVerification) TableName() string { return "secondary_email_verifications" }
|
||||
|
||||
func dbSecondaryFromModel(s models.SecondaryEmailVerification) DBSecondaryEmailVerification {
|
||||
return DBSecondaryEmailVerification{
|
||||
Account: s.Account,
|
||||
Email: s.Email,
|
||||
CodeHash: s.CodeHash,
|
||||
ExpiresAt: s.ExpiresAt,
|
||||
CreatedAt: s.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBSecondaryEmailVerification) toModel() models.SecondaryEmailVerification {
|
||||
return models.SecondaryEmailVerification{
|
||||
Account: d.Account,
|
||||
Email: d.Email,
|
||||
CodeHash: d.CodeHash,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// DBAppConfig 对应 app_configs 表,存储应用配置键值对(value 为 JSON 文本)。
|
||||
type DBAppConfig struct {
|
||||
ConfigKey string `gorm:"primaryKey;column:config_key;size:128"`
|
||||
ConfigValue string `gorm:"column:config_value;type:text;not null"`
|
||||
}
|
||||
|
||||
func (DBAppConfig) TableName() string { return "app_configs" }
|
||||
|
||||
// DBInviteCode 对应 invite_codes 表。
|
||||
type DBInviteCode struct {
|
||||
Code string `gorm:"primaryKey;column:code;size:32"`
|
||||
Note string `gorm:"column:note;size:512"`
|
||||
MaxUses int `gorm:"column:max_uses;default:0"`
|
||||
Uses int `gorm:"column:uses;default:0"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
}
|
||||
|
||||
func (DBInviteCode) TableName() string { return "invite_codes" }
|
||||
|
||||
func dbInviteFromEntry(e InviteEntry) DBInviteCode {
|
||||
return DBInviteCode{
|
||||
Code: e.Code,
|
||||
Note: e.Note,
|
||||
MaxUses: e.MaxUses,
|
||||
Uses: e.Uses,
|
||||
ExpiresAt: e.ExpiresAt,
|
||||
CreatedAt: e.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBInviteCode) toEntry() InviteEntry {
|
||||
return InviteEntry{
|
||||
Code: d.Code,
|
||||
Note: d.Note,
|
||||
MaxUses: d.MaxUses,
|
||||
Uses: d.Uses,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,34 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SavePending(record models.PendingUser) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(record.Account)
|
||||
return writeJSONFile(path, record)
|
||||
row := dbPendingFromModel(record)
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) GetPending(account string) (models.PendingUser, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBPendingUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.PendingUser{}, false, nil
|
||||
}
|
||||
var record models.PendingUser
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.PendingUser{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.PendingUser{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toModel(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeletePending(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
return s.db.Delete(&DBPendingUser{}, "account = ?", account).Error
|
||||
}
|
||||
|
||||
func (s *Store) pendingFilePath(account string) string {
|
||||
return filepath.Join(s.pendingDir, pendingFileName(account))
|
||||
}
|
||||
|
||||
func pendingFileName(account string) string {
|
||||
safe := strings.TrimSpace(account)
|
||||
if safe == "" {
|
||||
safe = "unknown"
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(safe))
|
||||
return encoded + ".json"
|
||||
}
|
||||
// DataDir 返回空字符串(MySQL 模式下无本地数据目录)。
|
||||
func (s *Store) DataDir() string { return "" }
|
||||
|
||||
219
sproutgate-backend/internal/storage/profile_likes.go
Normal file
219
sproutgate-backend/internal/storage/profile_likes.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
// MaxProfileLikesPerDay 每个点赞者每个自然日最多给多少位不同用户的公开主页点赞。
|
||||
const MaxProfileLikesPerDay = 5
|
||||
|
||||
var (
|
||||
ErrCannotLikeOwnProfile = errors.New("cannot like own profile")
|
||||
ErrAlreadyLikedToday = errors.New("already liked today")
|
||||
ErrDailyLikeLimitReached = errors.New("daily like limit reached")
|
||||
ErrProfileLikeTarget = errors.New("user not found")
|
||||
ErrProfileLikeLiker = errors.New("invalid liker")
|
||||
)
|
||||
|
||||
// DBProfileLikeDailyQuota 点赞者当日剩余可点赞人数(与 profile_likes 计数同步;新自然日新行即视为刷新为满额)。
|
||||
type DBProfileLikeDailyQuota struct {
|
||||
LikerAccount string `gorm:"primaryKey;column:liker_account;size:255"`
|
||||
LikeDate string `gorm:"primaryKey;column:like_date;size:10"`
|
||||
Remaining int `gorm:"column:remaining;not null"`
|
||||
UpdatedAt string `gorm:"column:updated_at;size:50"`
|
||||
}
|
||||
|
||||
func (DBProfileLikeDailyQuota) TableName() string { return "profile_like_daily_quota" }
|
||||
|
||||
// DBProfileLike 记录「谁在某日给谁的主页点赞」(每人每天对同一主页最多一条)。
|
||||
type DBProfileLike struct {
|
||||
LikerAccount string `gorm:"primaryKey;column:liker_account;size:255"`
|
||||
LikedAccount string `gorm:"primaryKey;column:liked_account;size:255"`
|
||||
LikeDate string `gorm:"primaryKey;column:like_date;size:10"` // YYYY-MM-DD,与签到同历
|
||||
CreatedAt string `gorm:"column:created_at;size:50"`
|
||||
}
|
||||
|
||||
func (DBProfileLike) TableName() string { return "profile_likes" }
|
||||
|
||||
// CountProfileLikes 主页累计获赞次数(历史所有日的点赞条数之和)。
|
||||
func (s *Store) CountProfileLikes(likedAccount string) (int64, error) {
|
||||
likedAccount = strings.TrimSpace(likedAccount)
|
||||
if likedAccount == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var n int64
|
||||
if err := s.db.Model(&DBProfileLike{}).Where("liked_account = ?", likedAccount).Count(&n).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ViewerHasLikedProfileToday 当前自然日(Asia/Shanghai)内是否已为该主页点过赞。
|
||||
func (s *Store) ViewerHasLikedProfileToday(likerAccount, likedAccount string) (bool, error) {
|
||||
likerAccount = strings.TrimSpace(likerAccount)
|
||||
likedAccount = strings.TrimSpace(likedAccount)
|
||||
if likerAccount == "" || likedAccount == "" {
|
||||
return false, nil
|
||||
}
|
||||
today := models.CurrentActivityDate()
|
||||
var n int64
|
||||
err := s.db.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND liked_account = ? AND like_date = ?", likerAccount, likedAccount, today).
|
||||
Count(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ProfileLikeRemainingToday 当日还可给多少人点赞(根据 profile_likes 计数计算,并与 quota 表对齐)。
|
||||
func (s *Store) ProfileLikeRemainingToday(likerAccount string) (int, error) {
|
||||
likerAccount = strings.TrimSpace(likerAccount)
|
||||
if likerAccount == "" {
|
||||
return 0, nil
|
||||
}
|
||||
today := models.CurrentActivityDate()
|
||||
var used int64
|
||||
if err := s.db.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerAccount, today).
|
||||
Count(&used).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rem := MaxProfileLikesPerDay - int(used)
|
||||
if rem < 0 {
|
||||
rem = 0
|
||||
}
|
||||
var q DBProfileLikeDailyQuota
|
||||
err := s.db.Where("liker_account = ? AND like_date = ?", likerAccount, today).First(&q).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return rem, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if q.Remaining != rem {
|
||||
_ = s.db.Model(&DBProfileLikeDailyQuota{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerAccount, today).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": rem,
|
||||
"updated_at": models.NowISO(),
|
||||
}).Error
|
||||
}
|
||||
return rem, nil
|
||||
}
|
||||
|
||||
// AddProfileLike 为公开主页点赞(每人每天每主页一次;每人每天最多给 MaxProfileLikesPerDay 位用户点赞)。返回新的累计赞数。
|
||||
func (s *Store) AddProfileLike(likerAccount, likedAccount string) (int64, error) {
|
||||
likerAccount = strings.TrimSpace(likerAccount)
|
||||
likedAccount = strings.TrimSpace(likedAccount)
|
||||
if likerAccount == "" || likedAccount == "" {
|
||||
return 0, errors.New("account is required")
|
||||
}
|
||||
if strings.EqualFold(likerAccount, likedAccount) {
|
||||
return 0, ErrCannotLikeOwnProfile
|
||||
}
|
||||
|
||||
likedUser, found, err := s.GetUser(likedAccount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !found || likedUser.Banned {
|
||||
return 0, ErrProfileLikeTarget
|
||||
}
|
||||
likerUser, foundL, err := s.GetUser(likerAccount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !foundL || likerUser.Banned {
|
||||
return 0, ErrProfileLikeLiker
|
||||
}
|
||||
|
||||
today := models.CurrentActivityDate()
|
||||
likerCanon := likerUser.Account
|
||||
likedCanon := likedUser.Account
|
||||
row := DBProfileLike{
|
||||
LikerAccount: likerCanon,
|
||||
LikedAccount: likedCanon,
|
||||
LikeDate: today,
|
||||
CreatedAt: models.NowISO(),
|
||||
}
|
||||
|
||||
txErr := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var quota DBProfileLikeDailyQuota
|
||||
qErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
First("a).Error
|
||||
if errors.Is(qErr, gorm.ErrRecordNotFound) {
|
||||
quota = DBProfileLikeDailyQuota{
|
||||
LikerAccount: likerCanon,
|
||||
LikeDate: today,
|
||||
Remaining: MaxProfileLikesPerDay,
|
||||
UpdatedAt: models.NowISO(),
|
||||
}
|
||||
if cerr := tx.Create("a).Error; cerr != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
First("a).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if qErr != nil {
|
||||
return qErr
|
||||
}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
First("a).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var used int64
|
||||
if err := tx.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Count(&used).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if int(used) >= MaxProfileLikesPerDay {
|
||||
if err := tx.Model(&DBProfileLikeDailyQuota{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": 0,
|
||||
"updated_at": models.NowISO(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrDailyLikeLimitReached
|
||||
}
|
||||
|
||||
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrAlreadyLikedToday
|
||||
}
|
||||
|
||||
var usedAfter int64
|
||||
if err := tx.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Count(&usedAfter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rem := MaxProfileLikesPerDay - int(usedAfter)
|
||||
if rem < 0 {
|
||||
rem = 0
|
||||
}
|
||||
return tx.Model(&DBProfileLikeDailyQuota{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": rem,
|
||||
"updated_at": models.NowISO(),
|
||||
}).Error
|
||||
})
|
||||
if txErr != nil {
|
||||
return 0, txErr
|
||||
}
|
||||
return s.CountProfileLikes(likedCanon)
|
||||
}
|
||||
@@ -3,13 +3,60 @@ package storage
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultForbiddenAccountsCSV = "sb,mail,nmsl,cnmb,smy,shumengya"
|
||||
defaultInviteRegisterRewardCoins = 10
|
||||
// MinSelfServiceAccountLen / MaxSelfServiceAccountLen 自助注册账号长度(仅小写与数字)。
|
||||
MinSelfServiceAccountLen = 3
|
||||
MaxSelfServiceAccountLen = 32
|
||||
)
|
||||
|
||||
func effectiveInviteRegisterRewardCoins(stored *int) int {
|
||||
if stored == nil {
|
||||
return defaultInviteRegisterRewardCoins
|
||||
}
|
||||
if *stored < 0 {
|
||||
return 0
|
||||
}
|
||||
return *stored
|
||||
}
|
||||
|
||||
var selfServiceAccountPattern = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
// NormalizeSelfServiceAccount 自助注册账号:去空白并转为小写。
|
||||
func NormalizeSelfServiceAccount(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func effectiveForbiddenAccountsCSV(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return defaultForbiddenAccountsCSV
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
func parseForbiddenAccountSet(csv string) map[string]struct{} {
|
||||
out := make(map[string]struct{})
|
||||
for _, part := range strings.Split(csv, ",") {
|
||||
t := strings.ToLower(strings.TrimSpace(part))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out[t] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InviteEntry 管理员发放的注册邀请码。
|
||||
type InviteEntry struct {
|
||||
Code string `json:"code"`
|
||||
@@ -20,64 +67,155 @@ type InviteEntry struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// RegistrationConfig 注册策略与邀请码列表(data/config/registration.json)。
|
||||
// RegistrationConfig 注册策略(不含邀请码列表,邀请码单独存入 invite_codes 表)。
|
||||
type RegistrationConfig struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
Invites []InviteEntry `json:"invites"`
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
ForbiddenAccounts string `json:"forbiddenAccounts"` // 逗号分隔;空串表示使用内置默认禁注列表
|
||||
InviteRegisterRewardCoins int `json:"inviteRegisterRewardCoins"` // 使用邀请码完成注册时赠送(生效值,默认 10)
|
||||
Invites []InviteEntry `json:"invites"` // 内存缓存,非 DB 字段
|
||||
}
|
||||
|
||||
func normalizeInviteCode(raw string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
type dbRegistrationPolicy struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
ForbiddenAccounts string `json:"forbiddenAccounts"`
|
||||
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins,omitempty"` // nil 表示未配置,按内置默认 10
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateRegistrationConfig() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, err := os.Stat(s.registrationPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := RegistrationConfig{RequireInviteCode: false, Invites: []InviteEntry{}}
|
||||
if err := writeJSONFile(s.registrationPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.registrationConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg RegistrationConfig
|
||||
if err := readJSONFile(s.registrationPath, &cfg); err != nil {
|
||||
var policy dbRegistrationPolicy
|
||||
found, err := s.getConfig("registration", &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Invites == nil {
|
||||
cfg.Invites = []InviteEntry{}
|
||||
if !found {
|
||||
policy = dbRegistrationPolicy{RequireInviteCode: false, ForbiddenAccounts: ""}
|
||||
if err := s.setConfig("registration", policy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.registrationConfig = cfg
|
||||
|
||||
// 从 invite_codes 表加载所有邀请码
|
||||
invites, err := s.loadAllInvites()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rewardEff := effectiveInviteRegisterRewardCoins(policy.InviteRegisterRewardCoins)
|
||||
s.mu.Lock()
|
||||
s.registrationConfig = RegistrationConfig{
|
||||
RequireInviteCode: policy.RequireInviteCode,
|
||||
ForbiddenAccounts: policy.ForbiddenAccounts,
|
||||
InviteRegisterRewardCoins: rewardEff,
|
||||
Invites: invites,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) persistRegistrationConfigLocked() error {
|
||||
return writeJSONFile(s.registrationPath, s.registrationConfig)
|
||||
func (s *Store) loadAllInvites() ([]InviteEntry, error) {
|
||||
var rows []DBInviteCode
|
||||
if err := s.db.Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]InviteEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
entries = append(entries, r.toEntry())
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// RegistrationRequireInvite 是否强制要求邀请码才能发起注册(发邮件验证码)。
|
||||
// RegistrationRequireInvite 是否强制邀请码。
|
||||
func (s *Store) RegistrationRequireInvite() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.registrationConfig.RequireInviteCode
|
||||
}
|
||||
|
||||
// GetRegistrationConfig 返回配置副本(管理端)。
|
||||
// RegistrationInviteRegisterRewardCoins 使用邀请码完成邮箱验证注册时赠送的萌芽币(未配置时为 10)。
|
||||
func (s *Store) RegistrationInviteRegisterRewardCoins() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.registrationConfig.InviteRegisterRewardCoins
|
||||
}
|
||||
|
||||
// GetRegistrationConfig 返回配置副本(含邀请码列表)。
|
||||
func (s *Store) GetRegistrationConfig() RegistrationConfig {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.registrationConfig
|
||||
out.Invites = append([]InviteEntry(nil), s.registrationConfig.Invites...)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := effectiveRegistrationConfigForAPI(s.registrationConfig)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetRegistrationRequireInvite 更新是否强制邀请码。
|
||||
func (s *Store) SetRegistrationRequireInvite(require bool) error {
|
||||
// effectiveRegistrationConfigForAPI 管理端展示用:禁注列表空时透出当前生效的内置默认文案。
|
||||
func effectiveRegistrationConfigForAPI(in RegistrationConfig) RegistrationConfig {
|
||||
out := RegistrationConfig{
|
||||
RequireInviteCode: in.RequireInviteCode,
|
||||
ForbiddenAccounts: in.ForbiddenAccounts,
|
||||
InviteRegisterRewardCoins: in.InviteRegisterRewardCoins,
|
||||
Invites: append([]InviteEntry(nil), in.Invites...),
|
||||
}
|
||||
if strings.TrimSpace(out.ForbiddenAccounts) == "" {
|
||||
out.ForbiddenAccounts = defaultForbiddenAccountsCSV
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidateSelfServiceAccount 自助注册账号:仅小写字母与数字,长度与禁注表(可配置,空则用内置默认)。
|
||||
func (s *Store) ValidateSelfServiceAccount(account string) error {
|
||||
acc := NormalizeSelfServiceAccount(account)
|
||||
if acc == "" {
|
||||
return errors.New("account is required")
|
||||
}
|
||||
if len(acc) < MinSelfServiceAccountLen || len(acc) > MaxSelfServiceAccountLen {
|
||||
return errors.New("account must be 3-32 characters")
|
||||
}
|
||||
if !selfServiceAccountPattern.MatchString(acc) {
|
||||
return errors.New("account may only contain lowercase letters and digits")
|
||||
}
|
||||
s.mu.RLock()
|
||||
csv := s.registrationConfig.ForbiddenAccounts
|
||||
s.mu.RUnlock()
|
||||
blocked := parseForbiddenAccountSet(effectiveForbiddenAccountsCSV(csv))
|
||||
if _, ok := blocked[acc]; ok {
|
||||
return errors.New("this account name is not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRegistrationPolicy 更新注册策略;inviteRewardCoins 为 nil 时不改库中该项(兼容旧客户端)。
|
||||
func (s *Store) UpdateRegistrationPolicy(require bool, forbiddenAccounts string, inviteRewardCoins *int) error {
|
||||
forbiddenAccounts = strings.TrimSpace(forbiddenAccounts)
|
||||
var policy dbRegistrationPolicy
|
||||
found, err := s.getConfig("registration", &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
policy = dbRegistrationPolicy{}
|
||||
}
|
||||
policy.RequireInviteCode = require
|
||||
policy.ForbiddenAccounts = forbiddenAccounts
|
||||
if inviteRewardCoins != nil {
|
||||
v := *inviteRewardCoins
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
policy.InviteRegisterRewardCoins = &v
|
||||
}
|
||||
if err := s.setConfig("registration", policy); err != nil {
|
||||
return err
|
||||
}
|
||||
rewardEff := effectiveInviteRegisterRewardCoins(policy.InviteRegisterRewardCoins)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.registrationConfig.RequireInviteCode = require
|
||||
return s.persistRegistrationConfigLocked()
|
||||
s.registrationConfig.ForbiddenAccounts = forbiddenAccounts
|
||||
s.registrationConfig.InviteRegisterRewardCoins = rewardEff
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func inviteEntryValid(e *InviteEntry) error {
|
||||
@@ -93,14 +231,14 @@ func inviteEntryValid(e *InviteEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateInviteForRegister 校验邀请码是否可用(发验证码前,不扣次)。
|
||||
// ValidateInviteForRegister 校验邀请码是否可用(不扣次)。
|
||||
func (s *Store) ValidateInviteForRegister(code string) error {
|
||||
n := normalizeInviteCode(code)
|
||||
if n == "" {
|
||||
return errors.New("invite code is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := range s.registrationConfig.Invites {
|
||||
e := &s.registrationConfig.Invites[i]
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
@@ -110,14 +248,16 @@ func (s *Store) ValidateInviteForRegister(code string) error {
|
||||
return errors.New("invalid invite code")
|
||||
}
|
||||
|
||||
// RedeemInvite 邮箱验证通过创建用户后扣减邀请码使用次数。
|
||||
// RedeemInvite 邮箱验证通过后扣减邀请码使用次数。
|
||||
func (s *Store) RedeemInvite(code string) error {
|
||||
n := normalizeInviteCode(code)
|
||||
if n == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i := range s.registrationConfig.Invites {
|
||||
e := &s.registrationConfig.Invites[i]
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
@@ -125,7 +265,10 @@ func (s *Store) RedeemInvite(code string) error {
|
||||
return err
|
||||
}
|
||||
e.Uses++
|
||||
return s.persistRegistrationConfigLocked()
|
||||
// 写回数据库
|
||||
return s.db.Model(&DBInviteCode{}).
|
||||
Where("code = ?", e.Code).
|
||||
Update("uses", e.Uses).Error
|
||||
}
|
||||
}
|
||||
return errors.New("invalid invite code")
|
||||
@@ -146,10 +289,11 @@ func randomInviteToken(n int) (string, error) {
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// AddInviteEntry 生成新邀请码并写入配置。
|
||||
// AddInviteEntry 生成新邀请码并写入数据库。
|
||||
func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (InviteEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var code string
|
||||
for attempt := 0; attempt < 24; attempt++ {
|
||||
c, err := randomInviteToken(8)
|
||||
@@ -171,6 +315,7 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
|
||||
if code == "" {
|
||||
return InviteEntry{}, errors.New("failed to generate unique invite code")
|
||||
}
|
||||
|
||||
expiresAt = strings.TrimSpace(expiresAt)
|
||||
if expiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339, expiresAt); err != nil {
|
||||
@@ -180,6 +325,7 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
|
||||
if maxUses < 0 {
|
||||
maxUses = 0
|
||||
}
|
||||
|
||||
entry := InviteEntry{
|
||||
Code: code,
|
||||
Note: strings.TrimSpace(note),
|
||||
@@ -188,11 +334,13 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: models.NowISO(),
|
||||
}
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites, entry)
|
||||
if err := s.persistRegistrationConfigLocked(); err != nil {
|
||||
s.registrationConfig.Invites = s.registrationConfig.Invites[:len(s.registrationConfig.Invites)-1]
|
||||
|
||||
row := dbInviteFromEntry(entry)
|
||||
if err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return InviteEntry{}, err
|
||||
}
|
||||
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites, entry)
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
@@ -202,13 +350,40 @@ func (s *Store) DeleteInviteEntry(code string) error {
|
||||
if n == "" {
|
||||
return errors.New("code is required")
|
||||
}
|
||||
|
||||
result := s.db.Where("UPPER(code) = ?", n).Delete(&DBInviteCode{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("invite not found")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, e := range s.registrationConfig.Invites {
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites[:i], s.registrationConfig.Invites[i+1:]...)
|
||||
return s.persistRegistrationConfigLocked()
|
||||
s.registrationConfig.Invites = append(
|
||||
s.registrationConfig.Invites[:i],
|
||||
s.registrationConfig.Invites[i+1:]...,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
return errors.New("invite not found")
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshInviteCache 重新从数据库加载邀请码列表(内部用)。
|
||||
func (s *Store) refreshInviteCache() error {
|
||||
invites, err := s.loadAllInvites()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.registrationConfig.Invites = invites
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 确保 gorm 包被使用
|
||||
var _ = gorm.ErrRecordNotFound
|
||||
|
||||
@@ -1,55 +1,31 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SaveReset(record models.ResetPassword) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(record.Account)
|
||||
return writeJSONFile(path, record)
|
||||
row := dbResetFromModel(record)
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) GetReset(account string) (models.ResetPassword, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBResetPassword
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.ResetPassword{}, false, nil
|
||||
}
|
||||
var record models.ResetPassword
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.ResetPassword{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.ResetPassword{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toModel(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteReset(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) resetFilePath(account string) string {
|
||||
return filepath.Join(s.resetDir, resetFileName(account))
|
||||
}
|
||||
|
||||
func resetFileName(account string) string {
|
||||
safe := strings.TrimSpace(account)
|
||||
if safe == "" {
|
||||
safe = "unknown"
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(safe))
|
||||
return encoded + ".json"
|
||||
return s.db.Delete(&DBResetPassword{}, "account = ?", account).Error
|
||||
}
|
||||
|
||||
@@ -1,60 +1,31 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SaveSecondaryVerification(record models.SecondaryEmailVerification) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(record.Account, record.Email)
|
||||
return writeJSONFile(path, record)
|
||||
row := dbSecondaryFromModel(record)
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) GetSecondaryVerification(account string, email string) (models.SecondaryEmailVerification, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(account, email)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBSecondaryEmailVerification
|
||||
result := s.db.First(&row, "account = ? AND email = ?", account, email)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.SecondaryEmailVerification{}, false, nil
|
||||
}
|
||||
var record models.SecondaryEmailVerification
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.SecondaryEmailVerification{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.SecondaryEmailVerification{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toModel(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSecondaryVerification(account string, email string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(account, email)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) secondaryFilePath(account string, email string) string {
|
||||
return filepath.Join(s.secondaryDir, secondaryFileName(account, email))
|
||||
}
|
||||
|
||||
func secondaryFileName(account string, email string) string {
|
||||
accountSafe := strings.TrimSpace(account)
|
||||
emailSafe := strings.TrimSpace(email)
|
||||
if accountSafe == "" {
|
||||
accountSafe = "unknown"
|
||||
}
|
||||
if emailSafe == "" {
|
||||
emailSafe = "unknown"
|
||||
}
|
||||
raw := accountSafe + "::" + emailSafe
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(raw))
|
||||
return encoded + ".json"
|
||||
return s.db.Delete(&DBSecondaryEmailVerification{}, "account = ? AND email = ?", account, email).Error
|
||||
}
|
||||
|
||||
@@ -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