2661 lines
88 KiB
Rust
2661 lines
88 KiB
Rust
use std::{
|
||
collections::BTreeMap,
|
||
convert::Infallible,
|
||
time::{SystemTime, UNIX_EPOCH},
|
||
};
|
||
|
||
use axum::{
|
||
Json,
|
||
body::to_bytes,
|
||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||
http::{HeaderName, StatusCode, header},
|
||
response::{
|
||
IntoResponse, Response,
|
||
sse::{Event, Sse},
|
||
},
|
||
};
|
||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||
use module_assets::{
|
||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||
};
|
||
use module_square_hole::{
|
||
SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX,
|
||
SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options,
|
||
normalize_shape_options,
|
||
};
|
||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::{
|
||
square_hole_agent::{
|
||
CreateSquareHoleSessionRequest, ExecuteSquareHoleActionRequest,
|
||
SendSquareHoleMessageRequest, SquareHoleActionResponse, SquareHoleAgentMessageResponse,
|
||
SquareHoleAnchorItemResponse, SquareHoleAnchorPackResponse,
|
||
SquareHoleCreatorConfigResponse, SquareHoleHoleOptionResponse,
|
||
SquareHoleResultDraftResponse, SquareHoleSessionResponse,
|
||
SquareHoleSessionSnapshotResponse, SquareHoleShapeOptionResponse,
|
||
},
|
||
square_hole_runtime::{
|
||
DropSquareHoleShapeRequest, SquareHoleDropFeedbackResponse, SquareHoleDropResponse,
|
||
SquareHoleHoleSnapshotResponse, SquareHoleRunResponse, SquareHoleRunSnapshotResponse,
|
||
SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest,
|
||
},
|
||
square_hole_works::{
|
||
PutSquareHoleWorkRequest, RegenerateSquareHoleWorkImageRequest,
|
||
SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
|
||
SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse,
|
||
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse,
|
||
SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
|
||
},
|
||
};
|
||
use shared_kernel::build_prefixed_uuid_id;
|
||
use spacetime_client::{
|
||
SpacetimeClientError, SquareHoleAgentMessageRecord, SquareHoleAgentMessageSubmitRecordInput,
|
||
SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentSessionRecord,
|
||
SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleCompileDraftRecordInput,
|
||
SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleOptionRecord,
|
||
SquareHoleHoleSnapshotRecord, SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput,
|
||
SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput,
|
||
SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord,
|
||
SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput,
|
||
};
|
||
|
||
use crate::{
|
||
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
||
api_response::json_success_body,
|
||
auth::AuthenticatedAccessToken,
|
||
http_error::AppError,
|
||
openai_image_generation::{
|
||
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||
require_openai_image_settings,
|
||
},
|
||
platform_errors::map_oss_error,
|
||
request_context::RequestContext,
|
||
square_hole_agent_turn::{
|
||
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
|
||
},
|
||
state::AppState,
|
||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||
};
|
||
|
||
const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent";
|
||
const SQUARE_HOLE_WORKS_PROVIDER: &str = "square-hole-works";
|
||
const SQUARE_HOLE_RUNTIME_PROVIDER: &str = "square-hole-runtime";
|
||
const SQUARE_HOLE_DEFAULT_THEME: &str = "纸箱";
|
||
const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能";
|
||
const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12;
|
||
const SQUARE_HOLE_DEFAULT_DIFFICULTY: u32 = 4;
|
||
const SQUARE_HOLE_QUESTION_THEME: &str = "你想做什么题材";
|
||
const SQUARE_HOLE_ENTITY_KIND: &str = "square_hole_work";
|
||
const SQUARE_HOLE_COVER_IMAGE_KIND: &str = "square_hole_cover_image";
|
||
const SQUARE_HOLE_BACKGROUND_IMAGE_KIND: &str = "square_hole_background_image";
|
||
const SQUARE_HOLE_SHAPE_IMAGE_KIND: &str = "square_hole_shape_image";
|
||
const SQUARE_HOLE_HOLE_IMAGE_KIND: &str = "square_hole_hole_image";
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SquareHoleConfigJson {
|
||
theme_text: String,
|
||
twist_rule: String,
|
||
shape_count: u32,
|
||
difficulty: u32,
|
||
#[serde(default)]
|
||
shape_options: Vec<SquareHoleConfigShapeOptionJson>,
|
||
#[serde(default)]
|
||
hole_options: Vec<SquareHoleConfigHoleOptionJson>,
|
||
#[serde(default)]
|
||
background_prompt: String,
|
||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||
cover_image_src: String,
|
||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||
background_image_src: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SquareHoleConfigShapeOptionJson {
|
||
option_id: String,
|
||
shape_kind: String,
|
||
label: String,
|
||
#[serde(default)]
|
||
target_hole_id: String,
|
||
image_prompt: String,
|
||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||
image_src: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SquareHoleConfigHoleOptionJson {
|
||
hole_id: String,
|
||
hole_kind: String,
|
||
label: String,
|
||
#[serde(default)]
|
||
image_prompt: String,
|
||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||
image_src: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct CompileSquareHoleDraftRequest {
|
||
#[serde(default)]
|
||
game_name: Option<String>,
|
||
#[serde(default)]
|
||
summary: Option<String>,
|
||
#[serde(default)]
|
||
tags: Option<Vec<String>>,
|
||
#[serde(default)]
|
||
cover_image_src: Option<String>,
|
||
}
|
||
|
||
pub async fn create_square_hole_agent_session(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<CreateSquareHoleSessionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?;
|
||
let config = build_config_from_create_request(&payload);
|
||
let seed_text = build_seed_text(&payload, &config);
|
||
let welcome_message_text = SQUARE_HOLE_QUESTION_THEME.to_string();
|
||
|
||
let session = state
|
||
.spacetime_client()
|
||
.create_square_hole_agent_session(SquareHoleAgentSessionCreateRecordInput {
|
||
session_id: build_prefixed_uuid_id(SQUARE_HOLE_SESSION_ID_PREFIX),
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
seed_text,
|
||
welcome_message_id: build_prefixed_uuid_id(SQUARE_HOLE_MESSAGE_ID_PREFIX),
|
||
welcome_message_text,
|
||
config_json: serialize_square_hole_config(&config),
|
||
created_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),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleSessionResponse {
|
||
session: map_square_hole_agent_session_response(session),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_square_hole_agent_session(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&session_id,
|
||
"sessionId",
|
||
)?;
|
||
|
||
let session = state
|
||
.spacetime_client()
|
||
.get_square_hole_agent_session(session_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleSessionResponse {
|
||
session: map_square_hole_agent_session_response(session),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn submit_square_hole_agent_message(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<SendSquareHoleMessageRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?;
|
||
let session = submit_and_finalize_square_hole_message(
|
||
&state,
|
||
&request_context,
|
||
authenticated.claims().user_id(),
|
||
session_id,
|
||
payload,
|
||
|_| {},
|
||
)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleSessionResponse {
|
||
session: map_square_hole_agent_session_response(session),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn stream_square_hole_agent_message(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<SendSquareHoleMessageRequest>, JsonRejection>,
|
||
) -> Result<Response, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&session_id,
|
||
"sessionId",
|
||
)?;
|
||
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let request_context_for_stream = request_context.clone();
|
||
let state = state.clone();
|
||
let stream = async_stream::stream! {
|
||
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
|
||
"square-hole",
|
||
owner_user_id.as_str(),
|
||
session_id.as_str(),
|
||
payload.client_message_id.as_str(),
|
||
"方洞挑战生成草稿",
|
||
));
|
||
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
|
||
tracing::warn!(error = %error, "方洞挑战生成草稿任务启动失败,主生成流程继续执行");
|
||
}
|
||
|
||
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||
let result = {
|
||
let run_turn = submit_and_finalize_square_hole_message(
|
||
&state,
|
||
&request_context_for_stream,
|
||
owner_user_id.as_str(),
|
||
session_id.clone(),
|
||
payload,
|
||
move |text| {
|
||
let _ = reply_tx.send(text.to_string());
|
||
},
|
||
);
|
||
tokio::pin!(run_turn);
|
||
|
||
loop {
|
||
tokio::select! {
|
||
result = &mut run_turn => break result,
|
||
maybe_text = reply_rx.recv() => {
|
||
if let Some(text) = maybe_text {
|
||
draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await;
|
||
yield Ok::<Event, Infallible>(square_hole_sse_json_event_or_error(
|
||
"reply_delta",
|
||
json!({ "text": text }),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
while let Some(text) = reply_rx.recv().await {
|
||
draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await;
|
||
yield Ok::<Event, Infallible>(square_hole_sse_json_event_or_error(
|
||
"reply_delta",
|
||
json!({ "text": text }),
|
||
));
|
||
}
|
||
|
||
let session = match result {
|
||
Ok(session) => session,
|
||
Err(response) => {
|
||
let message = extract_square_hole_response_error_message(response).await;
|
||
yield Ok::<Event, Infallible>(square_hole_sse_json_event_or_error(
|
||
"error",
|
||
json!({ "message": message }),
|
||
));
|
||
return;
|
||
}
|
||
};
|
||
|
||
let session_response = map_square_hole_agent_session_response(session);
|
||
yield Ok::<Event, Infallible>(square_hole_sse_json_event_or_error(
|
||
"session",
|
||
json!({ "session": session_response }),
|
||
));
|
||
yield Ok::<Event, Infallible>(square_hole_sse_json_event_or_error(
|
||
"done",
|
||
json!({ "ok": true }),
|
||
));
|
||
};
|
||
|
||
Ok(Sse::new(stream).into_response())
|
||
}
|
||
|
||
pub async fn execute_square_hole_agent_action(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<ExecuteSquareHoleActionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&session_id,
|
||
"sessionId",
|
||
)?;
|
||
|
||
let session = match payload.action.trim() {
|
||
"square_hole_compile_draft" => {
|
||
compile_square_hole_draft_for_session(
|
||
&state,
|
||
&request_context,
|
||
&authenticated,
|
||
session_id,
|
||
payload.game_name,
|
||
payload.summary,
|
||
payload.tags,
|
||
payload.cover_image_src,
|
||
)
|
||
.await?
|
||
}
|
||
"square_hole_generate_visual_assets" => {
|
||
generate_square_hole_visual_assets_for_session(
|
||
&state,
|
||
&request_context,
|
||
&authenticated,
|
||
session_id,
|
||
payload.regenerate_visual_assets.unwrap_or(false),
|
||
payload.visual_asset_slot,
|
||
payload.visual_asset_option_id,
|
||
)
|
||
.await?
|
||
}
|
||
_ => {
|
||
return Err(square_hole_bad_request(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
"unknown square hole action",
|
||
));
|
||
}
|
||
};
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleActionResponse {
|
||
session: map_square_hole_agent_session_response(session),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn compile_square_hole_agent_draft(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<CompileSquareHoleDraftRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let payload = payload
|
||
.map(|Json(payload)| payload)
|
||
.unwrap_or(CompileSquareHoleDraftRequest {
|
||
game_name: None,
|
||
summary: None,
|
||
tags: None,
|
||
cover_image_src: None,
|
||
});
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&session_id,
|
||
"sessionId",
|
||
)?;
|
||
|
||
let session = compile_square_hole_draft_for_session(
|
||
&state,
|
||
&request_context,
|
||
&authenticated,
|
||
session_id,
|
||
payload.game_name,
|
||
payload.summary,
|
||
payload.tags,
|
||
payload.cover_image_src,
|
||
)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleActionResponse {
|
||
session: map_square_hole_agent_session_response(session),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_square_hole_works(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let items = state
|
||
.spacetime_client()
|
||
.list_square_hole_works(authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorksResponse {
|
||
items: items
|
||
.into_iter()
|
||
.map(map_square_hole_work_summary_response)
|
||
.collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn list_square_hole_gallery(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let items = state
|
||
.spacetime_client()
|
||
.list_square_hole_gallery()
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorksResponse {
|
||
items: items
|
||
.into_iter()
|
||
.map(map_square_hole_work_summary_response)
|
||
.collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_square_hole_work_detail(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let item = state
|
||
.spacetime_client()
|
||
.get_square_hole_work_detail(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorkDetailResponse {
|
||
item: map_square_hole_work_profile_response(item),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn put_square_hole_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<PutSquareHoleWorkRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_WORKS_PROVIDER)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let existing = state
|
||
.spacetime_client()
|
||
.get_square_hole_work_detail(
|
||
profile_id.clone(),
|
||
authenticated.claims().user_id().to_string(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
let theme_text = payload
|
||
.theme_text
|
||
.clone()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or(existing.theme_text);
|
||
let hole_options = payload
|
||
.hole_options
|
||
.clone()
|
||
.map(square_hole_work_hole_options_to_records)
|
||
.unwrap_or_else(|| existing.hole_options.clone());
|
||
let hole_options_json = serialize_square_hole_hole_option_records(&hole_options);
|
||
let shape_options = payload
|
||
.shape_options
|
||
.clone()
|
||
.map(|options| square_hole_work_shape_options_to_records(options, hole_options.as_slice()))
|
||
.unwrap_or_else(|| existing.shape_options.clone());
|
||
let shape_options_json = serialize_square_hole_shape_option_records(&shape_options);
|
||
let item = state
|
||
.spacetime_client()
|
||
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
|
||
profile_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
game_name: payload.game_name,
|
||
theme_text,
|
||
twist_rule: payload.twist_rule,
|
||
summary_text: payload.summary,
|
||
tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(),
|
||
cover_image_src: payload.cover_image_src.unwrap_or_default(),
|
||
background_prompt: payload
|
||
.background_prompt
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or(existing.background_prompt),
|
||
background_image_src: payload
|
||
.background_image_src
|
||
.unwrap_or(existing.background_image_src.unwrap_or_default()),
|
||
shape_options_json,
|
||
hole_options_json,
|
||
shape_count: payload.shape_count,
|
||
difficulty: payload.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),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorkMutationResponse {
|
||
item: map_square_hole_work_profile_response(item),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn publish_square_hole_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let item = state
|
||
.spacetime_client()
|
||
.publish_square_hole_work(
|
||
profile_id,
|
||
authenticated.claims().user_id().to_string(),
|
||
current_utc_micros(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorkMutationResponse {
|
||
item: map_square_hole_work_profile_response(item),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn regenerate_square_hole_work_image(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<RegenerateSquareHoleWorkImageRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_WORKS_PROVIDER)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let item = regenerate_square_hole_visual_asset_for_work(
|
||
&state,
|
||
&request_context,
|
||
owner_user_id,
|
||
profile_id,
|
||
payload.visual_asset_slot,
|
||
payload.visual_asset_option_id,
|
||
)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorkMutationResponse {
|
||
item: map_square_hole_work_profile_response(item),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn delete_square_hole_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let items = state
|
||
.spacetime_client()
|
||
.delete_square_hole_work(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorksResponse {
|
||
items: items
|
||
.into_iter()
|
||
.map(map_square_hole_work_summary_response)
|
||
.collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn start_square_hole_run(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<StartSquareHoleRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let maybe_payload = payload.ok().map(|Json(payload)| payload);
|
||
let profile_id = maybe_payload
|
||
.map(|payload| payload.profile_id)
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or(profile_id);
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.start_square_hole_run(SquareHoleRunStartRecordInput {
|
||
run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX),
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
profile_id: profile_id.clone(),
|
||
started_at_ms: current_utc_ms(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
record_work_play_start_after_success(
|
||
&state,
|
||
&request_context,
|
||
WorkPlayTrackingDraft::new(
|
||
"square-hole",
|
||
profile_id.clone(),
|
||
&authenticated,
|
||
"/api/runtime/square-hole/...",
|
||
)
|
||
.profile_id(profile_id.clone())
|
||
.extra(json!({
|
||
"runId": run.run_id,
|
||
})),
|
||
)
|
||
.await;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleRunResponse {
|
||
run: map_square_hole_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_square_hole_run(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&run_id,
|
||
"runId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.get_square_hole_run(run_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleRunResponse {
|
||
run: map_square_hole_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn drop_square_hole_shape(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<DropSquareHoleShapeRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_RUNTIME_PROVIDER)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&run_id,
|
||
"runId",
|
||
)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&payload.hole_id,
|
||
"holeId",
|
||
)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&payload.client_event_id,
|
||
"clientEventId",
|
||
)?;
|
||
|
||
let confirmation = state
|
||
.spacetime_client()
|
||
.drop_square_hole_shape(SquareHoleRunDropRecordInput {
|
||
run_id: payload.run_id.unwrap_or(run_id),
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
hole_id: payload.hole_id,
|
||
client_snapshot_version: payload.client_snapshot_version,
|
||
client_event_id: payload.client_event_id,
|
||
dropped_at_ms: payload.dropped_at_ms.min(i64::MAX as u64) as i64,
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleDropResponse {
|
||
feedback: map_square_hole_feedback_response(confirmation.feedback),
|
||
run: map_square_hole_run_response(confirmation.run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn stop_square_hole_run(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<StopSquareHoleRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let _ = payload.ok();
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&run_id,
|
||
"runId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.stop_square_hole_run(SquareHoleRunStopRecordInput {
|
||
run_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
stopped_at_ms: current_utc_ms(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleRunResponse {
|
||
run: map_square_hole_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn restart_square_hole_run(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&run_id,
|
||
"runId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.restart_square_hole_run(SquareHoleRunRestartRecordInput {
|
||
source_run_id: run_id,
|
||
next_run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX),
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
restarted_at_ms: current_utc_ms(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleRunResponse {
|
||
run: map_square_hole_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn finish_square_hole_time_up(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&run_id,
|
||
"runId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.finish_square_hole_time_up(SquareHoleRunTimeUpRecordInput {
|
||
run_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
finished_at_ms: current_utc_ms(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleRunResponse {
|
||
run: map_square_hole_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
async fn submit_and_finalize_square_hole_message(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
owner_user_id: &str,
|
||
session_id: String,
|
||
payload: SendSquareHoleMessageRequest,
|
||
on_reply_update: impl FnMut(&str),
|
||
) -> Result<SquareHoleAgentSessionRecord, Response> {
|
||
ensure_non_empty(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&session_id,
|
||
"sessionId",
|
||
)?;
|
||
ensure_non_empty(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&payload.client_message_id,
|
||
"clientMessageId",
|
||
)?;
|
||
ensure_non_empty(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&payload.text,
|
||
"text",
|
||
)?;
|
||
|
||
let submitted = state
|
||
.spacetime_client()
|
||
.submit_square_hole_agent_message(SquareHoleAgentMessageSubmitRecordInput {
|
||
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| {
|
||
square_hole_error_response(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
let turn_result = run_square_hole_agent_turn(
|
||
SquareHoleAgentTurnRequest {
|
||
llm_client: state.llm_client(),
|
||
session: &submitted,
|
||
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||
},
|
||
on_reply_update,
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": SQUARE_HOLE_AGENT_PROVIDER,
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
let finalize_input = build_finalize_record_input(
|
||
session_id,
|
||
owner_user_id.to_string(),
|
||
build_prefixed_uuid_id(SQUARE_HOLE_MESSAGE_ID_PREFIX),
|
||
turn_result,
|
||
current_utc_micros(),
|
||
);
|
||
|
||
state
|
||
.spacetime_client()
|
||
.finalize_square_hole_agent_message(finalize_input)
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})
|
||
}
|
||
|
||
async fn compile_square_hole_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>,
|
||
) -> 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),
|
||
)
|
||
})?;
|
||
if session.current_turn < 2 || session.progress_percent < 100 {
|
||
return Err(square_hole_bad_request(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
"square hole 创作配置尚未确认完成",
|
||
));
|
||
}
|
||
|
||
let config = resolve_config_or_default(Some(&session.config));
|
||
let tags_json = tags
|
||
.as_ref()
|
||
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
|
||
|
||
state
|
||
.spacetime_client()
|
||
.compile_square_hole_draft(SquareHoleCompileDraftRecordInput {
|
||
session_id,
|
||
owner_user_id,
|
||
profile_id: build_prefixed_uuid_id(SQUARE_HOLE_PROFILE_ID_PREFIX),
|
||
author_display_name: resolve_author_display_name(state, authenticated),
|
||
game_name: game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))),
|
||
summary_text: summary,
|
||
tags_json,
|
||
cover_image_src,
|
||
compiled_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),
|
||
)
|
||
})
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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 put_result = oss_client
|
||
.put_object(
|
||
&http_client,
|
||
OssPutObjectRequest {
|
||
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_name: format!("image.{}", image.extension),
|
||
content_type: Some(image.mime_type.clone()),
|
||
access: OssObjectAccess::Private,
|
||
metadata: build_square_hole_asset_metadata(
|
||
asset_kind,
|
||
owner_user_id,
|
||
profile_id,
|
||
slot,
|
||
),
|
||
body: image.bytes,
|
||
},
|
||
)
|
||
.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(image.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 build_square_hole_asset_metadata(
|
||
asset_kind: &str,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
slot: &str,
|
||
) -> BTreeMap<String, String> {
|
||
BTreeMap::from([
|
||
("asset_kind".to_string(), asset_kind.to_string()),
|
||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||
("profile_id".to_string(), profile_id.to_string()),
|
||
(
|
||
"entity_kind".to_string(),
|
||
SQUARE_HOLE_ENTITY_KIND.to_string(),
|
||
),
|
||
("entity_id".to_string(), profile_id.to_string()),
|
||
("slot".to_string(), slot.to_string()),
|
||
])
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
),
|
||
}
|
||
}
|
||
|
||
fn map_square_hole_anchor_item_response(
|
||
anchor: SquareHoleAnchorItemRecord,
|
||
) -> SquareHoleAnchorItemResponse {
|
||
SquareHoleAnchorItemResponse {
|
||
key: anchor.key,
|
||
label: anchor.label,
|
||
value: anchor.value,
|
||
status: anchor.status,
|
||
}
|
||
}
|
||
|
||
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(),
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
fn map_square_hole_work_profile_response(
|
||
item: SquareHoleWorkProfileRecord,
|
||
) -> SquareHoleWorkProfileResponse {
|
||
SquareHoleWorkProfileResponse {
|
||
summary: map_square_hole_work_summary_response(item),
|
||
}
|
||
}
|
||
|
||
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),
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
fn map_square_hole_feedback_response(
|
||
feedback: SquareHoleDropFeedbackRecord,
|
||
) -> SquareHoleDropFeedbackResponse {
|
||
SquareHoleDropFeedbackResponse {
|
||
accepted: feedback.accepted,
|
||
reject_reason: feedback.reject_reason,
|
||
message: feedback.message,
|
||
}
|
||
}
|
||
|
||
fn build_config_from_create_request(
|
||
payload: &CreateSquareHoleSessionRequest,
|
||
) -> SquareHoleConfigJson {
|
||
let theme_text = payload
|
||
.theme_text
|
||
.as_deref()
|
||
.or(payload.seed_text.as_deref())
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME);
|
||
let hole_options = normalize_hole_options(Vec::new(), theme_text);
|
||
SquareHoleConfigJson {
|
||
theme_text: theme_text.to_string(),
|
||
twist_rule: payload
|
||
.twist_rule
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(SQUARE_HOLE_DEFAULT_TWIST_RULE)
|
||
.to_string(),
|
||
shape_count: payload
|
||
.shape_count
|
||
.unwrap_or(SQUARE_HOLE_DEFAULT_SHAPE_COUNT)
|
||
.max(1),
|
||
difficulty: payload
|
||
.difficulty
|
||
.unwrap_or(SQUARE_HOLE_DEFAULT_DIFFICULTY)
|
||
.clamp(1, 10),
|
||
shape_options: square_hole_shape_records_to_config_json(normalize_shape_options(
|
||
Vec::new(),
|
||
theme_text,
|
||
hole_options.as_slice(),
|
||
)),
|
||
hole_options: square_hole_hole_records_to_config_json(hole_options),
|
||
background_prompt: default_background_prompt(theme_text),
|
||
cover_image_src: String::new(),
|
||
background_image_src: String::new(),
|
||
}
|
||
}
|
||
|
||
fn resolve_config_or_default(
|
||
config: Option<&SquareHoleCreatorConfigRecord>,
|
||
) -> SquareHoleConfigJson {
|
||
config
|
||
.map(|config| SquareHoleConfigJson {
|
||
theme_text: config.theme_text.clone(),
|
||
twist_rule: config.twist_rule.clone(),
|
||
shape_count: config.shape_count.max(1),
|
||
difficulty: config.difficulty.clamp(1, 10),
|
||
shape_options: square_hole_shape_records_to_config_json(config.shape_options.clone()),
|
||
hole_options: square_hole_hole_records_to_config_json(config.hole_options.clone()),
|
||
background_prompt: config.background_prompt.clone(),
|
||
cover_image_src: config.cover_image_src.clone().unwrap_or_default(),
|
||
background_image_src: config.background_image_src.clone().unwrap_or_default(),
|
||
})
|
||
.unwrap_or_else(|| SquareHoleConfigJson {
|
||
theme_text: SQUARE_HOLE_DEFAULT_THEME.to_string(),
|
||
twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(),
|
||
shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT,
|
||
difficulty: SQUARE_HOLE_DEFAULT_DIFFICULTY,
|
||
shape_options: {
|
||
let hole_options = normalize_hole_options(Vec::new(), SQUARE_HOLE_DEFAULT_THEME);
|
||
square_hole_shape_records_to_config_json(normalize_shape_options(
|
||
Vec::new(),
|
||
SQUARE_HOLE_DEFAULT_THEME,
|
||
hole_options.as_slice(),
|
||
))
|
||
},
|
||
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(
|
||
Vec::new(),
|
||
SQUARE_HOLE_DEFAULT_THEME,
|
||
)),
|
||
background_prompt: default_background_prompt(SQUARE_HOLE_DEFAULT_THEME),
|
||
cover_image_src: String::new(),
|
||
background_image_src: String::new(),
|
||
})
|
||
}
|
||
|
||
fn serialize_square_hole_config(config: &SquareHoleConfigJson) -> Option<String> {
|
||
serde_json::to_string(config).ok()
|
||
}
|
||
|
||
fn deserialize_optional_string_as_default<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
{
|
||
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
|
||
}
|
||
|
||
fn build_seed_text(
|
||
payload: &CreateSquareHoleSessionRequest,
|
||
config: &SquareHoleConfigJson,
|
||
) -> 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.twist_rule, config.shape_count, config.difficulty
|
||
)
|
||
})
|
||
}
|
||
|
||
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||
let mut result = Vec::new();
|
||
for tag in tags {
|
||
let trimmed = tag.trim();
|
||
if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) {
|
||
result.push(trimmed.to_string());
|
||
}
|
||
if result.len() >= 6 {
|
||
break;
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
fn square_hole_shape_records_to_config_json(
|
||
options: Vec<impl Into<SquareHoleConfigShapeOptionJson>>,
|
||
) -> Vec<SquareHoleConfigShapeOptionJson> {
|
||
options.into_iter().map(Into::into).collect()
|
||
}
|
||
|
||
fn square_hole_hole_records_to_config_json(
|
||
options: Vec<impl Into<SquareHoleConfigHoleOptionJson>>,
|
||
) -> Vec<SquareHoleConfigHoleOptionJson> {
|
||
options.into_iter().map(Into::into).collect()
|
||
}
|
||
|
||
fn square_hole_work_shape_options_to_records(
|
||
options: Vec<SquareHoleWorkShapeOptionResponse>,
|
||
hole_options: &[SquareHoleHoleOptionRecord],
|
||
) -> Vec<SquareHoleShapeOptionRecord> {
|
||
let fallback_hole_id = hole_options
|
||
.first()
|
||
.map(|option| option.hole_id.clone())
|
||
.unwrap_or_else(|| "hole-1".to_string());
|
||
options
|
||
.into_iter()
|
||
.map(|option| SquareHoleShapeOptionRecord {
|
||
option_id: option.option_id,
|
||
shape_kind: option.shape_kind,
|
||
label: option.label,
|
||
target_hole_id: hole_options
|
||
.iter()
|
||
.find(|hole| hole.hole_id == option.target_hole_id)
|
||
.map(|hole| hole.hole_id.clone())
|
||
.unwrap_or_else(|| fallback_hole_id.clone()),
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn square_hole_work_hole_options_to_records(
|
||
options: Vec<SquareHoleWorkHoleOptionResponse>,
|
||
) -> Vec<SquareHoleHoleOptionRecord> {
|
||
options
|
||
.into_iter()
|
||
.map(|option| SquareHoleHoleOptionRecord {
|
||
hole_id: option.hole_id,
|
||
hole_kind: option.hole_kind,
|
||
label: option.label,
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn serialize_square_hole_shape_option_records(options: &[SquareHoleShapeOptionRecord]) -> String {
|
||
let json_options: Vec<SquareHoleConfigShapeOptionJson> =
|
||
options.iter().cloned().map(Into::into).collect();
|
||
serde_json::to_string(&json_options).unwrap_or_default()
|
||
}
|
||
|
||
fn serialize_square_hole_hole_option_records(options: &[SquareHoleHoleOptionRecord]) -> String {
|
||
let json_options: Vec<SquareHoleConfigHoleOptionJson> =
|
||
options.iter().cloned().map(Into::into).collect();
|
||
serde_json::to_string(&json_options).unwrap_or_default()
|
||
}
|
||
|
||
fn clean_prompt_text(value: &str, fallback: &str) -> String {
|
||
let cleaned = value
|
||
.trim()
|
||
.split_whitespace()
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
if cleaned.is_empty() {
|
||
fallback.to_string()
|
||
} else {
|
||
cleaned.chars().take(180).collect()
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
enum SquareHoleVisualAssetSlotRequest {
|
||
Cover,
|
||
Background,
|
||
Shape(String),
|
||
Hole(String),
|
||
}
|
||
|
||
fn normalize_square_hole_visual_asset_slot(
|
||
slot: Option<&str>,
|
||
option_id: Option<&str>,
|
||
) -> Option<SquareHoleVisualAssetSlotRequest> {
|
||
match slot.map(str::trim).unwrap_or_default() {
|
||
"cover" => Some(SquareHoleVisualAssetSlotRequest::Cover),
|
||
"background" => Some(SquareHoleVisualAssetSlotRequest::Background),
|
||
"shape" => option_id
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(|value| SquareHoleVisualAssetSlotRequest::Shape(value.to_string())),
|
||
"hole" => option_id
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(|value| SquareHoleVisualAssetSlotRequest::Hole(value.to_string())),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn should_generate_square_hole_cover_image(
|
||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||
regenerate_visual_assets: bool,
|
||
current_image_src: &str,
|
||
) -> bool {
|
||
matches!(
|
||
requested_slot,
|
||
Some(SquareHoleVisualAssetSlotRequest::Cover)
|
||
) || (requested_slot.is_none()
|
||
&& (regenerate_visual_assets || current_image_src.trim().is_empty()))
|
||
}
|
||
|
||
fn should_generate_square_hole_background_image(
|
||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||
regenerate_visual_assets: bool,
|
||
current_image_src: &str,
|
||
) -> bool {
|
||
matches!(
|
||
requested_slot,
|
||
Some(SquareHoleVisualAssetSlotRequest::Background)
|
||
) || (requested_slot.is_none()
|
||
&& (regenerate_visual_assets || current_image_src.trim().is_empty()))
|
||
}
|
||
|
||
fn should_generate_square_hole_shape_image(
|
||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||
regenerate_visual_assets: bool,
|
||
option: &SquareHoleShapeOptionRecord,
|
||
) -> bool {
|
||
match requested_slot {
|
||
Some(SquareHoleVisualAssetSlotRequest::Shape(option_id)) => option.option_id == *option_id,
|
||
Some(_) => false,
|
||
None => {
|
||
regenerate_visual_assets
|
||
|| option
|
||
.image_src
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.is_none()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn should_generate_square_hole_hole_image(
|
||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||
regenerate_visual_assets: bool,
|
||
option: &SquareHoleHoleOptionRecord,
|
||
) -> bool {
|
||
match requested_slot {
|
||
Some(SquareHoleVisualAssetSlotRequest::Hole(hole_id)) => option.hole_id == *hole_id,
|
||
Some(_) => false,
|
||
None => {
|
||
regenerate_visual_assets
|
||
|| option
|
||
.image_src
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.is_none()
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<module_square_hole::SquareHoleShapeOption> for SquareHoleConfigShapeOptionJson {
|
||
fn from(option: module_square_hole::SquareHoleShapeOption) -> Self {
|
||
Self {
|
||
option_id: option.option_id,
|
||
shape_kind: option.shape_kind,
|
||
label: option.label,
|
||
target_hole_id: option.target_hole_id,
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<SquareHoleShapeOptionRecord> for SquareHoleConfigShapeOptionJson {
|
||
fn from(option: SquareHoleShapeOptionRecord) -> Self {
|
||
Self {
|
||
option_id: option.option_id,
|
||
shape_kind: option.shape_kind,
|
||
label: option.label,
|
||
target_hole_id: option.target_hole_id,
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<module_square_hole::SquareHoleHoleOption> for SquareHoleConfigHoleOptionJson {
|
||
fn from(option: module_square_hole::SquareHoleHoleOption) -> Self {
|
||
Self {
|
||
hole_id: option.hole_id,
|
||
hole_kind: option.hole_kind,
|
||
label: option.label,
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<SquareHoleHoleOptionRecord> for SquareHoleConfigHoleOptionJson {
|
||
fn from(option: SquareHoleHoleOptionRecord) -> Self {
|
||
Self {
|
||
hole_id: option.hole_id,
|
||
hole_kind: option.hole_kind,
|
||
label: option.label,
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn resolve_author_display_name(
|
||
state: &AppState,
|
||
authenticated: &AuthenticatedAccessToken,
|
||
) -> String {
|
||
state
|
||
.auth_user_service()
|
||
.get_user_by_id(authenticated.claims().user_id())
|
||
.ok()
|
||
.flatten()
|
||
.map(|user| user.display_name)
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or_else(|| "玩家".to_string())
|
||
}
|
||
|
||
fn normalize_square_hole_run_status(value: &str) -> &str {
|
||
match value {
|
||
"Running" | "running" => "running",
|
||
"Won" | "won" => "won",
|
||
"Failed" | "failed" => "failed",
|
||
"Stopped" | "stopped" => "stopped",
|
||
_ => value,
|
||
}
|
||
}
|
||
|
||
fn ensure_non_empty(
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
value: &str,
|
||
field_name: &str,
|
||
) -> Result<(), Response> {
|
||
if value.trim().is_empty() {
|
||
return Err(square_hole_bad_request(
|
||
request_context,
|
||
provider,
|
||
format!("{field_name} is required").as_str(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn square_hole_json<T>(
|
||
payload: Result<Json<T>, JsonRejection>,
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
) -> Result<Json<T>, Response> {
|
||
payload.map_err(|error| {
|
||
square_hole_error_response(
|
||
request_context,
|
||
provider,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": provider,
|
||
"message": error.body_text(),
|
||
})),
|
||
)
|
||
})
|
||
}
|
||
|
||
async fn extract_square_hole_response_error_message(response: Response) -> String {
|
||
let status = response.status();
|
||
let body = match to_bytes(response.into_body(), 64 * 1024).await {
|
||
Ok(body) => body,
|
||
Err(_) => return format!("方洞挑战生成失败:{status}"),
|
||
};
|
||
let body_text = String::from_utf8_lossy(&body).trim().to_string();
|
||
if body_text.is_empty() {
|
||
return format!("方洞挑战生成失败:{status}");
|
||
}
|
||
|
||
if let Ok(body_json) = serde_json::from_str::<Value>(&body_text)
|
||
&& let Some(message) = find_square_hole_error_message(&body_json)
|
||
{
|
||
return message;
|
||
}
|
||
|
||
body_text
|
||
}
|
||
|
||
fn find_square_hole_error_message(value: &Value) -> Option<String> {
|
||
if let Some(message) = value
|
||
.get("details")
|
||
.and_then(|details| details.get("message"))
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|message| !message.is_empty())
|
||
{
|
||
return Some(message.to_string());
|
||
}
|
||
|
||
if let Some(message) = value
|
||
.get("message")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|message| !message.is_empty())
|
||
{
|
||
return Some(message.to_string());
|
||
}
|
||
|
||
match value {
|
||
Value::Array(items) => items.iter().find_map(find_square_hole_error_message),
|
||
Value::Object(object) => object.values().find_map(find_square_hole_error_message),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn square_hole_bad_request(
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
message: &str,
|
||
) -> Response {
|
||
square_hole_error_response(
|
||
request_context,
|
||
provider,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": provider,
|
||
"message": message,
|
||
})),
|
||
)
|
||
}
|
||
|
||
fn map_square_hole_client_error(error: SpacetimeClientError) -> AppError {
|
||
let status = match &error {
|
||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("不存在")
|
||
|| message.contains("not found")
|
||
|| message.contains("does not exist") =>
|
||
{
|
||
StatusCode::NOT_FOUND
|
||
}
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("发布需要")
|
||
|| message.contains("不能为空")
|
||
|| message.contains("必须") =>
|
||
{
|
||
StatusCode::BAD_REQUEST
|
||
}
|
||
_ => StatusCode::BAD_GATEWAY,
|
||
};
|
||
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
fn square_hole_error_response(
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
error: AppError,
|
||
) -> Response {
|
||
let mut response = error.into_response_with_context(Some(request_context));
|
||
response.headers_mut().insert(
|
||
HeaderName::from_static("x-genarrative-provider"),
|
||
header::HeaderValue::from_str(provider)
|
||
.unwrap_or_else(|_| header::HeaderValue::from_static("square-hole")),
|
||
);
|
||
response
|
||
}
|
||
|
||
fn square_hole_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
|
||
Event::default()
|
||
.event(event_name)
|
||
.json_data(payload)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "sse",
|
||
"message": format!("SSE payload 序列化失败:{error}"),
|
||
}))
|
||
})
|
||
}
|
||
|
||
fn square_hole_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
|
||
match square_hole_sse_json_event(event_name, payload) {
|
||
Ok(event) => event,
|
||
Err(error) => Event::default().event("error").data(format!("{error:?}")),
|
||
}
|
||
}
|
||
|
||
fn current_utc_micros() -> i64 {
|
||
let duration = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default();
|
||
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
|
||
}
|
||
|
||
fn current_utc_ms() -> i64 {
|
||
current_utc_micros().saturating_div(1000)
|
||
}
|