From c1dcf074bb9fc81da0041f08acc72632c3d71960 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 25 May 2026 14:03:38 +0800 Subject: [PATCH] feat: unify recommend anonymous runtime guest auth - Route recommended runtime launches through shared runtime guest token handling - Extend recommend-page anonymous play beyond jump-hop - Add regression coverage for runtime guest launch clients - Update docs to reflect the full anonymous-play matrix --- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- packages/shared/src/contracts/auth.ts | 7 + server-rs/crates/api-server/src/auth.rs | 141 +++++++++++++-- server-rs/crates/api-server/src/jump_hop.rs | 38 ++-- .../crates/api-server/src/modules/auth.rs | 3 +- .../crates/api-server/src/modules/jump_hop.rs | 8 +- .../api-server/src/modules/wooden_fish.rs | 8 +- .../crates/api-server/src/wooden_fish.rs | 14 +- .../api-server/src/work_play_tracking.rs | 22 ++- server-rs/crates/platform-auth/src/lib.rs | 163 ++++++++++++++++++ server-rs/crates/shared-contracts/src/auth.rs | 9 + .../PlatformEntryFlowShellImpl.tsx | 157 +++++++++++------ src/services/authService.ts | 37 ++++ .../barkBattleRuntimeClient.ts | 46 ++--- .../big-fish-runtime/bigFishRuntimeClient.ts | 38 ++-- src/services/jump-hop/jumpHopClient.ts | 35 ++-- .../match3d-runtime/match3dRuntimeClient.ts | 24 ++- .../puzzle-runtime/puzzleRuntimeClient.ts | 38 ++-- .../recommendedRuntimeGuestLaunch.test.ts | 113 ++++++++++++ src/services/runtimeGuestAuth.ts | 40 +++++ .../squareHoleRuntimeClient.ts | 24 ++- .../visualNovelRuntimeClient.ts | 52 +++--- src/services/wooden-fish/woodenFishClient.ts | 37 +++- 23 files changed, 820 insertions(+), 236 deletions(-) create mode 100644 src/services/recommendedRuntimeGuestLaunch.test.ts create mode 100644 src/services/runtimeGuestAuth.ts diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index aad3ba89..7652708c 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -124,7 +124,7 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > 删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。 -推荐页允许未登录直接游玩跳一跳运行态;`/api/runtime/jump-hop/runs`、`/jump` 和 `/restart` 采用可选鉴权,未登录时仍记录 `work_play_start`,但埋点需标记匿名语义。 +推荐页匿名游玩不再限定为跳一跳。推荐页嵌入运行态启动时统一先申请短期 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/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 60cbedd7..32222b53 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -24,7 +24,7 @@ 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, @@ -51,7 +51,6 @@ 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( @@ -231,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()) @@ -256,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, ) @@ -265,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; @@ -280,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) @@ -311,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) @@ -711,16 +702,11 @@ async fn persist_jump_hop_generated_image_asset( } 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) } 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 2ae8c053..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,7 +4,7 @@ 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, @@ -58,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/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 016cfa3a..68be6000 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, @@ -229,14 +229,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( @@ -256,7 +256,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")?; @@ -265,7 +265,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 @@ -287,7 +287,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")?; @@ -296,7 +296,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/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/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 07412fad..65bcbc89 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, @@ -550,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; @@ -3253,6 +3260,11 @@ export function PlatformEntryFlowShellImpl({ resolveRpgCreationErrorMessage(error, fallback), [], ); + const resolveBarkBattleErrorMessage = useCallback( + (error: unknown, fallback: string) => + resolveRpgCreationErrorMessage(error, fallback), + [], + ); const refreshBigFishShelf = useCallback(async () => { setIsBigFishLoadingLibrary(true); @@ -7135,11 +7147,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); @@ -7186,9 +7201,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) { @@ -7200,6 +7220,7 @@ export function PlatformEntryFlowShellImpl({ } }, [ + activeRecommendRuntimeKind, isVisualNovelBusy, resolvePuzzleErrorMessage, setIsVisualNovelBusy, @@ -7608,12 +7629,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); @@ -7902,9 +7923,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); @@ -8384,15 +8410,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); @@ -8488,10 +8514,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 } : {}), @@ -8559,11 +8586,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); @@ -8715,9 +8742,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) { @@ -8728,7 +8760,12 @@ export function PlatformEntryFlowShellImpl({ bigFishInputInFlightRef.current = false; } }, - [bigFishRun, resolveBigFishErrorMessage, setBigFishError], + [ + activeRecommendRuntimeKind, + bigFishRun, + resolveBigFishErrorMessage, + setBigFishError, + ], ); const reportBigFishObservedPlayTime = useCallback(() => { @@ -8929,12 +8966,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; @@ -9057,10 +9095,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); @@ -9117,6 +9153,10 @@ export function PlatformEntryFlowShellImpl({ return; } + const runtimeGuestOptions = + puzzleRuntimeAuthMode === 'isolated' + ? await buildRecommendRuntimeGuestOptions() + : {}; const targetProfileId = _target?.profileId?.trim() ?? ''; if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) { const itemPromise = @@ -9132,7 +9172,7 @@ export function PlatformEntryFlowShellImpl({ { targetProfileId, }, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + runtimeGuestOptions, ) : advancePuzzleNextLevel(puzzleRun.runId, { targetProfileId, @@ -9157,7 +9197,7 @@ export function PlatformEntryFlowShellImpl({ ? await advancePuzzleNextLevel( puzzleRun.runId, {}, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + runtimeGuestOptions, ) : await advancePuzzleNextLevel(puzzleRun.runId); setPuzzleRun(run); @@ -10993,11 +11033,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); @@ -11008,11 +11048,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( @@ -11031,9 +11067,10 @@ export function PlatformEntryFlowShellImpl({ ); const startBarkBattleRunFromWork = useCallback( - ( + async ( item: BarkBattleWorkSummary, returnStage: BarkBattleRuntimeReturnStage = 'work-detail', + options: { embedded?: boolean } = {}, ) => { if (item.status !== 'published') { setBarkBattleError('汪汪声浪作品发布后才能进入正式玩法。'); @@ -11045,17 +11082,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(() => { @@ -11327,7 +11380,9 @@ export function PlatformEntryFlowShellImpl({ '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', ); } else { - started = startBarkBattleRunFromWork(work, 'platform'); + started = await startBarkBattleRunFromWork(work, 'platform', { + embedded: true, + }); } } else if (isEdutainmentGalleryEntry(entry)) { started = await startBabyObjectMatchRuntimeFromEntry( diff --git a/src/services/authService.ts b/src/services/authService.ts index 0ea3c820..e7002375 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -25,6 +25,7 @@ import type { AuthWechatStartResponse, LogoutResponse, PublicUserSearchResponse, + RuntimeGuestTokenResponse, } from '../../packages/shared/src/contracts/auth'; import type { RedeemProfileReferralInviteCodeResponse } from '../../packages/shared/src/contracts/runtime'; import { @@ -61,6 +62,42 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = { skipRefresh: true, } satisfies ApiRequestOptions; +const runtimeGuestTokenCache: { + value: RuntimeGuestTokenResponse | null; +} = { + value: null, +}; + +function isRuntimeGuestTokenFresh(response: RuntimeGuestTokenResponse | null) { + if (!response?.expiresAt) { + return false; + } + const expiresAtMs = Date.parse(response.expiresAt); + return Number.isFinite(expiresAtMs) && expiresAtMs - Date.now() > 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 159d733f..d1e7fe13 100644 --- a/src/services/jump-hop/jumpHopClient.ts +++ b/src/services/jump-hop/jumpHopClient.ts @@ -17,11 +17,15 @@ import type { 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'; @@ -31,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, @@ -237,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, }, ); } @@ -260,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()}`, @@ -272,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, ); } 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/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 d2a0fc4e..11a652be 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'; @@ -27,6 +32,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, @@ -204,24 +216,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()}`, @@ -233,17 +256,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()}`, @@ -255,10 +285,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, + }, ); }