refactor: modularize api server assets and handlers
This commit is contained in:
315
server-rs/crates/api-server/src/square_hole/mappers.rs
Normal file
315
server-rs/crates/api-server/src/square_hole/mappers.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
686
server-rs/crates/api-server/src/square_hole/visual_assets.rs
Normal file
686
server-rs/crates/api-server/src/square_hole/visual_assets.rs
Normal 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user