refactor: split large modules and normalize rust layout

This commit is contained in:
kdletters
2026-05-18 19:40:14 +08:00
parent 472a47eae7
commit 269f35cecf
51 changed files with 17492 additions and 17169 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,881 @@
use super::*;
pub(super) async fn submit_and_finalize_match3d_message(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: String,
payload: SendMatch3DAgentMessageRequest,
) -> Result<Match3DAgentSessionRecord, Response> {
ensure_non_empty(
request_context,
MATCH3D_AGENT_PROVIDER,
&session_id,
"sessionId",
)?;
ensure_non_empty(
request_context,
MATCH3D_AGENT_PROVIDER,
&payload.client_message_id,
"clientMessageId",
)?;
ensure_non_empty(
request_context,
MATCH3D_AGENT_PROVIDER,
&payload.text,
"text",
)?;
let submitted = state
.spacetime_client()
.submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.to_string(),
user_message_id: payload.client_message_id.clone(),
user_message_text: payload.text.clone(),
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})?;
let next_turn = submitted.current_turn.saturating_add(1);
let next_config = build_config_from_message(&submitted, &payload);
let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn);
let progress_percent = resolve_progress_percent_for_turn(next_turn);
let stage = if progress_percent >= 100 {
"ReadyToCompile"
} else {
"Collecting"
}
.to_string();
state
.spacetime_client()
.finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput {
session_id,
owner_user_id: owner_user_id.to_string(),
assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)),
assistant_reply_text: Some(assistant_reply),
config_json: serialize_match3d_config(&next_config),
progress_percent,
stage,
updated_at_micros: current_utc_micros(),
error_message: None,
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})
}
pub(super) async fn load_match3d_agent_session_response_with_persisted_assets(
state: &AppState,
owner_user_id: &str,
session: Match3DAgentSessionRecord,
) -> Match3DAgentSessionSnapshotResponse {
let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else {
return map_match3d_agent_session_response(session);
};
let assets =
get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await;
map_match3d_agent_session_response_with_assets(session, &assets)
}
fn resolve_match3d_session_existing_profile_id(
session: &Match3DAgentSessionRecord,
) -> Option<String> {
session
.draft
.as_ref()
.map(|draft| draft.profile_id.trim())
.filter(|profile_id| !profile_id.is_empty())
.or_else(|| {
session
.published_profile_id
.as_deref()
.map(str::trim)
.filter(|profile_id| !profile_id.is_empty())
})
.map(str::to_string)
}
pub(super) async fn compile_match3d_draft_for_session(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
game_name: Option<String>,
summary: Option<String>,
tags: Option<Vec<String>>,
cover_image_src: Option<String>,
generate_click_sound: Option<bool>,
) -> Result<(Match3DAgentSessionRecord, Vec<Match3DGeneratedItemAsset>), Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let initial_session = state
.spacetime_client()
.get_match3d_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})?;
let mut config = resolve_config_or_default(initial_session.config.as_ref());
if let Some(generate_click_sound) = generate_click_sound {
config.generate_click_sound = generate_click_sound;
}
// 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session
// 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。
let has_complete_form_config = !config.theme_text.trim().is_empty()
&& config.clear_count > 0
&& (1..=10).contains(&config.difficulty);
if !has_complete_form_config
&& (initial_session.current_turn < 3 || initial_session.progress_percent < 100)
{
return Err(match3d_bad_request(
request_context,
MATCH3D_AGENT_PROVIDER,
"match3d 创作配置尚未确认完成",
));
}
let requested_game_name = normalize_optional_match3d_text(game_name);
let requested_summary = normalize_optional_match3d_text(summary);
let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty());
let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src);
let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
let profile_id = resolve_match3d_draft_profile_id(&initial_session);
let initial_game_name = requested_game_name
.clone()
.unwrap_or_else(|| fallback_work_metadata.game_name.clone());
let initial_tags = requested_tags
.clone()
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
execute_billable_match3d_draft_generation(
state,
request_context,
owner_user_id.as_str(),
billing_asset_id.as_str(),
async {
let mut session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id.clone(),
owner_user_id.clone(),
profile_id.clone(),
Some(initial_game_name),
requested_summary.clone().or_else(|| Some(String::new())),
Some(serde_json::to_string(&initial_tags).unwrap_or_default()),
requested_cover_image_src.clone(),
None,
None,
)
.await?;
if session.draft.is_none() {
return Err(match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"),
));
}
let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await;
let resolved_game_name = requested_game_name
.unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone());
let resolved_summary = requested_summary
.clone()
.unwrap_or_else(|| generated_work_metadata.metadata.summary.clone());
let resolved_tags = match requested_tags {
Some(tags) => tags,
None => {
generate_match3d_work_tags_for_plan(
state,
resolved_game_name.as_str(),
config.theme_text.as_str(),
resolved_summary.as_str(),
&generated_work_metadata.metadata.tags,
)
.await
}
};
generated_work_metadata.metadata.tags = resolved_tags.clone();
session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id.clone(),
profile_id.clone(),
Some(resolved_game_name),
Some(resolved_summary),
Some(serde_json::to_string(&resolved_tags).unwrap_or_default()),
requested_cover_image_src.clone(),
None,
None,
)
.await?;
let existing_assets = get_match3d_existing_generated_item_assets(
state,
owner_user_id.as_str(),
profile_id.as_str(),
)
.await;
let generated_item_assets = generate_match3d_item_assets(
state,
request_context,
authenticated,
owner_user_id.as_str(),
session.session_id.as_str(),
profile_id.as_str(),
&config,
generated_work_metadata.items,
existing_assets,
)
.await?;
let generated_item_assets = ensure_match3d_background_asset(
state,
request_context,
authenticated,
owner_user_id.as_str(),
session.session_id.as_str(),
profile_id.as_str(),
&config,
generated_work_metadata.background_prompt.as_str(),
generated_item_assets,
)
.await?;
let existing_cover_image_src = get_match3d_existing_cover_image_src(
state,
owner_user_id.as_str(),
profile_id.as_str(),
)
.await;
let default_cover_image_src = requested_cover_image_src
.clone()
.or(existing_cover_image_src)
.or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets));
let next_session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session.session_id.clone(),
owner_user_id.clone(),
profile_id,
None,
None,
None,
default_cover_image_src,
None,
serialize_match3d_generated_item_assets(&generated_item_assets),
)
.await?;
Ok((next_session, generated_item_assets))
},
)
.await
}
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
async fn execute_billable_match3d_draft_generation<T, Fut>(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
operation: Fut,
) -> Result<T, Response>
where
Fut: Future<Output = Result<T, Response>>,
{
let points_consumed = consume_match3d_draft_generation_points(
state,
request_context,
owner_user_id,
billing_asset_id,
)
.await?;
match operation.await {
Ok(value) => Ok(value),
Err(response) => {
if points_consumed {
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
.await;
}
Err(response)
}
}
}
async fn consume_match3d_draft_generation_points(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
) -> Result<bool, Response> {
let ledger_id = format!(
"asset_operation_consume:{}:match3d_draft_generation:{}",
owner_user_id, billing_asset_id
);
match state
.spacetime_client()
.consume_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
ledger_id,
current_utc_micros(),
)
.await
{
Ok(_) => Ok(true),
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
tracing::warn!(
owner_user_id,
billing_asset_id,
error = %error,
"抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
);
Ok(false)
}
Err(error) => Err(match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_asset_operation_wallet_error(error),
)),
}
}
async fn refund_match3d_draft_generation_points(
state: &AppState,
owner_user_id: &str,
billing_asset_id: &str,
) {
let ledger_id = format!(
"asset_operation_refund:{}:match3d_draft_generation:{}",
owner_user_id, billing_asset_id
);
if let Err(error) = state
.spacetime_client()
.refund_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
ledger_id,
current_utc_micros(),
)
.await
{
tracing::error!(
owner_user_id,
billing_asset_id,
error = %error,
"抓大鹅草稿生成失败后的泥点退款失败"
);
}
}
fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String {
session
.draft
.as_ref()
.map(|draft| draft.profile_id.trim())
.filter(|profile_id| !profile_id.is_empty())
.or_else(|| {
session
.published_profile_id
.as_deref()
.map(str::trim)
.filter(|profile_id| !profile_id.is_empty())
})
.map(str::to_string)
.unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX))
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn upsert_match3d_draft_snapshot(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
owner_user_id: String,
profile_id: String,
game_name: Option<String>,
summary_text: Option<String>,
tags_json: Option<String>,
cover_image_src: Option<String>,
cover_asset_id: Option<String>,
generated_item_assets_json: Option<String>,
) -> Result<Match3DAgentSessionRecord, Response> {
state
.spacetime_client()
.compile_match3d_draft(Match3DCompileDraftRecordInput {
session_id,
owner_user_id,
profile_id,
author_display_name: resolve_author_display_name(state, authenticated),
game_name,
summary_text,
tags_json,
cover_image_src,
cover_asset_id,
compiled_at_micros: current_utc_micros(),
generated_item_assets_json,
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})
}
pub(super) fn build_config_from_create_request(
payload: &CreateMatch3DAgentSessionRequest,
) -> Match3DConfigJson {
Match3DConfigJson {
theme_text: payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(MATCH3D_DEFAULT_THEME)
.to_string(),
reference_image_src: payload.reference_image_src.clone(),
clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT),
difficulty: payload
.difficulty
.unwrap_or(MATCH3D_DEFAULT_DIFFICULTY)
.clamp(1, 10),
asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()),
asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()),
asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()),
generate_click_sound: payload.generate_click_sound.unwrap_or(false),
}
}
fn build_config_from_message(
session: &Match3DAgentSessionRecord,
payload: &SendMatch3DAgentMessageRequest,
) -> Match3DConfigJson {
let current = resolve_config_or_default(session.config.as_ref());
let text = payload.text.trim();
let reference_image_src = payload
.reference_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or(current.reference_image_src);
let quick_fill_requested =
payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置");
let mut theme_text = current.theme_text;
let mut clear_count = current.clear_count.max(1);
let mut difficulty = current.difficulty.clamp(1, 10);
let asset_style_id = current.asset_style_id;
let asset_style_label = current.asset_style_label;
let asset_style_prompt = current.asset_style_prompt;
let generate_click_sound = current.generate_click_sound;
match session.current_turn {
0 => {
theme_text = if quick_fill_requested {
MATCH3D_DEFAULT_THEME.to_string()
} else {
parse_theme_answer(text).unwrap_or(theme_text)
};
}
1 => {
clear_count = if quick_fill_requested {
clear_count
} else {
parse_number_after_keywords(text, &["消除", "次数", "clearCount"])
.unwrap_or(clear_count)
}
.max(1);
}
_ => {
difficulty = if quick_fill_requested {
difficulty
} else {
parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty)
}
.clamp(1, 10);
}
}
Match3DConfigJson {
theme_text,
reference_image_src,
clear_count,
difficulty,
asset_style_id,
asset_style_label,
asset_style_prompt,
generate_click_sound,
}
}
pub(super) fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson {
config
.map(|config| Match3DConfigJson {
theme_text: config.theme_text.clone(),
reference_image_src: config.reference_image_src.clone(),
clear_count: config.clear_count.max(1),
difficulty: config.difficulty.clamp(1, 10),
asset_style_id: config.asset_style_id.clone(),
asset_style_label: config.asset_style_label.clone(),
asset_style_prompt: config.asset_style_prompt.clone(),
generate_click_sound: config.generate_click_sound,
})
.unwrap_or_else(|| Match3DConfigJson {
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
reference_image_src: None,
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
})
}
pub(super) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
pub(super) fn serialize_match3d_config(config: &Match3DConfigJson) -> Option<String> {
serde_json::to_string(config).ok()
}
pub(super) fn build_seed_text(
payload: &CreateMatch3DAgentSessionRequest,
config: &Match3DConfigJson,
) -> String {
payload
.seed_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
format!(
"{}题材,消除{}次,难度{}",
config.theme_text, config.clear_count, config.difficulty
)
})
}
fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String {
format!(
"已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}",
config.theme_text,
config.clear_count,
config.clear_count.saturating_mul(3),
config.difficulty
)
}
pub(super) fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String {
match current_turn {
0 => MATCH3D_QUESTION_THEME.to_string(),
1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(),
2 => MATCH3D_QUESTION_DIFFICULTY.to_string(),
_ => build_match3d_assistant_reply(config),
}
}
pub(super) fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 {
match current_turn {
0 => 0,
1 => 33,
2 => 66,
_ => 100,
}
}
fn parse_theme_answer(text: &str) -> Option<String> {
for marker in ["题材", "主题"] {
if let Some((_, value)) = text.split_once(marker) {
let normalized = value
.trim_matches(|ch: char| ch == ':' || ch == '' || ch.is_whitespace())
.split_whitespace()
.next()
.unwrap_or_default()
.trim_matches(['。', '', ',', ';', ''])
.to_string();
if !normalized.is_empty() {
return Some(normalized);
}
}
}
let trimmed = text.trim();
if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit())
{
return Some(trimmed.to_string());
}
None
}
fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option<u32> {
for keyword in keywords {
if let Some(index) = text.find(keyword) {
let suffix = &text[index + keyword.len()..];
if let Some(value) = first_positive_integer(suffix) {
return Some(value);
}
}
}
first_positive_integer(text)
}
fn first_positive_integer(text: &str) -> Option<u32> {
let mut digits = String::new();
for ch in text.chars() {
if ch.is_ascii_digit() {
digits.push(ch);
} else if !digits.is_empty() {
break;
}
}
digits.parse::<u32>().ok().filter(|value| *value > 0)
}
pub(super) fn normalize_tags(tags: Vec<String>) -> Vec<String> {
let mut result: Vec<String> = Vec::new();
for tag in tags {
let trimmed = normalize_match3d_tag(tag.as_str());
if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) {
result.push(trimmed);
}
if result.len() >= 6 {
break;
}
}
result
}
fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
async fn generate_match3d_draft_plan(
state: &AppState,
config: &Match3DConfigJson,
) -> Match3DGeneratedDraftPlan {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return fallback_match3d_draft_plan(config);
};
let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。";
let gameplay_item_count = resolve_match3d_gameplay_item_count(config);
let generated_item_count = resolve_match3d_generated_item_count(config);
let user_prompt = format!(
"题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundPrompt、items。gameName 为 4 到 12 个中文字符不要包含“作品”“游戏”summary 为 18 到 48 个中文字符的作品描述说明题材氛围和核心体验不要写规则说明tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字后续会用同一作品信息再次生成作品标签backgroundPrompt 是用于生成局内纯背景图的中文提示词只描述竖屏移动端抓大鹅题材氛围、色彩和环境不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name、itemSize 和 soundPrompt。name 为 2 到 6 个汉字itemSize 只能是“大”“中”“小”之一,按物品真实相对尺寸判断,例如西瓜/大箱子偏大,苹果/杯子偏中,糖果/钥匙偏小soundPrompt 只作为历史字段保留,可返回空字符串。",
config.theme_text, gameplay_item_count, generated_item_count
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config)
.unwrap_or_else(|| fallback_match3d_draft_plan(config)),
Err(error) => {
tracing::warn!(
provider = MATCH3D_AGENT_PROVIDER,
theme_text = config.theme_text.as_str(),
error = %error,
"抓大鹅草稿生成计划失败,降级使用本地生成计划"
);
fallback_match3d_draft_plan(config)
}
}
}
pub(super) fn parse_match3d_draft_plan(
raw: &str,
config: &Match3DConfigJson,
) -> Option<Match3DGeneratedDraftPlan> {
let raw = raw.trim();
let json_text = if let Some(start) = raw.find('{')
&& let Some(end) = raw.rfind('}')
&& end > start
{
&raw[start..=end]
} else {
raw
};
let value = serde_json::from_str::<Value>(json_text).ok()?;
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
if game_name.is_empty() {
return None;
}
let tags = value
.get("tags")
.and_then(Value::as_array)
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
.unwrap_or_default();
let fallback = fallback_match3d_draft_plan(config);
let summary = value
.get("summary")
.or_else(|| value.get("description"))
.or_else(|| value.get("workSummary"))
.or_else(|| value.get("work_summary"))
.and_then(Value::as_str)
.map(normalize_match3d_work_summary)
.filter(|value| !value.is_empty())
.unwrap_or(fallback.metadata.summary);
let items = value
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let name =
normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?);
if name.is_empty() {
return None;
}
let item_size = item
.get("itemSize")
.or_else(|| item.get("item_size"))
.or_else(|| item.get("size"))
.and_then(Value::as_str)
.map(normalize_match3d_item_size)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| infer_match3d_item_size(&name));
let sound_prompt = item
.get("soundPrompt")
.or_else(|| item.get("sound_prompt"))
.and_then(Value::as_str)
.map(normalize_match3d_audio_prompt)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name));
Some(Match3DGeneratedItemPlan {
name,
item_size,
sound_prompt,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let background_prompt = value
.get("backgroundPrompt")
.or_else(|| value.get("background_prompt"))
.and_then(Value::as_str)
.map(normalize_match3d_background_prompt)
.filter(|value| !value.is_empty())
.unwrap_or(fallback.background_prompt);
Some(Match3DGeneratedDraftPlan {
metadata: Match3DGeneratedWorkMetadata {
game_name,
summary,
tags: normalize_match3d_tag_candidates(tags),
},
items: normalize_match3d_item_plan(config, items),
background_prompt,
})
}
#[cfg(test)]
pub(super) fn parse_match3d_work_metadata(raw: &str) -> Option<Match3DGeneratedWorkMetadata> {
let config = Match3DConfigJson {
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
reference_image_src: None,
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
};
parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata)
}
fn normalize_match3d_game_name(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、'])
.chars()
.filter(|character| !character.is_control())
.take(16)
.collect::<String>()
.trim()
.to_string()
}
fn normalize_match3d_work_summary(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”'])
.split_whitespace()
.collect::<Vec<_>>()
.join("")
.chars()
.filter(|character| !character.is_control())
.take(80)
.collect::<String>()
.trim()
.to_string()
}
pub(super) fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata {
let theme = theme_text.trim();
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
Match3DGeneratedWorkMetadata {
game_name: format!("{normalized_theme}抓大鹅"),
summary: normalize_match3d_work_summary(
format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(),
),
tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]),
}
}
fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan {
let metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
let items = fallback_match3d_item_names(config.theme_text.as_str())
.into_iter()
.take(resolve_match3d_generated_item_count(config))
.map(|name| Match3DGeneratedItemPlan {
item_size: infer_match3d_item_size(&name),
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
name,
})
.collect::<Vec<_>>();
Match3DGeneratedDraftPlan {
background_prompt: build_fallback_match3d_background_prompt(config),
metadata,
items,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
pub(super) fn normalize_match3d_run_status(value: &str) -> &str {
match value {
"Running" => "running",
"Won" => "won",
"Failed" => "failed",
"Stopped" => "stopped",
_ => value,
}
}
pub(super) fn normalize_match3d_item_state(value: &str) -> &str {
match value {
"InBoard" => "in_board",
"InTray" => "in_tray",
"Cleared" => "cleared",
_ => value,
}
}
pub(super) fn normalize_match3d_failure_reason(value: &str) -> &str {
match value {
"TimeUp" => "time_up",
"TrayFull" => "tray_full",
_ => value,
}
}
pub(super) fn normalize_match3d_click_reject_reason(value: &str) -> &str {
match value {
"RejectedNotClickable" => "item_not_clickable",
"RejectedAlreadyMoved" => "item_not_in_board",
"RejectedTrayFull" => "tray_full",
"VersionConflict" => "snapshot_version_mismatch",
"RunFinished" => "run_not_active",
_ => value,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
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,
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,
aspect_ratio: &str,
) -> Value {
json!({
"contents": [{
"role": "user",
"parts": [{
"text": build_match3d_vector_engine_gemini_prompt(prompt, negative_prompt),
}],
}],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"],
"imageConfig": {
"aspectRatio": aspect_ratio,
},
},
})
}
pub(super) fn build_match3d_vector_engine_gemini_generate_content_url(
settings: &Match3DVectorEngineGeminiImageSettings,
) -> String {
let base_url = settings.base_url.trim_end_matches("/v1");
format!(
"{}/v1beta/models/{}:generateContent",
base_url, MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL
)
}
fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str) -> String {
let prompt = prompt.trim();
let negative_prompt = negative_prompt.trim();
if negative_prompt.is_empty() {
return prompt.to_string();
}
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);
collect_match3d_inline_image_data(payload, &mut values);
values
}
fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec<String>) {
match payload {
Value::Array(entries) => {
for entry in entries {
collect_match3d_inline_image_data(entry, results);
}
}
Value::Object(object) => {
for key in ["inlineData", "inline_data"] {
if let Some(Value::Object(inline_data)) = object.get(key) {
let mime_type = inline_data
.get("mimeType")
.or_else(|| inline_data.get("mime_type"))
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("image/png")
.to_ascii_lowercase();
if !mime_type.is_empty() && !mime_type.starts_with("image/") {
continue;
}
if let Some(data) = inline_data
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(data.to_string());
}
}
}
for nested_value in object.values() {
collect_match3d_inline_image_data(nested_value, results);
}
}
_ => {}
}
}
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) => {
for entry in entries {
collect_match3d_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key {
match nested_value {
Value::String(text) => {
let text = text.trim();
if !text.is_empty() {
results.push(text.to_string());
}
}
Value::Array(entries) => {
for entry in entries {
if let Some(text) = entry
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
}
}
}
_ => {}
}
}
collect_match3d_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
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",
}
}

File diff suppressed because it is too large Load Diff