Merge remote-tracking branch 'origin/master' into hermes/wechat

# Conflicts:
#	.hermes/shared-memory/pitfalls.md
#	.hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md
This commit is contained in:
2026-05-15 01:28:04 +08:00
266 changed files with 23417 additions and 4373 deletions

View File

@@ -17,6 +17,7 @@ module-ai = { workspace = true }
module-assets = { workspace = true, features = ["server-service"] }
module-auth = { workspace = true }
module-big-fish = { workspace = true }
module-bark-battle = { workspace = true }
module-combat = { workspace = true }
module-creative-agent = { workspace = true }
module-custom-world = { workspace = true }

View File

@@ -1,56 +0,0 @@
{
"next_user_id": 2,
"users_by_username": {
"phone_00000002": {
"user": {
"id": "user_00000001",
"public_user_code": "SY-00000001",
"username": "phone_00000002",
"display_name": "138****8000",
"phone_number_masked": "138****8000",
"login_method": "Phone",
"binding_status": "Active",
"wechat_bound": false,
"token_version": 1
},
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$hoXmK/LzABj2QfWZSO3SNA$Qg71V2iZCPyLOsoQLffiCv3KPkWVNSAsP6IooTIXi/w",
"password_login_enabled": false,
"phone_number": "+8613800138000"
}
},
"phone_to_user_id": {
"+8613800138000": "user_00000001"
},
"sessions_by_id": {
"usess_52522126b58d40e3b9e503808dd11e2c": {
"session": {
"session_id": "usess_52522126b58d40e3b9e503808dd11e2c",
"user_id": "user_00000001",
"refresh_token_hash": "f42140526caea3e4a9f533bcc2d8799feae4f96769ea975ef771b1ae11e4dbe9",
"issued_by_provider": "Phone",
"client_info": {
"client_type": "web_browser",
"client_runtime": "unknown",
"client_platform": "unknown",
"client_instance_id": null,
"device_fingerprint": null,
"device_display_name": "未知设备 / 未知客户端",
"mini_program_app_id": null,
"mini_program_env": null,
"user_agent": null,
"ip": null
},
"expires_at": "2026-05-25T15:41:01.0856147Z",
"revoked_at": null,
"created_at": "2026-04-25T15:41:01.0856147Z",
"updated_at": "2026-04-25T15:41:01.0856147Z",
"last_seen_at": "2026-04-25T15:41:01.0856147Z"
}
}
},
"session_id_by_refresh_token_hash": {
"f42140526caea3e4a9f533bcc2d8799feae4f96769ea975ef771b1ae11e4dbe9": "usess_52522126b58d40e3b9e503808dd11e2c"
},
"wechat_identity_by_provider_uid": {},
"user_id_by_provider_union_id": {}
}

View File

@@ -34,6 +34,10 @@ use crate::{
auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::{auth_sessions, revoke_auth_session},
bark_battle::{
create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run,
get_bark_battle_runtime_config, publish_bark_battle_work, start_bark_battle_run,
},
big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
@@ -77,6 +81,8 @@ use crate::{
generate_custom_world_opening_cg, generate_custom_world_scene_image,
generate_custom_world_scene_npc, upload_custom_world_cover_image,
},
edutainment_baby_drawing::create_baby_love_drawing_magic,
edutainment_baby_object::generate_baby_object_match_assets,
error_middleware::normalize_error_response,
health::health_check,
hyper3d_generation::{
@@ -90,12 +96,13 @@ use crate::{
match3d::{
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
generate_match3d_background_image_for_work, generate_match3d_cover_image,
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works,
list_match3d_gallery, persist_match3d_generated_model, publish_match3d_work,
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
generate_match3d_background_image_for_work, generate_match3d_container_image_for_work,
generate_match3d_cover_image, generate_match3d_item_assets_for_work,
generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run,
get_match3d_work_detail, get_match3d_works, list_match3d_gallery,
persist_match3d_generated_model, publish_match3d_work, put_match3d_audio_assets,
put_match3d_work, restart_match3d_run, start_match3d_run, stop_match3d_run,
stream_match3d_agent_message, submit_match3d_agent_message,
},
password_entry::password_entry,
password_management::{change_password, reset_password},
@@ -185,6 +192,7 @@ use crate::{
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 1024 * 1024;
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
const BABY_LOVE_DRAWING_MAGIC_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
pub fn build_router(state: AppState) -> Router {
@@ -648,6 +656,24 @@ pub fn build_router(state: AppState) -> Router {
"/api/creation-entry/config",
get(get_creation_entry_config_handler),
)
.route(
"/api/creation/edutainment/baby-object-match/assets",
post(generate_baby_object_match_assets).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/edutainment/baby-love-drawing/magic",
post(create_baby_love_drawing_magic)
.layer(DefaultBodyLimit::max(
BABY_LOVE_DRAWING_MAGIC_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/settings",
get(get_runtime_settings)
@@ -970,10 +996,15 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/creation/match3d/works/{profile_id}/cover-image",
post(generate_match3d_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
post(generate_match3d_cover_image)
// 中文注释:抓大鹅封面支持上传主图与多张参考图,沿用拼图参考图入口上限。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/background-image",
@@ -981,6 +1012,12 @@ pub fn build_router(state: AppState) -> Router {
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/match3d/works/{profile_id}/container-image",
post(generate_match3d_container_image_for_work).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/match3d/works/{profile_id}/item-assets",
post(generate_match3d_item_assets_for_work).route_layer(
@@ -1044,6 +1081,48 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/creation/bark-battle/drafts",
post(create_bark_battle_draft).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/bark-battle/works/publish",
post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/bark-battle/works/{work_id}/config",
get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/bark-battle/works/{work_id}/runs",
post(start_bark_battle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/bark-battle/runs/{run_id}",
get(get_bark_battle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/bark-battle/runs/{run_id}/finish",
post(finish_bark_battle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions",
post(create_square_hole_agent_session).route_layer(middleware::from_fn_with_state(

View File

@@ -165,6 +165,7 @@ pub(crate) fn should_skip_asset_operation_billing_for_connectivity(
|| message.contains("Service Unavailable")
|| message.contains("Failed to connect")
|| message.contains("WebSocket")
|| message.contains("No such procedure")
|| message.contains("连接已断开")
|| message.contains("连接在返回结果前已断开")
}
@@ -190,6 +191,11 @@ mod tests {
"Failed to connect: HTTP error: 503 Service Unavailable".to_string(),
),
));
assert!(should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::Procedure(
"No such procedure: consume_profile_wallet_points_and_return".to_string(),
),
));
assert!(!should_skip_asset_operation_billing_for_connectivity(
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
));

View File

@@ -0,0 +1,776 @@
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::Response,
};
use module_bark_battle::{BARK_BATTLE_RULESET_VERSION_V1, BarkBattleRuleset};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::bark_battle::{
BarkBattleConfigEditorPayload, BarkBattleDerivedMetrics, BarkBattleDifficultyPreset,
BarkBattleDraftConfig, BarkBattleDraftCreateRequest, BarkBattleFinishStatus,
BarkBattlePublishedConfig, BarkBattleRunFinishRequest, BarkBattleRunFinishResponse,
BarkBattleRunStartRequest, BarkBattleRunStartResponse, BarkBattleScoreSummary,
BarkBattleServerResult, BarkBattleWorkPublishRequest,
};
use shared_kernel::{
build_prefixed_uuid_id, format_rfc3339, format_timestamp_micros,
offset_datetime_to_unix_micros, parse_rfc3339,
};
use spacetime_client::{
BarkBattleDraftCreateRecordInput, BarkBattleRunFinishRecordInput, BarkBattleRunRecord,
BarkBattleRunStartRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
};
use time::{Duration as TimeDuration, OffsetDateTime};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
request_context::RequestContext,
state::AppState,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const BARK_BATTLE_RUNTIME_PROVIDER: &str = "bark-battle-runtime";
const BARK_BATTLE_DRAFT_ID_PREFIX: &str = "bark-battle-draft-";
const BARK_BATTLE_WORK_ID_PREFIX: &str = "bark-battle-work-";
const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-";
const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-";
const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle";
const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60;
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct BarkBattleRunSnapshotRecord {
run_id: String,
work_id: String,
config_version: u64,
ruleset_version: String,
difficulty_preset: String,
#[serde(default)]
client_started_at_micros: i64,
#[serde(default)]
server_started_at_micros: i64,
#[serde(default)]
server_finished_at_micros: Option<i64>,
#[serde(default)]
metrics_json: String,
#[serde(default)]
server_result: Option<String>,
#[serde(default)]
validation_status: String,
#[serde(default)]
anti_cheat_flags_json: String,
#[serde(default)]
leaderboard_score: Option<u64>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BarkBattleDraftConfigSnapshotRecord {
draft_id: String,
#[allow(dead_code)]
work_id: String,
#[allow(dead_code)]
config_version: u64,
#[allow(dead_code)]
ruleset_version: String,
#[serde(default)]
config_json: String,
updated_at_micros: i64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BarkBattleRuntimeConfigSnapshotRecord {
work_id: String,
source_draft_id: Option<String>,
config_version: u64,
ruleset_version: String,
#[serde(default)]
config_json: String,
published_at_micros: i64,
updated_at_micros: i64,
}
pub async fn create_bark_battle_draft(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<BarkBattleDraftCreateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = bark_battle_json(payload, &request_context)?;
let now = current_utc_micros();
let draft = state
.spacetime_client()
.create_bark_battle_draft(BarkBattleDraftCreateRecordInput {
draft_id: build_prefixed_uuid_id(BARK_BATTLE_DRAFT_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
work_id: build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX),
title: Some(payload.title),
description: payload.description,
theme_preset: payload.theme_preset,
player_dog_skin_preset: payload.player_dog_skin_preset,
opponent_dog_skin_preset: payload.opponent_dog_skin_preset,
difficulty_preset: Some(
difficulty_to_spacetime_string(&payload.difficulty_preset).to_string(),
),
leaderboard_enabled: Some(payload.leaderboard_enabled),
editor_state_json: Some("{}".to_string()),
created_at_micros: now,
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let draft = map_draft_config_record(draft, &request_context)?;
Ok(json_success_body(Some(&request_context), draft))
}
pub async fn publish_bark_battle_work(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<BarkBattleWorkPublishRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = bark_battle_json(payload, &request_context)?;
ensure_non_empty(&request_context, &payload.draft_id, "draftId")?;
let work_id = payload
.work_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX));
let published_snapshot_json = payload
.published_snapshot
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|error| {
bark_battle_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("publishedSnapshot JSON 序列化失败: {error}"),
})),
)
})?;
let published = state
.spacetime_client()
.publish_bark_battle_work(BarkBattleWorkPublishRecordInput {
draft_id: payload.draft_id,
owner_user_id: authenticated.claims().user_id().to_string(),
work_id,
published_snapshot_json,
published_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let published = map_published_config_record(published, &request_context)?;
Ok(json_success_body(Some(&request_context), published))
}
pub async fn get_bark_battle_runtime_config(
State(state): State<AppState>,
Path(work_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &work_id, "workId")?;
let config = state
.spacetime_client()
.get_bark_battle_runtime_config(work_id, Some(authenticated.claims().user_id().to_string()))
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let config = map_runtime_config_record(config, &request_context)?;
Ok(json_success_body(Some(&request_context), config))
}
pub async fn start_bark_battle_run(
State(state): State<AppState>,
Path(work_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<BarkBattleRunStartRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let maybe_payload = payload.ok().map(|Json(payload)| payload);
let request = maybe_payload.unwrap_or_else(|| BarkBattleRunStartRequest {
work_id: work_id.clone(),
config_version: None,
source_route: None,
client_runtime_version: None,
});
let work_id = if request.work_id.trim().is_empty() {
work_id
} else {
request.work_id.trim().to_string()
};
ensure_non_empty(&request_context, &work_id, "workId")?;
let owner_user_id = authenticated.claims().user_id().to_string();
let runtime_config = state
.spacetime_client()
.get_bark_battle_runtime_config(work_id.clone(), Some(owner_user_id.clone()))
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let runtime_config = map_runtime_config_record(runtime_config, &request_context)?;
if !request.work_id.trim().is_empty() && request.work_id.trim() != work_id {
return Err(bark_battle_bad_request(
&request_context,
"workId 与路径参数不一致",
));
}
if let Some(expected_version) = request.config_version {
if expected_version != runtime_config.config_version {
return Err(bark_battle_bad_request(
&request_context,
"configVersion 与已发布配置不一致",
));
}
}
let client_started_at_micros = current_utc_micros();
let run_token = build_prefixed_uuid_id(BARK_BATTLE_RUN_TOKEN_PREFIX);
let run = state
.spacetime_client()
.start_bark_battle_run(BarkBattleRunStartRecordInput {
run_id: build_prefixed_uuid_id(BARK_BATTLE_RUN_ID_PREFIX),
run_token: run_token.clone(),
owner_user_id: owner_user_id.clone(),
work_id: work_id.clone(),
config_version: u64::from(runtime_config.config_version),
ruleset_version: runtime_config.ruleset_version.clone(),
difficulty_preset: difficulty_to_spacetime_string(&runtime_config.difficulty_preset)
.to_string(),
client_started_at_micros,
server_started_at_micros: client_started_at_micros,
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let run_snapshot = parse_run_record(run, &request_context)?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
BARK_BATTLE_PLAY_TYPE_ID,
work_id.clone(),
&authenticated,
"/api/runtime/bark-battle/...",
)
.extra(json!({
"runId": run_snapshot.run_id,
"workId": work_id,
"configVersion": runtime_config.config_version,
"rulesetVersion": runtime_config.ruleset_version,
"difficultyPreset": runtime_config.difficulty_preset,
"sourceRoute": request.source_route,
"clientRuntimeVersion": request.client_runtime_version,
})),
)
.await;
let server_started_at = format_timestamp_micros(run_snapshot.server_started_at_micros);
let expires_at = format_timestamp_micros(
run_snapshot
.server_started_at_micros
.saturating_add(BARK_BATTLE_RUN_TTL_SECONDS * 1_000_000),
);
Ok(json_success_body(
Some(&request_context),
BarkBattleRunStartResponse {
run_id: run_snapshot.run_id,
run_token,
work_id: run_snapshot.work_id,
config_version: runtime_config.config_version,
ruleset_version: runtime_config.ruleset_version.clone(),
difficulty_preset: runtime_config.difficulty_preset.clone(),
runtime_config,
server_started_at,
expires_at,
},
))
}
pub async fn get_bark_battle_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_bark_battle_run(run_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let run = parse_run_record(run, &request_context)?;
Ok(json_success_body(Some(&request_context), run))
}
pub async fn finish_bark_battle_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<BarkBattleRunFinishRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = bark_battle_json(payload, &request_context)?;
ensure_non_empty(&request_context, &run_id, "runId")?;
ensure_non_empty(&request_context, &payload.work_id, "workId")?;
ensure_non_empty(&request_context, &payload.run_token, "runToken")?;
if payload.run_id != run_id {
return Err(bark_battle_bad_request(
&request_context,
"runId 与路径参数不一致",
));
}
if payload.ruleset_version != BARK_BATTLE_RULESET_VERSION_V1 {
return Err(bark_battle_bad_request(
&request_context,
"rulesetVersion 不支持",
));
}
let client_finished_at_micros = parse_client_time_to_micros(&payload.client_finished_at)
.map_err(|message| bark_battle_bad_request(&request_context, &message))?;
let derived = &payload.derived_metrics;
let opponent_final_energy = derive_server_opponent_final_energy(derived);
let metrics_json = serde_json::to_string(&json!({
"clientStartedAt": payload.client_started_at,
"clientFinishedAt": payload.client_finished_at,
"durationMs": payload.duration_ms,
"derivedMetrics": payload.derived_metrics,
"clientResult": payload.client_result,
"sampleDigest": payload.sample_digest,
"clientRuntimeVersion": payload.client_runtime_version,
}))
.unwrap_or_else(|_| "{}".to_string());
let derived_metrics_json = serde_json::to_string(derived).unwrap_or_else(|_| "{}".to_string());
let run = state
.spacetime_client()
.finish_bark_battle_run(BarkBattleRunFinishRecordInput {
run_id,
run_token: payload.run_token,
owner_user_id: authenticated.claims().user_id().to_string(),
work_id: payload.work_id.clone(),
config_version: u64::from(payload.config_version),
ruleset_version: payload.ruleset_version.clone(),
difficulty_preset: difficulty_to_spacetime_string(&payload.difficulty_preset)
.to_string(),
client_finished_at_micros,
server_finished_at_micros: current_utc_micros(),
duration_ms: payload.duration_ms,
trigger_count: u64::from(derived.trigger_count),
max_volume_millis: unit_to_millis(derived.max_volume),
average_volume_millis: unit_to_millis(derived.average_volume),
final_energy_millis: energy_to_millis(derived.final_energy),
opponent_final_energy_millis: energy_to_millis(opponent_final_energy),
max_combo: derived.combo_max,
metrics_json,
derived_metrics_json,
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let run = parse_run_record(run, &request_context)?;
Ok(json_success_body(
Some(&request_context),
map_finish_response(run, &payload.derived_metrics),
))
}
fn map_finish_response(
run: BarkBattleRunSnapshotRecord,
fallback_metrics: &BarkBattleDerivedMetrics,
) -> BarkBattleRunFinishResponse {
let score_summary =
parse_score_summary(&run.metrics_json).unwrap_or_else(|| BarkBattleScoreSummary {
duration_ms: 0,
trigger_count: fallback_metrics.trigger_count,
max_volume: fallback_metrics.max_volume,
average_volume: fallback_metrics.average_volume,
final_energy: fallback_metrics.final_energy,
combo_max: fallback_metrics.combo_max,
});
BarkBattleRunFinishResponse {
status: parse_finish_status(&run.validation_status),
run_id: run.run_id,
work_id: run.work_id,
config_version: run.config_version.min(u64::from(u32::MAX)) as u32,
ruleset_version: run.ruleset_version,
difficulty_preset: parse_difficulty_lossy(&run.difficulty_preset),
server_result: parse_server_result_lossy(run.server_result.as_deref()),
score_summary,
leaderboard_score: run.leaderboard_score,
anti_cheat_flags: parse_string_vec(&run.anti_cheat_flags_json),
updated_at: format_timestamp_micros(
run.server_finished_at_micros
.unwrap_or(run.server_started_at_micros),
),
}
}
fn parse_run_record(
value: BarkBattleRunRecord,
request_context: &RequestContext,
) -> Result<BarkBattleRunSnapshotRecord, Response> {
serde_json::from_value(value).map_err(|error| {
bark_battle_error_response(
request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("Bark Battle run JSON 解析失败: {error}"),
})),
)
})
}
fn parse_draft_snapshot_record(
value: Value,
request_context: &RequestContext,
) -> Result<BarkBattleDraftConfigSnapshotRecord, Response> {
serde_json::from_value(value)
.map_err(|error| bark_battle_snapshot_parse_error(request_context, "draft config", error))
}
fn parse_runtime_snapshot_record(
value: Value,
request_context: &RequestContext,
) -> Result<BarkBattleRuntimeConfigSnapshotRecord, Response> {
serde_json::from_value(value)
.map_err(|error| bark_battle_snapshot_parse_error(request_context, "runtime config", error))
}
fn map_draft_config_record(
value: Value,
request_context: &RequestContext,
) -> Result<BarkBattleDraftConfig, Response> {
let snapshot = parse_draft_snapshot_record(value, request_context)?;
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
Ok(BarkBattleDraftConfig {
draft_id: snapshot.draft_id,
title: editor_config.title,
description: editor_config.description,
theme_preset: editor_config.theme_preset,
player_dog_skin_preset: editor_config.player_dog_skin_preset,
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
difficulty_preset: editor_config.difficulty_preset,
leaderboard_enabled: editor_config.leaderboard_enabled,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
})
}
fn map_runtime_config_record(
value: Value,
request_context: &RequestContext,
) -> Result<shared_contracts::bark_battle::BarkBattleRuntimeConfig, Response> {
let snapshot = parse_runtime_snapshot_record(value, request_context)?;
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
let ruleset = BarkBattleRuleset::v1();
Ok(shared_contracts::bark_battle::BarkBattleRuntimeConfig {
work_id: snapshot.work_id,
config_version: snapshot.config_version.min(u64::from(u32::MAX)) as u32,
ruleset_version: snapshot.ruleset_version,
play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(),
duration_ms: ruleset.standard_duration_ms,
energy_min: 0.0,
energy_max: 100.0,
draw_threshold: ruleset.draw_threshold_energy as f32,
min_bark_gap_ms: ruleset.min_bark_gap_ms,
difficulty_preset: editor_config.difficulty_preset,
theme_preset: editor_config.theme_preset,
player_dog_skin_preset: editor_config.player_dog_skin_preset,
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
leaderboard_enabled: editor_config.leaderboard_enabled,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
})
}
fn map_published_config_record(
value: Value,
request_context: &RequestContext,
) -> Result<BarkBattlePublishedConfig, Response> {
let snapshot = parse_runtime_snapshot_record(value, request_context)?;
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
Ok(BarkBattlePublishedConfig {
work_id: snapshot.work_id,
draft_id: snapshot.source_draft_id,
config_version: snapshot.config_version.min(u64::from(u32::MAX)) as u32,
ruleset_version: snapshot.ruleset_version,
play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(),
title: editor_config.title,
description: editor_config.description,
theme_preset: editor_config.theme_preset,
player_dog_skin_preset: editor_config.player_dog_skin_preset,
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
difficulty_preset: editor_config.difficulty_preset,
leaderboard_enabled: editor_config.leaderboard_enabled,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
published_at: format_timestamp_micros(snapshot.published_at_micros),
})
}
fn parse_editor_config_record(
config_json: &str,
request_context: &RequestContext,
) -> Result<BarkBattleConfigEditorPayload, Response> {
serde_json::from_str(config_json).map_err(|error| {
bark_battle_error_response(
request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("Bark Battle configJson 解析失败: {error}"),
})),
)
})
}
fn bark_battle_snapshot_parse_error(
request_context: &RequestContext,
label: &str,
error: serde_json::Error,
) -> Response {
bark_battle_error_response(
request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("Bark Battle {label} JSON 解析失败: {error}"),
})),
)
}
fn bark_battle_json<T>(
payload: Result<Json<T>, JsonRejection>,
request_context: &RequestContext,
) -> Result<Json<T>, Response> {
payload.map_err(|error| {
bark_battle_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})
}
fn ensure_non_empty(
request_context: &RequestContext,
value: &str,
field_name: &str,
) -> Result<(), Response> {
if value.trim().is_empty() {
return Err(bark_battle_bad_request(
request_context,
&format!("{field_name} is required"),
));
}
Ok(())
}
fn bark_battle_bad_request(request_context: &RequestContext, message: &str) -> Response {
bark_battle_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": message,
})),
)
}
fn map_bark_battle_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message)
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("不能为空")
|| message.contains("不匹配")
|| message.contains("不支持")
|| message.contains("已结束")
|| message.contains("已存在") =>
{
StatusCode::BAD_REQUEST
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn bark_battle_error_response(request_context: &RequestContext, error: AppError) -> Response {
let mut response = error.into_response_with_context(Some(request_context));
response.headers_mut().insert(
HeaderName::from_static("x-genarrative-provider"),
header::HeaderValue::from_static(BARK_BATTLE_RUNTIME_PROVIDER),
);
response
}
fn parse_client_time_to_micros(value: &str) -> Result<i64, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("client timestamp is required".to_string());
}
if let Ok(micros) = trimmed.parse::<i64>() {
return Ok(micros);
}
parse_rfc3339(trimmed).map(offset_datetime_to_unix_micros)
}
fn current_utc_micros() -> i64 {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
}
fn unit_to_millis(value: f32) -> u32 {
(value.clamp(0.0, 1.0) * 1_000.0).round() as u32
}
fn energy_to_millis(value: f32) -> u32 {
(value.clamp(0.0, 100.0) * 1_000.0).round() as u32
}
fn derive_server_opponent_final_energy(metrics: &BarkBattleDerivedMetrics) -> f32 {
let ruleset = BarkBattleRuleset::v1();
let pressure = (metrics.average_volume * 24.0)
+ (metrics.max_volume * 16.0)
+ (metrics.trigger_count as f32 * 0.35)
+ (metrics.combo_max as f32 * 0.2);
(ruleset.max_final_energy - pressure).clamp(ruleset.min_final_energy, ruleset.max_final_energy)
}
fn difficulty_to_spacetime_string(value: &BarkBattleDifficultyPreset) -> &'static str {
match value {
BarkBattleDifficultyPreset::Easy => "easy",
BarkBattleDifficultyPreset::Normal => "normal",
BarkBattleDifficultyPreset::Hard => "hard",
}
}
fn parse_difficulty(value: &str) -> Result<BarkBattleDifficultyPreset, AppError> {
match value {
"easy" => Ok(BarkBattleDifficultyPreset::Easy),
"normal" => Ok(BarkBattleDifficultyPreset::Normal),
"hard" => Ok(BarkBattleDifficultyPreset::Hard),
_ => Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
"message": format!("Bark Battle difficultyPreset 不支持: {value}"),
})),
),
}
}
fn parse_difficulty_lossy(value: &str) -> BarkBattleDifficultyPreset {
parse_difficulty(value).unwrap_or(BarkBattleDifficultyPreset::Normal)
}
fn parse_finish_status(value: &str) -> BarkBattleFinishStatus {
match value {
"accepted" => BarkBattleFinishStatus::Accepted,
"accepted_with_flags" => BarkBattleFinishStatus::AcceptedWithFlags,
"rejected" => BarkBattleFinishStatus::Rejected,
_ => BarkBattleFinishStatus::Rejected,
}
}
fn parse_server_result_lossy(value: Option<&str>) -> BarkBattleServerResult {
match value {
Some("player_win") => BarkBattleServerResult::PlayerWin,
Some("opponent_win") => BarkBattleServerResult::OpponentWin,
Some("draw") => BarkBattleServerResult::Draw,
_ => BarkBattleServerResult::Draw,
}
}
fn parse_score_summary(metrics_json: &str) -> Option<BarkBattleScoreSummary> {
let value: Value = serde_json::from_str(metrics_json).ok()?;
let derived = value.get("derivedMetrics")?;
Some(BarkBattleScoreSummary {
duration_ms: value.get("durationMs")?.as_u64()?,
trigger_count: derived
.get("triggerCount")?
.as_u64()?
.min(u64::from(u32::MAX)) as u32,
max_volume: derived.get("maxVolume")?.as_f64()? as f32,
average_volume: derived.get("averageVolume")?.as_f64()? as f32,
final_energy: derived.get("finalEnergy")?.as_f64()? as f32,
combo_max: derived.get("comboMax")?.as_u64()?.min(u64::from(u32::MAX)) as u32,
})
}
fn parse_string_vec(value: &str) -> Vec<String> {
serde_json::from_str(value).unwrap_or_default()
}
#[allow(dead_code)]
fn format_rfc3339_or_timestamp_micros(micros: i64) -> String {
let seconds = micros.div_euclid(1_000_000);
let subsec_micros = micros.rem_euclid(1_000_000);
let Ok(value) = OffsetDateTime::from_unix_timestamp(seconds)
.map(|value| value + TimeDuration::microseconds(subsec_micros))
else {
return format_timestamp_micros(micros);
};
format_rfc3339(value).unwrap_or_else(|_| format_timestamp_micros(micros))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unit_and_energy_are_clamped_to_spacetime_millis() {
assert_eq!(unit_to_millis(0.625), 625);
assert_eq!(unit_to_millis(3.0), 1000);
assert_eq!(energy_to_millis(88.456), 88_456);
assert_eq!(energy_to_millis(120.0), 100_000);
}
#[test]
fn parses_rfc3339_and_numeric_client_timestamps() {
assert_eq!(
parse_client_time_to_micros("1713686401234567").unwrap(),
1_713_686_401_234_567
);
assert_eq!(
parse_client_time_to_micros("2024-04-21T04:00:01.234567Z").unwrap(),
1_713_672_001_234_567
);
}
}

View File

@@ -78,6 +78,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
if normalized.starts_with("/api/runtime/match3d") {
return Some("match3d");
}
if normalized.starts_with("/api/runtime/bark-battle") {
return Some("bark-battle");
}
if normalized.starts_with("/api/runtime/square-hole") {
return Some("square-hole");
}
@@ -90,6 +93,12 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
if normalized.starts_with("/api/creation/visual-novel") {
return Some("visual-novel");
}
if normalized.starts_with("/api/creation/edutainment/baby-object-match") {
return Some("baby-object-match");
}
if normalized.starts_with("/api/creation/edutainment/baby-love-drawing") {
return Some("baby-love-drawing");
}
None
}
@@ -112,40 +121,11 @@ pub(crate) fn test_creation_entry_config_response()
title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
},
creation_types: vec![
test_creation_type("rpg", false, true, 10),
test_creation_type("big-fish", false, true, 20),
test_creation_type("puzzle", true, true, 30),
test_creation_type("match3d", true, true, 40),
test_creation_type("square-hole", false, true, 50),
test_creation_type("visual-novel", true, false, 60),
test_creation_type("airp", true, false, 70),
test_creation_type("creative-agent", false, true, 80),
],
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
updated_at_micros: 0,
})
}
#[cfg(test)]
fn test_creation_type(
id: &str,
visible: bool,
open: bool,
sort_order: i32,
) -> module_runtime::CreationEntryTypeSnapshot {
module_runtime::CreationEntryTypeSnapshot {
id: id.to_string(),
title: id.to_string(),
subtitle: "测试入口".to_string(),
badge: "测试".to_string(),
image_src: format!("/creation-type-references/{id}.webp"),
visible,
open,
sort_order,
updated_at_micros: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -172,6 +152,33 @@ mod tests {
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
Some("visual-novel"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
Some("bark-battle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/edutainment/baby-object-match/assets"),
Some("baby-object-match"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/edutainment/baby-love-drawing/magic"),
Some("baby-love-drawing"),
);
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
#[test]
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
let config = test_creation_entry_config_response();
let baby_object_match = config
.creation_types
.iter()
.find(|item| item.id == "baby-object-match")
.expect("test creation entry config should include baby-object-match");
assert_eq!(baby_object_match.title, "宝贝识物");
assert!(baby_object_match.visible);
assert!(baby_object_match.open);
assert_eq!(baby_object_match.sort_order, 90);
}
}

View File

@@ -0,0 +1,337 @@
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{ColorType, ImageEncoder, codecs::png::PngEncoder};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
api_response::json_success_body,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
};
const BABY_LOVE_DRAWING_PROVIDER: &str = "vector-engine-gpt-image-2";
const BABY_LOVE_DRAWING_IMAGE_SIZE: &str = "1024x1024";
const BABY_LOVE_DRAWING_MAX_STROKES: usize = 600;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreateBabyLoveDrawingMagicRequest {
original_image_src: String,
#[serde(default)]
stroke_trace: Vec<BabyLoveDrawingStrokePayload>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BabyLoveDrawingStrokePayload {
stroke_id: String,
tool: String,
color: String,
#[serde(default)]
points: Vec<BabyLoveDrawingPointPayload>,
}
#[derive(Debug, Deserialize)]
struct BabyLoveDrawingPointPayload {
x: f64,
y: f64,
t: f64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateBabyLoveDrawingMagicResponse {
magic_image_src: String,
generation_provider: String,
prompt: String,
}
pub async fn create_baby_love_drawing_magic(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<CreateBabyLoveDrawingMagicRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
baby_love_drawing_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "edutainment-baby-drawing",
"message": error.body_text(),
})),
)
})?;
validate_magic_request(&payload)
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
let settings = require_openai_image_settings(&state)
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
let http_client = build_openai_image_http_client(&settings)
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
let prompt = build_baby_love_drawing_magic_prompt(payload.stroke_trace.as_slice());
let reference_images = vec![payload.original_image_src.trim().to_string()];
let generated = create_openai_image_generation(
&http_client,
&settings,
prompt.as_str(),
Some(build_baby_love_drawing_negative_prompt()),
BABY_LOVE_DRAWING_IMAGE_SIZE,
1,
reference_images.as_slice(),
"宝贝爱画绘画魔法图片生成失败",
)
.await
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
baby_love_drawing_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "宝贝爱画绘画魔法没有返回图片。",
})),
)
})?;
let magic_image_src = build_png_data_url(generated_image)
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
CreateBabyLoveDrawingMagicResponse {
magic_image_src,
generation_provider: BABY_LOVE_DRAWING_PROVIDER.to_string(),
prompt,
},
))
}
fn validate_magic_request(payload: &CreateBabyLoveDrawingMagicRequest) -> Result<(), AppError> {
let original_image_src = payload.original_image_src.trim();
if !original_image_src.starts_with("data:image/") || !original_image_src.contains(";base64,") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "edutainment-baby-drawing",
"message": "绘画原图必须是图片 Data URL。",
})),
);
}
if payload.stroke_trace.len() > BABY_LOVE_DRAWING_MAX_STROKES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "edutainment-baby-drawing",
"message": "绘画笔触数量过多,请重新完成绘画后再使用魔法。",
})),
);
}
Ok(())
}
fn build_baby_love_drawing_magic_prompt(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
let stroke_count = stroke_trace.len();
let brush_count = stroke_trace
.iter()
.filter(|stroke| stroke.tool.trim() == "brush")
.count();
let eraser_count = stroke_trace
.iter()
.filter(|stroke| stroke.tool.trim() == "eraser")
.count();
let color_summary = summarize_stroke_colors(stroke_trace);
let trace_bounds = summarize_trace_bounds(stroke_trace);
format!(
"根据参考图中的儿童绘画内容,为寓教于乐独立关卡“宝贝爱画”生成一张绘本风格图片。\n\
必须保留小朋友原始画面的主体构图、线条方向、颜色关系和童趣笔触,不要改成与原图无关的新内容。\n\
输出风格:明亮、温暖、柔和、卡通绘本风格,适合 4-8 岁儿童,画面干净,边缘柔和,有轻微纸面质感。\n\
笔触信息:总笔触 {stroke_count} 条,画笔 {brush_count} 条,橡皮 {eraser_count} 条,主要颜色 {color_summary},绘制范围 {trace_bounds}\n\
不要生成文字、水印、Logo、按钮、UI 面板、真实照片风、恐怖或成人化内容。"
)
}
fn summarize_stroke_colors(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
let mut colors = Vec::new();
for stroke in stroke_trace {
if stroke.stroke_id.trim().is_empty() {
continue;
}
let color = stroke.color.trim();
if color.is_empty() || colors.iter().any(|value| value == color) {
continue;
}
colors.push(color.to_string());
if colors.len() >= 5 {
break;
}
}
if colors.is_empty() {
"无明显颜色记录".to_string()
} else {
colors.join("")
}
}
fn summarize_trace_bounds(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
let mut min_x = 1.0_f64;
let mut min_y = 1.0_f64;
let mut max_x = 0.0_f64;
let mut max_y = 0.0_f64;
let mut has_point = false;
for point in stroke_trace.iter().flat_map(|stroke| stroke.points.iter()) {
if !(point.x.is_finite() && point.y.is_finite() && point.t.is_finite()) {
continue;
}
has_point = true;
min_x = min_x.min(point.x.clamp(0.0, 1.0));
min_y = min_y.min(point.y.clamp(0.0, 1.0));
max_x = max_x.max(point.x.clamp(0.0, 1.0));
max_y = max_y.max(point.y.clamp(0.0, 1.0));
}
if !has_point {
return "无可用坐标记录".to_string();
}
format!("x {:.2}-{:.2}, y {:.2}-{:.2}", min_x, max_x, min_y, max_y)
}
fn build_baby_love_drawing_negative_prompt() -> &'static str {
"文字水印Logo按钮UI面板复杂背景真实照片风恐怖元素成人化内容攻击性内容替换原图主体完全无关的新画面"
}
fn build_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(png_bytes)
))
}
fn normalize_generated_image_to_png(source: &[u8]) -> Result<Vec<u8>, AppError> {
let rgba_image = image::load_from_memory(source)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("解析宝贝爱画魔法图片失败:{error}"),
}))
})?
.to_rgba8();
let (width, height) = rgba_image.dimensions();
let mut encoded = Vec::new();
let encoder = PngEncoder::new(&mut encoded);
encoder
.write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into())
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("转换宝贝爱画魔法图片为 PNG 失败:{error}"),
}))
})?;
Ok(encoded)
}
fn baby_love_drawing_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_request() -> CreateBabyLoveDrawingMagicRequest {
CreateBabyLoveDrawingMagicRequest {
original_image_src: "data:image/png;base64,abcd".to_string(),
stroke_trace: vec![BabyLoveDrawingStrokePayload {
stroke_id: "stroke-1".to_string(),
tool: "brush".to_string(),
color: "#ef4444".to_string(),
points: vec![
BabyLoveDrawingPointPayload {
x: 0.2,
y: 0.3,
t: 1.0,
},
BabyLoveDrawingPointPayload {
x: 0.7,
y: 0.8,
t: 2.0,
},
],
}],
}
}
#[test]
fn magic_prompt_keeps_child_drawing_and_picture_book_style() {
let request = sample_request();
let prompt = build_baby_love_drawing_magic_prompt(request.stroke_trace.as_slice());
assert!(prompt.contains("宝贝爱画"));
assert!(prompt.contains("绘本风格"));
assert!(prompt.contains("保留小朋友原始画面"));
assert!(prompt.contains("#ef4444"));
assert!(prompt.contains("x 0.20-0.70"));
}
#[test]
fn magic_request_requires_image_data_url() {
let request = sample_request();
assert!(validate_magic_request(&request).is_ok());
let invalid = CreateBabyLoveDrawingMagicRequest {
original_image_src: "https://example.test/image.png".to_string(),
..sample_request()
};
assert!(validate_magic_request(&invalid).is_err());
}
#[test]
fn normalizes_png_to_png_data_url() {
let mut source = Vec::new();
let pixels = vec![255u8; 4 * 2 * 2];
let encoder = PngEncoder::new(&mut source);
encoder
.write_image(pixels.as_slice(), 2, 2, ColorType::Rgba8.into())
.expect("test png should encode");
let image_src = build_png_data_url(DownloadedOpenAiImage {
bytes: source,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
.expect("test png should normalize");
assert!(image_src.starts_with("data:image/png;base64,"));
}
#[test]
fn trace_summary_ignores_invalid_points() {
let mut request = sample_request();
request.stroke_trace[0]
.points
.push(BabyLoveDrawingPointPayload {
x: f64::NAN,
y: 0.1,
t: 3.0,
});
assert_eq!(
summarize_trace_bounds(request.stroke_trace.as_slice()),
"x 0.20-0.70, y 0.30-0.80",
);
}
}

View File

@@ -0,0 +1,642 @@
use std::time::Instant;
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use futures_util::{StreamExt, stream::FuturesUnordered};
use image::{ColorType, ImageEncoder, codecs::png::PngEncoder};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
api_response::json_success_body,
character_visual_assets::try_apply_background_alpha_to_png,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, OpenAiImageSettings, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
};
const BABY_OBJECT_MATCH_PROVIDER: &str = "vector-engine-gpt-image-2";
const BABY_OBJECT_MATCH_IMAGE_SIZE: &str = "1024x1024";
const BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE: &str = "1536x1024";
const BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS: u64 = 480_000;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateBabyObjectMatchAssetsRequest {
item_names: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GenerateBabyObjectMatchAssetsResponse {
assets: Vec<BabyObjectMatchItemAssetPayload>,
visual_package: BabyObjectMatchVisualPackagePayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BabyObjectMatchItemAssetPayload {
item_id: String,
item_name: String,
image_src: String,
asset_object_id: Option<String>,
generation_provider: String,
prompt: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BabyObjectMatchVisualAssetKind {
Background,
UiFrame,
GiftBox,
Basket,
SmokePuff,
}
impl BabyObjectMatchVisualAssetKind {
fn asset_id(self) -> &'static str {
match self {
Self::Background => "baby-object-visual-background",
Self::UiFrame => "baby-object-visual-ui-frame",
Self::GiftBox => "baby-object-visual-gift-box",
Self::Basket => "baby-object-visual-basket",
Self::SmokePuff => "baby-object-visual-smoke-puff",
}
}
fn contract_kind(self) -> &'static str {
match self {
Self::Background => "background",
Self::UiFrame => "ui-frame",
Self::GiftBox => "gift-box",
Self::Basket => "basket",
Self::SmokePuff => "smoke-puff",
}
}
fn requires_transparency(self) -> bool {
!matches!(self, Self::Background)
}
fn image_size(self) -> &'static str {
match self {
Self::Background => BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE,
Self::UiFrame | Self::GiftBox | Self::Basket | Self::SmokePuff => {
BABY_OBJECT_MATCH_IMAGE_SIZE
}
}
}
fn failure_context(self) -> &'static str {
match self {
Self::Background => "宝贝识物背景环境图片生成失败",
Self::UiFrame => "宝贝识物 UI 装饰图片生成失败",
Self::GiftBox => "宝贝识物礼物盒图片生成失败",
Self::Basket => "宝贝识物篮子图片生成失败",
Self::SmokePuff => "宝贝识物烟雾特效图片生成失败",
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BabyObjectMatchVisualPackagePayload {
theme_prompt: String,
assets: Vec<BabyObjectMatchVisualAssetPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BabyObjectMatchVisualAssetPayload {
asset_id: String,
asset_kind: String,
image_src: String,
asset_object_id: Option<String>,
generation_provider: String,
prompt: String,
}
pub async fn generate_baby_object_match_assets(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<GenerateBabyObjectMatchAssetsRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
baby_object_match_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "edutainment-baby-object",
"message": error.body_text(),
})),
)
})?;
let item_names = normalize_item_names(payload.item_names)
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
let settings = require_openai_image_settings(&state)
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
let settings = with_baby_object_match_image_timeout(settings);
let http_client = build_openai_image_http_client(&settings)
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
let request_started_at = Instant::now();
tracing::info!(
item_count = item_names.len(),
"宝贝识物 image-2 资源生成开始"
);
let (assets, visual_package) = tokio::try_join!(
build_baby_object_match_item_assets(&http_client, &settings, item_names.as_slice()),
build_baby_object_match_visual_package(&http_client, &settings, item_names.as_slice()),
)
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
tracing::info!(
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
"宝贝识物 image-2 资源生成完成"
);
Ok(json_success_body(
Some(&request_context),
GenerateBabyObjectMatchAssetsResponse {
assets,
visual_package,
},
))
}
fn normalize_item_names(item_names: Vec<String>) -> Result<Vec<String>, AppError> {
let normalized = item_names
.into_iter()
.map(|value| value.trim().to_string())
.collect::<Vec<_>>();
if normalized.len() != 2 || normalized.iter().any(|value| value.is_empty()) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "edutainment-baby-object",
"message": "请填写两个物品名称。",
})),
);
}
Ok(normalized)
}
fn build_baby_object_match_item_prompt(item_name: &str) -> String {
format!(
"为儿童动作 Demo 玩法“宝贝识物”生成物品素材。关键词:{item_name}\n\
风格必须与寓教于乐板块统一:明亮、温暖、卡通绘本质感,适合 4-8 岁儿童,物体边缘清晰,色彩干净,能自然放在草地舞台插画中。\n\
画面只允许出现一个围绕关键词“{item_name}”的单一物品主体,不要生成组合物、多个物体、人物、手、篮子、礼物盒或玩法 UI。\n\
不要生成背景、场景、氛围渲染、阴影地面、文字、水印、边框或按钮。背景必须是纯白或直接透明,便于服务端做透明抠图。\n\
输出为居中完整物品,留少量透明安全边距,最终素材将作为透明 PNG 进入游戏。"
)
}
fn build_baby_object_match_negative_prompt() -> &'static str {
"背景场景草地天空房间光效氛围多个物品组合套装人物篮子礼物盒包装文字标签文字水印LogoUI按钮边框真实照片风复杂投影"
}
fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> OpenAiImageSettings {
settings.request_timeout_ms = settings
.request_timeout_ms
.max(BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS);
settings
}
async fn build_baby_object_match_item_assets(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
item_names: &[String],
) -> Result<Vec<BabyObjectMatchItemAssetPayload>, AppError> {
let mut pending = FuturesUnordered::new();
// 中文注释:两个物品图互不依赖,并发生成可缩短创作等待时间。
for (index, item_name) in item_names.iter().cloned().enumerate() {
let prompt = build_baby_object_match_item_prompt(item_name.as_str());
pending.push(async move {
let asset_started_at = Instant::now();
tracing::info!(
asset_kind = "item",
item_index = index + 1,
item_name = %item_name,
"宝贝识物 image-2 物品资源生成开始"
);
let generated = create_openai_image_generation(
http_client,
settings,
prompt.as_str(),
Some(build_baby_object_match_negative_prompt()),
BABY_OBJECT_MATCH_IMAGE_SIZE,
1,
&[],
"宝贝识物物品图片生成失败",
)
.await?;
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "宝贝识物物品图片生成没有返回图片。",
}))
})?;
let image_src = build_transparent_png_data_url(generated_image)?;
tracing::info!(
asset_kind = "item",
item_index = index + 1,
item_name = %item_name,
elapsed_ms = asset_started_at.elapsed().as_millis() as u64,
"宝贝识物 image-2 物品资源生成完成"
);
Ok::<_, AppError>(BabyObjectMatchItemAssetPayload {
item_id: format!("baby-object-item-{}", index + 1),
item_name,
image_src,
asset_object_id: None,
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
prompt,
})
});
}
let mut assets = Vec::with_capacity(item_names.len());
while let Some(result) = pending.next().await {
assets.push(result?);
}
assets.sort_by_key(|asset| asset.item_id.clone());
Ok(assets)
}
async fn build_baby_object_match_visual_package(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
item_names: &[String],
) -> Result<BabyObjectMatchVisualPackagePayload, AppError> {
let package_started_at = Instant::now();
let theme_prompt = build_baby_object_match_visual_theme_prompt(item_names);
let kinds = [
BabyObjectMatchVisualAssetKind::Background,
BabyObjectMatchVisualAssetKind::UiFrame,
BabyObjectMatchVisualAssetKind::GiftBox,
BabyObjectMatchVisualAssetKind::Basket,
BabyObjectMatchVisualAssetKind::SmokePuff,
];
let mut pending = FuturesUnordered::new();
tracing::info!(
asset_count = kinds.len(),
"宝贝识物 image-2 视觉主题包生成开始"
);
for kind in kinds.iter().copied() {
let prompt = build_baby_object_match_visual_asset_prompt(kind, item_names, &theme_prompt);
pending.push(async move {
let asset_started_at = Instant::now();
let asset_kind = kind.contract_kind();
tracing::info!(asset_kind, "宝贝识物 image-2 视觉资源生成开始");
let generated = create_openai_image_generation(
http_client,
settings,
prompt.as_str(),
Some(build_baby_object_match_visual_negative_prompt(kind)),
kind.image_size(),
1,
&[],
kind.failure_context(),
)
.await?;
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("{}VectorEngine 没有返回图片。", kind.failure_context()),
}))
})?;
let image_src = if kind.requires_transparency() {
build_transparent_png_data_url(generated_image)?
} else {
build_png_data_url(generated_image)?
};
tracing::info!(
asset_kind,
elapsed_ms = asset_started_at.elapsed().as_millis() as u64,
"宝贝识物 image-2 视觉资源生成完成"
);
Ok::<_, AppError>(BabyObjectMatchVisualAssetPayload {
asset_id: kind.asset_id().to_string(),
asset_kind: asset_kind.to_string(),
image_src,
asset_object_id: None,
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
prompt,
})
});
}
let mut assets = Vec::with_capacity(kinds.len());
while let Some(result) = pending.next().await {
assets.push(result?);
}
assets.sort_by_key(|asset| match asset.asset_kind.as_str() {
"background" => 0,
"ui-frame" => 1,
"gift-box" => 2,
"basket" => 3,
"smoke-puff" => 4,
_ => 5,
});
tracing::info!(
elapsed_ms = package_started_at.elapsed().as_millis() as u64,
"宝贝识物 image-2 视觉主题包生成完成"
);
Ok(BabyObjectMatchVisualPackagePayload {
theme_prompt,
assets,
})
}
fn build_baby_object_match_visual_theme_prompt(item_names: &[String]) -> String {
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
let theme_hint = resolve_baby_object_match_theme_hint(item_names);
format!(
"根据创作者填写的两个物品关键词“{item_a}”和“{item_b}”,为儿童动作 Demo 玩法“宝贝识物”生成一套完整游戏视觉包装。\n\
视觉必须保持寓教于乐板块统一的明亮、温暖、卡通绘本插画风,适合 4-8 岁儿童。\n\
主题匹配:{theme_hint}\n\
所有资源需要围绕这两个关键词形成统一主题,但不能改变物品识别和左右篮子固定规则。"
)
}
fn resolve_baby_object_match_theme_hint(item_names: &[String]) -> &'static str {
let joined = item_names.join(" ").to_lowercase();
let fruit_keywords = [
"苹果",
"橘子",
"桔子",
"香蕉",
"葡萄",
"草莓",
"西瓜",
"",
"",
"水果",
"apple",
"orange",
"banana",
"grape",
"strawberry",
"watermelon",
"fruit",
];
let character_keywords = [
"佩琪",
"小猪佩奇",
"小猪佩琪",
"奥特曼",
"动漫",
"动画",
"卡通",
"玩具",
"角色",
"公仔",
"peppa",
"ultraman",
"anime",
"cartoon",
"toy",
"doll",
"figure",
];
if fruit_keywords
.iter()
.any(|keyword| joined.contains(keyword))
{
return "若关键词属于水果,背景环境和 UI 元素匹配果园、自然、阳光、树叶等主题。";
}
if character_keywords
.iter()
.any(|keyword| joined.contains(keyword))
{
return "若关键词属于动漫角色、玩具或公仔,背景环境和 UI 元素匹配动漫、玩具房、儿童玩具等主题。";
}
"根据关键词语义自然匹配合适主题,保持儿童寓教于乐插画风。"
}
fn build_baby_object_match_visual_asset_prompt(
kind: BabyObjectMatchVisualAssetKind,
item_names: &[String],
theme_prompt: &str,
) -> String {
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
let base = format!(
"{theme_prompt}\n\
当前两个关键词:{item_a}{item_b}\n\
输出必须是无文字、无水印、无 Logo 的游戏美术资源。"
);
match kind {
BabyObjectMatchVisualAssetKind::Background => format!(
"{base}\n\
生成游戏背景环境图。背景需要根据关键词主题匹配环境,例如水果可偏果园自然,动漫角色或玩具可偏动漫玩具主题。\n\
保持中间、屏幕中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右大篮子预留足够空间,不能画入礼物盒、篮子、物品、人物、文字或操作 UI。"
),
BabyObjectMatchVisualAssetKind::UiFrame => format!(
"{base}\n\
生成透明 PNG 的 UI 装饰框资源,用于字幕条和计数器的风格化包装。\n\
只生成柔和装饰边框、贴纸感边缘和少量主题点缀,不生成任何文字、数字、按钮、图标说明或大面积背景。背景需要纯白或透明友好,便于抠图。"
),
BabyObjectMatchVisualAssetKind::GiftBox => format!(
"{base}\n\
生成透明 PNG 的大号礼物盒资源。礼物盒会在游戏中以约 2 倍视觉尺寸展示,需要主体饱满、轮廓清晰、中心构图、边缘安全留白少,打开动画时可被烟雾遮罩后移除。\n\
礼物盒要与关键词主题匹配,可以带主题贴纸感装饰,但不能出现任何文字、人物、手、篮子或待分类物品。背景需要纯白或透明友好,便于抠图。"
),
BabyObjectMatchVisualAssetKind::Basket => format!(
"{base}\n\
生成透明 PNG 的大号篮子资源,游戏左右两侧会复用同一个篮子造型并以约 1.5 倍视觉尺寸展示。篮子主体要饱满、开口清晰、可读性高、边缘安全留白少。\n\
篮子要与关键词主题匹配,可以有主题色和贴纸感边缘,但不能出现任何文字、礼物盒、人物、手或待分类物品。背景需要纯白或透明友好,便于抠图。"
),
BabyObjectMatchVisualAssetKind::SmokePuff => format!(
"{base}\n\
生成透明 PNG 的烟雾弹出特效资源,用于礼物盒打开瞬间。画面只允许出现一团柔和、圆润、儿童绘本风的云朵烟雾和少量主题色星点,不要生成礼物盒、篮子、物品、人物、手、文字或 UI。\n\
烟雾需要中心构图、边缘柔和、透明边界干净,适合覆盖礼物盒打开区域并衬托中央物品弹出。背景需要纯白或透明友好,便于抠图。"
),
}
}
fn build_baby_object_match_visual_negative_prompt(
kind: BabyObjectMatchVisualAssetKind,
) -> &'static str {
match kind {
BabyObjectMatchVisualAssetKind::Background => {
"文字数字水印Logo按钮说明面板人物礼物盒篮子中心物品复杂前景遮挡真实照片风暗黑风"
}
BabyObjectMatchVisualAssetKind::UiFrame => {
"文字数字水印Logo按钮复杂面板大面积实心背景人物礼物盒篮子物品主体真实照片风"
}
BabyObjectMatchVisualAssetKind::GiftBox => {
"文字数字水印Logo人物篮子待分类物品大面积背景场景真实照片风"
}
BabyObjectMatchVisualAssetKind::Basket => {
"文字数字水印Logo人物礼物盒待分类物品大面积背景场景真实照片风"
}
BabyObjectMatchVisualAssetKind::SmokePuff => {
"文字数字水印Logo人物礼物盒篮子待分类物品大面积背景场景真实照片风硬边爆炸火焰"
}
}
}
fn build_transparent_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
let transparent_png_bytes =
try_apply_background_alpha_to_png(png_bytes.as_slice()).unwrap_or(png_bytes);
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(transparent_png_bytes)
))
}
fn build_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(png_bytes)
))
}
fn normalize_generated_image_to_png(source: &[u8]) -> Result<Vec<u8>, AppError> {
let rgba_image = image::load_from_memory(source)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("解析宝贝识物物品图片失败:{error}"),
}))
})?
.to_rgba8();
let (width, height) = rgba_image.dimensions();
let mut encoded = Vec::new();
let encoder = PngEncoder::new(&mut encoded);
encoder
.write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into())
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("转换宝贝识物物品图片为 PNG 失败:{error}"),
}))
})?;
Ok(encoded)
}
fn baby_object_match_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_locks_single_transparent_object_constraints() {
let prompt = build_baby_object_match_item_prompt("苹果");
assert!(prompt.contains("苹果"));
assert!(prompt.contains("卡通绘本"));
assert!(prompt.contains("单一物品"));
assert!(prompt.contains("不要生成背景"));
assert!(prompt.contains("透明 PNG"));
assert!(prompt.contains("纯白或直接透明"));
}
#[test]
fn visual_theme_prompt_maps_fruit_keywords_to_nature_theme() {
let names = vec!["苹果".to_string(), "橘子".to_string()];
let prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
assert!(prompt.contains("寓教于乐"));
assert!(prompt.contains("卡通绘本"));
assert!(prompt.contains("果园"));
assert!(prompt.contains("自然"));
}
#[test]
fn visual_theme_prompt_maps_character_keywords_to_toy_theme() {
let names = vec!["小猪佩琪".to_string(), "奥特曼".to_string()];
let prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
assert!(prompt.contains("寓教于乐"));
assert!(prompt.contains("动漫"));
assert!(prompt.contains("玩具"));
}
#[test]
fn visual_asset_prompt_keeps_background_clear_for_playfield() {
let names = vec!["苹果".to_string(), "香蕉".to_string()];
let theme_prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
let prompt = build_baby_object_match_visual_asset_prompt(
BabyObjectMatchVisualAssetKind::Background,
names.as_slice(),
theme_prompt.as_str(),
);
assert!(prompt.contains("背景环境图"));
assert!(prompt.contains("中间"));
assert!(prompt.contains("屏幕中下方"));
assert!(prompt.contains("无文字"));
}
#[test]
fn normalize_item_names_requires_two_non_empty_names() {
let names = normalize_item_names(vec![" 苹果 ".to_string(), "香蕉".to_string()])
.expect("two names should be valid");
assert_eq!(names, vec!["苹果".to_string(), "香蕉".to_string()]);
assert!(normalize_item_names(vec!["苹果".to_string()]).is_err());
assert!(normalize_item_names(vec!["苹果".to_string(), " ".to_string()]).is_err());
}
#[test]
fn baby_object_match_image_timeout_keeps_long_generation_alive() {
let settings = with_baby_object_match_image_timeout(OpenAiImageSettings {
base_url: "https://vector.example".to_string(),
api_key: "secret".to_string(),
request_timeout_ms: 180_000,
});
assert_eq!(
settings.request_timeout_ms,
BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS
);
}
#[test]
fn normalizes_png_to_transparent_png_data_url() {
let mut source = Vec::new();
let pixels = vec![255u8; 4 * 2 * 2];
let encoder = PngEncoder::new(&mut source);
encoder
.write_image(pixels.as_slice(), 2, 2, ColorType::Rgba8.into())
.expect("test png should encode");
let image_src = build_transparent_png_data_url(DownloadedOpenAiImage {
bytes: source,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
.expect("test png should normalize");
assert!(image_src.starts_with("data:image/png;base64,"));
}
}

View File

@@ -109,6 +109,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
StatusCode::UNAUTHORIZED => ("UNAUTHORIZED", "未授权访问"),
StatusCode::FORBIDDEN => ("FORBIDDEN", "禁止访问"),
StatusCode::NOT_FOUND => ("NOT_FOUND", "资源不存在"),
StatusCode::GONE => ("GONE", "资源已失效"),
StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"),
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),

View File

@@ -13,6 +13,7 @@ mod auth_payload;
mod auth_public_user;
mod auth_session;
mod auth_sessions;
mod bark_battle;
mod big_fish;
mod big_fish_agent_turn;
mod big_fish_draft_compiler;
@@ -34,6 +35,8 @@ mod custom_world_asset_prompts;
mod custom_world_foundation_draft;
mod custom_world_result_prompts;
mod custom_world_rpg_draft_prompts;
mod edutainment_baby_drawing;
mod edutainment_baby_object;
mod error_middleware;
mod health;
mod http_error;

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,13 @@ pub(crate) struct DownloadedOpenAiImage {
pub extension: String,
}
#[derive(Clone, Debug)]
pub(crate) struct OpenAiReferenceImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub file_name: String,
}
// 中文注释RPG、方洞等图片资产统一走 VectorEngine GPT-image-2-all避免把密钥或供应商协议暴露到前端。
pub(crate) fn require_openai_image_settings(
state: &AppState,
@@ -75,6 +82,8 @@ pub(crate) fn build_openai_image_http_client(
) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
// 中文注释:同一客户端也会承载 `/v1/images/edits` multipart 图生图请求,强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的兼容问题。
.http1_only()
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
@@ -157,6 +166,82 @@ pub(crate) async fn create_openai_image_generation(
)
}
pub(crate) async fn create_openai_image_edit(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
reference_image: &OpenAiReferenceImage,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let task_id = format!("vector-engine-edit-{}", current_utc_micros());
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
.file_name(reference_image.file_name.clone())
.mime_str(reference_image.mime_type.as_str())
.map_err(|error| {
map_openai_image_request_error(format!("{failure_context}:构造参考图失败:{error}"))
})?;
let form = reqwest::multipart::Form::new()
.part("image", image_part)
.text("model", GPT_IMAGE_2_MODEL.to_string())
.text(
"prompt",
build_prompt_with_negative(prompt, negative_prompt),
)
.text("n", "1")
.text("size", normalize_image_size(size));
let response = http_client
.post(vector_engine_images_edit_url(settings).as_str())
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.multipart(form)
.send()
.await
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:创建图片编辑任务失败:{error}"
))
})?;
let response_status = response.status();
let response_text = response.text().await.map_err(|error| {
map_openai_image_request_error(format!("{failure_context}:读取图片编辑响应失败:{error}"))
})?;
if !response_status.is_success() {
return Err(map_openai_image_upstream_error(
response_status.as_u16(),
response_text.as_str(),
failure_context,
));
}
let response_json = parse_json_payload(response_text.as_str(), failure_context)?;
let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt")
.or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt"));
let image_urls = extract_image_urls(&response_json.payload);
if !image_urls.is_empty() {
let mut generated = download_images_from_urls(http_client, task_id, image_urls, 1).await?;
generated.actual_prompt = actual_prompt;
return Ok(generated);
}
let b64_images = extract_b64_images(&response_json.payload);
if !b64_images.is_empty() {
let mut generated = images_from_base64(task_id, b64_images, 1);
generated.actual_prompt = actual_prompt;
return Ok(generated);
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{failure_context}VectorEngine 未返回编辑图片"),
})),
)
}
pub(crate) fn build_openai_image_request_body(
prompt: &str,
negative_prompt: Option<&str>,
@@ -453,6 +538,14 @@ fn vector_engine_images_generation_url(settings: &OpenAiImageSettings) -> String
}
}
fn vector_engine_images_edit_url(settings: &OpenAiImageSettings) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/edits", settings.base_url)
} else {
format!("{}/v1/images/edits", settings.base_url)
}
}
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
@@ -530,6 +623,29 @@ mod tests {
assert!(body["prompt"].as_str().unwrap_or_default().contains("避免"));
}
#[test]
fn vector_engine_edit_url_uses_images_edits_endpoint() {
let root_settings = OpenAiImageSettings {
base_url: "https://vector.example".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 180_000,
};
let v1_settings = OpenAiImageSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 180_000,
};
assert_eq!(
vector_engine_images_edit_url(&root_settings),
"https://vector.example/v1/images/edits"
);
assert_eq!(
vector_engine_images_edit_url(&v1_settings),
"https://vector.example/v1/images/edits"
);
}
#[test]
fn b64_json_response_decodes_png_image() {
let images = images_from_base64(

View File

@@ -1,29 +1,34 @@
/// 拼图首关关卡名生成提示词。
/// 拼图首关关卡名与 UI 背景提示词生成提示词。
///
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名;写回草稿和作品卡由业务路由处理。
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名,并产出运行态 UI 背景的正向视觉提示词;
/// 写回草稿和作品卡由业务路由处理。
pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。
你会收到拼图第一关的画面描述,部分请求还会附带已经生成完成的正式图片。请综合图片内容和画面描述,生成 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
你会收到拼图第一关的画面描述,部分请求还会附带已经生成完成的正式图片。请综合图片内容和画面描述,同时生成:
- 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
- 1 段用于生成 9:16 拼图运行态 UI 纯背景图的中文正向视觉提示词。
硬约束:
1. 只输出 JSON不要输出 Markdown、解释或代码块。
2. JSON 格式必须是 {"levelName":"关卡名"}。
2. JSON 格式必须是 {"levelName":"关卡名","uiBackgroundPrompt":"提示词"}。
3. levelName 必须是 2 到 8 个中文字符为主。
4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。
5. 不要输出标点、引号、编号、英文、emoji 或空白。
6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。
7. uiBackgroundPrompt 必须是 30 到 160 个中文字符,描述题材氛围、环境、色彩、光影和空间层次。
8. uiBackgroundPrompt 只写正向画面描述不要写规则说明不要出现拼图槽、棋盘、HUD、按钮、文字、水印、数字、拼图碎片、完整拼图图像或教程浮层。
"#;
pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String {
format!(
"画面描述:{picture_description}\n\n请生成第一关关卡名。",
"画面描述:{picture_description}\n\n请生成第一关关卡名和 UI 背景提示词",
picture_description = picture_description.trim(),
)
}
pub(crate) fn build_puzzle_first_level_name_vision_user_text(picture_description: &str) -> String {
format!(
"画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名。",
"画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名和 UI 背景提示词",
picture_description = picture_description.trim(),
)
}
@@ -38,6 +43,7 @@ mod tests {
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
assert!(prompt.contains("第一关关卡名"));
assert!(prompt.contains("UI 背景提示词"));
}
#[test]
@@ -46,5 +52,6 @@ mod tests {
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
assert!(prompt.contains("正式拼图图片"));
assert!(prompt.contains("UI 背景提示词"));
}
}

View File

@@ -105,9 +105,6 @@ use crate::{
},
request_context::RequestContext,
state::AppState,
vector_engine_audio_generation::{
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
},
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
@@ -127,9 +124,8 @@ const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
pub async fn create_puzzle_agent_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -201,13 +197,14 @@ pub async fn generate_puzzle_onboarding_work(
let now = current_utc_micros();
let session_id = build_prefixed_uuid_id("puzzle-onboarding-");
let level_name = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await;
let tags = generate_puzzle_work_tags(&state, level_name.as_str(), prompt_text.as_str()).await;
let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await;
let tags =
generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await;
let candidates = generate_puzzle_image_candidates(
&state,
"onboarding-guest",
session_id.as_str(),
level_name.as_str(),
naming.level_name.as_str(),
prompt_text.as_str(),
None,
false,
@@ -236,10 +233,10 @@ pub async fn generate_puzzle_onboarding_work(
})?;
let level = PuzzleDraftLevelRecord {
level_id: "onboarding-level-1".to_string(),
level_name: level_name.clone(),
level_name: naming.level_name.clone(),
picture_description: prompt_text.clone(),
picture_reference: None,
ui_background_prompt: None,
ui_background_prompt: naming.ui_background_prompt.clone(),
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
@@ -250,7 +247,7 @@ pub async fn generate_puzzle_onboarding_work(
generation_status: "ready".to_string(),
};
let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack(
level_name.as_str(),
naming.level_name.as_str(),
level.picture_description.as_str(),
));
let item = PuzzleWorkProfileRecord {
@@ -259,9 +256,9 @@ pub async fn generate_puzzle_onboarding_work(
owner_user_id: "onboarding-guest".to_string(),
source_session_id: None,
author_display_name: "陶泥儿主".to_string(),
work_title: level_name.clone(),
work_title: naming.level_name.clone(),
work_description: prompt_text.clone(),
level_name,
level_name: naming.level_name,
summary: prompt_text,
theme_tags: tags,
cover_image_src: level.cover_image_src.clone(),
@@ -919,14 +916,17 @@ pub async fn execute_puzzle_agent_action(
}),
));
}
if let Some(refined_level_name) = generate_puzzle_first_level_name_from_image(
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
&state,
target_level.picture_description.as_str(),
&candidates[0].downloaded_image,
)
.await
{
target_level.level_name = refined_level_name;
target_level.level_name = refined_naming.level_name;
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt = refined_naming.ui_background_prompt;
}
}
let generated_level_name = target_level.level_name.clone();
let levels_json_with_generated_name =
@@ -2396,7 +2396,11 @@ fn map_puzzle_work_summary_response(
.saturating_div(2)
.saturating_sub(item.point_incentive_claimed_points),
publish_ready: item.publish_ready,
levels: Vec::new(),
levels: item
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect(),
}
}
@@ -2404,15 +2408,8 @@ fn map_puzzle_work_profile_response(
state: &AppState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkProfileResponse {
let mut summary = map_puzzle_work_summary_response(state, item.clone());
summary.levels = item
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect();
PuzzleWorkProfileResponse {
summary,
summary: map_puzzle_work_summary_response(state, item.clone()),
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
}
}
@@ -2507,6 +2504,7 @@ fn map_puzzle_runtime_level_response(
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
ui_background_image_src: level.ui_background_image_src,
ui_background_image_object_key: level.ui_background_image_object_key,
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
@@ -3066,7 +3064,25 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
)
}
async fn generate_puzzle_first_level_name(state: &AppState, picture_description: &str) -> String {
#[derive(Clone, Debug, Eq, PartialEq)]
struct PuzzleLevelNaming {
level_name: String,
ui_background_prompt: Option<String>,
}
impl PuzzleLevelNaming {
fn fallback(picture_description: &str) -> Self {
Self {
level_name: build_fallback_puzzle_first_level_name(picture_description),
ui_background_prompt: None,
}
}
}
async fn generate_puzzle_first_level_name(
state: &AppState,
picture_description: &str,
) -> PuzzleLevelNaming {
if let Some(llm_client) = state.llm_client() {
let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description);
let response = llm_client
@@ -3081,10 +3097,9 @@ async fn generate_puzzle_first_level_name(state: &AppState, picture_description:
.await;
match response {
Ok(response) => {
if let Some(level_name) =
parse_puzzle_first_level_name_from_text(response.content.as_str())
if let Some(naming) = parse_puzzle_level_naming_from_text(response.content.as_str())
{
return level_name;
return naming;
}
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -3103,14 +3118,14 @@ async fn generate_puzzle_first_level_name(state: &AppState, picture_description:
}
}
build_fallback_puzzle_first_level_name(picture_description)
PuzzleLevelNaming::fallback(picture_description)
}
async fn generate_puzzle_first_level_name_from_image(
state: &AppState,
picture_description: &str,
image: &PuzzleDownloadedImage,
) -> Option<String> {
) -> Option<PuzzleLevelNaming> {
let Some(llm_client) = state.creative_agent_gpt5_client() else {
return None;
};
@@ -3141,7 +3156,7 @@ async fn generate_puzzle_first_level_name_from_image(
match response {
Ok(response) => {
parse_puzzle_first_level_name_from_text(response.content.as_str()).or_else(|| {
parse_puzzle_level_naming_from_text(response.content.as_str()).or_else(|| {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL,
@@ -3191,7 +3206,7 @@ fn resize_puzzle_level_name_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
Some(cursor.into_inner())
}
fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
fn parse_puzzle_level_naming_from_text(text: &str) -> Option<PuzzleLevelNaming> {
let trimmed = text.trim();
let json_text = if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
@@ -3211,7 +3226,66 @@ fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
.and_then(|value| value.get("level_name").and_then(Value::as_str))
})
.unwrap_or(trimmed);
normalize_puzzle_first_level_name(raw_name)
let level_name = normalize_puzzle_first_level_name(raw_name)?;
let ui_background_prompt = parsed
.as_ref()
.and_then(parse_puzzle_ui_background_prompt_field);
Some(PuzzleLevelNaming {
level_name,
ui_background_prompt,
})
}
#[cfg(test)]
fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name)
}
fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option<String> {
value
.get("uiBackgroundPrompt")
.and_then(Value::as_str)
.or_else(|| value.get("ui_background_prompt").and_then(Value::as_str))
.and_then(normalize_puzzle_generated_ui_background_prompt)
}
fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option<String> {
let normalized = value
.trim()
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.split_whitespace()
.collect::<Vec<_>>()
.join("");
let filtered = normalized
.replace("拼图槽", "")
.replace("棋盘", "")
.replace("HUD", "")
.replace("按钮", "")
.replace("文字", "")
.replace("水印", "")
.replace("数字", "")
.replace("拼图碎片", "")
.replace("完整拼图图像", "")
.replace("教程浮层", "");
let prompt = filtered
.chars()
.take(160)
.collect::<String>()
.trim()
.trim_matches(|ch: char| matches!(ch, '' | '。' | '、' | '' | ''))
.to_string();
if prompt.chars().count() >= 12 {
Some(prompt)
} else {
None
}
}
fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
@@ -3332,15 +3406,15 @@ fn build_puzzle_levels_with_primary_update(
levels
}
fn resolve_puzzle_background_music_title(
fn resolve_puzzle_initial_ui_background_prompt(
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> String {
let work_title = draft.work_title.trim();
if !work_title.is_empty() {
return work_title.to_string();
}
target_level.level_name.trim().to_string()
target_level
.ui_background_prompt
.as_deref()
.and_then(normalize_puzzle_generated_ui_background_prompt)
.unwrap_or_else(|| normalize_puzzle_ui_background_prompt("", draft, target_level))
}
fn normalize_puzzle_ui_background_prompt(
@@ -3371,7 +3445,7 @@ fn normalize_puzzle_ui_background_prompt(
draft.work_description.trim(),
target_level.picture_description.trim(),
tags.as_str(),
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素",
PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER,
]
.into_iter()
.filter(|value| !value.is_empty())
@@ -3394,30 +3468,6 @@ fn build_puzzle_ui_background_generation_prompt(level_name: &str, prompt: &str)
)
}
fn attach_puzzle_level_background_music(
levels: &mut [PuzzleDraftLevelRecord],
level_id: &str,
music: CreationAudioAsset,
) {
let Some(index) = levels
.iter()
.position(|level| level.level_id == level_id)
.or_else(|| (!levels.is_empty()).then_some(0))
else {
return;
};
levels[index].background_music = Some(PuzzleAudioAssetRecord {
task_id: music.task_id,
provider: music.provider,
asset_object_id: music.asset_object_id,
asset_kind: music.asset_kind,
audio_src: music.audio_src,
prompt: music.prompt,
title: music.title,
updated_at: music.updated_at,
});
}
fn attach_puzzle_level_ui_background(
levels: &mut [PuzzleDraftLevelRecord],
level_id: &str,
@@ -3436,38 +3486,6 @@ fn attach_puzzle_level_ui_background(
levels[index].ui_background_image_object_key = Some(generated.object_key);
}
async fn generate_puzzle_background_music_required(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
title: &str,
) -> Result<CreationAudioAsset, AppError> {
let normalized_title = title.trim();
if normalized_title.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成",
})));
}
generate_background_music_asset_for_creation(
state,
owner_user_id,
String::new(),
normalized_title.to_string(),
Some("轻快, 拼图, 循环, instrumental".to_string()),
None,
GeneratedCreationAudioTarget {
entity_kind: PUZZLE_ENTITY_KIND.to_string(),
entity_id: profile_id.to_string(),
slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(),
asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(),
profile_id: Some(profile_id.to_string()),
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
},
)
.await
}
async fn generate_puzzle_initial_ui_background_required(
state: &AppState,
owner_user_id: &str,
@@ -3475,7 +3493,7 @@ async fn generate_puzzle_initial_ui_background_required(
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> {
let prompt = normalize_puzzle_ui_background_prompt("", draft, target_level);
let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level);
let generated = generate_puzzle_ui_background_image(
state,
owner_user_id,
@@ -3490,11 +3508,6 @@ async fn generate_puzzle_initial_ui_background_required(
fn ensure_puzzle_initial_level_assets_ready(
level: &PuzzleDraftLevelRecord,
) -> Result<(), AppError> {
let has_background_music = level
.background_music
.as_ref()
.map(|music| music.audio_src.trim())
.is_some_and(|value| !value.is_empty());
let has_ui_background = level
.ui_background_image_src
.as_deref()
@@ -3505,23 +3518,22 @@ fn ensure_puzzle_initial_level_assets_ready(
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if has_background_music && has_ui_background {
if has_ui_background {
return Ok(());
}
let mut missing = Vec::new();
if !has_background_music {
missing.push("背景音乐");
}
if !has_ui_background {
missing.push("UI背景图");
}
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("")),
"missingAssets": missing,
})))
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("")),
"missingAssets": missing,
})),
)
}
fn find_puzzle_level_for_initial_asset_check<'a>(
@@ -3582,9 +3594,9 @@ async fn compile_puzzle_draft_with_initial_cover(
1,
target_level.candidates.len(),
);
let (generated_level_name, candidates_result) =
tokio::join!(level_name_future, candidates_future);
target_level.level_name = generated_level_name.clone();
let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future);
target_level.level_name = generated_naming.level_name.clone();
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
let candidates = candidates_result?;
let selected_candidate_id = candidates
.iter()
@@ -3597,21 +3609,22 @@ async fn compile_puzzle_draft_with_initial_cover(
"message": "拼图候选图生成结果为空",
}))
})?;
if let Some(refined_level_name) = generate_puzzle_first_level_name_from_image(
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
state,
target_level.picture_description.as_str(),
&candidates[0].downloaded_image,
)
.await
{
target_level.level_name = refined_level_name;
target_level.level_name = refined_naming.level_name;
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt = refined_naming.ui_background_prompt;
}
}
let generated_level_name = target_level.level_name.clone();
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
// 中文注释UI 背景先生成,避免其失败后留下已经扣费但未写入草稿的音乐资产。
// 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。
let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required(
state,
owner_user_id.as_str(),
@@ -3626,17 +3639,6 @@ async fn compile_puzzle_draft_with_initial_cover(
ui_prompt,
ui_background,
);
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
generate_puzzle_background_music_required(
state,
owner_user_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await?,
);
let ready_level =
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
.ok_or_else(|| {
@@ -3809,19 +3811,24 @@ async fn compile_puzzle_draft_with_uploaded_cover(
uploaded_downloaded_image.clone(),
current_utc_micros(),
);
let (generated_level_name, refined_level_name, persisted_upload_result) = tokio::join!(
let (mut generated_naming, refined_naming, persisted_upload_result) = tokio::join!(
level_name_future,
image_level_name_future,
persist_upload_future
);
target_level.level_name = refined_level_name.unwrap_or(generated_level_name.clone());
if let Some(refined_naming) = refined_naming {
generated_naming.level_name = refined_naming.level_name;
if refined_naming.ui_background_prompt.is_some() {
generated_naming.ui_background_prompt = refined_naming.ui_background_prompt;
}
}
target_level.level_name = generated_naming.level_name;
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
let generated_level_name = target_level.level_name.clone();
let persisted_upload = persisted_upload_result?;
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
// 中文注释:直用上传图时同样先补 UI 背景,再生成会单独扣费的音乐资产。
// 中文注释:直用上传图时同样只补 UI 背景;音频生成入口临时关闭。
let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required(
state,
owner_user_id.as_str(),
@@ -3836,17 +3843,6 @@ async fn compile_puzzle_draft_with_uploaded_cover(
ui_prompt,
ui_background,
);
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
generate_puzzle_background_music_required(
state,
owner_user_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await?,
);
let ready_level =
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
.ok_or_else(|| {
@@ -5061,6 +5057,39 @@ mod tests {
);
}
#[test]
fn puzzle_level_naming_parser_accepts_ui_background_prompt() {
let naming = parse_puzzle_level_naming_from_text(
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
)
.expect("naming should parse");
assert_eq!(naming.level_name, "雨夜猫街");
assert_eq!(
naming.ui_background_prompt.as_deref(),
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
);
}
#[test]
fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() {
let naming = parse_puzzle_level_naming_from_text(
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印保留暖色灯光"}"#,
)
.expect("naming should parse");
let prompt = naming
.ui_background_prompt
.as_deref()
.expect("prompt should parse");
assert!(!prompt.contains("拼图槽"));
assert!(!prompt.contains("棋盘"));
assert!(!prompt.contains("HUD"));
assert!(!prompt.contains("按钮"));
assert!(!prompt.contains("文字"));
assert!(!prompt.contains("水印"));
}
#[test]
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
assert_eq!(
@@ -5256,6 +5285,74 @@ mod tests {
);
}
#[test]
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
let level = PuzzleDraftLevelRecord {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: vec![PuzzleGeneratedImageCandidateRecord {
candidate_id: "candidate-1".to_string(),
image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(),
asset_id: "asset-1".to_string(),
prompt: "雨夜猫街".to_string(),
actual_prompt: None,
source_type: "generated".to_string(),
selected: true,
}],
selected_candidate_id: Some("candidate-1".to_string()),
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
cover_asset_id: Some("asset-1".to_string()),
generation_status: "ready".to_string(),
};
let response = map_puzzle_work_summary_response(
&state,
PuzzleWorkProfileRecord {
work_id: "puzzle-work-1".to_string(),
profile_id: "puzzle-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("puzzle-session-1".to_string()),
author_display_name: "玩家".to_string(),
work_title: "雨夜猫街".to_string(),
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
level_name: "雨夜猫街".to_string(),
summary: "一只猫在雨夜灯牌下回头。".to_string(),
theme_tags: vec!["".to_string()],
cover_image_src: None,
cover_asset_id: None,
publication_status: "draft".to_string(),
updated_at: "2026-05-08T00:00:00.000Z".to_string(),
published_at: None,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
publish_ready: false,
anchor_pack: test_puzzle_anchor_pack_record(),
levels: vec![level],
},
);
assert_eq!(response.levels.len(), 1);
assert_eq!(
response.levels[0].cover_image_src.as_deref(),
Some("/generated-puzzle-assets/session/cover.png")
);
assert_eq!(
response.levels[0].candidates[0].image_src,
"/generated-puzzle-assets/session/candidate-1.png"
);
}
#[test]
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
let prompt =
@@ -5268,6 +5365,34 @@ mod tests {
assert!(prompt.contains("文字"));
}
#[test]
fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() {
let mut draft = test_puzzle_draft_record();
draft.work_title = "模板作品名".to_string();
draft.work_description = "模板作品描述".to_string();
let mut target_level = draft.levels[0].clone();
target_level.level_name = "雨夜猫街".to_string();
let ai_prompt =
"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次";
target_level.ui_background_prompt = Some(ai_prompt.to_string());
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
assert_eq!(prompt, ai_prompt);
assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
}
#[test]
fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() {
let draft = test_puzzle_draft_record();
let target_level = draft.levels[0].clone();
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
assert!(prompt.contains("雨夜猫街"));
assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
}
#[test]
fn puzzle_ui_background_initial_attach_updates_first_level_fields() {
let draft = test_puzzle_draft_record();
@@ -5299,33 +5424,17 @@ mod tests {
}
#[test]
fn puzzle_initial_draft_assets_must_include_music_and_ui_background() {
fn puzzle_initial_draft_assets_must_include_ui_background() {
let mut draft = test_puzzle_draft_record();
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
assert!(missing_all.body_text().contains("背景音乐"));
assert!(missing_all.body_text().contains("UI背景图"));
draft.levels[0].ui_background_image_src =
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
let missing_music = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect_err("只有 UI 背景时仍不能完成草稿");
assert!(missing_music.body_text().contains("背景音乐"));
draft.levels[0].background_music = Some(PuzzleAudioAssetRecord {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
asset_object_id: Some("assetobj_1".to_string()),
asset_kind: Some("puzzle_background_music".to_string()),
audio_src: "/generated-puzzle-assets/session/music.mp3".to_string(),
prompt: Some(String::new()),
title: Some("雨夜猫街".to_string()),
updated_at: Some("2026-05-14T00:00:00Z".to_string()),
});
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect("音乐和 UI 背景存在时才能完成自动草稿");
.expect("UI 背景存在时即可完成自动草稿资源检查");
}
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {

View File

@@ -65,16 +65,6 @@ struct AudioAssetBindingTarget {
storage_scope: String,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedCreationAudioTarget {
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub profile_id: Option<String>,
pub storage_prefix: LegacyAssetPrefix,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AudioAssetSlot {
BackgroundMusic,
@@ -173,21 +163,13 @@ pub async fn create_visual_novel_background_music_task(
}
pub async fn create_background_music_task(
State(state): State<AppState>,
State(_state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
payload: Result<Json<creation_audio::CreateBackgroundMusicRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
create_background_music_task_response(
&state,
payload.prompt,
payload.title,
payload.tags,
payload.model,
)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
.map_err(|error| error.into_response_with_context(Some(&request_context)))
let _ = parse_json_payload(&request_context, payload)?;
Err(creation_audio_generation_disabled_error()
.into_response_with_context(Some(&request_context)))
}
pub async fn create_visual_novel_sound_effect_task(
@@ -241,210 +223,13 @@ pub async fn create_visual_novel_sound_effect_task(
}
pub async fn create_sound_effect_task(
State(state): State<AppState>,
State(_state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
payload: Result<Json<creation_audio::CreateSoundEffectRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
create_sound_effect_task_response(&state, payload.prompt, payload.duration, payload.seed)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
pub(crate) async fn generate_sound_effect_asset_for_creation(
state: &AppState,
owner_user_id: &str,
prompt: String,
duration: Option<u8>,
seed: Option<u64>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let normalized_prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
let task =
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::SoundEffect,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: None,
updated_at: Some(current_utc_iso_text()),
})
}
pub(crate) async fn generate_background_music_asset_for_creation(
state: &AppState,
owner_user_id: &str,
prompt: String,
title: String,
tags: Option<String>,
model: Option<String>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let normalized_prompt =
normalize_limited_text_allow_empty(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
let normalized_title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
let task = create_background_music_task_response(
state,
normalized_prompt.clone(),
normalized_title.clone(),
tags,
model,
)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::BackgroundMusic,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: Some(normalized_title),
updated_at: Some(current_utc_iso_text()),
})
}
async fn create_background_music_task_response(
state: &AppState,
prompt: String,
title: String,
tags: Option<String>,
model: Option<String>,
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
let settings = require_vector_engine_audio_settings(state)?;
let http_client = build_vector_engine_audio_http_client(&settings)?;
let prompt = normalize_limited_text_allow_empty(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
let title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
let tags = tags
.as_deref()
.map(|value| normalize_limited_text(value, "tags", SUNO_TAGS_MAX_CHARS))
.transpose()?;
let model =
normalize_optional_text(model.as_deref()).unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string());
let mut body = Map::from_iter([
("prompt".to_string(), Value::String(prompt)),
("mv".to_string(), Value::String(model)),
("title".to_string(), Value::String(title)),
("task".to_string(), Value::String("generate".to_string())),
("make_instrumental".to_string(), Value::Bool(true)),
]);
if let Some(tags) = tags {
body.insert("tags".to_string(), Value::String(tags));
}
let response = post_vector_engine_json(
&http_client,
&settings,
"/suno/submit/music",
Value::Object(body),
"提交 Suno 背景音乐任务失败",
)
.await?;
let task_id = extract_string_by_path(&response, &["data"])
.or_else(|| find_first_string_by_key(&response, "task_id"))
.or_else(|| find_first_string_by_key(&response, "taskId"))
.ok_or_else(|| {
vector_engine_bad_gateway("提交 Suno 背景音乐任务失败:上游未返回任务 ID")
})?;
Ok(creation_audio::AudioGenerationTaskResponse {
kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic,
task_id,
provider: VECTOR_ENGINE_SUNO_PROVIDER.to_string(),
status: "submitted".to_string(),
})
}
async fn create_sound_effect_task_response(
state: &AppState,
prompt: String,
duration: Option<u8>,
seed: Option<u64>,
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
let settings = require_vector_engine_audio_settings(state)?;
let http_client = build_vector_engine_audio_http_client(&settings)?;
let prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
let duration = duration
.unwrap_or(DEFAULT_SOUND_EFFECT_DURATION_SECONDS)
.clamp(2, 10);
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(VIDU_AUDIO_MODEL.to_string()),
),
("prompt".to_string(), Value::String(prompt)),
("duration".to_string(), json!(duration)),
]);
if let Some(seed) = seed {
body.insert("seed".to_string(), json!(seed));
}
let response = post_vector_engine_json(
&http_client,
&settings,
"/ent/v2/text2audio",
Value::Object(body),
"提交 Vidu 音效任务失败",
)
.await?;
let task_id = find_first_string_by_key(&response, "task_id")
.or_else(|| find_first_string_by_key(&response, "taskId"))
.ok_or_else(|| vector_engine_bad_gateway("提交 Vidu 音效任务失败:上游未返回任务 ID"))?;
let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into());
Ok(creation_audio::AudioGenerationTaskResponse {
kind: creation_audio::CreationAudioGenerationKind::SoundEffect,
task_id,
provider: VECTOR_ENGINE_VIDU_PROVIDER.to_string(),
status,
})
let _ = parse_json_payload(&request_context, payload)?;
Err(creation_audio_generation_disabled_error()
.into_response_with_context(Some(&request_context)))
}
pub async fn publish_visual_novel_background_music_asset(
@@ -516,45 +301,27 @@ pub async fn publish_visual_novel_sound_effect_asset(
}
pub async fn publish_background_music_asset(
State(state): State<AppState>,
Path(task_id): Path<String>,
State(_state): State<AppState>,
Path(_task_id): Path<String>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
axum::extract::Extension(_authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = parse_json_payload(&request_context, payload)?.0;
let target = build_creation_audio_target(payload)?;
publish_generated_audio_asset(
&state,
authenticated.claims().user_id(),
task_id,
AudioAssetSlot::BackgroundMusic,
target,
)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
.map_err(|error| error.into_response_with_context(Some(&request_context)))
Err(creation_audio_generation_disabled_error_for_target(payload)
.into_response_with_context(Some(&request_context)))
}
pub async fn publish_sound_effect_asset(
State(state): State<AppState>,
Path(task_id): Path<String>,
State(_state): State<AppState>,
Path(_task_id): Path<String>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
axum::extract::Extension(_authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = parse_json_payload(&request_context, payload)?.0;
let target = build_creation_audio_target(payload)?;
publish_generated_audio_asset(
&state,
authenticated.claims().user_id(),
task_id,
AudioAssetSlot::SoundEffect,
target,
)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
.map_err(|error| error.into_response_with_context(Some(&request_context)))
Err(creation_audio_generation_disabled_error_for_target(payload)
.into_response_with_context(Some(&request_context)))
}
async fn publish_generated_audio_asset(
@@ -650,45 +417,6 @@ async fn publish_generated_audio_asset(
})
}
async fn wait_for_generated_audio_asset(
state: &AppState,
owner_user_id: &str,
task_id: String,
slot: AudioAssetSlot,
target: AudioAssetBindingTarget,
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
let mut latest_status = String::new();
for _ in 0..40 {
let response = publish_generated_audio_asset(
state,
owner_user_id,
task_id.clone(),
slot,
target.clone(),
)
.await?;
if response
.audio_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return Ok(response);
}
latest_status = response.status;
tokio::time::sleep(Duration::from_millis(3_000)).await;
}
Err(vector_engine_bad_gateway(format!(
"音频生成超时:{}",
if latest_status.trim().is_empty() {
task_id
} else {
latest_status
}
)))
}
fn build_audio_billing_asset_id(
task_id: &str,
slot: AudioAssetSlot,
@@ -888,33 +616,21 @@ fn build_visual_novel_audio_target(
})
}
fn build_creation_audio_target(
fn creation_audio_generation_disabled_error() -> AppError {
AppError::from_status(StatusCode::GONE).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图与抓大鹅音频生成入口已临时关闭",
}))
}
fn creation_audio_generation_disabled_error_for_target(
payload: creation_audio::PublishGeneratedAudioAssetRequest,
) -> Result<AudioAssetBindingTarget, AppError> {
let entity_kind = normalize_limited_text(&payload.entity_kind, "entityKind", 80)?;
let entity_id = normalize_limited_text(&payload.entity_id, "entityId", 160)?;
let slot = normalize_limited_text(&payload.slot, "slot", 80)?;
let asset_kind = normalize_limited_text(&payload.asset_kind, "assetKind", 80)?;
let storage_prefix = match payload.storage_prefix {
Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets) => {
LegacyAssetPrefix::PuzzleAssets
}
Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets) => {
LegacyAssetPrefix::Match3DAssets
}
Some(creation_audio::CreationAudioStoragePrefix::CustomWorldScenes) | None => {
LegacyAssetPrefix::CustomWorldScenes
}
};
Ok(AudioAssetBindingTarget {
storage_scope: entity_kind.clone(),
entity_kind,
entity_id,
slot,
asset_kind,
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
storage_prefix,
})
) -> AppError {
creation_audio_generation_disabled_error().with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图与抓大鹅音频生成入口已临时关闭",
"entityKind": payload.entity_kind.trim(),
}))
}
fn require_vector_engine_audio_settings(
@@ -1253,24 +969,6 @@ fn normalize_limited_text(
Ok(normalized)
}
fn normalize_limited_text_allow_empty(
value: &str,
field: &'static str,
max_chars: usize,
) -> Result<String, AppError> {
let normalized = value.trim().to_string();
if normalized.chars().count() > max_chars {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"field": field,
"message": format!("{field} 超过 {} 字符", max_chars),
})),
);
}
Ok(normalized)
}
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
@@ -1369,11 +1067,6 @@ fn current_utc_micros() -> i64 {
shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
}
fn current_utc_iso_text() -> String {
shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc())
.unwrap_or_else(|_| shared_kernel::format_timestamp_micros(current_utc_micros()))
}
fn map_asset_field_error(error: module_assets::AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
@@ -1473,6 +1166,42 @@ mod tests {
);
}
#[test]
fn disabled_creation_audio_targets_return_gone() {
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
entity_kind: "puzzle_work".to_string(),
entity_id: "puzzle-profile-1".to_string(),
slot: "background_music".to_string(),
asset_kind: "puzzle_background_music".to_string(),
profile_id: Some("puzzle-profile-1".to_string()),
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets),
};
let error = creation_audio_generation_disabled_error_for_target(payload);
assert_eq!(error.status_code(), StatusCode::GONE);
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
entity_kind: "match3d_work".to_string(),
entity_id: "match3d-profile-1".to_string(),
slot: "background_music".to_string(),
asset_kind: "match3d_background_music".to_string(),
profile_id: Some("match3d-profile-1".to_string()),
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets),
};
let error = creation_audio_generation_disabled_error_for_target(payload);
assert_eq!(error.status_code(), StatusCode::GONE);
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
entity_kind: "match3d_item".to_string(),
entity_id: "match3d-item-1".to_string(),
slot: "click_sound".to_string(),
asset_kind: "match3d_click_sound".to_string(),
profile_id: Some("match3d-profile-1".to_string()),
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets),
};
let error = creation_audio_generation_disabled_error_for_target(payload);
assert_eq!(error.status_code(), StatusCode::GONE);
}
#[test]
fn validates_prompt_length() {
let prompt = "".repeat(VIDU_PROMPT_MAX_CHARS + 1);