Files
Genarrative/server-rs/crates/api-server/src/jump_hop.rs
高物 3931442249 Enforce Genarrative play-type SOP and update docs
Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows.
2026-05-20 12:12:00 +08:00

448 lines
15 KiB
Rust

use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::Response,
};
use serde_json::{Value, json};
use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopDraftResponse, JumpHopGalleryDetailResponse,
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopRestartRunRequest,
JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse,
JumpHopStartRunRequest, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
JumpHopWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
const JUMP_HOP_PROVIDER: &str = "jump-hop";
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
pub async fn create_jump_hop_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<JumpHopWorkspaceCreateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
validate_workspace_request(&request_context, &payload)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let session_id = build_prefixed_uuid_id("jump-hop-session-");
let now = current_utc_micros();
let draft = build_jump_hop_draft(&payload);
let session = JumpHopSessionSnapshotResponse {
session_id,
owner_user_id,
status: JumpHopGenerationStatus::Draft,
draft: Some(draft),
created_at: format_timestamp_micros(now),
updated_at: format_timestamp_micros(now),
};
Ok(json_success_body(
Some(&request_context),
JumpHopSessionResponse {
session: state
.spacetime_client()
.create_jump_hop_session(session)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})?,
},
))
}
pub async fn get_jump_hop_session(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
.spacetime_client()
.get_jump_hop_session(session_id, owner_user_id)
.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),
JumpHopSessionResponse { session },
))
}
pub async fn execute_jump_hop_action(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<JumpHopActionRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let response = state
.spacetime_client()
.execute_jump_hop_action(session_id, owner_user_id, payload)
.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), response))
}
pub async fn publish_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 work = state
.spacetime_client()
.publish_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),
JumpHopWorkMutationResponse { item: work },
))
}
pub async fn get_jump_hop_runtime_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &profile_id, "profileId")?;
let work = state
.spacetime_client()
.get_jump_hop_runtime_work(profile_id)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_RUNTIME_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
JumpHopWorkDetailResponse { item: work },
))
}
pub async fn start_jump_hop_run(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<JumpHopStartRunRequest>, JsonRejection>,
) -> 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 run = state
.spacetime_client()
.start_jump_hop_run(payload, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_RUNTIME_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
JumpHopRunResponse { run },
))
}
pub async fn jump_hop_run_jump(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<JumpHopJumpRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
let run = state
.spacetime_client()
.jump_hop_run_jump(
run_id,
authenticated.claims().user_id().to_string(),
payload,
)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_RUNTIME_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
JumpHopJumpResponse { run },
))
}
pub async fn restart_jump_hop_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<JumpHopRestartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
let run = state
.spacetime_client()
.restart_jump_hop_run(
run_id,
authenticated.claims().user_id().to_string(),
payload,
)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_RUNTIME_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
JumpHopRunResponse { run },
))
}
pub async fn list_jump_hop_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let gallery = state
.spacetime_client()
.list_jump_hop_gallery()
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_RUNTIME_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(Some(&request_context), gallery))
}
pub async fn get_jump_hop_gallery_detail(
State(state): State<AppState>,
Path(public_work_code): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?;
let work = state
.spacetime_client()
.get_jump_hop_gallery_detail(public_work_code)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_RUNTIME_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
JumpHopGalleryDetailResponse { item: work },
))
}
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
work_title: payload.work_title.trim().to_string(),
work_description: payload.work_description.trim().to_string(),
theme_tags: normalize_tags(payload.theme_tags.clone()),
difficulty: payload.difficulty.clone(),
style_preset: payload.style_preset.clone(),
character_prompt: payload.character_prompt.trim().to_string(),
tile_prompt: payload.tile_prompt.trim().to_string(),
end_mood_prompt: payload
.end_mood_prompt
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
character_asset: None,
tile_atlas_asset: None,
tile_assets: Vec::new(),
path: None,
cover_composite: None,
generation_status: JumpHopGenerationStatus::Draft,
}
}
fn validate_workspace_request(
request_context: &RequestContext,
payload: &JumpHopWorkspaceCreateRequest,
) -> Result<(), Response> {
ensure_non_empty(request_context, &payload.work_title, "workTitle")?;
ensure_non_empty(
request_context,
&payload.character_prompt,
"characterPrompt",
)?;
ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?;
if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID {
return Err(jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": JUMP_HOP_PROVIDER,
"message": "templateId 必须为 jump-hop",
})),
));
}
Ok(())
}
fn ensure_non_empty(
request_context: &RequestContext,
value: &str,
field: &str,
) -> Result<(), Response> {
if value.trim().is_empty() {
return Err(jump_hop_error_response(
request_context,
JUMP_HOP_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": JUMP_HOP_PROVIDER,
"field": field,
"message": format!("{field} 不能为空"),
})),
));
}
Ok(())
}
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
let mut normalized = Vec::new();
for tag in tags {
let tag = tag.trim();
if tag.is_empty() || normalized.iter().any(|item| item == tag) {
continue;
}
normalized.push(tag.to_string());
if normalized.len() >= 6 {
break;
}
}
normalized
}
fn jump_hop_json<T>(
payload: Result<Json<T>, JsonRejection>,
request_context: &RequestContext,
provider: &str,
) -> Result<Json<T>, Response> {
payload.map_err(|error| {
jump_hop_error_response(
request_context,
provider,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"message": error.to_string(),
})),
)
})
}
fn map_jump_hop_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message)
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("发布需要")
|| message.contains("不能为空")
|| message.contains("必须") =>
{
StatusCode::BAD_REQUEST
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn jump_hop_error_response(
request_context: &RequestContext,
provider: &str,
error: AppError,
) -> Response {
let mut response = error.into_response_with_context(Some(request_context));
response.headers_mut().insert(
HeaderName::from_static("x-genarrative-provider"),
header::HeaderValue::from_str(provider)
.unwrap_or_else(|_| header::HeaderValue::from_static("jump-hop")),
);
response
}
fn current_utc_micros() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_micros().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}