From e6866feb29054df15c1d410685841127b7120c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=91=E8=90=8C=E8=8A=BD?= <3205788256@qq.com> Date: Fri, 20 Mar 2026 20:42:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/mcp.json | 13 + .cursor/rules/mcp-messenger.mdc | 27 + AGENTS.md | 8 +- README.md | 116 +- sproutgate-backend/API_DOCS.md | 261 +- sproutgate-backend/docker-compose.yml | 4 +- .../internal/clientgeo/clientgeo.go | 70 + sproutgate-backend/internal/handlers/admin.go | 204 ++ .../internal/handlers/auth_client.go | 18 + .../internal/handlers/auth_login.go | 173 ++ .../internal/handlers/auth_password.go | 252 ++ .../internal/handlers/checkin.go | 91 + .../internal/handlers/handler.go | 11 + .../internal/handlers/handlers.go | 751 ------ .../internal/handlers/helpers.go | 70 + .../internal/handlers/profile.go | 74 + .../internal/handlers/public_registration.go | 14 + .../internal/handlers/registration_admin.go | 56 + .../internal/handlers/requests.go | 135 + .../internal/handlers/secondary_email.go | 154 ++ .../internal/models/activity.go | 106 + .../internal/models/authclient.go | 40 + sproutgate-backend/internal/models/pending.go | 1 + sproutgate-backend/internal/models/user.go | 146 +- .../internal/storage/registration.go | 214 ++ .../internal/storage/storage.go | 232 ++ sproutgate-backend/main.go | 33 +- sproutgate-frontend/src/App.jsx | 1434 ++--------- .../src/components/AdminPanel.jsx | 650 +++++ .../src/components/PublicUserPage.jsx | 127 + .../src/components/SplashScreen.jsx | 27 + .../src/components/UserPortal.jsx | 696 +++++ sproutgate-frontend/src/components/common.jsx | 73 + .../userPortal/UserPortalAuthSection.jsx | 245 ++ .../userPortal/UserPortalProfileSection.jsx | 282 +++ sproutgate-frontend/src/config.js | 187 ++ sproutgate-frontend/src/icons.jsx | 132 + sproutgate-frontend/src/styles.css | 2234 ++++++++++++++--- sproutgate.bat | 4 +- 39 files changed, 6986 insertions(+), 2379 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 .cursor/rules/mcp-messenger.mdc create mode 100644 sproutgate-backend/internal/clientgeo/clientgeo.go create mode 100644 sproutgate-backend/internal/handlers/admin.go create mode 100644 sproutgate-backend/internal/handlers/auth_client.go create mode 100644 sproutgate-backend/internal/handlers/auth_login.go create mode 100644 sproutgate-backend/internal/handlers/auth_password.go create mode 100644 sproutgate-backend/internal/handlers/checkin.go create mode 100644 sproutgate-backend/internal/handlers/handler.go delete mode 100644 sproutgate-backend/internal/handlers/handlers.go create mode 100644 sproutgate-backend/internal/handlers/helpers.go create mode 100644 sproutgate-backend/internal/handlers/profile.go create mode 100644 sproutgate-backend/internal/handlers/public_registration.go create mode 100644 sproutgate-backend/internal/handlers/registration_admin.go create mode 100644 sproutgate-backend/internal/handlers/requests.go create mode 100644 sproutgate-backend/internal/handlers/secondary_email.go create mode 100644 sproutgate-backend/internal/models/activity.go create mode 100644 sproutgate-backend/internal/models/authclient.go create mode 100644 sproutgate-backend/internal/storage/registration.go create mode 100644 sproutgate-frontend/src/components/AdminPanel.jsx create mode 100644 sproutgate-frontend/src/components/PublicUserPage.jsx create mode 100644 sproutgate-frontend/src/components/SplashScreen.jsx create mode 100644 sproutgate-frontend/src/components/UserPortal.jsx create mode 100644 sproutgate-frontend/src/components/common.jsx create mode 100644 sproutgate-frontend/src/components/userPortal/UserPortalAuthSection.jsx create mode 100644 sproutgate-frontend/src/components/userPortal/UserPortalProfileSection.jsx create mode 100644 sproutgate-frontend/src/config.js create mode 100644 sproutgate-frontend/src/icons.jsx diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..94a7238 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,13 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/.cursor/rules/mcp-messenger.mdc b/.cursor/rules/mcp-messenger.mdc new file mode 100644 index 0000000..9745167 --- /dev/null +++ b/.cursor/rules/mcp-messenger.mdc @@ -0,0 +1,27 @@ +--- +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/AGENTS.md b/AGENTS.md index 507f647..7cd0ed2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # Repository Guidelines ## Project Structure & Module Organization -- `sproutgate-frontend/`: React + Vite app. Entry at `src/main.jsx`, main UI in `src/App.jsx`, styles in `src/styles.css`. -- `sproutgate-backend/`: Go + Gin API. Entry at `main.go`, HTTP handlers in `internal/handlers/`, domain models in `internal/models/`, storage in `internal/storage/`. +- `sproutgate-frontend/`: React + Vite app. Entry at `src/main.jsx`, main UI in `src/App.jsx`, user portal partials in `src/components/userPortal/` (e.g. `UserPortalAuthSection.jsx`, `UserPortalProfileSection.jsx`), styles in `src/styles.css`. +- `sproutgate-backend/`: Go + Gin API. Entry at `main.go`, HTTP handlers in `internal/handlers/` (split across `handler.go`, `requests.go`, `helpers.go`, `auth_login.go`, `auth_password.go`, `secondary_email.go`, `profile.go`, `checkin.go`, `admin.go`), domain models in `internal/models/`, storage in `internal/storage/`. - `sproutgate-backend/data/`: JSON-backed config and data. Config files live in `data/config/`, user records in `data/users/`. - Root scripts `sproutgate.sh` and `sproutgate.bat` provide dev and build shortcuts. @@ -32,7 +32,7 @@ - PRs should include a concise summary, testing notes, and screenshots for UI changes; call out any config or env var updates. ## Security & Configuration Tips -- Backend configuration is stored under `sproutgate-backend/data/config/` (admin, auth, email). +- Backend configuration is stored under `sproutgate-backend/data/config/` (admin, auth, email, check-in, registration invites). - Frontend API base URL can be set via `sproutgate-frontend/.env` using `VITE_API_BASE`. - Avoid committing real credentials or production tokens; use local overrides for secrets. -- API reference lives in `sproutgate-backend/API_DOCS.md` and is served at `GET /api/docs`. +- API reference lives in `sproutgate-backend/API_DOCS.md` and is served at `GET /api/docs`. Brief JSON overview: `GET /` and `GET /api`. diff --git a/README.md b/README.md index ca14112..bc0b29d 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,120 @@ -# 萌芽账户认证中心(SproutGate) +# SproutGate(萌芽账户认证中心) -前后端分离的统一账户认证中心: -- 前端:React(`sproutgate-frontend`) -- 后端:Golang + Gin(`sproutgate-backend`) -- 数据:`data/` 与子目录 JSON 文件存储 +前后端分离的统一账户与轻量用户中心:注册登录、邮箱验证、找回密码、副邮箱、签到与资料管理;管理员可维护用户与签到配置。数据以 JSON 文件落盘,适合自建与小规模部署。 -## 快速启动 +## 架构一览 + +| 部分 | 技术栈 | 目录 | 说明 | +|------|--------|------|------| +| 前端 | React 18 + Vite 5 | [`sproutgate-frontend/`](./sproutgate-frontend/) | 用户门户、公开用户页、管理后台;`VITE_API_BASE` 指向后端 | +| 后端 | Go + Gin | [`sproutgate-backend/`](./sproutgate-backend/) | REST API、JWT、CORS;默认端口 `8080` | +| 数据 | JSON 文件 | [`sproutgate-backend/data/`](./sproutgate-backend/data/) | `config/`(管理员、认证、邮件等)、`users/`(用户记录) | + +### 前端路由与模块 + +- **`/`** — [`UserPortal`](./sproutgate-frontend/src/components/UserPortal.jsx):登录、注册、验证邮件、OAuth 式 `redirect_uri` 回跳等流程。 +- **`/user` / `/user/:account`** — [`PublicUserPage`](./sproutgate-frontend/src/components/PublicUserPage.jsx):公开资料展示(Markdown 等)。 +- **`/admin`** — [`AdminPanel`](./sproutgate-frontend/src/components/AdminPanel.jsx):用户 CRUD、签到配置;请求头携带管理员 Token(见下文配置)。 + +启动页与全局壳层见 [`App.jsx`](./sproutgate-frontend/src/App.jsx)、[`SplashScreen`](./sproutgate-frontend/src/components/SplashScreen.jsx)。 + +### 后端能力摘要 + +- **认证**:登录、注册、邮箱验证、忘记/重置密码、副邮箱申请与验证、JWT 校验、`/api/auth/me`、签到、资料更新。 +- **公开接口**:按账号获取公开用户信息;`GET /api/public/registration-policy` 查询是否强制邀请码注册。 +- **管理接口**:`/api/admin/*`(需 `X-Admin-Token`),用户管理、签到与**注册策略/邀请码**(`data/config/registration.json`)。 +- **运维**:`GET /` 与 `GET /api`(JSON 服务说明)、`GET /api/health`、`GET /api/docs`(返回 [`API_DOCS.md`](./sproutgate-backend/API_DOCS.md))。 + +完整契约见 [`sproutgate-backend/API_DOCS.md`](./sproutgate-backend/API_DOCS.md)(文内 **「统一登录前端:查询参数」**、**「回跳 URL」**、**`POST /api/auth/login` / `verify` / `me`** 等章节为第三方接入主参考)。 + +## 环境要求 + +- **后端**:Go 1.21+(以 `go.mod` 为准) +- **前端**:Node.js 18+(建议 LTS) + +## 快速开始 + +### 方式一:根目录脚本(推荐本地开发) + +**Windows** + +```bat +sproutgate.bat dev +``` + +**macOS / Linux** + +```bash +chmod +x sproutgate.sh +./sproutgate.sh dev +``` + +会启动后端 `go run .` 与前端 `npm run dev`(前端默认 `5173`,后端默认 `8080`)。 + +仅构建前端: + +```bat +sproutgate.bat build +``` + +```bash +./sproutgate.sh build +``` + +### 方式二:手动分别启动 + +**后端** -### 后端 ```bash cd sproutgate-backend go mod tidy go run . ``` -默认端口 `8080`,默认管理员 Token:`shumengya520`(位于 `sproutgate-backend/data/config/admin.json`)。 -邮件发送配置位于 `sproutgate-backend/data/config/email.json`。 +**前端** -### 前端 ```bash cd sproutgate-frontend npm install npm run dev ``` -如需自定义后端地址,新增 `sproutgate-frontend/.env`: -``` +### 前端连接后端 + +在 `sproutgate-frontend/.env`(自行创建)中设置: + +```env VITE_API_BASE=http://localhost:8080 ``` -### 管理员地址 -``` -http://localhost:5173/admin?token=shumengya520 +生产环境改为实际 API 地址即可。 + +### 配置与安全 + +- 管理员 Token、JWT 密钥、邮件 SMTP 等位于 **`sproutgate-backend/data/config/`**(如 `admin.json`、`auth.json`、`email.json`)。**部署到公网前请务必修改默认值,且不要将真实密钥提交到仓库。** +- 进入管理员后台:在任意页面顶栏 **连续点击 Logo 五次**(约 2.6 秒内),在弹窗中输入与 `admin.json` 一致的 Token;也可直接使用 `http://localhost:5173/admin?token=`(Token 会写入本地后再请求接口)。 + +### 可选:Docker 仅跑后端 API + +在 `sproutgate-backend` 目录: + +```bash +docker compose up -d --build ``` -## API 文档 +默认将容器内 `8080` 映射到主机 `28080`(可通过环境变量 `AUTH_API_PORT` 修改)。数据目录通过卷挂载到 `./data`。 -- 文件:`sproutgate-backend/API_DOCS.md` -- 在线:`GET /api/docs` +## 环境变量(后端) + +| 变量 | 说明 | +|------|------| +| `PORT` | 监听端口,默认 `8080` | +| `DATA_DIR` | 数据根目录;不设置时使用仓库内默认 `data` 布局 | + +## 仓库维护说明 + +更细的目录约定、代码风格与 PR 建议见 **[`AGENTS.md`](./AGENTS.md)**。 + +## 许可证 + +若仓库未包含 `LICENSE` 文件,使用前请与维护者确认授权方式。 diff --git a/sproutgate-backend/API_DOCS.md b/sproutgate-backend/API_DOCS.md index 779d5e2..8dbd354 100644 --- a/sproutgate-backend/API_DOCS.md +++ b/sproutgate-backend/API_DOCS.md @@ -1,6 +1,59 @@ # 萌芽账户认证中心 API 文档 -基础地址:`http://:8080` +访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`/api/docs` 与 `/api/health` 入口、路由前缀摘要)。 + +接入地址: +- 统一登录前端:`https://auth.shumengya.top` +- 后端 API:`https://auth.api.shumengya.top` +- 本地开发 API:`http://:8080` + +对外接入建议: +1. 第三方应用按钮跳转到统一登录前端。 +2. 登录成功后回跳到业务站点。 +3. 业务站点使用回跳带回的 `token` 调用后端 API。 + +示例按钮: +```html + + 使用萌芽统一账户认证登录 + +``` + +回跳说明: +- 用户已登录时,统一登录前端会提示“继续授权”或“切换账号”。 +- 登录成功后会回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)。 +- 第三方应用拿到 `token` 后,建议调用 **`POST /api/auth/verify`**(无副作用、适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问记录,适合业务拉全量资料)校验并解析用户身份。 + +### 统一登录前端:查询参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `redirect_uri` | 与 `return_url` 至少其一 | 登录成功后的回跳地址,须进行 URL 编码;可为绝对 URL 或相对路径(相对路径相对统一登录站点解析)。 | +| `return_url` | 同上 | 与 `redirect_uri` 同义,二者都传时优先 `redirect_uri`。 | +| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 | +| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 | +| `client_id` | 否 | 第三方应用稳定标识(字母数字开头,可含 `_.:-`,最长 64)。写入用户「应用接入记录」,并随登录请求提交给后端。 | +| `client_name` | 否 | 展示用名称(最长 128),与 `client_id` 配对;可选。 | + +### 回跳 URL:`#` 哈希参数 + +成功授权后,前端将使用 [`URLSearchParams`](https://developer.mozilla.org/zh-CN/docs/Web/API/URLSearchParams) 写入哈希,例如:`https://app.example.com/auth/callback#token=...&expiresAt=...&account=...&username=...&state=...`。 + +| 参数 | 说明 | +|------|------| +| `token` | JWT,调用受保护接口时放在请求头 `Authorization: Bearer `。 | +| `expiresAt` | 过期时间,RFC3339(与签发侧一致,当前默认为登录时起算 **7 天**)。 | +| `account` | 账户名(与 JWT `sub` 一致)。 | +| `username` | 展示用昵称,可能为空。 | +| `state` | 若登录请求携带了 `state`,则原样返回。 | + +业务站点回调页应用脚本读取 `location.hash`,解析后**仅在 HTTPS 环境**将 `token` 存于内存或安全存储,并尽快用后端 **`POST /api/auth/verify`** 校验(勿仅信任哈希中的明文字段)。 + +### 第三方后端接入建议 + +1. **仅信服务端**:回调页将 `token` 交给自有后端,由后端请求 `POST https:///api/auth/verify`(JSON body:`{"token":"..."}`),根据 `valid` 与 `user.account` 建立会话。 +2. **CORS**:浏览器直连 API 时须后端已配置 CORS(本服务默认允许任意 `Origin`);若从服务端发起请求则不受 CORS 限制。 +3. **令牌过期**:`verify` / `me` 返回 401 或 `verify` 中 `valid:false` 时,应引导用户重新走统一登录。 ## 认证与统一登录 @@ -11,10 +64,14 @@ ```json { "account": "demo", - "password": "demo123" + "password": "demo123", + "clientId": "my-app", + "clientName": "我的应用" } ``` +`clientId` / `clientName` 可选;规则与请求头 `X-Auth-Client` / `X-Auth-Client-Name` 一致。传入且格式合法时,会在登录成功后写入该用户的 **应用接入记录**(见下文 `authClients`)。 + 响应: ```json { @@ -29,6 +86,7 @@ "secondaryEmails": ["demo2@example.com"], "phone": "13800000000", "avatarUrl": "https://example.com/avatar.png", + "websiteUrl": "https://example.com", "bio": "### 简介", "createdAt": "2026-03-14T12:00:00Z", "updatedAt": "2026-03-14T12:00:00Z" @@ -36,6 +94,29 @@ } ``` +若账户已被管理员封禁,返回 **403**,且**不会签发 JWT**,响应示例: + +```json +{ + "error": "account is banned", + "banReason": "违规内容" +} +``` + +`banReason` 可能为空字符串或省略。 + +**常见 HTTP 状态码(登录)** + +| 状态码 | 含义 | +|--------|------| +| 200 | 成功,返回 `token`、`expiresAt`、`user`。 | +| 400 | 请求体非法或缺少 `account` / `password`。 | +| 401 | 账户不存在或密码错误(统一文案 `invalid credentials`)。 | +| 403 | 账户已封禁(见上文 JSON)。 | +| 500 | 服务器内部错误(读库、签发 JWT 失败等)。 | + +**JWT 概要**:算法 **HS256**;载荷含 `account`(与 `sub` 一致)、`iss`(见 `data/config/auth.json`)、`iat` / `exp`。客户端只需透传字符串,**勿在前端解析密钥**。 + ### 校验令牌 `POST /api/auth/verify` @@ -54,21 +135,79 @@ } ``` +若账户已封禁,返回 **200** 且 `valid` 为 **false**(不返回 `user` 对象),示例: + +```json +{ + "valid": false, + "error": "account is banned", + "banReason": "违规内容" +} +``` + +令牌过期、签名错误、issuer 不匹配等解析失败时返回 **401**,示例:`{"valid": false, "error": "invalid token"}`。 + +`verify` 与 `me` 的取舍:**仅校验身份、不改变用户数据**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer)。 + +**应用接入记录(可选)**:第三方在 **`POST /api/auth/verify`** 或 **`GET /api/auth/me`** 上携带请求头: + +- `X-Auth-Client`:应用 ID(格式同登录 JSON 的 `clientId`) +- `X-Auth-Client-Name`:可选展示名 + +校验成功且用户未封禁时,服务端会更新该用户 JSON 中的 `authClients` 数组(`clientId`、`displayName`、`firstSeenAt`、`lastSeenAt`)。**`POST /api/auth/verify` 的响应体 `user` 仍为 `Public()`,不含 `authClients`**,避免向调用方泄露用户在其他应用的接入情况;**`GET /api/auth/me`** 与管理员列表中的 `user`(`OwnerPublic`)**包含** `authClients`,用户可在统一登录前端的个人中心查看。 + ### 获取当前用户信息 `GET /api/auth/me` 请求头: `Authorization: Bearer ` +可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」): +- `X-Visit-Ip`:客户端公网 IP(与地理接口返回的 `ip` 一致即可) +- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName`、`regionName`、`cityName` 拼接为 `中国 四川 成都`) + +**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求 `GEO_LOOKUP_URL`(默认 `https://cf-ip-geo.smyhub.com/api?ip=`)解析展示位置并写入用户记录。 + +响应: +```json +{ + "user": { "account": "demo", "...": "..." }, + "checkIn": { + "rewardCoins": 1, + "checkedInToday": false, + "lastCheckInDate": "", + "lastCheckInAt": "", + "today": "2026-03-14" + } +} +``` + +> `user` 还会包含 `lastVisitAt`、`lastVisitDate`、`checkInDays`、`checkInStreak`、`visitDays`、`visitStreak` 等统计字段。 + +> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp`、`lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**公开用户资料接口** `GET /api/public/users/:account` 与 **`POST /api/auth/verify` 的 `user` 中不包含这两项**(避免公开展示或第三方校验时令牌响应携带访问隐私)。 + +> 说明:密码不会返回。 + +若账户在登录后被封禁,持旧 JWT 调用 `GET /api/auth/me`、`PUT /api/auth/profile`、`POST /api/auth/check-in`、辅助邮箱等需登录接口时,返回 **403**,正文同登录封禁响应(`error` + 可选 `banReason`)。客户端应作废本地令牌。 + +### 每日签到 +`POST /api/auth/check-in` + +请求头: +`Authorization: Bearer ` + 响应: ```json { + "checkedIn": true, + "alreadyCheckedIn": false, + "rewardCoins": 1, + "awardedCoins": 1, + "message": "签到成功", "user": { "account": "demo", "...": "..." } } ``` -> 说明:密码不会返回。 - ### 更新当前用户资料 `PUT /api/auth/profile` @@ -82,10 +221,13 @@ "username": "新昵称", "phone": "13800000000", "avatarUrl": "https://example.com/avatar.png", + "websiteUrl": "https://example.com", "bio": "### 新简介" } ``` +说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`。 + 响应: ```json { @@ -93,6 +235,48 @@ } ``` +## 用户广场 + +### 获取用户公开主页 +`GET /api/public/users/{account}` + +说明: +- 仅支持账户名 `account`,不支持昵称查询。 +- 适合第三方应用展示用户公开资料。 +- 若该账户已被封禁,返回 **404** `{"error":"user not found"}`(与不存在账户相同,避免公开资料泄露)。 +- 响应中含该用户**最近一次被服务端记录的**访问 IP(`lastVisitIp`)与展示用地理位置(`lastVisitDisplayLocation`,与本人中心一致);`POST /api/auth/verify` 返回的用户 JSON **不含**上述两项。 + +响应: +```json +{ + "user": { + "account": "demo", + "username": "示例用户", + "level": 3, + "sproutCoins": 10, + "avatarUrl": "https://example.com/avatar.png", + "websiteUrl": "https://example.com", + "lastVisitIp": "203.0.113.1", + "lastVisitDisplayLocation": "中国 广东省 深圳市", + "bio": "### 简介" + } +} +``` + +### 公开注册策略 +`GET /api/public/registration-policy` + +无需鉴权。用于前端判断是否展示「邀请码」输入框。 + +响应: +```json +{ + "requireInviteCode": false +} +``` + +当 `requireInviteCode` 为 **true** 时,`POST /api/auth/register` 必须携带有效 `inviteCode`(见下节)。 + ### 注册账号(发送邮箱验证码) `POST /api/auth/register` @@ -102,10 +286,13 @@ "account": "demo", "password": "demo123", "username": "示例用户", - "email": "demo@example.com" + "email": "demo@example.com", + "inviteCode": "ABCD1234" } ``` +- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。 + 响应: ```json { @@ -218,8 +405,50 @@ 请求时可使用以下任一方式携带: - Query:`?token=` - Header:`X-Admin-Token: ` + +### 签到奖励设置 +`GET /api/admin/check-in/config` + +`PUT /api/admin/check-in/config` + +请求: +```json +{ + "rewardCoins": 1 +} +``` - Header:`Authorization: Bearer ` +### 注册策略与邀请码 + +`GET /api/admin/registration` + +响应含 `requireInviteCode` 与 `invites` 数组(每项含 `code`、`note`、`maxUses`、`uses`、`expiresAt`、`createdAt`)。`maxUses` 为 0 表示不限次数。 + +`PUT /api/admin/registration` + +请求: +```json +{ "requireInviteCode": true } +``` + +`POST /api/admin/registration/invites` + +请求: +```json +{ + "note": "内测批次", + "maxUses": 10, + "expiresAt": "2026-12-31T15:59:59Z" +} +``` + +`expiresAt` 可省略;须为 RFC3339。响应 `201`,`invite` 内含服务端生成的 8 位邀请码。 + +`DELETE /api/admin/registration/invites/{code}` + +删除指定邀请码(`code` 与存储大小写可能不同,按不区分大小写匹配)。 + ### 获取用户列表 `GET /api/admin/users` @@ -246,6 +475,7 @@ "secondaryEmails": ["demo2@example.com"], "phone": "13800000000", "avatarUrl": "https://example.com/avatar.png", + "websiteUrl": "https://example.com", "bio": "### 简介" } ``` @@ -260,10 +490,18 @@ "username": "新昵称", "level": 1, "secondaryEmails": ["demo2@example.com"], - "sproutCoins": 99 + "sproutCoins": 99, + "websiteUrl": "https://example.com", + "banned": true, + "banReason": "违规说明(最多 500 字)" } ``` +- `banned`:是否封禁;解封时请传 `false`,并可将 `banReason` 置为空字符串。 +- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`(RFC3339,存于用户 JSON)。 + +管理员列表 `GET /api/admin/users` 中每条 `user` 可含 `banned`、`banReason`(不含 `bannedAt` 亦可从存储文件中查看)。 + ### 删除用户 `DELETE /api/admin/users/{account}` @@ -281,16 +519,25 @@ - 管理员 Token:`data/config/admin.json` - JWT 配置:`data/config/auth.json` - 邮件配置:`data/config/email.json` +- 注册策略与邀请码:`data/config/registration.json` ## 快速联调用示例 ```bash +# 服务根路径 JSON 说明 +curl -s http://localhost:8080/ | jq . + # 登录 curl -X POST http://localhost:8080/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"account":"demo","password":"demo123"}' -# 使用令牌获取用户信息 +# 校验令牌(推荐第三方网关先调此接口) +curl -X POST http://localhost:8080/api/auth/verify \ + -H 'Content-Type: application/json' \ + -d '{"token":""}' + +# 使用令牌获取用户信息(会更新访问记录) curl http://localhost:8080/api/auth/me \ -H 'Authorization: Bearer ' ``` diff --git a/sproutgate-backend/docker-compose.yml b/sproutgate-backend/docker-compose.yml index d81a187..4f16f99 100644 --- a/sproutgate-backend/docker-compose.yml +++ b/sproutgate-backend/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Dockerfile - container_name: sproutgate-auth + container_name: sproutgate-backend restart: unless-stopped environment: PORT: "8080" @@ -11,4 +11,4 @@ services: volumes: - ./data:/data ports: - - "${AUTH_API_PORT:-18080}:8080" + - "${AUTH_API_PORT:-28080}:8080" diff --git a/sproutgate-backend/internal/clientgeo/clientgeo.go b/sproutgate-backend/internal/clientgeo/clientgeo.go new file mode 100644 index 0000000..49e22a7 --- /dev/null +++ b/sproutgate-backend/internal/clientgeo/clientgeo.go @@ -0,0 +1,70 @@ +package clientgeo + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const DefaultLookupURL = "https://cf-ip-geo.smyhub.com/api" + +type apiPayload struct { + Geo *struct { + CountryName string `json:"countryName"` + RegionName string `json:"regionName"` + CityName string `json:"cityName"` + } `json:"geo"` +} + +func FormatDisplay(countryName, regionName, cityName string) string { + parts := make([]string, 0, 3) + for _, s := range []string{countryName, regionName, cityName} { + s = strings.TrimSpace(s) + if s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, " ") +} + +// FetchDisplayLocation 使用 cf-ip-geo 的 ?ip= 查询展示用位置(服务端调用,避免浏览器 CORS)。 +func FetchDisplayLocation(ctx context.Context, lookupURL, ip string) (string, error) { + ip = strings.TrimSpace(ip) + if ip == "" { + return "", nil + } + base := strings.TrimSuffix(strings.TrimSpace(lookupURL), "/") + u, err := url.Parse(base) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("invalid lookup URL") + } + q := u.Query() + q.Set("ip", ip) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", err + } + client := &http.Client{Timeout: 6 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("geo status %d", resp.StatusCode) + } + var payload apiPayload + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + if payload.Geo == nil { + return "", nil + } + return FormatDisplay(payload.Geo.CountryName, payload.Geo.RegionName, payload.Geo.CityName), nil +} diff --git a/sproutgate-backend/internal/handlers/admin.go b/sproutgate-backend/internal/handlers/admin.go new file mode 100644 index 0000000..145e258 --- /dev/null +++ b/sproutgate-backend/internal/handlers/admin.go @@ -0,0 +1,204 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + + "sproutgate-backend/internal/models" +) + +func (h *Handler) ListUsers(c *gin.Context) { + users, err := h.store.ListUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"}) + return + } + publicUsers := make([]models.UserPublic, 0, len(users)) + for _, u := range users { + publicUsers = append(publicUsers, u.OwnerPublic()) + } + 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 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(req.Account) + if req.Account == "" || strings.TrimSpace(req.Password) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + wu, err := normalizePublicWebsiteURL(req.WebsiteURL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + record := models.UserRecord{ + Account: req.Account, + PasswordHash: string(hash), + Username: req.Username, + Email: req.Email, + Level: req.Level, + SproutCoins: req.SproutCoins, + SecondaryEmails: req.SecondaryEmails, + Phone: req.Phone, + AvatarURL: req.AvatarURL, + WebsiteURL: wu, + Bio: req.Bio, + CreatedAt: models.NowISO(), + UpdatedAt: models.NowISO(), + } + if err := h.store.CreateUser(record); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"user": record.OwnerPublic()}) +} + +func (h *Handler) UpdateUser(c *gin.Context) { + account := strings.TrimSpace(c.Param("account")) + if account == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"}) + return + } + var req updateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + user, found, err := h.store.GetUser(account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + if req.Password != nil && strings.TrimSpace(*req.Password) != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + user.PasswordHash = string(hash) + } + if req.Username != nil { + user.Username = *req.Username + } + if req.Email != nil { + user.Email = *req.Email + } + if req.Level != nil { + user.Level = *req.Level + } + if req.SproutCoins != nil { + user.SproutCoins = *req.SproutCoins + } + if req.SecondaryEmails != nil { + user.SecondaryEmails = *req.SecondaryEmails + } + if req.Phone != nil { + user.Phone = *req.Phone + } + if req.AvatarURL != nil { + user.AvatarURL = *req.AvatarURL + } + if req.WebsiteURL != nil { + wu, err := normalizePublicWebsiteURL(*req.WebsiteURL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + user.WebsiteURL = wu + } + if req.Bio != nil { + user.Bio = *req.Bio + } + if req.Banned != nil { + user.Banned = *req.Banned + if !user.Banned { + user.BanReason = "" + user.BannedAt = "" + } else if strings.TrimSpace(user.BannedAt) == "" { + user.BannedAt = models.NowISO() + } + } + if req.BanReason != nil { + r := strings.TrimSpace(*req.BanReason) + if len(r) > maxBanReasonLen { + c.JSON(http.StatusBadRequest, gin.H{"error": "ban reason is too long"}) + return + } + if r != "" && !user.Banned { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot set ban reason while user is not banned"}) + return + } + user.BanReason = r + } + if err := h.store.SaveUser(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) + return + } + c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()}) +} + +func (h *Handler) DeleteUser(c *gin.Context) { + account := strings.TrimSpace(c.Param("account")) + if account == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"}) + return + } + if err := h.store.DeleteUser(account); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (h *Handler) AdminMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + token := adminTokenFromRequest(c) + if token == "" || token != h.store.AdminToken() { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"}) + c.Abort() + return + } + c.Next() + } +} diff --git a/sproutgate-backend/internal/handlers/auth_client.go b/sproutgate-backend/internal/handlers/auth_client.go new file mode 100644 index 0000000..64e86c5 --- /dev/null +++ b/sproutgate-backend/internal/handlers/auth_client.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "strings" + + "github.com/gin-gonic/gin" + + "sproutgate-backend/internal/models" +) + +func authClientFromHeaders(c *gin.Context) (id string, name string, ok bool) { + id, ok = models.NormalizeAuthClientID(strings.TrimSpace(c.GetHeader("X-Auth-Client"))) + if !ok { + return "", "", false + } + name = models.ClampAuthClientName(c.GetHeader("X-Auth-Client-Name")) + return id, name, true +} diff --git a/sproutgate-backend/internal/handlers/auth_login.go b/sproutgate-backend/internal/handlers/auth_login.go new file mode 100644 index 0000000..f7373fc --- /dev/null +++ b/sproutgate-backend/internal/handlers/auth_login.go @@ -0,0 +1,173 @@ +package handlers + +import ( + "errors" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + + "sproutgate-backend/internal/auth" + "sproutgate-backend/internal/clientgeo" + "sproutgate-backend/internal/models" +) + +func (h *Handler) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(req.Account) + if req.Account == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"}) + return + } + user, found, err := h.store.GetUser(req.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": "invalid credentials"}) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + if user.Banned { + writeBanJSON(c, user.BanReason) + return + } + token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) + return + } + if cid, ok := models.NormalizeAuthClientID(req.ClientID); ok { + name := models.ClampAuthClientName(req.ClientName) + if rec, err := h.store.RecordAuthClient(req.Account, cid, name); err == nil { + user = rec + } + } + c.JSON(http.StatusOK, gin.H{ + "token": token, + "expiresAt": expiresAt.Format(time.RFC3339), + "user": user.OwnerPublic(), + }) +} + +func (h *Handler) Verify(c *gin.Context) { + var req verifyRequest + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}) + return + } + claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"}) + return + } + user, 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{"valid": false, "error": "user not found"}) + 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) + return + } + if cid, cname, ok := authClientFromHeaders(c); ok { + _, _ = h.store.RecordAuthClient(claims.Account, cid, cname) + } + c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()}) +} + +func (h *Handler) Me(c *gin.Context) { + 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 + } + user, 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, user) { + return + } + if cid, cname, ok := authClientFromHeaders(c); ok { + if rec, err := h.store.RecordAuthClient(claims.Account, cid, cname); err == nil { + user = rec + } + } + today := models.CurrentActivityDate() + nowAt := models.CurrentActivityTime() + user, _, err = h.store.RecordVisit(claims.Account, today, nowAt) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save visit"}) + return + } + visitIP := strings.TrimSpace(c.GetHeader("X-Visit-Ip")) + visitLoc := strings.TrimSpace(c.GetHeader("X-Visit-Location")) + if visitIP == "" { + visitIP = strings.TrimSpace(c.ClientIP()) + } + lookupURL := strings.TrimSpace(os.Getenv("GEO_LOOKUP_URL")) + if lookupURL == "" { + lookupURL = clientgeo.DefaultLookupURL + } + if visitLoc == "" && visitIP != "" { + if loc, geoErr := clientgeo.FetchDisplayLocation(c.Request.Context(), lookupURL, visitIP); geoErr == nil { + visitLoc = loc + } + } + if visitIP != "" || visitLoc != "" { + user, err = h.store.UpdateLastVisitMeta(claims.Account, visitIP, visitLoc) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save visit meta"}) + return + } + } + checkInConfig := h.store.CheckInConfig() + c.JSON(http.StatusOK, gin.H{ + "user": user.OwnerPublic(), + "checkIn": gin.H{ + "rewardCoins": checkInConfig.RewardCoins, + "checkedInToday": user.LastCheckInDate == today, + "lastCheckInDate": user.LastCheckInDate, + "lastCheckInAt": user.LastCheckInAt, + "today": today, + }, + }) +} diff --git a/sproutgate-backend/internal/handlers/auth_password.go b/sproutgate-backend/internal/handlers/auth_password.go new file mode 100644 index 0000000..4e18afb --- /dev/null +++ b/sproutgate-backend/internal/handlers/auth_password.go @@ -0,0 +1,252 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + + "sproutgate-backend/internal/email" + "sproutgate-backend/internal/models" +) + +func (h *Handler) Register(c *gin.Context) { + var req registerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(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 + } + requireInv := h.store.RegistrationRequireInvite() + if requireInv && inviteTrim == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invite code is required"}) + return + } + if inviteTrim != "" { + if err := h.store.ValidateInviteForRegister(inviteTrim); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + if _, found, err := h.store.GetUser(req.Account); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } else if found { + c.JSON(http.StatusBadRequest, gin.H{"error": "account already exists"}) + return + } + + code, err := generateVerificationCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + expiresAt := time.Now().Add(10 * time.Minute) + pending := models.PendingUser{ + Account: req.Account, + PasswordHash: string(hash), + Username: req.Username, + Email: req.Email, + CodeHash: hashCode(code), + CreatedAt: models.NowISO(), + ExpiresAt: expiresAt.Format(time.RFC3339), + } + if inviteTrim != "" { + pending.InviteCode = strings.ToUpper(inviteTrim) + } + if err := h.store.SavePending(pending); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pending user"}) + return + } + if err := email.SendVerificationEmail(h.store.EmailConfig(), req.Email, code, 10*time.Minute); err != nil { + _ = h.store.DeletePending(req.Account) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{ + "sent": true, + "expiresAt": expiresAt.Format(time.RFC3339), + }) +} + +func (h *Handler) VerifyEmail(c *gin.Context) { + var req verifyEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(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 + } + pending, found, err := h.store.GetPending(req.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"}) + return + } + if !found { + c.JSON(http.StatusBadRequest, gin.H{"error": "pending registration not found"}) + return + } + expiresAt, err := time.Parse(time.RFC3339, pending.ExpiresAt) + if err != nil || time.Now().After(expiresAt) { + _ = h.store.DeletePending(req.Account) + c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"}) + return + } + if !verifyCode(req.Code, pending.CodeHash) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"}) + return + } + record := models.UserRecord{ + Account: pending.Account, + PasswordHash: pending.PasswordHash, + Username: pending.Username, + Email: pending.Email, + Level: 0, + SproutCoins: 0, + SecondaryEmails: []string{}, + CreatedAt: models.NowISO(), + UpdatedAt: models.NowISO(), + } + if err := h.store.CreateUser(record); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if strings.TrimSpace(pending.InviteCode) != "" { + if err := h.store.RedeemInvite(pending.InviteCode); err != nil { + _ = h.store.DeleteUser(record.Account) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + _ = h.store.DeletePending(req.Account) + c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.OwnerPublic()}) +} + +func (h *Handler) ForgotPassword(c *gin.Context) { + var req forgotPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(req.Account) + req.Email = strings.TrimSpace(req.Email) + if req.Account == "" || req.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account and email are required"}) + return + } + user, found, err := h.store.GetUser(req.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found || strings.TrimSpace(user.Email) == "" || user.Email != req.Email { + c.JSON(http.StatusBadRequest, gin.H{"error": "account or email not matched"}) + return + } + if user.Banned { + writeBanJSON(c, user.BanReason) + return + } + code, err := generateVerificationCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) + return + } + expiresAt := time.Now().Add(10 * time.Minute) + resetRecord := models.ResetPassword{ + Account: user.Account, + Email: user.Email, + CodeHash: hashCode(code), + CreatedAt: models.NowISO(), + ExpiresAt: expiresAt.Format(time.RFC3339), + } + if err := h.store.SaveReset(resetRecord); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reset token"}) + return + } + if err := email.SendResetPasswordEmail(h.store.EmailConfig(), user.Email, code, 10*time.Minute); err != nil { + _ = h.store.DeleteReset(user.Account) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{ + "sent": true, + "expiresAt": expiresAt.Format(time.RFC3339), + }) +} + +func (h *Handler) ResetPassword(c *gin.Context) { + var req resetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + req.Account = strings.TrimSpace(req.Account) + req.Code = strings.TrimSpace(req.Code) + if req.Account == "" || req.Code == "" || strings.TrimSpace(req.NewPassword) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "account, code and newPassword are required"}) + return + } + resetRecord, found, err := h.store.GetReset(req.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load reset token"}) + return + } + if !found { + c.JSON(http.StatusBadRequest, gin.H{"error": "reset request not found"}) + return + } + expiresAt, err := time.Parse(time.RFC3339, resetRecord.ExpiresAt) + if err != nil || time.Now().After(expiresAt) { + _ = h.store.DeleteReset(req.Account) + c.JSON(http.StatusBadRequest, gin.H{"error": "reset code expired"}) + return + } + if !verifyCode(req.Code, resetRecord.CodeHash) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reset code"}) + return + } + user, found, err := h.store.GetUser(req.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !found { + c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"}) + return + } + if user.Banned { + writeBanJSON(c, user.BanReason) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + user.PasswordHash = string(hash) + if err := h.store.SaveUser(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) + return + } + _ = h.store.DeleteReset(req.Account) + c.JSON(http.StatusOK, gin.H{"reset": true}) +} diff --git a/sproutgate-backend/internal/handlers/checkin.go b/sproutgate-backend/internal/handlers/checkin.go new file mode 100644 index 0000000..7ac26d3 --- /dev/null +++ b/sproutgate-backend/internal/handlers/checkin.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "errors" + "net/http" + "os" + + "github.com/gin-gonic/gin" + + "sproutgate-backend/internal/auth" + "sproutgate-backend/internal/models" + "sproutgate-backend/internal/storage" +) + +func (h *Handler) CheckIn(c *gin.Context) { + 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 + } + userPre, foundPre, err := h.store.GetUser(claims.Account) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if !foundPre { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + if abortIfUserBanned(c, userPre) { + return + } + today := models.CurrentActivityDate() + nowAt := models.CurrentActivityTime() + user, reward, alreadyCheckedIn, err := h.store.CheckIn(claims.Account, today, nowAt) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save check-in"}) + return + } + checkInConfig := h.store.CheckInConfig() + message := "签到成功" + if alreadyCheckedIn { + message = "今日已签到" + } + c.JSON(http.StatusOK, gin.H{ + "checkedIn": !alreadyCheckedIn, + "alreadyCheckedIn": alreadyCheckedIn, + "rewardCoins": h.store.CheckInConfig().RewardCoins, + "awardedCoins": reward, + "message": message, + "user": user.OwnerPublic(), + "checkIn": gin.H{ + "rewardCoins": checkInConfig.RewardCoins, + "checkedInToday": user.LastCheckInDate == today, + "lastCheckInDate": user.LastCheckInDate, + "lastCheckInAt": user.LastCheckInAt, + "today": today, + }, + }) +} + +func (h *Handler) GetCheckInConfig(c *gin.Context) { + cfg := h.store.CheckInConfig() + c.JSON(http.StatusOK, gin.H{"rewardCoins": cfg.RewardCoins}) +} + +func (h *Handler) UpdateCheckInConfig(c *gin.Context) { + var req updateCheckInConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.RewardCoins <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "rewardCoins must be greater than 0"}) + return + } + if err := h.store.UpdateCheckInConfig(storage.CheckInConfig{RewardCoins: req.RewardCoins}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save check-in config"}) + return + } + c.JSON(http.StatusOK, gin.H{"rewardCoins": req.RewardCoins}) +} diff --git a/sproutgate-backend/internal/handlers/handler.go b/sproutgate-backend/internal/handlers/handler.go new file mode 100644 index 0000000..14562cb --- /dev/null +++ b/sproutgate-backend/internal/handlers/handler.go @@ -0,0 +1,11 @@ +package handlers + +import "sproutgate-backend/internal/storage" + +type Handler struct { + store *storage.Store +} + +func NewHandler(store *storage.Store) *Handler { + return &Handler{store: store} +} diff --git a/sproutgate-backend/internal/handlers/handlers.go b/sproutgate-backend/internal/handlers/handlers.go deleted file mode 100644 index 01a536c..0000000 --- a/sproutgate-backend/internal/handlers/handlers.go +++ /dev/null @@ -1,751 +0,0 @@ -package handlers - -import ( - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/hex" - "fmt" - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - "golang.org/x/crypto/bcrypt" - - "sproutgate-backend/internal/auth" - "sproutgate-backend/internal/email" - "sproutgate-backend/internal/models" - "sproutgate-backend/internal/storage" -) - -type Handler struct { - store *storage.Store -} - -func NewHandler(store *storage.Store) *Handler { - return &Handler{store: store} -} - -type loginRequest struct { - Account string `json:"account"` - Password string `json:"password"` -} - -type verifyRequest struct { - Token string `json:"token"` -} - -type registerRequest struct { - Account string `json:"account"` - Password string `json:"password"` - Username string `json:"username"` - Email string `json:"email"` -} - -type verifyEmailRequest struct { - Account string `json:"account"` - Code string `json:"code"` -} - -type updateProfileRequest struct { - Password *string `json:"password"` - Username *string `json:"username"` - Phone *string `json:"phone"` - AvatarURL *string `json:"avatarUrl"` - Bio *string `json:"bio"` -} - -type forgotPasswordRequest struct { - Account string `json:"account"` - Email string `json:"email"` -} - -type resetPasswordRequest struct { - Account string `json:"account"` - Code string `json:"code"` - NewPassword string `json:"newPassword"` -} - -type secondaryEmailRequest struct { - Email string `json:"email"` -} - -type verifySecondaryEmailRequest struct { - Email string `json:"email"` - Code string `json:"code"` -} - -type createUserRequest struct { - Account string `json:"account"` - Password string `json:"password"` - Username string `json:"username"` - Email string `json:"email"` - Level int `json:"level"` - SproutCoins int `json:"sproutCoins"` - SecondaryEmails []string `json:"secondaryEmails"` - Phone string `json:"phone"` - AvatarURL string `json:"avatarUrl"` - Bio string `json:"bio"` -} - -type updateUserRequest struct { - Password *string `json:"password"` - Username *string `json:"username"` - Email *string `json:"email"` - Level *int `json:"level"` - SproutCoins *int `json:"sproutCoins"` - SecondaryEmails *[]string `json:"secondaryEmails"` - Phone *string `json:"phone"` - AvatarURL *string `json:"avatarUrl"` - Bio *string `json:"bio"` -} - -func (h *Handler) Login(c *gin.Context) { - var req loginRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) - return - } - req.Account = strings.TrimSpace(req.Account) - if req.Account == "" || req.Password == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"}) - return - } - user, found, err := h.store.GetUser(req.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": "invalid credentials"}) - return - } - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) - return - } - token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) - return - } - c.JSON(http.StatusOK, gin.H{ - "token": token, - "expiresAt": expiresAt.Format(time.RFC3339), - "user": user.Public(), - }) -} - -func (h *Handler) Verify(c *gin.Context) { - var req verifyRequest - if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}) - return - } - claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"}) - return - } - user, 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{"valid": false, "error": "user not found"}) - return - } - c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()}) -} - -func (h *Handler) Register(c *gin.Context) { - var req registerRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) - return - } - req.Account = strings.TrimSpace(req.Account) - req.Email = strings.TrimSpace(req.Email) - if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"}) - return - } - if _, found, err := h.store.GetUser(req.Account); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) - return - } else if found { - c.JSON(http.StatusBadRequest, gin.H{"error": "account already exists"}) - return - } - - code, err := generateVerificationCode() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) - return - } - hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) - return - } - expiresAt := time.Now().Add(10 * time.Minute) - pending := models.PendingUser{ - Account: req.Account, - PasswordHash: string(hash), - Username: req.Username, - Email: req.Email, - CodeHash: hashCode(code), - CreatedAt: models.NowISO(), - ExpiresAt: expiresAt.Format(time.RFC3339), - } - if err := h.store.SavePending(pending); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pending user"}) - return - } - if err := email.SendVerificationEmail(h.store.EmailConfig(), req.Email, code, 10*time.Minute); err != nil { - _ = h.store.DeletePending(req.Account) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) - return - } - c.JSON(http.StatusOK, gin.H{ - "sent": true, - "expiresAt": expiresAt.Format(time.RFC3339), - }) -} - -func (h *Handler) VerifyEmail(c *gin.Context) { - var req verifyEmailRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) - return - } - req.Account = strings.TrimSpace(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 - } - pending, found, err := h.store.GetPending(req.Account) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"}) - return - } - if !found { - c.JSON(http.StatusBadRequest, gin.H{"error": "pending registration not found"}) - return - } - expiresAt, err := time.Parse(time.RFC3339, pending.ExpiresAt) - if err != nil || time.Now().After(expiresAt) { - _ = h.store.DeletePending(req.Account) - c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"}) - return - } - if !verifyCode(req.Code, pending.CodeHash) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"}) - return - } - record := models.UserRecord{ - Account: pending.Account, - PasswordHash: pending.PasswordHash, - Username: pending.Username, - Email: pending.Email, - Level: 0, - SproutCoins: 0, - SecondaryEmails: []string{}, - CreatedAt: models.NowISO(), - UpdatedAt: models.NowISO(), - } - if err := h.store.CreateUser(record); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - _ = h.store.DeletePending(req.Account) - c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.Public()}) -} - -func (h *Handler) ForgotPassword(c *gin.Context) { - var req forgotPasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) - return - } - req.Account = strings.TrimSpace(req.Account) - req.Email = strings.TrimSpace(req.Email) - if req.Account == "" || req.Email == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "account and email are required"}) - return - } - user, found, err := h.store.GetUser(req.Account) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) - return - } - if !found || strings.TrimSpace(user.Email) == "" || user.Email != req.Email { - c.JSON(http.StatusBadRequest, gin.H{"error": "account or email not matched"}) - return - } - code, err := generateVerificationCode() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) - return - } - expiresAt := time.Now().Add(10 * time.Minute) - resetRecord := models.ResetPassword{ - Account: user.Account, - Email: user.Email, - CodeHash: hashCode(code), - CreatedAt: models.NowISO(), - ExpiresAt: expiresAt.Format(time.RFC3339), - } - if err := h.store.SaveReset(resetRecord); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reset token"}) - return - } - if err := email.SendResetPasswordEmail(h.store.EmailConfig(), user.Email, code, 10*time.Minute); err != nil { - _ = h.store.DeleteReset(user.Account) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) - return - } - c.JSON(http.StatusOK, gin.H{ - "sent": true, - "expiresAt": expiresAt.Format(time.RFC3339), - }) -} - -func (h *Handler) ResetPassword(c *gin.Context) { - var req resetPasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) - return - } - req.Account = strings.TrimSpace(req.Account) - req.Code = strings.TrimSpace(req.Code) - if req.Account == "" || req.Code == "" || strings.TrimSpace(req.NewPassword) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "account, code and newPassword are required"}) - return - } - resetRecord, found, err := h.store.GetReset(req.Account) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load reset token"}) - return - } - if !found { - c.JSON(http.StatusBadRequest, gin.H{"error": "reset request not found"}) - return - } - expiresAt, err := time.Parse(time.RFC3339, resetRecord.ExpiresAt) - if err != nil || time.Now().After(expiresAt) { - _ = h.store.DeleteReset(req.Account) - c.JSON(http.StatusBadRequest, gin.H{"error": "reset code expired"}) - return - } - if !verifyCode(req.Code, resetRecord.CodeHash) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reset code"}) - return - } - user, found, err := h.store.GetUser(req.Account) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) - return - } - if !found { - c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"}) - return - } - hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) - return - } - user.PasswordHash = string(hash) - if err := h.store.SaveUser(user); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) - return - } - _ = h.store.DeleteReset(req.Account) - c.JSON(http.StatusOK, gin.H{"reset": true}) -} - -func (h *Handler) RequestSecondaryEmail(c *gin.Context) { - 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 - } - var req secondaryEmailRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) - return - } - emailAddr := strings.TrimSpace(req.Email) - if emailAddr == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"}) - return - } - user, 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 strings.TrimSpace(user.Email) == emailAddr { - c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"}) - return - } - for _, e := range user.SecondaryEmails { - if e == emailAddr { - c.JSON(http.StatusBadRequest, gin.H{"error": "email already verified"}) - return - } - } - code, err := generateVerificationCode() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) - return - } - expiresAt := time.Now().Add(10 * time.Minute) - record := models.SecondaryEmailVerification{ - Account: user.Account, - Email: emailAddr, - CodeHash: hashCode(code), - CreatedAt: models.NowISO(), - ExpiresAt: expiresAt.Format(time.RFC3339), - } - if err := h.store.SaveSecondaryVerification(record); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save verification"}) - return - } - if err := email.SendVerificationEmail(h.store.EmailConfig(), emailAddr, code, 10*time.Minute); err != nil { - _ = h.store.DeleteSecondaryVerification(user.Account, emailAddr) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) - return - } - c.JSON(http.StatusOK, gin.H{ - "sent": true, - "expiresAt": expiresAt.Format(time.RFC3339), - }) -} - -func (h *Handler) VerifySecondaryEmail(c *gin.Context) { - 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 - } - var req verifySecondaryEmailRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) - return - } - emailAddr := strings.TrimSpace(req.Email) - code := strings.TrimSpace(req.Code) - if emailAddr == "" || code == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "email and code are required"}) - return - } - record, found, err := h.store.GetSecondaryVerification(claims.Account, emailAddr) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"}) - return - } - if !found { - c.JSON(http.StatusBadRequest, gin.H{"error": "verification not found"}) - return - } - expiresAt, err := time.Parse(time.RFC3339, record.ExpiresAt) - if err != nil || time.Now().After(expiresAt) { - _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) - c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"}) - return - } - if !verifyCode(code, record.CodeHash) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"}) - return - } - user, 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 - } - for _, e := range user.SecondaryEmails { - if e == emailAddr { - _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) - c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()}) - return - } - } - user.SecondaryEmails = append(user.SecondaryEmails, emailAddr) - if err := h.store.SaveUser(user); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) - return - } - _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) - c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()}) -} - -func (h *Handler) Me(c *gin.Context) { - 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 - } - user, 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 - } - c.JSON(http.StatusOK, gin.H{"user": user.Public()}) -} - -func (h *Handler) UpdateProfile(c *gin.Context) { - 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 - } - var req updateProfileRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) - return - } - user, 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 req.Password != nil && strings.TrimSpace(*req.Password) != "" { - hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) - return - } - user.PasswordHash = string(hash) - } - if req.Username != nil { - user.Username = *req.Username - } - if req.Phone != nil { - user.Phone = *req.Phone - } - if req.AvatarURL != nil { - user.AvatarURL = *req.AvatarURL - } - if req.Bio != nil { - user.Bio = *req.Bio - } - if err := h.store.SaveUser(user); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) - return - } - c.JSON(http.StatusOK, gin.H{"user": user.Public()}) -} - -func (h *Handler) ListUsers(c *gin.Context) { - users, err := h.store.ListUsers() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"}) - return - } - publicUsers := make([]models.UserPublic, 0, len(users)) - for _, u := range users { - publicUsers = append(publicUsers, u.Public()) - } - c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers}) -} - -func (h *Handler) CreateUser(c *gin.Context) { - var req createUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) - return - } - req.Account = strings.TrimSpace(req.Account) - if req.Account == "" || strings.TrimSpace(req.Password) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"}) - return - } - hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) - return - } - record := models.UserRecord{ - Account: req.Account, - PasswordHash: string(hash), - Username: req.Username, - Email: req.Email, - Level: req.Level, - SproutCoins: req.SproutCoins, - SecondaryEmails: req.SecondaryEmails, - Phone: req.Phone, - AvatarURL: req.AvatarURL, - Bio: req.Bio, - CreatedAt: models.NowISO(), - UpdatedAt: models.NowISO(), - } - if err := h.store.CreateUser(record); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, gin.H{"user": record.Public()}) -} - -func (h *Handler) UpdateUser(c *gin.Context) { - account := strings.TrimSpace(c.Param("account")) - if account == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"}) - return - } - var req updateUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) - return - } - user, found, err := h.store.GetUser(account) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) - return - } - if !found { - c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) - return - } - if req.Password != nil && strings.TrimSpace(*req.Password) != "" { - hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) - return - } - user.PasswordHash = string(hash) - } - if req.Username != nil { - user.Username = *req.Username - } - if req.Email != nil { - user.Email = *req.Email - } - if req.Level != nil { - user.Level = *req.Level - } - if req.SproutCoins != nil { - user.SproutCoins = *req.SproutCoins - } - if req.SecondaryEmails != nil { - user.SecondaryEmails = *req.SecondaryEmails - } - if req.Phone != nil { - user.Phone = *req.Phone - } - if req.AvatarURL != nil { - user.AvatarURL = *req.AvatarURL - } - if req.Bio != nil { - user.Bio = *req.Bio - } - if err := h.store.SaveUser(user); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) - return - } - c.JSON(http.StatusOK, gin.H{"user": user.Public()}) -} - -func (h *Handler) DeleteUser(c *gin.Context) { - account := strings.TrimSpace(c.Param("account")) - if account == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"}) - return - } - if err := h.store.DeleteUser(account); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"}) - return - } - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -func (h *Handler) AdminMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - token := adminTokenFromRequest(c) - if token == "" || token != h.store.AdminToken() { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"}) - c.Abort() - return - } - c.Next() - } -} - -func adminTokenFromRequest(c *gin.Context) string { - if token := strings.TrimSpace(c.Query("token")); token != "" { - return token - } - if token := strings.TrimSpace(c.GetHeader("X-Admin-Token")); token != "" { - return token - } - authHeader := strings.TrimSpace(c.GetHeader("Authorization")) - return bearerToken(authHeader) -} - -func bearerToken(header string) string { - if header == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(header), "bearer ") { - return strings.TrimSpace(header[7:]) - } - return "" -} - -func generateVerificationCode() (string, error) { - randomBytes := make([]byte, 3) - if _, err := rand.Read(randomBytes); err != nil { - return "", err - } - number := int(randomBytes[0])<<16 | int(randomBytes[1])<<8 | int(randomBytes[2]) - return fmt.Sprintf("%06d", number%1000000), nil -} - -func hashCode(code string) string { - sum := sha256.Sum256([]byte(code)) - return hex.EncodeToString(sum[:]) -} - -func verifyCode(code string, hash string) bool { - return subtle.ConstantTimeCompare([]byte(hashCode(code)), []byte(hash)) == 1 -} diff --git a/sproutgate-backend/internal/handlers/helpers.go b/sproutgate-backend/internal/handlers/helpers.go new file mode 100644 index 0000000..b58576a --- /dev/null +++ b/sproutgate-backend/internal/handlers/helpers.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "sproutgate-backend/internal/models" +) + +func bearerToken(header string) string { + if header == "" { + return "" + } + if strings.HasPrefix(strings.ToLower(header), "bearer ") { + return strings.TrimSpace(header[7:]) + } + return "" +} + +func adminTokenFromRequest(c *gin.Context) string { + if token := strings.TrimSpace(c.Query("token")); token != "" { + return token + } + if token := strings.TrimSpace(c.GetHeader("X-Admin-Token")); token != "" { + return token + } + authHeader := strings.TrimSpace(c.GetHeader("Authorization")) + return bearerToken(authHeader) +} + +func generateVerificationCode() (string, error) { + randomBytes := make([]byte, 3) + if _, err := rand.Read(randomBytes); err != nil { + return "", err + } + number := int(randomBytes[0])<<16 | int(randomBytes[1])<<8 | int(randomBytes[2]) + return fmt.Sprintf("%06d", number%1000000), nil +} + +func hashCode(code string) string { + sum := sha256.Sum256([]byte(code)) + return hex.EncodeToString(sum[:]) +} + +func verifyCode(code string, hash string) bool { + return subtle.ConstantTimeCompare([]byte(hashCode(code)), []byte(hash)) == 1 +} + +func writeBanJSON(c *gin.Context, reason string) { + h := gin.H{"error": "account is banned"} + if r := strings.TrimSpace(reason); r != "" { + h["banReason"] = r + } + c.JSON(http.StatusForbidden, h) +} + +func abortIfUserBanned(c *gin.Context, u models.UserRecord) bool { + if !u.Banned { + return false + } + writeBanJSON(c, u.BanReason) + return true +} diff --git a/sproutgate-backend/internal/handlers/profile.go b/sproutgate-backend/internal/handlers/profile.go new file mode 100644 index 0000000..eb803d0 --- /dev/null +++ b/sproutgate-backend/internal/handlers/profile.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + + "sproutgate-backend/internal/auth" +) + +func (h *Handler) UpdateProfile(c *gin.Context) { + 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 + } + var req updateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + user, 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, user) { + return + } + if req.Password != nil && strings.TrimSpace(*req.Password) != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + user.PasswordHash = string(hash) + } + if req.Username != nil { + user.Username = *req.Username + } + if req.Phone != nil { + user.Phone = *req.Phone + } + if req.AvatarURL != nil { + user.AvatarURL = *req.AvatarURL + } + if req.WebsiteURL != nil { + wu, err := normalizePublicWebsiteURL(*req.WebsiteURL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + user.WebsiteURL = wu + } + if req.Bio != nil { + user.Bio = *req.Bio + } + if err := h.store.SaveUser(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) + return + } + c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()}) +} diff --git a/sproutgate-backend/internal/handlers/public_registration.go b/sproutgate-backend/internal/handlers/public_registration.go new file mode 100644 index 0000000..f8e88e9 --- /dev/null +++ b/sproutgate-backend/internal/handlers/public_registration.go @@ -0,0 +1,14 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetPublicRegistrationPolicy 公开:是否必须邀请码(不含具体邀请码)。 +func (h *Handler) GetPublicRegistrationPolicy(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "requireInviteCode": h.store.RegistrationRequireInvite(), + }) +} diff --git a/sproutgate-backend/internal/handlers/registration_admin.go b/sproutgate-backend/internal/handlers/registration_admin.go new file mode 100644 index 0000000..9f8fd2b --- /dev/null +++ b/sproutgate-backend/internal/handlers/registration_admin.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) GetAdminRegistration(c *gin.Context) { + cfg := h.store.GetRegistrationConfig() + c.JSON(http.StatusOK, gin.H{ + "requireInviteCode": cfg.RequireInviteCode, + "invites": cfg.Invites, + }) +} + +func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) { + var req updateRegistrationPolicyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if err := h.store.SetRegistrationRequireInvite(req.RequireInviteCode); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save registration policy"}) + return + } + c.JSON(http.StatusOK, gin.H{"requireInviteCode": req.RequireInviteCode}) +} + +func (h *Handler) PostAdminInvite(c *gin.Context) { + var req createInviteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + entry, err := h.store.AddInviteEntry(req.Note, req.MaxUses, strings.TrimSpace(req.ExpiresAt)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"invite": entry}) +} + +func (h *Handler) DeleteAdminInvite(c *gin.Context) { + code := strings.TrimSpace(c.Param("code")) + if err := h.store.DeleteInviteEntry(code); err != nil { + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} diff --git a/sproutgate-backend/internal/handlers/requests.go b/sproutgate-backend/internal/handlers/requests.go new file mode 100644 index 0000000..c69178b --- /dev/null +++ b/sproutgate-backend/internal/handlers/requests.go @@ -0,0 +1,135 @@ +package handlers + +import ( + "errors" + "net/url" + "strings" +) + +type loginRequest struct { + Account string `json:"account"` + Password string `json:"password"` + ClientID string `json:"clientId"` + ClientName string `json:"clientName"` +} + +type verifyRequest struct { + Token string `json:"token"` +} + +type registerRequest struct { + Account string `json:"account"` + Password string `json:"password"` + Username string `json:"username"` + Email string `json:"email"` + InviteCode string `json:"inviteCode"` +} + +type verifyEmailRequest struct { + Account string `json:"account"` + Code string `json:"code"` +} + +type updateProfileRequest struct { + Password *string `json:"password"` + Username *string `json:"username"` + Phone *string `json:"phone"` + AvatarURL *string `json:"avatarUrl"` + WebsiteURL *string `json:"websiteUrl"` + Bio *string `json:"bio"` +} + +type forgotPasswordRequest struct { + Account string `json:"account"` + Email string `json:"email"` +} + +type resetPasswordRequest struct { + Account string `json:"account"` + Code string `json:"code"` + NewPassword string `json:"newPassword"` +} + +type secondaryEmailRequest struct { + Email string `json:"email"` +} + +type verifySecondaryEmailRequest struct { + Email string `json:"email"` + Code string `json:"code"` +} + +type updateCheckInConfigRequest struct { + RewardCoins int `json:"rewardCoins"` +} + +type updateRegistrationPolicyRequest struct { + RequireInviteCode bool `json:"requireInviteCode"` +} + +type createInviteRequest struct { + Note string `json:"note"` + MaxUses int `json:"maxUses"` + ExpiresAt string `json:"expiresAt"` +} + +type createUserRequest struct { + Account string `json:"account"` + Password string `json:"password"` + Username string `json:"username"` + Email string `json:"email"` + Level int `json:"level"` + SproutCoins int `json:"sproutCoins"` + SecondaryEmails []string `json:"secondaryEmails"` + Phone string `json:"phone"` + AvatarURL string `json:"avatarUrl"` + WebsiteURL string `json:"websiteUrl"` + Bio string `json:"bio"` +} + +const maxBanReasonLen = 500 + +type updateUserRequest struct { + Password *string `json:"password"` + Username *string `json:"username"` + Email *string `json:"email"` + Level *int `json:"level"` + SproutCoins *int `json:"sproutCoins"` + SecondaryEmails *[]string `json:"secondaryEmails"` + Phone *string `json:"phone"` + AvatarURL *string `json:"avatarUrl"` + WebsiteURL *string `json:"websiteUrl"` + Bio *string `json:"bio"` + Banned *bool `json:"banned"` + BanReason *string `json:"banReason"` +} + +const maxWebsiteURLLen = 2048 + +func normalizePublicWebsiteURL(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", nil + } + if len(s) > maxWebsiteURLLen { + return "", errors.New("website url is too long") + } + lower := strings.ToLower(s) + if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "data:") { + return "", errors.New("invalid website url") + } + candidate := s + if !strings.Contains(candidate, "://") { + candidate = "https://" + candidate + } + u, err := url.Parse(candidate) + if err != nil || u.Host == "" { + return "", errors.New("invalid website url") + } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return "", errors.New("only http and https urls are allowed") + } + u.Scheme = scheme + return u.String(), nil +} diff --git a/sproutgate-backend/internal/handlers/secondary_email.go b/sproutgate-backend/internal/handlers/secondary_email.go new file mode 100644 index 0000000..3b92dac --- /dev/null +++ b/sproutgate-backend/internal/handlers/secondary_email.go @@ -0,0 +1,154 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "sproutgate-backend/internal/auth" + "sproutgate-backend/internal/email" + "sproutgate-backend/internal/models" +) + +func (h *Handler) RequestSecondaryEmail(c *gin.Context) { + 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 + } + var req secondaryEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + emailAddr := strings.TrimSpace(req.Email) + if emailAddr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"}) + return + } + user, 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, user) { + return + } + if strings.TrimSpace(user.Email) == emailAddr { + c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"}) + return + } + for _, e := range user.SecondaryEmails { + if e == emailAddr { + c.JSON(http.StatusBadRequest, gin.H{"error": "email already verified"}) + return + } + } + code, err := generateVerificationCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) + return + } + expiresAt := time.Now().Add(10 * time.Minute) + record := models.SecondaryEmailVerification{ + Account: user.Account, + Email: emailAddr, + CodeHash: hashCode(code), + CreatedAt: models.NowISO(), + ExpiresAt: expiresAt.Format(time.RFC3339), + } + if err := h.store.SaveSecondaryVerification(record); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save verification"}) + return + } + if err := email.SendVerificationEmail(h.store.EmailConfig(), emailAddr, code, 10*time.Minute); err != nil { + _ = h.store.DeleteSecondaryVerification(user.Account, emailAddr) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{ + "sent": true, + "expiresAt": expiresAt.Format(time.RFC3339), + }) +} + +func (h *Handler) VerifySecondaryEmail(c *gin.Context) { + 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 + } + var req verifySecondaryEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + emailAddr := strings.TrimSpace(req.Email) + code := strings.TrimSpace(req.Code) + if emailAddr == "" || code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email and code are required"}) + return + } + record, found, err := h.store.GetSecondaryVerification(claims.Account, emailAddr) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"}) + return + } + if !found { + c.JSON(http.StatusBadRequest, gin.H{"error": "verification not found"}) + return + } + expiresAt, err := time.Parse(time.RFC3339, record.ExpiresAt) + if err != nil || time.Now().After(expiresAt) { + _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) + c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"}) + return + } + if !verifyCode(code, record.CodeHash) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"}) + return + } + user, 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, user) { + return + } + for _, e := range user.SecondaryEmails { + if e == emailAddr { + _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) + c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.OwnerPublic()}) + return + } + } + user.SecondaryEmails = append(user.SecondaryEmails, emailAddr) + if err := h.store.SaveUser(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) + return + } + _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) + c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.OwnerPublic()}) +} diff --git a/sproutgate-backend/internal/models/activity.go b/sproutgate-backend/internal/models/activity.go new file mode 100644 index 0000000..36515a1 --- /dev/null +++ b/sproutgate-backend/internal/models/activity.go @@ -0,0 +1,106 @@ +package models + +import ( + "strings" + "time" +) + +var activityLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +const ( + // 日与时刻之间留空格,避免「20日11点」粘连难读 + ActivityTimeLayout = "2006年1月2日 15点04分05秒" + // 历史数据可能无空格,解析时兼容 + activityTimeLayoutLegacy = "2006年1月2日15点04分05秒" + ActivityDateLayout = "2006-01-02" +) + +func CurrentActivityDate() string { + return time.Now().In(activityLocation).Format(ActivityDateLayout) +} + +func CurrentActivityTime() string { + return FormatActivityTime(time.Now()) +} + +func FormatActivityTime(t time.Time) string { + return t.In(activityLocation).Format(ActivityTimeLayout) +} + +func ParseActivityTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + layouts := []string{ActivityTimeLayout, activityTimeLayoutLegacy, time.RFC3339Nano, time.RFC3339} + for _, layout := range layouts { + if parsed, err := time.ParseInLocation(layout, value, activityLocation); err == nil { + return parsed.In(activityLocation), true + } + if parsed, err := time.Parse(layout, value); err == nil { + return parsed.In(activityLocation), true + } + } + return time.Time{}, false +} + +func ActivityDate(value string) (string, bool) { + parsed, ok := ParseActivityTime(value) + if !ok { + return "", false + } + return parsed.In(activityLocation).Format(ActivityDateLayout), true +} + +func HasActivityDate(values []string, date string) bool { + for _, value := range values { + if parsedDate, ok := ActivityDate(value); ok && parsedDate == date { + return true + } + } + return false +} + +func ActivitySummary(values []string, fallbackDate string) (days int, streak int, lastAt string) { + dateSet := make(map[string]struct{}, len(values)) + var latest time.Time + hasLatest := false + + for _, value := range values { + parsed, ok := ParseActivityTime(value) + if !ok { + continue + } + dateKey := parsed.In(activityLocation).Format(ActivityDateLayout) + dateSet[dateKey] = struct{}{} + if !hasLatest || parsed.After(latest) { + latest = parsed + hasLatest = true + lastAt = FormatActivityTime(parsed) + } + } + + if len(dateSet) == 0 && strings.TrimSpace(fallbackDate) != "" { + dateSet[strings.TrimSpace(fallbackDate)] = struct{}{} + days = 1 + streak = 1 + return + } + + days = len(dateSet) + if !hasLatest { + return + } + + cursor := time.Date(latest.In(activityLocation).Year(), latest.In(activityLocation).Month(), latest.In(activityLocation).Day(), 0, 0, 0, 0, activityLocation) + for { + key := cursor.Format(ActivityDateLayout) + if _, ok := dateSet[key]; !ok { + break + } + streak++ + cursor = cursor.AddDate(0, 0, -1) + } + + return +} diff --git a/sproutgate-backend/internal/models/authclient.go b/sproutgate-backend/internal/models/authclient.go new file mode 100644 index 0000000..7f769f8 --- /dev/null +++ b/sproutgate-backend/internal/models/authclient.go @@ -0,0 +1,40 @@ +package models + +import ( + "regexp" + "strings" +) + +const ( + MaxAuthClientIDLen = 64 + MaxAuthClientNameLen = 128 +) + +var authClientIDRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,63}$`) + +// AuthClientEntry 记录某第三方应用曾用本账号完成认证(登录 / 校验令牌 / 拉取 me)。 +type AuthClientEntry struct { + ClientID string `json:"clientId"` + DisplayName string `json:"displayName,omitempty"` + FirstSeenAt string `json:"firstSeenAt"` + LastSeenAt string `json:"lastSeenAt"` +} + +func NormalizeAuthClientID(raw string) (string, bool) { + s := strings.TrimSpace(raw) + if s == "" || len(s) > MaxAuthClientIDLen { + return "", false + } + if !authClientIDRe.MatchString(s) { + return "", false + } + return s, true +} + +func ClampAuthClientName(raw string) string { + s := strings.TrimSpace(raw) + if len(s) > MaxAuthClientNameLen { + return s[:MaxAuthClientNameLen] + } + return s +} diff --git a/sproutgate-backend/internal/models/pending.go b/sproutgate-backend/internal/models/pending.go index eafe25e..17e180a 100644 --- a/sproutgate-backend/internal/models/pending.go +++ b/sproutgate-backend/internal/models/pending.go @@ -8,4 +8,5 @@ type PendingUser struct { CodeHash string `json:"codeHash"` ExpiresAt string `json:"expiresAt"` CreatedAt string `json:"createdAt"` + InviteCode string `json:"inviteCode,omitempty"` } diff --git a/sproutgate-backend/internal/models/user.go b/sproutgate-backend/internal/models/user.go index feb1c07..03f562a 100644 --- a/sproutgate-backend/internal/models/user.go +++ b/sproutgate-backend/internal/models/user.go @@ -1,52 +1,144 @@ package models -import "time" +import ( + "strings" + "time" +) type UserRecord struct { - Account string `json:"account"` - PasswordHash string `json:"passwordHash"` - Username string `json:"username"` - Email string `json:"email"` - Level int `json:"level"` - SproutCoins int `json:"sproutCoins"` - SecondaryEmails []string `json:"secondaryEmails,omitempty"` - Phone string `json:"phone,omitempty"` - AvatarURL string `json:"avatarUrl,omitempty"` - Bio string `json:"bio,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Account string `json:"account"` + PasswordHash string `json:"passwordHash"` + Username string `json:"username"` + Email string `json:"email"` + Level int `json:"level"` + SproutCoins int `json:"sproutCoins"` + LastCheckInDate string `json:"lastCheckInDate,omitempty"` + LastCheckInAt string `json:"lastCheckInAt,omitempty"` + LastVisitDate string `json:"lastVisitDate,omitempty"` + LastVisitAt string `json:"lastVisitAt,omitempty"` + LastVisitIP string `json:"lastVisitIp,omitempty"` + LastVisitDisplayLocation string `json:"lastVisitDisplayLocation,omitempty"` + CheckInTimes []string `json:"checkInTimes,omitempty"` + VisitTimes []string `json:"visitTimes,omitempty"` + SecondaryEmails []string `json:"secondaryEmails,omitempty"` + Phone string `json:"phone,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + WebsiteURL string `json:"websiteUrl,omitempty"` + Bio string `json:"bio,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Banned bool `json:"banned"` + BanReason string `json:"banReason,omitempty"` + BannedAt string `json:"bannedAt,omitempty"` + AuthClients []AuthClientEntry `json:"authClients,omitempty"` } type UserPublic struct { - Account string `json:"account"` - Username string `json:"username"` - Email string `json:"email"` - Level int `json:"level"` - SproutCoins int `json:"sproutCoins"` - SecondaryEmails []string `json:"secondaryEmails,omitempty"` - Phone string `json:"phone,omitempty"` - AvatarURL string `json:"avatarUrl,omitempty"` - Bio string `json:"bio,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Account string `json:"account"` + Username string `json:"username"` + Email string `json:"email"` + Level int `json:"level"` + SproutCoins int `json:"sproutCoins"` + LastCheckInDate string `json:"lastCheckInDate,omitempty"` + LastCheckInAt string `json:"lastCheckInAt,omitempty"` + LastVisitDate string `json:"lastVisitDate,omitempty"` + CheckInDays int `json:"checkInDays"` + CheckInStreak int `json:"checkInStreak"` + LastVisitAt string `json:"lastVisitAt,omitempty"` + LastVisitIP string `json:"lastVisitIp,omitempty"` + LastVisitDisplayLocation string `json:"lastVisitDisplayLocation,omitempty"` + VisitDays int `json:"visitDays"` + VisitStreak int `json:"visitStreak"` + SecondaryEmails []string `json:"secondaryEmails,omitempty"` + Phone string `json:"phone,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + WebsiteURL string `json:"websiteUrl,omitempty"` + Bio string `json:"bio,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Banned bool `json:"banned,omitempty"` + BanReason string `json:"banReason,omitempty"` + BannedAt string `json:"bannedAt,omitempty"` + AuthClients []AuthClientEntry `json:"authClients,omitempty"` } func (u UserRecord) Public() UserPublic { + checkInDays, checkInStreak, lastCheckInAt := ActivitySummary(u.CheckInTimes, u.LastCheckInDate) + visitDays, visitStreak, lastVisitAt := ActivitySummary(u.VisitTimes, u.LastVisitDate) + if strings.TrimSpace(u.LastCheckInAt) != "" { + lastCheckInAt = u.LastCheckInAt + } + if strings.TrimSpace(u.LastVisitAt) != "" { + lastVisitAt = u.LastVisitAt + } return UserPublic{ Account: u.Account, - Username: u.Username, - Email: u.Email, - Level: u.Level, + Username: u.Username, + Email: u.Email, + Level: u.Level, SproutCoins: u.SproutCoins, + LastCheckInDate: u.LastCheckInDate, + LastCheckInAt: lastCheckInAt, + LastVisitDate: u.LastVisitDate, + CheckInDays: checkInDays, + CheckInStreak: checkInStreak, + 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, } } +// PublicProfile 在 Public 基础上附带最近访问 IP / 展示用地理位置,仅供「用户公开主页」接口使用。 +func (u UserRecord) PublicProfile() UserPublic { + p := u.Public() + p.LastVisitIP = strings.TrimSpace(u.LastVisitIP) + p.LastVisitDisplayLocation = strings.TrimSpace(u.LastVisitDisplayLocation) + return p +} + +// OwnerPublic 包含仅本人/管理员可见的字段(如最近访问 IP),勿用于公开资料接口。 +func (u UserRecord) OwnerPublic() UserPublic { + p := u.Public() + p.LastVisitIP = strings.TrimSpace(u.LastVisitIP) + p.LastVisitDisplayLocation = strings.TrimSpace(u.LastVisitDisplayLocation) + p.Banned = u.Banned + p.BanReason = strings.TrimSpace(u.BanReason) + p.BannedAt = strings.TrimSpace(u.BannedAt) + if len(u.AuthClients) > 0 { + p.AuthClients = append([]AuthClientEntry(nil), u.AuthClients...) + } + return p +} + +type UserShowcase struct { + Account string `json:"account"` + Username string `json:"username"` + Level int `json:"level"` + SproutCoins int `json:"sproutCoins"` + AvatarURL string `json:"avatarUrl,omitempty"` + WebsiteURL string `json:"websiteUrl,omitempty"` + Bio string `json:"bio,omitempty"` +} + +func (u UserRecord) Showcase() UserShowcase { + return UserShowcase{ + Account: u.Account, + Username: u.Username, + Level: u.Level, + SproutCoins: u.SproutCoins, + AvatarURL: u.AvatarURL, + WebsiteURL: u.WebsiteURL, + Bio: u.Bio, + } +} + func NowISO() string { return time.Now().Format(time.RFC3339) } diff --git a/sproutgate-backend/internal/storage/registration.go b/sproutgate-backend/internal/storage/registration.go new file mode 100644 index 0000000..cbc3d4c --- /dev/null +++ b/sproutgate-backend/internal/storage/registration.go @@ -0,0 +1,214 @@ +package storage + +import ( + "crypto/rand" + "errors" + "os" + "strings" + "time" + + "sproutgate-backend/internal/models" +) + +// InviteEntry 管理员发放的注册邀请码。 +type InviteEntry struct { + Code string `json:"code"` + Note string `json:"note,omitempty"` + MaxUses int `json:"maxUses"` // 0 表示不限次数 + Uses int `json:"uses"` + ExpiresAt string `json:"expiresAt,omitempty"` // RFC3339,空表示不过期 + CreatedAt string `json:"createdAt"` +} + +// RegistrationConfig 注册策略与邀请码列表(data/config/registration.json)。 +type RegistrationConfig struct { + RequireInviteCode bool `json:"requireInviteCode"` + Invites []InviteEntry `json:"invites"` +} + +func normalizeInviteCode(raw string) string { + return strings.ToUpper(strings.TrimSpace(raw)) +} + +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 { + return err + } + if cfg.Invites == nil { + cfg.Invites = []InviteEntry{} + } + s.registrationConfig = cfg + return nil +} + +func (s *Store) persistRegistrationConfigLocked() error { + return writeJSONFile(s.registrationPath, s.registrationConfig) +} + +// RegistrationRequireInvite 是否强制要求邀请码才能发起注册(发邮件验证码)。 +func (s *Store) RegistrationRequireInvite() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.registrationConfig.RequireInviteCode +} + +// GetRegistrationConfig 返回配置副本(管理端)。 +func (s *Store) GetRegistrationConfig() RegistrationConfig { + s.mu.Lock() + defer s.mu.Unlock() + out := s.registrationConfig + out.Invites = append([]InviteEntry(nil), s.registrationConfig.Invites...) + return out +} + +// SetRegistrationRequireInvite 更新是否强制邀请码。 +func (s *Store) SetRegistrationRequireInvite(require bool) error { + s.mu.Lock() + defer s.mu.Unlock() + s.registrationConfig.RequireInviteCode = require + return s.persistRegistrationConfigLocked() +} + +func inviteEntryValid(e *InviteEntry) error { + if strings.TrimSpace(e.ExpiresAt) != "" { + t, err := time.Parse(time.RFC3339, e.ExpiresAt) + if err == nil && time.Now().After(t) { + return errors.New("invite code expired") + } + } + if e.MaxUses > 0 && e.Uses >= e.MaxUses { + return errors.New("invite code has been fully used") + } + return nil +} + +// 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() + for i := range s.registrationConfig.Invites { + e := &s.registrationConfig.Invites[i] + if strings.EqualFold(e.Code, n) { + return inviteEntryValid(e) + } + } + return errors.New("invalid invite code") +} + +// 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) { + if err := inviteEntryValid(e); err != nil { + return err + } + e.Uses++ + return s.persistRegistrationConfigLocked() + } + } + return errors.New("invalid invite code") +} + +const inviteCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +func randomInviteToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + var sb strings.Builder + sb.Grow(n) + for i := 0; i < n; i++ { + sb.WriteByte(inviteCodeAlphabet[int(b[i])%len(inviteCodeAlphabet)]) + } + return sb.String(), nil +} + +// 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) + if err != nil { + return InviteEntry{}, err + } + dup := false + for _, ex := range s.registrationConfig.Invites { + if strings.EqualFold(ex.Code, c) { + dup = true + break + } + } + if !dup { + code = c + break + } + } + 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 { + return InviteEntry{}, errors.New("invalid expiresAt (use RFC3339)") + } + } + if maxUses < 0 { + maxUses = 0 + } + entry := InviteEntry{ + Code: code, + Note: strings.TrimSpace(note), + MaxUses: maxUses, + Uses: 0, + 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] + return InviteEntry{}, err + } + return entry, nil +} + +// DeleteInviteEntry 按码删除(大小写不敏感)。 +func (s *Store) DeleteInviteEntry(code string) error { + n := normalizeInviteCode(code) + if n == "" { + return errors.New("code is required") + } + 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() + } + } + return errors.New("invite not found") +} diff --git a/sproutgate-backend/internal/storage/storage.go b/sproutgate-backend/internal/storage/storage.go index 5b10302..4c9925d 100644 --- a/sproutgate-backend/internal/storage/storage.go +++ b/sproutgate-backend/internal/storage/storage.go @@ -32,6 +32,10 @@ type EmailConfig struct { Encryption string `json:"encryption"` } +type CheckInConfig struct { + RewardCoins int `json:"rewardCoins"` +} + type Store struct { dataDir string usersDir string @@ -41,10 +45,14 @@ type Store struct { adminConfigPath string authConfigPath string emailConfigPath string + checkInPath string + registrationPath string + registrationConfig RegistrationConfig adminToken string jwtSecret []byte issuer string emailConfig EmailConfig + checkInConfig CheckInConfig mu sync.Mutex } @@ -85,6 +93,8 @@ func NewStore(dataDir string) (*Store, error) { 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"), } if err := store.loadOrCreateAdminConfig(); err != nil { return nil, err @@ -95,6 +105,12 @@ func NewStore(dataDir string) (*Store, error) { if err := store.loadOrCreateEmailConfig(); err != nil { return nil, err } + if err := store.loadOrCreateCheckInConfig(); err != nil { + return nil, err + } + if err := store.loadOrCreateRegistrationConfig(); err != nil { + return nil, err + } return store, nil } @@ -118,6 +134,29 @@ func (s *Store) EmailConfig() EmailConfig { return s.emailConfig } +func (s *Store) CheckInConfig() CheckInConfig { + s.mu.Lock() + defer s.mu.Unlock() + cfg := s.checkInConfig + if cfg.RewardCoins <= 0 { + cfg.RewardCoins = 1 + } + return cfg +} + +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 { + return err + } + s.checkInConfig = cfg + return nil +} + func (s *Store) loadOrCreateAdminConfig() error { if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) { token, err := generateToken() @@ -244,6 +283,29 @@ func (s *Store) loadOrCreateEmailConfig() error { 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 { + return err + } + if cfg.RewardCoins <= 0 { + cfg.RewardCoins = 1 + if err := writeJSONFile(s.checkInPath, cfg); err != nil { + return err + } + } + s.checkInConfig = cfg + return nil +} + func generateSecret() ([]byte, error) { secret := make([]byte, 32) _, err := rand.Read(secret) @@ -319,6 +381,176 @@ func (s *Store) SaveUser(record models.UserRecord) error { return writeJSONFile(path, record) } +// RecordAuthClient 在成功认证后记录第三方应用标识(clientID 须已规范化)。 +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 + } + now := models.NowISO() + displayName = models.ClampAuthClientName(displayName) + found := false + for i := range record.AuthClients { + if record.AuthClients[i].ClientID == clientID { + record.AuthClients[i].LastSeenAt = now + if displayName != "" { + record.AuthClients[i].DisplayName = displayName + } + found = true + break + } + } + if !found { + record.AuthClients = append(record.AuthClients, models.AuthClientEntry{ + ClientID: clientID, + DisplayName: displayName, + FirstSeenAt: now, + LastSeenAt: now, + }) + } + record.UpdatedAt = now + if err := writeJSONFile(path, &record); err != nil { + return models.UserRecord{}, err + } + return record, nil +} + +func (s *Store) RecordVisit(account string, today string, at 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) { + return models.UserRecord{}, false, os.ErrNotExist + } + + var record models.UserRecord + if err := readJSONFile(path, &record); err != nil { + return models.UserRecord{}, false, err + } + + if record.LastVisitDate == today || models.HasActivityDate(record.VisitTimes, today) { + return record, 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() + } + record.UpdatedAt = models.NowISO() + if err := writeJSONFile(path, record); err != nil { + return models.UserRecord{}, false, err + } + return record, true, nil +} + +const maxLastVisitIPLen = 45 +const maxLastVisitDisplayLocationLen = 512 + +func clampVisitMeta(ip, displayLocation string) (string, string) { + ip = strings.TrimSpace(ip) + displayLocation = strings.TrimSpace(displayLocation) + if len(ip) > maxLastVisitIPLen { + ip = ip[:maxLastVisitIPLen] + } + if len(displayLocation) > maxLastVisitDisplayLocationLen { + displayLocation = displayLocation[:maxLastVisitDisplayLocationLen] + } + 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 == "" { + rec, found, err := s.GetUser(account) + if err != nil { + return models.UserRecord{}, err + } + if !found { + return models.UserRecord{}, os.ErrNotExist + } + return rec, nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + path := s.userFilePath(account) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return models.UserRecord{}, os.ErrNotExist + } + + var record models.UserRecord + if err := readJSONFile(path, &record); err != nil { + return models.UserRecord{}, err + } + if ip != "" { + record.LastVisitIP = ip + } + if displayLocation != "" { + record.LastVisitDisplayLocation = displayLocation + } + record.UpdatedAt = models.NowISO() + if err := writeJSONFile(path, record); err != nil { + return models.UserRecord{}, err + } + return record, nil +} + +func (s *Store) CheckIn(account string, today string, at string) (models.UserRecord, int, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + path := s.userFilePath(account) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + 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 record.LastCheckInDate == today || models.HasActivityDate(record.CheckInTimes, today) { + return record, 0, true, nil + } + + reward := s.checkInConfig.RewardCoins + if reward <= 0 { + reward = 1 + } + record.SproutCoins += reward + record.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() + } + record.UpdatedAt = models.NowISO() + if err := writeJSONFile(path, record); err != nil { + return models.UserRecord{}, 0, false, err + } + return record, reward, false, nil +} + func (s *Store) DeleteUser(account string) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/sproutgate-backend/main.go b/sproutgate-backend/main.go index 3374cac..d582fda 100644 --- a/sproutgate-backend/main.go +++ b/sproutgate-backend/main.go @@ -24,12 +24,34 @@ func main() { router.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Admin-Token"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Admin-Token", "X-Visit-Ip", "X-Visit-Location", "X-Auth-Client", "X-Auth-Client-Name"}, MaxAge: 12 * time.Hour, })) handler := handlers.NewHandler(store) + apiIntro := gin.H{ + "name": "SproutGate API", + "title": "萌芽账户认证中心", + "description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。", + "version": "0.1.0", + "links": gin.H{ + "apiDocs": "GET /api/docs — Markdown 接口说明(本仓库 API_DOCS.md)", + "health": "GET /api/health", + }, + "routePrefixes": []string{ + "/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入", + "/api/public — 公开用户资料、注册策略(是否强制邀请码)", + "/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token)", + }, + } + router.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, apiIntro) + }) + router.GET("/api", func(c *gin.Context) { + c.JSON(http.StatusOK, apiIntro) + }) + router.GET("/api/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", @@ -49,7 +71,10 @@ func main() { router.POST("/api/auth/secondary-email/verify", handler.VerifySecondaryEmail) router.POST("/api/auth/verify", handler.Verify) 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/:account", handler.GetPublicUser) + router.GET("/api/public/registration-policy", handler.GetPublicRegistrationPolicy) admin := router.Group("/api/admin") admin.Use(handler.AdminMiddleware()) @@ -57,6 +82,12 @@ func main() { admin.POST("/users", handler.CreateUser) admin.PUT("/users/:account", handler.UpdateUser) admin.DELETE("/users/:account", handler.DeleteUser) + admin.GET("/check-in/config", handler.GetCheckInConfig) + admin.PUT("/check-in/config", handler.UpdateCheckInConfig) + admin.GET("/registration", handler.GetAdminRegistration) + admin.PUT("/registration", handler.PutAdminRegistrationPolicy) + admin.POST("/registration/invites", handler.PostAdminInvite) + admin.DELETE("/registration/invites/:code", handler.DeleteAdminInvite) port := os.Getenv("PORT") if port == "" { diff --git a/sproutgate-frontend/src/App.jsx b/sproutgate-frontend/src/App.jsx index 0846528..2456c5b 100644 --- a/sproutgate-frontend/src/App.jsx +++ b/sproutgate-frontend/src/App.jsx @@ -1,102 +1,37 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { marked } from "marked"; - -const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8080"; -marked.setOptions({ breaks: true }); - -const icons = { - account: ( - - ), - password: ( - - ), - username: ( - - ), - email: ( - - ), - coins: ( - - ), - phone: ( - - ), - avatar: ( - - ), - bio: ( - - ), - token: ( - - ), - level: ( - - ), - secondaryEmail: ( - - ) -}; - -const emptyForm = { - account: "", - password: "", - username: "", - email: "", - level: 0, - sproutCoins: 0, - secondaryEmails: "", - phone: "", - avatarUrl: "", - bio: "" -}; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { API_BASE, getPublicAccountFromPathname, getAuthFlowFromSearch, LOGO_192_SRC } from "./config"; +import SplashScreen from "./components/SplashScreen"; +import PublicUserPage from "./components/PublicUserPage"; +import UserPortal from "./components/UserPortal"; +import AdminPanel from "./components/AdminPanel"; function App() { - const isAdmin = window.location.pathname.startsWith("/admin"); - const [booting, setBooting] = useState(true); + const pathname = window.location.pathname; + const search = window.location.search; + const isAdmin = pathname.startsWith("/admin"); + const isPublicUser = pathname === "/user" || pathname.startsWith("/user/"); + const authFlow = useMemo(() => getAuthFlowFromSearch(search), [search]); + const publicAccount = useMemo(() => getPublicAccountFromPathname(pathname), [pathname]); - const markReady = () => { - setBooting(false); + const [booting, setBooting] = useState(true); + const markReady = useMemo(() => () => setBooting(false), []); + + const [imagePreview, setImagePreview] = useState({ open: false, src: "", alt: "" }); + + const logoTapCountRef = useRef(0); + const logoTapResetRef = useRef(null); + const [adminGateOpen, setAdminGateOpen] = useState(false); + const [adminGateToken, setAdminGateToken] = useState(""); + const [adminGateError, setAdminGateError] = useState(""); + const [adminGateLoading, setAdminGateLoading] = useState(false); + + const openImagePreview = (src, alt = "") => { + if (!src) return; + setImagePreview({ open: true, src, alt }); + }; + + const closeImagePreview = () => { + setImagePreview({ open: false, src: "", alt: "" }); }; useEffect(() => { @@ -104,1129 +39,220 @@ function App() { return () => clearTimeout(timer); }, []); + useEffect(() => () => { + if (logoTapResetRef.current) clearTimeout(logoTapResetRef.current); + }, []); + + const handleLogoTap = () => { + if (logoTapResetRef.current) clearTimeout(logoTapResetRef.current); + logoTapCountRef.current += 1; + if (logoTapCountRef.current >= 5) { + logoTapCountRef.current = 0; + setAdminGateOpen(true); + setAdminGateError(""); + setAdminGateToken(""); + return; + } + logoTapResetRef.current = setTimeout(() => { + logoTapCountRef.current = 0; + }, 2600); + }; + + const closeAdminGate = () => { + setAdminGateOpen(false); + setAdminGateToken(""); + setAdminGateError(""); + setAdminGateLoading(false); + }; + + const submitAdminGate = async (e) => { + e.preventDefault(); + const t = adminGateToken.trim(); + if (!t) { + setAdminGateError("请输入管理员 Token"); + return; + } + setAdminGateLoading(true); + setAdminGateError(""); + try { + const res = await fetch(`${API_BASE}/api/admin/users`, { headers: { "X-Admin-Token": t } }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Token 无效或无权访问"); + } + localStorage.setItem("sproutgate_admin_token", t); + closeAdminGate(); + const base = import.meta.env.BASE_URL || "/"; + const prefix = base.endsWith("/") ? base : `${base}/`; + window.location.href = new URL("admin", `${window.location.origin}${prefix}`).href; + } catch (err) { + setAdminGateError(err.message || "验证失败"); + } finally { + setAdminGateLoading(false); + } + }; + + useEffect(() => { + const isImageUrl = (url) => { + if (!url) return false; + const clean = String(url).split("#")[0].split("?")[0]; + return /\.(png|jpe?g|gif|webp|svg)$/i.test(clean); + }; + + const onClick = (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + if (target.closest && target.closest(".image-preview-card")) return; + + const img = target.tagName === "IMG" ? target : target.closest?.("img"); + if (img && img.closest?.(".markdown-body")) { + const src = img.getAttribute("src") || ""; + const alt = img.getAttribute("alt") || ""; + if (isImageUrl(src)) { + event.preventDefault(); + event.stopPropagation(); + openImagePreview(src, alt); + } + return; + } + + const a = target.tagName === "A" ? target : target.closest?.("a[href]"); + if (a && a.closest?.(".markdown-body")) { + const href = a.getAttribute("href") || ""; + if (isImageUrl(href)) { + event.preventDefault(); + event.stopPropagation(); + openImagePreview(href, a.getAttribute("title") || a.textContent?.trim() || ""); + } + } + }; + + document.addEventListener("click", onClick, true); + return () => document.removeEventListener("click", onClick, true); + }, []); + + const headerBrand = ( +
+ +
+

萌芽账户认证中心

+
+
+ ); + + const headerNav = ( + + ); + return (
{booting && }
-
-
-

萌芽账户认证中心

-

统一认证 · 账户管理 · 简洁可用

+ {isPublicUser ? ( +
+
+
+
+ {headerBrand} + {headerNav} +
+
+ +
+
- -
- {isAdmin ? : } + ) : isAdmin ? ( +
+
+
+
+ {headerBrand} + {headerNav} +
+
+ +
+
+
+ ) : ( +
+
+
+
+ {headerBrand} + {headerNav} +
+
+ +
+
+
+ )}
-
- ); -} -function SplashScreen() { - return ( -
- - ); -} - -function IconLabel({ icon, text, hint }) { - return ( - - {icon} - {text} - {hint && {hint}} - - ); -} - -function InfoLabel({ icon, text }) { - return ( - - {icon} - {text} - - ); -} - -function TableCell({ icon, children, onClick }) { - return ( - - {icon} - {children} - - ); -} - -const parseEmailList = (value) => - value - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - -function UserPortal({ onReady }) { - const [account, setAccount] = useState(""); - const [password, setPassword] = useState(""); - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [mode, setMode] = useState("login"); - const [registerForm, setRegisterForm] = useState({ - account: "", - password: "", - username: "", - email: "", - code: "" - }); - const [registerSent, setRegisterSent] = useState(false); - const [registerExpiresAt, setRegisterExpiresAt] = useState(""); - const [registerLoading, setRegisterLoading] = useState(false); - const [registerError, setRegisterError] = useState(""); - const [registerMessage, setRegisterMessage] = useState(""); - const [resetForm, setResetForm] = useState({ - account: "", - email: "", - code: "", - newPassword: "" - }); - const [resetSent, setResetSent] = useState(false); - const [resetExpiresAt, setResetExpiresAt] = useState(""); - const [resetLoading, setResetLoading] = useState(false); - const [resetError, setResetError] = useState(""); - const [resetMessage, setResetMessage] = useState(""); - const [secondaryForm, setSecondaryForm] = useState({ - email: "", - code: "" - }); - const [secondarySent, setSecondarySent] = useState(false); - const [secondaryExpiresAt, setSecondaryExpiresAt] = useState(""); - const [secondaryLoading, setSecondaryLoading] = useState(false); - const [secondaryError, setSecondaryError] = useState(""); - const [secondaryMessage, setSecondaryMessage] = useState(""); - const [profileForm, setProfileForm] = useState({ - username: "", - phone: "", - avatarUrl: "", - bio: "", - password: "" - }); - const [profileLoading, setProfileLoading] = useState(false); - const [profileError, setProfileError] = useState(""); - const [profileMessage, setProfileMessage] = useState(""); - - useEffect(() => { - let cancelled = false; - const done = () => { - if (!cancelled && onReady) { - onReady(); - } - }; - const token = localStorage.getItem("sproutgate_token"); - if (token) { - fetch(`${API_BASE}/api/auth/me`, { - headers: { Authorization: `Bearer ${token}` } - }) - .then((res) => res.json()) - .then((data) => { - if (data.user) { - setUser(data.user); - } - }) - .catch(() => {}) - .finally(() => done()); - } else { - done(); - } - return () => { - cancelled = true; - }; - }, [onReady]); - - useEffect(() => { - if (user) { - setProfileForm({ - username: user.username || "", - phone: user.phone || "", - avatarUrl: user.avatarUrl || "", - bio: user.bio || "", - password: "" - }); - } - }, [user]); - - const handleLogin = async (event) => { - event.preventDefault(); - setLoading(true); - setError(""); - try { - const res = await fetch(`${API_BASE}/api/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ account, password }) - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "登录失败"); - } - localStorage.setItem("sproutgate_token", data.token); - setUser(data.user); - setAccount(""); - setPassword(""); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - const handleLogout = () => { - localStorage.removeItem("sproutgate_token"); - setUser(null); - }; - - const handleRegisterChange = (field, value) => { - setRegisterForm((prev) => ({ ...prev, [field]: value })); - }; - - const handleSendCode = async (event) => { - event.preventDefault(); - setRegisterLoading(true); - setRegisterError(""); - setRegisterMessage(""); - if (!registerForm.account || !registerForm.password || !registerForm.email) { - setRegisterError("请填写账户、密码和邮箱"); - setRegisterLoading(false); - return; - } - try { - const res = await fetch(`${API_BASE}/api/auth/register`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - account: registerForm.account, - password: registerForm.password, - username: registerForm.username, - email: registerForm.email - }) - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "发送验证码失败"); - } - setRegisterSent(true); - setRegisterExpiresAt(data.expiresAt || ""); - setRegisterMessage("验证码已发送,请检查邮箱"); - } catch (err) { - setRegisterError(err.message); - } finally { - setRegisterLoading(false); - } - }; - - const handleVerifyRegister = async (event) => { - event.preventDefault(); - setRegisterLoading(true); - setRegisterError(""); - setRegisterMessage(""); - if (!registerForm.account || !registerForm.code) { - setRegisterError("请输入账户与验证码"); - setRegisterLoading(false); - return; - } - try { - const res = await fetch(`${API_BASE}/api/auth/verify-email`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - account: registerForm.account, - code: registerForm.code - }) - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "验证失败"); - } - setRegisterMessage("注册成功,请使用账号登录"); - setRegisterSent(false); - setRegisterForm({ - account: "", - password: "", - username: "", - email: "", - code: "" - }); - setMode("login"); - } catch (err) { - setRegisterError(err.message); - } finally { - setRegisterLoading(false); - } - }; - - const handleResetChange = (field, value) => { - setResetForm((prev) => ({ ...prev, [field]: value })); - }; - - const handleSendReset = async (event) => { - event.preventDefault(); - setResetLoading(true); - setResetError(""); - setResetMessage(""); - if (!resetForm.account || !resetForm.email) { - setResetError("请填写账户与邮箱"); - setResetLoading(false); - return; - } - try { - const res = await fetch(`${API_BASE}/api/auth/forgot-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - account: resetForm.account, - email: resetForm.email - }) - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "发送重置邮件失败"); - } - setResetSent(true); - setResetExpiresAt(data.expiresAt || ""); - setResetMessage("重置验证码已发送,请检查邮箱"); - } catch (err) { - setResetError(err.message); - } finally { - setResetLoading(false); - } - }; - - const handleResetPassword = async (event) => { - event.preventDefault(); - setResetLoading(true); - setResetError(""); - setResetMessage(""); - if (!resetForm.account || !resetForm.code || !resetForm.newPassword) { - setResetError("请填写账户、验证码与新密码"); - setResetLoading(false); - return; - } - try { - const res = await fetch(`${API_BASE}/api/auth/reset-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - account: resetForm.account, - code: resetForm.code, - newPassword: resetForm.newPassword - }) - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "重置失败"); - } - setResetMessage("密码已重置,请使用新密码登录"); - setResetSent(false); - setResetForm({ - account: "", - email: "", - code: "", - newPassword: "" - }); - setMode("login"); - } catch (err) { - setResetError(err.message); - } finally { - setResetLoading(false); - } - }; - - const handleSecondaryChange = (field, value) => { - setSecondaryForm((prev) => ({ ...prev, [field]: value })); - }; - - const handleSendSecondary = async (event) => { - event.preventDefault(); - setSecondaryLoading(true); - setSecondaryError(""); - setSecondaryMessage(""); - const token = localStorage.getItem("sproutgate_token"); - if (!token) { - setSecondaryError("请先登录"); - setSecondaryLoading(false); - return; - } - if (!secondaryForm.email) { - setSecondaryError("请输入辅助邮箱"); - setSecondaryLoading(false); - return; - } - try { - const res = await fetch(`${API_BASE}/api/auth/secondary-email/request`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({ email: secondaryForm.email }) - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "发送验证码失败"); - } - setSecondarySent(true); - setSecondaryExpiresAt(data.expiresAt || ""); - setSecondaryMessage("验证码已发送,请检查邮箱"); - } catch (err) { - setSecondaryError(err.message); - } finally { - setSecondaryLoading(false); - } - }; - - const handleVerifySecondary = async (event) => { - event.preventDefault(); - setSecondaryLoading(true); - setSecondaryError(""); - setSecondaryMessage(""); - const token = localStorage.getItem("sproutgate_token"); - if (!token) { - setSecondaryError("请先登录"); - setSecondaryLoading(false); - return; - } - if (!secondaryForm.email || !secondaryForm.code) { - setSecondaryError("请输入邮箱与验证码"); - setSecondaryLoading(false); - return; - } - try { - const res = await fetch(`${API_BASE}/api/auth/secondary-email/verify`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({ - email: secondaryForm.email, - code: secondaryForm.code - }) - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "验证失败"); - } - if (data.user) { - setUser(data.user); - } - setSecondaryMessage("辅助邮箱验证成功"); - setSecondarySent(false); - setSecondaryForm({ email: "", code: "" }); - } catch (err) { - setSecondaryError(err.message); - } finally { - setSecondaryLoading(false); - } - }; - - const handleProfileChange = (field, value) => { - setProfileForm((prev) => ({ ...prev, [field]: value })); - }; - - const handleProfileSave = async (event) => { - event.preventDefault(); - setProfileLoading(true); - setProfileError(""); - setProfileMessage(""); - const token = localStorage.getItem("sproutgate_token"); - if (!token) { - setProfileError("请先登录"); - setProfileLoading(false); - return; - } - const payload = { - username: profileForm.username, - phone: profileForm.phone, - avatarUrl: profileForm.avatarUrl, - bio: profileForm.bio - }; - if (profileForm.password) { - payload.password = profileForm.password; - } - try { - const res = await fetch(`${API_BASE}/api/auth/profile`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}` - }, - body: JSON.stringify(payload) - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "保存失败"); - } - setUser(data.user); - setProfileMessage("保存成功"); - setProfileForm((prev) => ({ ...prev, password: "" })); - } catch (err) { - setProfileError(err.message); - } finally { - setProfileLoading(false); - } - }; - - return ( -
-
用户中心
- {!user && mode === "login" && ( -
-

登录

- - - {error &&
{error}
} - - - -
- )} - - {!user && mode === "register" && ( -
-

注册账号

- - - - - {registerSent && ( - - )} - {registerExpiresAt &&
验证码有效期至:{registerExpiresAt}
} - {registerError &&
{registerError}
} - {registerMessage &&
{registerMessage}
} -
- - -
-
- )} - - {!user && mode === "reset" && ( -
-

重置密码

- - - {resetSent && ( - <> - - - - )} - {resetExpiresAt &&
验证码有效期至:{resetExpiresAt}
} - {resetError &&
{resetError}
} - {resetMessage &&
{resetMessage}
} -
- - -
-
- )} - - {user && ( - <> -
-
- avatar -
-

{user.username || user.account}

-

{user.email || "未填写邮箱"}

- +
+
+ + {adminGateError &&
{adminGateError}
} +
+
+ +
-
-
-
- - {user.account} -
-
- - 已设置(不展示) -
-
- - {user.username || "未填写"} -
-
- - {user.email || "未填写"} -
-
- - {user.level ?? 0} 级 -
-
- - {user.sproutCoins} -
-
- - {user.phone || "未填写"} -
-
- - {user.avatarUrl || "未填写"} -
-
-
-

个人简介

-
-
+
- -
-

辅助邮箱

-
- {(user.secondaryEmails || []).length === 0 && 暂无辅助邮箱} - {(user.secondaryEmails || []).map((email) => ( - - {email} - - ))} -
- - {secondarySent && ( - - )} - {secondaryExpiresAt &&
验证码有效期至:{secondaryExpiresAt}
} - {secondaryError &&
{secondaryError}
} - {secondaryMessage &&
{secondaryMessage}
} -
- -
-
- -
-

修改资料

- - - - -