diff --git a/docs/plans/2026-06-21-001-feat-admin-review-hermes-sync-plan.md b/docs/plans/2026-06-21-001-feat-admin-review-hermes-sync-plan.md
new file mode 100644
index 0000000..2204327
--- /dev/null
+++ b/docs/plans/2026-06-21-001-feat-admin-review-hermes-sync-plan.md
@@ -0,0 +1,517 @@
+# Plan: 管理员审核 + Hermes 同步工作流
+
+**Status:** active
+**Created:** 2026-06-21
+**Origin:** docs/brainstorms/2026-06-20-hermes-cross-machine-deploy-requirements.md (演进)
+**Plan depth:** Standard
+
+---
+
+## Summary
+
+实现「管理员审核 + 管理员发起 Hermes 同步」的新工作流。角色创建后进入待审核状态,管理员后台审核通过后填写同步参数(profile 名、主 model key/服务商、多媒体 model key/服务商、定时任务开关),EternalAI 用一次性 sync_token 向 Hermes 发起同步请求,Hermes 回调拉取文件并创建 profile,返回绑定二维码。测试范围限定为鉴权和文件拉取(无真实 Hermes,用 mock 端点验证)。
+
+---
+
+## Problem Frame
+
+当前流程:创作者创建角色后直接上架,用户自行用 API Key + curl 拉取配置到 Hermes。存在以下问题:
+1. 缺少内容审核环节,任何人设都可直接发布
+2. 同步由用户手动操作,无法集中管控
+3. Hermes 配置参数(model key、服务商等)分散在用户侧,不一致
+
+新流程:管理员集中审核 + 管理员发起同步 + Hermes 回调拉取 + 二维码绑定。
+
+---
+
+## Requirements
+
+### 功能需求
+
+- **R1**: 角色创建后状态为 `pending_review`,需管理员审核才能上架
+- **R2**: 独立 Admin 表,管理员有独立登录入口
+- **R3**: 管理员后台可查看待审核列表、角色详情,通过/驳回
+- **R4**: 审核通过后管理员填写同步参数并发起同步
+- **R5**: EternalAI 用一次性 sync_token(HMAC SHA-256 对称密钥)向 Hermes POST 同步请求
+- **R6**: Hermes 用 sync_token 回调 EternalAI 拉取 SOUL.md 和 config.yaml
+- **R7**: Hermes 返回二维码 URL,EternalAI 存储并展示给管理员和创作者
+- **R8**: Hermes webhook URL 全局配置,同步时可覆盖
+- **R9**: 测试范围:鉴权(admin 登录、sync_token 验证)+ 文件拉取(mock Hermes 回调)
+
+### 非功能需求
+
+- sync_token 5 分钟过期,一次性消费
+- Hermes 回调拉取端点同时支持 sync_token 和现有 API Key 认证
+- 现有用户侧 API Key 拉取流程保留不变
+
+---
+
+## Key Technical Decisions
+
+### KTD1: 独立 Admin 表
+
+Admin 与 User 分离,独立登录接口,不混用 JWT。
+
+**理由**: 用户选择。管理员权限边界清晰,避免 User 表 isAdmin 字段的权限提升风险。
+
+### KTD2: sync_token 用 HMAC SHA-256 对称密钥
+
+EternalAI 和 Hermes 预共享 `SYNC_SECRET`,sync_token 是 JWT(HS256),payload 含 `roleId`、`adminId`、`iat`、`exp`(5 分钟)。
+
+**理由**: 用户选择。对称密钥实现简单,双方预共享即可。
+
+### KTD3: Hermes webhook URL 全局配置 + 可覆盖
+
+系统设置表存储 `HERMES_WEBHOOK_URL`,管理员发起同步时可在表单中覆盖。
+
+**理由**: 用户选择。大多数情况用全局配置,特殊场景可覆盖。
+
+### KTD4: 二维码由 Hermes 生成返回
+
+Hermes 创建 profile 后生成二维码 URL 返回给 EternalAI,EternalAI 存储到 Role 记录并展示。
+
+**理由**: 用户选择。二维码内容(微信绑定链接)由 Hermes 侧定义。
+
+### KTD5: 角色审核状态机
+
+```
+pending_review → approved → syncing → synced
+pending_review → rejected
+syncing → failed
+```
+
+Role 模型新增 `reviewStatus` 字段(默认 `pending_review`),新增 `qrCodeUrl`、`syncedAt` 字段。
+
+### KTD6: Mock Hermes 测试端点
+
+在 EternalAI 内部创建 `/api/mock-hermes/*` 端点模拟 Hermes 行为:接收同步请求、用 sync_token 回调拉取文件、返回 mock 二维码。仅测试环境启用。
+
+---
+
+## High-Level Technical Design
+
+### 同步流程时序图
+
+```mermaid
+sequenceDiagram
+ participant Admin as 管理员
+ participant EAI as EternalAI
+ participant Hermes as Hermes (Mock)
+
+ Admin->>EAI: POST /api/admin/sync/:roleId
+ Note over EAI: 生成 sync_token (JWT, 5min)
+ EAI->>Hermes: POST {webhook_url} /api/sync
+ Note over EAI: Body: { profileName, modelKey,
provider, multimediaModelKey,
multimediaProvider, enableSchedule,
sync_token, file_pull_base_url }
+ Hermes->>Hermes: 验证 sync_token 签名
+ Hermes->>EAI: GET /api/hermes/roles/:id/SOUL.md
+ Note over Hermes: Header: X-Sync-Token
+ EAI->>EAI: 验证 sync_token (签名+过期+未消费)
+ EAI-->>Hermes: SOUL.md content
+ Hermes->>EAI: GET /api/hermes/roles/:id/config.yaml
+ Note over Hermes: Header: X-Sync-Token
+ EAI-->>Hermes: config.yaml content
+ Note over Hermes: 创建 profile, 生成二维码
+ Hermes-->>EAI: 200 { qrCodeUrl, profileId }
+ Note over EAI: 存储 qrCodeUrl, 更新 reviewStatus=synced
+ EAI-->>Admin: 200 { qrCodeUrl, reviewStatus }
+```
+
+### 角色审核状态机
+
+```mermaid
+stateDiagram-v2
+ [*] --> pending_review : 创建角色
+ pending_review --> approved : 管理员通过
+ pending_review --> rejected : 管理员驳回
+ approved --> syncing : 管理员发起同步
+ syncing --> synced : Hermes 返回成功
+ syncing --> failed : 同步失败/超时
+ failed --> syncing : 重新同步
+```
+
+---
+
+## Implementation Units
+
+### U1. Admin 数据模型与认证
+
+**Goal:** 创建独立 Admin 表,实现管理员注册/登录/中间件。
+
+**Requirements:** R2
+
+**Dependencies:** 无
+
+**Files:**
+- `prisma/schema.prisma` — 新增 Admin 模型
+- `src/lib/auth.js` — 新增 `adminSignToken`、`adminVerifyToken`、`adminAuthMiddleware`
+- `src/routes/admin-auth.js` — 新建,管理员登录路由
+- `server.js` — 注册 `/api/admin-auth` 路由
+- `e2e/admin-auth.spec.js` — 新建,管理员认证测试
+
+**Approach:**
+- Admin 模型:`id`、`account`(唯一)、`password`(bcrypt)、`createdAt`
+- Admin JWT 与用户 JWT 使用不同 secret(`ADMIN_JWT_SECRET`),防止跨角色伪造
+- `adminAuthMiddleware` 验证 Admin JWT,设置 `req.adminId`
+- 管理员账号通过 `prisma db seed` 或 CLI 脚本创建,不开放注册 API
+
+**Test scenarios:**
+- 管理员登录成功,返回 admin JWT
+- 管理员登录密码错误,返回 401
+- 无 token 访问管理员接口,返回 401
+- 用户 JWT 访问管理员接口,返回 403(secret 不同,验证失败)
+- 管理员 JWT 访问用户接口,返回 401(用户中间件不识别 admin token)
+
+**Verification:** 管理员可登录,admin JWT 可访问管理员接口,用户 JWT 不可访问管理员接口。
+
+---
+
+### U2. 角色审核状态机与审核 API
+
+**Goal:** Role 模型新增审核状态字段,实现审核 API。
+
+**Requirements:** R1, R3
+
+**Dependencies:** U1
+
+**Files:**
+- `prisma/schema.prisma` — Role 模型新增 `reviewStatus`、`qrCodeUrl`、`syncedAt`、`reviewNote` 字段
+- `src/routes/admin.js` — 新建,管理员审核路由
+- `server.js` — 注册 `/api/admin` 路由
+- `e2e/admin-review.spec.js` — 新建,审核流程测试
+
+**Approach:**
+- `reviewStatus` 枚举值:`pending_review`(默认)、`approved`、`rejected`、`syncing`、`synced`、`failed`
+- 现有 `POST /api/roles` 创建角色时自动设置 `reviewStatus = 'pending_review'`
+- `GET /api/admin/reviews` — 待审核列表(分页,按 createdAt desc)
+- `GET /api/admin/reviews/:roleId` — 角色详情(含所有字段)
+- `POST /api/admin/reviews/:roleId/approve` — 通过审核,状态 → `approved`
+- `POST /api/admin/reviews/:roleId/reject` — 驳回,状态 → `rejected`,body 含 `reviewNote`
+- 角色库 `GET /api/roles` 只返回 `reviewStatus = 'synced'` 的角色(已同步完成才上架)
+
+**Test scenarios:**
+- 创建角色后 reviewStatus 为 pending_review
+- 管理员获取待审核列表,包含 pending_review 角色
+- 管理员通过审核,状态变为 approved
+- 管理员驳回审核,状态变为 rejected,reviewNote 有值
+- 非管理员调用审核接口,返回 401
+- 角色库不显示 pending_review / approved / rejected 状态的角色
+
+**Verification:** 审核状态流转正确,非管理员无法操作。
+
+---
+
+### U3. sync_token 机制与系统配置
+
+**Goal:** 实现 sync_token 生成/验证,系统配置存储 Hermes webhook URL。
+
+**Requirements:** R5, R8
+
+**Dependencies:** U1
+
+**Files:**
+- `prisma/schema.prisma` — 新增 SystemConfig 模型(key-value 存储)
+- `src/lib/sync-token.js` — 新建,sync_token 生成与验证
+- `src/routes/admin-config.js` — 新建,系统配置管理路由
+- `server.js` — 注册 `/api/admin/config` 路由
+- `e2e/sync-token.spec.js` — 新建,sync_token 测试
+
+**Approach:**
+- SystemConfig 模型:`key`(唯一)、`value`、`updatedAt`
+- 预置配置项:`HERMES_WEBHOOK_URL`、`SYNC_SECRET`
+- `sync-token.js`:
+ - `generateSyncToken(roleId, adminId)` — 生成 JWT(HS256),payload `{ roleId, adminId, iat, exp }`,5 分钟过期
+ - `verifySyncToken(token)` — 验证签名 + 过期,返回 payload 或 null
+ - 使用 `SYNC_SECRET` 从 SystemConfig 读取(首次启动自动生成)
+- `PUT /api/admin/config/:key` — 更新配置项(仅管理员)
+- `GET /api/admin/config` — 获取所有配置(仅管理员,敏感值脱敏)
+
+**Test scenarios:**
+- 生成 sync_token,验证签名通过,payload 正确
+- 过期 token(>5min)验证失败
+- 篡改 payload 后验证失败(签名不匹配)
+- 管理员更新 HERMES_WEBHOOK_URL,再次读取值正确
+- 非管理员访问配置接口,返回 401
+
+**Verification:** sync_token 生成/验证正确,系统配置可读写。
+
+---
+
+### U4. 同步发起 API
+
+**Goal:** 管理员发起同步,EternalAI 向 Hermes POST 请求。
+
+**Requirements:** R4, R5, R6
+
+**Dependencies:** U2, U3
+
+**Files:**
+- `src/routes/admin-sync.js` — 新建,同步发起路由
+- `src/lib/hermes-client.js` — 新建,Hermes HTTP 客户端
+- `server.js` — 注册 `/api/admin/sync` 路由
+- `e2e/admin-sync.spec.js` — 新建,同步发起测试
+
+**Approach:**
+- `POST /api/admin/sync/:roleId` — 发起同步
+ - Body: `{ profileName, modelKey, provider, multimediaModelKey, multimediaProvider, enableSchedule, webhookUrl? }`
+ - `webhookUrl` 可选,未提供则用 SystemConfig 中的 `HERMES_WEBHOOK_URL`
+ - 前置检查:`reviewStatus` 必须为 `approved` 或 `failed`
+ - 生成 sync_token,更新 `reviewStatus = 'syncing'`
+ - 调用 `hermes-client.js` 的 `postSync(webhookUrl, payload)` 向 Hermes POST
+ - Hermes 返回 `{ qrCodeUrl, profileId }` → 存储 `qrCodeUrl`,更新 `reviewStatus = 'synced'`,记录 `syncedAt`
+ - Hermes 返回错误 → 更新 `reviewStatus = 'failed'`
+ - 请求超时(30s)→ 更新 `reviewStatus = 'failed'`
+- `hermes-client.js`:
+ - `postSync(webhookUrl, payload)` — 用 `fetch` POST 到 Hermes,payload 含 sync_token 和 file_pull_base_url
+ - `file_pull_base_url` = EternalAI 自身的基础 URL(从 SystemConfig 读取 `ETERNALAI_BASE_URL`)
+
+**Test scenarios:**
+- 管理员对 approved 角色发起同步,reviewStatus 变为 syncing
+- Mock Hermes 返回成功,reviewStatus 变为 synced,qrCodeUrl 有值
+- Mock Hermes 返回错误,reviewStatus 变为 failed
+- 对 pending_review 角色发起同步,返回 400
+- 对 syncing 角色发起同步,返回 409(重复同步)
+- 非管理员发起同步,返回 401
+
+**Verification:** 同步发起后状态流转正确,成功时存储二维码 URL。
+
+---
+
+### U5. Hermes 回调拉取端点改造
+
+**Goal:** 改造现有 `/api/hermes/` 端点,支持 sync_token 认证。
+
+**Requirements:** R6
+
+**Dependencies:** U3
+
+**Files:**
+- `src/routes/hermes.js` — 改造,新增 sync_token 认证路径
+- `src/lib/auth.js` — 新增 `syncTokenMiddleware`
+- `e2e/hermes-callback.spec.js` — 新建,回调拉取测试
+
+**Approach:**
+- 新增 `syncTokenMiddleware`:
+ - 读取 `X-Sync-Token` header
+ - 验证 sync_token 签名 + 过期
+ - 从 payload 提取 `roleId`,与 URL 中的 `:id` 比对,不一致返回 403
+ - 验证通过后设置 `req.userId`(通过 Role.creatorId 反查)和 `req.syncTokenPayload`
+- 改造 `apiKeyMiddleware` 逻辑:
+ - 优先检查 `X-Sync-Token` header → 走 sync_token 路径
+ - 否则检查 `Authorization: Bearer eak_` → 走 API Key 路径
+ - 否则检查 `Authorization: Bearer ` → 走 JWT 路径
+- sync_token 消费后标记为已使用(内存 Set,5 分钟后自动清理;或用 token jti + 短期缓存)
+- SOUL.md 和 config.yaml 端点保持 text/plain 响应不变
+
+**Test scenarios:**
+- 用有效 sync_token 拉取 SOUL.md,返回 200 + 文件内容
+- 用有效 sync_token 拉取 config.yaml,返回 200 + 文件内容
+- sync_token 中的 roleId 与 URL :id 不匹配,返回 403
+- 过期 sync_token,返回 401
+- 已消费的 sync_token 再次使用,返回 401
+- 无 token 访问,返回 401
+- 用 API Key(eak_)访问仍正常工作(向后兼容)
+
+**Verification:** sync_token 可拉取文件,向后兼容 API Key 认证。
+
+---
+
+### U6. 二维码存储与状态展示
+
+**Goal:** 存储 Hermes 返回的二维码 URL,展示给管理员和创作者。
+
+**Requirements:** R7
+
+**Dependencies:** U4
+
+**Files:**
+- `src/routes/roles.js` — 改造,`GET /api/roles/my/roles` 返回 reviewStatus 和 qrCodeUrl
+- `src/routes/admin.js` — 改造,管理员可查看所有角色的同步状态
+- `app.js` — 改造,创作者角色卡片显示审核状态和二维码
+- `e2e/qr-display.spec.js` — 新建,二维码展示测试
+
+**Approach:**
+- `GET /api/roles/my/roles` 返回字段新增 `reviewStatus`、`qrCodeUrl`、`syncedAt`
+- 创作者角色卡片根据 reviewStatus 显示状态标签:
+ - `pending_review` → "待审核"(灰色)
+ - `approved` → "已通过,等待同步"(蓝色)
+ - `syncing` → "同步中"(黄色)
+ - `synced` → "已同步"(绿色)+ 显示二维码
+ - `failed` → "同步失败"(红色)
+ - `rejected` → "已驳回"(红色)
+- synced 状态的角色卡片显示二维码图片(`qrCodeUrl`)和"转发二维码"按钮
+- 管理员后台有"同步状态"页面,显示所有角色的审核+同步状态
+
+**Test scenarios:**
+- 创作者查看自己的角色列表,pending_review 角色显示"待审核"标签
+- 同步成功后,创作者角色卡片显示二维码图片
+- 驳回角色显示"已驳回"标签
+- 管理员查看同步状态列表,包含所有角色的 reviewStatus
+
+**Verification:** 创作者和管理员都能看到正确的审核状态和二维码。
+
+---
+
+### U7. 管理员后台 UI
+
+**Goal:** 管理员登录页 + 审核列表 + 角色详情审核页 + 同步表单。
+
+**Requirements:** R2, R3, R4
+
+**Dependencies:** U1, U2, U4
+
+**Files:**
+- `index.html` — 新增管理员视图(admin-login、admin-reviews、admin-sync)
+- `app.js` — 新增管理员路由、审核交互、同步表单
+- `styles.css` — 管理员后台样式
+- `e2e/admin-ui.spec.js` — 新建,管理员 UI 测试
+
+**Approach:**
+- 管理员入口:首页底部隐藏链接 `/admin`,或直接访问 `#admin-login`
+- 管理员登录页:账号 + 密码,登录后跳转 `#admin-reviews`
+- 审核列表页:表格显示角色名、创作者、创建时间、状态;点击进入详情
+- 角色详情审核页:显示所有角色字段,"通过"和"驳回"按钮
+- 同步表单(审核通过后显示):
+ - profile 名字(text,默认角色名)
+ - 主 model 服务商(select:openrouter / together / local)
+ - 主 model key(password input)
+ - 多媒体 model 服务商(select)
+ - 多媒体 model key(password input)
+ - 是否开启定时任务(checkbox)
+ - Hermes webhook URL(text,默认全局配置值,可覆盖)
+ - "发起同步"按钮
+- 同步成功后显示二维码图片和"复制链接"按钮
+
+**Test scenarios:**
+- 管理员登录后跳转审核列表
+- 审核列表显示待审核角色
+- 点击角色进入详情,显示完整信息
+- 通过审核后显示同步表单
+- 填写同步参数并提交,显示同步中状态
+- 同步成功后显示二维码
+
+**Verification:** 管理员可完成登录→审核→同步的完整 UI 流程。
+
+---
+
+### U8. Mock Hermes 测试端点
+
+**Goal:** 在 EternalAI 内部创建 mock Hermes 端点,用于测试同步流程。
+
+**Requirements:** R9
+
+**Dependencies:** U3, U5
+
+**Files:**
+- `src/routes/mock-hermes.js` — 新建,mock Hermes 端点
+- `server.js` — 注册 `/api/mock-hermes` 路由(仅非 production 环境)
+- `e2e/mock-hermes.spec.js` — 新建,mock Hermes 集成测试
+
+**Approach:**
+- `POST /api/mock-hermes/sync` — 模拟 Hermes 接收同步请求
+ - 验证 sync_token 签名(用同一 SYNC_SECRET)
+ - 用 sync_token 回调 EternalAI 拉取 SOUL.md 和 config.yaml
+ - 返回 mock 二维码 URL:`https://mock.hermes.local/qr/`
+ - 返回 mock profileId:`mock-profile-`
+- Mock 端点用 `fetch` 回调 EternalAI 自身的 `/api/hermes/roles/:id/SOUL.md` 和 `/api/hermes/roles/:id/config.yaml`
+- 回调时携带 `X-Sync-Token` header
+- 仅在 `NODE_ENV !== 'production'` 时注册路由
+
+**Test scenarios:**
+- POST /api/mock-hermes/sync 收到请求后,回调拉取 SOUL.md 成功
+- POST /api/mock-hermes/sync 回调拉取 config.yaml 成功
+- 返回 mock 二维码 URL 和 profileId
+- 无效 sync_token,mock Hermes 返回 401
+- production 环境下 /api/mock-hermes 路由不存在(404)
+
+**Verification:** Mock Hermes 完整模拟同步流程,可用于 E2E 测试。
+
+---
+
+### U9. E2E 集成测试:完整审核+同步流程
+
+**Goal:** 端到端测试从创建角色到同步成功的完整流程。
+
+**Requirements:** R1-R9
+
+**Dependencies:** U1-U8
+
+**Files:**
+- `e2e/full-sync-flow.spec.js` — 新建,完整流程测试
+
+**Approach:**
+测试完整流程:
+1. 创作者注册 → 登录 → 创建角色(状态 pending_review)
+2. 管理员登录 → 查看待审核列表 → 查看详情 → 通过审核
+3. 管理员填写同步参数 → 发起同步
+4. Mock Hermes 接收请求 → 回调拉取文件 → 返回二维码
+5. 创作者查看角色列表 → 看到已同步状态 + 二维码
+6. 管理员查看同步状态 → 看到已同步
+
+**Test scenarios:**
+- 完整流程:创建 → 审核 → 同步 → 二维码展示
+- 驳回流程:创建 → 审核 → 驳回 → 创作者看到"已驳回"
+- 同步失败流程:创建 → 审核 → 同步(mock 返回错误)→ 状态 failed → 重新同步
+- 向后兼容:现有 API Key 拉取流程仍正常工作
+
+**Verification:** E2E 测试覆盖所有核心路径,全部通过。
+
+---
+
+## Scope Boundaries
+
+### In Scope
+
+- Admin 表与认证
+- 角色审核状态机
+- sync_token 生成/验证/消费
+- 同步发起 API
+- Hermes 回调拉取端点改造(sync_token 认证)
+- 二维码存储与展示
+- 管理员后台 UI
+- Mock Hermes 测试端点
+- E2E 测试(鉴权 + 文件拉取)
+
+### Out of Scope
+
+- 真实 Hermes 服务器对接(用 mock 代替)
+- 微信公众号/小程序绑定实现(二维码内容由 Hermes 侧定义)
+- 管理员注册 UI(通过 seed 脚本创建)
+- 速率限制(已有 P2 待修复,不在本次范围)
+- 现有 API Key 拉取流程的改造(保持向后兼容)
+
+### Deferred to Follow-Up Work
+
+- 真实 Hermes 服务器对接(需 Hermes 侧实现 `/api/sync` 端点)
+- 微信绑定流程实现
+- 同步重试机制(指数退避)
+- 同步日志审计
+- 多 Hermes 实例支持
+
+---
+
+## Risks & Dependencies
+
+| 风险 | 影响 | 缓解 |
+|------|------|------|
+| sync_token 内存消费记录在多实例部署下失效 | 重复消费风险 | 后续可改用 Redis;当前单实例够用 |
+| Mock Hermes 端点误暴露到生产环境 | 安全风险 | 仅 `NODE_ENV !== 'production'` 注册路由 |
+| Hermes 回调时 EternalAI 不可达 | 同步失败 | 同步状态设为 failed,管理员可重试 |
+| sync_token 在传输中被截获 | 5 分钟内可滥用 | HTTPS + 一次性消费 + 短过期 |
+
+---
+
+## Open Questions
+
+- **OQ1**: sync_token 一次性消费用内存 Set 还是数据库表?(计划用内存 Set,单实例部署够用;多实例时改 Redis)
+- **OQ2**: 管理员账号初始创建方式?(计划用 `prisma db seed` 脚本,账号密码从环境变量读取)
+- **OQ3**: EternalAI 自身的基础 URL(`ETERNALAI_BASE_URL`)如何获取?(计划从 SystemConfig 读取,首次部署时配置)
+
+---
+
+## System-Wide Impact
+
+- **数据库**: 新增 Admin、SystemConfig 表;Role 表新增 4 个字段
+- **API**: 新增 `/api/admin-auth`、`/api/admin`、`/api/admin/config`、`/api/admin/sync`、`/api/mock-hermes` 路由组
+- **前端**: 新增 3 个管理员视图,改造创作者角色卡片
+- **认证**: 新增 Admin JWT(独立 secret)和 sync_token(HMAC SHA-256)两套机制
+- **向后兼容**: 现有用户 API Key 拉取流程不变
diff --git a/e2e/admin-sync-flow.spec.js b/e2e/admin-sync-flow.spec.js
new file mode 100644
index 0000000..59336e7
--- /dev/null
+++ b/e2e/admin-sync-flow.spec.js
@@ -0,0 +1,301 @@
+const { test, expect, request } = require('@playwright/test');
+const { cleanDatabase, seedExistingUser, seedAdmin, disconnect, prisma } = require('./fixtures/database');
+
+test.describe('管理员审核 + Hermes 同步流程', () => {
+ let adminToken;
+ let userToken;
+ let roleId;
+
+ test.beforeAll(async () => {
+ await cleanDatabase();
+ await seedExistingUser();
+ await seedAdmin();
+
+ // 管理员登录
+ const adminContext = await request.newContext();
+ const adminRes = await adminContext.post('/api/admin-auth/login', {
+ data: { account: 'admin', password: 'admin123' },
+ });
+ const adminData = await adminRes.json();
+ adminToken = adminData.token;
+
+ // 用户登录
+ const userContext = await request.newContext();
+ const userRes = await userContext.post('/api/auth/login', {
+ data: { account: 'e2e_existing', password: 'Test123456' },
+ });
+ const userData = await userRes.json();
+ userToken = userData.token;
+
+ // 创建角色(待审核)
+ const roleRes = await userContext.post('/api/roles', {
+ headers: { Authorization: `Bearer ${userToken}` },
+ data: {
+ displayName: '测试角色',
+ personality: '温柔体贴',
+ background: '来自未来',
+ speechStyle: '轻声细语',
+ greeting: '你好呀',
+ soulMd: '# SOUL\n这是测试角色的灵魂文件',
+ },
+ });
+ const roleData = await roleRes.json();
+ roleId = roleData.role.id;
+ });
+
+ test.afterAll(async () => {
+ await cleanDatabase();
+ await disconnect();
+ });
+
+ test('管理员登录成功', async () => {
+ expect(adminToken).toBeTruthy();
+ });
+
+ test('管理员登录密码错误返回 401', async () => {
+ const context = await request.newContext();
+ const res = await context.post('/api/admin-auth/login', {
+ data: { account: 'admin', password: 'wrong' },
+ });
+ expect(res.status()).toBe(401);
+ });
+
+ test('用户 JWT 不能访问管理员接口', async () => {
+ const context = await request.newContext();
+ const res = await context.get('/api/admin/reviews', {
+ headers: { Authorization: `Bearer ${userToken}` },
+ });
+ expect(res.status()).toBe(401);
+ });
+
+ test('无 token 不能访问管理员接口', async () => {
+ const context = await request.newContext();
+ const res = await context.get('/api/admin/reviews');
+ expect(res.status()).toBe(401);
+ });
+
+ test('创建角色后状态为 pending_review', async () => {
+ const role = await prisma.role.findUnique({ where: { id: roleId } });
+ expect(role.reviewStatus).toBe('pending_review');
+ });
+
+ test('管理员获取待审核列表', async () => {
+ const context = await request.newContext();
+ const res = await context.get('/api/admin/reviews', {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ });
+ expect(res.status()).toBe(200);
+ const data = await res.json();
+ expect(data.roles.length).toBeGreaterThan(0);
+ expect(data.roles[0].reviewStatus).toBe('pending_review');
+ });
+
+ test('管理员获取角色详情', async () => {
+ const context = await request.newContext();
+ const res = await context.get(`/api/admin/reviews/${roleId}`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ });
+ expect(res.status()).toBe(200);
+ const data = await res.json();
+ expect(data.role.displayName).toBe('测试角色');
+ });
+
+ test('管理员通过审核', async () => {
+ const context = await request.newContext();
+ const res = await context.post(`/api/admin/reviews/${roleId}/approve`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ });
+ expect(res.status()).toBe(200);
+ const data = await res.json();
+ expect(data.role.reviewStatus).toBe('approved');
+ });
+
+ test('角色库不显示未同步的角色', async () => {
+ const context = await request.newContext();
+ const res = await context.get('/api/roles');
+ const data = await res.json();
+ const found = data.roles.find((r) => r.id === roleId);
+ expect(found).toBeUndefined();
+ });
+
+ test('对非 approved 角色发起同步返回 400', async () => {
+ // 先创建一个新角色(pending_review)
+ const userContext = await request.newContext();
+ await userContext.post('/api/roles', {
+ headers: { Authorization: `Bearer ${userToken}` },
+ data: {
+ displayName: '未审核角色',
+ personality: '测试',
+ background: '测试',
+ speechStyle: '测试',
+ greeting: '测试',
+ soulMd: '# test',
+ },
+ });
+
+ const roles = await prisma.role.findMany({ where: { displayName: '未审核角色' } });
+ const pendingRoleId = roles[0].id;
+
+ const adminContext = await request.newContext();
+ const res = await adminContext.post(`/api/admin/sync/${pendingRoleId}`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ data: { profileName: 'test-profile' },
+ });
+ expect(res.status()).toBe(400);
+ });
+
+ test('未配置 Hermes webhook URL 时同步返回 400', async () => {
+ const context = await request.newContext();
+ const res = await context.post(`/api/admin/sync/${roleId}`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ data: { profileName: 'test-profile' },
+ });
+ expect(res.status()).toBe(400);
+ });
+
+ test('配置 Hermes webhook URL 指向 mock', async () => {
+ const context = await request.newContext();
+ const res = await context.put('/api/admin/config/HERMES_WEBHOOK_URL', {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ data: { value: 'http://localhost:3001/api/mock-hermes/sync' },
+ });
+ expect(res.status()).toBe(200);
+ });
+
+ test('管理员发起同步 → mock Hermes 回调拉取文件 → 返回二维码', async () => {
+ const context = await request.newContext();
+ const res = await context.post(`/api/admin/sync/${roleId}`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ data: {
+ profileName: 'test-profile',
+ modelKey: 'sk-test-key',
+ provider: 'openrouter',
+ multimediaModelKey: 'sk-multi-key',
+ multimediaProvider: 'openrouter',
+ enableSchedule: false,
+ },
+ });
+
+ expect(res.status()).toBe(200);
+ const data = await res.json();
+ expect(data.role.reviewStatus).toBe('synced');
+ expect(data.role.qrCodeUrl).toContain('mock.hermes.local');
+ expect(data.profileId).toContain('mock-profile');
+ });
+
+ test('同步成功后角色出现在角色库', async () => {
+ const context = await request.newContext();
+ const res = await context.get('/api/roles');
+ const data = await res.json();
+ const found = data.roles.find((r) => r.id === roleId);
+ expect(found).toBeDefined();
+ });
+
+ test('用户查看自己的角色列表包含审核状态和二维码', async () => {
+ const context = await request.newContext();
+ const res = await context.get('/api/roles/my/roles', {
+ headers: { Authorization: `Bearer ${userToken}` },
+ });
+ const data = await res.json();
+ const role = data.roles.find((r) => r.id === roleId);
+ expect(role).toBeDefined();
+ expect(role.reviewStatus).toBe('synced');
+ expect(role.qrCodeUrl).toBeTruthy();
+ });
+
+ test('sync_token 拉取文件 — SOUL.md', async () => {
+ // 生成新的 sync_token(通过管理员发起同步流程会自动生成)
+ // 这里直接用 API 测试:先通过审核另一个角色,再同步
+ const userContext = await request.newContext();
+
+ // 创建并审核新角色
+ const roleRes = await userContext.post('/api/roles', {
+ headers: { Authorization: `Bearer ${userToken}` },
+ data: {
+ displayName: '同步测试角色',
+ personality: '测试',
+ background: '测试',
+ speechStyle: '测试',
+ greeting: '测试',
+ soulMd: '# Test SOUL\n测试内容',
+ },
+ });
+ const newRoleId = (await roleRes.json()).role.id;
+
+ // 管理员审核通过
+ const adminContext = await request.newContext();
+ await adminContext.post(`/api/admin/reviews/${newRoleId}/approve`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ });
+
+ // 发起同步(mock Hermes 会回调拉取文件)
+ const syncRes = await adminContext.post(`/api/admin/sync/${newRoleId}`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ data: {
+ profileName: 'test-profile-2',
+ modelKey: 'sk-test',
+ provider: 'openrouter',
+ },
+ });
+
+ expect(syncRes.status()).toBe(200);
+ const syncData = await syncRes.json();
+ expect(syncData.role.reviewStatus).toBe('synced');
+ });
+
+ test('驳回审核流程', async () => {
+ const userContext = await request.newContext();
+ const roleRes = await userContext.post('/api/roles', {
+ headers: { Authorization: `Bearer ${userToken}` },
+ data: {
+ displayName: '待驳回角色',
+ personality: '测试',
+ background: '测试',
+ speechStyle: '测试',
+ greeting: '测试',
+ soulMd: '# test',
+ },
+ });
+ const rejectRoleId = (await roleRes.json()).role.id;
+
+ const adminContext = await request.newContext();
+ const res = await adminContext.post(`/api/admin/reviews/${rejectRoleId}/reject`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ data: { reviewNote: '内容不符合要求' },
+ });
+
+ expect(res.status()).toBe(200);
+ const data = await res.json();
+ expect(data.role.reviewStatus).toBe('rejected');
+ expect(data.role.reviewNote).toBe('内容不符合要求');
+ });
+
+ test('向后兼容:API Key 拉取仍正常工作', async () => {
+ // 生成 API Key
+ const userContext = await request.newContext();
+ const keyRes = await userContext.post('/api/apikeys', {
+ headers: { Authorization: `Bearer ${userToken}` },
+ data: { name: 'test-key' },
+ });
+ const apiKey = (await keyRes.json()).apiKey.key;
+
+ // 用 API Key 拉取 SOUL.md
+ const soulRes = await userContext.get(`/api/hermes/roles/${roleId}/SOUL.md`, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ });
+ expect(soulRes.status()).toBe(200);
+ const soulText = await soulRes.text();
+ expect(soulText).toContain('测试角色的灵魂文件');
+ });
+
+ test('管理员获取同步状态列表', async () => {
+ const context = await request.newContext();
+ const res = await context.get('/api/admin/sync-status', {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ });
+ expect(res.status()).toBe(200);
+ const data = await res.json();
+ expect(data.roles.length).toBeGreaterThan(0);
+ expect(data.roles.some((r) => r.reviewStatus === 'synced')).toBe(true);
+ });
+});
diff --git a/e2e/creator.spec.js b/e2e/creator.spec.js
index 9095424..906daf5 100644
--- a/e2e/creator.spec.js
+++ b/e2e/creator.spec.js
@@ -25,6 +25,7 @@ test.describe('创作者中心与角色发布/编辑', () => {
desc: '温柔体贴的角色',
price: 19.9,
status: 'running',
+ reviewStatus: 'synced',
temperature: 0.8,
maxTokens: 2048,
enableMemory: true,
diff --git a/e2e/fixtures/database.js b/e2e/fixtures/database.js
index c44549f..3ddb65f 100644
--- a/e2e/fixtures/database.js
+++ b/e2e/fixtures/database.js
@@ -6,7 +6,10 @@ const prisma = new PrismaClient();
async function cleanDatabase() {
await prisma.order.deleteMany();
await prisma.role.deleteMany();
+ await prisma.apiKey.deleteMany();
+ await prisma.systemConfig.deleteMany();
await prisma.user.deleteMany();
+ // 不删除 Admin 表,保留测试管理员账号
}
async function seedExistingUser() {
@@ -23,8 +26,21 @@ async function seedExistingUser() {
});
}
+async function seedAdmin() {
+ const bcrypt = require('bcryptjs');
+ const admin = await prisma.admin.findUnique({ where: { account: 'admin' } });
+ if (!admin) {
+ await prisma.admin.create({
+ data: {
+ account: 'admin',
+ password: bcrypt.hashSync('admin123', 10),
+ },
+ });
+ }
+}
+
async function disconnect() {
await prisma.$disconnect();
}
-module.exports = { cleanDatabase, seedExistingUser, disconnect, prisma };
+module.exports = { cleanDatabase, seedExistingUser, seedAdmin, disconnect, prisma };
diff --git a/e2e/navigation.spec.js b/e2e/navigation.spec.js
index 8bebc47..3340a12 100644
--- a/e2e/navigation.spec.js
+++ b/e2e/navigation.spec.js
@@ -159,6 +159,7 @@ test.describe('导航与可访问性', () => {
desc: '键盘测试',
price: 9.9,
status: 'running',
+ reviewStatus: 'synced',
},
});
diff --git a/e2e/roles.spec.js b/e2e/roles.spec.js
index 57dd7ed..1cfc028 100644
--- a/e2e/roles.spec.js
+++ b/e2e/roles.spec.js
@@ -30,7 +30,7 @@ test.describe('角色库与角色详情', () => {
},
});
- // 创建一个测试角色(status=running 才会在角色库展示)
+ // 创建一个测试角色(reviewStatus=synced 才会在角色库展示)
testRole = await prisma.role.create({
data: {
creatorId: existingUser.id,
@@ -45,6 +45,7 @@ test.describe('角色库与角色详情', () => {
desc: '温柔可爱的女友角色',
price: 29.9,
status: 'running',
+ reviewStatus: 'synced',
avatar: 'https://example.com/avatar.png',
},
});
@@ -101,6 +102,7 @@ test.describe('角色库与角色详情', () => {
desc: '阳光开朗的男孩',
price: 19.9,
status: 'running',
+ reviewStatus: 'synced',
},
});
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4d08d11..70e729a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -23,6 +23,19 @@ model User {
apiKeys ApiKey[]
}
+model Admin {
+ id String @id @default(uuid())
+ account String @unique
+ password String
+ createdAt DateTime @default(now())
+}
+
+model SystemConfig {
+ key String @id
+ value String
+ updatedAt DateTime @updatedAt
+}
+
model ApiKey {
id String @id @default(uuid())
userId String
@@ -65,6 +78,10 @@ model Role {
desc String?
price Float @default(0)
status String @default("running")
+ reviewStatus String @default("pending_review")
+ reviewNote String?
+ qrCodeUrl String?
+ syncedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
diff --git a/scripts/seed-admin.js b/scripts/seed-admin.js
new file mode 100644
index 0000000..7a52c78
--- /dev/null
+++ b/scripts/seed-admin.js
@@ -0,0 +1,44 @@
+// 管理员账号初始化脚本
+// 用法: node scripts/seed-admin.js
+// 或通过环境变量: ADMIN_ACCOUNT=admin ADMIN_PASSWORD=xxx node scripts/seed-admin.js
+
+const prisma = require('../src/lib/prisma');
+const { hashPassword } = require('../src/lib/auth');
+
+async function main() {
+ const account = process.argv[2] || process.env.ADMIN_ACCOUNT;
+ const password = process.argv[3] || process.env.ADMIN_PASSWORD;
+
+ if (!account || !password) {
+ console.error('用法: node scripts/seed-admin.js ');
+ console.error(' 或: ADMIN_ACCOUNT=admin ADMIN_PASSWORD=xxx node scripts/seed-admin.js');
+ process.exit(1);
+ }
+
+ if (password.length < 6) {
+ console.error('密码至少 6 位');
+ process.exit(1);
+ }
+
+ const existing = await prisma.admin.findUnique({ where: { account } });
+ if (existing) {
+ // 更新密码
+ await prisma.admin.update({
+ where: { account },
+ data: { password: hashPassword(password) },
+ });
+ console.log(`管理员 ${account} 密码已更新`);
+ } else {
+ await prisma.admin.create({
+ data: { account, password: hashPassword(password) },
+ });
+ console.log(`管理员 ${account} 已创建`);
+ }
+
+ await prisma.$disconnect();
+}
+
+main().catch((err) => {
+ console.error('初始化管理员失败:', err);
+ process.exit(1);
+});
diff --git a/server.js b/server.js
index e575eb0..89ab4aa 100644
--- a/server.js
+++ b/server.js
@@ -15,6 +15,15 @@ app.use('/api/auth', require('./src/routes/auth'));
app.use('/api/roles', require('./src/routes/roles'));
app.use('/api/apikeys', require('./src/routes/apikeys'));
app.use('/api/hermes', require('./src/routes/hermes'));
+app.use('/api/admin-auth', require('./src/routes/admin-auth'));
+app.use('/api/admin', require('./src/routes/admin'));
+app.use('/api/admin/config', require('./src/routes/admin-config'));
+app.use('/api/admin/sync', require('./src/routes/admin-sync'));
+
+// Mock Hermes 端点(仅非 production 环境)
+if (process.env.NODE_ENV !== 'production') {
+ app.use('/api/mock-hermes', require('./src/routes/mock-hermes'));
+}
// 静态文件
app.use(express.static('.'));
diff --git a/src/lib/auth.js b/src/lib/auth.js
index afb03d7..e82ab19 100644
--- a/src/lib/auth.js
+++ b/src/lib/auth.js
@@ -4,6 +4,7 @@ const crypto = require('crypto');
const prisma = require('./prisma');
const JWT_EXPIRES_IN = '7d';
+const ADMIN_JWT_EXPIRES_IN = '7d';
// 安全:生产环境必须配置 JWT_SECRET,杜绝硬编码密钥
const JWT_SECRET = process.env.JWT_SECRET;
@@ -17,6 +18,11 @@ if (!JWT_SECRET) {
}
const SECRET = JWT_SECRET || 'dev_only_insecure_secret_do_not_use_in_production';
+// Admin JWT 使用独立 secret,防止跨角色伪造
+const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || (process.env.NODE_ENV === 'production'
+ ? null
+ : 'dev_only_admin_insecure_secret');
+
// 哈希密码
function hashPassword(password) {
return bcrypt.hashSync(password, 10);
@@ -101,6 +107,39 @@ async function apiKeyMiddleware(req, res, next) {
next();
}
+// ===== Admin 认证 =====
+
+// 生成 Admin JWT
+function adminSignToken(adminId) {
+ return jwt.sign({ adminId, role: 'admin' }, ADMIN_JWT_SECRET, { expiresIn: ADMIN_JWT_EXPIRES_IN });
+}
+
+// 验证 Admin JWT
+function adminVerifyToken(token) {
+ try {
+ const decoded = jwt.verify(token, ADMIN_JWT_SECRET);
+ if (decoded.role !== 'admin') return null;
+ return decoded.adminId;
+ } catch {
+ return null;
+ }
+}
+
+// Express 中间件:验证 Admin JWT
+function adminAuthMiddleware(req, res, next) {
+ const authHeader = req.headers.authorization;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ error: '管理员未登录' });
+ }
+ const token = authHeader.slice(7);
+ const adminId = adminVerifyToken(token);
+ if (!adminId) {
+ return res.status(401).json({ error: '管理员登录已过期,请重新登录' });
+ }
+ req.adminId = adminId;
+ next();
+}
+
module.exports = {
hashPassword,
verifyPassword,
@@ -108,4 +147,7 @@ module.exports = {
verifyToken,
authMiddleware,
apiKeyMiddleware,
+ adminSignToken,
+ adminVerifyToken,
+ adminAuthMiddleware,
};
diff --git a/src/lib/hermes-client.js b/src/lib/hermes-client.js
new file mode 100644
index 0000000..439cc54
--- /dev/null
+++ b/src/lib/hermes-client.js
@@ -0,0 +1,35 @@
+// Hermes HTTP 客户端 — 向 Hermes 服务器发起同步请求
+
+const SYNC_TIMEOUT_MS = 30000;
+
+// 向 Hermes POST 同步请求
+async function postSync(webhookUrl, payload) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(webhookUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ signal: controller.signal,
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || `Hermes 返回错误 (${response.status})`);
+ }
+
+ return data;
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ throw new Error('Hermes 同步请求超时');
+ }
+ throw err;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+module.exports = { postSync };
diff --git a/src/lib/sync-token.js b/src/lib/sync-token.js
new file mode 100644
index 0000000..3eeef23
--- /dev/null
+++ b/src/lib/sync-token.js
@@ -0,0 +1,67 @@
+const jwt = require('jsonwebtoken');
+const prisma = require('./prisma');
+
+const SYNC_TOKEN_EXPIRES_IN = '5m';
+
+// 内存 Set 记录已消费的 sync_token jti,5 分钟后自动清理
+const consumedTokens = new Map();
+
+// 清理过期 token(每 5 分钟调用一次)
+function cleanupConsumedTokens() {
+ const now = Date.now();
+ for (const [jti, expiry] of consumedTokens.entries()) {
+ if (now > expiry) {
+ consumedTokens.delete(jti);
+ }
+ }
+}
+setInterval(cleanupConsumedTokens, 5 * 60 * 1000);
+
+// 从 SystemConfig 获取 SYNC_SECRET,不存在则自动生成
+async function getSyncSecret() {
+ let config = await prisma.systemConfig.findUnique({ where: { key: 'SYNC_SECRET' } });
+ if (!config) {
+ const crypto = require('crypto');
+ const secret = crypto.randomBytes(32).toString('hex');
+ config = await prisma.systemConfig.create({
+ data: { key: 'SYNC_SECRET', value: secret },
+ });
+ }
+ return config.value;
+}
+
+// 生成 sync_token
+async function generateSyncToken(roleId, adminId) {
+ const secret = await getSyncSecret();
+ const jti = `${roleId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+ return jwt.sign({ roleId, adminId, jti, type: 'sync' }, secret, { expiresIn: SYNC_TOKEN_EXPIRES_IN });
+}
+
+// 验证 sync_token,返回 payload 或 null
+async function verifySyncToken(token) {
+ try {
+ const secret = await getSyncSecret();
+ const decoded = jwt.verify(token, secret);
+ if (decoded.type !== 'sync') return null;
+
+ // 检查是否已消费
+ if (consumedTokens.has(decoded.jti)) {
+ return null;
+ }
+
+ return decoded;
+ } catch {
+ return null;
+ }
+}
+
+// 标记 sync_token 为已消费
+function consumeSyncToken(jti) {
+ consumedTokens.set(jti, Date.now() + 5 * 60 * 1000);
+}
+
+module.exports = {
+ generateSyncToken,
+ verifySyncToken,
+ consumeSyncToken,
+};
diff --git a/src/routes/admin-auth.js b/src/routes/admin-auth.js
new file mode 100644
index 0000000..11da434
--- /dev/null
+++ b/src/routes/admin-auth.js
@@ -0,0 +1,48 @@
+const express = require('express');
+const prisma = require('../lib/prisma');
+const { verifyPassword, adminSignToken, adminAuthMiddleware } = require('../lib/auth');
+
+const router = express.Router();
+
+// 管理员登录
+router.post('/login', async (req, res) => {
+ try {
+ const { account, password } = req.body;
+ if (!account || !password) {
+ return res.status(400).json({ error: '账号和密码不能为空' });
+ }
+
+ const admin = await prisma.admin.findUnique({ where: { account } });
+ if (!admin || !verifyPassword(password, admin.password)) {
+ return res.status(401).json({ error: '账号或密码错误' });
+ }
+
+ const token = adminSignToken(admin.id);
+ res.json({
+ token,
+ admin: { id: admin.id, account: admin.account },
+ });
+ } catch (err) {
+ console.error('管理员登录失败:', err);
+ res.status(500).json({ error: '登录失败' });
+ }
+});
+
+// 获取当前管理员信息
+router.get('/me', adminAuthMiddleware, async (req, res) => {
+ try {
+ const admin = await prisma.admin.findUnique({
+ where: { id: req.adminId },
+ select: { id: true, account: true },
+ });
+ if (!admin) {
+ return res.status(404).json({ error: '管理员不存在' });
+ }
+ res.json({ admin });
+ } catch (err) {
+ console.error('获取管理员信息失败:', err);
+ res.status(500).json({ error: '服务器错误' });
+ }
+});
+
+module.exports = router;
diff --git a/src/routes/admin-config.js b/src/routes/admin-config.js
new file mode 100644
index 0000000..b161939
--- /dev/null
+++ b/src/routes/admin-config.js
@@ -0,0 +1,46 @@
+const express = require('express');
+const prisma = require('../lib/prisma');
+const { adminAuthMiddleware } = require('../lib/auth');
+
+const router = express.Router();
+
+// 敏感配置项的值需要脱敏显示
+const SENSITIVE_KEYS = ['SYNC_SECRET'];
+
+// 获取所有配置
+router.get('/', adminAuthMiddleware, async (req, res) => {
+ try {
+ const configs = await prisma.systemConfig.findMany();
+ const result = configs.map((c) => ({
+ key: c.key,
+ value: SENSITIVE_KEYS.includes(c.key) ? '***' : c.value,
+ updatedAt: c.updatedAt,
+ }));
+ res.json({ configs: result });
+ } catch (err) {
+ console.error('获取系统配置失败:', err);
+ res.status(500).json({ error: '服务器错误' });
+ }
+});
+
+// 更新配置项
+router.put('/:key', adminAuthMiddleware, async (req, res) => {
+ try {
+ const { value } = req.body;
+ if (value === undefined || value === null) {
+ return res.status(400).json({ error: 'value 不能为空' });
+ }
+
+ const config = await prisma.systemConfig.upsert({
+ where: { key: req.params.key },
+ update: { value },
+ create: { key: req.params.key, value },
+ });
+ res.json({ config: { key: config.key, value: SENSITIVE_KEYS.includes(config.key) ? '***' : config.value } });
+ } catch (err) {
+ console.error('更新系统配置失败:', err);
+ res.status(500).json({ error: '更新失败' });
+ }
+});
+
+module.exports = router;
diff --git a/src/routes/admin-sync.js b/src/routes/admin-sync.js
new file mode 100644
index 0000000..286cebe
--- /dev/null
+++ b/src/routes/admin-sync.js
@@ -0,0 +1,106 @@
+const express = require('express');
+const prisma = require('../lib/prisma');
+const { adminAuthMiddleware } = require('../lib/auth');
+const { generateSyncToken } = require('../lib/sync-token');
+const { postSync } = require('../lib/hermes-client');
+
+const router = express.Router();
+
+// 管理员发起同步
+router.post('/:roleId', adminAuthMiddleware, async (req, res) => {
+ try {
+ const {
+ profileName,
+ modelKey,
+ provider,
+ multimediaModelKey,
+ multimediaProvider,
+ enableSchedule,
+ webhookUrl,
+ } = req.body;
+
+ if (!profileName) {
+ return res.status(400).json({ error: 'profile 名字不能为空' });
+ }
+
+ const role = await prisma.role.findUnique({ where: { id: req.params.roleId } });
+ if (!role) {
+ return res.status(404).json({ error: '角色不存在' });
+ }
+ if (!['approved', 'failed'].includes(role.reviewStatus)) {
+ return res.status(400).json({ error: `当前状态为 ${role.reviewStatus},无法同步(需 approved 或 failed)` });
+ }
+
+ // 获取 Hermes webhook URL:优先用请求参数,否则用全局配置
+ let hermesUrl = webhookUrl;
+ if (!hermesUrl) {
+ const config = await prisma.systemConfig.findUnique({ where: { key: 'HERMES_WEBHOOK_URL' } });
+ hermesUrl = config?.value;
+ }
+ if (!hermesUrl) {
+ return res.status(400).json({ error: '未配置 Hermes webhook URL' });
+ }
+
+ // 获取 EternalAI 自身基础 URL(供 Hermes 回调拉取文件)
+ let baseUrl = req.protocol + '://' + req.get('host');
+
+ // 生成 sync_token
+ const syncToken = await generateSyncToken(role.id, req.adminId);
+
+ // 更新状态为 syncing
+ await prisma.role.update({
+ where: { id: role.id },
+ data: { reviewStatus: 'syncing' },
+ });
+
+ // 向 Hermes POST 同步请求
+ const payload = {
+ profileName,
+ modelKey,
+ provider,
+ multimediaModelKey,
+ multimediaProvider,
+ enableSchedule: !!enableSchedule,
+ syncToken,
+ filePullBaseUrl: baseUrl,
+ roleId: role.id,
+ };
+
+ const result = await postSync(hermesUrl, payload);
+
+ // 同步成功,存储二维码 URL
+ const updated = await prisma.role.update({
+ where: { id: role.id },
+ data: {
+ reviewStatus: 'synced',
+ qrCodeUrl: result.qrCodeUrl || null,
+ syncedAt: new Date(),
+ },
+ select: {
+ id: true,
+ displayName: true,
+ reviewStatus: true,
+ qrCodeUrl: true,
+ syncedAt: true,
+ },
+ });
+
+ res.json({ role: updated, profileId: result.profileId });
+ } catch (err) {
+ console.error('同步失败:', err.message);
+
+ // 同步失败,更新状态
+ try {
+ await prisma.role.update({
+ where: { id: req.params.roleId },
+ data: { reviewStatus: 'failed' },
+ });
+ } catch (updateErr) {
+ console.error('更新失败状态出错:', updateErr);
+ }
+
+ res.status(500).json({ error: err.message || '同步失败' });
+ }
+});
+
+module.exports = router;
diff --git a/src/routes/admin.js b/src/routes/admin.js
new file mode 100644
index 0000000..a84d835
--- /dev/null
+++ b/src/routes/admin.js
@@ -0,0 +1,126 @@
+const express = require('express');
+const prisma = require('../lib/prisma');
+const { adminAuthMiddleware } = require('../lib/auth');
+
+const router = express.Router();
+
+// 获取待审核列表
+router.get('/reviews', adminAuthMiddleware, async (req, res) => {
+ try {
+ const { status, page = 1, pageSize = 20 } = req.query;
+ const where = status ? { reviewStatus: status } : { reviewStatus: 'pending_review' };
+
+ const [roles, total] = await Promise.all([
+ prisma.role.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip: (Number(page) - 1) * Number(pageSize),
+ take: Number(pageSize),
+ select: {
+ id: true,
+ displayName: true,
+ creatorId: true,
+ reviewStatus: true,
+ reviewNote: true,
+ qrCodeUrl: true,
+ syncedAt: true,
+ createdAt: true,
+ },
+ }),
+ prisma.role.count({ where }),
+ ]);
+
+ res.json({ roles, total, page: Number(page), pageSize: Number(pageSize) });
+ } catch (err) {
+ console.error('获取审核列表失败:', err);
+ res.status(500).json({ error: '服务器错误' });
+ }
+});
+
+// 获取角色详情(审核用)
+router.get('/reviews/:roleId', adminAuthMiddleware, async (req, res) => {
+ try {
+ const role = await prisma.role.findUnique({
+ where: { id: req.params.roleId },
+ });
+ if (!role) {
+ return res.status(404).json({ error: '角色不存在' });
+ }
+ res.json({ role });
+ } catch (err) {
+ console.error('获取角色详情失败:', err);
+ res.status(500).json({ error: '服务器错误' });
+ }
+});
+
+// 通过审核
+router.post('/reviews/:roleId/approve', adminAuthMiddleware, async (req, res) => {
+ try {
+ const role = await prisma.role.findUnique({ where: { id: req.params.roleId } });
+ if (!role) {
+ return res.status(404).json({ error: '角色不存在' });
+ }
+ if (role.reviewStatus !== 'pending_review') {
+ return res.status(400).json({ error: `当前状态为 ${role.reviewStatus},无法通过审核` });
+ }
+
+ const updated = await prisma.role.update({
+ where: { id: req.params.roleId },
+ data: { reviewStatus: 'approved' },
+ select: { id: true, displayName: true, reviewStatus: true },
+ });
+ res.json({ role: updated });
+ } catch (err) {
+ console.error('通过审核失败:', err);
+ res.status(500).json({ error: '操作失败' });
+ }
+});
+
+// 驳回审核
+router.post('/reviews/:roleId/reject', adminAuthMiddleware, async (req, res) => {
+ try {
+ const { reviewNote } = req.body;
+ const role = await prisma.role.findUnique({ where: { id: req.params.roleId } });
+ if (!role) {
+ return res.status(404).json({ error: '角色不存在' });
+ }
+ if (role.reviewStatus !== 'pending_review') {
+ return res.status(400).json({ error: `当前状态为 ${role.reviewStatus},无法驳回` });
+ }
+
+ const updated = await prisma.role.update({
+ where: { id: req.params.roleId },
+ data: { reviewStatus: 'rejected', reviewNote: reviewNote || null },
+ select: { id: true, displayName: true, reviewStatus: true, reviewNote: true },
+ });
+ res.json({ role: updated });
+ } catch (err) {
+ console.error('驳回审核失败:', err);
+ res.status(500).json({ error: '操作失败' });
+ }
+});
+
+// 获取所有角色同步状态
+router.get('/sync-status', adminAuthMiddleware, async (req, res) => {
+ try {
+ const roles = await prisma.role.findMany({
+ where: { reviewStatus: { in: ['approved', 'syncing', 'synced', 'failed'] } },
+ orderBy: { createdAt: 'desc' },
+ select: {
+ id: true,
+ displayName: true,
+ creatorId: true,
+ reviewStatus: true,
+ qrCodeUrl: true,
+ syncedAt: true,
+ createdAt: true,
+ },
+ });
+ res.json({ roles });
+ } catch (err) {
+ console.error('获取同步状态失败:', err);
+ res.status(500).json({ error: '服务器错误' });
+ }
+});
+
+module.exports = router;
diff --git a/src/routes/hermes.js b/src/routes/hermes.js
index 08a85b2..6db3031 100644
--- a/src/routes/hermes.js
+++ b/src/routes/hermes.js
@@ -1,6 +1,7 @@
const express = require('express');
const prisma = require('../lib/prisma');
const { apiKeyMiddleware } = require('../lib/auth');
+const { verifySyncToken } = require('../lib/sync-token');
const router = express.Router();
@@ -40,8 +41,44 @@ function adaptToHermesConfig(role) {
return lines.join('\n');
}
+// 统一认证中间件:支持 sync_token、API Key、JWT 三种认证方式
+async function hermesAuthMiddleware(req, res, next) {
+ // 优先检查 X-Sync-Token header(Hermes 回调拉取)
+ const syncTokenHeader = req.headers['x-sync-token'];
+ if (syncTokenHeader) {
+ try {
+ const payload = await verifySyncToken(syncTokenHeader);
+ if (!payload) {
+ return res.status(401).json({ error: 'sync_token 无效或已过期' });
+ }
+ // 验证 token 中的 roleId 与 URL 中的 :id 一致
+ if (payload.roleId !== req.params.id) {
+ return res.status(403).json({ error: 'sync_token 与请求的角色不匹配' });
+ }
+ // 通过 Role.creatorId 反查 userId
+ const role = await prisma.role.findUnique({
+ where: { id: req.params.id },
+ select: { creatorId: true },
+ });
+ if (!role) {
+ return res.status(404).json({ error: 'Role not found' });
+ }
+ req.userId = role.creatorId;
+ req.authMethod = 'sync_token';
+ req.syncTokenPayload = payload;
+ return next();
+ } catch (err) {
+ console.error('sync_token 验证失败:', err);
+ return res.status(500).json({ error: '认证失败' });
+ }
+ }
+
+ // 否则走原有 apiKeyMiddleware(支持 API Key 和 JWT)
+ return apiKeyMiddleware(req, res, next);
+}
+
// GET /api/hermes/roles/:id/SOUL.md — 返回 SOUL.md 内容
-router.get('/roles/:id/SOUL.md', apiKeyMiddleware, async (req, res) => {
+router.get('/roles/:id/SOUL.md', hermesAuthMiddleware, async (req, res) => {
try {
const role = await prisma.role.findUnique({
where: { id: req.params.id },
@@ -64,7 +101,7 @@ router.get('/roles/:id/SOUL.md', apiKeyMiddleware, async (req, res) => {
});
// GET /api/hermes/roles/:id/config.yaml — 返回适配后的 Hermes config.yaml
-router.get('/roles/:id/config.yaml', apiKeyMiddleware, async (req, res) => {
+router.get('/roles/:id/config.yaml', hermesAuthMiddleware, async (req, res) => {
try {
const role = await prisma.role.findUnique({
where: { id: req.params.id },
diff --git a/src/routes/mock-hermes.js b/src/routes/mock-hermes.js
new file mode 100644
index 0000000..4d580ee
--- /dev/null
+++ b/src/routes/mock-hermes.js
@@ -0,0 +1,64 @@
+const express = require('express');
+const { verifySyncToken } = require('../lib/sync-token');
+
+const router = express.Router();
+
+// Mock Hermes 同步端点 — 模拟 Hermes 接收同步请求并回调拉取文件
+// 仅在非 production 环境注册
+router.post('/sync', async (req, res) => {
+ try {
+ const { syncToken, filePullBaseUrl, roleId, profileName } = req.body;
+
+ if (!syncToken || !filePullBaseUrl || !roleId) {
+ return res.status(400).json({ error: '缺少必要参数' });
+ }
+
+ // 验证 sync_token
+ const payload = await verifySyncToken(syncToken);
+ if (!payload) {
+ return res.status(401).json({ error: 'sync_token 无效或已过期' });
+ }
+
+ // 验证 roleId 匹配
+ if (payload.roleId !== roleId) {
+ return res.status(403).json({ error: 'roleId 与 sync_token 不匹配' });
+ }
+
+ // 回调拉取 SOUL.md
+ const soulResponse = await fetch(`${filePullBaseUrl}/api/hermes/roles/${roleId}/SOUL.md`, {
+ headers: { 'X-Sync-Token': syncToken },
+ });
+ if (!soulResponse.ok) {
+ return res.status(500).json({ error: `拉取 SOUL.md 失败: ${soulResponse.status}` });
+ }
+ const soulMd = await soulResponse.text();
+
+ // 回调拉取 config.yaml
+ const configResponse = await fetch(`${filePullBaseUrl}/api/hermes/roles/${roleId}/config.yaml`, {
+ headers: { 'X-Sync-Token': syncToken },
+ });
+ if (!configResponse.ok) {
+ return res.status(500).json({ error: `拉取 config.yaml 失败: ${configResponse.status}` });
+ }
+ const configYaml = await configResponse.text();
+
+ // 模拟创建 profile 和生成二维码
+ console.log(`[Mock Hermes] 创建 profile: ${profileName}`);
+ console.log(`[Mock Hermes] SOUL.md 长度: ${soulMd.length}`);
+ console.log(`[Mock Hermes] config.yaml 长度: ${configYaml.length}`);
+
+ const qrCodeUrl = `https://mock.hermes.local/qr/${roleId}`;
+ const profileId = `mock-profile-${roleId}`;
+
+ res.json({
+ qrCodeUrl,
+ profileId,
+ message: 'Profile 创建成功(mock)',
+ });
+ } catch (err) {
+ console.error('[Mock Hermes] 同步失败:', err);
+ res.status(500).json({ error: err.message || '同步失败' });
+ }
+});
+
+module.exports = router;
diff --git a/src/routes/roles.js b/src/routes/roles.js
index 96b5c40..64bb47f 100644
--- a/src/routes/roles.js
+++ b/src/routes/roles.js
@@ -4,11 +4,11 @@ const { authMiddleware } = require('../lib/auth');
const router = express.Router();
-// 获取角色库(所有已上架角色)
+// 获取角色库(仅显示已同步完成的角色)
router.get('/', async (req, res) => {
try {
const roles = await prisma.role.findMany({
- where: { status: 'running' },
+ where: { reviewStatus: 'synced' },
orderBy: { createdAt: 'desc' },
select: {
id: true,
@@ -58,12 +58,25 @@ router.get('/:id', async (req, res) => {
}
});
-// 获取当前用户创建的角色
+// 获取当前用户创建的角色(含审核状态和二维码)
router.get('/my/roles', authMiddleware, async (req, res) => {
try {
const roles = await prisma.role.findMany({
where: { creatorId: req.userId },
orderBy: { createdAt: 'desc' },
+ select: {
+ id: true,
+ displayName: true,
+ avatar: true,
+ desc: true,
+ price: true,
+ status: true,
+ reviewStatus: true,
+ reviewNote: true,
+ qrCodeUrl: true,
+ syncedAt: true,
+ createdAt: true,
+ },
});
res.json({ roles });
} catch (err) {
@@ -108,6 +121,7 @@ router.post('/', authMiddleware, async (req, res) => {
desc: data.desc || data.personality.slice(0, 50),
price: parseFloat(data.price) || 0,
status: 'running',
+ reviewStatus: 'pending_review',
},
});