修复抠图幂等重放与动画轮询结果契约不一致 #676

Merged
lhk229 merged 4 commits from fix/495 into master 2026-10-07 21:48:48 +08:00
8 changed files with 283 additions and 128 deletions
@@ -134,7 +134,7 @@ A minimal `canvasCompletion` is:
Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume the returned animation artifacts and persisted identities; do not synthesize a duplicate animation asset from the first frame. Use complete project/library records when complete persisted state is needed.
Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. In a completed polling response, use `result.frames` for the ordered frame artifacts and `result.durationSeconds` for the duration in seconds. The nested `result.resource` and `result.asset` are compact references, not complete records; they do not include `imageSequenceFrames` or `imageSequenceDurationMs`. Read the complete project (`GET /api/external/v1/editor/projects/{projectId}`) or asset library (`GET /api/external/v1/editor/assets/library`) and match `resourceId` or `assetId` to obtain those formal sequence fields, whose duration is in milliseconds. MCP equivalents are `find_assets/get_project_resources` and `find_assets/list_library`. Do not synthesize a duplicate animation asset from the first frame.
For the lower-level asset/resource creation endpoints, `generationInputs` follows the metadata and media-runtime boundaries described in [Generation Inputs Metadata](#generation-inputs-metadata).
@@ -795,25 +795,20 @@ def _self_test() -> None:
"model": "seedance2.0-fast",
"prompt": "角色呼吸",
"previewVideoPath": "/generated/preview.mp4",
"frames": [{"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}],
"frames": [
{"imageSrc": "/generated/frame01.png", "width": 512, "height": 768},
{"imageSrc": "/generated/frame02.png", "width": 512, "height": 768},
],
"frameCount": 2,
"durationSeconds": 4,
"resource": {
"resourceId": "editor-resource-demo",
"assetKind": "character-animation",
"sourceResourceId": "editor-resource-preview-demo",
"imageSequenceFrames": [
{"imageSrc": "/generated/frame01.png", "width": 512, "height": 768},
{"imageSrc": "/generated/frame02.png", "width": 512, "height": 768},
],
"imageSequenceDurationMs": 4000,
},
"asset": {
"assetId": "editor-asset-demo",
"assetKind": "character-animation",
"imageSequenceFrames": [
{"imageSrc": "/generated/frame01.png", "width": 512, "height": 768},
{"imageSrc": "/generated/frame02.png", "width": 512, "height": 768},
],
"imageSequenceDurationMs": 4000,
},
}
if method == "POST":
@@ -841,8 +836,13 @@ def _self_test() -> None:
assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo"
assert result["asset"]["assetId"] == "editor-asset-demo"
assert result["asset"]["assetKind"] == "character-animation"
assert len(result["asset"]["imageSequenceFrames"]) == 2
assert result["asset"]["imageSequenceDurationMs"] == 4000
assert result["resource"]["resourceId"] == "editor-resource-demo"
assert result["frames"] == [
{"imageSrc": "/generated/frame01.png", "width": 512, "height": 768},
{"imageSrc": "/generated/frame02.png", "width": 512, "height": 768},
]
assert result["frameCount"] == 2
assert result["durationSeconds"] == 4
calls.clear()
background_result = client.remove_background(
"uploads/source.png",
@@ -4114,113 +4114,6 @@
"additionalProperties": false,
"description": "图片序列帧。数组位置是唯一播放顺序,不携带额外序号字段;每帧必须同时携带 objectKey 与 assetObjectId,imageSrc 按 objectKey 规范化为持久站内路径。"
},
"EditorCharacterAnimationGenerationResponse": {
"type": "object",
"required": [
"ok",
"taskId",
"model",
"prompt",
"previewVideoPath",
"frames",
"frameCount",
"durationSeconds",
"frameWidth",
"frameHeight",
"fps",
"priceMudPoints"
],
"properties": {
"ok": {
"type": "boolean"
},
"taskId": {
"type": "string"
},
"model": {
"type": "string"
},
"prompt": {
"type": "string"
},
"previewVideoPath": {
"type": "string"
},
"frames": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EditorImageSequenceFrame"
}
},
"frameCount": {
"type": "integer",
"minimum": 1
},
"durationSeconds": {
"type": "integer",
"minimum": 1
},
"frameWidth": {
"type": "integer",
"minimum": 1
},
"frameHeight": {
"type": "integer",
"minimum": 1
},
"fps": {
"type": "integer",
"minimum": 1
},
"priceMudPoints": {
"type": "integer",
"minimum": 0
},
"project": {
"anyOf": [
{
"$ref": "#/components/schemas/EditorProject"
},
{
"type": "null"
}
],
"description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。"
},
"resource": {
"anyOf": [
{
"$ref": "#/components/schemas/EditorProjectResource"
},
{
"type": "null"
}
],
"description": "最终透明角色动作对应的项目资源。携带 projectId 时,画布图层必须直接引用其 resourceId,不得再次创建重复资源。"
},
"asset": {
"anyOf": [
{
"$ref": "#/components/schemas/EditorAsset"
},
{
"type": "null"
}
],
"description": "最终透明角色动作素材。assetKind 为 character-animation,并直接包含序列帧字段。"
},
"queueState": {
"anyOf": [
{
"$ref": "#/components/schemas/ExternalGenerationJobStatusRecord"
},
{
"type": "null"
}
]
}
}
},
"EditorVideoGenerationRequest": {
"type": "object",
"required": [
@@ -4776,7 +4669,7 @@
}
},
"additionalProperties": true,
"description": "其它生成类型或历史结果的兼容 compact result。背景音乐(audioKind=background-music)与不带 audioKind 的图片、视频、图标序列帧、角色动作和 UI 拆解结果都落在这里。pixelArt 图片的 width/height、图标结果的 spritesheetWidth/spritesheetHeight 及关联 resource/asset 尺寸均表示最终逻辑分辨率 PNG 的实际值,不保证等于请求档位、provider 回图或画布 placeholder。该 fallback 明确排除 audioKind=sound-effect,避免吞掉 SFX 专用分支。"
"description": "其它生成类型或历史结果的兼容 compact result。背景音乐(audioKind=background-music)与不带 audioKind 的图片、视频、图标序列帧、角色动作和 UI 拆解结果都落在这里。角色动作使用顶层 frames(有序帧产物)与 durationSeconds(秒级时长);内嵌 resource/asset 是精简引用,不包含 imageSequenceFrames/imageSequenceDurationMs。需要正式序列记录时,按返回的 resourceId/assetId 查询 GET /api/external/v1/editor/projects/{projectId} 或 GET /api/external/v1/editor/assets/library,正式 imageSequenceDurationMs 单位为毫秒。pixelArt 图片的 width/height、图标结果的 spritesheetWidth/spritesheetHeight 及关联 resource/asset 尺寸均表示最终逻辑分辨率 PNG 的实际值,不保证等于请求档位、provider 回图或画布 placeholder。该 fallback 明确排除 audioKind=sound-effect,避免吞掉 SFX 专用分支。"
},
"ExternalEditorGenerationCompletedResult": {
"oneOf": [
@@ -2,6 +2,11 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
## External 动画轮询的精简引用不等于完整序列记录
- 动画 completed 结果通过顶层 `frames` 和 `durationSeconds` 提供有序帧与秒级时长;内嵌 `resource/asset` 只提供精简引用。正式 `imageSequenceFrames/imageSequenceDurationMs` 从完整项目或素材库按返回的 ID 读取,后者时长单位为毫秒。内嵌引用没有这两个字段不能据此判定持久化丢帧,也不能用首帧重复创建动画素材。
- 排查时分别核对真实精简函数、正式记录和 [External v1 OpenAPI](../../openapi/genarrative-external-v1.openapi.json);Python helper 的模拟响应必须遵循同一结构,不能用虚构的嵌套完整记录证明轮询契约成立。
## 2026-10-05 generationInputs 的保存与复用不等于自动生效或完整透传
- **现象 / 根因**:调用方把构图等要求只写入 `generationInputs.artSpec`,但实际生图输入没有这些要求;把 `JsonValue` 和“可复用规范”误读为服务端会自动组装提示词、完整保留全部元数据或自动用于下一次生成。
@@ -1336,6 +1341,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 现象:工程、素材、图层和元数据都已禁止 Data URL 后,服务器仍在生成高峰出现 SpacetimeDB / api-server 内存急剧膨胀甚至 OOM;读取少量正式生成任务也会造成远大于响应体的瞬时内存增长。
- 原因:同步接口 worker 化时把原请求整体序列化到 `external_generation_job.request_payload_json`,而前端又把已有 `objectKey` 下载成 Data URL 提交。任务表也是正式持久化边界;列表 procedure 若先收集完整任务行再截断,还会把 request/result 大字段在 SpacetimeDB、SDK mapper 和 BFF 多次持有。
- 内部重放例外:`get_external_generation_job_and_return` 按主键读取单条原始任务并校验 owner,供 External 去背景核对真实幂等键与原始请求指纹;不能拿丢弃这些字段的摘要兼容快照做校验,否则同请求重试也会返回 `409`。这不改变用户列表、状态和确认只读摘要的边界;回归验证必须覆盖真实 procedure 读取,不能仅手工构造完整任务记录。
- 处理:先在事故涉及的编辑器持久任务 JSON 上由 api-server 与 SpacetimeDB 两层递归拒绝 `data:` / `blob:` 并限制字节数;已有媒体传 `objectKey` / `resourceId` / `assetId`,本地派生图先用强唯一 key 上传。其它玩法若仍以 Data URL 作为正式请求契约,必须先资源化,不能直接扩大门禁造成玩法回归。列表、详情和 acknowledge 只走无 payload 的摘要投影,ack 不能为了同步旧字段重写大任务行;摘要错误文本也必须清除内联媒体并设硬上限,列表只能维护有界 top-N,不能先收集 owner 全量历史再截断。历史只通过迁移操作员的 dry-run + B-tree cursor 分批 procedure 压缩 `editor-canvas` 终态任务,cursor 选择读取量必须受 limit 约束,绝不全表扫描、绝不处理 pending / running;dry-run 后 apply 同一批时保持输入 cursor 不变,最后一批即使 `has_more=false` 只要仍有命中也必须 apply,只有 apply 成功后才推进到返回 cursor。SpacetimeDB CLI 2.5 的 `Option<T>` 非空参数必须使用 SATS sum 编码;维护脚本要统一编码 `cursor_job_id`、`owner_user_id` 和 `completed_before_micros`,否则首批空 cursor 可运行,但第二批或带截止时间的调用会在写入前被拒绝。
- 发布门禁:生产发布入口必须固定 `--delete-data=never` 与 scoped `--yes=migrate,break-clients`,普通 Jenkins 参数不得暴露清库开关;需要删数据的 schema 冲突必须直接阻断并重新检查 artifact/schema,不能靠裸 `--yes` 放行。
- 验证:构造嵌套 Data URL、Blob URL 和超限 JSON 确认入队失败;检查正式 UI procedure / client record 不含 request/result payload;用 dry-run 和 apply 测试确认活动任务不变、终态普通提示词保留且内联媒体被替换;至少带一次非空 `--cursor-job-id` 与 `--completed-before-micros` 验证 CLI Option 编码,而不是只测首批空 cursor。
@@ -36,12 +36,17 @@ VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部
- `acknowledge_external_generation_job_summaries_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入摘要投影的 `notification_acknowledged_at` 并追加审计事件。
- `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。
- `get_external_generation_job_summary_and_return`:按 `job_id` 从轻量摘要投影读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。
- `get_external_generation_job_and_return`:仅供受信后端执行幂等重放核对,按 `job_id` 读取原始任务行并校验 `owner_user_id`,保留真实 `dedupe_key` 和含原始请求指纹的 `request_payload_json`。不得用摘要兼容快照替代,否则相同请求会被误判为幂等冲突;同键同原始参数返回原任务,同键不同参数仍返回 `409`。该读取不触发入队、生成或扣费,也不替代 BFF 的摘要与结果查询。
- `get_external_generation_job_result_and_return`:仅供后端内部回填异步编辑器 Agent 工具调用;按 `job_id + owner_user_id` 返回 `status`、`last_error_message` 和已持久化的 `result_payload_json`,不返回请求 payload、lease 或其它 worker 字段。该 procedure 不替代摘要状态读取接口,也不经 BFF 暴露给前端。
External API job 复用同一个 `result_payload_json` 列,但只额外保存 `result` compact 引用:允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、大布局、Data URL、Blob URL、临时 signed URL、provider 原始响应和 lease/fencing 控制字段。普通站内 job 继续保持原 payload 语义,不能为了 External 查询把所有队列结果扩成第二套资产 read model。
角色动画的 completed compact result 通过顶层 `frames` 保留有序帧产物,通过 `durationSeconds` 保留秒级时长;内嵌 `resource/asset` 仍是精简引用,不包含 `imageSequenceFrames/imageSequenceDurationMs`。调用方需要正式序列记录时,按返回的 `resourceId/assetId` 从完整项目或素材库读取,正式时长单位为毫秒;不得把精简引用当完整记录,或根据首帧重复创建动画素材。OpenAPI、Skill 与 helper 自测共同遵循此读路径。
不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。
幂等读取回归使用 `npm run check:editor-idempotency-procedures`:隔离 standalone 验证 pending、running、completed 的原始请求身份、同键重放唯一任务及跨 owner / 不存在任务拒绝,不调用生成 Provider。脚本仅在临时构建目录注入公开测试 bootstrap hash;若设置 `GENARRATIVE_EDITOR_IDEMPOTENCY_SMOKE_WASM` 复用预编译模块,该模块也必须使用脚本中的测试 hash 构建,且不得用于部署。
这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿和编辑器业务结果回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。
## BFF 状态接口
+195 -4
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { chmod, mkdtemp, rm } from 'node:fs/promises';
import { chmod, mkdtemp, readFile, rm } from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
@@ -19,6 +20,11 @@ const database = 'editor-idempotency-smoke';
const expectedSpacetimeVersion = '2.8.3';
const expectedSpacetimeCommit = '8e410d2842147bd8e5a32a9589cc00c19f7478e2';
const commandTimeoutMs = 5 * 60 * 1000;
// 仅用于隔离 smoke;预编译 WASM 也必须使用这个公开测试值的 SHA-256 编译。
const smokeBootstrapSecret = 'a'.repeat(64);
const smokeBootstrapSecretHash = createHash('sha256')
.update(smokeBootstrapSecret)
.digest('hex');
function assert(condition, message) {
if (!condition) {
@@ -123,6 +129,20 @@ export function parseProcedureOutput(stdout, procedureName = 'procedure') {
};
}
function parseExternalGenerationJobResult(stdout, procedureName) {
const value = JSON.parse(String(stdout).trim());
assert(
Array.isArray(value) && value.length === 8,
`${procedureName} returned an invalid result shape.`,
);
return {
ok: value[0],
job: decodeOption(value[1]),
jobs: value[2],
errorMessage: decodeOption(value[7]),
};
}
export function parseSqlRows(stdout, queryLabel = 'SQL') {
let payload;
try {
@@ -278,7 +298,13 @@ function cliPrefix(configPath) {
return ['--config-path', configPath];
}
async function callProcedure(configPath, serverUrl, procedureName, input) {
async function callProcedure(
configPath,
serverUrl,
procedureName,
input,
parseOutput = parseProcedureOutput,
) {
const result = await runCommand('spacetime', [
...cliPrefix(configPath),
'call',
@@ -290,7 +316,168 @@ async function callProcedure(configPath, serverUrl, procedureName, input) {
procedureName,
JSON.stringify(input),
]);
return parseProcedureOutput(result.stdout, procedureName);
return parseOutput(result.stdout, procedureName);
}
async function initializeExternalGenerationRuntime(configPath, serverUrl) {
const pricingPath = path.join(
repoRoot,
'server-rs/crates/api-server/config/editor-generation-pricing.default.json',
);
const pricing = JSON.parse(await readFile(pricingPath, 'utf8'));
const models = Object.entries(pricing.models).map(([model, entry]) => ({
model,
unit: entry.unit,
price: entry.price === undefined ? [1, []] : [0, entry.price],
prices: Object.entries(entry.prices ?? {}).map(([key, price]) => ({
key,
price,
})),
}));
const initialized = await callProcedure(
configPath,
serverUrl,
'initialize_editor_generation_pricing_config_if_missing_and_return',
{
admin_user_id: 'external-generation-smoke',
models,
updated_at_micros: 1,
bootstrap_secret: smokeBootstrapSecret,
},
);
assertOk(initialized, 'external generation runtime identity bootstrap');
}
async function runExternalGenerationJobSmoke(configPath, serverUrl) {
const procedure = (name, input) =>
callProcedure(
configPath,
serverUrl,
name,
input,
parseExternalGenerationJobResult,
);
const ownerUserId = 'external-generation-smoke-owner';
const jobId = 'external-generation-smoke-job';
const dedupeKey = 'external-generation-smoke-dedupe';
const fingerprint = 'external-generation-smoke-fingerprint';
const requestPayloadJson = JSON.stringify({
sourceImageSrc: 'smoke-source-resource',
_externalApiRequestFingerprint: fingerprint,
});
const enqueueInput = {
job_id: jobId,
dedupe_key: dedupeKey,
job_kind: 'editor_background_removal',
owner_user_id: ownerUserId,
source_module: 'editor-canvas',
source_entity_id: 'external-generation-smoke-entity',
request_label: 'external generation smoke',
request_payload_json: requestPayloadJson,
max_attempts: 1,
available_at_micros: 1_000,
created_at_micros: 1_000,
price_mud_points: 0,
};
const getJob = (owner = ownerUserId, id = jobId) =>
procedure('get_external_generation_job_and_return', {
job_id: id,
owner_user_id: owner,
});
const assertOriginalJob = (result, status) => {
assert(
result.ok === true,
`${status} job read failed: ${result.errorMessage}`,
);
assert(Array.isArray(result.job), `${status} job read returned no job.`);
assert(
result.job[0] === jobId &&
result.job[1] === dedupeKey &&
result.job[7] === requestPayloadJson &&
result.job[8] === status,
`${status} job read lost its original ID, dedupe key, payload, or status.`,
);
assert(
JSON.parse(result.job[7])._externalApiRequestFingerprint === fingerprint,
`${status} job read lost the request fingerprint.`,
);
};
const queued = await procedure(
'enqueue_external_generation_job_and_return',
enqueueInput,
);
assert(
queued.ok === true && queued.job?.[0] === jobId,
'Job enqueue failed.',
);
assertOriginalJob(await getJob(), 'pending');
const replay = await procedure('enqueue_external_generation_job_and_return', {
...enqueueInput,
job_id: 'external-generation-smoke-replay-job',
});
assert(
replay.ok === true && replay.job?.[0] === jobId,
'Dedupe replay created a new job.',
);
const rows = await sqlRows(
configPath,
serverUrl,
`SELECT job_id FROM external_generation_job WHERE dedupe_key = '${dedupeKey}'`,
'external generation dedupe rows',
);
assert(
rows.length === 1 && rows[0][0] === jobId,
'Dedupe replay changed job count.',
);
for (const [label, owner, id] of [
['foreign owner', 'external-generation-smoke-other-owner', jobId],
['missing job', ownerUserId, 'external-generation-smoke-missing-job'],
]) {
const result = await getJob(owner, id);
assert(
result.ok === false &&
result.job === null &&
result.errorMessage === 'external_generation_job 不存在',
`${label} read did not fail closed.`,
);
}
const workerId = 'external-generation-smoke-worker';
const claimed = await procedure('claim_external_generation_jobs_and_return', {
worker_id: workerId,
limit: 1,
claimed_at_micros: 1_000,
lease_expires_at_micros: 3_600_001_000,
});
assert(
claimed.ok === true &&
claimed.jobs.length === 1 &&
claimed.jobs[0][0] === jobId,
`Job claim failed: ${claimed.errorMessage}`,
);
assertOriginalJob(await getJob(), 'running');
const leaseToken = decodeOption(claimed.jobs[0][20]);
assert(
typeof leaseToken === 'string' && leaseToken.length > 0,
'Claim returned no lease token.',
);
const completed = await procedure(
'complete_external_generation_job_and_return',
{
job_id: jobId,
worker_id: workerId,
lease_token: leaseToken,
result_payload_json: [1, []],
completed_at_micros: 2_000,
},
);
assert(
completed.ok === true,
`Job completion failed: ${completed.errorMessage}`,
);
assertOriginalJob(await getJob(), 'completed');
}
async function sqlRows(configPath, serverUrl, query, queryLabel) {
@@ -846,13 +1033,17 @@ export async function main() {
env: {
...process.env,
CARGO_TARGET_DIR: path.join(tempDir, 'cargo-target'),
GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256:
smokeBootstrapSecretHash,
},
sensitiveValues: [loginToken],
});
await runSmoke(configPath, serverUrl);
await initializeExternalGenerationRuntime(configPath, serverUrl);
await runExternalGenerationJobSmoke(configPath, serverUrl);
console.log(
'[editor-idempotency-smoke] Passed folder reads (empty, missing, foreign owner, no writes), exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.',
'[editor-idempotency-smoke] Passed folder reads, idempotent creates, and external generation job original-payload reads across pending/running/completed with owner isolation and dedupe replay.',
);
} catch (error) {
const standaloneOutput = sanitizeDiagnostic(
@@ -19846,6 +19846,51 @@ mod tests {
}
}
#[test]
fn compact_external_character_animation_result_keeps_frames_duration_and_references() {
let frames = json!([
{
"imageSrc": "/generated/animation/frame01.png",
"objectKey": "generated/animation/frame01.png",
"assetObjectId": "asset-object-frame01",
"width": 192,
"height": 256
},
{
"imageSrc": "/generated/animation/frame02.png",
"objectKey": "generated/animation/frame02.png",
"assetObjectId": "asset-object-frame02",
"width": 256,
"height": 192
}
]);
let result = compact_external_api_generation_result(json!({
"ok": true,
"durationSeconds": 4,
"frameCount": 2,
"frames": frames,
"resource": {
"resourceId": "resource-animation",
"objectKey": "generated/animation/frame01.png",
"assetObjectId": "asset-object-frame01"
},
"asset": {
"assetId": "asset-animation",
"objectKey": "generated/animation/frame01.png",
"assetObjectId": "asset-object-frame01"
},
"provider": "internal-provider"
}));
assert_eq!(result["frames"], frames);
assert_eq!(result["durationSeconds"], 4);
assert_eq!(result["frameCount"], 2);
assert_eq!(result["resource"]["resourceId"], "resource-animation");
assert_eq!(result["asset"]["assetId"], "asset-animation");
assert_eq!(result["resource"]["assetObjectId"], "asset-object-frame01");
assert_eq!(result["asset"]["assetObjectId"], "asset-object-frame01");
}
#[test]
fn game_creator_completed_jobs_keep_stable_results_for_all_current_media_kinds() {
let fixtures = [
@@ -1125,8 +1125,23 @@ fn get_external_generation_job_tx(
ctx: &ReducerContext,
input: ExternalGenerationJobGetInput,
) -> Result<ExternalGenerationJobSnapshot, String> {
get_external_generation_job_summary_tx(ctx, input)
.map(map_external_generation_job_summary_to_compat_snapshot)
validate_required("external_generation_job.job_id", &input.job_id)?;
validate_required(
"external_generation_job.owner_user_id",
&input.owner_user_id,
)?;
let job_id = input.job_id.trim().to_string();
let row = ctx
.db
.external_generation_job()
.job_id()
.find(&job_id)
.ok_or_else(|| "external_generation_job 不存在".to_string())?;
if row.owner_user_id.trim() != input.owner_user_id.trim() {
return Err("external_generation_job 不存在".to_string());
}
// 内部幂等重放需要真实 dedupe_key 与原始请求指纹,摘要兼容快照不包含这些字段。
Ok(map_external_generation_job_row(row))
}
fn get_external_generation_job_result_tx(