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