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

@@ -1,13 +1,3 @@
{
"mcpServers": {
"CursorMCP对话插件": {
"command": "node",
"args": [
"c:\\Users\\smy\\.cursor\\extensions\\local.xw-cursor-message-8.8.8\\dist\\mcp-server.mjs"
],
"env": {
"MESSENGER_DATA_DIR": "C:\\Users\\smy\\.moyu-message\\703c70e63617"
}
}
}
"mcpServers": {}
}

View File

@@ -1,27 +0,0 @@
---
description: "自动调用 Messenger MCP 检查待发送消息"
globs: "**/*"
alwaysApply: true
---
# MCP 工具调用规则
1. 每轮对话回复**完整输出后**,最后一步必须调用 `check_messages`,适用于 Agent/Plan/Debug/Ask 所有模式,无一例外
2. `check_messages` 会持续等待直到用户发来新消息;收到后处理并回复,然后再次调用 `check_messages`,形成永续循环:回复 → check_messages → 收到消息 → 回复 → check_messages
3. 严禁在回合开始时或回复未完成时调用 `check_messages`;唯一豁免:用户明确要求结束对话
4. Plan 模式写完方案后、Debug 模式分析完问题后、Ask 模式回答完问题后,均不得直接结束对话,必须调用 `check_messages` 等待用户反馈
5. 需要用户做选择或确认时(选方案、选框架、确认操作等),必须调用 `ask_question`;严禁使用 Cursor 内置的 ask_user_question
6. `ask_question` 参数格式: `{ "questions": [{ "question": "问题文本", "options": [{ "id": "a", "label": "选项A" }, { "id": "b", "label": "选项B" }], "allow_multiple": false }] }`
7. 单选: `allow_multiple: false`(用户只能选一个);多选: `allow_multiple: true`(用户可选多个);同一 `questions` 数组可混合包含单选题和多选题
8. 用户在每道题中均可额外输入自定义补充文本Other 输入框),无需为此添加额外选项
9. `ask_question` 收到用户回答后,处理完毕仍需调用 `check_messages` 继续监听
10. 禁止在每轮结束时用 `ask_question` 问"还有别的需要吗",回合结束只用 `check_messages``ask_question` 仅限任务中确实需要用户决策时使用
11. `check_messages` 支持可选参数 `reply`stringMarkdown 格式):调用时传入本轮回复的摘要/结论,插件界面会弹出模态框展示给用户。格式: `{ "reply": "## 结论\n修改完成..." }`
12. 当会话结束前,建议在调用 `check_messages` 时附带 `reply` 参数,将关键结论推送到插件界面;如果本轮无需特别总结,`reply` 可省略
13. 禁止调用subagent并发任务
14. 如果 `check_messages` 或 `ask_question` 因客户端取消、连接抖动或内部异常而意外返回/报错,且用户未明确要求结束对话,严禁把这类内部提示直接输出给用户,必须立刻再次调用对应工具继续等待
15. 执行多步任务时,每完成一个关键步骤后,必须调用 `send_progress` 推送进度摘要到远程控制台。格式: `{ "progress": "## 步骤1完成\n已修改xxx文件..." }`。`send_progress` 立即返回,不会等待消息,调用后继续执行下一步
16. `send_progress` 的 `progress` 参数应包含:已完成的操作、当前方案、下一步计划。使用简洁的 Markdown 格式
---
**回合结束(强制)**: 仅当回复**完整输出完毕后**,最后一步必须调用 CursorMCP对话插件 MCP 的 `check_messages` 工具(使用 CallMcpToolserver 为 CursorMCP对话插件 对应的 identifiertoolName 为 "check_messages"arguments 为 {})。严禁在回合开始或回复完成前调用。`ask_question` 仅用于任务中需要用户做选择时(如选框架、选方案)。

View File

@@ -0,0 +1,218 @@
// migrate 将旧版 JSON 文件数据迁移到 MySQL 数据库。
//
// 用法:
//
// go run ./cmd/migrate [--data-dir ./data]
//
// 环境变量(与主程序相同):
//
// APP_ENV=production 使用生产数据库(默认使用开发测试库)
// DB_DSN 自定义 DSN优先级最高
package main
import (
"encoding/base64"
"encoding/json"
"flag"
"log"
"os"
"path/filepath"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sproutgate-backend/internal/database"
"sproutgate-backend/internal/models"
"sproutgate-backend/internal/storage"
)
func main() {
dataDir := flag.String("data-dir", "./data", "旧版 JSON 数据目录路径")
flag.Parse()
absDir, err := filepath.Abs(*dataDir)
if err != nil {
log.Fatalf("路径解析失败: %v", err)
}
log.Printf("数据目录: %s", absDir)
db, err := database.Open()
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
// 自动迁移表结构
if err := db.AutoMigrate(
&storage.DBUser{},
&storage.DBPendingUser{},
&storage.DBResetPassword{},
&storage.DBSecondaryEmailVerification{},
&storage.DBAppConfig{},
&storage.DBInviteCode{},
); err != nil {
log.Fatalf("表结构同步失败: %v", err)
}
log.Println("✓ 表结构已同步")
migrateAdminConfig(db, absDir)
migrateAuthConfig(db, absDir)
migrateEmailConfig(db, absDir)
migrateCheckinConfig(db, absDir)
migrateRegistrationConfig(db, absDir)
migrateUsers(db, absDir)
log.Println("\n✅ 数据迁移完成!")
}
// ── 辅助函数 ──────────────────────────────────────────────────────────────────
func readJSONFile(path string, target any) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(raw, target)
}
// putConfig 将任意值序列化为 JSON 后写入 app_configs冲突时整体覆盖
func putConfig(db *gorm.DB, key string, value any) {
raw, err := json.Marshal(value)
if err != nil {
log.Printf(" ✗ 序列化 [%s] 失败: %v", key, err)
return
}
row := storage.DBAppConfig{ConfigKey: key, ConfigValue: string(raw)}
if err := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error; err != nil {
log.Printf(" ✗ app_configs[%s] 失败: %v", key, err)
} else {
log.Printf(" ✓ app_configs[%s]", key)
}
}
// ── 各节配置迁移 ──────────────────────────────────────────────────────────────
func migrateAdminConfig(db *gorm.DB, dataDir string) {
path := filepath.Join(dataDir, "config", "admin.json")
var cfg storage.AdminConfig
if err := readJSONFile(path, &cfg); err != nil {
log.Printf("[admin] 跳过(%v", err)
return
}
putConfig(db, "admin", cfg)
}
func migrateAuthConfig(db *gorm.DB, dataDir string) {
path := filepath.Join(dataDir, "config", "auth.json")
var cfg storage.AuthConfig
if err := readJSONFile(path, &cfg); err != nil {
log.Printf("[auth] 跳过(%v", err)
return
}
putConfig(db, "auth", cfg)
}
func migrateEmailConfig(db *gorm.DB, dataDir string) {
path := filepath.Join(dataDir, "config", "email.json")
var cfg storage.EmailConfig
if err := readJSONFile(path, &cfg); err != nil {
log.Printf("[email] 跳过(%v", err)
return
}
putConfig(db, "email", cfg)
}
func migrateCheckinConfig(db *gorm.DB, dataDir string) {
path := filepath.Join(dataDir, "config", "checkin.json")
var cfg storage.CheckInConfig
if err := readJSONFile(path, &cfg); err != nil {
log.Printf("[checkin] 跳过(%v", err)
return
}
putConfig(db, "checkin", cfg)
}
type oldRegistrationConfig struct {
RequireInviteCode bool `json:"requireInviteCode"`
Invites []storage.InviteEntry `json:"invites"`
}
type registrationPolicy struct {
RequireInviteCode bool `json:"requireInviteCode"`
ForbiddenAccounts string `json:"forbiddenAccounts"`
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins,omitempty"`
}
func migrateRegistrationConfig(db *gorm.DB, dataDir string) {
path := filepath.Join(dataDir, "config", "registration.json")
var old oldRegistrationConfig
if err := readJSONFile(path, &old); err != nil {
log.Printf("[registration] 跳过(%v", err)
return
}
putConfig(db, "registration", registrationPolicy{RequireInviteCode: old.RequireInviteCode})
for _, entry := range old.Invites {
row := storage.DBInviteCode{
Code: entry.Code,
Note: entry.Note,
MaxUses: entry.MaxUses,
Uses: entry.Uses,
ExpiresAt: entry.ExpiresAt,
CreatedAt: entry.CreatedAt,
}
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
log.Printf(" ✗ 邀请码 %s 失败: %v", entry.Code, err)
} else {
log.Printf(" ✓ 邀请码 %s", entry.Code)
}
}
}
func migrateUsers(db *gorm.DB, dataDir string) {
usersDir := filepath.Join(dataDir, "users")
entries, err := os.ReadDir(usersDir)
if err != nil {
log.Printf("[users] 读取目录失败: %v", err)
return
}
total, ok, skipped := 0, 0, 0
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
total++
path := filepath.Join(usersDir, entry.Name())
var record models.UserRecord
if err := readJSONFile(path, &record); err != nil {
log.Printf(" ✗ 读取 %s 失败: %v", entry.Name(), err)
skipped++
continue
}
// 优先用 JSON 中的 account 字段没有则从文件名base64解码
if strings.TrimSpace(record.Account) == "" {
name := strings.TrimSuffix(entry.Name(), ".json")
decoded, decErr := base64.RawURLEncoding.DecodeString(name)
if decErr != nil || len(decoded) == 0 {
log.Printf(" ✗ 无法确定账号,跳过 %s", entry.Name())
skipped++
continue
}
record.Account = string(decoded)
}
row := storage.DBUserFromRecord(record)
if err := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error; err != nil {
log.Printf(" ✗ 用户 %s 失败: %v", record.Account, err)
skipped++
} else {
log.Printf(" ✓ 用户 %s", record.Account)
ok++
}
}
log.Printf("[users] 合计 %d 个,成功 %d跳过 %d", total, ok, skipped)
}

View File

@@ -10,6 +10,7 @@ require (
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
@@ -19,7 +20,10 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
@@ -33,7 +37,9 @@ require (
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
)

View File

@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@@ -25,12 +27,18 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -83,6 +91,8 @@ golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
@@ -91,5 +101,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

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
}

View File

@@ -9,13 +9,18 @@ import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/database"
"sproutgate-backend/internal/handlers"
"sproutgate-backend/internal/storage"
)
func main() {
dataDir := os.Getenv("DATA_DIR")
store, err := storage.NewStore(dataDir)
db, err := database.Open()
if err != nil {
log.Fatalf("初始化数据库失败: %v", err)
}
store, err := storage.NewStore(db)
if err != nil {
log.Fatalf("初始化储存失败: %v", err)
}
@@ -36,12 +41,11 @@ func main() {
"description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。",
"version": "0.1.0",
"links": gin.H{
"apiDocs": "GET /api/docs — Markdown 接口说明(本仓库 API_DOCS.md",
"health": "GET /api/health",
"health": "GET /api/health",
},
"routePrefixes": []string{
"/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入",
"/api/public — 公开用户资料、注册策略(是否强制邀请码)",
"/api/public — 公开用户目录与资料、主页点赞、注册策略",
"/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token",
},
}
@@ -53,14 +57,15 @@ func main() {
})
router.GET("/api/health", func(c *gin.Context) {
env := os.Getenv("APP_ENV")
if env == "" {
env = "development"
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"dataDir": store.DataDir(),
"status": "ok",
"env": env,
})
})
router.GET("/api/docs", func(c *gin.Context) {
c.File("API_DOCS.md")
})
router.POST("/api/auth/login", handler.Login)
router.POST("/api/auth/register", handler.Register)
@@ -73,7 +78,9 @@ func main() {
router.GET("/api/auth/me", handler.Me)
router.POST("/api/auth/check-in", handler.CheckIn)
router.PUT("/api/auth/profile", handler.UpdateProfile)
router.GET("/api/public/users", handler.ListPublicUsers)
router.GET("/api/public/users/:account", handler.GetPublicUser)
router.POST("/api/public/users/:account/like", handler.PostPublicProfileLike)
router.GET("/api/public/registration-policy", handler.GetPublicRegistrationPolicy)
admin := router.Group("/api/admin")

View File

@@ -0,0 +1,110 @@
# SproutGate 后端文档
## 技术栈
- **语言**Go 1.20+
- **Web 框架**Gin跨域中间件 **`github.com/gin-contrib/cors`**
- **数据存储**MySQL通过 GORM + `gorm.io/driver/mysql`
- **JWT**`github.com/golang-jwt/jwt/v5`
- **密码哈希**`golang.org/x/crypto/bcrypt`
业务数据与配置均保存在 MySQL 中,**不再使用** `data/users/*.json``data/config/*.json` 作为运行时数据源。仓库中的 `data/` 仅可作迁移脚本输入或本地备份参考。
## 目录结构(要点)
| 路径 | 说明 |
|------|------|
| `main.go` | 入口:连接数据库、初始化 `Store`、注册路由 |
| `internal/database/` | MySQL 连接与环境选择(`DB_DSN` / `APP_ENV` |
| `internal/handlers/` | HTTP 处理:认证、资料、签到、公开页、管理端等 |
| `internal/models/` | 领域模型(用户、待激活、重置密码、辅助邮箱等) |
| `internal/storage/` | 持久化:`Store` + GORM 模型与业务方法 |
| `internal/auth/` | JWT 签发与校验 |
| `internal/email/` | 发信 |
| `cmd/migrate/` | 一次性工具:将旧版 JSON `data/` 导入 MySQL |
## 环境与数据库
连接字符串优先级:**`DB_DSN` > 内置规则**。
1. **若设置 `DB_DSN`**:直接使用该完整 DSN适合 CI、容器或临时切换
2. **否则**:根据 `APP_ENV` 选择内置库:
- `APP_ENV=production``prod` → 生产库:`192.168.1.100:3306` / 库名 `sproutgate` / 用户 `sproutgate`
- 未设置或其它值 → 开发/测试库:`10.1.1.100:3306` / 库名 `sproutgate-test` / 用户 `sproutgate-test`
其它常用环境变量:
| 变量 | 说明 | 默认 |
|------|------|------|
| `PORT` | HTTP 监听端口 | `8080` |
| `APP_ENV` | 影响默认 MySQL 选择与 `/api/health` 返回的 `env` | 空则 health 中为 `development` |
| `GIN_MODE` | `release` 时 GORM 日志级别更安静(警告级) | debug 模式 |
| `DB_DSN` | 完整 MySQL DSN覆盖上述内置地址 | 未设置 |
| `GEO_LOOKUP_URL` | `GET /api/auth/me` 未带 `X-Visit-Location` 时,服务端按 IP 请求该基址反查展示地理位置(实现见 `internal/clientgeo`,会拼接 `?ip=` | 默认 `https://cf-ip-geo.smyhub.com/api` |
**建议**:生产部署时设置 `APP_ENV=production`,并确保 MySQL 防火墙与账号权限正确;敏感连接信息也可只通过 `DB_DSN` 注入,无需改代码。
## 数据库表AutoMigrate
应用启动与 `migrate` 工具均会同步下列表结构GORM `AutoMigrate`
| 表名 | 用途 |
|------|------|
| `users` | 用户主数据;签到/访问时间列表、`auth_clients` 等以 JSON 文本列存储 |
| `pending_users` | 注册邮箱验证流程中的待激活用户 |
| `password_resets` | 忘记密码验证码 |
| `secondary_email_verifications` | 绑定辅助邮箱验证码 |
| `app_configs` | 键值配置:`admin``auth``email``checkin``registration`JSON |
| `invite_codes` | 注册邀请码 |
| `profile_likes` | 公开用户主页点赞明细(点赞者、被赞者、自然日等) |
| `profile_like_daily_quota` | 点赞者当日已用额度(与代码常量 `MaxProfileLikesPerDay` 配合) |
管理员令牌、JWT Secret、邮件 SMTP、签到奖励、是否强制邀请码等均从 `app_configs` / `invite_codes` 读写,首次无记录时由 `Store` 按逻辑补全默认项。
## 从旧 JSON 迁移到 MySQL
**`sproutgate-backend`** 目录下:
```bash
# 导入到当前环境对应的库(默认开发库,除非设置 APP_ENV 或 DB_DSN
go run ./cmd/migrate --data-dir ./data
```
Windows PowerShell 示例(写入生产库):
```powershell
$env:APP_ENV = "production"
go run ./cmd/migrate --data-dir ./data
Remove-Item Env:APP_ENV # 可选:清除变量,避免影响本机其它命令
```
迁移内容:`data/config/*.json`(写入 `app_configs``invite_codes`)、`data/users/*.json`(写入 `users`)。对已存在主键执行 **UPSERT**:同账号、同配置键会更新为迁移文件中的值。
## HTTP 路由速览
- **`GET /`**、**`GET /api`**API 简要说明 JSON`main.go` 内嵌字段,含 `version``routePrefixes` 等)。**当前未注册**单独返回 Markdown 的 `/api/docs` 路由;对外长文档见仓库根目录 **`萌芽账户认证中心-第三方应用API接入文档.md`**。
- **`GET /api/health`**`{ "status": "ok", "env": "<APP_ENV 或 development>" }`
- **`/api/auth/*`**:登录、注册、邮箱验证、忘记/重置密码、辅助邮箱、令牌校验、`/me`、签到、更新资料等;可选请求头 `X-Auth-Client``X-Auth-Client-Name`(记录第三方应用接入)。
- **`/api/public/*`**
- **`GET /api/public/users`**:公开用户目录(未封禁;默认按 `createdAt` 升序)。
- **`GET /api/public/users/:account`**:公开资料 + 累计赞数;若带合法 Bearer可附加「是否已赞今日」「当日剩余可点赞人数」等浏览者上下文。
- **`POST /api/public/users/:account/like`**:登录用户给指定主页点赞(不能赞自己;每人每主页每日一次;每自然日每名用户最多给 **5** 位不同用户点赞,常量见 `internal/storage/profile_likes.go`)。
- **`GET /api/public/registration-policy`**:是否强制邀请码等。
- **`/api/admin/*`**:需 **`X-Admin-Token`**(或 Query `token`):用户 CRUD、签到配置、注册策略与邀请码管理。
允许的 CORS 自定义头包含:`Authorization``X-Admin-Token``X-Visit-Ip``X-Visit-Location``X-Auth-Client``X-Auth-Client-Name` 等。
## 本地开发命令
```bash
cd sproutgate-backend
go mod tidy
go run .
```
默认监听 `:8080`。前端开发时请将 `VITE_API_BASE` 指向本服务(详见仓库根目录下 `sproutgate-frontend/前端文档.md`)。
## 安全提示
- 勿将生产 `DB_DSN`、SMTP 密码、真实 `admin` 令牌提交到版本库。
- 旧版 `data/config/email.json` 等若含真实口令,迁移后应以环境或运维密钥管理为准,并限制数据库与 SMTP 账号权限。

View File

@@ -1,18 +1,57 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { API_BASE, getPublicAccountFromPathname, getAuthFlowFromSearch, LOGO_192_SRC } from "./config";
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
API_BASE,
buildAuthDenyCallbackUrl,
getPublicAccountFromPathname,
getAuthFlowFromSearch,
LOGO_192_SRC
} from "./config";
import SplashScreen from "./components/SplashScreen";
import RandomSiteBackground from "./components/RandomSiteBackground";
import PublicUserPage from "./components/PublicUserPage";
import PublicUserListPage from "./components/PublicUserListPage";
import UserPortal from "./components/UserPortal";
import AdminPanel from "./components/AdminPanel";
function App() {
const pathname = window.location.pathname;
const search = window.location.search;
const basePrefix = useMemo(() => {
const baseUrl = import.meta.env.BASE_URL || "/";
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
}, []);
const authorizePathname = useMemo(() => {
try {
return new URL("authorize", `http://x${basePrefix}`).pathname;
} catch {
return "/authorize";
}
}, [basePrefix]);
const isAdmin = pathname.startsWith("/admin");
const isPublicUserList = pathname === "/users";
const isPublicUser = pathname === "/user" || pathname.startsWith("/user/");
const isAuthorizeRoute = pathname === authorizePathname;
const authFlow = useMemo(() => getAuthFlowFromSearch(search), [search]);
const publicAccount = useMemo(() => getPublicAccountFromPathname(pathname), [pathname]);
const authDenyHref = useMemo(() => {
const r = (authFlow?.redirectUri || "").trim();
if (!r) return "";
return buildAuthDenyCallbackUrl(r, authFlow?.state || "");
}, [authFlow]);
useLayoutEffect(() => {
const params = new URLSearchParams(search);
const hasRedirect = (params.get("redirect_uri") || params.get("return_url") || "").trim();
if (!hasRedirect) return;
if (isAuthorizeRoute) return;
if (isAdmin || isPublicUserList || isPublicUser) return;
const target = new URL("authorize", `${window.location.origin}${basePrefix}`);
target.search = search;
window.location.replace(target.href);
}, [search, pathname, isAuthorizeRoute, isAdmin, isPublicUserList, isPublicUser, basePrefix]);
const [booting, setBooting] = useState(true);
const markReady = useMemo(() => () => setBooting(false), []);
@@ -147,17 +186,50 @@ function App() {
</div>
);
const homeHref = import.meta.env.BASE_URL || "/";
const usersHref = useMemo(() => {
try {
return new URL("users", `${window.location.origin}${basePrefix}`).pathname;
} catch {
return "/users";
}
}, [basePrefix]);
const headerNav = (
<nav>
<a href="/">用户中心</a>
<a href={homeHref}>用户中心</a>
<a href={usersHref}>用户列表</a>
</nav>
);
const headerNavAuthorize = (
<nav className="oauth-authorize-nav">
<a href={homeHref}>用户中心</a>
{authDenyHref ? (
<a href={authDenyHref} className="oauth-header-deny">拒绝 / 取消</a>
) : null}
</nav>
);
return (
<div className="app-shell">
<RandomSiteBackground />
{booting && <SplashScreen />}
<div className="app">
{isPublicUser ? (
{isPublicUserList ? (
<div className="unified-user-shell">
<div className="unified-user-card">
<div className="unified-user-card-accent" aria-hidden />
<header className="unified-user-header">
{headerBrand}
{headerNav}
</header>
<div className="unified-user-main">
<PublicUserListPage onReady={markReady} onPreviewImage={openImagePreview} />
</div>
</div>
</div>
) : isPublicUser ? (
<div className="unified-user-shell">
<div className="unified-user-card">
<div className="unified-user-card-accent" aria-hidden />
@@ -183,6 +255,24 @@ function App() {
</div>
</div>
</div>
) : isAuthorizeRoute ? (
<div className="unified-user-shell oauth-authorize-shell">
<div className="unified-user-card oauth-authorize-card">
<div className="unified-user-card-accent" aria-hidden />
<header className="unified-user-header oauth-authorize-header">
{headerBrand}
{headerNavAuthorize}
</header>
<div className="unified-user-main oauth-authorize-main">
<UserPortal
onReady={markReady}
authFlow={authFlow}
onPreviewImage={openImagePreview}
oauthStandalone
/>
</div>
</div>
</div>
) : (
<div className="unified-user-shell">
<div className="unified-user-card">

View File

@@ -0,0 +1,61 @@
/** 纯数字 @qq.com大小写不敏感→ QQ 号码,用于与后端一致的展示逻辑 */
export function qqUinFromEmail(email) {
if (!email || typeof email !== "string") return "";
const e = email.trim().toLowerCase();
const at = e.lastIndexOf("@");
if (at <= 0 || at >= e.length - 1) return "";
const local = e.slice(0, at);
const domain = e.slice(at + 1);
if (domain !== "qq.com" || !local) return "";
if (!/^\d+$/.test(local)) return "";
return local;
}
export function qqUinFromUser(user) {
if (!user || typeof user !== "object") return "";
const primary = qqUinFromEmail(user.email);
if (primary) return primary;
for (const em of user.secondaryEmails || []) {
const u = qqUinFromEmail(em);
if (u) return u;
}
return "";
}
/** 与后端 ResolvedAvatarURL 一致的三档 QQ CDN依次作为 img onError 回退) */
export function qqAvatarUrlCandidates(uin) {
if (!uin) return [];
return [
`https://q.qlogo.cn/headimg_dl?dst_uin=${encodeURIComponent(uin)}&spec=640&img_type=jpg`,
`https://q1.qlogo.cn/g?b=qq&nk=${encodeURIComponent(uin)}&s=100`,
`https://q2.qlogo.cn/headimg_dl?dst_uin=${encodeURIComponent(uin)}&spec=100`
];
}
export function placeholderAvatarUrl(size) {
return `https://dummyimage.com/${size}x${size}/ddd/fff&text=Avatar`;
}
/** 用于 <img src>:自选链接优先,否则 QQ 邮箱推断的多 CDN否则 API 下发的 avatarUrl最后占位图 */
export function avatarImageCandidates(user, placeholderSize = 120) {
const custom = (user?.customAvatarUrl != null && String(user.customAvatarUrl).trim()) || "";
if (custom) return [custom];
const uin = qqUinFromUser(user);
if (uin) return qqAvatarUrlCandidates(uin);
const resolved = (user?.avatarUrl != null && String(user.avatarUrl).trim()) || "";
if (resolved) return [resolved];
return [placeholderAvatarUrl(placeholderSize)];
}
export function resolveDisplayAvatarUrl(user, placeholderSize = 120) {
const c = avatarImageCandidates(user, placeholderSize);
return c[0] || placeholderAvatarUrl(placeholderSize);
}
export function avatarStatusLabel(user) {
const custom = (user?.customAvatarUrl != null && String(user.customAvatarUrl).trim()) || "";
if (custom) return "已设置";
if (qqUinFromUser(user)) return "QQ 邮箱头像";
return "未填写";
}

View File

@@ -31,6 +31,8 @@ export default function AdminPanel({ onReady }) {
const [regError, setRegError] = useState("");
const [regMessage, setRegMessage] = useState("");
const [requireInviteReg, setRequireInviteReg] = useState(false);
const [forbiddenAccountsReg, setForbiddenAccountsReg] = useState("");
const [inviteRegisterRewardReg, setInviteRegisterRewardReg] = useState(10);
const [inviteList, setInviteList] = useState([]);
const [newInvNote, setNewInvNote] = useState("");
const [newInvMax, setNewInvMax] = useState(0);
@@ -99,6 +101,11 @@ export default function AdminPanel({ onReady }) {
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载注册策略失败");
setRequireInviteReg(Boolean(data.requireInviteCode));
setForbiddenAccountsReg(
typeof data.forbiddenAccounts === "string" ? data.forbiddenAccounts : ""
);
const ir = Number(data.inviteRegisterRewardCoins);
setInviteRegisterRewardReg(Number.isFinite(ir) && ir >= 0 ? ir : 10);
setInviteList(data.invites || []);
} catch (err) {
setRegError(err.message);
@@ -116,11 +123,20 @@ export default function AdminPanel({ onReady }) {
const res = await fetch(`${API_BASE}/api/admin/registration`, {
method: "PUT",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify({ requireInviteCode: requireInviteReg })
body: JSON.stringify({
requireInviteCode: requireInviteReg,
forbiddenAccounts: forbiddenAccountsReg,
inviteRegisterRewardCoins: Math.max(0, Math.floor(Number(inviteRegisterRewardReg)) || 0)
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "保存失败");
setRequireInviteReg(Boolean(data.requireInviteCode));
if (typeof data.forbiddenAccounts === "string") {
setForbiddenAccountsReg(data.forbiddenAccounts);
}
const irs = Number(data.inviteRegisterRewardCoins);
if (Number.isFinite(irs) && irs >= 0) setInviteRegisterRewardReg(irs);
setRegMessage("注册策略已保存");
} catch (err) {
setRegError(err.message);
@@ -198,7 +214,7 @@ export default function AdminPanel({ onReady }) {
sproutCoins: user.sproutCoins || 0,
secondaryEmails: (user.secondaryEmails || []).join(","),
phone: user.phone || "",
avatarUrl: user.avatarUrl || "",
avatarUrl: user.customAvatarUrl ?? "",
websiteUrl: user.websiteUrl || "",
bio: user.bio || "",
banned: Boolean(user.banned),
@@ -402,6 +418,26 @@ export default function AdminPanel({ onReady }) {
<span>开启后用户自助注册必须填写有效邀请码管理员创建用户不受影响</span>
</span>
</label>
<label className="full-span">
<IconLabel icon={icons.ban} text="禁止注册的账户名" hint="(逗号分隔,自助注册时完全匹配阻止;留空则使用内置默认列表)" />
<textarea
value={forbiddenAccountsReg}
onChange={(e) => setForbiddenAccountsReg(e.target.value)}
rows={3}
placeholder="sb,mail,example"
disabled={!token || regLoading}
/>
</label>
<label>
<IconLabel icon={icons.coins} text="邀请码注册奖励" hint="萌芽币填写邀请码并完成邮箱验证后发放0 表示不奖励)" />
<input
type="number"
min="0"
value={inviteRegisterRewardReg}
onChange={(e) => setInviteRegisterRewardReg(Number(e.target.value))}
disabled={!token || regLoading}
/>
</label>
<div className="actions compact">
<button type="button" className="primary" onClick={saveRegPolicy} disabled={!token || regSaving || regLoading}>
{regSaving ? "保存中…" : "保存注册策略"}
@@ -410,7 +446,7 @@ export default function AdminPanel({ onReady }) {
{regLoading ? "加载中…" : "刷新列表"}
</button>
</div>
<div className="hint">公开接口 <code className="inline-code">GET /api/public/registration-policy</code> 供前端判断是否显示邀请码输入框</div>
<div className="hint">公开接口 <code className="inline-code">GET /api/public/registration-policy</code> 供前端判断是否显示邀请码输入框及邀请注册奖励说明</div>
{regError && <div className="error">{regError}</div>}
{regMessage && <div className="success">{regMessage}</div>}

View File

@@ -0,0 +1,70 @@
import React, { useEffect, useState } from "react";
import { avatarImageCandidates, placeholderAvatarUrl } from "../avatar";
/**
* 用户头像:支持自选 URL含 GIF 动图、API 解析后的 avatarUrl、QQ 数字邮箱时的多 CDN 回退。
* 圆角裁切做在外层容器上,避免部分浏览器在圆角 img 上只播 GIF 第一帧。
*/
export default function AvatarImg({
user,
placeholderSize = 120,
alt = "avatar",
className = "",
previewable = false,
onPreviewImage,
previewLabel
}) {
const chain = avatarImageCandidates(user, placeholderSize);
const [idx, setIdx] = useState(0);
const src = chain[Math.min(idx, chain.length - 1)] || placeholderAvatarUrl(placeholderSize);
useEffect(() => {
setIdx(0);
}, [
user?.account,
user?.customAvatarUrl,
user?.avatarUrl,
user?.email,
(user?.secondaryEmails || []).join(",")
]);
const handleError = () => {
if (idx < chain.length - 1) setIdx((i) => i + 1);
};
const handlePreview = () => {
if (!onPreviewImage) return;
const primary = chain[0] || "";
onPreviewImage(primary, previewLabel || alt);
};
const shellClass = ["avatar-shell", className].filter(Boolean).join(" ");
const previewProps = previewable
? {
role: "button",
tabIndex: 0,
"aria-label": previewLabel || alt || "查看头像",
onClick: handlePreview,
onKeyDown: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handlePreview();
}
}
}
: {};
return (
<span className={shellClass} {...previewProps}>
<img
src={src}
alt={alt}
className="avatar-img"
onError={handleError}
decoding="async"
draggable={false}
/>
</span>
);
}

View File

@@ -0,0 +1,207 @@
import React, { useMemo, useState } from "react";
import {
buildAuthDenyCallbackUrl,
formatOAuthRedirectLabel,
formatWebsiteLabel
} from "../config";
import icons from "../icons";
import { MailtoEmail } from "./common";
import AvatarImg from "./AvatarImg";
const DEFAULT_PERMISSIONS = [
{
title: "账户标识",
desc: "获取账户名account用于在该应用中唯一识别你的萌芽身份。"
},
{
title: "基本资料",
desc: "获取昵称、头像、邮箱、等级、萌芽币、个人主页与简介等(以「验证令牌」「当前用户」等接口实际返回为准)。"
},
{
title: "访问令牌",
desc: "向你选择的应用颁发 JWT应用仅在令牌有效期内、经你同意后代表你请求萌芽账户认证中心接口不会获得你的登录密码。"
},
{
title: "应用接入记录",
desc: "在「应用接入」中记录该应用的 client_id 与名称,便于你在用户中心查看已与哪些应用建立关联。"
}
];
function scopeLabel(s) {
const lower = String(s || "").toLowerCase().trim();
const map = {
openid: "OpenID账户标识",
profile: "个人资料(昵称、头像等展示信息)",
email: "邮箱地址",
offline_access: "离线访问(延长令牌有效期的扩展能力,若应用支持)"
};
return map[lower] || s;
}
export default function OAuthConsentScreen({
authFlow,
user,
onAllow,
onSwitchAccount,
onPreviewImage
}) {
const [permsOpen, setPermsOpen] = useState(true);
const appLabel = useMemo(() => {
const name = (authFlow?.clientName || "").trim();
const id = (authFlow?.clientId || "").trim();
if (name) return name;
if (id) return id;
return "第三方应用";
}, [authFlow]);
const redirectLabel = formatOAuthRedirectLabel(authFlow?.redirectUri);
const redirectUrl = (authFlow?.redirectUri || "").trim();
const extraScopes = useMemo(() => {
const raw = authFlow?.scopes || [];
if (!raw.length) return [];
return raw.map(scopeLabel);
}, [authFlow]);
const handleDeny = () => {
const url = buildAuthDenyCallbackUrl(authFlow?.redirectUri, authFlow?.state || "");
if (url) window.location.replace(url);
};
return (
<div className="oauth-consent">
<div className="oauth-consent-brand">
<div className="oauth-consent-icon-wrap" aria-hidden>
<svg className="oauth-consent-lock" viewBox="0 0 24 24" width="28" height="28">
<rect x="5" y="10" width="14" height="11" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 10V8a4 4 0 0 1 8 0v2" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
</div>
<h1 className="oauth-consent-app-name">{appLabel}</h1>
<p className="oauth-consent-lead">请求访问你的<strong>萌芽统一账户</strong></p>
</div>
<div className="oauth-consent-card oauth-consent-card--identity">
<div className="oauth-consent-identity">
<AvatarImg
user={user}
placeholderSize={56}
alt="avatar"
className="oauth-consent-avatar previewable-image"
previewable
onPreviewImage={onPreviewImage}
previewLabel={user?.username || user?.account || "avatar"}
/>
<div className="oauth-consent-id-text">
<div className="oauth-consent-display">{user?.username || user?.account || "用户"}</div>
<div className="oauth-consent-as">
<span className="mono oauth-consent-account">@{user?.account}</span> 的身份授权
</div>
{user?.email ? (
<div className="oauth-consent-email">
<MailtoEmail address={user.email} className="profile-external-link">{user.email}</MailtoEmail>
</div>
) : (
<div className="muted oauth-consent-email">未绑定邮箱</div>
)}
</div>
</div>
</div>
<div className="oauth-consent-card oauth-consent-card--detail">
<div className="oauth-consent-section-label">应用信息</div>
<ul className="oauth-consent-meta">
<li>
<span className="icon oauth-consent-meta-icon">{icons.link}</span>
<div>
<div className="oauth-consent-meta-key">回跳地址</div>
{redirectUrl.match(/^https?:\/\//i) ? (
<a
href={redirectUrl}
target="_blank"
rel="noopener noreferrer"
className="oauth-consent-meta-link"
>
{redirectLabel}
</a>
) : (
<span className="mono oauth-consent-meta-val">{redirectLabel}</span>
)}
</div>
</li>
{(authFlow?.clientId || "").trim() ? (
<li>
<span className="icon oauth-consent-meta-icon">{icons.apps}</span>
<div>
<div className="oauth-consent-meta-key">应用 IDclient_id</div>
<span className="mono oauth-consent-meta-val">{authFlow.clientId.trim()}</span>
</div>
</li>
) : null}
{(authFlow?.clientName || "").trim() ? (
<li>
<span className="icon oauth-consent-meta-icon">{icons.username}</span>
<div>
<div className="oauth-consent-meta-key">应用名称</div>
<span className="oauth-consent-meta-val">{authFlow.clientName.trim()}</span>
</div>
</li>
) : null}
</ul>
<div className="oauth-consent-section-label oauth-consent-section-label--perm">将获取以下信息与权限</div>
<button
type="button"
className={`oauth-consent-perm-toggle${permsOpen ? " is-open" : ""}`}
onClick={() => setPermsOpen((v) => !v)}
aria-expanded={permsOpen}
>
<span className="oauth-consent-perm-toggle-text">查看详情</span>
<span className="oauth-consent-chevron" aria-hidden></span>
</button>
{permsOpen && (
<ul className="oauth-consent-perms">
{DEFAULT_PERMISSIONS.map((p) => (
<li key={p.title}>
<span className="oauth-consent-perm-icon">{icons.token}</span>
<div>
<div className="oauth-consent-perm-title">{p.title}</div>
<div className="oauth-consent-perm-desc">{p.desc}</div>
</div>
</li>
))}
{extraScopes.length > 0 ? (
<li>
<span className="oauth-consent-perm-icon">{icons.level}</span>
<div>
<div className="oauth-consent-perm-title">应用请求的 scope</div>
<ul className="oauth-consent-scope-list">
{extraScopes.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</div>
</li>
) : null}
</ul>
)}
</div>
<div className="oauth-consent-actions">
<button type="button" className="primary oauth-consent-allow" onClick={onAllow}>
允许
</button>
<button type="button" className="ghost oauth-consent-deny" onClick={handleDeny}>
拒绝
</button>
<button type="button" className="oauth-consent-switch text-button" onClick={onSwitchAccount}>
使用其他账号登录
</button>
</div>
<p className="oauth-consent-footnote">
点击允许你将返回应用 <span className="mono">{formatWebsiteLabel(redirectUrl) || redirectLabel}</span>并在浏览器地址的 Hash 中携带访问令牌
</p>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useState } from "react";
import { API_BASE, formatUserRegisteredAt } from "../config";
import icons from "../icons";
import AvatarImg from "./AvatarImg";
export default function PublicUserListPage({ onReady, onPreviewImage }) {
const [users, setUsers] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError("");
try {
const res = await fetch(`${API_BASE}/api/public/users`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载用户列表失败");
if (!cancelled) {
setUsers(Array.isArray(data.users) ? data.users : []);
setTotal(Number(data.total) || 0);
}
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) {
setLoading(false);
onReady?.();
}
}
})();
return () => { cancelled = true; };
}, [onReady]);
return (
<section className="panel public-user-list-page">
<div className="public-user-list-head">
<h2 className="public-user-list-title">公开用户</h2>
<p className="public-user-list-lead">
按注册时间从早到晚排列 {total}
</p>
</div>
{loading && <div className="unified-page-loading">加载中</div>}
{!loading && error && (
<div className="error public-user-list-error" role="alert">{error}</div>
)}
{!loading && !error && users.length === 0 && (
<div className="hint">暂无公开用户</div>
)}
{!loading && !error && users.length > 0 && (
<ul className="public-user-list">
{users.map((u) => {
const name = (u.username || "").trim() || u.account;
const profileUser = {
account: u.account,
username: u.username,
avatarUrl: u.avatarUrl
};
return (
<li key={u.account}>
<a className="public-user-list-row" href={`/user/${encodeURIComponent(u.account)}`}>
<AvatarImg
user={profileUser}
placeholderSize={56}
alt={name}
className="public-user-list-avatar previewable-image"
previewable
onPreviewImage={onPreviewImage}
previewLabel={name}
/>
<div className="public-user-list-meta">
<span className="public-user-list-name">{name}</span>
<span className="public-user-list-account mono">{u.account}</span>
<span className="public-user-list-sub">
<span className="icon stat-item-icon">{icons.level}</span>
Lv.{u.level ?? 0}
<span className="public-user-list-sub-sep" aria-hidden />
<span className="icon stat-item-icon">{icons.coins}</span>
{u.sproutCoins ?? 0}
<span className="public-user-list-sub-sep" aria-hidden />
注册 {formatUserRegisteredAt(u.createdAt)}
</span>
</div>
<span className="public-user-list-chevron" aria-hidden></span>
</a>
</li>
);
})}
</ul>
)}
</section>
);
}

View File

@@ -1,44 +1,100 @@
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { API_BASE, marked, formatWebsiteLabel, formatUserRegisteredAt } from "../config";
import icons from "../icons";
import { InfoRow, StatItem } from "./common";
import AvatarImg from "./AvatarImg";
export default function PublicUserPage({ account, onReady, onPreviewImage }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [profileLikeCount, setProfileLikeCount] = useState(0);
const [viewerHasLikedToday, setViewerHasLikedToday] = useState(false);
const [viewerLikesRemaining, setViewerLikesRemaining] = useState(5);
const [profileLikeDailyMax, setProfileLikeDailyMax] = useState(5);
const [viewerIsOwner, setViewerIsOwner] = useState(false);
const [likeLoading, setLikeLoading] = useState(false);
const [likeError, setLikeError] = useState("");
useEffect(() => {
let cancelled = false;
const authHeaders = () => {
try {
const token = (localStorage.getItem("sproutgate_token") || "").trim();
return token ? { Authorization: `Bearer ${token}` } : {};
} catch {
return {};
}
};
const loadPublicUser = async () => {
if (!account) {
setError("缺少账户名");
setLoading(false);
if (!cancelled && onReady) onReady();
return;
}
setLoading(true);
setError("");
setUser(null);
try {
const res = await fetch(`${API_BASE}/api/public/users/${encodeURIComponent(account)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载公开主页失败");
setUser(data.user || null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
if (!cancelled && onReady) onReady();
}
};
loadPublicUser();
return () => { cancelled = true; };
const loadPublicUser = useCallback(async () => {
if (!account) {
setError("缺少账户名");
setLoading(false);
onReady?.();
return;
}
setLoading(true);
setError("");
setUser(null);
setLikeError("");
try {
const res = await fetch(`${API_BASE}/api/public/users/${encodeURIComponent(account)}`, {
headers: { ...authHeaders() }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载公开主页失败");
setUser(data.user || null);
setProfileLikeCount(Number(data.profileLikeCount) || 0);
setViewerHasLikedToday(Boolean(data.viewerHasLikedToday));
const rem = Number(data.viewerLikesRemainingToday);
if (Number.isFinite(rem) && rem >= 0) setViewerLikesRemaining(rem);
const mx = Number(data.profileLikeDailyMax);
if (Number.isFinite(mx) && mx > 0) setProfileLikeDailyMax(mx);
setViewerIsOwner(Boolean(data.viewerIsOwner));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
onReady?.();
}
}, [account, onReady]);
const avatarUrl = user?.avatarUrl || "https://dummyimage.com/160x160/ddd/fff&text=Avatar";
useEffect(() => {
loadPublicUser();
}, [loadPublicUser]);
const handleProfileLike = async () => {
if (!account || likeLoading || viewerIsOwner || viewerHasLikedToday || viewerLikesRemaining <= 0) return;
const h = authHeaders();
if (!h.Authorization) {
setLikeError("请先登录用户中心后再点赞");
return;
}
setLikeLoading(true);
setLikeError("");
try {
const res = await fetch(`${API_BASE}/api/public/users/${encodeURIComponent(account)}/like`, {
method: "POST",
headers: { "Content-Type": "application/json", ...h }
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const rem = Number(data.viewerLikesRemainingToday);
if (Number.isFinite(rem) && rem >= 0) setViewerLikesRemaining(rem);
throw new Error(data.error || "点赞失败");
}
setProfileLikeCount(Number(data.profileLikeCount) || 0);
setViewerHasLikedToday(true);
const remOk = Number(data.viewerLikesRemainingToday);
if (Number.isFinite(remOk) && remOk >= 0) setViewerLikesRemaining(remOk);
const mxOk = Number(data.profileLikeDailyMax);
if (Number.isFinite(mxOk) && mxOk > 0) setProfileLikeDailyMax(mxOk);
} catch (err) {
setLikeError(err.message);
} finally {
setLikeLoading(false);
}
};
const displayName = user?.username || user?.account || "未命名用户";
return (
@@ -56,13 +112,14 @@ export default function PublicUserPage({ account, onReady, onPreviewImage }) {
{!loading && user && (
<div className="card profile">
<div className="profile-header">
<img
src={avatarUrl}
<AvatarImg
user={user}
placeholderSize={160}
alt={displayName}
className="previewable-image"
onClick={() => onPreviewImage?.(avatarUrl, displayName)}
role="button"
tabIndex={0}
previewable
onPreviewImage={onPreviewImage}
previewLabel={displayName}
/>
<div>
<h2>{displayName}</h2>
@@ -90,6 +147,7 @@ export default function PublicUserPage({ account, onReady, onPreviewImage }) {
<div className="profile-section-title">统计信息</div>
<div className="profile-stats-flow">
<StatItem icon={icons.heart} label="获赞" value={`${profileLikeCount}`} />
<StatItem icon={icons.level} label="等级" value={`${user.level ?? 0}`} />
<StatItem icon={icons.coins} label="萌芽币" value={user.sproutCoins ?? 0} />
<StatItem icon={icons.calendar} label="签到天数" value={`${user.checkInDays ?? 0}`} />
@@ -97,6 +155,35 @@ export default function PublicUserPage({ account, onReady, onPreviewImage }) {
<StatItem icon={icons.statClock} label="访问天数" value={`${user.visitDays ?? 0}`} />
<StatItem icon={icons.statRepeat} label="连续访问" value={`${user.visitStreak ?? 0}`} />
</div>
{!viewerIsOwner && (
<div className="profile-like-row">
{authHeaders().Authorization ? (
<span className="hint profile-like-quota">
今日还可点赞 <strong>{viewerLikesRemaining}</strong> / {profileLikeDailyMax} 每自然日刷新
</span>
) : null}
{viewerHasLikedToday ? (
<span className="profile-like-done">今日已为 Ta 点赞</span>
) : viewerLikesRemaining <= 0 ? (
<span className="profile-like-done profile-like-quota-exhausted">
今日点赞名额已用完明天可再给最多 {profileLikeDailyMax} 人点赞
</span>
) : (
<button
type="button"
className="primary profile-like-btn"
onClick={handleProfileLike}
disabled={likeLoading}
>
{likeLoading ? "提交中…" : "为 Ta 点赞"}
</button>
)}
{likeError ? <span className="error profile-like-error" role="alert">{likeError}</span> : null}
{!authHeaders().Authorization && !viewerHasLikedToday ? (
<span className="hint profile-like-hint">登录同一浏览器的用户中心后即可点赞每人每天最多 {profileLikeDailyMax} </span>
) : null}
</div>
)}
<div className="profile-section-title">活动记录</div>
<div className="profile-activity-row">

View File

@@ -0,0 +1,36 @@
import React, { useEffect, useState } from "react";
import { RAND_BG_API_ORIGIN } from "../config";
/**
* 固定全视口随机背景:拉取 randbg JSON 后铺图,高斯模糊强度为 UI 参考半径的 20%(见 CSS 变量)。
*/
export default function RandomSiteBackground() {
const [url, setUrl] = useState("");
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const endpoint = `${RAND_BG_API_ORIGIN}/api/random?format=json&mode=auto`;
const res = await fetch(endpoint, { credentials: "omit" });
if (!res.ok) return;
const data = await res.json().catch(() => ({}));
const next = typeof data.url === "string" ? data.url.trim() : "";
if (!cancelled && next) setUrl(next);
} catch {
/* 失败时仅保留 body 纯色底 */
}
};
load();
return () => { cancelled = true; };
}, []);
if (!url) return null;
return (
<div className="app-rand-bg" aria-hidden>
<img src={url} alt="" className="app-rand-bg__img" decoding="async" />
<div className="app-rand-bg__veil" />
</div>
);
}

View File

@@ -6,12 +6,15 @@ import {
clearAuthClientContext,
fetchClientVisitMeta,
formatAuthBanMessage,
persistAuthClientFromFlow
normalizeSelfServiceAccountInput,
persistAuthClientFromFlow,
randomSelfServiceAccount
} from "../config";
import UserPortalAuthSection from "./userPortal/UserPortalAuthSection";
import UserPortalProfileSection from "./userPortal/UserPortalProfileSection";
import OAuthConsentScreen from "./OAuthConsentScreen";
export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthStandalone = false }) {
const [account, setAccount] = useState("");
const [password, setPassword] = useState("");
const [user, setUser] = useState(null);
@@ -23,6 +26,9 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
account: "", password: "", username: "", email: "", code: "", inviteCode: ""
});
const [registrationRequireInvite, setRegistrationRequireInvite] = useState(false);
const [inviteRegisterReward, setInviteRegisterReward] = useState(10);
const [registrationAccountMin, setRegistrationAccountMin] = useState(3);
const [registrationAccountMax, setRegistrationAccountMax] = useState(32);
const [registerSent, setRegisterSent] = useState(false);
const [registerExpiresAt, setRegisterExpiresAt] = useState("");
const [registerLoading, setRegisterLoading] = useState(false);
@@ -79,6 +85,9 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
localStorage.removeItem("sproutgate_token");
localStorage.removeItem("sproutgate_token_expires_at");
setUser(null);
setProfileForm({
username: "", phone: "", avatarUrl: "", websiteUrl: "", bio: "", password: ""
});
};
const bearerHeaders = (tokenValue) => ({
@@ -160,6 +169,12 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
const data = await res.json().catch(() => ({}));
if (!cancelled && res.ok) {
setRegistrationRequireInvite(Boolean(data.requireInviteCode));
const ir = Number(data.inviteRegisterRewardCoins);
if (Number.isFinite(ir) && ir >= 0) setInviteRegisterReward(ir);
const am = Number(data.selfServiceAccountMax);
const ami = Number(data.selfServiceAccountMin);
if (Number.isFinite(am) && am >= 3 && am <= 32) setRegistrationAccountMax(am);
if (Number.isFinite(ami) && ami >= 3 && ami <= 32) setRegistrationAccountMin(ami);
}
} catch {
/* 忽略,默认不要求邀请码 */
@@ -168,18 +183,18 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
return () => { cancelled = true; };
}, []);
// 仅在外部 user 更新且资料弹窗关闭时同步表单,避免 /api/auth/me 二次请求在编辑期间把表单项覆盖回服务器值QQ 用户会把已填写的自定义头像链接清空)
useEffect(() => {
if (user) {
setProfileForm({
username: user.username || "",
phone: user.phone || "",
avatarUrl: user.avatarUrl || "",
websiteUrl: user.websiteUrl || "",
bio: user.bio || "",
password: ""
});
}
}, [user]);
if (!user || profileEditorOpen) return;
setProfileForm({
username: user.username || "",
phone: user.phone || "",
avatarUrl: user.customAvatarUrl ?? "",
websiteUrl: user.websiteUrl || "",
bio: user.bio || "",
password: ""
});
}, [user, profileEditorOpen]);
const handleLogin = async (event) => {
event.preventDefault();
@@ -216,7 +231,7 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
syncCurrentUser(data.token).catch(() => {});
setAccount("");
setPassword("");
if (isAuthFlow) {
if (isAuthFlow && !oauthStandalone) {
redirectToAuthCallback(data.token, data.user, data.expiresAt || "");
return;
}
@@ -228,10 +243,8 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
};
const handleLogout = () => {
localStorage.removeItem("sproutgate_token");
localStorage.removeItem("sproutgate_token_expires_at");
clearAuthClientContext();
setUser(null);
clearTokenAndUser();
setProfileEditorOpen(false);
setProfileEditorFocus("");
setSecondaryEditorOpen(false);
@@ -262,13 +275,23 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
};
const handleRegisterChange = (field, value) => {
if (field === "account") {
value = normalizeSelfServiceAccountInput(value, registrationAccountMax);
}
setRegisterForm((prev) => ({ ...prev, [field]: value }));
};
const resetRegisterFlow = () => {
setRegisterSent(false);
setRegisterExpiresAt("");
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
setRegisterForm({
account: randomSelfServiceAccount(10),
password: "",
username: "",
email: "",
code: "",
inviteCode: ""
});
};
const handleSendCode = async (event) => {
@@ -281,6 +304,11 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
setRegisterLoading(false);
return;
}
if (registerForm.account.length < registrationAccountMin) {
setRegisterError(`账户至少 ${registrationAccountMin} 位,仅小写字母与数字`);
setRegisterLoading(false);
return;
}
if (registrationRequireInvite && !String(registerForm.inviteCode || "").trim()) {
setRegisterError("请输入邀请码");
setRegisterLoading(false);
@@ -330,7 +358,12 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "验证失败");
setRegisterMessage("注册成功,请使用账号登录");
const coins = Number(data.user?.sproutCoins);
setRegisterMessage(
Number.isFinite(coins) && coins > 0
? `注册成功,已获得 ${coins} 萌芽币,请使用账号登录`
: "注册成功,请使用账号登录"
);
setRegisterSent(false);
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
setMode("login");
@@ -542,6 +575,16 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
const openProfileEditor = (field = "") => {
setProfileError("");
setProfileMessage("");
if (user) {
setProfileForm({
username: user.username || "",
phone: user.phone || "",
avatarUrl: user.customAvatarUrl ?? "",
websiteUrl: user.websiteUrl || "",
bio: user.bio || "",
password: ""
});
}
setProfileEditorFocus(field);
setProfileEditorOpen(true);
};
@@ -613,14 +656,26 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
}
};
if (oauthStandalone && !authFlow?.redirectUri?.trim()) {
return (
<section className="panel">
<div className="card form oauth-authorize-invalid">
<h2>无效授权链接</h2>
<p className="hint">缺少回跳地址redirect_uri return_url请从第三方应用重新发起登录</p>
</div>
</section>
);
}
const showOAuthConsent = oauthStandalone && isAuthFlow && Boolean(user);
const showProfile = Boolean(user) && !showOAuthConsent;
return (
<section className="panel">
<UserPortalAuthSection
isAuthFlow={isAuthFlow}
oauthStandalone={oauthStandalone}
user={user}
onPreviewImage={onPreviewImage}
handleContinueAuth={handleContinueAuth}
handleSwitchAuthAccount={handleSwitchAuthAccount}
mode={mode}
setMode={setMode}
account={account}
@@ -643,6 +698,9 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
setRegisterError={setRegisterError}
setRegisterMessage={setRegisterMessage}
registrationRequireInvite={registrationRequireInvite}
inviteRegisterReward={inviteRegisterReward}
registrationAccountMin={registrationAccountMin}
registrationAccountMax={registrationAccountMax}
resetRegisterFlow={resetRegisterFlow}
resetForm={resetForm}
handleResetChange={handleResetChange}
@@ -656,7 +714,16 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
setResetError={setResetError}
setResetMessage={setResetMessage}
/>
{user && (
{showOAuthConsent && (
<OAuthConsentScreen
authFlow={authFlow}
user={user}
onAllow={handleContinueAuth}
onSwitchAccount={handleSwitchAuthAccount}
onPreviewImage={onPreviewImage}
/>
)}
{showProfile && (
<UserPortalProfileSection
user={user}
onPreviewImage={onPreviewImage}

View File

@@ -1,13 +1,11 @@
import React from "react";
import icons from "../../icons";
import { IconLabel, MailtoEmail } from "../common";
import { IconLabel } from "../common";
export default function UserPortalAuthSection({
isAuthFlow,
oauthStandalone = false,
user,
onPreviewImage,
handleContinueAuth,
handleSwitchAuthAccount,
mode,
setMode,
account,
@@ -30,6 +28,9 @@ export default function UserPortalAuthSection({
setRegisterError,
setRegisterMessage,
registrationRequireInvite,
inviteRegisterReward = 10,
registrationAccountMin = 3,
registrationAccountMax = 32,
resetRegisterFlow,
resetForm,
handleResetChange,
@@ -45,45 +46,16 @@ export default function UserPortalAuthSection({
}) {
return (
<>
{isAuthFlow && user && (
<div className="card form">
<h2>第三方登录授权</h2>
<div className="hint">外部应用正在请求使用当前萌芽统一账户认证登录</div>
<div className="profile-header" style={{ marginTop: "12px" }}>
<img
src={user.avatarUrl || "https://dummyimage.com/120x120/ddd/fff&text=Avatar"}
alt="avatar"
className="previewable-image"
onClick={() => onPreviewImage?.(user.avatarUrl || "", user.username || user.account || "avatar")}
role="button"
tabIndex={0}
/>
<div>
<h3>{user.username || user.account}</h3>
<p>{user.account}</p>
<p>
{user.email ? (
<MailtoEmail address={user.email} className="profile-external-link">{user.email}</MailtoEmail>
) : (
"未填写邮箱"
)}
</p>
</div>
</div>
<div className="actions">
<button type="button" className="primary" onClick={handleContinueAuth}>继续授权</button>
<button type="button" className="ghost" onClick={handleSwitchAuthAccount}>切换账号</button>
</div>
</div>
)}
{!user && mode === "login" && (
<form className="card form auth-card" onSubmit={handleLogin}>
<div className="auth-form-head">
<h2>登录</h2>
<p className="auth-form-lead">使用萌芽统一账户进入用户中心</p>
{isAuthFlow && (
<div className="hint auth-form-hint">你正在通过统一登录为外部应用授权登录后将自动返回</div>
{isAuthFlow && oauthStandalone && (
<div className="hint auth-form-hint">登录成功后需在下一页确认向第三方应用授予的权限再返回应用</div>
)}
{isAuthFlow && !oauthStandalone && (
<div className="hint auth-form-hint">你正在通过统一登录为外部应用授权</div>
)}
</div>
<div className="auth-form-body">
@@ -139,8 +111,17 @@ export default function UserPortalAuthSection({
</div>
<div className="auth-form-body">
<label className="auth-field">
<IconLabel icon={icons.account} text="账户" />
<input value={registerForm.account} onChange={(e) => handleRegisterChange("account", e.target.value)} placeholder="请输入账户" />
<IconLabel
icon={icons.account}
text="账户"
hint={`${registrationAccountMin}${registrationAccountMax} 位小写字母与数字)`}
/>
<input
value={registerForm.account}
onChange={(e) => handleRegisterChange("account", e.target.value)}
placeholder="已随机生成,可改写"
autoComplete="off"
/>
</label>
<label className="auth-field">
<IconLabel icon={icons.password} text="密码" />
@@ -170,6 +151,9 @@ export default function UserPortalAuthSection({
/>
</label>
)}
{!registerSent && inviteRegisterReward > 0 && (
<div className="hint">使用有效邀请码完成注册后将获得 {inviteRegisterReward} 萌芽币</div>
)}
{registerSent && (
<label className="auth-field">
<IconLabel icon={icons.token} text="邮箱验证码" />

View File

@@ -1,7 +1,9 @@
import React from "react";
import { marked, formatWebsiteLabel, formatUserRegisteredAt, formatIsoDateTimeReadable } from "../../config";
import { avatarStatusLabel } from "../../avatar";
import icons from "../../icons";
import { IconLabel, MailtoEmail, StatItem, InfoRow } from "../common";
import AvatarImg from "../AvatarImg";
export default function UserPortalProfileSection({
user,
@@ -39,13 +41,14 @@ export default function UserPortalProfileSection({
return (
<div className="card profile">
<div className="profile-header">
<img
src={user.avatarUrl || "https://dummyimage.com/120x120/ddd/fff&text=Avatar"}
<AvatarImg
user={user}
placeholderSize={120}
alt="avatar"
className="previewable-image"
onClick={() => onPreviewImage?.(user.avatarUrl || "", user.username || user.account || "avatar")}
role="button"
tabIndex={0}
previewable
onPreviewImage={onPreviewImage}
previewLabel={user.username || user.account || "avatar"}
/>
<div>
<h2>{user.username || user.account}</h2>
@@ -79,7 +82,7 @@ export default function UserPortalProfileSection({
)}
</InfoRow>
<InfoRow icon={icons.phone} label="手机号" value={user.phone || "未填写"} actionLabel="修改" onAction={() => openProfileEditor("phone")} />
<InfoRow icon={icons.avatar} label="头像" value={user.avatarUrl ? "已设置" : "未填写"} actionLabel="修改" onAction={() => openProfileEditor("avatarUrl")} />
<InfoRow icon={icons.avatar} label="头像" value={avatarStatusLabel(user)} actionLabel="修改" onAction={() => openProfileEditor("avatarUrl")} />
<InfoRow icon={icons.link} label="个人主页" actionLabel="修改" onAction={() => openProfileEditor("websiteUrl")}>
{user.websiteUrl ? (
<a

View File

@@ -12,6 +12,32 @@ export const API_BASE = (() => {
/** `public/logo192.png`,含 Vite `base` 前缀,避免子路径部署时顶栏/开屏裂图 */
export const LOGO_192_SRC = `${import.meta.env.BASE_URL}logo192.png`;
/** 全站随机背景(`GET /api/random?format=json`,见 https://randbg.api.smyhub.com */
export const RAND_BG_API_ORIGIN = "https://randbg.api.smyhub.com";
const SELF_SERVICE_ACCOUNT_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
/** 与自助注册规则一致:随机小写字母与数字(默认长度 10 */
export function randomSelfServiceAccount(length = 10) {
const n = Math.max(3, Math.min(32, Number(length) || 10));
const buf = new Uint8Array(n);
crypto.getRandomValues(buf);
let s = "";
for (let i = 0; i < n; i++) {
s += SELF_SERVICE_ACCOUNT_ALPHABET[buf[i] % SELF_SERVICE_ACCOUNT_ALPHABET.length];
}
return s;
}
/** 输入时规范为自助注册允许的字符(小写、数字),超长截断 */
export function normalizeSelfServiceAccountInput(raw, maxLen = 32) {
const m = Math.max(3, Math.min(32, maxLen));
return String(raw || "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
.slice(0, m);
}
/** 浏览器侧 IP/地理(与后端写入「最后访问」字段配合使用) */
export const CLIENT_GEO_LOOKUP_URL = "https://cf-ip-geo.smyhub.com/api";
@@ -130,15 +156,35 @@ export function getPublicAccountFromPathname(pathname) {
export function getAuthFlowFromSearch(search) {
const params = new URLSearchParams(search);
const redirectUri = (params.get("redirect_uri") || params.get("return_url") || "").trim();
const scopeRaw = (params.get("scope") || "").trim();
const scopes = scopeRaw
? scopeRaw.split(/[\s+]+/).map((s) => s.trim()).filter(Boolean)
: [];
return {
redirectUri,
state: (params.get("state") || "").trim(),
prompt: (params.get("prompt") || "").trim(),
clientId: (params.get("client_id") || "").trim(),
clientName: (params.get("client_name") || "").trim()
clientName: (params.get("client_name") || "").trim(),
scopes,
scopeRaw
};
}
/** 授权页展示用:回跳地址可读标签(主机 + 路径) */
export function formatOAuthRedirectLabel(redirectUri) {
if (!redirectUri || typeof redirectUri !== "string") return "—";
const t = redirectUri.trim();
if (!t) return "—";
try {
const u = new URL(t, typeof window !== "undefined" ? window.location.href : "https://example.com");
const path = u.pathname === "/" ? "" : u.pathname;
return `${u.host}${path}` || u.host;
} catch {
return t.length > 64 ? `${t.slice(0, 61)}` : t;
}
}
const AUTH_CLIENT_ID_KEY = "sproutgate_auth_client_id";
const AUTH_CLIENT_NAME_KEY = "sproutgate_auth_client_name";
@@ -185,3 +231,15 @@ export function buildAuthCallbackUrl(redirectUri, payload) {
url.hash = hashParams.toString();
return url.toString();
}
/** 用户拒绝授权:回跳到应用地址,在 URL hash 中携带 OAuth 风格 error与成功回跳一致使用 fragment */
export function buildAuthDenyCallbackUrl(redirectUri, state = "") {
if (!redirectUri || typeof redirectUri !== "string") return "";
const url = new URL(redirectUri, window.location.href);
const hashParams = new URLSearchParams();
hashParams.set("error", "access_denied");
hashParams.set("error_description", "resource_owner_denied");
if (state) hashParams.set("state", String(state));
url.hash = hashParams.toString();
return url.toString();
}

View File

@@ -126,6 +126,17 @@ const icons = {
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M17 3h4v4M7 21H3v-4M3 12a9 9 0 0 1 14.3-7.2L21 8M21 12a9 9 0 0 1-14.3 7.2L3 16" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
heart: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 20s-7-4.6-9.6-9.1C-.3 8.3 1.8 4.2 6.5 4.2c2.4 0 4.6 1.1 5.5 2.8.9-1.7 3.1-2.8 5.5-2.8 4.7 0 6.8 4.1 4.1 6.7C19 15.4 12 20 12 20z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
</svg>
)
};

View File

@@ -51,8 +51,42 @@ a {
}
/* === Splash Screen === */
/* 全站随机背景:高斯模糊 = --app-rand-bg-blur-ref × 20%(默认 24px → 4.8px */
.app-shell {
position: relative;
min-height: 100vh;
isolation: isolate;
--app-rand-bg-blur-ref: 6px;
}
.app-rand-bg {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.app-rand-bg__img {
position: absolute;
left: -8%;
top: -8%;
width: 116%;
height: 116%;
object-fit: cover;
filter: blur(calc(var(--app-rand-bg-blur-ref) * 0.2));
transform: scale(1.02);
}
.app-rand-bg__veil {
position: absolute;
inset: 0;
background: rgba(244, 244, 245, 0.78);
}
.app-shell > .app {
position: relative;
z-index: 1;
}
.splash {
@@ -337,6 +371,128 @@ nav a:hover {
letter-spacing: -0.3px;
}
/* === 公开用户列表 /users === */
.public-user-list-page {
max-width: 640px;
margin: 0 auto;
}
.public-user-list-head {
margin-bottom: 14px;
}
.public-user-list-title {
margin: 0 0 6px;
font-size: 20px;
font-weight: 800;
color: #171717;
letter-spacing: -0.3px;
}
.public-user-list-lead {
margin: 0;
font-size: 14px;
color: #6b7280;
}
.public-user-list-error {
margin-top: 8px;
}
.public-user-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.public-user-list-row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 12px;
text-decoration: none;
color: inherit;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.public-user-list-row:hover {
border-color: #d4d4d4;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
background: #fafafa;
}
.public-user-list-row:focus-visible {
outline: 2px solid #404040;
outline-offset: 2px;
}
.public-user-list-row .public-user-list-avatar.avatar-shell {
flex-shrink: 0;
width: 56px;
height: 56px;
}
.public-user-list-row .public-user-list-avatar.avatar-shell img {
width: 56px;
height: 56px;
}
.public-user-list-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.public-user-list-name {
font-size: 16px;
font-weight: 700;
color: #111827;
}
.public-user-list-account {
font-size: 13px;
color: #737373;
}
.public-user-list-sub {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
font-size: 13px;
color: #6b7280;
margin-top: 4px;
}
.public-user-list-sub .icon {
flex-shrink: 0;
}
.public-user-list-sub-sep {
display: inline-block;
width: 1px;
height: 12px;
background: #d4d4d4;
margin: 0 2px;
}
.public-user-list-chevron {
flex-shrink: 0;
font-size: 22px;
font-weight: 300;
color: #a3a3a3;
line-height: 1;
}
/* 外层已是卡片,内层登录表单不再叠第二张卡片 */
.unified-user-main .auth-card {
background: transparent;
@@ -772,14 +928,28 @@ button:disabled {
border-bottom: 1px solid #e5e5e5;
}
.profile-header img {
/* 外层圆角裁切 + 内层平铺,避免 WebKit 下圆角直接加在 img 上时 GIF 只播首帧 */
.profile-header .avatar-shell {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
overflow: hidden;
border: 3px solid #e5e5e5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
flex-shrink: 0;
display: inline-block;
vertical-align: top;
line-height: 0;
}
.profile-header .avatar-shell .avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transform: translateZ(0);
backface-visibility: hidden;
image-rendering: auto;
}
.profile-header > div {
@@ -1050,6 +1220,51 @@ a.tag.tag-mailto:hover {
box-shadow: none;
}
/* 公开主页点赞 */
.profile-like-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
margin-top: 10px;
padding-top: 8px;
}
.profile-like-btn {
font-size: 14px;
padding: 8px 16px;
border-radius: 999px;
}
.profile-like-done {
font-size: 14px;
font-weight: 600;
color: #059669;
}
.profile-like-error {
font-size: 14px;
}
.profile-like-hint {
font-size: 13px;
width: 100%;
flex-basis: 100%;
margin: 0;
}
.profile-like-quota {
width: 100%;
flex-basis: 100%;
margin: 0;
font-size: 13px;
color: #525252;
}
.profile-like-quota-exhausted {
color: #b45309;
}
.card.profile .stat-item:hover {
background: transparent;
border: none;
@@ -1509,6 +1724,8 @@ a.tag.tag-mailto:hover {
border-radius: 8px;
background: #f8fafc;
box-shadow: 0 8px 26px rgba(15, 23, 42, 0.07);
transform: translateZ(0);
image-rendering: auto;
}
.previewable-image {
@@ -1878,7 +2095,7 @@ select {
gap: 14px;
}
.profile-header img {
.profile-header .avatar-shell {
width: 60px;
height: 60px;
}
@@ -1987,3 +2204,317 @@ select {
font-size: 21px;
}
}
/* —— OAuth 独立授权页 —— */
.oauth-authorize-shell .oauth-authorize-main {
max-width: 520px;
margin: 0 auto;
width: 100%;
}
.oauth-authorize-shell .panel {
padding: 0;
background: transparent;
box-shadow: none;
}
.oauth-authorize-header .oauth-authorize-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 18px;
}
.oauth-header-deny {
color: var(--muted, #64748b);
font-size: 0.95rem;
}
.oauth-authorize-invalid h2 {
margin-top: 0;
}
.oauth-consent {
display: flex;
flex-direction: column;
gap: 16px;
padding: 4px 0 8px;
color: #171717;
}
.oauth-consent-brand {
text-align: center;
padding: 8px 12px 4px;
}
.oauth-consent-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
margin: 0 auto 12px;
border-radius: 14px;
background: #f4f4f5;
border: 1px solid #e5e5e5;
color: #404040;
}
.oauth-consent-lock {
display: block;
opacity: 1;
}
.oauth-consent-app-name {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 8px;
line-height: 1.25;
color: #111827;
letter-spacing: -0.02em;
}
.oauth-consent-lead {
margin: 0;
font-size: 0.95rem;
color: #57534e;
}
.oauth-consent-card {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 12px;
padding: 18px 20px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.oauth-consent-card--identity {
padding: 16px 20px;
}
.oauth-consent-identity {
display: flex;
align-items: flex-start;
gap: 16px;
}
.oauth-consent-identity .oauth-consent-avatar,
.oauth-consent-identity .oauth-consent-avatar img {
width: 56px;
height: 56px;
border-radius: 12px;
flex-shrink: 0;
}
.oauth-consent-id-text {
flex: 1;
min-width: 0;
}
.oauth-consent-display {
font-weight: 600;
font-size: 1.05rem;
margin-bottom: 4px;
}
.oauth-consent-as {
font-size: 0.9rem;
color: #57534e;
margin-bottom: 6px;
}
.oauth-consent-account {
word-break: break-all;
}
.oauth-consent-email {
font-size: 0.88rem;
}
.oauth-consent .muted {
color: #a3a3a3;
}
.oauth-consent-section-label {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #737373;
margin-bottom: 12px;
}
.oauth-consent-section-label--perm {
margin-top: 20px;
margin-bottom: 8px;
}
.oauth-consent-meta {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.oauth-consent-meta > li {
display: flex;
gap: 12px;
align-items: flex-start;
}
.oauth-consent-meta-icon {
flex-shrink: 0;
margin-top: 2px;
color: #525252;
opacity: 1;
}
.oauth-consent-meta-key {
font-size: 0.78rem;
color: #6b7280;
margin-bottom: 2px;
}
.oauth-consent-meta-val,
.oauth-consent-meta-link {
font-size: 0.92rem;
word-break: break-all;
color: #262626;
}
.oauth-consent-meta-link {
color: #1d4ed8;
text-decoration: underline;
text-underline-offset: 2px;
}
.oauth-consent-meta-link:hover {
color: #1e40af;
}
.oauth-consent-perm-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 10px;
border: 1px solid #e5e5e5;
background: #fafafa;
color: #262626;
font-size: 0.9rem;
cursor: pointer;
}
.oauth-consent-perm-toggle:hover {
border-color: #d4d4d4;
background: #f4f4f5;
}
.oauth-consent-chevron {
transition: transform 0.15s ease;
}
.oauth-consent-perm-toggle.is-open .oauth-consent-chevron {
transform: rotate(-180deg);
}
.oauth-consent-perms {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.oauth-consent-perms > li {
display: flex;
gap: 12px;
align-items: flex-start;
}
.oauth-consent-perm-icon {
flex-shrink: 0;
margin-top: 2px;
color: #525252;
opacity: 1;
}
.oauth-consent-perm-title {
font-weight: 600;
font-size: 0.92rem;
margin-bottom: 4px;
color: #171717;
}
.oauth-consent-perm-desc {
font-size: 0.85rem;
color: #57534e;
line-height: 1.5;
}
.oauth-consent-scope-list {
margin: 6px 0 0;
padding-left: 1.1em;
font-size: 0.88rem;
color: #44403c;
}
.oauth-consent-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 4px;
}
.oauth-consent-allow {
width: 100%;
padding: 12px 16px;
font-size: 1rem;
}
.oauth-consent-deny {
width: 100%;
}
.oauth-consent-switch {
align-self: center;
margin-top: 4px;
font-size: 0.9rem;
}
.oauth-consent-footnote {
font-size: 0.78rem;
color: #737373;
line-height: 1.5;
margin: 8px 0 0;
text-align: center;
}
@media (min-width: 640px) {
.oauth-consent-actions {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.oauth-consent-allow {
flex: 1 1 160px;
width: auto;
}
.oauth-consent-deny {
flex: 0 1 auto;
width: auto;
min-width: 120px;
}
.oauth-consent-switch {
flex: 1 1 100%;
margin-top: 6px;
}
}

View File

@@ -0,0 +1,94 @@
# SproutGate 前端文档
## 技术栈
- **运行时**React 18
- **构建工具**Vite 5`@vitejs/plugin-react`
- **Markdown 渲染**`marked`(用于资料等富文本展示)
- **样式**:全局 `src/styles.css`
无专门路由库:根据 `window.location.pathname``App.jsx` 中分支渲染各视图。
**OAuth 与授权页**:若 URL 带有 `redirect_uri` / `return_url` 且当前路径不是 **`/authorize`**、也不是管理/用户列表/公开用户页,`useLayoutEffect` 会将浏览器 **`replace`** 到 **`{BASE_URL}authorize`** 并保留查询串,用于独立授权确认(`OAuthConsentScreen` + `UserPortal``oauthStandalone`)。顶栏在授权流程下提供「拒绝 / 取消」(`buildAuthDenyCallbackUrl`)。
## 目录结构(要点)
| 路径 | 说明 |
|------|------|
| `src/main.jsx` | 入口;生产环境可选注册 `public/sw.js` |
| `src/App.jsx` | 壳层:开屏、随机背景、按路径切换三大视图、管理员入口弹窗、图片预览 |
| `src/config.js` | `API_BASE`、OAuth 查询解析(`getAuthFlowFromSearch`,含 `scope``scopes`)、`buildAuthCallbackUrl` / `buildAuthDenyCallbackUrl``formatOAuthRedirectLabel``persistAuthClientFromFlow` / `authClientFetchHeaders`、地理与日期展示工具等 |
| `src/styles.css` | 全站样式(含 `.oauth-consent*``.oauth-authorize-*` 授权页) |
| `src/components/` | `UserPortal``OAuthConsentScreen``PublicUserPage``PublicUserListPage``AdminPanel``SplashScreen``RandomSiteBackground``AvatarImg` 等 |
| `src/components/userPortal/` | 用户门户局部:`UserPortalAuthSection``UserPortalProfileSection` 等 |
| `public/` | 静态资源(如 `logo192.png` |
## 环境与 API 地址
后端 API 基址由 **`src/config.js`** 中的 `API_BASE` 决定:
1.**`VITE_API_BASE`**`.env` / `.env.local`)有值,则使用该地址(尾部 `/` 会自动去掉)。
2. 否则:**开发模式**`vite` / `import.meta.env.DEV`)默认 `http://localhost:8080`**生产构建**默认 `https://auth.api.shumengya.top`
本地联调后端示例(在 `sproutgate-frontend` 目录):
```bash
# .env.local
VITE_API_BASE=http://localhost:8080
```
部署到其它域名时,务必设置 `VITE_API_BASE` 为实际网关或后端地址,再执行 `npm run build`
其它与 `config.js` 相关的常量(一般不必改):
| 常量 | 用途 |
|------|------|
| `LOGO_192_SRC` | 顶栏 logo`import.meta.env.BASE_URL` 以支持子路径部署 |
| `RAND_BG_API_ORIGIN` | 全站随机背景 API`https://randbg.api.smyhub.com` |
| `CLIENT_GEO_LOOKUP_URL` | 浏览器侧 IP/地理,与后端「最后访问」元数据配合 |
## 前端「路由」与页面
表格中为**部署在站点根路径**`vite.config` 默认 `base: '/'`)时的 `pathname`。子路径部署时,实际 pathname 会带前缀,需与 Vite **`base`** 一致(顶栏链接已通过 `new URL(..., origin + BASE_URL)` 生成)。
| 路径模式 | 组件 | 说明 |
|----------|------|------|
| `/` | `UserPortal` | 登录、注册、OAuth 查询参数(`redirect_uri``client_id` 等)、个人资料、签到等;无 `oauthStandalone` |
| `/authorize` | `UserPortal``oauthStandalone` | 第三方授权专用壳:无/无效回跳时错误提示;登录后展示 `OAuthConsentScreen`,不并排展示完整资料区 |
| `/users` | `PublicUserListPage` | 调用 `GET /api/public/users` 的公开用户目录 |
| `/user``/user/:account` | `PublicUserPage` | 公开资料与主页赞等(`account` 来自路径,支持 URL 编码) |
| `/admin` 或子路径以 `/admin` 开头 | `AdminPanel` | 管理后台(需有效管理员 Token见下 |
管理员入口:**连点顶栏 logo 5 次** 打开弹窗,输入与后端一致的 **`X-Admin-Token`**;校验通过后 Token 写入 `localStorage``sproutgate_admin_token`)并跳转到带 `BASE_URL` 前缀的 `admin` 路径。
## 与后端的约定
- 所有业务请求使用 **`API_BASE`** 拼接路径,例如 `${API_BASE}/api/auth/login`
- 认证接口在适用场景下可附带 **`authClientFetchHeaders()`** 返回的 **`X-Auth-Client`** / **`X-Auth-Client-Name`**(来自统一登录 URL 的 `client_id` / `client_name`,经 `sessionStorage` 持久化本会话)。
- 访问上报、地理展示等可按需传 **`X-Visit-Ip`**、**`X-Visit-Location`**(和 CORS 允许的头一致,详见后端文档)。
- 公开用户列表、主页、点赞分别对应后端 **`GET /api/public/users`**、**`GET /api/public/users/:account`**、**`POST /api/public/users/:account/like`**(点赞需 Bearer
- 管理端请求使用 **`X-Admin-Token`**(或后端支持的 Query `token`),与后端持久化的管理员配置一致(见 **`sproutgate-backend/后端文档.md`**)。
具体 REST 路径与说明以 **`sproutgate-backend/后端文档.md`**、根目录 **`萌芽账户认证中心-第三方应用API接入文档.md`** 及 **`AGENTS.md`** 为准。
## npm 脚本
| 命令 | 说明 |
|------|------|
| `npm install` | 安装依赖 |
| `npm run dev` | 启动 Vite 开发服务器(`--host`,默认端口以终端输出为准,常为 `5173` |
| `npm run build` | 生产构建,输出到 `dist/` |
| `npm run preview` | 本地预览构建结果(`--host` |
根目录若使用 `sproutgate.bat` / `sproutgate.sh``dev`,通常会同时拉起后端与前端,详见仓库 `AGENTS.md`
## 构建与部署注意
- **`VITE_API_BASE`** 在构建时注入,修改后需重新 **`npm run build`**。
- 若站点部署在子路径,需配置 Vite **`base`**(如 `vite.config`),顶栏 logo 已通过 `BASE_URL` 避免裂图。
- 生产环境若开启 Service Worker需保证 `public/sw.js` 与缓存策略符合实际 CDN/域名。
## 安全提示
- 勿把真实管理员 Token、用户口令写入前端仓库管理员 Token 仅用于请求头,存 `localStorage` 有 XSS 风险,生产环境应强化 CSP 与依赖审计。
- `.env.local` 通常列入 `.gitignore`,避免将内网 API 地址误提交。

View File

@@ -0,0 +1,30 @@
server {
server_name auth.api.shumengya.top;
location / {
proxy_pass http://10.1.1.233:28080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/auth.shumengya.top/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/auth.shumengya.top/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = auth.api.shumengya.top) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name auth.api.shumengya.top;
return 404; # managed by Certbot
}

View File

@@ -0,0 +1,30 @@
server {
server_name auth.shumengya.top;
location / {
proxy_pass http://10.1.1.100:2121/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/auth.shumengya.top/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/auth.shumengya.top/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = auth.shumengya.top) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name auth.shumengya.top;
return 404; # managed by Certbot
}

View File

@@ -0,0 +1,26 @@
server {
listen 2121;
server_name sproutgate-frontend;
root /shumengya/www/self-made/sproutgate-frontend;
index index.html;
location / {
# SPA fallback
try_files $uri $uri/ /index.html;
}
location ~ /\. {
deny all;
}
location ^~ /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
location ~* \.(css|js|map|png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -1,6 +1,6 @@
# 萌芽账户认证中心 API 文档
访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`/api/docs``/api/health` 入口、路由前缀摘要)
访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`links.health``routePrefixes` 等)。**运行时未挂载**返回 Markdown 的 **`GET /api/docs`**;完整 HTTP 清单以 **`sproutgate-backend/main.go`** 与本文件、**`sproutgate-backend/后端文档.md`** 为准
接入地址:
- 统一登录前端:`https://auth.shumengya.top`
@@ -8,21 +8,26 @@
- 本地开发 API`http://<host>:8080`
对外接入建议:
1. 第三方应用按钮跳转到统一登录前端。
2. 登录成功后回跳到业务站点。
3. 业务站点使用回跳带回的 `token` 调用后端 API
1. 第三方「登录」按钮跳转到统一登录前端**授权入口**(推荐 `.../authorize?...`,见下节)
2. 用户在统一登录站点完成登录与授权确认后,回跳到业务站点。
3. 业务站点使用回跳 URL **`#`** 哈希中的 `token`,由**自有后端**调用 `POST /api/auth/verify``GET /api/auth/me` 校验并建立会话
**推荐入口 URL生产** 将下列主机换为你的统一登录部署域名;子路径部署时在前端 `base` 后追加 `authorize`
示例按钮:
```html
<a href="https://auth.shumengya.top/?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123">
<a href="https://auth.shumengya.top/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&amp;state=abc123&amp;client_id=my-app&amp;client_name=%E6%88%91%E7%9A%84%E5%BA%94%E7%94%A8">
使用萌芽统一账户认证登录
</a>
```
说明:
- 若用户打开的是站点根路径且仅携带 `redirect_uri` / `return_url` 等查询参数,前端会自动 **重定向****`/authorize`**(保留同一套 query以使用独立授权确认页。
- `client_id` / `client_name` 会由浏览器写入 session并在用户登录时提交给后端用于累计 **应用接入记录**(详见下文「应用接入记录」)。
回跳说明:
- 用户已登录时,统一登录前端会提示“继续授权”或“切换账号”
- 登录成功后会回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)。
- 第三方应用拿到 `token` 后,建议调用 **`POST /api/auth/verify`**无副作用、适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问记录,适合业务拉全量资料)校验并解析用户身份。
- **`/authorize`** 流程下,已登录用户会看到明确的授权页(应用信息、权限说明、允许/拒绝);拒绝时回跳地址的 hash 中会携带 `error=access_denied`
- 登录并「允许」后,浏览器回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)。
- 第三方拿到 `token` 后,建议服务端调用 **`POST /api/auth/verify`**不更新「最后访问」等侧写,适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问/签到相关侧写,适合拉取个人中心级资料)校验并解析用户身份。
### 统一登录前端:查询参数
@@ -32,7 +37,7 @@
| `return_url` | 同上 | 与 `redirect_uri` 同义,二者都传时优先 `redirect_uri`。 |
| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 |
| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 |
| `client_id` | 否 | 第三方应用稳定标识(字母数字开头,可含 `_.:-`,最长 64。写入用户「应用接入记录」并随登录请求提交给后端。 |
| `client_id` | 否 | 第三方应用稳定标识;格式须符合后端校验(见下文「应用接入记录」)。写入 session 后随登录请求提交,用于 **应用接入记录**。 |
| `client_name` | 否 | 展示用名称(最长 128`client_id` 配对;可选。 |
### 回跳 URL`#` 哈希参数
@@ -53,7 +58,50 @@
1. **仅信服务端**:回调页将 `token` 交给自有后端,由后端请求 `POST https://<api-host>/api/auth/verify`JSON body`{"token":"..."}`),根据 `valid``user.account` 建立会话。
2. **CORS**:浏览器直连 API 时须后端已配置 CORS本服务默认允许任意 `Origin`);若从服务端发起请求则不受 CORS 限制。
3. **令牌过期**`verify` / `me` 返回 401 或 `verify``valid:false` 时,应引导用户重新走统一登录。
3. **令牌无效或封禁**`verify` / `me` 返回 **401**`verify` 因封禁返回 **403**`valid: false`时,应作废本地会话并引导用户重新走统一登录。
## 应用接入记录authClients
用户在统一登录前端的 **个人中心 → 应用接入** 中可看到已与账号发生过认证关联的第三方应用列表。每条记录对应一个稳定的 **`client_id`**,并展示展示名(若有)、**首次接入时间**、**最近接入时间**(均为 RFC3339。持久化在 **MySQL `users` 表**中对应用户的 **`auth_clients` JSON 列**;对外 API 字段名仍为 **`authClients`**(与历史 JSON 文件存储时的形状一致)。
示例展示含义(与界面文案一致):**infogenie**(万象口袋)首次 `2026-03-22T16:33:24+08:00` · 最近 `2026-03-30T19:22:45+08:00` —— 其中括号内为 `client_name` / `X-Auth-Client-Name` / 登录体 `clientName` 带来的展示名,`infogenie``client_id`
### `client_id` 与名称规则
- **`client_id`(必填才能记一条)**:长度 164必须以 **英文字母或数字** 开头;其余字符仅可为 `[A-Za-z0-9_.:-]`(与后端正则一致)。
- **`client_name` / 展示名(可选)**:任意去首尾空白后的字符串,最长 **128** 字符,超出由服务端截断;对应存储字段为 `displayName`
### 在哪些接口上累计记录
记录仅在 **令牌有效、用户未封禁、且 `client_id` 通过校验** 时写入或刷新 **`lastSeenAt`**;已存在同一 `client_id` 时更新 **`lastSeenAt`**,必要时更新 **`displayName`****不改动** **`firstSeenAt`**。
| 方式 | 接口 / 场景 | 如何传递 `client_id` 与展示名 |
|------|----------------|--------------------------------|
| **A. 统一登录 URL** | 用户经前端完成登录 | 授权页 URL 查询参数 **`client_id`**、**`client_name`**(见上表)。前端写入 session登录请求 `POST /api/auth/login` 的 JSON 中会带 **`clientId`** / **`clientName`**。 |
| **B. 资源拥有者密码登录** | `POST /api/auth/login` | JSON **`clientId`**、**`clientName`**(可选,规则同上)。 |
| **C. 服务端持 JWT 调用** | `POST /api/auth/verify` | 请求头 **`X-Auth-Client`**(必须为合法 id、**`X-Auth-Client-Name`**(可选)。**Body 仅含 `token`,不能替代请求头。** |
| **D. 服务端持 JWT 调用** | `GET /api/auth/me` | 同上:**`X-Auth-Client`**、**`X-Auth-Client-Name`**,与 **`Authorization: Bearer`** 同时使用。 |
说明:
- **A + B**:在登录成功、签发 JWT **之后**写库;`POST /api/auth/login` 的响应 `user` 为 `OwnerPublic()`**含** `authClients`(若本次写入了新记录)。
- **C**`POST /api/auth/verify` 在校验通过且未封禁后写库;响应 `user`**`Public()`,不含 `authClients`**,避免向第三方泄露用户在其他应用上的关联列表。
- **D**`GET /api/auth/me` 在校验通过且未封禁后写库;响应 `user`**`OwnerPublic()`,含 `authClients`**,与前端个人中心一致。
**封禁用户**`POST /api/auth/verify``GET /api/auth/me` 在命中封禁或令牌无效时 **不会** 更新应用接入记录(在写库逻辑之前即返回错误)。
**浏览器直连 API**:若前端或网关需带上 `X-Auth-Client`,请确认 CORS 允许该自定义头(本服务 CORS 已包含 `X-Auth-Client``X-Auth-Client-Name`)。
### 存储结构(`authClients` 数组元素)
```json
{
"clientId": "infogenie",
"displayName": "万象口袋",
"firstSeenAt": "2026-03-22T16:33:24+08:00",
"lastSeenAt": "2026-03-30T19:22:45+08:00"
}
```
## 认证与统一登录
@@ -70,7 +118,7 @@
}
```
`clientId` / `clientName` 可选;规则与请求头 `X-Auth-Client` / `X-Auth-Client-Name` 一致。传入且格式合法时,会在登录成功后写入该用户的 **应用接入记录**(见`authClients`)。
`clientId` / `clientName` 可选;规则与应用接入请求头一致。传入且格式合法时,会在登录成功后写入 **应用接入记录**(见**「应用接入记录(authClients)」**)。
响应:
```json
@@ -120,7 +168,9 @@
### 校验令牌
`POST /api/auth/verify`
请求
请求头:**`Content-Type: application/json`**。可选 **`X-Auth-Client`**、**`X-Auth-Client-Name`**(累计应用接入记录,见专章)。
请求体:
```json
{
"token": "jwt-token"
@@ -135,7 +185,7 @@
}
```
若账户已封禁,返回 **200**`valid`**false**(不返回 `user` 对象),示例
若账户已封禁,返回 **403**,正文示例(不返回 `user`
```json
{
@@ -145,28 +195,26 @@
}
```
令牌过期、签名错误、issuer 不匹配等解析失败时返回 **401**,示例:`{"valid": false, "error": "invalid token"}`
令牌过期、签名错误、issuer 不匹配`token` 被撤销(`token_epoch`等失败时返回 **401**,示例:`{"valid": false, "error": "invalid token"}``{"valid": false, "error": "token revoked"}`(以服务端实际字段为准)
`verify``me` 的取舍:**仅校验身份、不改变用户数据**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer
`verify``me` 的取舍:**仅校验身份、默认不更新「最后访问」等侧写**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer
**应用接入记录(可选)**:第三方**`POST /api/auth/verify`** 或 **`GET /api/auth/me`** 上携带请求头:
- `X-Auth-Client`:应用 ID格式同登录 JSON 的 `clientId`
- `X-Auth-Client-Name`:可选展示名
校验成功且用户未封禁时,服务端会更新该用户 JSON 中的 `authClients` 数组(`clientId``displayName``firstSeenAt``lastSeenAt`)。**`POST /api/auth/verify` 的响应体 `user` 仍为 `Public()`,不含 `authClients`**,避免向调用方泄露用户在其他应用的接入情况;**`GET /api/auth/me`** 与管理员列表中的 `user``OwnerPublic`**包含** `authClients`,用户可在统一登录前端的个人中心查看。
**应用接入记录****`verify` / `me`** 上使用 **`X-Auth-Client`**、**`X-Auth-Client-Name`** 的约定,以及响应 `user` 是否包含 `authClients`,见上文 **「应用接入记录authClients」**。
### 获取当前用户信息
`GET /api/auth/me`
请求头:
`Authorization: Bearer <jwt-token>`
- **`Authorization: Bearer <jwt-token>`**(必填)
可选 — **应用接入记录**(与 `verify` 相同语义,见 **「应用接入记录authClients」**
- **`X-Auth-Client`**、**`X-Auth-Client-Name`**
可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」
- `X-Visit-Ip`:客户端公网 IP与地理接口返回的 `ip` 一致即可)
- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName``regionName``cityName` 拼接为 `中国 四川 成都`
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求 `GEO_LOOKUP_URL`(默认 `https://cf-ip-geo.smyhub.com/api?ip=<ip>`)解析展示位置并写入用户记录。
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求环境变量 **`GEO_LOOKUP_URL`** 指定的基址(默认 `internal/clientgeo.DefaultLookupURL` 一致:`https://cf-ip-geo.smyhub.com/api`,实际请求会附加 **`?ip=`**)解析展示位置并写入用户记录。
响应:
```json
@@ -184,7 +232,7 @@
> `user` 还会包含 `lastVisitAt``lastVisitDate``checkInDays``checkInStreak``visitDays``visitStreak` 等统计字段。
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp``lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**公开用户资料接口** `GET /api/public/users/:account`**`POST /api/auth/verify``user` 中不包含这两项**(避免公开展示或第三方校验时令牌响应携带访问隐私)。
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp``lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**`POST /api/auth/verify` 返回`user``Public()`中不包含这两项**(避免第三方校验响应携带访问隐私)。**`GET /api/public/users/:account``user``PublicProfile()`)会包含这两项**,与公开主页展示策略一致。
> 说明:密码不会返回。
@@ -237,16 +285,42 @@
## 用户广场
### 获取公开用户目录
`GET /api/public/users`
无需鉴权。返回未封禁用户的简要列表,默认按 **`createdAt`**(注册时间)升序。
响应:
```json
{
"total": 2,
"users": [
{
"account": "demo",
"username": "示例用户",
"level": 0,
"sproutCoins": 10,
"avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 简介",
"createdAt": "2026-03-14T12:00:00+08:00"
}
]
}
```
### 获取用户公开主页
`GET /api/public/users/{account}`
说明:
- 仅支持账户名 `account`,不支持昵称查询。
- 仅支持路径参数 **账户名** `account`(可与存储大小写不同,服务端按不区分大小写匹配),不支持昵称查询。
- 适合第三方应用展示用户公开资料。
- 若该账户已被封禁,返回 **404** `{"error":"user not found"}`(与不存在账户相同,避免公开资料泄露)。
- 响应中含该用户**最近一次被服务端记录的**访问 IP`lastVisitIp`)与展示用地理位置(`lastVisitDisplayLocation`,与本人中心一致);`POST /api/auth/verify` 返回的用户 JSON **不含**上述两项
- 响应体在 `user` 之外包含累计赞数 **`profileLikeCount`**
- **`Authorization: Bearer`** 可选:若传入**有效未封禁**用户令牌且非主页主人,响应可额外包含 `viewerHasLikedToday``viewerLikesRemainingToday``profileLikeDailyMax`(当日是否已赞该主页、当日还可给多少人点赞、每自然日上限当前为 **5**,以后端常量为准)。若浏览者即主页主人,可返回 `viewerIsOwner: true`
- `user` 中含该用户**最近一次被服务端记录的**访问 IP`lastVisitIp`)与展示地理位置(`lastVisitDisplayLocation``POST /api/auth/verify` 返回的用户 JSON **不含**上述两项。
响应:
响应示例(未带 Bearer
```json
{
"user": {
@@ -259,7 +333,29 @@
"lastVisitIp": "203.0.113.1",
"lastVisitDisplayLocation": "中国 广东省 深圳市",
"bio": "### 简介"
}
},
"profileLikeCount": 12
}
```
### 公开主页点赞
`POST /api/public/users/{account}/like`
请求头:**`Authorization: Bearer <jwt-token>`**(必填)。
为路径中 **`account`** 对应用户的公开主页点赞。规则摘要(与 `internal/storage` 一致):
- 不能给自己点赞;每个点赞者对同一主页 **每个自然日最多一次**
- 每个点赞者每个自然日最多给 **5** 位**不同**用户点赞(超限返回 **400**,正文中可含 `viewerLikesRemainingToday``profileLikeDailyMax`)。
- 目标用户不存在或已封禁:**404**;点赞者封禁或令牌无效:**401** / **403** 等,以服务端 `error` 为准。
成功响应示例:
```json
{
"profileLikeCount": 13,
"viewerHasLikedToday": true,
"viewerLikesRemainingToday": 4,
"profileLikeDailyMax": 5
}
```
@@ -498,9 +594,9 @@
```
- `banned`:是否封禁;解封时请传 `false`,并可将 `banReason` 置为空字符串。
- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`RFC3339存于用户 JSON)。
- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`RFC3339存于数据库用户记录)。
管理员列表 `GET /api/admin/users` 中每条 `user` 可含 `banned``banReason`(不含 `bannedAt` 亦可从存储文件中查看)
管理员列表 `GET /api/admin/users` 中每条 `user` `OwnerPublic()`可含 `banned``banReason``bannedAt`(及 `authClients` 等)——以后端实际 JSON 为准
### 删除用户
`DELETE /api/admin/users/{account}`
@@ -512,14 +608,20 @@
## 数据存储说明
- 用户数据:`data/users/*.json`
- 注册待验证:`data/pending/*.json`
- 密码重置记录:`data/reset/*.json`
- 辅助邮箱验证:`data/secondary/*.json`
- 管理员 Token`data/config/admin.json`
- JWT 配置:`data/config/auth.json`
- 邮件配置:`data/config/email.json`
- 注册策略与邀请码:`data/config/registration.json`
当前发行版**以 MySQL 为唯一运行时数据源**GORM `AutoMigrate`),不再使用 `data/users/*.json` 等文件作为线上读写路径。表与用途概要:
| 表名 | 用途 |
|------|------|
| `users` | 用户主数据;`auth_clients` 等为 JSON 列 |
| `pending_users` | 注册邮箱待验证 |
| `password_resets` | 找回密码验证码 |
| `secondary_email_verifications` | 辅助邮箱验证 |
| `app_configs` | `admin` / `auth` / `email` / `checkin` / `registration` 等 JSON 配置 |
| `invite_codes` | 邀请码 |
| `profile_likes` | 公开主页点赞记录 |
| `profile_like_daily_quota` | 点赞每日额度辅助 |
从旧版 **`sproutgate-backend/data/`** 目录迁移时,使用 **`go run ./cmd/migrate --data-dir ./data`**(详见 **`sproutgate-backend/后端文档.md`**)。
## 快速联调用示例
@@ -535,9 +637,19 @@ curl -X POST http://localhost:8080/api/auth/login \
# 校验令牌(推荐第三方网关先调此接口)
curl -X POST http://localhost:8080/api/auth/verify \
-H 'Content-Type: application/json' \
-H 'X-Auth-Client: my-app' \
-d '{"token":"<jwt-token>"}'
# 使用令牌获取用户信息(会更新访问记录)
# 同上,并携带应用展示名(累计应用接入记录)
curl -X POST http://localhost:8080/api/auth/verify \
-H 'Content-Type: application/json' \
-H 'X-Auth-Client: infogenie' \
-H 'X-Auth-Client-Name: 万象口袋' \
-d '{"token":"<jwt-token>"}'
# 使用令牌获取用户信息(会更新访问记录);可一并携带 X-Auth-Client 刷新「应用接入」最近时间
curl http://localhost:8080/api/auth/me \
-H 'Authorization: Bearer <jwt-token>'
-H 'Authorization: Bearer <jwt-token>' \
-H 'X-Auth-Client: infogenie' \
-H 'X-Auth-Client-Name: 万象口袋'
```