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:
12
server-rs/Cargo.lock
generated
12
server-rs/Cargo.lock
generated
@@ -108,6 +108,7 @@ dependencies = [
|
||||
"opentelemetry",
|
||||
"platform-agent",
|
||||
"platform-auth",
|
||||
"platform-image",
|
||||
"platform-llm",
|
||||
"platform-oss",
|
||||
"platform-speech",
|
||||
@@ -2321,6 +2322,17 @@ dependencies = [
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platform-image"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"reqwest 0.12.28",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platform-llm"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -32,6 +32,7 @@ members = [
|
||||
"crates/module-visual-novel",
|
||||
"crates/platform-oss",
|
||||
"crates/platform-auth",
|
||||
"crates/platform-image",
|
||||
"crates/platform-llm",
|
||||
"crates/platform-speech",
|
||||
"crates/platform-agent",
|
||||
@@ -74,6 +75,7 @@ module-story = { path = "crates/module-story", default-features = false }
|
||||
module-visual-novel = { path = "crates/module-visual-novel", default-features = false }
|
||||
platform-agent = { path = "crates/platform-agent", default-features = false }
|
||||
platform-auth = { path = "crates/platform-auth", default-features = false }
|
||||
platform-image = { path = "crates/platform-image", default-features = false }
|
||||
platform-llm = { path = "crates/platform-llm", default-features = false }
|
||||
platform-oss = { path = "crates/platform-oss", default-features = false }
|
||||
platform-speech = { path = "crates/platform-speech", default-features = false }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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", "请求参数不合法"),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::domain::*;
|
||||
use crate::errors::RuntimeProfileFieldError;
|
||||
use crate::format_utc_micros;
|
||||
use shared_contracts::creation_entry_config::{
|
||||
CreationEntryConfigResponse, CreationEntryStartCardResponse, CreationEntryTypeModalResponse,
|
||||
CreationEntryTypeResponse,
|
||||
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
|
||||
};
|
||||
|
||||
pub fn build_creation_entry_config_response(
|
||||
@@ -28,6 +28,14 @@ pub fn build_creation_entry_config_response(
|
||||
title: snapshot.type_modal.title,
|
||||
description: snapshot.type_modal.description,
|
||||
},
|
||||
event_banner: CreationEntryEventBannerResponse {
|
||||
title: snapshot.event_banner.title,
|
||||
description: snapshot.event_banner.description,
|
||||
cover_image_src: snapshot.event_banner.cover_image_src,
|
||||
prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points,
|
||||
starts_at_text: snapshot.event_banner.starts_at_text,
|
||||
ends_at_text: snapshot.event_banner.ends_at_text,
|
||||
},
|
||||
creation_types: snapshot
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -40,6 +48,9 @@ pub fn build_creation_entry_config_response(
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: item.category_id,
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
})
|
||||
.collect(),
|
||||
@@ -59,6 +70,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
10,
|
||||
"recent",
|
||||
"最近创作",
|
||||
10,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -70,6 +84,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
false,
|
||||
true,
|
||||
20,
|
||||
"recommended",
|
||||
"热门推荐",
|
||||
20,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -81,6 +98,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
30,
|
||||
"recent",
|
||||
"最近创作",
|
||||
10,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -92,6 +112,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
40,
|
||||
"recent",
|
||||
"最近创作",
|
||||
10,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -103,6 +126,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
45,
|
||||
"recommended",
|
||||
"热门推荐",
|
||||
20,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -114,6 +140,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
47,
|
||||
"festival",
|
||||
"节日主题",
|
||||
30,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -125,6 +154,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
false,
|
||||
true,
|
||||
50,
|
||||
"material",
|
||||
"材质工艺",
|
||||
60,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -136,6 +168,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
false,
|
||||
60,
|
||||
"scene",
|
||||
"生活场景",
|
||||
50,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -147,6 +182,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
false,
|
||||
70,
|
||||
"character",
|
||||
"角色创作",
|
||||
40,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -158,6 +196,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
false,
|
||||
true,
|
||||
80,
|
||||
"recommended",
|
||||
"热门推荐",
|
||||
20,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -169,6 +210,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
85,
|
||||
"recommended",
|
||||
"热门推荐",
|
||||
20,
|
||||
updated_at_micros,
|
||||
),
|
||||
build_default_creation_entry_type_snapshot(
|
||||
@@ -180,6 +224,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
true,
|
||||
true,
|
||||
90,
|
||||
"character",
|
||||
"角色创作",
|
||||
40,
|
||||
updated_at_micros,
|
||||
),
|
||||
]
|
||||
@@ -195,6 +242,9 @@ fn build_default_creation_entry_type_snapshot(
|
||||
visible: bool,
|
||||
open: bool,
|
||||
sort_order: i32,
|
||||
category_id: &str,
|
||||
category_label: &str,
|
||||
category_sort_order: i32,
|
||||
updated_at_micros: i64,
|
||||
) -> CreationEntryTypeSnapshot {
|
||||
CreationEntryTypeSnapshot {
|
||||
@@ -206,6 +256,9 @@ fn build_default_creation_entry_type_snapshot(
|
||||
visible,
|
||||
open,
|
||||
sort_order,
|
||||
category_id: category_id.to_string(),
|
||||
category_label: category_label.to_string(),
|
||||
category_sort_order,
|
||||
updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,15 @@ pub const DEFAULT_CREATION_ENTRY_START_IDLE_BADGE: &str = "模板 Tab";
|
||||
pub const DEFAULT_CREATION_ENTRY_START_BUSY_BADGE: &str = "正在开启";
|
||||
pub const DEFAULT_CREATION_ENTRY_MODAL_TITLE: &str = "选择创作类型";
|
||||
pub const DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION: &str = "先选玩法类型,再进入对应创作工作台。";
|
||||
pub const DEFAULT_CREATION_ENTRY_CATEGORY_ID: &str = "recent";
|
||||
pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "最近创作";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_TITLE: &str = "主题创作赛";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION: &str = "用温暖的色彩,捏出秋天的故事。";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str =
|
||||
"/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS: u64 = 58_000;
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT: &str = "2024.10.20 10:00";
|
||||
pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59";
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -67,6 +76,17 @@ pub struct CreationEntryTypeModalSnapshot {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryEventBannerSnapshot {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub cover_image_src: String,
|
||||
pub prize_pool_mud_points: u64,
|
||||
pub starts_at_text: String,
|
||||
pub ends_at_text: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreationEntryTypeSnapshot {
|
||||
@@ -78,6 +98,9 @@ pub struct CreationEntryTypeSnapshot {
|
||||
pub visible: bool,
|
||||
pub open: bool,
|
||||
pub sort_order: i32,
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -87,6 +110,7 @@ pub struct CreationEntryConfigSnapshot {
|
||||
pub config_id: String,
|
||||
pub start_card: CreationEntryStartCardSnapshot,
|
||||
pub type_modal: CreationEntryTypeModalSnapshot,
|
||||
pub event_banner: CreationEntryEventBannerSnapshot,
|
||||
pub creation_types: Vec<CreationEntryTypeSnapshot>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
@@ -102,6 +126,9 @@ pub struct CreationEntryTypeAdminUpsertInput {
|
||||
pub visible: bool,
|
||||
pub open: bool,
|
||||
pub sort_order: i32,
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
|
||||
@@ -230,6 +230,9 @@ mod tests {
|
||||
assert!(baby_object_match.open);
|
||||
assert_eq!(baby_object_match.badge, "可创建");
|
||||
assert_eq!(baby_object_match.sort_order, 90);
|
||||
assert_eq!(baby_object_match.category_id, "character");
|
||||
assert_eq!(baby_object_match.category_label, "角色创作");
|
||||
assert_eq!(baby_object_match.category_sort_order, 40);
|
||||
assert_eq!(
|
||||
baby_object_match.image_src,
|
||||
"/child-motion-demo/picture-book-grass-stage.png"
|
||||
@@ -250,6 +253,8 @@ mod tests {
|
||||
assert!(rpg.open);
|
||||
assert_eq!(rpg.badge, "可创建");
|
||||
assert_eq!(rpg.sort_order, 10);
|
||||
assert_eq!(rpg.category_id, "recent");
|
||||
assert_eq!(rpg.category_label, "最近创作");
|
||||
assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp");
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ use url::Url;
|
||||
|
||||
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
|
||||
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
|
||||
pub const DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS: u64 = 15 * 60;
|
||||
pub const RUNTIME_GUEST_TOKEN_TYPE: &str = "runtime_guest";
|
||||
pub const RUNTIME_GUEST_SCOPE_PUBLIC_PLAY: &str = "runtime:public-play";
|
||||
pub const DEFAULT_REFRESH_COOKIE_NAME: &str = "genarrative_refresh_session";
|
||||
pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth";
|
||||
pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30;
|
||||
@@ -107,6 +110,21 @@ pub struct AccessTokenClaims {
|
||||
pub exp: u64,
|
||||
}
|
||||
|
||||
pub struct RuntimeGuestTokenClaimsInput {
|
||||
pub subject: String,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeGuestTokenClaims {
|
||||
pub iss: String,
|
||||
pub sub: String,
|
||||
pub typ: String,
|
||||
pub scope: String,
|
||||
pub iat: u64,
|
||||
pub exp: u64,
|
||||
}
|
||||
|
||||
// 统一承载 JWT 配置,避免 secret、issuer、ttl 在 api-server 与后续模块里散落。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct JwtConfig {
|
||||
@@ -417,6 +435,10 @@ impl JwtConfig {
|
||||
pub fn access_token_ttl_seconds(&self) -> u64 {
|
||||
self.access_token_ttl_seconds
|
||||
}
|
||||
|
||||
pub fn runtime_guest_token_ttl_seconds(&self) -> u64 {
|
||||
DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
impl RefreshCookieSameSite {
|
||||
@@ -1474,6 +1496,74 @@ impl AccessTokenClaims {
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeGuestTokenClaims {
|
||||
pub fn from_input(
|
||||
input: RuntimeGuestTokenClaimsInput,
|
||||
config: &JwtConfig,
|
||||
issued_at: OffsetDateTime,
|
||||
) -> Result<Self, JwtError> {
|
||||
let subject = normalize_required_field(input.subject, "runtime guest JWT sub 不能为空")?;
|
||||
let scope = normalize_required_field(input.scope, "runtime guest JWT scope 不能为空")?;
|
||||
|
||||
let issued_at_unix = issued_at.unix_timestamp();
|
||||
if issued_at_unix < 0 {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT iat 不能早于 Unix epoch"));
|
||||
}
|
||||
|
||||
let expires_at = issued_at
|
||||
.checked_add(Duration::seconds(
|
||||
i64::try_from(config.runtime_guest_token_ttl_seconds()).map_err(|_| {
|
||||
JwtError::InvalidConfig("runtime guest JWT 过期时间超出 i64 上限")
|
||||
})?,
|
||||
))
|
||||
.ok_or(JwtError::InvalidConfig("runtime guest JWT 过期时间计算溢出"))?;
|
||||
let expires_at_unix = expires_at.unix_timestamp();
|
||||
if expires_at_unix <= issued_at_unix {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat"));
|
||||
}
|
||||
|
||||
let claims = Self {
|
||||
iss: config.issuer().to_string(),
|
||||
sub: subject,
|
||||
typ: RUNTIME_GUEST_TOKEN_TYPE.to_string(),
|
||||
scope,
|
||||
iat: issued_at_unix as u64,
|
||||
exp: expires_at_unix as u64,
|
||||
};
|
||||
claims.validate_for_config(config)?;
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
pub fn subject(&self) -> &str {
|
||||
&self.sub
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> &str {
|
||||
&self.scope
|
||||
}
|
||||
|
||||
pub fn expires_at_unix(&self) -> u64 {
|
||||
self.exp
|
||||
}
|
||||
|
||||
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
|
||||
if self.iss.trim() != config.issuer() {
|
||||
return Err(JwtError::InvalidClaims(
|
||||
"runtime guest JWT iss 与当前配置不一致",
|
||||
));
|
||||
}
|
||||
normalize_required_field(self.sub.clone(), "runtime guest JWT sub 不能为空")?;
|
||||
normalize_required_field(self.scope.clone(), "runtime guest JWT scope 不能为空")?;
|
||||
if self.typ.trim() != RUNTIME_GUEST_TOKEN_TYPE {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT typ 非法"));
|
||||
}
|
||||
if self.exp <= self.iat {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AccessTokenDeviceInfo {
|
||||
pub fn normalize(self) -> Result<Self, JwtError> {
|
||||
Ok(Self {
|
||||
@@ -1526,6 +1616,26 @@ pub fn sign_access_token(
|
||||
.map_err(|error| JwtError::SignFailed(format!("JWT 签发失败:{error}")))
|
||||
}
|
||||
|
||||
pub fn sign_runtime_guest_token(
|
||||
claims: &RuntimeGuestTokenClaims,
|
||||
config: &JwtConfig,
|
||||
) -> Result<String, JwtError> {
|
||||
claims.validate_for_config(config)?;
|
||||
|
||||
let header = Header {
|
||||
alg: ACCESS_TOKEN_ALGORITHM,
|
||||
typ: Some("JWT".to_string()),
|
||||
..Header::default()
|
||||
};
|
||||
|
||||
encode(
|
||||
&header,
|
||||
claims,
|
||||
&EncodingKey::from_secret(config.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|error| JwtError::SignFailed(format!("runtime guest JWT 签发失败:{error}")))
|
||||
}
|
||||
|
||||
pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, JwtError> {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
@@ -1552,6 +1662,35 @@ pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessToke
|
||||
Ok(decoded.claims)
|
||||
}
|
||||
|
||||
pub fn verify_runtime_guest_token(
|
||||
token: &str,
|
||||
config: &JwtConfig,
|
||||
) -> Result<RuntimeGuestTokenClaims, JwtError> {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
return Err(JwtError::VerifyFailed("runtime guest JWT 不能为空".to_string()));
|
||||
}
|
||||
|
||||
let mut validation = Validation::new(ACCESS_TOKEN_ALGORITHM);
|
||||
validation.required_spec_claims = HashSet::from([
|
||||
"exp".to_string(),
|
||||
"iat".to_string(),
|
||||
"iss".to_string(),
|
||||
"sub".to_string(),
|
||||
]);
|
||||
validation.set_issuer(&[config.issuer()]);
|
||||
|
||||
let decoded = decode::<RuntimeGuestTokenClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(config.secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map_err(map_verify_error)?;
|
||||
|
||||
decoded.claims.validate_for_config(config)?;
|
||||
Ok(decoded.claims)
|
||||
}
|
||||
|
||||
pub fn read_refresh_session_token(
|
||||
cookie_header: &str,
|
||||
config: &RefreshCookieConfig,
|
||||
@@ -2218,6 +2357,30 @@ mod tests {
|
||||
.expect("real aliyun sms config should be valid")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_sign_and_verify_runtime_guest_token() {
|
||||
let config = build_jwt_config();
|
||||
let issued_at = OffsetDateTime::now_utc();
|
||||
let claims = RuntimeGuestTokenClaims::from_input(
|
||||
RuntimeGuestTokenClaimsInput {
|
||||
subject: "guest-runtime-123".to_string(),
|
||||
scope: RUNTIME_GUEST_SCOPE_PUBLIC_PLAY.to_string(),
|
||||
},
|
||||
&config,
|
||||
issued_at,
|
||||
)
|
||||
.expect("runtime guest claims should build");
|
||||
|
||||
let token = sign_runtime_guest_token(&claims, &config).expect("token should sign");
|
||||
let verified = verify_runtime_guest_token(&token, &config).expect("token should verify");
|
||||
|
||||
assert_eq!(verified, claims);
|
||||
assert_eq!(verified.subject(), "guest-runtime-123");
|
||||
assert_eq!(verified.scope(), RUNTIME_GUEST_SCOPE_PUBLIC_PLAY);
|
||||
assert_eq!(verified.typ, RUNTIME_GUEST_TOKEN_TYPE);
|
||||
assert_eq!(verified.expires_at_unix() - verified.iat, DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_sign_and_verify_access_token() {
|
||||
let config = build_jwt_config();
|
||||
|
||||
12
server-rs/crates/platform-image/Cargo.toml
Normal file
12
server-rs/crates/platform-image/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "platform-image"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
tracing = { workspace = true }
|
||||
1362
server-rs/crates/platform-image/src/lib.rs
Normal file
1362
server-rs/crates/platform-image/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ const OSS_V4_REQUEST: &str = "aliyun_v4_request";
|
||||
const OSS_V4_SERVICE: &str = "oss";
|
||||
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||||
|
||||
pub const LEGACY_PUBLIC_PREFIXES: [&str; 12] = [
|
||||
pub const LEGACY_PUBLIC_PREFIXES: [&str; 13] = [
|
||||
"generated-character-drafts",
|
||||
"generated-characters",
|
||||
"generated-animations",
|
||||
@@ -29,6 +29,7 @@ pub const LEGACY_PUBLIC_PREFIXES: [&str; 12] = [
|
||||
"generated-wooden-fish-assets",
|
||||
"generated-match3d-assets",
|
||||
"generated-puzzle-assets",
|
||||
"generated-jump-hop-assets",
|
||||
"generated-custom-world-scenes",
|
||||
"generated-custom-world-covers",
|
||||
"generated-bark-battle-assets",
|
||||
@@ -52,6 +53,7 @@ pub enum LegacyAssetPrefix {
|
||||
WoodenFishAssets,
|
||||
Match3DAssets,
|
||||
PuzzleAssets,
|
||||
JumpHopAssets,
|
||||
CustomWorldScenes,
|
||||
CustomWorldCovers,
|
||||
BarkBattleAssets,
|
||||
@@ -241,6 +243,7 @@ impl LegacyAssetPrefix {
|
||||
"generated-wooden-fish-assets" => Some(Self::WoodenFishAssets),
|
||||
"generated-match3d-assets" => Some(Self::Match3DAssets),
|
||||
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
|
||||
"generated-jump-hop-assets" => Some(Self::JumpHopAssets),
|
||||
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
|
||||
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
|
||||
"generated-bark-battle-assets" => Some(Self::BarkBattleAssets),
|
||||
@@ -259,6 +262,7 @@ impl LegacyAssetPrefix {
|
||||
Self::WoodenFishAssets => "generated-wooden-fish-assets",
|
||||
Self::Match3DAssets => "generated-match3d-assets",
|
||||
Self::PuzzleAssets => "generated-puzzle-assets",
|
||||
Self::JumpHopAssets => "generated-jump-hop-assets",
|
||||
Self::CustomWorldScenes => "generated-custom-world-scenes",
|
||||
Self::CustomWorldCovers => "generated-custom-world-covers",
|
||||
Self::BarkBattleAssets => "generated-bark-battle-assets",
|
||||
|
||||
@@ -30,6 +30,9 @@ pub struct AdminCreationEntryTypeConfigPayload {
|
||||
pub visible: bool,
|
||||
pub open: bool,
|
||||
pub sort_order: i32,
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -45,6 +48,9 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
|
||||
pub visible: bool,
|
||||
pub open: bool,
|
||||
pub sort_order: i32,
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -42,6 +42,15 @@ pub struct PublicUserSearchResponse {
|
||||
pub user: PublicUserSummaryPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeGuestTokenResponse {
|
||||
pub token: String,
|
||||
pub expires_at: String,
|
||||
pub subject: String,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryRequest {
|
||||
|
||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct CreationEntryConfigResponse {
|
||||
pub start_card: CreationEntryStartCardResponse,
|
||||
pub type_modal: CreationEntryTypeModalResponse,
|
||||
pub event_banner: CreationEntryEventBannerResponse,
|
||||
pub creation_types: Vec<CreationEntryTypeResponse>,
|
||||
}
|
||||
|
||||
@@ -24,6 +25,17 @@ pub struct CreationEntryTypeModalResponse {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryEventBannerResponse {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub cover_image_src: String,
|
||||
pub prize_pool_mud_points: u64,
|
||||
pub starts_at_text: String,
|
||||
pub ends_at_text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryTypeResponse {
|
||||
@@ -35,5 +47,8 @@ pub struct CreationEntryTypeResponse {
|
||||
pub visible: bool,
|
||||
pub open: bool,
|
||||
pub sort_order: i32,
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ pub struct JumpHopWorkspaceCreateRequest {
|
||||
pub struct JumpHopActionRequest {
|
||||
pub action_type: JumpHopActionType,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub work_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub work_description: Option<String>,
|
||||
@@ -102,6 +104,14 @@ pub struct JumpHopActionRequest {
|
||||
pub tile_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub end_mood_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub character_asset: Option<JumpHopCharacterAsset>,
|
||||
#[serde(default)]
|
||||
pub tile_atlas_asset: Option<JumpHopCharacterAsset>,
|
||||
#[serde(default)]
|
||||
pub tile_assets: Option<Vec<JumpHopTileAsset>>,
|
||||
#[serde(default)]
|
||||
pub cover_composite: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -226,8 +226,11 @@ impl SpacetimeClient {
|
||||
&self,
|
||||
profile_id: String,
|
||||
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
||||
self.get_jump_hop_work_profile(profile_id, String::new())
|
||||
.await
|
||||
let work = self
|
||||
.get_jump_hop_work_profile(profile_id, String::new())
|
||||
.await?;
|
||||
validate_jump_hop_runtime_ready(&work)?;
|
||||
Ok(work)
|
||||
}
|
||||
|
||||
pub async fn start_jump_hop_run(
|
||||
@@ -235,12 +238,17 @@ impl SpacetimeClient {
|
||||
payload: JumpHopStartRunRequest,
|
||||
owner_user_id: String,
|
||||
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
|
||||
let profile_id = payload.profile_id;
|
||||
let work = self
|
||||
.get_jump_hop_work_profile(profile_id.clone(), String::new())
|
||||
.await?;
|
||||
validate_jump_hop_runtime_ready(&work)?;
|
||||
let run_id = build_prefixed_uuid_id("jump-hop-run-");
|
||||
let procedure_input = JumpHopRunStartInput {
|
||||
client_event_id: format!("{run_id}:start"),
|
||||
run_id,
|
||||
owner_user_id,
|
||||
profile_id: payload.profile_id,
|
||||
profile_id,
|
||||
started_at_ms: current_unix_micros().div_euclid(1000),
|
||||
};
|
||||
self.start_jump_hop_run_with_input(procedure_input).await
|
||||
@@ -372,11 +380,91 @@ impl SpacetimeClient {
|
||||
&self,
|
||||
public_work_code: String,
|
||||
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
||||
self.get_jump_hop_work_profile(public_work_code, String::new())
|
||||
let gallery = self.list_jump_hop_gallery().await?;
|
||||
let requested_code = normalize_jump_hop_public_work_code(public_work_code.as_str());
|
||||
let card = gallery
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|item| {
|
||||
normalize_jump_hop_public_work_code(item.public_work_code.as_str()) == requested_code
|
||||
})
|
||||
.ok_or_else(|| SpacetimeClientError::Procedure("jump_hop public work 不存在".to_string()))?;
|
||||
|
||||
self.get_jump_hop_work_profile(card.profile_id, String::new())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn validate_jump_hop_runtime_ready(
|
||||
work: &JumpHopWorkProfileResponse,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
let status = work.summary.publication_status.trim().to_ascii_lowercase();
|
||||
if status != "published" {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 只能启动已发布作品",
|
||||
));
|
||||
}
|
||||
if work.summary.generation_status != JumpHopGenerationStatus::Ready {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 需要 ready 状态作品",
|
||||
));
|
||||
}
|
||||
validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?;
|
||||
validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
|
||||
if work.tile_assets.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 缺少地块资产",
|
||||
));
|
||||
}
|
||||
for (index, asset) in work.tile_assets.iter().enumerate() {
|
||||
if asset.image_src.trim().is_empty()
|
||||
|| asset.image_object_key.trim().is_empty()
|
||||
|| asset.asset_object_id.trim().is_empty()
|
||||
{
|
||||
return Err(SpacetimeClientError::validation_failed(format!(
|
||||
"jump-hop runtime 地块资产 #{index} 不完整"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if work.path.platforms.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 缺少可玩路径",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_jump_hop_character_asset_ready(
|
||||
asset: &JumpHopCharacterAsset,
|
||||
field: &str,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
if asset.image_src.trim().is_empty()
|
||||
|| asset.image_object_key.trim().is_empty()
|
||||
|| asset.asset_object_id.trim().is_empty()
|
||||
{
|
||||
return Err(SpacetimeClientError::validation_failed(format!(
|
||||
"jump-hop runtime {field} 不完整"
|
||||
)));
|
||||
}
|
||||
if asset.generation_provider.trim().is_empty()
|
||||
|| asset.generation_provider == "deterministic-placeholder"
|
||||
{
|
||||
return Err(SpacetimeClientError::validation_failed(format!(
|
||||
"jump-hop runtime {field} 不是可用真实生成资产"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_jump_hop_public_work_code(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.map(|character| character.to_ascii_uppercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
enum JumpHopActionProcedure {
|
||||
Compile(JumpHopDraftCompileInput),
|
||||
Update(JumpHopWorkUpdateInput),
|
||||
@@ -503,22 +591,61 @@ fn merge_action_into_draft(
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
||||
) && let Some(value) = payload
|
||||
.character_prompt
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.character_prompt = value.trim().to_string();
|
||||
) {
|
||||
if let Some(value) = payload
|
||||
.character_prompt
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.character_prompt = value.trim().to_string();
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
|
||||
) && let Some(value) = payload
|
||||
.tile_prompt
|
||||
) {
|
||||
if let Some(value) = payload
|
||||
.tile_prompt
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.tile_prompt = value.trim().to_string();
|
||||
}
|
||||
}
|
||||
if let Some(profile_id) = payload
|
||||
.profile_id
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
draft.tile_prompt = value.trim().to_string();
|
||||
draft.profile_id = Some(profile_id.to_string());
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
||||
) {
|
||||
if let Some(asset) = payload.character_asset.clone() {
|
||||
draft.character_asset = Some(asset);
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
|
||||
) {
|
||||
if let Some(asset) = payload.tile_atlas_asset.clone() {
|
||||
draft.tile_atlas_asset = Some(asset);
|
||||
}
|
||||
if let Some(assets) = payload.tile_assets.clone() {
|
||||
draft.tile_assets = assets;
|
||||
}
|
||||
}
|
||||
if let Some(value) = payload
|
||||
.cover_composite
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
draft.cover_composite = Some(value.to_string());
|
||||
}
|
||||
if draft.work_title.trim().is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
@@ -545,31 +672,30 @@ fn build_compile_input(
|
||||
draft.tile_atlas_asset = None;
|
||||
draft.tile_assets.clear();
|
||||
}
|
||||
let character_asset = ensure_character_asset(
|
||||
draft.character_asset.clone(),
|
||||
let character_asset = draft.character_asset.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object",
|
||||
)
|
||||
})?;
|
||||
let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object",
|
||||
)
|
||||
})?;
|
||||
let tile_assets = if draft.tile_assets.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object",
|
||||
));
|
||||
} else {
|
||||
draft.tile_assets.clone()
|
||||
};
|
||||
let cover_composite = resolve_cover_composite(
|
||||
draft,
|
||||
profile_id,
|
||||
&draft.character_prompt,
|
||||
force_character,
|
||||
refresh,
|
||||
now_micros,
|
||||
);
|
||||
let tile_atlas_asset = ensure_tile_atlas_asset(
|
||||
draft.tile_atlas_asset.clone(),
|
||||
profile_id,
|
||||
&draft.tile_prompt,
|
||||
force_tiles,
|
||||
now_micros,
|
||||
);
|
||||
let tile_assets = ensure_tile_assets(
|
||||
draft.tile_assets.clone(),
|
||||
profile_id,
|
||||
force_tiles,
|
||||
now_micros,
|
||||
);
|
||||
let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros);
|
||||
|
||||
draft.character_asset = Some(character_asset.clone());
|
||||
draft.tile_atlas_asset = Some(tile_atlas_asset.clone());
|
||||
draft.tile_assets = tile_assets.clone();
|
||||
draft.cover_composite = cover_composite.clone();
|
||||
draft.generation_status = JumpHopGenerationStatus::Ready;
|
||||
|
||||
@@ -698,8 +824,10 @@ fn ensure_character_asset(
|
||||
force_new: bool,
|
||||
now_micros: i64,
|
||||
) -> JumpHopCharacterAsset {
|
||||
if !force_new && let Some(asset) = existing {
|
||||
return asset;
|
||||
if !force_new {
|
||||
if let Some(asset) = existing {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
let revision = force_new.then_some(now_micros);
|
||||
let suffix = asset_revision_suffix(revision);
|
||||
@@ -722,8 +850,10 @@ fn ensure_tile_atlas_asset(
|
||||
force_new: bool,
|
||||
now_micros: i64,
|
||||
) -> JumpHopCharacterAsset {
|
||||
if !force_new && let Some(asset) = existing {
|
||||
return asset;
|
||||
if !force_new {
|
||||
if let Some(asset) = existing {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
let revision = force_new.then_some(now_micros);
|
||||
let suffix = asset_revision_suffix(revision);
|
||||
@@ -781,14 +911,15 @@ fn resolve_cover_composite(
|
||||
refresh: JumpHopAssetRefresh,
|
||||
now_micros: i64,
|
||||
) -> Option<String> {
|
||||
if matches!(refresh, JumpHopAssetRefresh::Preserve)
|
||||
&& let Some(value) = draft
|
||||
if matches!(refresh, JumpHopAssetRefresh::Preserve) {
|
||||
if let Some(value) = draft
|
||||
.cover_composite
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(value.to_string());
|
||||
{
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
let suffix = asset_revision_suffix(
|
||||
(!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros),
|
||||
|
||||
@@ -11,6 +11,9 @@ impl From<module_runtime::CreationEntryTypeAdminUpsertInput> for CreationEntryTy
|
||||
visible: input.visible,
|
||||
open: input.open,
|
||||
sort_order: input.sort_order,
|
||||
category_id: input.category_id,
|
||||
category_label: input.category_label,
|
||||
category_sort_order: input.category_sort_order,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +154,29 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
title: header.modal_title,
|
||||
description: header.modal_description,
|
||||
},
|
||||
event_banner: module_runtime::CreationEntryEventBannerSnapshot {
|
||||
title: creation_entry_text_or_default(
|
||||
header.event_title,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE,
|
||||
),
|
||||
description: creation_entry_text_or_default(
|
||||
header.event_description,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION,
|
||||
),
|
||||
cover_image_src: creation_entry_text_or_default(
|
||||
header.event_cover_image_src,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC,
|
||||
),
|
||||
prize_pool_mud_points: header.event_prize_pool_mud_points,
|
||||
starts_at_text: creation_entry_text_or_default(
|
||||
header.event_starts_at_text,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT,
|
||||
),
|
||||
ends_at_text: creation_entry_text_or_default(
|
||||
header.event_ends_at_text,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT,
|
||||
),
|
||||
},
|
||||
creation_types: creation_types
|
||||
.into_iter()
|
||||
.map(|item| module_runtime::CreationEntryTypeSnapshot {
|
||||
@@ -162,6 +188,15 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: creation_entry_text_or_default(
|
||||
item.category_id,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID,
|
||||
),
|
||||
category_label: creation_entry_text_or_default(
|
||||
item.category_label,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL,
|
||||
),
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
|
||||
})
|
||||
.collect(),
|
||||
@@ -185,6 +220,14 @@ fn map_creation_entry_config_snapshot(
|
||||
title: snapshot.type_modal.title,
|
||||
description: snapshot.type_modal.description,
|
||||
},
|
||||
event_banner: module_runtime::CreationEntryEventBannerSnapshot {
|
||||
title: snapshot.event_banner.title,
|
||||
description: snapshot.event_banner.description,
|
||||
cover_image_src: snapshot.event_banner.cover_image_src,
|
||||
prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points,
|
||||
starts_at_text: snapshot.event_banner.starts_at_text,
|
||||
ends_at_text: snapshot.event_banner.ends_at_text,
|
||||
},
|
||||
creation_types: snapshot
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -197,6 +240,9 @@ fn map_creation_entry_config_snapshot(
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: item.category_id,
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
})
|
||||
.collect(),
|
||||
@@ -204,6 +250,13 @@ fn map_creation_entry_config_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
fn creation_entry_text_or_default(value: Option<String>, default_value: &str) -> String {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| default_value.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_setting_procedure_result(
|
||||
result: RuntimeSettingProcedureResult,
|
||||
) -> Result<RuntimeSettingsRecord, SpacetimeClientError> {
|
||||
|
||||
@@ -234,6 +234,7 @@ pub mod creation_entry_config_procedure_result_type;
|
||||
pub mod creation_entry_config_snapshot_type;
|
||||
pub mod creation_entry_config_table;
|
||||
pub mod creation_entry_config_type;
|
||||
pub mod creation_entry_event_banner_snapshot_type;
|
||||
pub mod creation_entry_start_card_snapshot_type;
|
||||
pub mod creation_entry_type_admin_upsert_input_type;
|
||||
pub mod creation_entry_type_config_table;
|
||||
@@ -1262,6 +1263,7 @@ pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedur
|
||||
pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot;
|
||||
pub use creation_entry_config_table::*;
|
||||
pub use creation_entry_config_type::CreationEntryConfig;
|
||||
pub use creation_entry_event_banner_snapshot_type::CreationEntryEventBannerSnapshot;
|
||||
pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot;
|
||||
pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput;
|
||||
pub use creation_entry_type_config_table::*;
|
||||
@@ -3285,10 +3287,6 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
&self.visual_novel_work_profile,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.profile_id);
|
||||
diff.bark_battle_gallery_view = cache.apply_diff_to_table::<BarkBattleGalleryViewRow>(
|
||||
"bark_battle_gallery_view",
|
||||
&self.bark_battle_gallery_view,
|
||||
);
|
||||
diff.wooden_fish_agent_session = cache
|
||||
.apply_diff_to_table::<WoodenFishAgentSessionRow>(
|
||||
"wooden_fish_agent_session",
|
||||
@@ -3310,6 +3308,10 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
&self.wooden_fish_work_profile,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.profile_id);
|
||||
diff.bark_battle_gallery_view = cache.apply_diff_to_table::<BarkBattleGalleryViewRow>(
|
||||
"bark_battle_gallery_view",
|
||||
&self.bark_battle_gallery_view,
|
||||
);
|
||||
diff.big_fish_gallery_view = cache.apply_diff_to_table::<BigFishWorkSummarySnapshot>(
|
||||
"big_fish_gallery_view",
|
||||
&self.big_fish_gallery_view,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::creation_entry_event_banner_snapshot_type::CreationEntryEventBannerSnapshot;
|
||||
use super::creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot;
|
||||
use super::creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot;
|
||||
use super::creation_entry_type_snapshot_type::CreationEntryTypeSnapshot;
|
||||
@@ -14,6 +15,7 @@ pub struct CreationEntryConfigSnapshot {
|
||||
pub config_id: String,
|
||||
pub start_card: CreationEntryStartCardSnapshot,
|
||||
pub type_modal: CreationEntryTypeModalSnapshot,
|
||||
pub event_banner: CreationEntryEventBannerSnapshot,
|
||||
pub creation_types: Vec<CreationEntryTypeSnapshot>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ pub struct CreationEntryConfig {
|
||||
pub modal_title: String,
|
||||
pub modal_description: String,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
pub event_title: Option<String>,
|
||||
pub event_description: Option<String>,
|
||||
pub event_cover_image_src: Option<String>,
|
||||
pub event_prize_pool_mud_points: u64,
|
||||
pub event_starts_at_text: Option<String>,
|
||||
pub event_ends_at_text: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryConfig {
|
||||
@@ -33,6 +39,12 @@ pub struct CreationEntryConfigCols {
|
||||
pub modal_title: __sdk::__query_builder::Col<CreationEntryConfig, String>,
|
||||
pub modal_description: __sdk::__query_builder::Col<CreationEntryConfig, String>,
|
||||
pub updated_at: __sdk::__query_builder::Col<CreationEntryConfig, __sdk::Timestamp>,
|
||||
pub event_title: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
pub event_description: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
pub event_cover_image_src: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
pub event_prize_pool_mud_points: __sdk::__query_builder::Col<CreationEntryConfig, u64>,
|
||||
pub event_starts_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
pub event_ends_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for CreationEntryConfig {
|
||||
@@ -47,6 +59,21 @@ impl __sdk::__query_builder::HasCols for CreationEntryConfig {
|
||||
modal_title: __sdk::__query_builder::Col::new(table_name, "modal_title"),
|
||||
modal_description: __sdk::__query_builder::Col::new(table_name, "modal_description"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
event_title: __sdk::__query_builder::Col::new(table_name, "event_title"),
|
||||
event_description: __sdk::__query_builder::Col::new(table_name, "event_description"),
|
||||
event_cover_image_src: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"event_cover_image_src",
|
||||
),
|
||||
event_prize_pool_mud_points: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"event_prize_pool_mud_points",
|
||||
),
|
||||
event_starts_at_text: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"event_starts_at_text",
|
||||
),
|
||||
event_ends_at_text: __sdk::__query_builder::Col::new(table_name, "event_ends_at_text"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct CreationEntryEventBannerSnapshot {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub cover_image_src: String,
|
||||
pub prize_pool_mud_points: u64,
|
||||
pub starts_at_text: String,
|
||||
pub ends_at_text: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryEventBannerSnapshot {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -15,6 +15,9 @@ pub struct CreationEntryTypeAdminUpsertInput {
|
||||
pub visible: bool,
|
||||
pub open: bool,
|
||||
pub sort_order: i32,
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryTypeAdminUpsertInput {
|
||||
|
||||
@@ -16,6 +16,9 @@ pub struct CreationEntryTypeConfig {
|
||||
pub open: bool,
|
||||
pub sort_order: i32,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
pub category_id: Option<String>,
|
||||
pub category_label: Option<String>,
|
||||
pub category_sort_order: i32,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryTypeConfig {
|
||||
@@ -35,6 +38,9 @@ pub struct CreationEntryTypeConfigCols {
|
||||
pub open: __sdk::__query_builder::Col<CreationEntryTypeConfig, bool>,
|
||||
pub sort_order: __sdk::__query_builder::Col<CreationEntryTypeConfig, i32>,
|
||||
pub updated_at: __sdk::__query_builder::Col<CreationEntryTypeConfig, __sdk::Timestamp>,
|
||||
pub category_id: __sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
|
||||
pub category_label: __sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
|
||||
pub category_sort_order: __sdk::__query_builder::Col<CreationEntryTypeConfig, i32>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
|
||||
@@ -50,6 +56,12 @@ impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
|
||||
open: __sdk::__query_builder::Col::new(table_name, "open"),
|
||||
sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
category_id: __sdk::__query_builder::Col::new(table_name, "category_id"),
|
||||
category_label: __sdk::__query_builder::Col::new(table_name, "category_label"),
|
||||
category_sort_order: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"category_sort_order",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ pub struct CreationEntryTypeSnapshot {
|
||||
pub visible: bool,
|
||||
pub open: bool,
|
||||
pub sort_order: i32,
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<JumpHopGall
|
||||
jump_hop_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(|row| JumpHopGalleryCardViewRow {
|
||||
public_work_code: row.work_id.clone(),
|
||||
public_work_code: build_jump_hop_public_work_code(&row.profile_id),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
owner_user_id: row.owner_user_id,
|
||||
@@ -658,6 +658,25 @@ fn build_gallery_view_row(row: &JumpHopWorkProfileRow) -> Result<JumpHopGalleryV
|
||||
})
|
||||
}
|
||||
|
||||
fn build_jump_hop_public_work_code(profile_id: &str) -> String {
|
||||
let normalized = profile_id
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(|character| character.to_uppercase())
|
||||
.collect::<String>();
|
||||
let fallback = if normalized.is_empty() {
|
||||
"00000000".to_string()
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
let suffix = if fallback.len() > 8 {
|
||||
fallback[fallback.len() - 8..].to_string()
|
||||
} else {
|
||||
format!("{fallback:0>8}")
|
||||
};
|
||||
format!("JH-{suffix}")
|
||||
}
|
||||
|
||||
fn build_session_snapshot(
|
||||
row: &JumpHopAgentSessionRow,
|
||||
) -> Result<JumpHopAgentSessionSnapshot, String> {
|
||||
|
||||
@@ -1159,6 +1159,43 @@ where
|
||||
|
||||
fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value {
|
||||
let mut next_value = value.clone();
|
||||
if table_name == "creation_entry_config" {
|
||||
if let Some(object) = next_value.as_object_mut() {
|
||||
// 中文注释:入口活动横幅字段晚于创作入口配置表加入,旧迁移包按运行态默认横幅兼容。
|
||||
object
|
||||
.entry("event_title".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("event_description".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("event_cover_image_src".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("event_prize_pool_mud_points".to_string())
|
||||
.or_insert_with(|| serde_json::Value::from(58_000));
|
||||
object
|
||||
.entry("event_starts_at_text".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("event_ends_at_text".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
if table_name == "creation_entry_type_config" {
|
||||
if let Some(object) = next_value.as_object_mut() {
|
||||
// 中文注释:入口分类字段晚于入口类型配置表加入,旧迁移包按未分类兼容。
|
||||
object
|
||||
.entry("category_id".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("category_label".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("category_sort_order".to_string())
|
||||
.or_insert_with(|| serde_json::Value::from(0));
|
||||
}
|
||||
}
|
||||
if table_name == "user_account" {
|
||||
if let Some(object) = next_value.as_object_mut() {
|
||||
// 中文注释:头像字段晚于认证拆表加入,旧迁移包按未设置头像兼容。
|
||||
|
||||
@@ -11,6 +11,18 @@ pub struct CreationEntryConfig {
|
||||
pub(crate) modal_title: String,
|
||||
pub(crate) modal_description: String,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) event_title: Option<String>,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) event_description: Option<String>,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) event_cover_image_src: Option<String>,
|
||||
#[default(DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS)]
|
||||
pub(crate) event_prize_pool_mud_points: u64,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) event_starts_at_text: Option<String>,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) event_ends_at_text: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
@@ -28,6 +40,12 @@ pub struct CreationEntryTypeConfig {
|
||||
pub(crate) open: bool,
|
||||
pub(crate) sort_order: i32,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) category_id: Option<String>,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) category_label: Option<String>,
|
||||
#[default(0)]
|
||||
pub(crate) category_sort_order: i32,
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
@@ -88,6 +106,9 @@ fn upsert_creation_entry_type_config_in_tx(
|
||||
open: input.open,
|
||||
sort_order: input.sort_order,
|
||||
updated_at: now,
|
||||
category_id: Some(normalize_category_id(&input.category_id)),
|
||||
category_label: Some(normalize_category_label(&input.category_label)),
|
||||
category_sort_order: input.category_sort_order,
|
||||
};
|
||||
if ctx.db.creation_entry_type_config().id().find(&id).is_some() {
|
||||
ctx.db.creation_entry_type_config().id().update(row);
|
||||
@@ -120,6 +141,9 @@ fn get_or_seed_creation_entry_config_snapshot(
|
||||
visible: row.visible,
|
||||
open: row.open,
|
||||
sort_order: row.sort_order,
|
||||
category_id: normalize_optional_category_id(row.category_id.as_deref()),
|
||||
category_label: normalize_optional_category_label(row.category_label.as_deref()),
|
||||
category_sort_order: row.category_sort_order,
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -141,6 +165,29 @@ fn get_or_seed_creation_entry_config_snapshot(
|
||||
title: header.modal_title,
|
||||
description: header.modal_description,
|
||||
},
|
||||
event_banner: CreationEntryEventBannerSnapshot {
|
||||
title: normalize_optional_text(
|
||||
header.event_title.as_deref(),
|
||||
DEFAULT_CREATION_ENTRY_EVENT_TITLE,
|
||||
),
|
||||
description: normalize_optional_text(
|
||||
header.event_description.as_deref(),
|
||||
DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION,
|
||||
),
|
||||
cover_image_src: normalize_optional_text(
|
||||
header.event_cover_image_src.as_deref(),
|
||||
DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC,
|
||||
),
|
||||
prize_pool_mud_points: header.event_prize_pool_mud_points,
|
||||
starts_at_text: normalize_optional_text(
|
||||
header.event_starts_at_text.as_deref(),
|
||||
DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT,
|
||||
),
|
||||
ends_at_text: normalize_optional_text(
|
||||
header.event_ends_at_text.as_deref(),
|
||||
DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT,
|
||||
),
|
||||
},
|
||||
creation_types,
|
||||
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
|
||||
})
|
||||
@@ -164,6 +211,12 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
modal_title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
|
||||
modal_description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
|
||||
updated_at: now,
|
||||
event_title: Some(DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string()),
|
||||
event_description: Some(DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string()),
|
||||
event_cover_image_src: Some(DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC.to_string()),
|
||||
event_prize_pool_mud_points: DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
|
||||
event_starts_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string()),
|
||||
event_ends_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -348,6 +401,43 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeC
|
||||
open: snapshot.open,
|
||||
sort_order: snapshot.sort_order,
|
||||
updated_at: now,
|
||||
category_id: Some(snapshot.category_id),
|
||||
category_label: Some(snapshot.category_label),
|
||||
category_sort_order: snapshot.category_sort_order,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_category_id(value: &str) -> String {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string()
|
||||
} else {
|
||||
normalized.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_category_label(value: &str) -> String {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
DEFAULT_CREATION_ENTRY_CATEGORY_LABEL.to_string()
|
||||
} else {
|
||||
normalized.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_optional_category_id(value: Option<&str>) -> String {
|
||||
normalize_optional_text(value, DEFAULT_CREATION_ENTRY_CATEGORY_ID)
|
||||
}
|
||||
|
||||
fn normalize_optional_category_label(value: Option<&str>) -> String {
|
||||
normalize_optional_text(value, DEFAULT_CREATION_ENTRY_CATEGORY_LABEL)
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: Option<&str>, fallback: &str) -> String {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|normalized| !normalized.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user