From 650e1c77073ec555695168eadcc555149bd6a370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=91=E8=90=8C=E8=8A=BD?= <3205788256@qq.com> Date: Wed, 1 Apr 2026 22:04:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0SproutGate=E5=89=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/mcp.json | 12 +- .cursor/rules/mcp-messenger.mdc | 27 - sproutgate-backend/cmd/migrate/main.go | 218 ++++++ sproutgate-backend/go.mod | 8 +- sproutgate-backend/go.sum | 14 + sproutgate-backend/internal/auth/jwt.go | 8 +- sproutgate-backend/internal/database/db.go | 84 +++ sproutgate-backend/internal/handlers/admin.go | 28 +- .../internal/handlers/auth_login.go | 14 +- .../internal/handlers/auth_password.go | 19 +- .../internal/handlers/checkin.go | 3 + .../internal/handlers/helpers.go | 12 +- .../internal/handlers/profile.go | 3 + .../internal/handlers/public_profile.go | 154 +++++ .../internal/handlers/public_registration.go | 10 +- .../internal/handlers/registration_admin.go | 15 +- .../internal/handlers/requests.go | 4 +- .../internal/handlers/secondary_email.go | 6 + .../internal/models/qq_avatar.go | 55 ++ sproutgate-backend/internal/models/user.go | 32 +- .../internal/storage/dbmodels.go | 321 +++++++++ .../internal/storage/pending.go | 49 +- .../internal/storage/profile_likes.go | 219 +++++++ .../internal/storage/registration.go | 267 ++++++-- sproutgate-backend/internal/storage/reset.go | 48 +- .../internal/storage/secondary.go | 53 +- .../internal/storage/storage.go | 619 ++++++++---------- sproutgate-backend/main.go | 27 +- sproutgate-backend/后端文档.md | 110 ++++ sproutgate-frontend/src/App.jsx | 98 ++- sproutgate-frontend/src/avatar.js | 61 ++ .../src/components/AdminPanel.jsx | 42 +- .../src/components/AvatarImg.jsx | 70 ++ .../src/components/OAuthConsentScreen.jsx | 207 ++++++ .../src/components/PublicUserListPage.jsx | 95 +++ .../src/components/PublicUserPage.jsx | 155 ++++- .../src/components/RandomSiteBackground.jsx | 36 + .../src/components/UserPortal.jsx | 113 +++- .../userPortal/UserPortalAuthSection.jsx | 64 +- .../userPortal/UserPortalProfileSection.jsx | 15 +- sproutgate-frontend/src/config.js | 60 +- sproutgate-frontend/src/icons.jsx | 11 + sproutgate-frontend/src/styles.css | 537 ++++++++++++++- sproutgate-frontend/前端文档.md | 94 +++ 公网配置/auth.api.shumengya.top.conf | 30 + 公网配置/auth.shumengya.top.conf | 30 + 公网配置/sproutgate-frontend.conf | 26 + ... 萌芽账户认证中心-第三方应用API接入文档.md | 194 ++++-- ...试询问相关.md => 萌芽账户认证中心-项目经历.md | 0 49 files changed, 3609 insertions(+), 768 deletions(-) delete mode 100644 .cursor/rules/mcp-messenger.mdc create mode 100644 sproutgate-backend/cmd/migrate/main.go create mode 100644 sproutgate-backend/internal/database/db.go create mode 100644 sproutgate-backend/internal/handlers/public_profile.go create mode 100644 sproutgate-backend/internal/models/qq_avatar.go create mode 100644 sproutgate-backend/internal/storage/dbmodels.go create mode 100644 sproutgate-backend/internal/storage/profile_likes.go create mode 100644 sproutgate-backend/后端文档.md create mode 100644 sproutgate-frontend/src/avatar.js create mode 100644 sproutgate-frontend/src/components/AvatarImg.jsx create mode 100644 sproutgate-frontend/src/components/OAuthConsentScreen.jsx create mode 100644 sproutgate-frontend/src/components/PublicUserListPage.jsx create mode 100644 sproutgate-frontend/src/components/RandomSiteBackground.jsx create mode 100644 sproutgate-frontend/前端文档.md create mode 100644 公网配置/auth.api.shumengya.top.conf create mode 100644 公网配置/auth.shumengya.top.conf create mode 100644 公网配置/sproutgate-frontend.conf rename sproutgate-backend/API_DOCS.md => 萌芽账户认证中心-第三方应用API接入文档.md (51%) rename 项目面试询问相关.md => 萌芽账户认证中心-项目经历.md (100%) diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 94a7238..7001130 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -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": {} } \ No newline at end of file diff --git a/.cursor/rules/mcp-messenger.mdc b/.cursor/rules/mcp-messenger.mdc deleted file mode 100644 index 9745167..0000000 --- a/.cursor/rules/mcp-messenger.mdc +++ /dev/null @@ -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` 仅用于任务中需要用户做选择时(如选框架、选方案)。 diff --git a/sproutgate-backend/cmd/migrate/main.go b/sproutgate-backend/cmd/migrate/main.go new file mode 100644 index 0000000..8529338 --- /dev/null +++ b/sproutgate-backend/cmd/migrate/main.go @@ -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) +} diff --git a/sproutgate-backend/go.mod b/sproutgate-backend/go.mod index b4b94b2..3fd70bb 100644 --- a/sproutgate-backend/go.mod +++ b/sproutgate-backend/go.mod @@ -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 ) diff --git a/sproutgate-backend/go.sum b/sproutgate-backend/go.sum index fd392ce..8e16aea 100644 --- a/sproutgate-backend/go.sum +++ b/sproutgate-backend/go.sum @@ -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= diff --git a/sproutgate-backend/internal/auth/jwt.go b/sproutgate-backend/internal/auth/jwt.go index bc568ce..9854e5d 100644 --- a/sproutgate-backend/internal/auth/jwt.go +++ b/sproutgate-backend/internal/auth/jwt.go @@ -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, diff --git a/sproutgate-backend/internal/database/db.go b/sproutgate-backend/internal/database/db.go new file mode 100644 index 0000000..cd72c6b --- /dev/null +++ b/sproutgate-backend/internal/database/db.go @@ -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 +} diff --git a/sproutgate-backend/internal/handlers/admin.go b/sproutgate-backend/internal/handlers/admin.go index 145e258..2b39ea5 100644 --- a/sproutgate-backend/internal/handlers/admin.go +++ b/sproutgate-backend/internal/handlers/admin.go @@ -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 = "" diff --git a/sproutgate-backend/internal/handlers/auth_login.go b/sproutgate-backend/internal/handlers/auth_login.go index f7373fc..0f259d6 100644 --- a/sproutgate-backend/internal/handlers/auth_login.go +++ b/sproutgate-backend/internal/handlers/auth_login.go @@ -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 diff --git a/sproutgate-backend/internal/handlers/auth_password.go b/sproutgate-backend/internal/handlers/auth_password.go index 4e18afb..bc84be3 100644 --- a/sproutgate-backend/internal/handlers/auth_password.go +++ b/sproutgate-backend/internal/handlers/auth_password.go @@ -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(), diff --git a/sproutgate-backend/internal/handlers/checkin.go b/sproutgate-backend/internal/handlers/checkin.go index 7ac26d3..0cdbad7 100644 --- a/sproutgate-backend/internal/handlers/checkin.go +++ b/sproutgate-backend/internal/handlers/checkin.go @@ -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) diff --git a/sproutgate-backend/internal/handlers/helpers.go b/sproutgate-backend/internal/handlers/helpers.go index b58576a..ee6fcae 100644 --- a/sproutgate-backend/internal/handlers/helpers.go +++ b/sproutgate-backend/internal/handlers/helpers.go @@ -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 diff --git a/sproutgate-backend/internal/handlers/profile.go b/sproutgate-backend/internal/handlers/profile.go index eb803d0..d2775ad 100644 --- a/sproutgate-backend/internal/handlers/profile.go +++ b/sproutgate-backend/internal/handlers/profile.go @@ -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 { diff --git a/sproutgate-backend/internal/handlers/public_profile.go b/sproutgate-backend/internal/handlers/public_profile.go new file mode 100644 index 0000000..a84c314 --- /dev/null +++ b/sproutgate-backend/internal/handlers/public_profile.go @@ -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, + }) +} diff --git a/sproutgate-backend/internal/handlers/public_registration.go b/sproutgate-backend/internal/handlers/public_registration.go index f8e88e9..893833e 100644 --- a/sproutgate-backend/internal/handlers/public_registration.go +++ b/sproutgate-backend/internal/handlers/public_registration.go @@ -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", }) } diff --git a/sproutgate-backend/internal/handlers/registration_admin.go b/sproutgate-backend/internal/handlers/registration_admin.go index 9f8fd2b..d5b2ba9 100644 --- a/sproutgate-backend/internal/handlers/registration_admin.go +++ b/sproutgate-backend/internal/handlers/registration_admin.go @@ -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) { diff --git a/sproutgate-backend/internal/handlers/requests.go b/sproutgate-backend/internal/handlers/requests.go index c69178b..27fe6ff 100644 --- a/sproutgate-backend/internal/handlers/requests.go +++ b/sproutgate-backend/internal/handlers/requests.go @@ -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 { diff --git a/sproutgate-backend/internal/handlers/secondary_email.go b/sproutgate-backend/internal/handlers/secondary_email.go index 3b92dac..fa134f0 100644 --- a/sproutgate-backend/internal/handlers/secondary_email.go +++ b/sproutgate-backend/internal/handlers/secondary_email.go @@ -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) diff --git a/sproutgate-backend/internal/models/qq_avatar.go b/sproutgate-backend/internal/models/qq_avatar.go new file mode 100644 index 0000000..3ffecd3 --- /dev/null +++ b/sproutgate-backend/internal/models/qq_avatar.go @@ -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 "" +} diff --git a/sproutgate-backend/internal/models/user.go b/sproutgate-backend/internal/models/user.go index 03f562a..221eded 100644 --- a/sproutgate-backend/internal/models/user.go +++ b/sproutgate-backend/internal/models/user.go @@ -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) } diff --git a/sproutgate-backend/internal/storage/dbmodels.go b/sproutgate-backend/internal/storage/dbmodels.go new file mode 100644 index 0000000..65e8530 --- /dev/null +++ b/sproutgate-backend/internal/storage/dbmodels.go @@ -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, + } +} diff --git a/sproutgate-backend/internal/storage/pending.go b/sproutgate-backend/internal/storage/pending.go index b2712ab..7bb41ae 100644 --- a/sproutgate-backend/internal/storage/pending.go +++ b/sproutgate-backend/internal/storage/pending.go @@ -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 "" } diff --git a/sproutgate-backend/internal/storage/profile_likes.go b/sproutgate-backend/internal/storage/profile_likes.go new file mode 100644 index 0000000..1a05bd2 --- /dev/null +++ b/sproutgate-backend/internal/storage/profile_likes.go @@ -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) +} diff --git a/sproutgate-backend/internal/storage/registration.go b/sproutgate-backend/internal/storage/registration.go index cbc3d4c..a5f9890 100644 --- a/sproutgate-backend/internal/storage/registration.go +++ b/sproutgate-backend/internal/storage/registration.go @@ -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 diff --git a/sproutgate-backend/internal/storage/reset.go b/sproutgate-backend/internal/storage/reset.go index 547222a..a2f9b36 100644 --- a/sproutgate-backend/internal/storage/reset.go +++ b/sproutgate-backend/internal/storage/reset.go @@ -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 } diff --git a/sproutgate-backend/internal/storage/secondary.go b/sproutgate-backend/internal/storage/secondary.go index 911a637..06ff4a8 100644 --- a/sproutgate-backend/internal/storage/secondary.go +++ b/sproutgate-backend/internal/storage/secondary.go @@ -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 } diff --git a/sproutgate-backend/internal/storage/storage.go b/sproutgate-backend/internal/storage/storage.go index 7034705..a7365bc 100644 --- a/sproutgate-backend/internal/storage/storage.go +++ b/sproutgate-backend/internal/storage/storage.go @@ -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 } diff --git a/sproutgate-backend/main.go b/sproutgate-backend/main.go index 9dbaf47..83fc8aa 100644 --- a/sproutgate-backend/main.go +++ b/sproutgate-backend/main.go @@ -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") diff --git a/sproutgate-backend/后端文档.md b/sproutgate-backend/后端文档.md new file mode 100644 index 0000000..c40a5ce --- /dev/null +++ b/sproutgate-backend/后端文档.md @@ -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": "" }`。 +- **`/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 账号权限。 diff --git a/sproutgate-frontend/src/App.jsx b/sproutgate-frontend/src/App.jsx index 2456c5b..4d2822d 100644 --- a/sproutgate-frontend/src/App.jsx +++ b/sproutgate-frontend/src/App.jsx @@ -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() { ); + 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 = ( + ); + + const headerNavAuthorize = ( + ); return (
+ {booting && }
- {isPublicUser ? ( + {isPublicUserList ? ( +
+
+
+
+ {headerBrand} + {headerNav} +
+
+ +
+
+
+ ) : isPublicUser ? (
@@ -183,6 +255,24 @@ function App() {
+ ) : isAuthorizeRoute ? ( +
+
+
+
+ {headerBrand} + {headerNavAuthorize} +
+
+ +
+
+
) : (
diff --git a/sproutgate-frontend/src/avatar.js b/sproutgate-frontend/src/avatar.js new file mode 100644 index 0000000..43b0c7f --- /dev/null +++ b/sproutgate-frontend/src/avatar.js @@ -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`; +} + +/** 用于 :自选链接优先,否则 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 "未填写"; +} diff --git a/sproutgate-frontend/src/components/AdminPanel.jsx b/sproutgate-frontend/src/components/AdminPanel.jsx index b930d93..c5c4553 100644 --- a/sproutgate-frontend/src/components/AdminPanel.jsx +++ b/sproutgate-frontend/src/components/AdminPanel.jsx @@ -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 }) { 开启后,用户自助注册必须填写有效邀请码(管理员创建用户不受影响) +