收紧编辑器内部处理元数据边界
Project CI / Frontend tests (pull_request) Successful in 2m58s
Project CI / Native shell tests (pull_request) Successful in 12m45s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 9s

普通用户与公开响应统一移除生成 provider、内部处理模型和抠图审计字段。
普通生成请求移除 segModel,由后端固定抠图策略并清理客户端伪造的保留字段。
手动去背景继承可信源模型,角色动作与去背景失败统一使用稳定用户文案。
后台原始审计继续保留真实 provider 与抠图模型,并补齐 Owner、Public、Admin 和紧凑结果测试。
同步更新前端响应契约、外部 OpenAPI、后端架构文档和项目决策记录。
This commit is contained in:
2026-07-31 04:58:07 +00:00
parent 915eb4b739
commit 56fe1cb85d
23 changed files with 993 additions and 207 deletions
+41 -4
View File
@@ -4043,10 +4043,11 @@ mod tests {
use super::{
AdminDashboardGranularity, AdminDisplayNameDirectory, EditorShowcaseAssetRecord,
admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result,
admin_editor_asset_group_payload, admin_editor_showcase_asset_payload_from_record,
append_spacetime_sql_response_chunk, apply_admin_database_table_filters,
build_admin_asset_read_url_audit, build_admin_dashboard_chart,
build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request,
admin_editor_asset_group_payload, admin_editor_asset_payload_from_record,
admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk,
apply_admin_database_table_filters, build_admin_asset_read_url_audit,
build_admin_dashboard_chart, build_admin_database_table_row,
build_admin_editor_showcase_campaign_image_confirm_request,
build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview,
build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit,
clamp_admin_tracking_event_limit, enforce_admin_request_permission,
@@ -4154,6 +4155,42 @@ mod tests {
}
}
#[test]
fn admin_editor_asset_payload_keeps_internal_matting_audit_metadata() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let mut record = test_admin_editor_asset_record(
"asset-matting",
"task-matting",
"抠图结果",
"generated-character-drafts/editor/matting.png",
0,
);
record.model = Some("BgFilter complex".to_string());
record.provider = Some("BgFilter".to_string());
record.generation_inputs = Some(json!({
"fields": [],
"references": [],
"screenColorHex": "#CFEFFF",
"mattingProvider": "BgFilter",
"mattingModel": "birefnet"
}));
let payload = admin_editor_asset_payload_from_record(&state, record);
let value = serde_json::to_value(payload).expect("admin payload should serialize");
assert_eq!(value["model"], json!("BgFilter complex"));
assert_eq!(value["provider"], json!("BgFilter"));
assert_eq!(
value["generationInputs"]["screenColorHex"],
json!("#CFEFFF")
);
assert_eq!(
value["generationInputs"]["mattingProvider"],
json!("BgFilter")
);
assert_eq!(value["generationInputs"]["mattingModel"], json!("birefnet"));
}
#[test]
fn admin_asset_read_url_audit_keeps_actor_and_target_without_signed_url() {
let draft = build_admin_asset_read_url_audit(
@@ -1164,7 +1164,6 @@ pub(crate) async fn generate_editor_video_for_owner(
prompt: normalized.prompt,
actual_prompt: Some(generated.submitted_prompt),
model: normalized.model,
provider: "VectorEngine".to_string(),
task_id,
duration_seconds: normalized.duration_seconds,
resolution: normalized.resolution,
@@ -5827,7 +5826,22 @@ fn character_animation_error_response(
request_context: &RequestContext,
error: AppError,
) -> Response {
error.into_response_with_context(Some(request_context))
user_visible_character_animation_error(error).into_response_with_context(Some(request_context))
}
fn user_visible_character_animation_error(error: AppError) -> AppError {
if !error.status_code().is_server_error() {
return error;
}
tracing::error!(
status = %error.status_code(),
error = %error,
"editor_character_animation_internal_error_hidden_from_user"
);
AppError::from_status(error.status_code()).with_details(json!({
"message": "角色动作生成失败,请稍后重试。",
}))
}
fn editor_video_error_response(request_context: &RequestContext, error: AppError) -> Response {
@@ -5985,6 +5999,23 @@ mod tests {
use super::*;
use crate::AppConfig;
#[test]
fn character_animation_server_error_hides_internal_matting_details() {
let error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "BgFilter",
"message": "BgFilter birefnet provider 调用失败",
}));
let sanitized = user_visible_character_animation_error(error);
assert_eq!(sanitized.status_code(), StatusCode::BAD_GATEWAY);
assert_eq!(sanitized.body_text(), "角色动作生成失败,请稍后重试。");
let details = sanitized.details().expect("稳定错误应保留用户文案");
assert!(details.get("provider").is_none());
let lower = details.to_string().to_ascii_lowercase();
assert!(!lower.contains("bgfilter"));
assert!(!lower.contains("birefnet"));
}
#[test]
fn parse_video_data_url_accepts_mp4_payload() {
let parsed =
@@ -333,7 +333,6 @@ impl EditorAgentTool for GenerateImageTool {
style: None,
model: Some(args.model),
screen_color: None,
seg_model: None,
aspect_ratio: Some(args.aspect_ratio),
image_size: Some(args.image_size),
reference_image_srcs: Some(reference_image_srcs),
@@ -441,7 +440,6 @@ impl EditorAgentTool for GenerateCharacterTool {
style: None,
model: Some(args.model),
screen_color: Some("auto".to_string()),
seg_model: Some("birefnet".to_string()),
aspect_ratio: Some(args.aspect_ratio),
image_size: Some(args.image_size),
reference_image_srcs: Some(reference_image_srcs),
@@ -551,7 +549,6 @@ impl EditorAgentTool for GenerateUiDesignTool {
style: None,
model: Some(args.model),
screen_color: None,
seg_model: None,
aspect_ratio: Some(args.aspect_ratio),
image_size: Some(args.image_size),
reference_image_srcs: Some(reference_image_srcs),
@@ -805,7 +802,6 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
style: None,
model: Some(args.model),
screen_color: Some("auto".to_string()),
seg_model: Some("birefnet".to_string()),
aspect_ratio: Some(args.aspect_ratio),
image_size: Some(args.image_size),
project_id: Some(context.conversation.project_id.clone()),
File diff suppressed because it is too large Load Diff
@@ -32,8 +32,8 @@ use crate::{
editor_project_resource_payload_from_record, extract_editor_ui_design_assets_for_owner,
generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner,
map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string,
parse_editor_generation_json_payload, save_editor_project_layout_with_revision_and_get,
serialize_editor_asset_metadata,
parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
},
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
@@ -467,7 +467,7 @@ pub async fn create_external_editor_asset(
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let generation_inputs_json =
serialize_editor_asset_metadata(payload.generation_inputs.clone())?;
serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?;
let object_key = normalize_optional_string(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let asset = state
@@ -569,7 +569,7 @@ pub async fn create_external_editor_project_resource(
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let generation_inputs_json =
serialize_editor_asset_metadata(payload.generation_inputs.clone())?;
serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?;
let object_key = normalize_optional_string(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let resource = state
@@ -787,6 +787,12 @@ fn normalize_project_title(title: Option<String>) -> String {
.unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string())
}
fn serialize_external_editor_generation_inputs(
generation_inputs: Option<Value>,
) -> Result<Option<String>, AppError> {
serialize_editor_asset_metadata(sanitize_editor_client_generation_inputs(generation_inputs))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -809,6 +815,26 @@ mod tests {
assert_eq!(request.expected_revision, 7);
}
#[test]
fn external_editor_generation_inputs_cannot_forge_internal_audit_fields() {
let serialized = serialize_external_editor_generation_inputs(Some(json!({
"fields": [{"label": "角色设定", "value": "骑士"}],
"screenColorHex": "#00FF00",
"mattingProvider": "forged-provider",
"mattingModel": "forged-model",
"characterAnimation": {"durationSeconds": 4}
})))
.expect("外部编辑器生成输入应可序列化")
.expect("非空生成输入应保留");
let parsed: Value = serde_json::from_str(&serialized).expect("生成输入应为合法 JSON");
assert_eq!(parsed["fields"][0]["value"], "骑士");
assert_eq!(parsed["characterAnimation"]["durationSeconds"], 4);
assert!(parsed.get("screenColorHex").is_none());
assert!(parsed.get("mattingProvider").is_none());
assert!(parsed.get("mattingModel").is_none());
}
#[test]
fn exported_openapi_json_contains_external_editor_routes_and_security() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
@@ -1025,5 +1051,25 @@ mod tests {
parsed["components"]["securitySchemes"]["ExternalApiKey"]["scheme"],
"bearer"
);
for schema_name in [
"EditorProjectResource",
"EditorAsset",
"EditorImageGenerationResponse",
"EditorIconSpritesheetGenerationResponse",
"EditorVideoGenerationResponse",
"EditorAudioGenerationResponse",
] {
let schema = &parsed["components"]["schemas"][schema_name];
assert!(
schema["properties"].get("provider").is_none(),
"{schema_name} 不应向普通用户公开 provider"
);
assert!(
schema["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("provider"))),
"{schema_name} 不应要求 provider"
);
}
}
}
@@ -19,6 +19,9 @@ use spacetime_client::{
ExternalGenerationJobSummaryRecord, SpacetimeClientError,
};
use crate::editor_generation_queue::{
EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
@@ -180,7 +183,10 @@ fn map_external_generation_job_status(
ExternalGenerationJobStatus::Failed,
"生成失败。",
0,
job.last_error_message.clone(),
user_visible_external_generation_error(
job.job_kind.as_str(),
job.last_error_message.clone(),
),
),
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8, None),
};
@@ -196,6 +202,21 @@ fn map_external_generation_job_status(
}
}
/// 外部任务表保存的失败文本同时服务于 worker 诊断和用户通知。抠图及角色动作透明化任务的
/// 原始失败文本可能包含内部 provider、分割模型或实现细节,因此只能在普通用户读取边界
/// 替换成稳定文案。
/// 原始值仍留在任务记录、tracing 和后台审计路径中。
fn user_visible_external_generation_error(job_kind: &str, error: Option<String>) -> Option<String> {
if job_kind == EDITOR_BACKGROUND_REMOVAL_JOB_KIND && error.is_some() {
return Some("去除背景失败,请稍后重试。".to_string());
}
if job_kind == EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND && error.is_some() {
return Some("角色动作生成失败,请稍后重试。".to_string());
}
error
}
fn map_external_generation_job_status_detail(
job: ExternalGenerationJobSummaryRecord,
) -> ExternalGenerationJobStatusDetailRecord {
@@ -392,4 +413,70 @@ mod tests {
let generating = map_external_generation_job_status(job);
assert_eq!(generating.phase_detail, "正在生成。");
}
#[test]
fn background_removal_failure_hides_internal_processing_details_from_owner() {
let job = ExternalGenerationJobSummaryRecord {
job_id: "task-matting-failed".to_string(),
job_kind: EDITOR_BACKGROUND_REMOVAL_JOB_KIND.to_string(),
owner_user_id: "user-1".to_string(),
source_module: "editor-canvas".to_string(),
source_entity_id: "project-1".to_string(),
request_label: "去除背景".to_string(),
request_prompt: None,
status: "failed".to_string(),
last_error_message: Some(
"BgFilter birefnet provider 返回失败,model=segment-common-image".to_string(),
),
created_at: "2026-07-31T08:00:00Z".to_string(),
started_at: Some("2026-07-31T08:00:01Z".to_string()),
completed_at: Some("2026-07-31T08:00:10Z".to_string()),
updated_at: "2026-07-31T08:00:10Z".to_string(),
updated_at_micros: 1_000,
price_mud_points: 0,
refund_ledger_id: None,
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: None,
warning_message: None,
};
let status = map_external_generation_job_status(job.clone());
let task = map_external_generation_task_record(job);
assert_eq!(status.error.as_deref(), Some("去除背景失败,请稍后重试。"));
assert_eq!(task.error.as_deref(), Some("去除背景失败,请稍后重试。"));
for value in [status.error, task.error].into_iter().flatten() {
let lower = value.to_ascii_lowercase();
assert!(!lower.contains("bgfilter"));
assert!(!lower.contains("birefnet"));
assert!(!lower.contains("segment-common-image"));
}
}
#[test]
fn character_animation_failure_hides_internal_matting_details_from_owner() {
let message = user_visible_external_generation_error(
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
Some("解析 BgFilter 输入动作帧失败,seg_model=birefnet,provider=BgFilter".to_string()),
);
assert_eq!(message.as_deref(), Some("角色动作生成失败,请稍后重试。"));
let lower = message
.expect("失败任务应返回稳定文案")
.to_ascii_lowercase();
assert!(!lower.contains("bgfilter"));
assert!(!lower.contains("birefnet"));
assert!(!lower.contains("provider"));
}
#[test]
fn unrelated_generation_failure_keeps_its_user_visible_message() {
let message = user_visible_external_generation_error(
"editor_image_generation",
Some("生成服务暂时不可用。".to_string()),
);
assert_eq!(message.as_deref(), Some("生成服务暂时不可用。"));
}
}
@@ -1237,6 +1237,24 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
let Some(object) = result.as_object_mut() else {
return result;
};
// 紧凑结果会回传给普通用户的 Agent 工具调用卡片,不能把供应商或内部后处理实现
// 当作可见生成信息下发。正常的用户可见模型仍然保留,以便卡片恢复原有展示。
object.remove("provider");
if object
.get("model")
.and_then(Value::as_str)
.is_some_and(is_editor_internal_processing_model)
{
object.remove("model");
}
if let Some(generation_inputs) = object
.get_mut("generationInputs")
.and_then(Value::as_object_mut)
{
for field in ["screenColorHex", "mattingProvider", "mattingModel"] {
generation_inputs.remove(field);
}
}
object.remove("project");
object.remove("asset");
object.remove("spritesheetAsset");
@@ -1271,6 +1289,18 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
result
}
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,
@@ -1757,6 +1787,13 @@ mod tests {
"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",
@@ -1794,9 +1831,45 @@ mod tests {
.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_spritesheet_result_keeps_all_persisted_slices() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
@@ -338,7 +338,6 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
prompt: normalized.prompt.clone(),
actual_prompt: Some(normalized.prompt),
model: normalized.model,
provider: generated.provider,
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
audio_kind: "sound-effect".to_string(),
@@ -562,7 +561,6 @@ pub(crate) async fn generate_editor_background_music_for_owner(
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(),
@@ -424,7 +424,6 @@ pub struct EditorVideoGenerateResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_prompt: Option<String>,
pub model: String,
pub provider: String,
pub task_id: String,
pub duration_seconds: u32,
pub resolution: String,
@@ -503,7 +502,6 @@ pub struct EditorIconSpritesheetGenerateResponse {
#[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,
}
@@ -560,7 +558,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,
@@ -1260,7 +1257,6 @@ mod tests {
prompt: "让角色向镜头挥手".to_string(),
actual_prompt: Some("让角色向镜头挥手".to_string()),
model: "kling3.0-omni".to_string(),
provider: "VectorEngine".to_string(),
task_id: "task-1".to_string(),
duration_seconds: 5,
resolution: "480p".to_string(),
@@ -1287,6 +1283,7 @@ mod tests {
assert_eq!(payload["assetObjectId"], json!("assetobj_video_1"));
assert_eq!(payload["sourceType"], json!("generated"));
assert_eq!(payload["durationSeconds"], json!(5));
assert!(payload.get("provider").is_none());
}
#[test]
@@ -1323,7 +1320,6 @@ mod tests {
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
model: "gemini-3.1-flash-image-preview".to_string(),
provider: "VectorEngine".to_string(),
task_id: "task-icons".to_string(),
price_mud_points: 12,
})
@@ -1331,6 +1327,7 @@ mod tests {
assert_eq!(response_payload["priceMudPoints"], json!(12));
assert_eq!(response_payload["iconImageSrcs"], json!([]));
assert!(response_payload.get("provider").is_none());
assert_eq!(
response_payload["sliceWarning"],
json!({
@@ -1410,7 +1407,6 @@ mod tests {
prompt: "金币掉落叮当声".to_string(),
actual_prompt: Some("金币掉落叮当声".to_string()),
model: "audio1.0".to_string(),
provider: "VectorEngine".to_string(),
task_id: "sound-task-1".to_string(),
price_mud_points: 10,
audio_kind: "sound-effect".to_string(),
@@ -1439,6 +1435,7 @@ mod tests {
);
assert_eq!(response_payload["assetObjectId"], json!("assetobj_audio_1"));
assert_eq!(response_payload["audioKind"], json!("sound-effect"));
assert!(response_payload.get("provider").is_none());
assert_eq!(
response_payload["asset"]["assetKind"],
json!("sound-effect")