完善初始化更新

This commit is contained in:
2026-03-20 20:42:33 +08:00
parent 568ccb08fa
commit e6866feb29
39 changed files with 6986 additions and 2379 deletions

13
.cursor/mcp.json Normal file
View File

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

View File

@@ -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`stringMarkdown 格式):调用时传入本轮回复的摘要/结论,插件界面会弹出模态框展示给用户。格式: `{ "reply": "## 结论\n修改完成..." }`
12. 当会话结束前,建议在调用 `check_messages` 时附带 `reply` 参数,将关键结论推送到插件界面;如果本轮无需特别总结,`reply` 可省略
13. 禁止调用subagent并发任务
14. 如果 `check_messages` 或 `ask_question` 因客户端取消、连接抖动或内部异常而意外返回/报错,且用户未明确要求结束对话,严禁把这类内部提示直接输出给用户,必须立刻再次调用对应工具继续等待
15. 执行多步任务时,每完成一个关键步骤后,必须调用 `send_progress` 推送进度摘要到远程控制台。格式: `{ "progress": "## 步骤1完成\n已修改xxx文件..." }`。`send_progress` 立即返回,不会等待消息,调用后继续执行下一步
16. `send_progress` 的 `progress` 参数应包含:已完成的操作、当前方案、下一步计划。使用简洁的 Markdown 格式
---
**回合结束(强制)**: 仅当回复**完整输出完毕后**,最后一步必须调用 CursorMCP对话插件 MCP 的 `check_messages` 工具(使用 CallMcpToolserver 为 CursorMCP对话插件 对应的 identifiertoolName 为 "check_messages"arguments 为 {})。严禁在回合开始或回复完成前调用。`ask_question` 仅用于任务中需要用户做选择时(如选框架、选方案)。

View File

@@ -1,8 +1,8 @@
# Repository Guidelines # Repository Guidelines
## Project Structure & Module Organization ## 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-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/`, domain models in `internal/models/`, storage in `internal/storage/`. - `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/`. - `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. - 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. - PRs should include a concise summary, testing notes, and screenshots for UI changes; call out any config or env var updates.
## Security & Configuration Tips ## 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`. - 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. - 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`.

116
README.md
View File

@@ -1,40 +1,120 @@
# 萌芽账户认证中心(SproutGate # SproutGate(萌芽账户认证中心
前后端分离的统一账户认证中心: 前后端分离的统一账户与轻量用户中心:注册登录、邮箱验证、找回密码、副邮箱、签到与资料管理;管理员可维护用户与签到配置。数据以 JSON 文件落盘,适合自建与小规模部署。
- 前端React`sproutgate-frontend`
- 后端Golang + Gin`sproutgate-backend`
- 数据:`data/` 与子目录 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 ```bash
cd sproutgate-backend cd sproutgate-backend
go mod tidy go mod tidy
go run . go run .
``` ```
默认端口 `8080`,默认管理员 Token`shumengya520`(位于 `sproutgate-backend/data/config/admin.json`)。 **前端**
邮件发送配置位于 `sproutgate-backend/data/config/email.json`
### 前端
```bash ```bash
cd sproutgate-frontend cd sproutgate-frontend
npm install npm install
npm run dev npm run dev
``` ```
如需自定义后端地址,新增 `sproutgate-frontend/.env` ### 前端连接后端
```
`sproutgate-frontend/.env`(自行创建)中设置:
```env
VITE_API_BASE=http://localhost:8080 VITE_API_BASE=http://localhost:8080
``` ```
### 管理员地址 生产环境改为实际 API 地址即可。
```
http://localhost:5173/admin?token=shumengya520 ### 配置与安全
- 管理员 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>`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` 文件,使用前请与维护者确认授权方式。

View File

@@ -1,6 +1,59 @@
# 萌芽账户认证中心 API 文档 # 萌芽账户认证中心 API 文档
基础地址:`http://<host>:8080` 访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`/api/docs``/api/health` 入口、路由前缀摘要)。
接入地址:
- 统一登录前端:`https://auth.shumengya.top`
- 后端 API`https://auth.api.shumengya.top`
- 本地开发 API`http://<host>:8080`
对外接入建议:
1. 第三方应用按钮跳转到统一登录前端。
2. 登录成功后回跳到业务站点。
3. 业务站点使用回跳带回的 `token` 调用后端 API。
示例按钮:
```html
<a href="https://auth.shumengya.top/?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123">
使用萌芽统一账户认证登录
</a>
```
回跳说明:
- 用户已登录时,统一登录前端会提示“继续授权”或“切换账号”。
- 登录成功后会回跳到 `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 <token>`。 |
| `expiresAt` | 过期时间RFC3339与签发侧一致当前默认为登录时起算 **7 天**)。 |
| `account` | 账户名(与 JWT `sub` 一致)。 |
| `username` | 展示用昵称,可能为空。 |
| `state` | 若登录请求携带了 `state`,则原样返回。 |
业务站点回调页应用脚本读取 `location.hash`,解析后**仅在 HTTPS 环境**将 `token` 存于内存或安全存储,并尽快用后端 **`POST /api/auth/verify`** 校验(勿仅信任哈希中的明文字段)。
### 第三方后端接入建议
1. **仅信服务端**:回调页将 `token` 交给自有后端,由后端请求 `POST https://<api-host>/api/auth/verify`JSON body`{"token":"..."}`),根据 `valid``user.account` 建立会话。
2. **CORS**:浏览器直连 API 时须后端已配置 CORS本服务默认允许任意 `Origin`);若从服务端发起请求则不受 CORS 限制。
3. **令牌过期**`verify` / `me` 返回 401 或 `verify``valid:false` 时,应引导用户重新走统一登录。
## 认证与统一登录 ## 认证与统一登录
@@ -11,10 +64,14 @@
```json ```json
{ {
"account": "demo", "account": "demo",
"password": "demo123" "password": "demo123",
"clientId": "my-app",
"clientName": "我的应用"
} }
``` ```
`clientId` / `clientName` 可选;规则与请求头 `X-Auth-Client` / `X-Auth-Client-Name` 一致。传入且格式合法时,会在登录成功后写入该用户的 **应用接入记录**(见下文 `authClients`)。
响应: 响应:
```json ```json
{ {
@@ -29,6 +86,7 @@
"secondaryEmails": ["demo2@example.com"], "secondaryEmails": ["demo2@example.com"],
"phone": "13800000000", "phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png", "avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 简介", "bio": "### 简介",
"createdAt": "2026-03-14T12:00:00Z", "createdAt": "2026-03-14T12:00:00Z",
"updatedAt": "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` `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` `GET /api/auth/me`
请求头: 请求头:
`Authorization: Bearer <jwt-token>` `Authorization: Bearer <jwt-token>`
可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」
- `X-Visit-Ip`:客户端公网 IP与地理接口返回的 `ip` 一致即可)
- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName``regionName``cityName` 拼接为 `中国 四川 成都`
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求 `GEO_LOOKUP_URL`(默认 `https://cf-ip-geo.smyhub.com/api?ip=<ip>`)解析展示位置并写入用户记录。
响应:
```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 <jwt-token>`
响应: 响应:
```json ```json
{ {
"checkedIn": true,
"alreadyCheckedIn": false,
"rewardCoins": 1,
"awardedCoins": 1,
"message": "签到成功",
"user": { "account": "demo", "...": "..." } "user": { "account": "demo", "...": "..." }
} }
``` ```
> 说明:密码不会返回。
### 更新当前用户资料 ### 更新当前用户资料
`PUT /api/auth/profile` `PUT /api/auth/profile`
@@ -82,10 +221,13 @@
"username": "新昵称", "username": "新昵称",
"phone": "13800000000", "phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png", "avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 新简介" "bio": "### 新简介"
} }
``` ```
说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`
响应: 响应:
```json ```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` `POST /api/auth/register`
@@ -102,10 +286,13 @@
"account": "demo", "account": "demo",
"password": "demo123", "password": "demo123",
"username": "示例用户", "username": "示例用户",
"email": "demo@example.com" "email": "demo@example.com",
"inviteCode": "ABCD1234"
} }
``` ```
- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。
响应: 响应:
```json ```json
{ {
@@ -218,8 +405,50 @@
请求时可使用以下任一方式携带: 请求时可使用以下任一方式携带:
- Query`?token=<admin-token>` - Query`?token=<admin-token>`
- Header`X-Admin-Token: <admin-token>` - Header`X-Admin-Token: <admin-token>`
### 签到奖励设置
`GET /api/admin/check-in/config`
`PUT /api/admin/check-in/config`
请求:
```json
{
"rewardCoins": 1
}
```
- Header`Authorization: Bearer <admin-token>` - Header`Authorization: Bearer <admin-token>`
### 注册策略与邀请码
`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` `GET /api/admin/users`
@@ -246,6 +475,7 @@
"secondaryEmails": ["demo2@example.com"], "secondaryEmails": ["demo2@example.com"],
"phone": "13800000000", "phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png", "avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 简介" "bio": "### 简介"
} }
``` ```
@@ -260,10 +490,18 @@
"username": "新昵称", "username": "新昵称",
"level": 1, "level": 1,
"secondaryEmails": ["demo2@example.com"], "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}` `DELETE /api/admin/users/{account}`
@@ -281,16 +519,25 @@
- 管理员 Token`data/config/admin.json` - 管理员 Token`data/config/admin.json`
- JWT 配置:`data/config/auth.json` - JWT 配置:`data/config/auth.json`
- 邮件配置:`data/config/email.json` - 邮件配置:`data/config/email.json`
- 注册策略与邀请码:`data/config/registration.json`
## 快速联调用示例 ## 快速联调用示例
```bash ```bash
# 服务根路径 JSON 说明
curl -s http://localhost:8080/ | jq .
# 登录 # 登录
curl -X POST http://localhost:8080/api/auth/login \ curl -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"account":"demo","password":"demo123"}' -d '{"account":"demo","password":"demo123"}'
# 使用令牌获取用户信息 # 校验令牌(推荐第三方网关先调此接口)
curl -X POST http://localhost:8080/api/auth/verify \
-H 'Content-Type: application/json' \
-d '{"token":"<jwt-token>"}'
# 使用令牌获取用户信息(会更新访问记录)
curl http://localhost:8080/api/auth/me \ curl http://localhost:8080/api/auth/me \
-H 'Authorization: Bearer <jwt-token>' -H 'Authorization: Bearer <jwt-token>'
``` ```

View File

@@ -3,7 +3,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: sproutgate-auth container_name: sproutgate-backend
restart: unless-stopped restart: unless-stopped
environment: environment:
PORT: "8080" PORT: "8080"
@@ -11,4 +11,4 @@ services:
volumes: volumes:
- ./data:/data - ./data:/data
ports: ports:
- "${AUTH_API_PORT:-18080}:8080" - "${AUTH_API_PORT:-28080}:8080"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,4 +8,5 @@ type PendingUser struct {
CodeHash string `json:"codeHash"` CodeHash string `json:"codeHash"`
ExpiresAt string `json:"expiresAt"` ExpiresAt string `json:"expiresAt"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
InviteCode string `json:"inviteCode,omitempty"`
} }

View File

@@ -1,6 +1,9 @@
package models package models
import "time" import (
"strings"
"time"
)
type UserRecord struct { type UserRecord struct {
Account string `json:"account"` Account string `json:"account"`
@@ -9,12 +12,25 @@ type UserRecord struct {
Email string `json:"email"` Email string `json:"email"`
Level int `json:"level"` Level int `json:"level"`
SproutCoins int `json:"sproutCoins"` 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"` SecondaryEmails []string `json:"secondaryEmails,omitempty"`
Phone string `json:"phone,omitempty"` Phone string `json:"phone,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"` AvatarURL string `json:"avatarUrl,omitempty"`
WebsiteURL string `json:"websiteUrl,omitempty"`
Bio string `json:"bio,omitempty"` Bio string `json:"bio,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` 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 { type UserPublic struct {
@@ -23,30 +39,106 @@ type UserPublic struct {
Email string `json:"email"` Email string `json:"email"`
Level int `json:"level"` Level int `json:"level"`
SproutCoins int `json:"sproutCoins"` 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"` SecondaryEmails []string `json:"secondaryEmails,omitempty"`
Phone string `json:"phone,omitempty"` Phone string `json:"phone,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"` AvatarURL string `json:"avatarUrl,omitempty"`
WebsiteURL string `json:"websiteUrl,omitempty"`
Bio string `json:"bio,omitempty"` Bio string `json:"bio,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` 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 { 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{ return UserPublic{
Account: u.Account, Account: u.Account,
Username: u.Username, Username: u.Username,
Email: u.Email, Email: u.Email,
Level: u.Level, Level: u.Level,
SproutCoins: u.SproutCoins, SproutCoins: u.SproutCoins,
LastCheckInDate: u.LastCheckInDate,
LastCheckInAt: lastCheckInAt,
LastVisitDate: u.LastVisitDate,
CheckInDays: checkInDays,
CheckInStreak: checkInStreak,
LastVisitAt: lastVisitAt,
VisitDays: visitDays,
VisitStreak: visitStreak,
SecondaryEmails: u.SecondaryEmails, SecondaryEmails: u.SecondaryEmails,
Phone: u.Phone, Phone: u.Phone,
AvatarURL: u.AvatarURL, AvatarURL: u.AvatarURL,
WebsiteURL: u.WebsiteURL,
Bio: u.Bio, Bio: u.Bio,
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt, 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 { func NowISO() string {
return time.Now().Format(time.RFC3339) return time.Now().Format(time.RFC3339)
} }

View File

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

View File

@@ -32,6 +32,10 @@ type EmailConfig struct {
Encryption string `json:"encryption"` Encryption string `json:"encryption"`
} }
type CheckInConfig struct {
RewardCoins int `json:"rewardCoins"`
}
type Store struct { type Store struct {
dataDir string dataDir string
usersDir string usersDir string
@@ -41,10 +45,14 @@ type Store struct {
adminConfigPath string adminConfigPath string
authConfigPath string authConfigPath string
emailConfigPath string emailConfigPath string
checkInPath string
registrationPath string
registrationConfig RegistrationConfig
adminToken string adminToken string
jwtSecret []byte jwtSecret []byte
issuer string issuer string
emailConfig EmailConfig emailConfig EmailConfig
checkInConfig CheckInConfig
mu sync.Mutex mu sync.Mutex
} }
@@ -85,6 +93,8 @@ func NewStore(dataDir string) (*Store, error) {
adminConfigPath: filepath.Join(configDir, "admin.json"), adminConfigPath: filepath.Join(configDir, "admin.json"),
authConfigPath: filepath.Join(configDir, "auth.json"), authConfigPath: filepath.Join(configDir, "auth.json"),
emailConfigPath: filepath.Join(configDir, "email.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 { if err := store.loadOrCreateAdminConfig(); err != nil {
return nil, err return nil, err
@@ -95,6 +105,12 @@ func NewStore(dataDir string) (*Store, error) {
if err := store.loadOrCreateEmailConfig(); err != nil { if err := store.loadOrCreateEmailConfig(); err != nil {
return nil, err return nil, err
} }
if err := store.loadOrCreateCheckInConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateRegistrationConfig(); err != nil {
return nil, err
}
return store, nil return store, nil
} }
@@ -118,6 +134,29 @@ func (s *Store) EmailConfig() EmailConfig {
return s.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 { func (s *Store) loadOrCreateAdminConfig() error {
if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) {
token, err := generateToken() token, err := generateToken()
@@ -244,6 +283,29 @@ func (s *Store) loadOrCreateEmailConfig() error {
return nil 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) { func generateSecret() ([]byte, error) {
secret := make([]byte, 32) secret := make([]byte, 32)
_, err := rand.Read(secret) _, err := rand.Read(secret)
@@ -319,6 +381,176 @@ func (s *Store) SaveUser(record models.UserRecord) error {
return writeJSONFile(path, record) 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 { func (s *Store) DeleteUser(account string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()

View File

@@ -24,12 +24,34 @@ func main() {
router.Use(cors.New(cors.Config{ router.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, 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, MaxAge: 12 * time.Hour,
})) }))
handler := handlers.NewHandler(store) 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) { router.GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": "ok", "status": "ok",
@@ -49,7 +71,10 @@ func main() {
router.POST("/api/auth/secondary-email/verify", handler.VerifySecondaryEmail) router.POST("/api/auth/secondary-email/verify", handler.VerifySecondaryEmail)
router.POST("/api/auth/verify", handler.Verify) router.POST("/api/auth/verify", handler.Verify)
router.GET("/api/auth/me", handler.Me) router.GET("/api/auth/me", handler.Me)
router.POST("/api/auth/check-in", handler.CheckIn)
router.PUT("/api/auth/profile", handler.UpdateProfile) 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 := router.Group("/api/admin")
admin.Use(handler.AdminMiddleware()) admin.Use(handler.AdminMiddleware())
@@ -57,6 +82,12 @@ func main() {
admin.POST("/users", handler.CreateUser) admin.POST("/users", handler.CreateUser)
admin.PUT("/users/:account", handler.UpdateUser) admin.PUT("/users/:account", handler.UpdateUser)
admin.DELETE("/users/:account", handler.DeleteUser) 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") port := os.Getenv("PORT")
if port == "" { if port == "" {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,650 @@
import React, { useEffect, useMemo, useState } from "react";
import { API_BASE, emptyForm, formatIsoDateTimeReadable, parseEmailList } from "../config";
import icons from "../icons";
import { IconLabel, MailtoEmail, TableCell } from "./common";
export default function AdminPanel({ onReady }) {
const queryToken = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get("token") || "";
}, []);
const [token, setToken] = useState(queryToken || localStorage.getItem("sproutgate_admin_token") || "");
const [users, setUsers] = useState([]);
const [form, setForm] = useState(emptyForm);
const [selectedAccount, setSelectedAccount] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState("create");
const [loading, setLoading] = useState(false);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [error, setError] = useState("");
const [configError, setConfigError] = useState("");
const [message, setMessage] = useState("");
const [configMessage, setConfigMessage] = useState("");
const [checkInConfig, setCheckInConfig] = useState({ rewardCoins: 1 });
const [readySent, setReadySent] = useState(false);
const [regLoading, setRegLoading] = useState(false);
const [regSaving, setRegSaving] = useState(false);
const [regInvCreating, setRegInvCreating] = useState(false);
const [regError, setRegError] = useState("");
const [regMessage, setRegMessage] = useState("");
const [requireInviteReg, setRequireInviteReg] = useState(false);
const [inviteList, setInviteList] = useState([]);
const [newInvNote, setNewInvNote] = useState("");
const [newInvMax, setNewInvMax] = useState(0);
const [newInvExp, setNewInvExp] = useState("");
useEffect(() => {
if (token) {
localStorage.setItem("sproutgate_admin_token", token);
loadUsers();
loadCheckInConfig();
loadRegistration();
} else if (onReady && !readySent) {
onReady();
setReadySent(true);
}
}, [token, onReady, readySent]);
const loadUsers = async () => {
if (!token) return;
setLoading(true);
setError("");
try {
const res = await fetch(`${API_BASE}/api/admin/users`, {
headers: { "X-Admin-Token": token }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载用户失败");
setUsers(data.users || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
if (onReady && !readySent) {
onReady();
setReadySent(true);
}
}
};
const loadCheckInConfig = async () => {
if (!token) return;
setConfigLoading(true);
setConfigError("");
try {
const res = await fetch(`${API_BASE}/api/admin/check-in/config`, {
headers: { "X-Admin-Token": token }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载签到配置失败");
setCheckInConfig({ rewardCoins: Number(data.rewardCoins) || 1 });
} catch (err) {
setConfigError(err.message);
} finally {
setConfigLoading(false);
}
};
const loadRegistration = async () => {
if (!token) return;
setRegLoading(true);
setRegError("");
try {
const res = await fetch(`${API_BASE}/api/admin/registration`, {
headers: { "X-Admin-Token": token }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载注册策略失败");
setRequireInviteReg(Boolean(data.requireInviteCode));
setInviteList(data.invites || []);
} catch (err) {
setRegError(err.message);
} finally {
setRegLoading(false);
}
};
const saveRegPolicy = async () => {
if (!token) return;
setRegMessage("");
setRegError("");
setRegSaving(true);
try {
const res = await fetch(`${API_BASE}/api/admin/registration`, {
method: "PUT",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify({ requireInviteCode: requireInviteReg })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "保存失败");
setRequireInviteReg(Boolean(data.requireInviteCode));
setRegMessage("注册策略已保存");
} catch (err) {
setRegError(err.message);
} finally {
setRegSaving(false);
}
};
const handleCreateInvite = async () => {
if (!token) return;
setRegMessage("");
setRegError("");
let expiresAt = "";
if (newInvExp.trim()) {
const d = new Date(newInvExp);
if (Number.isNaN(d.getTime())) {
setRegError("过期时间无效");
return;
}
expiresAt = d.toISOString();
}
setRegInvCreating(true);
try {
const res = await fetch(`${API_BASE}/api/admin/registration/invites`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify({
note: newInvNote.trim(),
maxUses: Number(newInvMax) || 0,
expiresAt
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "生成失败");
const inv = data.invite;
setInviteList((prev) => [...prev, inv]);
setRegMessage(`已生成邀请码:${inv.code}(请复制保存)`);
setNewInvNote("");
setNewInvMax(0);
setNewInvExp("");
} catch (err) {
setRegError(err.message);
} finally {
setRegInvCreating(false);
}
};
const handleDeleteInvite = async (code) => {
if (!token || !code) return;
if (!window.confirm(`确认删除邀请码 ${code} 吗?`)) return;
setRegMessage("");
setRegError("");
try {
const res = await fetch(
`${API_BASE}/api/admin/registration/invites/${encodeURIComponent(code)}`,
{ method: "DELETE", headers: { "X-Admin-Token": token } }
);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "删除失败");
setInviteList((prev) => prev.filter((x) => x.code !== code));
setRegMessage("已删除邀请码");
} catch (err) {
setRegError(err.message);
}
};
const selectUser = (user) => {
setSelectedAccount(user.account);
setForm({
account: user.account,
password: "",
username: user.username || "",
email: user.email || "",
level: user.level ?? 0,
sproutCoins: user.sproutCoins || 0,
secondaryEmails: (user.secondaryEmails || []).join(","),
phone: user.phone || "",
avatarUrl: user.avatarUrl || "",
websiteUrl: user.websiteUrl || "",
bio: user.bio || "",
banned: Boolean(user.banned),
banReason: user.banReason || ""
});
setMessage("");
setError("");
setEditorMode("edit");
setEditorOpen(true);
};
const clearSelection = () => {
setSelectedAccount("");
setForm(emptyForm);
};
const openCreateUser = () => {
clearSelection();
setEditorMode("create");
setMessage("");
setError("");
setEditorOpen(true);
};
const closeEditor = () => {
setEditorOpen(false);
clearSelection();
setError("");
};
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleCheckInConfigChange = (value) => {
setCheckInConfig({ rewardCoins: value });
};
const handleSaveCheckInConfig = async () => {
setConfigMessage("");
setConfigError("");
const rewardCoins = Number(checkInConfig.rewardCoins) || 0;
if (rewardCoins <= 0) { setConfigError("奖励萌芽币必须大于 0"); return; }
try {
setConfigSaving(true);
const res = await fetch(`${API_BASE}/api/admin/check-in/config`, {
method: "PUT",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify({ rewardCoins })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "保存签到配置失败");
setCheckInConfig({ rewardCoins: Number(data.rewardCoins) || rewardCoins });
setConfigMessage("签到配置已保存");
} catch (err) {
setConfigError(err.message);
} finally {
setConfigSaving(false);
}
};
const handleCreate = async () => {
setMessage("");
setError("");
if (!form.account || !form.password) { setError("新建用户需要账户和密码"); return; }
try {
const res = await fetch(`${API_BASE}/api/admin/users`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify({
account: form.account,
password: form.password,
username: form.username,
email: form.email,
level: Number(form.level) || 0,
sproutCoins: Number(form.sproutCoins) || 0,
secondaryEmails: parseEmailList(form.secondaryEmails),
phone: form.phone,
avatarUrl: form.avatarUrl,
websiteUrl: form.websiteUrl,
bio: form.bio
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "创建失败");
setMessage("创建成功");
closeEditor();
loadUsers();
} catch (err) {
setError(err.message);
}
};
const handleUpdate = async () => {
if (!selectedAccount) { setError("请选择需要更新的账户"); return; }
setMessage("");
setError("");
const payload = {
username: form.username,
email: form.email,
level: Number(form.level) || 0,
sproutCoins: Number(form.sproutCoins) || 0,
secondaryEmails: parseEmailList(form.secondaryEmails),
phone: form.phone,
avatarUrl: form.avatarUrl,
websiteUrl: form.websiteUrl,
bio: form.bio,
banned: Boolean(form.banned),
banReason: (form.banReason || "").trim()
};
if (form.password) payload.password = form.password;
try {
const res = await fetch(`${API_BASE}/api/admin/users/${encodeURIComponent(selectedAccount)}`, {
method: "PUT",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "更新失败");
setMessage("更新成功");
setForm((prev) => ({ ...prev, password: "" }));
closeEditor();
loadUsers();
} catch (err) {
setError(err.message);
}
};
const handleDelete = async (account) => {
if (!account) return;
if (!window.confirm(`确认删除账户 ${account} 吗?`)) return;
setMessage("");
setError("");
try {
const res = await fetch(`${API_BASE}/api/admin/users/${encodeURIComponent(account)}`, {
method: "DELETE",
headers: { "X-Admin-Token": token }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "删除失败");
setMessage("删除成功");
if (account === selectedAccount) clearSelection();
loadUsers();
} catch (err) {
setError(err.message);
}
};
const editingUser = users.find((u) => u.account === selectedAccount);
const editingAuthClients = editingUser?.authClients || [];
return (
<section className="panel">
<div className="admin-console form">
<div className="panel-title">管理员控制台</div>
<div className="admin-section">
<h2 className="admin-section-heading">管理员 Token</h2>
<label>
<IconLabel icon={icons.token} text="Token" />
<input value={token} onChange={(e) => setToken(e.target.value.trim())} placeholder="请输入管理员 Token" />
</label>
<button className="ghost" onClick={loadUsers} disabled={!token || loading}>
{loading ? "加载中..." : "刷新用户列表"}
</button>
</div>
<div className="admin-section">
<h2 className="admin-section-heading">签到设置</h2>
<label>
<IconLabel icon={icons.coins} text="签到奖励(萌芽币)" />
<input
type="number"
min="1"
value={checkInConfig.rewardCoins}
onChange={(e) => handleCheckInConfigChange(e.target.value)}
disabled={!token || configLoading}
/>
</label>
<div className="hint">用户每天首次签到会获得这里设置的奖励</div>
{configError && <div className="error">{configError}</div>}
{configMessage && <div className="success">{configMessage}</div>}
<div className="actions">
<button className="primary" onClick={handleSaveCheckInConfig} disabled={!token || configSaving}>
{configSaving ? "保存中..." : "保存设置"}
</button>
</div>
</div>
<div className="admin-section">
<h2 className="admin-section-heading">注册与邀请码</h2>
<label className="admin-ban-row">
<IconLabel icon={icons.token} text="强制邀请码" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={requireInviteReg}
onChange={(e) => setRequireInviteReg(e.target.checked)}
disabled={!token || regLoading}
/>
<span>开启后用户自助注册必须填写有效邀请码管理员创建用户不受影响</span>
</span>
</label>
<div className="actions compact">
<button type="button" className="primary" onClick={saveRegPolicy} disabled={!token || regSaving || regLoading}>
{regSaving ? "保存中…" : "保存注册策略"}
</button>
<button type="button" className="ghost" onClick={loadRegistration} disabled={!token || regLoading}>
{regLoading ? "加载中…" : "刷新列表"}
</button>
</div>
<div className="hint">公开接口 <code className="inline-code">GET /api/public/registration-policy</code> 供前端判断是否显示邀请码输入框</div>
{regError && <div className="error">{regError}</div>}
{regMessage && <div className="success">{regMessage}</div>}
<h3 className="admin-subheading">生成新邀请码</h3>
<label>
<IconLabel icon={icons.username} text="备注(可选)" />
<input value={newInvNote} onChange={(e) => setNewInvNote(e.target.value)} placeholder="例如:内测批次 A" disabled={!token || regInvCreating} />
</label>
<label>
<IconLabel icon={icons.level} text="最大使用次数" hint="0 表示不限)" />
<input
type="number"
min="0"
value={newInvMax}
onChange={(e) => setNewInvMax(e.target.value)}
disabled={!token || regInvCreating}
/>
</label>
<label>
<IconLabel icon={icons.calendar} text="过期时间(可选)" />
<input
type="datetime-local"
value={newInvExp}
onChange={(e) => setNewInvExp(e.target.value)}
disabled={!token || regInvCreating}
/>
</label>
<div className="actions">
<button type="button" className="primary" onClick={handleCreateInvite} disabled={!token || regInvCreating}>
{regInvCreating ? "生成中…" : "生成邀请码"}
</button>
</div>
<h3 className="admin-subheading">已有邀请码</h3>
{inviteList.length === 0 ? (
<div className="hint">暂无邀请码</div>
) : (
<div className="admin-invite-list">
{inviteList.map((inv) => (
<div key={inv.code} className="admin-invite-row">
<div>
<span className="mono admin-invite-code">{inv.code}</span>
{inv.note ? <span className="muted"> · {inv.note}</span> : null}
<div className="hint admin-invite-meta">
已用 {inv.uses ?? 0}
{inv.maxUses > 0 ? ` / 上限 ${inv.maxUses}` : " / 不限次数"}
{inv.expiresAt ? ` · 过期 ${formatIsoDateTimeReadable(inv.expiresAt)}` : ""}
</div>
</div>
<button type="button" className="danger ghost" onClick={() => handleDeleteInvite(inv.code)}>
删除
</button>
</div>
))}
</div>
)}
</div>
<div className="admin-section">
<div className="list-header">
<h2 className="admin-section-heading admin-section-heading-inline">用户列表</h2>
<div className="actions compact">
<button className="primary" onClick={openCreateUser}>添加用户</button>
</div>
</div>
{message && <div className="success">{message}</div>}
{users.length === 0 && <div className="hint">暂无用户</div>}
<div className="table admin-table">
<div className="table-row header">
<TableCell icon={icons.account}>账户</TableCell>
<TableCell icon={icons.username}>用户名</TableCell>
<TableCell icon={icons.email}>邮箱</TableCell>
<TableCell icon={icons.level}>等级</TableCell>
<TableCell icon={icons.coins}>萌芽币</TableCell>
<TableCell icon={icons.ban}>状态</TableCell>
<TableCell icon={icons.visitIp}>最近 IP</TableCell>
<TableCell icon={icons.visitGeo}>最近位置</TableCell>
<span>操作</span>
</div>
{users.map((u) => (
<div className="table-row" key={u.account}>
<TableCell icon={icons.account} onClick={() => selectUser(u)}>{u.account}</TableCell>
<TableCell icon={icons.username}>{u.username || "-"}</TableCell>
<TableCell icon={icons.email}>
<span className="admin-email-cell">
{u.email ? (
<MailtoEmail address={u.email} className="profile-external-link">{u.email}</MailtoEmail>
) : (
<span>-</span>
)}
{(u.secondaryEmails || []).length > 0 && (
<>
<span className="muted"> / </span>
{(u.secondaryEmails || []).map((em, idx) => (
<React.Fragment key={em}>
{idx > 0 ? <span className="muted">, </span> : null}
<MailtoEmail address={em} className="profile-external-link">{em}</MailtoEmail>
</React.Fragment>
))}
</>
)}
</span>
</TableCell>
<TableCell icon={icons.level}>{u.level ?? 0} </TableCell>
<TableCell icon={icons.coins}>{u.sproutCoins}</TableCell>
<TableCell icon={icons.ban}>
{u.banned ? (
<span className="admin-user-banned" title={u.banReason || "已封禁"}>封禁</span>
) : (
"正常"
)}
</TableCell>
<TableCell icon={icons.visitIp}><span className="mono">{u.lastVisitIp || "-"}</span></TableCell>
<TableCell icon={icons.visitGeo}>{u.lastVisitDisplayLocation || "-"}</TableCell>
<span className="row-actions">
<button className="ghost" onClick={() => selectUser(u)}>编辑</button>
<button className="danger" onClick={() => handleDelete(u.account)}>删除</button>
</span>
</div>
))}
</div>
</div>
</div>
{editorOpen && (
<div className="modal-backdrop" onClick={closeEditor}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2>{editorMode === "edit" ? "编辑用户" : "新建用户配置"}</h2>
<p>{editorMode === "edit" ? "修改账户资料后保存" : "点击保存后创建新用户"}</p>
</div>
<button className="ghost modal-close" onClick={closeEditor} type="button">关闭</button>
</div>
<div className="modal-body">
<label>
<IconLabel icon={icons.account} text="账户" />
<input value={form.account} onChange={(e) => handleChange("account", e.target.value)} placeholder="唯一账户" disabled={editorMode === "edit"} />
</label>
<label>
<IconLabel icon={icons.password} text="密码" hint={editorMode === "edit" ? "(留空不修改)" : ""} />
<input type="password" value={form.password} onChange={(e) => handleChange("password", e.target.value)} placeholder={editorMode === "edit" ? "输入新密码" : "初始密码"} />
</label>
<label>
<IconLabel icon={icons.username} text="用户名" />
<input value={form.username} onChange={(e) => handleChange("username", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.email} text="邮箱" />
<input value={form.email} onChange={(e) => handleChange("email", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.level} text="等级" />
<input type="number" value={form.level} onChange={(e) => handleChange("level", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.coins} text="萌芽币" />
<input type="number" value={form.sproutCoins} onChange={(e) => handleChange("sproutCoins", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.secondaryEmail} text="辅助邮箱(逗号分隔)" />
<input value={form.secondaryEmails} onChange={(e) => handleChange("secondaryEmails", e.target.value)} placeholder="demo2@example.com, demo3@example.com" />
</label>
<label>
<IconLabel icon={icons.phone} text="手机号" />
<input value={form.phone} onChange={(e) => handleChange("phone", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.avatar} text="个人头像(链接)" />
<input value={form.avatarUrl} onChange={(e) => handleChange("avatarUrl", e.target.value)} />
</label>
<label className="full-span">
<IconLabel icon={icons.link} text="个人主页网站http/https" />
<input value={form.websiteUrl} onChange={(e) => handleChange("websiteUrl", e.target.value)} placeholder="留空表示无" />
</label>
<label className="full-span">
<IconLabel icon={icons.bio} text="个人简介(支持 Markdown" />
<textarea value={form.bio} onChange={(e) => handleChange("bio", e.target.value)} rows={4} />
</label>
{editorMode === "edit" && (
<>
<label className="admin-ban-row">
<IconLabel icon={icons.ban} text="封禁账户" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={Boolean(form.banned)}
onChange={(e) => handleChange("banned", e.target.checked)}
/>
<span>禁止登录与使用需登录的接口</span>
</span>
</label>
<label className="full-span">
<IconLabel icon={icons.ban} text="封禁理由(对用户登录错误提示可见)" />
<textarea
value={form.banReason}
onChange={(e) => handleChange("banReason", e.target.value)}
rows={3}
placeholder="填写封禁原因;解封请取消勾选「封禁账户」并保存"
disabled={!form.banned}
/>
</label>
</>
)}
{editorMode === "edit" && editingAuthClients.length > 0 && (
<div className="full-span admin-readonly-auth-clients">
<IconLabel icon={icons.apps} text="应用接入记录(只读)" />
<ul className="admin-auth-client-list">
{[...editingAuthClients]
.sort((a, b) => new Date(b.lastSeenAt || 0) - new Date(a.lastSeenAt || 0))
.map((row) => (
<li key={row.clientId}>
<strong>{row.clientId}</strong>
{row.displayName ? <span className="muted"> · {row.displayName}</span> : null}
<div className="muted admin-auth-client-meta">
首次 {formatIsoDateTimeReadable(row.firstSeenAt)} · 最近 {formatIsoDateTimeReadable(row.lastSeenAt)}
</div>
</li>
))}
</ul>
</div>
)}
{error && <div className="error">{error}</div>}
{message && <div className="success">{message}</div>}
</div>
<div className="modal-actions">
<button className="primary" onClick={editorMode === "edit" ? handleUpdate : handleCreate} type="button">
{editorMode === "edit" ? "保存修改" : "创建用户"}
</button>
<button className="ghost" onClick={closeEditor} type="button">取消</button>
</div>
</div>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,127 @@
import React, { useEffect, useState } from "react";
import { API_BASE, marked, formatWebsiteLabel, formatUserRegisteredAt } from "../config";
import icons from "../icons";
import { InfoRow, StatItem } from "./common";
export default function PublicUserPage({ account, onReady, onPreviewImage }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
const loadPublicUser = async () => {
if (!account) {
setError("缺少账户名");
setLoading(false);
if (!cancelled && onReady) onReady();
return;
}
setLoading(true);
setError("");
setUser(null);
try {
const res = await fetch(`${API_BASE}/api/public/users/${encodeURIComponent(account)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载公开主页失败");
setUser(data.user || null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
if (!cancelled && onReady) onReady();
}
};
loadPublicUser();
return () => { cancelled = true; };
}, [account, onReady]);
const avatarUrl = user?.avatarUrl || "https://dummyimage.com/160x160/ddd/fff&text=Avatar";
const displayName = user?.username || user?.account || "未命名用户";
return (
<section className="panel">
{loading && <div className="unified-page-loading">加载公开主页中</div>}
{!loading && error && (
<div className="unified-page-miss">
<h2>未找到用户</h2>
<div className="error">{error}</div>
<div className="actions">
<a className="primary" href="/">返回首页</a>
</div>
</div>
)}
{!loading && user && (
<div className="card profile">
<div className="profile-header">
<img
src={avatarUrl}
alt={displayName}
className="previewable-image"
onClick={() => onPreviewImage?.(avatarUrl, displayName)}
role="button"
tabIndex={0}
/>
<div>
<h2>{displayName}</h2>
</div>
</div>
<div className="profile-section-title profile-section-title--lead">基本信息</div>
<div className="profile-info-rows">
<InfoRow icon={icons.account} label="账户" value={user.account} />
<InfoRow icon={icons.username} label="用户名" value={user.username || "未填写"} />
<InfoRow icon={icons.calendar} label="注册时间" value={formatUserRegisteredAt(user.createdAt)} />
{user.websiteUrl ? (
<InfoRow icon={icons.link} label="个人主页">
<a
href={user.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="profile-external-link"
>
{formatWebsiteLabel(user.websiteUrl)}
</a>
</InfoRow>
) : null}
</div>
<div className="profile-section-title">统计信息</div>
<div className="profile-stats-flow">
<StatItem icon={icons.level} label="等级" value={`${user.level ?? 0}`} />
<StatItem icon={icons.coins} label="萌芽币" value={user.sproutCoins ?? 0} />
<StatItem icon={icons.calendar} label="签到天数" value={`${user.checkInDays ?? 0}`} />
<StatItem icon={icons.statLightning} label="连续签到" value={`${user.checkInStreak ?? 0}`} />
<StatItem icon={icons.statClock} label="访问天数" value={`${user.visitDays ?? 0}`} />
<StatItem icon={icons.statRepeat} label="连续访问" value={`${user.visitStreak ?? 0}`} />
</div>
<div className="profile-section-title">活动记录</div>
<div className="profile-activity-row">
<span>最后签到 <strong>{user.lastCheckInAt || user.lastCheckInDate || "未签到"}</strong></span>
<span>最后访问 <strong>{user.lastVisitAt || user.lastVisitDate || "未访问"}</strong></span>
</div>
<div className="profile-activity-row profile-visit-meta">
<span>
最后访问 IP{" "}
<strong className="mono">{user.lastVisitIp || "暂无"}</strong>
</span>
<span>
最后位置 <strong>{user.lastVisitDisplayLocation || "暂无"}</strong>
</span>
</div>
<div className="profile-section-title">个人简介</div>
<div className="markdown profile-markdown">
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: marked.parse(user.bio || "暂无简介") }}
/>
</div>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,27 @@
import React from "react";
import { LOGO_192_SRC } from "../config";
export default function SplashScreen() {
return (
<div className="splash">
<div className="splash-glow" aria-hidden="true" />
<div className="splash-content">
<div className="splash-logo-wrap">
<div className="splash-rings" aria-hidden="true">
<span />
<span />
<span />
</div>
<img className="splash-logo" src={LOGO_192_SRC} alt="SproutGate" width={120} height={120} decoding="async" />
</div>
<div className="splash-title">萌芽账户认证中心</div>
<div className="splash-subtitle">加载中</div>
<div className="splash-dots" aria-label="加载中">
<span />
<span />
<span />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,696 @@
import React, { useEffect, useState } from "react";
import {
API_BASE,
authClientFetchHeaders,
buildAuthCallbackUrl,
clearAuthClientContext,
fetchClientVisitMeta,
formatAuthBanMessage,
persistAuthClientFromFlow
} from "../config";
import UserPortalAuthSection from "./userPortal/UserPortalAuthSection";
import UserPortalProfileSection from "./userPortal/UserPortalProfileSection";
export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
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: "", inviteCode: ""
});
const [registrationRequireInvite, setRegistrationRequireInvite] = useState(false);
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: "", websiteUrl: "", bio: "", password: ""
});
const [profileLoading, setProfileLoading] = useState(false);
const [profileError, setProfileError] = useState("");
const [profileMessage, setProfileMessage] = useState("");
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
const [profileEditorFocus, setProfileEditorFocus] = useState("");
const [secondaryEditorOpen, setSecondaryEditorOpen] = useState(false);
const [checkInReward, setCheckInReward] = useState(1);
const [checkInToday, setCheckInToday] = useState(false);
const [checkInLoading, setCheckInLoading] = useState(false);
const [checkInError, setCheckInError] = useState("");
const [checkInMessage, setCheckInMessage] = useState("");
const isAuthFlow = Boolean(authFlow?.redirectUri);
const redirectToAuthCallback = (tokenValue, userData, expiresAt = "") => {
if (!isAuthFlow || !authFlow?.redirectUri || !tokenValue) return;
const callbackUrl = buildAuthCallbackUrl(authFlow.redirectUri, {
token: tokenValue,
account: userData?.account || "",
username: userData?.username || "",
expiresAt,
state: authFlow.state || ""
});
window.location.replace(callbackUrl);
};
const clearTokenAndUser = () => {
localStorage.removeItem("sproutgate_token");
localStorage.removeItem("sproutgate_token_expires_at");
setUser(null);
};
const bearerHeaders = (tokenValue) => ({
...authClientFetchHeaders(),
Authorization: `Bearer ${tokenValue}`
});
const syncCurrentUser = async (tokenValue) => {
const baseHeaders = bearerHeaders(tokenValue);
const res = await fetch(`${API_BASE}/api/auth/me`, { headers: baseHeaders });
const data = await res.json();
if (!res.ok) {
if (res.status === 403) {
clearTokenAndUser();
const msg = formatAuthBanMessage(data);
setError(msg);
throw new Error(msg);
}
throw new Error(data.error || "加载用户失败");
}
if (data.user) setUser(data.user);
const checkIn = data.checkIn || {};
setCheckInReward(Number(checkIn.rewardCoins) || 1);
setCheckInToday(Boolean(checkIn.checkedInToday));
fetchClientVisitMeta()
.then((meta) => {
if (!meta.ip && !meta.displayLocation) return null;
const h = { ...baseHeaders };
if (meta.ip) h["X-Visit-Ip"] = meta.ip;
if (meta.displayLocation) h["X-Visit-Location"] = meta.displayLocation;
return fetch(`${API_BASE}/api/auth/me`, { headers: h });
})
.then(async (r) => {
if (!r) return null;
const d = await r.json().catch(() => ({}));
if (r.status === 403) {
clearTokenAndUser();
setError(formatAuthBanMessage(d));
return null;
}
return r.ok ? d : null;
})
.then((d) => {
if (!d) return;
if (d.user) setUser(d.user);
const c = d.checkIn;
if (c) {
setCheckInReward(Number(c.rewardCoins) || 1);
setCheckInToday(Boolean(c.checkedInToday));
}
})
.catch(() => {});
return data;
};
useEffect(() => {
let cancelled = false;
const done = () => { if (!cancelled && onReady) onReady(); };
const token = localStorage.getItem("sproutgate_token");
if (token) {
syncCurrentUser(token).catch(() => {}).finally(() => done());
} else {
done();
}
return () => { cancelled = true; };
}, [onReady]);
useEffect(() => {
persistAuthClientFromFlow(authFlow);
}, [authFlow]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`${API_BASE}/api/public/registration-policy`);
const data = await res.json().catch(() => ({}));
if (!cancelled && res.ok) {
setRegistrationRequireInvite(Boolean(data.requireInviteCode));
}
} catch {
/* 忽略,默认不要求邀请码 */
}
})();
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (user) {
setProfileForm({
username: user.username || "",
phone: user.phone || "",
avatarUrl: user.avatarUrl || "",
websiteUrl: user.websiteUrl || "",
bio: user.bio || "",
password: ""
});
}
}, [user]);
const handleLogin = async (event) => {
event.preventDefault();
setLoading(true);
setError("");
try {
const loginPayload = { account, password };
const cid = (authFlow?.clientId || "").trim();
const cname = (authFlow?.clientName || "").trim();
if (cid) {
loginPayload.clientId = cid;
if (cname) loginPayload.clientName = cname;
} else {
const h = authClientFetchHeaders();
if (h["X-Auth-Client"]) {
loginPayload.clientId = h["X-Auth-Client"];
if (h["X-Auth-Client-Name"]) loginPayload.clientName = h["X-Auth-Client-Name"];
}
}
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(loginPayload)
});
const data = await res.json();
if (!res.ok) {
throw new Error(
res.status === 403 ? formatAuthBanMessage(data) : (data.error || "登录失败")
);
}
localStorage.setItem("sproutgate_token", data.token);
localStorage.setItem("sproutgate_token_expires_at", data.expiresAt || "");
setUser(data.user);
syncCurrentUser(data.token).catch(() => {});
setAccount("");
setPassword("");
if (isAuthFlow) {
redirectToAuthCallback(data.token, data.user, data.expiresAt || "");
return;
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem("sproutgate_token");
localStorage.removeItem("sproutgate_token_expires_at");
clearAuthClientContext();
setUser(null);
setProfileEditorOpen(false);
setProfileEditorFocus("");
setSecondaryEditorOpen(false);
setCheckInReward(1);
setCheckInToday(false);
setCheckInError("");
setCheckInMessage("");
};
const handleContinueAuth = () => {
const tokenValue = localStorage.getItem("sproutgate_token") || "";
if (!tokenValue) {
setError("当前没有可用的登录会话,请先重新登录");
return;
}
redirectToAuthCallback(
tokenValue, user,
localStorage.getItem("sproutgate_token_expires_at") || ""
);
};
const handleSwitchAuthAccount = () => {
handleLogout();
setMode("login");
setAccount("");
setPassword("");
setError("");
};
const handleRegisterChange = (field, value) => {
setRegisterForm((prev) => ({ ...prev, [field]: value }));
};
const resetRegisterFlow = () => {
setRegisterSent(false);
setRegisterExpiresAt("");
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
};
const handleSendCode = async (event) => {
event.preventDefault();
setRegisterLoading(true);
setRegisterError("");
setRegisterMessage("");
if (!registerForm.account || !registerForm.password || !registerForm.email) {
setRegisterError("请填写账户、密码和邮箱");
setRegisterLoading(false);
return;
}
if (registrationRequireInvite && !String(registerForm.inviteCode || "").trim()) {
setRegisterError("请输入邀请码");
setRegisterLoading(false);
return;
}
try {
const payload = {
account: registerForm.account,
password: registerForm.password,
username: registerForm.username,
email: registerForm.email
};
const inv = String(registerForm.inviteCode || "").trim();
if (inv) payload.inviteCode = inv;
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
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: "", inviteCode: "" });
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(
res.status === 403 ? formatAuthBanMessage(data) : (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(
res.status === 403 ? formatAuthBanMessage(data) : (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", ...bearerHeaders(token) },
body: JSON.stringify({ email: secondaryForm.email })
});
const data = await res.json();
if (!res.ok) {
if (res.status === 403) {
clearTokenAndUser();
setSecondaryError(formatAuthBanMessage(data));
} else {
throw new Error(data.error || "发送验证码失败");
}
return;
}
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", ...bearerHeaders(token) },
body: JSON.stringify({ email: secondaryForm.email, code: secondaryForm.code })
});
const data = await res.json();
if (!res.ok) {
if (res.status === 403) {
clearTokenAndUser();
setSecondaryError(formatAuthBanMessage(data));
} else {
throw new Error(data.error || "验证失败");
}
return;
}
if (data.user) setUser(data.user);
setSecondaryMessage("辅助邮箱验证成功");
setSecondarySent(false);
setSecondaryForm({ email: "", code: "" });
setSecondaryEditorOpen(false);
} 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,
websiteUrl: profileForm.websiteUrl,
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", ...bearerHeaders(token) },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) {
if (res.status === 403) {
clearTokenAndUser();
setProfileError(formatAuthBanMessage(data));
} else {
throw new Error(data.error || "保存失败");
}
return;
}
setUser(data.user);
setProfileMessage("保存成功");
setProfileForm((prev) => ({ ...prev, password: "" }));
setProfileEditorOpen(false);
setProfileEditorFocus("");
} catch (err) {
setProfileError(err.message);
} finally {
setProfileLoading(false);
}
};
const openProfileEditor = (field = "") => {
setProfileError("");
setProfileMessage("");
setProfileEditorFocus(field);
setProfileEditorOpen(true);
};
const openSecondaryEditor = () => {
setSecondaryError("");
setSecondaryMessage("");
setSecondarySent(false);
setSecondaryForm({ email: "", code: "" });
setSecondaryEditorOpen(true);
};
const closeProfileEditor = () => {
setProfileEditorOpen(false);
setProfileEditorFocus("");
};
const closeSecondaryEditor = () => {
setSecondaryEditorOpen(false);
setSecondarySent(false);
setSecondaryForm({ email: "", code: "" });
setSecondaryError("");
setSecondaryMessage("");
};
const profileFocusLabels = {
phone: "手机号",
avatarUrl: "头像",
websiteUrl: "个人主页",
bio: "简介",
username: "用户名"
};
const profileModalTitle = profileEditorFocus && profileFocusLabels[profileEditorFocus]
? `修改${profileFocusLabels[profileEditorFocus]}`
: "修改资料";
const handleCheckIn = async () => {
const token = localStorage.getItem("sproutgate_token");
if (!token) { setCheckInError("请先登录"); return; }
setCheckInLoading(true);
setCheckInError("");
setCheckInMessage("");
try {
const res = await fetch(`${API_BASE}/api/auth/check-in`, {
method: "POST",
headers: bearerHeaders(token)
});
const data = await res.json();
if (!res.ok) {
if (res.status === 403) {
clearTokenAndUser();
setCheckInError(formatAuthBanMessage(data));
} else {
throw new Error(data.error || "签到失败");
}
return;
}
if (data.user) setUser(data.user);
const checkIn = data.checkIn || {};
setCheckInReward(Number(checkIn.rewardCoins) || 1);
setCheckInToday(Boolean(checkIn.checkedInToday));
setCheckInMessage(
data.message || (data.alreadyCheckedIn ? "今日已签到" : `签到成功,获得 ${data.awardedCoins ?? data.rewardCoins ?? 0} 萌芽币`)
);
} catch (err) {
setCheckInError(err.message);
} finally {
setCheckInLoading(false);
}
};
return (
<section className="panel">
<UserPortalAuthSection
isAuthFlow={isAuthFlow}
user={user}
onPreviewImage={onPreviewImage}
handleContinueAuth={handleContinueAuth}
handleSwitchAuthAccount={handleSwitchAuthAccount}
mode={mode}
setMode={setMode}
account={account}
setAccount={setAccount}
password={password}
setPassword={setPassword}
loading={loading}
error={error}
setError={setError}
handleLogin={handleLogin}
registerForm={registerForm}
handleRegisterChange={handleRegisterChange}
registerSent={registerSent}
registerExpiresAt={registerExpiresAt}
registerLoading={registerLoading}
registerError={registerError}
registerMessage={registerMessage}
handleSendCode={handleSendCode}
handleVerifyRegister={handleVerifyRegister}
setRegisterError={setRegisterError}
setRegisterMessage={setRegisterMessage}
registrationRequireInvite={registrationRequireInvite}
resetRegisterFlow={resetRegisterFlow}
resetForm={resetForm}
handleResetChange={handleResetChange}
resetSent={resetSent}
resetExpiresAt={resetExpiresAt}
resetLoading={resetLoading}
resetError={resetError}
resetMessage={resetMessage}
handleSendReset={handleSendReset}
handleResetPassword={handleResetPassword}
setResetError={setResetError}
setResetMessage={setResetMessage}
/>
{user && (
<UserPortalProfileSection
user={user}
onPreviewImage={onPreviewImage}
handleLogout={handleLogout}
openProfileEditor={openProfileEditor}
openSecondaryEditor={openSecondaryEditor}
profileEditorOpen={profileEditorOpen}
closeProfileEditor={closeProfileEditor}
handleProfileSave={handleProfileSave}
profileForm={profileForm}
handleProfileChange={handleProfileChange}
profileLoading={profileLoading}
profileError={profileError}
profileMessage={profileMessage}
profileModalTitle={profileModalTitle}
secondaryEditorOpen={secondaryEditorOpen}
closeSecondaryEditor={closeSecondaryEditor}
handleSendSecondary={handleSendSecondary}
handleVerifySecondary={handleVerifySecondary}
secondaryForm={secondaryForm}
handleSecondaryChange={handleSecondaryChange}
secondarySent={secondarySent}
secondaryLoading={secondaryLoading}
secondaryError={secondaryError}
secondaryMessage={secondaryMessage}
secondaryExpiresAt={secondaryExpiresAt}
checkInReward={checkInReward}
checkInToday={checkInToday}
checkInLoading={checkInLoading}
checkInError={checkInError}
checkInMessage={checkInMessage}
handleCheckIn={handleCheckIn}
/>
)}
</section>
);
}

View File

@@ -0,0 +1,73 @@
import React from "react";
import { mailtoHref } from "../config";
export function IconLabel({ icon, text, hint }) {
return (
<span className="label-text">
<span className="icon">{icon}</span>
<span>{text}</span>
{hint && <span className="hint">{hint}</span>}
</span>
);
}
export function InfoLabel({ icon, text }) {
return (
<span className="info-label">
<span className="icon">{icon}</span>
<span>{text}</span>
</span>
);
}
export function TableCell({ icon, children, onClick }) {
return (
<span className="table-cell" onClick={onClick}>
<span className="icon">{icon}</span>
<span>{children}</span>
</span>
);
}
export function StatItem({ icon, label, value }) {
return (
<span className="stat-item">
<span className="stat-item-lead">
{icon ? <span className="icon stat-item-icon">{icon}</span> : null}
<span className="stat-item-label">{label}</span>
</span>
<strong className="stat-item-value">{value}</strong>
</span>
);
}
/** 邮箱展示为可点击的 mailto 链接(非法地址则回退为纯文本) */
export function MailtoEmail({ address, className, children }) {
const href = mailtoHref(address);
const text = children ?? address;
if (!href) {
return <span className={className}>{text}</span>;
}
return (
<a href={href} className={className || "mailto-email-link"}>
{text}
</a>
);
}
export function InfoRow({ icon, label, value, actionLabel, onAction, children }) {
return (
<div className="info-row">
<span className="info-row-label">
<span className="icon">{icon}</span>
{label}
</span>
<span className="info-row-value">{children ?? value}</span>
{actionLabel && (
<button type="button" className="text-button" onClick={onAction}>
{actionLabel}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,245 @@
import React from "react";
import icons from "../../icons";
import { IconLabel, MailtoEmail } from "../common";
export default function UserPortalAuthSection({
isAuthFlow,
user,
onPreviewImage,
handleContinueAuth,
handleSwitchAuthAccount,
mode,
setMode,
account,
setAccount,
password,
setPassword,
loading,
error,
setError,
handleLogin,
registerForm,
handleRegisterChange,
registerSent,
registerExpiresAt,
registerLoading,
registerError,
registerMessage,
handleSendCode,
handleVerifyRegister,
setRegisterError,
setRegisterMessage,
registrationRequireInvite,
resetRegisterFlow,
resetForm,
handleResetChange,
resetSent,
resetExpiresAt,
resetLoading,
resetError,
resetMessage,
handleSendReset,
handleResetPassword,
setResetError,
setResetMessage
}) {
return (
<>
{isAuthFlow && user && (
<div className="card form">
<h2>第三方登录授权</h2>
<div className="hint">外部应用正在请求使用当前萌芽统一账户认证登录</div>
<div className="profile-header" style={{ marginTop: "12px" }}>
<img
src={user.avatarUrl || "https://dummyimage.com/120x120/ddd/fff&text=Avatar"}
alt="avatar"
className="previewable-image"
onClick={() => onPreviewImage?.(user.avatarUrl || "", user.username || user.account || "avatar")}
role="button"
tabIndex={0}
/>
<div>
<h3>{user.username || user.account}</h3>
<p>{user.account}</p>
<p>
{user.email ? (
<MailtoEmail address={user.email} className="profile-external-link">{user.email}</MailtoEmail>
) : (
"未填写邮箱"
)}
</p>
</div>
</div>
<div className="actions">
<button type="button" className="primary" onClick={handleContinueAuth}>继续授权</button>
<button type="button" className="ghost" onClick={handleSwitchAuthAccount}>切换账号</button>
</div>
</div>
)}
{!user && mode === "login" && (
<form className="card form auth-card" onSubmit={handleLogin}>
<div className="auth-form-head">
<h2>登录</h2>
<p className="auth-form-lead">使用萌芽统一账户进入用户中心</p>
{isAuthFlow && (
<div className="hint auth-form-hint">你正在通过统一登录为外部应用授权登录后将自动返回</div>
)}
</div>
<div className="auth-form-body">
<label className="auth-field">
<IconLabel icon={icons.account} text="账户" />
<input
value={account}
onChange={(e) => setAccount(e.target.value)}
placeholder="请输入账户"
autoComplete="username"
name="account"
/>
</label>
<label className="auth-field">
<IconLabel icon={icons.password} text="密码" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoComplete="current-password"
name="password"
/>
</label>
{error && <div className="error auth-form-alert" role="alert">{error}</div>}
</div>
<div className="auth-form-actions">
<button type="submit" className="primary auth-submit" disabled={loading}>
{loading ? "登录中…" : "登录"}
</button>
</div>
<div className="auth-form-footer">
<button
type="button"
className="auth-footer-link"
onClick={() => { setMode("register"); setError(""); resetRegisterFlow?.(); setRegisterError(""); setRegisterMessage(""); }}
>
注册账号
</button>
<span className="auth-footer-sep" aria-hidden />
<button type="button" className="auth-footer-link" onClick={() => { setMode("reset"); setError(""); }}>
忘记密码
</button>
</div>
</form>
)}
{!user && mode === "register" && (
<form className="card form auth-card" onSubmit={registerSent ? handleVerifyRegister : handleSendCode}>
<div className="auth-form-head">
<h2>注册账号</h2>
<p className="auth-form-lead">创建萌芽统一账户验证码将发送至你的邮箱</p>
</div>
<div className="auth-form-body">
<label className="auth-field">
<IconLabel icon={icons.account} text="账户" />
<input value={registerForm.account} onChange={(e) => handleRegisterChange("account", e.target.value)} placeholder="请输入账户" />
</label>
<label className="auth-field">
<IconLabel icon={icons.password} text="密码" />
<input type="password" value={registerForm.password} onChange={(e) => handleRegisterChange("password", e.target.value)} placeholder="请输入密码" />
</label>
<label className="auth-field">
<IconLabel icon={icons.username} text="用户名" />
<input value={registerForm.username} onChange={(e) => handleRegisterChange("username", e.target.value)} placeholder="可选" />
</label>
<label className="auth-field">
<IconLabel icon={icons.email} text="邮箱" />
<input value={registerForm.email} onChange={(e) => handleRegisterChange("email", e.target.value)} placeholder="用于接收验证码" />
</label>
{(registrationRequireInvite || !registerSent) && (
<label className="auth-field">
<IconLabel
icon={icons.token}
text="邀请码"
hint={registrationRequireInvite ? "(必填)" : "(选填)"}
/>
<input
value={registerForm.inviteCode || ""}
onChange={(e) => handleRegisterChange("inviteCode", e.target.value)}
placeholder={registrationRequireInvite ? "请输入管理员发放的邀请码" : "有邀请码可填写,无则留空"}
disabled={registerSent}
autoCapitalize="characters"
/>
</label>
)}
{registerSent && (
<label className="auth-field">
<IconLabel icon={icons.token} text="邮箱验证码" />
<input value={registerForm.code} onChange={(e) => handleRegisterChange("code", e.target.value)} placeholder="6 位验证码" />
</label>
)}
{registerExpiresAt && <div className="hint">验证码有效期至{registerExpiresAt}</div>}
{registerError && <div className="error auth-form-alert" role="alert">{registerError}</div>}
{registerMessage && <div className="success auth-form-alert">{registerMessage}</div>}
</div>
<div className="auth-form-actions">
<button type="submit" className="primary auth-submit" disabled={registerLoading}>
{registerLoading ? "处理中…" : registerSent ? "完成注册" : "发送验证码"}
</button>
<button
type="button"
className="auth-footer-back"
onClick={() => { setMode("login"); resetRegisterFlow?.(); setRegisterError(""); setRegisterMessage(""); }}
>
返回登录
</button>
</div>
</form>
)}
{!user && mode === "reset" && (
<form className="card form auth-card" onSubmit={resetSent ? handleResetPassword : handleSendReset}>
<div className="auth-form-head">
<h2>重置密码</h2>
<p className="auth-form-lead">通过注册邮箱接收验证码设置新密码</p>
</div>
<div className="auth-form-body">
<label className="auth-field">
<IconLabel icon={icons.account} text="账户" />
<input value={resetForm.account} onChange={(e) => handleResetChange("account", e.target.value)} placeholder="请输入账户" />
</label>
<label className="auth-field">
<IconLabel icon={icons.email} text="邮箱" />
<input value={resetForm.email} onChange={(e) => handleResetChange("email", e.target.value)} placeholder="请输入注册邮箱" />
</label>
{resetSent && (
<>
<label className="auth-field">
<IconLabel icon={icons.token} text="邮箱验证码" />
<input value={resetForm.code} onChange={(e) => handleResetChange("code", e.target.value)} placeholder="6 位验证码" />
</label>
<label className="auth-field">
<IconLabel icon={icons.password} text="新密码" />
<input type="password" value={resetForm.newPassword} onChange={(e) => handleResetChange("newPassword", e.target.value)} placeholder="输入新密码" />
</label>
</>
)}
{resetExpiresAt && <div className="hint">验证码有效期至{resetExpiresAt}</div>}
{resetError && <div className="error auth-form-alert" role="alert">{resetError}</div>}
{resetMessage && <div className="success auth-form-alert">{resetMessage}</div>}
</div>
<div className="auth-form-actions">
<button type="submit" className="primary auth-submit" disabled={resetLoading}>
{resetLoading ? "处理中…" : resetSent ? "确认重置" : "发送重置邮件"}
</button>
<button
type="button"
className="auth-footer-back"
onClick={() => { setMode("login"); setResetError(""); setResetMessage(""); }}
>
返回登录
</button>
</div>
</form>
)}
</>
);
}

View File

@@ -0,0 +1,282 @@
import React from "react";
import { marked, formatWebsiteLabel, formatUserRegisteredAt, formatIsoDateTimeReadable } from "../../config";
import icons from "../../icons";
import { IconLabel, MailtoEmail, StatItem, InfoRow } from "../common";
export default function UserPortalProfileSection({
user,
onPreviewImage,
handleLogout,
openProfileEditor,
openSecondaryEditor,
profileEditorOpen,
closeProfileEditor,
handleProfileSave,
profileForm,
handleProfileChange,
profileLoading,
profileError,
profileMessage,
profileModalTitle,
secondaryEditorOpen,
closeSecondaryEditor,
handleSendSecondary,
handleVerifySecondary,
secondaryForm,
handleSecondaryChange,
secondarySent,
secondaryLoading,
secondaryError,
secondaryMessage,
secondaryExpiresAt,
checkInReward,
checkInToday,
checkInLoading,
checkInError,
checkInMessage,
handleCheckIn
}) {
return (
<div className="card profile">
<div className="profile-header">
<img
src={user.avatarUrl || "https://dummyimage.com/120x120/ddd/fff&text=Avatar"}
alt="avatar"
className="previewable-image"
onClick={() => onPreviewImage?.(user.avatarUrl || "", user.username || user.account || "avatar")}
role="button"
tabIndex={0}
/>
<div>
<h2>{user.username || user.account}</h2>
<p className="profile-header-email">
{user.email ? (
<MailtoEmail address={user.email} className="profile-external-link">
{user.email}
</MailtoEmail>
) : (
"未填写邮箱"
)}
</p>
<div className="profile-header-actions">
<a className="ghost" href={`/user/${encodeURIComponent(user.account)}`}>公开主页</a>
<button type="button" className="ghost" onClick={handleLogout}>退出登录</button>
</div>
</div>
</div>
<div className="profile-section-title profile-section-title--lead">基本信息</div>
<div className="profile-info-rows">
<InfoRow icon={icons.account} label="账户" value={user.account} />
<InfoRow icon={icons.username} label="用户名" value={user.username || "未填写"} actionLabel="修改" onAction={() => openProfileEditor("username")} />
<InfoRow icon={icons.calendar} label="注册时间" value={formatUserRegisteredAt(user.createdAt)} />
<InfoRow icon={icons.email} label="邮箱">
{user.email ? (
<MailtoEmail address={user.email} className="profile-external-link">
{user.email}
</MailtoEmail>
) : (
"未填写"
)}
</InfoRow>
<InfoRow icon={icons.phone} label="手机号" value={user.phone || "未填写"} actionLabel="修改" onAction={() => openProfileEditor("phone")} />
<InfoRow icon={icons.avatar} label="头像" value={user.avatarUrl ? "已设置" : "未填写"} actionLabel="修改" onAction={() => openProfileEditor("avatarUrl")} />
<InfoRow icon={icons.link} label="个人主页" actionLabel="修改" onAction={() => openProfileEditor("websiteUrl")}>
{user.websiteUrl ? (
<a
href={user.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="profile-external-link"
>
{formatWebsiteLabel(user.websiteUrl)}
</a>
) : (
<span className="muted">未填写</span>
)}
</InfoRow>
<InfoRow
icon={icons.secondaryEmail}
label="辅助邮箱"
actionLabel={(user.secondaryEmails || []).length ? "管理" : "添加"}
onAction={openSecondaryEditor}
>
{(user.secondaryEmails || []).length === 0 ? (
<span className="muted">暂无</span>
) : (
(user.secondaryEmails || []).map((email) => (
<MailtoEmail key={email} address={email} className="tag tag-mailto">
{email}
</MailtoEmail>
))
)}
</InfoRow>
<InfoRow icon={icons.bio} label="简介" value={user.bio ? "已填写" : "暂无简介"} actionLabel="修改" onAction={() => openProfileEditor("bio")} />
</div>
<div className="profile-section-title">统计信息</div>
<div className="profile-stats-flow">
<StatItem icon={icons.level} label="等级" value={`${user.level ?? 0}`} />
<StatItem icon={icons.coins} label="萌芽币" value={user.sproutCoins ?? 0} />
<StatItem icon={icons.calendar} label="签到天数" value={`${user.checkInDays ?? 0}`} />
<StatItem icon={icons.statLightning} label="连续签到" value={`${user.checkInStreak ?? 0}`} />
<StatItem icon={icons.statClock} label="访问天数" value={`${user.visitDays ?? 0}`} />
<StatItem icon={icons.statRepeat} label="连续访问" value={`${user.visitStreak ?? 0}`} />
</div>
<div className="profile-section-title">活动记录</div>
<div className="profile-activity-row">
<span>最后签到 <strong>{user.lastCheckInAt || user.lastCheckInDate || "未签到"}</strong></span>
<span>最后访问 <strong>{user.lastVisitAt || user.lastVisitDate || "未访问"}</strong></span>
</div>
<div className="profile-activity-row profile-visit-meta">
<span>
最后访问 IP{" "}
<strong className="mono">{user.lastVisitIp || "暂无"}</strong>
</span>
<span>
最后位置 <strong>{user.lastVisitDisplayLocation || "暂无"}</strong>
</span>
</div>
<div className="profile-section-title">应用接入记录</div>
<p className="profile-auth-clients-hint muted">
第三方应用在登录页 URL 中携带 <code className="inline-code">client_id</code>可选 <code className="inline-code">client_name</code>或在调用 <code className="inline-code">POST /api/auth/verify</code><code className="inline-code">GET /api/auth/me</code> 时传入请求头 <code className="inline-code">X-Auth-Client</code>即可在此累计展示
</p>
{(!user.authClients || user.authClients.length === 0) ? (
<div className="profile-auth-clients-empty muted">暂无接入记录</div>
) : (
<ul className="profile-auth-clients">
{[...user.authClients]
.sort((a, b) => {
const ta = new Date(a.lastSeenAt || 0).getTime();
const tb = new Date(b.lastSeenAt || 0).getTime();
return tb - ta;
})
.map((row) => (
<li key={row.clientId} className="profile-auth-client-row">
<span className="profile-auth-client-id">
<span className="icon profile-auth-client-icon" aria-hidden="true">{icons.apps}</span>
<strong>{row.clientId}</strong>
{row.displayName ? <span className="muted profile-auth-client-dname">{row.displayName}</span> : null}
</span>
<span className="profile-auth-client-times muted">
首次 {formatIsoDateTimeReadable(row.firstSeenAt)} · 最近 {formatIsoDateTimeReadable(row.lastSeenAt)}
</span>
</li>
))}
</ul>
)}
{profileEditorOpen && (
<div className="modal-backdrop" onClick={closeProfileEditor}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<form onSubmit={handleProfileSave}>
<div className="modal-header">
<div>
<h2>{profileModalTitle}</h2>
<p>填写完成后保存修改</p>
</div>
<button type="button" className="ghost modal-close" onClick={closeProfileEditor}>关闭</button>
</div>
<div className="modal-body">
<label>
<IconLabel icon={icons.username} text="用户名" />
<input value={profileForm.username} onChange={(e) => handleProfileChange("username", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.phone} text="手机号" />
<input value={profileForm.phone} onChange={(e) => handleProfileChange("phone", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.avatar} text="个人头像(链接)" />
<input value={profileForm.avatarUrl} onChange={(e) => handleProfileChange("avatarUrl", e.target.value)} />
</label>
<label className="full-span">
<IconLabel icon={icons.link} text="个人主页网站" hint="http/https可省略协议" />
<input
value={profileForm.websiteUrl}
onChange={(e) => handleProfileChange("websiteUrl", e.target.value)}
placeholder="https://example.com 或 example.com"
/>
</label>
<label className="full-span">
<IconLabel icon={icons.bio} text="个人简介(支持 Markdown" />
<textarea value={profileForm.bio} onChange={(e) => handleProfileChange("bio", e.target.value)} rows={4} />
</label>
<label className="full-span">
<IconLabel icon={icons.password} text="密码" hint="(留空不修改)" />
<input type="password" value={profileForm.password} onChange={(e) => handleProfileChange("password", e.target.value)} placeholder="输入新密码" />
</label>
{profileError && <div className="error full-span">{profileError}</div>}
{profileMessage && <div className="success full-span">{profileMessage}</div>}
</div>
<div className="modal-actions">
<button className="primary" disabled={profileLoading} type="submit">{profileLoading ? "保存中..." : "保存修改"}</button>
<button type="button" className="ghost" onClick={closeProfileEditor}>取消</button>
</div>
</form>
</div>
</div>
)}
{secondaryEditorOpen && (
<div className="modal-backdrop" onClick={closeSecondaryEditor}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<form onSubmit={secondarySent ? handleVerifySecondary : handleSendSecondary}>
<div className="modal-header">
<div>
<h2>辅助邮箱</h2>
<p>发送验证码并完成验证后辅助邮箱会更新到当前账户</p>
</div>
<button type="button" className="ghost modal-close" onClick={closeSecondaryEditor}>关闭</button>
</div>
<div className="modal-body">
<label>
<IconLabel icon={icons.secondaryEmail} text="辅助邮箱" />
<input value={secondaryForm.email} onChange={(e) => handleSecondaryChange("email", e.target.value)} placeholder="请输入辅助邮箱" />
</label>
{secondarySent && (
<label>
<IconLabel icon={icons.token} text="邮箱验证码" />
<input value={secondaryForm.code} onChange={(e) => handleSecondaryChange("code", e.target.value)} placeholder="6 位验证码" />
</label>
)}
{secondaryExpiresAt && <div className="hint full-span">验证码有效期至{secondaryExpiresAt}</div>}
{secondaryError && <div className="error full-span">{secondaryError}</div>}
{secondaryMessage && <div className="success full-span">{secondaryMessage}</div>}
</div>
<div className="modal-actions">
<button className="primary" disabled={secondaryLoading} type="submit">{secondarySent ? "确认验证" : "发送验证码"}</button>
<button type="button" className="ghost" onClick={closeSecondaryEditor}>取消</button>
</div>
</form>
</div>
</div>
)}
<div className="profile-section-title">个人简介</div>
<div className="markdown profile-markdown">
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: marked.parse(user.bio || "暂无简介") }}
/>
</div>
<div className="profile-section-title">每日签到</div>
<div className="profile-checkin-block">
<div className="hint">每天可签到一次签到奖励 {checkInReward} 萌芽币</div>
<div className="tag-list">
<span className="tag">{checkInToday ? "今日已签到" : "今日未签到"}</span>
<span className="tag">上次签到{user.lastCheckInAt || user.lastCheckInDate || "未签到"}</span>
</div>
{checkInError && <div className="error">{checkInError}</div>}
{checkInMessage && <div className="success">{checkInMessage}</div>}
<div className="actions profile-checkin-actions">
<button type="button" className="primary" onClick={handleCheckIn} disabled={checkInLoading || checkInToday}>
{checkInLoading ? "签到中..." : checkInToday ? "今日已签到" : `立即签到 +${checkInReward}`}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { marked } from "marked";
const DEFAULT_DEV_API_BASE = "http://localhost:8080";
const DEFAULT_PROD_API_BASE = "https://auth.api.shumengya.top";
export const API_BASE = (() => {
const configuredBase = import.meta.env.VITE_API_BASE?.trim();
const fallbackBase = import.meta.env.DEV ? DEFAULT_DEV_API_BASE : DEFAULT_PROD_API_BASE;
return (configuredBase || fallbackBase).replace(/\/+$/, "");
})();
/** `public/logo192.png`,含 Vite `base` 前缀,避免子路径部署时顶栏/开屏裂图 */
export const LOGO_192_SRC = `${import.meta.env.BASE_URL}logo192.png`;
/** 浏览器侧 IP/地理(与后端写入「最后访问」字段配合使用) */
export const CLIENT_GEO_LOOKUP_URL = "https://cf-ip-geo.smyhub.com/api";
export function formatVisitDisplayLocation(payload) {
const g = payload?.geo;
if (!g || typeof g !== "object") return "";
const parts = [g.countryName, g.regionName, g.cityName].filter(
(x) => typeof x === "string" && x.trim()
);
return parts.join(" ");
}
export async function fetchClientVisitMeta(timeoutMs = 2800) {
const ctrl = new AbortController();
const id = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(CLIENT_GEO_LOOKUP_URL, {
credentials: "omit",
signal: ctrl.signal
});
clearTimeout(id);
if (!res.ok) return { ip: "", displayLocation: "" };
const data = await res.json();
const ip = typeof data.ip === "string" ? data.ip.trim() : "";
const displayLocation = formatVisitDisplayLocation(data);
return { ip, displayLocation };
} catch {
clearTimeout(id);
return { ip: "", displayLocation: "" };
}
}
marked.setOptions({ breaks: true });
export { marked };
export const emptyForm = {
account: "",
password: "",
username: "",
email: "",
level: 0,
sproutCoins: 0,
secondaryEmails: "",
phone: "",
avatarUrl: "",
websiteUrl: "",
bio: "",
banned: false,
banReason: ""
};
/** 后端封禁响应:`error` + 可选 `banReason`(登录/me 等 403 */
export function formatAuthBanMessage(data) {
const base = (data && data.error && String(data.error).trim()) || "account is banned";
const reason = data && data.banReason != null && String(data.banReason).trim();
return reason ? `${base}${reason}` : base;
}
/** 展示用:从完整 URL 得到「主机 + 路径」短文案 */
export function formatWebsiteLabel(url) {
if (!url || typeof url !== "string") return "";
try {
const u = new URL(url);
const path = u.pathname === "/" ? "" : u.pathname;
return u.host + path;
} catch {
return url;
}
}
/** 用户 `createdAt`RFC3339→ 本地可读注册时间 */
export function formatUserRegisteredAt(iso) {
if (!iso || typeof iso !== "string") return "未知";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "long",
timeStyle: "short"
}).format(d);
}
/** RFC3339 / 后端存储时间 → 本地可读(用于应用接入记录等) */
export function formatIsoDateTimeReadable(iso) {
if (!iso || typeof iso !== "string") return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "medium"
}).format(d);
}
export const parseEmailList = (value) =>
value.split(",").map((item) => item.trim()).filter(Boolean);
/** 可点击发信:`mailto:`,排除明显非法地址 */
export function mailtoHref(address) {
const a = typeof address === "string" ? address.trim() : "";
if (!a || /\s/.test(a) || a.includes("<") || a.includes(">")) return null;
const at = a.indexOf("@");
if (at < 1 || at === a.length - 1) return null;
return `mailto:${a}`;
}
export function getPublicAccountFromPathname(pathname) {
if (pathname !== "/user" && !pathname.startsWith("/user/")) return "";
const raw = pathname === "/user" ? "" : pathname.slice("/user/".length).split("/")[0];
if (!raw) return "";
try {
return decodeURIComponent(raw).trim();
} catch {
return raw.trim();
}
}
export function getAuthFlowFromSearch(search) {
const params = new URLSearchParams(search);
const redirectUri = (params.get("redirect_uri") || params.get("return_url") || "").trim();
return {
redirectUri,
state: (params.get("state") || "").trim(),
prompt: (params.get("prompt") || "").trim(),
clientId: (params.get("client_id") || "").trim(),
clientName: (params.get("client_name") || "").trim()
};
}
const AUTH_CLIENT_ID_KEY = "sproutgate_auth_client_id";
const AUTH_CLIENT_NAME_KEY = "sproutgate_auth_client_name";
/** 从统一登录 URL 上的 `client_id` / `client_name` 写入 session后续请求带 `X-Auth-Client` 头以便记录接入应用 */
export function persistAuthClientFromFlow(authFlow) {
if (!authFlow?.clientId) return;
try {
sessionStorage.setItem(AUTH_CLIENT_ID_KEY, authFlow.clientId);
if (authFlow.clientName) sessionStorage.setItem(AUTH_CLIENT_NAME_KEY, authFlow.clientName);
} catch {
/* ignore */
}
}
export function authClientFetchHeaders() {
try {
const id = (sessionStorage.getItem(AUTH_CLIENT_ID_KEY) || "").trim();
const name = (sessionStorage.getItem(AUTH_CLIENT_NAME_KEY) || "").trim();
const h = {};
if (id) h["X-Auth-Client"] = id;
if (name) h["X-Auth-Client-Name"] = name;
return h;
} catch {
return {};
}
}
export function clearAuthClientContext() {
try {
sessionStorage.removeItem(AUTH_CLIENT_ID_KEY);
sessionStorage.removeItem(AUTH_CLIENT_NAME_KEY);
} catch {
/* ignore */
}
}
export function buildAuthCallbackUrl(redirectUri, payload) {
const url = new URL(redirectUri, window.location.href);
const hashParams = new URLSearchParams(url.hash ? url.hash.slice(1) : "");
Object.entries(payload).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") return;
hashParams.set(key, String(value));
});
url.hash = hashParams.toString();
return url.toString();
}

View File

@@ -0,0 +1,132 @@
import React from "react";
const icons = {
account: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M4 20c1.5-3.5 5-5 8-5s6.5 1.5 8 5" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
password: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="5" y="10" width="14" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 10V7a4 4 0 0 1 8 0v3" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
username: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="9" cy="8" r="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 20c1-3 4-4.5 6-4.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M14 7h7M14 11h7M14 15h5" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
email: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="6" width="18" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 8l9 6 9-6" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
coins: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<ellipse cx="12" cy="7" rx="7" ry="3.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M5 7v5c0 1.9 3.1 3.5 7 3.5s7-1.6 7-3.5V7" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M5 12v5c0 1.9 3.1 3.5 7 3.5s7-1.6 7-3.5v-5" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
phone: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="7" y="3" width="10" height="18" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<circle cx="12" cy="17" r="1" fill="currentColor" />
</svg>
),
avatar: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<circle cx="9" cy="10" r="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 17l5-4 4 3 4-5 5 6" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
bio: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h8l4 4v12H6z" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M14 4v4h4" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 12h8M8 16h6" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
link: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M10 13a5 5 0 0 1 0-7l1-1a5 5 0 0 1 7 7l-1 1" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M14 11a5 5 0 0 1 0 7l-1 1a5 5 0 0 1-7-7l1-1" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
token: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="7" cy="12" r="4" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M11 12h10M17 12v4M20 12v2" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
level: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M9 15h6M9 9h6M9 12h6" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
calendar: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="5" width="16" height="15" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M4 9h16M8 3v4M16 3v4" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 13h3M13 13h3M8 16h3" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
secondaryEmail: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="6" width="14" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M4 8l7 4 7-4" fill="none" stroke="currentColor" strokeWidth="1.8" />
<rect x="8" y="10" width="12" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
visitIp: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 12h18M12 3a15 15 0 0 1 0 18" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
visitGeo: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" fill="none" stroke="currentColor" strokeWidth="1.8" />
<circle cx="12" cy="10" r="2.2" fill="currentColor" />
</svg>
),
ban: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M6 18L18 6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
),
apps: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="4" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<rect x="13" y="4" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<rect x="4" y="13" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<rect x="13" y="13" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
statLightning: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M13 2L4 14h7l-1 8 9-12h-7l1-8z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
),
statClock: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M12 7v6l4 2" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
statRepeat: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M17 3h4v4M7 21H3v-4M3 12a9 9 0 0 1 14.3-7.2L21 8M21 12a9 9 0 0 1-14.3 7.2L3 16" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
)
};
export default icons;

File diff suppressed because it is too large Load Diff

View File

@@ -17,9 +17,7 @@ start "SproutGate Backend" cmd /k "cd /d %ROOT_DIR%sproutgate-backend && go run
echo ==^> Starting frontend (React)... echo ==^> Starting frontend (React)...
cd /d %ROOT_DIR%sproutgate-frontend cd /d %ROOT_DIR%sproutgate-frontend
if not exist node_modules (
npm install
)
npm run dev npm run dev
exit /b 0 exit /b 0