扩展外部生成Worker队列

新增外部生成队列概览和单任务状态契约

将跳一跳、拼消消、敲木鱼图片生成动作接入worker队列

前端生成等待页展示当前任务和队列数量

更新外部生成worker运维文档和团队决策记录
This commit is contained in:
2026-06-12 23:15:55 +08:00
parent 3bccfd1a83
commit 951caac32d
43 changed files with 1913 additions and 67 deletions

View File

@@ -44,6 +44,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::profile::router(state.clone()))
.merge(modules::assets::router(state.clone()))
.merge(modules::platform::router(state.clone()))
.merge(modules::external_generation::router(state.clone()))
.merge(modules::play_flow::router(state.clone()))
.route(
"/api/profile/recharge/wechat/notify",

View File

@@ -0,0 +1,108 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::Response,
};
use serde_json::json;
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverview,
ExternalGenerationQueueOverviewResponse,
};
use spacetime_client::{
ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord,
ExternalGenerationQueueStatsRecord, SpacetimeClientError,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
const EXTERNAL_GENERATION_PROVIDER: &str = "external_generation";
pub async fn get_external_generation_queue_overview(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<serde_json::Value>, Response> {
let stats = state
.spacetime_client()
.get_external_generation_queue_stats()
.await
.map_err(|error| external_generation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
ExternalGenerationQueueOverviewResponse {
overview: map_external_generation_queue_overview(stats),
},
))
}
pub async fn get_external_generation_job_status(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(job_id): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let job = state
.spacetime_client()
.get_external_generation_job(ExternalGenerationJobGetRecordInput {
job_id,
owner_user_id,
})
.await
.map_err(|error| external_generation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
ExternalGenerationJobStatusResponse {
job: map_external_generation_job_status(job),
},
))
}
fn map_external_generation_queue_overview(
stats: ExternalGenerationQueueStatsRecord,
) -> ExternalGenerationQueueOverview {
ExternalGenerationQueueOverview {
pending_count: stats.pending_count,
running_count: stats.running_active_count,
updated_at_micros: stats.now_micros,
}
}
fn map_external_generation_job_status(
job: ExternalGenerationJobRecord,
) -> ExternalGenerationJobStatusRecord {
let (status, phase_detail, progress) = match job.status.as_str() {
"completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100),
"running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35),
"failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0),
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8),
};
ExternalGenerationJobStatusRecord {
operation_id: job.job_id,
status,
phase_label: job.request_label,
phase_detail: phase_detail.to_string(),
progress,
error: job.last_error_message,
updated_at_micros: job.updated_at_micros,
}
}
fn external_generation_error_response(
request_context: &RequestContext,
error: SpacetimeClientError,
) -> Response {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": EXTERNAL_GENERATION_PROVIDER,
"message": error.to_string(),
}))
.into_response_with_context(Some(request_context))
}

View File

@@ -15,14 +15,26 @@ use tokio::{
use tracing::{error, info, warn};
use crate::{
jump_hop::{
JUMP_HOP_COMPILE_DRAFT_JOB_KIND, JumpHopCompileDraftWorkerPayload,
execute_jump_hop_compile_draft_worker_job,
},
puzzle::{
ExternalGenerationWriteLeaseGuard, PuzzleCompileDraftWorkerPayload,
PuzzleGenerateImagesWorkerPayload, PuzzleGenerateUiBackgroundWorkerPayload,
execute_puzzle_compile_draft_worker_job, execute_puzzle_generate_images_worker_job,
execute_puzzle_generate_ui_background_worker_job, release_puzzle_compile_background_claim,
},
puzzle_clear::{
PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND, PuzzleClearCompileDraftWorkerPayload,
execute_puzzle_clear_compile_draft_worker_job,
},
request_context::RequestContext,
state::{AppState, PuzzleApiState},
wooden_fish::{
WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND, WoodenFishGenerateImageAssetsWorkerPayload,
execute_wooden_fish_generate_image_assets_worker_job,
},
};
pub(crate) const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft";
@@ -395,6 +407,135 @@ async fn process_external_generation_job_once(
}
}
}
JUMP_HOP_COMPILE_DRAFT_JOB_KIND => {
let payload = match serde_json::from_str::<JumpHopCompileDraftWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("跳一跳生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
match execute_jump_hop_compile_draft_worker_job(&state, &request_context, payload).await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"status": session.status,
})
.to_string(),
),
)
.await
}
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND => {
let payload = match serde_json::from_str::<PuzzleClearCompileDraftWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("拼消消生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
match execute_puzzle_clear_compile_draft_worker_job(&state, &request_context, payload)
.await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"status": session.status,
})
.to_string(),
),
)
.await
}
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND => {
let payload = match serde_json::from_str::<WoodenFishGenerateImageAssetsWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("敲木鱼图片生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
match execute_wooden_fish_generate_image_assets_worker_job(
&state,
&request_context,
payload,
)
.await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"status": session.status,
})
.to_string(),
),
)
.await
}
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
unknown => {
warn!(
job_id = job.job_id,
@@ -412,6 +553,32 @@ async fn process_external_generation_job_once(
}
}
async fn response_error_message(response: axum::response::Response) -> String {
use axum::body::to_bytes;
let status = response.status();
let body_bytes = match to_bytes(response.into_body(), 64 * 1024).await {
Ok(bytes) => bytes,
Err(error) => {
return format!("外部生成任务失败:{status},响应读取失败:{error}");
}
};
let body_text = String::from_utf8_lossy(&body_bytes).trim().to_string();
if body_text.is_empty() {
return format!("外部生成任务失败:{status}");
}
if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(&body_text)
&& let Some(message) = body_json
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
{
return message.to_string();
}
body_text
}
async fn fail_queue_job_after_worker_error(
state: &AppState,
worker_id: &str,

View File

@@ -9,7 +9,11 @@ use module_assets::{
generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
@@ -20,7 +24,9 @@ use shared_contracts::jump_hop::{
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
};
use std::{
collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH},
@@ -49,6 +55,7 @@ use crate::{
};
const JUMP_HOP_TILE_ITEM_COUNT: usize = 18;
pub(crate) const JUMP_HOP_COMPILE_DRAFT_JOB_KIND: &str = "jump_hop_compile_draft";
const JUMP_HOP_PROVIDER: &str = "jump-hop";
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
@@ -72,6 +79,14 @@ const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024";
const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024;
const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct JumpHopCompileDraftWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub payload: JumpHopActionRequest,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct JumpHopTileAtlasSlice {
tile_type: JumpHopTileType,
@@ -174,6 +189,37 @@ pub async fn execute_jump_hop_action(
let owner_user_id = authenticated.claims().user_id().to_string();
let mut payload = payload;
let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft);
let should_queue_generation = matches!(
payload.action_type,
JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles
) && !state.config.external_generation_mode.is_inline();
if should_queue_generation {
let mut queued_response = state
.spacetime_client()
.mark_jump_hop_generation_queued(
session_id.clone(),
owner_user_id.clone(),
payload.clone(),
)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
let queue_job = enqueue_jump_hop_compile_draft_job(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
payload,
)
.await?;
queued_response.queue_state = Some(map_jump_hop_queue_job_status(queue_job));
return Ok(json_success_body(Some(&request_context), queued_response));
}
let generation_points_cost = if is_compile_draft {
resolve_jump_hop_generation_points_cost(&state).await
} else {
@@ -246,6 +292,99 @@ pub async fn execute_jump_hop_action(
}
}
async fn enqueue_jump_hop_compile_draft_job(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
payload: JumpHopActionRequest,
) -> Result<ExternalGenerationJobRecord, Response> {
let job_id = build_prefixed_uuid_id("extgen-");
let now_micros = current_utc_micros();
let request_payload_json = serde_json::to_string(&JumpHopCompileDraftWorkerPayload {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
payload,
})
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"message": format!("跳一跳 worker 任务参数序列化失败:{error}"),
})),
)
})?;
state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
dedupe_key: format!("jump-hop:compile-draft:{session_id}:{job_id}"),
job_id,
job_kind: JUMP_HOP_COMPILE_DRAFT_JOB_KIND.to_string(),
owner_user_id: owner_user_id.to_string(),
source_module: "jump-hop".to_string(),
source_entity_id: session_id.to_string(),
request_label: "跳一跳草稿生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now_micros,
created_at_micros: now_micros,
})
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})
}
fn map_jump_hop_queue_job_status(
job: ExternalGenerationJobRecord,
) -> ExternalGenerationJobStatusRecord {
ExternalGenerationJobStatusRecord {
operation_id: job.job_id,
status: ExternalGenerationJobStatus::Queued,
phase_label: job.request_label,
phase_detail: "排队中。".to_string(),
progress: 8,
error: job.last_error_message,
updated_at_micros: job.updated_at_micros,
}
}
pub(crate) async fn execute_jump_hop_compile_draft_worker_job(
state: &AppState,
request_context: &RequestContext,
mut worker_payload: JumpHopCompileDraftWorkerPayload,
) -> Result<JumpHopSessionSnapshotResponse, Response> {
maybe_generate_jump_hop_assets(
state,
request_context,
worker_payload.session_id.as_str(),
worker_payload.owner_user_id.as_str(),
&mut worker_payload.payload,
)
.await?;
let response = state
.spacetime_client()
.execute_jump_hop_action(
worker_payload.session_id,
worker_payload.owner_user_id,
worker_payload.payload,
)
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(response.session)
}
async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,
@@ -1005,15 +1144,8 @@ fn slice_jump_hop_tile_atlas(
let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
let tile_width = x1.saturating_sub(x0).max(1);
let tile_height = y1.saturating_sub(y0).max(1);
let faces = slice_jump_hop_tile_uv_faces(
&source,
x0,
y0,
tile_width,
tile_height,
row,
col,
)?;
let faces =
slice_jump_hop_tile_uv_faces(&source, x0, y0, tile_width, tile_height, row, col)?;
slices.push(JumpHopTileAtlasSlice {
tile_type: jump_hop_tile_type_by_index(index),
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
@@ -1043,22 +1175,70 @@ fn slice_jump_hop_tile_uv_faces(
Ok(JumpHopTileFaceSlices {
top: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Top,
1,
0,
)?,
front: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Front,
1,
1,
)?,
right: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Right,
2,
1,
)?,
back: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Back,
3,
1,
)?,
left: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Left,
0,
1,
)?,
bottom: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Bottom,
1,
2,
)?,
})
}
@@ -1095,12 +1275,7 @@ fn slice_jump_hop_tile_uv_face(
Ok(JumpHopTileFaceSlice {
face,
source_atlas_cell: format!(
"row-{}-col-{}/{}",
atlas_row + 1,
atlas_col + 1,
face_label
),
source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, face_label),
bytes: cursor.into_inner(),
})
}
@@ -1827,7 +2002,9 @@ mod tests {
assert!(prompt.contains("18个用于跳一跳地板的立方体主题物体 UV 展开包装图"));
assert!(prompt.contains("按三列六行均匀排布"));
assert!(prompt.contains("每个大单元格代表一个完整的 1x1x1 立方体方块物体"));
assert!(prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上"));
assert!(
prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上")
);
assert!(prompt.contains("cube object UV unwrap atlas / 立方体主题物体六面展开图集"));
assert!(prompt.contains("不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满"));
assert!(prompt.contains("游戏界面或图标集页面"));
@@ -1850,7 +2027,9 @@ mod tests {
assert!(prompt.contains("full-bleed opaque square face texture"));
assert!(prompt.contains("四角、边缘和中心都要有可识别内容"));
assert!(prompt.contains("不留透明、不留空白、不留实底背景"));
assert!(prompt.contains("允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点"));
assert!(prompt.contains(
"允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点"
));
assert!(prompt.contains("不要把一个小水果、小叶片、小石头或小物体放在面中央"));
assert!(prompt.contains("这不是透视渲染图"));
assert!(prompt.contains("不要画摄像机视角、透视块、已烘焙侧壁"));
@@ -1868,14 +2047,18 @@ mod tests {
assert!(prompt.contains("小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理"));
assert!(prompt.contains("English guardrail"));
assert!(prompt.contains("one vertical 1024x1536 image"));
assert!(prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas"));
assert!(
prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas")
);
assert!(prompt.contains("row1 col2 top"));
assert!(prompt.contains("row2 col1 left"));
assert!(prompt.contains("row2 col2 front"));
assert!(prompt.contains("row2 col3 right"));
assert!(prompt.contains("row2 col4 back"));
assert!(prompt.contains("row3 col2 bottom"));
assert!(prompt.contains("six different face textures that stitch into one recognizable cubified theme object"));
assert!(prompt.contains(
"six different face textures that stitch into one recognizable cubified theme object"
));
assert!(prompt.contains("no generic flat material"));
assert!(prompt.contains("no small centered stickers"));
assert!(prompt.contains("every face is full-bleed opaque square texture"));
@@ -2022,7 +2205,9 @@ mod tests {
"科幻芯片主题的俯视角清爽游戏化立体感平台素材",
);
assert!(prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图"));
assert!(
prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")
);
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材"));
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角"));
@@ -2118,12 +2303,10 @@ mod tests {
.max(1);
let tile_x = atlas_col.saturating_mul(cell_width);
let tile_y = atlas_row.saturating_mul(cell_height);
let uv_x = tile_x.saturating_add(
cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2,
);
let uv_y = tile_y.saturating_add(
cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2,
);
let uv_x = tile_x
.saturating_add(cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2);
let uv_y = tile_y
.saturating_add(cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2);
for y in uv_y + face_row * face_side..uv_y + (face_row + 1) * face_side {
for x in uv_x + face_col * face_side..uv_x + (face_col + 1) * face_side {
atlas.put_pixel(x, y, color);
@@ -2159,14 +2342,8 @@ mod tests {
),
"{message}"
);
assert!(
decoded.pixels().any(|pixel| pixel.0 == color),
"{message}"
);
assert!(
decoded.pixels().all(|pixel| pixel.0[3] == 255),
"{message}"
);
assert!(decoded.pixels().any(|pixel| pixel.0 == color), "{message}");
assert!(decoded.pixels().all(|pixel| pixel.0[3] == 255), "{message}");
}
#[test]

View File

@@ -40,6 +40,7 @@ mod edutainment_baby_drawing;
mod edutainment_baby_object;
mod error_middleware;
mod external_api_audit;
mod external_generation;
mod external_generation_worker;
mod external_generation_worker_controller;
pub(crate) mod generated_asset_sheets;

View File

@@ -5,6 +5,7 @@ pub mod bark_battle;
pub mod big_fish;
pub mod custom_world;
pub mod edutainment;
pub mod external_generation;
pub mod health;
pub mod internal;
pub mod jump_hop;

View File

@@ -0,0 +1,26 @@
use axum::{Router, middleware, routing::get};
use crate::{
auth::require_bearer_auth,
external_generation::{
get_external_generation_job_status, get_external_generation_queue_overview,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/runtime/external-generation/queue-overview",
get(get_external_generation_queue_overview).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/external-generation/jobs/{job_id}",
get(get_external_generation_job_status).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -11,7 +11,11 @@ use module_assets::{
generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_contracts::puzzle_clear::{
PuzzleClearActionRequest, PuzzleClearActionType, PuzzleClearCardAsset,
PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset,
@@ -22,7 +26,9 @@ use shared_contracts::puzzle_clear::{
PuzzleClearWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
};
use std::{
collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH},
@@ -51,6 +57,7 @@ const PUZZLE_CLEAR_CREATION_PROVIDER: &str = "puzzle-clear-creation";
const PUZZLE_CLEAR_RUNTIME_PROVIDER: &str = "puzzle-clear-runtime";
const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear";
const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消";
pub(crate) const PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_clear_compile_draft";
const PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/puzzle-clear/runs";
const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256;
const PUZZLE_CLEAR_SHEET_COLUMNS: u32 = 4;
@@ -76,6 +83,15 @@ const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD: f32 = 145.0;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0;
const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插";
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PuzzleClearCompileDraftWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub author_display_name: String,
pub payload: PuzzleClearActionRequest,
}
pub async fn create_puzzle_clear_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -160,6 +176,39 @@ pub async fn execute_puzzle_clear_action(
.unwrap_or("拼消消玩家")
.to_string();
let mut payload = payload;
let should_queue_generation = matches!(
payload.action_type,
PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas
) && !state.config.external_generation_mode.is_inline();
if should_queue_generation {
let mut queued_response = state
.spacetime_client()
.mark_puzzle_clear_generation_queued(
session_id.clone(),
owner_user_id.clone(),
author_display_name.clone(),
payload.clone(),
)
.await
.map_err(|error| {
puzzle_clear_error_response(
&request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
let queue_job = enqueue_puzzle_clear_compile_draft_job(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
author_display_name.as_str(),
payload,
)
.await?;
queued_response.queue_state = Some(map_puzzle_clear_queue_job_status(queue_job));
return Ok(json_success_body(Some(&request_context), queued_response));
}
if let Err(response) = maybe_prepare_puzzle_clear_assets_inner(
&state,
&request_context,
@@ -210,6 +259,129 @@ pub async fn execute_puzzle_clear_action(
Ok(json_success_body(Some(&request_context), response))
}
async fn enqueue_puzzle_clear_compile_draft_job(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
author_display_name: &str,
payload: PuzzleClearActionRequest,
) -> Result<ExternalGenerationJobRecord, Response> {
let job_id = build_prefixed_uuid_id("extgen-");
let now_micros = current_utc_micros();
let request_payload_json = serde_json::to_string(&PuzzleClearCompileDraftWorkerPayload {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
author_display_name: author_display_name.to_string(),
payload,
})
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"message": format!("拼消消 worker 任务参数序列化失败:{error}"),
})),
)
})?;
state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
dedupe_key: format!("puzzle-clear:compile-draft:{session_id}:{job_id}"),
job_id,
job_kind: PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND.to_string(),
owner_user_id: owner_user_id.to_string(),
source_module: "puzzle-clear".to_string(),
source_entity_id: session_id.to_string(),
request_label: "拼消消草稿生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now_micros,
created_at_micros: now_micros,
})
.await
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})
}
fn map_puzzle_clear_queue_job_status(
job: ExternalGenerationJobRecord,
) -> ExternalGenerationJobStatusRecord {
ExternalGenerationJobStatusRecord {
operation_id: job.job_id,
status: ExternalGenerationJobStatus::Queued,
phase_label: job.request_label,
phase_detail: "排队中。".to_string(),
progress: 8,
error: job.last_error_message,
updated_at_micros: job.updated_at_micros,
}
}
pub(crate) async fn execute_puzzle_clear_compile_draft_worker_job(
state: &AppState,
request_context: &RequestContext,
mut worker_payload: PuzzleClearCompileDraftWorkerPayload,
) -> Result<PuzzleClearSessionSnapshotResponse, Response> {
if let Err(response) = maybe_prepare_puzzle_clear_assets_inner(
state,
request_context,
worker_payload.session_id.as_str(),
worker_payload.owner_user_id.as_str(),
&mut worker_payload.payload,
)
.await
{
let (error_message, response) = extract_puzzle_clear_response_error_message(response).await;
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id = worker_payload.session_id,
error = %error_message,
"拼消消 worker 素材生成失败,准备回写 failed 状态"
);
if let Err(writeback_error) = state
.spacetime_client()
.mark_puzzle_clear_generation_failed(
worker_payload.session_id.clone(),
worker_payload.owner_user_id.clone(),
worker_payload.author_display_name.clone(),
worker_payload.payload.clone(),
)
.await
{
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id = worker_payload.session_id,
error = %writeback_error,
"拼消消 worker 失败状态回写失败"
);
}
return Err(response);
}
let response = state
.spacetime_client()
.execute_puzzle_clear_action(
worker_payload.session_id,
worker_payload.owner_user_id,
worker_payload.author_display_name,
worker_payload.payload,
)
.await
.map_err(|error| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
map_puzzle_clear_client_error(error),
)
})?;
Ok(response.session)
}
pub async fn list_puzzle_clear_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

View File

@@ -14,7 +14,11 @@ use module_assets::{
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_contracts::wooden_fish::{
WoodenFishActionRequest, WoodenFishAudioAsset, WoodenFishCheckpointRunRequest,
WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse,
@@ -24,7 +28,9 @@ use shared_contracts::wooden_fish::{
WoodenFishWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
};
use crate::generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
@@ -54,6 +60,8 @@ const WOODEN_FISH_CREATION_PROVIDER: &str = "wooden-fish-creation";
const WOODEN_FISH_RUNTIME_PROVIDER: &str = "wooden-fish-runtime";
const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish";
const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼";
pub(crate) const WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND: &str =
"wooden_fish_generate_image_assets";
const DEFAULT_HIT_OBJECT_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景";
const DEFAULT_HIT_OBJECT_ASSET_ID: &str = "wooden-fish-default-hit-object";
const DEFAULT_HIT_OBJECT_IMAGE_SRC: &str = "/wooden-fish/default-hit-object.png";
@@ -73,6 +81,15 @@ const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!(
));
const WOODEN_FISH_AUTHOR_FALLBACK_DISPLAY_NAME: &str = "玩家";
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WoodenFishGenerateImageAssetsWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub author_display_name: String,
pub payload: WoodenFishActionRequest,
}
pub async fn create_wooden_fish_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -155,6 +172,40 @@ pub async fn execute_wooden_fish_action(
payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
);
let should_queue_generation = matches!(
payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
| shared_contracts::wooden_fish::WoodenFishActionType::RegenerateHitObject
) && !state.config.external_generation_mode.is_inline();
if should_queue_generation {
let mut queued_response = state
.spacetime_client()
.mark_wooden_fish_generation_queued(
session_id.clone(),
owner_user_id.clone(),
author_display_name.clone(),
payload.clone(),
)
.await
.map_err(|error| {
wooden_fish_error_response(
&request_context,
WOODEN_FISH_CREATION_PROVIDER,
map_wooden_fish_client_error(error),
)
})?;
let queue_job = enqueue_wooden_fish_generate_image_assets_job(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
author_display_name.as_str(),
payload,
)
.await?;
queued_response.queue_state = Some(map_wooden_fish_queue_job_status(queue_job));
return Ok(json_success_body(Some(&request_context), queued_response));
}
let generation_points_cost = if is_compile_draft {
resolve_wooden_fish_generation_points_cost(&state).await
} else {
@@ -226,6 +277,70 @@ pub async fn execute_wooden_fish_action(
Ok(json_success_body(Some(&request_context), response))
}
async fn enqueue_wooden_fish_generate_image_assets_job(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
author_display_name: &str,
payload: WoodenFishActionRequest,
) -> Result<ExternalGenerationJobRecord, Response> {
let job_id = build_prefixed_uuid_id("extgen-");
let now_micros = current_utc_micros();
let request_payload_json = serde_json::to_string(&WoodenFishGenerateImageAssetsWorkerPayload {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
author_display_name: author_display_name.to_string(),
payload,
})
.map_err(|error| {
wooden_fish_error_response(
request_context,
WOODEN_FISH_CREATION_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"message": format!("敲木鱼 worker 任务参数序列化失败:{error}"),
})),
)
})?;
state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
dedupe_key: format!("wooden-fish:generate-image-assets:{session_id}:{job_id}"),
job_id,
job_kind: WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND.to_string(),
owner_user_id: owner_user_id.to_string(),
source_module: "wooden-fish".to_string(),
source_entity_id: session_id.to_string(),
request_label: "敲木鱼图片素材生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now_micros,
created_at_micros: now_micros,
})
.await
.map_err(|error| {
wooden_fish_error_response(
request_context,
WOODEN_FISH_CREATION_PROVIDER,
map_wooden_fish_client_error(error),
)
})
}
fn map_wooden_fish_queue_job_status(
job: ExternalGenerationJobRecord,
) -> ExternalGenerationJobStatusRecord {
ExternalGenerationJobStatusRecord {
operation_id: job.job_id,
status: ExternalGenerationJobStatus::Queued,
phase_label: job.request_label,
phase_detail: "排队中。".to_string(),
progress: 8,
error: job.last_error_message,
updated_at_micros: job.updated_at_micros,
}
}
pub async fn publish_wooden_fish_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -635,6 +750,40 @@ async fn execute_wooden_fish_action_with_generated_assets(
})
}
pub(crate) async fn execute_wooden_fish_generate_image_assets_worker_job(
state: &AppState,
request_context: &RequestContext,
mut worker_payload: WoodenFishGenerateImageAssetsWorkerPayload,
) -> Result<WoodenFishSessionSnapshotResponse, Response> {
let result = execute_wooden_fish_action_with_generated_assets(
state,
request_context,
worker_payload.session_id.as_str(),
worker_payload.owner_user_id.as_str(),
worker_payload.author_display_name.as_str(),
&mut worker_payload.payload,
)
.await;
if result.as_ref().err().is_some_and(|response| {
response.status().is_server_error()
&& matches!(
worker_payload.payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
)
}) {
mark_wooden_fish_generation_failed(
state,
request_context,
worker_payload.session_id.as_str(),
worker_payload.owner_user_id.as_str(),
worker_payload.author_display_name.as_str(),
)
.await;
}
let response = result?;
Ok(response.session)
}
async fn resolve_wooden_fish_generation_points_cost(state: &AppState) -> u64 {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state,