Files
Genarrative/server-rs/crates/api-server/src/square_hole.rs

1675 lines
55 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};
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::generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput,
normalize_generated_image_asset_mime,
};
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),
)
})
}
mod visual_assets;
use visual_assets::{
generate_square_hole_visual_assets_for_session, regenerate_square_hole_visual_asset_for_work,
};
mod mappers;
use mappers::*;
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)
}