diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6abcaa0b6..7e6bb8215 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1123,7 +1123,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。 - 原因:前端展示价和后端价格计算只证明价格能被展示 / 解析;如果 handler 没有包进 `execute_billable_asset_operation_with_cost`,或异步音频发布目标没有携带本次模型价格,外部 provider 仍会被调用但不会真实扣费。 - 处理:新增或改造编辑器外部生成入口时,确认前端请求不携带 `priceMudPoints`,后端按运行时模型定价重新计算价格,并用该价格进入资产扣费 wrapper。音频提交 / 发布分离时,把后端计算出的价格写入 `AudioAssetBindingTarget.billing_points_cost`。 -- 验证:结构性测试覆盖对应 handler 包含 `execute_billable_asset_operation_with_cost` 和价格变量;音频测试覆盖 `resolve_creation_audio_points_cost` 优先读取 editor target 的 `billing_points_cost`。 +- 验证:结构性测试覆盖对应 handler 包含 `execute_billable_asset_operation_with_cost` 和价格变量;`worker_billing_context_freezes_charge_and_preserves_job_metadata` 与 `logged_in_background_music_queue_preparation_uses_canonical_prompt_and_frozen_price` 覆盖现役队列冻结价格,原子计费测试覆盖提交失败与结果未知时的退款边界。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/api-server/src/vector_engine_audio_generation/`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`。 ## 本地 dev 启动日志先看成功锚点,不要把非阻断 warning 当失败 diff --git a/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md b/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md index 8e76aa04d..1cb113101 100644 --- a/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md +++ b/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md @@ -77,6 +77,7 @@ EditorGenerationResultPersistInput { ## api-server 接入 +- 旧分段持久化、worker 独立完成及其重复结果序列化实现,在失去现役调用方后直接删除;不保留只供旧测试调用的生产副本。废弃实现的专属测试随实现删除,不因清理而将旧用例改接到现役函数;现有现役行为测试保持原有覆盖。手动拆分、上传等现役路径使用的底层 helper 继续保留,HTTP / DTO、inline 模式和历史任务解析兼容不受清理影响。 - 通用持久化改为 `prepare -> build canvas candidate -> atomic commit`。prepare 阶段只生成稳定 ID、上传/验证对象和构造候选 DTO,不创建 resource/asset。 - api-server 继续复用现有画布 completion / replacement 逻辑计算候选 `layers_json` 和 `expected_revision`;统一 procedure 在最终事务内重新执行既有 layout 校验和 CAS。 - CAS 冲突只刷新当前 project、重新计算 layout 并重试 prepared commit;相同 operation、slot、对象和记录候选保持不变,禁止重跑 Provider。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 8b993c895..3703a381e 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -65,7 +65,7 @@ - 角色面板增加紧凑的 `像素艺术` 勾选项,请求使用可选字符串字段 `style`:未勾选传 `"none"`,勾选传 `"pixelArt"`。该选择可以随现有生成器快照和队列 payload 保存,但不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 - `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 在 `kind="character"` 时启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。同一图片生成请求 DTO 被其它 `kind` 复用时,只有普通图片和 `character` 支持 `"pixelArt"`,其它 `kind` 收到该值也按不支持风格降级。 -- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。角色链路使用「角色主体为像素风格」,**不得**使用「画面为像素风格」——角色生成后要按纯色抠像,绿幕底必须保持平整,同一段提示词里已写死「纯色背景必须平整无纹理、无渐变」,画面级像素化要求会与之互相拆台;且该提示词已禁止出现角色以外的场景内容,因此只需点名角色本身。注入发生在 `build_editor_character_image_prompt` 返回之后,该函数签名和输出契约不变。约束句**不会**进入角色链路的任何 `editor_project_resource`:原图 resource 的 prompt 列存的是 `role_setting`(用户原文),透明结果的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`;完整提交提示词是否留存取决于 provider:`persist_editor_provider_source_image` 写原图 asset object 元数据时用的是 `actual_prompt.unwrap_or(prompt)`,provider 未回 `actualPrompt` 时才存 `submitted_prompt`(含约束句),此时排障可按 `object_key` 查;provider 回了 `actualPrompt` 就存 provider 改写后的文本,该次生成的 `submitted_prompt` 在系统内一处都不落——外部 API 审计的 `request_payload` 只记 `promptChars` 字符数,没有提示词原文。响应体返回的是用户原文,前端显示不变。以上 prompt 列写入与 asset object 元数据规则都是既有行为,与 `web/master` 逐行一致,本次未改动。角色提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,本次未改写,等实测。 +- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。角色链路使用「角色主体为像素风格」,**不得**使用「画面为像素风格」——角色生成后要按纯色抠像,绿幕底必须保持平整,同一段提示词里已写死「纯色背景必须平整无纹理、无渐变」,画面级像素化要求会与之互相拆台;且该提示词已禁止出现角色以外的场景内容,因此只需点名角色本身。注入发生在 `build_editor_character_image_prompt` 返回之后,该函数签名和输出契约不变。约束句**不会**进入角色链路的任何 `editor_project_resource`:原图 resource 的 prompt 列存的是 `role_setting`(用户原文),透明结果的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`;原图 asset object 元数据由现役 `prepare_editor_generated_image` / upload-only helper 按传入的 `prompt` 构造,resource / asset 的 `actualPrompt` 由原子提交候选单独保存;排障应核对现役调用参数与记录,不能再按已退役的 provider-source 分步持久化函数推断提示词来源。角色提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,本次未改写,等实测。 - 角色 provider 回图先按统一业务像素矩阵执行交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。归一后的带纯色背景图先持久化并作为 BgFilter 输入;BgFilter 正常成功后,把 Alpha 蒙版回贴到这张同尺寸平底原图,再执行像素规整并上传透明主图。网格分析源使用已收口到实际交付尺寸的平底原图,RGBA 采样源使用 Alpha 已回贴的透明图;软 Alpha 只参与单格覆盖率和 Alpha 加权 RGB 计算,输出 Alpha 硬化为 `0 / 255`。 - 首版参数固定为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格覆盖率 `Σ(A / 255) / N >= 0.375` 且 `ΣA > 0` 时输出 `A=255`,颜色按 `Σ(A × RGB) / ΣA` 计算;否则输出 `[0,0,0,0]`。分析色数不限制最终输出色数。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级。 diff --git a/server-rs/crates/api-server/src/api_response.rs b/server-rs/crates/api-server/src/api_response.rs index c9e7ffee0..35a8bc64e 100644 --- a/server-rs/crates/api-server/src/api_response.rs +++ b/server-rs/crates/api-server/src/api_response.rs @@ -1,13 +1,4 @@ -use std::convert::Infallible; - -use axum::{ - Json, - body::Body, - http::{HeaderValue, header}, - response::{IntoResponse, Response}, -}; -use bytes::Bytes; -use futures_util::stream; +use axum::Json; use serde::Serialize; use serde_json::Value; #[cfg(test)] @@ -41,30 +32,6 @@ where Json(serde_json::to_value(data).unwrap_or(Value::Null)) } -pub fn json_success_data_bytes_response( - request_context: Option<&RequestContext>, - data_json: Bytes, -) -> Response { - if let Some(context) = request_context - && context.wants_envelope() - { - let meta = serde_json::to_vec(&build_api_response_meta(Some(context))) - .map(Bytes::from) - .unwrap_or_else(|_| Bytes::from_static(b"null")); - let chunks = [ - Bytes::from_static(b"{\"ok\":true,\"data\":"), - data_json, - Bytes::from_static(b",\"error\":null,\"meta\":"), - meta, - Bytes::from_static(b"}"), - ]; - let stream = stream::iter(chunks.into_iter().map(Ok::)); - return json_body_response(Body::from_stream(stream)); - } - - json_bytes_response(data_json) -} - pub fn json_error_body( request_context: Option<&RequestContext>, error: &ApiErrorPayload, @@ -98,19 +65,6 @@ fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiRespo ) } -fn json_bytes_response(bytes: Bytes) -> Response { - json_body_response(Body::from(bytes)) -} - -fn json_body_response(body: Body) -> Response { - let mut response = body.into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("application/json; charset=utf-8"), - ); - response -} - #[cfg(test)] mod tests { use super::*; @@ -152,31 +106,6 @@ mod tests { assert!(body.get("meta").is_none()); } - #[tokio::test] - async fn success_response_streams_cached_data_inside_standard_envelope() { - use http_body_util::BodyExt; - - let request_context = build_request_context(true); - let response = json_success_data_bytes_response( - Some(&request_context), - Bytes::from_static(br#"{"items":[]}"#), - ); - let body = response - .into_body() - .collect() - .await - .expect("response body should collect") - .to_bytes(); - let payload: Value = serde_json::from_slice(&body).expect("body should be json"); - - assert_eq!(payload["ok"], Value::Bool(true)); - assert_eq!(payload["data"]["items"], Value::Array(Vec::new())); - assert_eq!( - payload["meta"]["requestId"], - Value::String("req-test".to_string()) - ); - } - #[test] fn error_body_returns_legacy_shape_without_envelope_header() { let request_context = build_request_context(false); diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 88b2d90f9..e4b0b2d7d 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -256,7 +256,6 @@ mod tests { use super::{build_router, build_spacetime_unavailable_router}; const TEST_PASSWORD: &str = "secret123"; - const INTERNAL_TEST_SECRET: &str = "test-internal-secret"; /// 需 scripts/agc-analytics-smoke.mjs 创建的隔离数据库;认证用户为测试夹具, /// HTTP、facade、数据库事务和后台查询均走正式实现。 @@ -1120,25 +1119,6 @@ mod tests { let _ = std::fs::remove_dir_all(outbox_dir); } - #[cfg(any())] - fn build_internal_creative_agent_app() -> Router { - let mut config = AppConfig::default(); - config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string()); - build_router(AppState::new(config).expect("state should build")) - } - - #[cfg(any())] - fn internal_creative_agent_request(method: &str, uri: &str, body: Value) -> Request { - Request::builder() - .method(method) - .uri(uri) - .header("content-type", "application/json") - .header("x-genarrative-authenticated-user-id", "user-creative-test") - .header("x-genarrative-internal-api-secret", INTERNAL_TEST_SECRET) - .body(Body::from(body.to_string())) - .expect("creative agent request should build") - } - async fn read_json_response(response: axum::response::Response) -> Value { let body = response .into_body() @@ -1149,16 +1129,6 @@ mod tests { serde_json::from_slice(&body).expect("response body should be valid json") } - async fn read_text_response(response: axum::response::Response) -> String { - let body = response - .into_body() - .collect() - .await - .expect("response body should collect") - .to_bytes(); - String::from_utf8(body.to_vec()).expect("response body should be utf8") - } - #[tokio::test] async fn healthz_returns_legacy_compatible_payload_and_headers() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -1998,247 +1968,6 @@ mod tests { ); } - #[cfg(any())] - #[tokio::test] - async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() { - let app = build_internal_creative_agent_app(); - - let create_response = app - .clone() - .oneshot(internal_creative_agent_request( - "POST", - "/api/runtime/creative-agent/sessions", - serde_json::json!({ - "text": "做一个生日拼图", - "entryContext": "creation_home" - }), - )) - .await - .expect("create session request should succeed"); - assert_eq!(create_response.status(), StatusCode::OK); - let create_payload = read_json_response(create_response).await; - let session_id = create_payload["session"]["sessionId"] - .as_str() - .expect("session id should exist"); - - let edit_response = app - .clone() - .oneshot(internal_creative_agent_request( - "POST", - &format!("/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream"), - serde_json::json!({ - "clientMessageId": "creative-edit-test", - "instruction": "把标题改轻松一点", - "targetPuzzleSessionId": "puzzle-session-unconfirmed", - "currentDraft": { - "workTitle": "旧标题", - "workDescription": "旧描述", - "summary": "旧描述", - "themeTags": ["创意", "拼图", "灵感"], - "levels": [{ - "levelId": "puzzle-level-1", - "levelName": "第一关", - "pictureDescription": "旧图面", - "pictureReference": null, - "generationStatus": "idle", - "candidates": [] - }] - } - }), - )) - .await - .expect("draft edit request should be handled"); - - assert_eq!(edit_response.status(), StatusCode::BAD_REQUEST); - let edit_payload = read_json_response(edit_response).await; - assert_eq!( - edit_payload["error"]["details"]["message"], - Value::String("尚未绑定拼图草稿".to_string()) - ); - - let session_response = app - .oneshot(internal_creative_agent_request( - "GET", - &format!("/api/runtime/creative-agent/sessions/{session_id}"), - Value::Null, - )) - .await - .expect("get session request should succeed"); - let session_payload = read_json_response(session_response).await; - assert_eq!(session_payload["session"]["targetBinding"], Value::Null); - } - - #[cfg(any())] - #[tokio::test] - async fn creative_agent_message_stream_returns_template_confirmation_events() { - let app = build_internal_creative_agent_app(); - - let create_response = app - .clone() - .oneshot(internal_creative_agent_request( - "POST", - "/api/runtime/creative-agent/sessions", - serde_json::json!({ - "text": "做一个生日拼图", - "entryContext": "creation_home" - }), - )) - .await - .expect("create session request should succeed"); - assert_eq!(create_response.status(), StatusCode::OK); - let create_payload = read_json_response(create_response).await; - let session_id = create_payload["session"]["sessionId"] - .as_str() - .expect("session id should exist"); - - let stream_response = app - .clone() - .oneshot(internal_creative_agent_request( - "POST", - &format!("/api/runtime/creative-agent/sessions/{session_id}/messages/stream"), - serde_json::json!({ - "clientMessageId": "creative-message-stream-test", - "content": [{ - "type": "input_text", - "text": "做一个温暖的生日拼图" - }] - }), - )) - .await - .expect("message stream request should be handled"); - - assert_eq!(stream_response.status(), StatusCode::OK); - assert_eq!( - stream_response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()), - Some("text/event-stream") - ); - let stream_body = read_text_response(stream_response).await; - - assert!(stream_body.contains("event: stage")); - assert!(stream_body.contains("event: tool_started")); - assert!(stream_body.contains("event: tool_completed")); - assert!(stream_body.contains("event: puzzle_template_catalog")); - assert!(!stream_body.contains("event: puzzle_template_selection")); - assert!(!stream_body.contains("event: puzzle_cost_range")); - assert!(stream_body.contains("event: done")); - let tool_started_id = stream_body - .lines() - .skip_while(|line| *line != "event: tool_started") - .nth(1) - .and_then(|line| line.strip_prefix("data: ")) - .and_then(|data| serde_json::from_str::(data).ok()) - .and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string)) - .expect("tool_started should include toolCallId"); - let tool_completed_id = stream_body - .lines() - .skip_while(|line| *line != "event: tool_completed") - .nth(1) - .and_then(|line| line.strip_prefix("data: ")) - .and_then(|data| serde_json::from_str::(data).ok()) - .and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string)) - .expect("tool_completed should include toolCallId"); - assert_eq!(tool_started_id, tool_completed_id); - - let session_response = app - .oneshot(internal_creative_agent_request( - "GET", - &format!("/api/runtime/creative-agent/sessions/{session_id}"), - Value::Null, - )) - .await - .expect("get session request should succeed"); - let session_payload = read_json_response(session_response).await; - assert_eq!( - session_payload["session"]["stage"], - Value::String("waiting_template_confirmation".to_string()) - ); - assert_eq!( - session_payload["session"]["puzzleTemplateSelection"], - Value::Null - ); - assert!( - session_payload["session"]["puzzleTemplateCatalog"] - .as_array() - .map(|templates| templates.len() >= 3) - .unwrap_or(false) - ); - } - - #[cfg(any())] - #[tokio::test] - async fn creative_agent_confirm_template_rejects_non_puzzle_template() { - let app = build_internal_creative_agent_app(); - - let create_response = app - .clone() - .oneshot(internal_creative_agent_request( - "POST", - "/api/runtime/creative-agent/sessions", - serde_json::json!({ - "text": "做一个角色扮演开场", - "entryContext": "creation_home" - }), - )) - .await - .expect("create session request should succeed"); - assert_eq!(create_response.status(), StatusCode::OK); - let create_payload = read_json_response(create_response).await; - let session_id = create_payload["session"]["sessionId"] - .as_str() - .expect("session id should exist"); - - let confirm_response = app - .clone() - .oneshot(internal_creative_agent_request( - "POST", - &format!("/api/runtime/creative-agent/sessions/{session_id}/confirm-template"), - serde_json::json!({ - "selection": { - "templateId": "rpg.unsupported", - "title": "RPG", - "reason": "用户想创建 RPG", - "costRange": { - "minPoints": 2, - "maxPoints": 12, - "pricingUnit": "point", - "reason": "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准" - }, - "supportedLevelMode": "single_or_multi", - "selectedLevelMode": "single_level", - "plannedLevelCount": 1, - "requiresUserConfirmation": true - } - }), - )) - .await - .expect("confirm template request should be handled"); - - assert_eq!(confirm_response.status(), StatusCode::BAD_REQUEST); - let confirm_payload = read_json_response(confirm_response).await; - assert_eq!( - confirm_payload["error"]["details"]["provider"], - Value::String("module-puzzle".to_string()) - ); - - let session_response = app - .oneshot(internal_creative_agent_request( - "GET", - &format!("/api/runtime/creative-agent/sessions/{session_id}"), - Value::Null, - )) - .await - .expect("get session request should succeed"); - let session_payload = read_json_response(session_response).await; - assert_eq!( - session_payload["session"]["stage"], - Value::String("idle".to_string()) - ); - assert_eq!(session_payload["session"]["targetBinding"], Value::Null); - } - #[tokio::test] async fn runtime_story_legacy_routes_are_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index f9eb99573..9ba5795db 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -13,8 +13,6 @@ use crate::{ wallet_refund_outbox::{WalletRefundOutboxEnqueueOutcome, WalletRefundOutboxRecord}, }; -pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1; - #[derive(Clone, Debug)] struct ExternalGenerationBillingContext { job_id: String, @@ -145,29 +143,7 @@ where .await } -/// 资产操作统一执行入口:业务层只声明操作类型与资源 ID,钱包扣退费由服务层收口。 -pub(crate) async fn execute_billable_asset_operation( - state: &AppState, - owner_user_id: &str, - asset_kind: &str, - asset_id: &str, - operation: Fut, -) -> Result -where - Fut: Future>, -{ - execute_billable_asset_operation_with_cost( - state, - owner_user_id, - asset_kind, - asset_id, - ASSET_OPERATION_POINTS_COST, - operation, - ) - .await -} - -/// 生图等特殊操作可声明独立泥点成本,避免修改全局资产操作默认价格。 +/// 资产操作统一执行入口:业务层声明后端计算的泥点成本,钱包扣退费由服务层收口。 pub(crate) async fn execute_billable_asset_operation_with_cost( state: &AppState, owner_user_id: &str, @@ -706,25 +682,6 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A } } -pub(crate) fn should_skip_asset_operation_billing_for_connectivity( - error: &SpacetimeClientError, -) -> bool { - match error { - SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true, - SpacetimeClientError::Build(message) - | SpacetimeClientError::Procedure(message) - | SpacetimeClientError::Runtime(message) => { - message.contains("503") - || message.contains("Service Unavailable") - || message.contains("Failed to connect") - || message.contains("WebSocket") - || message.contains("No such procedure") - || message.contains("连接已断开") - || message.contains("连接在返回结果前已断开") - } - } -} - fn should_use_wallet_refund_emergency_spool(error: &SpacetimeClientError) -> bool { match error { SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true, @@ -875,27 +832,6 @@ mod tests { assert_eq!(wallet.balance_delta, -37); } - #[test] - fn asset_operation_connectivity_errors_are_classified_for_non_billing_fallbacks() { - assert_eq!(ASSET_OPERATION_POINTS_COST, 1); - assert!(should_skip_asset_operation_billing_for_connectivity( - &SpacetimeClientError::ConnectDropped - )); - assert!(should_skip_asset_operation_billing_for_connectivity( - &SpacetimeClientError::Runtime( - "Failed to connect: HTTP error: 503 Service Unavailable".to_string(), - ), - )); - assert!(should_skip_asset_operation_billing_for_connectivity( - &SpacetimeClientError::Procedure( - "No such procedure: consume_profile_wallet_points_and_return".to_string(), - ), - )); - assert!(!should_skip_asset_operation_billing_for_connectivity( - &SpacetimeClientError::Procedure("泥点余额不足".to_string()), - )); - } - #[test] fn wallet_refund_emergency_spool_requires_database_unavailability() { assert!(should_use_wallet_refund_emergency_spool( diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index e64d5c8ab..b75504dbe 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -298,13 +298,6 @@ pub enum ExternalGenerationMode { } impl ExternalGenerationMode { - pub fn as_str(self) -> &'static str { - match self { - Self::Inline => "inline", - Self::Queue => "queue", - } - } - pub fn is_inline(self) -> bool { matches!(self, Self::Inline) } diff --git a/server-rs/crates/api-server/src/editor_agent/mod.rs b/server-rs/crates/api-server/src/editor_agent/mod.rs index c7a62010f..55a9b2d17 100644 --- a/server-rs/crates/api-server/src/editor_agent/mod.rs +++ b/server-rs/crates/api-server/src/editor_agent/mod.rs @@ -11,6 +11,3 @@ pub use api::{ create_editor_agent_conversation, delete_editor_agent_conversation, get_editor_agent_conversation, list_editor_agent_conversations, }; - -#[cfg(test)] -pub(crate) use reconcile::reconcile_completed_editor_agent_tool_call_for_test; diff --git a/server-rs/crates/api-server/src/editor_agent/reconcile.rs b/server-rs/crates/api-server/src/editor_agent/reconcile.rs index 80f6f97ce..44e10f454 100644 --- a/server-rs/crates/api-server/src/editor_agent/reconcile.rs +++ b/server-rs/crates/api-server/src/editor_agent/reconcile.rs @@ -226,15 +226,6 @@ fn reconcile_completed_editor_agent_tool_call( Ok(()) } -#[cfg(test)] -pub(crate) fn reconcile_completed_editor_agent_tool_call_for_test( - message: &mut EditorAgentMessage, - result_payload_json: Option<&str>, -) -> Result<(), String> { - reconcile_completed_editor_agent_tool_call(message, result_payload_json) - .map_err(|error| format!("{error:?}")) -} - #[cfg(test)] mod tests { use super::*; diff --git a/server-rs/crates/api-server/src/editor_green_screen.rs b/server-rs/crates/api-server/src/editor_green_screen.rs index 6bd011bdb..8dd365dbc 100644 --- a/server-rs/crates/api-server/src/editor_green_screen.rs +++ b/server-rs/crates/api-server/src/editor_green_screen.rs @@ -146,17 +146,6 @@ fn editor_screen_background_color_prompt(color: EditorScreenBackgroundColor) -> ) } -pub(crate) fn editor_green_screen_asset_prompt_clause( - color: EditorScreenBackgroundColor, -) -> String { - format!( - "背景必须是{},且{},方便扣除背景;{}", - editor_screen_background_color_prompt(color), - EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS, - EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS - ) -} - pub(crate) fn editor_green_screen_character_prompt_clause( color: EditorScreenBackgroundColor, ) -> String { diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index cd4e3542e..f6ec1d0ed 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1268,7 +1268,7 @@ fn serialize_atomic_editor_generation_job_result( Ok(payload_json) } -/// 队列结果落库前的通用紧凑化。原子提交与 worker 直接 complete 两条路径共用同一份实现, +/// 队列结果原子提交前的通用紧凑化, /// 避免任何 consumer 把内部 provider 带入可重放的结果快照。 pub(crate) fn compact_editor_generation_result(mut result: Value) -> Value { let Some(object) = result.as_object_mut() else { @@ -1586,7 +1586,6 @@ struct EditorCanvasGenerationCompletionResult { #[derive(Debug)] pub(crate) struct PersistEditorGeneratedAssetInput { pub(crate) project_id: Option, - pub(crate) owner_user_id: String, pub(crate) folder_id: Option, pub(crate) label: String, pub(crate) image_src: String, @@ -1610,30 +1609,6 @@ pub(crate) struct PersistEditorGeneratedAssetInput { pub(crate) image_sequence_duration_ms: Option, } -pub(crate) struct PersistEditorGeneratedAssetRequest { - pub(crate) project_id: Option, - pub(crate) owner_user_id: String, - pub(crate) folder_id: Option, - pub(crate) label: String, - pub(crate) image_src: String, - pub(crate) object_key: Option, - pub(crate) asset_object_id: Option, - pub(crate) width: u32, - pub(crate) height: u32, - pub(crate) prompt: String, - pub(crate) actual_prompt: Option, - pub(crate) model: String, - pub(crate) provider: String, - pub(crate) task_id: String, - pub(crate) source_resource_id: Option, - pub(crate) asset_kind: Option, - pub(crate) generation_inputs: Option, - pub(crate) thumbnail_src: Option, - pub(crate) generation_cost_mud_points: u64, - pub(crate) image_sequence_frames: Option, - pub(crate) image_sequence_duration_ms: Option, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct EditorGeneratedImageStorageProfile { asset_kind: &'static str, @@ -2655,17 +2630,6 @@ pub async fn toggle_editor_showcase_asset_like( )) } -fn editor_scene_style_label(style_preset: &str) -> &'static str { - match style_preset.trim() { - "anime" => "日系动画", - "watercolor" => "清透水彩", - "flat" => "平面几何", - "stop-motion" => "定格模型", - "custom" => "自定义", - _ => "未知", - } -} - fn build_editor_scene_generation_inputs( payload: &EditorSceneGenerateRequest, generation_options: &EditorGenerationOptions, @@ -2933,81 +2897,6 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( .await } -/// Runs the non-generating validation and authorization required before a caller performs -/// preparatory provider work such as icon-spec metadata completion. Final dispatch deliberately -/// validates again because queued execution can happen later in another process. -pub(crate) async fn validate_editor_image_generation_parameters_for_owner( - state: &AppState, - request_context: &RequestContext, - caller: &EditorGenerationCaller, - payload: &EditorImageGenerationRequest, -) -> Result<(), AppError> { - ensure_editor_reference_image_sources_are_stable( - payload.reference_image_srcs.as_deref(), - "editor-image-generation", - "referenceImageSrcs", - "生成参考图", - )?; - let normalized_kind = payload.kind.as_deref().map(str::trim); - if matches!(normalized_kind, Some("character")) { - parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?; - } - let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); - let is_publication_material_generation = - matches!(normalized_kind, Some("publication-material")); - let generation_options = normalize_editor_generation_options( - if is_ui_design_generation || is_publication_material_generation { - Some(GPT_IMAGE_2_MODEL) - } else { - payload.model.as_deref() - }, - payload.aspect_ratio.as_deref(), - payload.image_size.as_deref(), - ); - let reference_limit = if matches!(normalized_kind, Some("quick-edit")) { - EDITOR_QUICK_EDIT_REFERENCE_LIMIT - .min(editor_provider_reference_limit(generation_options.model)) - } else { - EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT - }; - ensure_editor_reference_image_source_limit( - payload.reference_image_srcs.as_deref(), - reference_limit, - "editor-image-generation", - "referenceImageSrcs", - "生成参考图", - )?; - for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref()) - { - parse_editor_reference_image(state, caller.owner_user_id.as_str(), source).await?; - } - - let settings = require_openai_image_settings(state)?.with_external_api_audit_context( - request_context, - caller.audit_subject_user_id.clone(), - caller - .audit_project_id - .clone() - .or_else(|| payload.project_id.clone()), - ); - build_openai_image_http_client(&settings)?; - state - .editor_generation_pricing() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })) - })? - .image_generation_mud_points( - normalized_kind, - Some(generation_options.model), - Some(generation_options.image_size), - ); - Ok(()) -} - pub(crate) async fn generate_editor_image_for_owner( state: &AppState, request_context: &RequestContext, @@ -3401,7 +3290,6 @@ where Some(source_persisted.asset_object), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), - owner_user_id: caller.owner_user_id.clone(), folder_id: payload .asset_folder_id .clone() @@ -3664,7 +3552,6 @@ where Some(prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), - owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, label: asset_label, image_src: image_src.clone(), @@ -6196,7 +6083,6 @@ pub(crate) async fn edit_editor_image_for_owner_with_source_snapshot( Some(prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), - owner_user_id: owner_user_id.clone(), folder_id: payload.asset_folder_id, label: asset_label.clone(), image_src: image_src.clone(), @@ -6883,7 +6769,6 @@ pub(crate) async fn remove_editor_image_background_for_owner( Some(prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), - owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, label: asset_label.clone(), image_src: image_src.clone(), @@ -8624,7 +8509,6 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( Some(source_prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), - owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: editor_generated_asset_variant_label(source_label.as_str(), "原图"), image_src: source_image_src.clone(), @@ -8828,7 +8712,6 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( Some(spritesheet_prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), - owner_user_id: owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: source_label, image_src: spritesheet_image_src.clone(), @@ -10169,99 +10052,6 @@ fn fill_missing_media_identity_field( } } -pub(crate) async fn persist_editor_generated_asset( - state: &AppState, - mut input: PersistEditorGeneratedAssetInput, -) -> Result { - let generation_inputs_json = serialize_editor_generation_inputs( - input.asset_kind.as_deref(), - input.generation_inputs.clone(), - )?; - let image_sequence_frames_json = - serialize_editor_image_sequence_frames(input.image_sequence_frames.take())?; - let object_key = normalize_editor_object_key(input.object_key); - let asset_object_id = normalize_optional_string(input.asset_object_id); - let model = normalize_optional_string(Some(input.model)); - let image_src = normalize_editor_persisted_media_src(input.image_src, object_key.as_deref())?; - let now_micros = current_utc_micros(); - let resource = if let Some(project_id) = normalize_optional_string(input.project_id.clone()) { - let record = state - .spacetime_client() - .create_editor_project_resource(EditorProjectResourceCreateRecordInput { - resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX), - project_id, - owner_user_id: input.owner_user_id.clone(), - asset_object_id: asset_object_id.clone(), - image_src: image_src.clone(), - object_key: object_key.clone(), - width: input.width, - height: input.height, - source_type: "generated".to_string(), - prompt: Some(input.prompt.clone()), - actual_prompt: input.actual_prompt.clone(), - model: model.clone(), - provider: Some(input.provider.clone()), - task_id: Some(input.task_id.clone()), - source_resource_id: normalize_optional_string(input.source_resource_id.clone()), - asset_kind: normalize_optional_string(input.asset_kind.clone()), - generation_inputs_json: generation_inputs_json.clone(), - updated_at_micros: now_micros, - image_sequence_frames_json: image_sequence_frames_json.clone(), - image_sequence_duration_ms: input.image_sequence_duration_ms, - }) - .await - .map_err(map_editor_project_error)?; - Some(editor_project_resource_payload_from_record(record)) - } else { - None - }; - - let folder_id = - normalize_generated_asset_folder_id(input.folder_id, input.owner_user_id.as_str()); - let created_resource_id = resource - .as_ref() - .map(|resource| resource.resource_id.clone()) - .or_else(|| normalize_optional_string(input.source_resource_id.clone())); - let asset = if let Some(folder_id) = folder_id { - let record = state - .spacetime_client() - .create_editor_asset(EditorAssetCreateRecordInput { - asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX), - owner_user_id: input.owner_user_id.clone(), - folder_id, - label: input.label, - asset_object_id, - image_src, - object_key, - width: input.width, - height: input.height, - source_type: "generated".to_string(), - prompt: Some(input.prompt), - actual_prompt: input.actual_prompt, - model, - provider: Some(input.provider), - task_id: Some(input.task_id), - asset_kind: normalize_optional_string(input.asset_kind), - generation_inputs_json, - source_resource_id: created_resource_id, - generation_cost_mud_points: input.generation_cost_mud_points, - now_micros, - thumbnail_src: normalize_optional_string(input.thumbnail_src), - group_task_id: normalize_optional_string(input.group_task_id), - group_task_expected_asset_count: input.group_task_expected_asset_count, - image_sequence_frames_json, - image_sequence_duration_ms: input.image_sequence_duration_ms, - }) - .await - .map_err(map_editor_project_error)?; - Some(editor_asset_payload_from_record(record)) - } else { - None - }; - - Ok(EditorGeneratedAssetRecord { resource, asset }) -} - pub(crate) fn prepare_editor_generated_asset( caller: &EditorGenerationCaller, slot: &str, @@ -10444,113 +10234,6 @@ pub(crate) fn prepare_editor_generated_asset( }) } -pub(crate) async fn persist_editor_generated_media_asset( - state: &AppState, - mut input: PersistEditorGeneratedAssetRequest, -) -> Result< - ( - Option, - Option, - ), - AppError, -> { - input.generation_inputs = - sanitize_editor_client_generation_inputs(input.generation_inputs.take()); - let persisted = persist_editor_generated_asset( - state, - PersistEditorGeneratedAssetInput { - project_id: input.project_id, - owner_user_id: input.owner_user_id, - folder_id: input.folder_id, - label: input.label, - image_src: input.image_src, - object_key: input.object_key, - asset_object_id: input.asset_object_id, - width: input.width, - height: input.height, - prompt: input.prompt, - actual_prompt: input.actual_prompt, - model: input.model, - provider: input.provider, - task_id: input.task_id, - group_task_id: None, - group_task_expected_asset_count: None, - source_resource_id: input.source_resource_id, - asset_kind: input.asset_kind, - generation_inputs: input.generation_inputs, - thumbnail_src: input.thumbnail_src, - generation_cost_mud_points: input.generation_cost_mud_points, - image_sequence_frames: input.image_sequence_frames, - image_sequence_duration_ms: input.image_sequence_duration_ms, - }, - ) - .await?; - Ok((persisted.resource, persisted.asset)) -} - -pub(crate) async fn complete_editor_canvas_generation( - state: &AppState, - owner_user_id: &str, - project_id: Option<&str>, - completion: Option<&EditorCanvasGenerationCompletionRequest>, - resource: Option<&EditorProjectResourcePayload>, -) -> Result, AppError> { - let (Some(project_id), Some(completion), Some(resource)) = (project_id, completion, resource) - else { - return Ok(None); - }; - let project_id = project_id.trim(); - if project_id.is_empty() { - return Ok(None); - } - - let project = state - .spacetime_client() - .get_editor_project(EditorProjectGetRecordInput { - project_id: project_id.to_string(), - owner_user_id: owner_user_id.to_string(), - }) - .await - .map_err(map_editor_project_error)?; - let project = repair_editor_project_record_inline_media( - &EditorMediaStorageState::from_ref(state), - project, - ) - .await; - let expected_revision = project.canvas.revision; - let viewport = project.viewport.clone(); - let project_payload = editor_project_payload_from_record(project); - let Some(placeholder) = - resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)? - else { - return Ok(None); - }; - let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); - let item = - build_generated_canvas_layer_item(completion, &placeholder, resource, layer_id.as_str(), 0); - let completion_result = apply_editor_canvas_generation_items( - project_payload.layers, - completion, - placeholder, - vec![item], - Some(layer_id), - )?; - if !completion_result.changed { - return Ok(None); - } - let saved = save_editor_project_layout_with_revision_and_get( - state, - project_id, - owner_user_id, - viewport, - completion_result.layers, - expected_revision, - ) - .await?; - - Ok(Some(editor_project_payload_from_record(saved))) -} - #[derive(Clone, Copy)] pub(crate) struct EditorSourceOnlyFallbackResultContext<'a> { pub(crate) width: u32, @@ -10706,39 +10389,6 @@ pub(crate) async fn persist_editor_source_only_fallback_atomically( Ok((source_record, project, warning)) } -pub(crate) async fn complete_editor_source_only_fallback( - state: &AppState, - owner_user_id: &str, - project_id: Option<&str>, - completion: Option<&EditorCanvasGenerationCompletionRequest>, - source_record: &EditorGeneratedAssetRecord, - current_warning: Option, - dimension_warning: Option<&EditorGenerationWarningResponse>, -) -> Result< - ( - Option, - Option, - ), - AppError, -> { - let project = complete_editor_canvas_generation( - state, - owner_user_id, - project_id, - completion, - source_record.resource.as_ref(), - ) - .await?; - let warning = merge_editor_generation_warnings( - current_warning, - Some(editor_postprocess_fallback_warning_with_dimension( - "生成任务成功,后处理失败。", - dimension_warning, - )), - ); - Ok((project, warning)) -} - pub(crate) async fn complete_editor_canvas_generation_with_items( state: &AppState, owner_user_id: &str, @@ -10827,38 +10477,6 @@ pub(crate) async fn prepare_editor_canvas_generation_layout( })) } -pub(crate) async fn complete_editor_canvas_background_removal( - state: &AppState, - owner_user_id: &str, - project_id: Option<&str>, - target_layer_id: Option<&str>, - resource: Option<&EditorProjectResourcePayload>, - replacement_title: Option<&str>, - recenter_replacement: bool, -) -> Result, AppError> { - let Some(layout) = prepare_editor_canvas_background_removal_layout( - state, - owner_user_id, - project_id, - target_layer_id, - None, - resource, - replacement_title, - recenter_replacement, - ) - .await? - else { - return Ok(None); - }; - let saved = state - .spacetime_client() - .save_editor_project_layout_v2(layout) - .await - .map_err(map_editor_project_error)?; - - Ok(Some(editor_project_payload_from_record(saved))) -} - #[allow(clippy::too_many_arguments)] async fn prepare_editor_canvas_background_removal_layout( state: &AppState, @@ -12209,55 +11827,6 @@ fn prepare_editor_generated_image_object_data( }) } -pub(crate) struct PersistEditorProviderSourceResourceInput { - pub(crate) project_id: Option, - pub(crate) owner_user_id: String, - pub(crate) folder_id: Option, - pub(crate) label: String, - pub(crate) width: u32, - pub(crate) height: u32, - pub(crate) prompt: String, - pub(crate) actual_prompt: Option, - pub(crate) model: String, - pub(crate) task_id: String, - pub(crate) source_resource_id: Option, - pub(crate) asset_kind: Option, - pub(crate) generation_inputs: Option, - pub(crate) generation_cost_mud_points: u64, -} - -pub(crate) async fn persist_editor_generated_image( - state: &AppState, - owner_user_id: &str, - task_id: &str, - image: &DownloadedOpenAiImage, - prompt: &str, - actual_prompt: Option<&str>, - asset_kind: &str, - path_kind: &str, - file_stem: &str, - slot: &str, - provider: &str, -) -> Result { - persist_editor_generated_image_data( - &EditorMediaStorageState::from_ref(state), - owner_user_id, - task_id, - GeneratedImageAssetDataUrl { - format: normalize_generated_image_asset_mime(image.mime_type.as_str()), - bytes: image.bytes.clone(), - }, - prompt, - actual_prompt, - asset_kind, - path_kind, - file_stem, - slot, - provider, - ) - .await -} - #[allow(clippy::too_many_arguments)] pub(crate) async fn prepare_editor_generated_image( state: &AppState, @@ -12306,39 +11875,6 @@ pub(crate) async fn prepare_editor_generated_image( }) } -async fn persist_editor_generated_image_owned( - state: &AppState, - owner_user_id: &str, - task_id: &str, - image: DownloadedOpenAiImage, - prompt: &str, - actual_prompt: Option<&str>, - asset_kind: &str, - path_kind: &str, - file_stem: &str, - slot: &str, - provider: &str, -) -> Result { - let image_data = GeneratedImageAssetDataUrl { - format: normalize_generated_image_asset_mime(image.mime_type.as_str()), - bytes: image.bytes, - }; - persist_editor_generated_image_data( - &EditorMediaStorageState::from_ref(state), - owner_user_id, - task_id, - image_data, - prompt, - actual_prompt, - asset_kind, - path_kind, - file_stem, - slot, - provider, - ) - .await -} - async fn persist_editor_generated_image_data( state: &EditorMediaStorageState, owner_user_id: &str, @@ -12499,69 +12035,6 @@ async fn upload_editor_generated_image_object_prepared( }) } -pub(crate) async fn persist_editor_provider_source_image( - state: &AppState, - owner_user_id: &str, - task_id: &str, - image: DownloadedOpenAiImage, - prompt: &str, - actual_prompt: Option<&str>, - asset_kind: &str, - path_kind: &str, - file_stem: &str, -) -> Result { - persist_editor_generated_image_owned( - state, - owner_user_id, - task_id, - image, - prompt, - actual_prompt, - asset_kind, - path_kind, - &format!("{file_stem}-provider-source"), - EDITOR_PROVIDER_SOURCE_SLOT, - "vector-engine", - ) - .await -} - -pub(crate) async fn persist_editor_provider_source_resource( - state: &AppState, - persisted: PersistedEditorGeneratedImage, - input: PersistEditorProviderSourceResourceInput, -) -> Result { - persist_editor_generated_asset( - state, - PersistEditorGeneratedAssetInput { - project_id: input.project_id, - owner_user_id: input.owner_user_id, - folder_id: input.folder_id, - label: input.label, - image_src: editor_media_src_from_object_key(persisted.object_key.as_str()), - object_key: Some(persisted.object_key), - asset_object_id: Some(persisted.asset_object_id), - width: input.width, - height: input.height, - prompt: input.prompt, - actual_prompt: input.actual_prompt, - model: input.model, - provider: "VectorEngine".to_string(), - task_id: input.task_id, - group_task_id: None, - group_task_expected_asset_count: None, - source_resource_id: input.source_resource_id, - asset_kind: input.asset_kind, - generation_inputs: input.generation_inputs, - thumbnail_src: None, - generation_cost_mud_points: input.generation_cost_mud_points, - image_sequence_frames: None, - image_sequence_duration_ms: None, - }, - ) - .await -} - const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS: u64 = 300; const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES: u64 = 16; @@ -13175,27 +12648,6 @@ fn editor_background_removal_source_identity_conflict( })) } -fn resolve_editor_background_removal_source_model_from_records( - projects: &[EditorProjectRecord], - assets: &[EditorAssetRecord], - project_id: Option<&str>, - source_resource_id: Option<&str>, - source_reference: &str, - source_object_key: &str, -) -> Result, AppError> { - Ok( - resolve_editor_background_removal_source_metadata_from_records( - projects, - assets, - project_id, - source_resource_id, - source_reference, - source_object_key, - )? - .model, - ) -} - fn find_editor_background_removal_resource<'a>( projects: &'a [EditorProjectRecord], project_id: Option<&str>, @@ -14635,7 +14087,7 @@ mod tests { ( editor_source, "async fn persist_editor_source_only_fallback_atomically", - "async fn complete_editor_source_only_fallback", + "pub(crate) async fn complete_editor_canvas_generation_with_items", ), ( character_source, @@ -15370,99 +14822,6 @@ mod tests { ); } - #[test] - fn background_removal_source_model_recovers_normal_ancestor() { - let source_key = "generated-character-drafts/editor/result.png"; - let projects = vec![test_editor_project_record( - "project-1", - vec![ - test_editor_project_resource_record( - "resource-original", - "project-1", - "generated-character-drafts/editor/original.png", - Some("gpt-image-2"), - None, - ), - test_editor_project_resource_record( - "resource-internal", - "project-1", - "generated-character-drafts/editor/internal.png", - Some("birefnet"), - Some("resource-original"), - ), - test_editor_project_resource_record( - "resource-result", - "project-1", - source_key, - Some("BgFilter complex"), - Some("resource-internal"), - ), - ], - )]; - - let model = resolve_editor_background_removal_source_model_from_records( - projects.as_slice(), - &[], - Some("project-1"), - Some("resource-result"), - "resource-result", - source_key, - ) - .expect("matching resource should resolve"); - - assert_eq!(model.as_deref(), Some("gpt-image-2")); - } - - #[test] - fn background_removal_source_model_is_none_without_normal_ancestor() { - let source_key = "generated-character-drafts/editor/result.png"; - let projects = vec![test_editor_project_record( - "project-1", - vec![test_editor_project_resource_record( - "resource-result", - "project-1", - source_key, - Some("BgFilter complex"), - None, - )], - )]; - - let model = resolve_editor_background_removal_source_model_from_records( - projects.as_slice(), - &[], - Some("project-1"), - Some("resource-result"), - source_key, - source_key, - ) - .expect("matching resource should resolve"); - - assert_eq!(model, None); - } - - #[test] - fn background_removal_source_model_resolves_owned_asset_model() { - let source_key = "generated-character-drafts/editor/asset.png"; - let assets = vec![test_editor_asset_record( - "asset-1", - source_key, - Some("nanobanana2"), - None, - )]; - - let model = resolve_editor_background_removal_source_model_from_records( - &[], - assets.as_slice(), - None, - None, - "asset-1", - source_key, - ) - .expect("owned asset should resolve"); - - assert_eq!(model.as_deref(), Some("nanobanana2")); - } - #[test] fn background_removal_source_metadata_keeps_authoritative_kind_and_object_identity() { let source_key = "generated-character-drafts/editor/character.png"; @@ -15715,44 +15074,6 @@ mod tests { assert!(error.body_text().contains("sourceResourceId")); } - #[test] - fn background_removal_source_model_allows_source_from_another_owned_project() { - let projects = vec![ - test_editor_project_record( - "project-1", - vec![test_editor_project_resource_record( - "resource-1", - "project-1", - "generated-character-drafts/editor/project-1.png", - Some("gpt-image-2"), - None, - )], - ), - test_editor_project_record( - "project-2", - vec![test_editor_project_resource_record( - "resource-2", - "project-2", - "generated-character-drafts/editor/project-2.png", - Some("gpt-image-2"), - None, - )], - ), - ]; - - let model = resolve_editor_background_removal_source_model_from_records( - projects.as_slice(), - &[], - Some("project-1"), - Some("resource-2"), - "resource-2", - "generated-character-drafts/editor/project-2.png", - ) - .expect("projectId is the output target and must not scope an owned source resource"); - - assert_eq!(model.as_deref(), Some("gpt-image-2")); - } - #[test] fn editor_project_resource_public_payload_falls_back_to_public_user_code() { let state = AppState::new(AppConfig::default()).expect("state should build"); @@ -18302,34 +17623,6 @@ mod tests { ); } - #[test] - fn image_generation_preflight_checks_references_provider_and_pricing() { - let source = concat!( - include_str!("editor_project_icon.rs"), - include_str!("editor_project.rs") - ); - - assert_function_contains_in_order( - source, - "pub(crate) async fn validate_editor_image_generation_parameters_for_owner", - "pub(crate) async fn generate_editor_image_for_owner", - &[ - "ensure_editor_reference_image_sources_are_stable(", - "ensure_editor_reference_image_source_limit(", - "parse_editor_reference_image(", - "require_openai_image_settings(", - "build_openai_image_http_client(", - ".editor_generation_pricing()", - ], - ); - assert_function_not_contains( - source, - "pub(crate) async fn validate_editor_image_generation_parameters_for_owner", - "pub(crate) async fn generate_editor_image_for_owner", - &[".take(reference_limit)"], - ); - } - #[test] fn editor_ui_design_generation_keeps_ui_controls_out_of_negative_prompt() { let ui_design_negative_prompt = editor_image_generation_negative_prompt(true); @@ -18476,13 +17769,13 @@ mod tests { assert_function_contains( source, "async fn persist_editor_generated_image_data", - "async fn persist_editor_provider_source_image", + "const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS", &["Some(prompt.to_string())"], ); assert_function_not_contains( source, "async fn persist_editor_generated_image_data", - "async fn persist_editor_provider_source_image", + "const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS", &["actual_prompt.unwrap_or(prompt)"], ); } @@ -22337,14 +21630,14 @@ mod tests { assert_function_occurrence_count( source, "async fn resolve_editor_background_removal_source(", - "fn resolve_editor_background_removal_source_model_from_records(", + "fn find_editor_background_removal_resource", ".list_editor_projects(", 1, ); assert_function_occurrence_count( source, "async fn resolve_editor_background_removal_source(", - "fn resolve_editor_background_removal_source_model_from_records(", + "fn find_editor_background_removal_resource", ".get_editor_asset_library(", 1, ); @@ -23033,12 +22326,6 @@ mod tests { ], ); } - assert_function_contains( - source, - "async fn persist_editor_provider_source_image", - "async fn persist_editor_provider_source_resource", - &["persist_editor_generated_image_owned"], - ); // 中文注释:尺寸一致时必须在回读 provider 原图之前就返回,只有漂移才发起 GET。 assert_function_contains_in_order( source, @@ -23085,18 +22372,6 @@ mod tests { "async fn fallback_editor_screen_background_removal", &["fallback_image"], ); - assert_function_contains( - source, - "async fn persist_editor_generated_image_owned", - "async fn persist_editor_generated_image_data", - &["bytes: image.bytes"], - ); - assert_function_not_contains( - source, - "async fn persist_editor_generated_image_owned", - "async fn persist_editor_generated_image_data", - &["image.bytes.clone()"], - ); assert_function_contains( source, "async fn read_editor_reference_image_object", @@ -23126,13 +22401,13 @@ mod tests { assert_function_contains( source, "async fn persist_editor_generated_image_data", - "async fn persist_editor_provider_source_image", + "const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS", &["state.editor_oss_http_client()"], ); assert_function_not_contains( source, "async fn persist_editor_generated_image_data", - "async fn persist_editor_provider_source_image", + "const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS", &["reqwest::Client::new()"], ); // 中文注释:旧包装仍在 upload-only helper 之后 confirm asset object,供兄弟链路 @@ -23150,7 +22425,7 @@ mod tests { assert_function_not_contains( source, "async fn upload_editor_generated_image_object_data", - "async fn persist_editor_provider_source_image", + "const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS", &["confirm_asset_object("], ); // 中文注释:调用方拿到的是同一个 AppError,无法自行判断本函数内部走到了哪一步, @@ -23159,14 +22434,14 @@ mod tests { assert_function_occurrence_count( source, "async fn persist_editor_generated_image_data", - "async fn persist_editor_provider_source_image", + "const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", 4, ); assert_function_occurrence_count( source, "async fn upload_editor_generated_image_object_data", - "async fn persist_editor_provider_source_image", + "const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", 3, ); @@ -23174,13 +22449,13 @@ mod tests { assert_function_contains( source, "fn prepare_editor_generated_image_object_data", - "struct PersistEditorProviderSourceResourceInput", + "pub(crate) async fn prepare_editor_generated_image", &["prepare_put_object"], ); assert_function_not_contains( source, "fn prepare_editor_generated_image_object_data", - "struct PersistEditorProviderSourceResourceInput", + "pub(crate) async fn prepare_editor_generated_image", &[ "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", ".put_object(", @@ -23192,7 +22467,7 @@ mod tests { assert_function_contains_in_order( source, "async fn upload_editor_generated_image_object_prepared", - "async fn persist_editor_provider_source_image", + "const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS", &[ "result_persistence_started.store(true, Ordering::Release)", ".put_object(http_client, prepared.request)", @@ -23377,7 +22652,7 @@ mod tests { for (start, end, local_validation, first_remote_step, terminal) in [ ( "pub(crate) async fn enqueue_editor_image_generation_for_owner", - "pub(crate) async fn validate_editor_image_generation_parameters_for_owner", + "pub(crate) async fn generate_editor_image_for_owner", "ensure_editor_reference_image_sources_are_stable", ".editor_generation_pricing()", "enqueue_editor_generation_job_for_caller", @@ -23493,7 +22768,7 @@ mod tests { for (start, end, resolver) in [ ( "pub(crate) async fn enqueue_editor_image_generation_for_owner", - "pub(crate) async fn validate_editor_image_generation_parameters_for_owner", + "pub(crate) async fn generate_editor_image_for_owner", "resolve_editor_image_generation_target_folder_id(", ), ( diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index c109dab63..be15e4583 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -1249,7 +1249,6 @@ pub(crate) async fn prepare_editor_spritesheet_slices_for_generation( Some(upload.asset_object), PersistEditorGeneratedAssetInput { project_id: input.project_id.clone(), - owner_user_id: caller.owner_user_id.clone(), folder_id: input.asset_folder_id.clone(), label: upload.name.clone(), image_src: upload.image_src.clone(), @@ -1830,7 +1829,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( Some(source_persisted.asset_object), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), - owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"), image_src: source_image_src.clone(), @@ -2066,7 +2064,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( Some(spritesheet_prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), - owner_user_id: owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: spritesheet_label, image_src: spritesheet_image_src.clone(), diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index 30af7d894..dcc7b9ab1 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -1897,10 +1897,6 @@ fn extract_router_token_info( } } -fn extract_router_token_id(payload: &Value, token_name: &str) -> Result, String> { - extract_router_token_info(payload, token_name).map(|token| token.map(|token| token.id)) -} - fn provider_payload_data(value: &Value) -> &Value { value.get("data").unwrap_or(value) } @@ -2377,32 +2373,6 @@ mod tests { assert_eq!(payload["group"], "default"); } - #[test] - fn duplicate_fixed_router_tokens_require_reconciliation() { - let payload = json!({ - "data": [ - {"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER}, - {"id": 88, "name": LLM_ROUTER_TOKEN_IDENTIFIER} - ] - }); - - let error = extract_router_token_id(&payload, LLM_ROUTER_TOKEN_IDENTIFIER) - .expect_err("multiple fixed-name tokens must be rejected"); - assert!(error.contains("多个不同 token id")); - - let duplicate_same_id = json!({ - "data": [ - {"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER}, - {"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER} - ] - }); - assert_eq!( - extract_router_token_id(&duplicate_same_id, LLM_ROUTER_TOKEN_IDENTIFIER) - .expect("same token repeated in an envelope is not ambiguous"), - Some("77".to_string()) - ); - } - #[test] fn new_api_user_update_uses_numeric_id_and_fixed_group() { let payload = router_user_update_request(42, "router_abcd", "user_full-id"); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 6c89e8fb2..4eeb20c6a 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -6,12 +6,12 @@ use std::{ }; use axum::Json; -use serde_json::{Value, json}; +use serde_json::Value; use shared_kernel::offset_datetime_to_unix_micros; use spacetime_client::{ - ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, - ExternalGenerationJobFailRecordInput, ExternalGenerationJobRecord, - ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueWakeSubscription, + ExternalGenerationJobClaimRecordInput, ExternalGenerationJobFailRecordInput, + ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, + ExternalGenerationQueueWakeSubscription, }; use tokio::{ sync::{OwnedSemaphorePermit, Semaphore}, @@ -20,12 +20,8 @@ use tokio::{ }; use tracing::{error, info, warn}; -const MAX_EDITOR_GENERATION_WARNING_CHARS: usize = 2_048; // provider 必须先结束,给失败审计、计费结算和队列终态写回保留有效 lease 内的收尾窗口。 const EXTERNAL_GENERATION_WORKER_TERMINAL_WRITE_RESERVE: Duration = Duration::from_secs(60); -const EDITOR_GENERATION_SLICE_WARNING_PREFIX: &str = "图集已生成,但自动拆分未完成:"; -const EDITOR_GENERATION_WARNING_REDACTED_MESSAGE: &str = - "自动拆分未完成(告警详情含内联媒体引用,已省略)"; use crate::{ asset_billing::with_external_generation_billing_attempt_context, @@ -39,15 +35,13 @@ use crate::{ EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, - GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX, }, editor_project::{ - EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION, - EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationOperationContext, - EditorGenerationPhaseReporter, EditorGenerationQueueResultContext, - EditorImageEditQueuePayload, EditorImageEditRequest, EditorImageEditResolvedSource, - EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, - compact_editor_generation_result, edit_editor_image_for_owner_with_source_snapshot, + EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION, EditorBackgroundRemovalRequest, + EditorGenerationCaller, EditorGenerationOperationContext, EditorGenerationPhaseReporter, + EditorGenerationQueueResultContext, EditorImageEditQueuePayload, EditorImageEditRequest, + EditorImageEditResolvedSource, EditorImageGenerationRequest, + EditorUiDesignAssetExtractionRequest, edit_editor_image_for_owner_with_source_snapshot, extract_editor_ui_design_assets_for_owner, generate_editor_image_for_owner, remove_editor_image_background_for_owner, }, @@ -1275,342 +1269,6 @@ fn editor_generation_phase_reporter( )) } -async fn complete_editor_generation_job( - state: &AppState, - worker_id: &str, - job: &ExternalGenerationJobRecord, - response: Value, -) -> Result<(), String> { - complete_job( - state, - worker_id, - job, - Some(editor_generation_result_payload_json(job, &response)), - ) - .await -} - -fn editor_generation_result_payload_json( - job: &ExternalGenerationJobRecord, - response: &Value, -) -> String { - let mut payload = json!({ - "sourceModule": job.source_module.clone(), - "sourceEntityId": job.source_entity_id.clone(), - }); - if is_editor_agent_generation_job(job) - && let Some(object) = payload.as_object_mut() - { - // The Agent needs this compact result to restore its tool-call card. Other jobs keep - // master's metadata-only completion payload to avoid turning the queue into an asset API. - object.insert( - // TODO extract const - "editor-agent-tool-call-result".to_string(), - compact_editor_generation_result(response.clone()), - ); - } - if is_result_recovery_generation_job(job) - && let Some(object) = payload.as_object_mut() - { - object.insert( - "result".to_string(), - compact_external_api_generation_result(response.clone()), - ); - } - if let Some(warning) = extract_editor_generation_warning(response) - && let Some(object) = payload.as_object_mut() - { - object.insert("warning".to_string(), warning); - } - payload.to_string() -} - -fn is_result_recovery_generation_job(job: &ExternalGenerationJobRecord) -> bool { - let dedupe_key = job.dedupe_key.trim(); - dedupe_key.starts_with("external-api-generation:") - || dedupe_key.starts_with(&format!("{GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX}:")) -} - -fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool { - job.dedupe_key.trim().starts_with("editor-agent:") -} - -fn compact_external_api_generation_result(result: Value) -> Value { - let mut result = result.get("data").cloned().unwrap_or(result); - let Some(object) = result.as_object_mut() else { - return Value::Null; - }; - object.retain(|key, _| { - matches!( - key.as_str(), - "ok" | "imageSrc" - | "videoSrc" - | "audioSrc" - | "previewVideoPath" - | "thumbnailSrc" - | "objectKey" - | "assetObjectId" - | "width" - | "height" - | "sourceType" - | "model" - | "taskId" - | "durationSeconds" - | "loop" - | "resolution" - | "priceMudPoints" - | "audioKind" - | "spritesheetImageSrc" - | "spritesheetWidth" - | "spritesheetHeight" - | "iconImageSrcs" - | "sliceMode" - | "gridX" - | "gridY" - | "sliceCount" - | "frames" - | "frameCount" - | "frameWidth" - | "frameHeight" - | "fps" - | "resource" - | "asset" - | "spritesheetResource" - | "spritesheetAsset" - | "warning" - | "sliceWarning" - ) - }); - for field in ["resource", "spritesheetResource"] { - if let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) { - compact_external_generation_resource(resource); - } - } - for field in ["asset", "spritesheetAsset"] { - if let Some(asset) = object.get_mut(field).and_then(Value::as_object_mut) { - compact_external_generation_asset(asset); - } - } - if let Some(icons) = object - .get_mut("iconImageSrcs") - .and_then(Value::as_array_mut) - { - for icon in icons { - let Some(icon) = icon.as_object_mut() else { - continue; - }; - icon.retain(|key, _| { - matches!( - key.as_str(), - "name" | "imageSrc" | "objectKey" | "width" | "height" | "resource" | "asset" - ) - }); - if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) { - compact_external_generation_resource(resource); - } - if let Some(asset) = icon.get_mut("asset").and_then(Value::as_object_mut) { - compact_external_generation_asset(asset); - } - remove_unstable_external_generation_media_fields(icon); - } - } - if let Some(frames) = object.get_mut("frames").and_then(Value::as_array_mut) { - for frame in frames { - let Some(frame) = frame.as_object_mut() else { - continue; - }; - frame.retain(|key, _| { - matches!( - key.as_str(), - "frameIndex" | "imageSrc" | "objectKey" | "assetObjectId" | "width" | "height" - ) - }); - remove_unstable_external_generation_media_fields(frame); - } - } - for field in ["warning", "sliceWarning"] { - if let Some(warning) = object.get_mut(field).and_then(Value::as_object_mut) - && let Some(reason) = warning.get_mut("reason") - && let Some(value) = reason.as_str() - { - *reason = Value::String(normalize_editor_generation_warning_reason(value)); - } - } - remove_unstable_external_generation_media_fields(object); - result -} - -fn compact_external_generation_resource(resource: &mut serde_json::Map) { - resource.retain(|key, _| { - matches!( - key.as_str(), - "resourceId" - | "projectId" - | "objectKey" - | "assetObjectId" - | "imageSrc" - | "width" - | "height" - | "sourceType" - | "assetKind" - | "taskId" - | "sourceResourceId" - ) - }); - remove_unstable_external_generation_media_fields(resource); -} - -fn compact_external_generation_asset(asset: &mut serde_json::Map) { - asset.retain(|key, _| { - matches!( - key.as_str(), - "assetId" - | "folderId" - | "objectKey" - | "assetObjectId" - | "imageSrc" - | "thumbnailSrc" - | "width" - | "height" - | "sourceType" - | "assetKind" - | "taskId" - ) - }); - remove_unstable_external_generation_media_fields(asset); -} - -fn remove_unstable_external_generation_media_fields(object: &mut serde_json::Map) { - object.retain(|key, value| { - if !matches!( - key.as_str(), - "imageSrc" - | "videoSrc" - | "audioSrc" - | "previewVideoPath" - | "thumbnailSrc" - | "spritesheetImageSrc" - ) { - return true; - } - value - .as_str() - .is_some_and(is_stable_external_generation_media_reference) - }); -} - -fn is_stable_external_generation_media_reference(value: &str) -> bool { - let value = value.trim(); - !value.is_empty() - && value.starts_with('/') - && !value.starts_with("//") - && !value.contains('?') - && !value.contains('#') - && !value.to_ascii_lowercase().starts_with("data:") - && !value.to_ascii_lowercase().starts_with("blob:") - && !value.to_ascii_lowercase().starts_with("http://") - && !value.to_ascii_lowercase().starts_with("https://") -} - -fn is_editor_internal_processing_model(model: &str) -> bool { - matches!( - model.trim().to_ascii_lowercase().as_str(), - "anime-seg" - | "bgfilter complex" - | "birefnet" - | "connected-components" - | "screen-color-keying" - | "segment-common-image" - ) -} - -fn extract_editor_generation_warning_fields( - warning: Option<&Value>, - is_slice_warning: bool, -) -> Option<(String, String)> { - let warning = warning?; - let code = warning.get("code")?.as_str()?.trim(); - let reason = warning.get("reason")?.as_str()?.trim(); - if code.is_empty() || reason.is_empty() { - return None; - } - let reason = if is_slice_warning { - format!("{EDITOR_GENERATION_SLICE_WARNING_PREFIX}{reason}") - } else { - reason.to_string() - }; - Some((code.to_string(), reason)) -} - -fn extract_editor_generation_warning(response: &Value) -> Option { - let data = response.get("data").unwrap_or(response); - // 中文注释:风格归一化和像素规整产生的通用 warning 可以与 sliceWarning 并存。 - // 队列结果只有一个有界 warning 字段,因此按与 inline 响应相同的策略归一: - // code 不同时收敛为 multiple-generation-warnings,reason 按“通用在前、拆分在后” - // 顺序拼接,再交给既有上界收敛,不允许其中任何一条被静默丢弃。 - let common = extract_editor_generation_warning_fields(data.get("warning"), false); - let slice = extract_editor_generation_warning_fields(data.get("sliceWarning"), true); - let (code, reason) = match (common, slice) { - (None, None) => return None, - (Some(warning), None) | (None, Some(warning)) => warning, - (Some((common_code, common_reason)), Some((slice_code, slice_reason))) => { - let code = if common_code == slice_code { - common_code - } else { - EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE.to_string() - }; - (code, format!("{common_reason} {slice_reason}")) - } - }; - let reason = normalize_editor_generation_warning_reason(reason.as_str()); - Some(json!({ - "code": code, - "reason": reason, - })) -} - -fn normalize_editor_generation_warning_reason(reason: &str) -> String { - let normalized = reason.to_ascii_lowercase(); - if normalized.contains("data:") - || normalized.contains("blob:") - || normalized.contains("http://") - || normalized.contains("https://") - || normalized.contains("x-amz-") - || normalized.contains("signature=") - { - return EDITOR_GENERATION_WARNING_REDACTED_MESSAGE.to_string(); - } - let mut chars = reason.chars(); - let mut bounded = chars - .by_ref() - .take(MAX_EDITOR_GENERATION_WARNING_CHARS) - .collect::(); - if chars.next().is_some() { - bounded.push('…'); - } - bounded -} - -async fn complete_job( - state: &AppState, - worker_id: &str, - job: &ExternalGenerationJobRecord, - result_payload_json: Option, -) -> Result<(), String> { - state - .spacetime_client() - .complete_external_generation_job(ExternalGenerationJobCompleteRecordInput { - job_id: job.job_id.clone(), - worker_id: worker_id.to_string(), - lease_token: require_job_lease_token(job)?, - result_payload_json, - completed_at_micros: current_utc_micros(), - }) - .await - .map(|_| ()) - .map_err(|error| error.to_string()) -} - async fn fail_job( state: &AppState, worker_id: &str, @@ -1758,6 +1416,7 @@ fn current_utc_micros() -> i64 { #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[cfg(any())] #[test] @@ -2099,393 +1758,6 @@ mod tests { ); } - #[test] - fn editor_generation_result_payload_keeps_only_lightweight_slice_warning() { - let job = external_generation_job_record_fixture(Some("lease-1")); - let response = json!({ - "spritesheetImageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", - "iconImageSrcs": [{"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST"}], - "sliceWarning": { - "code": "insufficient-connected-components", - "reason": "连通域数量不足" - } - }); - - let payload: Value = - serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) - .expect("worker 结果应是合法 JSON"); - - assert_eq!(payload["sourceModule"], json!("editor")); - assert_eq!(payload["sourceEntityId"], json!("project-1")); - assert_eq!( - payload["warning"], - json!({ - "code": "insufficient-connected-components", - "reason": "图集已生成,但自动拆分未完成:连通域数量不足" - }) - ); - assert!(payload.get("spritesheetImageSrc").is_none()); - assert!(payload.get("iconImageSrcs").is_none()); - assert!(payload.get("editor-agent-tool-call-result").is_none()); - } - - #[test] - fn editor_agent_result_payload_keeps_compact_response() { - let mut job = external_generation_job_record_fixture(Some("lease-1")); - job.dedupe_key = "editor-agent:conversation-1:7:generate-image".to_string(); - job.request_payload_json = json!({ - "generationInputs": { "source": "editor-agent" }, - }) - .to_string(); - let response = json!({ - "imageSrc": "/api/assets/object/generated.png", - "objectKey": "users/user-1/generated.png", - "assetObjectId": "asset-object-1", - "width": 1024, - "height": 1024, - "sourceType": "generated", - "prompt": "castle", - "actualPrompt": null, - "model": "gpt-image-2", - "provider": "VectorEngine", - "generationInputs": { - "fields": { "prompt": "castle" }, - "characterAnimation": { "frameCount": 8 }, - "screenColorHex": "#CFEFFF", - "mattingProvider": "BgFilter", - "mattingModel": "birefnet", - }, - "taskId": "provider-task-1", - "resource": { - "resourceId": "resource-1", - "objectKey": "users/user-1/generated.png", - "assetObjectId": "asset-object-1", - "imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", - }, - "asset": { "assetId": "asset-1" }, - "project": { "projectId": "project-1" }, - }); - - let payload: Value = - serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) - .expect("worker result should be valid JSON"); - - assert_eq!( - payload["editor-agent-tool-call-result"]["imageSrc"], - json!("/api/assets/object/generated.png") - ); - assert_eq!( - payload["editor-agent-tool-call-result"]["resource"], - json!({ - "resourceId": "resource-1", - "objectKey": "users/user-1/generated.png", - "assetObjectId": "asset-object-1", - }) - ); - assert!( - payload["editor-agent-tool-call-result"] - .get("asset") - .is_none() - ); - assert!( - payload["editor-agent-tool-call-result"] - .get("project") - .is_none() - ); - assert_eq!( - payload["editor-agent-tool-call-result"]["model"], - json!("gpt-image-2") - ); - assert!( - payload["editor-agent-tool-call-result"] - .get("provider") - .is_none() - ); - assert_eq!( - payload["editor-agent-tool-call-result"]["generationInputs"], - json!({ - "fields": { "prompt": "castle" }, - "characterAnimation": { "frameCount": 8 }, - }) - ); - assert!(!payload.to_string().contains("data:image")); - } - - #[test] - fn compact_editor_generation_result_removes_internal_models_case_insensitively() { - for model in [ - "anime-seg", - " BgFilter Complex ", - "BIREFNET", - "connected-components", - "screen-color-keying", - "segment-common-image", - ] { - let compact = compact_editor_generation_result(json!({ - "model": model, - "provider": "internal-provider", - })); - - assert!(compact.get("model").is_none(), "model={model}"); - assert!(compact.get("provider").is_none(), "model={model}"); - } - } - - #[test] - fn editor_agent_audio_compact_results_remain_reconcileable() { - use crate::editor_agent::reconcile_completed_editor_agent_tool_call_for_test; - use platform_editor_agent::{ - agent::tools::{ - generate_background_music::GenerateBackgroundMusicTool, - generate_sound_effect::GenerateSoundEffectTool, - }, - framework::tool::Tool, - }; - use shared_contracts::editor_agent::{EditorAgentMessage, EditorAgentToolCallStatus}; - - for (tool_name, prompt, actual_prompt, audio_kind, model, provider) in [ - ( - GenerateSoundEffectTool::NAME, - "按钮点击声", - "A short button click", - "sound-effect", - "eleven_text_to_sound_v2", - "elevenlabs", - ), - ( - GenerateBackgroundMusicTool::NAME, - "森林背景音乐", - "森林背景音乐", - "background-music", - "chirp-v5", - "vectorengine", - ), - ] { - let mut job = external_generation_job_record_fixture(Some("lease-1")); - job.dedupe_key = format!("editor-agent:conversation-1:7:{tool_name}"); - job.request_payload_json = json!({ - "generationInputs": { "source": "editor-agent" }, - }) - .to_string(); - let response = json!({ - "ok": true, - "audioSrc": "/generated/audio.mp3", - "width": 420, - "height": 120, - "sourceType": "generated", - "prompt": prompt, - "actualPrompt": actual_prompt, - "model": model, - "provider": provider, - "taskId": "task-1", - "priceMudPoints": 5, - "audioKind": audio_kind, - "durationSeconds": if audio_kind == "sound-effect" { json!(5.25) } else { Value::Null }, - "loop": if audio_kind == "sound-effect" { json!(false) } else { Value::Null }, - }); - let payload: Value = - serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) - .expect("worker compact payload should serialize"); - assert!(payload.get("editor-agent-tool-call-result").is_some()); - assert!( - payload["editor-agent-tool-call-result"] - .get("provider") - .is_none() - ); - let mut message: EditorAgentMessage = serde_json::from_value(json!({ - "id": 1, - "role": "system", - "text": "waiting", - "attachments": [], - "toolCall": { - "toolName": tool_name, - "status": "not_completed", - "args": { "prompt": prompt }, - "displayArgs": { - "stringArgs": [], - "imageArgs": [], - "extras": { "priceMudPoints": 5 } - }, - "externalJobId": "job-1", - "images": [], - "audios": [] - }, - "createdAt": "2026-08-06T00:00:00Z" - })) - .expect("pending audio Agent message should deserialize"); - let payload_json = payload.to_string(); - reconcile_completed_editor_agent_tool_call_for_test( - &mut message, - Some(payload_json.as_str()), - ) - .expect("worker compact audio result should reconcile"); - let tool_call = message.tool_call.expect("tool call should remain present"); - assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed); - assert_eq!(tool_call.audios[0].audio_src, "/generated/audio.mp3"); - } - } - - #[test] - fn editor_agent_spritesheet_result_keeps_all_persisted_slices() { - let mut job = external_generation_job_record_fixture(Some("lease-1")); - job.dedupe_key = "editor-agent:conversation-1:7:generate-icon-spritesheet".to_string(); - job.request_payload_json = json!({ - "generationInputs": { "source": "editor-agent" }, - }) - .to_string(); - let response = json!({ - "spritesheetImageSrc": "/api/assets/object/sheet.png", - "spritesheetWidth": 512, - "spritesheetHeight": 512, - "taskId": "provider-task-1", - "spritesheetResource": { - "resourceId": "sheet-resource", - "objectKey": "users/user-1/sheet.png", - "assetObjectId": "sheet-object", - }, - "iconImageSrcs": [ - { - "name": "backpack", - "imageSrc": "/api/assets/object/backpack.png", - "width": 64, - "height": 64, - "resource": { - "resourceId": "icon-resource-1", - "objectKey": "users/user-1/backpack.png", - "assetObjectId": "icon-object-1", - "sourceResourceId": "sheet-resource", - "imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", - }, - "asset": { "assetId": "icon-asset-1" }, - }, - { - "name": "map", - "imageSrc": "/api/assets/object/map.png", - "width": 64, - "height": 64, - "resource": { - "resourceId": "icon-resource-2", - "objectKey": "users/user-1/map.png", - "assetObjectId": "icon-object-2", - "sourceResourceId": "sheet-resource", - }, - }, - ], - }); - - let payload: Value = - serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) - .expect("worker result should be valid JSON"); - - assert_eq!( - payload["editor-agent-tool-call-result"]["iconImageSrcs"] - .as_array() - .map(Vec::len), - Some(2) - ); - assert_eq!( - payload["editor-agent-tool-call-result"]["iconImageSrcs"][0]["resource"], - json!({ - "resourceId": "icon-resource-1", - "objectKey": "users/user-1/backpack.png", - "assetObjectId": "icon-object-1", - "sourceResourceId": "sheet-resource", - }) - ); - assert!( - payload["editor-agent-tool-call-result"]["iconImageSrcs"][0] - .get("asset") - .is_none() - ); - assert!(!payload.to_string().contains("data:image")); - } - - #[test] - fn editor_generation_result_payload_merges_common_and_slice_warnings() { - let job = external_generation_job_record_fixture(Some("lease-1")); - let response = json!({ - "data": { - "imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", - "warning": { - "code": "unsupported-image-style", - "reason": "不支持的图片风格,已按无风格继续生成。" - }, - "sliceWarning": { - "code": "insufficient-connected-components", - "reason": "有效连通域不足" - } - } - }); - - let payload: Value = - serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) - .expect("worker 结果应是合法 JSON"); - - // 中文注释:风格归一化告警与拆分告警可以并存,队列只有一个 warning 字段, - // 必须拼接后收敛 code,不能让其中任何一条消失。 - assert_eq!( - payload["warning"], - json!({ - "code": "multiple-generation-warnings", - "reason": "不支持的图片风格,已按无风格继续生成。 图集已生成,但自动拆分未完成:有效连通域不足" - }) - ); - assert!(payload.get("imageSrc").is_none()); - } - - #[test] - fn external_generation_result_payload_keeps_fixed_spritesheet_layout() { - let mut job = external_generation_job_record_fixture(Some("lease-1")); - job.dedupe_key = "external-api-generation:conversation-1:7:icon-spritesheet".to_string(); - let response = json!({ - "sliceMode": "grid", - "gridX": 2, - "gridY": 2, - "iconImageSrcs": [ - { "name": "素材 1", "imageSrc": "/api/assets/object/one.png" }, - { "name": "素材 2", "imageSrc": "/api/assets/object/two.png" }, - { "name": "素材 3", "imageSrc": "/api/assets/object/three.png" }, - { "name": "素材 4", "imageSrc": "/api/assets/object/four.png" } - ] - }); - - let payload: Value = - serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) - .expect("worker result should be valid JSON"); - - assert_eq!(payload["result"]["sliceMode"], json!("grid")); - assert_eq!( - payload["result"]["iconImageSrcs"].as_array().map(Vec::len), - Some(4) - ); - } - - #[test] - fn editor_generation_result_payload_keeps_single_warning_untouched() { - let job = external_generation_job_record_fixture(Some("lease-1")); - let response = json!({ - "data": { - "warning": { - "code": "postprocess-failed-source-preserved", - "reason": "生成任务成功,后处理失败。" - } - } - }); - - let payload: Value = - serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) - .expect("worker 结果应是合法 JSON"); - - // 中文注释:透明背景最终失败不会进入拆分,此时仍是单条告警,原样保留。 - assert_eq!( - payload["warning"], - json!({ - "code": "postprocess-failed-source-preserved", - "reason": "生成任务成功,后处理失败。" - }) - ); - } - #[test] fn editor_image_job_completion_is_committed_by_atomic_persistence() { let source = include_str!("external_generation_worker.rs"); @@ -2524,252 +1796,6 @@ mod tests { ); } - #[test] - fn editor_generation_result_payload_accepts_envelope_and_redacts_inline_media() { - let job = external_generation_job_record_fixture(Some("lease-1")); - let response = json!({ - "data": { - "sliceWarning": { - "code": "slice-persistence-failed", - "reason": "provider returned data:image/png;base64,AAAA" - } - } - }); - - let payload: Value = - serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) - .expect("worker 结果应是合法 JSON"); - - assert_eq!( - payload["warning"]["reason"], - json!(EDITOR_GENERATION_WARNING_REDACTED_MESSAGE) - ); - assert!(!payload.to_string().contains("data:image")); - } - - #[test] - fn external_api_result_keeps_stable_artifacts_and_removes_unstable_media() { - let mut job = external_generation_job_record_fixture(Some("lease-1")); - job.dedupe_key = "external-api-generation:editor_image_generation:fingerprint".to_string(); - let response = json!({ - "data": { - "imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", - "videoSrc": "blob:https://example.test/video", - "audioSrc": "https://cdn.example.test/audio.mp3?X-Amz-Signature=secret", - "previewVideoPath": "https://cdn.example.test/stable-looking-but-external.mp4", - "thumbnailSrc": "/api/assets/object/thumbnail.png?expires=1&signature=secret", - "objectKey": "users/user-1/generated/main.png", - "assetObjectId": "asset-object-main", - "width": 1024, - "height": 1024, - "durationSeconds": 7.42, - "loop": true, - "provider": "internal-provider-must-not-persist", - "resource": { - "resourceId": "resource-main", - "projectId": "project-1", - "objectKey": "users/user-1/generated/main.png", - "assetObjectId": "asset-object-main", - "sourceResourceId": "source-resource-main", - "imageSrc": "https://cdn.example.test/main.png?signature=secret", - "width": 1024, - "height": 1024, - "prompt": "不应复制完整资源元数据" - }, - "asset": { - "assetId": "asset-main", - "folderId": "folder-1", - "objectKey": "users/user-1/generated/main.png", - "assetObjectId": "asset-object-main", - "imageSrc": "/api/assets/object/main.png", - "thumbnailSrc": "https://cdn.example.test/thumb.png?signature=secret", - "width": 1024, - "height": 1024, - "generationInputs": {"private": true} - }, - "frames": [ - { - "imageSrc": "/generated/action/frame-01.png", - "objectKey": "generated/action/frame-01.png", - "assetObjectId": "asset-object-frame-01", - "width": 192, - "height": 256, - "provider": "internal-provider-must-not-persist" - }, - { - "imageSrc": "/generated/action/frame-02.png", - "objectKey": "generated/action/frame-02.png", - "assetObjectId": "asset-object-frame-02", - "width": 192, - "height": 256 - } - ], - "project": { - "projectId": "project-1", - "canvas": {"layers": ["large-layout-must-not-persist"]} - }, - "warning": { - "code": "dimension-restore-fallback", - "reason": "已保留 provider 实际输出尺寸。" - } - }, - "meta": { - "requestId": "worker-envelope-must-not-persist" - } - }); - - let payload: Value = - serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) - .expect("外部生成结果应是合法 JSON"); - let result = &payload["result"]; - - assert!(result.get("project").is_none()); - assert!(result.get("provider").is_none()); - for unstable_field in [ - "imageSrc", - "videoSrc", - "audioSrc", - "previewVideoPath", - "thumbnailSrc", - ] { - assert!( - result.get(unstable_field).is_none(), - "不稳定媒体字段 {unstable_field} 不得持久化" - ); - } - assert_eq!( - result["objectKey"], - json!("users/user-1/generated/main.png") - ); - assert_eq!(result["assetObjectId"], json!("asset-object-main")); - assert_eq!(result["durationSeconds"], json!(7.42)); - assert_eq!(result["loop"], json!(true)); - assert_eq!(result["resource"]["resourceId"], json!("resource-main")); - assert_eq!( - result["resource"]["sourceResourceId"], - json!("source-resource-main") - ); - assert_eq!( - result["resource"]["objectKey"], - json!("users/user-1/generated/main.png") - ); - assert!(result["resource"].get("imageSrc").is_none()); - assert!(result["resource"].get("prompt").is_none()); - assert_eq!(result["asset"]["assetId"], json!("asset-main")); - assert_eq!( - result["asset"]["imageSrc"], - json!("/api/assets/object/main.png") - ); - assert!(result["asset"].get("thumbnailSrc").is_none()); - assert!(result["asset"].get("generationInputs").is_none()); - assert_eq!( - result["frames"][0], - json!({ - "imageSrc": "/generated/action/frame-01.png", - "objectKey": "generated/action/frame-01.png", - "assetObjectId": "asset-object-frame-01", - "width": 192, - "height": 256 - }) - ); - assert_eq!( - result["warning"], - json!({ - "code": "dimension-restore-fallback", - "reason": "已保留 provider 实际输出尺寸。" - }) - ); - assert_eq!(payload["warning"], result["warning"]); - assert!(result.get("prompt").is_none()); - assert!(result.get("actualPrompt").is_none()); - let serialized = payload.to_string().to_ascii_lowercase(); - for forbidden in [ - "data:", - "blob:", - "x-amz-signature", - "?signature=", - "large-layout", - ] { - assert!( - !serialized.contains(forbidden), - "compact result 不应包含 {forbidden}" - ); - } - } - - #[test] - fn game_creator_client_result_keeps_completed_grid_spritesheet_for_recovery() { - let mut job = external_generation_job_record_fixture(Some("lease-1")); - job.dedupe_key = format!( - "{GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX}:editor_icon_spritesheet_generation:fingerprint" - ); - job.job_kind = EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND.to_string(); - let response = json!({ - "data": { - "spritesheetImageSrc": "/api/assets/object/core-sheet.png", - "spritesheetWidth": 1024, - "spritesheetHeight": 1024, - "sliceMode": "grid", - "gridX": 2, - "gridY": 2, - "spritesheetResource": { - "resourceId": "sheet-resource-1", - "objectKey": "users/user-1/core-sheet.png", - "imageSrc": "/api/assets/object/core-sheet.png" - }, - "spritesheetAsset": { - "assetId": "sheet-asset-1", - "objectKey": "users/user-1/core-sheet.png", - "imageSrc": "/api/assets/object/core-sheet.png" - }, - "iconImageSrcs": [ - {"name": "玩家", "objectKey": "users/user-1/player.png", "imageSrc": "/api/assets/object/player.png"}, - {"name": "目标", "objectKey": "users/user-1/targets.png", "imageSrc": "/api/assets/object/targets.png"}, - {"name": "场景", "objectKey": "users/user-1/scene.png", "imageSrc": "/api/assets/object/scene.png"}, - {"name": "反馈", "objectKey": "users/user-1/feedback.png", "imageSrc": "/api/assets/object/feedback.png"} - ] - } - }); - - let payload: Value = - serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) - .expect("游戏创作客户端完成结果应持久化为合法 JSON"); - - assert_eq!(payload["result"]["sliceMode"], json!("grid")); - assert_eq!( - payload["result"]["iconImageSrcs"].as_array().map(Vec::len), - Some(4) - ); - assert_eq!( - payload["result"]["spritesheetResource"]["resourceId"], - json!("sheet-resource-1") - ); - assert_eq!( - payload["result"]["spritesheetAsset"]["assetId"], - json!("sheet-asset-1") - ); - assert_eq!( - payload["result"]["iconImageSrcs"][0]["objectKey"], - json!("users/user-1/player.png") - ); - assert!(!payload.to_string().contains("prompt")); - } - - #[test] - fn non_external_job_does_not_publish_query_result() { - let job = external_generation_job_record_fixture(Some("lease-1")); - let payload: Value = serde_json::from_str(&editor_generation_result_payload_json( - &job, - &json!({ - "objectKey": "users/user-1/generated/main.png", - "resource": {"resourceId": "resource-main"} - }), - )) - .expect("普通编辑器任务结果应为合法 JSON"); - - assert!(payload.get("result").is_none()); - } - #[test] fn worker_job_timeout_uses_long_budget_for_image_and_video_jobs() { let config = AppConfig { diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 9d53dd690..32ee19402 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -839,36 +839,6 @@ fn is_responses_terminal_sse_event(event: &str) -> bool { .is_some_and(|kind| matches!(kind.as_str(), "response.completed" | "response.incomplete")) } -const SSE_DONE_MARKER: &[u8] = b"data: [DONE]"; - -fn find_sse_done_marker(bytes: &[u8]) -> Option { - bytes - .windows(SSE_DONE_MARKER.len()) - .enumerate() - .find_map(|(position, window)| { - (window == SSE_DONE_MARKER && (position == 0 || bytes[position - 1] == b'\n')) - .then_some(position) - }) -} - -fn find_sse_event_end(bytes: &[u8], event_start: usize) -> Option { - let event = &bytes[event_start..]; - if let Some(offset) = event.windows(2).position(|window| window == b"\n\n") { - return Some(event_start + offset + 2); - } - event - .windows(4) - .position(|window| window == b"\r\n\r\n") - .map(|offset| event_start + offset + 4) -} - -fn sse_done_marker_suffix_len(bytes: &[u8]) -> usize { - (1..SSE_DONE_MARKER.len()) - .rev() - .find(|&length| bytes.ends_with(&SSE_DONE_MARKER[..length])) - .unwrap_or(0) -} - async fn resolve_llm_router_client( state: &AppState, owner_user_id: &str, @@ -1588,18 +1558,6 @@ mod tests { } } - #[test] - fn responses_sse_done_marker_is_found_only_at_event_line_start() { - let bytes = b"data: {\"text\":\"data: [DONE]\"}\n\ndata: [DONE]\n\n"; - let done_start = find_sse_done_marker(bytes).expect("done event should be found"); - assert_eq!( - &bytes[done_start..done_start + SSE_DONE_MARKER.len()], - SSE_DONE_MARKER - ); - assert_eq!(find_sse_event_end(bytes, done_start), Some(bytes.len())); - assert_eq!(sse_done_marker_suffix_len(b"data: [DON"), 10); - } - #[test] fn append_utf8_chunk_preserves_multibyte_characters_split_across_chunks() { let mut text = String::new(); diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 2cf90c425..f3e6228fe 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -2,7 +2,6 @@ use axum::http::StatusCode; use platform_image::{ DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, - create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, create_vector_engine_image_edit_with_references_and_model, create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, create_vector_engine_nanobanana_generate_content, @@ -18,8 +17,7 @@ use time::OffsetDateTime; use crate::{ external_api_audit::{ - ExternalApiFailureDraft, build_external_api_failure_draft_from_platform_image_audit, - record_external_api_failure, + build_external_api_failure_draft_from_platform_image_audit, record_external_api_failure, }, http_error::AppError, request_context::RequestContext, @@ -248,83 +246,6 @@ pub(crate) async fn create_openai_nanobanana_generate_content( .await } -pub(crate) async fn create_openai_image_edit( - http_client: &reqwest::Client, - settings: &OpenAiImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - reference_image: &OpenAiReferenceImage, - failure_context: &str, -) -> Result { - let started_at_micros = current_utc_micros(); - let request_payload = json!({ - "size": size, - "promptChars": prompt.chars().count(), - "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), - "referenceImageCount": 1, - }); - let result = create_vector_engine_image_edit( - http_client, - &settings.provider_settings(), - prompt, - negative_prompt, - size, - reference_image, - failure_context, - ) - .await; - map_platform_image_result( - settings, - result, - "image_edit", - failure_context, - request_payload, - started_at_micros, - ) - .await -} - -pub(crate) async fn create_openai_image_edit_with_references( - http_client: &reqwest::Client, - settings: &OpenAiImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - candidate_count: u32, - reference_images: &[OpenAiReferenceImage], - failure_context: &str, -) -> Result { - let started_at_micros = current_utc_micros(); - let request_payload = json!({ - "size": size, - "candidateCount": candidate_count, - "promptChars": prompt.chars().count(), - "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), - "referenceImageCount": reference_images.len(), - }); - let result = create_vector_engine_image_edit_with_references( - http_client, - &settings.provider_settings(), - prompt, - negative_prompt, - size, - candidate_count, - reference_images, - failure_context, - ) - .await; - map_platform_image_result( - settings, - result, - "image_edit_with_references", - failure_context, - request_payload, - started_at_micros, - ) - .await -} - #[allow(clippy::too_many_arguments)] pub(crate) async fn create_openai_image_edit_with_references_and_model( http_client: &reqwest::Client, @@ -386,16 +307,6 @@ pub(crate) fn build_openai_image_request_body( } impl OpenAiImageSettings { - pub(crate) fn with_external_api_audit_actor( - mut self, - user_id: Option, - profile_id: Option, - ) -> Self { - self.external_api_audit_user_id = user_id; - self.external_api_audit_profile_id = profile_id; - self - } - pub(crate) fn with_external_api_audit_context( mut self, request_context: &RequestContext, @@ -501,14 +412,6 @@ async fn record_openai_image_failure_audit_if_configured( record_external_api_failure(state, draft).await; } -pub(crate) fn build_openai_image_failure_audit_draft( - error: &PlatformImageError, -) -> Option { - error - .audit() - .map(build_external_api_failure_draft_from_platform_image_audit) -} - pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError { let error = error.into_final_error(); let status = match error.status_hint() { @@ -705,37 +608,6 @@ mod tests { ); } - #[tokio::test] - async fn vector_engine_multi_reference_edit_rejects_empty_references() { - let settings = OpenAiImageSettings { - base_url: "https://vector.example".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000_000, - request_deadline: None, - external_api_audit_state: None, - external_api_audit_user_id: None, - external_api_audit_profile_id: None, - external_api_audit_request_id: None, - }; - let http_client = reqwest::Client::new(); - - let result = create_openai_image_edit_with_references( - &http_client, - &settings, - "提示词", - None, - "1:1", - 1, - &[], - "测试图片编辑失败", - ) - .await; - - let error = result.expect_err("empty references should be rejected locally"); - assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); - assert!(error.body_text().contains("缺少参考图")); - } - #[test] fn reference_data_url_stays_provider_owned() { let source = format!( diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/errors.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/errors.rs index a1bd54c48..8fd90c83d 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/errors.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/errors.rs @@ -6,40 +6,6 @@ use crate::{http_error::AppError, request_context::RequestContext}; use super::types::VECTOR_ENGINE_PROVIDER; -pub(super) fn normalize_limited_text( - value: &str, - field: &'static str, - max_chars: usize, -) -> Result { - let normalized = value.trim().to_string(); - if normalized.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "field": field, - "message": format!("{field} 不能为空"), - })), - ); - } - if normalized.chars().count() > max_chars { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "field": field, - "message": format!("{field} 超过 {} 字符", max_chars), - })), - ); - } - Ok(normalized) -} - -pub(super) fn normalize_optional_text(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - pub(super) fn map_asset_field_error(error: module_assets::AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", @@ -47,13 +13,6 @@ pub(super) fn map_asset_field_error(error: module_assets::AssetObjectFieldError) })) } -pub(super) fn map_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "spacetimedb", - "message": error.to_string(), - })) -} - pub(super) fn map_platform_audio_error(error: AudioError) -> AppError { let status = match error.status_hint() { AudioStatusHint::BadRequest => StatusCode::BAD_REQUEST, diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index bfe6c55ac..0a77eea5b 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -571,7 +571,6 @@ impl SoundEffectWorkerStages for ProductionSoundEffectWorkerStages<'_> { Some(asset_object), PersistEditorGeneratedAssetInput { project_id: normalize_optional_string(self.project_id.clone()), - owner_user_id: self.owner_user_id.to_string(), folder_id: self.asset_folder_id.clone(), label, image_src: input.persisted_audio.audio_src.clone(), @@ -1374,7 +1373,6 @@ async fn persist_editor_audio_generation( Some(input.generated.prepared.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: project_id.clone(), - owner_user_id: caller.owner_user_id.clone(), folder_id: input.asset_folder_id.clone(), label, image_src: input.generated.audio_src.clone(), diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs index 9285d2fba..0686bff21 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs @@ -12,18 +12,9 @@ use serde_json::json; use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState}; use super::{ - clock::current_utc_micros, - errors::map_asset_field_error, - types::{AudioAssetBindingTarget, AudioAssetSlot}, + clock::current_utc_micros, errors::map_asset_field_error, types::AudioAssetBindingTarget, }; -#[derive(Clone, Debug)] -pub(super) struct PersistedAudioAsset { - pub(super) asset_object_id: String, - pub(super) object_key: String, - pub(super) audio_src: String, -} - #[derive(Clone, Debug)] pub(super) struct PreparedAudioAsset { pub(super) asset_object: AssetObjectUpsertInput, @@ -32,24 +23,6 @@ pub(super) struct PreparedAudioAsset { pub(super) audio_src: String, } -pub(super) async fn persist_generated_audio_asset( - _state: &AppState, - _http_client: &reqwest::Client, - _owner_user_id: &str, - _task_id: &str, - _slot: AudioAssetSlot, - _task_kind: platform_audio::AudioTaskKind, - _target: AudioAssetBindingTarget, - _audio: DownloadedAudio, -) -> Result { - Err( - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-generation-operation", - "message": "旧音频分步持久化路径已禁用;编辑器生成必须使用原子结果提交。", - })), - ) -} - #[allow(clippy::too_many_arguments)] pub(super) async fn prepare_generated_audio_asset( state: &AppState, diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs index bcd1d7974..cda1e667a 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs @@ -1,187 +1,7 @@ -use std::time::Duration; - -use shared_contracts::creation_audio; - -use crate::{ - asset_billing::execute_billable_asset_operation_with_cost, http_error::AppError, - state::AppState, +use super::types::{ + AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST, }; -use super::{ - errors::{map_platform_audio_error, vector_engine_bad_gateway}, - persist::persist_generated_audio_asset, - settings::require_vector_engine_audio_settings, - types::{AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST}, -}; - -#[cfg(any())] -pub(super) async fn publish_generated_audio_asset( - state: &AppState, - owner_user_id: &str, - task_id: String, - slot: AudioAssetSlot, - target: AudioAssetBindingTarget, -) -> Result { - publish_generated_audio_asset_with_task_kind( - state, - owner_user_id, - task_id, - slot, - slot.task_kind(), - target, - ) - .await -} - -pub(super) async fn publish_generated_audio_asset_with_task_kind( - state: &AppState, - owner_user_id: &str, - task_id: String, - slot: AudioAssetSlot, - task_kind: platform_audio::AudioTaskKind, - target: AudioAssetBindingTarget, -) -> Result { - let task_id = platform_audio::normalize_limited_text(&task_id, "taskId", 160) - .map_err(map_platform_audio_error)?; - let settings = require_vector_engine_audio_settings(state)?; - let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) - .map_err(map_platform_audio_error)?; - let (status, audio_urls): (String, Vec) = - platform_audio::resolve_audio_task_download_urls( - &http_client, - &settings, - task_kind, - &task_id, - ) - .await - .map_err(map_platform_audio_error)?; - - if platform_audio::is_pending_task_status(&status) && audio_urls.is_empty() { - return Ok(creation_audio::GeneratedAudioAssetResponse { - kind: slot.creation_contract_kind(), - task_id, - provider: task_kind.provider().to_string(), - status: status.clone(), - asset_object_id: None, - object_key: None, - asset_kind: None, - audio_src: None, - }); - } - - if platform_audio::is_failed_task_status(&status) { - return Err(vector_engine_bad_gateway( - "音频生成任务失败,请调整提示词后重试", - )); - } - - let audio_url = audio_urls - .into_iter() - .next() - .ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?; - let billing_asset_kind = target.asset_kind.clone(); - let billing_asset_id = build_audio_billing_asset_id(&task_id, slot, &target); - let points_cost = resolve_creation_audio_points_cost(slot, &target); - let persisted = execute_billable_asset_operation_with_cost( - state, - owner_user_id, - billing_asset_kind.as_str(), - billing_asset_id.as_str(), - points_cost, - async { - let audio = platform_audio::download_generated_audio( - &http_client, - &audio_url, - task_kind.provider(), - ) - .await - .map_err(map_platform_audio_error)?; - persist_generated_audio_asset( - state, - &http_client, - owner_user_id, - &task_id, - slot, - task_kind, - target.clone(), - audio, - ) - .await - }, - ) - .await?; - - Ok(creation_audio::GeneratedAudioAssetResponse { - kind: slot.creation_contract_kind(), - task_id, - provider: task_kind.provider().to_string(), - status: "completed".to_string(), - asset_object_id: Some(persisted.asset_object_id), - object_key: Some(persisted.object_key), - asset_kind: Some(target.asset_kind), - audio_src: Some(persisted.audio_src), - }) -} - -pub(super) async fn wait_for_generated_audio_asset( - state: &AppState, - owner_user_id: &str, - task_id: String, - slot: AudioAssetSlot, - target: AudioAssetBindingTarget, -) -> Result { - wait_for_generated_audio_asset_with_task_kind( - state, - owner_user_id, - task_id, - slot, - slot.task_kind(), - target, - ) - .await -} - -pub(super) async fn wait_for_generated_audio_asset_with_task_kind( - state: &AppState, - owner_user_id: &str, - task_id: String, - slot: AudioAssetSlot, - task_kind: platform_audio::AudioTaskKind, - target: AudioAssetBindingTarget, -) -> Result { - let mut latest_status = String::new(); - for _ in 0..40 { - let response = publish_generated_audio_asset_with_task_kind( - state, - owner_user_id, - task_id.clone(), - slot, - task_kind, - target.clone(), - ) - .await?; - if response - .audio_src - .as_deref() - .map(str::trim) - .is_some_and(|value| !value.is_empty()) - { - return Ok(response); - } - latest_status = response.status; - tokio::time::sleep(Duration::from_millis(3_000)).await; - } - - Err(vector_engine_bad_gateway(format!( - "音频生成超时:{}", - if latest_status.trim().is_empty() { - task_id - } else { - latest_status - } - ))) -} - pub(super) fn build_audio_billing_asset_id( task_id: &str, slot: AudioAssetSlot, diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs deleted file mode 100644 index 47e5233ae..000000000 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs +++ /dev/null @@ -1,53 +0,0 @@ -use axum::http::StatusCode; -use platform_oss::LegacyAssetPrefix; -use serde_json::json; -use shared_contracts::{creation_audio, visual_novel as contract}; - -use crate::http_error::AppError; - -use super::{ - errors::{normalize_limited_text, normalize_optional_text}, - types::{AUDIO_ENTITY_KIND, AudioAssetBindingTarget, AudioAssetSlot, VECTOR_ENGINE_PROVIDER}, -}; - -pub(super) fn build_visual_novel_audio_target( - payload: contract::PublishVisualNovelGeneratedAudioAssetRequest, - slot: AudioAssetSlot, -) -> Result { - let entity_id = normalize_limited_text(&payload.scene_id, "sceneId", 160)?; - Ok(AudioAssetBindingTarget { - entity_kind: AUDIO_ENTITY_KIND.to_string(), - entity_id, - slot: slot.slot().to_string(), - asset_kind: slot.asset_kind().to_string(), - profile_id: normalize_optional_text(payload.profile_id.as_deref()), - storage_prefix: LegacyAssetPrefix::CustomWorldScenes, - storage_scope: "visual-novel".to_string(), - billing_points_cost: None, - }) -} - -pub(super) fn build_creation_audio_target( - payload: creation_audio::PublishGeneratedAudioAssetRequest, - _slot: AudioAssetSlot, -) -> Result { - Err(creation_audio_generation_disabled_error_for_target(payload)) -} - -pub(super) fn creation_audio_generation_disabled_error() -> AppError { - AppError::from_status(StatusCode::GONE).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "当前创作音频目标未开放", - })) -} - -pub(super) fn creation_audio_generation_disabled_error_for_target( - payload: creation_audio::PublishGeneratedAudioAssetRequest, -) -> AppError { - creation_audio_generation_disabled_error().with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "当前创作音频目标未开放", - "entityKind": payload.entity_kind.trim(), - "slot": payload.slot.trim(), - })) -} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs index 7af8eedbc..094694872 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs @@ -3,16 +3,6 @@ use platform_oss::LegacyAssetPrefix; use shared_contracts::creation_audio; pub(super) const VECTOR_ENGINE_PROVIDER: &str = platform_audio::VECTOR_ENGINE_PROVIDER; -#[cfg(any())] -pub(super) const AUDIO_ENTITY_KIND: &str = "visual_novel_scene"; -#[cfg(any())] -pub(super) const MUSIC_ASSET_KIND: &str = "visual_novel_music"; -#[cfg(any())] -pub(super) const AMBIENT_SOUND_ASSET_KIND: &str = "visual_novel_ambient_sound"; -#[cfg(any())] -pub(super) const MUSIC_SLOT: &str = "music"; -#[cfg(any())] -pub(super) const AMBIENT_SOUND_SLOT: &str = "ambient_sound"; pub(super) const CREATION_BACKGROUND_MUSIC_POINTS_COST: u64 = 5; #[derive(Clone, Debug)] @@ -39,22 +29,6 @@ impl AudioAssetSlot { } } - #[cfg(any())] - pub(super) fn asset_kind(self) -> &'static str { - match self { - Self::BackgroundMusic => MUSIC_ASSET_KIND, - Self::SoundEffect => AMBIENT_SOUND_ASSET_KIND, - } - } - - #[cfg(any())] - pub(super) fn slot(self) -> &'static str { - match self { - Self::BackgroundMusic => MUSIC_SLOT, - Self::SoundEffect => AMBIENT_SOUND_SLOT, - } - } - pub(super) fn file_stem(self) -> &'static str { self.task_kind().file_stem() } diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs index 6d79051ff..cb2a6e70c 100644 --- a/server-rs/crates/api-server/src/wallet_refund_outbox.rs +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -678,15 +678,6 @@ fn ledger_id_hash(ledger_id: &str) -> String { hex::encode(Sha256::digest(ledger_id.as_bytes())) } -fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool { - name.to_str().is_some_and(|value| { - (value.starts_with(PENDING_FILE_PREFIX) - || value.starts_with(OVERFLOW_FILE_PREFIX) - || value.starts_with(TEMP_FILE_PREFIX)) - && value.ends_with(OUTBOX_FILE_EXTENSION) - }) -} - fn is_capped_outbox_file_name(name: &std::ffi::OsStr) -> bool { name.to_str().is_some_and(|value| { ((value.starts_with(PENDING_FILE_PREFIX) && !value.starts_with(OVERFLOW_FILE_PREFIX)) @@ -718,6 +709,15 @@ async fn sync_directory_metadata(path: &Path) -> Result<(), WalletRefundOutboxEr mod tests { use super::*; + fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|value| { + (value.starts_with(PENDING_FILE_PREFIX) + || value.starts_with(OVERFLOW_FILE_PREFIX) + || value.starts_with(TEMP_FILE_PREFIX)) + && value.ends_with(OUTBOX_FILE_EXTENSION) + }) + } + fn sample_record(ledger_id: &str) -> WalletRefundOutboxRecord { WalletRefundOutboxRecord { owner_user_id: "user-1".to_string(),