refactor(api-server): split puzzle module
This commit is contained in:
@@ -16,6 +16,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-18 api-server 拼图能力按 HTTP/BFF 子模块拆分
|
||||||
|
|
||||||
|
- 背景:`server-rs/crates/api-server/src/puzzle.rs` 已膨胀为数千行大文件,混合 Axum handler、草稿编译、图片生成、VectorEngine / OSS 持久化、DTO mapper、标签生成和测试;继续在单文件内迭代会降低定位和评审效率。
|
||||||
|
- 决策:删除单文件 `puzzle.rs`,改为 `server-rs/crates/api-server/src/puzzle/` 目录模块。`mod.rs` 只保留聚合入口和 handler re-export;`handlers.rs` 放 HTTP handler;`draft.rs` 放表单草稿 / 编译 / snapshot helper;`generation.rs` 放图片与 UI 背景生成编排;`vector_engine.rs` 放 VectorEngine、下载、OSS、asset object / binding 和错误归一;`mappers.rs` / `tags.rs` 保留映射和标签 / 错误 helper;`tests.rs` 承接原 puzzle 单测。
|
||||||
|
- 边界:本次只改变 `api-server` 内部文件组织,不改变 `/api/runtime/puzzle/*` 路由、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义。领域规则后续仍应逐步沉到 `module-puzzle`,SpacetimeDB 表、reducer、procedure 和 row shape 仍留在 `spacetime-module`。
|
||||||
|
- 影响范围:`server-rs/crates/api-server/src/puzzle/`、`server-rs/crates/api-server/src/modules/puzzle.rs` 的 handler 引用、后端架构文档。
|
||||||
|
- 验证方式:执行 `cargo check -p api-server --manifest-path server-rs\Cargo.toml`;后续若改动 puzzle API 行为,再按对应路由补充定向测试和 `npm run dev:api-server` `/healthz` smoke。
|
||||||
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-05-18 Windows Jenkins PowerShell 统一改为显式 powershell.exe 启动
|
## 2026-05-18 Windows Jenkins PowerShell 统一改为显式 powershell.exe 启动
|
||||||
|
|
||||||
- 背景:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 本地环境里调用裸 `powershell` step 时触发 `CreateProcess error=5, 拒绝访问`,而 `powershell.exe` 本体与 workspace ACL 都正常。
|
- 背景:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 本地环境里调用裸 `powershell` step 时触发 `CreateProcess error=5, 拒绝访问`,而 `powershell.exe` 本体与 workspace ACL 都正常。
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ npm run check:server-rs-ddd
|
|||||||
3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。
|
3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。
|
||||||
4. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。
|
4. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。
|
||||||
|
|
||||||
|
拼图 `api-server` 内部拆分:
|
||||||
|
|
||||||
|
- `server-rs/crates/api-server/src/modules/puzzle.rs` 只负责路由装配、鉴权层和参考图 body limit;对外继续引用同一批 handler 名称。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/mod.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。
|
||||||
|
|
||||||
|
该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。
|
||||||
|
|
||||||
生成资产 Adapter 规则:
|
生成资产 Adapter 规则:
|
||||||
|
|
||||||
1. 稳定单图链路可收敛到 `api-server` 内部生成资产 Adapter:provider 生成、下载/base64 解码、MIME/extension 归一、OSS private upload、HEAD、asset object confirm、entity binding。
|
1. 稳定单图链路可收敛到 `api-server` 内部生成资产 Adapter:provider 生成、下载/base64 解码、MIME/extension 归一、OSS private upload、HEAD、asset object confirm、entity binding。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1909
server-rs/crates/api-server/src/puzzle/draft.rs
Normal file
1909
server-rs/crates/api-server/src/puzzle/draft.rs
Normal file
File diff suppressed because it is too large
Load Diff
264
server-rs/crates/api-server/src/puzzle/generation.rs
Normal file
264
server-rs/crates/api-server/src/puzzle/generation.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
|
||||||
|
if error.code() == "UPSTREAM_ERROR" {
|
||||||
|
let body_text = error.body_text();
|
||||||
|
return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": format!("拼图图片生成失败:{body_text}"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
|
||||||
|
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|
||||||
|
|| is_puzzle_request_timeout_message(error.body_text().as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn generate_puzzle_image_candidates(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
level_name: &str,
|
||||||
|
prompt: &str,
|
||||||
|
reference_image_src: Option<&str>,
|
||||||
|
use_reference_image_edit: bool,
|
||||||
|
image_model: Option<&str>,
|
||||||
|
candidate_count: u32,
|
||||||
|
candidate_start_index: usize,
|
||||||
|
) -> Result<Vec<GeneratedPuzzleImageCandidate>, AppError> {
|
||||||
|
let total_started_at = Instant::now();
|
||||||
|
let count = candidate_count.clamp(1, 1);
|
||||||
|
let resolved_model = resolve_puzzle_image_model(image_model);
|
||||||
|
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
|
||||||
|
let has_reference_image = has_puzzle_reference_image(reference_image_src);
|
||||||
|
let should_use_reference_image_edit =
|
||||||
|
should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit);
|
||||||
|
let actual_prompt = build_puzzle_vector_engine_generation_prompt(
|
||||||
|
build_puzzle_image_prompt(level_name, prompt).as_str(),
|
||||||
|
should_use_reference_image_edit,
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
prompt_chars = prompt.chars().count(),
|
||||||
|
actual_prompt_chars = actual_prompt.chars().count(),
|
||||||
|
has_reference_image,
|
||||||
|
use_reference_image_edit = should_use_reference_image_edit,
|
||||||
|
"拼图图片生成请求已准备"
|
||||||
|
);
|
||||||
|
let reference_image_started_at = Instant::now();
|
||||||
|
let reference_image = match reference_image_src
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.filter(|_| should_use_reference_image_edit)
|
||||||
|
{
|
||||||
|
Some(source) => {
|
||||||
|
let resolved =
|
||||||
|
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
reference_mime = %resolved.mime_type,
|
||||||
|
reference_bytes = resolved.bytes_len,
|
||||||
|
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图参考图解析完成"
|
||||||
|
);
|
||||||
|
Some(resolved)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
if !should_use_reference_image_edit {
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
has_reference_image,
|
||||||
|
use_reference_image_edit = should_use_reference_image_edit,
|
||||||
|
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图参考图解析跳过"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。
|
||||||
|
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
||||||
|
let settings = require_puzzle_vector_engine_settings(state)?;
|
||||||
|
let vector_engine_started_at = Instant::now();
|
||||||
|
let generated = if should_use_reference_image_edit {
|
||||||
|
let reference_image = reference_image.as_ref().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "puzzle",
|
||||||
|
"field": "referenceImageSrc",
|
||||||
|
"message": "AI 重绘需要提供参考图。",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let edit_result = create_puzzle_vector_engine_image_edit(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
actual_prompt.as_str(),
|
||||||
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
|
count,
|
||||||
|
reference_image,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match edit_result {
|
||||||
|
Ok(generated) => Ok(generated),
|
||||||
|
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
reference_mime = %reference_image.mime_type,
|
||||||
|
reference_bytes = reference_image.bytes_len,
|
||||||
|
error = %error,
|
||||||
|
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
|
||||||
|
);
|
||||||
|
create_puzzle_vector_engine_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
resolved_model,
|
||||||
|
actual_prompt.as_str(),
|
||||||
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
|
count,
|
||||||
|
Some(reference_image),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
create_puzzle_vector_engine_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
resolved_model,
|
||||||
|
actual_prompt.as_str(),
|
||||||
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
|
count,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
generated_image_count = generated.images.len(),
|
||||||
|
elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图 VectorEngine 生图与下载完成"
|
||||||
|
);
|
||||||
|
let mut items = Vec::with_capacity(generated.images.len());
|
||||||
|
|
||||||
|
for (index, image) in generated.images.into_iter().enumerate() {
|
||||||
|
let candidate_id = format!(
|
||||||
|
"{session_id}-candidate-{}",
|
||||||
|
candidate_start_index + index + 1
|
||||||
|
);
|
||||||
|
let downloaded_image = image.clone();
|
||||||
|
let persist_started_at = Instant::now();
|
||||||
|
let asset = persist_puzzle_generated_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
candidate_id.as_str(),
|
||||||
|
generated.task_id.as_str(),
|
||||||
|
image,
|
||||||
|
current_utc_micros(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
candidate_id = %candidate_id,
|
||||||
|
image_bytes = downloaded_image.bytes.len(),
|
||||||
|
image_mime = %downloaded_image.mime_type,
|
||||||
|
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图生成图片已写入 OSS 与资产索引"
|
||||||
|
);
|
||||||
|
items.push(GeneratedPuzzleImageCandidate {
|
||||||
|
record: PuzzleGeneratedImageCandidateRecord {
|
||||||
|
candidate_id,
|
||||||
|
image_src: asset.image_src,
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
prompt: prompt.to_string(),
|
||||||
|
actual_prompt: Some(actual_prompt.clone()),
|
||||||
|
source_type: resolved_model.candidate_source_type().to_string(),
|
||||||
|
// 单图生成结果总是直接成为当前正式图。
|
||||||
|
selected: index == 0,
|
||||||
|
},
|
||||||
|
downloaded_image,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
candidate_count = items.len(),
|
||||||
|
has_reference_image,
|
||||||
|
elapsed_ms = total_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图图片候选生成完成"
|
||||||
|
);
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn generate_puzzle_ui_background_image(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
level_name: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||||||
|
let settings = require_openai_image_settings(state)?;
|
||||||
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
|
let generated = create_openai_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
|
||||||
|
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"),
|
||||||
|
"9:16",
|
||||||
|
1,
|
||||||
|
&[],
|
||||||
|
"拼图 UI 背景图生成失败",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
|
"message": "拼图 UI 背景图生成失败:未返回图片",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
persist_puzzle_ui_background_image(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
generated.task_id.as_str(),
|
||||||
|
image,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn build_puzzle_ui_background_request_prompt_for_test(
|
||||||
|
level_name: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> String {
|
||||||
|
build_puzzle_ui_background_generation_prompt(level_name, prompt)
|
||||||
|
}
|
||||||
2009
server-rs/crates/api-server/src/puzzle/handlers.rs
Normal file
2009
server-rs/crates/api-server/src/puzzle/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
158
server-rs/crates/api-server/src/puzzle/mod.rs
Normal file
158
server-rs/crates/api-server/src/puzzle/mod.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
error::Error as StdError,
|
||||||
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Extension, Path as AxumPath, 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 image::ImageFormat;
|
||||||
|
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_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
||||||
|
use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest};
|
||||||
|
use platform_oss::{
|
||||||
|
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||||
|
OssSignedGetObjectUrlRequest,
|
||||||
|
};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use shared_contracts::{
|
||||||
|
creation_audio::CreationAudioAsset,
|
||||||
|
puzzle_agent::{
|
||||||
|
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
|
||||||
|
PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse,
|
||||||
|
PuzzleAgentSessionResponse, PuzzleAgentSessionSnapshotResponse,
|
||||||
|
PuzzleAgentSuggestedActionResponse, PuzzleAnchorItemResponse, PuzzleAnchorPackResponse,
|
||||||
|
PuzzleCreatorIntentResponse, PuzzleDraftLevelResponse, PuzzleFormDraftResponse,
|
||||||
|
PuzzleGeneratedImageCandidateResponse, PuzzleResultDraftResponse,
|
||||||
|
PuzzleResultPreviewBlockerResponse, PuzzleResultPreviewEnvelopeResponse,
|
||||||
|
PuzzleResultPreviewFindingResponse, SendPuzzleAgentMessageRequest,
|
||||||
|
},
|
||||||
|
puzzle_gallery::PuzzleGalleryDetailResponse,
|
||||||
|
puzzle_runtime::{
|
||||||
|
AdvancePuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||||||
|
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||||||
|
PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse,
|
||||||
|
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
|
||||||
|
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest,
|
||||||
|
UsePuzzleRuntimePropRequest,
|
||||||
|
},
|
||||||
|
puzzle_works::{
|
||||||
|
PutPuzzleWorkRequest, PuzzleOnboardingGenerateRequest, PuzzleOnboardingGenerateResponse,
|
||||||
|
PuzzleOnboardingSaveRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||||
|
PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||||
|
use spacetime_client::{
|
||||||
|
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
|
||||||
|
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
|
||||||
|
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||||
|
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord,
|
||||||
|
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
|
||||||
|
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||||
|
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput,
|
||||||
|
PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
|
||||||
|
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput,
|
||||||
|
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||||
|
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
|
||||||
|
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
|
||||||
|
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||||
|
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||||
|
};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
||||||
|
api_response::json_success_body,
|
||||||
|
asset_billing::{
|
||||||
|
execute_billable_asset_operation, execute_billable_asset_operation_with_cost,
|
||||||
|
should_skip_asset_operation_billing_for_connectivity,
|
||||||
|
},
|
||||||
|
auth::AuthenticatedAccessToken,
|
||||||
|
http_error::AppError,
|
||||||
|
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
|
||||||
|
openai_image_generation::{
|
||||||
|
DownloadedOpenAiImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, build_openai_image_http_client,
|
||||||
|
create_openai_image_generation, require_openai_image_settings,
|
||||||
|
},
|
||||||
|
platform_errors::map_oss_error,
|
||||||
|
prompt::puzzle::{
|
||||||
|
draft::{
|
||||||
|
PuzzleFormSeedPromptParts, build_puzzle_form_seed_prompt,
|
||||||
|
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
|
||||||
|
},
|
||||||
|
image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||||
|
level_name::{
|
||||||
|
PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT, build_puzzle_first_level_name_user_prompt,
|
||||||
|
build_puzzle_first_level_name_vision_user_text,
|
||||||
|
},
|
||||||
|
tags::{PUZZLE_TAG_GENERATION_SYSTEM_PROMPT, build_puzzle_tag_generation_user_prompt},
|
||||||
|
},
|
||||||
|
puzzle_agent_turn::{
|
||||||
|
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||||
|
run_puzzle_agent_turn,
|
||||||
|
},
|
||||||
|
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
|
||||||
|
request_context::RequestContext,
|
||||||
|
state::AppState,
|
||||||
|
vector_engine_audio_generation::{
|
||||||
|
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
|
||||||
|
},
|
||||||
|
work_author::resolve_work_author_by_user_id,
|
||||||
|
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
|
||||||
|
const PUZZLE_WORKS_PROVIDER: &str = "puzzle-works";
|
||||||
|
const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
|
||||||
|
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
|
||||||
|
const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
|
||||||
|
const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview";
|
||||||
|
const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2;
|
||||||
|
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
||||||
|
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
|
||||||
|
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
|
||||||
|
#[cfg(test)]
|
||||||
|
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
|
||||||
|
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
|
||||||
|
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||||
|
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||||
|
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
|
||||||
|
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
|
||||||
|
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
|
||||||
|
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||||
|
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
|
||||||
|
mod handlers;
|
||||||
|
pub(crate) use self::handlers::*;
|
||||||
|
|
||||||
|
mod mappers;
|
||||||
|
|
||||||
|
use self::mappers::*;
|
||||||
|
|
||||||
|
mod draft;
|
||||||
|
use self::draft::*;
|
||||||
|
|
||||||
|
mod tags;
|
||||||
|
|
||||||
|
use self::tags::*;
|
||||||
|
|
||||||
|
mod generation;
|
||||||
|
mod vector_engine;
|
||||||
|
|
||||||
|
use self::generation::*;
|
||||||
|
use self::vector_engine::*;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
880
server-rs/crates/api-server/src/puzzle/tests.rs
Normal file
880
server-rs/crates/api-server/src/puzzle/tests.rs
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_generated_image_size_is_square_1_1() {
|
||||||
|
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024");
|
||||||
|
assert_eq!(PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, "1024x1024");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
|
||||||
|
let body = build_puzzle_vector_engine_image_request_body(
|
||||||
|
PuzzleImageModel::Gemini31FlashPreview,
|
||||||
|
"一只猫在雨夜灯牌下回头。",
|
||||||
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
|
4,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
|
||||||
|
assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE);
|
||||||
|
assert_eq!(body["n"], 1);
|
||||||
|
assert!(body.get("official_fallback").is_none());
|
||||||
|
assert!(body.get("image").is_none());
|
||||||
|
assert!(
|
||||||
|
body["prompt"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("文字水印")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||||
|
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||||
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
|
image
|
||||||
|
.write_to(&mut cursor, ImageFormat::Png)
|
||||||
|
.expect("test image should encode");
|
||||||
|
let reference_image = PuzzleResolvedReferenceImage {
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
bytes_len: cursor.get_ref().len(),
|
||||||
|
bytes: cursor.into_inner(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = build_puzzle_vector_engine_image_request_body(
|
||||||
|
PuzzleImageModel::GptImage2,
|
||||||
|
"参考图里的小猫做成拼图主图。",
|
||||||
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
|
1,
|
||||||
|
Some(&reference_image),
|
||||||
|
);
|
||||||
|
|
||||||
|
let images = body["image"]
|
||||||
|
.as_array()
|
||||||
|
.expect("fallback generation should include reference image array");
|
||||||
|
assert_eq!(images.len(), 1);
|
||||||
|
assert!(
|
||||||
|
images[0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.starts_with("data:image/png;base64,")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
|
||||||
|
let settings = PuzzleVectorEngineSettings {
|
||||||
|
base_url: "https://vector.example/v1".to_string(),
|
||||||
|
api_key: "test-key".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
puzzle_vector_engine_images_edit_url(&settings),
|
||||||
|
"https://vector.example/v1/images/edits"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_edit_response_decodes_b64_image() {
|
||||||
|
let images = puzzle_images_from_base64(
|
||||||
|
"edit-1".to_string(),
|
||||||
|
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(images.images.len(), 1);
|
||||||
|
assert_eq!(images.images[0].mime_type, "image/png");
|
||||||
|
assert_eq!(images.images[0].extension, "png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_prompt_strongly_uses_reference_image() {
|
||||||
|
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true);
|
||||||
|
|
||||||
|
assert!(prompt.contains("参考图作为第一优先级"));
|
||||||
|
assert!(prompt.contains("严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围"));
|
||||||
|
assert!(prompt.contains("请生成雨夜猫街。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() {
|
||||||
|
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", false);
|
||||||
|
|
||||||
|
assert_eq!(prompt, "请生成雨夜猫街。");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_reference_image_edit_requires_ai_redraw() {
|
||||||
|
assert!(!should_use_puzzle_reference_image_edit(None, true));
|
||||||
|
assert!(!should_use_puzzle_reference_image_edit(
|
||||||
|
Some("data:image/png;base64,abcd"),
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(should_use_puzzle_reference_image_edit(
|
||||||
|
Some("data:image/png;base64,abcd"),
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||||
|
let sources = collect_puzzle_reference_image_sources(
|
||||||
|
Some("data:image/png;base64,a"),
|
||||||
|
&[
|
||||||
|
"data:image/png;base64,a".to_string(),
|
||||||
|
"data:image/png;base64,b".to_string(),
|
||||||
|
"data:image/png;base64,c".to_string(),
|
||||||
|
"data:image/png;base64,d".to_string(),
|
||||||
|
"data:image/png;base64,e".to_string(),
|
||||||
|
"data:image/png;base64,f".to_string(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(sources.len(), 5);
|
||||||
|
assert_eq!(sources[0], "data:image/png;base64,a");
|
||||||
|
assert_eq!(sources[1], "data:image/png;base64,b");
|
||||||
|
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||||
|
let error = map_puzzle_vector_engine_request_error(
|
||||||
|
"创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
|
||||||
|
let error = map_puzzle_vector_engine_upstream_error(
|
||||||
|
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||||
|
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
|
||||||
|
let timeout_error = map_puzzle_vector_engine_upstream_error(
|
||||||
|
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||||
|
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||||
|
);
|
||||||
|
assert!(should_fallback_puzzle_reference_edit_to_generation(
|
||||||
|
&timeout_error
|
||||||
|
));
|
||||||
|
|
||||||
|
let auth_error = map_puzzle_vector_engine_upstream_error(
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED,
|
||||||
|
r#"{"error":{"message":"invalid api key"}}"#,
|
||||||
|
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||||
|
);
|
||||||
|
assert!(!should_fallback_puzzle_reference_edit_to_generation(
|
||||||
|
&auth_error
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
|
||||||
|
let error = match reqwest::Client::new().get("http://[::1").build() {
|
||||||
|
Ok(_) => panic!("invalid url should fail request build"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
let app_error = map_puzzle_vector_engine_reqwest_error(
|
||||||
|
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||||
|
"https://api.vectorengine.ai/v1/images/edits",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = app_error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
|
||||||
|
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||||
|
"VECTOR_ENGINE_API_KEY 未配置".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() {
|
||||||
|
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||||
|
"APIMart 图片生成密钥未配置".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
let body = response.into_body();
|
||||||
|
let bytes = axum::body::to_bytes(body, usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body bytes should read");
|
||||||
|
let payload: Value =
|
||||||
|
serde_json::from_slice(&bytes).expect("error response should be valid json");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["details"]["provider"],
|
||||||
|
Value::String(VECTOR_ENGINE_PROVIDER.to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["details"]["message"],
|
||||||
|
Value::String("VectorEngine 图片生成密钥未配置".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||||
|
let levels_json = serde_json::to_string(&vec![json!({
|
||||||
|
"level_id": "puzzle-level-1",
|
||||||
|
"level_name": "雨夜猫街",
|
||||||
|
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||||
|
"candidates": [],
|
||||||
|
"selected_candidate_id": null,
|
||||||
|
"cover_image_src": null,
|
||||||
|
"cover_asset_id": null,
|
||||||
|
"generation_status": "idle",
|
||||||
|
})])
|
||||||
|
.expect("levels json");
|
||||||
|
let payload = ExecutePuzzleAgentActionRequest {
|
||||||
|
action: "generate_puzzle_images".to_string(),
|
||||||
|
prompt_text: None,
|
||||||
|
reference_image_src: None,
|
||||||
|
reference_image_srcs: Vec::new(),
|
||||||
|
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||||
|
ai_redraw: None,
|
||||||
|
candidate_count: Some(1),
|
||||||
|
candidate_id: None,
|
||||||
|
level_id: Some("puzzle-level-1".to_string()),
|
||||||
|
work_title: Some("暖灯猫街作品".to_string()),
|
||||||
|
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
|
||||||
|
picture_description: None,
|
||||||
|
level_name: None,
|
||||||
|
summary: Some("当前关卡画面。".to_string()),
|
||||||
|
theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]),
|
||||||
|
levels_json: Some(levels_json.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||||
|
"puzzle-session-1",
|
||||||
|
&payload,
|
||||||
|
Some(levels_json.as_str()),
|
||||||
|
1_713_686_401_234_567,
|
||||||
|
)
|
||||||
|
.expect("fallback session");
|
||||||
|
|
||||||
|
let draft = session.draft.expect("draft");
|
||||||
|
assert_eq!(session.stage, "ready_to_publish");
|
||||||
|
assert_eq!(draft.work_title, "暖灯猫街作品");
|
||||||
|
assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]);
|
||||||
|
assert_eq!(draft.levels[0].level_id, "puzzle-level-1");
|
||||||
|
assert_eq!(
|
||||||
|
draft.levels[0].picture_description,
|
||||||
|
"一只猫在雨夜灯牌下回头。"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#),
|
||||||
|
Some("雨夜猫街".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"),
|
||||||
|
Some("暖灯猫街".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
|
||||||
|
Some("雨夜猫街".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text(r#"{"levelNam"#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() {
|
||||||
|
let naming = parse_puzzle_level_naming_from_text(
|
||||||
|
r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
|
||||||
|
)
|
||||||
|
.expect("naming should parse");
|
||||||
|
|
||||||
|
assert_eq!(naming.level_name, "雨夜猫街");
|
||||||
|
assert_eq!(
|
||||||
|
naming.work_description.as_deref(),
|
||||||
|
Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图")
|
||||||
|
);
|
||||||
|
assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT);
|
||||||
|
assert!(naming.work_tags.contains(&"雨夜".to_string()));
|
||||||
|
assert!(naming.work_tags.contains(&"猫咪".to_string()));
|
||||||
|
assert!(naming.work_tags.contains(&"灯牌".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
naming.ui_background_prompt.as_deref(),
|
||||||
|
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() {
|
||||||
|
let naming = parse_puzzle_level_naming_from_text(
|
||||||
|
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景,中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印,保留暖色灯光"}"#,
|
||||||
|
)
|
||||||
|
.expect("naming should parse");
|
||||||
|
let prompt = naming
|
||||||
|
.ui_background_prompt
|
||||||
|
.as_deref()
|
||||||
|
.expect("prompt should parse");
|
||||||
|
|
||||||
|
assert!(!prompt.contains("拼图槽"));
|
||||||
|
assert!(!prompt.contains("棋盘"));
|
||||||
|
assert!(!prompt.contains("HUD"));
|
||||||
|
assert!(!prompt.contains("按钮"));
|
||||||
|
assert!(!prompt.contains("文字"));
|
||||||
|
assert!(!prompt.contains("水印"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
|
||||||
|
assert_eq!(
|
||||||
|
build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"),
|
||||||
|
"雨夜猫街"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"),
|
||||||
|
"奇境初见"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_level_name_image_data_url_downsizes_generated_image() {
|
||||||
|
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||||
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
|
image
|
||||||
|
.write_to(&mut cursor, ImageFormat::Png)
|
||||||
|
.expect("test image should encode");
|
||||||
|
let downloaded = PuzzleDownloadedImage {
|
||||||
|
extension: "png".to_string(),
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
bytes: cursor.into_inner(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let data_url =
|
||||||
|
build_puzzle_level_name_image_data_url(&downloaded).expect("data url should be generated");
|
||||||
|
|
||||||
|
assert!(data_url.starts_with("data:image/png;base64,"));
|
||||||
|
assert!(data_url.len() > "data:image/png;base64,".len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_first_level_name_snapshot_defaults_work_title() {
|
||||||
|
let levels_json = serde_json::to_string(&vec![json!({
|
||||||
|
"level_id": "puzzle-level-1",
|
||||||
|
"level_name": "猫画面",
|
||||||
|
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||||
|
"candidates": [],
|
||||||
|
"selected_candidate_id": null,
|
||||||
|
"cover_image_src": null,
|
||||||
|
"cover_asset_id": null,
|
||||||
|
"generation_status": "idle",
|
||||||
|
})])
|
||||||
|
.expect("levels json");
|
||||||
|
let payload = ExecutePuzzleAgentActionRequest {
|
||||||
|
action: "generate_puzzle_images".to_string(),
|
||||||
|
prompt_text: None,
|
||||||
|
reference_image_src: None,
|
||||||
|
reference_image_srcs: Vec::new(),
|
||||||
|
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||||
|
ai_redraw: None,
|
||||||
|
candidate_count: Some(1),
|
||||||
|
candidate_id: None,
|
||||||
|
level_id: Some("puzzle-level-1".to_string()),
|
||||||
|
work_title: Some("猫画面".to_string()),
|
||||||
|
work_description: None,
|
||||||
|
picture_description: None,
|
||||||
|
level_name: None,
|
||||||
|
summary: None,
|
||||||
|
theme_tags: Some(vec![]),
|
||||||
|
levels_json: Some(levels_json.clone()),
|
||||||
|
};
|
||||||
|
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||||
|
"puzzle-session-1",
|
||||||
|
&payload,
|
||||||
|
Some(levels_json.as_str()),
|
||||||
|
1_713_686_401_234_567,
|
||||||
|
)
|
||||||
|
.expect("fallback session");
|
||||||
|
|
||||||
|
let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||||
|
session,
|
||||||
|
"puzzle-level-1",
|
||||||
|
"雨夜猫街",
|
||||||
|
"猫画面",
|
||||||
|
1_713_686_401_234_568,
|
||||||
|
);
|
||||||
|
let draft = renamed.draft.expect("draft");
|
||||||
|
assert_eq!(draft.level_name, "雨夜猫街");
|
||||||
|
assert_eq!(draft.work_title, "雨夜猫街");
|
||||||
|
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() {
|
||||||
|
let mut session = PuzzleAgentSessionRecord {
|
||||||
|
session_id: "puzzle-session-1".to_string(),
|
||||||
|
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
current_turn: 1,
|
||||||
|
progress_percent: 94,
|
||||||
|
stage: "ready_to_publish".to_string(),
|
||||||
|
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||||
|
draft: Some(test_puzzle_draft_record()),
|
||||||
|
messages: Vec::new(),
|
||||||
|
last_assistant_reply: None,
|
||||||
|
published_profile_id: None,
|
||||||
|
suggested_actions: Vec::new(),
|
||||||
|
result_preview: None,
|
||||||
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let draft = session.draft.as_mut().expect("draft");
|
||||||
|
draft.work_title = "猫画面".to_string();
|
||||||
|
draft.work_description = String::new();
|
||||||
|
draft.summary = String::new();
|
||||||
|
draft.theme_tags = Vec::new();
|
||||||
|
}
|
||||||
|
let metadata = PuzzleLevelNaming {
|
||||||
|
level_name: "雨夜猫街".to_string(),
|
||||||
|
work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()),
|
||||||
|
work_tags: vec![
|
||||||
|
"插画".to_string(),
|
||||||
|
"灯牌".to_string(),
|
||||||
|
"街角".to_string(),
|
||||||
|
"猫咪".to_string(),
|
||||||
|
"暖色".to_string(),
|
||||||
|
"雨夜".to_string(),
|
||||||
|
],
|
||||||
|
ui_background_prompt: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||||
|
session,
|
||||||
|
&metadata,
|
||||||
|
"猫画面",
|
||||||
|
1_713_686_401_234_568,
|
||||||
|
);
|
||||||
|
|
||||||
|
let draft = session.draft.expect("draft");
|
||||||
|
assert_eq!(draft.work_title, "雨夜猫街");
|
||||||
|
assert_eq!(
|
||||||
|
draft.work_description,
|
||||||
|
"在湿润灯牌与猫影之间完成一套雨夜街角拼图"
|
||||||
|
);
|
||||||
|
assert_eq!(draft.summary, draft.work_description);
|
||||||
|
assert_eq!(draft.theme_tags, metadata.work_tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
|
||||||
|
let level = PuzzleDraftLevelResponse {
|
||||||
|
level_id: "puzzle-level-1".to_string(),
|
||||||
|
level_name: "雨夜猫街".to_string(),
|
||||||
|
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
picture_reference: None,
|
||||||
|
ui_background_prompt: None,
|
||||||
|
ui_background_image_src: None,
|
||||||
|
ui_background_image_object_key: None,
|
||||||
|
background_music: Some(CreationAudioAsset {
|
||||||
|
task_id: "suno-task-1".to_string(),
|
||||||
|
provider: "vector-engine-suno".to_string(),
|
||||||
|
asset_object_id: Some("assetobj_1".to_string()),
|
||||||
|
asset_kind: Some("puzzle_background_music".to_string()),
|
||||||
|
audio_src: "/generated-puzzle-assets/audio.mp3".to_string(),
|
||||||
|
prompt: Some("轻快拼图音乐".to_string()),
|
||||||
|
title: Some("雨夜猫街背景音乐".to_string()),
|
||||||
|
updated_at: Some("2026-05-11T00:00:00Z".to_string()),
|
||||||
|
}),
|
||||||
|
candidates: vec![],
|
||||||
|
selected_candidate_id: None,
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
generation_status: "ready".to_string(),
|
||||||
|
};
|
||||||
|
let request_context = RequestContext::new(
|
||||||
|
"test-request".to_string(),
|
||||||
|
"PUT /api/runtime/puzzle/works/test".to_string(),
|
||||||
|
Duration::ZERO,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
|
||||||
|
.expect("levels should serialize");
|
||||||
|
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||||
|
assert_eq!(
|
||||||
|
payload[0]["background_music"]["audio_src"],
|
||||||
|
Value::String("/generated-puzzle-assets/audio.mp3".to_string())
|
||||||
|
);
|
||||||
|
assert!(payload[0]["background_music"].get("audioSrc").is_none());
|
||||||
|
|
||||||
|
let records = parse_puzzle_level_records_from_module_json(&levels_json)
|
||||||
|
.expect("levels should map back into records");
|
||||||
|
let music = records[0]
|
||||||
|
.background_music
|
||||||
|
.as_ref()
|
||||||
|
.expect("background music should exist");
|
||||||
|
assert_eq!(music.audio_src, "/generated-puzzle-assets/audio.mp3");
|
||||||
|
assert_eq!(music.asset_kind.as_deref(), Some("puzzle_background_music"));
|
||||||
|
|
||||||
|
let response = map_puzzle_draft_level_response(records[0].clone());
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.background_music
|
||||||
|
.as_ref()
|
||||||
|
.map(|asset| asset.audio_src.as_str()),
|
||||||
|
Some("/generated-puzzle-assets/audio.mp3")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
|
||||||
|
let level = PuzzleDraftLevelResponse {
|
||||||
|
level_id: "puzzle-level-1".to_string(),
|
||||||
|
level_name: "雨夜猫街".to_string(),
|
||||||
|
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
picture_reference: None,
|
||||||
|
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
|
||||||
|
ui_background_image_src: Some(
|
||||||
|
"/generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||||
|
),
|
||||||
|
ui_background_image_object_key: Some(
|
||||||
|
"generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||||
|
),
|
||||||
|
background_music: None,
|
||||||
|
candidates: vec![],
|
||||||
|
selected_candidate_id: None,
|
||||||
|
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||||
|
cover_asset_id: Some("asset-1".to_string()),
|
||||||
|
generation_status: "ready".to_string(),
|
||||||
|
};
|
||||||
|
let request_context = RequestContext::new(
|
||||||
|
"test-request".to_string(),
|
||||||
|
"PUT /api/runtime/puzzle/works/test".to_string(),
|
||||||
|
Duration::ZERO,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
|
||||||
|
.expect("levels should serialize");
|
||||||
|
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||||
|
assert_eq!(
|
||||||
|
payload[0]["ui_background_prompt"],
|
||||||
|
Value::String("雨夜猫街竖屏拼图UI背景".to_string())
|
||||||
|
);
|
||||||
|
assert!(payload[0].get("uiBackgroundPrompt").is_none());
|
||||||
|
|
||||||
|
let records = parse_puzzle_level_records_from_module_json(&levels_json)
|
||||||
|
.expect("levels should map back into records");
|
||||||
|
assert_eq!(
|
||||||
|
records[0].ui_background_image_src.as_deref(),
|
||||||
|
Some("/generated-puzzle-assets/session/ui/background.png")
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = map_puzzle_draft_level_response(records[0].clone());
|
||||||
|
assert_eq!(
|
||||||
|
response.ui_background_image_object_key.as_deref(),
|
||||||
|
Some("generated-puzzle-assets/session/ui/background.png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||||
|
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
|
||||||
|
let level = PuzzleDraftLevelRecord {
|
||||||
|
level_id: "puzzle-level-1".to_string(),
|
||||||
|
level_name: "雨夜猫街".to_string(),
|
||||||
|
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
picture_reference: None,
|
||||||
|
ui_background_prompt: None,
|
||||||
|
ui_background_image_src: None,
|
||||||
|
ui_background_image_object_key: None,
|
||||||
|
background_music: None,
|
||||||
|
candidates: vec![PuzzleGeneratedImageCandidateRecord {
|
||||||
|
candidate_id: "candidate-1".to_string(),
|
||||||
|
image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(),
|
||||||
|
asset_id: "asset-1".to_string(),
|
||||||
|
prompt: "雨夜猫街".to_string(),
|
||||||
|
actual_prompt: None,
|
||||||
|
source_type: "generated".to_string(),
|
||||||
|
selected: true,
|
||||||
|
}],
|
||||||
|
selected_candidate_id: Some("candidate-1".to_string()),
|
||||||
|
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||||
|
cover_asset_id: Some("asset-1".to_string()),
|
||||||
|
generation_status: "ready".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = map_puzzle_work_summary_response(
|
||||||
|
&state,
|
||||||
|
PuzzleWorkProfileRecord {
|
||||||
|
work_id: "puzzle-work-1".to_string(),
|
||||||
|
profile_id: "puzzle-profile-1".to_string(),
|
||||||
|
owner_user_id: "user-1".to_string(),
|
||||||
|
source_session_id: Some("puzzle-session-1".to_string()),
|
||||||
|
author_display_name: "玩家".to_string(),
|
||||||
|
work_title: "雨夜猫街".to_string(),
|
||||||
|
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
level_name: "雨夜猫街".to_string(),
|
||||||
|
summary: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
theme_tags: vec!["猫".to_string()],
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
publication_status: "draft".to_string(),
|
||||||
|
updated_at: "2026-05-08T00:00:00.000Z".to_string(),
|
||||||
|
published_at: None,
|
||||||
|
play_count: 0,
|
||||||
|
remix_count: 0,
|
||||||
|
like_count: 0,
|
||||||
|
recent_play_count_7d: 0,
|
||||||
|
point_incentive_total_half_points: 0,
|
||||||
|
point_incentive_claimed_points: 0,
|
||||||
|
publish_ready: false,
|
||||||
|
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||||
|
levels: vec![level],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(response.levels.len(), 1);
|
||||||
|
assert_eq!(response.generation_status.as_deref(), Some("ready"));
|
||||||
|
assert_eq!(
|
||||||
|
response.levels[0].cover_image_src.as_deref(),
|
||||||
|
Some("/generated-puzzle-assets/session/cover.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response.levels[0].candidates[0].image_src,
|
||||||
|
"/generated-puzzle-assets/session/candidate-1.png"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
|
||||||
|
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
|
||||||
|
|
||||||
|
assert!(prompt.contains("9:16"));
|
||||||
|
assert!(prompt.contains("纯背景图"));
|
||||||
|
assert!(prompt.contains("不得出现拼图槽"));
|
||||||
|
assert!(prompt.contains("默认拼图槽"));
|
||||||
|
assert!(prompt.contains("文字"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() {
|
||||||
|
let mut draft = test_puzzle_draft_record();
|
||||||
|
draft.work_title = "模板作品名".to_string();
|
||||||
|
draft.work_description = "模板作品描述".to_string();
|
||||||
|
let mut target_level = draft.levels[0].clone();
|
||||||
|
target_level.level_name = "雨夜猫街".to_string();
|
||||||
|
let ai_prompt = "雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次";
|
||||||
|
target_level.ui_background_prompt = Some(ai_prompt.to_string());
|
||||||
|
|
||||||
|
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
|
||||||
|
|
||||||
|
assert_eq!(prompt, ai_prompt);
|
||||||
|
assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() {
|
||||||
|
let draft = test_puzzle_draft_record();
|
||||||
|
let target_level = draft.levels[0].clone();
|
||||||
|
|
||||||
|
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
|
||||||
|
|
||||||
|
assert!(prompt.contains("雨夜猫街"));
|
||||||
|
assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_ui_background_initial_attach_updates_first_level_fields() {
|
||||||
|
let draft = test_puzzle_draft_record();
|
||||||
|
let generated = GeneratedPuzzleUiBackgroundResponse {
|
||||||
|
image_src: "/generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||||
|
object_key: "generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||||
|
};
|
||||||
|
let mut levels = draft.levels.clone();
|
||||||
|
|
||||||
|
attach_puzzle_level_ui_background(
|
||||||
|
&mut levels,
|
||||||
|
"puzzle-level-1",
|
||||||
|
"雨夜猫街移动端拼图UI背景".to_string(),
|
||||||
|
generated,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
levels[0].ui_background_prompt.as_deref(),
|
||||||
|
Some("雨夜猫街移动端拼图UI背景")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
levels[0].ui_background_image_src.as_deref(),
|
||||||
|
Some("/generated-puzzle-assets/session/ui/background.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
levels[0].ui_background_image_object_key.as_deref(),
|
||||||
|
Some("generated-puzzle-assets/session/ui/background.png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_initial_draft_assets_must_include_ui_background() {
|
||||||
|
let mut draft = test_puzzle_draft_record();
|
||||||
|
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||||
|
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
|
||||||
|
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
|
||||||
|
assert!(missing_all.body_text().contains("UI背景图"));
|
||||||
|
|
||||||
|
draft.levels[0].ui_background_image_src =
|
||||||
|
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
|
||||||
|
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||||
|
.expect("UI 背景存在时即可完成自动草稿资源检查");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
|
||||||
|
let item = PuzzleAnchorItemRecord {
|
||||||
|
key: "visualSubject".to_string(),
|
||||||
|
label: "画面".to_string(),
|
||||||
|
value: "雨夜猫街".to_string(),
|
||||||
|
status: "confirmed".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
PuzzleAnchorPackRecord {
|
||||||
|
theme_promise: item.clone(),
|
||||||
|
visual_subject: item.clone(),
|
||||||
|
visual_mood: item.clone(),
|
||||||
|
composition_hooks: item.clone(),
|
||||||
|
tags_and_forbidden: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_puzzle_draft_record() -> PuzzleResultDraftRecord {
|
||||||
|
let anchor_pack = test_puzzle_anchor_pack_record();
|
||||||
|
PuzzleResultDraftRecord {
|
||||||
|
work_title: "雨夜猫街".to_string(),
|
||||||
|
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
level_name: "猫画面".to_string(),
|
||||||
|
summary: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
theme_tags: vec![],
|
||||||
|
forbidden_directives: vec![],
|
||||||
|
creator_intent: None,
|
||||||
|
anchor_pack,
|
||||||
|
candidates: vec![],
|
||||||
|
selected_candidate_id: None,
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
generation_status: "idle".to_string(),
|
||||||
|
levels: vec![PuzzleDraftLevelRecord {
|
||||||
|
level_id: "puzzle-level-1".to_string(),
|
||||||
|
level_name: "猫画面".to_string(),
|
||||||
|
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
picture_reference: None,
|
||||||
|
ui_background_prompt: None,
|
||||||
|
ui_background_image_src: None,
|
||||||
|
ui_background_image_object_key: None,
|
||||||
|
background_music: None,
|
||||||
|
candidates: vec![],
|
||||||
|
selected_candidate_id: None,
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
generation_status: "idle".to_string(),
|
||||||
|
}],
|
||||||
|
form_draft: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_primary_level_update_preserves_reference_for_regeneration() {
|
||||||
|
let draft = test_puzzle_draft_record();
|
||||||
|
let mut target_level = draft.levels[0].clone();
|
||||||
|
target_level.level_name = "雨夜猫街".to_string();
|
||||||
|
|
||||||
|
let levels = build_puzzle_levels_with_primary_update(
|
||||||
|
&draft,
|
||||||
|
&target_level,
|
||||||
|
Some("data:image/png;base64,abcd"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(levels[0].level_name, "雨夜猫街");
|
||||||
|
assert_eq!(
|
||||||
|
levels[0].picture_reference.as_deref(),
|
||||||
|
Some("data:image/png;base64,abcd")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_generated_fallback_snapshot_preserves_picture_reference() {
|
||||||
|
let anchor_pack = test_puzzle_anchor_pack_record();
|
||||||
|
let session = PuzzleAgentSessionRecord {
|
||||||
|
session_id: "puzzle-session-1".to_string(),
|
||||||
|
seed_text: "雨夜猫街".to_string(),
|
||||||
|
current_turn: 1,
|
||||||
|
progress_percent: 0,
|
||||||
|
stage: "draft_ready".to_string(),
|
||||||
|
anchor_pack: anchor_pack.clone(),
|
||||||
|
draft: Some(test_puzzle_draft_record()),
|
||||||
|
messages: Vec::new(),
|
||||||
|
last_assistant_reply: None,
|
||||||
|
published_profile_id: None,
|
||||||
|
suggested_actions: Vec::new(),
|
||||||
|
result_preview: None,
|
||||||
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
let candidate = PuzzleGeneratedImageCandidateRecord {
|
||||||
|
candidate_id: "puzzle-session-1-candidate-1".to_string(),
|
||||||
|
image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(),
|
||||||
|
asset_id: "puzzle-cover-1".to_string(),
|
||||||
|
prompt: "雨夜猫街".to_string(),
|
||||||
|
actual_prompt: Some("雨夜猫街".to_string()),
|
||||||
|
source_type: "generated:gpt-image-2".to_string(),
|
||||||
|
selected: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||||
|
session,
|
||||||
|
"puzzle-level-1",
|
||||||
|
vec![candidate],
|
||||||
|
Some("data:image/png;base64,abcd"),
|
||||||
|
1_713_686_401_234_568,
|
||||||
|
);
|
||||||
|
|
||||||
|
let draft = session.draft.expect("draft");
|
||||||
|
assert_eq!(
|
||||||
|
draft.levels[0].picture_reference.as_deref(),
|
||||||
|
Some("data:image/png;base64,abcd")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
||||||
|
let invalid_operation = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "spacetimedb",
|
||||||
|
"message": "操作不合法",
|
||||||
|
}));
|
||||||
|
let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "spacetimedb",
|
||||||
|
"message": "泥点余额不足",
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true));
|
||||||
|
assert!(!should_sync_puzzle_freeze_boundary(
|
||||||
|
&invalid_operation,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(!should_sync_puzzle_freeze_boundary(&other_error, true));
|
||||||
|
}
|
||||||
1273
server-rs/crates/api-server/src/puzzle/vector_engine.rs
Normal file
1273
server-rs/crates/api-server/src/puzzle/vector_engine.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user