清理旧创作模板退役过程记录与死代码
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m17s
Project CI / Backend tests (pull_request) Successful in 4m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m59s
Project CI / Native shell tests (pull_request) Successful in 5m57s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m47s
Project CI / Repository checks (pull_request) Successful in 2m11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m17s
Project CI / Backend tests (pull_request) Successful in 4m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m59s
Project CI / Native shell tests (pull_request) Successful in 5m57s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m47s
Project CI / Repository checks (pull_request) Successful in 2m11s
- 删除一次性 schema guard 退役表白名单、isApprovedRetiredTableDeletion 导出与专属测试,compareTables 收回为内部函数 - 删除 shared-contracts 未挂载的旧玩法契约模块、api-server 旧 story 模块与旧压测资源 scripts/loadtest/ 及对应忽略/排除配置 - 清理 decision-log、pitfalls 中旧表退役、两阶段清表窗口与旧玩法过程记录,保留改写后的通用条目 - 移除权威文档、模块 README 与运维文档中的旧模板、历史表与旧压测叙述
This commit is contained in:
@@ -13,79 +13,6 @@ const tableCatalogPath =
|
||||
const bindingsRoot = 'server-rs/crates/spacetime-client/src/module_bindings/';
|
||||
const allowBreaking = process.env.SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING === '1';
|
||||
|
||||
// 本次历史表退役的一次性迁移白名单。该清单只允许这些已确认表从基线消失;
|
||||
// 待删除结果进入后续主线基线后必须删除本清单,其他表仍按既有破坏性变更路径失败。
|
||||
export const APPROVED_RETIRED_TABLE_DELETIONS = Object.freeze([
|
||||
'player_progression',
|
||||
'chapter_progression',
|
||||
'npc_state',
|
||||
'story_session',
|
||||
'story_event',
|
||||
'inventory_slot',
|
||||
'battle_state',
|
||||
'treasure_record',
|
||||
'quest_record',
|
||||
'quest_log',
|
||||
'custom_world_profile',
|
||||
'custom_world_session',
|
||||
'custom_world_agent_session',
|
||||
'custom_world_agent_message',
|
||||
'custom_world_agent_operation',
|
||||
'custom_world_draft_card',
|
||||
'custom_world_gallery_entry',
|
||||
'puzzle_agent_session',
|
||||
'puzzle_background_compile_task',
|
||||
'puzzle_agent_message',
|
||||
'puzzle_work_profile',
|
||||
'puzzle_event',
|
||||
'puzzle_runtime_run',
|
||||
'puzzle_leaderboard_entry',
|
||||
'puzzle_clear_agent_session',
|
||||
'puzzle_clear_work_profile',
|
||||
'puzzle_clear_runtime_run',
|
||||
'puzzle_clear_event',
|
||||
'bark_battle_draft_config',
|
||||
'bark_battle_published_config',
|
||||
'bark_battle_runtime_run',
|
||||
'bark_battle_score_record',
|
||||
'bark_battle_leaderboard_entry',
|
||||
'bark_battle_work_stats_projection',
|
||||
'bark_battle_personal_best_projection',
|
||||
'match3d_agent_session',
|
||||
'match3d_agent_message',
|
||||
'match_3_d_work_profile',
|
||||
'match3d_runtime_run',
|
||||
'jump_hop_agent_session',
|
||||
'jump_hop_work_profile',
|
||||
'jump_hop_runtime_run',
|
||||
'jump_hop_event',
|
||||
'jump_hop_leaderboard_entry',
|
||||
'wooden_fish_agent_session',
|
||||
'wooden_fish_work_profile',
|
||||
'wooden_fish_runtime_run',
|
||||
'wooden_fish_event',
|
||||
'square_hole_agent_session',
|
||||
'square_hole_agent_message',
|
||||
'square_hole_work_profile',
|
||||
'square_hole_runtime_run',
|
||||
'visual_novel_agent_session',
|
||||
'visual_novel_agent_message',
|
||||
'visual_novel_work_profile',
|
||||
'visual_novel_runtime_run',
|
||||
'visual_novel_runtime_history_entry',
|
||||
'visual_novel_runtime_event',
|
||||
'big_fish_creation_session',
|
||||
'big_fish_agent_message',
|
||||
'big_fish_asset_slot',
|
||||
'big_fish_runtime_run',
|
||||
'big_fish_event',
|
||||
]);
|
||||
const approvedRetiredTableDeletions = new Set(APPROVED_RETIRED_TABLE_DELETIONS);
|
||||
|
||||
export function isApprovedRetiredTableDeletion(accessor) {
|
||||
return approvedRetiredTableDeletions.has(accessor);
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
return path.replace(/\\/gu, '/');
|
||||
}
|
||||
@@ -713,7 +640,7 @@ function fieldDescription(field) {
|
||||
return `${field.name}: ${field.type}`;
|
||||
}
|
||||
|
||||
export function compareTables(baseTables, currentTables) {
|
||||
function compareTables(baseTables, currentTables) {
|
||||
const failures = [];
|
||||
let schemaChanged = false;
|
||||
let breakingChanged = false;
|
||||
@@ -722,9 +649,6 @@ export function compareTables(baseTables, currentTables) {
|
||||
const currentTable = currentTables.get(accessor);
|
||||
if (!currentTable) {
|
||||
schemaChanged = true;
|
||||
if (isApprovedRetiredTableDeletion(accessor)) {
|
||||
continue;
|
||||
}
|
||||
breakingChanged = true;
|
||||
failures.push(
|
||||
`${baseTable.path}:${baseTable.line}: SpacetimeDB 表 ${accessor} 被删除或改名。表删除/改名必须先询问用户并确认迁移计划。`,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
APPROVED_RETIRED_TABLE_DELETIONS,
|
||||
collectTablesFromSources,
|
||||
compareTables,
|
||||
isFormalGateEnvironment,
|
||||
listReachableRustFiles,
|
||||
resolveBaseRef,
|
||||
@@ -38,15 +36,6 @@ function createGitResolver(entries: Record<string, string | null>) {
|
||||
return (args: string[]) => entries[args.join(' ')] ?? null;
|
||||
}
|
||||
|
||||
function collectTables(accessors: string[]) {
|
||||
return collectTablesFromSources(
|
||||
accessors.map((accessor, index) => ({
|
||||
path: `${sourceRoot}/schema.rs`,
|
||||
text: tableSource(`Table${index}`, accessor),
|
||||
})),
|
||||
).tables;
|
||||
}
|
||||
|
||||
describe('SpacetimeDB schema guard base resolution', () => {
|
||||
it('prefers an explicit Jenkins-provided base ref', () => {
|
||||
expect(
|
||||
@@ -136,61 +125,3 @@ describe('SpacetimeDB schema guard module reachability', () => {
|
||||
expect(result.failures[0]).toMatch(/table accessor example 重复定义/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpacetimeDB schema guard retired table deletion allowlist', () => {
|
||||
it('allows exactly the 63 approved historical tables to disappear', () => {
|
||||
expect(APPROVED_RETIRED_TABLE_DELETIONS).toHaveLength(63);
|
||||
expect(new Set(APPROVED_RETIRED_TABLE_DELETIONS).size).toBe(63);
|
||||
|
||||
const result = compareTables(
|
||||
collectTables([...APPROVED_RETIRED_TABLE_DELETIONS]),
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(result.failures).toEqual([]);
|
||||
expect(result.schemaChanged).toBe(true);
|
||||
expect(result.breakingChanged).toBe(false);
|
||||
});
|
||||
|
||||
it('still rejects deletion of a table outside the approved list', () => {
|
||||
const result = compareTables(
|
||||
collectTables([APPROVED_RETIRED_TABLE_DELETIONS[0], 'active_table']),
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]).toMatch(/active_table.*被删除或改名/u);
|
||||
expect(result.schemaChanged).toBe(true);
|
||||
expect(result.breakingChanged).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress retained-table field deletion or rename checks', () => {
|
||||
const baseTables = collectTablesFromSources([
|
||||
{
|
||||
path: `${sourceRoot}/ranks.rs`,
|
||||
text: `#[spacetimedb::table(accessor = retained_deleted_field)]\npub struct RetainedDeletedField {\n pub id: u64,\n pub title: String,\n}\n`,
|
||||
},
|
||||
{
|
||||
path: `${sourceRoot}/titles.rs`,
|
||||
text: `#[spacetimedb::table(accessor = retained_renamed_field)]\npub struct RetainedRenamedField {\n pub id: u64,\n pub title: String,\n}\n`,
|
||||
},
|
||||
]).tables;
|
||||
const currentTables = collectTablesFromSources([
|
||||
{
|
||||
path: `${sourceRoot}/ranks.rs`,
|
||||
text: `#[spacetimedb::table(accessor = retained_deleted_field)]\npub struct RetainedDeletedField {\n pub id: u64,\n}\n`,
|
||||
},
|
||||
{
|
||||
path: `${sourceRoot}/titles.rs`,
|
||||
text: `#[spacetimedb::table(accessor = retained_renamed_field)]\npub struct RetainedRenamedField {\n pub id: u64,\n pub name: String,\n}\n`,
|
||||
},
|
||||
]).tables;
|
||||
|
||||
const result = compareTables(baseTables, currentTables);
|
||||
|
||||
expect(result.failures).toHaveLength(2);
|
||||
expect(result.failures.join('\n')).toMatch(/字段数量减少/u);
|
||||
expect(result.failures.join('\n')).toMatch(/字段被删除或改名/u);
|
||||
expect(result.breakingChanged).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
# Genarrative 作品列表 K6 压测
|
||||
|
||||
> 2026-07-18 退役:本文与本目录只保留旧公开作品 / gallery 压测历史,`container:k6` 和 compose `k6` 运行目标已经下线;不得把这些脚本用于当前容量验收或恢复旧业务接口。
|
||||
|
||||
本目录用于对“作品列表/公开广场”读接口做本地压测。数据源来自私有 SpacetimeDB migration,但提取脚本只输出作品 profile 白名单表,并对用户、作者、作品号、asset id 等标识做稳定映射。
|
||||
|
||||
## 文件
|
||||
|
||||
- `extract-works-list-data.mjs`:从 migration JSON 提取作品列表压测数据;本地输出也会脱敏路由 ID,因此默认用于列表接口压测,详情接口需先把同一份脱敏数据导入目标环境。
|
||||
- `k6-works-list.js`:K6 压测脚本。
|
||||
- `data/spacetime-migration-7.local.json`:本地私有原始数据副本,已被 `.gitignore` 忽略,不要提交。
|
||||
- `data/works-list.local.json`:本地脱敏压测数据,已被 `.gitignore` 忽略,不要提交。
|
||||
- `data/works-list.sample.json`:可提交的少量脱敏样例。
|
||||
|
||||
## 数据边界
|
||||
|
||||
允许导入的表:
|
||||
|
||||
- `puzzle_work_profile`
|
||||
- `custom_world_profile`
|
||||
- `match3d_work_profile`
|
||||
- `square_hole_work_profile`
|
||||
- `big_fish_work_profile`
|
||||
- `visual_novel_work_profile`
|
||||
|
||||
明确不导入:
|
||||
|
||||
- 账号/认证:`user_account`、`auth_identity`、`refresh_session`、`auth_store_snapshot`
|
||||
- 钱包/邀请:`profile_wallet_ledger`、`profile_redeem_*`、`profile_invite_*`
|
||||
- 游玩历史/埋点/存档:`public_work_play_daily_stat`、`profile_played_world`、`puzzle_runtime_run`、`profile_save_archive`、`runtime_snapshot`
|
||||
- AI 任务过程:`ai_task`、`ai_task_stage`、`ai_text_chunk`
|
||||
- asset 二进制:`asset_object`、`asset_entity_binding`
|
||||
|
||||
提取脚本会移除 `source_session_id` / `source_agent_session_id` 等会话派生字段;这些字段不属于作品列表卡片压测必要字段。
|
||||
|
||||
## 重新提取数据
|
||||
|
||||
从仓库根目录执行:
|
||||
|
||||
```bash
|
||||
npm run loadtest:extract-works -- \
|
||||
--input scripts/loadtest/data/spacetime-migration-7.local.json \
|
||||
--output scripts/loadtest/data/works-list.local.json \
|
||||
--sample-output scripts/loadtest/data/works-list.sample.json
|
||||
```
|
||||
|
||||
也可以直接执行:
|
||||
|
||||
```bash
|
||||
node scripts/loadtest/extract-works-list-data.mjs \
|
||||
--input scripts/loadtest/data/spacetime-migration-7.local.json \
|
||||
--output scripts/loadtest/data/works-list.local.json \
|
||||
--sample-output scripts/loadtest/data/works-list.sample.json
|
||||
```
|
||||
|
||||
当前 local 全量提取结果:
|
||||
|
||||
- `puzzle_work_profile`: 80
|
||||
- `custom_world_profile`: 1
|
||||
- `match3d_work_profile`: 0
|
||||
- `normalizedWorks`: 81
|
||||
|
||||
当前可提交 sample 结果:
|
||||
|
||||
- `puzzle_work_profile`: 3
|
||||
- `custom_world_profile`: 1
|
||||
- `match3d_work_profile`: 0
|
||||
- `normalizedWorks`: 4
|
||||
|
||||
## 真实接口
|
||||
|
||||
已从 `server-rs/crates/api-server/src/app.rs` 确认的读接口:
|
||||
|
||||
公开接口,无需 Bearer token:
|
||||
|
||||
- `GET /api/runtime/puzzle/gallery`
|
||||
- `GET /api/runtime/puzzle/gallery/{profile_id}`
|
||||
- `GET /api/runtime/custom-world-gallery`
|
||||
- `GET /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}`
|
||||
- `GET /api/runtime/custom-world-gallery/by-code/{code}`
|
||||
|
||||
需要 Bearer token 的个人作品列表接口:
|
||||
|
||||
- `GET /api/runtime/puzzle/works`
|
||||
- `GET /api/runtime/puzzle/works/{profile_id}`
|
||||
- `GET /api/runtime/custom-world/works`
|
||||
|
||||
K6 脚本默认只跑公开列表接口;传入 `AUTH_TOKEN` 后会额外跑需要登录态的个人作品列表接口。当前真实列表 handler 未暴露分页/排序 query 参数,因此脚本不追加 `limit/offset`;若后续接口增加分页参数,再在 K6 中补随机分页。
|
||||
|
||||
详情接口默认不压测,因为本地数据中的 `profile_id` / `owner_user_id` 已脱敏,直接请求未导入脱敏数据的目标服务会 404。只有在目标环境已导入同一份脱敏数据,或改用真实 ID 本地文件时,才设置 `DETAIL_RATIO` 大于 0;详情请求不把 404 视为成功。
|
||||
|
||||
## 启动服务
|
||||
|
||||
按项目约定启动本地 dev 栈:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
注意端口可能漂移。以启动日志中的实际 api-server 端口为准,然后传给 K6。
|
||||
|
||||
注意:K6 的 `open()` 会按 `k6-works-list.js` 所在目录解析相对路径,因此 `WORKS_DATA` 应写成 `data/works-list.local.json`,不要写成 `scripts/loadtest/data/works-list.local.json`。
|
||||
|
||||
Bash / Git Bash:
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:<actual-api-port> WORKS_DATA=data/works-list.local.json npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:BASE_URL="http://127.0.0.1:<actual-api-port>"
|
||||
$env:WORKS_DATA="data/works-list.local.json"
|
||||
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
|
||||
```
|
||||
|
||||
## 50 HTTP req/s 口径
|
||||
|
||||
`k6-works-list.js` 默认一次 iteration 会依次请求两个公开列表接口:`/api/runtime/puzzle/gallery` 和 `/api/runtime/custom-world-gallery`。因此目标约 50 HTTP req/s 时,`ramping-arrival-rate` 的 `PEAK_RPS` 应设置为 `25`。如果传入 `AUTH_TOKEN` 或把 `DETAIL_RATIO` 设为大于 0,每次 iteration 的请求数会增加,需要重新折算。
|
||||
|
||||
验收目标:
|
||||
|
||||
- `http_req_failed < 1%`
|
||||
- `http_req_duration p95 < 2000ms`
|
||||
- `dropped_iterations = 0`
|
||||
- 压测窗口内 Nginx 无新增 502
|
||||
|
||||
## Smoke
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:8787 \
|
||||
WORKS_DATA=data/works-list.local.json \
|
||||
SCENARIO=smoke \
|
||||
DETAIL_RATIO=0 \
|
||||
npm run loadtest:k6:works
|
||||
```
|
||||
|
||||
默认:1 VU / 30s。
|
||||
|
||||
## Baseline
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:8787 \
|
||||
WORKS_DATA=data/works-list.local.json \
|
||||
SCENARIO=baseline \
|
||||
VUS=10 \
|
||||
DURATION=3m \
|
||||
DETAIL_RATIO=0 \
|
||||
npm run loadtest:k6:works
|
||||
```
|
||||
|
||||
默认阈值:
|
||||
|
||||
- `http_req_failed < 1%`
|
||||
- `http_req_duration p95 < 800ms`
|
||||
- `http_req_duration p99 < 1500ms`
|
||||
- `works_list_shape_error_rate < 1%`
|
||||
|
||||
## Spike
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:8787 \
|
||||
WORKS_DATA=data/works-list.local.json \
|
||||
SCENARIO=spike \
|
||||
START_RPS=5 \
|
||||
PEAK_RPS=25 \
|
||||
HOLD=60s \
|
||||
DETAIL_RATIO=0 \
|
||||
npm run loadtest:k6:works
|
||||
```
|
||||
|
||||
默认阈值:
|
||||
|
||||
- `http_req_failed < 1%`
|
||||
- `http_req_duration p95 < 2000ms`
|
||||
- `dropped_iterations = 0`
|
||||
- `works_list_shape_error_rate < 1%`
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:BASE_URL="https://genarrative.world"
|
||||
$env:WORKS_DATA="data/works-list.local.json"
|
||||
$env:SCENARIO="spike"
|
||||
$env:START_RPS="5"
|
||||
$env:PEAK_RPS="25"
|
||||
$env:HOLD="60s"
|
||||
$env:END_RPS="5"
|
||||
$env:DETAIL_RATIO="0"
|
||||
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
|
||||
```
|
||||
|
||||
线上 release 回归可使用同一组环境变量:
|
||||
|
||||
```bash
|
||||
SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works
|
||||
```
|
||||
|
||||
## 带登录态压测个人作品列表
|
||||
|
||||
先通过本地登录或接口获取 access token,然后传入:
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:8787 \
|
||||
AUTH_TOKEN='<access-token>' \
|
||||
SCENARIO=smoke \
|
||||
DETAIL_RATIO=0 \
|
||||
npm run loadtest:k6:works
|
||||
```
|
||||
|
||||
不要把 token 写入仓库文件、README 或 shell history 中可共享的位置。
|
||||
|
||||
## 详情接口压测
|
||||
|
||||
仅当目标环境存在 `WORKS_DATA` 中的同一批 `profileId/ownerUserId` 时启用:
|
||||
|
||||
```bash
|
||||
BASE_URL=http://127.0.0.1:8787 \
|
||||
WORKS_DATA=data/works-list.local.json \
|
||||
SCENARIO=smoke \
|
||||
DETAIL_RATIO=0.35 \
|
||||
npm run loadtest:k6:works
|
||||
```
|
||||
|
||||
如果详情请求返回 404,说明压测数据 ID 未导入目标环境或目标服务数据不一致,应先修正数据源,不要把 404 当成功。
|
||||
|
||||
## 排障
|
||||
|
||||
- 如果公开 gallery 返回 `creation_entry_disabled` 或 503,检查本地 creation entry 配置是否禁用了对应入口。
|
||||
- 如果高压下返回 429,优先确认目标环境是否设置了 `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS` 以及 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS`。429 表示 Nginx 或 api-server 背压已生效,不等同于业务错误;继续看内存、p95、`http_req_failed` 和 OTLP / Nginx timing 判断阈值是否偏低。
|
||||
- 如果直连 `api-server` 压测出现 `connection refused` 或 status 0,说明压力已经打到 TCP 监听 / accept 层;此时同时检查 `GENARRATIVE_API_LISTEN_BACKLOG`、Nginx upstream keepalive 和是否需要在 Nginx 前置限流,不能只靠应用层背压解释。
|
||||
- 如果个人作品列表返回 401,确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。
|
||||
- 如果详情全部 404,确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。
|
||||
|
||||
## 压测窗口采集
|
||||
|
||||
Nginx upstream timing:
|
||||
|
||||
```bash
|
||||
sudo tail -f /var/log/nginx/genarrative.access.log
|
||||
sudo tail -f /var/log/nginx/genarrative.error.log
|
||||
```
|
||||
|
||||
api-server 与 SpacetimeDB 日志:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u genarrative-api.service -f
|
||||
sudo journalctl -u spacetimedb.service -f
|
||||
```
|
||||
|
||||
api-server 的 OpenTelemetry 在生产与容器模板里默认开启。需要临时关闭时,显式把 `GENARRATIVE_OTEL_ENABLED=false`;需要验证 OTLP traces / metrics / logs 时,先在服务器本机启动只监听 `127.0.0.1` 的 `otelcol-contrib` debug exporter:
|
||||
|
||||
```bash
|
||||
npm run otel:debug
|
||||
```
|
||||
|
||||
如果要把本机数据转发给 Rider OpenTelemetry 面板,先在 Rider 的 OpenTelemetry 设置中启用固定 OTLP server port,例如 `17011`,再运行:
|
||||
|
||||
```bash
|
||||
RIDER_OTLP_GRPC_ENDPOINT=127.0.0.1:17011 npm run otel:rider
|
||||
```
|
||||
|
||||
脚本会在 `.codex-temp/otelcol/` 生成临时 collector 配置,默认接收 api-server 发到 `http://127.0.0.1:4318` 的 OTLP HTTP 数据。需要改端口时可设置:
|
||||
|
||||
- `OTELCOL_OTLP_HTTP_ENDPOINT`,默认 `127.0.0.1:4318`
|
||||
- `OTELCOL_OTLP_GRPC_ENDPOINT`,默认 `127.0.0.1:4317`
|
||||
- `RIDER_OTLP_GRPC_ENDPOINT`,默认 `127.0.0.1:17011`
|
||||
- `OTELCOL_BIN`,默认 `otelcol-contrib`
|
||||
|
||||
等价的 debug collector 配置如下:
|
||||
|
||||
```yaml
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 127.0.0.1:4317
|
||||
http:
|
||||
endpoint: 127.0.0.1:4318
|
||||
|
||||
exporters:
|
||||
debug:
|
||||
verbosity: detailed
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
exporters: [debug]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
exporters: [debug]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
exporters: [debug]
|
||||
```
|
||||
|
||||
```bash
|
||||
otelcol-contrib --config /etc/otelcol-contrib/genarrative-debug.yaml
|
||||
```
|
||||
|
||||
然后在 `/etc/genarrative/api-server.env` 中打开:
|
||||
|
||||
```env
|
||||
GENARRATIVE_OTEL_ENABLED=true
|
||||
OTEL_SERVICE_NAME=genarrative-api
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
||||
```
|
||||
|
||||
注意 `api-server` 当前使用 OTLP HTTP exporter,`OTEL_EXPORTER_OTLP_ENDPOINT` 必须指向 Collector 的 HTTP base endpoint `http://127.0.0.1:4318`。不要把它改成 Collector gRPC 端口 `4317`,也不要直接指向 Rider 的 gRPC 端口;Rider 只由 `npm run otel:rider` 启动的 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。
|
||||
|
||||
OTLP logs 是远端观测增量,不替代本地日志;api-server 日志仍看 `journalctl` / `logs/api-server/`,Nginx 日志仍看文件。日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`。
|
||||
|
||||
Rider 的 Logs 面板展示的是 OTLP log event 自身字段,不会自动把父 span 的全部 attributes 摊平到每一条日志。请求完成日志会直接携带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`;更完整的请求链路仍在 Traces 面板中按同一个 trace/span 关联查看。
|
||||
|
||||
压测期间可在 Metrics 面板或 debug exporter 中观察进程内存指标:
|
||||
|
||||
- `process.memory.usage`:进程常驻内存 / RSS。
|
||||
- `process.memory.virtual`:进程虚拟内存;Windows 当前按 `PrivateUsage` 上报,Linux 取 `VmSize`。
|
||||
- `genarrative.process.memory.private`:进程私有内存,Windows 来自 `PrivateUsage`,Linux 近似取 `/proc/self/status` 的 `VmData`。
|
||||
- `process.cpu.time`:进程 user + system 累计 CPU 秒数。
|
||||
- `genarrative.process.cpu.usage_percent`:两次指标采集之间的进程 CPU 使用率;100% 约等于占满 1 个 CPU core。
|
||||
- `process.thread.count`:线程数。
|
||||
- `process.windows.handle.count`:Windows 句柄数。
|
||||
- `process.unix.file_descriptor.count`:Linux 文件描述符数。
|
||||
- `genarrative.http.server.response_bodies.in_flight`:Axum / Hyper 仍持有的响应 body 数;如果内存高但该值很低,说明热点不在业务 handler 生命周期内。
|
||||
- `genarrative.http.server.request_permits.available`:应用层 HTTP 背压剩余 permit 数,带 `pool=default|gallery|detail|admin`;如果目标 pool 未接近 0,说明没有打满对应 `GENARRATIVE_API_*_MAX_CONCURRENT_REQUESTS`。
|
||||
- `genarrative.puzzle_gallery.cache.hits` / `genarrative.puzzle_gallery.cache.stale_hits` / `genarrative.puzzle_gallery.cache.misses` / `genarrative.puzzle_gallery.cache.refreshes_started` / `genarrative.puzzle_gallery.cache.refreshes_failed` / `genarrative.puzzle_gallery.cache.rebuilds`:拼图广场响应缓存 fresh 命中、stale 命中、未命中、后台刷新和重建次数。
|
||||
- `genarrative.puzzle_gallery.cache.rebuild.duration`:拼图广场缓存重建耗时。
|
||||
- `genarrative.puzzle_gallery.cache.data_json_bytes`:拼图广场缓存内预序列化 data JSON 大小。
|
||||
- `genarrative.spacetime.read.calls` / `genarrative.spacetime.read.duration_ms`:SpacetimeDB 订阅本地 cache 读次数和耗时;`read=list_puzzle_gallery` 表示当前路径走 view / local cache,不是 procedure。
|
||||
|
||||
若 `/api/runtime/puzzle/gallery` 单接口压测出现 GB 级瞬时内存峰值,先区分“持续泄漏”和“请求期分配峰值”:关闭 OTEL 后若峰值仍复现且压测结束后回落,主因通常不是 Collector / exporter。当前拼图广场列表命中缓存时应复用 `PuzzleGalleryCache` 中的预序列化 data JSON,只按请求拼接 envelope meta,不应每个请求重新深拷贝 `PuzzleGalleryResponse` 或构造完整 `serde_json::Value`。
|
||||
|
||||
本地 Windows 直连 `api-server` 压测还要单独看 K6 的 VU / 连接模型。已验证在 250 RPS、`PREALLOCATED_VUS=300` 时,哪怕打 `/healthz` 这种小响应,也可能因为本地 300 个 Established 连接触发 `api-server` private memory 瞬时升到约 7GB,压测结束后回落到 100MB 级;同样 250 RPS 改成 `PREALLOCATED_VUS=20 MAX_VUS=40` 后,拼图广场 p95 约 9ms,峰值降到约 600MB。这个现象说明高水位主要来自本机直连连接 / 发送链路,不等价于 SpacetimeDB 或拼图 JSON 缓存泄漏。做本地容量判断时优先让 VU 接近真实并发,避免用过高预分配 VU 把测试变成 Windows 本机连接缓冲压力测试;生产仍以 Nginx upstream keepalive、系统内存和 OTLP 指标一起判断。
|
||||
|
||||
线上回归辅助命令:
|
||||
|
||||
```bash
|
||||
systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax
|
||||
cat /proc/$(pidof api-server)/limits
|
||||
tr '\0' '\n' < /proc/$(pidof api-server)/environ | grep 'GENARRATIVE_API_.*MAX_CONCURRENT_REQUESTS'
|
||||
ss -ltnp | grep 8082
|
||||
curl -sS http://127.0.0.1:8082/healthz
|
||||
```
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
npx vitest run scripts/loadtest/extract-works-list-data.test.ts
|
||||
npx eslint scripts/loadtest/extract-works-list-data.mjs scripts/loadtest/extract-works-list-data.test.ts scripts/loadtest/k6-works-list.js
|
||||
```
|
||||
@@ -1,208 +0,0 @@
|
||||
{
|
||||
"source": "spacetime-migration-1.json",
|
||||
"generatedAt": "2026-05-16T13:35:40.282Z",
|
||||
"counts": {
|
||||
"puzzle_work_profile": 3,
|
||||
"custom_world_profile": 1,
|
||||
"match3d_work_profile": 0,
|
||||
"square_hole_work_profile": 0,
|
||||
"visual_novel_work_profile": 0
|
||||
},
|
||||
"tables": {
|
||||
"puzzle_work_profile": [
|
||||
{
|
||||
"profile_id": "profile-001",
|
||||
"work_id": "work-001",
|
||||
"owner_user_id": "user-001",
|
||||
"author_display_name": "author-001",
|
||||
"cover_asset_id": "asset-001",
|
||||
"cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
|
||||
"work_title": "化学家",
|
||||
"level_name": "文学家",
|
||||
"summary": "几个文学家正站在山上面对着瀑布侃侃而谈",
|
||||
"work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室",
|
||||
"levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…",
|
||||
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…",
|
||||
"theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]",
|
||||
"publication_status": {
|
||||
"Published": []
|
||||
},
|
||||
"play_count": 1,
|
||||
"like_count": 0,
|
||||
"remix_count": 1,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777703338322544
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777648804043558
|
||||
},
|
||||
"published_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777649364112270
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "profile-002",
|
||||
"work_id": "work-002",
|
||||
"owner_user_id": "user-002",
|
||||
"author_display_name": "author-002",
|
||||
"work_title": "我不知道",
|
||||
"level_name": "",
|
||||
"summary": "你猜我是谁",
|
||||
"work_description": "你猜我是谁",
|
||||
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
|
||||
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}",
|
||||
"theme_tags_json": "[\"我不知道\"]",
|
||||
"publication_status": {
|
||||
"Draft": []
|
||||
},
|
||||
"play_count": 0,
|
||||
"like_count": 0,
|
||||
"remix_count": 0,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777619351714201
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777619336673245
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "profile-003",
|
||||
"work_id": "work-003",
|
||||
"owner_user_id": "user-003",
|
||||
"author_display_name": "author-002",
|
||||
"work_title": "",
|
||||
"level_name": "",
|
||||
"summary": "",
|
||||
"work_description": "",
|
||||
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
|
||||
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}",
|
||||
"theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]",
|
||||
"publication_status": {
|
||||
"Draft": []
|
||||
},
|
||||
"play_count": 0,
|
||||
"like_count": 0,
|
||||
"remix_count": 0,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||
}
|
||||
}
|
||||
],
|
||||
"custom_world_profile": [
|
||||
{
|
||||
"profile_id": "profile-081",
|
||||
"owner_user_id": "user-002",
|
||||
"author_display_name": "author-012",
|
||||
"author_public_user_code": "author-code-001",
|
||||
"world_name": "青春飞扬校园",
|
||||
"summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长",
|
||||
"subtitle": "反内卷的自由学习之旅",
|
||||
"profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…",
|
||||
"publication_status": {
|
||||
"Draft": []
|
||||
},
|
||||
"play_count": 0,
|
||||
"like_count": 0,
|
||||
"remix_count": 0,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777532006629209
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777531745887256
|
||||
}
|
||||
}
|
||||
],
|
||||
"match3d_work_profile": [],
|
||||
"square_hole_work_profile": [],
|
||||
"visual_novel_work_profile": []
|
||||
},
|
||||
"profileIds": {
|
||||
"puzzle": ["profile-001", "profile-002", "profile-003"],
|
||||
"customWorld": ["profile-081"],
|
||||
"match3d": [],
|
||||
"squareHole": [],
|
||||
"bigFish": [],
|
||||
"visualNovel": []
|
||||
},
|
||||
"workIds": {
|
||||
"puzzle": ["work-001", "work-002", "work-003"],
|
||||
"customWorld": [],
|
||||
"match3d": [],
|
||||
"squareHole": [],
|
||||
"bigFish": [],
|
||||
"visualNovel": []
|
||||
},
|
||||
"normalizedWorks": [
|
||||
{
|
||||
"type": "puzzle",
|
||||
"workId": "work-001",
|
||||
"profileId": "profile-001",
|
||||
"ownerUserId": "user-001",
|
||||
"title": "化学家",
|
||||
"subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈",
|
||||
"publicationStatus": {
|
||||
"Published": []
|
||||
},
|
||||
"playCount": 1,
|
||||
"likeCount": 0,
|
||||
"remixCount": 1,
|
||||
"coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777703338322544
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "puzzle",
|
||||
"workId": "work-002",
|
||||
"profileId": "profile-002",
|
||||
"ownerUserId": "user-002",
|
||||
"title": "我不知道",
|
||||
"subtitle": "你猜我是谁",
|
||||
"publicationStatus": {
|
||||
"Draft": []
|
||||
},
|
||||
"playCount": 0,
|
||||
"likeCount": 0,
|
||||
"remixCount": 0,
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777619351714201
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "puzzle",
|
||||
"workId": "work-003",
|
||||
"profileId": "profile-003",
|
||||
"ownerUserId": "user-003",
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"publicationStatus": {
|
||||
"Draft": []
|
||||
},
|
||||
"playCount": 0,
|
||||
"likeCount": 0,
|
||||
"remixCount": 0,
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "customWorld",
|
||||
"profileId": "profile-081",
|
||||
"ownerUserId": "user-002",
|
||||
"title": "青春飞扬校园",
|
||||
"subtitle": "反内卷的自由学习之旅",
|
||||
"publicationStatus": {
|
||||
"Draft": []
|
||||
},
|
||||
"playCount": 0,
|
||||
"likeCount": 0,
|
||||
"remixCount": 0,
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777532006629209
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
{
|
||||
"source": "spacetime-migration-1.json",
|
||||
"generatedAt": "2026-05-18T11:54:04.280Z",
|
||||
"counts": {
|
||||
"puzzle_work_profile": 3,
|
||||
"custom_world_profile": 1,
|
||||
"match3d_work_profile": 0,
|
||||
"square_hole_work_profile": 0,
|
||||
"visual_novel_work_profile": 0
|
||||
},
|
||||
"tables": {
|
||||
"puzzle_work_profile": [
|
||||
{
|
||||
"profile_id": "profile-001",
|
||||
"work_id": "work-001",
|
||||
"owner_user_id": "user-001",
|
||||
"author_display_name": "author-001",
|
||||
"cover_asset_id": "asset-001",
|
||||
"cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
|
||||
"work_title": "化学家",
|
||||
"level_name": "文学家",
|
||||
"summary": "几个文学家正站在山上面对着瀑布侃侃而谈",
|
||||
"work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室",
|
||||
"levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…",
|
||||
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…",
|
||||
"theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]",
|
||||
"publication_status": {
|
||||
"Published": []
|
||||
},
|
||||
"play_count": 1,
|
||||
"like_count": 0,
|
||||
"remix_count": 1,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777703338322544
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777648804043558
|
||||
},
|
||||
"published_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777649364112270
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "profile-002",
|
||||
"work_id": "work-002",
|
||||
"owner_user_id": "user-002",
|
||||
"author_display_name": "author-002",
|
||||
"work_title": "我不知道",
|
||||
"level_name": "",
|
||||
"summary": "你猜我是谁",
|
||||
"work_description": "你猜我是谁",
|
||||
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
|
||||
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}",
|
||||
"theme_tags_json": "[\"我不知道\"]",
|
||||
"publication_status": {
|
||||
"Draft": []
|
||||
},
|
||||
"play_count": 0,
|
||||
"like_count": 0,
|
||||
"remix_count": 0,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777619351714201
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777619336673245
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "profile-003",
|
||||
"work_id": "work-003",
|
||||
"owner_user_id": "user-003",
|
||||
"author_display_name": "author-002",
|
||||
"work_title": "",
|
||||
"level_name": "",
|
||||
"summary": "",
|
||||
"work_description": "",
|
||||
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
|
||||
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}",
|
||||
"theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]",
|
||||
"publication_status": {
|
||||
"Draft": []
|
||||
},
|
||||
"play_count": 0,
|
||||
"like_count": 0,
|
||||
"remix_count": 0,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||
}
|
||||
}
|
||||
],
|
||||
"custom_world_profile": [
|
||||
{
|
||||
"profile_id": "profile-081",
|
||||
"owner_user_id": "user-002",
|
||||
"author_display_name": "author-012",
|
||||
"author_public_user_code": "author-code-001",
|
||||
"world_name": "青春飞扬校园",
|
||||
"summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长",
|
||||
"subtitle": "反内卷的自由学习之旅",
|
||||
"profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…",
|
||||
"publication_status": {
|
||||
"Draft": []
|
||||
},
|
||||
"play_count": 0,
|
||||
"like_count": 0,
|
||||
"remix_count": 0,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777532006629209
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777531745887256
|
||||
}
|
||||
}
|
||||
],
|
||||
"match3d_work_profile": [],
|
||||
"square_hole_work_profile": [],
|
||||
"visual_novel_work_profile": []
|
||||
},
|
||||
"profileIds": {
|
||||
"puzzle": ["profile-001", "profile-002", "profile-003"],
|
||||
"customWorld": ["profile-081"],
|
||||
"match3d": [],
|
||||
"squareHole": [],
|
||||
"bigFish": [],
|
||||
"visualNovel": []
|
||||
},
|
||||
"workIds": {
|
||||
"puzzle": ["work-001", "work-002", "work-003"],
|
||||
"customWorld": [],
|
||||
"match3d": [],
|
||||
"squareHole": [],
|
||||
"bigFish": [],
|
||||
"visualNovel": []
|
||||
},
|
||||
"normalizedWorks": [
|
||||
{
|
||||
"type": "puzzle",
|
||||
"workId": "work-001",
|
||||
"profileId": "profile-001",
|
||||
"ownerUserId": "user-001",
|
||||
"title": "化学家",
|
||||
"subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈",
|
||||
"publicationStatus": {
|
||||
"Published": []
|
||||
},
|
||||
"playCount": 1,
|
||||
"likeCount": 0,
|
||||
"remixCount": 1,
|
||||
"coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777703338322544
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "puzzle",
|
||||
"workId": "work-002",
|
||||
"profileId": "profile-002",
|
||||
"ownerUserId": "user-002",
|
||||
"title": "我不知道",
|
||||
"subtitle": "你猜我是谁",
|
||||
"publicationStatus": {
|
||||
"Draft": []
|
||||
},
|
||||
"playCount": 0,
|
||||
"likeCount": 0,
|
||||
"remixCount": 0,
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777619351714201
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "puzzle",
|
||||
"workId": "work-003",
|
||||
"profileId": "profile-003",
|
||||
"ownerUserId": "user-003",
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"publicationStatus": {
|
||||
"Draft": []
|
||||
},
|
||||
"playCount": 0,
|
||||
"likeCount": 0,
|
||||
"remixCount": 0,
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "customWorld",
|
||||
"profileId": "profile-081",
|
||||
"ownerUserId": "user-002",
|
||||
"title": "青春飞扬校园",
|
||||
"subtitle": "反内卷的自由学习之旅",
|
||||
"publicationStatus": {
|
||||
"Draft": []
|
||||
},
|
||||
"playCount": 0,
|
||||
"likeCount": 0,
|
||||
"remixCount": 0,
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777532006629209
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ALLOWED_TABLES = new Set([
|
||||
'puzzle_work_profile',
|
||||
'custom_world_profile',
|
||||
'match3d_work_profile',
|
||||
'square_hole_work_profile',
|
||||
'big_fish_work_profile',
|
||||
'visual_novel_work_profile',
|
||||
]);
|
||||
|
||||
const WORK_TABLE_TYPES = {
|
||||
puzzle_work_profile: 'puzzle',
|
||||
custom_world_profile: 'customWorld',
|
||||
match3d_work_profile: 'match3d',
|
||||
square_hole_work_profile: 'squareHole',
|
||||
big_fish_work_profile: 'bigFish',
|
||||
visual_novel_work_profile: 'visualNovel',
|
||||
};
|
||||
|
||||
const TABLE_OUTPUT_ORDER = [
|
||||
'puzzle_work_profile',
|
||||
'custom_world_profile',
|
||||
'match3d_work_profile',
|
||||
'square_hole_work_profile',
|
||||
'big_fish_work_profile',
|
||||
'visual_novel_work_profile',
|
||||
];
|
||||
|
||||
const WORK_TYPES = [
|
||||
'puzzle',
|
||||
'customWorld',
|
||||
'match3d',
|
||||
'squareHole',
|
||||
'bigFish',
|
||||
'visualNovel',
|
||||
];
|
||||
const SHORT_TEXT_LIMIT = 120;
|
||||
const LONG_TEXT_LIMIT = 500;
|
||||
const SENSITIVE_PATTERN =
|
||||
/(token|secret|password|passwd|phone|wallet|credential|authorization|auth[_-]?key|api[_-]?key)/giu;
|
||||
|
||||
class StableMapper {
|
||||
constructor(prefix) {
|
||||
this.prefix = prefix;
|
||||
this.values = new Map();
|
||||
}
|
||||
|
||||
map(value) {
|
||||
if (value === undefined || value === null || value === '') return value;
|
||||
const key = String(value);
|
||||
if (!this.values.has(key)) {
|
||||
this.values.set(
|
||||
key,
|
||||
`${this.prefix}-${String(this.values.size + 1).padStart(3, '0')}`,
|
||||
);
|
||||
}
|
||||
return this.values.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
function createContext() {
|
||||
return {
|
||||
user: new StableMapper('user'),
|
||||
session: new StableMapper('session'),
|
||||
author: new StableMapper('author'),
|
||||
authorCode: new StableMapper('author-code'),
|
||||
publicWorkCode: new StableMapper('public-work-code'),
|
||||
coverAsset: new StableMapper('asset'),
|
||||
work: new StableMapper('work'),
|
||||
profile: new StableMapper('profile'),
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkTypeBuckets() {
|
||||
return Object.fromEntries(WORK_TYPES.map((type) => [type, []]));
|
||||
}
|
||||
|
||||
function unwrapSpacetimeOption(value) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 1
|
||||
) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'some')) return value.some;
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'none')) return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function truncateText(value, limit) {
|
||||
if (value === undefined || value === null) return value;
|
||||
const text = String(value).replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= limit) return text;
|
||||
return `${text.slice(0, limit)}…`;
|
||||
}
|
||||
|
||||
function redactSensitiveText(value) {
|
||||
if (value === undefined || value === null) return value;
|
||||
return String(value).replace(SENSITIVE_PATTERN, '[redacted]');
|
||||
}
|
||||
|
||||
function sanitizeCoverImageSrc(value) {
|
||||
const unwrapped = unwrapSpacetimeOption(value);
|
||||
if (unwrapped === undefined || unwrapped === null || unwrapped === '')
|
||||
return unwrapped;
|
||||
const text = String(unwrapped);
|
||||
if (text.startsWith('data:image/')) return '[redacted-data-image]';
|
||||
let withoutQuery = text.split('?')[0].split('#')[0];
|
||||
if (withoutQuery.length > 180)
|
||||
withoutQuery = `${withoutQuery.slice(0, 180)}…`;
|
||||
return withoutQuery;
|
||||
}
|
||||
|
||||
function sanitizeLargeJson(value) {
|
||||
const unwrapped = unwrapSpacetimeOption(value);
|
||||
if (unwrapped === undefined || unwrapped === null) return unwrapped;
|
||||
if (typeof unwrapped === 'string') {
|
||||
return truncateText(redactSensitiveText(unwrapped), LONG_TEXT_LIMIT);
|
||||
}
|
||||
try {
|
||||
return truncateText(
|
||||
redactSensitiveText(JSON.stringify(unwrapped)),
|
||||
LONG_TEXT_LIMIT,
|
||||
);
|
||||
} catch {
|
||||
return truncateText(
|
||||
redactSensitiveText(String(unwrapped)),
|
||||
LONG_TEXT_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function firstDefined(row, keys) {
|
||||
for (const key of keys) {
|
||||
if (row[key] !== undefined && row[key] !== null) return row[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sanitizeShortField(row, sanitized, key) {
|
||||
if (row[key] !== undefined) {
|
||||
sanitized[key] = truncateText(
|
||||
unwrapSpacetimeOption(row[key]),
|
||||
SHORT_TEXT_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeWorkRow(row, ctx) {
|
||||
const sanitized = {};
|
||||
const profileId = unwrapSpacetimeOption(
|
||||
firstDefined(row, ['profile_id', 'profileId']),
|
||||
);
|
||||
const workId = unwrapSpacetimeOption(
|
||||
firstDefined(row, ['work_id', 'workId']),
|
||||
);
|
||||
|
||||
if (profileId !== undefined)
|
||||
sanitized.profile_id = ctx.profile.map(profileId);
|
||||
if (workId !== undefined) sanitized.work_id = ctx.work.map(workId);
|
||||
if (row.owner_user_id !== undefined) {
|
||||
sanitized.owner_user_id = ctx.user.map(
|
||||
unwrapSpacetimeOption(row.owner_user_id),
|
||||
);
|
||||
}
|
||||
if (row.user_id !== undefined)
|
||||
sanitized.user_id = ctx.user.map(unwrapSpacetimeOption(row.user_id));
|
||||
|
||||
if (row.author_display_name !== undefined) {
|
||||
sanitized.author_display_name = ctx.author.map(
|
||||
unwrapSpacetimeOption(row.author_display_name),
|
||||
);
|
||||
}
|
||||
if (row.public_work_code !== undefined) {
|
||||
sanitized.public_work_code = ctx.publicWorkCode.map(
|
||||
unwrapSpacetimeOption(row.public_work_code),
|
||||
);
|
||||
}
|
||||
if (row.author_public_user_code !== undefined) {
|
||||
sanitized.author_public_user_code = ctx.authorCode.map(
|
||||
unwrapSpacetimeOption(row.author_public_user_code),
|
||||
);
|
||||
}
|
||||
if (row.cover_asset_id !== undefined) {
|
||||
sanitized.cover_asset_id = ctx.coverAsset.map(
|
||||
unwrapSpacetimeOption(row.cover_asset_id),
|
||||
);
|
||||
}
|
||||
if (row.cover_image_src !== undefined)
|
||||
sanitized.cover_image_src = sanitizeCoverImageSrc(row.cover_image_src);
|
||||
|
||||
for (const key of [
|
||||
'title',
|
||||
'work_title',
|
||||
'level_name',
|
||||
'world_name',
|
||||
'summary',
|
||||
'summary_text',
|
||||
'description',
|
||||
'work_description',
|
||||
'subtitle',
|
||||
]) {
|
||||
sanitizeShortField(row, sanitized, key);
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
'levels_json',
|
||||
'profile_payload_json',
|
||||
'anchor_pack_json',
|
||||
'theme_tags_json',
|
||||
]) {
|
||||
if (row[key] !== undefined) sanitized[key] = sanitizeLargeJson(row[key]);
|
||||
}
|
||||
|
||||
const passthroughKeys = [
|
||||
'publication_status',
|
||||
'publicationStatus',
|
||||
'play_count',
|
||||
'playCount',
|
||||
'like_count',
|
||||
'likeCount',
|
||||
'remix_count',
|
||||
'remixCount',
|
||||
'updated_at',
|
||||
'created_at',
|
||||
'published_at',
|
||||
'visibility',
|
||||
'status',
|
||||
'category',
|
||||
'tags',
|
||||
];
|
||||
for (const key of passthroughKeys) {
|
||||
if (row[key] !== undefined)
|
||||
sanitized[key] = unwrapSpacetimeOption(row[key]);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function normalizeWork(tableName, row) {
|
||||
const type = WORK_TABLE_TYPES[tableName];
|
||||
return {
|
||||
type,
|
||||
workId: row.work_id,
|
||||
profileId: row.profile_id,
|
||||
ownerUserId: row.owner_user_id,
|
||||
publicWorkCode: row.public_work_code,
|
||||
title: row.title ?? row.work_title ?? row.level_name ?? row.world_name,
|
||||
subtitle:
|
||||
row.subtitle ??
|
||||
row.summary_text ??
|
||||
row.summary ??
|
||||
row.work_description ??
|
||||
row.description,
|
||||
publicationStatus:
|
||||
row.publicationStatus ?? row.publication_status ?? row.status,
|
||||
playCount: row.playCount ?? row.play_count ?? 0,
|
||||
likeCount: row.likeCount ?? row.like_count ?? 0,
|
||||
remixCount: row.remixCount ?? row.remix_count ?? 0,
|
||||
coverImageSrc: row.cover_image_src,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toRowsByTable(input) {
|
||||
const tables = Array.isArray(input?.tables) ? input.tables : [];
|
||||
const result = new Map();
|
||||
for (const table of tables) {
|
||||
if (!ALLOWED_TABLES.has(table?.name)) continue;
|
||||
result.set(table.name, Array.isArray(table.rows) ? table.rows : []);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function extractWorksListData(input, options = {}) {
|
||||
const ctx = createContext();
|
||||
const rowsByTable = toRowsByTable(input);
|
||||
const outputTables = {};
|
||||
const counts = {};
|
||||
const profileIds = createWorkTypeBuckets();
|
||||
const workIds = createWorkTypeBuckets();
|
||||
const normalizedWorks = [];
|
||||
|
||||
for (const tableName of TABLE_OUTPUT_ORDER) {
|
||||
const sourceRows = rowsByTable.get(tableName);
|
||||
if (!sourceRows) continue;
|
||||
const sanitizedRows = sourceRows.map((row) => sanitizeWorkRow(row, ctx));
|
||||
outputTables[tableName] = sanitizedRows;
|
||||
counts[tableName] = sanitizedRows.length;
|
||||
|
||||
const type = WORK_TABLE_TYPES[tableName];
|
||||
if (type) {
|
||||
for (const row of sanitizedRows) {
|
||||
if (row.profile_id) profileIds[type].push(row.profile_id);
|
||||
if (row.work_id) workIds[type].push(row.work_id);
|
||||
normalizedWorks.push(normalizeWork(tableName, row));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
source: options.source ?? 'unknown',
|
||||
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
||||
counts,
|
||||
tables: outputTables,
|
||||
profileIds,
|
||||
workIds,
|
||||
normalizedWorks,
|
||||
};
|
||||
}
|
||||
|
||||
function createSampleOutput(output, maxRowsPerTable = 3) {
|
||||
const tables = {};
|
||||
const counts = {};
|
||||
const allowedWorkIds = new Set();
|
||||
const allowedProfileIds = new Set();
|
||||
|
||||
for (const [tableName, rows] of Object.entries(output.tables)) {
|
||||
tables[tableName] = rows.slice(0, maxRowsPerTable);
|
||||
counts[tableName] = tables[tableName].length;
|
||||
const type = WORK_TABLE_TYPES[tableName];
|
||||
if (type) {
|
||||
for (const row of tables[tableName]) {
|
||||
if (row.work_id) allowedWorkIds.add(row.work_id);
|
||||
if (row.profile_id) allowedProfileIds.add(row.profile_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const profileIds = Object.fromEntries(
|
||||
Object.entries(output.profileIds).map(([type, ids]) => [
|
||||
type,
|
||||
ids.filter((id) => allowedProfileIds.has(id)).slice(0, maxRowsPerTable),
|
||||
]),
|
||||
);
|
||||
const workIds = Object.fromEntries(
|
||||
Object.entries(output.workIds).map(([type, ids]) => [
|
||||
type,
|
||||
ids.filter((id) => allowedWorkIds.has(id)).slice(0, maxRowsPerTable),
|
||||
]),
|
||||
);
|
||||
const normalizedWorks = output.normalizedWorks
|
||||
.filter(
|
||||
(work) =>
|
||||
allowedWorkIds.has(work.workId) ||
|
||||
allowedProfileIds.has(work.profileId),
|
||||
)
|
||||
.slice(0, maxRowsPerTable * 6);
|
||||
|
||||
return {
|
||||
...output,
|
||||
counts,
|
||||
tables,
|
||||
profileIds,
|
||||
workIds,
|
||||
normalizedWorks,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--input' || arg === '--output' || arg === '--sample-output') {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--'))
|
||||
throw new Error(`${arg} requires a value`);
|
||||
args[arg.slice(2)] = value;
|
||||
index += 1;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
args.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return 'Usage: node scripts/loadtest/extract-works-list-data.mjs --input <migration.json> --output <works-list.local.json> [--sample-output <works-list.sample.json>]';
|
||||
}
|
||||
|
||||
export async function runCli(argv = process.argv.slice(2)) {
|
||||
const args = parseArgs(argv);
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
if (!args.input) throw new Error('Missing required --input. ' + usage());
|
||||
if (!args.output) throw new Error('Missing required --output. ' + usage());
|
||||
|
||||
const raw = await readFile(args.input, 'utf8');
|
||||
const migration = JSON.parse(raw);
|
||||
const output = extractWorksListData(migration, {
|
||||
source: basename(args.input),
|
||||
});
|
||||
await writeFile(args.output, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (args['sample-output']) {
|
||||
const sample = createSampleOutput(output);
|
||||
await writeFile(
|
||||
args['sample-output'],
|
||||
`${JSON.stringify(sample, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`works-list extracted: source=${output.source}, tables=${Object.keys(output.tables).length}, normalizedWorks=${output.normalizedWorks.length}`,
|
||||
);
|
||||
for (const [tableName, count] of Object.entries(output.counts)) {
|
||||
console.log(` ${tableName}: ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
const isDirectRun =
|
||||
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isDirectRun) {
|
||||
runCli().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractWorksListData } from './extract-works-list-data.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL('./extract-works-list-data.mjs', import.meta.url),
|
||||
);
|
||||
|
||||
const fixtureMigration = {
|
||||
schema_version: 7,
|
||||
tables: [
|
||||
{
|
||||
name: 'puzzle_work_profile',
|
||||
rows: [
|
||||
{
|
||||
profile_id: 'profile-real-aaa',
|
||||
work_id: 'work-real-aaa',
|
||||
owner_user_id: 'owner-secret-123',
|
||||
author_display_name: 'Alice Secret',
|
||||
author_public_user_code: 'author-code-secret',
|
||||
public_work_code: 'public-code-secret',
|
||||
title: '超长标题'.repeat(20),
|
||||
summary: 'summary '.repeat(80),
|
||||
description: 'description '.repeat(120),
|
||||
publication_status: 'published',
|
||||
play_count: 42,
|
||||
like_count: 7,
|
||||
cover_asset_id: { some: 'asset-secret-cover' },
|
||||
cover_image_src: {
|
||||
some: 'https://cdn.example.test/cover.png?token=***&sig=abc',
|
||||
},
|
||||
levels_json: JSON.stringify({
|
||||
secret: 'level-token-value',
|
||||
data: 'x'.repeat(2000),
|
||||
}),
|
||||
theme_tags_json: JSON.stringify(['化学家', '实验室']),
|
||||
remix_count: 2,
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
profile_id: 'profile-real-bbb',
|
||||
work_id: 'work-real-bbb',
|
||||
owner_user_id: 'owner-secret-123',
|
||||
author_display_name: 'Alice Secret',
|
||||
publication_status: 'draft',
|
||||
play_count: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'custom_world_profile',
|
||||
rows: [
|
||||
{
|
||||
profile_id: 'world-profile-secret',
|
||||
work_id: 'world-work-secret',
|
||||
owner_user_id: 'world-owner-secret',
|
||||
title: '世界作品',
|
||||
profile_payload_json: '{"large":"' + 'y'.repeat(2000) + '"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'public_work_play_daily_stat',
|
||||
rows: [
|
||||
{
|
||||
source_type: 'puzzle',
|
||||
profile_id: 'profile-real-aaa',
|
||||
owner_user_id: 'owner-secret-123',
|
||||
user_id: 'player-secret-456',
|
||||
source_session_id: 'session-secret-789',
|
||||
played_day: '2026-05-01',
|
||||
play_count: 12,
|
||||
updated_at: '2026-05-02T00:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'user_account',
|
||||
rows: [
|
||||
{
|
||||
user_id: 'owner-secret-123',
|
||||
phone: '+8613800138000',
|
||||
auth_token: 'auth-token-secret',
|
||||
wallet_balance: 999,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'refresh_session',
|
||||
rows: [
|
||||
{
|
||||
token: 'refresh-token-secret',
|
||||
source_session_id: 'session-secret-789',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'profile_wallet_ledger',
|
||||
rows: [{ wallet_id: 'wallet-secret', amount: 100 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function withTempDir(fn) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'works-list-test-'));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('extractWorksListData', () => {
|
||||
it('只保留作品 profile 白名单表,禁用的行为/敏感表不会出现在输出 JSON 字符串中', () => {
|
||||
const output = extractWorksListData(fixtureMigration, {
|
||||
source: 'fixture.local.json',
|
||||
});
|
||||
const serialized = JSON.stringify(output);
|
||||
|
||||
expect(Object.keys(output.tables).sort()).toEqual([
|
||||
'custom_world_profile',
|
||||
'puzzle_work_profile',
|
||||
]);
|
||||
expect(serialized).not.toContain('public_work_play_daily_stat');
|
||||
expect(serialized).not.toContain('user_account');
|
||||
expect(serialized).not.toContain('refresh_session');
|
||||
expect(serialized).not.toContain('profile_wallet_ledger');
|
||||
expect(serialized).not.toContain('+8613800138000');
|
||||
expect(serialized).not.toContain('auth-token-secret');
|
||||
expect(serialized).not.toContain('wallet-secret');
|
||||
});
|
||||
|
||||
it('不会输出 owner/user/session/auth/token/phone/wallet 等敏感原值,owner 稳定映射', () => {
|
||||
const output = extractWorksListData(fixtureMigration, {
|
||||
source: 'fixture.local.json',
|
||||
});
|
||||
const serialized = JSON.stringify(output);
|
||||
|
||||
for (const secret of [
|
||||
'owner-secret-123',
|
||||
'player-secret-456',
|
||||
'session-secret-789',
|
||||
'Alice Secret',
|
||||
'author-code-secret',
|
||||
'public-code-secret',
|
||||
'asset-secret-cover',
|
||||
'SECRET_TOKEN',
|
||||
]) {
|
||||
expect(serialized).not.toContain(secret);
|
||||
}
|
||||
|
||||
expect(output.tables.puzzle_work_profile[0].owner_user_id).toBe('user-001');
|
||||
expect(output.tables.puzzle_work_profile[1].owner_user_id).toBe('user-001');
|
||||
expect(output.tables.puzzle_work_profile[0].author_display_name).toBe(
|
||||
'author-001',
|
||||
);
|
||||
expect(serialized).not.toContain('level-token-value');
|
||||
});
|
||||
|
||||
it('puzzle 数据生成 profileIds/workIds 和 normalizedWorks,并保留列表展示字段', () => {
|
||||
const output = extractWorksListData(fixtureMigration, {
|
||||
source: 'fixture.local.json',
|
||||
});
|
||||
|
||||
expect(output.source).toBe('fixture.local.json');
|
||||
expect(output.generatedAt).toEqual(expect.any(String));
|
||||
expect(output.counts.puzzle_work_profile).toBe(2);
|
||||
expect(output.profileIds.puzzle).toEqual(['profile-001', 'profile-002']);
|
||||
expect(output.workIds.puzzle).toEqual(['work-001', 'work-002']);
|
||||
expect(output.normalizedWorks).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'puzzle',
|
||||
workId: 'work-001',
|
||||
profileId: 'profile-001',
|
||||
publicationStatus: 'published',
|
||||
playCount: 42,
|
||||
title: expect.any(String),
|
||||
remixCount: 2,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(output.tables.puzzle_work_profile[0].cover_image_src).toBe(
|
||||
'https://cdn.example.test/cover.png',
|
||||
);
|
||||
expect(output.tables.puzzle_work_profile[0].theme_tags_json).toBe(
|
||||
'["化学家","实验室"]',
|
||||
);
|
||||
});
|
||||
|
||||
it('data image、URL token 和绝对输入路径不会泄露到输出', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const input = path.join(dir, 'migration.local.json');
|
||||
const output = path.join(dir, 'works-list.local.json');
|
||||
await writeFile(
|
||||
input,
|
||||
JSON.stringify({
|
||||
tables: [
|
||||
{
|
||||
name: 'puzzle_work_profile',
|
||||
rows: [
|
||||
{
|
||||
profile_id: 'profile-real',
|
||||
work_id: 'work-real',
|
||||
cover_image_src: {
|
||||
some: 'data:image/png;base64,SECRET_IMAGE_BYTES',
|
||||
},
|
||||
levels_json: JSON.stringify({
|
||||
token: 'SECRET_TOKEN_VALUE',
|
||||
title: 'safe',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await execFileAsync(process.execPath, [
|
||||
scriptPath,
|
||||
'--input',
|
||||
input,
|
||||
'--output',
|
||||
output,
|
||||
]);
|
||||
const extracted = JSON.parse(await readFile(output, 'utf8'));
|
||||
const serialized = JSON.stringify(extracted);
|
||||
|
||||
expect(extracted.source).toBe('migration.local.json');
|
||||
expect(serialized).not.toContain(dir);
|
||||
expect(serialized).not.toContain('SECRET_IMAGE_BYTES');
|
||||
expect(serialized).not.toContain('SECRET_TOKEN_VALUE');
|
||||
expect(extracted.tables.puzzle_work_profile[0].cover_image_src).toBe(
|
||||
'[redacted-data-image]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('sample-output 只输出少量脱敏样例', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const input = path.join(dir, 'migration.local.json');
|
||||
const output = path.join(dir, 'works-list.local.json');
|
||||
const sampleOutput = path.join(dir, 'works-list.sample.json');
|
||||
const manyRows = Array.from({ length: 5 }, (_, index) => ({
|
||||
profile_id: `profile-real-${index}`,
|
||||
work_id: `work-real-${index}`,
|
||||
owner_user_id: `owner-secret-${index}`,
|
||||
title: `作品 ${index}`,
|
||||
publication_status: 'published',
|
||||
play_count: index,
|
||||
}));
|
||||
await writeFile(
|
||||
input,
|
||||
JSON.stringify({
|
||||
tables: [{ name: 'puzzle_work_profile', rows: manyRows }],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await execFileAsync(process.execPath, [
|
||||
scriptPath,
|
||||
'--input',
|
||||
input,
|
||||
'--output',
|
||||
output,
|
||||
'--sample-output',
|
||||
sampleOutput,
|
||||
]);
|
||||
const sample = JSON.parse(await readFile(sampleOutput, 'utf8'));
|
||||
const serialized = JSON.stringify(sample);
|
||||
|
||||
expect(sample.tables.puzzle_work_profile).toHaveLength(3);
|
||||
expect(sample.normalizedWorks).toHaveLength(3);
|
||||
expect(serialized).not.toContain('owner-secret-0');
|
||||
expect(serialized).not.toContain('work-real-0');
|
||||
});
|
||||
});
|
||||
|
||||
it('CLI 参数缺失时退出非 0 并输出清晰错误', async () => {
|
||||
await expect(
|
||||
execFileAsync(process.execPath, [scriptPath, '--input', 'missing.json']),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining('--output'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,278 +0,0 @@
|
||||
/* global __ENV */
|
||||
import { check, sleep } from 'k6';
|
||||
import { SharedArray } from 'k6/data';
|
||||
import http from 'k6/http';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
|
||||
// k6 resolves open() paths relative to this script file, not the shell cwd.
|
||||
const DEFAULT_WORKS_DATA = 'data/works-list.local.json';
|
||||
const WORKS_DATA = __ENV.WORKS_DATA || DEFAULT_WORKS_DATA;
|
||||
const BASE_URL = (__ENV.BASE_URL || 'http://127.0.0.1:8787').replace(
|
||||
/\/+$/u,
|
||||
'',
|
||||
);
|
||||
const AUTH_TOKEN = __ENV.AUTH_TOKEN || '';
|
||||
const SCENARIO = __ENV.SCENARIO || 'smoke';
|
||||
const REQUEST_TIMEOUT = __ENV.REQUEST_TIMEOUT || '30s';
|
||||
const SLEEP_MIN_SECONDS = Number(__ENV.SLEEP_MIN_SECONDS || '0.5');
|
||||
const SLEEP_MAX_SECONDS = Number(__ENV.SLEEP_MAX_SECONDS || '2');
|
||||
const DETAIL_RATIO = Number(__ENV.DETAIL_RATIO || '0');
|
||||
|
||||
const worksListShapeErrorRate = new Rate('works_list_shape_error_rate');
|
||||
const worksDetailShapeErrorRate = new Rate('works_detail_shape_error_rate');
|
||||
const worksListDuration = new Trend('works_list_duration');
|
||||
const worksDetailDuration = new Trend('works_detail_duration');
|
||||
|
||||
const data = new SharedArray('works-list-data', () => [
|
||||
JSON.parse(open(WORKS_DATA)),
|
||||
])[0];
|
||||
const normalizedWorks = Array.isArray(data.normalizedWorks)
|
||||
? data.normalizedWorks
|
||||
: [];
|
||||
|
||||
const scenarioOptions = {
|
||||
smoke: {
|
||||
scenarios: {
|
||||
smoke: {
|
||||
executor: 'constant-vus',
|
||||
vus: Number(__ENV.VUS || 1),
|
||||
duration: __ENV.DURATION || '30s',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<0.01'],
|
||||
http_req_duration: ['p(95)<800'],
|
||||
works_list_shape_error_rate: ['rate<0.01'],
|
||||
},
|
||||
},
|
||||
baseline: {
|
||||
scenarios: {
|
||||
baseline: {
|
||||
executor: 'constant-vus',
|
||||
vus: Number(__ENV.VUS || 10),
|
||||
duration: __ENV.DURATION || '3m',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<0.01'],
|
||||
http_req_duration: ['p(95)<800', 'p(99)<1500'],
|
||||
works_list_shape_error_rate: ['rate<0.01'],
|
||||
},
|
||||
},
|
||||
spike: {
|
||||
scenarios: {
|
||||
spike: {
|
||||
executor: 'ramping-arrival-rate',
|
||||
startRate: Number(__ENV.START_RPS || 5),
|
||||
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
|
||||
maxVUs: Number(__ENV.MAX_VUS || 200),
|
||||
timeUnit: '1s',
|
||||
stages: [
|
||||
{
|
||||
target: Number(__ENV.PEAK_RPS || 25),
|
||||
duration: __ENV.RAMP_UP || '30s',
|
||||
},
|
||||
{
|
||||
target: Number(__ENV.PEAK_RPS || 25),
|
||||
duration: __ENV.HOLD || '2m',
|
||||
},
|
||||
{
|
||||
target: Number(__ENV.END_RPS || 5),
|
||||
duration: __ENV.RAMP_DOWN || '30s',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<0.01'],
|
||||
http_req_duration: ['p(95)<2000'],
|
||||
dropped_iterations: ['count==0'],
|
||||
works_list_shape_error_rate: ['rate<0.01'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const options = scenarioOptions[SCENARIO] || scenarioOptions.smoke;
|
||||
|
||||
const PUBLIC_ENDPOINTS = [
|
||||
{
|
||||
name: 'puzzle_gallery_list',
|
||||
method: 'GET',
|
||||
path: '/api/runtime/puzzle/gallery',
|
||||
expectCollectionKeys: ['items', 'works', 'entries'],
|
||||
},
|
||||
{
|
||||
name: 'custom_world_gallery_list',
|
||||
method: 'GET',
|
||||
path: '/api/runtime/custom-world-gallery',
|
||||
expectCollectionKeys: ['entries', 'items', 'works'],
|
||||
},
|
||||
];
|
||||
|
||||
const AUTH_ENDPOINTS = [
|
||||
{
|
||||
name: 'puzzle_works_list',
|
||||
method: 'GET',
|
||||
path: '/api/runtime/puzzle/works',
|
||||
expectCollectionKeys: ['items', 'works'],
|
||||
},
|
||||
{
|
||||
name: 'custom_world_works_list',
|
||||
method: 'GET',
|
||||
path: '/api/runtime/custom-world/works',
|
||||
expectCollectionKeys: ['items', 'entries', 'works'],
|
||||
},
|
||||
];
|
||||
|
||||
function requestParams(endpointName) {
|
||||
const headers = { 'x-genarrative-response-envelope': 'v1' };
|
||||
if (AUTH_TOKEN) headers.Authorization = `Bearer ${AUTH_TOKEN}`;
|
||||
return {
|
||||
headers,
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
tags: { endpoint: endpointName },
|
||||
};
|
||||
}
|
||||
|
||||
function buildUrl(path) {
|
||||
return `${BASE_URL}${path}`;
|
||||
}
|
||||
|
||||
function parseJson(response) {
|
||||
try {
|
||||
return response.json();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapPayload(json) {
|
||||
if (!json || typeof json !== 'object') return null;
|
||||
if (json.data && typeof json.data === 'object') return json.data;
|
||||
return json;
|
||||
}
|
||||
|
||||
function hasCollection(payload, keys) {
|
||||
return Boolean(payload) && keys.some((key) => Array.isArray(payload[key]));
|
||||
}
|
||||
|
||||
function firstCollection(payload, keys) {
|
||||
for (const key of keys) {
|
||||
if (payload && Array.isArray(payload[key])) return payload[key];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function hasListItemShape(payload, keys) {
|
||||
const collection = firstCollection(payload, keys);
|
||||
if (collection.length === 0) return true;
|
||||
const item = collection[0];
|
||||
const hasId = Boolean(
|
||||
item &&
|
||||
(item.profileId ||
|
||||
item.profile_id ||
|
||||
item.workId ||
|
||||
item.work_id ||
|
||||
item.publicWorkCode),
|
||||
);
|
||||
const hasTitle = Boolean(
|
||||
item &&
|
||||
(item.title ||
|
||||
item.workTitle ||
|
||||
item.work_title ||
|
||||
item.levelName ||
|
||||
item.worldName),
|
||||
);
|
||||
return hasId && hasTitle;
|
||||
}
|
||||
|
||||
function randomItem(items) {
|
||||
if (!items.length) return null;
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
function listEndpoints() {
|
||||
return AUTH_TOKEN
|
||||
? PUBLIC_ENDPOINTS.concat(AUTH_ENDPOINTS)
|
||||
: PUBLIC_ENDPOINTS;
|
||||
}
|
||||
|
||||
function detailEndpointFor(work) {
|
||||
if (!work || !work.profileId) return null;
|
||||
if (work.type === 'puzzle') {
|
||||
return {
|
||||
name: 'puzzle_gallery_detail',
|
||||
path: `/api/runtime/puzzle/gallery/${encodeURIComponent(work.profileId)}`,
|
||||
expectKeys: ['item', 'work', 'entry'],
|
||||
};
|
||||
}
|
||||
if (work.type === 'customWorld' && work.profileId && work.ownerUserId) {
|
||||
return {
|
||||
name: 'custom_world_gallery_detail',
|
||||
path: `/api/runtime/custom-world-gallery/${encodeURIComponent(work.ownerUserId)}/${encodeURIComponent(work.profileId)}`,
|
||||
expectKeys: ['entry', 'item', 'work'],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function performListRequest(endpoint) {
|
||||
const url = buildUrl(endpoint.path);
|
||||
const response = http.request(
|
||||
endpoint.method,
|
||||
url,
|
||||
null,
|
||||
requestParams(endpoint.name),
|
||||
);
|
||||
worksListDuration.add(response.timings.duration, { endpoint: endpoint.name });
|
||||
const json = parseJson(response);
|
||||
const payload = unwrapPayload(json);
|
||||
const ok = check(response, {
|
||||
[`${endpoint.name} status is 200`]: (res) => res.status === 200,
|
||||
[`${endpoint.name} returns json object`]: () => Boolean(payload),
|
||||
[`${endpoint.name} has collection`]: () =>
|
||||
hasCollection(payload, endpoint.expectCollectionKeys),
|
||||
[`${endpoint.name} list item shape`]: () =>
|
||||
hasListItemShape(payload, endpoint.expectCollectionKeys),
|
||||
});
|
||||
worksListShapeErrorRate.add(!ok, { endpoint: endpoint.name });
|
||||
}
|
||||
|
||||
function performDetailRequest() {
|
||||
const endpoint = detailEndpointFor(randomItem(normalizedWorks));
|
||||
if (!endpoint) return;
|
||||
|
||||
const response = http.get(
|
||||
buildUrl(endpoint.path),
|
||||
requestParams(endpoint.name),
|
||||
);
|
||||
worksDetailDuration.add(response.timings.duration, {
|
||||
endpoint: endpoint.name,
|
||||
});
|
||||
const json = parseJson(response);
|
||||
const payload = unwrapPayload(json);
|
||||
const ok = check(response, {
|
||||
[`${endpoint.name} status is 200`]: (res) => res.status === 200,
|
||||
[`${endpoint.name} has detail payload`]: () =>
|
||||
Boolean(payload) && endpoint.expectKeys.some((key) => payload[key]),
|
||||
});
|
||||
worksDetailShapeErrorRate.add(!ok, { endpoint: endpoint.name });
|
||||
}
|
||||
|
||||
export default function () {
|
||||
for (const endpoint of listEndpoints()) {
|
||||
performListRequest(endpoint);
|
||||
}
|
||||
if (
|
||||
normalizedWorks.length &&
|
||||
DETAIL_RATIO > 0 &&
|
||||
Math.random() < DETAIL_RATIO
|
||||
) {
|
||||
performDetailRequest();
|
||||
}
|
||||
|
||||
const jitter =
|
||||
SLEEP_MIN_SECONDS +
|
||||
Math.random() * Math.max(0, SLEEP_MAX_SECONDS - SLEEP_MIN_SECONDS);
|
||||
sleep(jitter);
|
||||
}
|
||||
Reference in New Issue
Block a user