feat: 更新SproutGate前后端代码
This commit is contained in:
@@ -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": {}
|
||||
}
|
||||
@@ -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`(string,Markdown 格式):调用时传入本轮回复的摘要/结论,插件界面会弹出模态框展示给用户。格式: `{ "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` 工具(使用 CallMcpTool,server 为 CursorMCP对话插件 对应的 identifier,toolName 为 "check_messages",arguments 为 {})。严禁在回合开始或回复完成前调用。`ask_question` 仅用于任务中需要用户做选择时(如选框架、选方案)。
|
||||
218
sproutgate-backend/cmd/migrate/main.go
Normal file
218
sproutgate-backend/cmd/migrate/main.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
84
sproutgate-backend/internal/database/db.go
Normal file
84
sproutgate-backend/internal/database/db.go
Normal 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
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
154
sproutgate-backend/internal/handlers/public_profile.go
Normal file
154
sproutgate-backend/internal/handlers/public_profile.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
55
sproutgate-backend/internal/models/qq_avatar.go
Normal file
55
sproutgate-backend/internal/models/qq_avatar.go
Normal 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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
321
sproutgate-backend/internal/storage/dbmodels.go
Normal file
321
sproutgate-backend/internal/storage/dbmodels.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
// ─── JSON 序列化辅助类型 ────────────────────────────────────────────────────────
|
||||
|
||||
// StringSlice 将 []string 序列化为 JSON 字符串存入 TEXT 列。
|
||||
type StringSlice []string
|
||||
|
||||
func (s StringSlice) Value() (driver.Value, error) {
|
||||
if s == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
b, err := json.Marshal([]string(s))
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func (s *StringSlice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = StringSlice{}
|
||||
return nil
|
||||
}
|
||||
var raw []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
raw = v
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("StringSlice.Scan: unsupported type %T", value)
|
||||
}
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
*s = StringSlice{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, (*[]string)(s))
|
||||
}
|
||||
|
||||
// AuthClientSlice 将 []models.AuthClientEntry 序列化为 JSON 存入 TEXT 列。
|
||||
type AuthClientSlice []models.AuthClientEntry
|
||||
|
||||
func (a AuthClientSlice) Value() (driver.Value, error) {
|
||||
if a == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
b, err := json.Marshal([]models.AuthClientEntry(a))
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func (a *AuthClientSlice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*a = AuthClientSlice{}
|
||||
return nil
|
||||
}
|
||||
var raw []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
raw = v
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("AuthClientSlice.Scan: unsupported type %T", value)
|
||||
}
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
*a = AuthClientSlice{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, (*[]models.AuthClientEntry)(a))
|
||||
}
|
||||
|
||||
// ─── GORM DB 模型 ─────────────────────────────────────────────────────────────
|
||||
|
||||
// DBUser 对应数据库 users 表。
|
||||
type DBUser struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null"`
|
||||
Username string `gorm:"column:username;not null;size:255"`
|
||||
Email string `gorm:"column:email;not null;size:255;index"`
|
||||
Level int `gorm:"column:level;default:0"`
|
||||
SproutCoins int `gorm:"column:sprout_coins;default:0"`
|
||||
LastCheckInDate string `gorm:"column:last_check_in_date;size:20"`
|
||||
LastCheckInAt string `gorm:"column:last_check_in_at;size:128"`
|
||||
LastVisitDate string `gorm:"column:last_visit_date;size:20"`
|
||||
LastVisitAt string `gorm:"column:last_visit_at;size:128"`
|
||||
LastVisitIP string `gorm:"column:last_visit_ip;size:45"`
|
||||
LastVisitDisplayLocation string `gorm:"column:last_visit_display_location;size:512"`
|
||||
CheckInTimes StringSlice `gorm:"column:check_in_times;type:mediumtext"`
|
||||
VisitTimes StringSlice `gorm:"column:visit_times;type:mediumtext"`
|
||||
SecondaryEmails StringSlice `gorm:"column:secondary_emails;type:text"`
|
||||
Phone string `gorm:"column:phone;size:50"`
|
||||
AvatarURL string `gorm:"column:avatar_url;size:1024"`
|
||||
WebsiteURL string `gorm:"column:website_url;size:1024"`
|
||||
Bio string `gorm:"column:bio;type:text"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
UpdatedAt string `gorm:"column:updated_at;size:50;not null"`
|
||||
Banned bool `gorm:"column:banned;default:false"`
|
||||
BanReason string `gorm:"column:ban_reason;type:text"`
|
||||
BannedAt string `gorm:"column:banned_at;size:50"`
|
||||
TokenEpoch int64 `gorm:"column:token_epoch;default:0"`
|
||||
AuthClients AuthClientSlice `gorm:"column:auth_clients;type:mediumtext"`
|
||||
}
|
||||
|
||||
func (DBUser) TableName() string { return "users" }
|
||||
|
||||
// DBUserFromRecord 将领域模型转换为 GORM 模型(导出供 migrate 工具使用)。
|
||||
func DBUserFromRecord(r models.UserRecord) DBUser {
|
||||
return dbUserFromRecord(r)
|
||||
}
|
||||
|
||||
func dbUserFromRecord(r models.UserRecord) DBUser {
|
||||
return DBUser{
|
||||
Account: r.Account,
|
||||
PasswordHash: r.PasswordHash,
|
||||
Username: r.Username,
|
||||
Email: r.Email,
|
||||
Level: r.Level,
|
||||
SproutCoins: r.SproutCoins,
|
||||
LastCheckInDate: r.LastCheckInDate,
|
||||
LastCheckInAt: r.LastCheckInAt,
|
||||
LastVisitDate: r.LastVisitDate,
|
||||
LastVisitAt: r.LastVisitAt,
|
||||
LastVisitIP: r.LastVisitIP,
|
||||
LastVisitDisplayLocation: r.LastVisitDisplayLocation,
|
||||
CheckInTimes: StringSlice(r.CheckInTimes),
|
||||
VisitTimes: StringSlice(r.VisitTimes),
|
||||
SecondaryEmails: StringSlice(r.SecondaryEmails),
|
||||
Phone: r.Phone,
|
||||
AvatarURL: r.AvatarURL,
|
||||
WebsiteURL: r.WebsiteURL,
|
||||
Bio: r.Bio,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
Banned: r.Banned,
|
||||
BanReason: r.BanReason,
|
||||
BannedAt: r.BannedAt,
|
||||
TokenEpoch: r.TokenEpoch,
|
||||
AuthClients: AuthClientSlice(r.AuthClients),
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBUser) toRecord() models.UserRecord {
|
||||
return models.UserRecord{
|
||||
Account: d.Account,
|
||||
PasswordHash: d.PasswordHash,
|
||||
Username: d.Username,
|
||||
Email: d.Email,
|
||||
Level: d.Level,
|
||||
SproutCoins: d.SproutCoins,
|
||||
LastCheckInDate: d.LastCheckInDate,
|
||||
LastCheckInAt: d.LastCheckInAt,
|
||||
LastVisitDate: d.LastVisitDate,
|
||||
LastVisitAt: d.LastVisitAt,
|
||||
LastVisitIP: d.LastVisitIP,
|
||||
LastVisitDisplayLocation: d.LastVisitDisplayLocation,
|
||||
CheckInTimes: []string(d.CheckInTimes),
|
||||
VisitTimes: []string(d.VisitTimes),
|
||||
SecondaryEmails: []string(d.SecondaryEmails),
|
||||
Phone: d.Phone,
|
||||
AvatarURL: d.AvatarURL,
|
||||
WebsiteURL: d.WebsiteURL,
|
||||
Bio: d.Bio,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
Banned: d.Banned,
|
||||
BanReason: d.BanReason,
|
||||
BannedAt: d.BannedAt,
|
||||
TokenEpoch: d.TokenEpoch,
|
||||
AuthClients: []models.AuthClientEntry(d.AuthClients),
|
||||
}
|
||||
}
|
||||
|
||||
// DBPendingUser 对应 pending_users 表。
|
||||
type DBPendingUser struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null"`
|
||||
Username string `gorm:"column:username;not null;size:255"`
|
||||
Email string `gorm:"column:email;not null;size:255;index"`
|
||||
CodeHash string `gorm:"column:code_hash;not null"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50;not null"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
InviteCode string `gorm:"column:invite_code;size:32"`
|
||||
}
|
||||
|
||||
func (DBPendingUser) TableName() string { return "pending_users" }
|
||||
|
||||
func dbPendingFromModel(p models.PendingUser) DBPendingUser {
|
||||
return DBPendingUser{
|
||||
Account: p.Account,
|
||||
PasswordHash: p.PasswordHash,
|
||||
Username: p.Username,
|
||||
Email: p.Email,
|
||||
CodeHash: p.CodeHash,
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
CreatedAt: p.CreatedAt,
|
||||
InviteCode: p.InviteCode,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBPendingUser) toModel() models.PendingUser {
|
||||
return models.PendingUser{
|
||||
Account: d.Account,
|
||||
PasswordHash: d.PasswordHash,
|
||||
Username: d.Username,
|
||||
Email: d.Email,
|
||||
CodeHash: d.CodeHash,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
InviteCode: d.InviteCode,
|
||||
}
|
||||
}
|
||||
|
||||
// DBResetPassword 对应 password_resets 表。
|
||||
type DBResetPassword struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
Email string `gorm:"column:email;not null;size:255"`
|
||||
CodeHash string `gorm:"column:code_hash;not null"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50;not null"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
}
|
||||
|
||||
func (DBResetPassword) TableName() string { return "password_resets" }
|
||||
|
||||
func dbResetFromModel(r models.ResetPassword) DBResetPassword {
|
||||
return DBResetPassword{
|
||||
Account: r.Account,
|
||||
Email: r.Email,
|
||||
CodeHash: r.CodeHash,
|
||||
ExpiresAt: r.ExpiresAt,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBResetPassword) toModel() models.ResetPassword {
|
||||
return models.ResetPassword{
|
||||
Account: d.Account,
|
||||
Email: d.Email,
|
||||
CodeHash: d.CodeHash,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// DBSecondaryEmailVerification 对应 secondary_email_verifications 表。
|
||||
// 联合主键 (account, email)。
|
||||
type DBSecondaryEmailVerification struct {
|
||||
Account string `gorm:"primaryKey;column:account;size:255"`
|
||||
Email string `gorm:"primaryKey;column:email;size:255"`
|
||||
CodeHash string `gorm:"column:code_hash;not null"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50;not null"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
}
|
||||
|
||||
func (DBSecondaryEmailVerification) TableName() string { return "secondary_email_verifications" }
|
||||
|
||||
func dbSecondaryFromModel(s models.SecondaryEmailVerification) DBSecondaryEmailVerification {
|
||||
return DBSecondaryEmailVerification{
|
||||
Account: s.Account,
|
||||
Email: s.Email,
|
||||
CodeHash: s.CodeHash,
|
||||
ExpiresAt: s.ExpiresAt,
|
||||
CreatedAt: s.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBSecondaryEmailVerification) toModel() models.SecondaryEmailVerification {
|
||||
return models.SecondaryEmailVerification{
|
||||
Account: d.Account,
|
||||
Email: d.Email,
|
||||
CodeHash: d.CodeHash,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// DBAppConfig 对应 app_configs 表,存储应用配置键值对(value 为 JSON 文本)。
|
||||
type DBAppConfig struct {
|
||||
ConfigKey string `gorm:"primaryKey;column:config_key;size:128"`
|
||||
ConfigValue string `gorm:"column:config_value;type:text;not null"`
|
||||
}
|
||||
|
||||
func (DBAppConfig) TableName() string { return "app_configs" }
|
||||
|
||||
// DBInviteCode 对应 invite_codes 表。
|
||||
type DBInviteCode struct {
|
||||
Code string `gorm:"primaryKey;column:code;size:32"`
|
||||
Note string `gorm:"column:note;size:512"`
|
||||
MaxUses int `gorm:"column:max_uses;default:0"`
|
||||
Uses int `gorm:"column:uses;default:0"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:50"`
|
||||
CreatedAt string `gorm:"column:created_at;size:50;not null;<-:create"`
|
||||
}
|
||||
|
||||
func (DBInviteCode) TableName() string { return "invite_codes" }
|
||||
|
||||
func dbInviteFromEntry(e InviteEntry) DBInviteCode {
|
||||
return DBInviteCode{
|
||||
Code: e.Code,
|
||||
Note: e.Note,
|
||||
MaxUses: e.MaxUses,
|
||||
Uses: e.Uses,
|
||||
ExpiresAt: e.ExpiresAt,
|
||||
CreatedAt: e.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DBInviteCode) toEntry() InviteEntry {
|
||||
return InviteEntry{
|
||||
Code: d.Code,
|
||||
Note: d.Note,
|
||||
MaxUses: d.MaxUses,
|
||||
Uses: d.Uses,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,34 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SavePending(record models.PendingUser) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(record.Account)
|
||||
return writeJSONFile(path, record)
|
||||
row := dbPendingFromModel(record)
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) GetPending(account string) (models.PendingUser, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBPendingUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.PendingUser{}, false, nil
|
||||
}
|
||||
var record models.PendingUser
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.PendingUser{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.PendingUser{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toModel(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeletePending(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
return s.db.Delete(&DBPendingUser{}, "account = ?", account).Error
|
||||
}
|
||||
|
||||
func (s *Store) pendingFilePath(account string) string {
|
||||
return filepath.Join(s.pendingDir, pendingFileName(account))
|
||||
}
|
||||
|
||||
func pendingFileName(account string) string {
|
||||
safe := strings.TrimSpace(account)
|
||||
if safe == "" {
|
||||
safe = "unknown"
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(safe))
|
||||
return encoded + ".json"
|
||||
}
|
||||
// DataDir 返回空字符串(MySQL 模式下无本地数据目录)。
|
||||
func (s *Store) DataDir() string { return "" }
|
||||
|
||||
219
sproutgate-backend/internal/storage/profile_likes.go
Normal file
219
sproutgate-backend/internal/storage/profile_likes.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
// MaxProfileLikesPerDay 每个点赞者每个自然日最多给多少位不同用户的公开主页点赞。
|
||||
const MaxProfileLikesPerDay = 5
|
||||
|
||||
var (
|
||||
ErrCannotLikeOwnProfile = errors.New("cannot like own profile")
|
||||
ErrAlreadyLikedToday = errors.New("already liked today")
|
||||
ErrDailyLikeLimitReached = errors.New("daily like limit reached")
|
||||
ErrProfileLikeTarget = errors.New("user not found")
|
||||
ErrProfileLikeLiker = errors.New("invalid liker")
|
||||
)
|
||||
|
||||
// DBProfileLikeDailyQuota 点赞者当日剩余可点赞人数(与 profile_likes 计数同步;新自然日新行即视为刷新为满额)。
|
||||
type DBProfileLikeDailyQuota struct {
|
||||
LikerAccount string `gorm:"primaryKey;column:liker_account;size:255"`
|
||||
LikeDate string `gorm:"primaryKey;column:like_date;size:10"`
|
||||
Remaining int `gorm:"column:remaining;not null"`
|
||||
UpdatedAt string `gorm:"column:updated_at;size:50"`
|
||||
}
|
||||
|
||||
func (DBProfileLikeDailyQuota) TableName() string { return "profile_like_daily_quota" }
|
||||
|
||||
// DBProfileLike 记录「谁在某日给谁的主页点赞」(每人每天对同一主页最多一条)。
|
||||
type DBProfileLike struct {
|
||||
LikerAccount string `gorm:"primaryKey;column:liker_account;size:255"`
|
||||
LikedAccount string `gorm:"primaryKey;column:liked_account;size:255"`
|
||||
LikeDate string `gorm:"primaryKey;column:like_date;size:10"` // YYYY-MM-DD,与签到同历
|
||||
CreatedAt string `gorm:"column:created_at;size:50"`
|
||||
}
|
||||
|
||||
func (DBProfileLike) TableName() string { return "profile_likes" }
|
||||
|
||||
// CountProfileLikes 主页累计获赞次数(历史所有日的点赞条数之和)。
|
||||
func (s *Store) CountProfileLikes(likedAccount string) (int64, error) {
|
||||
likedAccount = strings.TrimSpace(likedAccount)
|
||||
if likedAccount == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var n int64
|
||||
if err := s.db.Model(&DBProfileLike{}).Where("liked_account = ?", likedAccount).Count(&n).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ViewerHasLikedProfileToday 当前自然日(Asia/Shanghai)内是否已为该主页点过赞。
|
||||
func (s *Store) ViewerHasLikedProfileToday(likerAccount, likedAccount string) (bool, error) {
|
||||
likerAccount = strings.TrimSpace(likerAccount)
|
||||
likedAccount = strings.TrimSpace(likedAccount)
|
||||
if likerAccount == "" || likedAccount == "" {
|
||||
return false, nil
|
||||
}
|
||||
today := models.CurrentActivityDate()
|
||||
var n int64
|
||||
err := s.db.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND liked_account = ? AND like_date = ?", likerAccount, likedAccount, today).
|
||||
Count(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ProfileLikeRemainingToday 当日还可给多少人点赞(根据 profile_likes 计数计算,并与 quota 表对齐)。
|
||||
func (s *Store) ProfileLikeRemainingToday(likerAccount string) (int, error) {
|
||||
likerAccount = strings.TrimSpace(likerAccount)
|
||||
if likerAccount == "" {
|
||||
return 0, nil
|
||||
}
|
||||
today := models.CurrentActivityDate()
|
||||
var used int64
|
||||
if err := s.db.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerAccount, today).
|
||||
Count(&used).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rem := MaxProfileLikesPerDay - int(used)
|
||||
if rem < 0 {
|
||||
rem = 0
|
||||
}
|
||||
var q DBProfileLikeDailyQuota
|
||||
err := s.db.Where("liker_account = ? AND like_date = ?", likerAccount, today).First(&q).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return rem, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if q.Remaining != rem {
|
||||
_ = s.db.Model(&DBProfileLikeDailyQuota{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerAccount, today).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": rem,
|
||||
"updated_at": models.NowISO(),
|
||||
}).Error
|
||||
}
|
||||
return rem, nil
|
||||
}
|
||||
|
||||
// AddProfileLike 为公开主页点赞(每人每天每主页一次;每人每天最多给 MaxProfileLikesPerDay 位用户点赞)。返回新的累计赞数。
|
||||
func (s *Store) AddProfileLike(likerAccount, likedAccount string) (int64, error) {
|
||||
likerAccount = strings.TrimSpace(likerAccount)
|
||||
likedAccount = strings.TrimSpace(likedAccount)
|
||||
if likerAccount == "" || likedAccount == "" {
|
||||
return 0, errors.New("account is required")
|
||||
}
|
||||
if strings.EqualFold(likerAccount, likedAccount) {
|
||||
return 0, ErrCannotLikeOwnProfile
|
||||
}
|
||||
|
||||
likedUser, found, err := s.GetUser(likedAccount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !found || likedUser.Banned {
|
||||
return 0, ErrProfileLikeTarget
|
||||
}
|
||||
likerUser, foundL, err := s.GetUser(likerAccount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !foundL || likerUser.Banned {
|
||||
return 0, ErrProfileLikeLiker
|
||||
}
|
||||
|
||||
today := models.CurrentActivityDate()
|
||||
likerCanon := likerUser.Account
|
||||
likedCanon := likedUser.Account
|
||||
row := DBProfileLike{
|
||||
LikerAccount: likerCanon,
|
||||
LikedAccount: likedCanon,
|
||||
LikeDate: today,
|
||||
CreatedAt: models.NowISO(),
|
||||
}
|
||||
|
||||
txErr := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var quota DBProfileLikeDailyQuota
|
||||
qErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
First("a).Error
|
||||
if errors.Is(qErr, gorm.ErrRecordNotFound) {
|
||||
quota = DBProfileLikeDailyQuota{
|
||||
LikerAccount: likerCanon,
|
||||
LikeDate: today,
|
||||
Remaining: MaxProfileLikesPerDay,
|
||||
UpdatedAt: models.NowISO(),
|
||||
}
|
||||
if cerr := tx.Create("a).Error; cerr != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
First("a).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if qErr != nil {
|
||||
return qErr
|
||||
}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
First("a).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var used int64
|
||||
if err := tx.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Count(&used).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if int(used) >= MaxProfileLikesPerDay {
|
||||
if err := tx.Model(&DBProfileLikeDailyQuota{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": 0,
|
||||
"updated_at": models.NowISO(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrDailyLikeLimitReached
|
||||
}
|
||||
|
||||
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrAlreadyLikedToday
|
||||
}
|
||||
|
||||
var usedAfter int64
|
||||
if err := tx.Model(&DBProfileLike{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Count(&usedAfter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rem := MaxProfileLikesPerDay - int(usedAfter)
|
||||
if rem < 0 {
|
||||
rem = 0
|
||||
}
|
||||
return tx.Model(&DBProfileLikeDailyQuota{}).
|
||||
Where("liker_account = ? AND like_date = ?", likerCanon, today).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": rem,
|
||||
"updated_at": models.NowISO(),
|
||||
}).Error
|
||||
})
|
||||
if txErr != nil {
|
||||
return 0, txErr
|
||||
}
|
||||
return s.CountProfileLikes(likedCanon)
|
||||
}
|
||||
@@ -3,13 +3,60 @@ package storage
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultForbiddenAccountsCSV = "sb,mail,nmsl,cnmb,smy,shumengya"
|
||||
defaultInviteRegisterRewardCoins = 10
|
||||
// MinSelfServiceAccountLen / MaxSelfServiceAccountLen 自助注册账号长度(仅小写与数字)。
|
||||
MinSelfServiceAccountLen = 3
|
||||
MaxSelfServiceAccountLen = 32
|
||||
)
|
||||
|
||||
func effectiveInviteRegisterRewardCoins(stored *int) int {
|
||||
if stored == nil {
|
||||
return defaultInviteRegisterRewardCoins
|
||||
}
|
||||
if *stored < 0 {
|
||||
return 0
|
||||
}
|
||||
return *stored
|
||||
}
|
||||
|
||||
var selfServiceAccountPattern = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
// NormalizeSelfServiceAccount 自助注册账号:去空白并转为小写。
|
||||
func NormalizeSelfServiceAccount(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func effectiveForbiddenAccountsCSV(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return defaultForbiddenAccountsCSV
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
func parseForbiddenAccountSet(csv string) map[string]struct{} {
|
||||
out := make(map[string]struct{})
|
||||
for _, part := range strings.Split(csv, ",") {
|
||||
t := strings.ToLower(strings.TrimSpace(part))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out[t] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InviteEntry 管理员发放的注册邀请码。
|
||||
type InviteEntry struct {
|
||||
Code string `json:"code"`
|
||||
@@ -20,64 +67,155 @@ type InviteEntry struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// RegistrationConfig 注册策略与邀请码列表(data/config/registration.json)。
|
||||
// RegistrationConfig 注册策略(不含邀请码列表,邀请码单独存入 invite_codes 表)。
|
||||
type RegistrationConfig struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
Invites []InviteEntry `json:"invites"`
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
ForbiddenAccounts string `json:"forbiddenAccounts"` // 逗号分隔;空串表示使用内置默认禁注列表
|
||||
InviteRegisterRewardCoins int `json:"inviteRegisterRewardCoins"` // 使用邀请码完成注册时赠送(生效值,默认 10)
|
||||
Invites []InviteEntry `json:"invites"` // 内存缓存,非 DB 字段
|
||||
}
|
||||
|
||||
func normalizeInviteCode(raw string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
type dbRegistrationPolicy struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
ForbiddenAccounts string `json:"forbiddenAccounts"`
|
||||
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins,omitempty"` // nil 表示未配置,按内置默认 10
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateRegistrationConfig() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, err := os.Stat(s.registrationPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := RegistrationConfig{RequireInviteCode: false, Invites: []InviteEntry{}}
|
||||
if err := writeJSONFile(s.registrationPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.registrationConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg RegistrationConfig
|
||||
if err := readJSONFile(s.registrationPath, &cfg); err != nil {
|
||||
var policy dbRegistrationPolicy
|
||||
found, err := s.getConfig("registration", &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Invites == nil {
|
||||
cfg.Invites = []InviteEntry{}
|
||||
if !found {
|
||||
policy = dbRegistrationPolicy{RequireInviteCode: false, ForbiddenAccounts: ""}
|
||||
if err := s.setConfig("registration", policy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.registrationConfig = cfg
|
||||
|
||||
// 从 invite_codes 表加载所有邀请码
|
||||
invites, err := s.loadAllInvites()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rewardEff := effectiveInviteRegisterRewardCoins(policy.InviteRegisterRewardCoins)
|
||||
s.mu.Lock()
|
||||
s.registrationConfig = RegistrationConfig{
|
||||
RequireInviteCode: policy.RequireInviteCode,
|
||||
ForbiddenAccounts: policy.ForbiddenAccounts,
|
||||
InviteRegisterRewardCoins: rewardEff,
|
||||
Invites: invites,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) persistRegistrationConfigLocked() error {
|
||||
return writeJSONFile(s.registrationPath, s.registrationConfig)
|
||||
func (s *Store) loadAllInvites() ([]InviteEntry, error) {
|
||||
var rows []DBInviteCode
|
||||
if err := s.db.Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]InviteEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
entries = append(entries, r.toEntry())
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// RegistrationRequireInvite 是否强制要求邀请码才能发起注册(发邮件验证码)。
|
||||
// RegistrationRequireInvite 是否强制邀请码。
|
||||
func (s *Store) RegistrationRequireInvite() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.registrationConfig.RequireInviteCode
|
||||
}
|
||||
|
||||
// GetRegistrationConfig 返回配置副本(管理端)。
|
||||
// RegistrationInviteRegisterRewardCoins 使用邀请码完成邮箱验证注册时赠送的萌芽币(未配置时为 10)。
|
||||
func (s *Store) RegistrationInviteRegisterRewardCoins() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.registrationConfig.InviteRegisterRewardCoins
|
||||
}
|
||||
|
||||
// GetRegistrationConfig 返回配置副本(含邀请码列表)。
|
||||
func (s *Store) GetRegistrationConfig() RegistrationConfig {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.registrationConfig
|
||||
out.Invites = append([]InviteEntry(nil), s.registrationConfig.Invites...)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := effectiveRegistrationConfigForAPI(s.registrationConfig)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetRegistrationRequireInvite 更新是否强制邀请码。
|
||||
func (s *Store) SetRegistrationRequireInvite(require bool) error {
|
||||
// effectiveRegistrationConfigForAPI 管理端展示用:禁注列表空时透出当前生效的内置默认文案。
|
||||
func effectiveRegistrationConfigForAPI(in RegistrationConfig) RegistrationConfig {
|
||||
out := RegistrationConfig{
|
||||
RequireInviteCode: in.RequireInviteCode,
|
||||
ForbiddenAccounts: in.ForbiddenAccounts,
|
||||
InviteRegisterRewardCoins: in.InviteRegisterRewardCoins,
|
||||
Invites: append([]InviteEntry(nil), in.Invites...),
|
||||
}
|
||||
if strings.TrimSpace(out.ForbiddenAccounts) == "" {
|
||||
out.ForbiddenAccounts = defaultForbiddenAccountsCSV
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidateSelfServiceAccount 自助注册账号:仅小写字母与数字,长度与禁注表(可配置,空则用内置默认)。
|
||||
func (s *Store) ValidateSelfServiceAccount(account string) error {
|
||||
acc := NormalizeSelfServiceAccount(account)
|
||||
if acc == "" {
|
||||
return errors.New("account is required")
|
||||
}
|
||||
if len(acc) < MinSelfServiceAccountLen || len(acc) > MaxSelfServiceAccountLen {
|
||||
return errors.New("account must be 3-32 characters")
|
||||
}
|
||||
if !selfServiceAccountPattern.MatchString(acc) {
|
||||
return errors.New("account may only contain lowercase letters and digits")
|
||||
}
|
||||
s.mu.RLock()
|
||||
csv := s.registrationConfig.ForbiddenAccounts
|
||||
s.mu.RUnlock()
|
||||
blocked := parseForbiddenAccountSet(effectiveForbiddenAccountsCSV(csv))
|
||||
if _, ok := blocked[acc]; ok {
|
||||
return errors.New("this account name is not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRegistrationPolicy 更新注册策略;inviteRewardCoins 为 nil 时不改库中该项(兼容旧客户端)。
|
||||
func (s *Store) UpdateRegistrationPolicy(require bool, forbiddenAccounts string, inviteRewardCoins *int) error {
|
||||
forbiddenAccounts = strings.TrimSpace(forbiddenAccounts)
|
||||
var policy dbRegistrationPolicy
|
||||
found, err := s.getConfig("registration", &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
policy = dbRegistrationPolicy{}
|
||||
}
|
||||
policy.RequireInviteCode = require
|
||||
policy.ForbiddenAccounts = forbiddenAccounts
|
||||
if inviteRewardCoins != nil {
|
||||
v := *inviteRewardCoins
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
policy.InviteRegisterRewardCoins = &v
|
||||
}
|
||||
if err := s.setConfig("registration", policy); err != nil {
|
||||
return err
|
||||
}
|
||||
rewardEff := effectiveInviteRegisterRewardCoins(policy.InviteRegisterRewardCoins)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.registrationConfig.RequireInviteCode = require
|
||||
return s.persistRegistrationConfigLocked()
|
||||
s.registrationConfig.ForbiddenAccounts = forbiddenAccounts
|
||||
s.registrationConfig.InviteRegisterRewardCoins = rewardEff
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func inviteEntryValid(e *InviteEntry) error {
|
||||
@@ -93,14 +231,14 @@ func inviteEntryValid(e *InviteEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateInviteForRegister 校验邀请码是否可用(发验证码前,不扣次)。
|
||||
// ValidateInviteForRegister 校验邀请码是否可用(不扣次)。
|
||||
func (s *Store) ValidateInviteForRegister(code string) error {
|
||||
n := normalizeInviteCode(code)
|
||||
if n == "" {
|
||||
return errors.New("invite code is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := range s.registrationConfig.Invites {
|
||||
e := &s.registrationConfig.Invites[i]
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
@@ -110,14 +248,16 @@ func (s *Store) ValidateInviteForRegister(code string) error {
|
||||
return errors.New("invalid invite code")
|
||||
}
|
||||
|
||||
// RedeemInvite 邮箱验证通过创建用户后扣减邀请码使用次数。
|
||||
// RedeemInvite 邮箱验证通过后扣减邀请码使用次数。
|
||||
func (s *Store) RedeemInvite(code string) error {
|
||||
n := normalizeInviteCode(code)
|
||||
if n == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i := range s.registrationConfig.Invites {
|
||||
e := &s.registrationConfig.Invites[i]
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
@@ -125,7 +265,10 @@ func (s *Store) RedeemInvite(code string) error {
|
||||
return err
|
||||
}
|
||||
e.Uses++
|
||||
return s.persistRegistrationConfigLocked()
|
||||
// 写回数据库
|
||||
return s.db.Model(&DBInviteCode{}).
|
||||
Where("code = ?", e.Code).
|
||||
Update("uses", e.Uses).Error
|
||||
}
|
||||
}
|
||||
return errors.New("invalid invite code")
|
||||
@@ -146,10 +289,11 @@ func randomInviteToken(n int) (string, error) {
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// AddInviteEntry 生成新邀请码并写入配置。
|
||||
// AddInviteEntry 生成新邀请码并写入数据库。
|
||||
func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (InviteEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var code string
|
||||
for attempt := 0; attempt < 24; attempt++ {
|
||||
c, err := randomInviteToken(8)
|
||||
@@ -171,6 +315,7 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
|
||||
if code == "" {
|
||||
return InviteEntry{}, errors.New("failed to generate unique invite code")
|
||||
}
|
||||
|
||||
expiresAt = strings.TrimSpace(expiresAt)
|
||||
if expiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339, expiresAt); err != nil {
|
||||
@@ -180,6 +325,7 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
|
||||
if maxUses < 0 {
|
||||
maxUses = 0
|
||||
}
|
||||
|
||||
entry := InviteEntry{
|
||||
Code: code,
|
||||
Note: strings.TrimSpace(note),
|
||||
@@ -188,11 +334,13 @@ func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (Invi
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: models.NowISO(),
|
||||
}
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites, entry)
|
||||
if err := s.persistRegistrationConfigLocked(); err != nil {
|
||||
s.registrationConfig.Invites = s.registrationConfig.Invites[:len(s.registrationConfig.Invites)-1]
|
||||
|
||||
row := dbInviteFromEntry(entry)
|
||||
if err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return InviteEntry{}, err
|
||||
}
|
||||
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites, entry)
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
@@ -202,13 +350,40 @@ func (s *Store) DeleteInviteEntry(code string) error {
|
||||
if n == "" {
|
||||
return errors.New("code is required")
|
||||
}
|
||||
|
||||
result := s.db.Where("UPPER(code) = ?", n).Delete(&DBInviteCode{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("invite not found")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, e := range s.registrationConfig.Invites {
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites[:i], s.registrationConfig.Invites[i+1:]...)
|
||||
return s.persistRegistrationConfigLocked()
|
||||
s.registrationConfig.Invites = append(
|
||||
s.registrationConfig.Invites[:i],
|
||||
s.registrationConfig.Invites[i+1:]...,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
return errors.New("invite not found")
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshInviteCache 重新从数据库加载邀请码列表(内部用)。
|
||||
func (s *Store) refreshInviteCache() error {
|
||||
invites, err := s.loadAllInvites()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.registrationConfig.Invites = invites
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 确保 gorm 包被使用
|
||||
var _ = gorm.ErrRecordNotFound
|
||||
|
||||
@@ -1,55 +1,31 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SaveReset(record models.ResetPassword) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(record.Account)
|
||||
return writeJSONFile(path, record)
|
||||
row := dbResetFromModel(record)
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) GetReset(account string) (models.ResetPassword, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBResetPassword
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.ResetPassword{}, false, nil
|
||||
}
|
||||
var record models.ResetPassword
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.ResetPassword{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.ResetPassword{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toModel(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteReset(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) resetFilePath(account string) string {
|
||||
return filepath.Join(s.resetDir, resetFileName(account))
|
||||
}
|
||||
|
||||
func resetFileName(account string) string {
|
||||
safe := strings.TrimSpace(account)
|
||||
if safe == "" {
|
||||
safe = "unknown"
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(safe))
|
||||
return encoded + ".json"
|
||||
return s.db.Delete(&DBResetPassword{}, "account = ?", account).Error
|
||||
}
|
||||
|
||||
@@ -1,60 +1,31 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SaveSecondaryVerification(record models.SecondaryEmailVerification) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(record.Account, record.Email)
|
||||
return writeJSONFile(path, record)
|
||||
row := dbSecondaryFromModel(record)
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) GetSecondaryVerification(account string, email string) (models.SecondaryEmailVerification, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(account, email)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBSecondaryEmailVerification
|
||||
result := s.db.First(&row, "account = ? AND email = ?", account, email)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.SecondaryEmailVerification{}, false, nil
|
||||
}
|
||||
var record models.SecondaryEmailVerification
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.SecondaryEmailVerification{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.SecondaryEmailVerification{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toModel(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSecondaryVerification(account string, email string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(account, email)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) secondaryFilePath(account string, email string) string {
|
||||
return filepath.Join(s.secondaryDir, secondaryFileName(account, email))
|
||||
}
|
||||
|
||||
func secondaryFileName(account string, email string) string {
|
||||
accountSafe := strings.TrimSpace(account)
|
||||
emailSafe := strings.TrimSpace(email)
|
||||
if accountSafe == "" {
|
||||
accountSafe = "unknown"
|
||||
}
|
||||
if emailSafe == "" {
|
||||
emailSafe = "unknown"
|
||||
}
|
||||
raw := accountSafe + "::" + emailSafe
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(raw))
|
||||
return encoded + ".json"
|
||||
return s.db.Delete(&DBSecondaryEmailVerification{}, "account = ? AND email = ?", account, email).Error
|
||||
}
|
||||
|
||||
@@ -6,13 +6,17 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
// ─── 配置结构体(公开 API 不变)────────────────────────────────────────────────
|
||||
|
||||
type AdminConfig struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@@ -36,66 +40,36 @@ type CheckInConfig struct {
|
||||
RewardCoins int `json:"rewardCoins"`
|
||||
}
|
||||
|
||||
// ─── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store struct {
|
||||
dataDir string
|
||||
usersDir string
|
||||
pendingDir string
|
||||
resetDir string
|
||||
secondaryDir string
|
||||
adminConfigPath string
|
||||
authConfigPath string
|
||||
emailConfigPath string
|
||||
checkInPath string
|
||||
registrationPath string
|
||||
registrationConfig RegistrationConfig
|
||||
db *gorm.DB
|
||||
adminToken string
|
||||
jwtSecret []byte
|
||||
issuer string
|
||||
emailConfig EmailConfig
|
||||
checkInConfig CheckInConfig
|
||||
mu sync.Mutex
|
||||
registrationConfig RegistrationConfig
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewStore(dataDir string) (*Store, error) {
|
||||
if dataDir == "" {
|
||||
dataDir = "./data"
|
||||
}
|
||||
absDir, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
// NewStore 接收已建立连接的 *gorm.DB,自动迁移表结构并加载配置。
|
||||
func NewStore(db *gorm.DB) (*Store, error) {
|
||||
if err := db.AutoMigrate(
|
||||
&DBUser{},
|
||||
&DBPendingUser{},
|
||||
&DBResetPassword{},
|
||||
&DBSecondaryEmailVerification{},
|
||||
&DBAppConfig{},
|
||||
&DBInviteCode{},
|
||||
&DBProfileLike{},
|
||||
&DBProfileLikeDailyQuota{},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usersDir := filepath.Join(absDir, "users")
|
||||
pendingDir := filepath.Join(absDir, "pending")
|
||||
resetDir := filepath.Join(absDir, "reset")
|
||||
secondaryDir := filepath.Join(absDir, "secondary")
|
||||
configDir := filepath.Join(absDir, "config")
|
||||
if err := os.MkdirAll(usersDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(pendingDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(resetDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(secondaryDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{
|
||||
dataDir: absDir,
|
||||
usersDir: usersDir,
|
||||
pendingDir: pendingDir,
|
||||
resetDir: resetDir,
|
||||
secondaryDir: secondaryDir,
|
||||
adminConfigPath: filepath.Join(configDir, "admin.json"),
|
||||
authConfigPath: filepath.Join(configDir, "auth.json"),
|
||||
emailConfigPath: filepath.Join(configDir, "email.json"),
|
||||
checkInPath: filepath.Join(configDir, "checkin.json"),
|
||||
registrationPath: filepath.Join(configDir, "registration.json"),
|
||||
}
|
||||
|
||||
store := &Store{db: db}
|
||||
|
||||
if err := store.loadOrCreateAdminConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -111,32 +85,136 @@ func NewStore(dataDir string) (*Store, error) {
|
||||
if err := store.loadOrCreateRegistrationConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) DataDir() string {
|
||||
return s.dataDir
|
||||
// ─── 配置辅助 ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) getConfig(key string, target any) (bool, error) {
|
||||
var row DBAppConfig
|
||||
result := s.db.First(&row, "config_key = ?", key)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return true, json.Unmarshal([]byte(row.ConfigValue), target)
|
||||
}
|
||||
|
||||
func (s *Store) AdminToken() string {
|
||||
return s.adminToken
|
||||
func (s *Store) setConfig(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := DBAppConfig{ConfigKey: key, ConfigValue: string(raw)}
|
||||
return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *Store) JWTSecret() []byte {
|
||||
return s.jwtSecret
|
||||
// ─── Admin 配置 ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) AdminToken() string { return s.adminToken }
|
||||
|
||||
func (s *Store) loadOrCreateAdminConfig() error {
|
||||
var cfg AdminConfig
|
||||
found, err := s.getConfig("admin", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(cfg.Token) == "" {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = token
|
||||
if err := s.setConfig("admin", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.adminToken = cfg.Token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) JWTIssuer() string {
|
||||
return s.issuer
|
||||
// ─── Auth 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) JWTSecret() []byte { return s.jwtSecret }
|
||||
func (s *Store) JWTIssuer() string { return s.issuer }
|
||||
|
||||
func (s *Store) loadOrCreateAuthConfig() error {
|
||||
var cfg AuthConfig
|
||||
found, err := s.getConfig("auth", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secretBytes, decodeErr := base64.StdEncoding.DecodeString(cfg.JWTSecret)
|
||||
if !found || decodeErr != nil || len(secretBytes) == 0 {
|
||||
secretBytes, err = generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.JWTSecret = base64.StdEncoding.EncodeToString(secretBytes)
|
||||
}
|
||||
if strings.TrimSpace(cfg.Issuer) == "" {
|
||||
cfg.Issuer = "sproutgate"
|
||||
}
|
||||
if err := s.setConfig("auth", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.jwtSecret = secretBytes
|
||||
s.issuer = cfg.Issuer
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) EmailConfig() EmailConfig {
|
||||
return s.emailConfig
|
||||
// ─── Email 配置 ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) EmailConfig() EmailConfig { return s.emailConfig }
|
||||
|
||||
func (s *Store) loadOrCreateEmailConfig() error {
|
||||
var cfg EmailConfig
|
||||
found, err := s.getConfig("email", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed := !found
|
||||
if strings.TrimSpace(cfg.FromName) == "" {
|
||||
cfg.FromName = "萌芽账户认证中心"
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(cfg.FromAddress) == "" {
|
||||
cfg.FromAddress = "notice@smyhub.com"
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" {
|
||||
cfg.Username = cfg.FromAddress
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||
cfg.SMTPHost = "smtp.qiye.aliyun.com"
|
||||
changed = true
|
||||
}
|
||||
if cfg.SMTPPort == 0 {
|
||||
cfg.SMTPPort = 465
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(cfg.Encryption) == "" {
|
||||
cfg.Encryption = "SSL"
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := s.setConfig("email", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.emailConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── CheckIn 配置 ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) CheckInConfig() CheckInConfig {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cfg := s.checkInConfig
|
||||
if cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
@@ -145,160 +223,27 @@ func (s *Store) CheckInConfig() CheckInConfig {
|
||||
}
|
||||
|
||||
func (s *Store) UpdateCheckInConfig(cfg CheckInConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
}
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
if err := s.setConfig("checkin", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.checkInConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateAdminConfig() error {
|
||||
if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := AdminConfig{Token: token}
|
||||
if err := writeJSONFile(s.adminConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.adminToken = cfg.Token
|
||||
return nil
|
||||
}
|
||||
var cfg AdminConfig
|
||||
if err := readJSONFile(s.adminConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Token) == "" {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = token
|
||||
if err := writeJSONFile(s.adminConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.adminToken = cfg.Token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateAuthConfig() error {
|
||||
if _, err := os.Stat(s.authConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
secret, err := generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := AuthConfig{
|
||||
JWTSecret: base64.StdEncoding.EncodeToString(secret),
|
||||
Issuer: "sproutgate",
|
||||
}
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.jwtSecret = secret
|
||||
s.issuer = cfg.Issuer
|
||||
return nil
|
||||
}
|
||||
var cfg AuthConfig
|
||||
if err := readJSONFile(s.authConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
secretBytes, err := base64.StdEncoding.DecodeString(cfg.JWTSecret)
|
||||
if err != nil || len(secretBytes) == 0 {
|
||||
secretBytes, err = generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.JWTSecret = base64.StdEncoding.EncodeToString(secretBytes)
|
||||
if strings.TrimSpace(cfg.Issuer) == "" {
|
||||
cfg.Issuer = "sproutgate"
|
||||
}
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.Issuer) == "" {
|
||||
cfg.Issuer = "sproutgate"
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.jwtSecret = secretBytes
|
||||
s.issuer = cfg.Issuer
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateEmailConfig() error {
|
||||
if _, err := os.Stat(s.emailConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := EmailConfig{
|
||||
FromName: "萌芽账户认证中心",
|
||||
FromAddress: "notice@smyhub.com",
|
||||
Username: "",
|
||||
Password: "",
|
||||
SMTPHost: "smtp.qiye.aliyun.com",
|
||||
SMTPPort: 465,
|
||||
Encryption: "SSL",
|
||||
}
|
||||
if err := writeJSONFile(s.emailConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Username == "" {
|
||||
cfg.Username = cfg.FromAddress
|
||||
}
|
||||
s.emailConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg EmailConfig
|
||||
if err := readJSONFile(s.emailConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.FromName) == "" {
|
||||
cfg.FromName = "萌芽账户认证中心"
|
||||
}
|
||||
if strings.TrimSpace(cfg.FromAddress) == "" {
|
||||
cfg.FromAddress = "notice@smyhub.com"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" {
|
||||
cfg.Username = cfg.FromAddress
|
||||
}
|
||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||
cfg.SMTPHost = "smtp.qiye.aliyun.com"
|
||||
}
|
||||
if cfg.SMTPPort == 0 {
|
||||
cfg.SMTPPort = 465
|
||||
}
|
||||
if strings.TrimSpace(cfg.Encryption) == "" {
|
||||
cfg.Encryption = "SSL"
|
||||
}
|
||||
if err := writeJSONFile(s.emailConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.emailConfig = cfg
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateCheckInConfig() error {
|
||||
if _, err := os.Stat(s.checkInPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := CheckInConfig{RewardCoins: 1}
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.checkInConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg CheckInConfig
|
||||
if err := readJSONFile(s.checkInPath, &cfg); err != nil {
|
||||
found, err := s.getConfig("checkin", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.RewardCoins <= 0 {
|
||||
if !found || cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
if err := s.setConfig("checkin", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -306,158 +251,143 @@ func (s *Store) loadOrCreateCheckInConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateSecret() ([]byte, error) {
|
||||
secret := make([]byte, 32)
|
||||
_, err := rand.Read(secret)
|
||||
return secret, err
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
secret, err := generateSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(secret), nil
|
||||
}
|
||||
// ─── 用户 CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListUsers() ([]models.UserRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entries, err := os.ReadDir(s.usersDir)
|
||||
if err != nil {
|
||||
var rows []DBUser
|
||||
if err := s.db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := make([]models.UserRecord, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
var record models.UserRecord
|
||||
path := filepath.Join(s.usersDir, entry.Name())
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, record)
|
||||
users := make([]models.UserRecord, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
users = append(users, row.toRecord())
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(account string) (models.UserRecord, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, false, nil
|
||||
}
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, false, result.Error
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toRecord(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(record models.UserRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(record.Account)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return errors.New("account already exists")
|
||||
}
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = record.CreatedAt
|
||||
return writeJSONFile(path, record)
|
||||
|
||||
row := dbUserFromRecord(record)
|
||||
result := s.db.Create(&row)
|
||||
if result.Error != nil {
|
||||
if strings.Contains(result.Error.Error(), "Duplicate entry") ||
|
||||
strings.Contains(result.Error.Error(), "duplicate key") {
|
||||
return errors.New("account already exists")
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveUser(record models.UserRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(record.Account)
|
||||
record.UpdatedAt = models.NowISO()
|
||||
return writeJSONFile(path, record)
|
||||
row := dbUserFromRecord(record)
|
||||
return s.db.Save(&row).Error
|
||||
}
|
||||
|
||||
// RecordAuthClient 在成功认证后记录第三方应用标识(clientID 须已规范化)。
|
||||
func (s *Store) DeleteUser(account string) error {
|
||||
return s.db.Delete(&DBUser{}, "account = ?", account).Error
|
||||
}
|
||||
|
||||
// ─── RecordAuthClient ─────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) RecordAuthClient(account string, clientID string, displayName string) (models.UserRecord, error) {
|
||||
if clientID == "" {
|
||||
return models.UserRecord{}, errors.New("client id required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
return models.UserRecord{}, err
|
||||
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, result.Error
|
||||
}
|
||||
|
||||
now := models.NowISO()
|
||||
displayName = models.ClampAuthClientName(displayName)
|
||||
clients := []models.AuthClientEntry(row.AuthClients)
|
||||
found := false
|
||||
for i := range record.AuthClients {
|
||||
if record.AuthClients[i].ClientID == clientID {
|
||||
record.AuthClients[i].LastSeenAt = now
|
||||
for i := range clients {
|
||||
if clients[i].ClientID == clientID {
|
||||
clients[i].LastSeenAt = now
|
||||
if displayName != "" {
|
||||
record.AuthClients[i].DisplayName = displayName
|
||||
clients[i].DisplayName = displayName
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
record.AuthClients = append(record.AuthClients, models.AuthClientEntry{
|
||||
clients = append(clients, models.AuthClientEntry{
|
||||
ClientID: clientID,
|
||||
DisplayName: displayName,
|
||||
FirstSeenAt: now,
|
||||
LastSeenAt: now,
|
||||
})
|
||||
}
|
||||
record.UpdatedAt = now
|
||||
if err := writeJSONFile(path, &record); err != nil {
|
||||
row.AuthClients = AuthClientSlice(clients)
|
||||
row.UpdatedAt = now
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
return row.toRecord(), nil
|
||||
}
|
||||
|
||||
func (s *Store) RecordVisit(account string, today string, at string) (models.UserRecord, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// ─── RecordVisit ──────────────────────────────────────────────────────────────
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
func (s *Store) RecordVisit(account string, today string, at string) (models.UserRecord, bool, error) {
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, false, os.ErrNotExist
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, false, result.Error
|
||||
}
|
||||
|
||||
if record.LastVisitDate == today || models.HasActivityDate(record.VisitTimes, today) {
|
||||
return record, false, nil
|
||||
visitTimes := []string(row.VisitTimes)
|
||||
if row.LastVisitDate == today || models.HasActivityDate(visitTimes, today) {
|
||||
return row.toRecord(), false, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(at) == "" {
|
||||
at = models.CurrentActivityTime()
|
||||
}
|
||||
record.LastVisitDate = today
|
||||
record.LastVisitAt = at
|
||||
record.VisitTimes = append(record.VisitTimes, at)
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
row.LastVisitDate = today
|
||||
row.LastVisitAt = at
|
||||
row.VisitTimes = StringSlice(append(visitTimes, at))
|
||||
if row.CreatedAt == "" {
|
||||
row.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
row.UpdatedAt = models.NowISO()
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
return row.toRecord(), true, nil
|
||||
}
|
||||
|
||||
// ─── UpdateLastVisitMeta ─────────────────────────────────────────────────────
|
||||
|
||||
const maxLastVisitIPLen = 45
|
||||
const maxLastVisitDisplayLocationLen = 512
|
||||
|
||||
@@ -473,7 +403,6 @@ func clampVisitMeta(ip, displayLocation string) (string, string) {
|
||||
return ip, displayLocation
|
||||
}
|
||||
|
||||
// UpdateLastVisitMeta 更新用户最近一次访问的客户端 IP 与展示用地理位置(由前端调用地理接口后传入)。
|
||||
func (s *Store) UpdateLastVisitMeta(account string, ip string, displayLocation string) (models.UserRecord, error) {
|
||||
ip, displayLocation = clampVisitMeta(ip, displayLocation)
|
||||
if ip == "" && displayLocation == "" {
|
||||
@@ -487,101 +416,83 @@ func (s *Store) UpdateLastVisitMeta(account string, ip string, displayLocation s
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, result.Error
|
||||
}
|
||||
|
||||
if ip != "" {
|
||||
record.LastVisitIP = ip
|
||||
row.LastVisitIP = ip
|
||||
}
|
||||
if displayLocation != "" {
|
||||
record.LastVisitDisplayLocation = displayLocation
|
||||
row.LastVisitDisplayLocation = displayLocation
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
row.UpdatedAt = models.NowISO()
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
return row.toRecord(), nil
|
||||
}
|
||||
|
||||
func (s *Store) CheckIn(account string, today string, at string) (models.UserRecord, int, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// ─── CheckIn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
func (s *Store) CheckIn(account string, today string, at string) (models.UserRecord, int, bool, error) {
|
||||
var row DBUser
|
||||
result := s.db.First(&row, "account = ?", account)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return models.UserRecord{}, 0, false, os.ErrNotExist
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, 0, false, err
|
||||
if result.Error != nil {
|
||||
return models.UserRecord{}, 0, false, result.Error
|
||||
}
|
||||
|
||||
if record.LastCheckInDate == today || models.HasActivityDate(record.CheckInTimes, today) {
|
||||
return record, 0, true, nil
|
||||
checkInTimes := []string(row.CheckInTimes)
|
||||
if row.LastCheckInDate == today || models.HasActivityDate(checkInTimes, today) {
|
||||
return row.toRecord(), 0, true, nil
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
reward := s.checkInConfig.RewardCoins
|
||||
s.mu.RUnlock()
|
||||
if reward <= 0 {
|
||||
reward = 1
|
||||
}
|
||||
record.SproutCoins += reward
|
||||
record.LastCheckInDate = today
|
||||
|
||||
row.SproutCoins += reward
|
||||
row.LastCheckInDate = today
|
||||
if strings.TrimSpace(at) == "" {
|
||||
at = models.CurrentActivityTime()
|
||||
}
|
||||
record.LastCheckInAt = at
|
||||
record.CheckInTimes = append(record.CheckInTimes, at)
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
row.LastCheckInAt = at
|
||||
row.CheckInTimes = StringSlice(append(checkInTimes, at))
|
||||
if row.CreatedAt == "" {
|
||||
row.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
row.UpdatedAt = models.NowISO()
|
||||
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return models.UserRecord{}, 0, false, err
|
||||
}
|
||||
return record, reward, false, nil
|
||||
return row.toRecord(), reward, false, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func generateSecret() ([]byte, error) {
|
||||
secret := make([]byte, 32)
|
||||
_, err := rand.Read(secret)
|
||||
return secret, err
|
||||
}
|
||||
|
||||
func (s *Store) userFilePath(account string) string {
|
||||
return filepath.Join(s.usersDir, userFileName(account))
|
||||
}
|
||||
|
||||
func userFileName(account string) string {
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(account))
|
||||
return encoded + ".json"
|
||||
}
|
||||
|
||||
func readJSONFile(path string, target any) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
func generateToken() (string, error) {
|
||||
secret, err := generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, raw, 0644)
|
||||
return base64.RawURLEncoding.EncodeToString(secret), nil
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
110
sproutgate-backend/后端文档.md
Normal file
110
sproutgate-backend/后端文档.md
Normal 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 账号权限。
|
||||
@@ -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">
|
||||
|
||||
61
sproutgate-frontend/src/avatar.js
Normal file
61
sproutgate-frontend/src/avatar.js
Normal 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 "未填写";
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
|
||||
70
sproutgate-frontend/src/components/AvatarImg.jsx
Normal file
70
sproutgate-frontend/src/components/AvatarImg.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
sproutgate-frontend/src/components/OAuthConsentScreen.jsx
Normal file
207
sproutgate-frontend/src/components/OAuthConsentScreen.jsx
Normal 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">应用 ID(client_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>
|
||||
);
|
||||
}
|
||||
95
sproutgate-frontend/src/components/PublicUserListPage.jsx
Normal file
95
sproutgate-frontend/src/components/PublicUserListPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
36
sproutgate-frontend/src/components/RandomSiteBackground.jsx
Normal file
36
sproutgate-frontend/src/components/RandomSiteBackground.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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="邮箱验证码" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
94
sproutgate-frontend/前端文档.md
Normal file
94
sproutgate-frontend/前端文档.md
Normal 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 地址误提交。
|
||||
30
公网配置/auth.api.shumengya.top.conf
Normal file
30
公网配置/auth.api.shumengya.top.conf
Normal 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
|
||||
|
||||
|
||||
}
|
||||
30
公网配置/auth.shumengya.top.conf
Normal file
30
公网配置/auth.shumengya.top.conf
Normal 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
|
||||
|
||||
|
||||
}
|
||||
26
公网配置/sproutgate-frontend.conf
Normal file
26
公网配置/sproutgate-frontend.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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&state=abc123&client_id=my-app&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`(必填才能记一条)**:长度 1–64;必须以 **英文字母或数字** 开头;其余字符仅可为 `[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: 万象口袋'
|
||||
```
|
||||
Reference in New Issue
Block a user