修复账号场景生成的幂等与结果返回协议
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 2m2s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m10s
Project CI / Repository checks (pull_request) Successful in 1m55s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m0s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 2m2s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m10s
Project CI / Repository checks (pull_request) Successful in 1m55s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m0s
站内场景入口读取并透传可选幂等键,复用既有队列去重逻辑 场景配方保留 AGC 来源标记,恢复可下载的队列完成结果 补充非法幂等键、场景结果序列化和幂等命名空间回归测试 同步 OpenAPI、技术方案、实施计划及排障记录
This commit is contained in:
@@ -3059,7 +3059,8 @@
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"generationInputs": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
"$ref": "#/components/schemas/JsonValue",
|
||||
"description": "场景配方由服务端重建;仅保留 source 精确等于 ai-game-creator-client 的客户端来源标记,用于选择 AGC 队列结果与幂等命名空间。调用方 fields、action 和引用 provenance 不会覆盖服务端配方。"
|
||||
},
|
||||
"assetFolderId": {
|
||||
"type": ["string", "null"]
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- `docs/openapi/genarrative-external-v1.openapi.json` 与对应契约测试
|
||||
- AGC `src-tauri/src/agent/generation/canvas_generation.rs`、`src/agent/direct_runtime/mod.rs`、`src/agent/generation/external_generation_state.rs`、`src/agent/runtime_driver/recovery_scan.rs`、`src/project/manifest.rs`(背景阶段路由与身份口径)
|
||||
- `.codex/skills/genarrative-external-editor-api/references/`(外部 API 说明)
|
||||
- 明确不修改:站内场景路由行为、通用图片入口校验、SpacetimeDB schema、计费、动画/抠图链路(issue #495)。
|
||||
- 明确不修改:站内场景生成规则、通用图片入口校验、SpacetimeDB schema、计费、动画/抠图链路(issue #495);站内入口补齐现有 AGC 账号模式的幂等与结果协议。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
5. 客户端:`canvas_generation.rs` 请求构造为 Scene 拆专属分支——新路由 + `EditorSceneGenerateRequest` 形状 body(`sceneContent` = 现有场景 prompt 文本,`stylePreset = "custom"`,`customStyle` = 现有风格描述文案,其余字段沿用);修 Scene 保留账本匹配器(新路由 + `sceneContent` 口径),统一 `game-background`/`scene` 不一致。
|
||||
6. 客户端:身份/对账常量迁移——`direct_runtime/mod.rs` 背景身份三元组路由与 kind、`manifest.rs` 期望路由、`recovery_scan.rs`、`external_generation_state.rs`;保持旧路由登记可读。
|
||||
7. 文档:外部 API Skill references 补新路由;运行编码与 diff 检查。
|
||||
8. 审查修复:站内场景 handler 读取并透传可选幂等键;共享组装只保留精确 AGC 来源标记。增加非法键路由测试与场景 payload 到队列可下载结果的回归测试,同步 OpenAPI 元数据说明。
|
||||
|
||||
## 验证命令
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
## 不在本里程碑内
|
||||
|
||||
- 不改站内场景路由行为与通用图片入口的 scene 拒绝校验。
|
||||
- 保持站内场景生成规则,补齐账号模式幂等与结果协议;不改通用图片入口的 scene 拒绝校验。
|
||||
- 不改 SpacetimeDB schema、队列类型、计费档位。
|
||||
- 不迁移历史背景登记数据。
|
||||
- 不处理 issue #495 的两个既有问题。
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 账号场景入口透传可选幂等键、入队前拒绝非法键;场景配方保留 AGC 来源标记且队列完成结果包含可下载资源(路由校验及组装到结果序列化的定向测试)。
|
||||
- [ ] 新路由契约测试通过:路由矩阵与鉴权、参数 400、同键重放返回原任务、同键不同请求 409。
|
||||
- [ ] 外部场景路由与站内路由对相同输入产出相同的后端 Prompt 与入队 payload(共享实现单测对照)。
|
||||
- [ ] `docs/openapi/genarrative-external-v1.openapi.json` 与实现一致,相关契约检查通过。
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 2026-09-24 AGC 生成路由迁移必须核对账号队列契约
|
||||
|
||||
- AGC 普通账号会把 external editor 路由映射到站内入口;只验证 API Key 路由不足以证明客户端链路可用。
|
||||
- 站内入口必须读取客户端持久化的 `Idempotency-Key`,场景配方重建须保留精确的 `generationInputs.source = ai-game-creator-client` 标记。否则可能按请求 ID 重复入队,且普通队列 consumer 不保存客户端轮询所需的可下载 `result`。
|
||||
- 场景其余执行字段和引用身份仍由服务端重建,不整体信任调用方 `generationInputs`。验证应覆盖来源标记经过组装、队列清理和结果序列化的完整纯逻辑链路,以及账号路由的幂等键校验。
|
||||
|
||||
## 同一祖先下的多个项目会各自弹一次 UAC
|
||||
|
||||
- **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。
|
||||
|
||||
@@ -18,7 +18,7 @@ AGC 美术包背景阶段(结构化场景意图)
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改动站内 `/api/editor/scenes/generations` 的请求字段、Prompt 组装结果与计费语义。
|
||||
- 保持站内 `/api/editor/scenes/generations` 的场景字段、Prompt 和计费规则;补齐 AGC 账号模式所需的可选幂等键与队列结果协议。
|
||||
- 不放松通用 `/api/editor/images/generations` 与 `/api/external/v1/editor/images/generations` 对 `kind = scene` / `assetKind = scene` 的拒绝。
|
||||
- 不新增场景 Worker、任务表、计费档位或 SpacetimeDB schema。
|
||||
- 不改变美术包背景图的出图风格与尺寸(16:9 / 1K)。
|
||||
@@ -40,6 +40,7 @@ AGC 美术包背景阶段(结构化场景意图)
|
||||
4. 受理响应与现役外部生成入口同形(operationId 异步受理信封),轮询继续走 `/api/external/v1/generations/{operation_id}`。
|
||||
5. 入队后 `kind = scene`、`assetKind = scene`,队列类型、Worker、计费与持久化与站内场景路由一致;队列标题与任务摘要口径不变。
|
||||
6. AGC 美术包背景阶段以 `stylePreset = custom` + `customStyle` 承载现有风格描述,`sceneContent` 承载 brief 衍生的画面内容,出图风格与比例不因迁移改变。
|
||||
7. 普通账号自动映射到 `/api/editor/scenes/generations`,该入口读取可选 `Idempotency-Key` 并传给现有队列;未提供时保留站内按请求 ID 入队的行为。场景组装仅保留 `generationInputs.source = ai-game-creator-client` 这一精确标记,其余配方字段仍由服务端重建。该标记让账号任务沿用 AGC 幂等命名空间和包含可下载 `result` 的队列结果,轮询走 `/api/runtime/external-generation/jobs/{operation_id}`。
|
||||
|
||||
### 失败、重试与幂等
|
||||
|
||||
@@ -47,6 +48,7 @@ AGC 美术包背景阶段(结构化场景意图)
|
||||
2. 缺少或非法幂等键、越权 scope 的拒绝语义与现役外部生成入口一致。
|
||||
3. 同一幂等键 + 同一请求重放返回原任务,不新建任务、不重复扣费;同键不同请求返回 409。
|
||||
4. Provider 失败、取消与 lease 耗尽沿用现有扣退费语义。
|
||||
5. 账号场景入口拒绝非法幂等键(400);同键重放复用现有队列幂等实现。来源标记不参与权限授予,任务归属仍来自已认证用户。
|
||||
|
||||
### 权限、归属与数据边界
|
||||
|
||||
@@ -64,6 +66,7 @@ AGC 美术包背景阶段(结构化场景意图)
|
||||
|
||||
| 条款 | 验收方式 | 证据 |
|
||||
| ---- | -------- | ---- |
|
||||
| 账号入口幂等键校验与 AGC 下载结果 | 非法键路由测试、场景来源到结果序列化测试、共享队列幂等命名空间测试 | `cargo test --locked -p api-server scene`(18 项)、`editor_generation_queue::tests`(19 项)、`external`(158 项)通过;本地启动因 SpacetimeDB 连接拒绝未通过健康检查,真实 Provider 出图与账号同键重放尚未联调 |
|
||||
| 新路由受理/参数校验/鉴权/幂等重放 | api-server 契约测试与单测 | 待补 |
|
||||
| 与站内路由同一 Prompt 组装结果 | 共享实现的单测对照 | 待补 |
|
||||
| OpenAPI 与实现一致 | 契约测试 + `check:openapi` 类门禁 | 待补 |
|
||||
|
||||
@@ -2749,6 +2749,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_scene_generation_rejects_invalid_idempotency_key_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Queue,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138232", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_idempotency");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = build_router(state.clone());
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/scenes/generations")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("idempotency-key", "contains space")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"sceneContent": "雨夜小镇",
|
||||
"stylePreset": "anime",
|
||||
"generationInputs": { "source": "ai-game-creator-client" },
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should complete");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(state.test_editor_generation_enqueue_attempts(), 0);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(String::from_utf8_lossy(&body).contains("Idempotency-Key"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_scene_generation_rejects_inline_data_url_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
|
||||
@@ -826,6 +826,19 @@ mod tests {
|
||||
editor_generation_idempotency_namespace(&game_creator),
|
||||
GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX
|
||||
);
|
||||
let scene = crate::editor_project::build_editor_scene_image_generation_payload(
|
||||
serde_json::from_value(json!({
|
||||
"sceneContent": "雨夜小镇",
|
||||
"stylePreset": "anime",
|
||||
"generationInputs": { "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE },
|
||||
}))
|
||||
.expect("scene request should deserialize"),
|
||||
)
|
||||
.expect("scene payload should build");
|
||||
assert_eq!(
|
||||
editor_generation_idempotency_namespace(&scene),
|
||||
GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX,
|
||||
);
|
||||
assert_eq!(
|
||||
editor_generation_idempotency_namespace(&ordinary),
|
||||
EXTERNAL_API_GENERATION_DEDUPE_PREFIX
|
||||
|
||||
@@ -2658,12 +2658,23 @@ fn build_editor_scene_generation_inputs(
|
||||
{
|
||||
fields.push(json!({ "id": "customStyle", "title": "自定义画风", "value": custom_style }));
|
||||
}
|
||||
json!({
|
||||
let mut inputs = json!({
|
||||
"version": 2,
|
||||
"action": "scene.generate",
|
||||
"fields": fields,
|
||||
"references": references,
|
||||
})
|
||||
});
|
||||
// AGC 账号任务依赖来源标记选择幂等命名空间和可下载的队列结果;其余字段仍由服务端重建。
|
||||
if payload
|
||||
.generation_inputs
|
||||
.as_ref()
|
||||
.and_then(|inputs| inputs.get("source"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(GAME_CREATOR_CLIENT_GENERATION_SOURCE)
|
||||
{
|
||||
inputs["source"] = json!(GAME_CREATOR_CLIENT_GENERATION_SOURCE);
|
||||
}
|
||||
inputs
|
||||
}
|
||||
|
||||
fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str {
|
||||
@@ -2739,9 +2750,11 @@ pub async fn generate_editor_scene(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
headers: HeaderMap,
|
||||
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let Json(payload) = parse_editor_generation_json_payload(payload)?;
|
||||
let idempotency_key = optional_editor_idempotency_key(&headers)?;
|
||||
let image_payload = build_editor_scene_image_generation_payload(payload)?;
|
||||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
@@ -2750,7 +2763,7 @@ pub async fn generate_editor_scene(
|
||||
&request_context,
|
||||
&caller,
|
||||
image_payload,
|
||||
None,
|
||||
idempotency_key,
|
||||
)
|
||||
.await?;
|
||||
return Ok(json_success_body(
|
||||
@@ -13316,6 +13329,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_generation_preserves_agc_downloadable_queue_result() {
|
||||
let request = serde_json::from_value::<EditorSceneGenerateRequest>(json!({
|
||||
"sceneContent": "雨夜小镇",
|
||||
"stylePreset": "anime",
|
||||
"referenceImageSrcs": ["art-spec-resource"],
|
||||
"generationInputs": {
|
||||
"source": GAME_CREATOR_CLIENT_GENERATION_SOURCE,
|
||||
"action": "client-supplied-action",
|
||||
"fields": [{ "id": "prompt", "value": "不可信配方" }],
|
||||
"references": [{ "refId": "untrusted-resource" }],
|
||||
},
|
||||
}))
|
||||
.expect("scene request should deserialize");
|
||||
let mut image_payload = build_editor_scene_image_generation_payload(request)
|
||||
.expect("scene payload should build");
|
||||
image_payload.generation_inputs =
|
||||
sanitize_editor_queued_generation_inputs(image_payload.generation_inputs);
|
||||
let inputs = image_payload.generation_inputs.as_ref().unwrap();
|
||||
assert_eq!(inputs["source"], GAME_CREATOR_CLIENT_GENERATION_SOURCE);
|
||||
assert_eq!(inputs["action"], "scene.generate");
|
||||
assert_eq!(inputs["fields"][0]["value"], "雨夜小镇");
|
||||
assert_eq!(inputs["references"], json!([{ "id": "reference" }]));
|
||||
|
||||
let mut job = atomic_editor_generation_job_fixture();
|
||||
job.request_payload_json = serde_json::to_string(&image_payload).unwrap();
|
||||
let context = EditorGenerationQueueResultContext::from_job(&job);
|
||||
assert_eq!(
|
||||
context.consumer,
|
||||
EditorGenerationQueueConsumer::GameCreatorResourceEditor
|
||||
);
|
||||
let result: Value = serde_json::from_str(
|
||||
&serialize_atomic_editor_generation_job_result(
|
||||
&context,
|
||||
&json!({
|
||||
"ok": true,
|
||||
"objectKey": "generated/scene.png",
|
||||
"resource": {
|
||||
"resourceId": "scene-resource",
|
||||
"objectKey": "generated/scene.png",
|
||||
"assetObjectId": "scene-object",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.expect("AGC scene result should serialize"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["result"]["objectKey"], "generated/scene.png");
|
||||
assert_eq!(result["result"]["resource"]["resourceId"], "scene-resource");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_removal_options_preserve_queue_parameters_and_legacy_identity() {
|
||||
for (fields, mode, color) in [
|
||||
|
||||
Reference in New Issue
Block a user