feat: add admin review + Hermes sync workflow with sync_token auth
This commit is contained in:
parent
2055b62afd
commit
848939dc21
|
|
@ -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,<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 访问管理员接口,返回 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>` → 走 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/<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_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 拉取流程不变
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -25,6 +25,7 @@ test.describe('创作者中心与角色发布/编辑', () => {
|
||||||
desc: '温柔体贴的角色',
|
desc: '温柔体贴的角色',
|
||||||
price: 19.9,
|
price: 19.9,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
reviewStatus: 'synced',
|
||||||
temperature: 0.8,
|
temperature: 0.8,
|
||||||
maxTokens: 2048,
|
maxTokens: 2048,
|
||||||
enableMemory: true,
|
enableMemory: true,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ const prisma = new PrismaClient();
|
||||||
async function cleanDatabase() {
|
async function cleanDatabase() {
|
||||||
await prisma.order.deleteMany();
|
await prisma.order.deleteMany();
|
||||||
await prisma.role.deleteMany();
|
await prisma.role.deleteMany();
|
||||||
|
await prisma.apiKey.deleteMany();
|
||||||
|
await prisma.systemConfig.deleteMany();
|
||||||
await prisma.user.deleteMany();
|
await prisma.user.deleteMany();
|
||||||
|
// 不删除 Admin 表,保留测试管理员账号
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedExistingUser() {
|
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() {
|
async function disconnect() {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { cleanDatabase, seedExistingUser, disconnect, prisma };
|
module.exports = { cleanDatabase, seedExistingUser, seedAdmin, disconnect, prisma };
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ test.describe('导航与可访问性', () => {
|
||||||
desc: '键盘测试',
|
desc: '键盘测试',
|
||||||
price: 9.9,
|
price: 9.9,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
reviewStatus: 'synced',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ test.describe('角色库与角色详情', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 创建一个测试角色(status=running 才会在角色库展示)
|
// 创建一个测试角色(reviewStatus=synced 才会在角色库展示)
|
||||||
testRole = await prisma.role.create({
|
testRole = await prisma.role.create({
|
||||||
data: {
|
data: {
|
||||||
creatorId: existingUser.id,
|
creatorId: existingUser.id,
|
||||||
|
|
@ -45,6 +45,7 @@ test.describe('角色库与角色详情', () => {
|
||||||
desc: '温柔可爱的女友角色',
|
desc: '温柔可爱的女友角色',
|
||||||
price: 29.9,
|
price: 29.9,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
reviewStatus: 'synced',
|
||||||
avatar: 'https://example.com/avatar.png',
|
avatar: 'https://example.com/avatar.png',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -101,6 +102,7 @@ test.describe('角色库与角色详情', () => {
|
||||||
desc: '阳光开朗的男孩',
|
desc: '阳光开朗的男孩',
|
||||||
price: 19.9,
|
price: 19.9,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
reviewStatus: 'synced',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,19 @@ model User {
|
||||||
apiKeys ApiKey[]
|
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 {
|
model ApiKey {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
|
@ -65,6 +78,10 @@ model Role {
|
||||||
desc String?
|
desc String?
|
||||||
price Float @default(0)
|
price Float @default(0)
|
||||||
status String @default("running")
|
status String @default("running")
|
||||||
|
reviewStatus String @default("pending_review")
|
||||||
|
reviewNote String?
|
||||||
|
qrCodeUrl String?
|
||||||
|
syncedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,15 @@ app.use('/api/auth', require('./src/routes/auth'));
|
||||||
app.use('/api/roles', require('./src/routes/roles'));
|
app.use('/api/roles', require('./src/routes/roles'));
|
||||||
app.use('/api/apikeys', require('./src/routes/apikeys'));
|
app.use('/api/apikeys', require('./src/routes/apikeys'));
|
||||||
app.use('/api/hermes', require('./src/routes/hermes'));
|
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('.'));
|
app.use(express.static('.'));
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const crypto = require('crypto');
|
||||||
const prisma = require('./prisma');
|
const prisma = require('./prisma');
|
||||||
|
|
||||||
const JWT_EXPIRES_IN = '7d';
|
const JWT_EXPIRES_IN = '7d';
|
||||||
|
const ADMIN_JWT_EXPIRES_IN = '7d';
|
||||||
|
|
||||||
// 安全:生产环境必须配置 JWT_SECRET,杜绝硬编码密钥
|
// 安全:生产环境必须配置 JWT_SECRET,杜绝硬编码密钥
|
||||||
const JWT_SECRET = process.env.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';
|
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) {
|
function hashPassword(password) {
|
||||||
return bcrypt.hashSync(password, 10);
|
return bcrypt.hashSync(password, 10);
|
||||||
|
|
@ -101,6 +107,39 @@ async function apiKeyMiddleware(req, res, next) {
|
||||||
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 = {
|
module.exports = {
|
||||||
hashPassword,
|
hashPassword,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
|
|
@ -108,4 +147,7 @@ module.exports = {
|
||||||
verifyToken,
|
verifyToken,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
apiKeyMiddleware,
|
apiKeyMiddleware,
|
||||||
|
adminSignToken,
|
||||||
|
adminVerifyToken,
|
||||||
|
adminAuthMiddleware,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const prisma = require('../lib/prisma');
|
const prisma = require('../lib/prisma');
|
||||||
const { apiKeyMiddleware } = require('../lib/auth');
|
const { apiKeyMiddleware } = require('../lib/auth');
|
||||||
|
const { verifySyncToken } = require('../lib/sync-token');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -40,8 +41,44 @@ function adaptToHermesConfig(role) {
|
||||||
return lines.join('\n');
|
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 内容
|
// 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 {
|
try {
|
||||||
const role = await prisma.role.findUnique({
|
const role = await prisma.role.findUnique({
|
||||||
where: { id: req.params.id },
|
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
|
// 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 {
|
try {
|
||||||
const role = await prisma.role.findUnique({
|
const role = await prisma.role.findUnique({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -4,11 +4,11 @@ const { authMiddleware } = require('../lib/auth');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// 获取角色库(所有已上架角色)
|
// 获取角色库(仅显示已同步完成的角色)
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const roles = await prisma.role.findMany({
|
const roles = await prisma.role.findMany({
|
||||||
where: { status: 'running' },
|
where: { reviewStatus: 'synced' },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
@ -58,12 +58,25 @@ router.get('/:id', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取当前用户创建的角色
|
// 获取当前用户创建的角色(含审核状态和二维码)
|
||||||
router.get('/my/roles', authMiddleware, async (req, res) => {
|
router.get('/my/roles', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const roles = await prisma.role.findMany({
|
const roles = await prisma.role.findMany({
|
||||||
where: { creatorId: req.userId },
|
where: { creatorId: req.userId },
|
||||||
orderBy: { createdAt: 'desc' },
|
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 });
|
res.json({ roles });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -108,6 +121,7 @@ router.post('/', authMiddleware, async (req, res) => {
|
||||||
desc: data.desc || data.personality.slice(0, 50),
|
desc: data.desc || data.personality.slice(0, 50),
|
||||||
price: parseFloat(data.price) || 0,
|
price: parseFloat(data.price) || 0,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
reviewStatus: 'pending_review',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue