Merge remote-tracking branch 'origin/master' into codex/tiaoyitiao

# Conflicts:
#	server-rs/crates/api-server/src/jump_hop.rs
#	server-rs/crates/api-server/src/modules/jump_hop.rs
This commit is contained in:
2026-06-06 21:04:46 +08:00
451 changed files with 25780 additions and 2687 deletions

View File

@@ -29,6 +29,7 @@ module-inventory = { workspace = true }
module-match3d = { workspace = true }
module-npc = { workspace = true }
module-puzzle = { workspace = true }
module-puzzle-clear = { workspace = true }
module-runtime = { workspace = true }
module-runtime-story = { workspace = true }
module-runtime-item = { workspace = true }

View File

@@ -67,6 +67,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::jump_hop::router(state.clone()))
.merge(modules::wooden_fish::router(state.clone()))
.merge(modules::public_work::router(state.clone()))
.merge(modules::puzzle_clear::router(state.clone()))
.merge(modules::puzzle::router(state.clone()))
.merge(visual_novel_router(state.clone()))
.route(
@@ -2697,6 +2698,18 @@ mod tests {
bind_payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
assert_eq!(
bind_payload["user"]["phoneNumber"],
Value::String("+8613800138000".to_string())
);
assert_eq!(
bind_payload["user"]["wechatAccount"],
Value::String("wx-mini-code-bind-001".to_string())
);
assert_eq!(
bind_payload["user"]["wechatDisplayName"],
Value::String("微信旅人".to_string())
);
assert!(
bind_payload["token"]
.as_str()
@@ -3384,6 +3397,10 @@ mod tests {
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
assert_eq!(
payload["user"]["phoneNumber"],
Value::String("+8613800138016".to_string())
);
assert_eq!(
payload["availableLoginMethods"],
serde_json::json!(["phone", "password", "wechat"])
@@ -4119,8 +4136,7 @@ mod tests {
.await
.expect("banners body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("banners payload should be json");
let payload: Value = serde_json::from_slice(&body).expect("banners payload should be json");
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
assert_eq!(payload["eventBanners"][0]["renderMode"], "html");

View File

@@ -7,10 +7,13 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
public_user_code: user.public_user_code,
display_name: user.display_name,
avatar_url: user.avatar_url,
phone_number: user.phone_number,
phone_number_masked: user.phone_number_masked,
login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.as_str().to_string(),
wechat_bound: user.wechat_bound,
wechat_display_name: user.wechat_display_name,
wechat_account: user.wechat_account,
}
}

View File

@@ -30,7 +30,7 @@ use shared_kernel::{
use spacetime_client::{
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
BarkBattleRunFinishRecordInput, BarkBattleRunRecord, BarkBattleRunStartRecordInput,
BarkBattleWorkPublishRecordInput, SpacetimeClientError,
BarkBattleWorkDeleteRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
};
use time::{Duration as TimeDuration, OffsetDateTime};
@@ -406,6 +406,38 @@ pub async fn list_bark_battle_works(
))
}
pub async fn delete_bark_battle_work(
State(state): State<AppState>,
Path(work_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &work_id, "workId")?;
let items = state
.spacetime_client()
.delete_bark_battle_work(BarkBattleWorkDeleteRecordInput {
work_id,
owner_user_id: authenticated.claims().user_id().to_string(),
})
.await
.map_err(|error| {
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
})?;
let items = items
.into_iter()
.map(|item| {
let author_display_name =
resolve_bark_battle_author_display_name_for_record(&state, &item);
map_work_summary_record(item, &request_context, author_display_name)
})
.collect::<Result<Vec<_>, _>>()?;
Ok(json_success_body(
Some(&request_context),
BarkBattleWorksResponse { items },
))
}
pub async fn list_bark_battle_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

View File

@@ -77,17 +77,13 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
{
return Some("puzzle");
}
if normalized.starts_with("/api/runtime/puzzle/gallery/")
&& normalized.ends_with("/remix")
{
if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") {
return Some("puzzle");
}
if normalized == "/api/runtime/big-fish/agent/sessions" {
return Some("big-fish");
}
if normalized.starts_with("/api/runtime/big-fish/gallery/")
&& normalized.ends_with("/remix")
{
if normalized.starts_with("/api/runtime/big-fish/gallery/") && normalized.ends_with("/remix") {
return Some("big-fish");
}
if normalized == "/api/runtime/custom-world/agent/sessions"
@@ -115,6 +111,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
if normalized == "/api/creation/jump-hop/sessions" {
return Some("jump-hop");
}
if normalized == "/api/creation/puzzle-clear/sessions" {
return Some("puzzle-clear");
}
if normalized == "/api/creation/visual-novel/sessions" {
return Some("visual-novel");
}
@@ -178,6 +177,10 @@ mod tests {
resolve_creation_entry_route_id("/api/runtime/puzzle/agent/sessions"),
Some("puzzle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/puzzle-clear/sessions"),
Some("puzzle-clear"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle/gallery/profile-1/remix"),
Some("puzzle"),
@@ -236,6 +239,10 @@ mod tests {
resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"),
Some("wooden-fish"),

View File

@@ -42,6 +42,10 @@ impl AppError {
&self.message
}
pub fn details(&self) -> Option<&Value> {
self.details.as_ref()
}
pub fn body_text(&self) -> String {
// 批处理任务不能只读 HTTP 状态文案,否则 DashScope 返回的真实失败原因会被压成“上游服务请求失败”。
self.details

View File

@@ -250,6 +250,36 @@ pub async fn get_jump_hop_work_detail(
))
}
pub async fn delete_jump_hop_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 works = state
.spacetime_client()
.delete_jump_hop_work(
profile_id,
authenticated.claims().user_id().to_string(),
)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
JumpHopWorksResponse {
items: works.into_iter().map(|work| work.summary).collect(),
},
))
}
pub async fn get_jump_hop_runtime_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -311,7 +341,10 @@ pub async fn start_jump_hop_run(
) -> Result<Json<Value>, Response> {
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
let is_draft_runtime = payload.runtime_mode.as_deref() == Some("draft");
let is_draft_runtime = payload
.runtime_mode
.as_deref()
.is_some_and(is_jump_hop_draft_runtime_mode);
let owner_user_id = principal.subject().to_string();
let principal_kind = principal.kind().as_str();
let run = state
@@ -1268,6 +1301,10 @@ fn build_jump_hop_work_play_tracking_draft(
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
}
fn is_jump_hop_draft_runtime_mode(runtime_mode: &str) -> bool {
runtime_mode.trim().eq_ignore_ascii_case("draft")
}
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
let theme_text = normalize_theme_text(&payload.theme_text, &payload.work_title);
JumpHopDraftResponse {
@@ -1450,6 +1487,14 @@ fn current_utc_micros() -> i64 {
mod tests {
use super::*;
#[test]
fn jump_hop_draft_runtime_mode_detection_matches_client_normalization() {
assert!(is_jump_hop_draft_runtime_mode("draft"));
assert!(is_jump_hop_draft_runtime_mode(" DRAFT "));
assert!(!is_jump_hop_draft_runtime_mode("published"));
assert!(!is_jump_hop_draft_runtime_mode(""));
}
#[test]
fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() {
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");

View File

@@ -64,6 +64,7 @@ mod prompt;
mod public_work;
mod puzzle;
mod puzzle_agent_turn;
mod puzzle_clear;
mod puzzle_gallery_cache;
mod refresh_session;
mod registration_reward;

View File

@@ -13,6 +13,7 @@ pub mod platform;
pub mod profile;
pub mod public_work;
pub mod puzzle;
pub mod puzzle_clear;
pub mod square_hole;
pub mod story;
pub mod wooden_fish;

View File

@@ -1,15 +1,15 @@
use axum::{
Router, middleware,
routing::{get, post},
routing::{delete, get, post},
};
use crate::{
auth::require_bearer_auth,
bark_battle::{
create_bark_battle_draft, finish_bark_battle_run, generate_bark_battle_image_asset,
get_bark_battle_run, get_bark_battle_runtime_config, list_bark_battle_gallery,
list_bark_battle_works, publish_bark_battle_work, start_bark_battle_run,
update_bark_battle_draft_config,
create_bark_battle_draft, delete_bark_battle_work, finish_bark_battle_run,
generate_bark_battle_image_asset, get_bark_battle_run, get_bark_battle_runtime_config,
list_bark_battle_gallery, list_bark_battle_works, publish_bark_battle_work,
start_bark_battle_run, update_bark_battle_draft_config,
},
state::AppState,
};
@@ -51,6 +51,13 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/runtime/bark-battle/works/{work_id}",
delete(delete_bark_battle_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/bark-battle/gallery",
get(list_bark_battle_gallery),

View File

@@ -1,16 +1,17 @@
use axum::{
Router, middleware,
middleware,
routing::{get, post},
Router,
};
use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
jump_hop::{
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
get_jump_hop_leaderboard, get_jump_hop_runtime_work, get_jump_hop_session,
get_jump_hop_work_detail,
jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work,
restart_jump_hop_run, start_jump_hop_run,
create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action,
get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump,
list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run,
start_jump_hop_run,
},
state::AppState,
};
@@ -47,10 +48,12 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route(
"/api/creation/jump-hop/works/{profile_id}",
get(get_jump_hop_work_detail).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
get(get_jump_hop_work_detail)
.delete(delete_jump_hop_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/jump-hop/works/{profile_id}/publish",

View File

@@ -0,0 +1,116 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
puzzle_clear::{
advance_puzzle_clear_next_level, create_puzzle_clear_session, execute_puzzle_clear_action,
get_puzzle_clear_gallery_detail, get_puzzle_clear_run, get_puzzle_clear_runtime_work,
get_puzzle_clear_session, get_puzzle_clear_work, list_puzzle_clear_gallery,
list_puzzle_clear_works, mark_puzzle_clear_level_time_up, publish_puzzle_clear_work,
retry_puzzle_clear_level, start_puzzle_clear_run, swap_puzzle_clear_cards,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/creation/puzzle-clear/sessions",
post(create_puzzle_clear_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/puzzle-clear/sessions/{session_id}",
get(get_puzzle_clear_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/puzzle-clear/sessions/{session_id}/actions",
post(execute_puzzle_clear_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/puzzle-clear/works",
get(list_puzzle_clear_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/puzzle-clear/works/{profile_id}",
get(get_puzzle_clear_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/puzzle-clear/works/{profile_id}/publish",
post(publish_puzzle_clear_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/works/{profile_id}",
get(get_puzzle_clear_runtime_work),
)
.route(
"/api/runtime/puzzle-clear/runs",
post(start_puzzle_clear_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/runs/{run_id}",
get(get_puzzle_clear_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/runs/{run_id}/swap",
post(swap_puzzle_clear_cards).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/runs/{run_id}/retry-level",
post(retry_puzzle_clear_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/runs/{run_id}/next-level",
post(advance_puzzle_clear_next_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/runs/{run_id}/time-up",
post(mark_puzzle_clear_level_time_up).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle-clear/gallery",
get(list_puzzle_clear_gallery),
)
.route(
"/api/runtime/puzzle-clear/gallery/{public_work_code}",
get(get_puzzle_clear_gallery_detail),
)
}

View File

@@ -1,16 +1,16 @@
use axum::{
Router, middleware,
routing::{get, post},
routing::{delete, get, post},
};
use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
state::AppState,
wooden_fish::{
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work,
get_wooden_fish_session, list_wooden_fish_gallery, list_wooden_fish_works,
publish_wooden_fish_work, start_wooden_fish_run,
checkpoint_wooden_fish_run, create_wooden_fish_session, delete_wooden_fish_work,
execute_wooden_fish_action, finish_wooden_fish_run, get_wooden_fish_gallery_detail,
get_wooden_fish_runtime_work, get_wooden_fish_session, list_wooden_fish_gallery,
list_wooden_fish_works, publish_wooden_fish_work, start_wooden_fish_run,
},
};
@@ -44,6 +44,13 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/creation/wooden-fish/works/{profile_id}",
delete(delete_wooden_fish_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/wooden-fish/works/{profile_id}/publish",
post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state(

View File

@@ -1,5 +1,6 @@
use std::{
collections::BTreeMap,
collections::{BTreeMap, HashSet},
sync::{Mutex, OnceLock},
time::{Instant, SystemTime, UNIX_EPOCH},
};
@@ -130,6 +131,73 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
static PUZZLE_BACKGROUND_COMPILE_TASKS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
fn puzzle_background_compile_tasks() -> &'static Mutex<HashSet<String>> {
PUZZLE_BACKGROUND_COMPILE_TASKS.get_or_init(|| Mutex::new(HashSet::new()))
}
fn try_register_puzzle_background_compile_task(session_id: &str) -> bool {
match puzzle_background_compile_tasks().lock() {
Ok(mut tasks) => tasks.insert(session_id.to_string()),
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
error = %error,
"拼图后台生成任务注册表锁已损坏,允许本次任务继续"
);
true
}
}
}
fn unregister_puzzle_background_compile_task(session_id: &str) {
match puzzle_background_compile_tasks().lock() {
Ok(mut tasks) => {
tasks.remove(session_id);
}
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
error = %error,
"拼图后台生成任务注册表解锁失败,忽略清理"
);
}
}
}
fn has_puzzle_cover_image_src(value: &Option<String>) -> bool {
value
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
fn mark_puzzle_initial_generation_started_snapshot(
mut session: PuzzleAgentSessionRecord,
) -> PuzzleAgentSessionRecord {
session.stage = "image_refining".to_string();
session.progress_percent = session.progress_percent.max(88);
if let Some(draft) = session.draft.as_mut() {
let draft_needs_cover = !has_puzzle_cover_image_src(&draft.cover_image_src);
if let Some(primary_level) = draft.levels.first_mut() {
if !has_puzzle_cover_image_src(&primary_level.cover_image_src) {
primary_level.generation_status = "generating".to_string();
}
draft.generation_status = primary_level.generation_status.clone();
draft.candidates = primary_level.candidates.clone();
draft.selected_candidate_id = primary_level.selected_candidate_id.clone();
draft.cover_image_src = primary_level.cover_image_src.clone();
draft.cover_asset_id = primary_level.cover_asset_id.clone();
} else if draft_needs_cover {
draft.generation_status = "generating".to_string();
}
}
session
}
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
}

View File

@@ -1177,21 +1177,16 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
.or_else(|| levels.first())
}
pub(crate) async fn compile_puzzle_draft_with_initial_cover(
pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
state: &PuzzleApiState,
request_context: &RequestContext,
session_id: String,
compiled_session: PuzzleAgentSessionRecord,
owner_user_id: String,
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
image_model: Option<&str>,
now: i64,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
.await
.map_err(map_puzzle_compile_error)?;
let draft = compiled_session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -1419,7 +1414,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
match state
.spacetime_client()
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
session_id,
session_id: compiled_session.session_id.clone(),
owner_user_id,
level_id: Some(target_level.level_id),
candidate_id: selected_candidate_id,

View File

@@ -623,7 +623,7 @@ pub async fn execute_puzzle_agent_action(
session_id,
owner_user_id,
error_message,
failed_at_micros: now,
failed_at_micros: current_utc_micros(),
})
.await;
if let Err(error) = result {
@@ -668,27 +668,128 @@ pub async fn execute_puzzle_agent_action(
Err(response) => return Err(response),
};
let session = if ai_redraw {
execute_billable_asset_operation_with_cost(
state.root_state(),
&owner_user_id,
"puzzle_initial_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
compile_puzzle_draft_with_initial_cover(
&state,
&request_context,
if !try_register_puzzle_background_compile_task(&compile_session_id) {
tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %compile_session_id,
owner_user_id = %owner_user_id,
"拼图首图后台生成任务已存在,本次 action 直接返回生成中会话"
);
state
.spacetime_client()
.get_puzzle_agent_session(
compile_session_id.clone(),
owner_user_id.clone(),
)
.await
.map(mark_puzzle_initial_generation_started_snapshot)
.map_err(map_puzzle_client_error)
} else {
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft(
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
primary_reference_image_src,
payload.image_model.as_deref(),
now,
)
.await
},
)
.await
.map_err(map_puzzle_compile_error);
match compiled_session {
Ok(compiled_session) => {
let response_session =
mark_puzzle_initial_generation_started_snapshot(
compiled_session.clone(),
);
let background_state = state.clone();
let background_request_context = request_context.clone();
let background_session_id = compile_session_id.clone();
let background_owner_user_id = owner_user_id.clone();
let background_prompt_text = prompt_text.map(str::to_string);
let background_reference_image_src =
primary_reference_image_src.map(str::to_string);
let background_image_model = payload.image_model.clone();
let background_billing_asset_id =
format!("{background_session_id}:compile_puzzle_draft");
tokio::spawn(async move {
let operation_owner_user_id =
background_owner_user_id.clone();
let background_root_state =
background_state.root_state().clone();
let operation_state = background_state.clone();
let result = execute_billable_asset_operation_with_cost(
&background_root_state,
&background_owner_user_id,
"puzzle_initial_image",
&background_billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async move {
generate_puzzle_initial_cover_from_compiled_session(
&operation_state,
&background_request_context,
compiled_session,
operation_owner_user_id,
background_prompt_text.as_deref(),
background_reference_image_src.as_deref(),
background_image_model.as_deref(),
current_utc_micros(),
)
.await
},
)
.await;
match result {
Ok(session) => {
tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
owner_user_id = %background_owner_user_id,
"拼图首图后台生成任务完成"
);
}
Err(error) => {
let error_message = error.body_text();
let failure_result = background_state
.spacetime_client()
.mark_puzzle_draft_generation_failed(
PuzzleDraftCompileFailureRecordInput {
session_id: background_session_id.clone(),
owner_user_id: background_owner_user_id
.clone(),
error_message: error_message.clone(),
failed_at_micros: current_utc_micros(),
},
)
.await;
if let Err(mark_error) = failure_result {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %background_session_id,
owner_user_id = %background_owner_user_id,
message = %mark_error,
"拼图首图后台生成失败态回写失败"
);
}
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %background_session_id,
owner_user_id = %background_owner_user_id,
message = %error_message,
"拼图首图后台生成任务失败"
);
}
}
unregister_puzzle_background_compile_task(
&background_session_id,
);
});
Ok(response_session)
}
Err(error) => {
unregister_puzzle_background_compile_task(&compile_session_id);
Err(error)
}
}
}
} else {
compile_puzzle_draft_with_uploaded_cover(
&state,
@@ -716,7 +817,7 @@ pub async fn execute_puzzle_agent_action(
"compile_puzzle_draft",
"首关拼图草稿",
if ai_redraw {
"已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿"
"已编译首关草稿,并启动首关画面和 UI 资产后台生成"
} else {
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
},

View File

@@ -980,6 +980,41 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
);
}
#[test]
fn puzzle_compile_started_snapshot_marks_primary_level_generating() {
let mut session = PuzzleAgentSessionRecord {
session_id: "puzzle-session-1".to_string(),
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
current_turn: 1,
progress_percent: 88,
stage: "draft_ready".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.generation_status = "idle".to_string();
draft.levels[0].generation_status = "idle".to_string();
draft.levels[0].cover_image_src = None;
draft.levels[0].cover_asset_id = None;
}
let session = mark_puzzle_initial_generation_started_snapshot(session);
let draft = session.draft.expect("draft");
assert_eq!(session.stage, "image_refining");
assert_eq!(draft.generation_status, "generating");
assert_eq!(draft.levels[0].generation_status, "generating");
assert!(draft.cover_image_src.is_none());
assert!(draft.levels[0].cover_image_src.is_none());
}
#[test]
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");

File diff suppressed because it is too large Load Diff

View File

@@ -229,6 +229,33 @@ pub async fn list_wooden_fish_works(
))
}
pub async fn delete_wooden_fish_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 works = state
.spacetime_client()
.delete_wooden_fish_work(profile_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
wooden_fish_error_response(
&request_context,
WOODEN_FISH_CREATION_PROVIDER,
map_wooden_fish_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
WoodenFishWorksResponse {
items: works.into_iter().map(|work| work.summary).collect(),
},
))
}
pub async fn get_wooden_fish_runtime_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -1364,6 +1391,7 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use super::*;
use crate::AppConfig;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
#[test]
@@ -1535,8 +1563,7 @@ mod tests {
#[tokio::test]
async fn wooden_fish_draft_uses_default_hit_sound_asset_and_ignores_prompt() {
let state = crate::state::AppState::new(crate::config::AppConfig::default())
.expect("state should build");
let state = AppState::new(AppConfig::default()).expect("state should build");
let payload = WoodenFishWorkspaceCreateRequest {
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
work_title: "今日敲木鱼".to_string(),