Allow anonymous jump-hop recommend play
This commit is contained in:
@@ -50,6 +50,15 @@
|
|||||||
- 验证方式:目标机 `nginx -T 2>/dev/null | grep client_max_body_size` 应看到 `client_max_body_size 64m;`;大于 1 MiB 的参考图请求不再在 Nginx 层直接 413,access log 应出现有效 `upstream_status`。
|
- 验证方式:目标机 `nginx -T 2>/dev/null | grep client_max_body_size` 应看到 `client_max_body_size 64m;`;大于 1 MiB 的参考图请求不再在 Nginx 层直接 413,access log 应出现有效 `upstream_status`。
|
||||||
- 关联文档:`deploy/nginx/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联文档:`deploy/nginx/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-05-22 抓大鹅素材生成改为关卡整图派生三图
|
||||||
|
## 2026-05-24 跳一跳推荐页允许未登录直达运行态并记录匿名游玩埋点
|
||||||
|
|
||||||
|
- 背景:推荐页的跳一跳作品在未登录时曾被前端登录门禁拦住,导致公开推荐流无法直接游玩;同时游玩埋点如果只接受登录态 userId,会让匿名启动和匿名重开被静默丢失。
|
||||||
|
- 决策:跳一跳推荐页的运行态启动、跳跃和重开路由统一使用可选鉴权;未登录时仍允许进入运行态,并把 `work_play_start` 以匿名语义记录下来,而不是伪造用户身份或直接跳过埋点。
|
||||||
|
- 影响范围:`api-server` 跳一跳 runtime 路由、`work_play_tracking`、推荐页进入运行态逻辑、匿名推荐试玩测试、平台入口 / 玩法链路文档。
|
||||||
|
- 验证:登录态和未登录态都能从推荐页进入运行态;`work_play_start` 事件在匿名时仍产生,metadata 带匿名标记。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/api-server/src/auth.rs`、`server-rs/crates/api-server/src/work_play_tracking.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||||
|
|
||||||
## 2026-05-22 抓大鹅素材生成改为关卡整图派生三图
|
## 2026-05-22 抓大鹅素材生成改为关卡整图派生三图
|
||||||
|
|
||||||
- 背景:旧抓大鹅素材链路按物品 5x5 sheet、纯背景和独立容器图分开生产,难以保证背景、UI、容器和物品风格一致,也让结果页继续暴露背景 / 容器重生成入口。
|
- 背景:旧抓大鹅素材链路按物品 5x5 sheet、纯背景和独立容器图分开生产,难以保证背景、UI、容器和物品风格一致,也让结果页继续暴露背景 / 容器重生成入口。
|
||||||
@@ -59,6 +68,7 @@
|
|||||||
- 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/services/match3dSpritesheetParser.test.ts src/services/match3dGeneratedModelCache.test.ts`、`npm run typecheck`、`npm run check:encoding`。
|
- 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/services/match3dSpritesheetParser.test.ts src/services/match3dGeneratedModelCache.test.ts`、`npm run typecheck`、`npm run check:encoding`。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
|
||||||
## 2026-05-18 Rust 手写模块入口统一不用 mod.rs
|
## 2026-05-18 Rust 手写模块入口统一不用 mod.rs
|
||||||
|
|
||||||
- 背景:Rust 目录模块同时存在 `mod.rs` 与同名 `.rs` 两种入口形式,前次拆分已让 `spacetime-client/src/mapper.rs` 采用同名入口;继续新增 `mod.rs` 会让文件定位和评审口径不一致。
|
- 背景:Rust 目录模块同时存在 `mod.rs` 与同名 `.rs` 两种入口形式,前次拆分已让 `spacetime-client/src/mapper.rs` 采用同名入口;继续新增 `mod.rs` 会让文件定位和评审口径不一致。
|
||||||
|
|||||||
@@ -129,6 +129,15 @@
|
|||||||
- 验证:普通 route 请求在 SpacetimeDB 不可用时仍能返回,恢复后 sealed 文件会继续被清理。
|
- 验证:普通 route 请求在 SpacetimeDB 不可用时仍能返回,恢复后 sealed 文件会继续被清理。
|
||||||
- 关联:`server-rs/crates/api-server/src/tracking_outbox.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`server-rs/crates/api-server/src/tracking_outbox.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## release tracking outbox 权限错误先查 env 缺失
|
||||||
|
## 跳一跳推荐页匿名直玩要同步放行 runtime 路由和埋点
|
||||||
|
|
||||||
|
- 现象:推荐页能看到跳一跳公开卡片,但未登录点击后会被登录门禁拦住,或者进入运行态后没有 `work_play_start` 记录。
|
||||||
|
- 原因:前端只改了展示层登录门禁,后端 runtime 路由仍要求 bearer auth,或 tracking helper 仍把匿名请求当成无效输入直接丢弃。
|
||||||
|
- 处理:`/api/runtime/jump-hop/runs`、`/jump`、`/restart` 改为可选鉴权;未登录时直接允许启动、跳跃和重开,同时让 `work_play_tracking` 接受 `Option` 用户身份并在 metadata 中标记匿名语义,不要伪造 userId。
|
||||||
|
- 验证:未登录推荐页可以直接进入跳一跳运行态,且 `work_play_start` 事件仍会落库或出现在 outbox 中,metadata 含匿名标记。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/api-server/src/auth.rs`、`server-rs/crates/api-server/src/work_play_tracking.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||||
|
|
||||||
## release tracking outbox 权限错误先查 env 缺失
|
## release tracking outbox 权限错误先查 env 缺失
|
||||||
|
|
||||||
- 现象:release 机器 `journalctl -u genarrative-api.service` 每秒刷 `tracking outbox 定时封存 active 文件失败 error=Permission denied (os error 13)` 和 `tracking outbox 批量写入 SpacetimeDB 失败`。
|
- 现象:release 机器 `journalctl -u genarrative-api.service` 每秒刷 `tracking outbox 定时封存 active 文件失败 error=Permission denied (os error 13)` 和 `tracking outbox 批量写入 SpacetimeDB 失败`。
|
||||||
@@ -137,6 +146,7 @@
|
|||||||
- 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`。
|
- 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`。
|
||||||
- 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
|
||||||
## 外部 API 失败没法追溯先查 external_api_call_failure
|
## 外部 API 失败没法追溯先查 external_api_call_failure
|
||||||
|
|
||||||
- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。
|
- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。
|
||||||
|
|||||||
@@ -138,6 +138,13 @@ Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`:
|
|||||||
- `npm run check:server-rs-ddd`
|
- `npm run check:server-rs-ddd`
|
||||||
- `npm run dev:api-server` 后请求 `/healthz`
|
- `npm run dev:api-server` 后请求 `/healthz`
|
||||||
|
|
||||||
|
其中推荐页匿名游玩与 `work_play_start` 相关改动,至少要补跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check -p api-server --manifest-path server-rs/Cargo.toml
|
||||||
|
npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged out recommend page can enter runtime without login gate|logged out desktop recommend page renders runtime directly without login gate"
|
||||||
|
```
|
||||||
|
|
||||||
涉及 SpacetimeDB schema 时必须补:
|
涉及 SpacetimeDB schema 时必须补:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 >
|
|||||||
|
|
||||||
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
|
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
|
||||||
|
|
||||||
|
推荐页允许未登录直接游玩跳一跳运行态;`/api/runtime/jump-hop/runs`、`/jump` 和 `/restart` 采用可选鉴权,未登录时仍记录 `work_play_start`,但埋点需标记匿名语义。
|
||||||
|
|
||||||
## 敲木鱼
|
## 敲木鱼
|
||||||
|
|
||||||
对外名称:`敲木鱼`。工程域:`wooden-fish`。PRD 见 `docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。
|
对外名称:`敲木鱼`。工程域:`wooden-fish`。PRD 见 `docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。
|
||||||
|
|||||||
@@ -59,17 +59,44 @@ pub async fn require_bearer_auth(
|
|||||||
mut request: Request,
|
mut request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
let Some(authenticated) = authenticate_request(&state, &request)? else {
|
||||||
|
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
||||||
|
};
|
||||||
|
request.extensions_mut().insert(authenticated.clone());
|
||||||
|
|
||||||
|
let mut response = next.run(request).await;
|
||||||
|
response.extensions_mut().insert(authenticated);
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn attach_optional_bearer_auth(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
if let Some(authenticated) = authenticate_request(&state, &request)? {
|
||||||
|
request.extensions_mut().insert(authenticated.clone());
|
||||||
|
let mut response = next.run(request).await;
|
||||||
|
response.extensions_mut().insert(authenticated);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authenticate_request(
|
||||||
|
state: &AppState,
|
||||||
|
request: &Request,
|
||||||
|
) -> 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())
|
&& let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers())
|
||||||
{
|
{
|
||||||
request
|
return Ok(Some(AuthenticatedAccessToken::new(claims)));
|
||||||
.extensions_mut()
|
}
|
||||||
.insert(AuthenticatedAccessToken::new(claims.clone()));
|
|
||||||
let mut response = next.run(request).await;
|
if !request.headers().contains_key(AUTHORIZATION) {
|
||||||
response
|
return Ok(None);
|
||||||
.extensions_mut()
|
|
||||||
.insert(AuthenticatedAccessToken::new(claims));
|
|
||||||
return Ok(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let bearer_token = extract_bearer_token(request.headers())?;
|
let bearer_token = extract_bearer_token(request.headers())?;
|
||||||
@@ -145,16 +172,7 @@ pub async fn require_bearer_auth(
|
|||||||
.with_message("当前登录态已失效,请重新登录"));
|
.with_message("当前登录态已失效,请重新登录"));
|
||||||
}
|
}
|
||||||
|
|
||||||
request
|
Ok(Some(AuthenticatedAccessToken::new(claims)))
|
||||||
.extensions_mut()
|
|
||||||
.insert(AuthenticatedAccessToken::new(claims.clone()));
|
|
||||||
|
|
||||||
let mut response = next.run(request).await;
|
|
||||||
response
|
|
||||||
.extensions_mut()
|
|
||||||
.insert(AuthenticatedAccessToken::new(claims));
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn inspect_auth_claims(
|
pub async fn inspect_auth_claims(
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ use spacetime_client::SpacetimeClientError;
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
api_response::json_success_body,
|
||||||
request_context::RequestContext, state::AppState,
|
auth::AuthenticatedAccessToken,
|
||||||
|
http_error::AppError,
|
||||||
|
request_context::RequestContext,
|
||||||
|
state::AppState,
|
||||||
|
work_play_tracking::{record_work_play_start_after_success, WorkPlayTrackingDraft},
|
||||||
};
|
};
|
||||||
|
|
||||||
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
||||||
@@ -26,6 +30,8 @@ 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";
|
||||||
|
|
||||||
pub async fn create_jump_hop_session(
|
pub async fn create_jump_hop_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -170,14 +176,18 @@ 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>,
|
||||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
|
||||||
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 = authenticated
|
||||||
|
.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, authenticated.claims().user_id().to_string())
|
.start_jump_hop_run(payload, owner_user_id.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
jump_hop_error_response(
|
jump_hop_error_response(
|
||||||
@@ -187,6 +197,24 @@ pub async fn start_jump_hop_run(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
record_work_play_start_after_success(
|
||||||
|
&state,
|
||||||
|
&request_context,
|
||||||
|
build_jump_hop_work_play_tracking_draft(
|
||||||
|
authenticated,
|
||||||
|
run.profile_id.clone(),
|
||||||
|
JUMP_HOP_RUNTIME_RUNS_ROUTE,
|
||||||
|
)
|
||||||
|
.owner_user_id(run.owner_user_id.clone())
|
||||||
|
.run_id(run.run_id.clone())
|
||||||
|
.profile_id(run.profile_id.clone())
|
||||||
|
.extra(json!({
|
||||||
|
"runStatus": run.status,
|
||||||
|
"isAnonymous": maybe_authenticated.is_none(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
JumpHopRunResponse { run },
|
JumpHopRunResponse { run },
|
||||||
@@ -197,18 +225,18 @@ 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>,
|
||||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
|
||||||
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
|
||||||
|
.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(
|
.jump_hop_run_jump(run_id, owner_user_id, payload)
|
||||||
run_id,
|
|
||||||
authenticated.claims().user_id().to_string(),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
jump_hop_error_response(
|
jump_hop_error_response(
|
||||||
@@ -228,18 +256,18 @@ 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>,
|
||||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
|
||||||
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
|
||||||
|
.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(
|
.restart_jump_hop_run(run_id, owner_user_id, payload)
|
||||||
run_id,
|
|
||||||
authenticated.claims().user_id().to_string(),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
jump_hop_error_response(
|
jump_hop_error_response(
|
||||||
@@ -298,6 +326,19 @@ pub async fn get_jump_hop_gallery_detail(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_jump_hop_work_play_tracking_draft(
|
||||||
|
authenticated: Option<&AuthenticatedAccessToken>,
|
||||||
|
work_id: impl Into<String>,
|
||||||
|
source_route: &'static str,
|
||||||
|
) -> WorkPlayTrackingDraft {
|
||||||
|
match authenticated {
|
||||||
|
Some(authenticated) => {
|
||||||
|
WorkPlayTrackingDraft::new("jump-hop", work_id, authenticated, source_route)
|
||||||
|
}
|
||||||
|
None => WorkPlayTrackingDraft::anonymous("jump-hop", work_id, source_route),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
||||||
JumpHopDraftResponse {
|
JumpHopDraftResponse {
|
||||||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::require_bearer_auth,
|
auth::{attach_optional_bearer_auth, require_bearer_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,
|
||||||
@@ -51,21 +51,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(),
|
||||||
require_bearer_auth,
|
attach_optional_bearer_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(),
|
||||||
require_bearer_auth,
|
attach_optional_bearer_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(),
|
||||||
require_bearer_auth,
|
attach_optional_bearer_auth,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.route("/api/runtime/jump-hop/gallery", get(list_jump_hop_gallery))
|
.route("/api/runtime/jump-hop/gallery", get(list_jump_hop_gallery))
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub(crate) const WORK_PLAY_START_EVENT_KEY: &str = "work_play_start";
|
|||||||
pub(crate) struct WorkPlayTrackingDraft {
|
pub(crate) struct WorkPlayTrackingDraft {
|
||||||
pub play_type: &'static str,
|
pub play_type: &'static str,
|
||||||
pub work_id: String,
|
pub work_id: String,
|
||||||
pub user_id: String,
|
pub user_id: Option<String>,
|
||||||
pub owner_user_id: Option<String>,
|
pub owner_user_id: Option<String>,
|
||||||
pub profile_id: Option<String>,
|
pub profile_id: Option<String>,
|
||||||
pub run_id: Option<String>,
|
pub run_id: Option<String>,
|
||||||
@@ -28,7 +28,28 @@ impl WorkPlayTrackingDraft {
|
|||||||
authenticated: &AuthenticatedAccessToken,
|
authenticated: &AuthenticatedAccessToken,
|
||||||
source_route: &'static str,
|
source_route: &'static str,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let user_id = authenticated.claims().user_id().to_string();
|
Self::with_user_id(
|
||||||
|
play_type,
|
||||||
|
work_id,
|
||||||
|
Some(authenticated.claims().user_id().to_string()),
|
||||||
|
source_route,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn anonymous(
|
||||||
|
play_type: &'static str,
|
||||||
|
work_id: impl Into<String>,
|
||||||
|
source_route: &'static str,
|
||||||
|
) -> Self {
|
||||||
|
Self::with_user_id(play_type, work_id, None, source_route)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_user_id(
|
||||||
|
play_type: &'static str,
|
||||||
|
work_id: impl Into<String>,
|
||||||
|
user_id: Option<String>,
|
||||||
|
source_route: &'static str,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
play_type,
|
play_type,
|
||||||
work_id: work_id.into(),
|
work_id: work_id.into(),
|
||||||
@@ -91,7 +112,11 @@ async fn record_work_play_start_input_after_success(
|
|||||||
"workId": draft.work_id,
|
"workId": draft.work_id,
|
||||||
"sourceRoute": draft.source_route,
|
"sourceRoute": draft.source_route,
|
||||||
});
|
});
|
||||||
metadata["userId"] = json!(draft.user_id);
|
if let Some(user_id) = draft.user_id.as_deref() {
|
||||||
|
metadata["userId"] = json!(user_id);
|
||||||
|
} else {
|
||||||
|
metadata["userKind"] = json!("anonymous");
|
||||||
|
}
|
||||||
if let Some(owner_user_id) = draft.owner_user_id.as_deref() {
|
if let Some(owner_user_id) = draft.owner_user_id.as_deref() {
|
||||||
metadata["ownerUserId"] = json!(owner_user_id);
|
metadata["ownerUserId"] = json!(owner_user_id);
|
||||||
}
|
}
|
||||||
@@ -108,7 +133,7 @@ async fn record_work_play_start_input_after_success(
|
|||||||
let mut tracking = TrackingEventDraft::new(WORK_PLAY_START_EVENT_KEY, draft.play_type);
|
let mut tracking = TrackingEventDraft::new(WORK_PLAY_START_EVENT_KEY, draft.play_type);
|
||||||
tracking.scope_kind = RuntimeTrackingScopeKind::Work;
|
tracking.scope_kind = RuntimeTrackingScopeKind::Work;
|
||||||
tracking.scope_id = draft.work_id;
|
tracking.scope_id = draft.work_id;
|
||||||
tracking.user_id = Some(draft.user_id);
|
tracking.user_id = draft.user_id;
|
||||||
tracking.owner_user_id = draft.owner_user_id;
|
tracking.owner_user_id = draft.owner_user_id;
|
||||||
tracking.profile_id = draft.profile_id;
|
tracking.profile_id = draft.profile_id;
|
||||||
tracking.metadata = metadata;
|
tracking.metadata = metadata;
|
||||||
|
|||||||
@@ -7492,7 +7492,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
try {
|
try {
|
||||||
const [detail, runResponse] = await Promise.all([
|
const [detail, runResponse] = await Promise.all([
|
||||||
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
|
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
|
||||||
jumpHopClient.startRun(normalizedProfileId),
|
jumpHopClient.startRun(
|
||||||
|
normalizedProfileId,
|
||||||
|
options.embedded ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS : {},
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
if (detail?.item) {
|
if (detail?.item) {
|
||||||
setJumpHopWork(detail.item);
|
setJumpHopWork(detail.item);
|
||||||
@@ -11681,8 +11684,6 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
if (
|
if (
|
||||||
selectionStage !== 'platform' ||
|
selectionStage !== 'platform' ||
|
||||||
platformBootstrap.platformTab !== 'home' ||
|
platformBootstrap.platformTab !== 'home' ||
|
||||||
!platformBootstrap.isAuthenticated ||
|
|
||||||
!platformBootstrap.canReadProtectedData ||
|
|
||||||
platformBootstrap.isLoadingPlatform
|
platformBootstrap.isLoadingPlatform
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -11734,9 +11735,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
jumpHopRun,
|
jumpHopRun,
|
||||||
isStartingRecommendEntry,
|
isStartingRecommendEntry,
|
||||||
match3dRun,
|
match3dRun,
|
||||||
platformBootstrap.canReadProtectedData,
|
|
||||||
platformBootstrap.isLoadingPlatform,
|
platformBootstrap.isLoadingPlatform,
|
||||||
platformBootstrap.isAuthenticated,
|
|
||||||
platformBootstrap.platformTab,
|
platformBootstrap.platformTab,
|
||||||
puzzleRun,
|
puzzleRun,
|
||||||
recommendRuntimeEntries,
|
recommendRuntimeEntries,
|
||||||
|
|||||||
@@ -2495,17 +2495,34 @@ test('logged out recommend cover opens login modal again', async () => {
|
|||||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logged out desktop recommend page renders cover only', () => {
|
test('logged out desktop recommend page renders runtime directly', () => {
|
||||||
mockDesktopLayout();
|
mockDesktopLayout();
|
||||||
renderLoggedOutHomeView(vi.fn(), {
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
latestEntries: [puzzlePublicEntry],
|
latestEntries: [puzzlePublicEntry],
|
||||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
|
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
|
||||||
expect(screen.queryByText('今日游戏')).toBeNull();
|
expect(screen.queryByText('今日游戏')).toBeNull();
|
||||||
expect(screen.queryByText('作品分类')).toBeNull();
|
expect(screen.queryByText('作品分类')).toBeNull();
|
||||||
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
|
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logged out recommend page can enter runtime without login gate', () => {
|
||||||
|
mockDesktopLayout();
|
||||||
|
const openLoginModal = vi.fn();
|
||||||
|
const onOpenGalleryDetail = vi.fn();
|
||||||
|
renderLoggedOutHomeView(openLoginModal, {
|
||||||
|
latestEntries: [puzzlePublicEntry],
|
||||||
|
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||||
|
onOpenGalleryDetail,
|
||||||
|
recommendRuntimeContent: <div data-testid="recommend-runtime">运行内容</div>,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: /登录后游玩 奇幻拼图/u })).toBeNull();
|
||||||
|
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||||
|
expect(openLoginModal).not.toHaveBeenCalled();
|
||||||
|
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logged in recommend page uses gated recommend detail callback', async () => {
|
test('logged in recommend page uses gated recommend detail callback', async () => {
|
||||||
@@ -2581,7 +2598,7 @@ test('logged in recommend page uses gated recommend detail callback', async () =
|
|||||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logged out mobile recommend page renders cover instead of runtime', () => {
|
test('logged out mobile recommend page renders runtime instead of cover', () => {
|
||||||
const onOpenGalleryDetail = vi.fn();
|
const onOpenGalleryDetail = vi.fn();
|
||||||
renderLoggedOutHomeView(
|
renderLoggedOutHomeView(
|
||||||
vi.fn(),
|
vi.fn(),
|
||||||
@@ -2593,13 +2610,13 @@ test('logged out mobile recommend page renders cover instead of runtime', () =>
|
|||||||
'home',
|
'home',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
|
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
|
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('.platform-public-work-card__cover'),
|
document.querySelector('.platform-public-work-card__cover'),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }));
|
expect(screen.queryByRole('button', { name: /登录后游玩 奇幻拼图/u })).toBeNull();
|
||||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2611,7 +2628,8 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
|
|||||||
recommendRuntimeContent: null,
|
recommendRuntimeContent: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
|
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
|
||||||
|
expect(screen.getByText('加载中...')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => {
|
test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => {
|
||||||
|
|||||||
@@ -5273,16 +5273,6 @@ export function RpgEntryHomeView({
|
|||||||
正在读取公开作品...
|
正在读取公开作品...
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : !isAuthenticated && activeRecommendEntry ? (
|
|
||||||
<section className="platform-recommend-runtime-panel">
|
|
||||||
<RecommendCoverOnlyCard
|
|
||||||
entry={activeRecommendEntry}
|
|
||||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
|
|
||||||
activeRecommendEntry,
|
|
||||||
)}
|
|
||||||
onClick={openActiveRecommendEntry}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
) : recommendRuntimeError ? (
|
) : recommendRuntimeError ? (
|
||||||
<section className="platform-recommend-runtime-panel">
|
<section className="platform-recommend-runtime-panel">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import type {
|
|||||||
JumpHopWorkspaceCreateRequest,
|
JumpHopWorkspaceCreateRequest,
|
||||||
JumpHopWorkSummaryResponse,
|
JumpHopWorkSummaryResponse,
|
||||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
import {
|
||||||
|
type ApiRequestOptions,
|
||||||
|
type ApiRetryOptions,
|
||||||
|
requestJson,
|
||||||
|
} from '../apiClient';
|
||||||
import { createCreationAgentClient } from '../creation-agent';
|
import { createCreationAgentClient } from '../creation-agent';
|
||||||
|
|
||||||
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
|
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
|
||||||
@@ -26,6 +30,14 @@ const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
|||||||
baseDelayMs: 120,
|
baseDelayMs: 120,
|
||||||
maxDelayMs: 360,
|
maxDelayMs: 360,
|
||||||
};
|
};
|
||||||
|
type JumpHopRuntimeRequestOptions = Pick<
|
||||||
|
ApiRequestOptions,
|
||||||
|
| 'authImpact'
|
||||||
|
| 'skipAuth'
|
||||||
|
| 'skipRefresh'
|
||||||
|
| 'notifyAuthStateChange'
|
||||||
|
| 'clearAuthOnUnauthorized'
|
||||||
|
>;
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
JumpHopActionRequest,
|
JumpHopActionRequest,
|
||||||
@@ -208,7 +220,10 @@ export async function publishJumpHopWork(profileId: string) {
|
|||||||
return normalizeJumpHopWorkMutationResponse(response);
|
return normalizeJumpHopWorkMutationResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startJumpHopRuntimeRun(profileId: string) {
|
export async function startJumpHopRuntimeRun(
|
||||||
|
profileId: string,
|
||||||
|
options: JumpHopRuntimeRequestOptions = {},
|
||||||
|
) {
|
||||||
return requestJson<JumpHopRunResponse>(
|
return requestJson<JumpHopRunResponse>(
|
||||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
|
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
|
||||||
{
|
{
|
||||||
@@ -219,6 +234,13 @@ export async function startJumpHopRuntimeRun(profileId: string) {
|
|||||||
body: JSON.stringify({ profileId }),
|
body: JSON.stringify({ profileId }),
|
||||||
},
|
},
|
||||||
'启动跳一跳运行态失败',
|
'启动跳一跳运行态失败',
|
||||||
|
{
|
||||||
|
authImpact: options.authImpact,
|
||||||
|
skipAuth: options.skipAuth,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user