feat: add admin review + Hermes sync workflow with sync_token auth

This commit is contained in:
chiguyong 2026-06-21 15:25:01 +08:00
parent 2055b62afd
commit 848939dc21
19 changed files with 1500 additions and 7 deletions

View File

@ -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_tokenHMAC SHA-256 对称密钥)向 Hermes POST 同步请求
- **R6**: Hermes 用 sync_token 回调 EternalAI 拉取 SOUL.md 和 config.yaml
- **R7**: Hermes 返回二维码 URLEternalAI 存储并展示给管理员和创作者
- **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 是 JWTHS256payload 含 `roleId`、`adminId`、`iat`、`exp`5 分钟)。
**理由**: 用户选择。对称密钥实现简单,双方预共享即可。
### KTD3: Hermes webhook URL 全局配置 + 可覆盖
系统设置表存储 `HERMES_WEBHOOK_URL`,管理员发起同步时可在表单中覆盖。
**理由**: 用户选择。大多数情况用全局配置,特殊场景可覆盖。
### KTD4: 二维码由 Hermes 生成返回
Hermes 创建 profile 后生成二维码 URL 返回给 EternalAIEternalAI 存储到 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,<br/>provider, multimediaModelKey,<br/>multimediaProvider, enableSchedule,<br/>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 访问管理员接口,返回 403secret 不同,验证失败)
- 管理员 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
- 管理员驳回审核,状态变为 rejectedreviewNote 有值
- 非管理员调用审核接口,返回 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)` — 生成 JWTHS256payload `{ 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 到 Hermespayload 含 sync_token 和 file_pull_base_url
- `file_pull_base_url` = EternalAI 自身的基础 URL从 SystemConfig 读取 `ETERNALAI_BASE_URL`
**Test scenarios:**
- 管理员对 approved 角色发起同步reviewStatus 变为 syncing
- Mock Hermes 返回成功reviewStatus 变为 syncedqrCodeUrl 有值
- 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>` → 走 JWT 路径
- sync_token 消费后标记为已使用(内存 Set5 分钟后自动清理;或用 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 Keyeak_访问仍正常工作向后兼容
**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 服务商selectopenrouter / together / local
- 主 model keypassword input
- 多媒体 model 服务商select
- 多媒体 model keypassword input
- 是否开启定时任务checkbox
- Hermes webhook URLtext默认全局配置值可覆盖
- "发起同步"按钮
- 同步成功后显示二维码图片和"复制链接"按钮
**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/<roleId>`
- 返回 mock profileId`mock-profile-<roleId>`
- 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_tokenmock 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_tokenHMAC SHA-256两套机制
- **向后兼容**: 现有用户 API Key 拉取流程不变

301
e2e/admin-sync-flow.spec.js Normal file
View File

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

View File

@ -25,6 +25,7 @@ test.describe('创作者中心与角色发布/编辑', () => {
desc: '温柔体贴的角色',
price: 19.9,
status: 'running',
reviewStatus: 'synced',
temperature: 0.8,
maxTokens: 2048,
enableMemory: true,

View File

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

View File

@ -159,6 +159,7 @@ test.describe('导航与可访问性', () => {
desc: '键盘测试',
price: 9.9,
status: 'running',
reviewStatus: 'synced',
},
});

View File

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

View File

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

44
scripts/seed-admin.js Normal file
View File

@ -0,0 +1,44 @@
// 管理员账号初始化脚本
// 用法: node scripts/seed-admin.js <account> <password>
// 或通过环境变量: 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 <account> <password>');
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);
});

View File

@ -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('.'));

View File

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

35
src/lib/hermes-client.js Normal file
View File

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

67
src/lib/sync-token.js Normal file
View File

@ -0,0 +1,67 @@
const jwt = require('jsonwebtoken');
const prisma = require('./prisma');
const SYNC_TOKEN_EXPIRES_IN = '5m';
// 内存 Set 记录已消费的 sync_token jti5 分钟后自动清理
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,
};

48
src/routes/admin-auth.js Normal file
View File

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

View File

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

106
src/routes/admin-sync.js Normal file
View File

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

126
src/routes/admin.js Normal file
View File

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

View File

@ -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 headerHermes 回调拉取)
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 },

64
src/routes/mock-hermes.js Normal file
View File

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

View File

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