master #14
@@ -0,0 +1 @@
|
||||
C:/proj/Genarrative/.hermes/skills/behavior-driven-development
|
||||
@@ -0,0 +1,389 @@
|
||||
---
|
||||
name: genarrative-play-type-integration
|
||||
description: 在 Genarrative 中新增一个创作入口/玩法类型时,按入口配置、前端分流、契约、后端接口、工作台、结果页、可选 runtime 与作品架的顺序接入。
|
||||
license: MIT
|
||||
metadata:
|
||||
author: Hermes Agent
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Genarrative 新增玩法类型接入流程
|
||||
|
||||
用于在 Genarrative 中新增一个创作入口/玩法类型,而不是单纯说明用户如何从入口创建作品。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 新增一个游戏玩法入口
|
||||
- 让某个玩法从“敬请期待”变为可创建
|
||||
- 为新玩法补齐创作工作台、结果页、发布与试玩链路
|
||||
- 将新玩法接入创作中心作品架与广场
|
||||
|
||||
## 先判断接入级别
|
||||
|
||||
### 1. 只做入口占位
|
||||
|
||||
只需要新增入口配置,不接 session/workspace/result/runtime。
|
||||
|
||||
适合:
|
||||
|
||||
- 敬请期待
|
||||
- 灰度占位
|
||||
|
||||
### 2. 可进入创作工作台
|
||||
|
||||
需要补齐前端分流、session、工作台、结果页,至少能生成草稿。
|
||||
|
||||
### 3. 完整玩法闭环
|
||||
|
||||
需要补齐:
|
||||
|
||||
- 创作入口
|
||||
- 工作台
|
||||
- 草稿生成
|
||||
- 结果页
|
||||
- 发布
|
||||
- 试玩 runtime
|
||||
- 作品架 / 广场 / 分享
|
||||
|
||||
## 推荐接入顺序
|
||||
|
||||
### Step 1: 先定玩法 ID 和能力边界
|
||||
|
||||
先明确:
|
||||
|
||||
- `id` 是什么
|
||||
- 入口是否可见
|
||||
- 是否可点击创建
|
||||
- 是否需要对话式创作
|
||||
- 是否需要生成中页面
|
||||
- 是否需要 result/runtime/gallery/share
|
||||
|
||||
不要先随便起临时 ID 再改名。
|
||||
|
||||
### Step 2: 新增入口配置
|
||||
|
||||
文件:
|
||||
|
||||
- `src/config/newWorkEntryConfig.ts`
|
||||
|
||||
在 `creationTypes` 中新增:
|
||||
|
||||
- `id`
|
||||
- `title`
|
||||
- `subtitle`
|
||||
- `badge`
|
||||
- `visible`
|
||||
- `open`
|
||||
|
||||
如果只是占位:
|
||||
|
||||
- `visible: true`
|
||||
- `open: false`
|
||||
|
||||
### Step 3: 确认类型过滤逻辑
|
||||
|
||||
文件:
|
||||
|
||||
- `src/components/platform-entry/platformEntryCreationTypes.ts`
|
||||
|
||||
检查:
|
||||
|
||||
- `getVisiblePlatformCreationTypes()` 是否能展示新类型
|
||||
- `isPlatformCreationTypeVisible()` 是否能识别新类型
|
||||
- `locked` / `hidden` 是否正确映射
|
||||
|
||||
### Step 4: 扩展页面阶段
|
||||
|
||||
文件:
|
||||
|
||||
- `src/components/platform-entry/platformEntryTypes.ts`
|
||||
|
||||
为新玩法补充 `SelectionStage`:
|
||||
|
||||
- `*-agent-workspace`
|
||||
- `*-generating`(可选)
|
||||
- `*-result`
|
||||
- `*-runtime`(可选)
|
||||
- `*-gallery-detail`(可选)
|
||||
|
||||
### Step 5: 在总流程中加类型分流
|
||||
|
||||
文件:
|
||||
|
||||
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
|
||||
在 `handleCreationHubCreateType(type)` 中新增分支,确保:
|
||||
|
||||
- 能进入对应工作台
|
||||
- 能设置对应 `selectionStage`
|
||||
- 能关闭类型弹层
|
||||
|
||||
同时按玩法补齐:
|
||||
|
||||
- `open<Play>AgentWorkspace()`
|
||||
- `leave<Play>Flow()`
|
||||
- `submit<Play>Message()`(对话式玩法)
|
||||
- `execute<Play>Action()`
|
||||
|
||||
### Step 6: 接入通用 Agent flow controller
|
||||
|
||||
文件:
|
||||
|
||||
- `src/components/platform-entry/usePlatformCreationAgentFlowController.ts`
|
||||
|
||||
如果是 Agent 型玩法,复用通用控制器:
|
||||
|
||||
- `createSession`
|
||||
- `getSession`
|
||||
- `streamMessage`
|
||||
- `executeAction`
|
||||
- `isBusy`
|
||||
- `error`
|
||||
- `streamingReplyText`
|
||||
- `selectionStage` 切换
|
||||
|
||||
### Step 7: 定义 shared contracts
|
||||
|
||||
前端:
|
||||
|
||||
- `packages/shared/src/contracts/`
|
||||
|
||||
后端:
|
||||
|
||||
- `server-rs/crates/shared-contracts/src/`
|
||||
|
||||
至少补齐:
|
||||
|
||||
- session snapshot
|
||||
- create session request/response
|
||||
- message request/response
|
||||
- action request/response
|
||||
- draft/result 结构
|
||||
- work summary / gallery 结构(如果需要)
|
||||
- runtime 结构(如果需要)
|
||||
|
||||
### Step 8: 实现前端 service client
|
||||
|
||||
目录参考:
|
||||
|
||||
- `src/services/`
|
||||
|
||||
按玩法补:
|
||||
|
||||
- creation client
|
||||
- runtime client(可选)
|
||||
- works client(可选)
|
||||
- gallery client(可选)
|
||||
|
||||
建议保持和现有玩法一致的 API base 与命名风格。
|
||||
|
||||
### Step 9: 接后端 API
|
||||
|
||||
文件参考:
|
||||
|
||||
- `server-rs/crates/api-server/src/puzzle.rs`
|
||||
- `server-rs/crates/api-server/src/puzzle_agent_turn.rs`
|
||||
- `server-rs/crates/api-server/src/match3d.rs`
|
||||
|
||||
通常需要:
|
||||
|
||||
- create session
|
||||
- get session
|
||||
- send message
|
||||
- stream message
|
||||
- execute action
|
||||
- publish / save / delete
|
||||
- runtime start / action(可选)
|
||||
- gallery / detail(可选)
|
||||
|
||||
后端设计优先按 Genarrative 的 DDD 分层拆开,不要把玩法规则、数据库事务、LLM 调用和 HTTP handler 混在一个文件里:
|
||||
|
||||
- `module-<play>`:纯领域规则、状态机、draft/runtime 校验,不依赖 Axum、SpacetimeDB 或外部平台。
|
||||
- `shared-contracts`:前后端 DTO、请求/响应、session snapshot、draft/result/runtime 结构。
|
||||
- `spacetime-module`:表定义、reducer/procedure、事务编排、migration;表结构变化要同步生成绑定。
|
||||
- `spacetime-client`:api-server 到 SpacetimeDB 的 facade,隐藏 reducer 调用细节。
|
||||
- `api-server`:Axum 路由、鉴权、SSE/stream、应用层编排。
|
||||
- `platform-*`:LLM、资产上传、鉴权、第三方服务等副作用。
|
||||
|
||||
建议按四条线设计后端能力:
|
||||
|
||||
- Agent 创作线:session、turn、stream、compile action。
|
||||
- Works 作品线:保存、发布、删除、草稿恢复。
|
||||
- Gallery 广场线:公开列表、详情、like/remix/share。
|
||||
- Runtime 运行态线:开始试玩、提交动作、读取状态。
|
||||
|
||||
### Step 10: 新增工作台组件
|
||||
|
||||
目录建议:
|
||||
|
||||
- `src/components/<play>-creation/<Play>AgentWorkspace.tsx`
|
||||
|
||||
两种形态:
|
||||
|
||||
#### 对话式
|
||||
|
||||
适合设定逐轮补齐。
|
||||
|
||||
参考:
|
||||
|
||||
- `BigFishAgentWorkspace.tsx`
|
||||
- `Match3DAgentWorkspace.tsx`
|
||||
|
||||
#### 表单式
|
||||
|
||||
适合输入结构明确的玩法。
|
||||
|
||||
参考:
|
||||
|
||||
- `PuzzleAgentWorkspace.tsx`
|
||||
|
||||
### Step 11: 在渲染树中挂载新页面
|
||||
|
||||
文件:
|
||||
|
||||
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
|
||||
补齐:
|
||||
|
||||
- workspace 分支
|
||||
- generating 分支(如需要)
|
||||
- result 分支
|
||||
- runtime 分支(如需要)
|
||||
|
||||
### Step 12: 新增结果页
|
||||
|
||||
目录建议:
|
||||
|
||||
- `src/components/<play>-result/<Play>ResultView.tsx`
|
||||
|
||||
结果页至少支持:
|
||||
|
||||
- 展示 draft
|
||||
- 返回编辑
|
||||
- 发布
|
||||
- 试玩
|
||||
- 错误展示
|
||||
|
||||
### Step 13: 需要试玩就补 runtime
|
||||
|
||||
目录建议:
|
||||
|
||||
- `src/components/<play>-runtime/<Play>RuntimeShell.tsx`
|
||||
|
||||
如果玩法是游戏类,建议补完整 runtime 闭环。
|
||||
|
||||
### Step 14: 接入作品架 / 广场 / 分享
|
||||
|
||||
需要改:
|
||||
|
||||
- `src/components/custom-world-home/creationWorkShelf.ts`
|
||||
- `src/components/custom-world-home/CustomWorldCreationHub.tsx`
|
||||
- `src/services/publicWorkCode.ts`
|
||||
|
||||
如果玩法支持发布,还要补:
|
||||
|
||||
- public work code
|
||||
- public detail
|
||||
- publish share modal
|
||||
- like/remix(可选)
|
||||
|
||||
### Step 15: 处理登录态与草稿恢复
|
||||
|
||||
要考虑:
|
||||
|
||||
- 刷新恢复草稿
|
||||
- 退出登录清空私有状态
|
||||
- result/draft 缺失时回退
|
||||
- busy / generating / runtime 中断恢复
|
||||
|
||||
### Step 16: 补测试
|
||||
|
||||
至少覆盖:
|
||||
|
||||
- 入口展示
|
||||
- 类型分流
|
||||
- 工作台打开
|
||||
- session 创建
|
||||
- compile action
|
||||
- result 页切换
|
||||
- 发布后刷新作品架
|
||||
- runtime 进入与退出
|
||||
|
||||
## 最小改动清单
|
||||
|
||||
### 只做占位
|
||||
|
||||
只改:
|
||||
|
||||
- `src/config/newWorkEntryConfig.ts`
|
||||
|
||||
### 做到可进入工作台
|
||||
|
||||
至少改:
|
||||
|
||||
- `newWorkEntryConfig.ts`
|
||||
- `platformEntryTypes.ts`
|
||||
- `PlatformEntryFlowShellImpl.tsx`
|
||||
- 新玩法 service client
|
||||
- 新玩法工作台组件
|
||||
- shared contracts
|
||||
- 后端 API
|
||||
|
||||
### 做到完整闭环
|
||||
|
||||
还要补:
|
||||
|
||||
- result 页
|
||||
- runtime
|
||||
- works / gallery
|
||||
- public code
|
||||
- share
|
||||
- 作品架聚合
|
||||
- 测试
|
||||
|
||||
## 常见坑
|
||||
|
||||
1. 只加入口配置不够,类型分流和页面阶段也要补。
|
||||
2. `SelectionStage` 不扩展,前端无法安全切页。
|
||||
3. 新玩法如果要出现在作品架,必须改聚合逻辑,不只是加入口。
|
||||
4. 发布后不刷新 works/gallery,用户会看不到新作品。
|
||||
5. 如果走 SpacetimeDB,表结构变化要同步 migration 和绑定;`spacetime-client/src/module_bindings/` 通常是生成物,不要为了修编译或格式化而手改,优先改 module 源 schema/reducer/procedure 后重新生成。
|
||||
6. 做 analytics/tracking 这类 runtime 能力时,不要只补 API DTO;先在 `module-runtime` 写纯函数测试(例如 day/week/month/quarter/year bucket 聚合、scope/event 过滤),RED 后再补领域类型与聚合函数。
|
||||
7. 时间粒度聚合建议复用已有 date dimension 逻辑,把 daily stat 映射到 day/week/month/quarter/year bucket;bucket 输出要有稳定排序,并显式携带 `bucketKey`、`bucketStartDateKey`、`bucketEndDateKey`、`value`。
|
||||
8. 后端 shared-contracts 与前端 `packages/shared/src/contracts/runtime.ts` 要同步补 request/response/type union;admin-web 若有独立 `api/adminApiTypes.ts`,也要同步,避免共享包已更新但管理端本地类型缺失。
|
||||
9. 退出登录时要清空新玩法私有状态,避免串用户。
|
||||
10. 移动端入口卡片增多后要检查布局和滚动体验。
|
||||
|
||||
## 验证标准
|
||||
|
||||
一个玩法算真正接入成功,至少要满足:
|
||||
|
||||
- 入口能展示
|
||||
- 能进入对应工作台
|
||||
- 能创建 session
|
||||
- 能生成草稿
|
||||
- 能进入结果页
|
||||
- 能返回编辑
|
||||
- 如果需要,可试玩
|
||||
- 如果需要,可发布
|
||||
- 发布后能回到作品架 / 广场 / 分享链路
|
||||
|
||||
## 建议验证命令
|
||||
|
||||
按改动范围选择:
|
||||
|
||||
```bash
|
||||
# 后端 contracts / module-runtime / api-server
|
||||
cd server-rs
|
||||
cargo test -p shared-contracts
|
||||
cargo test -p module-runtime
|
||||
cargo check -p api-server
|
||||
|
||||
# SpacetimeDB schema/reducer/procedure 改动后,优先在有 CLI 的机器重新生成 bindings
|
||||
npm run spacetime:generate -- --rust-only
|
||||
|
||||
# 前端类型
|
||||
npm run admin-web:typecheck
|
||||
```
|
||||
|
||||
如果新增完整前端玩法闭环,还要按项目实际脚本补充 web typecheck、lint 或 Playwright/单元测试。
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: gpt-image-2-apimart
|
||||
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
||||
---
|
||||
|
||||
# gpt-image-2 VectorEngine
|
||||
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2-all` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the local task and decide whether the image is project-bound.
|
||||
2. Prefer `scripts/generate-template-samples.mjs` for puzzle template thumbnails or small batches.
|
||||
3. Run dry-run first:
|
||||
|
||||
```powershell
|
||||
node .codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs --dry-run
|
||||
```
|
||||
|
||||
4. If dry-run looks correct and the user asked for real assets, run live generation with a small limit:
|
||||
|
||||
```powershell
|
||||
node .codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs --live --limit 6
|
||||
```
|
||||
|
||||
5. Save final project assets under `public/` or another explicitly requested workspace path.
|
||||
6. Never print `VECTOR_ENGINE_API_KEY`. Report only whether configuration exists.
|
||||
|
||||
## Request Contract
|
||||
|
||||
The repository image path uses:
|
||||
|
||||
```text
|
||||
POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations
|
||||
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Default body:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-image-2-all",
|
||||
"prompt": "<prompt>",
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
}
|
||||
```
|
||||
|
||||
For a reference image, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"image": ["data:image/png;base64,..."]
|
||||
}
|
||||
```
|
||||
|
||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2-all currently returns synchronously; do not poll APIMart task endpoints.
|
||||
|
||||
## Environment
|
||||
|
||||
Load environment values from process env first, then `.env.secrets.local`, `.env.local`, and `.env.example`.
|
||||
|
||||
Required for live generation:
|
||||
|
||||
- `VECTOR_ENGINE_BASE_URL`
|
||||
- `VECTOR_ENGINE_API_KEY`
|
||||
|
||||
Optional:
|
||||
|
||||
- `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`
|
||||
|
||||
If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat.
|
||||
|
||||
## Prompt Rules
|
||||
|
||||
- Use Chinese prompts when generating project puzzle templates.
|
||||
- Keep template samples square, clear, image-only, and suitable for puzzle thumbnails.
|
||||
- Avoid text, watermark, UI chrome, buttons, borders, and tutorial overlays.
|
||||
- Include local negative constraints in the prompt instead of relying on provider-specific negative prompt fields.
|
||||
|
||||
## Resources
|
||||
|
||||
- `scripts/generate-template-samples.mjs`: dry-run or live-generate puzzle template sample thumbnails.
|
||||
- `assets/puzzle-template-prompts.json`: default prompt list consumed by the script.
|
||||
@@ -0,0 +1,7 @@
|
||||
interface:
|
||||
display_name: "GPT Image 2 VectorEngine"
|
||||
short_description: "Generate project thumbnails through VectorEngine"
|
||||
brand_color: "#10B981"
|
||||
default_prompt: "Use $gpt-image-2-apimart to dry-run or generate puzzle template thumbnails through VectorEngine."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"id": "couple-memory",
|
||||
"title": "情侣合照拼图",
|
||||
"prompt": "温暖自然光下的一对情侣纪念合照,城市咖啡馆窗边,桌面有花束和两杯热饮,人物神情自然,画面主体清晰,前中后景层次明确,适合切成拼图。"
|
||||
},
|
||||
{
|
||||
"id": "family-keepsake",
|
||||
"title": "家庭纪念拼图",
|
||||
"prompt": "三代家人在客厅沙发前的家庭纪念合照,柔和午后阳光,孩子抱着生日蛋糕,长辈微笑,画面温暖完整,细节丰富但不杂乱。"
|
||||
},
|
||||
{
|
||||
"id": "friends-party",
|
||||
"title": "朋友聚会拼图",
|
||||
"prompt": "朋友们在露台夜晚聚会,彩灯、桌上零食和举杯瞬间,人物分布有层次,中央焦点清楚,氛围轻松热闹,适合社交分享拼图。"
|
||||
},
|
||||
{
|
||||
"id": "festival-card",
|
||||
"title": "节日贺卡拼图",
|
||||
"prompt": "节日餐桌与礼物布置,暖色灯光、彩带、蜡烛和窗外烟花,画面像无字贺卡,主体集中,边角细节可辨,适合节日拼图。"
|
||||
},
|
||||
{
|
||||
"id": "knowledge-summary",
|
||||
"title": "知识总结拼图",
|
||||
"prompt": "一张无文字的知识学习主题插画,书桌上有打开的笔记本、便签、咖啡、台灯和思维导图式图形元素,构图整洁,重点明确,适合学习打卡拼图。"
|
||||
},
|
||||
{
|
||||
"id": "product-detail",
|
||||
"title": "商品细节拼图",
|
||||
"prompt": "精致商品静物展示,一只高质感香水瓶放在丝绸与花瓣之间,玻璃反光清晰,包装和材质细节丰富,背景干净,适合作为电商细节拼图。"
|
||||
},
|
||||
{
|
||||
"id": "healing-landscape",
|
||||
"title": "治愈风景拼图",
|
||||
"prompt": "治愈风景插画,清晨湖边、薄雾、远山、木栈道和一盏小灯,色彩柔和,层次清楚,局部元素可辨,适合长时间拼图。"
|
||||
},
|
||||
{
|
||||
"id": "cute-pet",
|
||||
"title": "宠物可爱拼图",
|
||||
"prompt": "一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。"
|
||||
},
|
||||
{
|
||||
"id": "hot-topic-poster",
|
||||
"title": "热点海报拼图",
|
||||
"prompt": "电影感热点海报风插画,雨夜街头、霓虹反光、奔跑的人影和远处光束,强烈视觉焦点,画面无文字,适合热点话题拼图。"
|
||||
},
|
||||
{
|
||||
"id": "event-invitation",
|
||||
"title": "活动邀请拼图",
|
||||
"prompt": "活动邀请主题插画,展厅入口、花艺装置、签到台和柔和灯带,人群剪影自然分布,画面高级干净,无文字,适合活动预热拼图。"
|
||||
},
|
||||
{
|
||||
"id": "daily-challenge",
|
||||
"title": "每日挑战拼图",
|
||||
"prompt": "每日挑战主题插画,清爽桌面上摆放相机、明信片、计时器和小奖章,色彩明亮,构图有趣,细节可拆解,适合平台每日拼图。"
|
||||
},
|
||||
{
|
||||
"id": "children-learning",
|
||||
"title": "儿童认知拼图",
|
||||
"prompt": "儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字,适合儿童教育拼图。"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,321 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
|
||||
const promptsPath = path.join(
|
||||
skillRoot,
|
||||
'assets',
|
||||
'puzzle-template-prompts.json',
|
||||
);
|
||||
const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates');
|
||||
const defaultTimeoutMs = 180000;
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
const raw = process.argv[index];
|
||||
if (raw.startsWith('--')) {
|
||||
const next = process.argv[index + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
args.set(raw, next);
|
||||
index += 1;
|
||||
} else {
|
||||
args.set(raw, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readDotenv(fileName) {
|
||||
const filePath = path.join(repoRoot, fileName);
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const values = {};
|
||||
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
let value = match[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
values[match[1]] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function resolveEnv() {
|
||||
const loaded = {
|
||||
...readDotenv('.env.example'),
|
||||
...readDotenv('.env.local'),
|
||||
...readDotenv('.env.secrets.local'),
|
||||
...process.env,
|
||||
};
|
||||
return {
|
||||
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
|
||||
.trim()
|
||||
.replace(/\/+$/u, ''),
|
||||
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||
timeoutMs: Number.parseInt(
|
||||
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||
10,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVectorEngineImagesGenerationUrl(baseUrl) {
|
||||
return baseUrl.endsWith('/v1')
|
||||
? `${baseUrl}/images/generations`
|
||||
: `${baseUrl}/v1/images/generations`;
|
||||
}
|
||||
|
||||
function buildPrompt(template) {
|
||||
return [
|
||||
'请生成一张高清 1:1 方形插画,用作拼图创作模板样例图。',
|
||||
`画面主体:${template.prompt}`,
|
||||
'要求:主体清晰集中,前中后景层次明确,边角有可辨识细节,适合切成 3x3 到 7x7 拼图。',
|
||||
'避免:文字、水印、边框、按钮、UI 元素、教程标注、低清晰度、过度模糊、杂乱构图。',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function collectStringsByKey(value, targetKey, output) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (key === targetKey) {
|
||||
if (typeof nested === 'string' && nested.trim()) {
|
||||
output.push(nested.trim());
|
||||
}
|
||||
if (Array.isArray(nested)) {
|
||||
nested.forEach((entry) => {
|
||||
if (typeof entry === 'string' && entry.trim()) {
|
||||
output.push(entry.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
collectStringsByKey(nested, targetKey, output);
|
||||
}
|
||||
}
|
||||
|
||||
function extractImageUrls(payload) {
|
||||
const urls = [];
|
||||
collectStringsByKey(payload, 'url', urls);
|
||||
collectStringsByKey(payload, 'image', urls);
|
||||
collectStringsByKey(payload, 'image_url', urls);
|
||||
return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url));
|
||||
}
|
||||
|
||||
function extractBase64Images(payload) {
|
||||
const values = [];
|
||||
collectStringsByKey(payload, 'b64_json', values);
|
||||
return values;
|
||||
}
|
||||
|
||||
function inferExtensionFromContentType(contentType) {
|
||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalized === 'image/png') {
|
||||
return 'png';
|
||||
}
|
||||
if (normalized === 'image/webp') {
|
||||
return 'webp';
|
||||
}
|
||||
if (normalized === 'image/gif') {
|
||||
return 'gif';
|
||||
}
|
||||
return 'jpg';
|
||||
}
|
||||
|
||||
function inferExtensionFromBytes(bytes) {
|
||||
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) {
|
||||
return 'png';
|
||||
}
|
||||
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
|
||||
return 'jpg';
|
||||
}
|
||||
if (
|
||||
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
return 'png';
|
||||
}
|
||||
|
||||
async function fetchJson(url, options, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadUrl(url, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { signal: abortController.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`download ${response.status}`);
|
||||
}
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
bytes,
|
||||
extension: inferExtensionFromContentType(
|
||||
response.headers.get('content-type') || 'image/jpeg',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateOne(env, template, outDir) {
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2-all',
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
|
||||
const urls = extractImageUrls(payload);
|
||||
const b64Images = extractBase64Images(payload);
|
||||
|
||||
let image;
|
||||
if (urls[0]) {
|
||||
image = await downloadUrl(urls[0], env.timeoutMs);
|
||||
} else if (b64Images[0]) {
|
||||
const bytes = Buffer.from(b64Images[0], 'base64');
|
||||
image = {
|
||||
bytes,
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
};
|
||||
} else {
|
||||
throw new Error(`VectorEngine returned no image for ${template.id}`);
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outputPath = path.join(outDir, `${template.id}.${image.extension}`);
|
||||
writeFileSync(outputPath, image.bytes);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
const dryRun = args.has('--dry-run') || !args.has('--live');
|
||||
const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir));
|
||||
const limit = Number.parseInt(String(args.get('--limit') || '0'), 10);
|
||||
const onlyIds = String(args.get('--only') || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const templates = JSON.parse(readFileSync(promptsPath, 'utf8')).filter(
|
||||
(template) => !onlyIds.length || onlyIds.includes(template.id),
|
||||
);
|
||||
const selectedTemplates = limit > 0 ? templates.slice(0, limit) : templates;
|
||||
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'dry-run',
|
||||
outDir,
|
||||
count: selectedTemplates.length,
|
||||
requests: selectedTemplates.map((template) => ({
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
body: {
|
||||
model: 'gpt-image-2-all',
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
},
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const env = resolveEnv();
|
||||
if (!env.baseUrl || !env.apiKey) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||
hasBaseUrl: Boolean(env.baseUrl),
|
||||
hasApiKey: Boolean(env.apiKey),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generated = [];
|
||||
for (const template of selectedTemplates) {
|
||||
console.log(`Generating ${template.id}...`);
|
||||
generated.push(await generateOne(env, template, outDir));
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
count: generated.length,
|
||||
files: generated,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -44,8 +44,8 @@ spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings
|
||||
### Publishing & Deployment
|
||||
|
||||
```bash
|
||||
# Publish to Maincloud (default)
|
||||
spacetime publish my-database --yes
|
||||
# Publish to an explicit server
|
||||
spacetime publish my-database --server http://127.0.0.1:3101 --yes
|
||||
|
||||
# Publish to local server
|
||||
spacetime publish my-database --server local --yes
|
||||
@@ -133,8 +133,8 @@ spacetime logout
|
||||
|
||||
| Name | URL | Description |
|
||||
|------|-----|-------------|
|
||||
| `maincloud` | `https://maincloud.spacetimedb.com` | Production cloud (default) |
|
||||
| `local` | `http://127.0.0.1:3000` | Local development server |
|
||||
| `dev` | `http://127.0.0.1:3101` | Genarrative local development server |
|
||||
|
||||
## Common Workflows
|
||||
|
||||
@@ -224,6 +224,6 @@ rustup target add wasm32-unknown-unknown
|
||||
## Notes
|
||||
|
||||
- Many commands are marked UNSTABLE and may change
|
||||
- Default server is `maincloud` unless configured otherwise
|
||||
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on the CLI default
|
||||
- Use `--yes` flag in scripts to avoid interactive prompts
|
||||
- Dev mode watches files and auto-rebuilds on changes
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 微信小程序 web-view 登录配置。
|
||||
# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。
|
||||
WECHAT_MINI_PROGRAM_APP_ID=""
|
||||
WECHAT_MINI_PROGRAM_APP_SECRET=""
|
||||
WECHAT_JS_CODE_SESSION_ENDPOINT=""
|
||||
@@ -103,6 +103,9 @@ WECHAT_REDIRECT_PATH="/"
|
||||
WECHAT_AUTHORIZE_ENDPOINT="https://open.weixin.qq.com/connect/qrconnect"
|
||||
WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||
WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo"
|
||||
WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session"
|
||||
WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token"
|
||||
WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber"
|
||||
WECHAT_STATE_TTL_MINUTES="15"
|
||||
WECHAT_MOCK_USER_ID="wx-mock-user"
|
||||
WECHAT_MOCK_UNION_ID="wx-mock-union"
|
||||
@@ -119,6 +122,11 @@ RPG_LLM_WEB_SEARCH_ENABLED="true"
|
||||
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
|
||||
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
|
||||
|
||||
# Server-side APIMart image generation config for optional puzzle image models.
|
||||
APIMART_BASE_URL="https://api.apimart.ai/v1"
|
||||
APIMART_API_KEY="YOUR_APIMART_API_KEY"
|
||||
APIMART_IMAGE_REQUEST_TIMEOUT_MS="180000"
|
||||
|
||||
# 阿里云 OSS 配置。
|
||||
# Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量,
|
||||
# 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。
|
||||
@@ -168,6 +176,10 @@ VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS="150000"
|
||||
# Keep this off by default for cleaner logs.
|
||||
VITE_LLM_DEBUG_LOG="false"
|
||||
|
||||
# Optional: global frontend debug mode. When empty, it follows Vite dev mode.
|
||||
# Set to "true" to expose local diagnostic panels, or "false" to hide them.
|
||||
VITE_DEBUG_MODE=""
|
||||
|
||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||
# the fixed `bge-large-zh` embedding model.
|
||||
|
||||
+17
-9
@@ -34,6 +34,19 @@ ALIYUN_SMS_RETURN_VERIFY_CODE="false"
|
||||
|
||||
VITE_AUTH_ALLOW_DEV_GUEST="false"
|
||||
|
||||
|
||||
# api-server 非公共模型与可变网关配置(从 config.rs 默认值迁移到本地环境变量)
|
||||
GENARRATIVE_LLM_PROVIDER="ark"
|
||||
GENARRATIVE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
|
||||
GENARRATIVE_LLM_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e"
|
||||
GENARRATIVE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
|
||||
APIMART_BASE_URL="https://api.apimart.ai/v1"
|
||||
APIMART_API_KEY=""
|
||||
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
|
||||
DASHSCOPE_SCENE_IMAGE_MODEL="wan2.2-t2i-flash"
|
||||
DASHSCOPE_REFERENCE_IMAGE_MODEL="qwen-image-2.0"
|
||||
DASHSCOPE_COVER_IMAGE_MODEL="wan2.2-t2i-flash"
|
||||
ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS=420000
|
||||
# 启用服务端大模型调试日志(记录所有输入输出)
|
||||
LLM_DEBUG_LOG="true"
|
||||
|
||||
@@ -43,22 +56,17 @@ LLM_DEBUG_LOG="true"
|
||||
ALIYUN_OSS_BUCKET="xushi-dev"
|
||||
ALIYUN_OSS_REGION="oss-cn-beijing"
|
||||
ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com"
|
||||
ALIYUN_OSS_ACCESS_KEY_ID="LTAI5t7aiyw6uDFW4miJvU8f"
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
|
||||
|
||||
# Local Rust backend target for Vite dev proxy.
|
||||
RUST_SERVER_TARGET="http://127.0.0.1:3100"
|
||||
GENARRATIVE_API_TARGET="http://127.0.0.1:3100"
|
||||
RUST_SERVER_TARGET="http://127.0.0.1:8082"
|
||||
GENARRATIVE_API_TARGET="http://127.0.0.1:8082"
|
||||
GENARRATIVE_API_PORT="8082"
|
||||
|
||||
GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101"
|
||||
GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr"
|
||||
GENARRATIVE_SPACETIME_TOKEN=""
|
||||
|
||||
GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com"
|
||||
GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr"
|
||||
GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUsyN05YUjBaQkRUVEVCNlFQQjFXNzU2MiIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NzU1NTQ1NiwiZXhwIjoxODQwNjI3NDU2fQ.iy5qN-3lGPQnkya-wsABtqEgRk1VM2XGxTfxuLV5-eTMfX8cR20sWSx7pnoZcLEwYOkz6cEOb4krhMJmTeBax9Z114o_iwISau3wjjHbeKL9or-039zfYfKb3TtJo3_DZaJSu-ECcMZNl4P1zLmtoRSwl-_AMET4sGzPw0_qR-e49_QGDJz1EEhr7aphybl1xCejCebM8XiJjaRz48vL7-lkwBl90uP-0h7Xx8ToTT2h1egmlcYAvaJalVLHIQqzyYxPUT_Zw9TW7VYExZLhJWdGpQzEm0aXZ2fbch9qVrKpZP2xQ9YjppuLxUFFJeQwhmFf6yc67s6J7LqNvL2-ZA"
|
||||
|
||||
# admin
|
||||
GENARRATIVE_ADMIN_USERNAME=admin
|
||||
GENARRATIVE_ADMIN_PASSWORD=123456
|
||||
ADMIN_API_TARGET=http://127.0.0.1:8082
|
||||
ADMIN_API_TARGET=http://127.0.0.1:3100
|
||||
|
||||
@@ -37,6 +37,12 @@ module.exports = {
|
||||
'simple-import-sort/exports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/match3d-runtime/Match3DPhysicsBoard.tsx'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
|
||||
+11
@@ -29,3 +29,14 @@ temp*build*/
|
||||
/public/generated-characters
|
||||
/.codex-temp
|
||||
/target/
|
||||
/logs
|
||||
.worktrees/
|
||||
.env.secrets.local
|
||||
|
||||
# Local load-test data extracted from private migration files
|
||||
scripts/loadtest/data/*.local.json
|
||||
|
||||
# Local load-test run artifacts
|
||||
scripts/loadtest/data/k6-*.log
|
||||
scripts/loadtest/data/k6-*summary*.md
|
||||
scripts/loadtest/data/latest-*-prefix.txt
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Genarrative 团队 Hermes 共享记忆
|
||||
|
||||
本目录用于在仓库内共享团队级 Hermes 上下文,供 3 名开发人员在各自本地 Hermes 中读取、更新和同步。
|
||||
|
||||
## 使用原则
|
||||
|
||||
- `.hermes/` 中只保存可以进入 Git 的团队共享内容。
|
||||
- 不提交个人配置、API Key、会话转录、模型密钥、本地路径密钥等敏感内容。
|
||||
- 个人 Hermes 的 `~/.hermes/config.yaml`、`~/.hermes/.env`、`~/.hermes/sessions/` 不应复制到本仓库。
|
||||
- 开发前先阅读本目录下与任务相关的记忆文件;开发后如产生稳定知识,更新对应文档。
|
||||
- 若本目录内容与 `docs/` 或代码事实冲突,以当前代码和最新 `docs/` 为准,并同步修正过期记忆。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.hermes/
|
||||
├─ README.md # 本说明
|
||||
├─ shared-memory/
|
||||
│ ├─ project-overview.md # 项目概览与当前技术路线
|
||||
│ ├─ team-conventions.md # 团队协作约定
|
||||
│ ├─ development-workflow.md # 开发、测试、提交流程
|
||||
│ ├─ document-map.md # README / AGENTS / docs 阅读索引
|
||||
│ ├─ decision-log.md # 长期决策记录
|
||||
│ ├─ pitfalls.md # 踩坑与排障记录
|
||||
│ └─ handoff-template.md # 任务交接模板
|
||||
├─ plans/ # 阶段性计划与实施方案
|
||||
├─ skills/ # 仓库级 Hermes skills
|
||||
└─ plugins/ # 仓库级 Hermes plugins(需显式启用项目 plugin)
|
||||
```
|
||||
|
||||
## 仓库级 Plugins
|
||||
|
||||
本仓库可共享的 Hermes plugin 放在 `.hermes/plugins/<plugin-name>/`。当前已包含:
|
||||
|
||||
- `.hermes/plugins/game-studio/`:浏览器游戏设计、原型、2D/3D 技术栈、素材管线与 playtest 相关工作流。
|
||||
|
||||
Hermes 的项目级 plugin 默认不会自动加载。团队成员拉取仓库后,如需使用本仓库内 plugin,请在仓库根目录启动 Hermes 前设置:
|
||||
|
||||
```bash
|
||||
export HERMES_ENABLE_PROJECT_PLUGINS=1
|
||||
```
|
||||
|
||||
然后确认当前 Hermes 配置的 `plugins.enabled` 中包含 `game-studio`。如果成员本机尚未启用过该 plugin,当前 Hermes 的 `hermes plugins enable` 只识别用户级或内置 plugin,可能不会识别项目级 plugin;可用以下命令写入个人配置:
|
||||
|
||||
```bash
|
||||
python - <<'PY'
|
||||
from hermes_cli.config import load_config, save_config
|
||||
config = load_config()
|
||||
plugins = config.setdefault('plugins', {})
|
||||
enabled = set(plugins.get('enabled') or [])
|
||||
disabled = set(plugins.get('disabled') or [])
|
||||
enabled.add('game-studio')
|
||||
disabled.discard('game-studio')
|
||||
plugins['enabled'] = sorted(enabled)
|
||||
plugins['disabled'] = sorted(disabled)
|
||||
save_config(config)
|
||||
PY
|
||||
```
|
||||
|
||||
启用后重新进入一个新 Hermes 会话。`hermes plugins list` 当前主要展示内置和用户级 plugin,未必列出项目级 plugin;如需验证项目级扫描,可在仓库根目录运行:
|
||||
|
||||
```bash
|
||||
HERMES_ENABLE_PROJECT_PLUGINS=1 HERMES_PLUGINS_DEBUG=1 hermes chat -q "请读取 game-studio:game-studio skill 并概括它的用途"
|
||||
```
|
||||
|
||||
该 plugin 注册的是带命名空间的 plugin skills,可用类似 `game-studio:phaser-2d-game` 的名称显式加载。
|
||||
|
||||
## 推荐给 Hermes 的启动提示
|
||||
|
||||
在本仓库中开始复杂任务时,可以先对 Hermes 说:
|
||||
|
||||
```text
|
||||
请先读取 AGENTS.md 以及 .hermes/shared-memory/ 下与本任务相关的团队共享记忆,再开始分析。若任务完成后产生稳定项目知识,请更新 .hermes/shared-memory/ 对应文件。
|
||||
```
|
||||
|
||||
## 需要沉淀到这里的内容
|
||||
|
||||
- 长期有效的架构约定
|
||||
- 反复会用到的本地开发/测试流程
|
||||
- 已确认的接口契约或模块边界
|
||||
- 重要技术决策及原因
|
||||
- 踩坑、排障方式、验证命令
|
||||
- 团队协作规则和任务交接规范
|
||||
|
||||
## 不应沉淀到这里的内容
|
||||
|
||||
- API Key、Token、Cookie、私有密钥
|
||||
- 个人账号、个人本地绝对路径、个人隐私信息
|
||||
- 大段临时聊天记录
|
||||
- 尚未确认的一次性猜测
|
||||
- 构建产物、日志、缓存、数据库 dump
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
# 邀请码有效期与后台二次确认实施计划
|
||||
|
||||
> **For Hermes:** 按 plan 模式,仅输出并保存实施计划,不直接改业务代码。
|
||||
|
||||
**Goal:** 为邀请码新增开始日期与截止日期,并让后台所有会修改数据的操作在提交前增加二次确认,降低误操作风险。
|
||||
|
||||
**Architecture:**
|
||||
邀请码仍作为“用户稳定邀请身份码”保留,不做停用删除;在数据层增加 `starts_at` / `expires_at`,前台填写邀请码时按时间窗校验,后台列表与编辑页展示状态。后台所有写操作统一先弹二次确认,再真正调用 API,避免对兑换码、邀请码、任务配置等管理动作误触发。
|
||||
|
||||
**Tech Stack:**
|
||||
Rust / SpacetimeDB / Axum / shared-contracts / TS + React 的 admin-web。
|
||||
|
||||
---
|
||||
|
||||
## 当前上下文
|
||||
|
||||
- 邀请码当前只有 `user_id`、`invite_code`、`metadata_json`、`created_at`、`updated_at`,没有状态字段。
|
||||
- 目前后台存在邀请码管理入口,但没有停用能力,也没有有效期概念。
|
||||
- 邀请码用于 `redeem_profile_referral_invite_code` 时的实时校验,适合增加“时间窗”而不是“禁用删除”。
|
||||
- 后台已存在兑换码、任务配置等可写操作;本次要求把所有后台操作统一加二次确认,包括新增、编辑、禁用、删除等写入口。
|
||||
|
||||
---
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **邀请码不做软删除**:保留历史记录和邀请链路。
|
||||
2. **有效期由时间窗推导**:
|
||||
- `starts_at` 为空表示立即生效。
|
||||
- `expires_at` 为空表示长期有效。
|
||||
3. **前台只拒绝新绑定**:已绑定关系不回溯修改。
|
||||
4. **后台写操作统一确认**:所有会触发 POST / PATCH / DELETE 的管理动作,在真正提交前必须弹出二次确认。
|
||||
5. **尽量少改接口语义**:优先在现有 admin upsert/list 体系内扩展字段,而不是新增一套并行 API。
|
||||
|
||||
---
|
||||
|
||||
## 方案概要
|
||||
|
||||
### 邀请码时间窗
|
||||
|
||||
新增字段:
|
||||
- `starts_at: Option<Timestamp>`
|
||||
- `expires_at: Option<Timestamp>`
|
||||
|
||||
校验规则:
|
||||
- 当前时间 `< starts_at`:返回“邀请码未生效”
|
||||
- 当前时间 `>= expires_at`:返回“邀请码已过期”
|
||||
- 其他情况允许填写
|
||||
|
||||
建议把状态展示为:
|
||||
- 未生效
|
||||
- 有效
|
||||
- 已过期
|
||||
- 长期有效(两个字段都为空或仅无截止)
|
||||
|
||||
### 后台二次确认
|
||||
|
||||
对 admin-web 所有管理动作统一加确认弹窗/对话框,至少覆盖:
|
||||
- 兑换码新增/更新
|
||||
- 兑换码停用
|
||||
- 邀请码新增/更新
|
||||
- 任务配置新增/更新
|
||||
- 任务配置停用
|
||||
- 其他后续新增的后台写操作
|
||||
|
||||
确认文案要求:
|
||||
- 显示对象标识(如 code / inviteCode / taskId)
|
||||
- 显示操作类型(新增 / 更新 / 停用)
|
||||
- 明确提醒“该操作会立即影响线上数据”
|
||||
- 允许取消返回,不调用 API
|
||||
|
||||
---
|
||||
|
||||
## 预期修改文件
|
||||
|
||||
### 1. 服务端领域与契约
|
||||
- `server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- `ProfileInviteCode` 表结构新增开始/截止字段
|
||||
- 邀请码 upsert 逻辑写入时间窗
|
||||
- 邀请码 redeem 逻辑增加时间窗校验
|
||||
- 邀请中心快照补充时间窗/状态
|
||||
- `server-rs/crates/spacetime-module/src/migration.rs`
|
||||
- 兼容旧表数据,给旧邀请码补默认空值
|
||||
- `server-rs/crates/shared-contracts/src/runtime*.rs` 或对应生成/手写契约文件
|
||||
- `AdminUpsertProfileInviteCodeRequest` 扩展字段
|
||||
- `ProfileInviteCodeAdminResponse` 扩展字段
|
||||
- 如需要,增加时间窗相关状态枚举或派生字段
|
||||
- `server-rs/crates/spacetime-client/src/module_bindings/*`
|
||||
- 重新生成 bindings
|
||||
- mapper 补齐新字段
|
||||
|
||||
### 2. API Server
|
||||
- `server-rs/crates/api-server/src/runtime_profile.rs`
|
||||
- 接收/转发邀请码时间窗参数
|
||||
- 返回新增字段给后台
|
||||
- 如需要,调整校验错误文案
|
||||
- `server-rs/crates/api-server/src/app.rs`
|
||||
- 若有新路由或错误码需挂接,在此统一登记
|
||||
|
||||
### 3. Admin Web
|
||||
- `apps/admin-web/src/api/adminApiTypes.ts`
|
||||
- 增加邀请码时间窗字段
|
||||
- 如有需要,增加后台操作请求结构字段
|
||||
- `apps/admin-web/src/api/adminApiClient.ts`
|
||||
- 透传新的请求/响应字段
|
||||
- `apps/admin-web/src/app/adminRoutes.ts`
|
||||
- 不一定需要改,但如果新增独立页面/子面板,需要在此登记
|
||||
- `apps/admin-web/src/styles/admin.css`
|
||||
- 确认弹窗与时间窗展示样式
|
||||
- `apps/admin-web/src/**` 实际管理页面组件
|
||||
- 邀请码编辑表单
|
||||
- 邀请码列表状态展示
|
||||
- 所有写操作前的二次确认弹窗
|
||||
|
||||
### 4. 文档
|
||||
- `docs/technical/` 或 `docs/design/`
|
||||
- 补一份邀请码时间窗与后台确认交互说明
|
||||
- 如现有文档已经覆盖后台管理规范,则优先补充现有文档,不重复造新说明页
|
||||
|
||||
---
|
||||
|
||||
## 分步实施计划
|
||||
|
||||
### Task 1: 明确数据模型与契约扩展
|
||||
**Objective:** 定义邀请码开始/截止日期字段及其在响应中的展示方式。
|
||||
|
||||
**要点:**
|
||||
- 确认字段名采用 `starts_at` / `expires_at`,避免与现有字段语义冲突。
|
||||
- 确认时间类型统一用 `Timestamp` / 毫秒微秒整数转换策略。
|
||||
- 明确返回给后台的字段是否需要附带派生状态(如 `status`)。
|
||||
|
||||
**产出:**
|
||||
- 契约字段定义
|
||||
- 状态枚举/派生规则
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 更新 SpacetimeDB 表与迁移
|
||||
**Objective:** 让邀请码表可保存有效期,并兼容旧数据。
|
||||
|
||||
**要点:**
|
||||
- 修改 `ProfileInviteCode` 表结构。
|
||||
- 更新迁移逻辑,旧记录默认无开始/截止。
|
||||
- 检查是否需要补充索引或查询辅助字段。
|
||||
|
||||
**验证:**
|
||||
- 旧数据能正常读取。
|
||||
- 新数据能写入开始/截止。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 实现邀请填写时的时间窗校验
|
||||
**Objective:** 在邀请码被填写时正确拒绝未生效或已过期的邀请码。
|
||||
|
||||
**要点:**
|
||||
- 在 `redeem_profile_referral_invite_code_record` 内增加开始/截止校验。
|
||||
- 保持“自己的邀请码不能填”“邀请码不存在”等原有错误优先级清晰。
|
||||
- 保留历史绑定关系不受影响。
|
||||
|
||||
**验证:**
|
||||
- 未到开始时间时返回明确错误。
|
||||
- 超过截止时间时返回明确错误。
|
||||
- 正常区间可绑定成功。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 扩展后台邀请码管理接口
|
||||
**Objective:** 让后台可以创建/修改邀请码时间窗,并在列表中查看状态。
|
||||
|
||||
**要点:**
|
||||
- 扩展 `AdminUpsertProfileInviteCodeRequest`。
|
||||
- 扩展 `ProfileInviteCodeAdminResponse`。
|
||||
- `api-server` 接口负责接收新字段并转发。
|
||||
- 列表接口返回可读时间字段与状态。
|
||||
|
||||
**验证:**
|
||||
- 后台表单提交后,返回结果包含时间窗信息。
|
||||
- 列表页能看到状态与时间。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 给后台所有写操作加二次确认
|
||||
**Objective:** 统一拦截所有后台写动作,避免误点直接生效。
|
||||
|
||||
**覆盖范围建议:**
|
||||
- 邀请码新增/更新
|
||||
- 兑换码新增/更新/停用
|
||||
- 任务配置新增/更新/停用
|
||||
- 后续新增的管理写操作
|
||||
|
||||
**实现要求:**
|
||||
- 在真正调用 API 之前弹出确认框。
|
||||
- 确认框需要展示对象名、操作类型、影响范围。
|
||||
- 取消后不发送请求。
|
||||
- 尽量抽象出通用确认组件/通用 action 包装函数,避免每个页面重复写。
|
||||
|
||||
**验证:**
|
||||
- 点击“保存”不会直接提交,需先确认。
|
||||
- 点击“取消”不会发请求。
|
||||
- 所有后台写入口行为一致。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 补充文档与交互说明
|
||||
**Objective:** 把新规则写进项目文档,避免后续实现偏差。
|
||||
|
||||
**要点:**
|
||||
- 记录邀请码时间窗语义。
|
||||
- 记录后台二次确认规范。
|
||||
- 说明哪些动作属于“必须确认”的写操作。
|
||||
|
||||
---
|
||||
|
||||
## 测试与验证
|
||||
|
||||
### 服务端
|
||||
- 邀请码时间窗单测 / 集成测试
|
||||
- 邀请码 redeem 流程回归测试
|
||||
- 旧数据兼容测试
|
||||
|
||||
### API / 前端
|
||||
- 管理后台列表展示正确
|
||||
- 表单提交能回传新字段
|
||||
- 二次确认取消后不请求接口
|
||||
- 二次确认确认后正常提交
|
||||
|
||||
### 推荐验证命令
|
||||
- 视项目现有脚本执行对应后端测试
|
||||
- 前端按 admin-web 构建/测试脚本验证
|
||||
- 如涉及生成绑定,先确认生成产物无漏字段
|
||||
|
||||
---
|
||||
|
||||
## 风险与权衡
|
||||
|
||||
1. **时间字段格式不统一**
|
||||
- 风险:前后端对时间单位理解不一致。
|
||||
- 处理:在契约层明确是 ISO 字符串还是微秒整数,并全链路统一。
|
||||
|
||||
2. **后台“所有操作”范围过大**
|
||||
- 风险:遗漏某些写入口。
|
||||
- 处理:先枚举现有写 API,再做统一确认封装。
|
||||
|
||||
3. **邀请码过期后历史链接解释成本**
|
||||
- 风险:用户误以为历史邀请码失效影响已绑定关系。
|
||||
- 处理:文案明确“仅影响新填写,不影响已绑定记录”。
|
||||
|
||||
4. **契约与生成绑定联动较多**
|
||||
- 风险:字段变更后生成文件数量较多。
|
||||
- 处理:先改源契约与服务端,再统一重生成 bindings。
|
||||
|
||||
---
|
||||
|
||||
## 待确认问题
|
||||
|
||||
1. `starts_at` / `expires_at` 在接口里要返回 **ISO 字符串** 还是 **微秒整数**?
|
||||
2. 后台二次确认是否统一用一个全局弹窗组件,还是页面级本地实现?
|
||||
3. 邀请码列表是否需要直接展示“状态标签”还是只展示时间字段由前端推导?
|
||||
4. 现有后台所有写操作里,是否还要覆盖调试类接口,还是仅覆盖业务管理接口?
|
||||
|
||||
---
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
1. 先确认时间字段格式与确认弹窗范围。
|
||||
2. 再改服务端契约与迁移。
|
||||
3. 再改 redeem 校验与后台接口。
|
||||
4. 最后统一改 admin-web 的二次确认与表单展示。
|
||||
|
||||
---
|
||||
|
||||
**结论:** 这是一个适合分阶段落地的改动,建议先做“邀请码时间窗 + 后台统一二次确认”的基础能力,再补交互细节。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
# K6 作品列表压测计划(使用 spacetime-migration-7.json 作为数据源)
|
||||
|
||||
## 目标
|
||||
|
||||
使用 K6 对 Genarrative 的“作品列表”相关接口进行压测,并将用户提供的 `spacetime-migration-7.json` 作为压测数据源;数据处理时**只导入作品列表相关数据**,不导入用户、会话、钱包、埋点、运行存档等非作品表,避免把敏感或无关数据带入压测环境。
|
||||
|
||||
## 当前上下文
|
||||
|
||||
- 工作区:`/c/proj/Genarrative`
|
||||
- 原始迁移文件:`C:\Users\DSK\AppData\Local\hermes\cache\documents\doc_150e84029b2d_spacetime-migration-7.json`
|
||||
- 已确认原始迁移文件结构:
|
||||
- `schema_version = 1`
|
||||
- `tables = 53`
|
||||
- 作品相关表中当前有数据的重点表:
|
||||
- `puzzle_work_profile`:80 行
|
||||
- `custom_world_profile`:1 行
|
||||
- `match3d_work_profile`:0 行
|
||||
- `big_fish_*`:当前样本中相关表为 0 行
|
||||
- 原始文件还包含 `user_account`、`auth_identity`、`refresh_session`、`profile_wallet_ledger`、`asset_object`、运行记录等数据,压测导入时必须过滤。
|
||||
- 当前仓库未发现现成 K6 脚本或 `k6` 相关文件,需要新增压测脚本与数据提取脚本。
|
||||
- `package.json` 当前有 `dev/dev:rust/test/check` 等脚本,未发现 K6 npm script。
|
||||
|
||||
## 范围约束
|
||||
|
||||
### 本次只导入/使用
|
||||
|
||||
1. 作品列表表:
|
||||
- `puzzle_work_profile`
|
||||
- `custom_world_profile`
|
||||
- 后续若接口覆盖其他玩法,可扩展:
|
||||
- `match3d_work_profile`
|
||||
- `square_hole_work_profile`(以实际 SpacetimeDB 表名为准)
|
||||
- `big_fish_work_profile`(以实际 SpacetimeDB 表名为准)
|
||||
- `visual_novel_work_profile`(以实际 SpacetimeDB 表名为准)
|
||||
2. 为作品列表卡片展示所需的最小字段:
|
||||
- 稳定 ID:`profile_id`、`work_id` 或 `public_work_code`
|
||||
- 标题:`work_title` / `level_name` / `world_name`
|
||||
- 描述:`work_description` / `summary` / `summary_text` / `subtitle`
|
||||
- 作者:`owner_user_id`、`author_display_name`、`author_public_user_code`
|
||||
- 封面:`cover_image_src`、`cover_asset_id`(如果接口只返回 asset id,则压测阶段不额外导入二进制 asset)
|
||||
- 状态与计数:`publication_status`、`published_at`、`play_count`、`like_count`、`remix_count`
|
||||
- 作品内容摘要:`levels_json`、`profile_payload_json`、`theme_tags_json` 等列表渲染或进入作品详情可能需要的 JSON 字段
|
||||
|
||||
### 本次不导入/不使用
|
||||
|
||||
- 认证与账号:`user_account`、`auth_identity`、`refresh_session`、`auth_store_snapshot`
|
||||
- 用户资产与钱包:`profile_wallet_ledger`、`profile_dashboard_state`、`profile_redeem_*`、`profile_invite_*`
|
||||
- 游玩历史/存档/运行态:`profile_played_world`、`public_work_play_daily_stat`、`puzzle_runtime_run`、`profile_save_archive`、`runtime_snapshot` 等
|
||||
- AI 任务过程:`ai_task`、`ai_task_stage`、`ai_text_chunk`
|
||||
- asset 二进制与绑定:`asset_object`、`asset_entity_binding`,除非后续确认作品列表接口强依赖它们;即便需要,也只导入作品列表封面所需的最小 metadata,不导入原始大对象。
|
||||
|
||||
## 推荐目录与文件
|
||||
|
||||
建议新增:
|
||||
|
||||
```text
|
||||
.hermes/plans/2026-05-11_195214-k6-works-list-load-test-plan.md # 本计划
|
||||
scripts/loadtest/extract-works-list-data.mjs # 从迁移文件提取作品列表数据
|
||||
scripts/loadtest/k6-works-list.js # K6 压测脚本
|
||||
scripts/loadtest/data/works-list.sample.json # 过滤后的样例数据(不要提交敏感原始迁移全量)
|
||||
scripts/loadtest/README.md # 执行说明与指标阈值
|
||||
```
|
||||
|
||||
可选新增 npm scripts:
|
||||
|
||||
```json
|
||||
{
|
||||
"loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs",
|
||||
"loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js"
|
||||
}
|
||||
```
|
||||
|
||||
## 数据提取方案
|
||||
|
||||
### 输入
|
||||
|
||||
默认读取:
|
||||
|
||||
```bash
|
||||
node scripts/loadtest/extract-works-list-data.mjs \
|
||||
--input "C:/Users/DSK/AppData/Local/hermes/cache/documents/doc_150e84029b2d_spacetime-migration-7.json" \
|
||||
--output scripts/loadtest/data/works-list.local.json
|
||||
```
|
||||
|
||||
### 输出结构
|
||||
|
||||
建议输出为 K6 直接可读的 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "spacetime-migration-7.json",
|
||||
"generatedAt": "<iso datetime>",
|
||||
"tables": {
|
||||
"puzzle_work_profile": [
|
||||
{
|
||||
"profile_id": "...",
|
||||
"work_id": "...",
|
||||
"owner_user_id": "...",
|
||||
"work_title": "...",
|
||||
"work_description": "...",
|
||||
"publication_status": "Published",
|
||||
"published_at": { "__timestamp_micros_since_unix_epoch__": 0 },
|
||||
"play_count": 0,
|
||||
"like_count": 0,
|
||||
"levels_json": "..."
|
||||
}
|
||||
],
|
||||
"custom_world_profile": []
|
||||
},
|
||||
"workIds": {
|
||||
"puzzle": ["<profile_id>"],
|
||||
"customWorld": ["<profile_id>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 过滤原则
|
||||
|
||||
1. 按 `tables[].name` 白名单过滤,只保留作品 profile 表。
|
||||
2. 对每个 row 再按字段白名单过滤,避免误带账号、手机号、token、钱包流水等字段。
|
||||
3. 对特别大的字段进行处理:
|
||||
- `cover_image_src` 如果是 `data:image/...base64`,默认替换为占位符或截断,避免压测数据文件过大。
|
||||
- `levels_json`、`profile_payload_json` 保留原文,但可以记录大小;如果过大,再提供 `--compact` 选项只保留摘要。
|
||||
4. 输出 `.local.json` 默认加入 `.gitignore`;如果要提交样例数据,只提交脱敏/裁剪后的 `works-list.sample.json`。
|
||||
|
||||
## K6 压测接口矩阵
|
||||
|
||||
需要先确认本地 api-server 实际端口。默认以 `http://127.0.0.1:8787` 为例,实际运行时通过环境变量覆盖:
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:<actual-api-port> k6 run scripts/loadtest/k6-works-list.js
|
||||
```
|
||||
|
||||
初版建议覆盖以下“作品列表”读接口,具体路径以仓库服务端路由为准,实施时需要通过搜索 api-server 路由确认:
|
||||
|
||||
| 场景 | 目的 | 候选路径 |
|
||||
| --- | --- | --- |
|
||||
| 拼图作品列表 | 作品列表主场景之一,当前数据量最多 | `/api/creation/puzzle/works` 或实际 puzzle works list route |
|
||||
| RPG/自定义世界作品列表 | 使用 `custom_world_profile` 数据 | `/api/creation/custom-world/works` 或实际 custom world works route |
|
||||
| 作品详情/启动前读取 | 模拟用户从列表点进作品 | `/api/creation/*/works/:profileId` 或 `/api/runtime/*/works/:profileId` |
|
||||
| 公开作品库 | 如果首页/发现页依赖 | `/api/runtime/*/works` 或 gallery/list route |
|
||||
|
||||
> 注意:不要凭空固定 endpoint。实施阶段先用 `search_files` / 路由源码确认真实路径,再写入 K6 脚本。
|
||||
|
||||
## K6 场景设计
|
||||
|
||||
### 阶段 1:基线 smoke
|
||||
|
||||
目的:确认脚本、数据和目标服务可用。
|
||||
|
||||
```js
|
||||
export const options = {
|
||||
scenarios: {
|
||||
smoke: {
|
||||
executor: 'constant-vus',
|
||||
vus: 1,
|
||||
duration: '30s'
|
||||
}
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<0.01'],
|
||||
http_req_duration: ['p(95)<800']
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 阶段 2:常规读压
|
||||
|
||||
目的:模拟日常列表浏览。
|
||||
|
||||
- `constant-vus`: 10/25/50 三档
|
||||
- 每个 VU 随机选择作品类型和列表分页参数
|
||||
- `sleep(0.5~2s)` 模拟用户停留
|
||||
- 阈值建议:
|
||||
- `http_req_failed < 1%`
|
||||
- `p95 < 800ms`
|
||||
- `p99 < 1500ms`
|
||||
|
||||
### 阶段 3:峰值/突刺
|
||||
|
||||
目的:模拟首页入口或活动导致的作品列表突增。
|
||||
|
||||
- `ramping-arrival-rate`
|
||||
- 从 5 RPS 增长到 100 RPS,维持 2~5 分钟,再降回
|
||||
- 单独输出 `checks`:列表接口状态码、响应 JSON shape、items 数量
|
||||
|
||||
### 阶段 4:容量探索
|
||||
|
||||
目的:找瓶颈,不作为每次回归必跑。
|
||||
|
||||
- 每轮提升 RPS 或 VU
|
||||
- 观察:api-server CPU/内存、SpacetimeDB 日志、错误率、p95/p99
|
||||
- 一旦 `http_req_failed >= 5%` 或 p95 持续超过 2s,停止继续加压并记录容量点。
|
||||
|
||||
## K6 脚本设计要点
|
||||
|
||||
1. 使用 `SharedArray` 加载 `works-list.local.json`,避免每个 VU 重复解析大 JSON。
|
||||
2. 基于数据源里的 `profile_id` / `work_id` 随机抽样,保证请求覆盖真实作品 ID。
|
||||
3. 对列表接口添加分页/排序 query,例如:
|
||||
- `?limit=20&offset=0`
|
||||
- `?pageSize=20&cursor=...`(以真实 API 为准)
|
||||
4. 使用 `check()` 验证:
|
||||
- HTTP 200
|
||||
- 响应体是 JSON
|
||||
- `items` 或 `works` 是数组
|
||||
- 列表项包含 `profileId/profile_id`、标题字段、状态字段
|
||||
5. 使用 `Trend` / `Rate` 细分指标:
|
||||
- `works_list_duration`
|
||||
- `works_detail_duration`
|
||||
- `works_list_shape_error_rate`
|
||||
6. 支持环境变量:
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:8787 \
|
||||
WORKS_DATA=scripts/loadtest/data/works-list.local.json \
|
||||
SCENARIO=baseline \
|
||||
k6 run scripts/loadtest/k6-works-list.js
|
||||
```
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. **确认路由**
|
||||
- 搜索 api-server / BFF 的作品列表路由。
|
||||
- 明确各玩法对应 endpoint、鉴权要求、分页参数、返回字段。
|
||||
2. **实现数据提取脚本**
|
||||
- 新增 `scripts/loadtest/extract-works-list-data.mjs`。
|
||||
- 只按表白名单读取作品列表 profile 表。
|
||||
- 对字段做白名单与脱敏/截断。
|
||||
- 输出 `works-list.local.json`。
|
||||
3. **生成本地压测数据**
|
||||
- 用用户提供的迁移文件生成 `scripts/loadtest/data/works-list.local.json`。
|
||||
- 验证输出只包含作品表和作品字段。
|
||||
4. **实现 K6 脚本**
|
||||
- 新增 `scripts/loadtest/k6-works-list.js`。
|
||||
- 支持 `BASE_URL`、`WORKS_DATA`、`SCENARIO`。
|
||||
- 覆盖列表接口,必要时增加详情/启动前读取接口。
|
||||
5. **新增执行说明**
|
||||
- 在 `scripts/loadtest/README.md` 写明:安装 K6、启动本地 dev 栈、提取数据、运行 smoke/baseline/spike、查看结果。
|
||||
6. **本地验证**
|
||||
- 启动 Genarrative dev 栈;注意端口可能漂移,使用实际 api-server 端口。
|
||||
- 跑 smoke:`SCENARIO=smoke`。
|
||||
- 确认失败率、p95、响应 shape。
|
||||
7. **可选集成 npm scripts**
|
||||
- 如果团队希望标准化入口,再加入 `package.json` scripts。
|
||||
8. **记录结果**
|
||||
- 将 smoke/baseline/spike 的结果摘要追加到 `scripts/loadtest/README.md` 或单独保存到 `.hermes/plans/` 的结果文档中。
|
||||
|
||||
## 启动与运行建议
|
||||
|
||||
本地服务启动按当前 Genarrative dev 栈约定:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
如果 SpacetimeDB/API/Vite 端口被占用,项目脚本会寻找可用端口;压测时必须从启动日志中读取实际 api-server 地址,并传给 K6:
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:<actual-api-port> \
|
||||
WORKS_DATA=scripts/loadtest/data/works-list.local.json \
|
||||
SCENARIO=smoke \
|
||||
k6 run scripts/loadtest/k6-works-list.js
|
||||
```
|
||||
|
||||
## 验证标准
|
||||
|
||||
### 数据源验证
|
||||
|
||||
- `works-list.local.json` 中只出现作品 profile 表。
|
||||
- 不出现以下字段或内容:
|
||||
- `password_hash`
|
||||
- `refresh_token_hash`
|
||||
- `phone_number_e164`
|
||||
- `phone_number_masked`
|
||||
- `wallet_ledger_id`
|
||||
- `auth_identity`
|
||||
- `user_account`
|
||||
- `puzzle_work_profile` 行数应接近原始文件中的 80 行。
|
||||
- `custom_world_profile` 行数应接近原始文件中的 1 行。
|
||||
|
||||
### K6 smoke 验证
|
||||
|
||||
- 所有目标接口返回 2xx。
|
||||
- `http_req_failed < 1%`。
|
||||
- 响应 JSON shape 与 shared contracts 对齐:`items` 或 `works` 数组。
|
||||
- K6 输出中能区分不同 endpoint 的耗时。
|
||||
|
||||
### 性能阈值初稿
|
||||
|
||||
- Smoke:`p95 < 800ms`,失败率 `< 1%`
|
||||
- Baseline:`p95 < 1000ms`,`p99 < 2000ms`,失败率 `< 1%`
|
||||
- Spike:允许短暂 p95 抖动,但 1 分钟内应恢复;失败率 `< 5%`
|
||||
|
||||
阈值后续需要结合本地机器性能、SpacetimeDB 本地模式和正式部署规格调整。
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **原始迁移文件包含敏感数据。** 必须只提取作品列表白名单字段,禁止把原始 JSON 全量提交到仓库。
|
||||
2. **base64 封面可能导致压测数据膨胀。** 默认截断或替换为占位符,除非本次明确要测封面 payload 对响应体积的影响。
|
||||
3. **本地 SpacetimeDB 与 api-server 端口会漂移。** 不要硬编码端口,运行时通过 `BASE_URL` 注入。
|
||||
4. **列表接口可能需要鉴权。** 若实际接口要求登录,不要导入真实 refresh session;应使用本地测试账号或专门的压测 token 生成流程。
|
||||
5. **作品表名/接口路径可能与候选名称不完全一致。** 实施前必须以源码路由为准。
|
||||
6. **本计划仅保存压测方案,不执行实际压测。** 后续执行时再创建/修改脚本、导出过滤数据、跑 K6 并记录结果。
|
||||
|
||||
## 开放问题
|
||||
|
||||
1. 压测目标是本地 dev 栈、测试环境,还是预发/生产只读接口?不同环境阈值和安全边界不同。
|
||||
2. “作品列表”是否只包含拼图和自定义世界,还是要覆盖 match3d、square-hole、big-fish、visual-novel 的统一列表入口?
|
||||
3. 是否允许使用专门压测账号/token?如果接口无鉴权则无需处理。
|
||||
4. 是否需要测封面/asset 加载,还是只测作品列表 JSON API?
|
||||
@@ -0,0 +1,447 @@
|
||||
# Genarrative 容灾方案设计计划
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 基于当前 Genarrative 单机生产部署、Jenkins 流水线、SpacetimeDB 与 Rust `api-server` 架构,补齐一套可落地、可演练、可审计的容灾方案。
|
||||
|
||||
**Architecture:** 首版容灾不引入复杂多活系统,优先围绕现有 `systemd + Nginx + SpacetimeDB + api-server + Jenkins` 单机生产推荐方案做“备份可恢复、版本可回滚、故障可切换、演练可复盘”。方案采用分层容灾:入口层、静态资源层、API 服务层、SpacetimeDB 数据层、外部服务与密钥层、Jenkins/发布链路层。
|
||||
|
||||
**Tech Stack:** Nginx、systemd、SpacetimeDB self-hosting、Rust `api-server` / Axum、Jenkins Pipeline、Shell/Node.js 运维脚本、仓库 `deploy/` 与 `docs/technical/` 文档体系。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前上下文与已确认事实
|
||||
|
||||
### 1.1 当前生产部署口径
|
||||
|
||||
来自 `docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` 的现状:
|
||||
|
||||
- 生产为单机推荐方案,不使用 Docker。
|
||||
- 公网入口为 Nginx,负责 HTTPS、静态站点、后台静态页面、维护页、`/admin/api/` 与临时 `/api/*` 反向代理。
|
||||
- SpacetimeDB 作为 systemd 服务运行:
|
||||
- `spacetimedb.service`
|
||||
- 监听:`127.0.0.1:3101`
|
||||
- 数据根目录:`/stdb`
|
||||
- Rust `api-server` 作为 systemd 服务运行:
|
||||
- `genarrative-api.service`
|
||||
- 监听:`127.0.0.1:8082`
|
||||
- 环境文件:`/etc/genarrative/api-server.env`
|
||||
- 静态站点发布到 release/current 目录:
|
||||
- `/opt/genarrative/releases/<version>/`
|
||||
- `/opt/genarrative/current`
|
||||
- `/srv/genarrative/web`
|
||||
- 已有维护模式:
|
||||
- 开关文件:`/var/lib/genarrative/maintenance/enabled`
|
||||
- API 发布、SpacetimeDB 模块发布、数据库导入、服务器配置变更必须进入维护模式。
|
||||
- 已有数据库导入导出 Jenkins Job:
|
||||
- `Genarrative-Database-Export`
|
||||
- `Genarrative-Database-Import`
|
||||
- 对应文件:`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`
|
||||
- 已有回滚基本口径:
|
||||
- Web 回滚:切 `/srv/genarrative/web` 或 `/opt/genarrative/current` 到上一版本并 reload Nginx。
|
||||
- API 回滚:切 `/opt/genarrative/current` 到上一版本并重启 `genarrative-api.service`。
|
||||
- SpacetimeDB 模块回滚:发布上一版本 `spacetime_module.wasm`。
|
||||
- 数据回滚:使用导入流水线恢复指定备份,必须进入维护模式。
|
||||
|
||||
### 1.2 关键风险
|
||||
|
||||
- 当前是单机生产拓扑,单机磁盘、系统盘、`/stdb`、Nginx 或公网 IP 故障会造成整体不可用。
|
||||
- SpacetimeDB 是核心业务真相,容灾重点必须围绕 `/stdb`、数据库导出产物、schema 迁移与导入验证。
|
||||
- `/etc/genarrative/api-server.env` 持有生产密钥,不能进入 Git,也不能写进普通备份明文归档。
|
||||
- Jenkins controller/agent 同时承担构建、发布、备份、导入导出编排;Jenkins 不可用时仍需要有最小人工恢复路径。
|
||||
- 外部 LLM、图片、语音、3D 网关不是本仓库可控系统,容灾只能做到配置降级、超时隔离、能力熔断与可观测告警。
|
||||
|
||||
---
|
||||
|
||||
## 2. 容灾目标
|
||||
|
||||
### 2.1 恢复目标建议
|
||||
|
||||
| 灾难类型 | 目标 RTO | 目标 RPO | 首版策略 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Web 静态资源发布失败 | 5 分钟 | 0 | release/current 原子切换回滚 |
|
||||
| API 发布失败 | 10 分钟 | 0 | 维护模式 + 上一版二进制回滚 |
|
||||
| SpacetimeDB wasm 发布失败 | 15 分钟 | 0 或按迁移前备份 | 发布前导出 + 上一版 wasm 回滚 |
|
||||
| 数据误写 / 迁移失败 | 30-60 分钟 | 最近一次导出点 | 导入流水线从备份恢复 |
|
||||
| 生产机磁盘损坏 | 2-4 小时 | 最近一次异地备份 | 新机器 provision + 拉取 release 包 + 恢复数据库 |
|
||||
| Jenkins controller 不可用 | 1-2 小时 | 不影响线上数据 | 手工脚本恢复 + Jenkins 备份恢复 |
|
||||
| 第三方模型网关不可用 | 5-15 分钟内降级 | 不丢核心数据 | 配置切换 / 功能熔断 / 队列失败可重试 |
|
||||
|
||||
### 2.2 首版不做
|
||||
|
||||
- 不做跨地域双活写入。
|
||||
- 不做 SpacetimeDB 在线主从复制,除非后续官方能力与项目压测验证支持。
|
||||
- 不让前端绕过 `api-server` 直接承担正式业务真相。
|
||||
- 不把生产密钥、Token、数据库 dump、Jenkins secret 写入 Git。
|
||||
- 不恢复旧 `server-node`、Express、PostgreSQL 或 Docker 一体化部署方案。
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体容灾设计
|
||||
|
||||
### 3.1 分层策略
|
||||
|
||||
1. **入口层:Nginx / DNS / HTTPS**
|
||||
- 保留 Nginx 配置模板在 Git:`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`。
|
||||
- 为 release 环境建立 Nginx 配置备份与证书恢复流程。
|
||||
- 明确 DNS 切换预案:生产机不可恢复时,将域名指向灾备机公网 IP。
|
||||
|
||||
2. **静态资源层:Web / Admin Web**
|
||||
- 依赖 `web.tar.gz`、`web.tar.gz.sha256`、`release-manifest.json`。
|
||||
- 保留最近 N 个 release 目录与构建产物指针。
|
||||
- 回滚只切软链,不重新构建。
|
||||
|
||||
3. **API 服务层:Rust `api-server`**
|
||||
- 依赖归档的 `api-server` 二进制、checksum、`release-manifest.json`。
|
||||
- `/etc/genarrative/api-server.env` 通过加密备份或密钥管理恢复,不进入 release 包。
|
||||
- systemd unit 由 `deploy/systemd/genarrative-api.service` 重新安装。
|
||||
|
||||
4. **数据层:SpacetimeDB**
|
||||
- 每次高风险发布前强制导出数据库。
|
||||
- 定时导出:建议每天至少 1 次;高活跃期可每 4 小时 1 次。
|
||||
- 导出产物同时保存在:Jenkins 归档 + 生产机 `SERVER_BACKUP_DIRECTORY` + 异地对象存储/备份机。
|
||||
- 导入前自动生成安全备份,保留当前实现口径。
|
||||
|
||||
5. **发布编排层:Jenkins**
|
||||
- Jenkins Job、Jenkinsfile 在 Git 中可恢复。
|
||||
- Jenkins controller 配置、凭据、插件清单需要额外备份。
|
||||
- 发布 agent 使用 inbound + systemd 自恢复,agent secret 仅存在目标机或 Jenkins 凭据。
|
||||
|
||||
6. **密钥与外部服务层**
|
||||
- `/etc/genarrative/api-server.env`、Jenkins Secret Text、SSH PEM、agent secret 不进 Git。
|
||||
- 制定密钥清单和恢复责任人,但不在仓库记录明文。
|
||||
- 外部服务配置按 `docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md` 维护必配项。
|
||||
|
||||
---
|
||||
|
||||
## 4. 建议新增/更新的文档
|
||||
|
||||
### Task 1: 新增生产容灾技术方案文档
|
||||
|
||||
**Objective:** 形成团队可共享、可执行的容灾总纲。
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
|
||||
- Modify: `docs/technical/README.md`(若已有技术索引,应加入该文档入口)
|
||||
- Optional Modify: `.hermes/shared-memory/project-overview.md`(只加稳定索引,不写敏感信息)
|
||||
|
||||
**文档必须覆盖:**
|
||||
|
||||
1. 容灾目标:RTO/RPO 表。
|
||||
2. 生产资产清单:Nginx、systemd、release/current、`/stdb`、`/etc/genarrative/api-server.env`、Jenkins、构建产物。
|
||||
3. 备份策略:
|
||||
- 数据库导出。
|
||||
- release 产物保留。
|
||||
- Nginx/systemd/env 配置备份。
|
||||
- Jenkins 配置备份。
|
||||
4. 恢复流程:
|
||||
- Web 回滚。
|
||||
- API 回滚。
|
||||
- Stdb module 回滚。
|
||||
- 数据恢复。
|
||||
- 整机重建。
|
||||
5. 演练计划:每月一次数据库恢复演练,每季度一次整机重建演练。
|
||||
6. 安全边界:密钥不进 Git,备份加密,最小权限。
|
||||
7. 验收命令与人工检查清单。
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
Expected: PASS,无中文乱码、无 BOM/CRLF 问题。
|
||||
|
||||
---
|
||||
|
||||
## 5. 建议新增/更新的脚本与流水线
|
||||
|
||||
### Task 2: 增强数据库定时备份流水线
|
||||
|
||||
**Objective:** 把现有人工导出扩展为可定时执行、可异地保存、可审计的备份流程。
|
||||
|
||||
**Files:**
|
||||
- Modify: `jenkins/Jenkinsfile.production-database-export`
|
||||
- Modify: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
|
||||
- Optional Create: `scripts/deploy/production-backup-sync.sh`
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- 在 Jenkins Job 中保留人工触发能力,同时建议配置 cron:
|
||||
- development:每天凌晨。
|
||||
- release:每天凌晨或业务低峰。
|
||||
- 增加备份命名规范:
|
||||
- `spacetime-migration-<database>-<yyyyMMdd-HHmmss>-<source_commit>.json`
|
||||
- 增加 `SERVER_BACKUP_DIRECTORY` 默认建议:
|
||||
- `/var/backups/genarrative/spacetimedb/<database>/`
|
||||
- 增加备份保留策略:
|
||||
- 本机保留 7-14 天。
|
||||
- 异地保留 30-90 天。
|
||||
- 如实现 `production-backup-sync.sh`,只做同步框架,不硬编码真实 bucket、账号、endpoint 或密钥。
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
bash -n scripts/deploy/production-backup-sync.sh
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
Expected: shell 语法通过;文档编码检查通过。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 增加灾备恢复 Runbook
|
||||
|
||||
**Objective:** 在真正故障时不依赖临场推理,按清单执行恢复。
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/operations/PRODUCTION_DR_RUNBOOK_2026-05-11.md`
|
||||
- Modify: `docs/operations/README.md`(如果存在)
|
||||
|
||||
**Runbook sections:**
|
||||
|
||||
1. 故障分级:P0/P1/P2。
|
||||
2. 第一响应:
|
||||
- 判断 Nginx 是否在线。
|
||||
- 判断 `genarrative-api.service` 是否在线。
|
||||
- 判断 `spacetimedb.service` 是否在线。
|
||||
- 判断磁盘是否满。
|
||||
- 判断 Jenkins agent 是否在线。
|
||||
3. 快速止血:
|
||||
- 开维护模式。
|
||||
- 禁止继续发布。
|
||||
- 保留现场日志。
|
||||
4. 回滚流程:
|
||||
- Web 回滚命令。
|
||||
- API 回滚命令。
|
||||
- Stdb wasm 回滚命令。
|
||||
5. 数据恢复流程:
|
||||
- 选择备份。
|
||||
- dry-run 导入。
|
||||
- 确认导入。
|
||||
- smoke test。
|
||||
6. 整机重建流程:
|
||||
- 新机器 provision。
|
||||
- 恢复 `/etc/genarrative/api-server.env`。
|
||||
- 恢复 SpacetimeDB 数据。
|
||||
- 发布最近稳定 release。
|
||||
- DNS 切换。
|
||||
7. 复盘模板。
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 增加备份健康检查与恢复演练记录模板
|
||||
|
||||
**Objective:** 防止“有备份但不可恢复”。
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/operations/DR_DRILL_REPORT_TEMPLATE.md`
|
||||
- Optional Create: `scripts/deploy/verify-database-backup.sh`
|
||||
- Modify: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
|
||||
|
||||
**建议检查项:**
|
||||
|
||||
- 备份文件存在且大小非 0。
|
||||
- 备份文件 checksum 可验证。
|
||||
- 备份文件可被 `Genarrative-Database-Import` dry-run 解析。
|
||||
- 最近一次备份时间未超过 RPO 阈值。
|
||||
- 导入后 `/healthz` 可用。
|
||||
- 首页、后台登录页、关键 API smoke 可用。
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
bash -n scripts/deploy/verify-database-backup.sh
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
---
|
||||
|
||||
## 6. 具体恢复流程草案
|
||||
|
||||
### 6.1 Web 静态资源回滚
|
||||
|
||||
1. 进入目标机。
|
||||
2. 查看 release 目录:`/opt/genarrative/releases/`。
|
||||
3. 选择上一个稳定版本。
|
||||
4. 切换 `/srv/genarrative/web` 或 `/opt/genarrative/current` 软链。
|
||||
5. 执行 Nginx 配置检查与 reload。
|
||||
6. 访问首页与后台静态入口。
|
||||
|
||||
验收:
|
||||
|
||||
- `/` 返回最新稳定页面。
|
||||
- `/admin/` 返回后台页面。
|
||||
- 静态资源无 404。
|
||||
|
||||
### 6.2 API 回滚
|
||||
|
||||
1. 开维护模式。
|
||||
2. 切 `/opt/genarrative/current` 到上一版包含稳定 `api-server` 的 release。
|
||||
3. 重启 `genarrative-api.service`。
|
||||
4. 本机检查 `http://127.0.0.1:8082/healthz`。
|
||||
5. 检查 Nginx 反代路径。
|
||||
6. 解除维护模式。
|
||||
|
||||
验收:
|
||||
|
||||
- `systemctl status genarrative-api.service` 正常。
|
||||
- `/healthz` 正常。
|
||||
- 后台 `/admin/api/*` 基础接口正常。
|
||||
|
||||
### 6.3 SpacetimeDB 模块回滚
|
||||
|
||||
1. 开维护模式。
|
||||
2. 确认目标数据库名与当前 API 环境一致:`GENARRATIVE_SPACETIME_DATABASE`。
|
||||
3. 选择上一版 `spacetime_module.wasm`。
|
||||
4. 使用 `spacetimedb` 服务用户发布上一版 wasm。
|
||||
5. 重启或检查 `spacetimedb.service`。
|
||||
6. 检查 `api-server` 对目标数据库访问。
|
||||
7. 解除维护模式。
|
||||
|
||||
注意:如果 schema 已迁移且旧 wasm 不兼容当前数据,需要走数据恢复,不应直接盲目发布旧 wasm。
|
||||
|
||||
### 6.4 数据恢复
|
||||
|
||||
1. 开维护模式。
|
||||
2. 从 Jenkins 归档或 `SERVER_BACKUP_DIRECTORY` 选择备份。
|
||||
3. 先执行导入 dry-run。
|
||||
4. 真正导入前生成当前数据库安全备份。
|
||||
5. 执行导入。
|
||||
6. 执行 smoke test。
|
||||
7. 解除维护模式。
|
||||
|
||||
必须记录:
|
||||
|
||||
- 备份文件名。
|
||||
- 来源 Job/build number。
|
||||
- 恢复目标 database。
|
||||
- 恢复开始/结束时间。
|
||||
- 恢复后验证结果。
|
||||
|
||||
### 6.5 整机重建
|
||||
|
||||
1. 准备新 Linux 机器。
|
||||
2. 接入 Jenkins release deploy agent,或准备人工 SSH 运维路径。
|
||||
3. 运行 `Genarrative-Server-Provision`:
|
||||
- 创建用户和目录。
|
||||
- 安装 SpacetimeDB。
|
||||
- 安装 systemd unit。
|
||||
- 安装 Nginx 配置。
|
||||
4. 恢复 `/etc/genarrative/api-server.env`。
|
||||
5. 发布最近稳定 Web/API/Stdb 产物。
|
||||
6. 导入最近一次有效数据库备份。
|
||||
7. smoke test。
|
||||
8. 切 DNS。
|
||||
9. 观察 30-60 分钟。
|
||||
|
||||
---
|
||||
|
||||
## 7. 文件可能变更清单
|
||||
|
||||
首版落地建议按以下文件收口:
|
||||
|
||||
- Create: `docs/technical/PRODUCTION_DISASTER_RECOVERY_PLAN_2026-05-11.md`
|
||||
- Create: `docs/operations/PRODUCTION_DR_RUNBOOK_2026-05-11.md`
|
||||
- Create: `docs/operations/DR_DRILL_REPORT_TEMPLATE.md`
|
||||
- Modify: `docs/technical/README.md`
|
||||
- Modify: `docs/operations/README.md`(若存在)
|
||||
- Modify: `.hermes/shared-memory/project-overview.md`(仅增加文档索引)
|
||||
- Optional Modify: `jenkins/Jenkinsfile.production-database-export`
|
||||
- Optional Modify: `jenkins/Jenkinsfile.production-database-import`
|
||||
- Optional Create: `scripts/deploy/production-backup-sync.sh`
|
||||
- Optional Create: `scripts/deploy/verify-database-backup.sh`
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试与验收
|
||||
|
||||
### 8.1 文档与编码
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### 8.2 Shell 脚本语法
|
||||
|
||||
如新增 shell 脚本:
|
||||
|
||||
```bash
|
||||
bash -n scripts/deploy/production-backup-sync.sh
|
||||
bash -n scripts/deploy/verify-database-backup.sh
|
||||
```
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### 8.3 Jenkinsfile 静态检查
|
||||
|
||||
建议在 Jenkins UI 或本地 Jenkins Pipeline Linter 中检查:
|
||||
|
||||
- `jenkins/Jenkinsfile.production-database-export`
|
||||
- `jenkins/Jenkinsfile.production-database-import`
|
||||
|
||||
Expected: Pipeline syntax valid。
|
||||
|
||||
### 8.4 演练验收
|
||||
|
||||
至少完成一次 development 目标演练:
|
||||
|
||||
1. 触发 `Genarrative-Database-Export`。
|
||||
2. 确认备份产物存在并归档。
|
||||
3. 使用 `Genarrative-Database-Import` dry-run 验证备份可解析。
|
||||
4. 不覆盖生产数据的前提下,记录演练报告。
|
||||
|
||||
release 目标演练应在业务低峰进行,并先确认通知渠道可用。
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险、取舍与开放问题
|
||||
|
||||
### 9.1 风险
|
||||
|
||||
- 单机生产仍存在物理机级单点故障,首版只能通过“快速重建 + 异地备份”降低恢复时间。
|
||||
- SpacetimeDB schema 回滚不一定可逆,必须把发布前备份作为强约束。
|
||||
- Jenkins controller 若在本地 Windows,controller 自身备份和恢复需要单独制定,不应只依赖 agent 自恢复。
|
||||
- 外部模型网关失败可能影响创作能力,但不应影响已发布作品浏览和后台基础能力。
|
||||
|
||||
### 9.2 取舍
|
||||
|
||||
- 选择先做可执行 runbook 和备份恢复演练,而不是直接引入复杂多活。
|
||||
- 选择继续复用现有 Jenkins 导入导出流水线,降低工程改造风险。
|
||||
- 选择不把密钥恢复细节写死到 Git 文档,避免泄露。
|
||||
|
||||
### 9.3 开放问题
|
||||
|
||||
1. release 环境是否已经有独立备份机或对象存储?如果有,需要补充备份同步目标,但不能提交密钥。
|
||||
2. Jenkins controller 的 `JENKINS_HOME` 当前实际部署在哪里?是否已有周期备份?
|
||||
3. 生产域名 DNS TTL 当前是多少?是否可降低到适合故障切换的值?
|
||||
4. `/stdb` 所在磁盘是否独立于系统盘?是否已有磁盘水位告警?
|
||||
5. release 环境的通知渠道除邮件外是否需要接入企业微信/飞书/Telegram?
|
||||
|
||||
---
|
||||
|
||||
## 10. 推荐实施顺序
|
||||
|
||||
1. 先只落文档:技术方案 + runbook + 演练模板。
|
||||
2. 在 development 目标做一次数据库导出 + dry-run 导入演练。
|
||||
3. 根据演练结果补脚本:备份同步、备份健康检查。
|
||||
4. 再把 release 备份设置为定时任务。
|
||||
5. 最后规划整机重建演练与 DNS 切换演练。
|
||||
|
||||
首版完成标准:
|
||||
|
||||
- 团队任一成员打开 runbook,即可在 30 分钟内完成 Web/API 回滚或数据库备份 dry-run 恢复。
|
||||
- 最近一次数据库备份时间、备份位置、checksum、恢复演练结果可追溯。
|
||||
- 生产密钥仍只存在于服务器/Jenkins 凭据/加密备份中,不进入 Git。
|
||||
@@ -0,0 +1,403 @@
|
||||
# 当前项目安全漏洞检查计划
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill only if the user later asks to execute this plan. 本计划当前仅用于规划,不实施代码修改。
|
||||
|
||||
**Goal:** 对 Genarrative 当前工作区做一次可复现的安全漏洞基线检查,覆盖依赖漏洞、密钥泄露、常见高风险代码模式、后端 Rust crate 风险和前端/Node 供应链风险,并输出可落地的整改清单。
|
||||
|
||||
**Architecture:** 采用“只读扫描 → 结果归档 → 人工分级 → 最小修复建议”的方式推进。先不直接升级依赖或改代码,避免安全扫描引入不可控 breaking change;执行阶段只在用户确认后运行扫描命令,并把报告保存到 `docs/audits/` 或 `.hermes/plans/` 附件中。
|
||||
|
||||
**Tech Stack:** Node/Vite/React/TypeScript、Rust workspace/Axum/SpacetimeDB、npm lockfile、Cargo.lock、Git worktree。
|
||||
|
||||
---
|
||||
|
||||
## 当前上下文 / 假设
|
||||
|
||||
- 当前有效工作区:`C:/proj/Genarrative/.worktrees/hermes-3337436a`。
|
||||
- 本次用户以 `/plan` 模式要求“检查一下当前项目的安全漏洞”,因此本轮只制定计划,不执行会产生报告、安装工具、修改依赖、提交或推送的操作。
|
||||
- 已确认项目包含:
|
||||
- 根 `package.json`,脚本包括 `npm run lint`、`npm run test`、`npm run build`、`npm run check:encoding`。
|
||||
- 根 `package-lock.json`。
|
||||
- `server-rs/Cargo.toml` 和 `server-rs/Cargo.lock`。
|
||||
- `apps/admin-web/package.json`、`packages/shared/package.json`。
|
||||
- `.hermes/shared-memory/development-workflow.md` 要求开发前读取共享记忆,并以当前代码、`docs/`、`AGENTS.md` 为准。
|
||||
- 安全扫描不应把真实密钥写入仓库;发现疑似密钥时只记录文件位置、变量名、脱敏片段和处置建议。
|
||||
|
||||
## 总体策略
|
||||
|
||||
1. 先做仓库状态和范围确认,避免扫描其他 worktree 或错误路径。
|
||||
2. 优先运行不会修改文件的安全检查:`npm audit --json`、`cargo audit`、密钥扫描、危险代码模式扫描。
|
||||
3. 分前端供应链、后端供应链、源码安全、配置/脚本安全四类归档。
|
||||
4. 对结果按严重级别分层:Critical / High / Medium / Low / Informational。
|
||||
5. 对每个真实问题给出:影响范围、证据、可行修复、验证命令、是否需要业务回归。
|
||||
6. 只有在用户确认进入执行/修复阶段后,才做依赖升级、代码修复、文档更新、测试和提交。
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step Plan
|
||||
|
||||
### Task 1: 确认扫描工作区和基线状态
|
||||
|
||||
**Objective:** 确保后续扫描针对当前 worktree,且不会误把既有未提交变更当成安全修复结果。
|
||||
|
||||
**Files:**
|
||||
- Read-only: `AGENTS.md`
|
||||
- Read-only: `.hermes/README.md`
|
||||
- Read-only: `.hermes/shared-memory/development-workflow.md`
|
||||
- Read-only: `package.json`
|
||||
- Read-only: `server-rs/Cargo.toml`
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
pwd
|
||||
git status --short
|
||||
git branch --show-current
|
||||
git rev-parse --show-toplevel
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
- `pwd` / `git rev-parse --show-toplevel` 指向 `C:/proj/Genarrative/.worktrees/hermes-3337436a` 对应路径。
|
||||
- 分支为当前隔离 worktree 分支。
|
||||
- 记录是否已有未提交变更;如存在,扫描报告需标注“基于含未提交变更的工作区”。
|
||||
|
||||
**Validation:**
|
||||
- 不修改任何项目文件。
|
||||
- 如发现路径不是当前 worktree,停止并重新确认路径。
|
||||
|
||||
### Task 2: 生成依赖清单和锁文件基线
|
||||
|
||||
**Objective:** 明确 Node 与 Rust 依赖入口,避免漏扫子包或 admin web。
|
||||
|
||||
**Files:**
|
||||
- Read-only: `package.json`
|
||||
- Read-only: `package-lock.json`
|
||||
- Read-only: `apps/admin-web/package.json`
|
||||
- Read-only: `packages/shared/package.json`
|
||||
- Read-only: `server-rs/Cargo.toml`
|
||||
- Read-only: `server-rs/Cargo.lock`
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
npm --version
|
||||
node --version
|
||||
cargo --version
|
||||
rustc --version
|
||||
```
|
||||
|
||||
可选只读清单:
|
||||
|
||||
```bash
|
||||
npm ls --all --json > /tmp/genarrative-npm-ls.json
|
||||
cargo metadata --manifest-path server-rs/Cargo.toml --format-version 1 > /tmp/genarrative-cargo-metadata.json
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
- 明确 npm / Node / Rust / Cargo 版本。
|
||||
- 若 `npm ls` 因 peer dependency 或历史依赖问题非 0,保留输出并继续 audit。
|
||||
|
||||
**Validation:**
|
||||
- `/tmp` 输出不进入 Git。
|
||||
- 不运行 `npm install`、`npm update`、`cargo update`。
|
||||
|
||||
### Task 3: Node 供应链漏洞扫描
|
||||
|
||||
**Objective:** 检查根 lockfile 覆盖的前端、脚本和 admin web 依赖漏洞。
|
||||
|
||||
**Files:**
|
||||
- Read-only: `package-lock.json`
|
||||
- Read-only: `package.json`
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
npm audit --json > /tmp/genarrative-npm-audit.json
|
||||
npm audit --audit-level=moderate
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
- `npm audit --json` 生成机器可读结果。
|
||||
- 第二条命令给出人类可读摘要;如返回非 0,按漏洞严重度记录,不直接执行 `npm audit fix`。
|
||||
|
||||
**Result fields to extract:**
|
||||
- package name
|
||||
- vulnerable versions
|
||||
- installed version
|
||||
- severity
|
||||
- CVE / GHSA
|
||||
- via chain
|
||||
- fixAvailable 是否为 major/breaking
|
||||
- affected direct dependency or transitive dependency
|
||||
|
||||
**Validation:**
|
||||
- 不执行 `npm audit fix`。
|
||||
- 如 npm registry 网络不可用,记录阻塞原因和可重试命令。
|
||||
|
||||
### Task 4: Rust 供应链漏洞扫描
|
||||
|
||||
**Objective:** 检查 `server-rs` workspace 的 Cargo 依赖漏洞、弃用 crate 和 yanked crate。
|
||||
|
||||
**Files:**
|
||||
- Read-only: `server-rs/Cargo.toml`
|
||||
- Read-only: `server-rs/Cargo.lock`
|
||||
|
||||
**Commands:**
|
||||
|
||||
优先:
|
||||
|
||||
```bash
|
||||
cargo audit --json --manifest-path server-rs/Cargo.toml > /tmp/genarrative-cargo-audit.json
|
||||
cargo audit --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
如果本机没有 `cargo audit`:
|
||||
|
||||
```bash
|
||||
cargo install cargo-audit --locked
|
||||
cargo audit --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
**Execution note:**
|
||||
- 安装 `cargo-audit` 会改变用户 Cargo 工具目录,不属于纯只读扫描;执行前需用户确认。
|
||||
- 如果用户不希望安装工具,则记录“Rust 漏洞扫描未完成”,并给出本地安装或 CI 执行建议。
|
||||
|
||||
**Result fields to extract:**
|
||||
- advisory id
|
||||
- package
|
||||
- version
|
||||
- patched versions
|
||||
- unaffected versions
|
||||
- severity / CVSS if available
|
||||
- dependency path
|
||||
- whether it is runtime reachable in `api-server` / `spacetime-module`
|
||||
|
||||
**Validation:**
|
||||
- 不运行 `cargo update`。
|
||||
- 不改 `Cargo.lock`。
|
||||
|
||||
### Task 5: 密钥和敏感配置泄露扫描
|
||||
|
||||
**Objective:** 检查仓库中是否误提交 API key、token、私钥、cookie、`.env` 类文件或个人 Hermes 配置。
|
||||
|
||||
**Files / paths to scan:**
|
||||
- Full repo excluding `.git/`, `node_modules/`, `target/`, `dist/`, build artifacts。
|
||||
- 特别关注:`.hermes/`、`scripts/`、`server-rs/`、`apps/admin-web/`、`src/`、`docs/`。
|
||||
|
||||
**Preferred commands:**
|
||||
|
||||
如果有 gitleaks:
|
||||
|
||||
```bash
|
||||
gitleaks detect --source . --no-git --redact --report-format json --report-path /tmp/genarrative-gitleaks.json
|
||||
```
|
||||
|
||||
如果没有 gitleaks,先用只读 grep/ripgrep 兜底:
|
||||
|
||||
```bash
|
||||
git ls-files -z | xargs -0 grep -nIE "(api[_-]?key|secret|password|passwd|token|private[_-]?key|BEGIN (RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY|AKIA[0-9A-Z]{16}|xox[baprs]-|sk-[A-Za-z0-9_-]{20,})" > /tmp/genarrative-secret-grep.txt || true
|
||||
```
|
||||
|
||||
**Execution note:**
|
||||
- 安装 gitleaks 需要用户确认。
|
||||
- grep 结果包含 false positive,必须人工分级,不得直接当作泄露结论。
|
||||
|
||||
**Validation:**
|
||||
- 报告中对值做脱敏,只保留前后 3-4 位或完全不记录值。
|
||||
- 如果发现 `.env.local` 或真实 token 被跟踪,立即标为 Critical。
|
||||
|
||||
### Task 6: 常见源码安全模式扫描
|
||||
|
||||
**Objective:** 快速发现高风险代码模式:命令注入、动态执行、路径穿越、危险反序列化、XSS、日志泄密、宽松 CORS 等。
|
||||
|
||||
**Files / paths:**
|
||||
- `src/**/*.{ts,tsx,js,mjs,cjs}`
|
||||
- `apps/admin-web/**/*.{ts,tsx,js,mjs,cjs}`
|
||||
- `scripts/**/*.{js,mjs,cjs,ts}`
|
||||
- `server-rs/crates/**/*.rs`
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
# JS/TS 动态执行与 HTML 注入
|
||||
rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages
|
||||
|
||||
# Node 命令执行风险
|
||||
rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages
|
||||
|
||||
# Rust 命令、文件路径、unwrap 风险热点
|
||||
rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates
|
||||
|
||||
# 宽松 CORS / Cookie / Auth 相关热点
|
||||
rg -n "allow_origin|Any|cookie|Authorization|Bearer|refresh|access_token|set_cookie|SameSite|Secure|HttpOnly" server-rs/crates src apps scripts
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
- 输出作为“热点清单”,不等同于漏洞。
|
||||
- 对 auth/session、文件上传、OSS 签名、外部 LLM/图片服务请求、SpacetimeDB 访问 facade 做人工复核。
|
||||
|
||||
**Validation:**
|
||||
- 每个疑似问题必须能说明可利用条件,无法说明则降级为 Informational。
|
||||
|
||||
### Task 7: Web/API 安全配置人工复核
|
||||
|
||||
**Objective:** 对项目特有的安全边界做代码审阅,补足工具扫描无法覆盖的业务风险。
|
||||
|
||||
**Likely files to review:**
|
||||
- `server-rs/crates/api-server/src/**`
|
||||
- `server-rs/crates/platform-auth/src/**`
|
||||
- `server-rs/crates/platform-oss/src/**`
|
||||
- `server-rs/crates/platform-llm/src/**`
|
||||
- `server-rs/crates/spacetime-client/src/**`
|
||||
- `src/services/**`
|
||||
- `apps/admin-web/src/**`
|
||||
- `scripts/*deploy*`
|
||||
- `scripts/*api-server*`
|
||||
- `.github/workflows/**` if present
|
||||
|
||||
**Checklist:**
|
||||
- Auth / session:access token 与 refresh cookie 的生命周期、SameSite/Secure/HttpOnly、错误日志是否泄露 token。
|
||||
- CORS:开发环境与生产环境是否区分,是否存在生产 `Any`。
|
||||
- SSRF / outbound:LLM、图片生成、OSS、任意 URL 下载是否校验协议和大小。
|
||||
- Upload / Data URL:大小限制、MIME 校验、base64 解析错误处理。
|
||||
- Path traversal:脚本和后端是否拼接用户输入路径。
|
||||
- Admin:后台接口是否有权限校验,是否复用普通用户 token。
|
||||
- SpacetimeDB:private table / reducer 是否绕过 api-server facade 暴露敏感数据。
|
||||
- Logging:日志是否打印 API key、token、cookie、用户私密内容。
|
||||
|
||||
**Validation:**
|
||||
- 对每个命中的真实风险,记录具体文件路径和函数名。
|
||||
- 对“需要运行环境才能验证”的风险,列出 smoke 或单测建议。
|
||||
|
||||
### Task 8: 汇总漏洞分级与整改建议
|
||||
|
||||
**Objective:** 把扫描结果转成团队可执行的安全整改报告。
|
||||
|
||||
**Deliverable candidates:**
|
||||
- `docs/audits/SECURITY_VULNERABILITY_SCAN_YYYY-MM-DD.md`
|
||||
- 或如果用户只要临时报告:`.hermes/plans/assets/security-scan-YYYY-MM-DD.md`
|
||||
|
||||
**Report structure:**
|
||||
|
||||
```markdown
|
||||
# 安全漏洞扫描报告 YYYY-MM-DD
|
||||
|
||||
## 扫描范围
|
||||
## 扫描命令与环境
|
||||
## 摘要
|
||||
## Critical
|
||||
## High
|
||||
## Medium
|
||||
## Low
|
||||
## Informational / False Positive
|
||||
## 依赖升级建议
|
||||
## 代码修复建议
|
||||
## 需要人工确认的问题
|
||||
## 验证命令
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- 报告不包含真实密钥。
|
||||
- 每条问题都有“证据、影响、建议、验证”。
|
||||
- 明确哪些是工具扫描结果,哪些是人工判断。
|
||||
|
||||
### Task 9: 如用户要求修复,再分批执行最小修复
|
||||
|
||||
**Objective:** 避免一次性大规模升级导致回归,把修复拆为可验证的小批次。
|
||||
|
||||
**Suggested order:**
|
||||
1. Critical secrets:立即移除、轮换密钥、补 `.gitignore`/文档约束(注意项目约束:不要在 `.gitignore` 中添加 `.env.local`)。
|
||||
2. Critical/High direct dependencies:优先升级 direct dependency,运行最小测试。
|
||||
3. Critical/High transitive dependencies:评估是否由 direct dependency patch/minor 升级带出。
|
||||
4. 源码漏洞:按入口编写回归测试,再修复。
|
||||
5. Medium/Low:按风险和 breaking change 代价排期。
|
||||
|
||||
**Required verification after fixes:**
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
npm run lint:eslint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build
|
||||
cd server-rs && cargo test --workspace
|
||||
```
|
||||
|
||||
后端 API 或 auth 修复涉及运行态时,还需要:
|
||||
|
||||
```bash
|
||||
npm run api-server
|
||||
# 另一个终端检查 /healthz 并执行对应 smoke
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- 修复后重新跑对应 audit / secret scan。
|
||||
- 走 `requesting-code-review` 的独立安全复核流程。
|
||||
|
||||
---
|
||||
|
||||
## Files likely to change(仅修复阶段)
|
||||
|
||||
本计划阶段不修改以下文件;只有用户确认执行修复时才可能变化:
|
||||
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- `apps/admin-web/package.json`
|
||||
- `server-rs/Cargo.toml`
|
||||
- `server-rs/Cargo.lock`
|
||||
- `server-rs/crates/api-server/src/**`
|
||||
- `server-rs/crates/platform-auth/src/**`
|
||||
- `server-rs/crates/platform-oss/src/**`
|
||||
- `server-rs/crates/platform-llm/src/**`
|
||||
- `src/services/**`
|
||||
- `apps/admin-web/src/**`
|
||||
- `scripts/**`
|
||||
- `docs/audits/SECURITY_VULNERABILITY_SCAN_YYYY-MM-DD.md`
|
||||
- `.hermes/shared-memory/pitfalls.md`(仅当发现长期有效、会反复踩的安全排障经验时更新)
|
||||
|
||||
## Tests / Validation
|
||||
|
||||
安全扫描执行阶段:
|
||||
|
||||
```bash
|
||||
npm audit --json > /tmp/genarrative-npm-audit.json
|
||||
npm audit --audit-level=moderate
|
||||
cargo audit --manifest-path server-rs/Cargo.toml
|
||||
rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages
|
||||
rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages
|
||||
rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates
|
||||
```
|
||||
|
||||
修复执行阶段:
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
npm run lint:eslint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build
|
||||
cd server-rs && cargo test --workspace
|
||||
```
|
||||
|
||||
如变更后端运行态、安全中间件、auth/session:
|
||||
|
||||
```bash
|
||||
npm run api-server
|
||||
# 检查 /healthz
|
||||
# 执行相关 auth / API smoke
|
||||
```
|
||||
|
||||
## Risks, tradeoffs, and open questions
|
||||
|
||||
- `npm audit fix` 可能升级 major version,破坏 Vite/React/ESLint/Vitest 兼容性;必须先人工审查 `fixAvailable`。
|
||||
- `cargo audit` 可能需要安装 `cargo-audit`;安装工具属于用户环境变更,应先确认。
|
||||
- 密钥扫描极易产生 false positive;必须人工复核,报告中禁止输出真实密钥。
|
||||
- Rust `unwrap/expect` 不是天然漏洞;只有对外部输入、网络、文件、数据库响应等不可信数据造成 panic/DoS 时才升级为真实风险。
|
||||
- Web 安全检查需要区分开发环境和生产环境;开发 CORS 放宽不等于生产漏洞,但生产配置必须有明确边界。
|
||||
- 如果扫描发现历史提交中曾泄露密钥,删除当前文件不够,必须轮换密钥并考虑历史清理策略。
|
||||
- 当前计划未直接访问 CI/Jenkins/生产配置;若用户希望覆盖 CI/CD、镜像、部署主机和运行时端口,需要补充 Jenkins console、部署脚本和生产环境配置的只读访问方式。
|
||||
|
||||
## Missing artifacts / follow-up checkpoints
|
||||
|
||||
- 尚未获得用户确认是否允许安装 `cargo-audit` / `gitleaks` 等工具。
|
||||
- 尚未执行真实扫描,因此当前没有漏洞结论;执行后需要生成正式报告。
|
||||
- 如果用户希望“检查当前项目”包含远端仓库历史 secrets、Docker 镜像、Jenkins 凭据和生产运行时配置,需要另行确认访问范围和凭据边界。
|
||||
@@ -0,0 +1,206 @@
|
||||
# 远端作品列表压测排查报告
|
||||
|
||||
时间:2026-05-12 06:16 CST
|
||||
目标:`http://82.157.175.59`
|
||||
SSH:远端生产机 root 账号(具体私钥路径仅保留在本机环境,不写入仓库)
|
||||
|
||||
## 背景
|
||||
|
||||
远端 `k6-works-list.js` 压测中:
|
||||
|
||||
- smoke 通过。
|
||||
- baseline 10 VU:无 HTTP 错误,但 p95/p99 超阈值。
|
||||
- 50 RPS spike:`http_req_failed` / `works_list_shape_error_rate` 约 21.99%。
|
||||
- 100 RPS spike:`http_req_failed` / `works_list_shape_error_rate` 约 25.47%。
|
||||
- 从 k6 check 看,失败主要集中在 `puzzle_gallery_list`,`custom_world_gallery_list` 基本正常。
|
||||
|
||||
## 已完成排查
|
||||
|
||||
### 1. 服务器进程与资源
|
||||
|
||||
远端服务监听:
|
||||
|
||||
- Rust api-server:`127.0.0.1:8082`,systemd 服务 `genarrative-api.service`。
|
||||
- SpacetimeDB:`127.0.0.1:3101`,systemd 服务 `spacetimedb.service`。
|
||||
- Nginx:公网 80 反代 `/api/*` 到 `127.0.0.1:8082`。
|
||||
|
||||
服务器规格/状态:
|
||||
|
||||
- 2 vCPU。
|
||||
- 内存约 1.9GiB。
|
||||
- Swap 约 1.9GiB,已有约 600MiB 使用。
|
||||
- `/` 磁盘约 69%。
|
||||
- Rust api-server 当前 CPU 不高。
|
||||
- SpacetimeDB 当前 CPU 不高。
|
||||
|
||||
发现一个独立异常:
|
||||
|
||||
- PM2 下旧 `server-node` 进程 `genarrative` 正在重启风暴。
|
||||
- cwd:`/work/Genarrative/server-node`
|
||||
- 错误:连接 `127.0.0.1:5432` PostgreSQL 被拒绝。
|
||||
- PM2 restart 次数已超过 33 万。
|
||||
- 该进程不是当前公网 `/api/*` 使用的 Rust api-server,但会制造额外 CPU/内存/日志抖动。
|
||||
|
||||
### 2. 压测窗口服务端日志
|
||||
|
||||
子任务聚合了 2026-05-12 04:50-05:05 的 nginx 与 api-server 日志。
|
||||
|
||||
nginx access:
|
||||
|
||||
- `/api/runtime/puzzle/gallery`:4661 次,全部 200。
|
||||
- `/api/runtime/custom-world-gallery`:4659 次,全部 200。
|
||||
|
||||
api-server journal:
|
||||
|
||||
`/api/runtime/puzzle/gallery`:
|
||||
|
||||
- completed:4661
|
||||
- status:200 全部
|
||||
- slow_request:0
|
||||
- latency_ms:min 13 / p50 30 / p90 43 / p95 50 / p99 62 / max 88
|
||||
|
||||
`/api/runtime/custom-world-gallery`:
|
||||
|
||||
- completed:4659
|
||||
- status:200 全部
|
||||
- slow_request:0
|
||||
- latency_ms:min 0 / p50 1 / p90 5 / p95 7 / p99 13 / max 49
|
||||
|
||||
结论:
|
||||
|
||||
- 在服务端视角,两个接口在该窗口都没有 5xx,也没有慢请求。
|
||||
- 这与 k6 客户端侧 30s timeout / failed check 存在明显不一致。
|
||||
- 需要进一步区分:客户端侧网络/连接耗尽/本机 k6 执行环境问题,还是 k6 统计混合/响应解析问题。
|
||||
|
||||
### 3. k6 脚本行为
|
||||
|
||||
文件:`scripts/loadtest/k6-works-list.js`
|
||||
|
||||
无 `AUTH_TOKEN` 时,每轮 iteration 顺序请求两个接口:
|
||||
|
||||
1. `GET /api/runtime/puzzle/gallery`
|
||||
2. `GET /api/runtime/custom-world-gallery`
|
||||
|
||||
`DETAIL_RATIO=0` 时不会请求详情。
|
||||
|
||||
`works_list_shape_error_rate` 不只代表字段结构错误,只要下面任意 check 失败都会计入:
|
||||
|
||||
- status is 200
|
||||
- returns json object
|
||||
- has collection
|
||||
- list item shape
|
||||
|
||||
因此 timeout、非 JSON、非 200、响应结构不符合都会表现为 shape error。
|
||||
|
||||
数据文件实际路径:
|
||||
|
||||
- `scripts/loadtest/data/works-list.local.json`
|
||||
|
||||
脚本里 `data/works-list.local.json` 是相对 k6 脚本文件解析的,因此本身合理。
|
||||
|
||||
### 4. 代码层疑似瓶颈
|
||||
|
||||
虽然这次远端服务端日志没有复现慢请求,但代码层仍发现一个真实性能隐患。
|
||||
|
||||
`/api/runtime/puzzle/gallery` 调用链:
|
||||
|
||||
- `server-rs/crates/api-server/src/app.rs:1192`
|
||||
- `server-rs/crates/api-server/src/puzzle.rs:1385-1409`
|
||||
- `server-rs/crates/spacetime-client/src/puzzle.rs:367-381`
|
||||
- `server-rs/crates/spacetime-module/src/puzzle.rs:430-443`
|
||||
- `server-rs/crates/spacetime-module/src/puzzle.rs:1393-1404`
|
||||
|
||||
关键实现:
|
||||
|
||||
- `list_puzzle_gallery_tx` 对 `puzzle_work_profile().iter()` 全表扫描。
|
||||
- 再过滤 `publication_status == Published`。
|
||||
- 对每个公开作品调用 `build_puzzle_work_profile_from_row_with_recent_count`。
|
||||
- 该函数调用 `count_recent_public_work_plays(ctx, "puzzle", &row.profile_id, now_micros)`。
|
||||
|
||||
`count_recent_public_work_plays`:
|
||||
|
||||
- 文件:`server-rs/crates/spacetime-module/src/runtime/profile.rs:1296-1321`
|
||||
- 当前实现对 `public_work_play_daily_stat().iter()` 全表扫描过滤。
|
||||
- 但表定义已有复合索引:
|
||||
- `server-rs/crates/spacetime-module/src/runtime/profile.rs:242-248`
|
||||
- `by_public_work_play_daily_stat_work_day(source_type, profile_id, played_day)`
|
||||
- 当前统计函数未使用该索引。
|
||||
|
||||
复杂度风险:
|
||||
|
||||
```text
|
||||
puzzle gallery ~= O(puzzle_work_profile 全表扫描 + Published作品数 * public_work_play_daily_stat 全表扫描)
|
||||
```
|
||||
|
||||
`custom-world-gallery` 与 puzzle 的差异:
|
||||
|
||||
- custom-world 使用 `CustomWorldGalleryEntry` 公开读模型表。
|
||||
- puzzle 直接从 `puzzle_work_profile` 即席拼装。
|
||||
- 两者都调用 recent count,但 puzzle 更容易受作品表规模和统计表规模影响。
|
||||
|
||||
## 当前判断
|
||||
|
||||
本次排查有两个层面的结论:
|
||||
|
||||
1. 生产服务端日志没有证明 `puzzle/gallery` 在 04:50-05:05 窗口真的 30s 慢或 5xx。
|
||||
- api-server 记录的 p95 只有 50ms。
|
||||
- nginx 看到两个接口都是 200。
|
||||
- 所以 k6 侧的 30s timeout 需要进一步从客户端网络、连接池、Windows/k6 执行环境、summary 混合统计角度验证。
|
||||
|
||||
2. 代码层确实存在可修的性能隐患。
|
||||
- `count_recent_public_work_plays` 未使用已有索引。
|
||||
- puzzle gallery 对每个作品重复做 recent count。
|
||||
- puzzle gallery 未使用 `publication_status` 索引或读模型。
|
||||
|
||||
## 建议下一步
|
||||
|
||||
### A. 先处理服务器 PM2 重启风暴
|
||||
|
||||
建议确认旧 Node 服务是否仍需要。
|
||||
|
||||
如果不需要,应停止并禁用 PM2 中的旧 `server-node`:
|
||||
|
||||
```bash
|
||||
PM2_HOME=/home/ubuntu/.pm2 pm2 stop genarrative
|
||||
PM2_HOME=/home/ubuntu/.pm2 pm2 delete genarrative
|
||||
PM2_HOME=/home/ubuntu/.pm2 pm2 save
|
||||
```
|
||||
|
||||
这是生产侧操作,执行前需要确认。
|
||||
|
||||
### B. 单接口短压验证客户端/服务端不一致
|
||||
|
||||
不要继续用混合脚本大压。
|
||||
|
||||
建议新增或临时使用单接口 k6 脚本,分别只测:
|
||||
|
||||
- `/api/runtime/puzzle/gallery`
|
||||
- `/api/runtime/custom-world-gallery`
|
||||
|
||||
并在同一时间窗口并行采集:
|
||||
|
||||
- k6 客户端 summary
|
||||
- nginx access 请求数/状态码
|
||||
- api-server journal latency
|
||||
- 本机到服务器网络错误/timeout
|
||||
|
||||
目标是确认 timeout 是不是发生在客户端侧连接/网络,而不是服务端处理慢。
|
||||
|
||||
### C. 修复代码性能隐患
|
||||
|
||||
优先级建议:
|
||||
|
||||
1. `count_recent_public_work_plays` 改为使用 `by_public_work_play_daily_stat_work_day` 复合索引,或至少改成批量统计,避免 N 次全表扫描。
|
||||
2. `list_puzzle_gallery_tx` 使用 `by_puzzle_work_publication_status` 索引查询 Published,或参考 custom-world 建立 `puzzle_gallery_entry` 公开读模型。
|
||||
3. gallery 列表页不要实时逐条扫描统计表,可维护读模型或批量聚合 `recent_play_count_7d`。
|
||||
|
||||
### D. 调整 k6 脚本输出
|
||||
|
||||
建议 k6 summary 按 endpoint tag 输出或新增单接口模式,否则 overall 指标会把 puzzle/custom-world 混在一起。
|
||||
|
||||
建议增加:
|
||||
|
||||
- `ENDPOINT=puzzle_gallery_list`
|
||||
- `ENDPOINT=custom_world_gallery_list`
|
||||
|
||||
让脚本只跑一个 endpoint,避免诊断时混淆。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user