清理后端编译警告
删除后端未使用的历史 helper、mapper、handler 和 re-export 将仅测试使用的导入、常量和辅助函数收口到 cfg(test) 补齐 Jump Hop 测试构造体字段并对齐 Match3D 当前素材表测试契约 验证后端 workspace cargo check 与 Match3D、Puzzle 相关测试
This commit is contained in:
@@ -884,6 +884,7 @@ fn extract_sql_statement_columns(statement: &Value) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload {
|
||||
build_admin_database_table_row_for_table("", row, columns)
|
||||
}
|
||||
|
||||
@@ -1052,6 +1052,7 @@ fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str
|
||||
resolve_work_author_by_user_id(state, owner_user_id, None, None).display_name
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn normalize_author_display_name(display_name: Option<String>) -> String {
|
||||
display_name
|
||||
.map(|value| value.trim().to_string())
|
||||
|
||||
@@ -37,7 +37,7 @@ use spacetime_client::{
|
||||
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
|
||||
CustomWorldLibraryEntryRecord,
|
||||
CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput,
|
||||
CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput,
|
||||
CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord,
|
||||
|
||||
@@ -114,41 +114,6 @@ pub(super) fn build_custom_world_library_list_profile_payload(
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_custom_world_gallery_card_response(
|
||||
state: &AppState,
|
||||
entry: CustomWorldGalleryEntryRecord,
|
||||
) -> CustomWorldGalleryCardResponse {
|
||||
let author = resolve_work_author_by_user_id(
|
||||
state,
|
||||
&entry.owner_user_id,
|
||||
Some(&entry.author_display_name),
|
||||
Some(&entry.author_public_user_code),
|
||||
);
|
||||
CustomWorldGalleryCardResponse {
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
public_work_code: entry.public_work_code,
|
||||
author_public_user_code: author
|
||||
.public_user_code
|
||||
.unwrap_or(entry.author_public_user_code),
|
||||
visibility: entry.visibility,
|
||||
published_at: entry.published_at,
|
||||
updated_at: entry.updated_at,
|
||||
author_display_name: author.display_name,
|
||||
world_name: entry.world_name,
|
||||
subtitle: entry.subtitle,
|
||||
summary_text: entry.summary_text,
|
||||
cover_image_src: entry.cover_image_src,
|
||||
theme_mode: entry.theme_mode,
|
||||
playable_npc_count: entry.playable_npc_count,
|
||||
landmark_count: entry.landmark_count,
|
||||
play_count: entry.play_count,
|
||||
remix_count: entry.remix_count,
|
||||
like_count: entry.like_count,
|
||||
recent_play_count_7d: entry.recent_play_count_7d,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_public_work_custom_world_gallery_card_response(
|
||||
state: &AppState,
|
||||
entry: spacetime_client::PublicWorkGalleryEntryRecord,
|
||||
|
||||
@@ -10,9 +10,9 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use image::{
|
||||
DynamicImage, GenericImageView, ImageFormat, codecs::jpeg::JpegEncoder, imageops::FilterType,
|
||||
};
|
||||
use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType};
|
||||
#[cfg(test)]
|
||||
use image::ImageFormat;
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#[cfg(test)]
|
||||
use axum::http::StatusCode;
|
||||
use module_runtime::RuntimeTrackingScopeKind;
|
||||
use platform_image::PlatformImageFailureAudit;
|
||||
@@ -157,6 +158,7 @@ pub(crate) fn build_external_api_failure_draft_from_platform_image_audit(
|
||||
}
|
||||
|
||||
/// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str {
|
||||
status_class(Some(status_code.as_u16()))
|
||||
}
|
||||
@@ -304,6 +306,7 @@ fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Val
|
||||
metadata
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_retryable_external_api_failure(
|
||||
status_code: Option<u16>,
|
||||
timeout: bool,
|
||||
|
||||
@@ -9,29 +9,13 @@ use crate::{
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use generated_asset_sheets_impl::{
|
||||
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetSliceImage,
|
||||
GeneratedAssetSheetUpload,
|
||||
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
|
||||
crop_generated_asset_sheet_view_edge_matte,
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options,
|
||||
};
|
||||
|
||||
pub(crate) fn build_generated_asset_sheet_prompt(
|
||||
input: &GeneratedAssetSheetPromptInput<'_>,
|
||||
) -> Result<String, AppError> {
|
||||
generated_asset_sheets_impl::build_generated_asset_sheet_prompt(input)
|
||||
.map_err(map_generated_asset_sheet_error)
|
||||
}
|
||||
|
||||
pub(crate) fn slice_generated_asset_sheet(
|
||||
image: &DownloadedOpenAiImage,
|
||||
item_names: &[String],
|
||||
grid_size: usize,
|
||||
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, AppError> {
|
||||
generated_asset_sheets_impl::slice_generated_asset_sheet(image, item_names, grid_size)
|
||||
.map_err(map_generated_asset_sheet_error)
|
||||
}
|
||||
|
||||
pub(crate) fn slice_generated_asset_sheet_two_items_per_row(
|
||||
image: &DownloadedOpenAiImage,
|
||||
item_names: &[String],
|
||||
|
||||
@@ -6,15 +6,8 @@ pub mod helpers {
|
||||
pub use platform_image::generated_assets::helpers::*;
|
||||
}
|
||||
|
||||
pub(crate) use adapter::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetAdapterBoundary,
|
||||
GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput,
|
||||
GeneratedImageAssetPreparedPut,
|
||||
};
|
||||
pub(crate) use adapter::GeneratedImageAssetAdapter;
|
||||
pub(crate) use helpers::{
|
||||
GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError, GeneratedImageAssetImageFormat,
|
||||
GeneratedImageAssetMetadataInput, GeneratedImageAssetStoragePaths,
|
||||
build_generated_image_asset_metadata, build_generated_image_asset_storage_paths,
|
||||
decode_generated_image_asset_data_url, merge_generated_image_asset_metadata,
|
||||
GeneratedImageAssetDataUrl, decode_generated_image_asset_data_url,
|
||||
normalize_generated_image_asset_mime,
|
||||
};
|
||||
|
||||
@@ -74,10 +74,9 @@ use crate::{
|
||||
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, OpenAiGeneratedImages, OpenAiReferenceImage,
|
||||
build_openai_image_http_client, create_openai_image_edit,
|
||||
create_openai_image_edit_with_references, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client,
|
||||
create_openai_image_edit, create_openai_image_edit_with_references,
|
||||
create_openai_image_generation, require_openai_image_settings,
|
||||
},
|
||||
platform_errors::map_oss_error,
|
||||
request_context::RequestContext,
|
||||
@@ -87,7 +86,6 @@ use crate::{
|
||||
},
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
};
|
||||
|
||||
const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent";
|
||||
const MATCH3D_WORKS_PROVIDER: &str = "match3d-works";
|
||||
const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime";
|
||||
@@ -101,7 +99,9 @@ const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 20;
|
||||
const MATCH3D_ITEM_VIEW_COUNT: usize = 5;
|
||||
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 10;
|
||||
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 20;
|
||||
#[cfg(test)]
|
||||
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL: &str = "gemini-3-pro-image-preview";
|
||||
#[cfg(test)]
|
||||
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO: &str = "1:1";
|
||||
const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000;
|
||||
const MATCH3D_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000;
|
||||
@@ -509,7 +509,9 @@ use self::runtime::*;
|
||||
mod item_assets;
|
||||
use self::item_assets::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod vector_engine_gemini;
|
||||
#[cfg(test)]
|
||||
use self::vector_engine_gemini::*;
|
||||
|
||||
fn ensure_non_empty(
|
||||
@@ -528,6 +530,16 @@ fn ensure_non_empty(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn match3d_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
fn match3d_json<T>(
|
||||
payload: Result<Json<T>, JsonRejection>,
|
||||
request_context: &RequestContext,
|
||||
|
||||
@@ -735,10 +735,9 @@ pub(super) struct Match3DMaterialSheet {
|
||||
pub(super) image: DownloadedOpenAiImage,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) struct Match3DVectorEngineGeminiImageSettings {
|
||||
pub(super) base_url: String,
|
||||
pub(super) api_key: String,
|
||||
pub(super) request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1482,6 +1481,7 @@ pub(super) fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgrou
|
||||
.is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn build_match3d_material_sheet_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset {
|
||||
Match3DGeneratedItemAsset {
|
||||
item_id: format!("match3d-item-{index}"),
|
||||
@@ -149,17 +147,17 @@ fn match3d_item_image_path_segments_stay_unique_for_chinese_names() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
let width = 500;
|
||||
let height = 500;
|
||||
fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
|
||||
let width = 1000;
|
||||
let height = 1000;
|
||||
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
|
||||
let mut sheet = image::RgbaImage::new(width, height);
|
||||
for row in 0..5 {
|
||||
for col in 0..5 {
|
||||
for row in 0..10 {
|
||||
for col in 0..10 {
|
||||
let color = image::Rgba([
|
||||
32 + row as u8 * 40,
|
||||
24 + col as u8 * 36,
|
||||
210 - row as u8 * 30,
|
||||
24 + row as u8 * 16,
|
||||
30 + col as u8 * 14,
|
||||
210 - row as u8 * 10,
|
||||
255,
|
||||
]);
|
||||
for y in row * 100..(row + 1) * 100 {
|
||||
@@ -182,22 +180,24 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
|
||||
|
||||
assert_eq!(slices.len(), 3);
|
||||
for (row, views) in slices.iter().enumerate() {
|
||||
for (item_index, views) in slices.iter().enumerate() {
|
||||
assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT);
|
||||
for (col, view) in views.iter().enumerate() {
|
||||
for (view_index, view) in views.iter().enumerate() {
|
||||
let decoded = image::load_from_memory(view.bytes.as_slice())
|
||||
.expect("view should decode")
|
||||
.to_rgba8();
|
||||
let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2);
|
||||
let source_row = item_index / 2;
|
||||
let source_col = (item_index % 2) * MATCH3D_ITEM_VIEW_COUNT + view_index;
|
||||
assert_eq!(
|
||||
pixel.0,
|
||||
[
|
||||
32 + row as u8 * 40,
|
||||
24 + col as u8 * 36,
|
||||
210 - row as u8 * 30,
|
||||
24 + source_row as u8 * 16,
|
||||
30 + source_col as u8 * 14,
|
||||
210 - source_row as u8 * 10,
|
||||
255,
|
||||
],
|
||||
"row {row} col {col} should be cut from the fixed 5*5 grid row"
|
||||
"item {item_index} view {view_index} should be cut from the fixed 10*10 grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -205,8 +205,8 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() {
|
||||
let width = 500;
|
||||
let height = 500;
|
||||
let width = 1000;
|
||||
let height = 1000;
|
||||
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
|
||||
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
|
||||
for y in 1..5 {
|
||||
@@ -689,35 +689,35 @@ fn match3d_legacy_item_asset_without_size_defaults_to_large() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() {
|
||||
fn match3d_draft_item_plan_rounds_up_to_full_spritesheet_batch() {
|
||||
let plan = parse_match3d_draft_plan(
|
||||
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#,
|
||||
&config("水果", 12, 4),
|
||||
)
|
||||
.expect("draft plan should parse");
|
||||
|
||||
assert_eq!(plan.items.len(), 10);
|
||||
assert_eq!(plan.items.len(), MATCH3D_MATERIAL_ITEM_BATCH_SIZE);
|
||||
assert_eq!(plan.items[8].name, "蓝莓");
|
||||
assert_ne!(plan.items[9].name, "蓝莓");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_generated_item_count_rounds_up_to_five_multiples() {
|
||||
fn match3d_generated_item_count_uses_full_spritesheet_batch() {
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 8, 2)),
|
||||
5
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 12, 4)),
|
||||
10
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 16, 6)),
|
||||
15
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
|
||||
25
|
||||
MATCH3D_MAX_GENERATED_ITEM_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -733,12 +733,12 @@ fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_item_asset_points_cost_counts_five_item_batches() {
|
||||
fn match3d_item_asset_points_cost_counts_spritesheet_batches() {
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(0), 0);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(1), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(5), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(6), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(10), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(20), 2);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(21), 4);
|
||||
assert_eq!(calculate_match3d_item_assets_points_cost(40), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -777,7 +777,10 @@ fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() {
|
||||
);
|
||||
|
||||
assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]);
|
||||
assert_eq!(plan.padded_item_names.len(), 5);
|
||||
assert_eq!(
|
||||
plan.padded_item_names.len(),
|
||||
MATCH3D_MATERIAL_ITEM_BATCH_SIZE
|
||||
);
|
||||
assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]);
|
||||
assert_eq!(
|
||||
calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()),
|
||||
@@ -900,28 +903,27 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() {
|
||||
fn match3d_material_sheet_prompt_requires_uniform_ten_by_ten_layout() {
|
||||
let prompt = build_match3d_material_sheet_prompt(
|
||||
&config("水果", 12, 4),
|
||||
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
|
||||
);
|
||||
|
||||
assert!(prompt.contains("5行*5列"));
|
||||
assert!(prompt.contains("严格5*5均匀排布"));
|
||||
assert!(prompt.contains("10行*10列"));
|
||||
assert!(prompt.contains("素材间距严格均匀分布"));
|
||||
assert!(prompt.contains("每一行包含两种物品"));
|
||||
assert!(prompt.contains("每种物品的五个不同形态"));
|
||||
assert!(prompt.contains("绿幕背景"));
|
||||
assert!(prompt.contains("#00FF00"));
|
||||
assert!(prompt.contains("单个素材格宽度的1/4空白间距"));
|
||||
assert!(prompt.contains("约25%单格宽度"));
|
||||
assert!(prompt.contains("禁止主体跨格"));
|
||||
assert!(prompt.contains("贴边或越界"));
|
||||
assert!(prompt.contains("严禁出现两种高相似度的物品"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_prompt_hardens_pixel_retro_style() {
|
||||
fn match3d_pixel_retro_style_prompt_hardens_asset_style_and_negative_prompt() {
|
||||
let mut config = config("水果", 12, 4);
|
||||
config.asset_style_id = Some("pixel-retro".to_string());
|
||||
config.asset_style_label = Some("像素复古".to_string());
|
||||
let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]);
|
||||
let prompt = resolve_match3d_asset_style_prompt(&config).expect("style prompt should exist");
|
||||
let negative_prompt = build_match3d_material_sheet_negative_prompt(&config);
|
||||
|
||||
assert!(prompt.contains("64x64"));
|
||||
@@ -1004,13 +1006,9 @@ fn match3d_extracts_vector_engine_gemini_inline_image_data() {
|
||||
fn match3d_vector_engine_gemini_url_accepts_root_or_v1_base() {
|
||||
let root_settings = Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: "https://api.vectorengine.cn".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
let v1_settings = Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: "https://api.vectorengine.cn/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,165 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn generate_match3d_material_sheet(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
) -> Result<Match3DMaterialSheet, AppError> {
|
||||
let settings = require_match3d_vector_engine_gemini_image_settings(state)?;
|
||||
let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?;
|
||||
let prompt = build_match3d_material_sheet_prompt(config, item_names);
|
||||
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
|
||||
let generated = create_match3d_vector_engine_gemini_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
negative_prompt.as_str(),
|
||||
"抓大鹅素材图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅素材图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DMaterialSheet {
|
||||
task_id: generated.task_id,
|
||||
prompt,
|
||||
image_src: None,
|
||||
image_object_key: None,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_match3d_vector_engine_gemini_image_settings(
|
||||
state: &AppState,
|
||||
) -> Result<Match3DVectorEngineGeminiImageSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_vector_engine_gemini_image_http_client(
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_match3d_vector_engine_gemini_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let request_body = build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt,
|
||||
negative_prompt,
|
||||
MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO,
|
||||
);
|
||||
let response = http_client
|
||||
.post(build_match3d_vector_engine_gemini_generate_content_url(
|
||||
settings,
|
||||
))
|
||||
.query(&[("key", settings.api_key.as_str())])
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
status,
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
));
|
||||
}
|
||||
|
||||
let payload = parse_match3d_json_payload(
|
||||
response_text.as_str(),
|
||||
"解析抓大鹅 VectorEngine Gemini 图片生成响应失败",
|
||||
"vector-engine-gemini",
|
||||
)?;
|
||||
let image_urls = extract_match3d_image_urls(&payload);
|
||||
if !image_urls.is_empty() {
|
||||
return download_match3d_images_from_urls(
|
||||
http_client,
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
image_urls,
|
||||
1,
|
||||
"vector-engine-gemini",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let b64_images = extract_match3d_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(match3d_images_from_base64(
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
b64_images,
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片",
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
@@ -201,125 +41,6 @@ fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str
|
||||
format!("{prompt}\n避免:{negative_prompt}")
|
||||
}
|
||||
|
||||
async fn download_match3d_images_from_urls(
|
||||
http_client: &reqwest::Client,
|
||||
task_id: String,
|
||||
image_urls: Vec<String>,
|
||||
candidate_count: u32,
|
||||
provider: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
{
|
||||
images
|
||||
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
|
||||
}
|
||||
Ok(OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_match3d_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
provider: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("下载抓大鹅生成图片失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": "下载抓大鹅生成图片失败",
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
|
||||
Ok(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_images_from_base64(
|
||||
task_id: String,
|
||||
b64_images: Vec<String>,
|
||||
candidate_count: u32,
|
||||
) -> OpenAiGeneratedImages {
|
||||
let images = b64_images
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
|
||||
.collect();
|
||||
OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
|
||||
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
|
||||
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
|
||||
Some(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_match3d_json_payload(
|
||||
raw_text: &str,
|
||||
failure_context: &str,
|
||||
provider: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("{failure_context}:{error}"),
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "url", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
|
||||
let mut deduped = Vec::new();
|
||||
for url in urls {
|
||||
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
|
||||
deduped.push(url);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
|
||||
let mut values = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
|
||||
@@ -365,12 +86,6 @@ fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec<String>)
|
||||
}
|
||||
}
|
||||
|
||||
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, target_key, &mut results);
|
||||
results.into_iter().next()
|
||||
}
|
||||
|
||||
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
@@ -408,79 +123,3 @@ fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &m
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
upstream_status: reqwest::StatusCode,
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> AppError {
|
||||
let message = parse_match3d_api_error_message(raw_text, fallback_message);
|
||||
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
|
||||
tracing::warn!(
|
||||
provider = "vector-engine-gemini",
|
||||
upstream_status = upstream_status.as_u16(),
|
||||
message = %message,
|
||||
raw_excerpt = %raw_excerpt,
|
||||
"抓大鹅 VectorEngine Gemini 图片生成上游请求失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"upstreamStatus": upstream_status.as_u16(),
|
||||
"message": message,
|
||||
"rawExcerpt": raw_excerpt,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
let trimmed = raw_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return fallback_message.to_string();
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
|
||||
for key in ["message", "code"] {
|
||||
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
|
||||
return if key == "message" {
|
||||
value
|
||||
} else {
|
||||
format!("{fallback_message}({value})")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
|
||||
raw_text.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/png");
|
||||
match mime_type {
|
||||
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||||
mime_type.to_string()
|
||||
}
|
||||
_ => "image/png".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn match3d_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,54 +189,6 @@ pub(super) fn resolve_author_display_name(
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "玩家".to_string())
|
||||
}
|
||||
pub(super) async fn ensure_match3d_background_asset(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
config: &Match3DConfigJson,
|
||||
background_prompt: &str,
|
||||
mut assets: Vec<Match3DGeneratedItemAsset>,
|
||||
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
|
||||
let normalized_prompt = normalize_match3d_background_prompt(background_prompt);
|
||||
let resolved_prompt = if normalized_prompt.is_empty() {
|
||||
build_fallback_match3d_background_prompt(config)
|
||||
} else {
|
||||
normalized_prompt
|
||||
};
|
||||
if let Some(existing_background) = find_match3d_generated_background_asset(&assets) {
|
||||
if is_match3d_background_asset_ready(&existing_background) {
|
||||
return Ok(assets);
|
||||
}
|
||||
}
|
||||
|
||||
let generated_background = generate_match3d_level_asset_bundle(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
config,
|
||||
&resolved_prompt,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
attach_match3d_background_asset_to_assets(&mut assets, generated_background);
|
||||
persist_match3d_generated_item_assets_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
&assets,
|
||||
)
|
||||
.await?;
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_or_generate_match3d_level_asset_bundle(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
@@ -769,6 +721,7 @@ pub(super) fn build_match3d_background_from_scene_prompt() -> String {
|
||||
"移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn build_match3d_background_generation_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
prompt: &str,
|
||||
|
||||
@@ -2,9 +2,12 @@ use axum::http::StatusCode;
|
||||
use platform_image::{
|
||||
DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage,
|
||||
VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client,
|
||||
build_vector_engine_image_request_body, create_vector_engine_image_edit,
|
||||
create_vector_engine_image_edit_with_references, create_vector_engine_image_generation,
|
||||
download_remote_image as download_platform_image_remote_image, vector_engine_images_edit_url,
|
||||
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
|
||||
create_vector_engine_image_generation,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use platform_image::{
|
||||
build_vector_engine_image_request_body, vector_engine_images_edit_url,
|
||||
vector_engine_images_generation_url,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -233,15 +236,7 @@ pub(crate) async fn create_openai_image_edit_with_references(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
download_platform_image_remote_image(http_client, image_url)
|
||||
.await
|
||||
.map_err(map_platform_image_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_openai_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
@@ -430,10 +425,12 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
|
||||
AppError::from_status(status).with_details(details)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn vector_engine_images_generation_url_for_test(settings: &OpenAiImageSettings) -> String {
|
||||
vector_engine_images_generation_url(&settings.provider_settings())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn vector_engine_images_edit_url_for_test(settings: &OpenAiImageSettings) -> String {
|
||||
vector_engine_images_edit_url(&settings.provider_settings())
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RuntimeStoryTextPromptParams<'a> {
|
||||
pub world_type: &'a str,
|
||||
pub character: Value,
|
||||
pub monsters: Value,
|
||||
pub history: Value,
|
||||
pub choice: Value,
|
||||
pub context: Value,
|
||||
pub available_options: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RuntimeNpcDialoguePromptParams<'a> {
|
||||
pub world_type: &'a str,
|
||||
@@ -25,42 +14,6 @@ pub(crate) struct RuntimeNpcDialoguePromptParams<'a> {
|
||||
pub available_options: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RuntimeReasonedStoryPromptParams<'a> {
|
||||
pub world_type: &'a str,
|
||||
pub character: &'a Value,
|
||||
pub monsters: Vec<Value>,
|
||||
pub history: Vec<Value>,
|
||||
pub context: Value,
|
||||
pub choice: &'a str,
|
||||
pub result_summary: &'a str,
|
||||
pub requested_option: Value,
|
||||
pub available_options: Vec<Value>,
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_story_director_system_prompt(initial: bool) -> &'static str {
|
||||
if initial {
|
||||
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。"
|
||||
} else {
|
||||
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_story_director_user_prompt(
|
||||
params: RuntimeStoryTextPromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"worldType": params.world_type,
|
||||
"character": params.character,
|
||||
"monsters": params.monsters,
|
||||
"history": params.history,
|
||||
"choice": params.choice,
|
||||
"context": params.context,
|
||||
"availableOptions": params.available_options,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str {
|
||||
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。"
|
||||
}
|
||||
@@ -200,31 +153,6 @@ pub(crate) fn build_npc_recruit_dialogue_user_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_reasoned_story_system_prompt() -> &'static str {
|
||||
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态,不要发明额外奖励。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_reasoned_story_user_prompt(
|
||||
params: RuntimeReasonedStoryPromptParams<'_>,
|
||||
) -> String {
|
||||
let state_prompt = json!({
|
||||
"worldType": params.world_type,
|
||||
"character": params.character,
|
||||
"monsters": params.monsters,
|
||||
"history": params.history,
|
||||
"context": params.context,
|
||||
"choice": params.choice,
|
||||
"resultSummary": params.result_summary,
|
||||
"requestedOption": params.requested_option,
|
||||
"availableOptions": params.available_options,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
format!(
|
||||
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{state_prompt}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。
|
||||
你只输出这名 NPC 此刻会对玩家说的一轮回复。
|
||||
只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
|
||||
|
||||
@@ -23,7 +23,7 @@ use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
||||
use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest};
|
||||
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
|
||||
use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||
use serde_json::{Map, Value, json};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::{
|
||||
creation_audio::CreationAudioAsset,
|
||||
puzzle_agent::{
|
||||
@@ -58,7 +58,7 @@ use spacetime_client::{
|
||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput,
|
||||
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
|
||||
PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
|
||||
@@ -977,6 +977,7 @@ pub(crate) fn attach_selected_puzzle_candidate_to_levels(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn resolve_puzzle_initial_ui_background_prompt(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
@@ -1042,6 +1043,7 @@ pub(crate) fn build_puzzle_ui_background_generation_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn attach_puzzle_level_ui_background(
|
||||
levels: &mut [PuzzleDraftLevelRecord],
|
||||
level_id: &str,
|
||||
@@ -1083,27 +1085,6 @@ pub(crate) fn attach_puzzle_level_asset_bundle(
|
||||
level.ui_background_image_object_key = Some(generated.level_background.object_key);
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_initial_ui_background_required(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> {
|
||||
let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level);
|
||||
let generated = generate_puzzle_ui_background_image(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
target_level.level_name.as_str(),
|
||||
prompt.as_str(),
|
||||
)
|
||||
.await?;
|
||||
Ok((prompt, generated))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_level_asset_bundle_required(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
|
||||
@@ -396,49 +396,6 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_gallery_card_response(
|
||||
state: &PuzzleApiState,
|
||||
item: PuzzleGalleryCardRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let author = resolve_puzzle_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
None,
|
||||
);
|
||||
PuzzleWorkSummaryResponse {
|
||||
work_id: item.work_id,
|
||||
profile_id: item.profile_id,
|
||||
owner_user_id: item.owner_user_id,
|
||||
source_session_id: item.source_session_id,
|
||||
author_display_name: author.display_name,
|
||||
work_title: item.work_title,
|
||||
work_description: item.work_description,
|
||||
level_name: item.level_name,
|
||||
summary: item.summary,
|
||||
theme_tags: item.theme_tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
cover_asset_id: item.cover_asset_id,
|
||||
publication_status: item.publication_status,
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
play_count: item.play_count,
|
||||
remix_count: item.remix_count,
|
||||
like_count: item.like_count,
|
||||
recent_play_count_7d: item.recent_play_count_7d,
|
||||
point_incentive_total_half_points: item.point_incentive_total_half_points,
|
||||
point_incentive_claimed_points: item.point_incentive_claimed_points,
|
||||
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
|
||||
point_incentive_claimable_points: item
|
||||
.point_incentive_total_half_points
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status: item.generation_status,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_public_work_puzzle_gallery_card_response(
|
||||
state: &PuzzleApiState,
|
||||
item: spacetime_client::PublicWorkGalleryEntryRecord,
|
||||
|
||||
@@ -44,7 +44,6 @@ fn puzzle_vector_engine_create_request_never_embeds_reference_image() {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: cursor.get_ref().len(),
|
||||
bytes: cursor.into_inner(),
|
||||
signed_read_url: None,
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
@@ -197,15 +196,11 @@ fn puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() {
|
||||
fn puzzle_vector_engine_create_request_never_embeds_reference_payload() {
|
||||
let reference_image = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 4,
|
||||
bytes: b"test".to_vec(),
|
||||
signed_read_url: Some(
|
||||
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
@@ -583,7 +578,6 @@ fn puzzle_uploaded_cover_can_reuse_resolved_history_image() {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 8,
|
||||
bytes: b"pngbytes".to_vec(),
|
||||
signed_read_url: None,
|
||||
};
|
||||
|
||||
let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved);
|
||||
|
||||
@@ -45,7 +45,6 @@ pub(crate) struct PuzzleResolvedReferenceImage {
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) bytes_len: usize,
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) signed_read_url: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct GeneratedPuzzleImageCandidate {
|
||||
@@ -318,10 +317,10 @@ pub(crate) fn build_puzzle_downloaded_image_reference(
|
||||
mime_type: image.mime_type.clone(),
|
||||
bytes_len: image.bytes.len(),
|
||||
bytes: image.bytes.clone(),
|
||||
signed_read_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
image_model: PuzzleImageModel,
|
||||
prompt: &str,
|
||||
@@ -330,7 +329,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Value {
|
||||
let body = Map::from_iter([
|
||||
let body = serde_json::Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(image_model.request_model_name().to_string()),
|
||||
@@ -415,32 +414,6 @@ pub(crate) fn collect_puzzle_reference_image_sources(
|
||||
sources
|
||||
}
|
||||
|
||||
pub(crate) fn collect_legacy_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in legacy_reference_image_src
|
||||
.into_iter()
|
||||
.chain(reference_image_srcs.iter().map(String::as_str))
|
||||
{
|
||||
let normalized = source.trim();
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !sources
|
||||
.iter()
|
||||
.any(|existing: &String| existing == normalized)
|
||||
{
|
||||
sources.push(normalized.to_string());
|
||||
}
|
||||
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
pub(crate) fn has_puzzle_reference_images(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
@@ -463,6 +436,7 @@ pub(crate) fn should_use_puzzle_reference_image_generation(
|
||||
use_reference_image_generation && has_puzzle_reference_image(reference_image_src)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String {
|
||||
let prompt = prompt.trim();
|
||||
let negative_prompt = negative_prompt.trim();
|
||||
@@ -525,7 +499,6 @@ pub(crate) async fn resolve_puzzle_reference_image(
|
||||
mime_type: parsed.mime_type,
|
||||
bytes_len,
|
||||
bytes: parsed.bytes,
|
||||
signed_read_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -758,7 +731,6 @@ async fn download_signed_puzzle_reference_image(
|
||||
mime_type,
|
||||
bytes_len,
|
||||
bytes: body.to_vec(),
|
||||
signed_read_url: Some(signed_read_url),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1075,47 +1047,6 @@ pub(crate) fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
|
||||
Some(output)
|
||||
}
|
||||
|
||||
pub(crate) fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_puzzle_strings_by_key(payload, target_key, &mut results);
|
||||
results.into_iter().next()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_puzzle_strings_by_key(
|
||||
payload: &Value,
|
||||
target_key: &str,
|
||||
results: &mut Vec<String>,
|
||||
) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_puzzle_strings_by_key(entry, target_key, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if key == target_key {
|
||||
collect_puzzle_string_values(value, results);
|
||||
}
|
||||
collect_puzzle_strings_by_key(value, target_key, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::String(text) => results.push(text.to_string()),
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_puzzle_string_values(item, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
|
||||
@@ -10,9 +10,11 @@ use shared_contracts::{
|
||||
puzzle_works::PuzzleWorkSummaryResponse,
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, MutexGuard, OwnedMutexGuard, RwLock},
|
||||
sync::{Mutex, MutexGuard, RwLock},
|
||||
time,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use tokio::sync::OwnedMutexGuard;
|
||||
|
||||
use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext};
|
||||
|
||||
@@ -69,6 +71,7 @@ impl PuzzleGalleryCache {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub async fn read_stale_response(&self) -> Option<PuzzleGalleryCachedResponse> {
|
||||
let guard = self.inner.read().await;
|
||||
let entry = guard.as_ref()?;
|
||||
@@ -77,6 +80,7 @@ impl PuzzleGalleryCache {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn try_acquire_owned_rebuild_guard(&self) -> Option<OwnedMutexGuard<()>> {
|
||||
self.rebuild_lock.clone().try_lock_owned().ok()
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ use axum::extract::FromRef;
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
use module_auth::{
|
||||
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
||||
RefreshAuthStoreSnapshotResult, RefreshSessionService, WechatAuthService,
|
||||
WechatAuthStateService,
|
||||
RefreshSessionService, WechatAuthService, WechatAuthStateService,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
use module_auth::RefreshAuthStoreSnapshotResult;
|
||||
use module_runtime::RuntimeSnapshotRecord;
|
||||
#[cfg(test)]
|
||||
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||
@@ -316,6 +317,7 @@ pub enum AppStateInitError {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
#[cfg(test)]
|
||||
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
|
||||
Self::new_with_empty_auth_store(config)
|
||||
}
|
||||
@@ -661,6 +663,7 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn refresh_auth_store_from_snapshot_json(
|
||||
&self,
|
||||
snapshot_json: &str,
|
||||
|
||||
@@ -97,22 +97,10 @@ pub(crate) fn record_puzzle_gallery_cache_hit() {
|
||||
puzzle_gallery_cache_metrics().hits.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_stale_hit() {
|
||||
puzzle_gallery_cache_metrics().stale_hits.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_miss() {
|
||||
puzzle_gallery_cache_metrics().misses.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_refresh_started() {
|
||||
puzzle_gallery_cache_metrics().refreshes_started.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_refresh_failed() {
|
||||
puzzle_gallery_cache_metrics().refreshes_failed.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_rebuild(
|
||||
duration: std::time::Duration,
|
||||
data_bytes: usize,
|
||||
@@ -208,10 +196,7 @@ struct HttpMetrics {
|
||||
|
||||
struct PuzzleGalleryCacheMetrics {
|
||||
hits: Counter<u64>,
|
||||
stale_hits: Counter<u64>,
|
||||
misses: Counter<u64>,
|
||||
refreshes_started: Counter<u64>,
|
||||
refreshes_failed: Counter<u64>,
|
||||
rebuilds: Counter<u64>,
|
||||
rebuild_duration: opentelemetry::metrics::Histogram<f64>,
|
||||
data_json_bytes: opentelemetry::metrics::Histogram<u64>,
|
||||
@@ -301,22 +286,10 @@ fn puzzle_gallery_cache_metrics() -> &'static PuzzleGalleryCacheMetrics {
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.hits")
|
||||
.with_description("Puzzle gallery response cache hits")
|
||||
.build(),
|
||||
stale_hits: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.stale_hits")
|
||||
.with_description("Puzzle gallery stale response cache hits")
|
||||
.build(),
|
||||
misses: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.misses")
|
||||
.with_description("Puzzle gallery response cache misses")
|
||||
.build(),
|
||||
refreshes_started: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.refreshes_started")
|
||||
.with_description("Puzzle gallery background refresh start count")
|
||||
.build(),
|
||||
refreshes_failed: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.refreshes_failed")
|
||||
.with_description("Puzzle gallery background refresh failure count")
|
||||
.build(),
|
||||
rebuilds: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.rebuilds")
|
||||
.with_description("Puzzle gallery response cache rebuild count")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use axum::http::{Method, StatusCode};
|
||||
#[cfg(not(test))]
|
||||
use module_auth::AuthLoginMethod;
|
||||
use module_runtime::RuntimeTrackingScopeKind;
|
||||
use serde_json::{Value, json};
|
||||
@@ -553,6 +554,7 @@ fn is_dynamic_path_segment(segment: &str) -> bool {
|
||||
|| lower.starts_with("session")
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub async fn record_daily_login_tracking_event_after_success(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
|
||||
@@ -18,7 +18,5 @@ pub use handlers::{
|
||||
publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset,
|
||||
};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use generation::generate_background_music_asset_for_creation;
|
||||
pub(crate) use generation::generate_sound_effect_asset_for_creation;
|
||||
pub(crate) use types::GeneratedCreationAudioTarget;
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::{
|
||||
clock::{current_utc_iso_text, current_utc_micros},
|
||||
errors::{map_platform_audio_error, vector_engine_bad_gateway},
|
||||
publish::wait_for_generated_audio_asset,
|
||||
tasks::{create_background_music_task_response, create_sound_effect_task_response},
|
||||
tasks::create_sound_effect_task_response,
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot, GeneratedCreationAudioTarget},
|
||||
};
|
||||
|
||||
@@ -86,92 +86,6 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
outcome
|
||||
}
|
||||
|
||||
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 started_at_micros = current_utc_micros();
|
||||
let normalized_prompt = platform_audio::normalize_limited_text_allow_empty(
|
||||
&prompt,
|
||||
"prompt",
|
||||
platform_audio::SUNO_PROMPT_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let normalized_title = platform_audio::normalize_limited_text(
|
||||
&title,
|
||||
"title",
|
||||
platform_audio::SUNO_TITLE_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let request_payload = json!({
|
||||
"kind": "background_music",
|
||||
"promptChars": normalized_prompt.chars().count(),
|
||||
"titleChars": normalized_title.chars().count(),
|
||||
"hasTags": tags.as_ref().is_some_and(|value| !value.trim().is_empty()),
|
||||
"model": model,
|
||||
"targetEntityKind": target.entity_kind,
|
||||
"targetEntityId": target.entity_id,
|
||||
"targetSlot": target.slot,
|
||||
"targetAssetKind": target.asset_kind,
|
||||
});
|
||||
let outcome = async {
|
||||
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::<_, AppError>(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()),
|
||||
})
|
||||
}
|
||||
.await;
|
||||
record_creation_audio_generation_run(
|
||||
state,
|
||||
"background_music",
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
&outcome,
|
||||
)
|
||||
.await;
|
||||
outcome
|
||||
}
|
||||
|
||||
async fn record_creation_audio_generation_run(
|
||||
state: &AppState,
|
||||
operation: &'static str,
|
||||
|
||||
@@ -1,42 +1,10 @@
|
||||
use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest};
|
||||
use platform_audio::SoundEffectTaskRequest;
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
use super::{errors::map_platform_audio_error, settings::require_vector_engine_audio_settings};
|
||||
|
||||
pub(super) 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 = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let task = platform_audio::submit_background_music_task(
|
||||
&http_client,
|
||||
&settings,
|
||||
BackgroundMusicTaskRequest {
|
||||
prompt,
|
||||
title,
|
||||
tags,
|
||||
model,
|
||||
instrumental: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)?;
|
||||
|
||||
Ok(creation_audio::AudioGenerationTaskResponse {
|
||||
kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic,
|
||||
task_id: task.task_id,
|
||||
provider: task.provider,
|
||||
status: task.status,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn create_sound_effect_task_response(
|
||||
state: &AppState,
|
||||
prompt: String,
|
||||
|
||||
@@ -287,7 +287,7 @@ pub(crate) struct WechatMiniProgramMessagePushQuery {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatMiniProgramEncryptedMessage {
|
||||
#[serde(rename = "ToUserName", alias = "to_user_name", default)]
|
||||
to_user_name: Option<String>,
|
||||
_to_user_name: Option<String>,
|
||||
#[serde(rename = "Encrypt", alias = "encrypt")]
|
||||
encrypt: String,
|
||||
}
|
||||
|
||||
@@ -229,33 +229,6 @@ pub async fn list_wooden_fish_works(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_wooden_fish_work(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||||
let works = state
|
||||
.spacetime_client()
|
||||
.delete_wooden_fish_work(profile_id, authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
&request_context,
|
||||
WOODEN_FISH_CREATION_PROVIDER,
|
||||
map_wooden_fish_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
WoodenFishWorksResponse {
|
||||
items: works.into_iter().map(|work| work.summary).collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_wooden_fish_runtime_work(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
|
||||
@@ -87,6 +87,7 @@ fn orphan_work_author_summary() -> WorkAuthorSummary {
|
||||
}
|
||||
|
||||
/// 中文注释:运维回填只处理空作者或认证仓储不可再解析的历史 owner_user_id,避免把有效作品误转给占位账号。
|
||||
#[cfg(test)]
|
||||
pub fn should_rebind_orphan_work_owner(
|
||||
auth_user_service: &module_auth::AuthUserService,
|
||||
owner_user_id: &str,
|
||||
|
||||
@@ -1177,6 +1177,7 @@ mod tests {
|
||||
tile_atlas_asset: None,
|
||||
tile_assets: None,
|
||||
cover_composite: None,
|
||||
back_button_asset: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1273,6 +1274,7 @@ mod tests {
|
||||
tile_assets: Vec::new(),
|
||||
path: None,
|
||||
cover_composite: None,
|
||||
back_button_asset: None,
|
||||
generation_status: JumpHopGenerationStatus::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,24 +174,6 @@ use module_npc::{
|
||||
NpcStanceProfile as DomainNpcStanceProfile, NpcStateSnapshot as DomainNpcStateSnapshot,
|
||||
ResolveNpcInteractionInput as DomainResolveNpcInteractionInput,
|
||||
};
|
||||
use module_puzzle::{
|
||||
PuzzleAgentMessageSnapshot as DomainPuzzleAgentMessageSnapshot,
|
||||
PuzzleAgentSessionSnapshot as DomainPuzzleAgentSessionSnapshot,
|
||||
PuzzleAgentSuggestedAction as DomainPuzzleAgentSuggestedAction,
|
||||
PuzzleAnchorItem as DomainPuzzleAnchorItem, PuzzleAnchorPack as DomainPuzzleAnchorPack,
|
||||
PuzzleBoardSnapshot as DomainPuzzleBoardSnapshot,
|
||||
PuzzleCellPosition as DomainPuzzleCellPosition,
|
||||
PuzzleCreatorIntent as DomainPuzzleCreatorIntent, PuzzleDraftLevel as DomainPuzzleDraftLevel,
|
||||
PuzzleGeneratedImageCandidate as DomainPuzzleGeneratedImageCandidate,
|
||||
PuzzleMergedGroupState as DomainPuzzleMergedGroupState,
|
||||
PuzzlePieceState as DomainPuzzlePieceState, PuzzleResultDraft as DomainPuzzleResultDraft,
|
||||
PuzzleResultPreviewBlocker as DomainPuzzleResultPreviewBlocker,
|
||||
PuzzleResultPreviewEnvelope as DomainPuzzleResultPreviewEnvelope,
|
||||
PuzzleResultPreviewFinding as DomainPuzzleResultPreviewFinding,
|
||||
PuzzleRunSnapshot as DomainPuzzleRunSnapshot,
|
||||
PuzzleRuntimeLevelSnapshot as DomainPuzzleRuntimeLevelSnapshot,
|
||||
PuzzleWorkProfile as DomainPuzzleWorkProfile,
|
||||
};
|
||||
use module_runtime::{
|
||||
AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeBrowseHistoryRecord,
|
||||
RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord,
|
||||
|
||||
@@ -58,20 +58,6 @@ pub(crate) fn map_custom_world_library_detail_result(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn map_custom_world_gallery_list_result(
|
||||
result: CustomWorldGalleryListResult,
|
||||
) -> Result<Vec<CustomWorldGalleryEntryRecord>, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
Ok(result
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(map_custom_world_gallery_entry_snapshot)
|
||||
.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
pub(crate) fn map_custom_world_library_mutation_result(
|
||||
result: CustomWorldLibraryMutationResult,
|
||||
) -> Result<CustomWorldLibraryMutationRecord, SpacetimeClientError> {
|
||||
|
||||
@@ -553,18 +553,6 @@ fn map_match3d_gallery_entry(row: Match3DGalleryViewRow) -> PublicWorkGalleryEnt
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_detail_entry(row: Match3DGalleryViewRow) -> PublicWorkDetailEntry {
|
||||
let detail_payload_json = json_string(json!({
|
||||
"sourceType": "match3d",
|
||||
"themeText": row.theme_text,
|
||||
"referenceImageSrc": row.reference_image_src,
|
||||
"clearCount": row.clear_count,
|
||||
"difficulty": row.difficulty,
|
||||
"generatedItemAssetsReady": row.generated_item_assets_json.as_ref().is_some_and(|value| !value.trim().is_empty()),
|
||||
}));
|
||||
gallery_to_detail(map_match3d_gallery_entry(row), detail_payload_json)
|
||||
}
|
||||
|
||||
fn map_square_hole_gallery_entry(row: SquareHoleGalleryViewRow) -> PublicWorkGalleryEntry {
|
||||
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
|
||||
|
||||
|
||||
@@ -1912,174 +1912,6 @@ pub(crate) fn build_profile_save_archive_snapshot_from_row(
|
||||
}
|
||||
}
|
||||
|
||||
fn read_string_from_json(value: Option<&JsonValue>) -> Option<String> {
|
||||
value
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn resolve_profile_world_snapshot_meta(
|
||||
game_state: Option<&serde_json::Map<String, JsonValue>>,
|
||||
) -> Option<RuntimeProfileWorldSnapshotMeta> {
|
||||
let game_state = game_state?;
|
||||
let custom_world_profile = game_state
|
||||
.get("customWorldProfile")
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
if let Some(custom_world_profile) = custom_world_profile {
|
||||
let profile_id = read_string_from_json(custom_world_profile.get("id"));
|
||||
let world_title = read_string_from_json(custom_world_profile.get("name"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("title")));
|
||||
if profile_id.is_some() || world_title.is_some() {
|
||||
let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string());
|
||||
return Some(RuntimeProfileWorldSnapshotMeta {
|
||||
world_key: profile_id
|
||||
.as_ref()
|
||||
.map(|profile_id| format!("custom:{profile_id}"))
|
||||
.unwrap_or_else(|| format!("custom:{world_title}")),
|
||||
owner_user_id: None,
|
||||
profile_id,
|
||||
world_type: Some("CUSTOM".to_string()),
|
||||
world_title,
|
||||
world_subtitle: read_string_from_json(custom_world_profile.get("summary"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let world_type = read_string_from_json(game_state.get("worldType"))?;
|
||||
let current_scene_preset = game_state
|
||||
.get("currentScenePreset")
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
Some(RuntimeProfileWorldSnapshotMeta {
|
||||
world_key: format!("builtin:{world_type}"),
|
||||
owner_user_id: None,
|
||||
profile_id: None,
|
||||
world_type: Some(world_type.clone()),
|
||||
world_title: current_scene_preset
|
||||
.and_then(|preset| read_string_from_json(preset.get("name")))
|
||||
.unwrap_or_else(|| build_builtin_world_title(&world_type)),
|
||||
world_subtitle: current_scene_preset
|
||||
.and_then(|preset| {
|
||||
read_string_from_json(preset.get("summary"))
|
||||
.or_else(|| read_string_from_json(preset.get("description")))
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_profile_save_archive_meta(
|
||||
game_state: &JsonValue,
|
||||
current_story_json: Option<&str>,
|
||||
) -> Option<RuntimeProfileSaveArchiveMeta> {
|
||||
if is_non_persistent_runtime_snapshot(game_state) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let game_state_object = game_state.as_object();
|
||||
let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?;
|
||||
let story_engine_memory = game_state_object
|
||||
.and_then(|state| state.get("storyEngineMemory"))
|
||||
.and_then(JsonValue::as_object);
|
||||
let continue_game_digest = story_engine_memory
|
||||
.and_then(|memory| read_string_from_json(memory.get("continueGameDigest")));
|
||||
let current_story_text = parse_optional_json_str(current_story_json)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|story| story.as_object().cloned())
|
||||
.and_then(|story| read_string_from_json(story.get("text")));
|
||||
let custom_world_profile = game_state_object
|
||||
.and_then(|state| state.get("customWorldProfile"))
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
if let Some(custom_world_profile) = custom_world_profile {
|
||||
let world_name = read_string_from_json(custom_world_profile.get("name"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("title")))
|
||||
.unwrap_or_else(|| world_meta.world_title.clone());
|
||||
let subtitle = read_string_from_json(custom_world_profile.get("summary"))
|
||||
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
|
||||
.unwrap_or_else(|| world_meta.world_subtitle.clone());
|
||||
let summary_text = continue_game_digest
|
||||
.or(current_story_text)
|
||||
.or_else(|| {
|
||||
if subtitle.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(subtitle.clone())
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
|
||||
|
||||
return Some(RuntimeProfileSaveArchiveMeta {
|
||||
world_key: world_meta.world_key,
|
||||
owner_user_id: world_meta.owner_user_id,
|
||||
profile_id: world_meta.profile_id,
|
||||
world_type: world_meta.world_type,
|
||||
world_name,
|
||||
subtitle,
|
||||
summary_text,
|
||||
cover_image_src: read_string_from_json(custom_world_profile.get("coverImageSrc")),
|
||||
});
|
||||
}
|
||||
|
||||
let summary_text = continue_game_digest
|
||||
.or(current_story_text)
|
||||
.or_else(|| {
|
||||
if world_meta.world_subtitle.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(world_meta.world_subtitle.clone())
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
|
||||
let current_scene_preset = game_state_object
|
||||
.and_then(|state| state.get("currentScenePreset"))
|
||||
.and_then(JsonValue::as_object);
|
||||
|
||||
Some(RuntimeProfileSaveArchiveMeta {
|
||||
world_key: world_meta.world_key,
|
||||
owner_user_id: world_meta.owner_user_id,
|
||||
profile_id: world_meta.profile_id,
|
||||
world_type: world_meta.world_type,
|
||||
world_name: world_meta.world_title,
|
||||
subtitle: world_meta.world_subtitle.clone(),
|
||||
summary_text,
|
||||
cover_image_src: current_scene_preset
|
||||
.and_then(|preset| read_string_from_json(preset.get("imageSrc"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_non_persistent_runtime_snapshot(game_state: &JsonValue) -> bool {
|
||||
let Some(game_state) = game_state.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if game_state
|
||||
.get("runtimePersistenceDisabled")
|
||||
.and_then(JsonValue::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
read_string_from_json(game_state.get("runtimeMode")).as_deref(),
|
||||
Some("preview") | Some("test")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_builtin_world_title(world_type: &str) -> String {
|
||||
match world_type {
|
||||
"WUXIA" => "武侠世界".to_string(),
|
||||
"XIANXIA" => "仙侠世界".to_string(),
|
||||
_ => "叙事世界".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile_dashboard_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileDashboardGetInput,
|
||||
|
||||
Reference in New Issue
Block a user