This commit is contained in:
2026-05-13 03:10:55 +08:00
154 changed files with 16812 additions and 708 deletions

View File

@@ -0,0 +1,120 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = process.cwd();
const failures = [];
const smokeSteps = [
{
label: '小程序壳请求与 hash 回跳静态检查',
run: checkMiniProgramShell,
},
{
label: 'api-server 小程序登录与会话来源测试',
run: () =>
runCommand('cargo', [
'test',
'-p',
'api-server',
'wechat_miniprogram_login_returns_system_token_and_marks_session_source',
'--manifest-path',
'server-rs/Cargo.toml',
'--',
'--nocapture',
]),
},
{
label: 'H5 auth hash 消费测试',
run: () =>
runCommand(process.execPath, [
fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)),
'run',
'src/services/authService.test.ts',
'-t',
'consumes auth callback hash and persists the returned access token',
]),
},
];
for (const step of smokeSteps) {
console.log(`[wechat-miniprogram-auth-smoke] ${step.label}`);
step.run();
}
if (failures.length > 0) {
console.error('\n[wechat-miniprogram-auth-smoke] 未通过:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('\n[wechat-miniprogram-auth-smoke] 通过');
function checkMiniProgramShell() {
const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
const shellTemplatePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.wxml');
const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts');
ensureNeedles(shellPath, [
'/api/auth/wechat/miniprogram-login',
'/api/auth/wechat/bind-phone',
"'x-client-type': MINI_PROGRAM_CLIENT_TYPE",
"'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME",
'auth_provider',
'auth_token',
'auth_binding_status',
'bindingStatus',
'pending_bind_phone',
'wechatPhoneCode',
]);
ensureNeedles(shellTemplatePath, ['getPhoneNumber', 'bindgetphonenumber']);
// 中文注释:这里锁定 H5 消费回跳 hash 的真实测试输入,避免只检查实现文本。
ensureNeedles(authServiceTestPath, [
'#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone',
'consumeAuthCallbackResult()',
"bindingStatus: 'pending_bind_phone'",
"expect(getStoredAccessToken()).toBe('jwt-callback-token')",
]);
}
function ensureNeedles(relativeOrFullPath, needles) {
if (!existsSync(relativeOrFullPath)) {
failures.push(`缺少文件:${relativeOrFullPath}`);
return;
}
const content = readFileSync(relativeOrFullPath, 'utf8');
for (const needle of needles) {
if (!content.includes(needle)) {
failures.push(`${relativeOrFullPath} 缺少内容:${needle}`);
}
}
}
function runCommand(command, args) {
const result = spawnSync(command, args, {
cwd: repoRoot,
env: process.env,
shell: false,
stdio: 'inherit',
});
if (result.error) {
failures.push(`${command} 启动失败:${result.error.message}`);
return;
}
if (result.signal) {
failures.push(`${command} 被信号终止:${result.signal}`);
return;
}
if ((result.status ?? 0) !== 0) {
failures.push(`${command} ${args.join(' ')} 退出码 ${result.status}`);
}
}

View File

@@ -0,0 +1,145 @@
import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const promptsPath = path.join(repoRoot, 'public', 'bark-battle-assets', 'bark-battle-image-prompts.json');
const outDir = path.join(repoRoot, 'public', 'bark-battle-assets', 'generated');
const args = new Set(process.argv.slice(2));
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 || 180000), 10),
};
}
function generationUrl(baseUrl) {
return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`;
}
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) => typeof entry === 'string' && entry.trim() && output.push(entry.trim()));
}
collectStringsByKey(nested, targetKey, output);
}
}
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, 300)}`);
return JSON.parse(text);
} 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());
const type = response.headers.get('content-type') || '';
const extension = type.includes('webp') ? 'webp' : type.includes('jpeg') ? 'jpg' : 'png';
return { bytes, extension };
} finally {
clearTimeout(timer);
}
}
const rawTemplates = JSON.parse(readFileSync(promptsPath, 'utf8'));
const onlyIds = process.argv
.slice(2)
.flatMap((arg, index, values) => (arg === '--only' ? String(values[index + 1] || '').split(',') : []))
.map((value) => value.trim())
.filter(Boolean);
const templates = rawTemplates.filter((template) => !onlyIds.length || onlyIds.includes(template.id));
const dryRun = args.has('--dry-run') || !args.has('--live');
const requests = templates.map((template) => ({ id: template.id, title: template.title, body: { model: 'gpt-image-2-all', prompt: template.prompt, n: 1, size: '1024x1024' } }));
if (dryRun) {
console.log(JSON.stringify({ mode: 'dry-run', outDir, count: requests.length, requests }, 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);
}
mkdirSync(outDir, { recursive: true });
const files = [];
for (const request of requests) {
console.log(`Generating ${request.id}...`);
const payload = await fetchJson(generationUrl(env.baseUrl), {
method: 'POST',
headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(request.body),
}, env.timeoutMs);
const urls = [];
const b64 = [];
collectStringsByKey(payload, 'url', urls);
collectStringsByKey(payload, 'image', urls);
collectStringsByKey(payload, 'image_url', urls);
collectStringsByKey(payload, 'b64_json', b64);
let image;
const url = [...new Set(urls)].find((item) => /^https?:\/\//u.test(item));
if (url) {
image = await downloadUrl(url, env.timeoutMs);
} else if (b64[0]) {
const bytes = Buffer.from(b64[0], 'base64');
image = { bytes, extension: inferExtensionFromBytes(bytes) };
} else {
throw new Error(`VectorEngine returned no image for ${request.id}`);
}
const outputPath = path.join(outDir, `${request.id}.${image.extension}`);
writeFileSync(outputPath, image.bytes);
files.push(outputPath);
}
console.log(JSON.stringify({ ok: true, count: files.length, files }, null, 2));

View File

@@ -1,4 +1,5 @@
import { Buffer } from 'node:buffer';
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
@@ -10,16 +11,13 @@ import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const defaultOut = path.join(
repoRoot,
'public',
'child-motion-demo',
'picture-book-grass-stage.webp',
);
const defaultSize = '1536x1024';
const assetDir = path.join(repoRoot, 'public', 'child-motion-demo');
const intermediateDir = path.join(repoRoot, 'tmp', 'child-motion-demo-assets');
const defaultTimeoutMs = 180000;
const chromaKeyColor = '#ff00ff';
const layoutReferenceOutput = 'picture-book-stage-layout-v2.png';
const prompt = [
const backgroundPrompt = [
'请生成一张横版儿童动作互动游戏舞台背景图,卡通绘本风格,温暖明亮。',
'画面下半部分必须是开阔柔软的草地地面,适合叠加半透明角色轮廓和地面圆圈指示环。',
'远处有柔和小山坡、树木、天空和浅色云朵,中心和下方前景保持干净开阔。',
@@ -28,6 +26,252 @@ const prompt = [
'不要出现人物、动物、文字、按钮、UI、边框、水印、摄像头画面、真实照片质感。',
].join('');
const styleReferenceNote = [
'参考图仅用于统一卡通绘本草地舞台的色彩、笔触、纸张纹理和明亮童趣气质。',
'不要复制参考图构图,不要出现真实照片质感。',
].join('');
const layoutReferencePrompt = [
'请基于参考背景重新设计一张 16:9 儿童动作互动游戏热身关版式参考图,卡通绘本草地风格保持统一。',
'背景品质和明亮草地绘本质感沿用参考图,不要把背景做暗或做成科技风。',
'画面中心到下方中部保持开阔,留给半透明角色轮廓和地面椭圆指示环。',
'底部只放一条自然的前景草坪边缘,占舞台高度约 18% 到 22%,草叶比例真实可爱,不要拉伸成扁平色块。',
'顶部居中放一个小型横向 HUD 软纸条,占舞台宽度约 45% 到 52%,高度约 9% 到 12%,不要做成整屏顶部栏。',
'右下角放一个小型五格状态条,占舞台宽度约 28% 到 34%,高度约 6% 到 8%,不要压住角色脚下区域。',
'开始按钮占位使用小型胶囊按钮和轻盈托盘,整体不要超过舞台宽度 26%。',
'所有 UI 都是无文字、无图标的空白资源占位,边缘带少量草叶、水彩纸张纹理和浅蓝高光。',
'不要出现人物、动物、文字、数字、水印、摄像头画面、真实照片质感。',
].join('');
const chromaKeyNote = [
`背景必须是完全纯色、均匀一致的 ${chromaKeyColor} 品红色,用于后续去背。`,
'背景不能有阴影、渐变、纹理、地面、反光或光照变化。',
`主体中不要使用 ${chromaKeyColor} 或接近品红的颜色。`,
'主体边缘保持清晰,四周留出充足空白。',
'不要出现文字、水印、真实照片质感。',
].join('');
const noStretchNote = [
'资源自身必须按最终用途设计比例绘制,不要画成方形卡片再留大面积空白。',
'网页端会按资源原始比例等比缩放使用,不会把资源横向或纵向强行拉伸。',
'不要出现文字、数字、按钮文案、水印、真实照片质感。',
].join('');
const assetDefinitions = [
{
id: 'background',
output: 'picture-book-grass-stage.png',
size: '1536x1024',
prompt: backgroundPrompt,
transparent: false,
useBackgroundReference: false,
},
{
id: 'layout-reference-v2',
output: layoutReferenceOutput,
outputDirectory: 'intermediate',
size: '2048x1152',
prompt: layoutReferencePrompt,
transparent: false,
useBackgroundReference: true,
},
{
id: 'floor',
output: 'picture-book-foreground-grass-v2.png',
sourceOutput: 'picture-book-foreground-grass-v2-source.png',
size: '2048x768',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 2048,
canvasHeight: 640,
fit: 'cover-width',
fillWidth: 1.04,
anchorY: 'bottom',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏的底部前景草坪资源,不是完整背景。',
'主体是一条横向自然草地边缘,用于覆盖 16:9 舞台最下方约五分之一高度。',
'草坪顶部边缘有松散手绘草叶和少量浅色小花,底部更厚实,中心不要出现硬平台、椭圆地毯或 UI 栏。',
'整体应像绘本背景自然延伸出来的草地前景,比例宽而舒展,草叶不能被压扁或横向拉伸。',
'不要天空、远山、人物、角色、按钮、面板、边框。',
'风格必须和参考背景一致:明亮、温暖、卡通绘本、水彩笔触、轻微纸张纹理。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'ground-ring',
output: 'picture-book-ground-ring-v2.png',
sourceOutput: 'picture-book-ground-ring-v2-source.png',
size: '1536x512',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1200,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.92,
fillHeight: 0.78,
anchorY: 'center',
padding: 24,
},
prompt: [
'请生成一个儿童动作互动游戏地面椭圆指示环资产。',
'主体是单个透视椭圆环,直接设计成贴在草地地面上的椭圆,不要依赖网页后期压扁。',
'圆环由柔软草叶、水彩绿色描边和浅色高光组成,中心留空,边缘带轻微绘本手绘不规则感。',
'整体清爽、明亮、儿童绘本风,不要科技感,不要霓虹,不要金属材质。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'character-outline',
output: 'picture-book-character-outline-v2.png',
sourceOutput: 'picture-book-character-outline-v2-source.png',
size: '1024x1536',
transparent: true,
transparencyCleanup: 'character-outline',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1024,
canvasHeight: 1536,
fit: 'contain',
fillWidth: 0.78,
fillHeight: 0.9,
anchorY: 'bottom',
padding: 28,
},
prompt: [
'请生成一个儿童动作互动游戏的半透明角色轮廓指示器资产。',
'主体是正面站立的人形轮廓,儿童友好比例,无五官、无衣服细节、无性别特征,双臂自然微微张开。',
'视觉上像浅蓝绿色水彩发光描边加半透明白色填充,用于表示真实用户的位置剪影。',
'轮廓需要简洁清晰,适合缩放到游戏舞台中使用。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'hud-strip',
output: 'picture-book-hud-strip-v2.png',
sourceOutput: 'picture-book-hud-strip-v2-source.png',
size: '1536x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 2200,
canvasHeight: 420,
fit: 'contain',
fillWidth: 0.96,
fillHeight: 0.92,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏顶部 HUD 软纸条资产,不是方形面板。',
'主体是一条细长横向顶部信息条,目标宽高比约 5:1像轻盈软纸丝带不要做成圆形徽章、方形卡片或厚重弹窗。',
'中间为浅米白到淡浅绿色水彩软纸区域,左右边缘可以有少量草叶装饰,但不能扩大成大圆端。',
'边缘有少量草叶、浅蓝高光和绘本纸张纹理,中心必须干净空白,方便网页叠加标题和进度。',
'形状轻盈,适合放在 16:9 舞台顶部居中,占画面宽度约一半,不要做成全宽导航栏或后台系统面板。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'calibration-strip',
output: 'picture-book-calibration-strip-v2.png',
sourceOutput: 'picture-book-calibration-strip-v2-source.png',
size: '1536x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1800,
canvasHeight: 360,
fit: 'contain',
fillWidth: 0.96,
fillHeight: 0.9,
anchorY: 'center',
padding: 16,
},
prompt: [
'请生成儿童动作互动游戏右下角五格状态条资产,不是方形面板。',
'主体是横向小型状态条,内部有五个柔和小胶囊或五个浅色分隔留白区域,但不要写任何文字或数字。',
'整体用于舞台右下角,轻薄、不厚重,不压住角色脚下区域。',
'米白、淡浅绿和浅蓝水彩高光为主,边缘可以有少量草叶和纸张纹理,风格必须和参考背景一致。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'start-panel',
output: 'picture-book-start-panel-v2.png',
sourceOutput: 'picture-book-start-panel-v2-source.png',
size: '1024x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1280,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.88,
fillHeight: 0.88,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏开始按钮背后的轻盈托盘资产,不是完整弹窗。',
'主体是一个小型横向圆角软纸托盘,中心空白,适合只承载一个开始按钮。',
'边缘可以有少量草叶、浅蓝高光和淡绿色纸张纹理,整体要比 HUD 更小、更轻,不要做成大卡片。',
'不要文字、数字、图标或按钮文案。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'ui-button',
output: 'picture-book-ui-button-v2.png',
sourceOutput: 'picture-book-ui-button-v2-source.png',
size: '1024x512',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1300,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.86,
fillHeight: 0.76,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成一个儿童动作互动游戏主按钮背景资产。',
'主体是横向胶囊形按钮,无文字,绿色草地色为主,带浅蓝天空高光和柔和水彩纸张质感。',
'按钮中心保持干净,适合网页叠加“开始游戏”等文字。',
'整体要圆润、明亮、童趣、绘本感,不要科技感、金属感、真实照片质感。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
];
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const raw = process.argv[index];
@@ -36,7 +280,14 @@ for (let index = 2; index < process.argv.length; index += 1) {
}
const next = process.argv[index + 1];
if (next && !next.startsWith('--')) {
args.set(raw, next);
const existing = args.get(raw);
if (Array.isArray(existing)) {
existing.push(next);
} else if (existing) {
args.set(raw, [existing, next]);
} else {
args.set(raw, next);
}
index += 1;
} else {
args.set(raw, true);
@@ -138,6 +389,63 @@ function extractBase64Images(payload) {
return values;
}
function inferExtensionFromBytes(bytes, preferredPath) {
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 path.extname(preferredPath).replace(/^\./u, '') || 'png';
}
function toDataUrl(filePath) {
if (!existsSync(filePath)) {
return null;
}
const bytes = readFileSync(filePath);
const extension = inferExtensionFromBytes(bytes, filePath);
const mime = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`;
return `data:${mime};base64,${bytes.toString('base64')}`;
}
function pushReferenceImage(body, filePath) {
const reference = toDataUrl(filePath);
if (!reference) {
return false;
}
body.image = [...(body.image || []), reference];
return true;
}
function buildRequestBody(asset, size) {
const body = {
model: 'gpt-image-2-all',
prompt: asset.prompt,
n: 1,
size: size || asset.size,
};
if (asset.useBackgroundReference) {
pushReferenceImage(
body,
path.join(assetDir, 'picture-book-grass-stage.png'),
);
}
if (asset.useLayoutReference) {
pushReferenceImage(
body,
path.join(intermediateDir, layoutReferenceOutput),
);
}
return body;
}
async function fetchWithTimeout(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
@@ -180,27 +488,518 @@ async function downloadImage(url, timeoutMs) {
}
}
const size = String(args.get('--size') || defaultSize);
const outPath = path.resolve(String(args.get('--out') || defaultOut));
const requestBody = {
model: 'gpt-image-2-all',
prompt,
n: 1,
size,
};
function outputPathFor(asset) {
if (asset.outputDirectory === 'intermediate') {
return path.join(intermediateDir, asset.output);
}
return path.join(assetDir, asset.output);
}
if (args.has('--dry-run') || !args.has('--live')) {
function sourceOutputPathFor(asset) {
return path.join(intermediateDir, asset.sourceOutput || asset.output);
}
function opaqueSourceOutputPathFor(asset) {
return path.join(
intermediateDir,
`${path.basename(asset.sourceOutput || asset.output, path.extname(asset.sourceOutput || asset.output))}-rgb.png`,
);
}
function normalizeOutputPath(preferredPath, imageBytes) {
const actualExtension = inferExtensionFromBytes(imageBytes, preferredPath);
const outputPath =
path.extname(preferredPath).toLowerCase() === `.${actualExtension}`
? preferredPath
: path.join(
path.dirname(preferredPath),
`${path.basename(preferredPath, path.extname(preferredPath))}.${actualExtension}`,
);
return { actualExtension, outputPath };
}
function resolveCodexHome() {
if (process.env.CODEX_HOME) {
return process.env.CODEX_HOME;
}
if (process.env.USERPROFILE) {
return path.join(process.env.USERPROFILE, '.codex');
}
if (process.env.HOME) {
return path.join(process.env.HOME, '.codex');
}
return null;
}
function findChromaKeyHelper() {
const codexHome = resolveCodexHome();
if (!codexHome) {
return null;
}
const helper = path.join(
codexHome,
'skills',
'.system',
'imagegen',
'scripts',
'remove_chroma_key.py',
);
return existsSync(helper) ? helper : null;
}
function removeChromaKey(sourcePath, finalPath) {
const helper = findChromaKeyHelper();
if (!helper) {
throw new Error(
'Missing Codex imagegen remove_chroma_key.py helper for transparent assets',
);
}
const result = spawnSync(
'python',
[
helper,
'--input',
sourcePath,
'--out',
finalPath,
'--key-color',
chromaKeyColor,
'--auto-key',
'border',
'--soft-matte',
'--transparent-threshold',
'12',
'--opaque-threshold',
'220',
'--despill',
'--force',
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`remove_chroma_key.py failed: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function removeUiPanelChromaKey(sourcePath, finalPath) {
const script = [
'from PIL import Image, ImageFilter',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'im = Image.open(source).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'corner = im.getpixel((0, 0))',
'key = corner[:3]',
'for y in range(h):',
' for x in range(w):',
' r, g, b, _ = px[x, y]',
' brightness = (r + g + b) / 3',
' dist = ((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) ** 0.5',
' magenta_bias = r + b - 1.85 * g',
' if brightness < 42 or dist < 155 or (r > 185 and b > 150 and g < 190 and magenta_bias > 235):',
' alpha = 0',
' elif dist < 225:',
' alpha = int(max(0, min(255, (dist - 155) / 70 * 255)))',
' else:',
' alpha = 255',
' if alpha > 0 and r > g + 28 and b > g + 20:',
' r = min(r, g + 18)',
' b = min(b, g + 14)',
' px[x, y] = (r, g, b, alpha)',
'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.45))',
'im.putalpha(alpha)',
'im.save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to clean UI panel transparency: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
const script = [
'from PIL import Image, ImageFilter',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'im = Image.open(source).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'for y in range(h):',
' for x in range(w):',
' r, g, b, _ = px[x, y]',
' magenta_strength = min(r, b) - g',
' magenta_bg = r > 180 and b > 170 and g < 145 and magenta_strength > 70',
' hot_bg = r > 225 and b > 205 and g < 190 and magenta_strength > 55',
' if magenta_bg or hot_bg:',
' alpha = 0',
' else:',
' alpha = 255',
' if alpha > 0 and r > g + 35 and b > g + 22:',
' r = min(r, g + 24)',
' b = min(b, g + 20)',
' px[x, y] = (r, g, b, alpha)',
'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.35))',
'im.putalpha(alpha)',
'im.save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to clean character outline transparency: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function normalizeTransparentAsset(finalPath, layoutNormalization) {
if (!layoutNormalization) {
return;
}
const script = [
'from PIL import Image',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'canvas_w = int(sys.argv[3])',
'canvas_h = int(sys.argv[4])',
'fit = sys.argv[5]',
'fill_w = float(sys.argv[6])',
'fill_h = float(sys.argv[7])',
'anchor_y = sys.argv[8]',
'padding = int(sys.argv[9])',
'im = Image.open(source).convert("RGBA")',
'alpha = im.getchannel("A").point(lambda a: 255 if a > 8 else 0)',
'bbox = alpha.getbbox()',
'if bbox is None:',
' im.save(out)',
' raise SystemExit(0)',
'left, top, right, bottom = bbox',
'left = max(0, left - padding)',
'top = max(0, top - padding)',
'right = min(im.width, right + padding)',
'bottom = min(im.height, bottom + padding)',
'subject = im.crop((left, top, right, bottom))',
'target_w = max(1, int(canvas_w * fill_w))',
'target_h = max(1, int(canvas_h * fill_h))',
'scale_w = target_w / subject.width',
'scale_h = target_h / subject.height',
'scale = max(scale_w, scale_h) if fit == "cover-width" else min(scale_w, scale_h)',
'new_w = max(1, int(subject.width * scale))',
'new_h = max(1, int(subject.height * scale))',
'subject = subject.resize((new_w, new_h), Image.Resampling.LANCZOS)',
'if new_w > canvas_w:',
' crop_left = max(0, (new_w - canvas_w) // 2)',
' subject = subject.crop((crop_left, 0, crop_left + canvas_w, new_h))',
' new_w = canvas_w',
'if new_h > canvas_h:',
' if anchor_y == "bottom":',
' crop_top = new_h - canvas_h',
' elif anchor_y == "top":',
' crop_top = 0',
' else:',
' crop_top = max(0, (new_h - canvas_h) // 2)',
' subject = subject.crop((0, crop_top, new_w, crop_top + canvas_h))',
' new_h = canvas_h',
'canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))',
'x = (canvas_w - new_w) // 2',
'if anchor_y == "bottom":',
' y = canvas_h - new_h',
'elif anchor_y == "top":',
' y = 0',
'else:',
' y = (canvas_h - new_h) // 2',
'canvas.alpha_composite(subject, (x, y))',
'canvas.save(out)',
].join('\n');
const result = spawnSync(
'python',
[
'-c',
script,
finalPath,
finalPath,
String(layoutNormalization.canvasWidth),
String(layoutNormalization.canvasHeight),
layoutNormalization.fit || 'contain',
String(layoutNormalization.fillWidth || 0.92),
String(layoutNormalization.fillHeight || 0.92),
layoutNormalization.anchorY || 'center',
String(layoutNormalization.padding || 0),
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`Failed to normalize transparent asset canvas: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function scrubChromaFringe(finalPath) {
const script = [
'from PIL import Image',
'import sys',
'path = sys.argv[1]',
'im = Image.open(path).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'for y in range(h):',
' for x in range(w):',
' r, g, b, a = px[x, y]',
' if a == 0:',
' continue',
' magenta_bias = min(r, b) - g',
' is_magenta_edge = r > 135 and b > 135 and magenta_bias > 24 and abs(r - b) < 92',
' if is_magenta_edge and a < 90:',
' px[x, y] = (r, g, b, 0)',
' continue',
' if is_magenta_edge:',
' neutral = max(g, min(248, int((r + b + g) / 3)))',
' r = min(r, neutral + 18)',
' b = min(b, neutral + 16)',
' g = max(g, min(neutral, 230))',
' px[x, y] = (r, g, b, a)',
'im.save(path)',
].join('\n');
const result = spawnSync('python', ['-c', script, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to scrub chroma fringe: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function writeOpaquePng(sourcePath, outputPath) {
const result = spawnSync(
'python',
[
'-c',
[
'from PIL import Image',
'import sys',
'Image.open(sys.argv[1]).convert("RGB").save(sys.argv[2])',
].join('; '),
sourcePath,
outputPath,
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`Failed to normalize transparent source before chroma key removal: ${(result.stderr || result.stdout).trim()}`,
);
}
}
async function generateAsset(asset, env, size, force) {
const finalPath = outputPathFor(asset);
if (!force && existsSync(finalPath)) {
return {
id: asset.id,
ok: true,
skipped: true,
file: finalPath,
};
}
if (args.has('--postprocess-only')) {
if (!asset.transparent) {
return {
id: asset.id,
ok: true,
skipped: true,
file: finalPath,
};
}
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
mkdirSync(intermediateDir, { recursive: true });
const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
writeOpaquePng(sourcePath, opaqueSourcePath);
if (asset.transparencyCleanup === 'soft-panel') {
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
} else if (asset.transparencyCleanup === 'character-outline') {
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
} else {
removeChromaKey(opaqueSourcePath, finalPath);
}
normalizeTransparentAsset(finalPath, asset.layoutNormalization);
scrubChromaFringe(finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
const requestBody = buildRequestBody(asset, size);
const payloadText = await fetchWithTimeout(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const payload = JSON.parse(payloadText);
const urls = extractImageUrls(payload);
const base64Images = extractBase64Images(payload);
const imageBytes = urls[0]
? await downloadImage(urls[0], env.timeoutMs)
: base64Images[0]
? Buffer.from(base64Images[0], 'base64')
: null;
if (!imageBytes) {
throw new Error(`VectorEngine returned no image for ${asset.id}`);
}
mkdirSync(assetDir, { recursive: true });
mkdirSync(intermediateDir, { recursive: true });
const preferredPath = asset.transparent
? sourceOutputPathFor(asset)
: finalPath;
const { actualExtension, outputPath } = normalizeOutputPath(
preferredPath,
imageBytes,
);
writeFileSync(outputPath, imageBytes);
if (asset.transparent) {
const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
writeOpaquePng(outputPath, opaqueSourcePath);
if (asset.transparencyCleanup === 'soft-panel') {
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
} else if (asset.transparencyCleanup === 'character-outline') {
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
} else {
removeChromaKey(opaqueSourcePath, finalPath);
}
normalizeTransparentAsset(finalPath, asset.layoutNormalization);
scrubChromaFringe(finalPath);
}
return {
id: asset.id,
ok: true,
file: asset.transparent ? finalPath : outputPath,
sourceFile: asset.transparent ? outputPath : undefined,
size: requestBody.size,
extension: actualExtension,
source: urls[0] ? 'url' : 'b64_json',
usedReferenceImage: Boolean(requestBody.image),
};
}
function normalizeSelection(value) {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function selectAssets() {
const selectedIds = new Set([
...normalizeSelection(args.get('--asset')),
...normalizeSelection(args.get('--only')),
]);
if (selectedIds.size === 0) {
return assetDefinitions;
}
return assetDefinitions.filter((asset) => selectedIds.has(asset.id));
}
function dryRun(selectedAssets, size) {
console.log(
JSON.stringify(
{
mode: 'dry-run',
outPath,
body: requestBody,
assets: selectedAssets.map((asset) => {
const body = buildRequestBody(asset, size);
return {
id: asset.id,
outputPath: outputPathFor(asset),
sourceOutputPath: asset.transparent
? sourceOutputPathFor(asset)
: undefined,
transparent: asset.transparent,
body: {
...body,
image: body.image ? ['<local style reference image>'] : undefined,
},
};
}),
},
null,
2,
),
);
}
const selectedAssets = selectAssets();
const unknownAssetRequested =
selectedAssets.length === 0 &&
(args.has('--asset') || args.has('--only'));
if (unknownAssetRequested) {
console.error(
JSON.stringify({
ok: false,
error: 'No matching child motion demo asset id',
availableIds: assetDefinitions.map((asset) => asset.id),
}),
);
process.exit(1);
}
const size = args.has('--size') ? String(args.get('--size')) : undefined;
if (args.has('--dry-run') || !args.has('--live')) {
dryRun(selectedAssets, size);
process.exit(0);
}
@@ -217,43 +1016,17 @@ if (!env.baseUrl || !env.apiKey) {
process.exit(1);
}
const payloadText = await fetchWithTimeout(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const payload = JSON.parse(payloadText);
const urls = extractImageUrls(payload);
const base64Images = extractBase64Images(payload);
const imageBytes = urls[0]
? await downloadImage(urls[0], env.timeoutMs)
: base64Images[0]
? Buffer.from(base64Images[0], 'base64')
: null;
if (!imageBytes) {
throw new Error('VectorEngine returned no image');
const force = Boolean(args.get('--force'));
const results = [];
for (const asset of selectedAssets) {
results.push(await generateAsset(asset, env, size, force));
}
mkdirSync(path.dirname(outPath), { recursive: true });
writeFileSync(outPath, imageBytes);
console.log(
JSON.stringify(
{
ok: true,
file: outPath,
size,
source: urls[0] ? 'url' : 'b64_json',
results,
},
null,
2,

View File

@@ -295,12 +295,62 @@ ensure_spacetime_owner_client_token() {
echo "[server-provision] 已同步 SpacetimeDB CLI 登录态;后续首次 publish 将使用同一 client identity。"
}
render_nginx_brotli_directives() {
if ! command -v nginx >/dev/null 2>&1; then
echo " # Brotli 未启用:目标服务器未找到 nginx 命令。"
return
fi
local brotli_snippet
brotli_snippet="$(mktemp)"
cat >"${brotli_snippet}" <<'EOF'
include /etc/nginx/modules-enabled/*.conf;
events {}
http {
brotli on;
brotli_comp_level 4;
brotli_min_length 1024;
brotli_types application/json;
}
EOF
if nginx -t -c "${brotli_snippet}" >/dev/null 2>&1; then
cat <<'EOF'
brotli on;
brotli_comp_level 4;
brotli_min_length 1024;
brotli_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
image/svg+xml;
EOF
else
echo " # Brotli 未启用nginx -t 不接受 brotli 指令。"
fi
rm -f "${brotli_snippet}"
}
render_nginx_template() {
local template="$1"
local rendered_brotli
rendered_brotli="$(render_nginx_brotli_directives)"
sed \
-e "s/genarrative.example.com/${SERVER_NAME}/g" \
-e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/r /dev/stdin" \
-e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/d" \
"${template}" <<<"${rendered_brotli}"
}
render_nginx_https_config() {
sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf
render_nginx_template deploy/nginx/genarrative.conf
}
render_nginx_development_http_config() {
sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative-dev-http.conf
render_nginx_template deploy/nginx/genarrative-dev-http.conf
}
render_api_env_example() {

205
scripts/loadtest/README.md Normal file
View File

@@ -0,0 +1,205 @@
# Genarrative 作品列表 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"
```
## 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=100 \
HOLD=2m \
DETAIL_RATIO=0 \
npm run loadtest:k6:works
```
默认阈值:
- `http_req_failed < 5%`
- `http_req_duration p95 < 2000ms`
- `works_list_shape_error_rate < 5%`
## 带登录态压测个人作品列表
先通过本地登录或接口获取 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 配置是否禁用了对应入口。
- 如果个人作品列表返回 401确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。
- 如果详情全部 404确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。
## 验证命令
```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
```

View File

@@ -0,0 +1,214 @@
{
"source": "spacetime-migration-7.local.json",
"generatedAt": "2026-05-11T13:09:51.569Z",
"counts": {
"puzzle_work_profile": 3,
"custom_world_profile": 1,
"match3d_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": []
},
"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
}
}
]
}

View File

@@ -0,0 +1,370 @@
#!/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;
});
}

View File

@@ -0,0 +1,247 @@
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'),
});
});
});

View File

@@ -0,0 +1,229 @@
/* 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',
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
maxVUs: Number(__ENV.MAX_VUS || 200),
timeUnit: '1s',
stages: [
{ target: Number(__ENV.START_RPS || 5), duration: __ENV.RAMP_UP || '30s' },
{ target: Number(__ENV.PEAK_RPS || 100), duration: __ENV.HOLD || '2m' },
{ target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' },
],
},
},
thresholds: {
http_req_failed: ['rate<0.05'],
http_req_duration: ['p(95)<2000'],
works_list_shape_error_rate: ['rate<0.05'],
},
},
};
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 keys.some((key) => Array.isArray(payload?.[key]));
}
function firstCollection(payload, keys) {
for (const key of keys) {
if (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?.profileId || item?.profile_id || item?.workId || item?.work_id || item?.publicWorkCode,
);
const hasTitle = Boolean(
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`]: () => 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);
}

View File

@@ -1,4 +1,7 @@
import crypto from 'node:crypto';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
if (crypto.webcrypto) {
if (typeof crypto.getRandomValues !== 'function') {
@@ -13,4 +16,7 @@ if (crypto.webcrypto) {
}
}
await import('../node_modules/vite/bin/vite.js');
const require = createRequire(import.meta.url);
const vitePackageJsonPath = require.resolve('vite/package.json');
const viteBinPath = join(dirname(vitePackageJsonPath), 'bin', 'vite.js');
await import(pathToFileURL(viteBinPath).href);