修复音频生成响应泄露 Provider
收紧音频公开 DTO 与前端响应类型 统一清理 inline 和 Agent 队列结果中的 Provider 覆盖嵌套快照脱敏与历史 Agent 回填兼容
This commit is contained in:
@@ -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]
|
||||
|
||||
+3
-2
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user