合并主分支

解决合并冲突
This commit is contained in:
2026-08-08 10:47:31 +08:00
140 changed files with 11920 additions and 2046 deletions
+200
View File
@@ -1912,6 +1912,206 @@ mod tests {
);
}
#[tokio::test]
async fn editor_image_generation_rejects_scene_contract_bypasses() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138232", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_bypass");
let app = build_router(state);
let requests = [
(
"scene kind",
serde_json::json!({
"prompt": "绕过后端场景 Prompt 组装",
"kind": "scene",
}),
),
(
"scene asset kind",
serde_json::json!({
"prompt": "把普通图片伪装成正式场景产物",
"assetKind": "scene",
}),
),
];
for (case_name, request_body) in requests {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{case_name} must not bypass the dedicated scene contract"
);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("/api/editor/scenes/generations"),
"{case_name} should point callers to the scene endpoint: {body_text}"
);
}
}
#[tokio::test]
async fn editor_scene_generation_requires_bearer_auth() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let request_body = serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "anime",
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn editor_scene_generation_rejects_invalid_required_inputs() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138230", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_validation");
let app = build_router(state);
let requests = [
(
"empty sceneContent",
serde_json::json!({
"sceneContent": " ",
"stylePreset": "anime",
}),
"sceneContent",
),
(
"unknown stylePreset",
serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "unknown",
}),
"stylePreset",
),
(
"missing customStyle",
serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "custom",
}),
"customStyle",
),
];
for (case_name, request_body, expected_message) in requests {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{case_name} should return 400"
);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains(expected_message),
"{case_name} should report {expected_message}: {body_text}"
);
}
}
#[tokio::test]
async fn editor_scene_generation_rejects_inline_data_url_before_queueing() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_reference");
let app = build_router(state);
let request_body = serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "anime",
"referenceImageSrcs": ["data:image/png;base64,AAAA"],
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("先上传 OSS"),
"handler should reject inline editor scene references: {body_text}"
);
}
#[tokio::test]
async fn editor_pixel_art_style_wrong_types_return_bad_request() {
let state = AppState::new(AppConfig {
@@ -83,7 +83,7 @@ use crate::{
EditorGenerationOperationContext, PreparedEditorGenerationResultItem,
build_editor_canvas_generated_layer_item, editor_asset_payload_from_record,
editor_project_payload_from_record, editor_project_resource_payload_from_record,
persist_editor_generation_result_atomically,
persist_editor_generation_result_atomically, preflight_editor_billable_generation_target,
remove_editor_generated_screen_background_with_bgfilter,
resolve_editor_reference_object_key_for_owner, sanitize_editor_client_generation_inputs,
serialize_editor_generation_inputs, serialize_editor_image_sequence_frames,
@@ -643,7 +643,7 @@ pub(crate) async fn enqueue_editor_character_animation_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
payload: EditorCharacterAnimationGenerateRequest,
mut payload: EditorCharacterAnimationGenerateRequest,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, Response> {
if matches_inline_media_source(payload.source_image_src.as_str()) {
@@ -654,6 +654,19 @@ pub(crate) async fn enqueue_editor_character_animation_for_owner(
),
));
}
let target = preflight_editor_billable_generation_target(
state,
owner_user_id,
payload.project_id.clone(),
payload
.asset_folder_id
.clone()
.or_else(|| Some("project".to_string())),
)
.await
.map_err(|error| character_animation_error_response(request_context, error))?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
let pricing = state.editor_generation_pricing().await.map_err(|error| {
character_animation_error_response(
request_context,
@@ -695,7 +708,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
caller: EditorGenerationCaller,
payload: Result<Json<EditorCharacterAnimationGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
let Json(mut payload) = payload.map_err(|error| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -705,15 +718,9 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
)
})?;
let owner_user_id = caller.owner_user_id.clone();
let project_id = payload.project_id.clone();
let canvas_completion = payload.canvas_completion.clone();
let generation_inputs = payload.generation_inputs.clone();
let source_resource_id = payload.source_resource_id.clone();
let asset_folder_id = payload.asset_folder_id.clone();
let asset_label =
resolve_editor_character_animation_asset_label(payload.asset_label.as_deref());
if canvas_completion.is_some()
&& project_id
if payload.canvas_completion.is_some()
&& payload
.project_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
@@ -732,6 +739,26 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
),
));
}
let target = preflight_editor_billable_generation_target(
&state,
owner_user_id.as_str(),
payload.project_id.clone(),
payload
.asset_folder_id
.clone()
.or_else(|| Some("project".to_string())),
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
let project_id = payload.project_id.clone();
let canvas_completion = payload.canvas_completion.clone();
let generation_inputs = payload.generation_inputs.clone();
let source_resource_id = payload.source_resource_id.clone();
let asset_folder_id = payload.asset_folder_id.clone();
let asset_label =
resolve_editor_character_animation_asset_label(payload.asset_label.as_deref());
let pricing = state.editor_generation_pricing().await.map_err(|error| {
character_animation_error_response(
@@ -1079,9 +1106,21 @@ pub(crate) async fn enqueue_editor_video_generation_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
payload: EditorVideoGenerateRequest,
mut payload: EditorVideoGenerateRequest,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, Response> {
ensure_editor_video_reference_sources_are_stable(&payload)
.map_err(|error| editor_video_error_response(request_context, error))?;
let target = preflight_editor_billable_generation_target(
state,
owner_user_id,
payload.project_id.clone(),
payload.asset_folder_id.clone(),
)
.await
.map_err(|error| editor_video_error_response(request_context, error))?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
let pricing = state.editor_generation_pricing().await.map_err(|error| {
editor_video_error_response(
request_context,
@@ -1116,7 +1155,7 @@ pub(crate) async fn generate_editor_video_for_owner(
caller: EditorGenerationCaller,
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
let Json(mut payload) = payload.map_err(|error| {
editor_video_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -1126,6 +1165,18 @@ pub(crate) async fn generate_editor_video_for_owner(
)
})?;
let owner_user_id = caller.owner_user_id.clone();
ensure_editor_video_reference_sources_are_stable(&payload)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let target = preflight_editor_billable_generation_target(
&state,
owner_user_id.as_str(),
payload.project_id.clone(),
payload.asset_folder_id.clone(),
)
.await
.map_err(|error| editor_video_error_response(&request_context, error))?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
let project_id = payload.project_id.clone();
let canvas_completion = payload.canvas_completion.clone();
let generation_inputs = payload.generation_inputs.clone();
@@ -4434,6 +4485,26 @@ fn matches_inline_media_source(value: &str) -> bool {
value.starts_with("data:") || value.starts_with("blob:")
}
fn ensure_editor_video_reference_sources_are_stable(
payload: &EditorVideoGenerateRequest,
) -> Result<(), AppError> {
for (label, sources) in [
("参考图片", payload.reference_image_srcs.as_slice()),
("参考视频", payload.reference_video_srcs.as_slice()),
("参考音频", payload.reference_audio_srcs.as_slice()),
] {
if sources
.iter()
.any(|source| matches_inline_media_source(source.as_str()))
{
return Err(editor_video_bad_request(format!(
"{label}必须先上传 OSS,并使用稳定媒体引用。"
)));
}
}
Ok(())
}
fn validate_editor_video_reference_src(
value: &str,
label: &str,
@@ -7101,6 +7172,72 @@ mod tests {
);
}
#[test]
fn editor_character_animation_and_video_targets_preflight_before_charge_or_queue() {
let source = include_str!("character_animation_assets.rs");
for (start, end, local_validation, terminal) in [
(
"pub(crate) async fn enqueue_editor_character_animation_for_owner",
"pub(crate) async fn generate_editor_character_animation_for_owner",
"matches_inline_media_source",
"enqueue_editor_generation_job_for_caller",
),
(
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
"matches_inline_media_source",
"execute_billable_asset_operation_with_cost",
),
(
"pub(crate) async fn enqueue_editor_video_generation_for_owner",
"pub(crate) async fn generate_editor_video_for_owner",
"ensure_editor_video_reference_sources_are_stable",
"enqueue_editor_generation_job_for_caller",
),
(
"pub(crate) async fn generate_editor_video_for_owner",
"pub async fn get_character_animation_job",
"ensure_editor_video_reference_sources_are_stable",
"execute_billable_asset_operation_with_cost",
),
] {
assert_function_contains_in_order(
source,
start,
end,
&[
local_validation,
"preflight_editor_billable_generation_target",
"payload.project_id = target.project_id",
"payload.asset_folder_id = target.asset_folder_id",
".editor_generation_pricing()",
terminal,
],
);
}
for (start, end) in [
(
"pub(crate) async fn enqueue_editor_character_animation_for_owner",
"pub(crate) async fn generate_editor_character_animation_for_owner",
),
(
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
),
] {
assert_function_contains_in_order(
source,
start,
end,
&[
"preflight_editor_billable_generation_target",
".or_else(|| Some(\"project\".to_string()))",
"payload.asset_folder_id = target.asset_folder_id",
],
);
}
}
#[test]
fn editor_character_animation_rejects_inline_media_before_queueing() {
let source = include_str!("character_animation_assets.rs");
+37 -2
View File
@@ -1,4 +1,4 @@
use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration};
use std::{env, fmt, fs, net::SocketAddr, path::PathBuf, time::Duration};
use platform_llm::{
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
@@ -27,7 +27,7 @@ const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com
const DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000;
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
@@ -212,6 +212,41 @@ pub struct AppConfig {
pub slow_request_threshold_ms: u64,
}
impl fmt::Debug for AppConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// 配置由环境变量和私密文件聚合而来。这里只输出排障所需的封闭运行摘要,
// 不递归格式化任一字符串配置,避免 URL 凭据、Token、私钥或未来新增字段进入日志。
f.debug_struct("AppConfig")
.field("bind_port", &self.bind_port)
.field("listen_backlog", &self.listen_backlog)
.field("worker_threads", &self.worker_threads)
.field("process_role", &self.process_role)
.field("external_generation_mode", &self.external_generation_mode)
.field(
"external_generation_worker_concurrency",
&self.external_generation_worker_concurrency,
)
.field("max_concurrent_requests", &self.max_concurrent_requests)
.field(
"admin_max_concurrent_requests",
&self.admin_max_concurrent_requests,
)
.field("spacetime_pool_size", &self.spacetime_pool_size)
.field("sms_auth_enabled", &self.sms_auth_enabled)
.field("wechat_auth_enabled", &self.wechat_auth_enabled)
.field("wechat_pay_enabled", &self.wechat_pay_enabled)
.field("aliyun_matting_enabled", &self.aliyun_matting_enabled)
.field("tracking_outbox_enabled", &self.tracking_outbox_enabled)
.field(
"wallet_refund_outbox_enabled",
&self.wallet_refund_outbox_enabled,
)
.field("otel_enabled", &self.otel_enabled)
.field("credentials", &"<redacted>")
.finish_non_exhaustive()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProcessRole {
Api,
@@ -137,7 +137,7 @@ impl EditorGenerationPricingConfig {
}
match kind.map(str::trim) {
Some("spec") => self.spec_model_mud_points(model),
Some("character" | "icon" | "ui-design" | "publication-material") => {
Some("character" | "icon" | "ui-design" | "publication-material" | "scene") => {
self.image_model_mud_points(model, image_size)
}
_ => self.image_model_mud_points(model, image_size),
@@ -748,6 +748,14 @@ mod tests {
),
12
);
assert_eq!(
editor_image_generation_mud_points(
Some("scene"),
Some("gemini-3.1-flash-image-preview"),
Some("1K")
),
12
);
assert_eq!(
editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("1K")),
3
@@ -321,6 +321,15 @@ async fn enqueue_serialized_editor_generation_job_with_identity(
job_id: String,
dedupe_key: String,
) -> Result<ExternalGenerationJobRecord, AppError> {
#[cfg(test)]
if state.record_test_editor_generation_enqueue_attempt() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
"message": "测试已在 SpacetimeDB 写入前截获编辑器生成入队",
})),
);
}
let now_micros = current_utc_micros();
state
.spacetime_client()
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,15 @@ pub struct ExternalApiPrincipal {
}
impl ExternalApiPrincipal {
#[cfg(test)]
pub(crate) fn for_test(owner_user_id: &str, scopes: &[&str]) -> Self {
Self {
owner_user_id: owner_user_id.to_string(),
key_id: "external-api-key-test".to_string(),
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
}
}
pub fn owner_user_id(&self) -> &str {
self.owner_user_id.as_str()
}
@@ -39,7 +39,8 @@ use crate::{
editor_project_resource_payload_from_record,
enqueue_editor_icon_spritesheet_generation_for_owner, enqueue_editor_image_edit_for_owner,
enqueue_editor_image_generation_for_owner,
enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error,
enqueue_editor_ui_design_asset_extraction_for_owner,
ensure_generic_editor_image_generation_contract, map_editor_project_error,
normalize_editor_persisted_media_src, normalize_optional_string,
parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_generation_inputs,
@@ -737,6 +738,7 @@ pub async fn generate_external_editor_image(
let Json(payload) = parse_editor_generation_json_payload(payload)?;
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)?;
ensure_generic_editor_image_generation_contract(&payload)?;
let project_id = payload.project_id.clone();
let job = enqueue_editor_image_generation_for_owner(
&state,
@@ -1117,9 +1119,11 @@ fn serialize_external_editor_image_sequence_frames(
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, body::Body, routing::post};
use spacetime_client::{
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord,
};
use tower::ServiceExt;
fn external_editor_project_resource_fixture(
resource_id: &str,
@@ -1622,6 +1626,80 @@ mod tests {
);
}
async fn assert_external_generic_image_scene_bypass_is_rejected_before_queueing(
case_name: &str,
idempotency_key: &str,
request_body: Value,
) {
let state = AppState::new(crate::config::AppConfig::default())
.expect("external image test state should build");
state.fail_test_editor_generation_enqueue();
let app = Router::new()
.route(
"/api/external/v1/editor/images/generations",
post(generate_external_editor_image),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test(
"user-external-scene-bypass",
&[SCOPE_EDITOR_IMAGE_GENERATE],
)))
.with_state(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/external/v1/editor/images/generations")
.header("content-type", "application/json")
.header(IDEMPOTENCY_KEY_HEADER, idempotency_key)
.body(Body::from(request_body.to_string()))
.expect("external scene bypass request should build"),
)
.await
.expect("external scene bypass response should return");
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("external scene bypass response body should collect");
let body_text = String::from_utf8_lossy(&body);
assert_eq!(
(status, state.test_editor_generation_enqueue_attempts()),
(StatusCode::BAD_REQUEST, 0),
"{case_name} must fail at the generic External boundary before queueing: {body_text}",
);
assert!(
body_text.contains("/api/editor/scenes/generations"),
"{case_name} should direct callers to the dedicated scene contract: {body_text}",
);
}
#[tokio::test]
async fn external_generic_image_generation_rejects_scene_kind_before_queueing() {
assert_external_generic_image_scene_bypass_is_rejected_before_queueing(
"scene kind",
"scene-bypass-kind",
json!({
"prompt": "绕过后端场景 Prompt 组装",
"kind": "scene",
}),
)
.await;
}
#[tokio::test]
async fn external_generic_image_generation_rejects_scene_asset_kind_before_queueing() {
assert_external_generic_image_scene_bypass_is_rejected_before_queueing(
"scene asset kind",
"scene-bypass-asset-kind",
json!({
"prompt": "把普通图片伪装成正式场景产物",
"assetKind": "scene",
}),
)
.await;
}
#[test]
fn generation_lookup_hides_cross_owner_jobs_as_not_found() {
let not_found = map_external_generation_lookup_error(SpacetimeClientError::Procedure(
@@ -1847,6 +1925,29 @@ mod tests {
.get("default")
.is_none()
);
let image_kind_schema =
&parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["kind"];
assert!(
image_kind_schema["enum"]
.as_array()
.is_some_and(|values| !values.contains(&json!("scene")))
);
assert!(
image_kind_schema["description"]
.as_str()
.is_some_and(|description| description.contains("不开放结构化游戏场景"))
);
let image_asset_kind_schema = &parsed["components"]["schemas"]["EditorImageGenerationRequest"]
["properties"]["assetKind"];
assert_eq!(
image_asset_kind_schema["anyOf"][0]["not"]["pattern"],
json!(r"^\s*scene\s*$")
);
assert!(
image_asset_kind_schema["description"]
.as_str()
.is_some_and(|description| description.contains("禁止使用 scene"))
);
let generation_references = &parsed["components"]["schemas"]["EditorImageGenerationRequest"]
["properties"]["referenceImageSrcs"];
assert_eq!(generation_references["maxItems"], 9);
@@ -19,10 +19,10 @@ use crate::{
create_editor_project, create_editor_project_resource, delete_editor_asset,
delete_editor_asset_folder, delete_editor_project, edit_editor_image,
extract_editor_ui_design_assets, generate_editor_icon_spritesheet, generate_editor_image,
get_editor_asset_library, get_editor_generation_pricing, get_editor_project,
list_editor_projects, list_public_editor_project_resources, load_recent_editor_project,
remove_editor_image_background, rename_editor_project, save_editor_project_layout,
snap_editor_image_to_pixel_art, split_editor_icon_spritesheet,
generate_editor_scene, get_editor_asset_library, get_editor_generation_pricing,
get_editor_project, list_editor_projects, list_public_editor_project_resources,
load_recent_editor_project, remove_editor_image_background, rename_editor_project,
save_editor_project_layout, snap_editor_image_to_pixel_art, split_editor_icon_spritesheet,
submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset,
update_editor_asset_folder, update_editor_project_resource_showcase,
},
@@ -191,6 +191,13 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/editor/scenes/generations",
post(generate_editor_scene).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/images/edits",
post(edit_editor_image).route_layer(middleware::from_fn_with_state(
@@ -1,3 +1,4 @@
pub(crate) mod background_music;
pub(crate) mod character_animation;
pub(crate) mod character_visual;
pub(crate) mod editor_scene;
@@ -0,0 +1,279 @@
use std::borrow::Cow;
const STYLE_ANIME: &str = "anime";
const STYLE_WATERCOLOR: &str = "watercolor";
const STYLE_FLAT: &str = "flat";
const STYLE_STOP_MOTION: &str = "stop-motion";
const STYLE_CUSTOM: &str = "custom";
const SCENE_CONTENT_PLACEHOLDER: &str = "{{SCENE_CONTENT}}";
const STYLE_PROMPT_PLACEHOLDER: &str = "{{STYLE_PROMPT}}";
const ASPECT_RATIO_PLACEHOLDER: &str = "{{ASPECT_RATIO}}";
const FRAME_ORIENTATION_PLACEHOLDER: &str = "{{FRAME_ORIENTATION}}";
const EDITOR_SCENE_STRUCTURE_PROMPT: &str = r###"生成一张静态、单幅、单层合成完成的叙事场景背景,可直接用于视觉小说、对话驱动型游戏、故事事件、章节画面或环境展示。
画面内容:
{{SCENE_CONTENT}}
视觉风格:
{{STYLE_PROMPT}}
画面结构与构图要求:
- 输出一张连续、完整、铺满画面的环境背景,画幅方向与用户所选图片比例 {{ASPECT_RATIO}} 一致({{FRAME_ORIENTATION}});不拆分图层,不制作分屏、拼贴或多格画面。
- 使用稳定的固定视角,以自然平视视角或最适合该地点的建立镜头呈现场景;避免俯视玩法视角、等距视角、鱼眼、夸张广角和倾斜镜头。
- 建立清楚可读的前景、中景和远景关系。单层是指最终输出为一张合成图,不是取消场景的空间纵深。
- 环境必须完整、自然并具有明确的场所特征;画面即使不叠加人物、文字和界面,也应当能够独立成立。
- 通过建筑、家具、植物、道路和环境道具组织视线,形成一个明确但不过度突出的主要环境锚点。
- 主要环境锚点放在中景或画面中上部,不要把唯一的视觉焦点放在画面下四分之一区域。
- 中央及下方区域保持适度安静,降低过密的小型细节、强烈高光和尖锐对比,以便后续覆盖角色立绘、对话框、标题或事件文字。
- “保持安静”只能通过控制细节密度和视觉对比实现,不得制造空盒子、纯色空洞、角色轮廓形缺口或明显的界面占位区。
- 左、中、右区域均不应承载无法被遮挡的唯一叙事信息,保证角色立绘可以在不同位置覆盖画面。
- 前景道具可以辅助建立空间和叙事,但不得大面积遮挡主要环境,不使用贴近镜头的巨大物体封住画面。
- 保持水平和垂直结构稳定,透视关系清楚;除非画面内容明确要求,不制造剧烈运动、爆炸、战斗或瞬间动作。
资产限制:
- 仅生成环境背景。
- 除非画面内容中明确要求,否则不出现人物、人形角色、角色立绘、人群或显著生物主体。
- 允许出现少量动物,但动物只能作为次要环境细节,不能成为画面主角。
- 不出现对话框、用户界面、字幕、标题、说明文字、边框、品牌标志、水印或其他可读文字。
- 黑板、海报、画板、标牌和电子屏幕应为空白,或仅使用简化且不可辨认的图形符号。
- 不出现分屏、拼贴、多格画面、角色表、资产表、设定图、平面图、瓦片地图、等距地图或俯视玩法战场。
- 不把按钮、槽位、交互标记、任务标记、导航信息、碰撞体或其他玩法指示烘烤进图片。
- 输出一张边缘完整、完成度均匀、可直接用于游戏的精修环境背景图。"###;
const EDITOR_SCENE_ANIME_PROMPT: &str = r###"将“日系动画背景美术”作为锁定的视觉风格。无论画面内容如何变化,以下视觉规律都必须清楚可见。
风格方向:
采用成熟日系二维动画背景美术常见的精修数字绘景体系。最终图像应首先被识别为一张手绘完成的二维栅格环境绘景,其次才被识别为一个具有可信空间和材质的真实地点。
整体使用“选择性线稿+不透明数字上色+局部柔和绘制”的线面结合方式,不模拟实拍照片,也不把整幅环境处理成角色插画式的硬边赛璐璐画面。
必须保持的视觉锚点:
- 空间结构、建筑比例和物体形体保持可信;表面纹理和微小结构经过动画化归纳。真实感主要用于比例、空间和光照一致性,不用于照片级微观材质模拟。
- 将复杂环境概括为大型轮廓、中型结构和少量成组细节。先保证物体外形和色块关系清楚,再使用有限笔触补充表面信息,避免逐项描摹所有纹理。
- 使用具有三级强弱关系的选择性线稿。主要外轮廓和重要结构转折使用细而稳定的有色线;次级接缝使用更细、更浅的线;自然物、柔软表面和较远层次允许轮廓断开、减弱或融入色面。
- 线条颜色从物体固有色中取得较深、较灰的色彩,而不是统一覆盖纯黑描边。近处结构线可以清楚,远处线条必须随色彩与空间自然退去。
- 硬质人造物以清楚边缘、稳定平面和简化高光表现;植物、云层、泥土及其他自然表面使用成组轮廓、簇状笔触和局部虚实边缘表现,不逐片勾勒所有叶片或颗粒。
- 使用不透明数字色块建立基础固有色,再以少量半透明罩色、柔边笔刷和短促实体笔触完成色彩。笔触应被组织在物体形体内部,不形成随意涂抹或厚重油画堆积。
- 固有色保持清楚、明净而经过协调。色彩可以随时间、天气和地点改变,但始终保持明确的色彩主次,不让所有颜色具有相同饱和度和视觉重量。
- 阴影使用带有环境色的色相偏移,而不是在固有色上直接叠加黑色或中性灰。暗部保留可辨认的局部颜色,最深色彩只用于少量接触处和结构缝隙。
- 主要受光区、中间调和阴影区形成两到四个清楚的大明度组。投影与明确结构转折可以使用较硬边缘;环境过渡、反射光和远处色彩使用柔和边缘,避免全画面只有硬边赛璐璐阴影或只有模糊渐变。
- 高光保持克制,只在玻璃、金属、湿润表面或强受光边缘上出现窄而明确的亮部。普通表面维持哑光数字绘景质感,不使用统一塑料高光。
- 空间退远主要通过降低对比度、减弱线稿、压缩细节和使色彩趋向环境色完成,不依靠摄影景深、高斯模糊或大面积雾化遮挡。
- 最终表面允许保留轻微的实体数字笔刷痕迹和非常细的数字笔刷颗粒,但整体必须整洁、稳定、完成度均匀。大形准确,局部笔触可见,缩小观看时仍然具有清楚的日系二维动画环境识别度。
视觉禁用:
避免照片写实、照片描摹和照片拼贴;避免真实PBR材质、光滑三维渲染和游戏引擎截图;避免全场景统一粗黑漫画轮廓、角色式硬边赛璐璐阴影和过度平面卡通化;避免油画厚涂、概念设计式狂乱笔触、透明水彩渗化、水粉纸面和明显传统纸张纹理;避免绝对平滑的矢量色块;避免橙青电影调色、HDR、压死黑位、过量泛光、镜头光晕、色差、散景和浅景深;避免用高密度照片纹理、随机噪点或过度锐化制造虚假的精细度。
最终效果应当是:空间可信、形体经过动画化归纳、细有色线与手绘色面自然融合、色彩明净但不廉价、光影具有设计感而不电影写实的精修日系动画环境背景美术。"###;
const EDITOR_SCENE_WATERCOLOR_PROMPT: &str = r###"将“透明水彩环境绘景”作为锁定的视觉风格。无论画面内容如何变化,以下视觉规律都必须清楚可见。
风格方向:
使用真实冷压棉浆水彩纸、清水与透明水彩颜料完成精修二维环境绘景。最终图像必须首先被识别为一张在实体水彩纸上逐层绘制的透明水彩作品,而不是普通数字插画叠加纸纹或水渍滤镜。
采用“透明罩染、纸白发光、色彩叠加、边缘随水分变化”的绘制体系。画面可以保留完整、丰富的固有色,但所有色彩都必须具有透明度,底层颜色和纸张本白能够透过上层颜料继续参与最终成像。
必须保持的视觉锚点:
- 使用自然白或轻微暖白的冷压棉浆水彩纸。纸面保留细密凹凸、棉纤维和轻微纸纹,但不得处理成明显宣纸、旧纸、棕黄色仿古纸或粗糙画布。
- 主要亮部直接来自未上色的纸张本白。不得使用大面积不透明白色颜料或数字纯白覆盖制造高光。
- 保持环境结构、建筑比例和物体形体可信,同时将复杂表面归纳为大型透明色面、中型罩染层和少量关键笔触。避免逐项描摹所有微小纹理。
- 不使用统一黑色墨线包围所有物体。主要形体通过色彩边界、明度变化和冷暖关系建立;必要结构线只能使用较浅的中性色或局部深色水彩,并自然融入周围色面。
- 使用透明与半透明水色逐层罩染。底层色彩必须能够透过上层颜料显现,通过色彩叠加产生新的色彩,而不是使用不透明数字色块直接覆盖。
- 保留画面内容中主要物体的固有色。根据场景使用五至八组协调色彩,使蓝色、黄色、红色、绿色、紫色及其他必要颜色清楚可辨;不得默认压缩成黑白、灰褐或统一低饱和旧画色彩。
- 色彩整体保持清透明亮、主次明确。大面积环境色可以柔和,中型物体保持可读,少量关键物体可以拥有较高饱和度,但不得使全画面所有颜色同等鲜艳。
- 阴影采用透明的色彩叠染,通过冷暖偏移和互补色彩形成,不直接在固有色上叠加黑色或不透明灰色。暗部仍应保留底层色彩和纸张反光。
- 清楚保留真实水分运动的证据,包括湿碰湿扩散、柔和渗化、水渍边、回流、花边、颜料颗粒沉积和局部固有色不均。水迹必须服从物体形体和明暗关系,不得随机铺满画面。
- 同一物体允许硬边、软边与消失边并存。受控干燥区域形成清楚边缘;含水区域自然扩散;远处或受光边缘可以融入纸白。
- 穿插少量干笔、擦笔、断续笔触和纸纹露底,表现木材、草地、墙面和其他表面,但不得形成油画厚涂、粉质覆盖或数码纹理喷涂。
- 光影通过纸白、透明罩染、冷暖色彩和边缘虚实共同表现。主要受光区、中间调和阴影区形成清楚层级,同时保持水彩色彩的轻盈与通透。
- 空间退远通过增加清水、降低颜色浓度、减少笔触、减弱边缘和降低对比完成,不依靠摄影景深、高斯模糊、均匀灰雾或电影镜头效果。
- 最终画面必须结构完整、色彩充分、细节经过取舍且完成度均匀。缩小观看时应当首先是一张清楚可读的完整场景,放大后才能看到纸纹、罩染、水渍边和颜料颗粒。
视觉禁用:
避免宣纸水墨、书写性墨线、飞白枯笔和大面积纯黑墨块主导画面;避免默认生成黑白水彩、灰褐速写或统一棕黄色旅行手账;避免不透明水粉、蛋彩、丙烯、油画厚涂和粉质覆盖;避免硬边赛璐璐阴影、统一黑色漫画描边、平滑矢量色块和数字渐变。
避免照片描摹、照片拼贴、真实PBR材质、三维渲染和游戏引擎截图;避免HDR、塑料高光、镜头光晕、散景、景深、过度锐化和电影调色;避免仅在普通数字插画上叠加纸纹、水渍、颗粒或边缘滤镜;避免随机颜料爆炸、失控泼洒和水迹破坏主要形体。
最终效果应当是:完整可信的环境形体由透明罩染、纸白发光、色彩叠加、湿碰湿扩散、干湿边缘、水渍回流和颜料颗粒共同构成的精修透明水彩环境绘景。"###;
const EDITOR_SCENE_FLAT_PROMPT: &str = r###"将“平面几何场景美术”作为锁定的视觉风格。无论画面内容如何变化,以下视觉规律都必须清楚可见。
风格方向:
使用高度图形化的二维平面几何语言重新构成完整环境。最终图像必须首先被识别为经过精密形状设计和色彩规划的平面场景插画,而不是照片描摹、手绘媒介作品、低多边形三维或带文字的信息海报。
采用“大型几何形状+有限纯色+离散明暗+形状重叠造空间”的视觉体系。对象依靠轮廓、面积、方向和色彩关系被识别,不依赖真实纹理、复杂反射或细密笔触。
必须保持的视觉锚点:
- 将建筑、植物、地面和物件归纳为清楚的大型与中型几何形状,综合使用直线、折线、圆弧、矩形、梯形和少量受控曲线。减少琐碎结构与微小写实细节。
- 每个主要对象必须拥有一眼可辨的整体轮廓。先建立大的形状关系,再使用少量小形状补充必要接缝和标志性细节,不通过自动描摹产生破碎边缘。
- 形状边界干净、稳定、清楚,基本不使用传统手绘轮廓线。必要分隔线采用与色彩体系一致的有色线,不使用粗黑漫画描边。
- 使用五至八组纯色或近纯色组成统一色板,并保持明确的主色、辅助色和强调色面积关系。禁止无限增加相近色彩造成普通数字绘画效果。
- 每种对象只使用两至三个大的明暗层级。亮面、中间面和阴影面之间使用清楚边界,不使用连续渐变、柔光塑形或照片式色彩变化。
- 阴影自身也是经过设计的平面形状,轮廓简洁并服务于色彩节奏。阴影不包含真实反射、复杂半透明、环境遮蔽噪点或柔软摄影边缘。
- 通过形状重叠、大小变化、色彩冷暖、明度分区和细节递减表达空间。空间必须可读,但不追求真实材质与摄影空气感。
- 植物和云层使用成组的图形轮廓与重复模块表现,不逐片绘制叶片或毛边;硬质对象使用稳定平面、规则转折和简化接缝表现。
- 表面基本不出现纸张、颗粒、刷痕、照片纹理、锈迹或木纹,只允许极轻微且统一的色彩变化避免机械僵硬。
- 细节密度保持低到中等。大型轮廓在缩略尺寸下仍必须清楚,任何小型装饰都不能破坏整体形状识别。
- 最终图像保持全幅统一的二维平面逻辑,不让局部物体突然出现真实三维高光、手绘笔触或照片材质。
视觉禁用:
避免连续渐变、柔焦、透明水色、传统纸纹、实体刷痕和颗粒堆积;避免照片贴图、写实材质、复杂高光、真实反射、环境遮蔽和电影光效;避免可见三角形分面、低多边形三维体积和透视产品渲染。
避免自动描摹照片形成的碎裂轮廓;避免廉价图标、信息图、UI面板、海报排版和企业矢量图库感;避免仅通过减少细节得到空洞色块,而没有经过设计的大中小形状、色彩面积和视觉节奏。
最终效果应当是:完整环境被归纳为清楚几何形状、有限色彩、离散明暗和有秩序的形状重叠,形成高可读、低纹理、色彩关系明确的精修二维平面场景美术。"###;
const EDITOR_SCENE_STOP_MOTION_PROMPT: &str = r###"将“手工模型定格场景”作为锁定的视觉风格。无论画面内容如何变化,以下视觉规律都必须清楚可见。
风格方向:
使用黏土、橡皮泥、泡沫、木料、布料、涂装纸板、金属丝和混合手工材料搭建真实微缩环境,并以定格动画布景摄影方式呈现。最终图像必须首先被识别为一座实际存在的手工模型布景,而不是电脑三维、照片级真实地点或平面插画。
采用“可触摸实体体积+手工材料证据+轻微制作误差+真实棚拍光影”的视觉体系。所有对象都应像能够被制作者拿起、摆放和逐格拍摄。
必须保持的视觉锚点:
- 建筑、植物、地面和道具全部具有明确实体体积,并由可辨认的手工材料制作。不同材料保持各自真实厚度、硬度和表面粗糙度。
- 造型略带手工不对称,直线、圆角、连接处和重复零件存在细微差异,但整体结构稳定、精修且易于识别。
- 黏土和橡皮泥表面保留轻微指纹、压痕、揉捏纹和工具刻痕;纸板与泡沫保留切口、接缝、涂层和少量露底;布料保留纤维与缝线。
- 连接处允许出现少量胶水痕、金属丝骨架、模具接缝、补土和手工拼装误差。这些痕迹必须克制,不把布景变成破损废品。
- 手工涂色略有不均,边缘可见刷痕、颜色覆盖差和细小磨损。色彩仍保持协调,不使用电脑生成的完美材质贴图。
- 植物、云、水面与柔软物体也必须以实际可制作材料表现,例如纸片、泡沫、纤维、树脂或透明薄膜,不允许退回真实自然照片。
- 使用真实摄影棚式主光、补光和环境反射。光线在模型表面形成自然接触阴影、柔和高光和微小遮挡阴影,证明对象确实占有体积。
- 不同材料的高光宽度和粗糙度有所区别,但整体以哑光、可触摸的手工质感为主,不追求复杂PBR反射。
- 允许轻微微缩摄影感和有限景深,但主要环境层次仍应清楚,不能依靠极端浅景深掩盖材料与结构。
- 细节必须符合可实际制作的尺寸与工艺,不出现无限微观结构、真实城市纹理或计算机程序化噪点。
- 最终画面保留实物摄影与逐格拍摄的可信度,所有区域统一属于同一座手工模型布景。
视觉禁用:
避免光滑电脑三维、完美曲面、程序化材质、照片贴图、复杂PBR反射和产品级塑料渲染;避免真实建筑摄影、真人场景、纯平面剪纸和无体积数字插画。
避免所有表面同一种黏土质感;避免过量指纹、污渍和损坏;避免极端微距景深、电影色差、镜头光晕和过度戏剧化照明;避免仅通过圆润造型和玩具题材冒充定格布景。
最终效果应当是:完整环境由具有真实体积的黏土、纸板、泡沫、木料、布料和混合材料搭建,并通过可见手工痕迹与真实棚拍光影形成可信的精修定格动画场景。"###;
fn editor_scene_frame_orientation(aspect_ratio: &str) -> &'static str {
match aspect_ratio {
"16:9" | "4:3" | "3:2" => "横幅",
"9:16" | "2:3" => "竖幅",
_ => "方形",
}
}
fn resolve_editor_scene_style_prompt<'a>(
style_preset: &str,
custom_style: Option<&'a str>,
) -> Result<Cow<'a, str>, &'static str> {
match style_preset.trim() {
STYLE_ANIME => Ok(Cow::Borrowed(EDITOR_SCENE_ANIME_PROMPT)),
STYLE_WATERCOLOR => Ok(Cow::Borrowed(EDITOR_SCENE_WATERCOLOR_PROMPT)),
STYLE_FLAT => Ok(Cow::Borrowed(EDITOR_SCENE_FLAT_PROMPT)),
STYLE_STOP_MOTION => Ok(Cow::Borrowed(EDITOR_SCENE_STOP_MOTION_PROMPT)),
STYLE_CUSTOM => custom_style
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| Cow::Owned(value.to_string()))
.ok_or("选择自定义画风时必须填写 customStyle"),
_ => Err("stylePreset 仅支持 anime、watercolor、flat、stop-motion 或 custom"),
}
}
pub(crate) fn build_editor_scene_prompt(
scene_content: &str,
style_preset: &str,
custom_style: Option<&str>,
normalized_aspect_ratio: &str,
) -> Result<String, &'static str> {
let scene_content = scene_content.trim();
if scene_content.is_empty() {
return Err("sceneContent 不能为空");
}
let style_prompt = resolve_editor_scene_style_prompt(style_preset, custom_style)?;
// 比例和画幅方向来自后端白名单标准化结果,可以在插入用户文本前安全替换。
let controlled_structure = EDITOR_SCENE_STRUCTURE_PROMPT
.replace(ASPECT_RATIO_PLACEHOLDER, normalized_aspect_ratio)
.replace(
FRAME_ORIENTATION_PLACEHOLDER,
editor_scene_frame_orientation(normalized_aspect_ratio),
);
let (before_scene_content, after_scene_content) = controlled_structure
.split_once(SCENE_CONTENT_PLACEHOLDER)
.ok_or("场景 Prompt 模板缺少 SCENE_CONTENT 占位符")?;
let (between_scene_and_style, after_style_prompt) = after_scene_content
.split_once(STYLE_PROMPT_PLACEHOLDER)
.ok_or("场景 Prompt 模板缺少 STYLE_PROMPT 占位符")?;
// 用户画面内容和自定义画风必须最后一次性拼入;拼入后禁止再对整段 Prompt
// 执行模板替换,避免用户原文中的 {{...}} token 被当成内部占位符展开。
Ok(format!(
"{before_scene_content}{scene_content}{between_scene_and_style}{style_prompt}{after_style_prompt}"
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scene_prompt_uses_normalized_frame_orientation() {
let horizontal = build_editor_scene_prompt("雨夜小镇", STYLE_ANIME, None, "16:9")
.expect("horizontal scene prompt");
let square = build_editor_scene_prompt("雨夜小镇", STYLE_WATERCOLOR, None, "1:1")
.expect("square scene prompt");
let vertical = build_editor_scene_prompt("雨夜小镇", STYLE_FLAT, None, "9:16")
.expect("vertical scene prompt");
assert!(horizontal.contains("图片比例 16:9 一致(横幅)"));
assert!(square.contains("图片比例 1:1 一致(方形)"));
assert!(vertical.contains("图片比例 9:16 一致(竖幅)"));
}
#[test]
fn scene_prompt_uses_all_preset_styles() {
for (preset, expected) in [
(STYLE_ANIME, "日系动画背景美术"),
(STYLE_WATERCOLOR, "透明水彩环境绘景"),
(STYLE_FLAT, "平面几何场景美术"),
(STYLE_STOP_MOTION, "手工模型定格场景"),
] {
let prompt = build_editor_scene_prompt("海边车站", preset, None, "16:9")
.expect("preset scene prompt");
assert!(
prompt.contains(expected),
"missing preset marker: {expected}"
);
assert_eq!(prompt.matches("视觉风格:").count(), 1);
}
}
#[test]
fn scene_prompt_uses_custom_style_without_preset_text() {
let prompt = build_editor_scene_prompt(
"安静的海边车站",
STYLE_CUSTOM,
Some("90年代复古像素风"),
"3:2",
)
.expect("custom scene prompt");
assert!(prompt.contains("90年代复古像素风"));
assert!(!prompt.contains("日系动画背景美术"));
}
#[test]
fn scene_prompt_rejects_missing_required_inputs() {
assert!(build_editor_scene_prompt("", STYLE_ANIME, None, "16:9").is_err());
assert!(build_editor_scene_prompt("场景", STYLE_CUSTOM, None, "16:9").is_err());
assert!(build_editor_scene_prompt("场景", "unknown", None, "16:9").is_err());
}
#[test]
fn scene_prompt_does_not_expand_placeholder_tokens_from_user_inputs() {
let scene_content = "保留 {{STYLE_PROMPT}}、{{ASPECT_RATIO}} 与 {{FRAME_ORIENTATION}} 原文";
let custom_style = "保留 {{SCENE_CONTENT}}、{{ASPECT_RATIO}} 与 {{FRAME_ORIENTATION}} 原文";
let prompt =
build_editor_scene_prompt(scene_content, STYLE_CUSTOM, Some(custom_style), "16:9")
.expect("custom scene prompt");
assert!(prompt.contains(scene_content));
assert!(prompt.contains(custom_style));
assert!(prompt.contains("图片比例 16:9 一致(横幅)"));
}
#[test]
fn scene_prompt_source_does_not_include_document_placeholders() {
for prompt in [
EDITOR_SCENE_STRUCTURE_PROMPT,
EDITOR_SCENE_ANIME_PROMPT,
EDITOR_SCENE_WATERCOLOR_PROMPT,
EDITOR_SCENE_FLAT_PROMPT,
EDITOR_SCENE_STOP_MOTION_PROMPT,
] {
assert!(!prompt.contains("[图片]"));
assert!(!prompt.contains("8.1"));
assert!(!prompt.contains("8.2"));
}
assert_eq!(
EDITOR_SCENE_STRUCTURE_PROMPT
.matches(SCENE_CONTENT_PLACEHOLDER)
.count(),
1
);
assert_eq!(
EDITOR_SCENE_STRUCTURE_PROMPT
.matches(STYLE_PROMPT_PLACEHOLDER)
.count(),
1
);
}
}
+165 -2
View File
@@ -1,5 +1,7 @@
#[cfg(test)]
use std::sync::Mutex;
#[cfg(test)]
use std::sync::atomic::AtomicUsize;
use std::{
collections::BTreeMap,
error::Error,
@@ -122,9 +124,15 @@ impl BackpressureState {
}
}
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct AppState(Arc<AppStateInner>);
impl fmt::Debug for AppState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("AppState").field(self.0.as_ref()).finish()
}
}
impl std::ops::Deref for AppState {
type Target = AppStateInner;
@@ -225,7 +233,6 @@ impl FromRef<AppState> for PuzzleApiState {
}
// Axum/Hyper 会在路由树和连接 service 上频繁 clone stateAppState 外层必须保持浅拷贝。
#[derive(Debug)]
pub struct AppStateInner {
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
#[allow(dead_code)]
@@ -244,6 +251,10 @@ pub struct AppStateInner {
test_feature_gate_config: Arc<Mutex<Option<Vec<module_runtime::FeatureGateConfigSnapshot>>>>,
#[cfg(test)]
test_spacetime_health: Arc<Mutex<Option<SpacetimeClientHealthSnapshot>>>,
#[cfg(test)]
test_editor_generation_enqueue_attempts: AtomicUsize,
#[cfg(test)]
test_fail_editor_generation_enqueue: AtomicBool,
oss_client: Option<OssClient>,
#[cfg_attr(test, allow(dead_code))]
auth_store: InMemoryAuthStore,
@@ -285,6 +296,35 @@ pub struct AppStateInner {
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
}
impl fmt::Debug for AppStateInner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// AppState 聚合多个仍可独立 Debug 的平台 client。这里使用封闭摘要,禁止 Debug
// 递归下钻到 JWT、管理员口令、OSS、微信、LLM 等运行时凭据。
f.debug_struct("AppStateInner")
.field("config", &self.config)
.field("ready", &self.ready.load(Ordering::Relaxed))
.field(
"bgfilter_worker_reached",
&self.bgfilter_worker_reached.load(Ordering::Relaxed),
)
.field("admin_runtime_enabled", &self.admin_runtime.is_some())
.field("oss_client_enabled", &self.oss_client.is_some())
.field("spacetime_client", &self.spacetime_client)
.field("tracking_outbox_enabled", &self.tracking_outbox.is_some())
.field(
"wallet_refund_outbox_enabled",
&self.wallet_refund_outbox.is_some(),
)
.field("llm_client_enabled", &self.llm_client.is_some())
.field(
"editor_agent_llm_client_enabled",
&self.editor_agent_llm_client.is_some(),
)
.field("matting_client_enabled", &self.matting_client.is_some())
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug)]
#[cfg(any())]
struct CreativeAgentSessionRuntimeRecord {
@@ -553,6 +593,10 @@ impl AppState {
test_spacetime_health: Arc::new(Mutex::new(Some(
SpacetimeClientHealthSnapshot::healthy_for_test(),
))),
#[cfg(test)]
test_editor_generation_enqueue_attempts: AtomicUsize::new(0),
#[cfg(test)]
test_fail_editor_generation_enqueue: AtomicBool::new(false),
oss_client,
auth_store,
password_entry_service,
@@ -731,6 +775,26 @@ impl AppState {
.expect("test spacetime health should lock") = Some(snapshot);
}
#[cfg(test)]
pub(crate) fn fail_test_editor_generation_enqueue(&self) {
self.test_fail_editor_generation_enqueue
.store(true, Ordering::Release);
}
#[cfg(test)]
pub(crate) fn record_test_editor_generation_enqueue_attempt(&self) -> bool {
self.test_editor_generation_enqueue_attempts
.fetch_add(1, Ordering::AcqRel);
self.test_fail_editor_generation_enqueue
.load(Ordering::Acquire)
}
#[cfg(test)]
pub(crate) fn test_editor_generation_enqueue_attempts(&self) -> usize {
self.test_editor_generation_enqueue_attempts
.load(Ordering::Acquire)
}
#[cfg(any())]
pub async fn upsert_creation_entry_type_config(
&self,
@@ -2253,6 +2317,105 @@ mod tests {
use super::*;
#[test]
fn debug_summaries_redact_all_runtime_credentials() {
const SENSITIVE_KEY_LURE: &str = "ISSUE_148_DEBUG_SECRET_LURE";
let secret = || Some(SENSITIVE_KEY_LURE.to_string());
let config = AppConfig {
bgfilter_internal_token: secret(),
editor_bgfilter_token: secret(),
aliyun_matting_access_key_id: secret(),
aliyun_matting_access_key_secret: secret(),
admin_username: Some("debug-admin".to_string()),
admin_password: secret(),
internal_api_secret: secret(),
jwt_secret: SENSITIVE_KEY_LURE.to_string(),
sms_access_key_id: secret(),
sms_access_key_secret: secret(),
sms_mock_verify_code: SENSITIVE_KEY_LURE.to_string(),
wechat_app_secret: secret(),
wechat_mini_program_app_secret: secret(),
wechat_pay_private_key_pem: secret(),
wechat_pay_private_key_path: Some(std::path::PathBuf::from(SENSITIVE_KEY_LURE)),
wechat_pay_platform_public_key_pem: secret(),
wechat_pay_platform_public_key_path: Some(std::path::PathBuf::from(SENSITIVE_KEY_LURE)),
wechat_pay_api_v3_key: secret(),
wechat_mini_program_virtual_payment_offer_id: secret(),
wechat_mini_program_virtual_payment_app_key: secret(),
wechat_mini_program_virtual_payment_sandbox_app_key: secret(),
wechat_mini_program_message_token: secret(),
wechat_mini_program_message_encoding_aes_key: secret(),
oss_bucket: Some("debug-bucket".to_string()),
oss_endpoint: Some("oss.example.invalid".to_string()),
oss_access_key_id: secret(),
oss_access_key_secret: secret(),
spacetime_server_url: format!("https://spacetime.invalid/?token={SENSITIVE_KEY_LURE}"),
spacetime_database: format!("debug-{SENSITIVE_KEY_LURE}"),
spacetime_token: secret(),
spacetime_runtime_service_bootstrap_secret: secret(),
llm_base_url: "https://llm.example.invalid".to_string(),
llm_api_key: secret(),
llm_model: "debug-model".to_string(),
dashscope_api_key: secret(),
vector_engine_base_url: "https://vector.example.invalid".to_string(),
vector_engine_api_key: secret(),
hyper3d_api_key: secret(),
volcengine_speech_api_key: secret(),
volcengine_speech_app_id: secret(),
volcengine_speech_access_key: secret(),
ark_character_video_api_key: secret(),
..AppConfig::default()
};
let spacetime_config = spacetime_client_config_for_process(&config);
let spacetime_client = SpacetimeClient::new(spacetime_config.clone());
let state = AppState::new(config.clone()).expect("state should build");
let config_debug = format!("{config:?}");
let expected_config_debug = format!(
"AppConfig {{ bind_port: {:?}, listen_backlog: {:?}, worker_threads: {:?}, process_role: {:?}, external_generation_mode: {:?}, external_generation_worker_concurrency: {:?}, max_concurrent_requests: {:?}, admin_max_concurrent_requests: {:?}, spacetime_pool_size: {:?}, sms_auth_enabled: {:?}, wechat_auth_enabled: {:?}, wechat_pay_enabled: {:?}, aliyun_matting_enabled: {:?}, tracking_outbox_enabled: {:?}, wallet_refund_outbox_enabled: {:?}, otel_enabled: {:?}, credentials: \"<redacted>\", .. }}",
config.bind_port,
config.listen_backlog,
config.worker_threads,
config.process_role,
config.external_generation_mode,
config.external_generation_worker_concurrency,
config.max_concurrent_requests,
config.admin_max_concurrent_requests,
config.spacetime_pool_size,
config.sms_auth_enabled,
config.wechat_auth_enabled,
config.wechat_pay_enabled,
config.aliyun_matting_enabled,
config.tracking_outbox_enabled,
config.wallet_refund_outbox_enabled,
config.otel_enabled,
);
assert_eq!(
config_debug, expected_config_debug,
"AppConfig Debug 只能输出显式允许的枚举、数值、布尔值和脱敏占位;新增自由字符串必须默认缺席"
);
let outputs = [
("AppConfig", config_debug.clone()),
("SpacetimeClientConfig", format!("{spacetime_config:?}")),
("SpacetimeClient", format!("{spacetime_client:?}")),
("AppStateInner", format!("{:?}", state.0.as_ref())),
("AppState", format!("{state:?}")),
];
for (type_name, output) in outputs {
assert!(
!output.contains(SENSITIVE_KEY_LURE),
"{type_name} Debug leaked the credential lure: {output}"
);
}
assert!(config_debug.contains("process_role"));
assert!(config_debug.contains("<redacted>"));
assert!(format!("{spacetime_config:?}").contains("pool_size"));
assert!(format!("{state:?}").contains("ready: true"));
}
#[test]
fn app_state_reuses_character_animation_oss_client_and_eight_permits() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -33,8 +33,8 @@ use crate::{
build_editor_canvas_generated_layer_item, editor_asset_payload_from_record,
editor_project_payload_from_record, editor_project_resource_payload_from_record,
normalize_optional_string, persist_editor_generation_result_atomically,
prepare_editor_generated_asset, sanitize_editor_client_generation_inputs,
with_editor_media_duration_generation_input,
preflight_editor_billable_generation_target, prepare_editor_generated_asset,
sanitize_editor_client_generation_inputs, with_editor_media_duration_generation_input,
},
http_error::AppError,
request_context::RequestContext,
@@ -240,9 +240,19 @@ pub(crate) async fn enqueue_editor_sound_effect_generation_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
payload: assets::EditorSoundEffectGenerateRequest,
mut payload: assets::EditorSoundEffectGenerateRequest,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, Response> {
let target = preflight_editor_billable_generation_target(
state,
owner_user_id,
payload.project_id.clone(),
payload.asset_folder_id.clone(),
)
.await
.map_err(|error| error.into_response_with_context(Some(request_context)))?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
let pricing = state.editor_generation_pricing().await.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_details(json!({
@@ -276,8 +286,18 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
caller: EditorGenerationCaller,
payload: Result<Json<assets::EditorSoundEffectGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let Json(mut payload) = parse_json_payload(&request_context, payload)?;
let owner_user_id = caller.owner_user_id.clone();
let target = preflight_editor_billable_generation_target(
&state,
owner_user_id.as_str(),
payload.project_id.clone(),
payload.asset_folder_id.clone(),
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
let project_id = payload.project_id.clone();
let canvas_completion = payload.canvas_completion.clone();
let generation_inputs = payload.generation_inputs.clone();
@@ -443,9 +463,19 @@ async fn enqueue_prepared_editor_background_music_generation_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
prepared: PreparedEditorBackgroundMusicQueueJob,
mut prepared: PreparedEditorBackgroundMusicQueueJob,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, Response> {
let target = preflight_editor_billable_generation_target(
state,
owner_user_id,
prepared.payload.project_id.clone(),
prepared.payload.asset_folder_id.clone(),
)
.await
.map_err(|error| error.into_response_with_context(Some(request_context)))?;
prepared.payload.project_id = target.project_id;
prepared.payload.asset_folder_id = target.asset_folder_id;
let source_entity_id = editor_generation_source_entity_id(
prepared.payload.project_id.as_deref(),
"editor-background-music",
@@ -540,8 +570,18 @@ pub(crate) async fn generate_editor_background_music_for_owner(
caller: EditorGenerationCaller,
payload: Result<Json<assets::EditorBackgroundMusicGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let Json(mut payload) = parse_json_payload(&request_context, payload)?;
let owner_user_id = caller.owner_user_id.clone();
let target = preflight_editor_billable_generation_target(
&state,
owner_user_id.as_str(),
payload.project_id.clone(),
payload.asset_folder_id.clone(),
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
let project_id = payload.project_id.clone();
let canvas_completion = payload.canvas_completion.clone();
let generation_inputs = payload.generation_inputs.clone();
@@ -1251,6 +1291,64 @@ mod tests {
assert!(body.contains("\"actualPrompt\": input.prompt"));
}
#[test]
fn editor_audio_targets_preflight_before_provider_or_queue() {
let source = include_str!("generation.rs");
for (start, end, payload_prefix, terminal) in [
(
"pub(crate) async fn enqueue_editor_sound_effect_generation_for_owner",
"pub(crate) async fn generate_editor_sound_effect_for_owner",
"payload",
"enqueue_editor_generation_job_for_caller",
),
(
"pub(crate) async fn generate_editor_sound_effect_for_owner",
"fn build_editor_sound_effect_generate_response",
"payload",
"platform_audio::submit_editor_sound_effect_task",
),
(
"async fn enqueue_prepared_editor_background_music_generation_for_owner",
"async fn enqueue_logged_in_editor_background_music_generation_for_owner",
"prepared.payload",
"enqueue_editor_generation_job_for_caller",
),
(
"pub(crate) async fn generate_editor_background_music_for_owner",
"fn build_editor_audio_target",
"payload",
"platform_audio::submit_editor_background_music_task",
),
] {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let body = &source[start_index..];
let end_index = body
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &body[..end_index];
let preflight = body
.find("preflight_editor_billable_generation_target")
.unwrap_or_else(|| panic!("{start} must preflight the generation target"));
let project_assignment = body
.find(format!("{payload_prefix}.project_id = target.project_id").as_str())
.unwrap_or_else(|| panic!("{start} must reuse canonical project id"));
let folder_assignment = body
.find(format!("{payload_prefix}.asset_folder_id = target.asset_folder_id").as_str())
.unwrap_or_else(|| panic!("{start} must reuse canonical asset folder id"));
let terminal = body
.find(terminal)
.unwrap_or_else(|| panic!("{start} must retain {terminal}"));
assert!(
preflight < project_assignment
&& project_assignment < folder_assignment
&& folder_assignment < terminal,
"{start} must reuse canonical target before {terminal}"
);
}
}
#[test]
fn editor_background_music_normalization_uses_canonical_generation_prompt() {
let representative = background_music_prompt_fixture("representative-complex");