补齐 External v1 场景生成路由的本地真实栈受理 smoke
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / Backend tests (push) Successful in 5m9s
Project CI / Native shell tests (push) Successful in 6m14s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m6s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m12s
Project CI / Frontend tests (push) Successful in 2m42s
Project CI / AI game creator shell web tests (push) Successful in 1m41s
Project CI / Repository checks (push) Successful in 2m16s

- 新增 scripts/check-external-v1-scene-generation-smoke.mjs:dev 账号现建外部 API Key,校验非法 Key 401、缺幂等键/空 sceneContent/custom 缺 customStyle 各自 400、合法请求 202 且返回 operationId、同键重放同一 operationId、同键换请求体 409
- 轮询生成状态得到 completed 与 assetObjectId,并用 external read-url 换签名地址下载成功(200 image/png),证明 Provider 出图在本机可用
- 里程碑最后一条验收改为已勾选,并把先前「Provider 出图未验证」的环境结论更新为已实测
This commit is contained in:
kdletters
2026-09-28 19:52:16 +08:00
parent ad2ab4cd60
commit eb7c00658a
3 changed files with 258 additions and 2 deletions
@@ -37,7 +37,7 @@
- [x] 外部场景路由与站内路由对相同输入产出相同的后端 Prompt 与入队 payload(共享实现单测对照)。
- [x] `docs/openapi/genarrative-external-v1.openapi.json` 与实现一致,相关契约检查通过。
- [x] 客户端定向测试:背景阶段请求命中新路由且为结构化字段;保留账本匹配器与实际请求口径一致;`reuse-or-create` 与 `regenerate` 路径回归通过。
- [ ] 本地真实栈 smoke:`npm run dev:api-server` + 测试 Key 下美术包背景阶段受理成功(Provider 出图按环境可用性记录为已验证或未验证)。
- [x] 本地真实栈 smoke:`npm run check:external-v1-scene-generation-smoke` 用真实 Key 打通新路由的鉴权/参数/受理/幂等,Provider 出图也验证成功(见文末本轮核对)。
## 证据要求
@@ -58,5 +58,6 @@
- 变异验证:把场景分支的路由改回 `/api/external/v1/editor/images/generations` 后该用例立刻变红(夹具拒绝通用路由、生成失败),改回后恢复绿(1.59s)。因此这一条勾选不是镜像断言。
- 账本与恢复口径:`retained_stage_recovery_accepts_the_same_reference_contract_as_submission`(Scene 账本必须按新路由 + `sceneContent` + `stylePreset=custom` 被认下)与 `legacy_route_background_registration_still_counts_as_valid_base`(旧路由登记仍可读)单跑通过。
- `reuse-or-create` / `regenerate` 回归:`direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet`(复用已登记背景)、`failed_background_stage_after_local_commit_restores_recorded_spec_and_background` 与 `failed_complete_package_regeneration_restores_replaced_spec_and_background`(失败后按记录恢复 spec / 背景)单跑通过。
- 仍未取得:**本地真实栈 + 测试 Key 的受理 smoke**(`npm run dev:api-server` 后用真实 API Key POST 新路由校验受理与重放)。本轮只读核对过可行性:`deploy/env/api-server.env` 不存在、`.env`/`.env.local` 存在但没有监听中的 dev 端口;`npm run check:api-server-env` 报缺 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`(另有阿里云抠图 AccessKey 缺失),所以这条 smoke 目前在**本机环境**上不具备执行条件,不是代码问题。
- 本地真实栈 smoke(**本轮补齐 2026-09-28**):新增 `scripts/check-external-v1-scene-generation-smoke.mjs`(`npm run check:external-v1-scene-generation-smoke`,需要本地 dev 栈)。dev 账号现建一个外部 API Key 后:非法 Key 401、缺 `Idempotency-Key` 400、空 `sceneContent` 400、`custom` 缺 `customStyle` 400;合法场景请求 **202 + operationId `task-…`**,同键重放返回**同一** operationId,同键换请求体 409 `CONFLICT`。轮询 `GET /api/external/v1/generations/{operationId}` 得到 `status=completed` 与 `assetObjectId`,再用 `/api/external/v1/assets/read-url` 换签名地址下载成功(`200 image/png`)。**12 项 PASS**。
- Provider 出图口径更新:本轮实测本机 **Provider 可用且确实出图**(模型 `vector-engine-nanobanana`,objectKey `generated-character-drafts/editor/scene-images/…/image.png`),因此这一条不再记「未验证」;先前记录的「缺 TIANTOKEN 配置」只影响编辑器真实生成配置检查,不阻塞场景路由的 Provider 出图。
- 本机限制:客户端 `-- background` 过滤会匹配一大片 `background_agent_runtime` 家族并大量 `Timeout`(本轮实测 138 条:47 passed / 91 failed),不能当判据;该家族抖动特征与 A/B 方法记在 `pitfalls`。
+1
View File
@@ -79,6 +79,7 @@
"check:game-distribution-upload-resume": "node scripts/check-game-distribution-upload-resume.mjs",
"check:agc-project-snapshot-admin-http": "node scripts/check-agc-project-snapshot-admin-http.mjs",
"check:game-distribution-sandbox-e2e": "node scripts/check-game-distribution-sandbox-e2e.mjs",
"check:external-v1-scene-generation-smoke": "node scripts/check-external-v1-scene-generation-smoke.mjs",
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
"check:preview-deployer": "node scripts/check-preview-deployer.mjs",
"check:maintenance-page": "node scripts/check-maintenance-page.mjs",
@@ -0,0 +1,254 @@
// External v1 场景生成路由的本地真实栈受理 smoke。
//
// 用法:
// E2E_API_BASE 默认 http://127.0.0.1:4198
// E2E_EXTERNAL_API_KEY 可选;不给时脚本在 dev 环境注册一个账号并通过
// /api/profile/api-keys 现建一个 Key(dev 密码登录会自动注册)。
//
// 覆盖:非法 Key 401 → 缺幂等键 400 → 空 sceneContent 400 → custom 缺 customStyle 400 →
// 合法请求 202 且返回 operationId → 同键重放返回同一 operationId → 同键换请求体 409 →
// 按 operationId 查一次生成状态(Provider 出图按环境可用性如实记录,不作为受理判据)。
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const DEV_PASSWORD = 'GenE2e123!';
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, externalKey } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
if (externalKey) finalHeaders.Authorization = `Bearer ${externalKey}`;
let finalBody;
if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return {
status: response.status,
text,
json,
data: json?.data,
error: json?.error,
};
}
async function resolveExternalKey() {
const provided = (process.env.E2E_EXTERNAL_API_KEY ?? '').trim();
if (provided) return provided;
const phone = `133${String(Date.now()).slice(-8)}`;
const registered = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
const token = registered.data?.token;
check(
'dev 账号注册/登录成功',
registered.status === 200 && Boolean(token),
`status=${registered.status}`,
);
if (!token) process.exit(1);
const created = await api('/api/profile/api-keys', {
method: 'POST',
token,
body: { name: 'E2E 场景路由 smoke' },
});
const apiKey = created.data?.apiKey;
check(
'创建外部 API Key 成功',
created.status === 200 && Boolean(apiKey),
`status=${created.status}`,
);
if (!apiKey) process.exit(1);
return apiKey;
}
const SCENE_PATH = '/api/external/v1/editor/scenes/generations';
async function main() {
const apiKey = await resolveExternalKey();
const unauthorized = await api(SCENE_PATH, {
method: 'POST',
externalKey: 'tnr_sk_invalid_probe',
headers: { 'Idempotency-Key': `scene-smoke-unauthorized-${Date.now()}` },
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'flat' },
});
check(
'非法 API Key 被拒(401)',
unauthorized.status === 401,
`status=${unauthorized.status}`,
);
const stamp = Date.now();
const missingKey = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'flat' },
});
check(
'缺 Idempotency-Key 被拒(400)',
missingKey.status === 400,
`status=${missingKey.status}`,
);
const blankScene = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': `scene-smoke-blank-${stamp}` },
body: { sceneContent: ' ', stylePreset: 'flat' },
});
check(
'空 sceneContent 被拒(400)',
blankScene.status === 400,
`status=${blankScene.status}`,
);
const customWithoutStyle = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': `scene-smoke-custom-${stamp}` },
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'custom' },
});
check(
'custom 风格缺 customStyle 被拒(400)',
customWithoutStyle.status === 400,
`status=${customWithoutStyle.status}`,
);
const idempotencyKey = `scene-smoke-${stamp}`;
const body = {
sceneContent: '夕阳下的海边小镇,远处有灯塔',
stylePreset: 'flat',
aspectRatio: '16:9',
imageSize: '1K',
generationInputs: { source: 'ai-game-creator-client' },
};
const accepted = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': idempotencyKey },
body,
});
const operationId = accepted.data?.operationId ?? accepted.data?.operation_id;
check(
'合法场景请求被受理(202 + operationId)',
accepted.status === 202 && Boolean(operationId),
`status=${accepted.status} operationId=${operationId ?? ''} msg=${accepted.error?.message ?? ''}`,
);
if (!operationId) process.exit(1);
const replayed = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': idempotencyKey },
body,
});
check(
'同键同请求重放返回同一 operationId',
replayed.status === 202 &&
(replayed.data?.operationId ?? replayed.data?.operation_id) ===
operationId,
`status=${replayed.status} operationId=${replayed.data?.operationId ?? ''}`,
);
const conflicting = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': idempotencyKey },
body: { ...body, sceneContent: '同一个幂等键换了画面内容' },
});
check(
'同键换请求体被拒(409)',
conflicting.status === 409,
`status=${conflicting.status} code=${conflicting.error?.code ?? ''}`,
);
let generation = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
generation = await api(`/api/external/v1/generations/${operationId}`, {
externalKey: apiKey,
});
const status = generation.data?.status ?? '';
if (
status === 'completed' ||
status === 'succeeded' ||
status === 'failed' ||
status === 'cancelled'
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
const status = generation?.data?.status ?? 'unknown';
check(
'按 operationId 能查到生成状态',
generation?.status === 200 && status !== 'unknown',
`status=${generation?.status} generationStatus=${status}`,
);
const result = generation?.data?.result ?? {};
const assetObjectId = result.assetObjectId ?? result.asset?.assetObjectId;
const objectKey = result.asset?.objectKey ?? result.objectKey ?? '';
const providerProducedImage =
status === 'completed' && Boolean(assetObjectId);
check(
'Provider 出图成功并返回素材引用(completed + assetObjectId)',
providerProducedImage,
`generationStatus=${status} assetObjectId=${assetObjectId ?? ''}`,
);
if (providerProducedImage && objectKey) {
const readUrl = await api(
`/api/external/v1/assets/read-url?objectKey=${encodeURIComponent(objectKey)}`,
{ externalKey: apiKey },
);
const signedUrl = readUrl.data?.read?.signedUrl ?? readUrl.data?.signedUrl;
const imageResponse = signedUrl ? await fetch(signedUrl) : null;
const contentType = imageResponse?.headers.get('content-type') ?? '';
check(
'出图素材能通过 external read-url 换签名地址下载',
readUrl.status === 200 &&
Boolean(signedUrl) &&
imageResponse?.status === 200 &&
contentType.startsWith('image/'),
`readUrl=${readUrl.status} objectKey=${objectKey} download=${imageResponse?.status ?? 'n/a'} type=${contentType}`,
);
}
console.log(
`\nProvider 出图:${
providerProducedImage
? `已验证(assetObjectId=${assetObjectId}${objectKey ? ` objectKey=${objectKey}` : ''})`
: `未验证(当前 generationStatus=${status},本机图像 Provider 不可用时属环境限制)`
}`,
);
console.log(`生成状态原始响应:${generation?.text?.slice(0, 400) ?? ''}`);
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(
`[check-external-v1-scene-generation-smoke] 运行失败:${error}`,
);
process.exit(1);
});