Files
Genarrative/server-rs/crates/api-server/src/puzzle_clear.rs
kdletters 7048c05ebf 补齐外部编辑器生成资产链路
更新外部编辑器 API skill 的画板、素材库和美术规范引导。

扩展 Python helper,自动准备画板会话、长超时生成并补齐角色动画素材入库。

将新建项目和生成任务 ID 前缀调整为 proj/task,并保留旧 ID 兼容。

更新 OpenAPI 说明和项目记忆。
2026-07-03 15:19:44 +08:00

2810 lines
108 KiB
Rust
Raw Permalink 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 axum::{
Json,
body::{Body, to_bytes},
extract::{Extension, Path, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::Response,
};
use image::GenericImageView;
use module_assets::{
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_contracts::puzzle_clear::{
PuzzleClearActionRequest, PuzzleClearActionType, PuzzleClearCardAsset,
PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset,
PuzzleClearNextLevelRequest, PuzzleClearPatternGroup, PuzzleClearRetryLevelRequest,
PuzzleClearRunResponse, PuzzleClearSessionResponse, PuzzleClearSessionSnapshotResponse,
PuzzleClearStartRunRequest, PuzzleClearSwapRequest, PuzzleClearTimeUpRequest,
PuzzleClearWorkDetailResponse, PuzzleClearWorkMutationResponse, PuzzleClearWorksResponse,
PuzzleClearWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
};
use std::{
collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH},
};
use crate::{
api_response::json_success_body,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
decode_generated_image_asset_data_url, normalize_generated_image_asset_mime,
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const PUZZLE_CLEAR_PROVIDER: &str = "puzzle-clear";
const PUZZLE_CLEAR_CREATION_PROVIDER: &str = "puzzle-clear-creation";
const PUZZLE_CLEAR_RUNTIME_PROVIDER: &str = "puzzle-clear-runtime";
const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear";
const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消";
pub(crate) const PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_clear_compile_draft";
const PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/puzzle-clear/runs";
const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256;
const PUZZLE_CLEAR_SHEET_COLUMNS: u32 = 4;
const PUZZLE_CLEAR_SHEET_ROWS: u32 = 6;
const PUZZLE_CLEAR_SHEET_COLUMNS_USIZE: usize = 4;
const PUZZLE_CLEAR_SHEET_ROWS_USIZE: usize = 6;
const PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS: u32 = 10;
const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10;
const PUZZLE_CLEAR_SHEET_UNUSED_CELL: &str = ".";
const PUZZLE_CLEAR_SHEET_FILLER_CELL: &str = "FILL";
const PUZZLE_CLEAR_ATLAS_GENERATION_SIZE: &str = "1024x1536";
const PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE: &str = "1024x1024";
const PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp";
const PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS: usize = 4;
const PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 58;
const PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO: f32 = 0.018;
const PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO: f32 = 0.045;
const PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD: f32 = 0.34;
const PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD: f32 = 0.66;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD: i32 = 170;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD: f32 = 0.92;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD: f32 = 145.0;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0;
const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插";
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PuzzleClearCompileDraftWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub author_display_name: String,
pub payload: PuzzleClearActionRequest,
}
pub async fn create_puzzle_clear_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PuzzleClearWorkspaceCreateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) =
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_CREATION_PROVIDER)?;
validate_workspace_request(&request_context, &payload)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let session_id = build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_SESSION_ID_PREFIX);
let now = current_utc_micros();
let draft = build_puzzle_clear_draft(&payload);
let session = PuzzleClearSessionSnapshotResponse {
session_id,
owner_user_id,
status: PuzzleClearGenerationStatus::Draft,
draft: Some(draft),
created_at: format_timestamp_micros(now),
updated_at: format_timestamp_micros(now),
};
Ok(json_success_body(
Some(&request_context),
PuzzleClearSessionResponse {
session: state
.spacetime_client()
.create_puzzle_clear_session(session)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?,
},
))
}
pub async fn get_puzzle_clear_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, &session_id, "sessionId")?;
let session = state
.spacetime_client()
.get_puzzle_clear_session(session_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearSessionResponse { session },
))
}
pub async fn execute_puzzle_clear_action(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PuzzleClearActionRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let Json(payload) =
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_CREATION_PROVIDER)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let author_display_name = authenticated
.claims()
.display_name
.as_deref()
.unwrap_or("拼消消玩家")
.to_string();
let mut payload = payload;
let should_queue_generation = matches!(
payload.action_type,
PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas
) && !state.config.external_generation_mode.is_inline();
if should_queue_generation {
let mut queued_response = state
.spacetime_client()
.mark_puzzle_clear_generation_queued(
session_id.clone(),
owner_user_id.clone(),
author_display_name.clone(),
payload.clone(),
)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
let queue_job = enqueue_puzzle_clear_compile_draft_job(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
author_display_name.as_str(),
payload,
)
.await?;
queued_response.queue_state = Some(map_puzzle_clear_queue_job_status(queue_job));
return Ok(json_success_body(Some(&request_context), queued_response));
}
if let Err(response) = maybe_prepare_puzzle_clear_assets_inner(
&state,
&request_context,
session_id.as_str(),
owner_user_id.as_str(),
&mut payload,
)
.await
{
let (error_message, response) = extract_puzzle_clear_response_error_message(response).await;
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id,
error = %error_message,
"拼消消素材生成失败,准备回写 failed 状态"
);
if let Err(writeback_error) = state
.spacetime_client()
.mark_puzzle_clear_generation_failed(
session_id.clone(),
owner_user_id.clone(),
author_display_name.clone(),
payload.clone(),
)
.await
{
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id,
error = %writeback_error,
"拼消消素材生成失败状态回写失败"
);
}
return Err(response);
}
let response = state
.spacetime_client()
.execute_puzzle_clear_action(session_id, owner_user_id, author_display_name, payload)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(Some(&request_context), response))
}
async fn enqueue_puzzle_clear_compile_draft_job(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
author_display_name: &str,
payload: PuzzleClearActionRequest,
) -> Result<ExternalGenerationJobRecord, Response> {
let job_id = build_prefixed_uuid_id("task-");
let now_micros = current_utc_micros();
let request_payload_json = serde_json::to_string(&PuzzleClearCompileDraftWorkerPayload {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
author_display_name: author_display_name.to_string(),
payload,
})
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"message": format!("拼消消 worker 任务参数序列化失败:{error}"),
})),
)
})?;
let price_mud_points = resolve_puzzle_clear_generation_points_cost(state).await;
state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
dedupe_key: format!("puzzle-clear:compile-draft:{session_id}:{job_id}"),
job_id,
job_kind: PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND.to_string(),
owner_user_id: owner_user_id.to_string(),
source_module: "puzzle-clear".to_string(),
source_entity_id: session_id.to_string(),
request_label: "拼消消草稿生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now_micros,
created_at_micros: now_micros,
price_mud_points,
})
.await
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})
}
async fn resolve_puzzle_clear_generation_points_cost(state: &AppState) -> u64 {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
PUZZLE_CLEAR_TEMPLATE_ID,
u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
)
.await
}
fn map_puzzle_clear_queue_job_status(
job: ExternalGenerationJobRecord,
) -> ExternalGenerationJobStatusRecord {
ExternalGenerationJobStatusRecord {
operation_id: job.job_id,
status: ExternalGenerationJobStatus::Queued,
phase_label: job.request_label,
phase_detail: "排队中。".to_string(),
progress: 8,
error: job.last_error_message,
updated_at_micros: job.updated_at_micros,
}
}
pub(crate) async fn execute_puzzle_clear_compile_draft_worker_job(
state: &AppState,
request_context: &RequestContext,
mut worker_payload: PuzzleClearCompileDraftWorkerPayload,
) -> Result<PuzzleClearSessionSnapshotResponse, Response> {
if let Err(response) = maybe_prepare_puzzle_clear_assets_inner(
state,
request_context,
worker_payload.session_id.as_str(),
worker_payload.owner_user_id.as_str(),
&mut worker_payload.payload,
)
.await
{
let (error_message, response) = extract_puzzle_clear_response_error_message(response).await;
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id = worker_payload.session_id,
error = %error_message,
"拼消消 worker 素材生成失败,准备回写 failed 状态"
);
if let Err(writeback_error) = state
.spacetime_client()
.mark_puzzle_clear_generation_failed(
worker_payload.session_id.clone(),
worker_payload.owner_user_id.clone(),
worker_payload.author_display_name.clone(),
worker_payload.payload.clone(),
)
.await
{
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id = worker_payload.session_id,
error = %writeback_error,
"拼消消 worker 失败状态回写失败"
);
}
return Err(response);
}
let response = state
.spacetime_client()
.execute_puzzle_clear_action(
worker_payload.session_id,
worker_payload.owner_user_id,
worker_payload.author_display_name,
worker_payload.payload,
)
.await
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(response.session)
}
pub async fn list_puzzle_clear_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let works = state
.spacetime_client()
.list_puzzle_clear_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearWorksResponse {
items: works.into_iter().map(|work| work.summary).collect(),
},
))
}
pub async fn get_puzzle_clear_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, &profile_id, "profileId")?;
let item = state
.spacetime_client()
.get_puzzle_clear_work_profile(profile_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearWorkDetailResponse { item },
))
}
pub async fn publish_puzzle_clear_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, &profile_id, "profileId")?;
let item = state
.spacetime_client()
.publish_puzzle_clear_work(profile_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearWorkMutationResponse { item },
))
}
pub async fn get_puzzle_clear_runtime_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &profile_id, "profileId")?;
let item = state
.spacetime_client()
.get_puzzle_clear_runtime_work(profile_id)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearWorkDetailResponse { item },
))
}
pub async fn start_puzzle_clear_run(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<PuzzleClearStartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) =
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
let owner_user_id = principal.subject().to_string();
let principal_kind = principal.kind().as_str();
let run = state
.spacetime_client()
.start_puzzle_clear_run(payload, owner_user_id)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
record_work_play_start_after_success(
&state,
&request_context,
build_puzzle_clear_work_play_tracking_draft(
&principal,
run.profile_id.clone(),
PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE,
)
.owner_user_id(run.owner_user_id.clone())
.run_id(run.run_id.clone())
.profile_id(run.profile_id.clone())
.extra(json!({
"runStatus": run.status,
"principalKind": principal_kind,
"levelIndex": run.level_index,
})),
)
.await;
Ok(json_success_body(
Some(&request_context),
PuzzleClearRunResponse { run },
))
}
pub async fn get_puzzle_clear_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_puzzle_clear_run(run_id, principal.subject().to_string())
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearRunResponse { run },
))
}
pub async fn swap_puzzle_clear_cards(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<PuzzleClearSwapRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) =
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
let run = state
.spacetime_client()
.swap_puzzle_clear_cards(run_id, principal.subject().to_string(), payload)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearRunResponse { run },
))
}
pub async fn retry_puzzle_clear_level(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<PuzzleClearRetryLevelRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) =
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
let run = state
.spacetime_client()
.retry_puzzle_clear_level(run_id, principal.subject().to_string(), payload)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearRunResponse { run },
))
}
pub async fn advance_puzzle_clear_next_level(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<PuzzleClearNextLevelRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) =
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
let run = state
.spacetime_client()
.advance_puzzle_clear_next_level(run_id, principal.subject().to_string(), payload)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearRunResponse { run },
))
}
pub async fn mark_puzzle_clear_level_time_up(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<PuzzleClearTimeUpRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) =
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
let run = state
.spacetime_client()
.mark_puzzle_clear_level_time_up(run_id, principal.subject().to_string(), payload)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearRunResponse { run },
))
}
pub async fn list_puzzle_clear_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_puzzle_clear_gallery()
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
json!({
"items": items,
"hasMore": false,
"nextCursor": null,
}),
))
}
pub async fn get_puzzle_clear_gallery_detail(
State(state): State<AppState>,
Path(public_work_code): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?;
let normalized_code = normalize_public_work_code(public_work_code.as_str());
let items = state
.spacetime_client()
.list_puzzle_clear_gallery()
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
let profile_id = items
.into_iter()
.find(|item| {
normalize_public_work_code(
build_puzzle_clear_public_work_code(&item.profile_id).as_str(),
) == normalized_code
|| normalize_public_work_code(item.profile_id.as_str()) == normalized_code
})
.map(|item| item.profile_id)
.ok_or_else(|| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": PUZZLE_CLEAR_PROVIDER,
"message": "拼消消公开作品不存在",
})),
)
})?;
let item = state
.spacetime_client()
.get_puzzle_clear_work_profile(profile_id, String::new())
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_RUNTIME_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleClearWorkDetailResponse { item },
))
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PuzzleClearAtlasCardSlice {
group: PuzzleClearPatternGroup,
task_id: Option<String>,
part_x: u32,
part_y: u32,
bytes: Vec<u8>,
}
#[derive(Clone, Copy, Debug)]
struct PuzzleClearAtlasSheetSpec {
sheet_id: &'static str,
layout: [[&'static str; PUZZLE_CLEAR_SHEET_COLUMNS_USIZE]; PUZZLE_CLEAR_SHEET_ROWS_USIZE],
layout_prompt: &'static str,
}
#[derive(Clone, Debug)]
struct PuzzleClearGeneratedSheet {
spec: PuzzleClearAtlasSheetSpec,
prompt: String,
task_id: String,
image: DownloadedOpenAiImage,
}
async fn maybe_prepare_puzzle_clear_assets_inner(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
payload: &mut PuzzleClearActionRequest,
) -> Result<(), Response> {
if !matches!(
payload.action_type,
PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas
) {
return Ok(());
}
if payload.atlas_asset.is_some()
&& payload
.pattern_groups
.as_ref()
.is_some_and(|groups| !groups.is_empty())
&& payload
.card_assets
.as_ref()
.is_some_and(|cards| !cards.is_empty())
{
return Ok(());
}
let profile_id = payload
.profile_id
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| {
build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_PROFILE_ID_PREFIX)
});
payload.profile_id = Some(profile_id.clone());
if payload.generate_board_background.unwrap_or(false)
&& payload
.board_background_asset
.as_ref()
.is_none_or(|asset| asset.image_src.trim().is_empty())
{
let board_background_prompt = payload
.board_background_prompt
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let theme_prompt = payload.theme_prompt.as_deref().unwrap_or_default();
let background_asset = generate_and_persist_puzzle_clear_board_background(
state,
request_context,
owner_user_id,
profile_id.as_str(),
board_background_prompt.unwrap_or(theme_prompt),
)
.await?;
payload.board_background_asset = Some(background_asset);
} else if let Some(asset) = payload.board_background_asset.clone() {
if asset.image_src.trim_start().starts_with("data:image/") {
payload.board_background_asset = Some(
persist_puzzle_clear_data_url_asset(
state,
request_context,
owner_user_id,
profile_id.as_str(),
"board-background-upload",
asset.prompt.as_str(),
asset.image_src.as_str(),
1024,
1536,
)
.await?,
);
}
}
let groups = planned_puzzle_clear_pattern_groups();
let groups_by_id = groups
.iter()
.cloned()
.map(|group| (group.group_id.clone(), group))
.collect::<BTreeMap<_, _>>();
let sheet_specs = puzzle_clear_atlas_sheet_specs();
let settings = require_openai_image_settings(state)
.map(|settings| {
settings.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.clone()),
)
})
.map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
let http_client = build_openai_image_http_client(&settings).map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
let mut generated_sheets = Vec::with_capacity(sheet_specs.len());
for sheet_spec in sheet_specs {
let sheet_prompt = build_puzzle_clear_atlas_prompt(
payload.theme_prompt.as_deref().unwrap_or_default(),
&sheet_spec,
);
let mut accepted_sheet = None;
for attempt_index in 0..PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS {
let failure_context = format!(
"拼消消素材 {} 生成失败,第 {} 次",
sheet_spec.sheet_id,
attempt_index + 1
);
let generated = match create_openai_image_generation(
&http_client,
&settings,
sheet_prompt.as_str(),
Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT),
PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
1,
&[],
failure_context.as_str(),
)
.await
{
Ok(generated) => generated,
Err(error)
if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS
&& is_retryable_puzzle_clear_sheet_generation_error(&error) =>
{
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
attempt = attempt_index + 1,
generation_error = %error.body_text(),
"拼消消素材 sheet 生成遇到可重试上游错误,准备重试"
);
continue;
}
Err(error) => {
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
error,
));
}
};
let task_id = generated.task_id;
let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("拼消消素材 {} 生成成功但未返回图片。", sheet_spec.sheet_id),
})),
)
})?;
match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) {
Ok(()) => {
accepted_sheet = Some(PuzzleClearGeneratedSheet {
spec: sheet_spec,
prompt: sheet_prompt.clone(),
task_id,
image,
});
break;
}
Err(error) if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS => {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
attempt = attempt_index + 1,
quality_error = %error.body_text(),
"拼消消素材 sheet 质量校验未通过,准备重试"
);
}
Err(error) => {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
attempt = attempt_index + 1,
quality_error = %error.body_text(),
"拼消消素材 sheet 质量校验最终未通过"
);
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
error,
));
}
}
}
let Some(accepted_sheet) = accepted_sheet else {
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 多次生成后仍未得到可切图集。", sheet_spec.sheet_id),
})),
));
};
generated_sheets.push(accepted_sheet);
}
let mut slices = Vec::new();
for generated_sheet in &generated_sheets {
slices.extend(
slice_puzzle_clear_sheet(
&generated_sheet.image,
&generated_sheet.spec,
&groups_by_id,
generated_sheet.task_id.as_str(),
)
.map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?,
);
}
let atlas_image =
compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
let atlas_prompt = generated_sheets
.iter()
.map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt))
.collect::<Vec<_>>()
.join("\n\n---\n\n");
let atlas_task_ids = generated_sheets
.iter()
.map(|sheet| sheet.task_id.as_str())
.collect::<Vec<_>>()
.join(",");
let atlas_asset = persist_puzzle_clear_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"atlas",
atlas_prompt.as_str(),
Some(atlas_task_ids.as_str()),
atlas_image,
PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS,
PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_ROWS,
request_context,
)
.await?;
let mut card_assets = Vec::with_capacity(slices.len());
for slice in slices {
let task_id = slice.task_id.clone();
card_assets.push(
persist_puzzle_clear_card_slice(
state,
owner_user_id,
profile_id.as_str(),
task_id.as_deref(),
slice,
request_context,
)
.await?,
);
}
payload.atlas_asset = Some(atlas_asset);
payload.pattern_groups = Some(groups);
payload.card_assets = Some(card_assets);
tracing::info!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id,
profile_id,
group_count = payload.pattern_groups.as_ref().map_or(0, Vec::len),
card_count = payload.card_assets.as_ref().map_or(0, Vec::len),
"拼消消素材 atlas 已生成、切片并写入资产索引"
);
Ok(())
}
async fn extract_puzzle_clear_response_error_message(response: Response) -> (String, Response) {
let status = response.status();
let (parts, body) = response.into_parts();
let body_bytes = match to_bytes(body, 64 * 1024).await {
Ok(bytes) => bytes,
Err(_) => {
let rebuilt = Response::from_parts(parts, Body::empty());
return (format!("拼消消素材生成失败:{status}"), rebuilt);
}
};
let body_text = String::from_utf8_lossy(&body_bytes).trim().to_string();
let message = if body_text.is_empty() {
format!("拼消消素材生成失败:{status}")
} else if let Ok(body_json) = serde_json::from_str::<Value>(&body_text) {
body_json
.get("error")
.and_then(|error| error.get("message"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.unwrap_or(body_text.as_str())
.to_string()
} else {
body_text
};
let rebuilt = Response::from_parts(parts, Body::from(body_bytes));
(message, rebuilt)
}
fn planned_puzzle_clear_pattern_groups() -> Vec<PuzzleClearPatternGroup> {
module_puzzle_clear::plan_puzzle_clear_pattern_groups(PUZZLE_CLEAR_ATLAS_CELL_SIZE)
.unwrap_or_default()
.into_iter()
.map(|group| PuzzleClearPatternGroup {
group_id: group.group_id,
shape: group.shape.as_str().to_string(),
width: group.width,
height: group.height,
atlas_x: group.atlas_x,
atlas_y: group.atlas_y,
atlas_width: group.atlas_width,
atlas_height: group.atlas_height,
})
.collect()
}
fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
vec![
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-01",
layout: [
["D01", "D01", "D01", "A02"],
["D01", "D01", "D01", "A02"],
["D02", "D02", "C01", "C01"],
["D02", "D02", "C01", "C01"],
["D02", "D02", "C02", "C02"],
["A01", "A01", "C02", "C02"],
],
layout_prompt: concat!(
"本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n",
"第1行:D01 D01 D01 A02\n",
"第2行:D01 D01 D01 A02\n",
"第3行:D02 D02 C01 C01\n",
"第4行:D02 D02 C01 C01\n",
"第5行:D02 D02 C02 C02\n",
"第6行:A01 A01 C02 C02\n\n",
"A 表示 1x2 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。"
),
},
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-02",
layout: [
["D03", "D03", "D03", "A04"],
["D03", "D03", "D03", "A04"],
["C03", "C03", "C04", "C04"],
["C03", "C03", "C04", "C04"],
["B01", "B01", "B01", "A06"],
["B03", "B03", "B03", "A06"],
],
layout_prompt: concat!(
"本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n",
"第1行:D03 D03 D03 A04\n",
"第2行:D03 D03 D03 A04\n",
"第3行:C03 C03 C04 C04\n",
"第4行:C03 C03 C04 C04\n",
"第5行:B01 B01 B01 A06\n",
"第6行:B03 B03 B03 A06\n\n",
"A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。"
),
},
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-03",
layout: [
["B02", "B04", "A03", "A03"],
["B02", "B04", "A05", "A05"],
["B02", "B04", "A07", "A07"],
["B05", "B05", "B05", "A08"],
["A09", "A09", "A10", "A08"],
["A11", "A11", "A10", PUZZLE_CLEAR_SHEET_FILLER_CELL],
],
layout_prompt: concat!(
"本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n",
"第1行:B02 B04 A03 A03\n",
"第2行:B02 B04 A05 A05\n",
"第3行:B02 B04 A07 A07\n",
"第4行:B05 B05 B05 A08\n",
"第5行:A09 A09 A10 A08\n",
"第6行:A11 A11 A10 FILL\n\n",
"A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案。FILL 是后台会丢弃的补位格,请画成主题一致但不参与玩法的小照片裁片,不要写字或编号。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。"
),
},
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-04",
layout: [
["A12", "A13", "A13", "A14"],
["A12", "A15", "A15", "A14"],
["A16", "A17", "A17", "A18"],
["A16", "A19", "A19", "A18"],
["A20", "A21", "A21", "A22"],
["A20", "A23", "A23", "A22"],
],
layout_prompt: concat!(
"本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n",
"第1行:A12 A13 A13 A14\n",
"第2行:A12 A15 A15 A14\n",
"第3行:A16 A17 A17 A18\n",
"第4行:A16 A19 A19 A18\n",
"第5行:A20 A21 A21 A22\n",
"第6行:A20 A23 A23 A22\n\n",
"A 表示 1x2 复合图案。相同编号表示同一视觉家族:横向 1x2 和纵向 1x2 要共享同一场景锚点,用色调、道具和背景线索互相呼应;每个 256 单元仍需完整可读,但不要做成彼此无关的随机独立小图。"
),
},
]
}
fn build_puzzle_clear_atlas_prompt(
theme_prompt: &str,
sheet_spec: &PuzzleClearAtlasSheetSpec,
) -> String {
let subject = normalize_non_empty_str(theme_prompt).unwrap_or_else(|| "梦幻物件".to_string());
format!(
concat!(
"生成一张拼消消素材工作表,主题是「{subject}」,竖版 1024x1536。\n\n",
"这张图供程序后台按 4 列 x 6 行裁切,每个裁切单元为 256x256 的正方形。4x6 网格只用于后台理解,画面中绝对不要画出网格线、切分线、边框、编号或坐标。\n\n",
"这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域都要属于同一视觉家族,必须有明确背景、环境、道具、光影和构图线索,像从同一组丰富照片或插画中裁出的局部。\n\n",
"每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;同组之间要共享同一场景锚点、主色和道具语言。禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上。\n\n",
"相同编号连续占据的格子表示同一视觉家族,不是随机独立小图。请把同编号区域画成一组可辨认的兄弟卡片,至少共享一个明显场景锚点(同一张桌子、同一窗景、同一庭院、同一篮子或同一器物系统);每个格子可以展示这个家族的不同局部、视角或连贯片段,但仍需完整可读,不能在单格内部再切出第二张图或第二个场景。\n\n",
"同一张 sheet 内,不同编号必须使用不同视觉概念,并且拉开主色、场景和道具,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n",
"每个 256x256 单元独立查看时,都应该有可辨识的局部信息:可以包含主体局部、背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素。不要让小卡只有一个孤立主体加纯色背景。\n\n",
"不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;FILL 补位格可以生成主题一致的小照片裁片,但后台会丢弃它,不要在 FILL 中写字、编号或画规则说明。\n\n",
"图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片。不能有外轮廓框、白色描边、圆角框、阴影框、分隔线、参考线或贴纸边。\n\n",
"画风为高清、清爽、适合休闲消除游戏的丰富主题插画;颜色鲜明,边缘干净,不能出现文字、Logo、水印、按钮、UI 或教程元素。\n\n",
"{layout_prompt}"
),
subject = subject,
layout_prompt = sheet_spec.layout_prompt
)
}
fn build_puzzle_clear_board_background_prompt(theme_prompt: &str) -> String {
let subject = normalize_non_empty_str(theme_prompt).unwrap_or_else(|| "拼消消".to_string());
format!(
concat!(
"生成拼消消中央背景底图,1:1 正方形,尺寸与中央棋盘一致,主题是「{subject}」。",
"这张图不是棋盘装饰,而是玩家逐渐消除卡片后慢慢看见的目标画面;",
"画面需要让玩家有探索、揭开底图全貌和追求目标完成的感受。",
"请以主题为核心设计精致完整的主题场景或主题主视觉,主体清晰、细节丰富、色彩和氛围强绑定主题,强表现力,必须一眼体现主题。",
"不要文字、水印、按钮、教程浮层或明显网格。"
),
subject = subject
)
}
async fn generate_and_persist_puzzle_clear_board_background(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
profile_id: &str,
theme_prompt: &str,
) -> Result<PuzzleClearImageAsset, Response> {
let prompt = build_puzzle_clear_board_background_prompt(theme_prompt);
let settings = require_openai_image_settings(state)
.map(|settings| {
settings.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
)
})
.map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
let http_client = build_openai_image_http_client(&settings).map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
let generated = create_openai_image_generation(
&http_client,
&settings,
prompt.as_str(),
Some("文字、水印、按钮、教程浮层、明显网格"),
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE,
1,
&[],
"拼消消场地底图生成失败",
)
.await
.map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
let task_id = generated.task_id;
let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "拼消消场地底图生成成功但未返回图片。",
})),
)
})?;
persist_puzzle_clear_generated_image_asset(
state,
owner_user_id,
profile_id,
"board-background",
prompt.as_str(),
Some(task_id.as_str()),
image,
1024,
1024,
request_context,
)
.await
}
async fn persist_puzzle_clear_data_url_asset(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
profile_id: &str,
slot: &str,
prompt: &str,
data_url: &str,
width: u32,
height: u32,
) -> Result<PuzzleClearImageAsset, Response> {
let parsed = decode_generated_image_asset_data_url(data_url).map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "generated-image-assets",
"message": format!("拼消消图片 Data URL 解析失败:{error:?}"),
})),
)
})?;
let image = DownloadedOpenAiImage {
extension: parsed.format.extension,
mime_type: parsed.format.mime_type,
bytes: parsed.bytes,
};
persist_puzzle_clear_generated_image_asset(
state,
owner_user_id,
profile_id,
slot,
prompt,
None,
image,
width,
height,
request_context,
)
.await
}
#[derive(Clone, Copy, Debug)]
struct PuzzleClearSheetCellBounds {
x0: u32,
y0: u32,
x1: u32,
y1: u32,
}
impl PuzzleClearSheetCellBounds {
fn width(self) -> u32 {
self.x1.saturating_sub(self.x0).max(1)
}
fn height(self) -> u32 {
self.y1.saturating_sub(self.y0).max(1)
}
fn area(self) -> u32 {
self.width().saturating_mul(self.height()).max(1)
}
}
#[derive(Clone, Copy, Debug)]
struct PuzzleClearSheetCellQuality {
foreground_ratio: f32,
exposed_edge_count: usize,
strongest_edge_ratio: f32,
strongest_internal_seam_ratio: f32,
}
fn validate_puzzle_clear_sheet_quality(
image: &DownloadedOpenAiImage,
sheet_spec: &PuzzleClearAtlasSheetSpec,
) -> Result<(), AppError> {
// 中文注释:生成图进入正式切片前先做像素级门禁,避免把明显错位的 sheet 持久化成卡牌资产。
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 解码失败:{error}", sheet_spec.sheet_id),
}))
})?;
let source_width = source.width();
let source_height = source.height();
if source_width < PUZZLE_CLEAR_SHEET_COLUMNS || source_height < PUZZLE_CLEAR_SHEET_ROWS {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 尺寸过小,无法做切片质量校验。", sheet_spec.sheet_id),
})),
);
}
let mut findings = Vec::new();
let mut advisory_findings = Vec::new();
for row in 0..PUZZLE_CLEAR_SHEET_ROWS {
for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS {
let group_id = sheet_spec.layout[row as usize][col as usize];
let bounds = puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height);
let quality =
analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds);
let cell_label = format!("第{}行第{}列", row + 1, col + 1);
if group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL {
if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO {
findings.push(format!("{cell_label} 空白格有主体"));
}
continue;
}
if group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL {
continue;
}
if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO {
findings.push(format!("{cell_label} 主体过少"));
}
if quality.strongest_internal_seam_ratio
> PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
{
findings.push(format!("{cell_label} 单格内部疑似拼接线"));
}
if quality.exposed_edge_count >= 2
&& quality.strongest_edge_ratio > PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD
{
advisory_findings.push(format!("{cell_label} 主体贴到不同图案边界"));
}
}
}
if !advisory_findings.is_empty() {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
quality_warning = %advisory_findings.join(""),
"拼消消素材 sheet 检测到边界接触,已作为提示继续切片"
);
}
if findings.is_empty() {
return Ok(());
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"reason": "invalid_puzzle_clear_sheet_quality",
"message": format!(
"拼消消素材 {} 不满足切片质量:{}。请重新生成图集。",
sheet_spec.sheet_id,
findings.join(""),
),
"findings": findings,
})),
)
}
fn is_retryable_puzzle_clear_sheet_generation_error(error: &AppError) -> bool {
if !matches!(
error.status_code(),
StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT | StatusCode::TOO_MANY_REQUESTS
) {
return false;
}
error
.details()
.and_then(|details| details.get("retryable"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn analyze_puzzle_clear_sheet_cell_quality(
source: &image::DynamicImage,
sheet_spec: &PuzzleClearAtlasSheetSpec,
row: u32,
col: u32,
bounds: PuzzleClearSheetCellBounds,
) -> PuzzleClearSheetCellQuality {
let background = sample_puzzle_clear_sheet_cell_background(source, bounds);
let width = bounds.width() as usize;
let height = bounds.height() as usize;
let mut mask = vec![0u8; width.saturating_mul(height)];
let mut foreground_pixels = 0u32;
for local_y in 0..height {
let y = bounds.y0 + local_y as u32;
for local_x in 0..width {
let x = bounds.x0 + local_x as u32;
if is_puzzle_clear_sheet_foreground_pixel(source.get_pixel(x, y).0, background) {
mask[local_y * width + local_x] = 1;
foreground_pixels = foreground_pixels.saturating_add(1);
}
}
}
let (exposed_edge_count, strongest_edge_ratio) =
measure_puzzle_clear_sheet_exposed_edges(&mask, width, height, sheet_spec, row, col);
let strongest_internal_seam_ratio = measure_puzzle_clear_sheet_internal_seam(source, bounds);
PuzzleClearSheetCellQuality {
foreground_ratio: foreground_pixels as f32 / bounds.area() as f32,
exposed_edge_count,
strongest_edge_ratio,
strongest_internal_seam_ratio,
}
}
fn puzzle_clear_sheet_cell_bounds(
row: u32,
col: u32,
source_width: u32,
source_height: u32,
) -> PuzzleClearSheetCellBounds {
let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS);
let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS);
let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS)
.max(x0 + 1)
.min(source_width);
let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS)
.max(y0 + 1)
.min(source_height);
PuzzleClearSheetCellBounds { x0, y0, x1, y1 }
}
fn is_puzzle_clear_sheet_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool {
if pixel[3] <= 24 {
return false;
}
let alpha_diff = (pixel[3] as i32 - background[3] as i32).abs();
let color_diff = (pixel[0] as i32 - background[0] as i32).abs()
+ (pixel[1] as i32 - background[1] as i32).abs()
+ (pixel[2] as i32 - background[2] as i32).abs();
alpha_diff >= 48 || color_diff >= PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD
}
fn sample_puzzle_clear_sheet_cell_background(
source: &image::DynamicImage,
bounds: PuzzleClearSheetCellBounds,
) -> [u8; 4] {
let sample_size = (bounds.width().min(bounds.height()) / 12).clamp(2, 8);
let points = [
(bounds.x0, bounds.y0),
(bounds.x1.saturating_sub(sample_size), bounds.y0),
(bounds.x0, bounds.y1.saturating_sub(sample_size)),
(
bounds.x1.saturating_sub(sample_size),
bounds.y1.saturating_sub(sample_size),
),
];
let mut samples = Vec::new();
for (start_x, start_y) in points {
let mut totals = [0u32; 4];
let mut count = 0u32;
for y in start_y..start_y.saturating_add(sample_size).min(bounds.y1) {
for x in start_x..start_x.saturating_add(sample_size).min(bounds.x1) {
let pixel = source.get_pixel(x, y).0;
totals[0] = totals[0].saturating_add(pixel[0] as u32);
totals[1] = totals[1].saturating_add(pixel[1] as u32);
totals[2] = totals[2].saturating_add(pixel[2] as u32);
totals[3] = totals[3].saturating_add(pixel[3] as u32);
count = count.saturating_add(1);
}
}
if count > 0 {
samples.push([
(totals[0] / count) as u8,
(totals[1] / count) as u8,
(totals[2] / count) as u8,
(totals[3] / count) as u8,
]);
}
}
samples
.into_iter()
.max_by_key(|sample| {
let max_channel = sample[0].max(sample[1]).max(sample[2]) as u16;
let min_channel = sample[0].min(sample[1]).min(sample[2]) as u16;
let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16;
let saturation = max_channel.saturating_sub(min_channel);
(luminance, u16::MAX.saturating_sub(saturation))
})
.unwrap_or([255, 255, 255, 255])
}
fn measure_puzzle_clear_sheet_exposed_edges(
mask: &[u8],
width: usize,
height: usize,
sheet_spec: &PuzzleClearAtlasSheetSpec,
row: u32,
col: u32,
) -> (usize, f32) {
if width == 0 || height == 0 || mask.len() < width.saturating_mul(height) {
return (0, 0.0);
}
let band = (width.min(height) / 24).clamp(6, 12);
let mut exposed_edges = 0usize;
let mut strongest_ratio = 0.0f32;
let edge_specs = [
((-1i32, 0i32), 0usize, 0usize, width, band),
((1, 0), 0, height.saturating_sub(band), width, band),
((0, -1), 0, 0, band, height),
((0, 1), width.saturating_sub(band), 0, band, height),
];
for ((row_delta, col_delta), start_x, start_y, edge_width, edge_height) in edge_specs {
if puzzle_clear_sheet_neighbor_is_same_group(sheet_spec, row, col, row_delta, col_delta) {
continue;
}
let mut foreground = 0usize;
let mut total = 0usize;
for local_y in start_y..start_y.saturating_add(edge_height).min(height) {
for local_x in start_x..start_x.saturating_add(edge_width).min(width) {
total = total.saturating_add(1);
if mask[local_y * width + local_x] != 0 {
foreground = foreground.saturating_add(1);
}
}
}
if total == 0 {
continue;
}
let ratio = foreground as f32 / total as f32;
strongest_ratio = strongest_ratio.max(ratio);
if ratio > PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD {
exposed_edges = exposed_edges.saturating_add(1);
}
}
(exposed_edges, strongest_ratio)
}
fn measure_puzzle_clear_sheet_internal_seam(
source: &image::DynamicImage,
bounds: PuzzleClearSheetCellBounds,
) -> f32 {
let width = bounds.width();
let height = bounds.height();
if width < 48 || height < 48 {
return 0.0;
}
let margin_x = (width / 8).clamp(18, 36);
let margin_y = (height / 8).clamp(18, 36);
let x_start = bounds.x0.saturating_add(margin_x).max(bounds.x0 + 1);
let x_end = bounds.x1.saturating_sub(margin_x).max(x_start + 1);
let y_start = bounds.y0.saturating_add(margin_y).max(bounds.y0 + 1);
let y_end = bounds.y1.saturating_sub(margin_y).max(y_start + 1);
let mut strongest = 0.0f32;
for x in x_start..x_end {
let mut strong = 0u32;
let mut total = 0u32;
for y in y_start..y_end {
let left = source.get_pixel(x.saturating_sub(1), y).0;
let right = source.get_pixel(x, y).0;
if puzzle_clear_rgb_distance(left, right)
>= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD
{
strong = strong.saturating_add(1);
}
total = total.saturating_add(1);
}
if total > 0 {
let line_ratio = strong as f32 / total as f32;
if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
&& puzzle_clear_sheet_internal_seam_has_flat_split(
source, bounds, x, true, x_start, x_end, y_start, y_end,
)
{
strongest = strongest.max(line_ratio);
}
}
}
for y in y_start..y_end {
let mut strong = 0u32;
let mut total = 0u32;
for x in x_start..x_end {
let top = source.get_pixel(x, y.saturating_sub(1)).0;
let bottom = source.get_pixel(x, y).0;
if puzzle_clear_rgb_distance(top, bottom)
>= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD
{
strong = strong.saturating_add(1);
}
total = total.saturating_add(1);
}
if total > 0 {
let line_ratio = strong as f32 / total as f32;
if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
&& puzzle_clear_sheet_internal_seam_has_flat_split(
source, bounds, y, false, x_start, x_end, y_start, y_end,
)
{
strongest = strongest.max(line_ratio);
}
}
}
strongest
}
fn puzzle_clear_sheet_internal_seam_has_flat_split(
source: &image::DynamicImage,
bounds: PuzzleClearSheetCellBounds,
split: u32,
vertical: bool,
x_start: u32,
x_end: u32,
y_start: u32,
y_end: u32,
) -> bool {
// 中文注释:富场景照片里常有窗框、桌沿、地平线等贯穿强边,只有两侧都近似人工平铺色块时才按拼贴硬失败。
let band = (bounds.width().min(bounds.height()) / 10).clamp(14, 28);
let (first, second) = if vertical {
let left_start = split.saturating_sub(band).max(x_start);
let left_end = split.saturating_sub(2).max(left_start);
let right_start = split.saturating_add(2).min(x_end);
let right_end = split.saturating_add(band).min(x_end).max(right_start);
(
puzzle_clear_rgb_stats_for_region(source, left_start, left_end, y_start, y_end),
puzzle_clear_rgb_stats_for_region(source, right_start, right_end, y_start, y_end),
)
} else {
let top_start = split.saturating_sub(band).max(y_start);
let top_end = split.saturating_sub(2).max(top_start);
let bottom_start = split.saturating_add(2).min(y_end);
let bottom_end = split.saturating_add(band).min(y_end).max(bottom_start);
(
puzzle_clear_rgb_stats_for_region(source, x_start, x_end, top_start, top_end),
puzzle_clear_rgb_stats_for_region(source, x_start, x_end, bottom_start, bottom_end),
)
};
if first.count == 0 || second.count == 0 {
return false;
}
let side_contrast = puzzle_clear_rgb_stats_distance(first, second);
let side_texture = first.texture().max(second.texture());
side_contrast >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD
&& side_texture <= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX
}
#[derive(Clone, Copy, Debug, Default)]
struct PuzzleClearRgbStats {
count: u64,
sum: [u64; 3],
sum_square: [u64; 3],
}
impl PuzzleClearRgbStats {
fn push(&mut self, pixel: [u8; 4]) {
self.count = self.count.saturating_add(1);
for (index, channel) in pixel.iter().take(3).enumerate() {
let value = *channel as u64;
self.sum[index] = self.sum[index].saturating_add(value);
self.sum_square[index] = self.sum_square[index].saturating_add(value * value);
}
}
fn mean_channel(self, index: usize) -> f32 {
if self.count == 0 {
return 0.0;
}
self.sum[index] as f32 / self.count as f32
}
fn texture(self) -> f32 {
if self.count == 0 {
return f32::MAX;
}
let mut variance_sum = 0.0f32;
for index in 0..3 {
let mean = self.mean_channel(index);
let mean_square = self.sum_square[index] as f32 / self.count as f32;
variance_sum += (mean_square - mean * mean).max(0.0);
}
variance_sum.sqrt()
}
}
fn puzzle_clear_rgb_stats_for_region(
source: &image::DynamicImage,
x0: u32,
x1: u32,
y0: u32,
y1: u32,
) -> PuzzleClearRgbStats {
let mut stats = PuzzleClearRgbStats::default();
if x0 >= x1 || y0 >= y1 {
return stats;
}
for y in (y0..y1).step_by(4) {
for x in (x0..x1).step_by(4) {
stats.push(source.get_pixel(x, y).0);
}
}
stats
}
fn puzzle_clear_rgb_stats_distance(left: PuzzleClearRgbStats, right: PuzzleClearRgbStats) -> f32 {
(0..3)
.map(|index| (left.mean_channel(index) - right.mean_channel(index)).abs())
.sum()
}
fn puzzle_clear_rgb_distance(left: [u8; 4], right: [u8; 4]) -> i32 {
(left[0] as i32 - right[0] as i32).abs()
+ (left[1] as i32 - right[1] as i32).abs()
+ (left[2] as i32 - right[2] as i32).abs()
}
fn is_puzzle_clear_sheet_discarded_cell(group_id: &str) -> bool {
group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL || group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL
}
fn puzzle_clear_sheet_neighbor_is_same_group(
sheet_spec: &PuzzleClearAtlasSheetSpec,
row: u32,
col: u32,
row_delta: i32,
col_delta: i32,
) -> bool {
let current = sheet_spec.layout[row as usize][col as usize];
if is_puzzle_clear_sheet_discarded_cell(current) {
return false;
}
let neighbor_row = row as i32 + row_delta;
let neighbor_col = col as i32 + col_delta;
if neighbor_row < 0
|| neighbor_col < 0
|| neighbor_row >= PUZZLE_CLEAR_SHEET_ROWS as i32
|| neighbor_col >= PUZZLE_CLEAR_SHEET_COLUMNS as i32
{
return false;
}
sheet_spec.layout[neighbor_row as usize][neighbor_col as usize] == current
}
fn slice_puzzle_clear_sheet(
image: &DownloadedOpenAiImage,
sheet_spec: &PuzzleClearAtlasSheetSpec,
groups_by_id: &BTreeMap<String, PuzzleClearPatternGroup>,
task_id: &str,
) -> Result<Vec<PuzzleClearAtlasCardSlice>, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 解码失败:{error}", sheet_spec.sheet_id),
}))
})?;
let source_width = source.width();
let source_height = source.height();
if source_width < 16 || source_height < 16 {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 尺寸过小,无法切割。", sheet_spec.sheet_id),
})),
);
}
let mut slices = Vec::new();
let mut cells_by_group: BTreeMap<&str, Vec<(u32, u32)>> = BTreeMap::new();
for (row, cells) in sheet_spec.layout.iter().enumerate() {
for (col, group_id) in cells.iter().enumerate() {
if is_puzzle_clear_sheet_discarded_cell(group_id) {
continue;
}
cells_by_group
.entry(*group_id)
.or_default()
.push((row as u32, col as u32));
}
}
for (group_id, cells) in cells_by_group {
let group = groups_by_id.get(group_id).ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材布局引用了未知图案组:{group_id}"),
}))
})?;
let min_row = cells.iter().map(|(row, _)| *row).min().unwrap_or(0);
let min_col = cells.iter().map(|(_, col)| *col).min().unwrap_or(0);
let expected_cell_count = (group.width * group.height) as usize;
if cells.len() != expected_cell_count {
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!(
"拼消消素材 {} 的布局 {} 格数不匹配,期望 {} 格,实际 {} 格。",
sheet_spec.sheet_id,
group_id,
expected_cell_count,
cells.len(),
),
})),
);
}
for part_y in 0..group.height {
for part_x in 0..group.width {
let expected_cell = (min_row + part_y, min_col + part_x);
if !cells.contains(&expected_cell) {
return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!(
"拼消消素材 {} 的布局 {} 不是完整连续矩形,缺少第 {} 行第 {} 列。",
sheet_spec.sheet_id,
group_id,
expected_cell.0 + 1,
expected_cell.1 + 1,
),
})));
}
}
}
for (row, col) in cells {
let part_x = col.saturating_sub(min_col);
let part_y = row.saturating_sub(min_row);
let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS);
let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS);
let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS)
.max(x0 + 1)
.min(source_width);
let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS)
.max(y0 + 1)
.min(source_height);
let cropped = source.crop_imm(x0, y0, x1 - x0, y1 - y0).resize_exact(
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
image::imageops::FilterType::Lanczos3,
);
let mut cursor = std::io::Cursor::new(Vec::new());
cropped
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 切片失败:{error}", sheet_spec.sheet_id),
}))
})?;
slices.push(PuzzleClearAtlasCardSlice {
group: group.clone(),
task_id: Some(task_id.to_string()),
part_x,
part_y,
bytes: cursor.into_inner(),
});
}
}
Ok(slices)
}
fn compose_puzzle_clear_final_atlas(
slices: &[PuzzleClearAtlasCardSlice],
groups_by_id: &BTreeMap<String, PuzzleClearPatternGroup>,
) -> Result<DownloadedOpenAiImage, AppError> {
let width = PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS;
let height = PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_ROWS;
let mut atlas = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 248, 234, 255]));
for slice in slices {
let group = groups_by_id.get(&slice.group.group_id).ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消最终 atlas 缺少图案组:{}", slice.group.group_id),
}))
})?;
let piece = image::load_from_memory(slice.bytes.as_slice())
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消最终 atlas 切片解码失败:{error}"),
}))
})?
.to_rgba8();
image::imageops::overlay(
&mut atlas,
&piece,
i64::from(group.atlas_x + slice.part_x * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
i64::from(group.atlas_y + slice.part_y * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
);
}
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(atlas)
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消最终 atlas 合成失败:{error}"),
}))
})?;
Ok(DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: cursor.into_inner(),
})
}
fn scale_sheet_coord(value: u32, actual: u32, sheet_cells: u32) -> u32 {
((u64::from(value) * u64::from(actual)) / u64::from(sheet_cells)) as u32
}
fn normalize_non_empty_str(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
async fn persist_puzzle_clear_card_slice(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
task_id: Option<&str>,
slice: PuzzleClearAtlasCardSlice,
request_context: &RequestContext,
) -> Result<PuzzleClearCardAsset, Response> {
let card_id = format!(
"{}-part-{}-{}",
slice.group.group_id, slice.part_x, slice.part_y
);
let prompt = format!(
"拼消消素材切片 {} {}:{}",
slice.group.group_id, slice.part_x, slice.part_y
);
let image = DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: slice.bytes,
};
let persisted = persist_puzzle_clear_generated_image_asset(
state,
owner_user_id,
profile_id,
format!("cards/{card_id}").as_str(),
prompt.as_str(),
task_id,
image,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
request_context,
)
.await?;
Ok(PuzzleClearCardAsset {
card_id,
group_id: slice.group.group_id,
shape: slice.group.shape,
orientation: if slice.group.width >= slice.group.height {
"horizontal".to_string()
} else {
"vertical".to_string()
},
part_x: slice.part_x,
part_y: slice.part_y,
image_src: persisted.image_src,
image_object_key: persisted.image_object_key,
asset_object_id: persisted.asset_object_id,
source_atlas_cell: format!("{}:{}:{}", persisted.asset_id, slice.part_x, slice.part_y),
})
}
#[allow(clippy::too_many_arguments)]
async fn persist_puzzle_clear_generated_image_asset(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
slot: &str,
prompt: &str,
task_id: Option<&str>,
image: DownloadedOpenAiImage,
width: u32,
height: u32,
request_context: &RequestContext,
) -> Result<PuzzleClearImageAsset, Response> {
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
let prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::PuzzleClearAssets,
path_segments: vec![profile_id.to_string(), slot.to_string()],
file_stem: "image".to_string(),
image: GeneratedImageAssetDataUrl {
format: image_format,
bytes: image.bytes,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(format!("puzzle-clear-{slot}")),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some("puzzle_clear_work".to_string()),
entity_id: Some(profile_id.to_string()),
slot: Some(slot.to_string()),
provider: Some("vector-engine".to_string()),
task_id: task_id.map(ToString::to_string),
},
extra_metadata: BTreeMap::new(),
})
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备拼消消图片资产上传请求失败:{error:?}"),
})),
)
})?;
let persisted_mime_type = prepared.format.mime_type.clone();
let oss_client = state.oss_client().ok_or_else(|| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
})),
)
})?;
let http_client = reqwest::Client::new();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
)
})?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
)
})?;
let now_micros = current_utc_micros();
let asset_object_input = build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key.clone(),
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
format!("puzzle-clear-{slot}"),
task_id.map(ToString::to_string),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
Some(profile_id.to_string()),
now_micros,
)
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
})),
)
})?;
let asset_object = state
.spacetime_client()
.confirm_asset_object(asset_object_input)
.await
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
})),
)
})?;
let binding_input = build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id.clone(),
"puzzle_clear_work".to_string(),
profile_id.to_string(),
slot.to_string(),
format!("puzzle-clear-{slot}"),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
now_micros,
)
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
})),
)
})?;
state
.spacetime_client()
.bind_asset_object_to_entity(binding_input)
.await
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
})),
)
})?;
Ok(PuzzleClearImageAsset {
asset_id: format!("{profile_id}-{}-{now_micros}", slot.replace('/', "-")),
image_src: put_result.legacy_public_path,
image_object_key: head.object_key,
asset_object_id: asset_object.asset_object_id,
generation_provider: "vector-engine".to_string(),
prompt: prompt.to_string(),
width,
height,
})
}
fn build_puzzle_clear_draft(
payload: &PuzzleClearWorkspaceCreateRequest,
) -> PuzzleClearDraftResponse {
PuzzleClearDraftResponse {
template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(),
template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
profile_id: None,
work_title: payload.work_title.trim().to_string(),
work_description: payload.work_description.trim().to_string(),
theme_prompt: payload.theme_prompt.trim().to_string(),
board_background_prompt: payload.board_background_prompt.trim().to_string(),
generate_board_background: payload.generate_board_background,
board_background_asset: payload.board_background_asset.clone(),
card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()),
atlas_asset: None,
pattern_groups: Vec::new(),
card_assets: Vec::new(),
generation_status: PuzzleClearGenerationStatus::Draft,
}
}
fn validate_workspace_request(
request_context: &RequestContext,
payload: &PuzzleClearWorkspaceCreateRequest,
) -> Result<(), Response> {
if payload.template_id.trim() != PUZZLE_CLEAR_TEMPLATE_ID {
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_CLEAR_PROVIDER,
"message": "templateId 必须为 puzzle-clear",
})),
));
}
ensure_non_empty(request_context, &payload.work_title, "workTitle")?;
ensure_non_empty(request_context, &payload.theme_prompt, "themePrompt")?;
Ok(())
}
fn ensure_non_empty(
request_context: &RequestContext,
value: &str,
field: &str,
) -> Result<(), Response> {
if value.trim().is_empty() {
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_CLEAR_PROVIDER,
"field": field,
"message": format!("{field} 不能为空"),
})),
));
}
Ok(())
}
fn puzzle_clear_json<T>(
payload: Result<Json<T>, JsonRejection>,
request_context: &RequestContext,
provider: &str,
) -> Result<Json<T>, Response> {
payload.map_err(|error| {
puzzle_clear_error_response(
request_context,
provider,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"message": error.to_string(),
})),
)
})
}
fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError {
let message = error.to_string();
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(value)
if value.contains("不存在")
|| value.contains("not found")
|| value.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(value)
if value.contains("发布需要")
|| value.contains("不能为空")
|| value.contains("必须")
|| value.contains("无权") =>
{
StatusCode::BAD_REQUEST
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": message,
}))
}
fn puzzle_clear_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(PUZZLE_CLEAR_PROVIDER)),
);
response
}
fn build_puzzle_clear_work_play_tracking_draft(
principal: &RuntimePrincipal,
work_id: impl Into<String>,
source_route: &'static str,
) -> WorkPlayTrackingDraft {
WorkPlayTrackingDraft::runtime_principal("puzzle-clear", work_id, principal, source_route)
}
fn normalize_public_work_code(value: &str) -> String {
value
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.map(|character| character.to_ascii_uppercase())
.collect()
}
fn build_puzzle_clear_public_work_code(profile_id: &str) -> String {
let normalized = normalize_public_work_code(profile_id);
let fallback = if normalized.is_empty() {
"00000000".to_string()
} else {
normalized
};
let suffix = if fallback.len() >= 8 {
fallback[fallback.len() - 8..].to_string()
} else {
format!("{fallback:0>8}")
};
format!("PC-{suffix}")
}
#[cfg(test)]
mod tests {
use super::{
PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT,
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_UNUSED_CELL, PuzzleClearAtlasSheetSpec, build_puzzle_clear_atlas_prompt,
build_puzzle_clear_board_background_prompt, build_puzzle_clear_draft,
is_puzzle_clear_sheet_discarded_cell, is_retryable_puzzle_clear_sheet_generation_error,
planned_puzzle_clear_pattern_groups, puzzle_clear_atlas_sheet_specs,
validate_puzzle_clear_sheet_quality,
};
use crate::http_error::AppError;
use crate::openai_image_generation::DownloadedOpenAiImage;
use axum::http::StatusCode;
use image::{ImageFormat, Rgba, RgbaImage};
use serde_json::json;
use shared_contracts::puzzle_clear::PuzzleClearWorkspaceCreateRequest;
use std::io::Cursor;
#[test]
fn puzzle_clear_atlas_prompt_uses_sheet_cells_and_subject() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.next()
.expect("sheet exists");
let prompt = build_puzzle_clear_atlas_prompt("水果", &sheet);
assert!(prompt.contains("主题是「水果」"));
assert!(prompt.contains("竖版 1024x1536"));
assert!(prompt.contains("4 列 x 6 行裁切"));
assert!(prompt.contains("256x256 的正方形"));
assert!(prompt.contains("完整的单场景照片裁片"));
assert!(prompt.contains("照片式构图"));
assert!(prompt.contains("主题微场景拼图卡"));
assert!(prompt.contains("明确背景、环境、道具、光影和构图线索"));
assert!(prompt.contains("每个 256x256 单元本身就是一张完整的单场景照片裁片"));
assert!(prompt.contains("禁止在一个单元内部出现两张照片"));
assert!(prompt.contains("内部竖切"));
assert!(prompt.contains("内部横切"));
assert!(prompt.contains("场景变化只能发生在 256 单元边界上"));
assert!(prompt.contains("同一视觉家族"));
assert!(prompt.contains("同一场景锚点"));
assert!(prompt.contains("同一套连拍"));
assert!(prompt.contains("彼此无关的随机独立小图"));
assert!(prompt.contains("不能在单格内部再切出第二张图或第二个场景"));
assert!(prompt.contains("不同编号必须使用不同视觉概念"));
assert!(prompt.contains("不要把同一种主体换角度、换大小、换姿势后重复使用"));
assert!(prompt.contains("果园、集市摊位、野餐布、果汁杯、厨房案板"));
assert!(prompt.contains("可以包含主体局部、背景纹理、桌面、草地、天空"));
assert!(prompt.contains("不要让小卡只有一个孤立主体加纯色背景"));
assert!(prompt.contains("FILL 补位格可以生成主题一致的小照片裁片"));
assert!(prompt.contains("后台会丢弃它"));
assert!(prompt.contains("图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片"));
assert!(prompt.contains("外轮廓框"));
assert!(prompt.contains("贴纸边"));
assert!(prompt.contains("圆角框"));
assert!(prompt.contains("阴影框"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("纯色背景"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("白底商品图"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("孤立主体"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同品种重复"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同一物体多角度"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格内部拼接线"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格双图"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("照片拼贴"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("相册拼贴"));
assert!(!prompt.contains("135 幅"));
assert!(!prompt.contains("24 列 x 38 行"));
assert!(!prompt.contains("卡牌小格"));
assert!(!prompt.contains("卡牌排版图"));
assert!(!prompt.contains("贴纸表"));
}
#[test]
fn puzzle_clear_sheet_plan_matches_reduced_asset_strategy() {
let sheets = puzzle_clear_atlas_sheet_specs();
let groups = planned_puzzle_clear_pattern_groups();
let occupied_sheet_cells = sheets
.iter()
.flat_map(|sheet| sheet.layout.iter().flatten())
.filter(|group_id| **group_id != PUZZLE_CLEAR_SHEET_UNUSED_CELL)
.count();
let playable_sheet_cells = sheets
.iter()
.flat_map(|sheet| sheet.layout.iter().flatten())
.filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id))
.count();
let filler_sheet_cells = sheets
.iter()
.flat_map(|sheet| sheet.layout.iter().flatten())
.filter(|group_id| **group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL)
.count();
let mut sheet_cells_by_group = std::collections::BTreeMap::<&str, u32>::new();
for group_id in sheets
.iter()
.flat_map(|sheet| sheet.layout.iter().flatten())
.filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id))
{
*sheet_cells_by_group.entry(*group_id).or_default() += 1;
}
let group_cells = groups
.iter()
.map(|group| group.width * group.height)
.sum::<u32>();
assert_eq!(sheets.len(), 4);
assert_eq!(groups.len(), 35);
assert_eq!(occupied_sheet_cells, 96);
assert_eq!(playable_sheet_cells, 95);
assert_eq!(filler_sheet_cells, 1);
assert_eq!(group_cells, 95);
assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256);
assert_eq!(sheet_cells_by_group.len(), groups.len());
for group in &groups {
assert_eq!(
sheet_cells_by_group.get(group.group_id.as_str()).copied(),
Some(group.width * group.height),
);
}
}
#[test]
fn puzzle_clear_sheet_quality_allows_edge_contact_as_advisory_warning() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-04")
.expect("sheet exists");
let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
for row in 0..6u32 {
for col in 0..4u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let color = Rgba([
70u8.saturating_add((row * 23) as u8),
80u8.saturating_add((col * 31) as u8),
160,
255,
]);
for y in base_y + 80..base_y + 176 {
for x in base_x + 80..base_x + 176 {
source.put_pixel(x, y, color);
}
}
}
}
for y in 0..180u32 {
for x in 0..180u32 {
source.put_pixel(x, y, Rgba([215, 48, 62, 255]));
}
}
let mut encoded = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, ImageFormat::Png)
.expect("test image should encode");
let image = DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
};
validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect("edge contact is advisory because generated sheets often touch borders");
}
fn build_test_puzzle_clear_sheet_image_with_cell_pollution(
row: u32,
col: u32,
) -> DownloadedOpenAiImage {
let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
for row in 0..6u32 {
for col in 0..4u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let color = Rgba([
70u8.saturating_add((row * 23) as u8),
80u8.saturating_add((col * 31) as u8),
160,
255,
]);
for y in base_y + 80..base_y + 176 {
for x in base_x + 80..base_x + 176 {
source.put_pixel(x, y, color);
}
}
}
}
let cell_y0 = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let cell_x0 = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
for y in cell_y0 + 40..cell_y0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 {
for x in cell_x0 + 40..cell_x0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 {
source.put_pixel(x, y, Rgba([215, 48, 62, 255]));
}
}
let mut encoded = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, ImageFormat::Png)
.expect("test image should encode");
DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
}
}
#[test]
fn puzzle_clear_sheet_quality_allows_filler_cell_pollution() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-03")
.expect("sheet exists");
let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(5, 3);
validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect("filler cell is generated only to stabilize the sheet and is discarded later");
}
#[test]
fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() {
let sheet = PuzzleClearAtlasSheetSpec {
sheet_id: "blank-test",
layout: [
[PUZZLE_CLEAR_SHEET_UNUSED_CELL, "A01", "A01", "A02"],
["A03", "A03", "A04", "A04"],
["A05", "A05", "A06", "A06"],
["A07", "A07", "A08", "A08"],
["A09", "A09", "A10", "A10"],
["A11", "A11", "A12", "A12"],
],
layout_prompt: "test",
};
let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(0, 0);
let error = validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect_err("blank cell pollution should be rejected");
assert!(error.body_text().contains("空白格有主体"));
}
#[test]
fn puzzle_clear_sheet_quality_allows_textured_scene_divider() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-04")
.expect("sheet exists");
let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
for row in 0..6u32 {
for col in 0..4u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let color = Rgba([
70u8.saturating_add((row * 23) as u8),
80u8.saturating_add((col * 31) as u8),
160,
255,
]);
for y in base_y + 80..base_y + 176 {
for x in base_x + 80..base_x + 176 {
source.put_pixel(x, y, color);
}
}
}
}
for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
let noise = ((x * 17 + y * 31) % 120) as u8;
let color = if x < PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 {
Rgba([
72u8.saturating_add(noise),
88u8.saturating_add(((x * 7 + y * 11) % 96) as u8),
112u8.saturating_add(((x * 5 + y * 13) % 72) as u8),
255,
])
} else {
Rgba([
104u8.saturating_add(((x * 19 + y * 3) % 92) as u8),
76u8.saturating_add(noise),
56u8.saturating_add(((x * 11 + y * 23) % 88) as u8),
255,
])
};
source.put_pixel(x, y, color);
}
source.put_pixel(PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2, y, Rgba([24, 24, 24, 255]));
}
let mut encoded = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, ImageFormat::Png)
.expect("test image should encode");
let image = DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
};
validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect("textured photo-like scene divider should not be rejected as collage");
}
#[test]
fn puzzle_clear_sheet_quality_rejects_internal_photo_seam() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-04")
.expect("sheet exists");
let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
for row in 0..6u32 {
for col in 0..4u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let color = Rgba([
70u8.saturating_add((row * 23) as u8),
80u8.saturating_add((col * 31) as u8),
160,
255,
]);
for y in base_y + 80..base_y + 176 {
for x in base_x + 80..base_x + 176 {
source.put_pixel(x, y, color);
}
}
}
}
for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 {
source.put_pixel(x, y, Rgba([206, 46, 62, 255]));
}
for x in PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
source.put_pixel(x, y, Rgba([38, 112, 218, 255]));
}
}
let mut encoded = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, ImageFormat::Png)
.expect("test image should encode");
let image = DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
};
let error = validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect_err("internal photo seam should be rejected");
assert!(error.body_text().contains("单格内部疑似拼接线"));
}
#[test]
fn puzzle_clear_sheet_generation_retries_only_retryable_upstream_errors() {
let retryable_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "上游服务请求失败",
"retryable": true,
}));
assert!(is_retryable_puzzle_clear_sheet_generation_error(
&retryable_error
));
let non_retryable_gateway =
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "上游服务请求失败",
"retryable": false,
}));
assert!(!is_retryable_puzzle_clear_sheet_generation_error(
&non_retryable_gateway
));
let bad_request = AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "vector-engine",
"message": "请求参数不合法",
"retryable": true,
}));
assert!(!is_retryable_puzzle_clear_sheet_generation_error(
&bad_request
));
}
#[test]
fn puzzle_clear_board_background_prompt_reveals_theme_goal() {
let prompt = build_puzzle_clear_board_background_prompt("星港花园");
assert!(prompt.contains("星港花园"));
assert!(prompt.contains("逐渐消除"));
assert!(prompt.contains("底图全貌"));
assert!(prompt.contains("探索"));
assert!(prompt.contains("追求目标"));
assert!(prompt.contains("尺寸与中央棋盘一致"));
assert!(prompt.contains("精致"));
assert!(prompt.contains("强表现力"));
assert_eq!(PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, "1024x1024");
assert!(!prompt.contains("画面干净"));
assert!(!prompt.contains("适合作为卡牌棋盘底图"));
assert!(!prompt.contains("9:16"));
}
#[test]
fn puzzle_clear_draft_uses_existing_card_back_placeholder() {
let draft = build_puzzle_clear_draft(&PuzzleClearWorkspaceCreateRequest {
template_id: "puzzle-clear".to_string(),
work_title: "星港拼消消".to_string(),
work_description: String::new(),
theme_prompt: "星港".to_string(),
board_background_prompt: String::new(),
generate_board_background: true,
board_background_asset: None,
});
assert_eq!(
draft.card_back_image_src.as_deref(),
Some("/creation-type-references/puzzle.webp"),
);
}
}
fn current_utc_micros() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_micros().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}