feat: 更新SproutGate前后端代码

This commit is contained in:
2026-04-01 22:04:01 +08:00
parent 90590c7cb0
commit 650e1c7707
49 changed files with 3609 additions and 768 deletions

View File

@@ -8,17 +8,19 @@ import (
)
type Claims struct {
Account string `json:"account"`
Account string `json:"account"`
TokenEpoch int64 `json:"epoch"` // 与 users.token_epoch 一致;管理员封禁等操作会递增,旧 JWT 全部作废
jwt.RegisteredClaims
}
func GenerateToken(secret []byte, issuer string, account string, ttl time.Duration) (string, time.Time, error) {
func GenerateToken(secret []byte, issuer string, account string, tokenEpoch int64, ttl time.Duration) (string, time.Time, error) {
if account == "" {
return "", time.Time{}, errors.New("account is required")
}
expiresAt := time.Now().Add(ttl)
claims := Claims{
Account: account,
Account: account,
TokenEpoch: tokenEpoch,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: account,

View File

@@ -0,0 +1,84 @@
package database
import (
"fmt"
"log"
"os"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// DBConfig holds MySQL connection parameters.
type DBConfig struct {
Host string
Port string
User string
Password string
DBName string
}
func (c DBConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
c.User, c.Password, c.Host, c.Port, c.DBName)
}
func devConfig() DBConfig {
return DBConfig{
Host: "10.1.1.100",
Port: "3306",
User: "sproutgate-test",
Password: "sproutgate-test",
DBName: "sproutgate-test",
}
}
func prodConfig() DBConfig {
return DBConfig{
Host: "192.168.1.100",
Port: "3306",
User: "sproutgate",
Password: "sproutgate",
DBName: "sproutgate",
}
}
// Open 根据环境变量打开数据库连接。
// 优先级DB_DSN > APP_ENV(production/prod -> 生产库) > 默认开发库
func Open() (*gorm.DB, error) {
dsn := os.Getenv("DB_DSN")
if dsn == "" {
env := os.Getenv("APP_ENV")
if env == "production" || env == "prod" {
dsn = prodConfig().DSN()
log.Println("[database] 使用生产数据库 192.168.1.100:3306/sproutgate")
} else {
dsn = devConfig().DSN()
log.Println("[database] 使用开发数据库 10.1.1.100:3306/sproutgate-test")
}
} else {
log.Println("[database] 使用自定义 DB_DSN")
}
logLevel := logger.Info
if os.Getenv("GIN_MODE") == "release" {
logLevel = logger.Warn
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logLevel),
})
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
return db, nil
}

View File

@@ -23,30 +23,6 @@ func (h *Handler) ListUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers})
}
func (h *Handler) GetPublicUser(c *gin.Context) {
account := strings.TrimSpace(c.Param("account"))
if account == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
return
}
users, err := h.store.ListUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
return
}
for _, user := range users {
if strings.EqualFold(strings.TrimSpace(user.Account), account) {
if user.Banned {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, gin.H{"user": user.PublicProfile()})
return
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
}
func (h *Handler) CreateUser(c *gin.Context) {
var req createUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -151,7 +127,11 @@ func (h *Handler) UpdateUser(c *gin.Context) {
user.Bio = *req.Bio
}
if req.Banned != nil {
wasBanned := user.Banned
user.Banned = *req.Banned
if user.Banned && !wasBanned {
user.TokenEpoch++
}
if !user.Banned {
user.BanReason = ""
user.BannedAt = ""

View File

@@ -43,7 +43,7 @@ func (h *Handler) Login(c *gin.Context) {
writeBanJSON(c, user.BanReason)
return
}
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour)
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, user.TokenEpoch, 7*24*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
@@ -82,11 +82,10 @@ func (h *Handler) Verify(c *gin.Context) {
return
}
if user.Banned {
h := gin.H{"valid": false, "error": "account is banned"}
if r := strings.TrimSpace(user.BanReason); r != "" {
h["banReason"] = r
}
c.JSON(http.StatusOK, h)
writeBanJSON(c, user.BanReason)
return
}
if abortIfTokenEpochStale(c, claims, user) {
return
}
if cid, cname, ok := authClientFromHeaders(c); ok {
@@ -118,6 +117,9 @@ func (h *Handler) Me(c *gin.Context) {
if abortIfUserBanned(c, user) {
return
}
if abortIfTokenEpochStale(c, claims, user) {
return
}
if cid, cname, ok := authClientFromHeaders(c); ok {
if rec, err := h.store.RecordAuthClient(claims.Account, cid, cname); err == nil {
user = rec

View File

@@ -11,6 +11,7 @@ import (
"sproutgate-backend/internal/email"
"sproutgate-backend/internal/models"
"sproutgate-backend/internal/storage"
)
func (h *Handler) Register(c *gin.Context) {
@@ -19,13 +20,17 @@ func (h *Handler) Register(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
req.Account = storage.NormalizeSelfServiceAccount(req.Account)
req.Email = strings.TrimSpace(req.Email)
inviteTrim := strings.TrimSpace(req.InviteCode)
if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"})
return
}
if err := h.store.ValidateSelfServiceAccount(req.Account); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
requireInv := h.store.RegistrationRequireInvite()
if requireInv && inviteTrim == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invite code is required"})
@@ -89,12 +94,16 @@ func (h *Handler) VerifyEmail(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
req.Account = strings.TrimSpace(req.Account)
req.Account = storage.NormalizeSelfServiceAccount(req.Account)
req.Code = strings.TrimSpace(req.Code)
if req.Account == "" || req.Code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account and code are required"})
return
}
if err := h.store.ValidateSelfServiceAccount(req.Account); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pending, found, err := h.store.GetPending(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"})
@@ -114,13 +123,17 @@ func (h *Handler) VerifyEmail(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
return
}
sproutCoins := 0
if strings.TrimSpace(pending.InviteCode) != "" {
sproutCoins = h.store.RegistrationInviteRegisterRewardCoins()
}
record := models.UserRecord{
Account: pending.Account,
PasswordHash: pending.PasswordHash,
Username: pending.Username,
Email: pending.Email,
Level: 0,
SproutCoins: 0,
SproutCoins: sproutCoins,
SecondaryEmails: []string{},
CreatedAt: models.NowISO(),
UpdatedAt: models.NowISO(),

View File

@@ -35,6 +35,9 @@ func (h *Handler) CheckIn(c *gin.Context) {
if abortIfUserBanned(c, userPre) {
return
}
if abortIfTokenEpochStale(c, claims, userPre) {
return
}
today := models.CurrentActivityDate()
nowAt := models.CurrentActivityTime()
user, reward, alreadyCheckedIn, err := h.store.CheckIn(claims.Account, today, nowAt)

View File

@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/models"
)
@@ -54,13 +55,22 @@ func verifyCode(code string, hash string) bool {
}
func writeBanJSON(c *gin.Context, reason string) {
h := gin.H{"error": "account is banned"}
h := gin.H{"error": "account is banned", "valid": false}
if r := strings.TrimSpace(reason); r != "" {
h["banReason"] = r
}
c.JSON(http.StatusForbidden, h)
}
// abortIfTokenEpochStale 若 JWT 内 epoch 与用户当前 token_epoch 不一致,则拒绝(例如管理员已封禁并递增 epoch
func abortIfTokenEpochStale(c *gin.Context, claims *auth.Claims, u models.UserRecord) bool {
if claims.TokenEpoch == u.TokenEpoch {
return false
}
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "token revoked"})
return true
}
func abortIfUserBanned(c *gin.Context, u models.UserRecord) bool {
if !u.Banned {
return false

View File

@@ -38,6 +38,9 @@ func (h *Handler) UpdateProfile(c *gin.Context) {
if abortIfUserBanned(c, user) {
return
}
if abortIfTokenEpochStale(c, claims, user) {
return
}
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {

View File

@@ -0,0 +1,154 @@
package handlers
import (
"errors"
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/models"
"sproutgate-backend/internal/storage"
)
// ListPublicUsers 公开用户目录(未封禁用户,默认按注册时间从早到晚)。
func (h *Handler) ListPublicUsers(c *gin.Context) {
users, err := h.store.ListUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
return
}
out := make([]models.PublicUserListEntry, 0, len(users))
for _, u := range users {
if u.Banned {
continue
}
out = append(out, u.PublicListEntry())
}
sort.Slice(out, func(i, j int) bool {
return strings.TrimSpace(out[i].CreatedAt) < strings.TrimSpace(out[j].CreatedAt)
})
c.JSON(http.StatusOK, gin.H{"total": len(out), "users": out})
}
func (h *Handler) GetPublicUser(c *gin.Context) {
account := strings.TrimSpace(c.Param("account"))
if account == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
return
}
users, err := h.store.ListUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
return
}
for _, user := range users {
if strings.EqualFold(strings.TrimSpace(user.Account), account) {
if user.Banned {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
likeCount, err := h.store.CountProfileLikes(user.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load likes"})
return
}
payload := gin.H{
"user": user.PublicProfile(),
"profileLikeCount": likeCount,
}
if token := bearerToken(c.GetHeader("Authorization")); token != "" {
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
if err == nil {
viewer, vFound, vErr := h.store.GetUser(claims.Account)
if vErr == nil && vFound && !viewer.Banned && claims.TokenEpoch == viewer.TokenEpoch {
if strings.EqualFold(viewer.Account, user.Account) {
payload["viewerIsOwner"] = true
} else {
has, _ := h.store.ViewerHasLikedProfileToday(viewer.Account, user.Account)
payload["viewerHasLikedToday"] = has
rem, _ := h.store.ProfileLikeRemainingToday(viewer.Account)
payload["viewerLikesRemainingToday"] = rem
payload["profileLikeDailyMax"] = storage.MaxProfileLikesPerDay
}
}
}
}
c.JSON(http.StatusOK, payload)
return
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
}
func (h *Handler) PostPublicProfileLike(c *gin.Context) {
likedParam := strings.TrimSpace(c.Param("account"))
if likedParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
return
}
token := bearerToken(c.GetHeader("Authorization"))
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
likerUser, found, err := h.store.GetUser(claims.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !found {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
if abortIfUserBanned(c, likerUser) {
return
}
if abortIfTokenEpochStale(c, claims, likerUser) {
return
}
newCount, err := h.store.AddProfileLike(likerUser.Account, likedParam)
if err != nil {
switch {
case errors.Is(err, storage.ErrCannotLikeOwnProfile):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
case errors.Is(err, storage.ErrProfileLikeTarget):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
case errors.Is(err, storage.ErrAlreadyLikedToday):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
case errors.Is(err, storage.ErrDailyLikeLimitReached):
rem, _ := h.store.ProfileLikeRemainingToday(likerUser.Account)
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
"viewerLikesRemainingToday": rem,
"profileLikeDailyMax": storage.MaxProfileLikesPerDay,
})
return
case errors.Is(err, storage.ErrProfileLikeLiker):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
rem, _ := h.store.ProfileLikeRemainingToday(likerUser.Account)
c.JSON(http.StatusOK, gin.H{
"profileLikeCount": newCount,
"viewerHasLikedToday": true,
"viewerLikesRemainingToday": rem,
"profileLikeDailyMax": storage.MaxProfileLikesPerDay,
})
}

View File

@@ -4,11 +4,17 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/storage"
)
// GetPublicRegistrationPolicy 公开:是否必须邀请码(不含具体邀请码)。
// GetPublicRegistrationPolicy 公开:是否必须邀请码、自助注册账号规则说明(不含具体邀请码)。
func (h *Handler) GetPublicRegistrationPolicy(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"requireInviteCode": h.store.RegistrationRequireInvite(),
"requireInviteCode": h.store.RegistrationRequireInvite(),
"inviteRegisterRewardCoins": h.store.RegistrationInviteRegisterRewardCoins(),
"selfServiceAccountMin": storage.MinSelfServiceAccountLen,
"selfServiceAccountMax": storage.MaxSelfServiceAccountLen,
"selfServiceAccountHint": "lowercase letters and digits only",
})
}

View File

@@ -10,8 +10,10 @@ import (
func (h *Handler) GetAdminRegistration(c *gin.Context) {
cfg := h.store.GetRegistrationConfig()
c.JSON(http.StatusOK, gin.H{
"requireInviteCode": cfg.RequireInviteCode,
"invites": cfg.Invites,
"requireInviteCode": cfg.RequireInviteCode,
"forbiddenAccounts": cfg.ForbiddenAccounts,
"inviteRegisterRewardCoins": cfg.InviteRegisterRewardCoins,
"invites": cfg.Invites,
})
}
@@ -21,11 +23,16 @@ func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if err := h.store.SetRegistrationRequireInvite(req.RequireInviteCode); err != nil {
if err := h.store.UpdateRegistrationPolicy(req.RequireInviteCode, req.ForbiddenAccounts, req.InviteRegisterRewardCoins); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save registration policy"})
return
}
c.JSON(http.StatusOK, gin.H{"requireInviteCode": req.RequireInviteCode})
cfg := h.store.GetRegistrationConfig()
c.JSON(http.StatusOK, gin.H{
"requireInviteCode": cfg.RequireInviteCode,
"forbiddenAccounts": cfg.ForbiddenAccounts,
"inviteRegisterRewardCoins": cfg.InviteRegisterRewardCoins,
})
}
func (h *Handler) PostAdminInvite(c *gin.Context) {

View File

@@ -64,7 +64,9 @@ type updateCheckInConfigRequest struct {
}
type updateRegistrationPolicyRequest struct {
RequireInviteCode bool `json:"requireInviteCode"`
RequireInviteCode bool `json:"requireInviteCode"`
ForbiddenAccounts string `json:"forbiddenAccounts"`
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins"` // nil 表示不修改此项
}
type createInviteRequest struct {

View File

@@ -46,6 +46,9 @@ func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
if abortIfUserBanned(c, user) {
return
}
if abortIfTokenEpochStale(c, claims, user) {
return
}
if strings.TrimSpace(user.Email) == emailAddr {
c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"})
return
@@ -137,6 +140,9 @@ func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
if abortIfUserBanned(c, user) {
return
}
if abortIfTokenEpochStale(c, claims, user) {
return
}
for _, e := range user.SecondaryEmails {
if e == emailAddr {
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)

View File

@@ -0,0 +1,55 @@
package models
import "strings"
func qqNumericLocalPart(email string) string {
e := strings.TrimSpace(strings.ToLower(email))
if e == "" {
return ""
}
at := strings.LastIndex(e, "@")
if at <= 0 || at >= len(e)-1 {
return ""
}
local, domain := e[:at], e[at+1:]
if domain != "qq.com" {
return ""
}
if local == "" {
return ""
}
for _, r := range local {
if r < '0' || r > '9' {
return ""
}
}
return local
}
func firstQQUINFromRecord(u UserRecord) string {
if q := qqNumericLocalPart(u.Email); q != "" {
return q
}
for _, em := range u.SecondaryEmails {
if q := qqNumericLocalPart(em); q != "" {
return q
}
}
return ""
}
func qqAvatarHeadimgURL(uin string) string {
return "https://q.qlogo.cn/headimg_dl?dst_uin=" + uin + "&spec=640&img_type=jpg"
}
// ResolvedAvatarURL 返回用户自选头像;若未设置但主邮箱或辅助邮箱为「纯数字 @qq.com」
// 则返回 QQ 头像 CDN 地址(与手动填写该链接等价,不入库)。
func (u UserRecord) ResolvedAvatarURL() string {
if s := strings.TrimSpace(u.AvatarURL); s != "" {
return s
}
if uin := firstQQUINFromRecord(u); uin != "" {
return qqAvatarHeadimgURL(uin)
}
return ""
}

View File

@@ -30,6 +30,7 @@ type UserRecord struct {
Banned bool `json:"banned"`
BanReason string `json:"banReason,omitempty"`
BannedAt string `json:"bannedAt,omitempty"`
TokenEpoch int64 `json:"-"` // 递增使用户此前签发的 JWT 全局失效;不对外 JSON 序列化
AuthClients []AuthClientEntry `json:"authClients,omitempty"`
}
@@ -52,6 +53,7 @@ type UserPublic struct {
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
Phone string `json:"phone,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
CustomAvatarURL string `json:"customAvatarUrl,omitempty"` // 用户自选头像链接;为空时 avatarUrl 可能为 QQ 邮箱推断头像
WebsiteURL string `json:"websiteUrl,omitempty"`
Bio string `json:"bio,omitempty"`
CreatedAt string `json:"createdAt"`
@@ -85,13 +87,14 @@ func (u UserRecord) Public() UserPublic {
LastVisitAt: lastVisitAt,
VisitDays: visitDays,
VisitStreak: visitStreak,
SecondaryEmails: u.SecondaryEmails,
Phone: u.Phone,
AvatarURL: u.AvatarURL,
WebsiteURL: u.WebsiteURL,
Bio: u.Bio,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
SecondaryEmails: u.SecondaryEmails,
Phone: u.Phone,
AvatarURL: u.ResolvedAvatarURL(),
CustomAvatarURL: strings.TrimSpace(u.AvatarURL),
WebsiteURL: u.WebsiteURL,
Bio: u.Bio,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
@@ -133,12 +136,25 @@ func (u UserRecord) Showcase() UserShowcase {
Username: u.Username,
Level: u.Level,
SproutCoins: u.SproutCoins,
AvatarURL: u.AvatarURL,
AvatarURL: u.ResolvedAvatarURL(),
WebsiteURL: u.WebsiteURL,
Bio: u.Bio,
}
}
// PublicUserListEntry 公开用户目录条目(含注册时间供排序与展示)。
type PublicUserListEntry struct {
UserShowcase
CreatedAt string `json:"createdAt"`
}
func (u UserRecord) PublicListEntry() PublicUserListEntry {
return PublicUserListEntry{
UserShowcase: u.Showcase(),
CreatedAt: u.CreatedAt,
}
}
func NowISO() string {
return time.Now().Format(time.RFC3339)
}

View 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,
}
}

View File

@@ -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 "" }

View 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(&quota).Error
if errors.Is(qErr, gorm.ErrRecordNotFound) {
quota = DBProfileLikeDailyQuota{
LikerAccount: likerCanon,
LikeDate: today,
Remaining: MaxProfileLikesPerDay,
UpdatedAt: models.NowISO(),
}
if cerr := tx.Create(&quota).Error; cerr != nil {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("liker_account = ? AND like_date = ?", likerCanon, today).
First(&quota).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(&quota).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)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}