修复音频生成响应泄露 Provider
Project CI / Repository checks (pull_request) Successful in 1m6s
Project CI / Frontend tests (pull_request) Successful in 2m52s
Project CI / Backend tests (pull_request) Failing after 3m1s
Project CI / Native shell tests (pull_request) Failing after 9m54s

收紧音频公开 DTO 与前端响应类型

统一清理 inline 和 Agent 队列结果中的 Provider

覆盖嵌套快照脱敏与历史 Agent 回填兼容
This commit is contained in:
2026-08-08 12:26:41 +00:00
parent 0edefc67ec
commit dea6576f89
10 changed files with 113 additions and 70 deletions
@@ -1572,7 +1572,6 @@ mod tests {
"prompt": "按钮点击声",
"actualPrompt": "A short button click",
"model": "eleven_text_to_sound_v2",
"provider": "elevenlabs",
"taskId": "task-1",
"priceMudPoints": 5,
"audioKind": "sound-effect"
@@ -1130,21 +1130,12 @@ fn serialize_atomic_editor_generation_job_result(
}
/// 队列结果落库前的通用紧凑化。原子提交与 worker 直接 complete 两条路径共用同一份实现,
/// 避免其中一条单独演进后丢掉音频豁免(历史上就出现过:豁免只加在 worker 副本里,
/// 原子化改造新写的副本没带上,导致 Agent 音效成功却回填失败)。
/// 避免任何 consumer 把内部 provider 带入可重放的结果快照。
pub(crate) fn compact_editor_generation_result(mut result: Value) -> Value {
let Some(object) = result.as_object_mut() else {
return result;
};
// 音频 Agent 使用完整音频 DTO 回填,必须保留其必填 provider;其它生成结果继续隐藏
// 供应商和内部后处理实现。正常的用户可见模型仍然保留,以便卡片恢复原有展示。
let is_audio_result = object
.get("audioKind")
.and_then(Value::as_str)
.is_some_and(|kind| matches!(kind, "sound-effect" | "background-music"));
if !is_audio_result {
object.remove("provider");
}
object.remove("provider");
if object
.get("model")
.and_then(Value::as_str)
@@ -19708,11 +19699,9 @@ mod tests {
}
}
/// 画布 Agent 音频回填读的是完整 `EditorAudioGenerateResponse`,其 `provider` 是必填
/// 紧凑化对 External API 消费者隐藏 provider(见上一条),但对 Agent 消费者必须保留,
/// 否则 ElevenLabs、扣费、OSS 与画布写回全部成功之后,卡片仍会在四次回填尝试后显示失败。
/// 画布 Agent 音频回填只读取稳定产物引用;provider 不属于工具卡片或回填 DTO
#[test]
fn atomic_agent_audio_result_keeps_provider_for_tool_call_backfill() {
fn atomic_agent_audio_result_hides_provider_and_stays_backfillable() {
for audio_kind in ["sound-effect", "background-music"] {
let mut job = atomic_editor_generation_job_fixture();
job.request_payload_json = json!({
@@ -19748,13 +19737,12 @@ mod tests {
.expect("agent audio compact payload should be JSON");
let result = &payload["editor-agent-tool-call-result"];
assert_eq!(result["provider"], "elevenlabs", "{audio_kind}");
// 真正的锁:生产端紧凑化后的 payload 必须仍能被消费端 DTO 反序列化。
assert!(result.get("provider").is_none(), "{audio_kind}");
// 生产端紧凑化后的 payload 必须仍能被消费端 DTO 反序列化并完成回填
let backfilled: EditorAudioGenerateResponse = serde_json::from_value(result.clone())
.unwrap_or_else(|error| {
panic!("{audio_kind} agent payload must stay backfillable: {error}")
});
assert_eq!(backfilled.provider, "elevenlabs");
assert_eq!(backfilled.audio_kind, audio_kind);
}
}
@@ -414,7 +414,10 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
.await
.map_err(&error_response)?;
Ok(json_success_body(Some(&request_context), response))
Ok(json_success_body(
Some(&request_context),
sanitize_editor_audio_generation_response_for_user(response),
))
}
struct ProductionSoundEffectWorkerBilling<'a> {
@@ -966,7 +969,6 @@ fn build_editor_background_music_generate_response(
prompt: normalized.gpt_description_prompt.clone(),
actual_prompt: Some(normalized.gpt_description_prompt),
model: platform_audio::SUNO_DEFAULT_MODEL.to_string(),
provider: generated.provider,
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
audio_kind: "background-music".to_string(),
@@ -979,6 +981,41 @@ fn build_editor_background_music_generate_response(
}
}
/// 音频结果同时服务于同步用户响应和 Agent 任务回填。顶层公开 DTO 已不含 provider
/// 但 project/resource/asset 是兼容历史的 JSON 快照,仍须在用户边界递归剥离,避免
/// 内部持久化字段经嵌套快照重新暴露。
fn sanitize_editor_audio_generation_response_for_user(
mut response: assets::EditorAudioGenerateResponse,
) -> assets::EditorAudioGenerateResponse {
for snapshot in [
&mut response.project,
&mut response.resource,
&mut response.asset,
] {
if let Some(snapshot) = snapshot.as_mut() {
strip_editor_audio_provider_fields(snapshot);
}
}
response
}
fn strip_editor_audio_provider_fields(value: &mut Value) {
match value {
Value::Object(fields) => {
fields.remove("provider");
for nested in fields.values_mut() {
strip_editor_audio_provider_fields(nested);
}
}
Value::Array(items) => {
for item in items {
strip_editor_audio_provider_fields(item);
}
}
_ => {}
}
}
pub(crate) async fn generate_editor_background_music_for_owner(
state: AppState,
request_context: RequestContext,
@@ -1083,22 +1120,24 @@ pub(crate) async fn generate_editor_background_music_for_owner(
Ok(json_success_body(
Some(&request_context),
build_editor_background_music_generate_response(
audio_src.clone(),
shared_contracts::creation_audio::GeneratedAudioAssetResponse {
kind: AudioAssetSlot::BackgroundMusic.creation_contract_kind(),
task_id: persisted.provider_task_id,
provider: persisted.provider,
status: "completed".to_string(),
asset_object_id: Some(persisted.asset_object_id),
object_key: Some(persisted.object_key),
asset_kind: Some("editor_background_music".to_string()),
audio_src: Some(audio_src),
},
normalized,
persisted.project,
persisted.resource,
persisted.asset,
sanitize_editor_audio_generation_response_for_user(
build_editor_background_music_generate_response(
audio_src.clone(),
shared_contracts::creation_audio::GeneratedAudioAssetResponse {
kind: AudioAssetSlot::BackgroundMusic.creation_contract_kind(),
task_id: persisted.provider_task_id,
provider: persisted.provider,
status: "completed".to_string(),
asset_object_id: Some(persisted.asset_object_id),
object_key: Some(persisted.object_key),
asset_kind: Some("editor_background_music".to_string()),
audio_src: Some(audio_src),
},
normalized,
persisted.project,
persisted.resource,
persisted.asset,
),
),
))
}
@@ -2049,27 +2088,38 @@ mod tests {
#[test]
fn editor_background_music_inline_response_keeps_canonical_prompt_equal() {
let representative = background_music_prompt_fixture("representative-complex");
let response = build_editor_background_music_generate_response(
"/generated-editor-audios/background-music.wav".to_string(),
shared_contracts::creation_audio::GeneratedAudioAssetResponse {
kind:
shared_contracts::creation_audio::CreationAudioGenerationKind::BackgroundMusic,
task_id: "task-background-music-1".to_string(),
provider: platform_audio::VECTOR_ENGINE_PROVIDER.to_string(),
status: "completed".to_string(),
asset_object_id: Some("asset-object-1".to_string()),
object_key: Some("generated/editor/background-music.wav".to_string()),
asset_kind: Some("editor_background_music".to_string()),
audio_src: Some("/generated-editor-audios/background-music.wav".to_string()),
},
NormalizedEditorBackgroundMusicRequest {
gpt_description_prompt: representative.prompt.clone(),
make_instrumental: true,
price_mud_points: 5,
},
Some(serde_json::json!({ "projectId": "project-1" })),
Some(serde_json::json!({ "resourceId": "resource-1" })),
Some(serde_json::json!({ "assetId": "asset-1" })),
let response = super::sanitize_editor_audio_generation_response_for_user(
build_editor_background_music_generate_response(
"/generated-editor-audios/background-music.wav".to_string(),
shared_contracts::creation_audio::GeneratedAudioAssetResponse {
kind:
shared_contracts::creation_audio::CreationAudioGenerationKind::BackgroundMusic,
task_id: "task-background-music-1".to_string(),
provider: platform_audio::VECTOR_ENGINE_PROVIDER.to_string(),
status: "completed".to_string(),
asset_object_id: Some("asset-object-1".to_string()),
object_key: Some("generated/editor/background-music.wav".to_string()),
asset_kind: Some("editor_background_music".to_string()),
audio_src: Some("/generated-editor-audios/background-music.wav".to_string()),
},
NormalizedEditorBackgroundMusicRequest {
gpt_description_prompt: representative.prompt.clone(),
make_instrumental: true,
price_mud_points: 5,
},
Some(serde_json::json!({
"projectId": "project-1",
"resources": [{ "provider": "vector-engine" }]
})),
Some(serde_json::json!({
"resourceId": "resource-1",
"provider": "vector-engine"
})),
Some(serde_json::json!({
"assetId": "asset-1",
"provider": "vector-engine"
})),
),
);
assert_eq!(response.prompt, representative.prompt);
@@ -2081,6 +2131,14 @@ mod tests {
serde_json::to_value(response).expect("inline response should serialize to JSON");
assert_eq!(serialized["prompt"], serialized["actualPrompt"]);
assert_eq!(serialized["prompt"], representative.prompt);
assert!(serialized.get("provider").is_none());
assert!(
serialized
.pointer("/project/resources/0/provider")
.is_none()
);
assert!(serialized.pointer("/resource/provider").is_none());
assert!(serialized.pointer("/asset/provider").is_none());
}
#[test]
@@ -159,7 +159,6 @@ where
prompt: input.user_prompt,
actual_prompt: Some(actual_prompt),
model: input.model,
provider: platform_audio::ELEVENLABS_PROVIDER.to_string(),
task_id: input.task_id,
price_mud_points: input.price_mud_points,
audio_kind: "sound-effect".to_string(),
@@ -513,7 +512,9 @@ mod tests {
Some("Bright metallic coin pickup.")
);
assert_eq!(response.model, assets::EDITOR_SOUND_EFFECT_MODEL);
assert_eq!(response.provider, platform_audio::ELEVENLABS_PROVIDER);
let response_payload =
serde_json::to_value(&response).expect("sound effect response should serialize");
assert!(response_payload.get("provider").is_none());
assert_eq!(response.task_id, "job-sfx-t6");
assert_eq!(response.duration_seconds, Some(7.42));
assert_eq!(response.loop_enabled, Some(loop_enabled));
@@ -770,7 +770,6 @@ pub struct EditorAudioGenerateResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_prompt: Option<String>,
pub model: String,
pub provider: String,
pub task_id: String,
pub price_mud_points: u32,
pub audio_kind: String,
@@ -1684,7 +1683,6 @@ mod tests {
prompt: "金币掉落叮当声".to_string(),
actual_prompt: Some("金币掉落叮当声".to_string()),
model: EDITOR_SOUND_EFFECT_MODEL.to_string(),
provider: "elevenlabs".to_string(),
task_id: "sound-task-1".to_string(),
price_mud_points: 10,
audio_kind: "sound-effect".to_string(),
@@ -1717,11 +1715,17 @@ mod tests {
assert_eq!(response_payload["audioKind"], json!("sound-effect"));
assert_eq!(response_payload["durationSeconds"], json!(7.42));
assert_eq!(response_payload["loop"], json!(true));
assert_eq!(response_payload["provider"], json!("elevenlabs"));
assert!(response_payload.get("provider").is_none());
assert_eq!(
response_payload["asset"]["assetKind"],
json!("sound-effect")
);
let mut legacy_payload = response_payload;
legacy_payload["provider"] = json!("elevenlabs");
let parsed: EditorAudioGenerateResponse = serde_json::from_value(legacy_payload)
.expect("legacy audio results with provider should remain readable");
assert_eq!(parsed.audio_kind, "sound-effect");
}
#[test]
@@ -462,7 +462,6 @@ describe('ImageCanvasGenerationLayerModel', () => {
prompt: '金币掉落叮当声',
actualPrompt: '金币掉落叮当声',
model: 'audio1.0',
provider: 'VectorEngine',
taskId: 'sound-task-1',
priceMudPoints: 10,
audioKind: 'sound-effect',
@@ -148,7 +148,6 @@ function createAudioGenerated(overrides = {}) {
prompt: '金币掉落叮当声',
actualPrompt: '金币掉落叮当声',
model: 'audio1.0',
provider: 'VectorEngine',
taskId: 'task-audio-generated',
priceMudPoints: 5,
audioKind: 'sound-effect' as const,
@@ -1657,7 +1657,6 @@ describe('useImageCanvasGenerationWorkflow', () => {
prompt: '金币跳出音',
actualPrompt: 'A bright coin pop sound',
model: 'eleven_text_to_sound_v2',
provider: 'elevenlabs',
taskId: 'task-sound',
audioKind: 'sound-effect',
durationSeconds: 7.42,
@@ -1813,7 +1813,6 @@ describe('editorProjectClient', () => {
prompt: '金币掉落叮当声',
actualPrompt: 'A bright coin pickup chime',
model: 'eleven_text_to_sound_v2',
provider: 'elevenlabs',
taskId: 'sound-task-1',
audioKind: 'sound-effect',
asset: {
@@ -1867,7 +1866,6 @@ describe('editorProjectClient', () => {
prompt: '按钮确认短促音',
actualPrompt: 'A short confirmation click',
model: 'eleven_text_to_sound_v2',
provider: 'elevenlabs',
taskId: 'sound-task-null',
audioKind: 'sound-effect',
});
@@ -1969,7 +1967,6 @@ describe('editorProjectClient', () => {
prompt: canonicalPrompt,
actualPrompt: canonicalPrompt,
model: 'chirp-v4',
provider: 'VectorEngine',
taskId: 'music-task-1',
audioKind: 'background-music',
});
@@ -596,7 +596,6 @@ export type EditorAudioGenerationResult = {
prompt: string;
actualPrompt?: string | null;
model: string;
provider: string;
taskId: string;
priceMudPoints: number;
audioKind: 'sound-effect' | 'background-music';