From eb7c00658aca5d5993e03affacbad973a1dff9b6 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:52:16 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20External=20v1=20=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E7=94=9F=E6=88=90=E8=B7=AF=E7=94=B1=E7=9A=84=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E7=9C=9F=E5=AE=9E=E6=A0=88=E5=8F=97=E7=90=86=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 出图未验证」的环境结论更新为已实测 --- ...‹碑】ExternalV1游戏场景生成路由-2026-09-24.md | 5 +- package.json | 1 + ...eck-external-v1-scene-generation-smoke.mjs | 254 ++++++++++++++++++ 3 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 scripts/check-external-v1-scene-generation-smoke.mjs diff --git a/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md index 53a4610ab..15cc151a3 100644 --- a/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md +++ b/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md @@ -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`。 \ No newline at end of file diff --git a/package.json b/package.json index ebd920962..0953c042a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-external-v1-scene-generation-smoke.mjs b/scripts/check-external-v1-scene-generation-smoke.mjs new file mode 100644 index 000000000..9645ed7ae --- /dev/null +++ b/scripts/check-external-v1-scene-generation-smoke.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); +});