Merge remote-tracking branch 'origin/master' into codex/wooden-fish-template

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	.hermes/shared-memory/pitfalls.md
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
2026-05-26 02:00:11 +08:00
140 changed files with 12176 additions and 3526 deletions

View File

@@ -34,6 +34,7 @@ module-story = { workspace = true }
module-visual-novel = { workspace = true }
platform-agent = { workspace = true }
platform-auth = { workspace = true }
platform-image = { workspace = true }
platform-llm = { workspace = true }
platform-oss = { workspace = true }
platform-speech = { workspace = true }

View File

@@ -251,6 +251,9 @@ fn map_admin_creation_entry_type_config(
visible: entry.visible,
open: entry.open,
sort_order: entry.sort_order,
category_id: entry.category_id,
category_label: entry.category_label,
category_sort_order: entry.category_sort_order,
updated_at_micros: entry.updated_at_micros,
}
}
@@ -275,6 +278,9 @@ fn validate_admin_creation_entry_config(
visible: payload.visible,
open: payload.open,
sort_order: payload.sort_order,
category_id: payload.category_id.trim().to_string(),
category_label: payload.category_label.trim().to_string(),
category_sort_order: payload.category_sort_order,
})
}

View File

@@ -9,9 +9,13 @@ use axum::{
response::Response,
};
use platform_auth::{
AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token, verify_access_token,
AccessTokenClaims, AuthProvider, BindingStatus, RuntimeGuestTokenClaims,
RuntimeGuestTokenClaimsInput, RUNTIME_GUEST_SCOPE_PUBLIC_PLAY, read_refresh_session_token,
sign_runtime_guest_token, verify_access_token, verify_runtime_guest_token,
};
use serde_json::{Value, json};
use shared_contracts::auth::RuntimeGuestTokenResponse;
use shared_kernel::{format_rfc3339, new_uuid_simple_string};
use time::OffsetDateTime;
use tracing::warn;
@@ -34,6 +38,18 @@ pub struct RefreshSessionToken {
token: String,
}
#[derive(Clone, Debug)]
pub enum RuntimePrincipal {
User(AuthenticatedAccessToken),
Guest(RuntimeGuestTokenClaims),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimePrincipalKind {
User,
Guest,
}
impl AuthenticatedAccessToken {
pub fn new(claims: AccessTokenClaims) -> Self {
Self { claims }
@@ -54,6 +70,66 @@ impl RefreshSessionToken {
}
}
impl RuntimePrincipal {
pub fn subject(&self) -> &str {
match self {
Self::User(authenticated) => authenticated.claims().user_id(),
Self::Guest(claims) => claims.subject(),
}
}
pub fn kind(&self) -> RuntimePrincipalKind {
match self {
Self::User(_) => RuntimePrincipalKind::User,
Self::Guest(_) => RuntimePrincipalKind::Guest,
}
}
}
impl RuntimePrincipalKind {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Guest => "guest",
}
}
}
pub async fn issue_runtime_guest_token(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, AppError> {
let issued_at = OffsetDateTime::now_utc();
let claims = RuntimeGuestTokenClaims::from_input(
RuntimeGuestTokenClaimsInput {
subject: format!("guest-runtime-{}", new_uuid_simple_string()),
scope: RUNTIME_GUEST_SCOPE_PUBLIC_PLAY.to_string(),
},
state.auth_jwt_config(),
issued_at,
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?;
let token = sign_runtime_guest_token(&claims, state.auth_jwt_config()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?;
let expires_at = OffsetDateTime::from_unix_timestamp(claims.expires_at_unix() as i64)
.ok()
.and_then(|value| format_rfc3339(value).ok())
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
Ok(json_success_body(
Some(&request_context),
RuntimeGuestTokenResponse {
token,
expires_at,
subject: claims.subject().to_string(),
scope: claims.scope().to_string(),
},
))
}
pub async fn require_bearer_auth(
State(state): State<AppState>,
mut request: Request,
@@ -70,29 +146,70 @@ pub async fn require_bearer_auth(
Ok(response)
}
pub async fn attach_optional_bearer_auth(
pub async fn require_runtime_principal_auth(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
if let Some(authenticated) = authenticate_request(&state, &request)? {
request.extensions_mut().insert(authenticated.clone());
let mut response = next.run(request).await;
response.extensions_mut().insert(authenticated);
return Ok(response);
let Some(principal) = authenticate_runtime_principal(&state, &request)? else {
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
};
request.extensions_mut().insert(principal.clone());
let mut response = next.run(request).await;
response.extensions_mut().insert(principal);
Ok(response)
}
fn authenticate_runtime_principal(
state: &AppState,
request: &Request,
) -> Result<Option<RuntimePrincipal>, AppError> {
if !request.headers().contains_key(AUTHORIZATION) {
return Ok(None);
}
Ok(next.run(request).await)
match authenticate_request(state, request) {
Ok(Some(authenticated)) => Ok(Some(RuntimePrincipal::User(authenticated))),
Ok(None) => Ok(None),
Err(_) => {
let bearer_token = extract_bearer_token(request.headers())?;
let request_id = request
.extensions()
.get::<RequestContext>()
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let claims = verify_runtime_guest_token(&bearer_token, state.auth_jwt_config())
.map_err(|error| {
warn!(
%request_id,
error = %error,
"runtime guest JWT 校验失败"
);
AppError::from_status(StatusCode::UNAUTHORIZED)
})?;
if claims.scope() != RUNTIME_GUEST_SCOPE_PUBLIC_PLAY {
warn!(
%request_id,
scope = %claims.scope(),
"runtime guest JWT scope 非法"
);
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
}
Ok(Some(RuntimePrincipal::Guest(claims)))
}
}
}
fn authenticate_request(
state: &AppState,
request: &Request,
) -> Result<Option<AuthenticatedAccessToken>, AppError> {
if allows_internal_forwarded_auth(request.uri().path())
&& let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers())
{
return Ok(Some(AuthenticatedAccessToken::new(claims)));
if allows_internal_forwarded_auth(request.uri().path()) {
if let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) {
return Ok(Some(AuthenticatedAccessToken::new(claims)));
}
}
if !request.headers().contains_key(AUTHORIZATION) {

View File

@@ -143,6 +143,15 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
},
event_banner: module_runtime::CreationEntryEventBannerSnapshot {
title: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string(),
cover_image_src: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC
.to_string(),
prize_pool_mud_points: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(),
ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(),
},
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
updated_at_micros: 0,
})
@@ -259,5 +268,8 @@ mod tests {
assert!(baby_object_match.open);
assert_eq!(baby_object_match.badge, "\u{53ef}\u{521b}\u{5efa}");
assert_eq!(baby_object_match.sort_order, 90);
assert_eq!(baby_object_match.category_id, "character");
assert_eq!(baby_object_match.category_label, "\u{89d2}\u{8272}\u{521b}\u{4f5c}");
assert_eq!(baby_object_match.category_sort_order, 40);
}
}

View File

@@ -1,4 +1,5 @@
use axum::http::StatusCode;
use platform_image::PlatformImageFailureAudit;
use module_runtime::RuntimeTrackingScopeKind;
use serde_json::{Value, json};
use time::OffsetDateTime;
@@ -109,6 +110,28 @@ impl ExternalApiFailureDraft {
}
}
pub(crate) fn build_external_api_failure_draft_from_platform_image_audit(
audit: &PlatformImageFailureAudit,
) -> ExternalApiFailureDraft {
ExternalApiFailureDraft::new(
audit.provider,
audit.endpoint.clone(),
audit.operation.clone(),
audit.failure_stage,
audit.error_message.clone(),
)
.with_status_code(audit.status_code)
.with_optional_status_class(audit.status_class)
.with_timeout(audit.timeout)
.with_retryable(audit.retryable)
.with_error_source(audit.error_source.clone())
.with_raw_excerpt(audit.raw_excerpt.clone())
.with_latency_ms(audit.latency_ms)
.with_prompt_chars(audit.prompt_chars)
.with_reference_image_count(audit.reference_image_count)
.with_image_model(audit.image_model)
}
/// 中文注释下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。
pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str {
status_class(Some(status_code.as_u16()))

View File

@@ -113,6 +113,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"),
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
StatusCode::GATEWAY_TIMEOUT => ("GATEWAY_TIMEOUT", "上游服务请求超时"),
StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"),
StatusCode::SERVICE_UNAVAILABLE => ("SERVICE_UNAVAILABLE", "服务暂不可用"),
_ if status_code.is_client_error() => ("BAD_REQUEST", "请求参数不合法"),

View File

@@ -4,33 +4,53 @@ use axum::{
http::{HeaderName, StatusCode, header},
response::Response,
};
use module_assets::{
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde_json::{Value, json};
use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopDraftResponse, JumpHopGalleryDetailResponse,
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopRestartRunRequest,
JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse,
JumpHopStartRunRequest, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType,
JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse,
JumpHopWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
http_error::AppError,
generated_asset_sheets::{
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
slice_generated_asset_sheet,
},
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
normalize_generated_image_asset_mime,
},
openai_image_generation::{
build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
work_play_tracking::{record_work_play_start_after_success, WorkPlayTrackingDraft},
};
const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = ["start", "normal", "target", "finish", "bonus", "accent"];
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 = "跳一跳";
const JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID: &str = "anonymous-runtime";
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
pub async fn create_jump_hop_session(
@@ -109,6 +129,15 @@ pub async fn execute_jump_hop_action(
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 mut payload = payload;
maybe_generate_jump_hop_assets(
&state,
&request_context,
session_id.as_str(),
owner_user_id.as_str(),
&mut payload,
)
.await?;
let response = state
.spacetime_client()
.execute_jump_hop_action(session_id, owner_user_id, payload)
@@ -149,6 +178,31 @@ pub async fn publish_jump_hop_work(
))
}
pub async fn list_jump_hop_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let works = state
.spacetime_client()
.list_jump_hop_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
JumpHopWorksResponse {
items: works.into_iter().map(|work| work.summary).collect(),
},
))
}
pub async fn get_jump_hop_runtime_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -176,15 +230,13 @@ pub async fn get_jump_hop_runtime_work(
pub async fn start_jump_hop_run(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
Extension(principal): Extension<RuntimePrincipal>,
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 authenticated = maybe_authenticated.as_ref().map(|Extension(authenticated)| authenticated);
let owner_user_id = authenticated
.map(|authenticated| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let owner_user_id = principal.subject().to_string();
let principal_kind = principal.kind().as_str();
let run = state
.spacetime_client()
.start_jump_hop_run(payload, owner_user_id.clone())
@@ -201,7 +253,7 @@ pub async fn start_jump_hop_run(
&state,
&request_context,
build_jump_hop_work_play_tracking_draft(
authenticated,
&principal,
run.profile_id.clone(),
JUMP_HOP_RUNTIME_RUNS_ROUTE,
)
@@ -210,7 +262,7 @@ pub async fn start_jump_hop_run(
.profile_id(run.profile_id.clone())
.extra(json!({
"runStatus": run.status,
"isAnonymous": maybe_authenticated.is_none(),
"principalKind": principal_kind,
})),
)
.await;
@@ -225,15 +277,12 @@ pub async fn jump_hop_run_jump(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
Extension(principal): Extension<RuntimePrincipal>,
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 owner_user_id = maybe_authenticated
.as_ref()
.map(|Extension(authenticated)| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let owner_user_id = principal.subject().to_string();
let run = state
.spacetime_client()
.jump_hop_run_jump(run_id, owner_user_id, payload)
@@ -256,15 +305,12 @@ pub async fn restart_jump_hop_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
Extension(principal): Extension<RuntimePrincipal>,
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 owner_user_id = maybe_authenticated
.as_ref()
.map(|Extension(authenticated)| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let owner_user_id = principal.subject().to_string();
let run = state
.spacetime_client()
.restart_jump_hop_run(run_id, owner_user_id, payload)
@@ -326,19 +372,344 @@ pub async fn get_jump_hop_gallery_detail(
))
}
async fn maybe_generate_jump_hop_assets(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
payload: &mut JumpHopActionRequest,
) -> Result<(), Response> {
if !matches!(payload.action_type, JumpHopActionType::CompileDraft) {
return Ok(());
}
if payload.character_asset.is_some()
&& payload.tile_atlas_asset.is_some()
&& payload.tile_assets.as_ref().is_some_and(|assets| !assets.is_empty())
{
return Ok(());
}
let profile_id = payload
.profile_id
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-"));
payload.profile_id = Some(profile_id.clone());
let settings = require_openai_image_settings(state)
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let http_client = build_openai_image_http_client(&settings)
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let character_prompt = payload
.character_prompt
.as_deref()
.unwrap_or("俯视角可爱主角,透明背景");
let tile_prompt = payload
.tile_prompt
.as_deref()
.unwrap_or("等距立体地块图集");
let character_generated = create_openai_image_generation(
&http_client,
&settings,
character_prompt,
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
"1024*1024",
1,
&[],
"跳一跳角色资产生成失败",
)
.await
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let character_image = character_generated.images.into_iter().next().ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "跳一跳角色资产生成成功但未返回图片。",
})),
)
})?;
let character_asset = persist_jump_hop_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"character",
character_prompt,
character_image,
LegacyAssetPrefix::JumpHopAssets,
768,
768,
request_context,
)
.await?;
let sheet_prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
subject_text: tile_prompt,
item_names: &vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
grid_size: 3,
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视图"),
special_prompt: Some("每个格子对应一个 tile 类型,供跳一跳地块裁切使用。"),
})
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let tile_generated = create_openai_image_generation(
&http_client,
&settings,
sheet_prompt.as_str(),
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
"1024*1024",
1,
&[],
"跳一跳地块图集生成失败",
)
.await
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "跳一跳地块图集生成成功但未返回图片。",
})),
)
})?;
let tile_slices = slice_generated_asset_sheet(
&tile_image,
&vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
3,
)
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"tile-atlas",
tile_prompt,
tile_image,
LegacyAssetPrefix::JumpHopAssets,
1024,
1024,
request_context,
)
.await?;
let tile_assets = tile_slices
.into_iter()
.enumerate()
.map(|(index, row)| JumpHopTileAsset {
tile_type: match index {
0 => JumpHopTileType::Start,
1 => JumpHopTileType::Normal,
2 => JumpHopTileType::Target,
3 => JumpHopTileType::Finish,
4 => JumpHopTileType::Bonus,
_ => JumpHopTileType::Accent,
},
image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
asset_object_id: format!("{profile_id}-tile-{index}-object"),
source_atlas_cell: format!("cell-{index}"),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
landing_radius: 34.0,
})
.collect::<Vec<_>>();
payload.character_asset = Some(character_asset);
payload.tile_atlas_asset = Some(tile_atlas_asset);
payload.tile_assets = Some(tile_assets);
payload.cover_composite = payload
.cover_composite
.clone()
.or_else(|| Some(format!("/generated-jump-hop-assets/{profile_id}/cover-composite.png")));
Ok(())
}
async fn persist_jump_hop_generated_image_asset(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
slot: &str,
prompt: &str,
image: crate::openai_image_generation::DownloadedOpenAiImage,
prefix: LegacyAssetPrefix,
width: u32,
height: u32,
request_context: &RequestContext,
) -> Result<JumpHopCharacterAsset, Response> {
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix,
path_segments: vec![profile_id.to_string(), slot.to_string()],
file_stem: "image".to_string(),
image: GeneratedImageAssetDataUrl {
format: image_format,
bytes: image.bytes,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(format!("jump-hop-{slot}")),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some("jump_hop_work".to_string()),
entity_id: Some(profile_id.to_string()),
slot: Some(slot.to_string()),
provider: Some("vector-engine".to_string()),
task_id: None,
},
extra_metadata: BTreeMap::new(),
})
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备跳一跳图片资产上传请求失败:{error:?}"),
})),
)
})?;
let persisted_mime_type = prepared.format.mime_type.clone();
let oss_client = state.oss_client().ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
})),
)
})?;
let http_client = reqwest::Client::new();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
)
})?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
)
})?;
let now_micros = current_utc_micros();
let asset_object_input = build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key.clone(),
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
format!("jump-hop-{slot}"),
None,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
Some(profile_id.to_string()),
now_micros,
)
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
})),
)
})?;
let asset_object = state
.spacetime_client()
.confirm_asset_object(asset_object_input)
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
})),
)
})?;
let binding_input = build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id.clone(),
"jump_hop_work".to_string(),
profile_id.to_string(),
slot.to_string(),
format!("jump-hop-{slot}"),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
now_micros,
)
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
})),
)
})?;
state
.spacetime_client()
.bind_asset_object_to_entity(binding_input)
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
})),
)
})?;
Ok(JumpHopCharacterAsset {
asset_id: format!("{profile_id}-{slot}-{now_micros}"),
image_src: put_result.legacy_public_path,
image_object_key: head.object_key,
asset_object_id: asset_object.asset_object_id,
generation_provider: "vector-engine".to_string(),
prompt: prompt.to_string(),
width,
height,
})
}
fn build_jump_hop_work_play_tracking_draft(
authenticated: Option<&AuthenticatedAccessToken>,
principal: &RuntimePrincipal,
work_id: impl Into<String>,
source_route: &'static str,
) -> WorkPlayTrackingDraft {
match authenticated {
Some(authenticated) => {
WorkPlayTrackingDraft::new("jump-hop", work_id, authenticated, source_route)
}
None => WorkPlayTrackingDraft::anonymous("jump-hop", work_id, source_route),
}
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
}
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),

View File

@@ -4,7 +4,7 @@ use axum::{
};
use crate::{
auth::{attach_refresh_session_token, require_bearer_auth},
auth::{attach_refresh_session_token, issue_runtime_guest_token, require_bearer_auth},
auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::{auth_sessions, revoke_auth_session},
@@ -65,6 +65,7 @@ pub fn router(state: AppState) -> Router<AppState> {
attach_refresh_session_token,
)),
)
.route("/api/auth/runtime-guest-token", post(issue_runtime_guest_token))
.route("/api/auth/phone/send-code", post(send_phone_code))
.route("/api/auth/phone/login", post(phone_login))
.route("/api/auth/wechat/start", get(start_wechat_login))

View File

@@ -4,11 +4,11 @@ use axum::{
};
use crate::{
auth::{attach_optional_bearer_auth, require_bearer_auth},
auth::{require_bearer_auth, require_runtime_principal_auth},
jump_hop::{
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
},
state::AppState,
};
@@ -36,6 +36,13 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/creation/jump-hop/works",
get(list_jump_hop_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/jump-hop/works/{profile_id}/publish",
post(publish_jump_hop_work).route_layer(middleware::from_fn_with_state(
@@ -51,21 +58,21 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/runtime/jump-hop/runs",
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_optional_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/jump-hop/runs/{run_id}/jump",
post(jump_hop_run_jump).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_optional_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/jump-hop/runs/{run_id}/restart",
post(restart_jump_hop_run).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_optional_bearer_auth,
require_runtime_principal_auth,
)),
)
.route("/api/runtime/jump-hop/gallery", get(list_jump_hop_gallery))

View File

@@ -6,7 +6,7 @@ use axum::{
};
use crate::{
auth::require_bearer_auth,
auth::{require_bearer_auth, require_runtime_principal_auth},
puzzle::{
advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session,
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
@@ -130,56 +130,56 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/runtime/puzzle/runs",
post(start_puzzle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}",
get(get_puzzle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/swap",
post(swap_puzzle_pieces).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/drag",
post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/next-level",
post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/pause",
post(update_puzzle_run_pause).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/props",
post(use_puzzle_runtime_prop).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/leaderboard",
post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.with_state(PuzzleApiState::from_ref(&state))

View File

@@ -4,7 +4,7 @@ use axum::{
};
use crate::{
auth::require_bearer_auth,
auth::{require_bearer_auth, require_runtime_principal_auth},
state::AppState,
wooden_fish::{
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
@@ -52,21 +52,21 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/runtime/wooden-fish/runs",
post(start_wooden_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/wooden-fish/runs/{run_id}/checkpoint",
post(checkpoint_wooden_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/wooden-fish/runs/{run_id}/finish",
post(finish_wooden_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
use std::{
collections::BTreeMap,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
time::{Instant, SystemTime, UNIX_EPOCH},
};
use axum::{
@@ -76,7 +76,7 @@ use crate::{
execute_billable_asset_operation, execute_billable_asset_operation_with_cost,
should_skip_asset_operation_billing_for_connectivity,
},
auth::AuthenticatedAccessToken,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
http_error::AppError,
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
@@ -103,7 +103,7 @@ use crate::{
},
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
request_context::RequestContext,
state::PuzzleApiState,
state::{AppState, PuzzleApiState},
work_author::resolve_puzzle_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
};
@@ -122,12 +122,24 @@ const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 6 * 1024 * 1024;
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
}
pub(crate) fn build_puzzle_reference_image_too_large_message(actual_bytes: usize) -> String {
format!(
"参考图过大,请压缩后再上传(当前 {},最多 6MB",
format_puzzle_reference_image_upload_bytes(actual_bytes)
)
}
const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面生成对应的拼图游戏关卡画面要求画面中所有元素精致且风格高度一致画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图拼图区域宽度与画面宽度同宽紧贴画面横向边缘拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字";
const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成纯绿色绿幕背景的spritesheet。背景必须是统一纯绿色绿幕高饱和亮绿色接近 #00FF00背景平整无纹理、无渐变、无阴影、无场景内容后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字每个按钮必须是独立完整图形按钮之间保留足够纯绿色绿幕空白不能相互接触、重叠或连成一片方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。";
const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面仅保留背景图补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容";

View File

@@ -1666,7 +1666,7 @@ pub async fn remix_puzzle_gallery_work(
pub async fn start_puzzle_run(
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<StartPuzzleRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1690,7 +1690,7 @@ pub async fn start_puzzle_run(
.spacetime_client()
.start_puzzle_run(PuzzleRunStartRecordInput {
run_id: build_prefixed_uuid_id("puzzle-run-"),
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
profile_id: payload.profile_id.clone(),
level_id: payload.level_id.clone(),
started_at_micros: current_utc_micros(),
@@ -1707,16 +1707,18 @@ pub async fn start_puzzle_run(
record_puzzle_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
WorkPlayTrackingDraft::runtime_principal(
"puzzle",
payload.profile_id.clone(),
&authenticated,
&principal,
"/api/runtime/puzzle/...",
)
.profile_id(payload.profile_id.clone())
.owner_user_id(principal.subject().to_string())
.extra(json!({
"levelId": payload.level_id,
"runId": run.run_id,
"principalKind": principal.kind().as_str(),
})),
)
.await;
@@ -1733,13 +1735,13 @@ pub async fn get_puzzle_run(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_puzzle_run(run_id, authenticated.claims().user_id().to_string())
.get_puzzle_run(run_id, principal.subject().to_string())
.await
.map_err(|error| {
puzzle_error_response(
@@ -1761,7 +1763,7 @@ pub async fn swap_puzzle_pieces(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<SwapPuzzlePiecesRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1792,7 +1794,7 @@ pub async fn swap_puzzle_pieces(
.spacetime_client()
.swap_puzzle_pieces(PuzzleRunSwapRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
first_piece_id: payload.first_piece_id,
second_piece_id: payload.second_piece_id,
swapped_at_micros: current_utc_micros(),
@@ -1818,7 +1820,7 @@ pub async fn drag_puzzle_piece_or_group(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<DragPuzzlePieceRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1843,7 +1845,7 @@ pub async fn drag_puzzle_piece_or_group(
.spacetime_client()
.drag_puzzle_piece_or_group(PuzzleRunDragRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
piece_id: payload.piece_id,
target_row: payload.target_row,
target_col: payload.target_col,
@@ -1870,7 +1872,7 @@ pub async fn advance_puzzle_next_level(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<AdvancePuzzleNextLevelRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
@@ -1897,7 +1899,7 @@ pub async fn advance_puzzle_next_level(
.spacetime_client()
.advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
target_profile_id: payload.target_profile_id,
advanced_at_micros: current_utc_micros(),
})
@@ -1922,7 +1924,7 @@ pub async fn update_puzzle_run_pause(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<UpdatePuzzleRuntimePauseRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1941,7 +1943,7 @@ pub async fn update_puzzle_run_pause(
.spacetime_client()
.update_puzzle_run_pause(PuzzleRunPauseRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
paused: payload.paused,
updated_at_micros: current_utc_micros(),
})
@@ -1966,7 +1968,7 @@ pub async fn use_puzzle_runtime_prop(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<UsePuzzleRuntimePropRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1987,7 +1989,7 @@ pub async fn use_puzzle_runtime_prop(
"propKind",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let owner_user_id = principal.subject().to_string();
let prop_kind = payload.prop_kind.trim().to_string();
let billing_asset_kind = match prop_kind.as_str() {
"hint" => "puzzle_prop_hint",
@@ -2064,7 +2066,7 @@ pub async fn submit_puzzle_leaderboard(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<SubmitPuzzleLeaderboardRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -2084,7 +2086,7 @@ pub async fn submit_puzzle_leaderboard(
.spacetime_client()
.submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
profile_id: payload.profile_id,
grid_size: payload.grid_size,
elapsed_ms: payload.elapsed_ms.max(1_000),

View File

@@ -1,5 +1,7 @@
use super::*;
use crate::openai_image_generation::GPT_IMAGE_2_MODEL;
use crate::openai_image_generation::{GPT_IMAGE_2_MODEL, map_platform_image_error};
use platform_image::{PlatformImageError, VECTOR_ENGINE_PROVIDER};
use std::time::Duration;
#[test]
fn puzzle_generated_image_size_is_square_1_1() {
@@ -218,45 +220,6 @@ fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() {
assert!(body.get("image").is_none());
}
#[test]
fn puzzle_vector_engine_generation_url_normalizes_base_url() {
let settings = PuzzleVectorEngineSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
};
assert_eq!(
puzzle_vector_engine_images_generation_url(&settings),
"https://vector.example/v1/images/generations"
);
}
#[test]
fn puzzle_vector_engine_edit_url_normalizes_base_url() {
let settings = PuzzleVectorEngineSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
};
assert_eq!(
puzzle_vector_engine_images_edit_url(&settings),
"https://vector.example/v1/images/edits"
);
}
#[test]
fn puzzle_vector_engine_edit_response_decodes_b64_image() {
let images = puzzle_images_from_base64(
"edit-1".to_string(),
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
1,
);
assert_eq!(images.images.len(), 1);
assert_eq!(images.images[0].mime_type, "image/png");
assert_eq!(images.images[0].extension, "png");
}
#[test]
fn puzzle_vector_engine_prompt_strongly_uses_reference_image() {
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true);
@@ -379,9 +342,18 @@ fn puzzle_asset_object_reference_requires_matching_owner() {
#[test]
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_request_error(
"创建拼图 VectorEngine 图片生成任务失败operation timed out".to_string(),
);
let error = map_platform_image_error(PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
message: "创建拼图 VectorEngine 图片生成任务失败operation timed out".to_string(),
endpoint: Some("https://vector.example/v1/images/generations".to_string()),
timeout: true,
connect: false,
request: true,
body: false,
status_code: None,
source: None,
audit: None,
});
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
@@ -389,11 +361,14 @@ fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
#[test]
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片生成任务失败",
);
let error = map_platform_image_error(PlatformImageError::Upstream {
provider: VECTOR_ENGINE_PROVIDER,
message: "VectorEngine generation endpoint timeout".to_string(),
upstream_status: reqwest::StatusCode::GATEWAY_TIMEOUT.as_u16(),
raw_excerpt: r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#
.to_string(),
audit: None,
});
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);

View File

@@ -1,4 +1,7 @@
use super::*;
use crate::openai_image_generation::{
OpenAiReferenceImage, create_openai_image_edit_with_references,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PuzzleImageModel {
@@ -26,6 +29,8 @@ impl PuzzleImageModel {
pub(crate) struct PuzzleVectorEngineSettings {
pub(crate) base_url: String,
pub(crate) api_key: String,
pub(crate) request_timeout_ms: u64,
pub(crate) external_api_audit_state: Option<AppState>,
}
pub(crate) struct PuzzleGeneratedImages {
@@ -78,6 +83,25 @@ impl PuzzleDownloadedImage {
bytes: image.bytes,
}
}
pub(crate) fn from_openai_image(image: DownloadedOpenAiImage) -> Self {
Self {
extension: image.extension,
mime_type: normalize_puzzle_downloaded_image_mime_type(image.mime_type.as_str()),
bytes: image.bytes,
}
}
}
impl PuzzleVectorEngineSettings {
fn to_openai_settings(&self) -> crate::openai_image_generation::OpenAiImageSettings {
crate::openai_image_generation::OpenAiImageSettings {
base_url: self.base_url.clone(),
api_key: self.api_key.clone(),
request_timeout_ms: self.request_timeout_ms,
external_api_audit_state: self.external_api_audit_state.clone(),
}
}
}
pub(crate) struct ParsedPuzzleImageDataUrl {
@@ -151,27 +175,18 @@ pub(crate) fn require_puzzle_vector_engine_settings(
Ok(PuzzleVectorEngineSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.vector_engine_image_request_timeout_ms().max(1),
external_api_audit_state: Some(state.root_state().clone()),
})
}
pub(crate) fn build_puzzle_image_http_client(
state: &PuzzleApiState,
image_model: PuzzleImageModel,
_image_model: PuzzleImageModel,
) -> Result<reqwest::Client, AppError> {
let provider = image_model.provider_name();
let request_timeout_ms = state.vector_engine_image_request_timeout_ms();
let settings = require_puzzle_vector_engine_settings(state)?;
reqwest::Client::builder()
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
// 中文注释:参考图走 multipart edits强制 HTTP/1.1 可降低部分网关对长耗时上传流的兼容风险。
.http1_only()
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": provider,
"message": format!("构造拼图图片生成 HTTP 客户端失败:{error}"),
}))
})
build_openai_image_http_client(&settings.to_openai_settings())
}
pub(crate) fn to_puzzle_generated_image_candidate(
@@ -213,198 +228,66 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
.await;
}
let request_body = build_puzzle_vector_engine_image_request_body(
image_model,
let generated = create_openai_image_generation(
http_client,
&settings.to_openai_settings(),
prompt,
negative_prompt,
Some(negative_prompt),
size,
candidate_count,
reference_image,
);
let request_url = puzzle_vector_engine_images_generation_url(settings);
let request_started_at = Instant::now();
let response = http_client
.post(request_url.as_str())
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
.await
.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"创建拼图 VectorEngine 图片生成任务失败:{error}"
))
})?;
let status = response.status();
let upstream_elapsed_ms = request_started_at.elapsed().as_millis() as u64;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = image_model.request_model_name(),
endpoint = %request_url,
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
size,
has_reference_image = reference_image.is_some(),
elapsed_ms = upstream_elapsed_ms,
"拼图 VectorEngine 图片生成 HTTP 返回"
);
let response_text = response.text().await.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"读取拼图 VectorEngine 图片生成响应失败:{error}"
))
})?;
if !status.is_success() {
return Err(map_puzzle_vector_engine_upstream_error(
status,
response_text.as_str(),
"创建拼图 VectorEngine 图片生成任务失败",
));
}
let payload = parse_puzzle_json_payload(
response_text.as_str(),
"解析拼图 VectorEngine 图片生成响应失败",
)?;
let image_urls = extract_puzzle_image_urls(&payload);
if !image_urls.is_empty() {
let download_started_at = Instant::now();
let images = download_puzzle_images_from_urls(
http_client,
format!("vector-engine-{}", current_utc_micros()),
image_urls,
candidate_count,
)
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = image_model.request_model_name(),
image_count = images.images.len(),
elapsed_ms = download_started_at.elapsed().as_millis() as u64,
"拼图 VectorEngine 图片下载完成"
);
return Ok(images);
}
let b64_images = extract_puzzle_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(puzzle_images_from_base64(
format!("vector-engine-{}", current_utc_micros()),
b64_images,
candidate_count,
));
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图 VectorEngine 图片生成未返回图片地址",
})),
&[],
"拼图 VectorEngine 图片生成失败",
)
.await?;
Ok(PuzzleGeneratedImages {
task_id: generated.task_id,
images: generated
.images
.into_iter()
.map(PuzzleDownloadedImage::from_openai_image)
.collect(),
})
}
pub(crate) async fn create_puzzle_vector_engine_image_edit(
http_client: &reqwest::Client,
settings: &PuzzleVectorEngineSettings,
image_model: PuzzleImageModel,
_image_model: PuzzleImageModel,
prompt: &str,
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: &PuzzleResolvedReferenceImage,
) -> Result<PuzzleGeneratedImages, AppError> {
let request_url = puzzle_vector_engine_images_edit_url(settings);
let task_id = format!("vector-engine-edit-{}", current_utc_micros());
let file_name = format!(
"puzzle-reference.{}",
puzzle_mime_to_extension(reference_image.mime_type.as_str())
);
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
.file_name(file_name)
.mime_str(reference_image.mime_type.as_str())
.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"构造拼图 VectorEngine 图片编辑参考图失败:{error}"
))
})?;
let form = reqwest::multipart::Form::new()
.part("image", image_part)
.text("model", image_model.request_model_name().to_string())
.text(
"prompt",
build_puzzle_vector_engine_prompt(prompt, negative_prompt),
)
.text("n", candidate_count.clamp(1, 1).to_string())
.text("size", size.to_string());
let request_started_at = Instant::now();
let response = http_client
.post(request_url.as_str())
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::ACCEPT, "application/json")
.multipart(form)
.send()
.await
.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"创建拼图 VectorEngine 图片编辑任务失败:{error}"
))
})?;
let status = response.status();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = image_model.request_model_name(),
endpoint = %request_url,
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
let generated = create_openai_image_edit_with_references(
http_client,
&settings.to_openai_settings(),
prompt,
Some(negative_prompt),
size,
reference_mime = %reference_image.mime_type,
reference_bytes = reference_image.bytes_len,
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
"拼图 VectorEngine 图片编辑 HTTP 返回"
);
let response_text = response.text().await.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"读取拼图 VectorEngine 图片编辑响应失败:{error}"
))
})?;
if !status.is_success() {
return Err(map_puzzle_vector_engine_upstream_error(
status,
response_text.as_str(),
"创建拼图 VectorEngine 图片编辑任务失败",
));
}
let payload = parse_puzzle_json_payload(
response_text.as_str(),
"解析拼图 VectorEngine 图片编辑响应失败",
)?;
let image_urls = extract_puzzle_image_urls(&payload);
if !image_urls.is_empty() {
return download_puzzle_images_from_urls(http_client, task_id, image_urls, candidate_count)
.await;
}
let b64_images = extract_puzzle_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(puzzle_images_from_base64(
task_id,
b64_images,
candidate_count,
));
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图 VectorEngine 图片编辑未返回图片",
})),
candidate_count,
&[OpenAiReferenceImage {
bytes: reference_image.bytes.clone(),
mime_type: reference_image.mime_type.clone(),
file_name,
}],
"拼图 VectorEngine 图片编辑失败",
)
.await?;
Ok(PuzzleGeneratedImages {
task_id: generated.task_id,
images: generated
.images
.into_iter()
.map(PuzzleDownloadedImage::from_openai_image)
.collect(),
})
}
pub(crate) fn build_puzzle_downloaded_image_reference(
@@ -569,42 +452,6 @@ pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &
format!("{prompt}\n避免:{negative_prompt}")
}
pub(crate) fn puzzle_vector_engine_images_generation_url(
settings: &PuzzleVectorEngineSettings,
) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/generations", settings.base_url)
} else {
format!("{}/v1/images/generations", settings.base_url)
}
}
pub(crate) fn puzzle_vector_engine_images_edit_url(
settings: &PuzzleVectorEngineSettings,
) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/edits", settings.base_url)
} else {
format!("{}/v1/images/edits", settings.base_url)
}
}
pub(crate) async fn download_puzzle_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
image_urls: Vec<String>,
candidate_count: u32,
) -> Result<PuzzleGeneratedImages, AppError> {
let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 1) as usize)
{
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
}
Ok(PuzzleGeneratedImages { task_id, images })
}
pub(crate) fn parse_puzzle_asset_object_reference(source: &str) -> Option<&str> {
source
.trim()
@@ -643,15 +490,13 @@ pub(crate) async fn resolve_puzzle_reference_image(
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
let bytes_len = parsed.bytes.len();
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图过大,请压缩后重试。",
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
"actualBytes": bytes_len,
})),
);
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": build_puzzle_reference_image_too_large_message(bytes_len),
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
"actualBytes": bytes_len,
})));
}
return Ok(PuzzleResolvedReferenceImage {
mime_type: parsed.mime_type,
@@ -803,16 +648,16 @@ pub(crate) fn validate_puzzle_reference_asset_object(
if asset_object.content_length == 0
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
{
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产大小不符合拼图生成要求。",
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
"actualBytes": asset_object.content_length,
})),
);
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": build_puzzle_reference_image_too_large_message(
asset_object.content_length as usize,
),
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
"actualBytes": asset_object.content_length,
})));
}
if let Some(expected_owner_user_id) = owner_user_id
.map(str::trim)
@@ -892,40 +737,6 @@ async fn download_signed_puzzle_reference_image(
})
}
pub(crate) async fn download_puzzle_remote_image(
http_client: &reqwest::Client,
image_url: &str,
) -> Result<PuzzleDownloadedImage, AppError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
map_puzzle_image_request_error(format!("下载拼图正式图片失败:{error}"))
})?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let bytes = response.bytes().await.map_err(|error| {
map_puzzle_image_request_error(format!("读取拼图正式图片内容失败:{error}"))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "puzzle-image",
"message": "下载拼图正式图片失败",
"status": status.as_u16(),
})),
);
}
let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str());
Ok(PuzzleDownloadedImage {
extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes: bytes.to_vec(),
})
}
pub(crate) async fn persist_puzzle_generated_asset(
state: &PuzzleApiState,
owner_user_id: &str,
@@ -1199,18 +1010,6 @@ pub(crate) fn build_puzzle_level_asset_metadata(
])
}
pub(crate) fn parse_puzzle_json_payload(
raw_text: &str,
fallback_message: &str,
) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{fallback_message}{error}"),
}))
})
}
pub(crate) fn parse_puzzle_image_data_url(value: &str) -> Option<ParsedPuzzleImageDataUrl> {
let body = value.strip_prefix("data:")?;
let (mime_type, data) = body.split_once(";base64,")?;
@@ -1251,49 +1050,6 @@ pub(crate) fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
Some(output)
}
pub(crate) fn extract_puzzle_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_puzzle_strings_by_key(payload, "image", &mut urls);
collect_puzzle_strings_by_key(payload, "url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
pub(crate) fn extract_puzzle_b64_images(payload: &Value) -> Vec<String> {
let mut values = Vec::new();
collect_puzzle_strings_by_key(payload, "b64_json", &mut values);
values
}
pub(crate) fn puzzle_images_from_base64(
task_id: String,
b64_images: Vec<String>,
candidate_count: u32,
) -> PuzzleGeneratedImages {
let images = b64_images
.into_iter()
.take(candidate_count.clamp(1, 1) as usize)
.filter_map(|raw| decode_puzzle_generated_image_base64(raw.as_str()))
.collect();
PuzzleGeneratedImages { task_id, images }
}
pub(crate) fn decode_puzzle_generated_image_base64(raw: &str) -> Option<PuzzleDownloadedImage> {
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
let mime_type = infer_puzzle_image_mime_type(bytes.as_slice());
Some(PuzzleDownloadedImage {
extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes,
})
}
pub(crate) fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_puzzle_strings_by_key(payload, target_key, &mut results);
@@ -1335,22 +1091,6 @@ pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec<St
}
}
pub(crate) fn infer_puzzle_image_mime_type(bytes: &[u8]) -> String {
if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
return "image/png".to_string();
}
if bytes.starts_with(b"\xFF\xD8\xFF") {
return "image/jpeg".to_string();
}
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
return "image/webp".to_string();
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return "image/gif".to_string();
}
"image/png".to_string()
}
pub(crate) fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
@@ -1389,21 +1129,6 @@ pub(crate) fn map_puzzle_image_request_error(message: String) -> AppError {
}))
}
pub(crate) fn map_puzzle_vector_engine_request_error(message: String) -> AppError {
let is_timeout = is_puzzle_request_timeout_message(message.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
"timeout": is_timeout,
}))
}
pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("timed out")
@@ -1412,64 +1137,6 @@ pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool {
|| lower.contains("deadline has elapsed")
}
pub(crate) fn map_puzzle_vector_engine_upstream_error(
upstream_status: reqwest::StatusCode,
raw_text: &str,
fallback_message: &str,
) -> AppError {
let message = parse_puzzle_api_error_message(raw_text, fallback_message);
let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800);
let is_timeout = is_puzzle_request_timeout_message(message.as_str())
|| is_puzzle_request_timeout_message(raw_excerpt.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
upstream_status = upstream_status.as_u16(),
timeout = is_timeout,
message = %message,
raw_excerpt = %raw_excerpt,
"拼图 VectorEngine 上游请求失败"
);
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"upstreamStatus": upstream_status.as_u16(),
"message": message,
"rawExcerpt": raw_excerpt,
"timeout": is_timeout,
}))
}
pub(crate) fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> String {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return fallback_message.to_string();
}
if let Ok(payload) = serde_json::from_str::<Value>(trimmed)
&& let Some(message) = find_first_puzzle_string_by_key(&payload, "message")
{
return message;
}
fallback_message.to_string()
}
pub(crate) fn trim_puzzle_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
let normalized = raw_text.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.chars().count() <= max_chars {
return normalized;
}
let keep_chars = max_chars.saturating_sub(3);
format!(
"{}...",
normalized.chars().take(keep_chars).collect::<String>()
)
}
pub(crate) fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}

View File

@@ -516,6 +516,10 @@ impl AppState {
visible: enabled,
open: enabled,
sort_order: i32::try_from(config.creation_types.len()).unwrap_or(i32::MAX),
category_id: module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string(),
category_label: module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL
.to_string(),
category_sort_order: 0,
updated_at_micros: 0,
},
);

View File

@@ -32,7 +32,7 @@ use crate::generated_image_assets::{
};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client,
@@ -220,14 +220,14 @@ pub async fn get_wooden_fish_runtime_work(
pub async fn start_wooden_fish_run(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<WoodenFishStartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
let run = state
.spacetime_client()
.start_wooden_fish_run(payload, authenticated.claims().user_id().to_string())
.start_wooden_fish_run(payload, principal.subject().to_string())
.await
.map_err(|error| {
wooden_fish_error_response(
@@ -247,7 +247,7 @@ pub async fn checkpoint_wooden_fish_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<WoodenFishCheckpointRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
@@ -256,7 +256,7 @@ pub async fn checkpoint_wooden_fish_run(
.spacetime_client()
.checkpoint_wooden_fish_run(
run_id,
authenticated.claims().user_id().to_string(),
principal.subject().to_string(),
payload,
)
.await
@@ -278,7 +278,7 @@ pub async fn finish_wooden_fish_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<WoodenFishFinishRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
@@ -287,7 +287,7 @@ pub async fn finish_wooden_fish_run(
.spacetime_client()
.finish_wooden_fish_run(
run_id,
authenticated.claims().user_id().to_string(),
principal.subject().to_string(),
payload,
)
.await

View File

@@ -2,7 +2,7 @@ use module_runtime::RuntimeTrackingScopeKind;
use serde_json::{Value, json};
use crate::{
auth::AuthenticatedAccessToken,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
request_context::RequestContext,
state::{AppState, PuzzleApiState},
tracking::{TrackingEventDraft, record_tracking_event_after_success},
@@ -36,12 +36,28 @@ impl WorkPlayTrackingDraft {
)
}
pub(crate) fn anonymous(
pub(crate) fn runtime_principal(
play_type: &'static str,
work_id: impl Into<String>,
principal: &RuntimePrincipal,
source_route: &'static str,
) -> Self {
Self::with_user_id(play_type, work_id, None, source_route)
match principal {
RuntimePrincipal::User(authenticated) => {
Self::new(play_type, work_id, authenticated, source_route)
}
RuntimePrincipal::Guest(claims) => Self::with_user_id(
play_type,
work_id,
Some(claims.subject().to_string()),
source_route,
)
.extra(json!({
"principalKind": "guest",
"guestSubject": claims.subject(),
"guestScope": claims.scope(),
})),
}
}
fn with_user_id(