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
This commit is contained in:
kdletters
2026-05-25 14:03:38 +08:00
parent 9a0bc6b129
commit c1dcf074bb
23 changed files with 820 additions and 236 deletions

View File

@@ -124,7 +124,7 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 >
删除等破坏性动作当前未接入 jump-hop 删除 API如果后续要在作品架提供删除入口必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。 删除等破坏性动作当前未接入 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 等账号/所有权动作仍保持普通用户鉴权
## 敲木鱼 ## 敲木鱼

View File

@@ -25,6 +25,13 @@ export type PublicUserSearchResponse = {
user: PublicUserSummary; user: PublicUserSummary;
}; };
export type RuntimeGuestTokenResponse = {
token: string;
expiresAt: string;
subject: string;
scope: string;
};
export type AuthEntryRequest = { export type AuthEntryRequest = {
phone: string; phone: string;
password: string; password: string;

View File

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

View File

@@ -24,7 +24,7 @@ use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}};
use crate::{ use crate::{
api_response::json_success_body, api_response::json_success_body,
auth::AuthenticatedAccessToken, auth::{AuthenticatedAccessToken, RuntimePrincipal},
http_error::AppError, http_error::AppError,
generated_asset_sheets::{ generated_asset_sheets::{
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt, 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_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; 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"; const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
pub async fn create_jump_hop_session( 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( pub async fn start_jump_hop_run(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>, Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<JumpHopStartRunRequest>, JsonRejection>, payload: Result<Json<JumpHopStartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> { ) -> Result<Json<Value>, Response> {
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
let authenticated = maybe_authenticated.as_ref().map(|Extension(authenticated)| authenticated); let owner_user_id = principal.subject().to_string();
let owner_user_id = authenticated let principal_kind = principal.kind().as_str();
.map(|authenticated| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let run = state let run = state
.spacetime_client() .spacetime_client()
.start_jump_hop_run(payload, owner_user_id.clone()) .start_jump_hop_run(payload, owner_user_id.clone())
@@ -256,7 +253,7 @@ pub async fn start_jump_hop_run(
&state, &state,
&request_context, &request_context,
build_jump_hop_work_play_tracking_draft( build_jump_hop_work_play_tracking_draft(
authenticated, &principal,
run.profile_id.clone(), run.profile_id.clone(),
JUMP_HOP_RUNTIME_RUNS_ROUTE, JUMP_HOP_RUNTIME_RUNS_ROUTE,
) )
@@ -265,7 +262,7 @@ pub async fn start_jump_hop_run(
.profile_id(run.profile_id.clone()) .profile_id(run.profile_id.clone())
.extra(json!({ .extra(json!({
"runStatus": run.status, "runStatus": run.status,
"isAnonymous": maybe_authenticated.is_none(), "principalKind": principal_kind,
})), })),
) )
.await; .await;
@@ -280,15 +277,12 @@ pub async fn jump_hop_run_jump(
State(state): State<AppState>, State(state): State<AppState>,
Path(run_id): Path<String>, Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>, Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<JumpHopJumpRequest>, JsonRejection>, payload: Result<Json<JumpHopJumpRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> { ) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?; ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
let owner_user_id = maybe_authenticated let owner_user_id = principal.subject().to_string();
.as_ref()
.map(|Extension(authenticated)| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let run = state let run = state
.spacetime_client() .spacetime_client()
.jump_hop_run_jump(run_id, owner_user_id, payload) .jump_hop_run_jump(run_id, owner_user_id, payload)
@@ -311,15 +305,12 @@ pub async fn restart_jump_hop_run(
State(state): State<AppState>, State(state): State<AppState>,
Path(run_id): Path<String>, Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>, Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<JumpHopRestartRunRequest>, JsonRejection>, payload: Result<Json<JumpHopRestartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> { ) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?; ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
let owner_user_id = maybe_authenticated let owner_user_id = principal.subject().to_string();
.as_ref()
.map(|Extension(authenticated)| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let run = state let run = state
.spacetime_client() .spacetime_client()
.restart_jump_hop_run(run_id, owner_user_id, payload) .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( fn build_jump_hop_work_play_tracking_draft(
authenticated: Option<&AuthenticatedAccessToken>, principal: &RuntimePrincipal,
work_id: impl Into<String>, work_id: impl Into<String>,
source_route: &'static str, source_route: &'static str,
) -> WorkPlayTrackingDraft { ) -> WorkPlayTrackingDraft {
match authenticated { WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
Some(authenticated) => {
WorkPlayTrackingDraft::new("jump-hop", work_id, authenticated, source_route)
}
None => WorkPlayTrackingDraft::anonymous("jump-hop", work_id, source_route),
}
} }

View File

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

View File

@@ -4,7 +4,7 @@ use axum::{
}; };
use crate::{ use crate::{
auth::{attach_optional_bearer_auth, require_bearer_auth}, auth::{require_bearer_auth, require_runtime_principal_auth},
jump_hop::{ jump_hop::{
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail, 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, 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<AppState> {
"/api/runtime/jump-hop/runs", "/api/runtime/jump-hop/runs",
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state( post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
attach_optional_bearer_auth, require_runtime_principal_auth,
)), )),
) )
.route( .route(
"/api/runtime/jump-hop/runs/{run_id}/jump", "/api/runtime/jump-hop/runs/{run_id}/jump",
post(jump_hop_run_jump).route_layer(middleware::from_fn_with_state( post(jump_hop_run_jump).route_layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
attach_optional_bearer_auth, require_runtime_principal_auth,
)), )),
) )
.route( .route(
"/api/runtime/jump-hop/runs/{run_id}/restart", "/api/runtime/jump-hop/runs/{run_id}/restart",
post(restart_jump_hop_run).route_layer(middleware::from_fn_with_state( post(restart_jump_hop_run).route_layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
attach_optional_bearer_auth, require_runtime_principal_auth,
)), )),
) )
.route("/api/runtime/jump-hop/gallery", get(list_jump_hop_gallery)) .route("/api/runtime/jump-hop/gallery", get(list_jump_hop_gallery))

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ use module_runtime::RuntimeTrackingScopeKind;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::{ use crate::{
auth::AuthenticatedAccessToken, auth::{AuthenticatedAccessToken, RuntimePrincipal},
request_context::RequestContext, request_context::RequestContext,
state::{AppState, PuzzleApiState}, state::{AppState, PuzzleApiState},
tracking::{TrackingEventDraft, record_tracking_event_after_success}, 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, play_type: &'static str,
work_id: impl Into<String>, work_id: impl Into<String>,
principal: &RuntimePrincipal,
source_route: &'static str, source_route: &'static str,
) -> Self { ) -> 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( fn with_user_id(

View File

@@ -21,6 +21,9 @@ use url::Url;
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256; pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60; 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_NAME: &str = "genarrative_refresh_session";
pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth"; pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth";
pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30; pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30;
@@ -107,6 +110,21 @@ pub struct AccessTokenClaims {
pub exp: u64, 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 与后续模块里散落。 // 统一承载 JWT 配置,避免 secret、issuer、ttl 在 api-server 与后续模块里散落。
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct JwtConfig { pub struct JwtConfig {
@@ -417,6 +435,10 @@ impl JwtConfig {
pub fn access_token_ttl_seconds(&self) -> u64 { pub fn access_token_ttl_seconds(&self) -> u64 {
self.access_token_ttl_seconds self.access_token_ttl_seconds
} }
pub fn runtime_guest_token_ttl_seconds(&self) -> u64 {
DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS
}
} }
impl RefreshCookieSameSite { impl RefreshCookieSameSite {
@@ -1474,6 +1496,74 @@ impl AccessTokenClaims {
} }
} }
impl RuntimeGuestTokenClaims {
pub fn from_input(
input: RuntimeGuestTokenClaimsInput,
config: &JwtConfig,
issued_at: OffsetDateTime,
) -> Result<Self, JwtError> {
let subject = normalize_required_field(input.subject, "runtime guest JWT sub 不能为空")?;
let scope = normalize_required_field(input.scope, "runtime guest JWT scope 不能为空")?;
let issued_at_unix = issued_at.unix_timestamp();
if issued_at_unix < 0 {
return Err(JwtError::InvalidClaims("runtime guest JWT iat 不能早于 Unix epoch"));
}
let expires_at = issued_at
.checked_add(Duration::seconds(
i64::try_from(config.runtime_guest_token_ttl_seconds()).map_err(|_| {
JwtError::InvalidConfig("runtime guest JWT 过期时间超出 i64 上限")
})?,
))
.ok_or(JwtError::InvalidConfig("runtime guest JWT 过期时间计算溢出"))?;
let expires_at_unix = expires_at.unix_timestamp();
if expires_at_unix <= issued_at_unix {
return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat"));
}
let claims = Self {
iss: config.issuer().to_string(),
sub: subject,
typ: RUNTIME_GUEST_TOKEN_TYPE.to_string(),
scope,
iat: issued_at_unix as u64,
exp: expires_at_unix as u64,
};
claims.validate_for_config(config)?;
Ok(claims)
}
pub fn subject(&self) -> &str {
&self.sub
}
pub fn scope(&self) -> &str {
&self.scope
}
pub fn expires_at_unix(&self) -> u64 {
self.exp
}
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
if self.iss.trim() != config.issuer() {
return Err(JwtError::InvalidClaims(
"runtime guest JWT iss 与当前配置不一致",
));
}
normalize_required_field(self.sub.clone(), "runtime guest JWT sub 不能为空")?;
normalize_required_field(self.scope.clone(), "runtime guest JWT scope 不能为空")?;
if self.typ.trim() != RUNTIME_GUEST_TOKEN_TYPE {
return Err(JwtError::InvalidClaims("runtime guest JWT typ 非法"));
}
if self.exp <= self.iat {
return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat"));
}
Ok(())
}
}
impl AccessTokenDeviceInfo { impl AccessTokenDeviceInfo {
pub fn normalize(self) -> Result<Self, JwtError> { pub fn normalize(self) -> Result<Self, JwtError> {
Ok(Self { Ok(Self {
@@ -1526,6 +1616,26 @@ pub fn sign_access_token(
.map_err(|error| JwtError::SignFailed(format!("JWT 签发失败:{error}"))) .map_err(|error| JwtError::SignFailed(format!("JWT 签发失败:{error}")))
} }
pub fn sign_runtime_guest_token(
claims: &RuntimeGuestTokenClaims,
config: &JwtConfig,
) -> Result<String, JwtError> {
claims.validate_for_config(config)?;
let header = Header {
alg: ACCESS_TOKEN_ALGORITHM,
typ: Some("JWT".to_string()),
..Header::default()
};
encode(
&header,
claims,
&EncodingKey::from_secret(config.secret.as_bytes()),
)
.map_err(|error| JwtError::SignFailed(format!("runtime guest JWT 签发失败:{error}")))
}
pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, JwtError> { pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, JwtError> {
let token = token.trim(); let token = token.trim();
if token.is_empty() { if token.is_empty() {
@@ -1552,6 +1662,35 @@ pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessToke
Ok(decoded.claims) Ok(decoded.claims)
} }
pub fn verify_runtime_guest_token(
token: &str,
config: &JwtConfig,
) -> Result<RuntimeGuestTokenClaims, JwtError> {
let token = token.trim();
if token.is_empty() {
return Err(JwtError::VerifyFailed("runtime guest JWT 不能为空".to_string()));
}
let mut validation = Validation::new(ACCESS_TOKEN_ALGORITHM);
validation.required_spec_claims = HashSet::from([
"exp".to_string(),
"iat".to_string(),
"iss".to_string(),
"sub".to_string(),
]);
validation.set_issuer(&[config.issuer()]);
let decoded = decode::<RuntimeGuestTokenClaims>(
token,
&DecodingKey::from_secret(config.secret.as_bytes()),
&validation,
)
.map_err(map_verify_error)?;
decoded.claims.validate_for_config(config)?;
Ok(decoded.claims)
}
pub fn read_refresh_session_token( pub fn read_refresh_session_token(
cookie_header: &str, cookie_header: &str,
config: &RefreshCookieConfig, config: &RefreshCookieConfig,
@@ -2218,6 +2357,30 @@ mod tests {
.expect("real aliyun sms config should be valid") .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] #[test]
fn round_trip_sign_and_verify_access_token() { fn round_trip_sign_and_verify_access_token() {
let config = build_jwt_config(); let config = build_jwt_config();

View File

@@ -42,6 +42,15 @@ pub struct PublicUserSearchResponse {
pub user: PublicUserSummaryPayload, 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)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PasswordEntryRequest { pub struct PasswordEntryRequest {

View File

@@ -117,6 +117,7 @@ import {
BACKGROUND_AUTH_REQUEST_OPTIONS, BACKGROUND_AUTH_REQUEST_OPTIONS,
} from '../../services/apiClient'; } from '../../services/apiClient';
import { import {
ensureRuntimeGuestToken,
getPublicAuthUserByCode, getPublicAuthUserByCode,
getPublicAuthUserById, getPublicAuthUserById,
} from '../../services/authService'; } from '../../services/authService';
@@ -127,6 +128,7 @@ import {
publishBarkBattleWork, publishBarkBattleWork,
updateBarkBattleDraftConfig, updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation'; } from '../../services/bark-battle-creation';
import { startBarkBattleRun } from '../../services/bark-battle-runtime';
import { import {
createBigFishCreationSession, createBigFishCreationSession,
executeBigFishCreationAction, executeBigFishCreationAction,
@@ -550,8 +552,13 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
]); ]);
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS =
BACKGROUND_AUTH_REQUEST_OPTIONS; BACKGROUND_AUTH_REQUEST_OPTIONS;
const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS = async function buildRecommendRuntimeGuestOptions() {
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; const { token } = await ensureRuntimeGuestToken();
return {
...RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
runtimeGuestToken: token,
};
}
const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3;
@@ -3253,6 +3260,11 @@ export function PlatformEntryFlowShellImpl({
resolveRpgCreationErrorMessage(error, fallback), resolveRpgCreationErrorMessage(error, fallback),
[], [],
); );
const resolveBarkBattleErrorMessage = useCallback(
(error: unknown, fallback: string) =>
resolveRpgCreationErrorMessage(error, fallback),
[],
);
const refreshBigFishShelf = useCallback(async () => { const refreshBigFishShelf = useCallback(async () => {
setIsBigFishLoadingLibrary(true); setIsBigFishLoadingLibrary(true);
@@ -7135,11 +7147,14 @@ export function PlatformEntryFlowShellImpl({
profileId: targetProfileId, profileId: targetProfileId,
mode: 'play' as const, mode: 'play' as const,
}; };
const runtimeGuestOptions = options.embedded
? await buildRecommendRuntimeGuestOptions()
: {};
const { run } = options.embedded const { run } = options.embedded
? await startVisualNovelRun( ? await startVisualNovelRun(
targetProfileId, targetProfileId,
startRunPayload, startRunPayload,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, runtimeGuestOptions,
) )
: await startVisualNovelRun(targetProfileId, startRunPayload); : await startVisualNovelRun(targetProfileId, startRunPayload);
setVisualNovelWork(workDetail); setVisualNovelWork(workDetail);
@@ -7186,9 +7201,14 @@ export function PlatformEntryFlowShellImpl({
setVisualNovelError(null); setVisualNovelError(null);
setIsVisualNovelBusy(true); setIsVisualNovelBusy(true);
try { try {
const runtimeGuestOptions =
activeRecommendRuntimeKind === 'visual-novel'
? await buildRecommendRuntimeGuestOptions()
: {};
const nextRun = await streamVisualNovelRuntimeAction( const nextRun = await streamVisualNovelRuntimeAction(
visualNovelRun.runId, visualNovelRun.runId,
payload, payload,
runtimeGuestOptions,
); );
setVisualNovelRun(nextRun); setVisualNovelRun(nextRun);
} catch (error) { } catch (error) {
@@ -7200,6 +7220,7 @@ export function PlatformEntryFlowShellImpl({
} }
}, },
[ [
activeRecommendRuntimeKind,
isVisualNovelBusy, isVisualNovelBusy,
resolvePuzzleErrorMessage, resolvePuzzleErrorMessage,
setIsVisualNovelBusy, setIsVisualNovelBusy,
@@ -7608,12 +7629,12 @@ export function PlatformEntryFlowShellImpl({
setJumpHopError(null); setJumpHopError(null);
setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail'); setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail');
try { try {
const runtimeGuestOptions = options.embedded
? await buildRecommendRuntimeGuestOptions()
: {};
const [detail, runResponse] = await Promise.all([ const [detail, runResponse] = await Promise.all([
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null), jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
jumpHopClient.startRun( jumpHopClient.startRun(normalizedProfileId, runtimeGuestOptions),
normalizedProfileId,
options.embedded ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS : {},
),
]); ]);
if (detail?.item) { if (detail?.item) {
setJumpHopWork(detail.item); setJumpHopWork(detail.item);
@@ -7902,9 +7923,14 @@ export function PlatformEntryFlowShellImpl({
setWoodenFishError(null); setWoodenFishError(null);
setWoodenFishRuntimeReturnStage(options.returnStage ?? 'work-detail'); setWoodenFishRuntimeReturnStage(options.returnStage ?? 'work-detail');
try { try {
const runtimeGuestOptions = options.embedded
? await buildRecommendRuntimeGuestOptions()
: {};
const [detail, runResponse] = await Promise.all([ const [detail, runResponse] = await Promise.all([
woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null), woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null),
woodenFishClient.startRun(normalizedProfileId), options.embedded
? woodenFishClient.startRun(normalizedProfileId, runtimeGuestOptions)
: woodenFishClient.startRun(normalizedProfileId),
]); ]);
if (detail?.item) { if (detail?.item) {
setWoodenFishWork(detail.item); setWoodenFishWork(detail.item);
@@ -8384,15 +8410,15 @@ export function PlatformEntryFlowShellImpl({
profileId: item.profileId, profileId: item.profileId,
levelId: levelId ?? null, levelId: levelId ?? null,
}; };
const runtimeGuestOptions = options.embedded
? await buildRecommendRuntimeGuestOptions()
: {};
const authMode = options.embedded const authMode = options.embedded
? 'isolated' ? 'isolated'
: (options.authMode ?? 'default'); : (options.authMode ?? 'default');
const { run } = const { run } =
authMode === 'isolated' authMode === 'isolated'
? await startPuzzleRun( ? await startPuzzleRun(startRunPayload, runtimeGuestOptions)
startRunPayload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
: await startPuzzleRun(startRunPayload); : await startPuzzleRun(startRunPayload);
setSelectedPuzzleDetail(item); setSelectedPuzzleDetail(item);
setPuzzleRun(run); setPuzzleRun(run);
@@ -8488,10 +8514,11 @@ export function PlatformEntryFlowShellImpl({
runtimeProfile.generatedBackgroundAsset, runtimeProfile.generatedBackgroundAsset,
{ expireSeconds: 300 }, { expireSeconds: 300 },
); );
const runtimeGuestOptions = options.embedded
? await buildRecommendRuntimeGuestOptions()
: {};
const runtimeOptions = { const runtimeOptions = {
...(options.embedded ...runtimeGuestOptions,
? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS
: {}),
...(typeof options.itemTypeCountOverride === 'number' ...(typeof options.itemTypeCountOverride === 'number'
? { itemTypeCountOverride: options.itemTypeCountOverride } ? { itemTypeCountOverride: options.itemTypeCountOverride }
: {}), : {}),
@@ -8559,11 +8586,11 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleError(null); setSquareHoleError(null);
try { try {
const runtimeGuestOptions = options.embedded
? await buildRecommendRuntimeGuestOptions()
: {};
const { run } = options.embedded const { run } = options.embedded
? await startSquareHoleRun( ? await startSquareHoleRun(profile.profileId, runtimeGuestOptions)
profile.profileId,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: await startSquareHoleRun(profile.profileId); : await startSquareHoleRun(profile.profileId);
setSquareHoleRun(run); setSquareHoleRun(run);
setSquareHoleRuntimeReturnStage(returnStage); setSquareHoleRuntimeReturnStage(returnStage);
@@ -8715,9 +8742,14 @@ export function PlatformEntryFlowShellImpl({
bigFishInputInFlightRef.current = true; bigFishInputInFlightRef.current = true;
try { try {
const runtimeGuestOptions =
activeRecommendRuntimeKind === 'big-fish'
? await buildRecommendRuntimeGuestOptions()
: {};
const { run } = await submitBigFishRuntimeInput( const { run } = await submitBigFishRuntimeInput(
bigFishRun.runId, bigFishRun.runId,
payload, payload,
runtimeGuestOptions,
); );
setBigFishRun(run); setBigFishRun(run);
} catch (error) { } catch (error) {
@@ -8728,7 +8760,12 @@ export function PlatformEntryFlowShellImpl({
bigFishInputInFlightRef.current = false; bigFishInputInFlightRef.current = false;
} }
}, },
[bigFishRun, resolveBigFishErrorMessage, setBigFishError], [
activeRecommendRuntimeKind,
bigFishRun,
resolveBigFishErrorMessage,
setBigFishError,
],
); );
const reportBigFishObservedPlayTime = useCallback(() => { const reportBigFishObservedPlayTime = useCallback(() => {
@@ -8929,12 +8966,13 @@ export function PlatformEntryFlowShellImpl({
profileId: currentLevel.profileId, profileId: currentLevel.profileId,
levelId: resolvePuzzleRestartLevelId(currentRun, detailItem), levelId: resolvePuzzleRestartLevelId(currentRun, detailItem),
}; };
const runtimeGuestOptions =
puzzleRuntimeAuthMode === 'isolated'
? await buildRecommendRuntimeGuestOptions()
: {};
const { run } = const { run } =
puzzleRuntimeAuthMode === 'isolated' puzzleRuntimeAuthMode === 'isolated'
? await startPuzzleRun( ? await startPuzzleRun(startRunPayload, runtimeGuestOptions)
startRunPayload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
: await startPuzzleRun(startRunPayload); : await startPuzzleRun(startRunPayload);
setSelectedPuzzleDetail(detailItem); setSelectedPuzzleDetail(detailItem);
puzzleRunRef.current = run; puzzleRunRef.current = run;
@@ -9057,10 +9095,8 @@ export function PlatformEntryFlowShellImpl({
const submitLeaderboardPromise = const submitLeaderboardPromise =
puzzleRuntimeAuthMode === 'isolated' puzzleRuntimeAuthMode === 'isolated'
? submitPuzzleLeaderboard( ? buildRecommendRuntimeGuestOptions().then((runtimeGuestOptions) =>
puzzleRun.runId, submitPuzzleLeaderboard(puzzleRun.runId, payload, runtimeGuestOptions),
payload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
) )
: submitPuzzleLeaderboard(puzzleRun.runId, payload); : submitPuzzleLeaderboard(puzzleRun.runId, payload);
@@ -9117,6 +9153,10 @@ export function PlatformEntryFlowShellImpl({
return; return;
} }
const runtimeGuestOptions =
puzzleRuntimeAuthMode === 'isolated'
? await buildRecommendRuntimeGuestOptions()
: {};
const targetProfileId = _target?.profileId?.trim() ?? ''; const targetProfileId = _target?.profileId?.trim() ?? '';
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) { if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
const itemPromise = const itemPromise =
@@ -9132,7 +9172,7 @@ export function PlatformEntryFlowShellImpl({
{ {
targetProfileId, targetProfileId,
}, },
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, runtimeGuestOptions,
) )
: advancePuzzleNextLevel(puzzleRun.runId, { : advancePuzzleNextLevel(puzzleRun.runId, {
targetProfileId, targetProfileId,
@@ -9157,7 +9197,7 @@ export function PlatformEntryFlowShellImpl({
? await advancePuzzleNextLevel( ? await advancePuzzleNextLevel(
puzzleRun.runId, puzzleRun.runId,
{}, {},
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, runtimeGuestOptions,
) )
: await advancePuzzleNextLevel(puzzleRun.runId); : await advancePuzzleNextLevel(puzzleRun.runId);
setPuzzleRun(run); setPuzzleRun(run);
@@ -10993,11 +11033,11 @@ export function PlatformEntryFlowShellImpl({
setBigFishRuntimeReturnStage(returnStage); setBigFishRuntimeReturnStage(returnStage);
setBigFishRun(null); setBigFishRun(null);
try { try {
const runtimeGuestOptions = options.embedded
? await buildRecommendRuntimeGuestOptions()
: {};
const { run } = options.embedded const { run } = options.embedded
? await startBigFishRuntimeRun( ? await startBigFishRuntimeRun(sessionId, runtimeGuestOptions)
sessionId,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: await startBigFishRuntimeRun(sessionId); : await startBigFishRuntimeRun(sessionId);
setBigFishRuntimeStartedAt(Date.now()); setBigFishRuntimeStartedAt(Date.now());
setBigFishRun(run); setBigFishRun(run);
@@ -11008,11 +11048,7 @@ export function PlatformEntryFlowShellImpl({
); );
} }
const recordPlayPromise = options.embedded const recordPlayPromise = options.embedded
? recordBigFishPlay( ? recordBigFishPlay(sessionId, { elapsedMs: 0 }, runtimeGuestOptions)
sessionId,
{ elapsedMs: 0 },
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: recordBigFishPlay(sessionId, { elapsedMs: 0 }); : recordBigFishPlay(sessionId, { elapsedMs: 0 });
void recordPlayPromise.catch((error) => { void recordPlayPromise.catch((error) => {
setBigFishError( setBigFishError(
@@ -11031,9 +11067,10 @@ export function PlatformEntryFlowShellImpl({
); );
const startBarkBattleRunFromWork = useCallback( const startBarkBattleRunFromWork = useCallback(
( async (
item: BarkBattleWorkSummary, item: BarkBattleWorkSummary,
returnStage: BarkBattleRuntimeReturnStage = 'work-detail', returnStage: BarkBattleRuntimeReturnStage = 'work-detail',
options: { embedded?: boolean } = {},
) => { ) => {
if (item.status !== 'published') { if (item.status !== 'published') {
setBarkBattleError('汪汪声浪作品发布后才能进入正式玩法。'); setBarkBattleError('汪汪声浪作品发布后才能进入正式玩法。');
@@ -11045,17 +11082,33 @@ export function PlatformEntryFlowShellImpl({
setBarkBattleRuntimeMode('published'); setBarkBattleRuntimeMode('published');
setBarkBattlePublishedConfig(mapBarkBattleWorkToPublishedConfig(item)); setBarkBattlePublishedConfig(mapBarkBattleWorkToPublishedConfig(item));
setBarkBattleRuntimeReturnStage(returnStage); setBarkBattleRuntimeReturnStage(returnStage);
selectionStageRef.current = 'bark-battle-runtime'; try {
setSelectionStage('bark-battle-runtime'); const runtimeGuestOptions = options.embedded
pushAppHistoryPath( ? await buildRecommendRuntimeGuestOptions()
buildPublicWorkStagePath( : {};
'bark-battle-runtime', const runResponse = options.embedded
buildBarkBattlePublicWorkCode(item.workId), ? await startBarkBattleRun(item.workId, {}, runtimeGuestOptions)
), : await startBarkBattleRun(item.workId);
); void runResponse;
return true; 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(() => { const startSelectedPublicWork = useCallback(() => {
@@ -11327,7 +11380,9 @@ export function PlatformEntryFlowShellImpl({
'当前汪汪声浪作品信息不完整,暂时无法进入玩法。', '当前汪汪声浪作品信息不完整,暂时无法进入玩法。',
); );
} else { } else {
started = startBarkBattleRunFromWork(work, 'platform'); started = await startBarkBattleRunFromWork(work, 'platform', {
embedded: true,
});
} }
} else if (isEdutainmentGalleryEntry(entry)) { } else if (isEdutainmentGalleryEntry(entry)) {
started = await startBabyObjectMatchRuntimeFromEntry( started = await startBabyObjectMatchRuntimeFromEntry(

View File

@@ -25,6 +25,7 @@ import type {
AuthWechatStartResponse, AuthWechatStartResponse,
LogoutResponse, LogoutResponse,
PublicUserSearchResponse, PublicUserSearchResponse,
RuntimeGuestTokenResponse,
} from '../../packages/shared/src/contracts/auth'; } from '../../packages/shared/src/contracts/auth';
import type { RedeemProfileReferralInviteCodeResponse } from '../../packages/shared/src/contracts/runtime'; import type { RedeemProfileReferralInviteCodeResponse } from '../../packages/shared/src/contracts/runtime';
import { import {
@@ -61,6 +62,42 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = {
skipRefresh: true, skipRefresh: true,
} satisfies ApiRequestOptions; } 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<RuntimeGuestTokenResponse>(
'/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'; const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone';
export function normalizePhoneInput(phoneInput: string) { export function normalizePhoneInput(phoneInput: string) {

View File

@@ -6,10 +6,14 @@ import type {
BarkBattleRuntimeConfig, BarkBattleRuntimeConfig,
} from '../../../packages/shared/src/contracts/barkBattle'; } from '../../../packages/shared/src/contracts/barkBattle';
import { import {
type ApiRequestOptions,
type ApiRetryOptions, type ApiRetryOptions,
requestJson, requestJson,
} from '../apiClient'; } from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const BARK_BATTLE_RUNTIME_READ_RETRY: ApiRetryOptions = { const BARK_BATTLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1, maxRetries: 1,
@@ -24,28 +28,20 @@ const BARK_BATTLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
retryUnsafeMethods: true, retryUnsafeMethods: true,
}; };
export type BarkBattleRuntimeRequestOptions = Pick< export type BarkBattleRuntimeRequestOptions = RuntimeGuestRequestOptions;
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
export function getBarkBattleRuntimeConfig( export function getBarkBattleRuntimeConfig(
workId: string, workId: string,
options: BarkBattleRuntimeRequestOptions = {}, options: BarkBattleRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BarkBattleRuntimeConfig>( return requestJson<BarkBattleRuntimeConfig>(
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/config`, `/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/config`,
{ method: 'GET' }, { method: 'GET', headers: buildRuntimeGuestHeaders(options) },
'读取汪汪声浪大作战配置失败', '读取汪汪声浪大作战配置失败',
{ {
retry: BARK_BATTLE_RUNTIME_READ_RETRY, retry: BARK_BATTLE_RUNTIME_READ_RETRY,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }
@@ -55,11 +51,12 @@ export function startBarkBattleRun(
payload: Partial<BarkBattleRunStartRequest> = {}, payload: Partial<BarkBattleRunStartRequest> = {},
options: BarkBattleRuntimeRequestOptions = {}, options: BarkBattleRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BarkBattleRunStartResponse>( return requestJson<BarkBattleRunStartResponse>(
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/runs`, `/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/runs`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }),
body: JSON.stringify({ body: JSON.stringify({
...payload, ...payload,
workId: payload.workId ?? workId, workId: payload.workId ?? workId,
@@ -68,10 +65,7 @@ export function startBarkBattleRun(
'启动汪汪声浪大作战正式局失败', '启动汪汪声浪大作战正式局失败',
{ {
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY, retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }
@@ -80,16 +74,14 @@ export function getBarkBattleRun(
runId: string, runId: string,
options: BarkBattleRuntimeRequestOptions = {}, options: BarkBattleRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<unknown>( return requestJson<unknown>(
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}`, `/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}`,
{ method: 'GET' }, { method: 'GET', headers: buildRuntimeGuestHeaders(options) },
'读取汪汪声浪大作战单局失败', '读取汪汪声浪大作战单局失败',
{ {
retry: BARK_BATTLE_RUNTIME_READ_RETRY, retry: BARK_BATTLE_RUNTIME_READ_RETRY,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }
@@ -99,11 +91,12 @@ export function finishBarkBattleRun(
payload: BarkBattleRunFinishRequest, payload: BarkBattleRunFinishRequest,
options: BarkBattleRuntimeRequestOptions = {}, options: BarkBattleRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BarkBattleFinishResponse>( return requestJson<BarkBattleFinishResponse>(
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}/finish`, `/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}/finish`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }),
body: JSON.stringify({ body: JSON.stringify({
...payload, ...payload,
runId: payload.runId ?? runId, runId: payload.runId ?? runId,
@@ -112,10 +105,7 @@ export function finishBarkBattleRun(
'提交汪汪声浪大作战成绩失败', '提交汪汪声浪大作战成绩失败',
{ {
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY, retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }

View File

@@ -5,10 +5,14 @@ import type {
} from '../../../packages/shared/src/contracts/bigFish'; } from '../../../packages/shared/src/contracts/bigFish';
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import { import {
type ApiRequestOptions,
type ApiRetryOptions, type ApiRetryOptions,
requestJson, requestJson,
} from '../apiClient'; } from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = { const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1, maxRetries: 1,
@@ -16,13 +20,7 @@ const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360, maxDelayMs: 360,
retryUnsafeMethods: true, retryUnsafeMethods: true,
}; };
type BigFishRuntimeRequestOptions = Pick< type BigFishRuntimeRequestOptions = RuntimeGuestRequestOptions;
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
/** /**
* 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。 * 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。
@@ -32,20 +30,20 @@ export function recordBigFishPlay(
payload: RecordBigFishPlayRequest, payload: RecordBigFishPlayRequest,
options: BigFishRuntimeRequestOptions = {}, options: BigFishRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BigFishWorksResponse>( return requestJson<BigFishWorksResponse>(
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`, `/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, },
'记录大鱼吃小鱼游玩失败', '记录大鱼吃小鱼游玩失败',
{ {
retry: BIG_FISH_RUNTIME_WRITE_RETRY, retry: BIG_FISH_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }
@@ -54,18 +52,17 @@ export function startBigFishRun(
sessionId: string, sessionId: string,
options: BigFishRuntimeRequestOptions = {}, options: BigFishRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BigFishRunResponse>( return requestJson<BigFishRunResponse>(
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`, `/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`,
{ {
method: 'POST', method: 'POST',
headers: buildRuntimeGuestHeaders(options),
}, },
'启动大鱼吃小鱼玩法失败', '启动大鱼吃小鱼玩法失败',
{ {
retry: BIG_FISH_RUNTIME_WRITE_RETRY, retry: BIG_FISH_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }
@@ -83,17 +80,22 @@ export function getBigFishRun(runId: string) {
export function submitBigFishInput( export function submitBigFishInput(
runId: string, runId: string,
payload: SubmitBigFishInputRequest, payload: SubmitBigFishInputRequest,
options: BigFishRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<BigFishRunResponse>( return requestJson<BigFishRunResponse>(
`/api/runtime/big-fish/runs/${encodeURIComponent(runId)}/input`, `/api/runtime/big-fish/runs/${encodeURIComponent(runId)}/input`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, },
'同步大鱼吃小鱼输入失败', '同步大鱼吃小鱼输入失败',
{ {
retry: BIG_FISH_RUNTIME_WRITE_RETRY, retry: BIG_FISH_RUNTIME_WRITE_RETRY,
...requestOptions,
}, },
); );
} }

View File

@@ -17,11 +17,15 @@ import type {
JumpHopWorkSummaryResponse, JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop'; } from '../../../packages/shared/src/contracts/jumpHop';
import { import {
type ApiRequestOptions,
type ApiRetryOptions, type ApiRetryOptions,
requestJson, requestJson,
} from '../apiClient'; } from '../apiClient';
import { createCreationAgentClient } from '../creation-agent'; 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_API_BASE = '/api/creation/jump-hop/sessions';
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works'; const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
@@ -31,14 +35,7 @@ const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
baseDelayMs: 120, baseDelayMs: 120,
maxDelayMs: 360, maxDelayMs: 360,
}; };
type JumpHopRuntimeRequestOptions = Pick< type JumpHopRuntimeRequestOptions = RuntimeGuestRequestOptions;
ApiRequestOptions,
| 'authImpact'
| 'skipAuth'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
export type { export type {
JumpHopActionRequest, JumpHopActionRequest,
@@ -237,22 +234,20 @@ export async function startJumpHopRuntimeRun(
profileId: string, profileId: string,
options: JumpHopRuntimeRequestOptions = {}, options: JumpHopRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<JumpHopRunResponse>( return requestJson<JumpHopRunResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/runs`, `${JUMP_HOP_RUNTIME_API_BASE}/runs`,
{ {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
}, },
body: JSON.stringify({ profileId }), body: JSON.stringify({ profileId }),
}, },
'启动跳一跳运行态失败', '启动跳一跳运行态失败',
{ {
authImpact: options.authImpact, ...requestOptions,
skipAuth: options.skipAuth,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }
@@ -260,7 +255,9 @@ export async function startJumpHopRuntimeRun(
export async function submitJumpHopJump( export async function submitJumpHopJump(
runId: string, runId: string,
payload: { chargeMs: number }, payload: { chargeMs: number },
options: JumpHopRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const requestPayload = { const requestPayload = {
chargeMs: payload.chargeMs, chargeMs: payload.chargeMs,
clientEventId: `jump-${runId}-${Date.now()}`, clientEventId: `jump-${runId}-${Date.now()}`,
@@ -272,26 +269,34 @@ export async function submitJumpHopJump(
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
}, },
body: JSON.stringify(requestPayload), 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<JumpHopRunResponse>( return requestJson<JumpHopRunResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`, `${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`,
{ {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
}, },
body: JSON.stringify({ body: JSON.stringify({
clientActionId: `restart-${runId}-${Date.now()}`, clientActionId: `restart-${runId}-${Date.now()}`,
}), }),
}, },
'重新开始跳一跳失败', '重新开始跳一跳失败',
requestOptions,
); );
} }

View File

@@ -9,10 +9,14 @@ import type {
StopMatch3DRunRequest, StopMatch3DRunRequest,
} from '../../../packages/shared/src/contracts/match3dRuntime'; } from '../../../packages/shared/src/contracts/match3dRuntime';
import { import {
type ApiRequestOptions,
type ApiRetryOptions, type ApiRetryOptions,
requestJson, requestJson,
} from '../apiClient'; } from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = { const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1, maxRetries: 1,
@@ -25,13 +29,7 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360, maxDelayMs: 360,
retryUnsafeMethods: true, retryUnsafeMethods: true,
}; };
export type Match3DRuntimeRequestOptions = Pick< export type Match3DRuntimeRequestOptions = RuntimeGuestRequestOptions & {
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
> & {
itemTypeCountOverride?: number | null; itemTypeCountOverride?: number | null;
}; };
@@ -76,6 +74,7 @@ export function startMatch3DRun(
profileId: string, profileId: string,
options: Match3DRuntimeRequestOptions = {}, options: Match3DRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const payload: StartMatch3DRunRequest = { const payload: StartMatch3DRunRequest = {
profileId, profileId,
itemTypeCountOverride: options.itemTypeCountOverride ?? null, itemTypeCountOverride: options.itemTypeCountOverride ?? null,
@@ -85,16 +84,15 @@ export function startMatch3DRun(
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`, `/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, },
'启动抓大鹅玩法失败', '启动抓大鹅玩法失败',
{ {
retry: MATCH3D_RUNTIME_WRITE_RETRY, retry: MATCH3D_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }

View File

@@ -9,10 +9,14 @@ import type {
UsePuzzleRuntimePropRequest, UsePuzzleRuntimePropRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { import {
type ApiRequestOptions,
type ApiRetryOptions, type ApiRetryOptions,
requestJson, requestJson,
} from '../apiClient'; } from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs'; const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = { const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
@@ -26,13 +30,7 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360, maxDelayMs: 360,
retryUnsafeMethods: true, retryUnsafeMethods: true,
}; };
type PuzzleRuntimeRequestOptions = Pick< type PuzzleRuntimeRequestOptions = RuntimeGuestRequestOptions;
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
/** /**
* 从某个已发布拼图作品开始一次 run。 * 从某个已发布拼图作品开始一次 run。
@@ -41,20 +39,20 @@ export async function startPuzzleRun(
payload: StartPuzzleRunRequest, payload: StartPuzzleRunRequest,
options: PuzzleRuntimeRequestOptions = {}, options: PuzzleRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<PuzzleRunResponse>( return requestJson<PuzzleRunResponse>(
PUZZLE_RUNTIME_API_BASE, PUZZLE_RUNTIME_API_BASE,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, },
'启动拼图玩法失败', '启动拼图玩法失败',
{ {
retry: PUZZLE_RUNTIME_WRITE_RETRY, retry: PUZZLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }
@@ -125,6 +123,7 @@ export async function advancePuzzleNextLevel(
payload: AdvancePuzzleNextLevelRequest = {}, payload: AdvancePuzzleNextLevelRequest = {},
options: PuzzleRuntimeRequestOptions = {}, options: PuzzleRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const targetProfileId = payload.targetProfileId?.trim() ?? ''; const targetProfileId = payload.targetProfileId?.trim() ?? '';
return requestJson<PuzzleRunResponse>( return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`, `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
@@ -132,18 +131,19 @@ export async function advancePuzzleNextLevel(
method: 'POST', method: 'POST',
...(targetProfileId ...(targetProfileId
? { ? {
headers: { 'Content-Type': 'application/json' }, headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify({ targetProfileId }), body: JSON.stringify({ targetProfileId }),
} }
: {}), : {
headers: buildRuntimeGuestHeaders(options),
}),
}, },
'进入下一关失败', '进入下一关失败',
{ {
retry: PUZZLE_RUNTIME_WRITE_RETRY, retry: PUZZLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }

View File

@@ -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<typeof import('./apiClient')>('./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,
}),
);
},
);
});

View File

@@ -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<RuntimeGuestRequestOptions, 'runtimeGuestToken'>,
headers: Record<string, string> = {},
) {
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;
}

View File

@@ -5,10 +5,14 @@ import type {
StopSquareHoleRunRequest, StopSquareHoleRunRequest,
} from '../../../packages/shared/src/contracts/squareHoleRuntime'; } from '../../../packages/shared/src/contracts/squareHoleRuntime';
import { import {
type ApiRequestOptions,
type ApiRetryOptions, type ApiRetryOptions,
requestJson, requestJson,
} from '../apiClient'; } from '../apiClient';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = { const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1, maxRetries: 1,
@@ -21,13 +25,7 @@ const SQUARE_HOLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360, maxDelayMs: 360,
retryUnsafeMethods: true, retryUnsafeMethods: true,
}; };
type SquareHoleRuntimeRequestOptions = Pick< type SquareHoleRuntimeRequestOptions = RuntimeGuestRequestOptions;
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
/** /**
* 基于作品启动一局方洞挑战正式 run。 * 基于作品启动一局方洞挑战正式 run。
@@ -36,20 +34,20 @@ export function startSquareHoleRun(
profileId: string, profileId: string,
options: SquareHoleRuntimeRequestOptions = {}, options: SquareHoleRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<SquareHoleRunResponse>( return requestJson<SquareHoleRunResponse>(
`/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`, `/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
body: JSON.stringify({ profileId }), body: JSON.stringify({ profileId }),
}, },
'启动方洞挑战失败', '启动方洞挑战失败',
{ {
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }

View File

@@ -19,12 +19,16 @@ import type {
import { parseApiErrorMessage } from '../../../packages/shared/src/http'; import { parseApiErrorMessage } from '../../../packages/shared/src/http';
import type { TextStreamOptions } from '../aiTypes'; import type { TextStreamOptions } from '../aiTypes';
import { import {
type ApiRequestOptions,
type ApiRetryOptions, type ApiRetryOptions,
fetchWithApiAuth, fetchWithApiAuth,
requestJson, requestJson,
} from '../apiClient'; } from '../apiClient';
import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse'; 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_API_BASE = '/api/runtime/visual-novel';
const VISUAL_NOVEL_RUNTIME_READ_RETRY: ApiRetryOptions = { const VISUAL_NOVEL_RUNTIME_READ_RETRY: ApiRetryOptions = {
@@ -39,16 +43,11 @@ const VISUAL_NOVEL_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
retryUnsafeMethods: true, retryUnsafeMethods: true,
}; };
export type VisualNovelRuntimeStreamOptions = TextStreamOptions & { export type VisualNovelRuntimeStreamOptions = TextStreamOptions &
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void; RuntimeGuestRequestOptions & {
}; onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
type VisualNovelRuntimeRequestOptions = Pick< };
ApiRequestOptions, type VisualNovelRuntimeRequestOptions = RuntimeGuestRequestOptions;
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
export type VisualNovelSaveArchiveResumeResponse = export type VisualNovelSaveArchiveResumeResponse =
ProfileSaveArchiveResumeResponse< ProfileSaveArchiveResumeResponse<
@@ -84,11 +83,20 @@ async function openVisualNovelRuntimeSsePost(
payload: unknown, payload: unknown,
fallbackMessage: string, fallbackMessage: string,
signal?: AbortSignal, signal?: AbortSignal,
options: RuntimeGuestRequestOptions = {},
) { ) {
const response = await fetchWithApiAuth(url, { const requestOptions = buildRuntimeGuestAuthOptions(options);
...buildJsonInit('POST', payload), const response = await fetchWithApiAuth(
signal, url,
}); {
...buildJsonInit('POST', payload),
headers: buildRuntimeGuestHeaders(options, {
'Content-Type': 'application/json',
}),
signal,
},
requestOptions,
);
if (!response.ok) { if (!response.ok) {
const responseText = await response.text(); const responseText = await response.text();
@@ -107,17 +115,20 @@ export async function startVisualNovelRun(
payload: VisualNovelStartRunRequest, payload: VisualNovelStartRunRequest,
options: VisualNovelRuntimeRequestOptions = {}, options: VisualNovelRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<VisualNovelRunResponse>( return requestJson<VisualNovelRunResponse>(
`${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`, `${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, retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
timeoutMs: 15000, timeoutMs: 15000,
authImpact: options.authImpact, ...requestOptions,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
}, },
); );
} }
@@ -154,6 +165,7 @@ export async function streamVisualNovelRuntimeAction(
payload, payload,
'推进视觉小说失败', '推进视觉小说失败',
options.signal, options.signal,
options,
); );
return readVisualNovelRuntimeRunFromSse(response, { return readVisualNovelRuntimeRunFromSse(response, {

View File

@@ -18,6 +18,11 @@ import type {
} from '../../../packages/shared/src/contracts/woodenFish'; } from '../../../packages/shared/src/contracts/woodenFish';
import { type ApiRetryOptions, requestJson } from '../apiClient'; import { type ApiRetryOptions, requestJson } from '../apiClient';
import { createCreationAgentClient } from '../creation-agent'; 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_API_BASE = '/api/creation/wooden-fish/sessions';
const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works'; const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works';
@@ -27,6 +32,13 @@ const WOODEN_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
baseDelayMs: 120, baseDelayMs: 120,
maxDelayMs: 360, maxDelayMs: 360,
}; };
const WOODEN_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
type WoodenFishRuntimeRequestOptions = RuntimeGuestRequestOptions;
export type { export type {
WoodenFishActionRequest, WoodenFishActionRequest,
@@ -204,24 +216,35 @@ export async function publishWoodenFishWork(profileId: string) {
return normalizeWoodenFishWorkMutationResponse(response); return normalizeWoodenFishWorkMutationResponse(response);
} }
export async function startWoodenFishRuntimeRun(profileId: string) { export async function startWoodenFishRuntimeRun(
profileId: string,
options: WoodenFishRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<WoodenFishRunResponse>( return requestJson<WoodenFishRunResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/runs`, `${WOODEN_FISH_RUNTIME_API_BASE}/runs`,
{ {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
}, },
body: JSON.stringify({ profileId }), body: JSON.stringify({ profileId }),
}, },
'启动敲木鱼运行态失败', '启动敲木鱼运行态失败',
{
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
...requestOptions,
},
); );
} }
export async function checkpointWoodenFishRun( export async function checkpointWoodenFishRun(
runId: string, runId: string,
payload: Omit<WoodenFishCheckpointRunRequest, 'clientEventId'>, payload: Omit<WoodenFishCheckpointRunRequest, 'clientEventId'>,
options: WoodenFishRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const requestPayload: WoodenFishCheckpointRunRequest = { const requestPayload: WoodenFishCheckpointRunRequest = {
...payload, ...payload,
clientEventId: `checkpoint-${runId}-${Date.now()}`, clientEventId: `checkpoint-${runId}-${Date.now()}`,
@@ -233,17 +256,24 @@ export async function checkpointWoodenFishRun(
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
}, },
body: JSON.stringify(requestPayload), body: JSON.stringify(requestPayload),
}, },
'保存敲木鱼进度失败', '保存敲木鱼进度失败',
{
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
...requestOptions,
},
); );
} }
export async function finishWoodenFishRun( export async function finishWoodenFishRun(
runId: string, runId: string,
payload: Omit<WoodenFishFinishRunRequest, 'clientEventId'>, payload: Omit<WoodenFishFinishRunRequest, 'clientEventId'>,
options: WoodenFishRuntimeRequestOptions = {},
) { ) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const requestPayload: WoodenFishFinishRunRequest = { const requestPayload: WoodenFishFinishRunRequest = {
...payload, ...payload,
clientEventId: `finish-${runId}-${Date.now()}`, clientEventId: `finish-${runId}-${Date.now()}`,
@@ -255,10 +285,15 @@ export async function finishWoodenFishRun(
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
}, },
body: JSON.stringify(requestPayload), body: JSON.stringify(requestPayload),
}, },
'结束敲木鱼运行失败', '结束敲木鱼运行失败',
{
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
...requestOptions,
},
); );
} }