扩展外部生成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
+8
View File
@@ -16,6 +16,14 @@
---
## 2026-06-12 外部生成 worker 扩展到跳一跳、拼消消和敲木鱼
- 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。
- 决策:队列 BFF 暴露用户可见队列概览 `GET /api/runtime/external-generation/queue-overview` 和单 job 状态 `GET /api/runtime/external-generation/jobs/{jobId}`;首版固定“单动作单 job”,不拆提示词 / 生图 / 切图 / 持久化等阶段 job。进入队列的范围为跳一跳 `compile-draft` / `regenerate-tiles`、拼消消 `compile-draft` / `regenerate-atlas`、敲木鱼 `compile-draft` / `regenerate-hit-object` 图片资产动作;非外部图片生成动作继续 inline。
- 影响范围:外部生成 worker Module、api-server BFF、生成页等待展示、跳一跳 / 拼消消 / 敲木鱼创作与结果页生成动作、本地和生产验证文档。
- 验证方式:本地 `npm run dev` 默认保留 inline 开发体验;验证 worker 队列、等待展示、lease 或扩缩容时显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 并启动 worker,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz``/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板
- 背景:release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。
@@ -1,6 +1,6 @@
# 外部生成 Worker 化方案
更新时间:`2026-06-07`
更新时间:`2026-06-12`
## 背景
@@ -13,7 +13,9 @@
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。
- SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。
- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`;后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。
- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`本轮扩展到跳一跳、拼消消和敲木鱼的外部图片生成动作。后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。
- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。
- 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`
## Module 与 Interface
@@ -25,9 +27,19 @@
- `complete_external_generation_job_and_return`worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`
- `fail_external_generation_job_and_return`worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`
- `get_external_generation_queue_stats_and_return`controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。
- `get_external_generation_job_and_return`:按 `job_id` 读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。
这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。
## BFF 状态接口
队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table
- `GET /api/runtime/external-generation/queue-overview`:队列概览,用于生成页、调试面板或后台观测当前用户可见的等待状态。返回 pending / running / completed / failed / cancelled 数量、最早等待时间、当前可见 job 摘要,以及是否存在过期 lease 需要等待 worker 重领。
- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId``jobKind``sourceModule``sourceEntityId``status``attempt``maxAttempts``createdAt``startedAt``completedAt``updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。
BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页展示“排队中 / 处理中 / 失败 / 完成”时,应优先用单 job 状态补充等待信息,再继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。
## 任务表
新增私有表 `external_generation_job`
@@ -107,6 +119,8 @@ controller 配置:
## 已接入的拼图纵切
### 拼图
`compile_puzzle_draft`
1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。
@@ -129,7 +143,23 @@ controller 配置:
2. worker 执行原结果页 UI 背景链路:归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。
3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job,让前端轮询能收敛。
Match3D、Wooden Fish、Visual Novel 音频等后续外部生成 action 按同一模式迁移。
### 跳一跳、拼消消和敲木鱼扩展范围
以下动作按同一 worker 模式迁移。命名以现有玩法 action 为准,队列 `job_kind` 采用后端稳定 snake_case,不新增平行队列:
- 跳一跳 `jump-hop`
- `compile-draft`:草稿编译阶段需要生成地块 / 视觉资产时入队,例如 `jump_hop_compile_draft`
- `regenerate-tiles`:结果页地块图集重生入队,例如 `jump_hop_regenerate_tiles`
- 拼消消 `puzzle-clear`
- `compile-draft`:草稿编译阶段需要生成场地底图和卡片 atlas 时入队,例如 `puzzle_clear_compile_draft`
- `regenerate-atlas`:结果页素材 atlas 重生入队,例如 `puzzle_clear_regenerate_atlas`
- 敲木鱼 `wooden-fish`
- `compile-draft`:草稿编译阶段需要生成背景、敲击物或其它图片资产时入队,例如 `wooden_fish_compile_draft`
- `regenerate-hit-object`:结果页敲击物图片重生入队,例如 `wooden_fish_regenerate_hit_object`
这些动作首版都保持“单动作单 job”:一次 `compile-draft` 或一次 `regenerate-*` 请求只创建一个 job,worker 内部负责该动作所需的 provider 调用、素材处理、OSS 持久化、失败态写回和业务成功写回。非外部图片生成动作,例如纯元信息保存、标签编辑、发布、试玩启动、运行态动作、删除和公开 read model 读取,继续 inline 执行。
每个玩法迁移时必须同时接入业务写回 lease guardworker 路径带 `external_generation_job_id / worker_id / lease_token`,inline 路径三项同时为空。过期 worker 不得写 session / work profile;业务失败态写回成功后才允许 job 进入 `failed`
## 验收
@@ -159,7 +189,9 @@ GENARRATIVE_PROCESS_ROLE=all npm run dev
curl -f http://127.0.0.1:<api-port>/healthz
```
本地同步排查可显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline npm run dev:api-server`,用于确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行;该模式不覆盖 worker 队列 smoke。生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行
本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认队列概览 BFF 可读、单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
systemd 生产 controller 与手动兜底示例:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
export type ExternalGenerationJobStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
export interface ExternalGenerationQueueOverview {
pendingCount: number;
runningCount: number;
updatedAtMicros: number;
}
export interface ExternalGenerationQueueOverviewResponse {
overview: ExternalGenerationQueueOverview;
}
export interface ExternalGenerationJobStatusRecord {
operationId: string;
status: ExternalGenerationJobStatus;
phaseLabel: string;
phaseDetail: string;
progress: number;
error?: string | null;
updatedAtMicros: number;
}
export interface ExternalGenerationJobStatusResponse {
job: ExternalGenerationJobStatusRecord;
}
+3
View File
@@ -1,3 +1,5 @@
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge';
export type JumpHopStylePreset =
@@ -206,6 +208,7 @@ export interface JumpHopActionResponse {
actionType: JumpHopActionType;
session: JumpHopSessionSnapshotResponse;
work: JumpHopWorkProfileResponse | null;
queueState?: ExternalGenerationJobStatusRecord | null;
}
export interface JumpHopWorkSummaryResponse {
@@ -1,4 +1,5 @@
import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession';
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type PuzzleAgentSuggestedActionType =
| 'request_summary'
@@ -41,6 +42,7 @@ export interface PuzzleAgentOperationRecord {
phaseDetail: string;
progress: number;
error?: string | null;
queueState?: ExternalGenerationJobStatusRecord | null;
}
export type PuzzleAgentActionRequest =
@@ -1,3 +1,5 @@
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type PuzzleClearGenerationStatus = 'draft' | 'generating' | 'ready' | 'failed';
export type PuzzleClearShapeKind = '1x2' | '1x3' | '2x2' | '2x3';
@@ -109,6 +111,7 @@ export interface PuzzleClearActionResponse {
actionType: PuzzleClearActionType;
session: PuzzleClearSessionSnapshotResponse;
work: PuzzleClearWorkProfileResponse | null;
queueState?: ExternalGenerationJobStatusRecord | null;
}
export interface PuzzleClearWorkSummaryResponse {
@@ -1,3 +1,5 @@
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type WoodenFishGenerationStatus =
| 'draft'
| 'generating'
@@ -104,6 +106,7 @@ export interface WoodenFishActionResponse {
actionType: WoodenFishActionType;
session: WoodenFishSessionSnapshotResponse;
work: WoodenFishWorkProfileResponse | null;
queueState?: ExternalGenerationJobStatusRecord | null;
}
export interface WoodenFishWorkSummaryResponse {
+1
View File
@@ -8,6 +8,7 @@ export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent';
export * from './contracts/edutainmentBabyDrawing';
export * from './contracts/edutainmentBabyObject';
export * from './contracts/externalGeneration';
export type * from './contracts/hyper3d';
export * from './contracts/match3dAgent';
export * from './contracts/match3dRuntime';
+1
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",
@@ -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))
}
@@ -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,
+218 -41
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]
+1
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;
@@ -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;
@@ -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,
)),
)
}
+173 -1
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>,
+150 -1
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,
@@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExternalGenerationJobStatus {
Queued,
Running,
Completed,
Failed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalGenerationQueueOverview {
pub pending_count: u32,
pub running_count: u32,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalGenerationQueueOverviewResponse {
pub overview: ExternalGenerationQueueOverview,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalGenerationJobStatusRecord {
pub operation_id: String,
pub status: ExternalGenerationJobStatus,
pub phase_label: String,
pub phase_detail: String,
pub progress: u8,
pub error: Option<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalGenerationJobStatusResponse {
pub job: ExternalGenerationJobStatusRecord,
}
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::external_generation::ExternalGenerationJobStatusRecord;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum JumpHopDifficulty {
@@ -311,6 +313,8 @@ pub struct JumpHopActionResponse {
pub session: JumpHopSessionSnapshotResponse,
#[serde(default)]
pub work: Option<JumpHopWorkProfileResponse>,
#[serde(default)]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -11,6 +11,7 @@ pub mod creation_agent_document_input;
pub mod creation_audio;
pub mod creation_entry_config;
pub mod creative_agent;
pub mod external_generation;
pub mod hyper3d;
pub mod jump_hop;
pub mod llm;
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::external_generation::ExternalGenerationJobStatusRecord;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PuzzleClearGenerationStatus {
@@ -141,6 +143,8 @@ pub struct PuzzleClearActionResponse {
pub action_type: PuzzleClearActionType,
pub session: PuzzleClearSessionSnapshotResponse,
pub work: Option<PuzzleClearWorkProfileResponse>,
#[serde(default)]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::external_generation::ExternalGenerationJobStatusRecord;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum WoodenFishGenerationStatus {
@@ -164,6 +166,8 @@ pub struct WoodenFishActionResponse {
pub session: WoodenFishSessionSnapshotResponse,
#[serde(default)]
pub work: Option<WoodenFishWorkProfileResponse>,
#[serde(default)]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -127,6 +127,31 @@ impl SpacetimeClient {
.await
}
pub async fn get_external_generation_job(
&self,
input: ExternalGenerationJobGetRecordInput,
) -> Result<ExternalGenerationJobRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"get_external_generation_job_and_return",
move |connection, sender| {
connection
.procedures()
.get_external_generation_job_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_external_generation_job_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn get_external_generation_queue_stats(
&self,
) -> Result<ExternalGenerationQueueStatsRecord, SpacetimeClientError> {
@@ -113,6 +113,55 @@ impl SpacetimeClient {
action_type: payload.action_type,
session,
work,
queue_state: None,
})
}
pub async fn mark_jump_hop_generation_queued(
&self,
session_id: String,
owner_user_id: String,
payload: JumpHopActionRequest,
) -> Result<JumpHopActionResponse, SpacetimeClientError> {
let current = self
.get_jump_hop_session(session_id.clone(), owner_user_id.clone())
.await?;
let action_type = payload.action_type.clone();
let scope = match action_type {
JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft,
JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles,
_ => {
return Err(SpacetimeClientError::validation_failed(
"jump-hop queued generation 只支持 compile-draft/regenerate-tiles",
));
}
};
let mut base_draft = current.draft.clone();
if matches!(action_type, JumpHopActionType::RegenerateTiles)
&& let Some(draft) = base_draft.as_mut()
{
draft.tile_atlas_asset = None;
draft.tile_assets.clear();
}
let mut draft = merge_action_into_draft(base_draft, &payload, scope)?;
let profile_id = resolve_jump_hop_profile_id(&draft, &action_type)?;
draft.profile_id = Some(profile_id.clone());
draft.generation_status = JumpHopGenerationStatus::Generating;
let session = self
.compile_jump_hop_draft(build_generating_compile_input(
&current,
&owner_user_id,
&profile_id,
&draft,
current_unix_micros(),
)?)
.await?;
Ok(JumpHopActionResponse {
action_type,
session,
work: None,
queue_state: None,
})
}
@@ -804,6 +853,50 @@ fn build_compile_input(
})
}
fn build_generating_compile_input(
current: &JumpHopSessionSnapshotResponse,
owner_user_id: &str,
profile_id: &str,
draft: &JumpHopDraftResponse,
now_micros: i64,
) -> Result<JumpHopDraftCompileInput, SpacetimeClientError> {
Ok(JumpHopDraftCompileInput {
session_id: current.session_id.clone(),
owner_user_id: owner_user_id.to_string(),
profile_id: profile_id.to_string(),
author_display_name: "跳一跳玩家".to_string(),
seed_text: draft.work_title.clone(),
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
theme_tags_json: Some(json_string(&draft.theme_tags)?),
theme_text: Some(draft.theme_text.clone()),
difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()),
style_preset: Some(style_to_str(&draft.style_preset).to_string()),
character_prompt: Some(draft.character_prompt.clone()),
tile_prompt: Some(draft.tile_prompt.clone()),
end_mood_prompt: draft.end_mood_prompt.clone(),
character_asset_json: draft
.character_asset
.as_ref()
.map(json_string)
.transpose()?,
tile_atlas_asset_json: draft
.tile_atlas_asset
.as_ref()
.map(json_string)
.transpose()?,
tile_assets_json: Some(json_string(&draft.tile_assets)?),
cover_composite: draft.cover_composite.clone(),
back_button_asset_json: draft
.back_button_asset
.as_ref()
.map(json_string)
.transpose()?,
generation_status: Some("generating".to_string()),
compiled_at_micros: now_micros,
})
}
fn build_update_input(
owner_user_id: &str,
profile_id: &str,

Some files were not shown because too many files have changed in this diff Show More