修复 inline 泄露 provider问题
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled

This commit is contained in:
2026-08-08 09:52:18 +00:00
parent a9575f0f04
commit d58e5cbebd
3 changed files with 197 additions and 26 deletions
@@ -20,8 +20,8 @@ use spacetime_client::{
};
use crate::editor_generation_queue::{
EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -218,6 +218,9 @@ fn user_visible_external_generation_error(job_kind: &str, error: Option<String>)
if job_kind == EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND && error.is_some() {
return Some("音效生成失败,请稍后重试。".to_string());
}
if job_kind == EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND && error.is_some() {
return Some("背景音乐生成失败,请稍后重试。".to_string());
}
error
}
@@ -519,6 +522,30 @@ mod tests {
}
}
#[test]
fn background_music_failure_hides_provider_and_transport_details_from_owner() {
let message = user_visible_external_generation_error(
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND,
Some(
"提交 Vector Engine 背景音乐任务失败:error sending request for url \
(https://vector-engine.internal/v1/audio/generations): connection error: \
Connection refused (os error 111)"
.to_string(),
),
);
assert_eq!(message.as_deref(), Some("背景音乐生成失败,请稍后重试。"));
let lower = message
.expect("失败任务应返回稳定文案")
.to_ascii_lowercase();
for forbidden in ["vector engine", "vector-engine", "https://", "os error"] {
assert!(
!lower.contains(forbidden),
"普通用户文案不应包含 {forbidden}"
);
}
}
#[test]
fn unrelated_generation_failure_keeps_its_user_visible_message() {
let message = user_visible_external_generation_error(
@@ -314,6 +314,14 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
) -> Result<Json<Value>, Response> {
let Json(mut payload) = parse_json_payload(&request_context, payload)?;
let owner_user_id = caller.owner_user_id.clone();
let error_response = |error| {
editor_audio_generation_error_response(
&request_context,
&caller,
"音效生成失败,请稍后重试。",
error,
)
};
let target = preflight_editor_billable_generation_target(
&state,
owner_user_id.as_str(),
@@ -321,7 +329,7 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
payload.asset_folder_id.clone(),
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
let project_id = payload.project_id.clone();
@@ -337,16 +345,15 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
"message": error.to_string(),
}))
})
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
let normalized = normalize_editor_sound_effect_request_with_pricing(payload.clone(), &pricing)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
let request_deadline = request_context.external_call_deadline();
let mut settings = require_elevenlabs_audio_settings(&state)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let mut settings = require_elevenlabs_audio_settings(&state).map_err(&error_response)?;
settings.request_deadline = request_deadline;
let http_client = platform_audio::build_elevenlabs_audio_http_client(&settings)
.map_err(map_platform_audio_error)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
let llm_client = state
.editor_agent_llm_client()
.ok_or_else(|| {
@@ -356,18 +363,17 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
"message": "音效 Prompt 英文化服务未配置",
}))
})
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
let target = build_editor_audio_target(
require_editor_audio_operation(&caller)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?
.map_err(&error_response)?
.operation_id
.as_str(),
"sound_effect",
"editor_sound_effect",
Some(u64::from(normalized.price_mud_points)),
);
let operation = require_editor_audio_operation(&caller)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let operation = require_editor_audio_operation(&caller).map_err(&error_response)?;
let stages = ProductionSoundEffectWorkerStages {
state: &state,
caller: &caller,
@@ -406,7 +412,7 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
},
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
Ok(json_success_body(Some(&request_context), response))
}
@@ -981,6 +987,14 @@ pub(crate) async fn generate_editor_background_music_for_owner(
) -> Result<Json<Value>, Response> {
let Json(mut payload) = parse_json_payload(&request_context, payload)?;
let owner_user_id = caller.owner_user_id.clone();
let error_response = |error| {
editor_audio_generation_error_response(
&request_context,
&caller,
"背景音乐生成失败,请稍后重试。",
error,
)
};
let target = preflight_editor_billable_generation_target(
&state,
owner_user_id.as_str(),
@@ -988,7 +1002,7 @@ pub(crate) async fn generate_editor_background_music_for_owner(
payload.asset_folder_id.clone(),
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
let project_id = payload.project_id.clone();
@@ -1005,14 +1019,13 @@ pub(crate) async fn generate_editor_background_music_for_owner(
"message": error.to_string(),
}))
})
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
let normalized = normalize_editor_background_music_request_with_pricing(payload, &pricing)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let settings = require_vector_engine_audio_settings(&state)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
let settings = require_vector_engine_audio_settings(&state).map_err(&error_response)?;
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
.map_err(map_platform_audio_error)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
let task = platform_audio::submit_editor_background_music_task(
&http_client,
&settings,
@@ -1024,10 +1037,10 @@ pub(crate) async fn generate_editor_background_music_for_owner(
)
.await
.map_err(map_platform_audio_error)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
let target = build_editor_audio_target(
require_editor_audio_operation(&caller)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?
.map_err(&error_response)?
.operation_id
.as_str(),
"background_music",
@@ -1045,7 +1058,7 @@ pub(crate) async fn generate_editor_background_music_for_owner(
target,
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
let audio_src = generated.audio_src.clone();
let persisted = persist_editor_audio_generation(
&state,
@@ -1066,7 +1079,7 @@ pub(crate) async fn generate_editor_background_music_for_owner(
},
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.map_err(&error_response)?;
Ok(json_success_body(
Some(&request_context),
@@ -1158,6 +1171,29 @@ fn build_inline_editor_audio_caller<T: serde::Serialize>(
})
}
/// 编辑器音频的 inline 兼容模式会把生成结果直接回给普通用户;provider / 传输诊断只能
/// 留在 worker 路径和服务端审计中,不能随这个同步 HTTP 响应泄露出去。
///
/// 队列 worker 带有 `worker_id`,仍保留原始 `AppError` 响应以便现有 worker 抽取失败文本、
/// 写入任务记录并由任务读取边界统一脱敏。4xx 也保持原状,避免丢失用户可操作的校验提示。
fn editor_audio_generation_error_response(
request_context: &RequestContext,
caller: &EditorGenerationCaller,
user_message: &'static str,
error: AppError,
) -> Response {
let is_inline_user_request = caller
.operation
.as_ref()
.is_some_and(|operation| operation.worker_id.is_none());
if is_inline_user_request && error.status_code().is_server_error() {
return AppError::from_status(error.status_code())
.with_message(user_message)
.into_response_with_context(Some(request_context));
}
error.into_response_with_context(Some(request_context))
}
fn require_editor_audio_operation(
caller: &EditorGenerationCaller,
) -> Result<&EditorGenerationOperationContext, AppError> {
@@ -1581,6 +1617,7 @@ fn editor_audio_bad_request(message: impl Into<String>) -> AppError {
#[cfg(test)]
mod tests {
use axum::{body::to_bytes, http::StatusCode};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::assets;
@@ -1590,8 +1627,8 @@ mod tests {
CanonicalEditorBackgroundMusicSubmissionPayload, NormalizedEditorBackgroundMusicRequest,
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,
editor_audio_generation_error_response, normalize_editor_background_music_request,
normalize_editor_sound_effect_request, prepare_editor_background_music_queue_job,
};
use crate::{
editor_generation_queue::{
@@ -1599,6 +1636,7 @@ mod tests {
serialize_editor_generation_job_payload_for_test,
},
editor_project::{EditorGenerationCaller, EditorGenerationOperationContext},
http_error::AppError,
request_context::RequestContext,
};
@@ -1690,6 +1728,112 @@ mod tests {
assert!(operation.lease_token.is_none());
}
#[tokio::test]
async fn inline_audio_server_error_hides_provider_diagnostics_in_both_response_shapes() {
for wants_envelope in [false, true] {
let request_context = RequestContext::new(
format!("request-audio-error-{wants_envelope}"),
"POST /api/editor/audios/sound-effects/generations".to_string(),
std::time::Duration::ZERO,
wants_envelope,
);
let payload = sound_effect_payload("机械齿轮转动", None, None, false);
let caller = build_inline_editor_audio_caller(
"user-1".to_string(),
&request_context,
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
&payload,
)
.expect("inline caller should be prepared");
let response = editor_audio_generation_error_response(
&request_context,
&caller,
"音效生成失败,请稍后重试。",
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "elevenlabs",
"message": "请求 ElevenLabs 音效生成失败:connection refused",
"endpoint": "https://api.elevenlabs.test/v1/sound-generation",
"source": "tcp connect error",
"status": 502,
})),
);
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let bytes = to_bytes(response.into_body(), 64 * 1024)
.await
.expect("error response body should be readable");
let response_payload: Value =
serde_json::from_slice(&bytes).expect("error response should be JSON");
assert_eq!(
response_payload["error"]["message"],
json!("音效生成失败,请稍后重试。")
);
assert!(response_payload["error"].get("details").is_none());
let serialized = response_payload.to_string().to_ascii_lowercase();
for forbidden in [
"elevenlabs",
"api.elevenlabs.test",
"sound-generation",
"tcp connect error",
"\"endpoint\"",
"\"source\"",
] {
assert!(
!serialized.contains(forbidden),
"inline 用户响应不应包含 {forbidden}"
);
}
}
}
#[tokio::test]
async fn queued_audio_server_error_keeps_diagnostics_for_worker_processing() {
let request_context = RequestContext::new(
"request-audio-worker-error".to_string(),
"external-generation-worker".to_string(),
std::time::Duration::ZERO,
true,
);
let caller = EditorGenerationCaller {
owner_user_id: "user-1".to_string(),
audit_subject_user_id: Some("user-1".to_string()),
audit_project_id: None,
phase_reporter: None,
operation: Some(EditorGenerationOperationContext {
operation_kind: EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND.to_string(),
operation_id: "task-audio-worker-error".to_string(),
operation_fingerprint: "fingerprint-audio-worker-error".to_string(),
worker_id: Some("worker-audio-1".to_string()),
lease_token: Some("lease-audio-1".to_string()),
queue_result_context: None,
}),
};
let response = editor_audio_generation_error_response(
&request_context,
&caller,
"音效生成失败,请稍后重试。",
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "elevenlabs",
"endpoint": "https://api.elevenlabs.test/v1/sound-generation",
"source": "tcp connect error",
})),
);
let bytes = to_bytes(response.into_body(), 64 * 1024)
.await
.expect("worker error response body should be readable");
let response_payload: Value =
serde_json::from_slice(&bytes).expect("worker error response should be JSON");
assert_eq!(
response_payload["error"]["details"]["provider"],
json!("elevenlabs")
);
assert_eq!(
response_payload["error"]["details"]["endpoint"],
json!("https://api.elevenlabs.test/v1/sound-generation")
);
}
#[test]
fn audio_queue_result_keeps_required_editor_agent_response_fields() {
let source = include_str!("generation.rs");