refactor: modularize api server assets and handlers

This commit is contained in:
2026-05-14 22:54:52 +08:00
parent 4ba1ebbbdf
commit 1b54db4f92
47 changed files with 8081 additions and 6142 deletions

View File

@@ -0,0 +1,315 @@
use super::*;
pub(super) fn map_square_hole_agent_session_response(
session: SquareHoleAgentSessionRecord,
) -> SquareHoleSessionSnapshotResponse {
SquareHoleSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage.clone(),
anchor_pack: map_square_hole_anchor_pack_response_for_turn(
session.anchor_pack,
session.current_turn,
session.stage.as_str(),
),
config: map_square_hole_config_response(session.config),
draft: session.draft.map(map_square_hole_draft_response),
messages: session
.messages
.into_iter()
.map(map_square_hole_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
published_profile_id: session.published_profile_id,
updated_at: session.updated_at,
}
}
pub(super) fn map_square_hole_anchor_pack_response_for_turn(
anchor: SquareHoleAnchorPackRecord,
current_turn: u32,
stage: &str,
) -> SquareHoleAnchorPackResponse {
let is_ready = matches!(
stage,
"ReadyToCompile"
| "ready_to_compile"
| "DraftCompiled"
| "draft_compiled"
| "draft_ready"
| "Published"
| "published"
);
let collected_count = if is_ready { 4 } else { current_turn.min(4) };
SquareHoleAnchorPackResponse {
theme: map_square_hole_anchor_item_response_for_collected(
anchor.theme,
collected_count >= 1,
),
twist_rule: map_square_hole_anchor_item_response_for_collected(
anchor.twist_rule,
collected_count >= 2,
),
shape_count: map_square_hole_anchor_item_response_for_collected(
anchor.shape_count,
collected_count >= 3,
),
difficulty: map_square_hole_anchor_item_response_for_collected(
anchor.difficulty,
collected_count >= 4,
),
}
}
pub(super) fn map_square_hole_anchor_item_response(
anchor: SquareHoleAnchorItemRecord,
) -> SquareHoleAnchorItemResponse {
SquareHoleAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
pub(super) fn map_square_hole_anchor_item_response_for_collected(
anchor: SquareHoleAnchorItemRecord,
collected: bool,
) -> SquareHoleAnchorItemResponse {
if collected {
return map_square_hole_anchor_item_response(anchor);
}
SquareHoleAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: String::new(),
status: "missing".to_string(),
}
}
pub(super) fn map_square_hole_config_response(
config: SquareHoleCreatorConfigRecord,
) -> SquareHoleCreatorConfigResponse {
SquareHoleCreatorConfigResponse {
theme_text: config.theme_text,
twist_rule: config.twist_rule,
shape_count: config.shape_count,
difficulty: config.difficulty,
shape_options: config
.shape_options
.into_iter()
.map(map_square_hole_shape_option_response)
.collect(),
hole_options: config
.hole_options
.into_iter()
.map(map_square_hole_hole_option_response)
.collect(),
background_prompt: config.background_prompt,
cover_image_src: config.cover_image_src,
background_image_src: config.background_image_src,
}
}
pub(super) fn map_square_hole_draft_response(
draft: SquareHoleResultDraftRecord,
) -> SquareHoleResultDraftResponse {
SquareHoleResultDraftResponse {
profile_id: draft.profile_id,
game_name: draft.game_name,
theme_text: draft.theme_text,
twist_rule: draft.twist_rule,
summary: draft.summary,
tags: draft.tags,
cover_image_src: draft.cover_image_src,
background_prompt: draft.background_prompt,
background_image_src: draft.background_image_src,
shape_options: draft
.shape_options
.into_iter()
.map(map_square_hole_shape_option_response)
.collect(),
hole_options: draft
.hole_options
.into_iter()
.map(map_square_hole_hole_option_response)
.collect(),
shape_count: draft.shape_count,
difficulty: draft.difficulty,
publish_ready: draft.publish_ready,
blockers: draft.blockers,
}
}
pub(super) fn map_square_hole_message_response(
message: SquareHoleAgentMessageRecord,
) -> SquareHoleAgentMessageResponse {
SquareHoleAgentMessageResponse {
id: message.id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
pub(super) fn map_square_hole_work_summary_response(
item: SquareHoleWorkProfileRecord,
) -> SquareHoleWorkSummaryResponse {
SquareHoleWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
game_name: item.game_name,
theme_text: item.theme_text,
twist_rule: item.twist_rule,
summary: item.summary,
tags: item.tags,
cover_image_src: item.cover_image_src,
background_prompt: item.background_prompt,
background_image_src: item.background_image_src,
shape_options: item
.shape_options
.into_iter()
.map(map_square_hole_work_shape_option_response)
.collect(),
hole_options: item
.hole_options
.into_iter()
.map(map_square_hole_work_hole_option_response)
.collect(),
shape_count: item.shape_count,
difficulty: item.difficulty,
publication_status: item.publication_status,
play_count: item.play_count,
updated_at: item.updated_at,
published_at: item.published_at,
publish_ready: item.publish_ready,
}
}
pub(super) fn map_square_hole_work_profile_response(
item: SquareHoleWorkProfileRecord,
) -> SquareHoleWorkProfileResponse {
SquareHoleWorkProfileResponse {
summary: map_square_hole_work_summary_response(item),
}
}
pub(super) fn map_square_hole_run_response(run: SquareHoleRunRecord) -> SquareHoleRunSnapshotResponse {
SquareHoleRunSnapshotResponse {
run_id: run.run_id,
profile_id: run.profile_id,
owner_user_id: run.owner_user_id,
status: normalize_square_hole_run_status(run.status.as_str()).to_string(),
snapshot_version: run.snapshot_version,
started_at_ms: run.started_at_ms,
duration_limit_ms: run.duration_limit_ms,
remaining_ms: run.remaining_ms,
total_shape_count: run.total_shape_count,
completed_shape_count: run.completed_shape_count,
combo: run.combo,
best_combo: run.best_combo,
score: run.score,
rule_label: run.rule_label,
background_image_src: run.background_image_src,
current_shape: run.current_shape.map(map_square_hole_shape_response),
holes: run
.holes
.into_iter()
.map(map_square_hole_hole_response)
.collect(),
last_feedback: run.last_feedback.map(map_square_hole_feedback_response),
}
}
pub(super) fn map_square_hole_shape_response(
item: SquareHoleShapeSnapshotRecord,
) -> SquareHoleShapeSnapshotResponse {
SquareHoleShapeSnapshotResponse {
shape_id: item.shape_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
color: item.color,
image_src: item.image_src,
}
}
pub(super) fn map_square_hole_hole_response(
slot: SquareHoleHoleSnapshotRecord,
) -> SquareHoleHoleSnapshotResponse {
SquareHoleHoleSnapshotResponse {
hole_id: slot.hole_id,
hole_kind: slot.hole_kind,
label: slot.label,
x: slot.x,
y: slot.y,
image_src: slot.image_src,
}
}
pub(super) fn map_square_hole_shape_option_response(
item: SquareHoleShapeOptionRecord,
) -> SquareHoleShapeOptionResponse {
SquareHoleShapeOptionResponse {
option_id: item.option_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
pub(super) fn map_square_hole_hole_option_response(
item: SquareHoleHoleOptionRecord,
) -> SquareHoleHoleOptionResponse {
SquareHoleHoleOptionResponse {
hole_id: item.hole_id,
hole_kind: item.hole_kind,
label: item.label,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
pub(super) fn map_square_hole_work_shape_option_response(
item: SquareHoleShapeOptionRecord,
) -> SquareHoleWorkShapeOptionResponse {
SquareHoleWorkShapeOptionResponse {
option_id: item.option_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
pub(super) fn map_square_hole_work_hole_option_response(
item: SquareHoleHoleOptionRecord,
) -> SquareHoleWorkHoleOptionResponse {
SquareHoleWorkHoleOptionResponse {
hole_id: item.hole_id,
hole_kind: item.hole_kind,
label: item.label,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
pub(super) fn map_square_hole_feedback_response(
feedback: SquareHoleDropFeedbackRecord,
) -> SquareHoleDropFeedbackResponse {
SquareHoleDropFeedbackResponse {
accepted: feedback.accepted,
reject_reason: feedback.reject_reason,
message: feedback.message,
}
}

View File

@@ -0,0 +1,686 @@
use super::*;
pub(super) async fn generate_square_hole_visual_assets_for_session(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
regenerate_visual_assets: bool,
visual_asset_slot: Option<String>,
visual_asset_option_id: Option<String>,
) -> Result<SquareHoleAgentSessionRecord, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
.spacetime_client()
.get_square_hole_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let profile_id = session
.draft
.as_ref()
.map(|draft| draft.profile_id.clone())
.ok_or_else(|| {
square_hole_bad_request(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
"square hole 草稿尚未编译,不能生成图片资产",
)
})?;
let mut work = state
.spacetime_client()
.get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let requested_slot = normalize_square_hole_visual_asset_slot(
visual_asset_slot.as_deref(),
visual_asset_option_id.as_deref(),
);
let cover_image_src = match work.cover_image_src.clone() {
Some(value)
if !should_generate_square_hole_cover_image(
requested_slot.as_ref(),
regenerate_visual_assets,
value.as_str(),
) =>
{
Some(value)
}
_ => Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
"cover",
SQUARE_HOLE_COVER_IMAGE_KIND,
build_square_hole_cover_prompt(&work).as_str(),
"16:9",
"生成方洞挑战封面图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
),
};
let background_image_src = match work.background_image_src.clone() {
Some(value)
if !should_generate_square_hole_background_image(
requested_slot.as_ref(),
regenerate_visual_assets,
value.as_str(),
) =>
{
Some(value)
}
_ => Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
"background",
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
build_square_hole_background_prompt(&work).as_str(),
"16:9",
"生成方洞挑战背景图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
),
};
let mut shape_options = work.shape_options.clone();
let prompt_work = work.clone();
for option in shape_options.iter_mut() {
if !should_generate_square_hole_shape_image(
requested_slot.as_ref(),
regenerate_visual_assets,
option,
) {
continue;
}
option.image_src = Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
option.option_id.as_str(),
SQUARE_HOLE_SHAPE_IMAGE_KIND,
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战形状贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
);
}
let mut hole_options = work.hole_options.clone();
for option in hole_options.iter_mut() {
if !should_generate_square_hole_hole_image(
requested_slot.as_ref(),
regenerate_visual_assets,
option,
) {
continue;
}
option.image_src = Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
option.hole_id.as_str(),
SQUARE_HOLE_HOLE_IMAGE_KIND,
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战洞口贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
);
}
work = state
.spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
profile_id,
owner_user_id: owner_user_id.clone(),
game_name: work.game_name.clone(),
theme_text: work.theme_text.clone(),
twist_rule: work.twist_rule.clone(),
summary_text: work.summary.clone(),
tags_json: serde_json::to_string(&normalize_tags(work.tags.clone()))
.unwrap_or_default(),
cover_image_src: cover_image_src.clone().unwrap_or_default(),
background_prompt: work.background_prompt.clone(),
background_image_src: background_image_src.clone().unwrap_or_default(),
shape_options_json: serialize_square_hole_shape_option_records(&shape_options),
hole_options_json: serialize_square_hole_hole_option_records(&hole_options),
shape_count: work.shape_count,
difficulty: work.difficulty,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let mut next_session = state
.spacetime_client()
.get_square_hole_agent_session(session_id, owner_user_id)
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
if let Some(draft) = next_session.draft.as_mut() {
draft.cover_image_src = work.cover_image_src.clone();
draft.background_image_src = work.background_image_src.clone();
draft.background_prompt = work.background_prompt.clone();
draft.shape_options = work.shape_options.clone();
draft.hole_options = work.hole_options.clone();
}
Ok(next_session)
}
pub(super) async fn regenerate_square_hole_visual_asset_for_work(
state: &AppState,
request_context: &RequestContext,
owner_user_id: String,
profile_id: String,
visual_asset_slot: String,
visual_asset_option_id: Option<String>,
) -> Result<SquareHoleWorkProfileRecord, Response> {
let mut work = state
.spacetime_client()
.get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let requested_slot = normalize_square_hole_visual_asset_slot(
Some(visual_asset_slot.as_str()),
visual_asset_option_id.as_deref(),
)
.ok_or_else(|| {
square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"图片槽位不存在",
)
})?;
let synthetic_session_id = work
.source_session_id
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| profile_id.clone());
let prompt_work = work.clone();
match &requested_slot {
SquareHoleVisualAssetSlotRequest::Cover => {
work.cover_image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
"cover",
SQUARE_HOLE_COVER_IMAGE_KIND,
build_square_hole_cover_prompt(&prompt_work).as_str(),
"16:9",
"生成方洞挑战封面图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Background => {
work.background_image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
"background",
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
build_square_hole_background_prompt(&prompt_work).as_str(),
"16:9",
"生成方洞挑战背景图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Shape(option_id) => {
let Some(option) = work
.shape_options
.iter_mut()
.find(|option| option.option_id == *option_id)
else {
return Err(square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"形状图片槽位不存在",
));
};
option.image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
option.option_id.as_str(),
SQUARE_HOLE_SHAPE_IMAGE_KIND,
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战形状贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Hole(hole_id) => {
let Some(option) = work
.hole_options
.iter_mut()
.find(|option| option.hole_id == *hole_id)
else {
return Err(square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"洞口图片槽位不存在",
));
};
option.image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
option.hole_id.as_str(),
SQUARE_HOLE_HOLE_IMAGE_KIND,
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战洞口贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
}
state
.spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
profile_id,
owner_user_id,
game_name: work.game_name.clone(),
theme_text: work.theme_text.clone(),
twist_rule: work.twist_rule.clone(),
summary_text: work.summary.clone(),
tags_json: serde_json::to_string(&normalize_tags(work.tags.clone()))
.unwrap_or_default(),
cover_image_src: work.cover_image_src.clone().unwrap_or_default(),
background_prompt: work.background_prompt.clone(),
background_image_src: work.background_image_src.clone().unwrap_or_default(),
shape_options_json: serialize_square_hole_shape_option_records(&work.shape_options),
hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options),
shape_count: work.shape_count,
difficulty: work.difficulty,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
map_square_hole_client_error(error),
)
})
}
async fn generate_square_hole_image_data_url(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
slot: &str,
asset_kind: &str,
prompt: &str,
size: &str,
failure_context: &str,
) -> Result<String, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
&settings,
prompt,
Some(build_square_hole_negative_prompt().as_str()),
size,
1,
&[],
failure_context,
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("{failure_context}:上游未返回图片"),
}))
})?;
let fallback_data_url = format_square_hole_data_url(&image);
match persist_square_hole_generated_asset(
state,
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
generated.task_id.as_str(),
image,
current_utc_micros(),
)
.await
{
Ok(image_src) => Ok(image_src),
Err(error) => {
tracing::warn!(
provider = "square-hole-assets",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
message = %error.body_text(),
"方洞图片已生成但资产持久化失败,降级回写 Data URL"
);
Ok(fallback_data_url)
}
}
}
fn format_square_hole_data_url(image: &DownloadedOpenAiImage) -> String {
format!(
"data:{};base64,{}",
image.mime_type,
BASE64_STANDARD.encode(&image.bytes)
)
}
#[allow(clippy::too_many_arguments)]
async fn persist_square_hole_generated_asset(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
slot: &str,
asset_kind: &str,
task_id: &str,
image: DownloadedOpenAiImage,
generated_at_micros: i64,
) -> Result<String, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let storage_slot = sanitize_square_hole_asset_segment(slot, "slot");
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::SquareHoleAssets,
path_segments: vec![
sanitize_square_hole_asset_segment(session_id, "session"),
sanitize_square_hole_asset_segment(profile_id, "profile"),
sanitize_square_hole_asset_segment(asset_kind, "asset"),
storage_slot.clone(),
format!("asset-{generated_at_micros}"),
],
file_stem: "image".to_string(),
image: GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
bytes: image.bytes,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(SQUARE_HOLE_ENTITY_KIND.to_string()),
entity_id: Some(profile_id.to_string()),
slot: Some(slot.to_string()),
provider: Some("openai".to_string()),
task_id: Some(task_id.to_string()),
},
extra_metadata: BTreeMap::from([("profile_id".to_string(), profile_id.to_string())]),
})
.map_err(map_square_hole_generated_image_asset_error)?;
let persisted_mime_type = prepared.format.mime_type.clone();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(map_square_hole_asset_oss_error)?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_square_hole_asset_oss_error)?;
match state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(generated_at_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
asset_kind.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
Some(profile_id.to_string()),
generated_at_micros,
)
.map_err(map_square_hole_asset_field_error)?,
)
.await
{
Ok(asset_object) => {
if let Err(error) = state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(generated_at_micros),
asset_object.asset_object_id,
SQUARE_HOLE_ENTITY_KIND.to_string(),
profile_id.to_string(),
slot.to_string(),
asset_kind.to_string(),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
generated_at_micros,
)
.map_err(map_square_hole_asset_field_error)?,
)
.await
{
tracing::warn!(
provider = "spacetimedb",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
error = %error,
"方洞图片资产绑定失败,历史素材索引可能缺少绑定记录"
);
}
}
Err(error) => {
tracing::warn!(
provider = "spacetimedb",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
error = %error,
"方洞图片资产对象确认失败,历史素材索引可能缺少本次记录"
);
}
}
Ok(put_result.legacy_public_path)
}
fn map_square_hole_generated_image_asset_error(
error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError,
) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备方洞图片资产上传请求失败:{error:?}"),
}))
}
fn map_square_hole_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
fn map_square_hole_asset_field_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "square-hole-assets",
"message": error.to_string(),
}))
}
fn sanitize_square_hole_asset_segment(value: &str, fallback: &str) -> String {
let sanitized = value
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
fallback.to_string()
} else {
sanitized
}
}
fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String {
format!(
"移动端休闲游戏封面图。主题:{}。玩法反差:{}。画面主体是贴着主题图案的几何形状正在靠近不同洞口,视觉清晰、色彩明快、偏游戏资产质感。不要文字、不要 UI、不要水印。",
clean_prompt_text(&work.theme_text, "奇怪形状"),
clean_prompt_text(&work.twist_rule, "反直觉分拣")
)
}
fn build_square_hole_background_prompt(work: &SquareHoleWorkProfileRecord) -> String {
let custom_prompt = work.background_prompt.trim();
if !custom_prompt.is_empty() {
return format!(
"移动端休闲游戏运行背景。{}。画面中央预留清晰操作空间,边缘可有主题装饰,低噪声,不要文字、不要 UI、不要水印。",
custom_prompt
);
}
format!(
"移动端休闲游戏运行背景。主题:{}。柔和纵深、玩具盒或舞台感,中央预留清晰操作空间,不要文字、不要 UI、不要水印。",
clean_prompt_text(&work.theme_text, "奇怪形状")
)
}
fn build_square_hole_shape_prompt(
work: &SquareHoleWorkProfileRecord,
option: &SquareHoleShapeOptionRecord,
) -> String {
let image_prompt = option.image_prompt.trim();
let option_prompt = if image_prompt.is_empty() {
format!("{} 主题的 {}", work.theme_text, option.label)
} else {
image_prompt.to_string()
};
format!(
"单个游戏道具贴图,透明或干净浅色背景。几何形状:{}。主题贴图:{}。要求主体居中、边缘清晰、适合贴在可拖拽形状上,不要文字、不要 UI、不要水印。",
clean_prompt_text(&option.label, "形状"),
clean_prompt_text(&option_prompt, "主题图案")
)
}
fn build_square_hole_hole_prompt(
work: &SquareHoleWorkProfileRecord,
option: &SquareHoleHoleOptionRecord,
) -> String {
let image_prompt = option.image_prompt.trim();
let option_prompt = if image_prompt.is_empty() {
format!("{} 主题的 {}", work.theme_text, option.label)
} else {
image_prompt.to_string()
};
format!(
"单个游戏洞口贴图,透明或干净浅色背景。洞口名称:{}。主题贴图:{}。要求主体居中、边缘清晰、适合放在可接收拖拽形状的洞口平面上,不要文字、不要 UI、不要水印。",
clean_prompt_text(&option.label, "洞口"),
clean_prompt_text(&option_prompt, "主题洞口")
)
}
fn build_square_hole_negative_prompt() -> String {
"文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string()
}