修复 External SFX 轮询契约
Project CI / Repository checks (pull_request) Successful in 1m2s
Project CI / Frontend tests (pull_request) Successful in 2m55s
Project CI / Backend tests (pull_request) Successful in 3m51s
Project CI / Native shell tests (pull_request) Successful in 13m14s

新增具名 SFX compact 结果联合与稳定引用约束

补强轮询 OpenAPI 以及音效 metadata 结果测试
This commit is contained in:
2026-08-08 05:39:41 +00:00
parent 38e195060b
commit 8a42b23ea4
3 changed files with 283 additions and 5 deletions
@@ -4818,6 +4818,152 @@
}
}
},
"ExternalEditorGenerationCompactResourceReference": {
"type": "object",
"required": [
"resourceId"
],
"properties": {
"resourceId": {
"type": "string",
"minLength": 1
},
"projectId": {
"type": "string",
"minLength": 1
},
"objectKey": {
"type": "string",
"minLength": 1
},
"assetObjectId": {
"type": "string",
"minLength": 1
},
"sourceResourceId": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": true,
"description": "完成态 compact result 中的稳定画布资源引用;不是完整 resource 快照。"
},
"ExternalEditorGenerationCompactAssetReference": {
"type": "object",
"required": [
"assetId"
],
"properties": {
"assetId": {
"type": "string",
"minLength": 1
},
"folderId": {
"type": "string",
"minLength": 1
},
"objectKey": {
"type": "string",
"minLength": 1
},
"assetObjectId": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": true,
"description": "完成态 compact result 中的稳定素材库引用;不是完整 asset 快照。"
},
"ExternalEditorSoundEffectCompactResult": {
"type": "object",
"required": [
"audioKind"
],
"properties": {
"audioKind": {
"type": "string",
"const": "sound-effect"
},
"durationSeconds": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 600,
"description": "SFX V2 保存 MP3 后探测得到的实际时长;不是请求时长。历史完成结果可缺少此字段。"
},
"loop": {
"type": "boolean",
"description": "SFX V2 冻结并发送给 provider 的 Loop;历史完成结果可缺少此字段。"
},
"taskId": {
"type": "string",
"minLength": 1
},
"objectKey": {
"type": "string",
"minLength": 1,
"description": "持久化音频对象的稳定引用;需要临时下载或预览 URL 时使用 assets/read-url。"
},
"assetObjectId": {
"type": "string",
"minLength": 1
},
"resource": {
"anyOf": [
{
"$ref": "#/components/schemas/ExternalEditorGenerationCompactResourceReference"
},
{
"type": "null"
}
]
},
"asset": {
"anyOf": [
{
"$ref": "#/components/schemas/ExternalEditorGenerationCompactAssetReference"
},
{
"type": "null"
}
]
}
},
"additionalProperties": true,
"description": "External SFX 完成态的 compact result。仅包含稳定产物引用和可安全消费的 SFX 元数据;不包含完整 project/canvas/asset 快照或脱敏的 Prompt、模型与 provider 字段。"
},
"ExternalEditorGenerationGenericCompactResult": {
"type": "object",
"not": {
"type": "object",
"required": [
"audioKind"
],
"properties": {
"audioKind": {
"const": "sound-effect"
}
}
},
"additionalProperties": true,
"description": "其它生成类型或历史结果的兼容 compact result。该 fallback 明确排除 audioKind=sound-effect,避免吞掉 SFX 专用分支。"
},
"ExternalEditorGenerationCompletedResult": {
"oneOf": [
{
"$ref": "#/components/schemas/ExternalEditorSoundEffectCompactResult"
},
{
"$ref": "#/components/schemas/ExternalEditorGenerationGenericCompactResult"
}
],
"discriminator": {
"propertyName": "audioKind",
"mapping": {
"sound-effect": "#/components/schemas/ExternalEditorSoundEffectCompactResult"
}
},
"description": "External v1 轮询 completed 状态的命名 compact result 联合。"
},
"ExternalEditorGenerationSubmissionResponse": {
"type": "object",
"required": [
@@ -4902,9 +5048,8 @@
"type": "string"
},
"result": {
"type": "object",
"description": "completed 时返回的 compact 稳定结果引用;不包含完整 project/canvas、Data URL、Blob URL 或临时签名 URL。",
"additionalProperties": true
"$ref": "#/components/schemas/ExternalEditorGenerationCompletedResult",
"description": "仅在 completed 时返回的 compact 稳定结果引用;queued、running 和 failed 不返回该字段。为兼容历史完成结果,此字段不作为无版本迁移的必填约束。"
},
"pollAfterMs": {
"type": "integer",
@@ -2157,6 +2157,79 @@ mod tests {
audio_response["properties"]["loop"]["type"],
json!(["boolean", "null"])
);
let generation_job =
&parsed["components"]["schemas"]["ExternalEditorGenerationJobResponse"];
assert_eq!(
generation_job["properties"]["result"]["$ref"],
"#/components/schemas/ExternalEditorGenerationCompletedResult"
);
assert!(
generation_job["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("result"))),
"历史 completed 轮询结果可能没有 result,不能在无版本迁移时改为必填"
);
let completed_result =
&parsed["components"]["schemas"]["ExternalEditorGenerationCompletedResult"];
assert_eq!(
completed_result["oneOf"][0]["$ref"],
"#/components/schemas/ExternalEditorSoundEffectCompactResult"
);
assert_eq!(
completed_result["oneOf"][1]["$ref"],
"#/components/schemas/ExternalEditorGenerationGenericCompactResult"
);
assert_eq!(
completed_result["discriminator"]["propertyName"],
"audioKind"
);
assert_eq!(
completed_result["discriminator"]["mapping"]["sound-effect"],
"#/components/schemas/ExternalEditorSoundEffectCompactResult"
);
let sound_effect_result =
&parsed["components"]["schemas"]["ExternalEditorSoundEffectCompactResult"];
assert_eq!(sound_effect_result["required"], json!(["audioKind"]));
assert_eq!(
sound_effect_result["properties"]["audioKind"]["const"],
"sound-effect"
);
assert_eq!(
sound_effect_result["properties"]["durationSeconds"]["type"],
"number"
);
assert_eq!(
sound_effect_result["properties"]["durationSeconds"]["exclusiveMinimum"],
json!(0)
);
assert_eq!(
sound_effect_result["properties"]["durationSeconds"]["maximum"],
json!(600)
);
assert_eq!(sound_effect_result["properties"]["loop"]["type"], "boolean");
for stable_reference in ["taskId", "objectKey", "assetObjectId", "resource", "asset"] {
assert!(
sound_effect_result["properties"]
.get(stable_reference)
.is_some(),
"SFX compact result must expose {stable_reference} when it is present"
);
}
for redacted_field in ["prompt", "actualPrompt", "model", "provider"] {
assert!(
sound_effect_result["properties"]
.get(redacted_field)
.is_none(),
"SFX compact schema must not declare redacted {redacted_field}"
);
}
let generic_result =
&parsed["components"]["schemas"]["ExternalEditorGenerationGenericCompactResult"];
assert_eq!(generic_result["not"]["required"], json!(["audioKind"]));
assert_eq!(
generic_result["not"]["properties"]["audioKind"]["const"],
"sound-effect"
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/audios/background-music/generations")
@@ -1582,12 +1582,14 @@ fn editor_audio_bad_request(message: impl Into<String>) -> AppError {
#[cfg(test)]
mod tests {
use serde::Deserialize;
use serde_json::Value;
use serde_json::{Value, json};
use shared_contracts::assets;
use super::super::sound_effect_worker::{PersistedSoundEffectAudio, SoundEffectWritebackInput};
use super::{
CanonicalEditorBackgroundMusicSubmissionPayload, NormalizedEditorBackgroundMusicRequest,
build_editor_background_music_generate_response, build_inline_editor_audio_caller,
NormalizedEditorSoundEffectRequest, build_editor_background_music_generate_response,
build_inline_editor_audio_caller, build_sound_effect_job_result_payload,
normalize_editor_background_music_request, normalize_editor_sound_effect_request,
prepare_editor_background_music_queue_job,
};
@@ -1596,6 +1598,7 @@ mod tests {
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
serialize_editor_generation_job_payload_for_test,
},
editor_project::{EditorGenerationCaller, EditorGenerationOperationContext},
request_context::RequestContext,
};
@@ -1703,6 +1706,63 @@ mod tests {
assert!(body.contains("\"actualPrompt\": input.prompt"));
}
#[test]
fn sound_effect_compact_result_uses_persisted_metadata_for_duration_and_loop() {
let caller = EditorGenerationCaller {
owner_user_id: "user-1".to_string(),
audit_subject_user_id: Some("user-1".to_string()),
audit_project_id: Some("project-1".to_string()),
phase_reporter: None,
operation: Some(EditorGenerationOperationContext {
operation_kind: EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND.to_string(),
operation_id: "operation-sfx-1".to_string(),
operation_fingerprint: "fingerprint-sfx-1".to_string(),
worker_id: Some("worker-sfx-1".to_string()),
lease_token: Some("lease-sfx-1".to_string()),
queue_result_context: None,
}),
};
let normalized = NormalizedEditorSoundEffectRequest {
prompt: "请求的音效描述".to_string(),
model: assets::EDITOR_SOUND_EFFECT_MODEL.to_string(),
duration: Some(1.0),
loop_enabled: false,
price_mud_points: 7,
};
let input = SoundEffectWritebackInput {
actual_prompt: "provider prompt".to_string(),
generation_inputs: json!({
"soundEffect": {
"requestedDurationSeconds": 1.0,
"actualDurationSeconds": 7.42,
"loop": true
}
}),
persisted_audio: PersistedSoundEffectAudio {
asset_object_id: "asset-object-sfx-1".to_string(),
object_key: "generated/editor/sfx-1.mp3".to_string(),
audio_src: "/generated-editor-audios/sfx-1.mp3".to_string(),
asset_object: None,
binding: None,
},
};
let payload: Value = serde_json::from_str(
build_sound_effect_job_result_payload(&caller, &normalized, &input, None, None)
.expect("queued SFX worker result should be serialized")
.as_str(),
)
.expect("queued SFX worker result should be JSON");
assert_eq!(payload["result"]["durationSeconds"], 7.42);
assert_eq!(payload["result"]["loop"], true);
assert_ne!(
payload["result"]["durationSeconds"],
json!(normalized.duration)
);
assert_ne!(payload["result"]["loop"], json!(normalized.loop_enabled));
}
#[test]
fn editor_audio_targets_preflight_before_provider_or_queue() {
let source = include_str!("generation.rs");