82 lines
140 KiB
JSON
82 lines
140 KiB
JSON
{"type":"session","version":3,"id":"019ed051-b08b-70ab-8300-8d3420b89da1","timestamp":"2026-06-16T12:04:35.083Z","cwd":"/shumengya/project/agent/sproutclaw"}
|
||
{"type":"model_change","id":"9cea3f5b","parentId":null,"timestamp":"2026-06-16T12:04:35.326Z","provider":"xiaomimimo-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"c0527268","parentId":"9cea3f5b","timestamp":"2026-06-16T12:04:35.326Z","thinkingLevel":"high"}
|
||
{"type":"message","id":"6f74dd2c","parentId":"c0527268","timestamp":"2026-06-16T12:05:09.093Z","message":{"role":"user","content":[{"type":"text","text":"查看一下/shumengya/project/frontend-backend/SproutGate这个项目给我讲讲"}],"timestamp":1781611509093}}
|
||
{"type":"message","id":"bf1eff37","parentId":"6f74dd2c","timestamp":"2026-06-16T12:05:10.625Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户想了解 /shumengya/project/frontend-backend/SproutGate 这个项目。我需要先看看这个项目的结构,然后读取关键文件来理解它是做什么的。\n\n首先,让我看看这个目录的内容。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_8d1a5935ba65457f95761ff1","name":"bash","arguments":{"command":"ls -la /shumengya/project/frontend-backend/SproutGate"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":10513,"output":83,"cacheRead":1024,"cacheWrite":0,"totalTokens":11620,"cost":{"input":0.0042052,"output":0.000166,"cacheRead":0.00008192,"cacheWrite":0,"total":0.0044531200000000005}},"stopReason":"toolUse","timestamp":1781611509094,"responseId":"b42870eaac3d48eeaa161da37552f37b"}}
|
||
{"type":"message","id":"2589801a","parentId":"bf1eff37","timestamp":"2026-06-16T12:05:10.641Z","message":{"role":"toolResult","toolCallId":"call_8d1a5935ba65457f95761ff1","toolName":"bash","content":[{"type":"text","text":"总计 88\ndrwxr-xr-x 9 root root 4096 6月 3日 14:41 .\ndrwxr-xr-x 15 root root 4096 4月18日 14:17 ..\ndrwxr-xr-x 2 root root 4096 4月 2日 00:00 公网配置\n-rw-r--r-- 1 root root 25827 6月 3日 14:41 萌芽账户认证中心-第三方应用API接入文档.md\n-rw-r--r-- 1 root root 0 4月 2日 00:00 萌芽账户认证中心-项目经历.md\n-rw-r--r-- 1 root root 3168 3月21日 00:00 AGENTS.md\ndrwxr-xr-x 2 root root 4096 6月 3日 14:41 api-docs\ndrwxr-xr-x 2 root root 4096 4月 2日 00:00 .cursor\n-rw-r--r-- 1 root root 572 3月29日 00:00 dev.bat\n-rw-r--r-- 1 root root 915 3月29日 00:00 dev.sh\ndrwxr-xr-x 3 root root 4096 6月 3日 14:41 docs\ndrwxr-xr-x 8 root root 4096 6月16日 20:04 .git\n-rw-r--r-- 1 root root 303 3月18日 22:16 .gitignore\n-rw-r--r-- 1 root root 7539 3月29日 00:00 README.md\ndrwxr-xr-x 5 root root 4096 6月 3日 14:41 sproutgate-backend\ndrwxr-xr-x 6 root root 4096 4月 2日 00:00 sproutgate-frontend\n"}],"isError":false,"timestamp":1781611510641}}
|
||
{"type":"message","id":"b51f5d83","parentId":"2589801a","timestamp":"2026-06-16T12:05:12.116Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"这个项目看起来是一个前后端分离的项目,有 frontend 和 backend 两个目录。项目名是 SproutGate,从目录结构看,应该是“萌芽账户认证中心”相关的项目。让我先读 README.md 了解整体情况。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_c3360733fd3543d48252072e","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/README.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":664,"output":85,"cacheRead":11520,"cacheWrite":0,"totalTokens":12269,"cost":{"input":0.0002656,"output":0.00016999999999999999,"cacheRead":0.0009216000000000001,"cacheWrite":0,"total":0.0013572}},"stopReason":"toolUse","timestamp":1781611510641,"responseId":"8952b51d35304771afb9f43f43a29088"}}
|
||
{"type":"message","id":"a35333a7","parentId":"b51f5d83","timestamp":"2026-06-16T12:05:12.120Z","message":{"role":"toolResult","toolCallId":"call_c3360733fd3543d48252072e","toolName":"read","content":[{"type":"text","text":"# SproutGate(萌芽账户认证中心)\n\n前后端分离的统一账户与轻量用户中心:注册登录、邮箱验证、找回密码、副邮箱、签到与资料管理;管理员可维护用户、签到与注册/邀请码策略。数据以 **JSON 文件落盘**(无传统数据库),适合自建与小规模部署。第三方站点(如万象口袋)通过 **OAuth 式查询参数** 与 **JWT / 校验接口** 接入,详见 [`sproutgate-backend/API_DOCS.md`](./sproutgate-backend/API_DOCS.md)。\n\n---\n\n## 架构总览\n\n```mermaid\nflowchart LR\n subgraph client [浏览器]\n FE[React + Vite SPA]\n end\n subgraph api [sproutgate-backend]\n Gin[Gin HTTP]\n H[handlers]\n S[storage JSON]\n J[auth JWT]\n Gin --> H --> S\n H --> J\n end\n FE -->|HTTPS /api| Gin\n S --> FS[(data/config + data/users)]\n```\n\n| 部分 | 技术栈 | 目录 | 说明 |\n|------|--------|------|------|\n| 前端 | React 18 + Vite 5 | [`sproutgate-frontend/`](./sproutgate-frontend/) | 无 `react-router`;由 [`App.jsx`](./sproutgate-frontend/src/App.jsx) 按 `pathname` 切换视图;`VITE_API_BASE` 指向后端 |\n| 后端 | Go 1.20 + Gin | [`sproutgate-backend/`](./sproutgate-backend/) | 单体 REST;默认端口 `8080`;CORS 放行常用头(含 `Authorization`、`X-Admin-Token`、`X-Auth-Client*`) |\n| 数据 | JSON 文件 | [`sproutgate-backend/data/`](./sproutgate-backend/data/) | `config/`(`admin.json`、`auth.json` 含 JWT 密钥、`email.json`、`checkin.json`、`registration.json` 等)、`users/`(用户记录) |\n\n---\n\n## 后端代码结构(`sproutgate-backend/`)\n\n| 包 / 目录 | 职责 |\n|-----------|------|\n| [`main.go`](./sproutgate-backend/main.go) | 初始化 [`storage.Store`](./sproutgate-backend/internal/storage/storage.go)、注册路由、监听 `PORT` |\n| [`internal/handlers/`](./sproutgate-backend/internal/handlers/) | HTTP 处理:`auth_login`、`auth_password`、`profile`、`checkin`、`admin`、`public_registration`、`registration_admin`、`secondary_email`、`auth_client` 等;[`handler.go`](./sproutgate-backend/internal/handlers/handler.go) 聚合依赖 |\n| [`internal/storage/`](./sproutgate-backend/internal/storage/storage.go) | 数据目录布局、读写 JSON、待验证/重置令牌等 |\n| [`internal/models/`](./sproutgate-backend/internal/models/) | `User`、`Activity`、待审核、副邮箱、接入方元数据等结构体 |\n| [`internal/auth/jwt.go`](./sproutgate-backend/internal/auth/jwt.go) | JWT 签发与解析(HS256) |\n| [`internal/email/mailer.go`](./sproutgate-backend/internal/email/mailer.go) | SMTP 发信(配置来自 `data/config/email.json`) |\n| [`internal/clientgeo/`](./sproutgate-backend/internal/clientgeo/clientgeo.go) | 与访问来源等辅助逻辑配合(请求头见 `main.go` CORS) |\n\n**API 前缀(与 [`main.go`](./sproutgate-backend/main.go) 一致)**\n\n| 前缀 | 说明 |\n|------|------|\n| `/api/auth/*` | 登录、注册、邮箱验证、忘记/重置密码、副邮箱、`POST /verify`、`GET /me`、签到、`PUT /profile`;可带 `X-Auth-Client` / `X-Auth-Client-Name` 记录第三方应用 |\n| `/api/public/*` | 公开用户资料、注册策略(是否强制邀请码等) |\n| `/api/admin/*` | 需 [`AdminMiddleware`](./sproutgate-backend/internal/handlers/admin.go):请求头 `X-Admin-Token` 或 Query `token`,与用户 JWT 分离 |\n\n**运维与文档**:`GET /`、`GET /api`(JSON 服务说明)、`GET /api/health`、`GET /api/docs`(返回仓库内 `API_DOCS.md`)。\n\n---\n\n## 前端代码结构(`sproutgate-frontend/`)\n\n| 路径 | 说明 |\n|------|------|\n| [`src/App.jsx`](./sproutgate-frontend/src/App.jsx) | 根组件:根据路径渲染门户 / 公开用户页 / 管理端;顶栏 **连点 Logo** 进入管理员流程 |\n| [`src/config.js`](./sproutgate-frontend/src/config.js) | `VITE_API_BASE`、开发与生产默认 API 地址 |\n| [`src/components/UserPortal.jsx`](./sproutgate-frontend/src/components/UserPortal.jsx) | `/`:登录、注册、邮件验证、`redirect_uri` 统一登录回跳等 |\n| [`src/components/PublicUserPage.jsx`](./sproutgate-frontend/src/components/PublicUserPage.jsx) | `/user`、`/user/:account`:公开资料 |\n| [`src/components/AdminPanel.jsx`](./sproutgate-frontend/src/components/AdminPanel.jsx) | `/admin`:用户与签到、注册策略管理 |\n\n---\n\n## 认证与安全(摘要)\n\n1. **用户会话**:登录成功后签发 JWT(`Authorization: Bearer`);受保护接口解析 JWT 后按 `account` 加载用户;封禁等状态见 handlers 内校验。\n2. **令牌校验**:`POST /api/auth/verify` 供第三方后端校验用户 token(见 API 文档)。\n3. **管理员**:与业务 JWT 独立,使用 **`X-Admin-Token`**(或 Query `token`)与 `data/config/admin.json` 比对。\n4. **第三方统一登录门户**:入口 URL 查询参数 `redirect_uri`、`client_id`、`client_name` 等;前端通过 `sessionStorage` 与请求头 **`X-Auth-Client`** / **`X-Auth-Client-Name`** 与后端协同(CORS 已允许上述头)。\n\n部署到公网前:**修改默认 JWT 密钥、管理员 Token、SMTP 密码**,且勿将含真实密钥的 `data/config` 提交到公开仓库。\n\n---\n\n## 环境要求\n\n- **后端**:Go **1.20+**(以 [`sproutgate-backend/go.mod`](./sproutgate-backend/go.mod) 为准)\n- **前端**:Node.js **18+**(建议 LTS)\n\n---\n\n## 快速开始\n\n### 方式一:根目录脚本(推荐本地开发)\n\n**Windows**\n\n```bat\nsproutgate.bat dev\n```\n\n**macOS / Linux**\n\n```bash\nchmod +x sproutgate.sh\n./sproutgate.sh dev\n```\n\n会启动后端 `go run .` 与前端 `npm run dev`(前端默认 **5173**,后端默认 **8080**)。\n\n仅构建前端:\n\n```bat\nsproutgate.bat build\n```\n\n```bash\n./sproutgate.sh build\n```\n\n### 方式二:手动分别启动\n\n**后端**\n\n```bash\ncd sproutgate-backend\ngo mod tidy\ngo run .\n```\n\n**前端**\n\n```bash\ncd sproutgate-frontend\nnpm install\nnpm run dev\n```\n\n### 前端连接后端\n\n在 `sproutgate-frontend/.env`(自行创建)中设置:\n\n```env\nVITE_API_BASE=http://localhost:8080\n```\n\n生产环境改为实际 API 地址(如 `https://auth.api.example.com`)。\n\n### 配置与安全\n\n- 管理员 Token、JWT 密钥、邮件 SMTP 等位于 **`sproutgate-backend/data/config/`**(如 `admin.json`、`auth.json`、`email.json`)。**部署到公网前请务必修改默认值,且不要将真实密钥提交到仓库。**\n- 进入管理员后台:在任意页面顶栏 **连续点击 Logo 五次**(约 2.6 秒内),在弹窗中输入与 `admin.json` 一致的 Token;也可直接使用 `http://localhost:5173/admin?token=<Token>`(Token 会写入本地后再请求接口)。\n\n### 可选:Docker 仅跑后端 API\n\n在 [`sproutgate-backend`](./sproutgate-backend) 目录:\n\n```bash\ndocker compose up -d --build\n```\n\n默认将容器内 `8080` 映射到主机 **`28080`**(可通过环境变量 **`AUTH_API_PORT`** 修改)。数据目录通过卷挂载到 **`./data`**。\n\n---\n\n## 环境变量(后端)\n\n| 变量 | 说明 |\n|------|------|\n| `PORT` | 监听端口,默认 `8080` |\n| `DATA_DIR` | 数据根目录;不设置时使用仓库内相对路径下的 `data` 布局 |\n\n---\n\n## 相关文档\n\n| 文档 | 内容 |\n|------|------|\n| [`sproutgate-backend/API_DOCS.md`](./sproutgate-backend/API_DOCS.md) | HTTP 接口契约(第三方接入主参考) |\n| [`AGENTS.md`](./AGENTS.md) | 目录约定、协作与 PR 建议 |\n\n---\n\n## 许可证\n\n若仓库未包含 `LICENSE` 文件,使用前请与维护者确认授权方式。\n"}],"isError":false,"timestamp":1781611512119}}
|
||
{"type":"message","id":"f8ed5a94","parentId":"a35333a7","timestamp":"2026-06-16T12:05:14.347Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我对这个项目有了整体了解。这是一个前后端分离的统一账户认证中心项目,叫做 SproutGate(萌芽账户认证中心)。主要功能包括:注册登录、邮箱验证、找回密码、副邮箱、签到与资料管理等。数据用 JSON 文件存储,没有传统数据库。第三方站点可以通过 OAuth 式查询参数和 JWT 接入。\n\n让我再看看一些关键文件来深入了解。先看看 AGENTS.md,了解项目规范和目录约定。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_941e0003ba584fd1bd3c4ed3","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/AGENTS.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":2373,"output":134,"cacheRead":12160,"cacheWrite":0,"totalTokens":14667,"cost":{"input":0.0009492000000000001,"output":0.000268,"cacheRead":0.0009728,"cacheWrite":0,"total":0.00219}},"stopReason":"toolUse","timestamp":1781611512120,"responseId":"3d49527d798d47ab9068ca601a89594e"}}
|
||
{"type":"message","id":"36c4b83e","parentId":"f8ed5a94","timestamp":"2026-06-16T12:05:14.348Z","message":{"role":"toolResult","toolCallId":"call_941e0003ba584fd1bd3c4ed3","toolName":"read","content":[{"type":"text","text":"# Repository Guidelines\n\n## Project Structure & Module Organization\n- `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`.\n- `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/`.\n- `sproutgate-backend/data/`: JSON-backed config and data. Config files live in `data/config/`, user records in `data/users/`.\n- Root scripts `sproutgate.sh` and `sproutgate.bat` provide dev and build shortcuts.\n\n## Build, Test, and Development Commands\n- `./sproutgate.sh dev` or `sproutgate.bat dev`: start backend and frontend dev servers.\n- `./sproutgate.sh build` or `sproutgate.bat build`: build the frontend bundle.\n- `cd sproutgate-backend && go mod tidy`: sync Go dependencies.\n- `cd sproutgate-backend && go run .`: run the API (default port `8080`).\n- `cd sproutgate-frontend && npm install`: install frontend dependencies.\n- `cd sproutgate-frontend && npm run dev`: run Vite dev server (default `5173`).\n- `cd sproutgate-frontend && npm run build`: create a production build.\n- `cd sproutgate-frontend && npm run preview`: preview the build locally.\n\n## Coding Style & Naming Conventions\n- Go code should be `gofmt`-formatted; packages are lowercase, exported types use PascalCase, JSON tags use lowerCamelCase.\n- Frontend code follows existing `src/*.jsx` patterns: 2-space indentation, double quotes, components in PascalCase, hooks prefixed with `use`.\n- File naming mirrors current structure: Go files are lowercase (use underscores when needed, e.g., `secondary_email.go`); React components use `.jsx`.\n\n## Testing Guidelines\n- No automated test suites are configured yet (no `*_test.go` and no `npm test` script).\n- When adding backend tests, use Go's standard testing (`*_test.go`) and run `go test ./...` in `sproutgate-backend/`.\n- If frontend tests are introduced, document the runner and command in `sproutgate-frontend/package.json` and update this guide.\n\n## Commit & Pull Request Guidelines\n- Git history currently contains only `Initial commit`; there is no established commit convention yet.\n- Use short, imperative commit summaries; add a scope when helpful (e.g., `frontend: refine admin table`).\n- PRs should include a concise summary, testing notes, and screenshots for UI changes; call out any config or env var updates.\n\n## Security & Configuration Tips\n- Backend configuration is stored under `sproutgate-backend/data/config/` (admin, auth, email, check-in, registration invites).\n- Frontend API base URL can be set via `sproutgate-frontend/.env` using `VITE_API_BASE`.\n- Avoid committing real credentials or production tokens; use local overrides for secrets.\n- API reference lives in `sproutgate-backend/API_DOCS.md` and is served at `GET /api/docs`. Brief JSON overview: `GET /` and `GET /api`.\n"}],"isError":false,"timestamp":1781611514348}}
|
||
{"type":"message","id":"c69d560b","parentId":"36c4b83e","timestamp":"2026-06-16T12:05:16.912Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我看看后端的 main.go 和一些关键文件,了解具体实现。先看看后端目录结构。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_d3820dd9119943a88a0388e3","name":"bash","arguments":{"command":"ls -la /shumengya/project/frontend-backend/SproutGate/sproutgate-backend"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":926,"output":66,"cacheRead":14528,"cacheWrite":0,"totalTokens":15520,"cost":{"input":0.00037040000000000006,"output":0.00013199999999999998,"cacheRead":0.00116224,"cacheWrite":0,"total":0.00166464}},"stopReason":"toolUse","timestamp":1781611514348,"responseId":"f6f7d3de29c74e49bc575245a338ca17"}}
|
||
{"type":"message","id":"39d335fa","parentId":"c69d560b","timestamp":"2026-06-16T12:05:16.926Z","message":{"role":"toolResult","toolCallId":"call_d3820dd9119943a88a0388e3","toolName":"bash","content":[{"type":"text","text":"总计 80\ndrwxr-xr-x 5 root root 4096 6月 3日 14:41 .\ndrwxr-xr-x 9 root root 4096 6月 3日 14:41 ..\n-rw-r--r-- 1 root root 6214 4月 2日 00:00 后端文档.md\n-rw-r--r-- 1 root root 1365 6月 3日 14:41 build-linux-amd64.bat\n-rw-r--r-- 1 root root 93 6月 3日 14:41 build-Linux-amd.bat\ndrwxr-xr-x 3 root root 4096 4月 2日 00:00 cmd\n-rw-r--r-- 1 root root 0 6月 3日 14:41 .codex\n-rw-r--r-- 1 root root 295 3月21日 00:00 docker-compose.yml\n-rw-r--r-- 1 root root 665 6月 3日 14:41 Dockerfile\n-rw-r--r-- 1 root root 160 6月 3日 14:41 .dockerignore\ndrwxr-xr-x 2 root root 4096 6月 3日 14:41 docs\n-rw-r--r-- 1 root root 2507 6月 3日 14:41 go.mod\n-rw-r--r-- 1 root root 17276 6月 3日 14:41 go.sum\ndrwxr-xr-x 9 root root 4096 4月 2日 00:00 internal\n-rw-r--r-- 1 root root 5735 6月 3日 14:41 main.go\n"}],"isError":false,"timestamp":1781611516926}}
|
||
{"type":"message","id":"67ea011e","parentId":"39d335fa","timestamp":"2026-06-16T12:05:18.036Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看 main.go 了解后端的入口点。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_1dd8615ae3d1488a9fe0a4a9","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/sproutgate-backend/main.go"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":592,"output":50,"cacheRead":15424,"cacheWrite":0,"totalTokens":16066,"cost":{"input":0.0002368,"output":0.00009999999999999999,"cacheRead":0.0012339200000000001,"cacheWrite":0,"total":0.00157072}},"stopReason":"toolUse","timestamp":1781611516927,"responseId":"72a894c1a95c4d60808a7b9681c27c4a"}}
|
||
{"type":"message","id":"ad2008ab","parentId":"67ea011e","timestamp":"2026-06-16T12:05:18.039Z","message":{"role":"toolResult","toolCallId":"call_1dd8615ae3d1488a9fe0a4a9","toolName":"read","content":[{"type":"text","text":"//go:generate swag init -g main.go -o docs --parseInternal\n\n// @title 萌芽账户认证中心 API\n// @version 0.1.0\n// @description 统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。\n// @host localhost:8080\n// @BasePath /\n\n// @securityDefinitions.apikey BearerAuth\n// @in header\n// @name Authorization\n// @description JWT:Authorization 头填写「Bearer 」+ token(示例:Bearer eyJ...)。\n\n// @securityDefinitions.apikey AdminToken\n// @in header\n// @name X-Admin-Token\n// @description 管理端令牌;也可使用 Query `token` 或与部分客户端相同的 `Authorization` 头(见实现)。\n\n// @schemes http https\npackage main\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-gonic/gin\"\n\tswaggerFiles \"github.com/swaggo/files\"\n\tginSwagger \"github.com/swaggo/gin-swagger\"\n\n\t_ \"sproutgate-backend/docs\"\n\t\"sproutgate-backend/internal/database\"\n\t\"sproutgate-backend/internal/handlers\"\n\t\"sproutgate-backend/internal/storage\"\n)\n\nfunc apiIntroPayload() gin.H {\n\treturn gin.H{\n\t\t\"name\": \"SproutGate API\",\n\t\t\"title\": \"萌芽账户认证中心\",\n\t\t\"description\": \"统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。\",\n\t\t\"version\": \"0.1.0\",\n\t\t\"links\": gin.H{\n\t\t\t\"health\": \"GET /api/health\",\n\t\t\t\"docs\": \"GET /swagger/index.html\",\n\t\t},\n\t\t\"routePrefixes\": []string{\n\t\t\t\"/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入\",\n\t\t\t\"/api/public — 公开用户目录与资料、主页点赞、注册策略\",\n\t\t\t\"/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token)\",\n\t\t},\n\t}\n}\n\n// GetRoot\n// @Summary API 简介 JSON\n// @Description 与 GET /api 相同,返回名称、版本与路由前缀说明。\n// @Tags meta\n// @Produce json\n// @Success 200 {object} map[string]interface{}\n// @Router / [get]\nfunc GetRoot(c *gin.Context) {\n\tc.JSON(http.StatusOK, apiIntroPayload())\n}\n\n// GetAPI\n// @Summary API 简介 JSON\n// @Description 与 GET / 相同。\n// @Tags meta\n// @Produce json\n// @Success 200 {object} map[string]interface{}\n// @Router /api [get]\nfunc GetAPI(c *gin.Context) {\n\tc.JSON(http.StatusOK, apiIntroPayload())\n}\n\n// GetHealth\n// @Summary 健康检查\n// @Tags meta\n// @Produce json\n// @Success 200 {object} map[string]interface{}\n// @Router /api/health [get]\nfunc GetHealth(c *gin.Context) {\n\tenv := os.Getenv(\"APP_ENV\")\n\tif env == \"\" {\n\t\tenv = \"development\"\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"status\": \"ok\",\n\t\t\"env\": env,\n\t})\n}\n\nfunc main() {\n\tdb, err := database.Open()\n\tif err != nil {\n\t\tlog.Fatalf(\"初始化数据库失败: %v\", err)\n\t}\n\n\tstore, err := storage.NewStore(db)\n\tif err != nil {\n\t\tlog.Fatalf(\"初始化储存失败: %v\", err)\n\t}\n\n\trouter := gin.Default()\n\trouter.Use(cors.New(cors.Config{\n\t\tAllowOrigins: []string{\"*\"},\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\", \"X-Admin-Token\", \"X-Visit-Ip\", \"X-Visit-Location\", \"X-Auth-Client\", \"X-Auth-Client-Name\"},\n\t\tMaxAge: 12 * time.Hour,\n\t}))\n\n\thandler := handlers.NewHandler(store)\n\n\trouter.GET(\"/\", GetRoot)\n\trouter.GET(\"/api\", GetAPI)\n\n\trouter.GET(\"/api/health\", GetHealth)\n\n\trouter.GET(\"/api/docs\", func(c *gin.Context) {\n\t\tc.Redirect(http.StatusFound, \"/swagger/index.html\")\n\t})\n\trouter.GET(\"/swagger/*any\", ginSwagger.WrapHandler(swaggerFiles.Handler))\n\n\trouter.POST(\"/api/auth/login\", handler.Login)\n\trouter.POST(\"/api/auth/register\", handler.Register)\n\trouter.POST(\"/api/auth/verify-email\", handler.VerifyEmail)\n\trouter.POST(\"/api/auth/forgot-password\", handler.ForgotPassword)\n\trouter.POST(\"/api/auth/reset-password\", handler.ResetPassword)\n\trouter.POST(\"/api/auth/secondary-email/request\", handler.RequestSecondaryEmail)\n\trouter.POST(\"/api/auth/secondary-email/verify\", handler.VerifySecondaryEmail)\n\trouter.POST(\"/api/auth/verify\", handler.Verify)\n\trouter.GET(\"/api/auth/me\", handler.Me)\n\trouter.POST(\"/api/auth/check-in\", handler.CheckIn)\n\trouter.PUT(\"/api/auth/profile\", handler.UpdateProfile)\n\trouter.GET(\"/api/auth/oauth/:provider/start\", handler.OAuthStart)\n\trouter.GET(\"/api/auth/oauth/:provider/callback\", handler.OAuthCallback)\n\trouter.POST(\"/api/auth/oauth/:provider/bind\", handler.OAuthBindURL)\n\trouter.DELETE(\"/api/auth/oauth/:provider\", handler.OAuthUnlink)\n\trouter.GET(\"/api/public/users\", handler.ListPublicUsers)\n\trouter.GET(\"/api/public/users/:account\", handler.GetPublicUser)\n\trouter.POST(\"/api/public/users/:account/like\", handler.PostPublicProfileLike)\n\trouter.GET(\"/api/public/registration-policy\", handler.GetPublicRegistrationPolicy)\n\n\tadmin := router.Group(\"/api/admin\")\n\tadmin.Use(handler.AdminMiddleware())\n\tadmin.GET(\"/users\", handler.ListUsers)\n\tadmin.POST(\"/users\", handler.CreateUser)\n\tadmin.PUT(\"/users/:account\", handler.UpdateUser)\n\tadmin.DELETE(\"/users/:account\", handler.DeleteUser)\n\tadmin.GET(\"/check-in/config\", handler.GetCheckInConfig)\n\tadmin.PUT(\"/check-in/config\", handler.UpdateCheckInConfig)\n\tadmin.GET(\"/registration\", handler.GetAdminRegistration)\n\tadmin.PUT(\"/registration\", handler.PutAdminRegistrationPolicy)\n\tadmin.POST(\"/registration/invites\", handler.PostAdminInvite)\n\tadmin.DELETE(\"/registration/invites/:code\", handler.DeleteAdminInvite)\n\tadmin.GET(\"/oauth\", handler.GetAdminOAuth)\n\tadmin.PUT(\"/oauth\", handler.PutAdminOAuth)\n\tadmin.GET(\"/turnstile\", handler.GetAdminTurnstile)\n\tadmin.PUT(\"/turnstile\", handler.PutAdminTurnstile)\n\n\tport := os.Getenv(\"PORT\")\n\tif port == \"\" {\n\t\tport = \"8080\"\n\t}\n\tif err := router.Run(\":\" + port); err != nil {\n\t\tlog.Fatalf(\"server stopped: %v\", err)\n\t}\n}\n"}],"isError":false,"timestamp":1781611518038}}
|
||
{"type":"message","id":"4e8bd1f5","parentId":"ad2008ab","timestamp":"2026-06-16T12:05:19.528Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我看看前端的主要组件,特别是 App.jsx。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_aae1753ca8804fd4bf8d2933","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/sproutgate-frontend/src/App.jsx"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1573,"output":51,"cacheRead":16000,"cacheWrite":0,"totalTokens":17624,"cost":{"input":0.0006292,"output":0.000102,"cacheRead":0.00128,"cacheWrite":0,"total":0.0020112000000000003}},"stopReason":"toolUse","timestamp":1781611518039,"responseId":"89a996df48c747df99fdf5ce3e2a8d99"}}
|
||
{"type":"message","id":"fcac8a1d","parentId":"4e8bd1f5","timestamp":"2026-06-16T12:05:19.529Z","message":{"role":"toolResult","toolCallId":"call_aae1753ca8804fd4bf8d2933","toolName":"read","content":[{"type":"text","text":"import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport {\n API_BASE,\n buildAuthDenyCallbackUrl,\n getPublicAccountFromPathname,\n getAuthFlowFromSearch,\n LOGO_192_SRC\n} from \"./config\";\nimport SplashScreen from \"./components/SplashScreen\";\nimport RandomSiteBackground from \"./components/RandomSiteBackground\";\nimport PublicUserPage from \"./components/PublicUserPage\";\nimport PublicUserListPage from \"./components/PublicUserListPage\";\nimport UserPortal from \"./components/UserPortal\";\nimport AdminPanel from \"./components/AdminPanel\";\n\nfunction App() {\n const pathname = window.location.pathname;\n const search = window.location.search;\n const basePrefix = useMemo(() => {\n const baseUrl = import.meta.env.BASE_URL || \"/\";\n return baseUrl.endsWith(\"/\") ? baseUrl : `${baseUrl}/`;\n }, []);\n const authorizePathname = useMemo(() => {\n try {\n return new URL(\"authorize\", `http://x${basePrefix}`).pathname;\n } catch {\n return \"/authorize\";\n }\n }, [basePrefix]);\n\n const isAdmin = pathname.startsWith(\"/admin\");\n const isPublicUserList = pathname === \"/users\";\n const isPublicUser = pathname === \"/user\" || pathname.startsWith(\"/user/\");\n const isAuthorizeRoute = pathname === authorizePathname;\n const authFlow = useMemo(() => getAuthFlowFromSearch(search), [search]);\n const publicAccount = useMemo(() => getPublicAccountFromPathname(pathname), [pathname]);\n\n const isHomeUserCenter = !isAdmin && !isPublicUserList && !isPublicUser && !isAuthorizeRoute;\n const usesUserPortal = isAuthorizeRoute || isHomeUserCenter;\n\n const [guestAuthLayout, setGuestAuthLayout] = useState(false);\n const shellAuthGuest = guestAuthLayout && usesUserPortal;\n /** 与未登录用户中心同套全屏 + 底图 + 主区磨砂 */\n const shellPublicImmersive = isPublicUserList || isPublicUser;\n const shellImmersive = Boolean(shellAuthGuest || shellPublicImmersive);\n\n useLayoutEffect(() => {\n if (!usesUserPortal) setGuestAuthLayout(false);\n }, [usesUserPortal]);\n\n const authDenyHref = useMemo(() => {\n const r = (authFlow?.redirectUri || \"\").trim();\n if (!r) return \"\";\n return buildAuthDenyCallbackUrl(r, authFlow?.state || \"\");\n }, [authFlow]);\n\n useLayoutEffect(() => {\n const params = new URLSearchParams(search);\n const hasRedirect = (params.get(\"redirect_uri\") || params.get(\"return_url\") || \"\").trim();\n if (!hasRedirect) return;\n if (isAuthorizeRoute) return;\n if (isAdmin || isPublicUserList || isPublicUser) return;\n const target = new URL(\"authorize\", `${window.location.origin}${basePrefix}`);\n target.search = search;\n window.location.replace(target.href);\n }, [search, pathname, isAuthorizeRoute, isAdmin, isPublicUserList, isPublicUser, basePrefix]);\n\n const [booting, setBooting] = useState(true);\n const markReady = useMemo(() => () => setBooting(false), []);\n\n const [imagePreview, setImagePreview] = useState({ open: false, src: \"\", alt: \"\" });\n\n const logoTapCountRef = useRef(0);\n const logoTapResetRef = useRef(null);\n const [adminGateOpen, setAdminGateOpen] = useState(false);\n const [adminGateToken, setAdminGateToken] = useState(\"\");\n const [adminGateError, setAdminGateError] = useState(\"\");\n const [adminGateLoading, setAdminGateLoading] = useState(false);\n\n const openImagePreview = (src, alt = \"\") => {\n if (!src) return;\n setImagePreview({ open: true, src, alt });\n };\n\n const closeImagePreview = () => {\n setImagePreview({ open: false, src: \"\", alt: \"\" });\n };\n\n useEffect(() => {\n const timer = setTimeout(() => setBooting(false), 4000);\n return () => clearTimeout(timer);\n }, []);\n\n useEffect(() => () => {\n if (logoTapResetRef.current) clearTimeout(logoTapResetRef.current);\n }, []);\n\n const handleLogoTap = () => {\n if (logoTapResetRef.current) clearTimeout(logoTapResetRef.current);\n logoTapCountRef.current += 1;\n if (logoTapCountRef.current >= 5) {\n logoTapCountRef.current = 0;\n setAdminGateOpen(true);\n setAdminGateError(\"\");\n setAdminGateToken(\"\");\n return;\n }\n logoTapResetRef.current = setTimeout(() => {\n logoTapCountRef.current = 0;\n }, 2600);\n };\n\n const closeAdminGate = () => {\n setAdminGateOpen(false);\n setAdminGateToken(\"\");\n setAdminGateError(\"\");\n setAdminGateLoading(false);\n };\n\n const submitAdminGate = async (e) => {\n e.preventDefault();\n const t = adminGateToken.trim();\n if (!t) {\n setAdminGateError(\"请输入管理员 Token\");\n return;\n }\n setAdminGateLoading(true);\n setAdminGateError(\"\");\n try {\n const res = await fetch(`${API_BASE}/api/admin/users`, { headers: { \"X-Admin-Token\": t } });\n if (!res.ok) {\n const data = await res.json().catch(() => ({}));\n throw new Error(data.error || \"Token 无效或无权访问\");\n }\n localStorage.setItem(\"sproutgate_admin_token\", t);\n closeAdminGate();\n const base = import.meta.env.BASE_URL || \"/\";\n const prefix = base.endsWith(\"/\") ? base : `${base}/`;\n window.location.href = new URL(\"admin\", `${window.location.origin}${prefix}`).href;\n } catch (err) {\n setAdminGateError(err.message || \"验证失败\");\n } finally {\n setAdminGateLoading(false);\n }\n };\n\n useEffect(() => {\n const isImageUrl = (url) => {\n if (!url) return false;\n const clean = String(url).split(\"#\")[0].split(\"?\")[0];\n return /\\.(png|jpe?g|gif|webp|svg)$/i.test(clean);\n };\n\n const onClick = (event) => {\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n if (target.closest && target.closest(\".image-preview-card\")) return;\n\n const img = target.tagName === \"IMG\" ? target : target.closest?.(\"img\");\n if (img && img.closest?.(\".markdown-body\")) {\n const src = img.getAttribute(\"src\") || \"\";\n const alt = img.getAttribute(\"alt\") || \"\";\n if (isImageUrl(src)) {\n event.preventDefault();\n event.stopPropagation();\n openImagePreview(src, alt);\n }\n return;\n }\n\n const a = target.tagName === \"A\" ? target : target.closest?.(\"a[href]\");\n if (a && a.closest?.(\".markdown-body\")) {\n const href = a.getAttribute(\"href\") || \"\";\n if (isImageUrl(href)) {\n event.preventDefault();\n event.stopPropagation();\n openImagePreview(href, a.getAttribute(\"title\") || a.textContent?.trim() || \"\");\n }\n }\n };\n\n document.addEventListener(\"click\", onClick, true);\n return () => document.removeEventListener(\"click\", onClick, true);\n }, []);\n\n const headerBrand = (\n <div className=\"app-header-brand\">\n <button\n type=\"button\"\n className=\"app-header-brand-btn\"\n onClick={handleLogoTap}\n aria-label=\"站点标志(在图标或标题上连续点击五次可打开管理员验证)\"\n >\n <img className=\"app-header-logo\" src={LOGO_192_SRC} alt=\"\" width={40} height={40} decoding=\"async\" />\n <div className=\"app-header-brand-text\">\n <h1>萌芽账户认证中心</h1>\n </div>\n </button>\n </div>\n );\n\n const homeHref = import.meta.env.BASE_URL || \"/\";\n const usersHref = useMemo(() => {\n try {\n return new URL(\"users\", `${window.location.origin}${basePrefix}`).pathname;\n } catch {\n return \"/users\";\n }\n }, [basePrefix]);\n\n const headerNav = (\n <nav>\n <a href={homeHref}>用户中心</a>\n <a href={usersHref}>用户列表</a>\n </nav>\n );\n\n const headerNavAuthorize = (\n <nav className=\"oauth-authorize-nav\">\n <a href={homeHref}>用户中心</a>\n {authDenyHref ? (\n <a href={authDenyHref} className=\"oauth-header-deny\">拒绝 / 取消</a>\n ) : null}\n </nav>\n );\n\n return (\n <div className={`app-shell${shellImmersive ? \" app-shell--auth-guest\" : \"\"}`}>\n <RandomSiteBackground />\n {booting && <SplashScreen onLogoTap={handleLogoTap} />}\n <div className={`app${shellImmersive ? \" app--auth-guest\" : \"\"}`}>\n {isPublicUserList ? (\n <div className=\"unified-user-shell unified-user-shell--auth-guest\">\n <div className=\"unified-user-card\">\n <div className=\"unified-user-card-accent\" aria-hidden />\n <header className=\"unified-user-header\">\n {headerBrand}\n {headerNav}\n </header>\n <div className=\"unified-user-main\">\n <PublicUserListPage onReady={markReady} onPreviewImage={openImagePreview} />\n </div>\n </div>\n </div>\n ) : isPublicUser ? (\n <div className=\"unified-user-shell unified-user-shell--auth-guest\">\n <div className=\"unified-user-card\">\n <div className=\"unified-user-card-accent\" aria-hidden />\n <header className=\"unified-user-header\">\n {headerBrand}\n {headerNav}\n </header>\n <div className=\"unified-user-main\">\n <PublicUserPage account={publicAccount} onReady={markReady} onPreviewImage={openImagePreview} />\n </div>\n </div>\n </div>\n ) : isAdmin ? (\n <div className=\"unified-user-shell\">\n <div className=\"unified-user-card\">\n <div className=\"unified-user-card-accent\" aria-hidden />\n <header className=\"unified-user-header\">\n {headerBrand}\n {headerNav}\n </header>\n <div className=\"unified-user-main\">\n <AdminPanel onReady={markReady} />\n </div>\n </div>\n </div>\n ) : isAuthorizeRoute ? (\n <div\n className={`unified-user-shell oauth-authorize-shell${\n shellAuthGuest ? \" unified-user-shell--auth-guest\" : \"\"\n }`}\n >\n <div className=\"unified-user-card oauth-authorize-card\">\n <div className=\"unified-user-card-accent\" aria-hidden />\n <header className=\"unified-user-header oauth-authorize-header\">\n {headerBrand}\n {headerNavAuthorize}\n </header>\n <div className=\"unified-user-main oauth-authorize-main\">\n <UserPortal\n onReady={markReady}\n authFlow={authFlow}\n onPreviewImage={openImagePreview}\n onGuestAuthLayoutChange={setGuestAuthLayout}\n oauthStandalone\n />\n </div>\n </div>\n </div>\n ) : (\n <div\n className={`unified-user-shell${shellAuthGuest ? \" unified-user-shell--auth-guest\" : \"\"}`}\n >\n <div className=\"unified-user-card\">\n <div className=\"unified-user-card-accent\" aria-hidden />\n <header className=\"unified-user-header\">\n {headerBrand}\n {headerNav}\n </header>\n <div className=\"unified-user-main\">\n <UserPortal\n onReady={markReady}\n authFlow={authFlow}\n onPreviewImage={openImagePreview}\n onGuestAuthLayoutChange={setGuestAuthLayout}\n />\n </div>\n </div>\n </div>\n )}\n </div>\n\n {adminGateOpen && (\n <div className=\"modal-backdrop\" onClick={closeAdminGate}>\n <div className=\"modal-card\" onClick={(e) => e.stopPropagation()}>\n <form onSubmit={submitAdminGate}>\n <div className=\"modal-header\">\n <div>\n <h2>管理员入口</h2>\n <p>请输入与后端 <code className=\"inline-code\">X-Admin-Token</code> 一致的管理员 Token。</p>\n <p className=\"hint admin-gate-token-hint\">\n 未修改过本机/内网 data 时,默认示例常为 <code className=\"inline-code\">shumengya520</code>,以你部署的后端 <code className=\"inline-code\">config</code> 为准。\n </p>\n </div>\n <button type=\"button\" className=\"ghost modal-close\" onClick={closeAdminGate}>关闭</button>\n </div>\n <div className=\"modal-body\">\n <label className=\"full-span\">\n <span className=\"label-text\">管理员 Token</span>\n <input\n type=\"password\"\n autoComplete=\"off\"\n value={adminGateToken}\n onChange={(e) => setAdminGateToken(e.target.value)}\n placeholder=\"X-Admin-Token\"\n />\n </label>\n {adminGateError && <div className=\"error full-span\">{adminGateError}</div>}\n </div>\n <div className=\"modal-actions\">\n <button type=\"submit\" className=\"primary\" disabled={adminGateLoading}>\n {adminGateLoading ? \"验证中…\" : \"进入后台\"}\n </button>\n <button type=\"button\" className=\"ghost\" onClick={closeAdminGate}>取消</button>\n </div>\n </form>\n </div>\n </div>\n )}\n\n {imagePreview.open && (\n <div className=\"modal-backdrop\" onClick={closeImagePreview}>\n <div className=\"modal-card image-preview-card\" onClick={(e) => e.stopPropagation()}>\n <div className=\"image-preview-header\">\n <div className=\"image-preview-meta\">\n <div className=\"image-preview-title\">{imagePreview.alt || \"图片预览\"}</div>\n <div className=\"image-preview-subtitle\">点击遮罩关闭</div>\n </div>\n <button type=\"button\" className=\"ghost modal-close\" onClick={closeImagePreview}>\n 关闭\n </button>\n </div>\n <div className=\"image-preview-body\">\n <img className=\"image-preview-img\" src={imagePreview.src} alt={imagePreview.alt || \"preview\"} />\n </div>\n </div>\n </div>\n )}\n </div>\n );\n}\n\nexport default App;\n"}],"isError":false,"timestamp":1781611519529}}
|
||
{"type":"message","id":"a5c561b8","parentId":"fcac8a1d","timestamp":"2026-06-16T12:05:20.720Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我看看一些后端的关键文件,比如 handlers 目录,了解具体的功能实现。先看看 handlers 目录结构。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_94396c2235064c68aa55214a","name":"bash","arguments":{"command":"ls -la /shumengya/project/frontend-backend/SproutGate/sproutgate-backend/internal/handlers"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":3310,"output":74,"cacheRead":17536,"cacheWrite":0,"totalTokens":20920,"cost":{"input":0.001324,"output":0.000148,"cacheRead":0.00140288,"cacheWrite":0,"total":0.00287488}},"stopReason":"toolUse","timestamp":1781611519529,"responseId":"87800f8cb74e4cb28576738d19dde7dd"}}
|
||
{"type":"message","id":"678c87f2","parentId":"a5c561b8","timestamp":"2026-06-16T12:05:20.730Z","message":{"role":"toolResult","toolCallId":"call_94396c2235064c68aa55214a","toolName":"bash","content":[{"type":"text","text":"总计 128\ndrwxr-xr-x 2 root root 4096 6月 3日 14:41 .\ndrwxr-xr-x 9 root root 4096 4月 2日 00:00 ..\n-rw-r--r-- 1 root root 6633 6月 3日 14:41 admin.go\n-rw-r--r-- 1 root root 407 3月21日 00:00 auth_client.go\n-rw-r--r-- 1 root root 7169 6月 3日 14:41 auth_login.go\n-rw-r--r-- 1 root root 10399 6月 3日 14:41 auth_password.go\n-rw-r--r-- 1 root root 3776 6月 3日 14:41 checkin.go\n-rw-r--r-- 1 root root 193 3月21日 00:00 handler.go\n-rw-r--r-- 1 root root 2025 4月 2日 00:00 helpers.go\n-rw-r--r-- 1 root root 5610 6月 3日 14:41 oauth_admin.go\n-rw-r--r-- 1 root root 28656 6月 3日 14:41 oauth.go\n-rw-r--r-- 1 root root 2404 6月 3日 14:41 profile.go\n-rw-r--r-- 1 root root 5965 6月 3日 14:41 public_profile.go\n-rw-r--r-- 1 root root 1618 6月 3日 14:41 public_registration.go\n-rw-r--r-- 1 root root 3464 6月 3日 14:41 registration_admin.go\n-rw-r--r-- 1 root root 6792 6月 3日 14:41 requests.go\n-rw-r--r-- 1 root root 5809 6月 3日 14:41 secondary_email.go\n-rw-r--r-- 1 root root 2731 6月 3日 14:41 turnstile.go\n"}],"isError":false,"timestamp":1781611520730}}
|
||
{"type":"message","id":"47115051","parentId":"678c87f2","timestamp":"2026-06-16T12:05:23.008Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看 auth_login.go 了解登录功能。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_ee845e438f5e4976b1878d76","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/sproutgate-backend/internal/handlers/auth_login.go"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":717,"output":53,"cacheRead":20800,"cacheWrite":0,"totalTokens":21570,"cost":{"input":0.00028680000000000003,"output":0.000106,"cacheRead":0.0016640000000000001,"cacheWrite":0,"total":0.0020568}},"stopReason":"toolUse","timestamp":1781611520730,"responseId":"943c5ffef905473ab361bce9cc6ecbd0"}}
|
||
{"type":"message","id":"cc5036b2","parentId":"47115051","timestamp":"2026-06-16T12:05:23.011Z","message":{"role":"toolResult","toolCallId":"call_ee845e438f5e4976b1878d76","toolName":"read","content":[{"type":"text","text":"package handlers\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"sproutgate-backend/internal/auth\"\n\t\"sproutgate-backend/internal/clientgeo\"\n\t\"sproutgate-backend/internal/models\"\n)\n\n// Login\n// @Summary 账号密码登录\n// @Description 可选 Turnstile:开启时 body 需带 turnstileToken。\n// @Tags auth\n// @Accept json\n// @Produce json\n// @Param body body LoginRequest true \"登录请求\"\n// @Success 200 {object} map[string]interface{} \"token、expiresAt、user\"\n// @Failure 400 {object} map[string]interface{}\n// @Failure 401 {object} map[string]interface{}\n// @Failure 500 {object} map[string]interface{}\n// @Router /api/auth/login [post]\nfunc (h *Handler) Login(c *gin.Context) {\n\tvar req LoginRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"invalid request\"})\n\t\treturn\n\t}\n\th.store.MaybeSyncRuntimeConfigFromDB()\n\treq.Account = strings.TrimSpace(req.Account)\n\tif req.Account == \"\" || req.Password == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"account and password are required\"})\n\t\treturn\n\t}\n\tif tcfg := h.store.GetTurnstileConfig(); tcfg.Enabled {\n\t\tif err := verifyTurnstileToken(c.Request.Context(), tcfg.SecretKey, req.TurnstileToken, c.ClientIP()); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\t}\n\tuser, found, err := h.store.GetUser(req.Account)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to load user\"})\n\t\treturn\n\t}\n\tif !found {\n\t\tc.JSON(http.StatusUnauthorized, gin.H{\"error\": \"invalid credentials\"})\n\t\treturn\n\t}\n\tif err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {\n\t\tc.JSON(http.StatusUnauthorized, gin.H{\"error\": \"invalid credentials\"})\n\t\treturn\n\t}\n\tif user.Banned {\n\t\twriteBanJSON(c, user.BanReason)\n\t\treturn\n\t}\n\ttoken, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, user.TokenEpoch, 7*24*time.Hour)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to generate token\"})\n\t\treturn\n\t}\n\tif cid, ok := models.NormalizeAuthClientID(req.ClientID); ok {\n\t\tname := models.ClampAuthClientName(req.ClientName)\n\t\tif rec, err := h.store.RecordAuthClient(req.Account, cid, name); err == nil {\n\t\t\tuser = rec\n\t\t}\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"token\": token,\n\t\t\"expiresAt\": expiresAt.Format(time.RFC3339),\n\t\t\"user\": user.OwnerPublic(),\n\t})\n}\n\n// Verify\n// @Summary 校验 JWT\n// @Description 校验 token 是否有效;可在请求头带 X-Auth-Client / X-Auth-Client-Name 记录接入应用。\n// @Tags auth\n// @Accept json\n// @Produce json\n// @Param body body VerifyRequest true \"token\"\n// @Success 200 {object} map[string]interface{} \"valid、user\"\n// @Failure 400 {object} map[string]interface{}\n// @Failure 401 {object} map[string]interface{}\n// @Failure 500 {object} map[string]interface{}\n// @Router /api/auth/verify [post]\nfunc (h *Handler) Verify(c *gin.Context) {\n\tvar req VerifyRequest\n\tif err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"token is required\"})\n\t\treturn\n\t}\n\tclaims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token)\n\tif err != nil {\n\t\tc.JSON(http.StatusUnauthorized, gin.H{\"valid\": false, \"error\": \"invalid token\"})\n\t\treturn\n\t}\n\tuser, found, err := h.store.GetUser(claims.Account)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to load user\"})\n\t\treturn\n\t}\n\tif !found {\n\t\tc.JSON(http.StatusUnauthorized, gin.H{\"valid\": false, \"error\": \"user not found\"})\n\t\treturn\n\t}\n\tif user.Banned {\n\t\twriteBanJSON(c, user.BanReason)\n\t\treturn\n\t}\n\tif abortIfTokenEpochStale(c, claims, user) {\n\t\treturn\n\t}\n\tif cid, cname, ok := authClientFromHeaders(c); ok {\n\t\t_, _ = h.store.RecordAuthClient(claims.Account, cid, cname)\n\t}\n\tc.JSON(http.StatusOK, gin.H{\"valid\": true, \"user\": user.Public()})\n}\n\n// Me\n// @Summary 当前用户(含签到摘要)\n// @Description 需要 Authorization: Bearer。可选头:X-Visit-Ip、X-Visit-Location、X-Auth-Client、X-Auth-Client-Name。\n// @Tags auth\n// @Accept json\n// @Produce json\n// @Security BearerAuth\n// @Param X-Visit-Ip header string false \"访客 IP\"\n// @Param X-Visit-Location header string false \"访客地区展示文案\"\n// @Param X-Auth-Client header string false \"接入应用 ID\"\n// @Param X-Auth-Client-Name header string false \"接入应用名称\"\n// @Success 200 {object} map[string]interface{} \"user、checkIn\"\n// @Failure 401 {object} map[string]interface{}\n// @Failure 500 {object} map[string]interface{}\n// @Router /api/auth/me [get]\nfunc (h *Handler) Me(c *gin.Context) {\n\ttoken := bearerToken(c.GetHeader(\"Authorization\"))\n\tif token == \"\" {\n\t\tc.JSON(http.StatusUnauthorized, gin.H{\"error\": \"missing token\"})\n\t\treturn\n\t}\n\tclaims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)\n\tif err != nil {\n\t\tc.JSON(http.StatusUnauthorized, gin.H{\"error\": \"invalid token\"})\n\t\treturn\n\t}\n\tuser, found, err := h.store.GetUser(claims.Account)\n\tif err != nil {\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to load user\"})\n\t\treturn\n\t}\n\tif !found {\n\t\tc.JSON(http.StatusUnauthorized, gin.H{\"error\": \"user not found\"})\n\t\treturn\n\t}\n\tif abortIfUserBanned(c, user) {\n\t\treturn\n\t}\n\tif abortIfTokenEpochStale(c, claims, user) {\n\t\treturn\n\t}\n\tif cid, cname, ok := authClientFromHeaders(c); ok {\n\t\tif rec, err := h.store.RecordAuthClient(claims.Account, cid, cname); err == nil {\n\t\t\tuser = rec\n\t\t}\n\t}\n\ttoday := models.CurrentActivityDate()\n\tnowAt := models.CurrentActivityTime()\n\tuser, _, err = h.store.RecordVisit(claims.Account, today, nowAt)\n\tif err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\tc.JSON(http.StatusUnauthorized, gin.H{\"error\": \"user not found\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to save visit\"})\n\t\treturn\n\t}\n\tvisitIP := strings.TrimSpace(c.GetHeader(\"X-Visit-Ip\"))\n\tvisitLoc := strings.TrimSpace(c.GetHeader(\"X-Visit-Location\"))\n\tif visitIP == \"\" {\n\t\tvisitIP = strings.TrimSpace(c.ClientIP())\n\t}\n\tlookupURL := strings.TrimSpace(os.Getenv(\"GEO_LOOKUP_URL\"))\n\tif lookupURL == \"\" {\n\t\tlookupURL = clientgeo.DefaultLookupURL\n\t}\n\tif visitLoc == \"\" && visitIP != \"\" {\n\t\tif loc, geoErr := clientgeo.FetchDisplayLocation(c.Request.Context(), lookupURL, visitIP); geoErr == nil {\n\t\t\tvisitLoc = loc\n\t\t}\n\t}\n\tif visitIP != \"\" || visitLoc != \"\" {\n\t\tuser, err = h.store.UpdateLastVisitMeta(claims.Account, visitIP, visitLoc)\n\t\tif err != nil {\n\t\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\t\tc.JSON(http.StatusUnauthorized, gin.H{\"error\": \"user not found\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\"error\": \"failed to save visit meta\"})\n\t\t\treturn\n\t\t}\n\t}\n\tcheckInConfig := h.store.CheckInConfig()\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"user\": user.OwnerPublic(),\n\t\t\"checkIn\": gin.H{\n\t\t\t\"rewardCoins\": checkInConfig.RewardCoins,\n\t\t\t\"checkedInToday\": user.LastCheckInDate == today,\n\t\t\t\"lastCheckInDate\": user.LastCheckInDate,\n\t\t\t\"lastCheckInAt\": user.LastCheckInAt,\n\t\t\t\"today\": today,\n\t\t},\n\t})\n}\n"}],"isError":false,"timestamp":1781611523010}}
|
||
{"type":"message","id":"b9205ccc","parentId":"cc5036b2","timestamp":"2026-06-16T12:05:27.171Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我看看前端的一些主要组件,比如 UserPortal。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_68efd5c78b3c47eca6391db7","name":"bash","arguments":{"command":"ls -la /shumengya/project/frontend-backend/SproutGate/sproutgate-frontend/src/components"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":2051,"output":57,"cacheRead":21504,"cacheWrite":0,"totalTokens":23612,"cost":{"input":0.0008204000000000001,"output":0.00011399999999999999,"cacheRead":0.00172032,"cacheWrite":0,"total":0.00265472}},"stopReason":"toolUse","timestamp":1781611523012,"responseId":"4712d8d99a8a4a24b4fb997899f19e5b"}}
|
||
{"type":"message","id":"f1d3d50b","parentId":"b9205ccc","timestamp":"2026-06-16T12:05:27.188Z","message":{"role":"toolResult","toolCallId":"call_68efd5c78b3c47eca6391db7","toolName":"bash","content":[{"type":"text","text":"总计 148\ndrwxr-xr-x 4 root root 4096 6月 3日 14:41 .\ndrwxr-xr-x 3 root root 4096 6月 3日 14:41 ..\n-rw-r--r-- 1 root root 55239 6月 3日 14:41 AdminPanel.jsx\n-rw-r--r-- 1 root root 1835 4月 2日 00:00 AvatarImg.jsx\n-rw-r--r-- 1 root root 2032 6月 3日 14:41 common.jsx\ndrwxr-xr-x 2 root root 4096 6月 3日 14:41 oauth\n-rw-r--r-- 1 root root 7967 4月 2日 00:00 OAuthConsentScreen.jsx\n-rw-r--r-- 1 root root 3622 6月 3日 14:41 PublicUserListPage.jsx\n-rw-r--r-- 1 root root 8903 4月 2日 00:00 PublicUserPage.jsx\n-rw-r--r-- 1 root root 1140 4月 2日 00:00 RandomSiteBackground.jsx\n-rw-r--r-- 1 root root 1565 6月 3日 14:41 SplashScreen.jsx\ndrwxr-xr-x 2 root root 4096 6月 3日 14:41 userPortal\n-rw-r--r-- 1 root root 33449 6月 3日 14:41 UserPortal.jsx\n"}],"isError":false,"timestamp":1781611527188}}
|
||
{"type":"message","id":"1874777a","parentId":"f1d3d50b","timestamp":"2026-06-16T12:05:30.140Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看 UserPortal.jsx 了解用户门户的主要功能。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_cf46c5adfb464545a4dff9d5","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/sproutgate-frontend/src/components/UserPortal.jsx","limit":100}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":501,"output":65,"cacheRead":23552,"cacheWrite":0,"totalTokens":24118,"cost":{"input":0.00020040000000000002,"output":0.00013,"cacheRead":0.00188416,"cacheWrite":0,"total":0.00221456}},"stopReason":"toolUse","timestamp":1781611527189,"responseId":"3dff44a040a44e09a78f05f2be1ad2fa"}}
|
||
{"type":"message","id":"bf108c6d","parentId":"1874777a","timestamp":"2026-06-16T12:05:30.142Z","message":{"role":"toolResult","toolCallId":"call_cf46c5adfb464545a4dff9d5","toolName":"read","content":[{"type":"text","text":"import React, { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n API_BASE,\n authClientFetchHeaders,\n buildAuthCallbackUrl,\n clearAuthClientContext,\n fetchClientVisitMeta,\n formatAuthBanMessage,\n getOAuthReturnTo,\n normalizeSelfServiceAccountInput,\n persistAuthClientFromFlow,\n randomSelfServiceAccount\n} from \"../config\";\nimport UserPortalAuthSection from \"./userPortal/UserPortalAuthSection\";\nimport UserPortalProfileSection from \"./userPortal/UserPortalProfileSection\";\nimport OAuthConsentScreen from \"./OAuthConsentScreen\";\n\nexport default function UserPortal({\n onReady,\n authFlow,\n onPreviewImage,\n oauthStandalone = false,\n onGuestAuthLayoutChange\n}) {\n const [account, setAccount] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [user, setUser] = useState(null);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState(\"\");\n const [mode, setMode] = useState(\"login\");\n\n const [registerForm, setRegisterForm] = useState({\n account: \"\", password: \"\", username: \"\", email: \"\", code: \"\", inviteCode: \"\"\n });\n const [registrationRequireInvite, setRegistrationRequireInvite] = useState(false);\n const [inviteRegisterReward, setInviteRegisterReward] = useState(10);\n const [registrationAccountMin, setRegistrationAccountMin] = useState(3);\n const [registrationAccountMax, setRegistrationAccountMax] = useState(32);\n const [registerSent, setRegisterSent] = useState(false);\n const [registerExpiresAt, setRegisterExpiresAt] = useState(\"\");\n const [registerLoading, setRegisterLoading] = useState(false);\n const [registerError, setRegisterError] = useState(\"\");\n const [registerMessage, setRegisterMessage] = useState(\"\");\n\n const [resetForm, setResetForm] = useState({\n account: \"\", email: \"\", code: \"\", newPassword: \"\"\n });\n const [resetSent, setResetSent] = useState(false);\n const [resetExpiresAt, setResetExpiresAt] = useState(\"\");\n const [resetLoading, setResetLoading] = useState(false);\n const [resetError, setResetError] = useState(\"\");\n const [resetMessage, setResetMessage] = useState(\"\");\n\n const [secondaryForm, setSecondaryForm] = useState({ email: \"\", code: \"\" });\n const [secondarySent, setSecondarySent] = useState(false);\n const [secondaryExpiresAt, setSecondaryExpiresAt] = useState(\"\");\n const [secondaryLoading, setSecondaryLoading] = useState(false);\n const [secondaryError, setSecondaryError] = useState(\"\");\n const [secondaryMessage, setSecondaryMessage] = useState(\"\");\n\n const [profileForm, setProfileForm] = useState({\n username: \"\", phone: \"\", avatarUrl: \"\", websiteUrl: \"\", bio: \"\", password: \"\"\n });\n const [profileLoading, setProfileLoading] = useState(false);\n const [profileError, setProfileError] = useState(\"\");\n const [profileMessage, setProfileMessage] = useState(\"\");\n const [profileEditorOpen, setProfileEditorOpen] = useState(false);\n const [profileEditorFocus, setProfileEditorFocus] = useState(\"\");\n const [secondaryEditorOpen, setSecondaryEditorOpen] = useState(false);\n\n const [checkInReward, setCheckInReward] = useState(1);\n const [checkInToday, setCheckInToday] = useState(false);\n const [checkInLoading, setCheckInLoading] = useState(false);\n const [checkInError, setCheckInError] = useState(\"\");\n const [checkInMessage, setCheckInMessage] = useState(\"\");\n\n const [githubOAuthEnabled, setGithubOAuthEnabled] = useState(false);\n const [giteaOAuthEnabled, setGiteaOAuthEnabled] = useState(false);\n const [googleOAuthEnabled, setGoogleOAuthEnabled] = useState(false);\n const [microsoftOAuthEnabled, setMicrosoftOAuthEnabled] = useState(false);\n const [linuxdoOAuthEnabled, setLinuxdoOAuthEnabled] = useState(false);\n const [oauthLogos, setOauthLogos] = useState({\n github: \"\", gitea: \"\", google: \"\", microsoft: \"\", linuxdo: \"\"\n });\n\n const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n const [turnstileSiteKey, setTurnstileSiteKey] = useState(\"\");\n const [loginTurnstileToken, setLoginTurnstileToken] = useState(\"\");\n const [registerTurnstileToken, setRegisterTurnstileToken] = useState(\"\");\n const loginTurnstileRef = useRef(null);\n const registerTurnstileRef = useRef(null);\n\n const isAuthFlow = Boolean(authFlow?.redirectUri);\n\n const redirectToAuthCallback = (tokenValue, userData, expiresAt = \"\") => {\n if (!isAuthFlow || !authFlow?.redirectUri || !tokenValue) return;\n const callbackUrl = buildAuthCallbackUrl(authFlow.redirectUri, {\n token: tokenValue,\n account: userData?.account || \"\",\n username: userData?.username || \"\",\n\n[836 more lines in file. Use offset=101 to continue.]"}],"isError":false,"timestamp":1781611530142}}
|
||
{"type":"message","id":"a0196f69","parentId":"bf108c6d","timestamp":"2026-06-16T12:05:31.962Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我看看后端文档,了解 API 的详细信息。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_4778eba96086426b924d962a","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/sproutgate-backend/后端文档.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1233,"output":54,"cacheRead":24000,"cacheWrite":0,"totalTokens":25287,"cost":{"input":0.0004932000000000001,"output":0.000108,"cacheRead":0.00192,"cacheWrite":0,"total":0.0025212000000000004}},"stopReason":"toolUse","timestamp":1781611530143,"responseId":"fdf67d012f6041c7b744a54d8faf738d"}}
|
||
{"type":"message","id":"363ad2e7","parentId":"a0196f69","timestamp":"2026-06-16T12:05:31.964Z","message":{"role":"toolResult","toolCallId":"call_4778eba96086426b924d962a","toolName":"read","content":[{"type":"text","text":"# SproutGate 后端文档\r\n\r\n## 技术栈\r\n\r\n- **语言**:Go 1.20+\r\n- **Web 框架**:Gin;跨域中间件 **`github.com/gin-contrib/cors`**\r\n- **数据存储**:MySQL(通过 GORM + `gorm.io/driver/mysql`)\r\n- **JWT**:`github.com/golang-jwt/jwt/v5`\r\n- **密码哈希**:`golang.org/x/crypto/bcrypt`\r\n\r\n业务数据与配置均保存在 MySQL 中,**不再使用** `data/users/*.json` 与 `data/config/*.json` 作为运行时数据源。仓库中的 `data/` 仅可作迁移脚本输入或本地备份参考。\r\n\r\n## 目录结构(要点)\r\n\r\n| 路径 | 说明 |\r\n|------|------|\r\n| `main.go` | 入口:连接数据库、初始化 `Store`、注册路由 |\r\n| `internal/database/` | MySQL 连接与环境选择(`DB_DSN` / `APP_ENV`) |\r\n| `internal/handlers/` | HTTP 处理:认证、资料、签到、公开页、管理端等 |\r\n| `internal/models/` | 领域模型(用户、待激活、重置密码、辅助邮箱等) |\r\n| `internal/storage/` | 持久化:`Store` + GORM 模型与业务方法 |\r\n| `internal/auth/` | JWT 签发与校验 |\r\n| `internal/email/` | 发信 |\r\n| `cmd/migrate/` | 一次性工具:将旧版 JSON `data/` 导入 MySQL |\r\n\r\n## 环境与数据库\r\n\r\n连接字符串优先级:**`DB_DSN` > 内置规则**。\r\n\r\n1. **若设置 `DB_DSN`**:直接使用该完整 DSN(适合 CI、容器或临时切换)。\r\n2. **否则**:根据 `APP_ENV` 选择内置库:\r\n - `APP_ENV=production` 或 `prod` → 生产库:`192.168.1.100:3306` / 库名 `sproutgate` / 用户 `sproutgate`\r\n - 未设置或其它值 → 开发/测试库:`10.1.1.100:3306` / 库名 `sproutgate-test` / 用户 `sproutgate-test`\r\n\r\n其它常用环境变量:\r\n\r\n| 变量 | 说明 | 默认 |\r\n|------|------|------|\r\n| `PORT` | HTTP 监听端口 | `8080` |\r\n| `APP_ENV` | 影响默认 MySQL 选择与 `/api/health` 返回的 `env` | 空则 health 中为 `development` |\r\n| `GIN_MODE` | `release` 时 GORM 日志级别更安静(警告级) | debug 模式 |\r\n| `DB_DSN` | 完整 MySQL DSN,覆盖上述内置地址 | 未设置 |\r\n| `GEO_LOOKUP_URL` | `GET /api/auth/me` 未带 `X-Visit-Location` 时,服务端按 IP 请求该基址反查展示地理位置(实现见 `internal/clientgeo`,会拼接 `?ip=`) | 默认 `https://cf-ip-geo.smyhub.com/api` |\r\n\r\n**建议**:生产部署时设置 `APP_ENV=production`,并确保 MySQL 防火墙与账号权限正确;敏感连接信息也可只通过 `DB_DSN` 注入,无需改代码。\r\n\r\n## 数据库表(AutoMigrate)\r\n\r\n应用启动与 `migrate` 工具均会同步下列表结构(GORM `AutoMigrate`):\r\n\r\n| 表名 | 用途 |\r\n|------|------|\r\n| `users` | 用户主数据;签到/访问时间列表、`auth_clients` 等以 JSON 文本列存储 |\r\n| `pending_users` | 注册邮箱验证流程中的待激活用户 |\r\n| `password_resets` | 忘记密码验证码 |\r\n| `secondary_email_verifications` | 绑定辅助邮箱验证码 |\r\n| `app_configs` | 键值配置:`admin`、`auth`、`email`、`checkin`、`registration`(JSON) |\r\n| `invite_codes` | 注册邀请码 |\r\n| `profile_likes` | 公开用户主页点赞明细(点赞者、被赞者、自然日等) |\r\n| `profile_like_daily_quota` | 点赞者当日已用额度(与代码常量 `MaxProfileLikesPerDay` 配合) |\r\n\r\n管理员令牌、JWT Secret、邮件 SMTP、签到奖励、是否强制邀请码等,均从 `app_configs` / `invite_codes` 读写,首次无记录时由 `Store` 按逻辑补全默认项。\r\n\r\n## 从旧 JSON 迁移到 MySQL\r\n\r\n在 **`sproutgate-backend`** 目录下:\r\n\r\n```bash\r\n# 导入到当前环境对应的库(默认开发库,除非设置 APP_ENV 或 DB_DSN)\r\ngo run ./cmd/migrate --data-dir ./data\r\n```\r\n\r\nWindows PowerShell 示例(写入生产库):\r\n\r\n```powershell\r\n$env:APP_ENV = \"production\"\r\ngo run ./cmd/migrate --data-dir ./data\r\nRemove-Item Env:APP_ENV # 可选:清除变量,避免影响本机其它命令\r\n```\r\n\r\n迁移内容:`data/config/*.json`(写入 `app_configs` 与 `invite_codes`)、`data/users/*.json`(写入 `users`)。对已存在主键执行 **UPSERT**:同账号、同配置键会更新为迁移文件中的值。\r\n\r\n## HTTP 路由速览\r\n\r\n- **`GET /`**、**`GET /api`**:API 简要说明 JSON(`main.go` 内嵌字段,含 `version`、`routePrefixes` 等)。**当前未注册**单独返回 Markdown 的 `/api/docs` 路由;对外长文档见仓库根目录 **`萌芽账户认证中心-第三方应用API接入文档.md`**。\r\n- **`GET /api/health`**:`{ \"status\": \"ok\", \"env\": \"<APP_ENV 或 development>\" }`。\r\n- **`/api/auth/*`**:登录、注册、邮箱验证、忘记/重置密码、辅助邮箱、令牌校验、`/me`、签到、更新资料等;可选请求头 `X-Auth-Client`、`X-Auth-Client-Name`(记录第三方应用接入)。\r\n- **`/api/public/*`**:\r\n - **`GET /api/public/users`**:公开用户目录(未封禁;默认按 `createdAt` 升序)。\r\n - **`GET /api/public/users/:account`**:公开资料 + 累计赞数;若带合法 Bearer,可附加「是否已赞今日」「当日剩余可点赞人数」等浏览者上下文。\r\n - **`POST /api/public/users/:account/like`**:登录用户给指定主页点赞(不能赞自己;每人每主页每日一次;每自然日每名用户最多给 **5** 位不同用户点赞,常量见 `internal/storage/profile_likes.go`)。\r\n - **`GET /api/public/registration-policy`**:是否强制邀请码等。\r\n- **`/api/admin/*`**:需 **`X-Admin-Token`**(或 Query `token`):用户 CRUD、签到配置、注册策略与邀请码管理。\r\n\r\n允许的 CORS 自定义头包含:`Authorization`、`X-Admin-Token`、`X-Visit-Ip`、`X-Visit-Location`、`X-Auth-Client`、`X-Auth-Client-Name` 等。\r\n\r\n## 本地开发命令\r\n\r\n```bash\r\ncd sproutgate-backend\r\ngo mod tidy\r\ngo run .\r\n```\r\n\r\n默认监听 `:8080`。前端开发时请将 `VITE_API_BASE` 指向本服务(详见仓库根目录下 `sproutgate-frontend/前端文档.md`)。\r\n\r\n## 安全提示\r\n\r\n- 勿将生产 `DB_DSN`、SMTP 密码、真实 `admin` 令牌提交到版本库。\r\n- 旧版 `data/config/email.json` 等若含真实口令,迁移后应以环境或运维密钥管理为准,并限制数据库与 SMTP 账号权限。\r\n"}],"isError":false,"timestamp":1781611531963}}
|
||
{"type":"message","id":"457d9706","parentId":"363ad2e7","timestamp":"2026-06-16T12:05:34.379Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我看看前端的目录结构,了解整体布局。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_23bcb21322f24a8cbc883ade","name":"bash","arguments":{"command":"ls -la /shumengya/project/frontend-backend/SproutGate/sproutgate-frontend/src"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1960,"output":56,"cacheRead":25216,"cacheWrite":0,"totalTokens":27232,"cost":{"input":0.0007840000000000001,"output":0.000112,"cacheRead":0.00201728,"cacheWrite":0,"total":0.0029132800000000003}},"stopReason":"toolUse","timestamp":1781611531965,"responseId":"282a3ff9f7874aae9d4adc664921bb88"}}
|
||
{"type":"message","id":"bcd03264","parentId":"457d9706","timestamp":"2026-06-16T12:05:34.393Z","message":{"role":"toolResult","toolCallId":"call_23bcb21322f24a8cbc883ade","toolName":"bash","content":[{"type":"text","text":"总计 112\ndrwxr-xr-x 3 root root 4096 6月 3日 14:41 .\ndrwxr-xr-x 6 root root 4096 4月 2日 00:00 ..\n-rw-r--r-- 1 root root 14156 6月 3日 14:41 App.jsx\n-rw-r--r-- 1 root root 2342 4月 2日 00:00 avatar.js\ndrwxr-xr-x 4 root root 4096 6月 3日 14:41 components\n-rw-r--r-- 1 root root 8648 6月 3日 14:41 config.js\n-rw-r--r-- 1 root root 6544 4月 2日 00:00 icons.jsx\n-rw-r--r-- 1 root root 459 3月18日 22:16 main.jsx\n-rw-r--r-- 1 root root 55853 6月 3日 14:41 styles.css\n"}],"isError":false,"timestamp":1781611534393}}
|
||
{"type":"message","id":"d05bd279","parentId":"bcd03264","timestamp":"2026-06-16T12:05:36.829Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看 config.js 了解前端配置。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_dd4af0c2a4424fe5974d7432","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/sproutgate-frontend/src/config.js"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":395,"output":49,"cacheRead":27136,"cacheWrite":0,"totalTokens":27580,"cost":{"input":0.00015800000000000002,"output":0.000098,"cacheRead":0.00217088,"cacheWrite":0,"total":0.00242688}},"stopReason":"toolUse","timestamp":1781611534394,"responseId":"a2298fc1ea344403a1a0027096c402fc"}}
|
||
{"type":"message","id":"38fc4692","parentId":"d05bd279","timestamp":"2026-06-16T12:05:36.832Z","message":{"role":"toolResult","toolCallId":"call_dd4af0c2a4424fe5974d7432","toolName":"read","content":[{"type":"text","text":"import { marked } from \"marked\";\n\nconst DEFAULT_DEV_API_BASE = \"http://localhost:8080\";\nconst DEFAULT_PROD_API_BASE = \"https://auth.api.shumengya.top\";\n\nexport const API_BASE = (() => {\n const configuredBase = import.meta.env.VITE_API_BASE?.trim();\n const fallbackBase = import.meta.env.DEV ? DEFAULT_DEV_API_BASE : DEFAULT_PROD_API_BASE;\n return (configuredBase || fallbackBase).replace(/\\/+$/, \"\");\n})();\n\n/** OAuth 成功回跳:当前页(不含 # fragment),与后端 `allowedReturnPrefixes` 白名单配合 */\nexport function getOAuthReturnTo() {\n if (typeof window === \"undefined\") return \"\";\n return `${window.location.origin}${window.location.pathname}${window.location.search}`;\n}\n\n/** `public/logo192.png`,含 Vite `base` 前缀,避免子路径部署时顶栏/开屏裂图 */\nexport const LOGO_192_SRC = `${import.meta.env.BASE_URL}logo192.png`;\n\n/** 全站随机背景(`GET /api/random?format=json`,见 https://randbg.api.smyhub.com) */\nexport const RAND_BG_API_ORIGIN = \"https://randbg.api.smyhub.com\";\n\nconst SELF_SERVICE_ACCOUNT_ALPHABET = \"abcdefghijklmnopqrstuvwxyz0123456789\";\n\n/** 与自助注册规则一致:随机小写字母与数字(默认长度 10) */\nexport function randomSelfServiceAccount(length = 10) {\n const n = Math.max(3, Math.min(32, Number(length) || 10));\n const buf = new Uint8Array(n);\n crypto.getRandomValues(buf);\n let s = \"\";\n for (let i = 0; i < n; i++) {\n s += SELF_SERVICE_ACCOUNT_ALPHABET[buf[i] % SELF_SERVICE_ACCOUNT_ALPHABET.length];\n }\n return s;\n}\n\n/** 输入时规范为自助注册允许的字符(小写、数字),超长截断 */\nexport function normalizeSelfServiceAccountInput(raw, maxLen = 32) {\n const m = Math.max(3, Math.min(32, maxLen));\n return String(raw || \"\")\n .toLowerCase()\n .replace(/[^a-z0-9]/g, \"\")\n .slice(0, m);\n}\n\n/** 浏览器侧 IP/地理(与后端写入「最后访问」字段配合使用) */\nexport const CLIENT_GEO_LOOKUP_URL = \"https://cf-ip-geo.smyhub.com/api\";\n\nexport function formatVisitDisplayLocation(payload) {\n const g = payload?.geo;\n if (!g || typeof g !== \"object\") return \"\";\n const parts = [g.countryName, g.regionName, g.cityName].filter(\n (x) => typeof x === \"string\" && x.trim()\n );\n return parts.join(\" \");\n}\n\nexport async function fetchClientVisitMeta(timeoutMs = 2800) {\n const ctrl = new AbortController();\n const id = setTimeout(() => ctrl.abort(), timeoutMs);\n try {\n const res = await fetch(CLIENT_GEO_LOOKUP_URL, {\n credentials: \"omit\",\n signal: ctrl.signal\n });\n clearTimeout(id);\n if (!res.ok) return { ip: \"\", displayLocation: \"\" };\n const data = await res.json();\n const ip = typeof data.ip === \"string\" ? data.ip.trim() : \"\";\n const displayLocation = formatVisitDisplayLocation(data);\n return { ip, displayLocation };\n } catch {\n clearTimeout(id);\n return { ip: \"\", displayLocation: \"\" };\n }\n}\n\nmarked.setOptions({ breaks: true });\nexport { marked };\n\nexport const emptyForm = {\n account: \"\",\n password: \"\",\n username: \"\",\n email: \"\",\n level: 0,\n sproutCoins: 0,\n secondaryEmails: \"\",\n phone: \"\",\n avatarUrl: \"\",\n websiteUrl: \"\",\n bio: \"\",\n banned: false,\n banReason: \"\"\n};\n\n/** 后端封禁响应:`error` + 可选 `banReason`(登录/me 等 403) */\nexport function formatAuthBanMessage(data) {\n const base = (data && data.error && String(data.error).trim()) || \"account is banned\";\n const reason = data && data.banReason != null && String(data.banReason).trim();\n return reason ? `${base}:${reason}` : base;\n}\n\n/** 展示用:从完整 URL 得到「主机 + 路径」短文案 */\nexport function formatWebsiteLabel(url) {\n if (!url || typeof url !== \"string\") return \"\";\n try {\n const u = new URL(url);\n const path = u.pathname === \"/\" ? \"\" : u.pathname;\n return u.host + path;\n } catch {\n return url;\n }\n}\n\n/** 用户 `createdAt`(RFC3339)→ 本地可读注册时间 */\nexport function formatUserRegisteredAt(iso) {\n if (!iso || typeof iso !== \"string\") return \"未知\";\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return new Intl.DateTimeFormat(\"zh-CN\", {\n dateStyle: \"long\",\n timeStyle: \"short\"\n }).format(d);\n}\n\n/** RFC3339 / 后端存储时间 → 本地可读(用于应用接入记录等) */\nexport function formatIsoDateTimeReadable(iso) {\n if (!iso || typeof iso !== \"string\") return \"—\";\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return new Intl.DateTimeFormat(\"zh-CN\", {\n dateStyle: \"medium\",\n timeStyle: \"medium\"\n }).format(d);\n}\n\nexport const parseEmailList = (value) =>\n value.split(\",\").map((item) => item.trim()).filter(Boolean);\n\n/** 可点击发信:`mailto:`,排除明显非法地址 */\nexport function mailtoHref(address) {\n const a = typeof address === \"string\" ? address.trim() : \"\";\n if (!a || /\\s/.test(a) || a.includes(\"<\") || a.includes(\">\")) return null;\n const at = a.indexOf(\"@\");\n if (at < 1 || at === a.length - 1) return null;\n return `mailto:${a}`;\n}\n\nexport function getPublicAccountFromPathname(pathname) {\n if (pathname !== \"/user\" && !pathname.startsWith(\"/user/\")) return \"\";\n const raw = pathname === \"/user\" ? \"\" : pathname.slice(\"/user/\".length).split(\"/\")[0];\n if (!raw) return \"\";\n try {\n return decodeURIComponent(raw).trim();\n } catch {\n return raw.trim();\n }\n}\n\nexport function getAuthFlowFromSearch(search) {\n const params = new URLSearchParams(search);\n const redirectUri = (params.get(\"redirect_uri\") || params.get(\"return_url\") || \"\").trim();\n const scopeRaw = (params.get(\"scope\") || \"\").trim();\n const scopes = scopeRaw\n ? scopeRaw.split(/[\\s+]+/).map((s) => s.trim()).filter(Boolean)\n : [];\n return {\n redirectUri,\n state: (params.get(\"state\") || \"\").trim(),\n prompt: (params.get(\"prompt\") || \"\").trim(),\n clientId: (params.get(\"client_id\") || \"\").trim(),\n clientName: (params.get(\"client_name\") || \"\").trim(),\n scopes,\n scopeRaw\n };\n}\n\n/** 授权页展示用:回跳地址可读标签(主机 + 路径) */\nexport function formatOAuthRedirectLabel(redirectUri) {\n if (!redirectUri || typeof redirectUri !== \"string\") return \"—\";\n const t = redirectUri.trim();\n if (!t) return \"—\";\n try {\n const u = new URL(t, typeof window !== \"undefined\" ? window.location.href : \"https://example.com\");\n const path = u.pathname === \"/\" ? \"\" : u.pathname;\n return `${u.host}${path}` || u.host;\n } catch {\n return t.length > 64 ? `${t.slice(0, 61)}…` : t;\n }\n}\n\nconst AUTH_CLIENT_ID_KEY = \"sproutgate_auth_client_id\";\nconst AUTH_CLIENT_NAME_KEY = \"sproutgate_auth_client_name\";\n\n/** 从统一登录 URL 上的 `client_id` / `client_name` 写入 session,后续请求带 `X-Auth-Client` 头以便记录接入应用 */\nexport function persistAuthClientFromFlow(authFlow) {\n if (!authFlow?.clientId) return;\n try {\n sessionStorage.setItem(AUTH_CLIENT_ID_KEY, authFlow.clientId);\n if (authFlow.clientName) sessionStorage.setItem(AUTH_CLIENT_NAME_KEY, authFlow.clientName);\n } catch {\n /* ignore */\n }\n}\n\nexport function authClientFetchHeaders() {\n try {\n const id = (sessionStorage.getItem(AUTH_CLIENT_ID_KEY) || \"\").trim();\n const name = (sessionStorage.getItem(AUTH_CLIENT_NAME_KEY) || \"\").trim();\n const h = {};\n if (id) h[\"X-Auth-Client\"] = id;\n if (name) h[\"X-Auth-Client-Name\"] = name;\n return h;\n } catch {\n return {};\n }\n}\n\nexport function clearAuthClientContext() {\n try {\n sessionStorage.removeItem(AUTH_CLIENT_ID_KEY);\n sessionStorage.removeItem(AUTH_CLIENT_NAME_KEY);\n } catch {\n /* ignore */\n }\n}\n\nexport function buildAuthCallbackUrl(redirectUri, payload) {\n const url = new URL(redirectUri, window.location.href);\n const hashParams = new URLSearchParams(url.hash ? url.hash.slice(1) : \"\");\n Object.entries(payload).forEach(([key, value]) => {\n if (value === undefined || value === null || value === \"\") return;\n hashParams.set(key, String(value));\n });\n url.hash = hashParams.toString();\n return url.toString();\n}\n\n/** 用户拒绝授权:回跳到应用地址,在 URL hash 中携带 OAuth 风格 error(与成功回跳一致使用 fragment) */\nexport function buildAuthDenyCallbackUrl(redirectUri, state = \"\") {\n if (!redirectUri || typeof redirectUri !== \"string\") return \"\";\n const url = new URL(redirectUri, window.location.href);\n const hashParams = new URLSearchParams();\n hashParams.set(\"error\", \"access_denied\");\n hashParams.set(\"error_description\", \"resource_owner_denied\");\n if (state) hashParams.set(\"state\", String(state));\n url.hash = hashParams.toString();\n return url.toString();\n}\n"}],"isError":false,"timestamp":1781611536832}}
|
||
{"type":"message","id":"3e70b898","parentId":"38fc4692","timestamp":"2026-06-16T12:05:38.974Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我看看数据库相关代码,了解数据存储结构。先看看 database 目录。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_ea0e6ee0101d4d9884698251","name":"bash","arguments":{"command":"ls -la /shumengya/project/frontend-backend/SproutGate/sproutgate-backend/internal/database"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":2357,"output":63,"cacheRead":27520,"cacheWrite":0,"totalTokens":29940,"cost":{"input":0.0009428,"output":0.000126,"cacheRead":0.0022016,"cacheWrite":0,"total":0.0032704}},"stopReason":"toolUse","timestamp":1781611536834,"responseId":"34ce397050a54a499c01ee5ec27ed97a"}}
|
||
{"type":"message","id":"7f8756b5","parentId":"3e70b898","timestamp":"2026-06-16T12:05:38.988Z","message":{"role":"toolResult","toolCallId":"call_ea0e6ee0101d4d9884698251","toolName":"bash","content":[{"type":"text","text":"总计 12\ndrwxr-xr-x 2 root root 4096 4月 2日 00:00 .\ndrwxr-xr-x 9 root root 4096 4月 2日 00:00 ..\n-rw-r--r-- 1 root root 1800 4月 2日 00:00 db.go\n"}],"isError":false,"timestamp":1781611538988}}
|
||
{"type":"message","id":"eef03f1c","parentId":"7f8756b5","timestamp":"2026-06-16T12:05:40.277Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看 db.go 文件,了解数据库连接。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_c313e1cb07e441e09f0ccc04","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/sproutgate-backend/internal/database/db.go"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":221,"output":50,"cacheRead":29824,"cacheWrite":0,"totalTokens":30095,"cost":{"input":0.00008840000000000001,"output":0.00009999999999999999,"cacheRead":0.00238592,"cacheWrite":0,"total":0.00257432}},"stopReason":"toolUse","timestamp":1781611538989,"responseId":"2cbe655aaa64419fb6e49cbf610be42c"}}
|
||
{"type":"message","id":"fce80e21","parentId":"eef03f1c","timestamp":"2026-06-16T12:05:40.279Z","message":{"role":"toolResult","toolCallId":"call_c313e1cb07e441e09f0ccc04","toolName":"read","content":[{"type":"text","text":"package database\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\n\t\"gorm.io/driver/mysql\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n)\n\n// DBConfig holds MySQL connection parameters.\ntype DBConfig struct {\n\tHost string\n\tPort string\n\tUser string\n\tPassword string\n\tDBName string\n}\n\nfunc (c DBConfig) DSN() string {\n\treturn fmt.Sprintf(\"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local\",\n\t\tc.User, c.Password, c.Host, c.Port, c.DBName)\n}\n\nfunc devConfig() DBConfig {\n\treturn DBConfig{\n\t\tHost: \"10.1.1.100\",\n\t\tPort: \"3306\",\n\t\tUser: \"sproutgate-test\",\n\t\tPassword: \"sproutgate-test\",\n\t\tDBName: \"sproutgate-test\",\n\t}\n}\n\nfunc prodConfig() DBConfig {\n\treturn DBConfig{\n\t\tHost: \"192.168.1.100\",\n\t\tPort: \"3306\",\n\t\tUser: \"sproutgate\",\n\t\tPassword: \"sproutgate\",\n\t\tDBName: \"sproutgate\",\n\t}\n}\n\n// Open 根据环境变量打开数据库连接。\n// 优先级:DB_DSN > APP_ENV(production/prod -> 生产库) > 默认开发库\nfunc Open() (*gorm.DB, error) {\n\tdsn := os.Getenv(\"DB_DSN\")\n\tif dsn == \"\" {\n\t\tenv := os.Getenv(\"APP_ENV\")\n\t\tif env == \"production\" || env == \"prod\" {\n\t\t\tdsn = prodConfig().DSN()\n\t\t\tlog.Println(\"[database] 使用生产数据库 192.168.1.100:3306/sproutgate\")\n\t\t} else {\n\t\t\tdsn = devConfig().DSN()\n\t\t\tlog.Println(\"[database] 使用开发数据库 10.1.1.100:3306/sproutgate-test\")\n\t\t}\n\t} else {\n\t\tlog.Println(\"[database] 使用自定义 DB_DSN\")\n\t}\n\n\tlogLevel := logger.Info\n\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\tlogLevel = logger.Warn\n\t}\n\n\tdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logLevel),\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"连接数据库失败: %w\", err)\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsqlDB.SetMaxOpenConns(25)\n\tsqlDB.SetMaxIdleConns(5)\n\n\treturn db, nil\n}\n"}],"isError":false,"timestamp":1781611540279}}
|
||
{"type":"message","id":"f25552e8","parentId":"fce80e21","timestamp":"2026-06-16T12:05:44.997Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我看看 storage 目录,了解数据存储的具体实现。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_ac5e65535981474285846a8a","name":"bash","arguments":{"command":"ls -la /shumengya/project/frontend-backend/SproutGate/sproutgate-backend/internal/storage"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":690,"output":58,"cacheRead":30016,"cacheWrite":0,"totalTokens":30764,"cost":{"input":0.00027600000000000004,"output":0.000116,"cacheRead":0.00240128,"cacheWrite":0,"total":0.00279328}},"stopReason":"toolUse","timestamp":1781611540280,"responseId":"a52284cb2d34440a98c5975be9872612"}}
|
||
{"type":"message","id":"437e143b","parentId":"f25552e8","timestamp":"2026-06-16T12:05:45.011Z","message":{"role":"toolResult","toolCallId":"call_ac5e65535981474285846a8a","toolName":"bash","content":[{"type":"text","text":"总计 100\ndrwxr-xr-x 2 root root 4096 6月 3日 14:41 .\ndrwxr-xr-x 9 root root 4096 4月 2日 00:00 ..\n-rw-r--r-- 1 root root 1928 6月 3日 14:41 config_hot_reload.go\n-rw-r--r-- 1 root root 12598 6月 3日 14:41 dbmodels.go\n-rw-r--r-- 1 root root 15110 6月 3日 14:41 oauth.go\n-rw-r--r-- 1 root root 911 4月 2日 00:00 pending.go\n-rw-r--r-- 1 root root 7028 4月 2日 00:00 profile_likes.go\n-rw-r--r-- 1 root root 11049 4月 2日 00:00 registration.go\n-rw-r--r-- 1 root root 792 4月 2日 00:00 reset.go\n-rw-r--r-- 1 root root 992 4月 2日 00:00 secondary.go\n-rw-r--r-- 1 root root 16703 6月 3日 14:41 storage.go\n-rw-r--r-- 1 root root 726 6月 3日 14:41 turnstile.go\n"}],"isError":false,"timestamp":1781611545010}}
|
||
{"type":"message","id":"0c77d446","parentId":"437e143b","timestamp":"2026-06-16T12:05:47.334Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看 storage.go 了解主要的存储实现。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_2ec084aec0764c27a0278e87","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/sproutgate-backend/internal/storage/storage.go","limit":150}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":506,"output":62,"cacheRead":30656,"cacheWrite":0,"totalTokens":31224,"cost":{"input":0.00020240000000000001,"output":0.000124,"cacheRead":0.00245248,"cacheWrite":0,"total":0.00277888}},"stopReason":"toolUse","timestamp":1781611545012,"responseId":"d3764a41380040d7adcce931aba27d7c"}}
|
||
{"type":"message","id":"a0cd6653","parentId":"0c77d446","timestamp":"2026-06-16T12:05:47.337Z","message":{"role":"toolResult","toolCallId":"call_2ec084aec0764c27a0278e87","toolName":"read","content":[{"type":"text","text":"package storage\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\n\t\"sproutgate-backend/internal/models\"\n)\n\n// ─── 配置结构体(公开 API 不变)────────────────────────────────────────────────\n\ntype AdminConfig struct {\n\tToken string `json:\"token\"`\n}\n\ntype AuthConfig struct {\n\tJWTSecret string `json:\"jwtSecret\"`\n\tIssuer string `json:\"issuer\"`\n}\n\ntype EmailConfig struct {\n\tFromName string `json:\"fromName\"`\n\tFromAddress string `json:\"fromAddress\"`\n\tUsername string `json:\"username\"`\n\tPassword string `json:\"password\"`\n\tSMTPHost string `json:\"smtpHost\"`\n\tSMTPPort int `json:\"smtpPort\"`\n\tEncryption string `json:\"encryption\"`\n}\n\ntype CheckInConfig struct {\n\tRewardCoins int `json:\"rewardCoins\"`\n}\n\n// OAuthConfig 第三方登录(GitHub / Gitea / Google / Microsoft / Linux.do),密钥也可由环境变量覆盖。\ntype OAuthConfig struct {\n\tGitHubEnabled bool `json:\"githubEnabled\"`\n\tGiteaEnabled bool `json:\"giteaEnabled\"`\n\tGoogleEnabled bool `json:\"googleEnabled\"`\n\tMicrosoftEnabled bool `json:\"microsoftEnabled\"`\n\tLinuxdoEnabled bool `json:\"linuxdoEnabled\"`\n\tGitHubClientID string `json:\"githubClientId\"`\n\tGitHubClientSecret string `json:\"githubClientSecret\"`\n\tGiteaBaseURL string `json:\"giteaBaseUrl\"`\n\tGiteaClientID string `json:\"giteaClientId\"`\n\tGiteaClientSecret string `json:\"giteaClientSecret\"`\n\tGoogleClientID string `json:\"googleClientId\"`\n\tGoogleClientSecret string `json:\"googleClientSecret\"`\n\tMicrosoftClientID string `json:\"microsoftClientId\"`\n\tMicrosoftClientSecret string `json:\"microsoftClientSecret\"`\n\tMicrosoftTenant string `json:\"microsoftTenant\"`\n\tLinuxdoConnectBaseURL string `json:\"linuxdoConnectBaseUrl\"`\n\tLinuxdoClientID string `json:\"linuxdoClientId\"`\n\tLinuxdoClientSecret string `json:\"linuxdoClientSecret\"`\n\tGitHubLogoURL string `json:\"githubLogoUrl\"`\n\tGiteaLogoURL string `json:\"giteaLogoUrl\"`\n\tGoogleLogoURL string `json:\"googleLogoUrl\"`\n\tMicrosoftLogoURL string `json:\"microsoftLogoUrl\"`\n\tLinuxdoLogoURL string `json:\"linuxdoLogoUrl\"`\n\tAllowedReturnPrefixes []string `json:\"allowedReturnPrefixes\"`\n\tAllowOAuthSignUpWhenInviteRequired bool `json:\"allowOAuthSignUpWhenInviteRequired\"`\n}\n\n// TurnstileConfig Cloudflare Turnstile 人机验证配置。\ntype TurnstileConfig struct {\n\tEnabled bool `json:\"enabled\"`\n\tSiteKey string `json:\"siteKey\"`\n\tSecretKey string `json:\"secretKey\"`\n}\n\n// ─── Store ─────────────────────────────────────────────────────────────────────\n\ntype Store struct {\n\tdb *gorm.DB\n\tadminToken string\n\tjwtSecret []byte\n\tissuer string\n\temailConfig EmailConfig\n\tcheckInConfig CheckInConfig\n\tregistrationConfig RegistrationConfig\n\toauthConfig OAuthConfig\n\tturnstileConfig TurnstileConfig\n\tmu sync.RWMutex\n\thotReloadMu sync.Mutex\n\tlastHotReload time.Time\n}\n\n// NewStore 接收已建立连接的 *gorm.DB,自动迁移表结构并加载配置。\nfunc NewStore(db *gorm.DB) (*Store, error) {\n\tif err := db.AutoMigrate(\n\t\t&DBUser{},\n\t\t&DBPendingUser{},\n\t\t&DBResetPassword{},\n\t\t&DBSecondaryEmailVerification{},\n\t\t&DBAppConfig{},\n\t\t&DBInviteCode{},\n\t\t&DBProfileLike{},\n\t\t&DBProfileLikeDailyQuota{},\n\t); err != nil {\n\t\treturn nil, err\n\t}\n\n\tstore := &Store{db: db}\n\n\tif err := store.loadOrCreateAdminConfig(); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := store.loadOrCreateAuthConfig(); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := store.loadOrCreateEmailConfig(); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := store.loadOrCreateCheckInConfig(); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := store.loadOrCreateRegistrationConfig(); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := store.loadOrCreateOAuthConfig(); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := store.loadOrCreateTurnstileConfig(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn store, nil\n}\n\n// ─── 配置辅助 ────────────────────────────────────────────────────────────────\n\nfunc (s *Store) getConfig(key string, target any) (bool, error) {\n\tvar row DBAppConfig\n\tresult := s.db.First(&row, \"config_key = ?\", key)\n\tif errors.Is(result.Error, gorm.ErrRecordNotFound) {\n\t\treturn false, nil\n\t}\n\tif result.Error != nil {\n\t\treturn false, result.Error\n\t}\n\treturn true, json.Unmarshal([]byte(row.ConfigValue), target)\n\n[396 more lines in file. Use offset=151 to continue.]"}],"isError":false,"timestamp":1781611547336}}
|
||
{"type":"message","id":"f53882eb","parentId":"a0cd6653","timestamp":"2026-06-16T12:05:49.709Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我看看前端的一些主要组件,特别是 OAuth 相关的功能。让我看看 OAuthConsentScreen.jsx。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_2e1d182d45414f5c9489c9ec","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/sproutgate-frontend/src/components/OAuthConsentScreen.jsx"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1321,"output":68,"cacheRead":31104,"cacheWrite":0,"totalTokens":32493,"cost":{"input":0.0005284,"output":0.000136,"cacheRead":0.00248832,"cacheWrite":0,"total":0.0031527200000000003}},"stopReason":"toolUse","timestamp":1781611547338,"responseId":"41023d626f614fb0aa35809184f26707"}}
|
||
{"type":"message","id":"eac33f3a","parentId":"f53882eb","timestamp":"2026-06-16T12:05:49.712Z","message":{"role":"toolResult","toolCallId":"call_2e1d182d45414f5c9489c9ec","toolName":"read","content":[{"type":"text","text":"import React, { useMemo, useState } from \"react\";\nimport {\n buildAuthDenyCallbackUrl,\n formatOAuthRedirectLabel,\n formatWebsiteLabel\n} from \"../config\";\nimport icons from \"../icons\";\nimport { MailtoEmail } from \"./common\";\nimport AvatarImg from \"./AvatarImg\";\n\nconst DEFAULT_PERMISSIONS = [\n {\n title: \"账户标识\",\n desc: \"获取账户名(account),用于在该应用中唯一识别你的萌芽身份。\"\n },\n {\n title: \"基本资料\",\n desc: \"获取昵称、头像、邮箱、等级、萌芽币、个人主页与简介等(以「验证令牌」「当前用户」等接口实际返回为准)。\"\n },\n {\n title: \"访问令牌\",\n desc: \"向你选择的应用颁发 JWT;应用仅在令牌有效期内、经你同意后代表你请求萌芽账户认证中心接口,不会获得你的登录密码。\"\n },\n {\n title: \"应用接入记录\",\n desc: \"在「应用接入」中记录该应用的 client_id 与名称,便于你在用户中心查看已与哪些应用建立关联。\"\n }\n];\n\nfunction scopeLabel(s) {\n const lower = String(s || \"\").toLowerCase().trim();\n const map = {\n openid: \"OpenID(账户标识)\",\n profile: \"个人资料(昵称、头像等展示信息)\",\n email: \"邮箱地址\",\n offline_access: \"离线访问(延长令牌有效期的扩展能力,若应用支持)\"\n };\n return map[lower] || s;\n}\n\nexport default function OAuthConsentScreen({\n authFlow,\n user,\n onAllow,\n onSwitchAccount,\n onPreviewImage\n}) {\n const [permsOpen, setPermsOpen] = useState(true);\n const appLabel = useMemo(() => {\n const name = (authFlow?.clientName || \"\").trim();\n const id = (authFlow?.clientId || \"\").trim();\n if (name) return name;\n if (id) return id;\n return \"第三方应用\";\n }, [authFlow]);\n\n const redirectLabel = formatOAuthRedirectLabel(authFlow?.redirectUri);\n const redirectUrl = (authFlow?.redirectUri || \"\").trim();\n\n const extraScopes = useMemo(() => {\n const raw = authFlow?.scopes || [];\n if (!raw.length) return [];\n return raw.map(scopeLabel);\n }, [authFlow]);\n\n const handleDeny = () => {\n const url = buildAuthDenyCallbackUrl(authFlow?.redirectUri, authFlow?.state || \"\");\n if (url) window.location.replace(url);\n };\n\n return (\n <div className=\"oauth-consent\">\n <div className=\"oauth-consent-brand\">\n <div className=\"oauth-consent-icon-wrap\" aria-hidden>\n <svg className=\"oauth-consent-lock\" viewBox=\"0 0 24 24\" width=\"28\" height=\"28\">\n <rect x=\"5\" y=\"10\" width=\"14\" height=\"11\" rx=\"2\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" />\n <path d=\"M8 10V8a4 4 0 0 1 8 0v2\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" />\n </svg>\n </div>\n <h1 className=\"oauth-consent-app-name\">{appLabel}</h1>\n <p className=\"oauth-consent-lead\">请求访问你的<strong>萌芽统一账户</strong></p>\n </div>\n\n <div className=\"oauth-consent-card oauth-consent-card--identity\">\n <div className=\"oauth-consent-identity\">\n <AvatarImg\n user={user}\n placeholderSize={56}\n alt=\"avatar\"\n className=\"oauth-consent-avatar previewable-image\"\n previewable\n onPreviewImage={onPreviewImage}\n previewLabel={user?.username || user?.account || \"avatar\"}\n />\n <div className=\"oauth-consent-id-text\">\n <div className=\"oauth-consent-display\">{user?.username || user?.account || \"用户\"}</div>\n <div className=\"oauth-consent-as\">\n 以 <span className=\"mono oauth-consent-account\">@{user?.account}</span> 的身份授权\n </div>\n {user?.email ? (\n <div className=\"oauth-consent-email\">\n <MailtoEmail address={user.email} className=\"profile-external-link\">{user.email}</MailtoEmail>\n </div>\n ) : (\n <div className=\"muted oauth-consent-email\">未绑定邮箱</div>\n )}\n </div>\n </div>\n </div>\n\n <div className=\"oauth-consent-card oauth-consent-card--detail\">\n <div className=\"oauth-consent-section-label\">应用信息</div>\n <ul className=\"oauth-consent-meta\">\n <li>\n <span className=\"icon oauth-consent-meta-icon\">{icons.link}</span>\n <div>\n <div className=\"oauth-consent-meta-key\">回跳地址</div>\n {redirectUrl.match(/^https?:\\/\\//i) ? (\n <a\n href={redirectUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"oauth-consent-meta-link\"\n >\n {redirectLabel}\n </a>\n ) : (\n <span className=\"mono oauth-consent-meta-val\">{redirectLabel}</span>\n )}\n </div>\n </li>\n {(authFlow?.clientId || \"\").trim() ? (\n <li>\n <span className=\"icon oauth-consent-meta-icon\">{icons.apps}</span>\n <div>\n <div className=\"oauth-consent-meta-key\">应用 ID(client_id)</div>\n <span className=\"mono oauth-consent-meta-val\">{authFlow.clientId.trim()}</span>\n </div>\n </li>\n ) : null}\n {(authFlow?.clientName || \"\").trim() ? (\n <li>\n <span className=\"icon oauth-consent-meta-icon\">{icons.username}</span>\n <div>\n <div className=\"oauth-consent-meta-key\">应用名称</div>\n <span className=\"oauth-consent-meta-val\">{authFlow.clientName.trim()}</span>\n </div>\n </li>\n ) : null}\n </ul>\n\n <div className=\"oauth-consent-section-label oauth-consent-section-label--perm\">将获取以下信息与权限</div>\n <button\n type=\"button\"\n className={`oauth-consent-perm-toggle${permsOpen ? \" is-open\" : \"\"}`}\n onClick={() => setPermsOpen((v) => !v)}\n aria-expanded={permsOpen}\n >\n <span className=\"oauth-consent-perm-toggle-text\">查看详情</span>\n <span className=\"oauth-consent-chevron\" aria-hidden>▾</span>\n </button>\n {permsOpen && (\n <ul className=\"oauth-consent-perms\">\n {DEFAULT_PERMISSIONS.map((p) => (\n <li key={p.title}>\n <span className=\"oauth-consent-perm-icon\">{icons.token}</span>\n <div>\n <div className=\"oauth-consent-perm-title\">{p.title}</div>\n <div className=\"oauth-consent-perm-desc\">{p.desc}</div>\n </div>\n </li>\n ))}\n {extraScopes.length > 0 ? (\n <li>\n <span className=\"oauth-consent-perm-icon\">{icons.level}</span>\n <div>\n <div className=\"oauth-consent-perm-title\">应用请求的 scope</div>\n <ul className=\"oauth-consent-scope-list\">\n {extraScopes.map((s) => (\n <li key={s}>{s}</li>\n ))}\n </ul>\n </div>\n </li>\n ) : null}\n </ul>\n )}\n </div>\n\n <div className=\"oauth-consent-actions\">\n <button type=\"button\" className=\"primary oauth-consent-allow\" onClick={onAllow}>\n 允许\n </button>\n <button type=\"button\" className=\"ghost oauth-consent-deny\" onClick={handleDeny}>\n 拒绝\n </button>\n <button type=\"button\" className=\"oauth-consent-switch text-button\" onClick={onSwitchAccount}>\n 使用其他账号登录\n </button>\n </div>\n\n <p className=\"oauth-consent-footnote\">\n 点击「允许」后,你将返回应用 <span className=\"mono\">{formatWebsiteLabel(redirectUrl) || redirectLabel}</span>,并在浏览器地址的 Hash 中携带访问令牌。\n </p>\n </div>\n );\n}\n"}],"isError":false,"timestamp":1781611549711}}
|
||
{"type":"message","id":"adadc70e","parentId":"eac33f3a","timestamp":"2026-06-16T12:05:51.326Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我看看 API 文档,了解第三方应用如何接入。先看看萌芽账户认证中心-第三方应用API接入文档.md。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_313ee076ef684232b6fa3eb5","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/萌芽账户认证中心-第三方应用API接入文档.md","limit":200}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1997,"output":83,"cacheRead":32384,"cacheWrite":0,"totalTokens":34464,"cost":{"input":0.0007988000000000001,"output":0.000166,"cacheRead":0.00259072,"cacheWrite":0,"total":0.00355552}},"stopReason":"toolUse","timestamp":1781611549713,"responseId":"a5b10aa8c8044269a31ac07dcb01963e"}}
|
||
{"type":"message","id":"d1bd572f","parentId":"adadc70e","timestamp":"2026-06-16T12:05:51.329Z","message":{"role":"toolResult","toolCallId":"call_313ee076ef684232b6fa3eb5","toolName":"read","content":[{"type":"text","text":"# 萌芽账户认证中心 API 文档\n\n访问 `**GET /**` 或 `**GET /api**`(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`links.health`、`routePrefixes` 等)。**运行时未挂载**返回 Markdown 的 `**GET /api/docs`**;完整 HTTP 清单以 `**sproutgate-backend/main.go`** 与本文件、`**sproutgate-backend/后端文档.md**` 为准。\n\n接入地址:\n\n- 统一登录前端:`https://auth.shumengya.top`\n- 后端 API:`https://auth.api.shumengya.top`\n- 本地开发 API:`http://<host>:8080`\n\n对外接入建议:\n\n1. 第三方「登录」按钮跳转到统一登录前端的 **授权入口**(推荐 `.../authorize?...`,见下节)。\n2. 用户在统一登录站点完成登录与授权确认后,回跳到业务站点。\n3. 业务站点使用回跳 URL `**#`** 哈希中的 `token`,由**自有后端**调用 `POST /api/auth/verify` 或 `GET /api/auth/me` 校验并建立会话。\n\n**推荐入口 URL(生产):** 将下列主机换为你的统一登录部署域名;子路径部署时在前端 `base` 后追加 `authorize`。\n\n```html\n<a href=\"https://auth.shumengya.top/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123&client_id=my-app&client_name=%E6%88%91%E7%9A%84%E5%BA%94%E7%94%A8\">\n 使用萌芽统一账户认证登录\n</a>\n```\n\n说明:\n\n- 若用户打开的是站点根路径且仅携带 `redirect_uri` / `return_url` 等查询参数,前端会自动 **重定向** 到 `**/authorize`**(保留同一套 query),以使用独立授权确认页。\n- `client_id` / `client_name` 会由浏览器写入 session,并在用户登录时提交给后端,用于累计 **应用接入记录**(详见下文「应用接入记录」)。\n\n回跳说明:\n\n- 在 `**/authorize`** 流程下,已登录用户会看到明确的授权页(应用信息、权限说明、允许/拒绝);拒绝时回跳地址的 hash 中会携带 `error=access_denied` 等。\n- 登录并「允许」后,浏览器回跳到 `redirect_uri`(或 `return_url`),并在 URL `**#fragment`**(哈希)中带上令牌与用户信息(见下表)。\n- 第三方拿到 `token` 后,建议服务端调用 `**POST /api/auth/verify**`(不更新「最后访问」等侧写,适合网关鉴权)或 `**GET /api/auth/me**`(会更新访问/签到相关侧写,适合拉取个人中心级资料)校验并解析用户身份。\n\n### 统一登录前端:查询参数\n\n\n| 参数 | 必填 | 说明 |\n| -------------- | ------------------- | ------------------------------------------------------------------- |\n| `redirect_uri` | 与 `return_url` 至少其一 | 登录成功后的回跳地址,须进行 URL 编码;可为绝对 URL 或相对路径(相对路径相对统一登录站点解析)。 |\n| `return_url` | 同上 | 与 `redirect_uri` 同义,二者都传时优先 `redirect_uri`。 |\n| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 |\n| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 |\n| `client_id` | 否 | 第三方应用稳定标识;格式须符合后端校验(见下文「应用接入记录」)。写入 session 后随登录请求提交,用于 **应用接入记录**。 |\n| `client_name` | 否 | 展示用名称(最长 128),与 `client_id` 配对;可选。 |\n\n\n### 回跳 URL:`#` 哈希参数\n\n成功授权后,前端将使用 `[URLSearchParams](https://developer.mozilla.org/zh-CN/docs/Web/API/URLSearchParams)` 写入哈希,例如:`https://app.example.com/auth/callback#token=...&expiresAt=...&account=...&username=...&state=...`。\n\n\n| 参数 | 说明 |\n| ----------- | -------------------------------------------------- |\n| `token` | JWT,调用受保护接口时放在请求头 `Authorization: Bearer <token>`。 |\n| `expiresAt` | 过期时间,RFC3339(与签发侧一致,当前默认为登录时起算 **7 天**)。 |\n| `account` | 账户名(与 JWT `sub` 一致)。 |\n| `username` | 展示用昵称,可能为空。 |\n| `state` | 若登录请求携带了 `state`,则原样返回。 |\n\n\n业务站点回调页应用脚本读取 `location.hash`,解析后**仅在 HTTPS 环境**将 `token` 存于内存或安全存储,并尽快用后端 `**POST /api/auth/verify`** 校验(勿仅信任哈希中的明文字段)。\n\n### 第三方后端接入建议\n\n1. **仅信服务端**:回调页将 `token` 交给自有后端,由后端请求 `POST https://<api-host>/api/auth/verify`(JSON body:`{\"token\":\"...\"}`),根据 `valid` 与 `user.account` 建立会话。\n2. **CORS**:浏览器直连 API 时须后端已配置 CORS(本服务默认允许任意 `Origin`);若从服务端发起请求则不受 CORS 限制。\n3. **令牌无效或封禁**:`verify` / `me` 返回 **401**、`verify` 因封禁返回 **403**(`valid: false`)时,应作废本地会话并引导用户重新走统一登录。\n\n## 应用接入记录(authClients)\n\n用户在统一登录前端的 **个人中心 → 应用接入** 中可看到已与账号发生过认证关联的第三方应用列表。每条记录对应一个稳定的 `**client_id`**,并展示展示名(若有)、首次接入时间、最近接入时间(均为 RFC3339)。持久化在 MySQL `users` 表中对应用户的 `**auth_clients` JSON 列**;对外 API 字段名仍为 `**authClients`**(与历史 JSON 文件存储时的形状一致)。\n\n示例展示含义(与界面文案一致):**infogenie**(万象口袋)首次 `2026-03-22T16:33:24+08:00` · 最近 `2026-03-30T19:22:45+08:00` —— 其中括号内为 `client_name` / `X-Auth-Client-Name` / 登录体 `clientName` 带来的展示名,`infogenie` 为 `client_id`。\n\n### `client_id` 与名称规则\n\n- `**client_id`(必填才能记一条)**:长度 1–64;必须以 **英文字母或数字** 开头;其余字符仅可为 `[A-Za-z0-9_.:-]`(与后端正则一致)。\n- `**client_name` / 展示名(可选)**:任意去首尾空白后的字符串,最长 **128** 字符,超出由服务端截断;对应存储字段为 `displayName`。\n\n### 在哪些接口上累计记录\n\n记录仅在 **令牌有效、用户未封禁、且 `client_id` 通过校验** 时写入或刷新 `**lastSeenAt`**;已存在同一 `client_id` 时更新 `**lastSeenAt`**,必要时更新 `**displayName**`,不改动 `**firstSeenAt**`。\n\n\n| 方式 | 接口 / 场景 | 如何传递 `client_id` 与展示名 |\n| ------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |\n| **A. 统一登录 URL** | 用户经前端完成登录 | 授权页 URL 查询参数 `**client_id`**、`**client_name`**(见上表)。前端写入 session,登录请求 `POST /api/auth/login` 的 JSON 中会带 `**clientId**` / `**clientName**`。 |\n| **B. 资源拥有者密码登录** | `POST /api/auth/login` | JSON `**clientId`**、`**clientName`**(可选,规则同上)。 |\n| **C. 服务端持 JWT 调用** | `POST /api/auth/verify` | 请求头 `**X-Auth-Client`**(必须为合法 id)、`**X-Auth-Client-Name`**(可选)。**Body 仅含 `token`,不能替代请求头。** |\n| **D. 服务端持 JWT 调用** | `GET /api/auth/me` | 同上:`**X-Auth-Client`**、`**X-Auth-Client-Name`**,与 `**Authorization: Bearer**` 同时使用。 |\n\n\n说明:\n\n- **A + B**:在登录成功、签发 JWT **之后**写库;`POST /api/auth/login` 的响应 `user` 为 `OwnerPublic()`,**含** `authClients`(若本次写入了新记录)。\n- **C**:`POST /api/auth/verify` 在校验通过且未封禁后写库;响应 `user` 为 `**Public()`,不含 `authClients`**,避免向第三方泄露用户在其他应用上的关联列表。\n- **D**:`GET /api/auth/me` 在校验通过且未封禁后写库;响应 `user` 为 `**OwnerPublic()`,含 `authClients`**,与前端个人中心一致。\n\n**封禁用户**:`POST /api/auth/verify` 与 `GET /api/auth/me` 在命中封禁或令牌无效时 **不会** 更新应用接入记录(在写库逻辑之前即返回错误)。\n\n**浏览器直连 API**:若前端或网关需带上 `X-Auth-Client`,请确认 CORS 允许该自定义头(本服务 CORS 已包含 `X-Auth-Client`、`X-Auth-Client-Name`)。\n\n### 存储结构(`authClients` 数组元素)\n\n```json\n{\n \"clientId\": \"infogenie\",\n \"displayName\": \"万象口袋\",\n \"firstSeenAt\": \"2026-03-22T16:33:24+08:00\",\n \"lastSeenAt\": \"2026-03-30T19:22:45+08:00\"\n}\n```\n\n## 认证与统一登录\n\n### 登录获取统一令牌\n\n`POST /api/auth/login`\n\n请求:\n\n```json\n{\n \"account\": \"demo\",\n \"password\": \"demo123\",\n \"clientId\": \"my-app\",\n \"clientName\": \"我的应用\"\n}\n```\n\n`clientId` / `clientName` 可选;规则与应用接入请求头一致。传入且格式合法时,会在登录成功后写入 **应用接入记录**(见上文 **「应用接入记录(authClients)」**)。\n\n响应:\n\n```json\n{\n \"token\": \"jwt-token\",\n \"expiresAt\": \"2026-03-14T12:00:00Z\",\n \"user\": {\n \"account\": \"demo\",\n \"username\": \"示例用户\",\n \"email\": \"demo@example.com\",\n \"level\": 0,\n \"sproutCoins\": 10,\n \"secondaryEmails\": [\"demo2@example.com\"],\n \"phone\": \"13800000000\",\n \"avatarUrl\": \"https://example.com/avatar.png\",\n \"websiteUrl\": \"https://example.com\",\n \"bio\": \"### 简介\",\n \"createdAt\": \"2026-03-14T12:00:00Z\",\n \"updatedAt\": \"2026-03-14T12:00:00Z\"\n }\n}\n```\n\n若账户已被管理员封禁,返回 **403**,且**不会签发 JWT**,响应示例:\n\n```json\n{\n \"error\": \"account is banned\",\n \"banReason\": \"违规内容\"\n}\n```\n\n`banReason` 可能为空字符串或省略。\n\n**常见 HTTP 状态码(登录)**\n\n\n| 状态码 | 含义 |\n| --- | --------------------------------------- |\n| 200 | 成功,返回 `token`、`expiresAt`、`user`。 |\n| 400 | 请求体非法或缺少 `account` / `password`。 |\n| 401 | 账户不存在或密码错误(统一文案 `invalid credentials`)。 |\n| 403 | 账户已封禁(见上文 JSON)。 |\n| 500 | 服务器内部错误(读库、签发 JWT 失败等)。 |\n\n\n**JWT 概要**:算法 **HS256**;载荷含 `account`(与 `sub` 一致)、`iss`(见 `data/config/auth.json`)、`iat` / `exp`。客户端只需透传字符串,**勿在前端解析密钥**。\n\n### 校验令牌\n\n`POST /api/auth/verify`\n\n请求头:`**Content-Type: application/json`**。可选 `**X-Auth-Client`**、`**X-Auth-Client-Name**`(累计应用接入记录,见专章)。\n\n请求体:\n\n```json\n{\n \"token\": \"jwt-token\"\n}\n```\n\n响应:\n\n```json\n{\n\n[528 more lines in file. Use offset=201 to continue.]"}],"isError":false,"timestamp":1781611551329}}
|
||
|
||
{"name":"SproutGate项目解析","timestamp":"2026-06-16T12:07:11Z","type":"session_info"}{"type":"message","id":"440bf1a2","parentId":"d1bd572f","timestamp":"2026-06-16T12:07:18.471Z","message":{"role":"user","content":[{"type":"text","text":"继续"}],"timestamp":1781611638470}}
|
||
{"type":"message","id":"fde88c2b","parentId":"440bf1a2","timestamp":"2026-06-16T12:07:22.095Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户想让我继续查看这个项目。我刚才已经读取了很多关键文件,现在应该给用户一个完整的介绍。让我先看看还有哪些重要部分没有查看,比如前端的组件结构、样式等,然后给出一个全面的总结。\n\n让我再看看一些关键文件来完善我的理解。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_1f3c853a457d49ecb27ff220","name":"read","arguments":{"path":"/shumengya/project/frontend-backend/SproutGate/萌芽账户认证中心-第三方应用API接入文档.md","offset":201}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":62,"output":117,"cacheRead":37696,"cacheWrite":0,"totalTokens":37875,"cost":{"input":0.000024800000000000003,"output":0.000234,"cacheRead":0.00301568,"cacheWrite":0,"total":0.00327448}},"stopReason":"toolUse","timestamp":1781611638472,"responseId":"8d0444e1fbf243eaa7eb97e32f13a676"}}
|
||
{"type":"message","id":"2b0e1bf2","parentId":"fde88c2b","timestamp":"2026-06-16T12:07:22.098Z","message":{"role":"toolResult","toolCallId":"call_1f3c853a457d49ecb27ff220","toolName":"read","content":[{"type":"text","text":" \"valid\": true,\n \"user\": { \"account\": \"demo\", \"...\": \"...\" }\n}\n```\n\n若账户已封禁,返回 **403**,正文示例(不返回 `user`):\n\n```json\n{\n \"valid\": false,\n \"error\": \"account is banned\",\n \"banReason\": \"违规内容\"\n}\n```\n\n令牌过期、签名错误、issuer 不匹配、`token` 被撤销(`token_epoch`)等失败时返回 **401**,示例:`{\"valid\": false, \"error\": \"invalid token\"}` 或 `{\"valid\": false, \"error\": \"token revoked\"}`(以服务端实际字段为准)。\n\n`verify` 与 `me` 的取舍:**仅校验身份、默认不更新「最后访问」等侧写**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer)。\n\n**应用接入记录**:在 `**verify` / `me`** 上使用 `**X-Auth-Client`**、`**X-Auth-Client-Name**` 的约定,以及响应 `user` 是否包含 `authClients`,见上文 **「应用接入记录(authClients)」**。\n\n### 获取当前用户信息\n\n`GET /api/auth/me`\n\n请求头:\n\n- `**Authorization: Bearer <jwt-token>`**(必填)\n\n可选 — **应用接入记录**(与 `verify` 相同语义,见 **「应用接入记录(authClients)」**):\n\n- `**X-Auth-Client`**、`**X-Auth-Client-Name`**\n\n可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」):\n\n- `X-Visit-Ip`:客户端公网 IP(与地理接口返回的 `ip` 一致即可)\n- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName`、`regionName`、`cityName` 拼接为 `中国 四川 成都`)\n\n**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求环境变量 `**GEO_LOOKUP_URL`** 指定的基址(默认与 `internal/clientgeo.DefaultLookupURL` 一致:`https://cf-ip-geo.smyhub.com/api`,实际请求会附加 `**?ip=`**)解析展示位置并写入用户记录。\n\n响应:\n\n```json\n{\n \"user\": { \"account\": \"demo\", \"...\": \"...\" },\n \"checkIn\": {\n \"rewardCoins\": 1,\n \"checkedInToday\": false,\n \"lastCheckInDate\": \"\",\n \"lastCheckInAt\": \"\",\n \"today\": \"2026-03-14\"\n }\n}\n```\n\n> `user` 还会包含 `lastVisitAt`、`lastVisitDate`、`checkInDays`、`checkInStreak`、`visitDays`、`visitStreak` 等统计字段。\n\n> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp`、`lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。`**POST /api/auth/verify` 返回的 `user`(`Public()`)中不包含这两项**(避免第三方校验响应携带访问隐私)。`**GET /api/public/users/:account` 的 `user`(`PublicProfile()`)会包含这两项**,与公开主页展示策略一致。\n\n> 说明:密码不会返回。\n\n若账户在登录后被封禁,持旧 JWT 调用 `GET /api/auth/me`、`PUT /api/auth/profile`、`POST /api/auth/check-in`、辅助邮箱等需登录接口时,返回 **403**,正文同登录封禁响应(`error` + 可选 `banReason`)。客户端应作废本地令牌。\n\n### 每日签到\n\n`POST /api/auth/check-in`\n\n请求头:\n`Authorization: Bearer <jwt-token>`\n\n响应:\n\n```json\n{\n \"checkedIn\": true,\n \"alreadyCheckedIn\": false,\n \"rewardCoins\": 1,\n \"awardedCoins\": 1,\n \"message\": \"签到成功\",\n \"user\": { \"account\": \"demo\", \"...\": \"...\" }\n}\n```\n\n### 更新当前用户资料\n\n`PUT /api/auth/profile`\n\n请求头:\n`Authorization: Bearer <jwt-token>`\n\n请求(字段可选):\n\n```json\n{\n \"password\": \"newpass\",\n \"username\": \"新昵称\",\n \"phone\": \"13800000000\",\n \"avatarUrl\": \"https://example.com/avatar.png\",\n \"websiteUrl\": \"https://example.com\",\n \"bio\": \"### 新简介\"\n}\n```\n\n说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`。\n\n响应:\n\n```json\n{\n \"user\": { \"account\": \"demo\", \"...\": \"...\" }\n}\n```\n\n## 用户广场\n\n### 获取公开用户目录\n\n`GET /api/public/users`\n\n无需鉴权。返回未封禁用户的简要列表,默认按 `**createdAt**`(注册时间)升序。\n\n响应:\n\n```json\n{\n \"total\": 2,\n \"users\": [\n {\n \"account\": \"demo\",\n \"username\": \"示例用户\",\n \"level\": 0,\n \"sproutCoins\": 10,\n \"avatarUrl\": \"https://example.com/avatar.png\",\n \"websiteUrl\": \"https://example.com\",\n \"bio\": \"### 简介\",\n \"createdAt\": \"2026-03-14T12:00:00+08:00\"\n }\n ]\n}\n```\n\n### 获取用户公开主页\n\n`GET /api/public/users/{account}`\n\n说明:\n\n- 仅支持路径参数 **账户名** `account`(可与存储大小写不同,服务端按不区分大小写匹配),不支持昵称查询。\n- 适合第三方应用展示用户公开资料。\n- 若该账户已被封禁,返回 **404** `{\"error\":\"user not found\"}`(与不存在账户相同,避免公开资料泄露)。\n- 响应体在 `user` 之外包含累计赞数 `**profileLikeCount`**。\n- `**Authorization: Bearer`** 可选:若传入**有效未封禁**用户令牌且非主页主人,响应可额外包含 `viewerHasLikedToday`、`viewerLikesRemainingToday`、`profileLikeDailyMax`(当日是否已赞该主页、当日还可给多少人点赞、每自然日上限当前为 **5**,以后端常量为准)。若浏览者即主页主人,可返回 `viewerIsOwner: true`。\n- `user` 中含该用户**最近一次被服务端记录的**访问 IP(`lastVisitIp`)与展示地理位置(`lastVisitDisplayLocation`);`POST /api/auth/verify` 返回的用户 JSON **不含**上述两项。\n\n响应示例(未带 Bearer):\n\n```json\n{\n \"user\": {\n \"account\": \"demo\",\n \"username\": \"示例用户\",\n \"level\": 3,\n \"sproutCoins\": 10,\n \"avatarUrl\": \"https://example.com/avatar.png\",\n \"websiteUrl\": \"https://example.com\",\n \"lastVisitIp\": \"203.0.113.1\",\n \"lastVisitDisplayLocation\": \"中国 广东省 深圳市\",\n \"bio\": \"### 简介\"\n },\n \"profileLikeCount\": 12\n}\n```\n\n### 公开主页点赞\n\n`POST /api/public/users/{account}/like`\n\n请求头:`**Authorization: Bearer <jwt-token>**`(必填)。\n\n为路径中 `**account**` 对应用户的公开主页点赞。规则摘要(与 `internal/storage` 一致):\n\n- 不能给自己点赞;每个点赞者对同一主页 **每个自然日最多一次**。\n- 每个点赞者每个自然日最多给 **5** 位**不同**用户点赞(超限返回 **400**,正文中可含 `viewerLikesRemainingToday`、`profileLikeDailyMax`)。\n- 目标用户不存在或已封禁:**404**;点赞者封禁或令牌无效:**401** / **403** 等,以服务端 `error` 为准。\n\n成功响应示例:\n\n```json\n{\n \"profileLikeCount\": 13,\n \"viewerHasLikedToday\": true,\n \"viewerLikesRemainingToday\": 4,\n \"profileLikeDailyMax\": 5\n}\n```\n\n### 公开注册策略\n\n`GET /api/public/registration-policy`\n\n无需鉴权。用于前端判断是否展示「邀请码」输入框。\n\n响应:\n\n```json\n{\n \"requireInviteCode\": false\n}\n```\n\n当 `requireInviteCode` 为 **true** 时,`POST /api/auth/register` 必须携带有效 `inviteCode`(见下节)。\n\n### 注册账号(发送邮箱验证码)\n\n`POST /api/auth/register`\n\n请求:\n\n```json\n{\n \"account\": \"demo\",\n \"password\": \"demo123\",\n \"username\": \"示例用户\",\n \"email\": \"demo@example.com\",\n \"inviteCode\": \"ABCD1234\"\n}\n```\n\n- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。\n\n响应:\n\n```json\n{\n \"sent\": true,\n \"expiresAt\": \"2026-03-14T12:10:00Z\"\n}\n```\n\n### 验证邮箱并完成注册\n\n`POST /api/auth/verify-email`\n\n请求:\n\n```json\n{\n \"account\": \"demo\",\n \"code\": \"123456\"\n}\n```\n\n响应:\n\n```json\n{\n \"created\": true,\n \"user\": { \"account\": \"demo\", \"...\": \"...\" }\n}\n```\n\n### 忘记密码(发送重置验证码)\n\n`POST /api/auth/forgot-password`\n\n请求:\n\n```json\n{\n \"account\": \"demo\",\n \"email\": \"demo@example.com\"\n}\n```\n\n响应:\n\n```json\n{\n \"sent\": true,\n \"expiresAt\": \"2026-03-14T12:10:00Z\"\n}\n```\n\n### 重置密码\n\n`POST /api/auth/reset-password`\n\n请求:\n\n```json\n{\n \"account\": \"demo\",\n \"code\": \"123456\",\n \"newPassword\": \"newpass\"\n}\n```\n\n响应:\n\n```json\n{ \"reset\": true }\n```\n\n### 申请添加辅助邮箱(发送验证码)\n\n`POST /api/auth/secondary-email/request`\n\n请求头:\n`Authorization: Bearer <jwt-token>`\n\n请求:\n\n```json\n{\n \"email\": \"demo2@example.com\"\n}\n```\n\n响应:\n\n```json\n{\n \"sent\": true,\n \"expiresAt\": \"2026-03-14T12:10:00Z\"\n}\n```\n\n### 验证辅助邮箱\n\n`POST /api/auth/secondary-email/verify`\n\n请求头:\n`Authorization: Bearer <jwt-token>`\n\n请求:\n\n```json\n{\n \"email\": \"demo2@example.com\",\n \"code\": \"123456\"\n}\n```\n\n响应:\n\n```json\n{\n \"verified\": true,\n \"user\": { \"account\": \"demo\", \"...\": \"...\" }\n}\n```\n\n## 管理端接口(需要管理员 Token)\n\n管理员 Token 存放在 `data/config/admin.json` 中;如果文件不存在,后端启动时会自动生成并写入该文件。\n请求时可使用以下任一方式携带:\n\n- Query:`?token=<admin-token>`\n- Header:`X-Admin-Token: <admin-token>`\n\n### 签到奖励设置\n\n`GET /api/admin/check-in/config`\n\n`PUT /api/admin/check-in/config`\n\n请求:\n\n```json\n{\n \"rewardCoins\": 1\n}\n```\n\n- Header:`Authorization: Bearer <admin-token>`\n\n### 注册策略与邀请码\n\n`GET /api/admin/registration`\n\n响应含 `requireInviteCode` 与 `invites` 数组(每项含 `code`、`note`、`maxUses`、`uses`、`expiresAt`、`createdAt`)。`maxUses` 为 0 表示不限次数。\n\n`PUT /api/admin/registration`\n\n请求:\n\n```json\n{ \"requireInviteCode\": true }\n```\n\n`POST /api/admin/registration/invites`\n\n请求:\n\n```json\n{\n \"note\": \"内测批次\",\n \"maxUses\": 10,\n \"expiresAt\": \"2026-12-31T15:59:59Z\"\n}\n```\n\n`expiresAt` 可省略;须为 RFC3339。响应 `201`,`invite` 内含服务端生成的 8 位邀请码。\n\n`DELETE /api/admin/registration/invites/{code}`\n\n删除指定邀请码(`code` 与存储大小写可能不同,按不区分大小写匹配)。\n\n### 获取用户列表\n\n`GET /api/admin/users`\n\n响应:\n\n```json\n{\n \"total\": 1,\n \"users\": [{ \"account\": \"demo\", \"...\": \"...\" }]\n}\n```\n\n### 新建用户\n\n`POST /api/admin/users`\n\n请求:\n\n```json\n{\n \"account\": \"demo\",\n \"password\": \"demo123\",\n \"username\": \"示例用户\",\n \"email\": \"demo@example.com\",\n \"level\": 0,\n \"sproutCoins\": 10,\n \"secondaryEmails\": [\"demo2@example.com\"],\n \"phone\": \"13800000000\",\n \"avatarUrl\": \"https://example.com/avatar.png\",\n \"websiteUrl\": \"https://example.com\",\n \"bio\": \"### 简介\"\n}\n```\n\n### 更新用户\n\n`PUT /api/admin/users/{account}`\n\n请求(字段可选):\n\n```json\n{\n \"password\": \"newpass\",\n \"username\": \"新昵称\",\n \"level\": 1,\n \"secondaryEmails\": [\"demo2@example.com\"],\n \"sproutCoins\": 99,\n \"websiteUrl\": \"https://example.com\",\n \"banned\": true,\n \"banReason\": \"违规说明(最多 500 字)\"\n}\n```\n\n- `banned`:是否封禁;解封时请传 `false`,并可将 `banReason` 置为空字符串。\n- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`(RFC3339,存于数据库用户记录)。\n\n管理员列表 `GET /api/admin/users` 中每条 `user` 为 `OwnerPublic()`,可含 `banned`、`banReason`、`bannedAt`(及 `authClients` 等)——以后端实际 JSON 为准。\n\n### 删除用户\n\n`DELETE /api/admin/users/{account}`\n\n响应:\n\n```json\n{ \"deleted\": true }\n```\n\n## 数据存储说明\n\n当前发行版**以 MySQL 为唯一运行时数据源**(GORM `AutoMigrate`),不再使用 `data/users/*.json` 等文件作为线上读写路径。表与用途概要:\n\n\n| 表名 | 用途 |\n| ------------------------------- | ----------------------------------------------------------------- |\n| `users` | 用户主数据;`auth_clients` 等为 JSON 列 |\n| `pending_users` | 注册邮箱待验证 |\n| `password_resets` | 找回密码验证码 |\n| `secondary_email_verifications` | 辅助邮箱验证 |\n| `app_configs` | `admin` / `auth` / `email` / `checkin` / `registration` 等 JSON 配置 |\n| `invite_codes` | 邀请码 |\n| `profile_likes` | 公开主页点赞记录 |\n| `profile_like_daily_quota` | 点赞每日额度辅助 |\n\n\n从旧版 `**sproutgate-backend/data/`** 目录迁移时,使用 `**go run ./cmd/migrate --data-dir ./data`**(详见 `**sproutgate-backend/后端文档.md**`)。\n\n## 快速联调用示例\n\n```bash\n# 服务根路径 JSON 说明\ncurl -s http://localhost:8080/ | jq .\n\n# 登录\ncurl -X POST http://localhost:8080/api/auth/login \\\n -H 'Content-Type: application/json' \\\n -d '{\"account\":\"demo\",\"password\":\"demo123\"}'\n\n# 校验令牌(推荐第三方网关先调此接口)\ncurl -X POST http://localhost:8080/api/auth/verify \\\n -H 'Content-Type: application/json' \\\n -H 'X-Auth-Client: my-app' \\\n -d '{\"token\":\"<jwt-token>\"}'\n\n# 同上,并携带应用展示名(累计应用接入记录)\ncurl -X POST http://localhost:8080/api/auth/verify \\\n -H 'Content-Type: application/json' \\\n -H 'X-Auth-Client: infogenie' \\\n -H 'X-Auth-Client-Name: 万象口袋' \\\n -d '{\"token\":\"<jwt-token>\"}'\n\n# 使用令牌获取用户信息(会更新访问记录);可一并携带 X-Auth-Client 刷新「应用接入」最近时间\ncurl http://localhost:8080/api/auth/me \\\n -H 'Authorization: Bearer <jwt-token>' \\\n -H 'X-Auth-Client: infogenie' \\\n -H 'X-Auth-Client-Name: 万象口袋'\n```\n\n"}],"isError":false,"timestamp":1781611642097}}
|
||
{"type":"message","id":"927d4dee","parentId":"2b0e1bf2","timestamp":"2026-06-16T12:07:36.283Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我对这个项目有了全面的了解。让我总结一下这个项目的主要特点和功能,然后给用户一个清晰的介绍。\n\n这个项目叫做 SproutGate(萌芽账户认证中心),是一个前后端分离的统一账户认证系统。主要特点:\n\n1. **功能丰富**:\n - 用户注册登录(支持邮箱验证)\n - 找回密码\n - 辅助邮箱绑定\n - 每日签到系统\n - 用户资料管理\n - 公开用户主页\n - 管理员后台\n\n2. **技术栈**:\n - 前端:React 18 + Vite 5(无路由库,通过 pathname 切换视图)\n - 后端:Go 1.20+ + Gin 框架\n - 数据库:MySQL(通过 GORM 操作)\n - 认证:JWT(HS256)\n\n3. **第三方接入**:\n - 支持 OAuth 式查询参数接入\n - 应用接入记录(authClients)\n - 支持 GitHub、Gitea、Google、Microsoft、Linux.do 等第三方登录\n\n4. **特色功能**:\n - 管理员入口:连续点击 Logo 五次进入\n - 公开用户主页和点赞系统\n - 邀请码注册策略\n - Cloudflare Turnstile 人机验证\n\n5. **部署方式**:\n - Docker 部署(docker-compose)\n - 数据持久化到 MySQL\n - 支持环境变量配置\n\n现在我可以给用户一个完整的介绍了。","thinkingSignature":"reasoning_content"},{"type":"text","text":"好的,我已经详细查看了 SproutGate 项目。这是一个**前后端分离的统一账户认证中心**,功能相当完整。\n\n## 项目概览\n\n**SproutGate(萌芽账户认证中心)** 是一个为自建和小规模部署设计的统一认证系统,支持第三方站点(如万象口袋)通过 OAuth 式流程接入。\n\n## 技术栈\n\n| 部分 | 技术 |\n|------|------|\n| **前端** | React 18 + Vite 5(无 react-router,通过 pathname 切换视图) |\n| **后端** | Go 1.20+ + Gin 框架 |\n| **数据库** | MySQL(通过 GORM 操作,已从早期 JSON 文件存储迁移到 MySQL) |\n| **认证** | JWT(HS256 算法) |\n\n## 核心功能\n\n### 用户端\n- **注册登录**:支持邮箱验证、邀请码策略\n- **密码找回**:通过邮箱验证码重置密码\n- **辅助邮箱**:绑定多个备用邮箱\n- **每日签到**:可配置签到奖励(萌芽币)\n- **资料管理**:头像、昵称、个人网站、简介等\n- **公开主页**:用户可公开个人资料,支持点赞系统\n\n### 第三方接入\n- **OAuth 式授权流程**:第三方站点跳转到统一登录页,用户授权后回跳携带 JWT\n- **应用接入记录**:记录哪些第三方应用关联了用户账号\n- **令牌校验接口**:`POST /api/auth/verify` 供第三方后端验证用户身份\n\n### 管理端\n- **用户管理**:CRUD、封禁/解封\n- **签到配置**:设置签到奖励数量\n- **注册策略**:是否强制邀请码、管理邀请码\n- **OAuth 配置**:管理第三方登录提供商\n- **Turnstile 配置**:Cloudflare 人机验证开关\n\n## 项目结构\n\n```\nSproutGate/\n├── sproutgate-backend/ # Go 后端\n│ ├── main.go # 入口,路由注册\n│ ├── internal/\n│ │ ├── handlers/ # HTTP 处理器(登录、注册、资料等)\n│ │ ├── storage/ # 数据存储层(GORM 操作)\n│ │ ├── models/ # 领域模型\n│ │ ├── auth/ # JWT 签发/解析\n│ │ └── database/ # MySQL 连接\n│ └── docs/ # Swagger 文档\n├── sproutgate-frontend/ # React 前端\n│ └── src/\n│ ├── App.jsx # 根组件(根据 pathname 切换视图)\n│ ├── components/\n│ │ ├── UserPortal.jsx # 用户中心(登录、注册、资料)\n│ │ ├── AdminPanel.jsx # 管理后台\n│ │ ├── PublicUserPage.jsx # 公开用户主页\n│ │ └── OAuthConsentScreen.jsx # OAuth 授权确认页\n│ └── config.js # API 地址、工具函数\n└── 萌芽账户认证中心-第三方应用API接入文档.md # 详细 API 文档\n```\n\n## 特色设计\n\n1. **管理员入口**:在顶栏**连续点击 Logo 五次**(约 2.6 秒内),弹出管理员 Token 验证窗口\n2. **统一登录授权页**:已登录用户会看到明确的授权确认页(应用信息、权限说明、允许/拒绝)\n3. **回跳机制**:授权成功后,JWT 通过 URL `#hash` 参数回跳到第三方站点,避免通过 query 参数泄露\n4. **访问位置记录**:支持记录用户最后访问 IP 和地理位置(通过 Cloudflare Geo API)\n\n## 部署地址(生产)\n\n- 统一登录前端:`https://auth.shumengya.top`\n- 后端 API:`https://auth.api.shumengya.top`\n\n## 数据库表\n\n| 表名 | 用途 |\n|------|------|\n| `users` | 用户主数据 |\n| `pending_users` | 注册待验证 |\n| `password_resets` | 找回密码验证码 |\n| `secondary_email_verifications` | 辅助邮箱验证 |\n| `app_configs` | 配置项(管理员令牌、JWT 密钥等) |\n| `invite_codes` | 邀请码 |\n| `profile_likes` | 主页点赞记录 |\n\n这个项目设计得比较完整,文档也很详细,特别是第三方应用接入的 API 文档写得很清晰。有什么具体想了解的吗?(。・ω・。)"}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":4376,"output":1415,"cacheRead":37696,"cacheWrite":0,"totalTokens":43487,"cost":{"input":0.0017504,"output":0.00283,"cacheRead":0.00301568,"cacheWrite":0,"total":0.00759608}},"stopReason":"stop","timestamp":1781611642099,"responseId":"bfa3427c1d3149358b54b9bfe8af7e11"}}
|
||
{"type":"thinking_level_change","id":"a996d9c8","parentId":"927d4dee","timestamp":"2026-06-16T12:09:32.169Z","thinkingLevel":"high"}
|
||
|
||
{"name":"SproutGate项目解析","timestamp":"2026-06-16T13:03:24Z","type":"session_info"}{"type":"model_change","id":"6bdc3816","timestamp":"2026-06-16T13:03:28.108Z","provider":"xiaomimimo-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"d4d8d829","parentId":"6bdc3816","timestamp":"2026-06-16T13:03:28.108Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"15192d93","parentId":"d4d8d829","timestamp":"2026-06-17T02:13:26.977Z","provider":"xiaomimimo-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"4ff83ae7","parentId":"15192d93","timestamp":"2026-06-17T02:13:26.977Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"5605f8be","parentId":"4ff83ae7","timestamp":"2026-06-17T02:14:43.881Z","provider":"xiaomimimo-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"bae9a21e","parentId":"5605f8be","timestamp":"2026-06-17T02:14:43.881Z","thinkingLevel":"high"}
|
||
|
||
{"name":"SproutGate项目解析","timestamp":"2026-06-17T02:28:43Z","type":"session_info"}{"type":"model_change","id":"eecf7f25","timestamp":"2026-06-18T00:32:12.053Z","provider":"xiaomimimo-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"e7a5e3a6","parentId":"eecf7f25","timestamp":"2026-06-18T00:32:12.053Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"7eb3eebb","parentId":"e7a5e3a6","timestamp":"2026-06-18T02:03:52.926Z","provider":"xiaomimimo-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"cb0b3ba0","parentId":"7eb3eebb","timestamp":"2026-06-18T02:03:52.926Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"e336ac9c","parentId":"cb0b3ba0","timestamp":"2026-06-21T09:45:01.873Z","provider":"opencode-go-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"49aebb3d","parentId":"e336ac9c","timestamp":"2026-06-21T09:45:01.873Z","thinkingLevel":"high"}
|
||
|
||
{"name":"SproutGate项目解析","timestamp":"2026-06-21T09:45:14Z","type":"session_info"}{"type":"model_change","id":"08429d72","timestamp":"2026-06-21T09:45:19.529Z","provider":"opencode-go-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"9155a49e","parentId":"08429d72","timestamp":"2026-06-21T09:45:19.529Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"8f48bb71","parentId":"9155a49e","timestamp":"2026-06-21T09:45:52.646Z","provider":"opencode-go-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"030785e4","parentId":"8f48bb71","timestamp":"2026-06-21T09:45:52.646Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"9531cd7d","parentId":"030785e4","timestamp":"2026-06-21T09:45:55.515Z","provider":"opencode-go-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"f7ffa910","parentId":"9531cd7d","timestamp":"2026-06-21T09:45:55.515Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"2cee70f2","parentId":"f7ffa910","timestamp":"2026-06-21T09:46:16.960Z","provider":"opencode-go-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"2f795589","parentId":"2cee70f2","timestamp":"2026-06-21T09:46:16.960Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"1b1d0d9a","parentId":"2f795589","timestamp":"2026-06-21T09:46:27.927Z","provider":"opencode-go-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"49a78984","parentId":"1b1d0d9a","timestamp":"2026-06-21T09:46:27.927Z","thinkingLevel":"high"}
|
||
|
||
{"name":"SproutGate","timestamp":"2026-06-21T09:46:40Z","type":"session_info"}
|
||
{"name":"SproutGate项目解析","timestamp":"2026-06-21T09:46:48Z","type":"session_info"}{"type":"model_change","id":"462c2608","timestamp":"2026-06-21T09:47:02.903Z","provider":"opencode-go-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"2b9ca94e","parentId":"462c2608","timestamp":"2026-06-21T09:47:02.903Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"5b992684","parentId":"2b9ca94e","timestamp":"2026-06-21T09:47:06.363Z","provider":"opencode-go-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"770ad47a","parentId":"5b992684","timestamp":"2026-06-21T09:47:06.363Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"5c80baab","parentId":"770ad47a","timestamp":"2026-06-21T09:55:47.429Z","provider":"opencode-go-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"caf7a24d","parentId":"5c80baab","timestamp":"2026-06-21T09:55:47.429Z","thinkingLevel":"high"}
|