清理后端编译警告

删除后端未使用的历史 helper、mapper、handler 和 re-export

将仅测试使用的导入、常量和辅助函数收口到 cfg(test)

补齐 Jump Hop 测试构造体字段并对齐 Match3D 当前素材表测试契约

验证后端 workspace cargo check 与 Match3D、Puzzle 相关测试
This commit is contained in:
2026-06-07 22:20:58 +08:00
parent cc84656a1f
commit decded991e
35 changed files with 109 additions and 1146 deletions

View File

@@ -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)
}

View File

@@ -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())

View File

@@ -37,7 +37,7 @@ use spacetime_client::{
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldLibraryEntryRecord,
CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput,
CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput,
CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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],

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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],

View File

@@ -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!(

View File

@@ -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",
}
}

View File

@@ -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,

View File

@@ -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())
}

View File

@@ -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 或解释。

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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(';')

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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")

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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>,

View File

@@ -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,

View File

@@ -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,
}
}

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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);

View File

@@ -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,