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:
@@ -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 }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("森林冒险", "森林主题清爽游戏化立体感平台");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
116
server-rs/crates/api-server/src/modules/puzzle_clear.rs
Normal file
116
server-rs/crates/api-server/src/modules/puzzle_clear.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 背景为第一关图片。"
|
||||
},
|
||||
|
||||
@@ -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("雨夜猫街", "雨夜猫街主题背景");
|
||||
|
||||
2626
server-rs/crates/api-server/src/puzzle_clear.rs
Normal file
2626
server-rs/crates/api-server/src/puzzle_clear.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user