Files
Genarrative/server-rs/crates/api-server/src/square_hole.rs
历冰郁-hermes版 3ad1075227
Some checks failed
CI / verify (push) Has been cancelled
feat: add work-level play tracking
2026-05-09 19:57:22 +08:00

2661 lines
88 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}