diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 1adf9f40..afd5b8d8 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -118,7 +118,7 @@ npm run check:server-rs-ddd 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 5. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 -6. 拼图图生图参考图主链不得再把大图 Data URL 塞进创作 JSON body;前端先直传 OSS 并提交 `referenceImageAssetObjectId(s)`,`api-server` 校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取,Data URL / `/generated-*` 仅作为旧请求兼容。 +6. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 7. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 ## SpacetimeDB schema 变更规则 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 2c59f449..71cd5ff4 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -219,7 +219,7 @@ Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该 - Windows 下载阶段如果出现 `curl: (18)` 或响应体截断,流水线会保留同名 `.download` 临时文件并用 `curl -C -` 断点续传;只有完整返回但 SHA256 digest 仍不匹配时才删除临时文件后重新下载。目标 Linux 节点仍只接收 `stash/unstash` 带过去的本地下载件,不回退外网下载。 - Windows 下载阶段如果走代理,在 `Genarrative-Server-Provision` 参数 `PROVISION_DOWNLOAD_PROXY` 填写 Windows Jenkins 节点可访问的 HTTP 代理,例如 `http://127.0.0.1:7890`;不要填写目标 release 机器视角的 `127.0.0.1`,除非代理确实运行在该 Windows 节点本机。Linux 目标机阶段会强制要求使用本地下载件,缺少文件直接失败,不再回退到外网下载。 - `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。 -- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 只是反代兜底,防止旧客户端或兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;长期主链不得依赖大 JSON body 承载图片,拼图参考图应先直传 OSS,只向创作接口提交 `referenceImageAssetObjectId(s)`,由后端签只读 URL 给外部模型读取。真实业务上限仍由 Rust 路由 `DefaultBodyLimit`、资产确认时 OSS HEAD 和解码后字节校验控制。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否仍在提交 Data URL 而不是 `assetObjectId`。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。 +- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。 - 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。 - 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。 - 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index a538870c..6a5029af 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -124,7 +124,11 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > 平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 -推荐页允许未登录直接游玩跳一跳运行态;`/api/runtime/jump-hop/runs`、`/jump` 和 `/restart` 采用可选鉴权,未登录时仍记录 `work_play_start`,但埋点需标记匿名语义。 +跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。 + +删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。 + +推荐页匿名游玩不再限定为跳一跳。推荐页嵌入运行态启动时统一先申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续透传 runtime guest token;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 ## 敲木鱼 diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index a6c38a51..918c4c48 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -25,6 +25,13 @@ export type PublicUserSearchResponse = { user: PublicUserSummary; }; +export type RuntimeGuestTokenResponse = { + token: string; + expiresAt: string; + subject: string; + scope: string; +}; + export type AuthEntryRequest = { phone: string; password: string; diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 856e04bf..19fafe66 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -47,6 +47,7 @@ export interface JumpHopWorkspaceCreateRequest { export interface JumpHopActionRequest { actionType: JumpHopActionType; + profileId?: string | null; workTitle?: string | null; workDescription?: string | null; themeTags?: string[] | null; @@ -55,6 +56,10 @@ export interface JumpHopActionRequest { characterPrompt?: string | null; tilePrompt?: string | null; endMoodPrompt?: string | null; + characterAsset?: JumpHopCharacterAsset | null; + tileAtlasAsset?: JumpHopCharacterAsset | null; + tileAssets?: JumpHopTileAsset[] | null; + coverComposite?: string | null; } export interface JumpHopCharacterAsset { diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index 35cf5127..1b27e0a1 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -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, + Extension(request_context): Extension, +) -> Result, 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, 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, mut request: Request, next: Next, ) -> Result { - 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, 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::() + .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, 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) { diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 8ad45a0a..32222b53 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -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, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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, Path(profile_id): Path, @@ -176,15 +230,13 @@ pub async fn get_jump_hop_runtime_work( pub async fn start_jump_hop_run( State(state): State, Extension(request_context): Extension, - maybe_authenticated: Option>, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, - maybe_authenticated: Option>, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, - maybe_authenticated: Option>, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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::>(); + 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 { + 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, 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(), diff --git a/server-rs/crates/api-server/src/modules/auth.rs b/server-rs/crates/api-server/src/modules/auth.rs index d3455b39..54513715 100644 --- a/server-rs/crates/api-server/src/modules/auth.rs +++ b/server-rs/crates/api-server/src/modules/auth.rs @@ -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 { 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)) diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs index 42374060..48864e8d 100644 --- a/server-rs/crates/api-server/src/modules/jump_hop.rs +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -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 { 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 { "/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)) diff --git a/server-rs/crates/api-server/src/modules/wooden_fish.rs b/server-rs/crates/api-server/src/modules/wooden_fish.rs index f9ad51a3..daef33ad 100644 --- a/server-rs/crates/api-server/src/modules/wooden_fish.rs +++ b/server-rs/crates/api-server/src/modules/wooden_fish.rs @@ -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 { "/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( diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 84dfac4f..88132af9 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -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元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容"; diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 85ed78c1..3832530a 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -643,15 +643,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 +801,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) diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index e254f3aa..4eedff39 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -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, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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 diff --git a/server-rs/crates/api-server/src/work_play_tracking.rs b/server-rs/crates/api-server/src/work_play_tracking.rs index f443b1e1..f16d5595 100644 --- a/server-rs/crates/api-server/src/work_play_tracking.rs +++ b/server-rs/crates/api-server/src/work_play_tracking.rs @@ -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, + 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( diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index da9221b6..9e2a657a 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -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 { + 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 { 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 { + 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 { let token = token.trim(); if token.is_empty() { @@ -1552,6 +1662,35 @@ pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result Result { + 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::( + 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(); diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 830656b5..a9b3935e 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -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", diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 4bc26c85..1e7b2f33 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -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 { diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index e4d4657d..cd2c0a51 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -87,6 +87,8 @@ pub struct JumpHopWorkspaceCreateRequest { pub struct JumpHopActionRequest { pub action_type: JumpHopActionType, #[serde(default)] + pub profile_id: Option, + #[serde(default)] pub work_title: Option, #[serde(default)] pub work_description: Option, @@ -102,6 +104,14 @@ pub struct JumpHopActionRequest { pub tile_prompt: Option, #[serde(default)] pub end_mood_prompt: Option, + #[serde(default)] + pub character_asset: Option, + #[serde(default)] + pub tile_atlas_asset: Option, + #[serde(default)] + pub tile_assets: Option>, + #[serde(default)] + pub cover_composite: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 7d798b88..2b35ba32 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -226,8 +226,11 @@ impl SpacetimeClient { &self, profile_id: String, ) -> Result { - 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 { + 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 { - 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 { - 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), diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index d84c754c..0209f748 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -46,7 +46,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec Result String { + let normalized = profile_id + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(|character| character.to_uppercase()) + .collect::(); + 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 { diff --git a/src/components/common/CreativeImageInputPanel.tsx b/src/components/common/CreativeImageInputPanel.tsx index 1c23c838..5c6a7721 100644 --- a/src/components/common/CreativeImageInputPanel.tsx +++ b/src/components/common/CreativeImageInputPanel.tsx @@ -53,6 +53,7 @@ export type CreativeImageInputPanelProps = { aiRedraw: boolean; promptReferenceImages: CreativeImageInputReferenceImage[]; promptReferenceLimit?: number; + imageLimitHint?: string | null; imageModelPicker?: ReactNode; error?: string | null; inputError?: string | null; @@ -96,6 +97,7 @@ export function CreativeImageInputPanel({ aiRedraw, promptReferenceImages, promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT, + imageLimitHint = null, imageModelPicker = null, error = null, inputError = null, @@ -276,6 +278,11 @@ export function CreativeImageInputPanel({ {mainImageMeta ?
{mainImageMeta}
: null} + {imageLimitHint ? ( +
+ {imageLimitHint} +
+ ) : null} {showPrompt ? ( diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 1d7aa357..ccd20cd5 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -6,6 +6,7 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; @@ -61,6 +62,9 @@ type CustomWorldCreationHubProps = { squareHoleItems?: SquareHoleWorkSummary[]; onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void; onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null; + jumpHopItems?: JumpHopWorkSummaryResponse[]; + onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; + onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null; puzzleItems?: PuzzleWorkSummary[]; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null; @@ -169,6 +173,9 @@ export function CustomWorldCreationHub({ squareHoleItems = [], onOpenSquareHoleDetail, onDeleteSquareHole = null, + jumpHopItems = [], + onOpenJumpHopDetail, + onDeleteJumpHop = null, puzzleItems = [], onOpenPuzzleDetail, onDeletePuzzle = null, @@ -201,6 +208,7 @@ export function CustomWorldCreationHub({ bigFishItems, match3dItems, squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [], + jumpHopItems, puzzleItems, babyObjectMatchItems, barkBattleItems, @@ -210,6 +218,7 @@ export function CustomWorldCreationHub({ canDeleteMatch3D: Boolean(onDeleteMatch3D), canDeleteSquareHole: isSquareHoleCreationVisible && Boolean(onDeleteSquareHole), + canDeleteJumpHop: Boolean(onDeleteJumpHop), canDeletePuzzle: Boolean(onDeletePuzzle), canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch), canDeleteBarkBattle: Boolean(onDeleteBarkBattle), @@ -223,6 +232,8 @@ export function CustomWorldCreationHub({ onDeleteMatch3D: onDeleteMatch3D ?? undefined, onOpenSquareHoleDetail, onDeleteSquareHole: onDeleteSquareHole ?? undefined, + onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined, + onDeleteJumpHop: onDeleteJumpHop ?? undefined, onOpenPuzzleDetail, onDeletePuzzle: onDeletePuzzle ?? undefined, onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined, @@ -249,6 +260,7 @@ export function CustomWorldCreationHub({ onDeleteBabyObjectMatch, onDeleteBarkBattle, onDeleteVisualNovel, + onDeleteJumpHop, onClaimPuzzlePointIncentive, onOpenBigFishDetail, onOpenDraft, @@ -262,7 +274,9 @@ export function CustomWorldCreationHub({ getWorkState, puzzleItems, rpgLibraryEntries, - squareHoleItems, + onOpenSquareHoleDetail, + onOpenJumpHopDetail, + jumpHopItems, visualNovelItems, ], ); @@ -310,6 +324,9 @@ export function CustomWorldCreationHub({ case 'square-hole': onOpenSquareHoleDetail?.(item.source.item); return; + case 'jump-hop': + onOpenJumpHopDetail?.(item.source.item); + return; case 'rpg': if (item.status === 'draft') { onOpenDraft(item.source.item); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 7358741e..24fde099 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -59,6 +59,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record = 'big-fish': '/creation-type-references/big-fish.webp', match3d: '/creation-type-references/match3d.webp', 'square-hole': '/creation-type-references/square-hole.webp', + 'jump-hop': '/creation-type-references/jump-hop.webp', puzzle: '/creation-type-references/puzzle.webp', 'baby-object-match': '/creation-type-references/creative-agent.webp', 'bark-battle': '/creation-type-references/bark-battle.webp', diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index f99727cd..e1fecab8 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -7,12 +7,14 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildBabyObjectMatchPublicWorkCode, buildCustomWorldPublicWorkCode, buildBarkBattlePublicWorkCode, buildBigFishPublicWorkCode, + buildJumpHopPublicWorkCode, buildMatch3DPublicWorkCode, buildPuzzlePublicWorkCode, buildSquareHolePublicWorkCode, @@ -31,6 +33,7 @@ export type CreationWorkShelfKind = | 'big-fish' | 'match3d' | 'square-hole' + | 'jump-hop' | 'puzzle' | 'baby-object-match' | 'bark-battle' @@ -83,6 +86,10 @@ export type CreationWorkShelfSource = kind: 'square-hole'; item: SquareHoleWorkSummary; } + | { + kind: 'jump-hop'; + item: JumpHopWorkSummaryResponse; + } | { kind: 'puzzle'; item: PuzzleWorkSummary; @@ -137,6 +144,7 @@ export function buildCreationWorkShelfItems(params: { bigFishItems: BigFishWorkSummary[]; match3dItems?: Match3DWorkSummary[]; squareHoleItems?: SquareHoleWorkSummary[]; + jumpHopItems?: JumpHopWorkSummaryResponse[]; puzzleItems: PuzzleWorkSummary[]; babyObjectMatchItems?: BabyObjectMatchDraft[]; barkBattleItems?: BarkBattleWorkSummary[]; @@ -145,6 +153,7 @@ export function buildCreationWorkShelfItems(params: { canDeleteBigFish?: boolean; canDeleteMatch3D?: boolean; canDeleteSquareHole?: boolean; + canDeleteJumpHop?: boolean; canDeletePuzzle?: boolean; canDeleteBabyObjectMatch?: boolean; canDeleteBarkBattle?: boolean; @@ -158,6 +167,8 @@ export function buildCreationWorkShelfItems(params: { onDeleteMatch3D?: (item: Match3DWorkSummary) => void; onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void; onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void; + onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; + onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: (item: PuzzleWorkSummary) => void; onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void; @@ -177,6 +188,7 @@ export function buildCreationWorkShelfItems(params: { bigFishItems, match3dItems = [], squareHoleItems = [], + jumpHopItems = [], puzzleItems, babyObjectMatchItems = [], barkBattleItems = [], @@ -185,6 +197,7 @@ export function buildCreationWorkShelfItems(params: { canDeleteBigFish = false, canDeleteMatch3D = false, canDeleteSquareHole = false, + canDeleteJumpHop = false, canDeletePuzzle = false, canDeleteBabyObjectMatch = false, canDeleteBarkBattle = false, @@ -198,6 +211,8 @@ export function buildCreationWorkShelfItems(params: { onDeleteMatch3D, onOpenSquareHoleDetail, onDeleteSquareHole, + onOpenJumpHopDetail, + onDeleteJumpHop, onOpenPuzzleDetail, onDeletePuzzle, onClaimPuzzlePointIncentive, @@ -236,6 +251,12 @@ export function buildCreationWorkShelfItems(params: { onDelete: onDeleteSquareHole, }), ), + ...jumpHopItems.map((item) => + mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, { + onOpen: onOpenJumpHopDetail, + onDelete: onDeleteJumpHop, + }), + ), ...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle, { onOpen: onOpenPuzzleDetail, @@ -749,6 +770,51 @@ function mapSquareHoleWorkToShelfItem( }; } +function mapJumpHopWorkToShelfItem( + item: JumpHopWorkSummaryResponse, + canDelete: boolean, + adapter: WorkShelfAdapter, +): CreationWorkShelfItem { + const status = item.publicationStatus === 'published' ? 'published' : 'draft'; + const publicWorkCode = + status === 'published' ? buildJumpHopPublicWorkCode(item.profileId) : null; + const coverImageSrc = normalizeCoverImageSrc(item.coverImageSrc); + return { + id: item.workId, + kind: 'jump-hop', + status, + title: item.workTitle, + summary: item.workDescription, + authorDisplayName: resolveAuthorDisplayName(item), + updatedAt: item.updatedAt, + coverImageSrc, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + publicWorkCode, + sharePath: + publicWorkCode && status === 'published' + ? buildPublicWorkStagePath('work-detail', publicWorkCode) + : null, + openActionLabel: status === 'published' ? '查看详情' : '继续创作', + canDelete, + canShare: status === 'published' && Boolean(publicWorkCode), + badges: [ + buildStatusBadge(status), + { id: 'type', label: '跳一跳', tone: 'neutral' }, + ], + metrics: + status === 'published' + ? buildPublishedMetrics({ + playCount: item.playCount, + remixCount: 0, + likeCount: 0, + }) + : [], + actions: buildWorkShelfActions(item, adapter), + source: { kind: 'jump-hop', item }, + }; +} + function resolveAuthorDisplayName( ...sources: Array diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 0ccbafd7..e0d871ec 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -117,6 +117,7 @@ import { BACKGROUND_AUTH_REQUEST_OPTIONS, } from '../../services/apiClient'; import { + ensureRuntimeGuestToken, getPublicAuthUserByCode, getPublicAuthUserById, } from '../../services/authService'; @@ -127,6 +128,7 @@ import { publishBarkBattleWork, updateBarkBattleDraftConfig, } from '../../services/bark-battle-creation'; +import { startBarkBattleRun } from '../../services/bark-battle-runtime'; import { createBigFishCreationSession, executeBigFishCreationAction, @@ -177,9 +179,10 @@ import { type JumpHopRunResponse, type JumpHopSessionResponse, type JumpHopSessionSnapshotResponse, - type JumpHopWorkProfileResponse, - type JumpHopWorkspaceCreateRequest, + JumpHopWorkProfileResponse, + JumpHopWorkspaceCreateRequest, } from '../../services/jump-hop/jumpHopClient'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import { match3dCreationClient } from '../../services/match3d-creation'; import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { @@ -549,8 +552,13 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([ ]); const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = BACKGROUND_AUTH_REQUEST_OPTIONS; -const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS = - RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; +async function buildRecommendRuntimeGuestOptions() { + const { token } = await ensureRuntimeGuestToken(); + return { + ...RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, + runtimeGuestToken: token, + }; +} const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; @@ -1943,7 +1951,7 @@ function hasRecoverableGeneratedPuzzleDraft( ); } -function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) { +function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] { switch (item.source.kind) { case 'rpg': return collectDraftNoticeKeys('rpg', [ @@ -1972,6 +1980,13 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) { item.source.item.profileId, item.source.item.sourceSessionId, ]); + case 'jump-hop': + return collectDraftNoticeKeys('jump-hop', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); case 'puzzle': return collectDraftNoticeKeys('puzzle', [ item.id, @@ -2057,6 +2072,39 @@ function buildPendingBigFishWorks( })); } +function buildPendingJumpHopWorks( + pending: Record | undefined, + existingItems: readonly JumpHopWorkSummaryResponse[], +): JumpHopWorkSummaryResponse[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => ({ + runtimeKind: 'jump-hop', + workId: `jump-hop-work-${sessionId}`, + profileId: `jump-hop-profile-${sessionId}`, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '跳一跳草稿', + workDescription: '正在生成跳一跳玩法草稿。', + themeTags: [], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus: state.status === 'generating' ? 'generating' : 'ready', + })); +} + function buildPendingMatch3DWorks( pending: Record | undefined, existingItems: readonly Match3DWorkSummary[], @@ -2727,6 +2775,9 @@ export function PlatformEntryFlowShellImpl({ const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState< JumpHopGalleryCardResponse[] >([]); + const [jumpHopWorks, setJumpHopWorks] = useState< + JumpHopWorkSummaryResponse[] + >([]); const [jumpHopRuntimeReturnStage, setJumpHopRuntimeReturnStage] = useState('jump-hop-result'); const [jumpHopGenerationState, setJumpHopGenerationState] = @@ -2943,6 +2994,10 @@ export function PlatformEntryFlowShellImpl({ creationEntryTypes, 'big-fish', ); + const isJumpHopCreationVisible = isPlatformCreationTypeVisible( + creationEntryTypes, + 'jump-hop', + ); const isSquareHoleCreationVisible = isPlatformCreationTypeVisible( creationEntryTypes, 'square-hole', @@ -3293,6 +3348,11 @@ export function PlatformEntryFlowShellImpl({ resolveRpgCreationErrorMessage(error, fallback), [], ); + const resolveBarkBattleErrorMessage = useCallback( + (error: unknown, fallback: string) => + resolveRpgCreationErrorMessage(error, fallback), + [], + ); const refreshBigFishShelf = useCallback(async () => { setIsBigFishLoadingLibrary(true); @@ -3392,6 +3452,22 @@ export function PlatformEntryFlowShellImpl({ } }, []); + const refreshJumpHopShelf = useCallback(async () => { + if (!isJumpHopCreationVisible) { + setJumpHopWorks([]); + return []; + } + + try { + const worksResponse = await jumpHopClient.listWorks(); + setJumpHopWorks(worksResponse.items); + return worksResponse.items; + } catch { + setJumpHopWorks([]); + return []; + } + }, [isJumpHopCreationVisible]); + const refreshWoodenFishGallery = useCallback(async () => { try { const galleryResponse = await woodenFishClient.listGallery(); @@ -3601,6 +3677,22 @@ export function PlatformEntryFlowShellImpl({ selectionStage, ]); + useEffect(() => { + if (!platformBootstrap.canReadProtectedData) { + setJumpHopWorks([]); + return; + } + + if (platformBootstrap.platformTab === 'create' || selectionStage === 'platform') { + void refreshJumpHopShelf(); + } + }, [ + platformBootstrap.canReadProtectedData, + platformBootstrap.platformTab, + refreshJumpHopShelf, + selectionStage, + ]); + const sessionController = useRpgCreationSessionController({ userId: authUi?.user?.id, openLoginModal: authUi?.openLoginModal, @@ -3948,6 +4040,16 @@ export function PlatformEntryFlowShellImpl({ ], [bigFishWorks, pendingDraftShelfItems], ); + const jumpHopShelfItems = useMemo( + () => [ + ...buildPendingJumpHopWorks( + pendingDraftShelfItems['jump-hop'], + jumpHopWorks, + ), + ...jumpHopWorks, + ], + [jumpHopWorks, pendingDraftShelfItems], + ); const match3dShelfItems = useMemo( () => [ ...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks), @@ -4023,6 +4125,13 @@ export function PlatformEntryFlowShellImpl({ ...bigFishShelfItems.flatMap((item) => collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]), ), + ...jumpHopShelfItems.flatMap((item) => + collectDraftNoticeKeys('jump-hop', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), ...match3dShelfItems.flatMap((item) => collectDraftNoticeKeys('match3d', [ item.workId, @@ -4065,6 +4174,7 @@ export function PlatformEntryFlowShellImpl({ babyObjectMatchDrafts, barkBattleShelfItems, bigFishShelfItems, + jumpHopShelfItems, creationHubItems, isSquareHoleCreationVisible, match3dShelfItems, @@ -7276,11 +7386,14 @@ export function PlatformEntryFlowShellImpl({ profileId: targetProfileId, mode: 'play' as const, }; + const runtimeGuestOptions = options.embedded + ? await buildRecommendRuntimeGuestOptions() + : {}; const { run } = options.embedded ? await startVisualNovelRun( targetProfileId, startRunPayload, - RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, + runtimeGuestOptions, ) : await startVisualNovelRun(targetProfileId, startRunPayload); setVisualNovelWork(workDetail); @@ -7327,9 +7440,14 @@ export function PlatformEntryFlowShellImpl({ setVisualNovelError(null); setIsVisualNovelBusy(true); try { + const runtimeGuestOptions = + activeRecommendRuntimeKind === 'visual-novel' + ? await buildRecommendRuntimeGuestOptions() + : {}; const nextRun = await streamVisualNovelRuntimeAction( visualNovelRun.runId, payload, + runtimeGuestOptions, ); setVisualNovelRun(nextRun); } catch (error) { @@ -7341,6 +7459,7 @@ export function PlatformEntryFlowShellImpl({ } }, [ + activeRecommendRuntimeKind, isVisualNovelBusy, resolvePuzzleErrorMessage, setIsVisualNovelBusy, @@ -7551,6 +7670,22 @@ export function PlatformEntryFlowShellImpl({ setJumpHopSession(response.session); setJumpHopWork(response.work ?? null); setJumpHopGenerationState(readyState); + if (response.work) { + setJumpHopWorks((current) => + [response.work!.summary, ...current.filter((item) => item.workId !== response.work!.summary.workId)], + ); + markPendingDraftReady('jump-hop', created.session.sessionId, false); + markDraftReady( + 'jump-hop', + [ + created.session.sessionId, + response.work.summary.workId, + response.work.summary.profileId, + ], + false, + ); + void refreshJumpHopShelf().catch(() => undefined); + } setSelectionStage('jump-hop-result'); } catch (error) { const errorMessage = resolveRpgCreationErrorMessage( @@ -7665,6 +7800,10 @@ export function PlatformEntryFlowShellImpl({ try { const response = await jumpHopClient.publishWork(profileId); setJumpHopWork(response.item); + setJumpHopWorks((current) => + [response.item.summary, ...current.filter((item) => item.workId !== response.item.summary.workId)], + ); + void refreshJumpHopShelf().catch(() => undefined); openPublishShareModal({ title: response.item.summary.workTitle || '跳一跳', publicWorkCode: buildJumpHopPublicWorkCode( @@ -7729,12 +7868,12 @@ export function PlatformEntryFlowShellImpl({ setJumpHopError(null); setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail'); try { + const runtimeGuestOptions = options.embedded + ? await buildRecommendRuntimeGuestOptions() + : {}; const [detail, runResponse] = await Promise.all([ jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null), - jumpHopClient.startRun( - normalizedProfileId, - options.embedded ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS : {}, - ), + jumpHopClient.startRun(normalizedProfileId, runtimeGuestOptions), ]); if (detail?.item) { setJumpHopWork(detail.item); @@ -8066,9 +8205,14 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishError(null); setWoodenFishRuntimeReturnStage(options.returnStage ?? 'work-detail'); try { + const runtimeGuestOptions = options.embedded + ? await buildRecommendRuntimeGuestOptions() + : {}; const [detail, runResponse] = await Promise.all([ woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null), - woodenFishClient.startRun(normalizedProfileId), + options.embedded + ? woodenFishClient.startRun(normalizedProfileId, runtimeGuestOptions) + : woodenFishClient.startRun(normalizedProfileId), ]); if (detail?.item) { setWoodenFishWork(detail.item); @@ -8496,15 +8640,15 @@ export function PlatformEntryFlowShellImpl({ profileId: item.profileId, levelId: levelId ?? null, }; + const runtimeGuestOptions = options.embedded + ? await buildRecommendRuntimeGuestOptions() + : {}; const authMode = options.embedded ? 'isolated' : (options.authMode ?? 'default'); const { run } = authMode === 'isolated' - ? await startPuzzleRun( - startRunPayload, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, - ) + ? await startPuzzleRun(startRunPayload, runtimeGuestOptions) : await startPuzzleRun(startRunPayload); setSelectedPuzzleDetail(item); setPuzzleRun(run); @@ -8600,10 +8744,11 @@ export function PlatformEntryFlowShellImpl({ runtimeProfile.generatedBackgroundAsset, { expireSeconds: 300 }, ); + const runtimeGuestOptions = options.embedded + ? await buildRecommendRuntimeGuestOptions() + : {}; const runtimeOptions = { - ...(options.embedded - ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS - : {}), + ...runtimeGuestOptions, ...(typeof options.itemTypeCountOverride === 'number' ? { itemTypeCountOverride: options.itemTypeCountOverride } : {}), @@ -8671,11 +8816,11 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleError(null); try { + const runtimeGuestOptions = options.embedded + ? await buildRecommendRuntimeGuestOptions() + : {}; const { run } = options.embedded - ? await startSquareHoleRun( - profile.profileId, - RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, - ) + ? await startSquareHoleRun(profile.profileId, runtimeGuestOptions) : await startSquareHoleRun(profile.profileId); setSquareHoleRun(run); setSquareHoleRuntimeReturnStage(returnStage); @@ -8827,9 +8972,14 @@ export function PlatformEntryFlowShellImpl({ bigFishInputInFlightRef.current = true; try { + const runtimeGuestOptions = + activeRecommendRuntimeKind === 'big-fish' + ? await buildRecommendRuntimeGuestOptions() + : {}; const { run } = await submitBigFishRuntimeInput( bigFishRun.runId, payload, + runtimeGuestOptions, ); setBigFishRun(run); } catch (error) { @@ -8840,7 +8990,12 @@ export function PlatformEntryFlowShellImpl({ bigFishInputInFlightRef.current = false; } }, - [bigFishRun, resolveBigFishErrorMessage, setBigFishError], + [ + activeRecommendRuntimeKind, + bigFishRun, + resolveBigFishErrorMessage, + setBigFishError, + ], ); const reportBigFishObservedPlayTime = useCallback(() => { @@ -9041,12 +9196,13 @@ export function PlatformEntryFlowShellImpl({ profileId: currentLevel.profileId, levelId: resolvePuzzleRestartLevelId(currentRun, detailItem), }; + const runtimeGuestOptions = + puzzleRuntimeAuthMode === 'isolated' + ? await buildRecommendRuntimeGuestOptions() + : {}; const { run } = puzzleRuntimeAuthMode === 'isolated' - ? await startPuzzleRun( - startRunPayload, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, - ) + ? await startPuzzleRun(startRunPayload, runtimeGuestOptions) : await startPuzzleRun(startRunPayload); setSelectedPuzzleDetail(detailItem); puzzleRunRef.current = run; @@ -9169,10 +9325,8 @@ export function PlatformEntryFlowShellImpl({ const submitLeaderboardPromise = puzzleRuntimeAuthMode === 'isolated' - ? submitPuzzleLeaderboard( - puzzleRun.runId, - payload, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + ? buildRecommendRuntimeGuestOptions().then((runtimeGuestOptions) => + submitPuzzleLeaderboard(puzzleRun.runId, payload, runtimeGuestOptions), ) : submitPuzzleLeaderboard(puzzleRun.runId, payload); @@ -9229,6 +9383,10 @@ export function PlatformEntryFlowShellImpl({ return; } + const runtimeGuestOptions = + puzzleRuntimeAuthMode === 'isolated' + ? await buildRecommendRuntimeGuestOptions() + : {}; const targetProfileId = _target?.profileId?.trim() ?? ''; if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) { const itemPromise = @@ -9244,7 +9402,7 @@ export function PlatformEntryFlowShellImpl({ { targetProfileId, }, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + runtimeGuestOptions, ) : advancePuzzleNextLevel(puzzleRun.runId, { targetProfileId, @@ -9269,7 +9427,7 @@ export function PlatformEntryFlowShellImpl({ ? await advancePuzzleNextLevel( puzzleRun.runId, {}, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + runtimeGuestOptions, ) : await advancePuzzleNextLevel(puzzleRun.runId); setPuzzleRun(run); @@ -10354,6 +10512,43 @@ export function PlatformEntryFlowShellImpl({ [openPublicWorkDetail, setJumpHopError, setSelectionStage], ); + const openJumpHopDraft = useCallback( + async (item: JumpHopWorkSummaryResponse) => { + markDraftNoticeSeen( + collectDraftNoticeKeys('jump-hop', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ); + + if (item.publicationStatus === 'published') { + void openJumpHopPublicWorkDetail(item.profileId); + return; + } + + setJumpHopError(null); + setPublicWorkDetailError(null); + setIsJumpHopBusy(true); + try { + const detail = await jumpHopClient.getWorkDetail(item.profileId); + setJumpHopSession(null); + setJumpHopRun(null); + setJumpHopWork(detail.item); + setJumpHopRuntimeReturnStage('jump-hop-result'); + enterCreateTab(); + setSelectionStage('jump-hop-result'); + } catch (error) { + setJumpHopError( + resolveRpgCreationErrorMessage(error, '读取跳一跳草稿失败。'), + ); + } finally { + setIsJumpHopBusy(false); + } + }, + [enterCreateTab, markDraftNoticeSeen, openPublicWorkDetail, setSelectionStage], + ); + const openWoodenFishPublicWorkDetail = useCallback( async (profileId: string) => { setIsPublicWorkDetailBusy(true); @@ -11120,11 +11315,11 @@ export function PlatformEntryFlowShellImpl({ setBigFishRuntimeReturnStage(returnStage); setBigFishRun(null); try { + const runtimeGuestOptions = options.embedded + ? await buildRecommendRuntimeGuestOptions() + : {}; const { run } = options.embedded - ? await startBigFishRuntimeRun( - sessionId, - RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, - ) + ? await startBigFishRuntimeRun(sessionId, runtimeGuestOptions) : await startBigFishRuntimeRun(sessionId); setBigFishRuntimeStartedAt(Date.now()); setBigFishRun(run); @@ -11135,11 +11330,7 @@ export function PlatformEntryFlowShellImpl({ ); } const recordPlayPromise = options.embedded - ? recordBigFishPlay( - sessionId, - { elapsedMs: 0 }, - RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, - ) + ? recordBigFishPlay(sessionId, { elapsedMs: 0 }, runtimeGuestOptions) : recordBigFishPlay(sessionId, { elapsedMs: 0 }); void recordPlayPromise.catch((error) => { setBigFishError( @@ -11158,9 +11349,10 @@ export function PlatformEntryFlowShellImpl({ ); const startBarkBattleRunFromWork = useCallback( - ( + async ( item: BarkBattleWorkSummary, returnStage: BarkBattleRuntimeReturnStage = 'work-detail', + options: { embedded?: boolean } = {}, ) => { if (item.status !== 'published') { setBarkBattleError('汪汪声浪作品发布后才能进入正式玩法。'); @@ -11172,17 +11364,33 @@ export function PlatformEntryFlowShellImpl({ setBarkBattleRuntimeMode('published'); setBarkBattlePublishedConfig(mapBarkBattleWorkToPublishedConfig(item)); setBarkBattleRuntimeReturnStage(returnStage); - selectionStageRef.current = 'bark-battle-runtime'; - setSelectionStage('bark-battle-runtime'); - pushAppHistoryPath( - buildPublicWorkStagePath( - 'bark-battle-runtime', - buildBarkBattlePublicWorkCode(item.workId), - ), - ); - return true; + try { + const runtimeGuestOptions = options.embedded + ? await buildRecommendRuntimeGuestOptions() + : {}; + const runResponse = options.embedded + ? await startBarkBattleRun(item.workId, {}, runtimeGuestOptions) + : await startBarkBattleRun(item.workId); + void runResponse; + selectionStageRef.current = 'bark-battle-runtime'; + if (!options.embedded) { + setSelectionStage('bark-battle-runtime'); + pushAppHistoryPath( + buildPublicWorkStagePath( + 'bark-battle-runtime', + buildBarkBattlePublicWorkCode(item.workId), + ), + ); + } + return true; + } catch (error) { + setBarkBattleError( + resolveBarkBattleErrorMessage(error, '启动汪汪声浪玩法失败。'), + ); + return false; + } }, - [setSelectionStage], + [resolveBarkBattleErrorMessage, setSelectionStage], ); const startSelectedPublicWork = useCallback(() => { @@ -11454,7 +11662,9 @@ export function PlatformEntryFlowShellImpl({ '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', ); } else { - started = startBarkBattleRunFromWork(work, 'platform'); + started = await startBarkBattleRunFromWork(work, 'platform', { + embedded: true, + }); } } else if (isEdutainmentGalleryEntry(entry)) { started = await startBabyObjectMatchRuntimeFromEntry( @@ -13119,6 +13329,7 @@ export function PlatformEntryFlowShellImpl({ deletingWorkId={deletingCreationWorkId} rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []} + jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []} onOpenBigFishDetail={ isBigFishCreationVisible ? (item) => { @@ -13128,6 +13339,15 @@ export function PlatformEntryFlowShellImpl({ } : undefined } + onOpenJumpHopDetail={ + isJumpHopCreationVisible + ? (item) => { + runProtectedAction(() => { + void openJumpHopDraft(item); + }); + } + : undefined + } onDeleteBigFish={ isBigFishCreationVisible ? (item) => { @@ -13135,6 +13355,7 @@ export function PlatformEntryFlowShellImpl({ } : null } + onDeleteJumpHop={null} match3dItems={match3dShelfItems} onOpenMatch3DDetail={(item) => { runProtectedAction(() => { diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index ee7704ea..3785425c 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -551,9 +551,9 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () => expect(onCreateFromForm).toHaveBeenCalledWith({ seedText: 'first-level.png', pictureDescription: 'first-level.png', - referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png', + referenceImageSrc: 'data:image/png;base64,uploaded-square', referenceImageSrcs: [], - referenceImageAssetObjectId: 'asset-reference-first-level.png', + referenceImageAssetObjectId: null, referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: false, @@ -616,22 +616,10 @@ test('puzzle workspace submits history image when AI redraw is off', async () => }); }); -test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => { +test('puzzle workspace submits uploaded reference image as data URL when AI redraw is on', async () => { const onCreateFromForm = vi.fn(); const uploadedDataUrl = 'data:image/png;base64,uploaded-square'; stubReferenceImageUpload(uploadedDataUrl); - vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({ - assetObjectId: 'asset-reference-main-1', - assetKind: 'puzzle_cover_image', - objectKey: 'generated-puzzle-assets/reference/main-1.png', - imageSrc: '/generated-puzzle-assets/reference/main-1.png', - ownerUserId: 'user-1', - ownerLabel: '账号 user-1', - profileId: null, - entityId: null, - createdAt: '1713686400.000000Z', - updatedAt: '1713686400.000000Z', - }); render( { expect(screen.getByAltText('拼图图片')).toBeTruthy(); }); - expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({ - file: expect.any(File), - }); + expect(puzzleAssetClient.uploadReferenceImage).not.toHaveBeenCalled(); fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), { target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' }, }); @@ -663,9 +649,9 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a expect(onCreateFromForm).toHaveBeenCalledWith({ seedText: '保留上传画面的主体和构图,改成雨夜灯街。', pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。', - referenceImageSrc: null, + referenceImageSrc: 'data:image/png;base64,uploaded-square', referenceImageSrcs: [], - referenceImageAssetObjectId: 'asset-reference-main-1', + referenceImageAssetObjectId: null, referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: true, @@ -754,12 +740,12 @@ test('puzzle workspace uploads prompt references as asset object ids', async () seedText: '一只猫在雨夜灯牌下回头。', pictureDescription: '一只猫在雨夜灯牌下回头。', referenceImageSrc: null, - referenceImageSrcs: [], - referenceImageAssetObjectId: null, - referenceImageAssetObjectIds: [ - 'asset-reference-prompt-1', - 'asset-reference-prompt-2', + referenceImageSrcs: [ + 'data:image/png;base64,reference-1', + 'data:image/png;base64,reference-2', ], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: true, }); @@ -842,15 +828,15 @@ test('puzzle workspace uploads prompt reference images from the description box' seedText: '一只猫在雨夜灯牌下回头。', pictureDescription: '一只猫在雨夜灯牌下回头。', referenceImageSrc: null, - referenceImageSrcs: [], - referenceImageAssetObjectId: null, - referenceImageAssetObjectIds: [ - 'asset-reference-reference-1.png', - 'asset-reference-reference-2.png', - 'asset-reference-reference-3.png', - 'asset-reference-reference-4.png', - 'asset-reference-reference-5.png', + referenceImageSrcs: [ + 'data:image/png;base64,reference-1', + 'data:image/png;base64,reference-2', + 'data:image/png;base64,reference-3', + 'data:image/png;base64,reference-4', + 'data:image/png;base64,reference-5', ], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], imageModel: 'gpt-image-2', aiRedraw: true, }); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx index d941464a..da06f2b1 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx @@ -16,11 +16,9 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works import { cropPuzzleReferenceImageDataUrl, isPuzzleReferenceImageSquare, - puzzleReferenceImageDataUrlToFile, readPuzzleReferenceImageAsDataUrl, readPuzzleReferenceImageForUpload, } from '../../services/puzzleReferenceImage'; -import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient'; import { CreativeImageInputPanel, type CreativeImageInputReferenceImage, @@ -409,11 +407,10 @@ export function PuzzleAgentWorkspace({ return; } - const asset = await puzzleAssetClient.uploadReferenceImage({ file }); setFormState((current) => ({ ...current, - referenceImageSrc: asset.imageSrc || uploadImage.dataUrl, - referenceImageAssetObjectId: asset.assetObjectId, + referenceImageSrc: uploadImage.dataUrl, + referenceImageAssetObjectId: '', referenceImageLabel: file.name.trim() || '本地拼图图片', })); setReferenceImageError(null); @@ -441,18 +438,12 @@ export function PuzzleAgentWorkspace({ try { const images = await Promise.all( - files.slice(0, remainingSlots).map(async (file, index) => { - const [imageSrc, asset] = await Promise.all([ - readPuzzleReferenceImageAsDataUrl(file), - puzzleAssetClient.uploadReferenceImage({ file }), - ]); - return { - id: `prompt-upload:${Date.now()}:${index}:${file.name}`, - label: file.name.trim() || `参考图 ${index + 1}`, - imageSrc: asset.imageSrc || imageSrc, - assetObjectId: asset.assetObjectId, - }; - }), + files.slice(0, remainingSlots).map(async (file, index) => ({ + id: `prompt-upload:${Date.now()}:${index}:${file.name}`, + label: file.name.trim() || `参考图 ${index + 1}`, + imageSrc: await readPuzzleReferenceImageAsDataUrl(file), + assetObjectId: null, + })), ); setFormState((current) => ({ ...current, @@ -515,15 +506,10 @@ export function PuzzleAgentWorkspace({ cropY: currentCropState.cropRect.y, cropSize: currentCropState.cropRect.size, }); - const file = puzzleReferenceImageDataUrlToFile( - dataUrl, - currentCropState.fileName, - ); - const asset = await puzzleAssetClient.uploadReferenceImage({ file }); setFormState((current) => ({ ...current, - referenceImageSrc: asset.imageSrc || dataUrl, - referenceImageAssetObjectId: asset.assetObjectId, + referenceImageSrc: dataUrl, + referenceImageAssetObjectId: '', referenceImageLabel: currentCropState.label, })); setCropState(null); @@ -651,6 +637,7 @@ export function PuzzleAgentWorkspace({ aiRedraw={formState.aiRedraw} promptReferenceImages={formState.referenceImageSrcs} promptReferenceLimit={PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT} + imageLimitHint="图片≤6MB" imageModelPicker={ 15_000; +} + +export function clearRuntimeGuestTokenCache() { + runtimeGuestTokenCache.value = null; +} + +export async function ensureRuntimeGuestToken() { + if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) { + return runtimeGuestTokenCache.value!; + } + + const response = await requestJson( + '/api/auth/runtime-guest-token', + { + method: 'POST', + }, + '获取匿名运行态身份失败', + PUBLIC_AUTH_REQUEST_OPTIONS, + ); + + runtimeGuestTokenCache.value = response; + return response; +} + const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone'; export function normalizePhoneInput(phoneInput: string) { diff --git a/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts b/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts index 9e3ddde2..211cfdad 100644 --- a/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts +++ b/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts @@ -6,10 +6,14 @@ import type { BarkBattleRuntimeConfig, } from '../../../packages/shared/src/contracts/barkBattle'; import { - type ApiRequestOptions, type ApiRetryOptions, requestJson, } from '../apiClient'; +import { + buildRuntimeGuestAuthOptions, + buildRuntimeGuestHeaders, + type RuntimeGuestRequestOptions, +} from '../runtimeGuestAuth'; const BARK_BATTLE_RUNTIME_READ_RETRY: ApiRetryOptions = { maxRetries: 1, @@ -24,28 +28,20 @@ const BARK_BATTLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = { retryUnsafeMethods: true, }; -export type BarkBattleRuntimeRequestOptions = Pick< - ApiRequestOptions, - | 'authImpact' - | 'skipRefresh' - | 'notifyAuthStateChange' - | 'clearAuthOnUnauthorized' ->; +export type BarkBattleRuntimeRequestOptions = RuntimeGuestRequestOptions; export function getBarkBattleRuntimeConfig( workId: string, options: BarkBattleRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/config`, - { method: 'GET' }, + { method: 'GET', headers: buildRuntimeGuestHeaders(options) }, '读取汪汪声浪大作战配置失败', { retry: BARK_BATTLE_RUNTIME_READ_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } @@ -55,11 +51,12 @@ export function startBarkBattleRun( payload: Partial = {}, options: BarkBattleRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/runs`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }), body: JSON.stringify({ ...payload, workId: payload.workId ?? workId, @@ -68,10 +65,7 @@ export function startBarkBattleRun( '启动汪汪声浪大作战正式局失败', { retry: BARK_BATTLE_RUNTIME_WRITE_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } @@ -80,16 +74,14 @@ export function getBarkBattleRun( runId: string, options: BarkBattleRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}`, - { method: 'GET' }, + { method: 'GET', headers: buildRuntimeGuestHeaders(options) }, '读取汪汪声浪大作战单局失败', { retry: BARK_BATTLE_RUNTIME_READ_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } @@ -99,11 +91,12 @@ export function finishBarkBattleRun( payload: BarkBattleRunFinishRequest, options: BarkBattleRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}/finish`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }), body: JSON.stringify({ ...payload, runId: payload.runId ?? runId, @@ -112,10 +105,7 @@ export function finishBarkBattleRun( '提交汪汪声浪大作战成绩失败', { retry: BARK_BATTLE_RUNTIME_WRITE_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } diff --git a/src/services/big-fish-runtime/bigFishRuntimeClient.ts b/src/services/big-fish-runtime/bigFishRuntimeClient.ts index 204be416..16b02528 100644 --- a/src/services/big-fish-runtime/bigFishRuntimeClient.ts +++ b/src/services/big-fish-runtime/bigFishRuntimeClient.ts @@ -5,10 +5,14 @@ import type { } from '../../../packages/shared/src/contracts/bigFish'; import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import { - type ApiRequestOptions, type ApiRetryOptions, requestJson, } from '../apiClient'; +import { + buildRuntimeGuestAuthOptions, + buildRuntimeGuestHeaders, + type RuntimeGuestRequestOptions, +} from '../runtimeGuestAuth'; const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = { maxRetries: 1, @@ -16,13 +20,7 @@ const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = { maxDelayMs: 360, retryUnsafeMethods: true, }; -type BigFishRuntimeRequestOptions = Pick< - ApiRequestOptions, - | 'authImpact' - | 'skipRefresh' - | 'notifyAuthStateChange' - | 'clearAuthOnUnauthorized' ->; +type BigFishRuntimeRequestOptions = RuntimeGuestRequestOptions; /** * 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。 @@ -32,20 +30,20 @@ export function recordBigFishPlay( payload: RecordBigFishPlayRequest, options: BigFishRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildRuntimeGuestHeaders(options, { + 'Content-Type': 'application/json', + }), body: JSON.stringify(payload), }, '记录大鱼吃小鱼游玩失败', { retry: BIG_FISH_RUNTIME_WRITE_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } @@ -54,18 +52,17 @@ export function startBigFishRun( sessionId: string, options: BigFishRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`, { method: 'POST', + headers: buildRuntimeGuestHeaders(options), }, '启动大鱼吃小鱼玩法失败', { retry: BIG_FISH_RUNTIME_WRITE_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } @@ -83,17 +80,22 @@ export function getBigFishRun(runId: string) { export function submitBigFishInput( runId: string, payload: SubmitBigFishInputRequest, + options: BigFishRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `/api/runtime/big-fish/runs/${encodeURIComponent(runId)}/input`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildRuntimeGuestHeaders(options, { + 'Content-Type': 'application/json', + }), body: JSON.stringify(payload), }, '同步大鱼吃小鱼输入失败', { retry: BIG_FISH_RUNTIME_WRITE_RETRY, + ...requestOptions, }, ); } diff --git a/src/services/jump-hop/jumpHopClient.ts b/src/services/jump-hop/jumpHopClient.ts index 02c39091..d1e7fe13 100644 --- a/src/services/jump-hop/jumpHopClient.ts +++ b/src/services/jump-hop/jumpHopClient.ts @@ -12,15 +12,20 @@ import type { JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, + JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, JumpHopWorkSummaryResponse, } from '../../../packages/shared/src/contracts/jumpHop'; import { - type ApiRequestOptions, type ApiRetryOptions, requestJson, } from '../apiClient'; import { createCreationAgentClient } from '../creation-agent'; +import { + buildRuntimeGuestAuthOptions, + buildRuntimeGuestHeaders, + type RuntimeGuestRequestOptions, +} from '../runtimeGuestAuth'; const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions'; const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works'; @@ -30,14 +35,7 @@ const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = { baseDelayMs: 120, maxDelayMs: 360, }; -type JumpHopRuntimeRequestOptions = Pick< - ApiRequestOptions, - | 'authImpact' - | 'skipAuth' - | 'skipRefresh' - | 'notifyAuthStateChange' - | 'clearAuthOnUnauthorized' ->; +type JumpHopRuntimeRequestOptions = RuntimeGuestRequestOptions; export type { JumpHopActionRequest, @@ -53,6 +51,7 @@ export type { JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, + JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; export type CreateJumpHopSessionRequest = { @@ -211,6 +210,17 @@ export async function getJumpHopGalleryDetail(publicWorkCode: string) { return normalizeJumpHopWorkDetailResponse(response); } +export async function listJumpHopWorks() { + return requestJson( + JUMP_HOP_WORKS_API_BASE, + { method: 'GET' }, + '读取跳一跳作品列表失败', + { + retry: JUMP_HOP_RUNTIME_READ_RETRY, + }, + ); +} + export async function publishJumpHopWork(profileId: string) { const response = await requestJson( `${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`, @@ -224,22 +234,20 @@ export async function startJumpHopRuntimeRun( profileId: string, options: JumpHopRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `${JUMP_HOP_RUNTIME_API_BASE}/runs`, { method: 'POST', headers: { 'content-type': 'application/json', + ...buildRuntimeGuestHeaders(options), }, body: JSON.stringify({ profileId }), }, '启动跳一跳运行态失败', { - authImpact: options.authImpact, - skipAuth: options.skipAuth, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } @@ -247,7 +255,9 @@ export async function startJumpHopRuntimeRun( export async function submitJumpHopJump( runId: string, payload: { chargeMs: number }, + options: JumpHopRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); const requestPayload = { chargeMs: payload.chargeMs, clientEventId: `jump-${runId}-${Date.now()}`, @@ -259,26 +269,34 @@ export async function submitJumpHopJump( method: 'POST', headers: { 'content-type': 'application/json', + ...buildRuntimeGuestHeaders(options), }, body: JSON.stringify(requestPayload), }, '提交跳一跳起跳失败', + requestOptions, ); } -export async function restartJumpHopRuntimeRun(runId: string) { +export async function restartJumpHopRuntimeRun( + runId: string, + options: JumpHopRuntimeRequestOptions = {}, +) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`, { method: 'POST', headers: { 'content-type': 'application/json', + ...buildRuntimeGuestHeaders(options), }, body: JSON.stringify({ clientActionId: `restart-${runId}-${Date.now()}`, }), }, '重新开始跳一跳失败', + requestOptions, ); } @@ -289,6 +307,7 @@ export const jumpHopClient = { getGalleryDetail: getJumpHopGalleryDetail, getWorkDetail: getJumpHopWorkDetail, listGallery: listJumpHopGallery, + listWorks: listJumpHopWorks, publishWork: publishJumpHopWork, restartRun: restartJumpHopRuntimeRun, startRun: startJumpHopRuntimeRun, diff --git a/src/services/match3d-runtime/match3dRuntimeClient.ts b/src/services/match3d-runtime/match3dRuntimeClient.ts index 5167093d..f1b0b5ec 100644 --- a/src/services/match3d-runtime/match3dRuntimeClient.ts +++ b/src/services/match3d-runtime/match3dRuntimeClient.ts @@ -9,10 +9,14 @@ import type { StopMatch3DRunRequest, } from '../../../packages/shared/src/contracts/match3dRuntime'; import { - type ApiRequestOptions, type ApiRetryOptions, requestJson, } from '../apiClient'; +import { + buildRuntimeGuestAuthOptions, + buildRuntimeGuestHeaders, + type RuntimeGuestRequestOptions, +} from '../runtimeGuestAuth'; const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = { maxRetries: 1, @@ -25,13 +29,7 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = { maxDelayMs: 360, retryUnsafeMethods: true, }; -export type Match3DRuntimeRequestOptions = Pick< - ApiRequestOptions, - | 'authImpact' - | 'skipRefresh' - | 'notifyAuthStateChange' - | 'clearAuthOnUnauthorized' -> & { +export type Match3DRuntimeRequestOptions = RuntimeGuestRequestOptions & { itemTypeCountOverride?: number | null; }; @@ -76,6 +74,7 @@ export function startMatch3DRun( profileId: string, options: Match3DRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); const payload: StartMatch3DRunRequest = { profileId, itemTypeCountOverride: options.itemTypeCountOverride ?? null, @@ -85,16 +84,15 @@ export function startMatch3DRun( `/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildRuntimeGuestHeaders(options, { + 'Content-Type': 'application/json', + }), body: JSON.stringify(payload), }, '启动抓大鹅玩法失败', { retry: MATCH3D_RUNTIME_WRITE_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index 92d46f70..53414f3a 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -9,10 +9,14 @@ import type { UsePuzzleRuntimePropRequest, } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import { - type ApiRequestOptions, type ApiRetryOptions, requestJson, } from '../apiClient'; +import { + buildRuntimeGuestAuthOptions, + buildRuntimeGuestHeaders, + type RuntimeGuestRequestOptions, +} from '../runtimeGuestAuth'; const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs'; const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = { @@ -26,13 +30,7 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = { maxDelayMs: 360, retryUnsafeMethods: true, }; -type PuzzleRuntimeRequestOptions = Pick< - ApiRequestOptions, - | 'authImpact' - | 'skipRefresh' - | 'notifyAuthStateChange' - | 'clearAuthOnUnauthorized' ->; +type PuzzleRuntimeRequestOptions = RuntimeGuestRequestOptions; /** * 从某个已发布拼图作品开始一次 run。 @@ -41,20 +39,20 @@ export async function startPuzzleRun( payload: StartPuzzleRunRequest, options: PuzzleRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( PUZZLE_RUNTIME_API_BASE, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildRuntimeGuestHeaders(options, { + 'Content-Type': 'application/json', + }), body: JSON.stringify(payload), }, '启动拼图玩法失败', { retry: PUZZLE_RUNTIME_WRITE_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } @@ -125,6 +123,7 @@ export async function advancePuzzleNextLevel( payload: AdvancePuzzleNextLevelRequest = {}, options: PuzzleRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); const targetProfileId = payload.targetProfileId?.trim() ?? ''; return requestJson( `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`, @@ -132,18 +131,19 @@ export async function advancePuzzleNextLevel( method: 'POST', ...(targetProfileId ? { - headers: { 'Content-Type': 'application/json' }, + headers: buildRuntimeGuestHeaders(options, { + 'Content-Type': 'application/json', + }), body: JSON.stringify({ targetProfileId }), } - : {}), + : { + headers: buildRuntimeGuestHeaders(options), + }), }, '进入下一关失败', { retry: PUZZLE_RUNTIME_WRITE_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } diff --git a/src/services/puzzle-works/puzzleAssetClient.test.ts b/src/services/puzzle-works/puzzleAssetClient.test.ts new file mode 100644 index 00000000..c8ad9db5 --- /dev/null +++ b/src/services/puzzle-works/puzzleAssetClient.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment jsdom + +import { describe, expect, test } from 'vitest'; + +import { + PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, + validatePuzzleReferenceImageFile, +} from './puzzleAssetClient'; + +describe('puzzle reference image upload validation', () => { + test('limits uploads to 6MB', () => { + expect(PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES).toBe(6 * 1024 * 1024); + }); + + test('rejects files that exceed the upload limit with a precise message', () => { + const file = new File([ + 'x'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES + 1), + ], 'too-large.png', { type: 'image/png' }); + + expect(() => validatePuzzleReferenceImageFile(file)).toThrow( + '参考图过大,请压缩后再上传(当前 6.0MB,最多 6MB)。', + ); + }); +}); diff --git a/src/services/puzzle-works/puzzleAssetClient.ts b/src/services/puzzle-works/puzzleAssetClient.ts index 30c90ddf..7583ced6 100644 --- a/src/services/puzzle-works/puzzleAssetClient.ts +++ b/src/services/puzzle-works/puzzleAssetClient.ts @@ -1,5 +1,9 @@ import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient'; import { requestJson } from '../apiClient'; +import { + PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, + validatePuzzleReferenceImageFile, +} from '../puzzleReferenceImage'; export type PuzzleHistoryAsset = { assetObjectId: string; @@ -40,8 +44,6 @@ type ConfirmAssetObjectResponse = { }; }; -const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024; - const MIME_BY_EXTENSION: Record = { jpeg: 'image/jpeg', jpg: 'image/jpeg', @@ -58,14 +60,9 @@ function resolvePuzzleImageContentType(file: File) { return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream'; } -function validatePuzzleReferenceImageFile(file: File) { +function validatePuzzleReferenceImageUploadFile(file: File) { const contentType = resolvePuzzleImageContentType(file); - if (file.size <= 0) { - throw new Error('参考图文件为空,请重新选择。'); - } - if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) { - throw new Error('参考图过大,请压缩后再上传。'); - } + validatePuzzleReferenceImageFile(file); if (!contentType.startsWith('image/')) { throw new Error('参考图必须是图片文件。'); } @@ -96,7 +93,7 @@ async function postDirectUploadFile( export async function uploadPuzzleReferenceImage(payload: { file: File; }): Promise { - validatePuzzleReferenceImageFile(payload.file); + validatePuzzleReferenceImageUploadFile(payload.file); const contentType = resolvePuzzleImageContentType(payload.file); const uploadedAt = Date.now(); const ticket = await requestJson( @@ -157,7 +154,12 @@ export async function uploadPuzzleReferenceImage(payload: { export const puzzleReferenceAssetTestUtils = { maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, - validateFile: validatePuzzleReferenceImageFile, + validateFile: validatePuzzleReferenceImageUploadFile, +}; + +export { + PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, + validatePuzzleReferenceImageUploadFile as validatePuzzleReferenceImageFile, }; /** diff --git a/src/services/puzzleReferenceImage.test.ts b/src/services/puzzleReferenceImage.test.ts index 4bfb71bf..a09c30c2 100644 --- a/src/services/puzzleReferenceImage.test.ts +++ b/src/services/puzzleReferenceImage.test.ts @@ -92,7 +92,7 @@ describe('readPuzzleReferenceImageAsDataUrl', () => { const dataUrl = await readPuzzleReferenceImageAsDataUrl(file); expect(dataUrl).toBe(`data:image/jpeg;base64,${'C'.repeat(1000)}`); - expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1536, 1152); + expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1024, 768); expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.84); expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.76); expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.68); @@ -114,7 +114,7 @@ describe('readPuzzleReferenceImageAsDataUrl', () => { }); await expect(readPuzzleReferenceImageAsDataUrl(file)).rejects.toThrow( - '参考图过大,请换一张尺寸更小的图片。', + '参考图过大,请压缩后再上传(当前 10.0MB,最多 6MB)。', ); }); }); diff --git a/src/services/puzzleReferenceImage.ts b/src/services/puzzleReferenceImage.ts index 1eac5862..4a9eaa0f 100644 --- a/src/services/puzzleReferenceImage.ts +++ b/src/services/puzzleReferenceImage.ts @@ -1,8 +1,29 @@ const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1024; const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024; +export const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 6 * 1024 * 1024; export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; const PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE = 1; +export function formatPuzzleReferenceImageUploadBytes(bytes: number) { + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} + +export function buildPuzzleReferenceImageTooLargeMessage(actualBytes: number) { + return `参考图过大,请压缩后再上传(当前 ${formatPuzzleReferenceImageUploadBytes(actualBytes)},最多 6MB)。`; +} + +export function validatePuzzleReferenceImageFile(file: File) { + if (file.size <= 0) { + throw new Error('参考图文件为空,请重新选择。'); + } + if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) { + throw new Error(buildPuzzleReferenceImageTooLargeMessage(file.size)); + } + if (file.type.trim() && !file.type.trim().startsWith('image/')) { + throw new Error('参考图必须是图片文件。'); + } +} + type PuzzleReferenceImageSize = { width: number; height: number; @@ -36,7 +57,7 @@ function readFileAsDataUrl(file: File) { function ensureReferenceImageWithinLimit(dataUrl: string) { if (dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) { - throw new Error('参考图过大,请换一张尺寸更小的图片。'); + throw new Error(buildPuzzleReferenceImageTooLargeMessage(dataUrl.length)); } return dataUrl; } @@ -130,6 +151,7 @@ async function compressReferenceImageDataUrl(file: File, dataUrl: string) { } export async function readPuzzleReferenceImageAsDataUrl(file: File) { + validatePuzzleReferenceImageFile(file); const dataUrl = await readFileAsDataUrl(file); try { const compressedDataUrl = await compressReferenceImageDataUrl( @@ -150,6 +172,7 @@ export async function readPuzzleReferenceImageAsDataUrl(file: File) { export async function readPuzzleReferenceImageForUpload( file: File, ): Promise { + validatePuzzleReferenceImageFile(file); const dataUrl = await readFileAsDataUrl(file); const image = await loadReferenceImage(dataUrl); const size = resolveReferenceImageNaturalSize(image); diff --git a/src/services/recommendedRuntimeGuestLaunch.test.ts b/src/services/recommendedRuntimeGuestLaunch.test.ts new file mode 100644 index 00000000..9960d6fe --- /dev/null +++ b/src/services/recommendedRuntimeGuestLaunch.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiClientMocks = vi.hoisted(() => ({ + requestJson: vi.fn(), +})); + +vi.mock('./apiClient', async () => { + const actual = + await vi.importActual('./apiClient'); + return { + ...actual, + requestJson: apiClientMocks.requestJson, + }; +}); + +import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient'; +import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient'; +import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient'; +import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient'; +import { startPuzzleRun } from './puzzle-runtime/puzzleRuntimeClient'; +import { startSquareHoleRun } from './square-hole-runtime/squareHoleRuntimeClient'; +import { startVisualNovelRun } from './visual-novel-runtime/visualNovelRuntimeClient'; + +describe('recommended runtime guest launch clients', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiClientMocks.requestJson.mockResolvedValue({ run: {} }); + }); + + it.each([ + { + name: 'jump-hop', + start: () => + startJumpHopRuntimeRun('jump-hop-profile-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/jump-hop/runs', + }, + { + name: 'visual-novel', + start: () => + startVisualNovelRun( + 'visual-novel-profile-1', + { profileId: 'visual-novel-profile-1', mode: 'play' }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/visual-novel/works/visual-novel-profile-1/runs', + }, + { + name: 'match3d', + start: () => + startMatch3DRun('match3d-profile-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/match3d/works/match3d-profile-1/runs', + }, + { + name: 'square-hole', + start: () => + startSquareHoleRun('square-hole-profile-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/square-hole/works/square-hole-profile-1/runs', + }, + { + name: 'big-fish', + start: () => + startBigFishRun('big-fish-session-1', { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/big-fish/sessions/big-fish-session-1/runs', + }, + { + name: 'bark-battle', + start: () => + startBarkBattleRun('bark-battle-work-1', {}, { + runtimeGuestToken: 'runtime-guest-token', + }), + expectedUrl: '/api/runtime/bark-battle/works/bark-battle-work-1/runs', + }, + { + name: 'puzzle', + start: () => + startPuzzleRun( + { profileId: 'puzzle-profile-1', levelId: 'level-1' }, + { runtimeGuestToken: 'runtime-guest-token' }, + ), + expectedUrl: '/api/runtime/puzzle/runs', + }, + ])( + '$name start request uses the runtime guest bearer token without touching login auth', + async ({ start, expectedUrl }) => { + await start(); + + const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0]; + expect(url).toBe(expectedUrl); + expect(init).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer runtime-guest-token', + }), + }), + ); + expect(options).toEqual( + expect.objectContaining({ + skipAuth: true, + skipRefresh: true, + }), + ); + }, + ); +}); diff --git a/src/services/runtimeGuestAuth.ts b/src/services/runtimeGuestAuth.ts new file mode 100644 index 00000000..a8c45c26 --- /dev/null +++ b/src/services/runtimeGuestAuth.ts @@ -0,0 +1,40 @@ +import type { ApiRequestOptions } from './apiClient'; + +export type RuntimeGuestRequestOptions = Pick< + ApiRequestOptions, + | 'authImpact' + | 'skipAuth' + | 'skipRefresh' + | 'notifyAuthStateChange' + | 'clearAuthOnUnauthorized' +> & { + runtimeGuestToken?: string; +}; + +export function buildRuntimeGuestHeaders( + options: Pick, + headers: Record = {}, +) { + const runtimeGuestToken = options.runtimeGuestToken?.trim(); + if (!runtimeGuestToken) { + return headers; + } + + return { + ...headers, + Authorization: `Bearer ${runtimeGuestToken}`, + }; +} + +export function buildRuntimeGuestAuthOptions< + TOptions extends RuntimeGuestRequestOptions, +>(options: TOptions) { + const runtimeGuestToken = options.runtimeGuestToken?.trim(); + return { + authImpact: options.authImpact, + skipAuth: runtimeGuestToken ? true : options.skipAuth, + skipRefresh: runtimeGuestToken ? true : options.skipRefresh, + notifyAuthStateChange: options.notifyAuthStateChange, + clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + } satisfies ApiRequestOptions; +} diff --git a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts index ff32786f..083c9dec 100644 --- a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts +++ b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts @@ -5,10 +5,14 @@ import type { StopSquareHoleRunRequest, } from '../../../packages/shared/src/contracts/squareHoleRuntime'; import { - type ApiRequestOptions, type ApiRetryOptions, requestJson, } from '../apiClient'; +import { + buildRuntimeGuestAuthOptions, + buildRuntimeGuestHeaders, + type RuntimeGuestRequestOptions, +} from '../runtimeGuestAuth'; const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = { maxRetries: 1, @@ -21,13 +25,7 @@ const SQUARE_HOLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = { maxDelayMs: 360, retryUnsafeMethods: true, }; -type SquareHoleRuntimeRequestOptions = Pick< - ApiRequestOptions, - | 'authImpact' - | 'skipRefresh' - | 'notifyAuthStateChange' - | 'clearAuthOnUnauthorized' ->; +type SquareHoleRuntimeRequestOptions = RuntimeGuestRequestOptions; /** * 基于作品启动一局方洞挑战正式 run。 @@ -36,20 +34,20 @@ export function startSquareHoleRun( profileId: string, options: SquareHoleRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildRuntimeGuestHeaders(options, { + 'Content-Type': 'application/json', + }), body: JSON.stringify({ profileId }), }, '启动方洞挑战失败', { retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } diff --git a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts index 338cc1ab..b2210823 100644 --- a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts +++ b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts @@ -19,12 +19,16 @@ import type { import { parseApiErrorMessage } from '../../../packages/shared/src/http'; import type { TextStreamOptions } from '../aiTypes'; import { - type ApiRequestOptions, type ApiRetryOptions, fetchWithApiAuth, requestJson, } from '../apiClient'; import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse'; +import { + buildRuntimeGuestAuthOptions, + buildRuntimeGuestHeaders, + type RuntimeGuestRequestOptions, +} from '../runtimeGuestAuth'; const VISUAL_NOVEL_RUNTIME_API_BASE = '/api/runtime/visual-novel'; const VISUAL_NOVEL_RUNTIME_READ_RETRY: ApiRetryOptions = { @@ -39,16 +43,11 @@ const VISUAL_NOVEL_RUNTIME_WRITE_RETRY: ApiRetryOptions = { retryUnsafeMethods: true, }; -export type VisualNovelRuntimeStreamOptions = TextStreamOptions & { - onEvent?: (event: VisualNovelRuntimeStreamEvent) => void; -}; -type VisualNovelRuntimeRequestOptions = Pick< - ApiRequestOptions, - | 'authImpact' - | 'skipRefresh' - | 'notifyAuthStateChange' - | 'clearAuthOnUnauthorized' ->; +export type VisualNovelRuntimeStreamOptions = TextStreamOptions & + RuntimeGuestRequestOptions & { + onEvent?: (event: VisualNovelRuntimeStreamEvent) => void; + }; +type VisualNovelRuntimeRequestOptions = RuntimeGuestRequestOptions; export type VisualNovelSaveArchiveResumeResponse = ProfileSaveArchiveResumeResponse< @@ -84,11 +83,20 @@ async function openVisualNovelRuntimeSsePost( payload: unknown, fallbackMessage: string, signal?: AbortSignal, + options: RuntimeGuestRequestOptions = {}, ) { - const response = await fetchWithApiAuth(url, { - ...buildJsonInit('POST', payload), - signal, - }); + const requestOptions = buildRuntimeGuestAuthOptions(options); + const response = await fetchWithApiAuth( + url, + { + ...buildJsonInit('POST', payload), + headers: buildRuntimeGuestHeaders(options, { + 'Content-Type': 'application/json', + }), + signal, + }, + requestOptions, + ); if (!response.ok) { const responseText = await response.text(); @@ -107,17 +115,20 @@ export async function startVisualNovelRun( payload: VisualNovelStartRunRequest, options: VisualNovelRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`, - buildJsonInit('POST', payload), + { + ...buildJsonInit('POST', payload), + headers: buildRuntimeGuestHeaders(options, { + 'Content-Type': 'application/json', + }), + }, '启动视觉小说运行失败', { retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY, timeoutMs: 15000, - authImpact: options.authImpact, - skipRefresh: options.skipRefresh, - notifyAuthStateChange: options.notifyAuthStateChange, - clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, + ...requestOptions, }, ); } @@ -154,6 +165,7 @@ export async function streamVisualNovelRuntimeAction( payload, '推进视觉小说失败', options.signal, + options, ); return readVisualNovelRuntimeRunFromSse(response, { diff --git a/src/services/wooden-fish/woodenFishClient.ts b/src/services/wooden-fish/woodenFishClient.ts index 55859167..8aa08ef4 100644 --- a/src/services/wooden-fish/woodenFishClient.ts +++ b/src/services/wooden-fish/woodenFishClient.ts @@ -18,6 +18,11 @@ import type { } from '../../../packages/shared/src/contracts/woodenFish'; import { type ApiRetryOptions, requestJson } from '../apiClient'; import { createCreationAgentClient } from '../creation-agent'; +import { + buildRuntimeGuestAuthOptions, + buildRuntimeGuestHeaders, + type RuntimeGuestRequestOptions, +} from '../runtimeGuestAuth'; const WOODEN_FISH_API_BASE = '/api/creation/wooden-fish/sessions'; const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works'; @@ -29,6 +34,13 @@ const WOODEN_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = { baseDelayMs: 120, maxDelayMs: 360, }; +const WOODEN_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = { + maxRetries: 1, + baseDelayMs: 120, + maxDelayMs: 360, + retryUnsafeMethods: true, +}; +type WoodenFishRuntimeRequestOptions = RuntimeGuestRequestOptions; export type { WoodenFishActionRequest, @@ -210,24 +222,35 @@ export async function publishWoodenFishWork(profileId: string) { return normalizeWoodenFishWorkMutationResponse(response); } -export async function startWoodenFishRuntimeRun(profileId: string) { +export async function startWoodenFishRuntimeRun( + profileId: string, + options: WoodenFishRuntimeRequestOptions = {}, +) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `${WOODEN_FISH_RUNTIME_API_BASE}/runs`, { method: 'POST', headers: { 'content-type': 'application/json', + ...buildRuntimeGuestHeaders(options), }, body: JSON.stringify({ profileId }), }, '启动敲木鱼运行态失败', + { + retry: WOODEN_FISH_RUNTIME_WRITE_RETRY, + ...requestOptions, + }, ); } export async function checkpointWoodenFishRun( runId: string, payload: Omit, + options: WoodenFishRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); const requestPayload: WoodenFishCheckpointRunRequest = { ...payload, clientEventId: `checkpoint-${runId}-${Date.now()}`, @@ -239,17 +262,24 @@ export async function checkpointWoodenFishRun( method: 'POST', headers: { 'content-type': 'application/json', + ...buildRuntimeGuestHeaders(options), }, body: JSON.stringify(requestPayload), }, '保存敲木鱼进度失败', + { + retry: WOODEN_FISH_RUNTIME_WRITE_RETRY, + ...requestOptions, + }, ); } export async function finishWoodenFishRun( runId: string, payload: Omit, + options: WoodenFishRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); const requestPayload: WoodenFishFinishRunRequest = { ...payload, clientEventId: `finish-${runId}-${Date.now()}`, @@ -261,10 +291,15 @@ export async function finishWoodenFishRun( method: 'POST', headers: { 'content-type': 'application/json', + ...buildRuntimeGuestHeaders(options), }, body: JSON.stringify(requestPayload), }, '结束敲木鱼运行失败', + { + retry: WOODEN_FISH_RUNTIME_WRITE_RETRY, + ...requestOptions, + }, ); }