feat: 统一推荐页匿名游玩的 Runtime Guest 认证 #32
@@ -118,7 +118,7 @@ npm run check:server-rs-ddd
|
||||
3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
|
||||
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。
|
||||
5. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。
|
||||
6. 拼图图生图参考图主链不得再把大图 Data URL 塞进创作 JSON body;前端先直传 OSS 并提交 `referenceImageAssetObjectId(s)`,`api-server` 校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取,Data URL / `/generated-*` 仅作为旧请求兼容。
|
||||
6. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。
|
||||
7. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。
|
||||
|
||||
## SpacetimeDB schema 变更规则
|
||||
|
||||
@@ -219,7 +219,7 @@ Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该
|
||||
- Windows 下载阶段如果出现 `curl: (18)` 或响应体截断,流水线会保留同名 `.download` 临时文件并用 `curl -C -` 断点续传;只有完整返回但 SHA256 digest 仍不匹配时才删除临时文件后重新下载。目标 Linux 节点仍只接收 `stash/unstash` 带过去的本地下载件,不回退外网下载。
|
||||
- Windows 下载阶段如果走代理,在 `Genarrative-Server-Provision` 参数 `PROVISION_DOWNLOAD_PROXY` 填写 Windows Jenkins 节点可访问的 HTTP 代理,例如 `http://127.0.0.1:7890`;不要填写目标 release 机器视角的 `127.0.0.1`,除非代理确实运行在该 Windows 节点本机。Linux 目标机阶段会强制要求使用本地下载件,缺少文件直接失败,不再回退到外网下载。
|
||||
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。
|
||||
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 只是反代兜底,防止旧客户端或兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;长期主链不得依赖大 JSON body 承载图片,拼图参考图应先直传 OSS,只向创作接口提交 `referenceImageAssetObjectId(s)`,由后端签只读 URL 给外部模型读取。真实业务上限仍由 Rust 路由 `DefaultBodyLimit`、资产确认时 OSS HEAD 和解码后字节校验控制。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否仍在提交 Data URL 而不是 `assetObjectId`。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
|
||||
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
|
||||
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。
|
||||
- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。
|
||||
- 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。
|
||||
|
||||
@@ -124,7 +124,11 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 >
|
||||
|
||||
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
|
||||
|
||||
推荐页允许未登录直接游玩跳一跳运行态;`/api/runtime/jump-hop/runs`、`/jump` 和 `/restart` 采用可选鉴权,未登录时仍记录 `work_play_start`,但埋点需标记匿名语义。
|
||||
跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。
|
||||
|
||||
删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。
|
||||
|
||||
推荐页匿名游玩不再限定为跳一跳。推荐页嵌入运行态启动时统一先申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续透传 runtime guest token;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
|
||||
|
||||
## 敲木鱼
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ export type PublicUserSearchResponse = {
|
||||
user: PublicUserSummary;
|
||||
};
|
||||
|
||||
export type RuntimeGuestTokenResponse = {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
subject: string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
export type AuthEntryRequest = {
|
||||
phone: string;
|
||||
password: string;
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface JumpHopWorkspaceCreateRequest {
|
||||
|
||||
export interface JumpHopActionRequest {
|
||||
actionType: JumpHopActionType;
|
||||
profileId?: string | null;
|
||||
workTitle?: string | null;
|
||||
workDescription?: string | null;
|
||||
themeTags?: string[] | null;
|
||||
@@ -55,6 +56,10 @@ export interface JumpHopActionRequest {
|
||||
characterPrompt?: string | null;
|
||||
tilePrompt?: string | null;
|
||||
endMoodPrompt?: string | null;
|
||||
characterAsset?: JumpHopCharacterAsset | null;
|
||||
tileAtlasAsset?: JumpHopCharacterAsset | null;
|
||||
tileAssets?: JumpHopTileAsset[] | null;
|
||||
coverComposite?: string | null;
|
||||
}
|
||||
|
||||
export interface JumpHopCharacterAsset {
|
||||
|
||||
@@ -9,9 +9,13 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token, verify_access_token,
|
||||
AccessTokenClaims, AuthProvider, BindingStatus, RuntimeGuestTokenClaims,
|
||||
RuntimeGuestTokenClaimsInput, RUNTIME_GUEST_SCOPE_PUBLIC_PLAY, read_refresh_session_token,
|
||||
sign_runtime_guest_token, verify_access_token, verify_runtime_guest_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::auth::RuntimeGuestTokenResponse;
|
||||
use shared_kernel::{format_rfc3339, new_uuid_simple_string};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -34,6 +38,18 @@ pub struct RefreshSessionToken {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RuntimePrincipal {
|
||||
User(AuthenticatedAccessToken),
|
||||
Guest(RuntimeGuestTokenClaims),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RuntimePrincipalKind {
|
||||
User,
|
||||
Guest,
|
||||
}
|
||||
|
||||
impl AuthenticatedAccessToken {
|
||||
pub fn new(claims: AccessTokenClaims) -> Self {
|
||||
Self { claims }
|
||||
@@ -54,6 +70,66 @@ impl RefreshSessionToken {
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimePrincipal {
|
||||
pub fn subject(&self) -> &str {
|
||||
match self {
|
||||
Self::User(authenticated) => authenticated.claims().user_id(),
|
||||
Self::Guest(claims) => claims.subject(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> RuntimePrincipalKind {
|
||||
match self {
|
||||
Self::User(_) => RuntimePrincipalKind::User,
|
||||
Self::Guest(_) => RuntimePrincipalKind::Guest,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimePrincipalKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::Guest => "guest",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn issue_runtime_guest_token(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let issued_at = OffsetDateTime::now_utc();
|
||||
let claims = RuntimeGuestTokenClaims::from_input(
|
||||
RuntimeGuestTokenClaimsInput {
|
||||
subject: format!("guest-runtime-{}", new_uuid_simple_string()),
|
||||
scope: RUNTIME_GUEST_SCOPE_PUBLIC_PLAY.to_string(),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
issued_at,
|
||||
)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})?;
|
||||
let token = sign_runtime_guest_token(&claims, state.auth_jwt_config()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})?;
|
||||
let expires_at = OffsetDateTime::from_unix_timestamp(claims.expires_at_unix() as i64)
|
||||
.ok()
|
||||
.and_then(|value| format_rfc3339(value).ok())
|
||||
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
RuntimeGuestTokenResponse {
|
||||
token,
|
||||
expires_at,
|
||||
subject: claims.subject().to_string(),
|
||||
scope: claims.scope().to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn require_bearer_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
@@ -70,29 +146,70 @@ pub async fn require_bearer_auth(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn attach_optional_bearer_auth(
|
||||
pub async fn require_runtime_principal_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
if let Some(authenticated) = authenticate_request(&state, &request)? {
|
||||
request.extensions_mut().insert(authenticated.clone());
|
||||
let mut response = next.run(request).await;
|
||||
response.extensions_mut().insert(authenticated);
|
||||
return Ok(response);
|
||||
let Some(principal) = authenticate_runtime_principal(&state, &request)? else {
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
||||
};
|
||||
request.extensions_mut().insert(principal.clone());
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
response.extensions_mut().insert(principal);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn authenticate_runtime_principal(
|
||||
state: &AppState,
|
||||
request: &Request,
|
||||
) -> Result<Option<RuntimePrincipal>, AppError> {
|
||||
if !request.headers().contains_key(AUTHORIZATION) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
match authenticate_request(state, request) {
|
||||
Ok(Some(authenticated)) => Ok(Some(RuntimePrincipal::User(authenticated))),
|
||||
Ok(None) => Ok(None),
|
||||
Err(_) => {
|
||||
let bearer_token = extract_bearer_token(request.headers())?;
|
||||
let request_id = request
|
||||
.extensions()
|
||||
.get::<RequestContext>()
|
||||
.map(|context| context.request_id().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let claims = verify_runtime_guest_token(&bearer_token, state.auth_jwt_config())
|
||||
.map_err(|error| {
|
||||
warn!(
|
||||
%request_id,
|
||||
error = %error,
|
||||
"runtime guest JWT 校验失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
})?;
|
||||
if claims.scope() != RUNTIME_GUEST_SCOPE_PUBLIC_PLAY {
|
||||
warn!(
|
||||
%request_id,
|
||||
scope = %claims.scope(),
|
||||
"runtime guest JWT scope 非法"
|
||||
);
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
||||
}
|
||||
Ok(Some(RuntimePrincipal::Guest(claims)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticate_request(
|
||||
state: &AppState,
|
||||
request: &Request,
|
||||
) -> Result<Option<AuthenticatedAccessToken>, AppError> {
|
||||
if allows_internal_forwarded_auth(request.uri().path())
|
||||
&& let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers())
|
||||
{
|
||||
return Ok(Some(AuthenticatedAccessToken::new(claims)));
|
||||
if allows_internal_forwarded_auth(request.uri().path()) {
|
||||
if let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) {
|
||||
return Ok(Some(AuthenticatedAccessToken::new(claims)));
|
||||
}
|
||||
}
|
||||
|
||||
if !request.headers().contains_key(AUTHORIZATION) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::{attach_refresh_session_token, require_bearer_auth},
|
||||
auth::{attach_refresh_session_token, issue_runtime_guest_token, require_bearer_auth},
|
||||
auth_me::auth_me,
|
||||
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
||||
auth_sessions::{auth_sessions, revoke_auth_session},
|
||||
@@ -65,6 +65,7 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
attach_refresh_session_token,
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/runtime-guest-token", post(issue_runtime_guest_token))
|
||||
.route("/api/auth/phone/send-code", post(send_phone_code))
|
||||
.route("/api/auth/phone/login", post(phone_login))
|
||||
.route("/api/auth/wechat/start", get(start_wechat_login))
|
||||
|
||||
@@ -4,11 +4,11 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::{attach_optional_bearer_auth, require_bearer_auth},
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
jump_hop::{
|
||||
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
|
||||
get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
|
||||
publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
||||
list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -36,6 +36,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/jump-hop/works",
|
||||
get(list_jump_hop_works).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/jump-hop/works/{profile_id}/publish",
|
||||
post(publish_jump_hop_work).route_layer(middleware::from_fn_with_state(
|
||||
@@ -51,21 +58,21 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/jump-hop/runs",
|
||||
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_optional_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/jump-hop/runs/{run_id}/jump",
|
||||
post(jump_hop_run_jump).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_optional_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/jump-hop/runs/{run_id}/restart",
|
||||
post(restart_jump_hop_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_optional_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route("/api/runtime/jump-hop/gallery", get(list_jump_hop_gallery))
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
state::AppState,
|
||||
wooden_fish::{
|
||||
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
|
||||
@@ -52,21 +52,21 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/wooden-fish/runs",
|
||||
post(start_wooden_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/wooden-fish/runs/{run_id}/checkpoint",
|
||||
post(checkpoint_wooden_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/wooden-fish/runs/{run_id}/finish",
|
||||
post(finish_wooden_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
|
||||
@@ -122,12 +122,24 @@ const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
|
||||
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 6 * 1024 * 1024;
|
||||
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
|
||||
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
|
||||
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
|
||||
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
|
||||
|
||||
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
|
||||
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_reference_image_too_large_message(actual_bytes: usize) -> String {
|
||||
format!(
|
||||
"参考图过大,请压缩后再上传(当前 {},最多 6MB)。",
|
||||
format_puzzle_reference_image_upload_bytes(actual_bytes)
|
||||
)
|
||||
}
|
||||
|
||||
const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面,生成对应的拼图游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图,拼图区域宽度与画面宽度同宽,紧贴画面横向边缘,拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字";
|
||||
const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素,将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成纯绿色绿幕背景的spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列:返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字,每个按钮必须是独立完整图形,按钮之间保留足够纯绿色绿幕空白,不能相互接触、重叠或连成一片,方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框,直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。";
|
||||
const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容";
|
||||
|
||||
@@ -643,15 +643,13 @@ pub(crate) async fn resolve_puzzle_reference_image(
|
||||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||||
let bytes_len = parsed.bytes.len();
|
||||
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图过大,请压缩后重试。",
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": bytes_len,
|
||||
})),
|
||||
);
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": build_puzzle_reference_image_too_large_message(bytes_len),
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": bytes_len,
|
||||
})));
|
||||
}
|
||||
return Ok(PuzzleResolvedReferenceImage {
|
||||
mime_type: parsed.mime_type,
|
||||
@@ -803,16 +801,16 @@ pub(crate) fn validate_puzzle_reference_asset_object(
|
||||
if asset_object.content_length == 0
|
||||
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产大小不符合拼图生成要求。",
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": asset_object.content_length,
|
||||
})),
|
||||
);
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": build_puzzle_reference_image_too_large_message(
|
||||
asset_object.content_length as usize,
|
||||
),
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": asset_object.content_length,
|
||||
})));
|
||||
}
|
||||
if let Some(expected_owner_user_id) = owner_user_id
|
||||
.map(str::trim)
|
||||
|
||||
@@ -32,7 +32,7 @@ use crate::generated_image_assets::{
|
||||
};
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client,
|
||||
@@ -220,14 +220,14 @@ pub async fn get_wooden_fish_runtime_work(
|
||||
pub async fn start_wooden_fish_run(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<WoodenFishStartRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?;
|
||||
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.start_wooden_fish_run(payload, authenticated.claims().user_id().to_string())
|
||||
.start_wooden_fish_run(payload, principal.subject().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
@@ -247,7 +247,7 @@ pub async fn checkpoint_wooden_fish_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<WoodenFishCheckpointRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||
@@ -256,7 +256,7 @@ pub async fn checkpoint_wooden_fish_run(
|
||||
.spacetime_client()
|
||||
.checkpoint_wooden_fish_run(
|
||||
run_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
principal.subject().to_string(),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
@@ -278,7 +278,7 @@ pub async fn finish_wooden_fish_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<WoodenFishFinishRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||
@@ -287,7 +287,7 @@ pub async fn finish_wooden_fish_run(
|
||||
.spacetime_client()
|
||||
.finish_wooden_fish_run(
|
||||
run_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
principal.subject().to_string(),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -2,7 +2,7 @@ use module_runtime::RuntimeTrackingScopeKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
request_context::RequestContext,
|
||||
state::{AppState, PuzzleApiState},
|
||||
tracking::{TrackingEventDraft, record_tracking_event_after_success},
|
||||
@@ -36,12 +36,28 @@ impl WorkPlayTrackingDraft {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn anonymous(
|
||||
pub(crate) fn runtime_principal(
|
||||
play_type: &'static str,
|
||||
work_id: impl Into<String>,
|
||||
principal: &RuntimePrincipal,
|
||||
source_route: &'static str,
|
||||
) -> Self {
|
||||
Self::with_user_id(play_type, work_id, None, source_route)
|
||||
match principal {
|
||||
RuntimePrincipal::User(authenticated) => {
|
||||
Self::new(play_type, work_id, authenticated, source_route)
|
||||
}
|
||||
RuntimePrincipal::Guest(claims) => Self::with_user_id(
|
||||
play_type,
|
||||
work_id,
|
||||
Some(claims.subject().to_string()),
|
||||
source_route,
|
||||
)
|
||||
.extra(json!({
|
||||
"principalKind": "guest",
|
||||
"guestSubject": claims.subject(),
|
||||
"guestScope": claims.scope(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_user_id(
|
||||
|
||||
@@ -21,6 +21,9 @@ use url::Url;
|
||||
|
||||
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
|
||||
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
|
||||
pub const DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS: u64 = 15 * 60;
|
||||
pub const RUNTIME_GUEST_TOKEN_TYPE: &str = "runtime_guest";
|
||||
pub const RUNTIME_GUEST_SCOPE_PUBLIC_PLAY: &str = "runtime:public-play";
|
||||
pub const DEFAULT_REFRESH_COOKIE_NAME: &str = "genarrative_refresh_session";
|
||||
pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth";
|
||||
pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30;
|
||||
@@ -107,6 +110,21 @@ pub struct AccessTokenClaims {
|
||||
pub exp: u64,
|
||||
}
|
||||
|
||||
pub struct RuntimeGuestTokenClaimsInput {
|
||||
pub subject: String,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeGuestTokenClaims {
|
||||
pub iss: String,
|
||||
pub sub: String,
|
||||
pub typ: String,
|
||||
pub scope: String,
|
||||
pub iat: u64,
|
||||
pub exp: u64,
|
||||
}
|
||||
|
||||
// 统一承载 JWT 配置,避免 secret、issuer、ttl 在 api-server 与后续模块里散落。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct JwtConfig {
|
||||
@@ -417,6 +435,10 @@ impl JwtConfig {
|
||||
pub fn access_token_ttl_seconds(&self) -> u64 {
|
||||
self.access_token_ttl_seconds
|
||||
}
|
||||
|
||||
pub fn runtime_guest_token_ttl_seconds(&self) -> u64 {
|
||||
DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
impl RefreshCookieSameSite {
|
||||
@@ -1474,6 +1496,74 @@ impl AccessTokenClaims {
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeGuestTokenClaims {
|
||||
pub fn from_input(
|
||||
input: RuntimeGuestTokenClaimsInput,
|
||||
config: &JwtConfig,
|
||||
issued_at: OffsetDateTime,
|
||||
) -> Result<Self, JwtError> {
|
||||
let subject = normalize_required_field(input.subject, "runtime guest JWT sub 不能为空")?;
|
||||
let scope = normalize_required_field(input.scope, "runtime guest JWT scope 不能为空")?;
|
||||
|
||||
let issued_at_unix = issued_at.unix_timestamp();
|
||||
if issued_at_unix < 0 {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT iat 不能早于 Unix epoch"));
|
||||
}
|
||||
|
||||
let expires_at = issued_at
|
||||
.checked_add(Duration::seconds(
|
||||
i64::try_from(config.runtime_guest_token_ttl_seconds()).map_err(|_| {
|
||||
JwtError::InvalidConfig("runtime guest JWT 过期时间超出 i64 上限")
|
||||
})?,
|
||||
))
|
||||
.ok_or(JwtError::InvalidConfig("runtime guest JWT 过期时间计算溢出"))?;
|
||||
let expires_at_unix = expires_at.unix_timestamp();
|
||||
if expires_at_unix <= issued_at_unix {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat"));
|
||||
}
|
||||
|
||||
let claims = Self {
|
||||
iss: config.issuer().to_string(),
|
||||
sub: subject,
|
||||
typ: RUNTIME_GUEST_TOKEN_TYPE.to_string(),
|
||||
scope,
|
||||
iat: issued_at_unix as u64,
|
||||
exp: expires_at_unix as u64,
|
||||
};
|
||||
claims.validate_for_config(config)?;
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
pub fn subject(&self) -> &str {
|
||||
&self.sub
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> &str {
|
||||
&self.scope
|
||||
}
|
||||
|
||||
pub fn expires_at_unix(&self) -> u64 {
|
||||
self.exp
|
||||
}
|
||||
|
||||
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
|
||||
if self.iss.trim() != config.issuer() {
|
||||
return Err(JwtError::InvalidClaims(
|
||||
"runtime guest JWT iss 与当前配置不一致",
|
||||
));
|
||||
}
|
||||
normalize_required_field(self.sub.clone(), "runtime guest JWT sub 不能为空")?;
|
||||
normalize_required_field(self.scope.clone(), "runtime guest JWT scope 不能为空")?;
|
||||
if self.typ.trim() != RUNTIME_GUEST_TOKEN_TYPE {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT typ 非法"));
|
||||
}
|
||||
if self.exp <= self.iat {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AccessTokenDeviceInfo {
|
||||
pub fn normalize(self) -> Result<Self, JwtError> {
|
||||
Ok(Self {
|
||||
@@ -1526,6 +1616,26 @@ pub fn sign_access_token(
|
||||
.map_err(|error| JwtError::SignFailed(format!("JWT 签发失败:{error}")))
|
||||
}
|
||||
|
||||
pub fn sign_runtime_guest_token(
|
||||
claims: &RuntimeGuestTokenClaims,
|
||||
config: &JwtConfig,
|
||||
) -> Result<String, JwtError> {
|
||||
claims.validate_for_config(config)?;
|
||||
|
||||
let header = Header {
|
||||
alg: ACCESS_TOKEN_ALGORITHM,
|
||||
typ: Some("JWT".to_string()),
|
||||
..Header::default()
|
||||
};
|
||||
|
||||
encode(
|
||||
&header,
|
||||
claims,
|
||||
&EncodingKey::from_secret(config.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|error| JwtError::SignFailed(format!("runtime guest JWT 签发失败:{error}")))
|
||||
}
|
||||
|
||||
pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, JwtError> {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
@@ -1552,6 +1662,35 @@ pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessToke
|
||||
Ok(decoded.claims)
|
||||
}
|
||||
|
||||
pub fn verify_runtime_guest_token(
|
||||
token: &str,
|
||||
config: &JwtConfig,
|
||||
) -> Result<RuntimeGuestTokenClaims, JwtError> {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
return Err(JwtError::VerifyFailed("runtime guest JWT 不能为空".to_string()));
|
||||
}
|
||||
|
||||
let mut validation = Validation::new(ACCESS_TOKEN_ALGORITHM);
|
||||
validation.required_spec_claims = HashSet::from([
|
||||
"exp".to_string(),
|
||||
"iat".to_string(),
|
||||
"iss".to_string(),
|
||||
"sub".to_string(),
|
||||
]);
|
||||
validation.set_issuer(&[config.issuer()]);
|
||||
|
||||
let decoded = decode::<RuntimeGuestTokenClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(config.secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map_err(map_verify_error)?;
|
||||
|
||||
decoded.claims.validate_for_config(config)?;
|
||||
Ok(decoded.claims)
|
||||
}
|
||||
|
||||
pub fn read_refresh_session_token(
|
||||
cookie_header: &str,
|
||||
config: &RefreshCookieConfig,
|
||||
@@ -2218,6 +2357,30 @@ mod tests {
|
||||
.expect("real aliyun sms config should be valid")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_sign_and_verify_runtime_guest_token() {
|
||||
let config = build_jwt_config();
|
||||
let issued_at = OffsetDateTime::now_utc();
|
||||
let claims = RuntimeGuestTokenClaims::from_input(
|
||||
RuntimeGuestTokenClaimsInput {
|
||||
subject: "guest-runtime-123".to_string(),
|
||||
scope: RUNTIME_GUEST_SCOPE_PUBLIC_PLAY.to_string(),
|
||||
},
|
||||
&config,
|
||||
issued_at,
|
||||
)
|
||||
.expect("runtime guest claims should build");
|
||||
|
||||
let token = sign_runtime_guest_token(&claims, &config).expect("token should sign");
|
||||
let verified = verify_runtime_guest_token(&token, &config).expect("token should verify");
|
||||
|
||||
assert_eq!(verified, claims);
|
||||
assert_eq!(verified.subject(), "guest-runtime-123");
|
||||
assert_eq!(verified.scope(), RUNTIME_GUEST_SCOPE_PUBLIC_PLAY);
|
||||
assert_eq!(verified.typ, RUNTIME_GUEST_TOKEN_TYPE);
|
||||
assert_eq!(verified.expires_at_unix() - verified.iat, DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_sign_and_verify_access_token() {
|
||||
let config = build_jwt_config();
|
||||
|
||||
@@ -20,7 +20,7 @@ const OSS_V4_REQUEST: &str = "aliyun_v4_request";
|
||||
const OSS_V4_SERVICE: &str = "oss";
|
||||
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||||
|
||||
pub const LEGACY_PUBLIC_PREFIXES: [&str; 12] = [
|
||||
pub const LEGACY_PUBLIC_PREFIXES: [&str; 13] = [
|
||||
"generated-character-drafts",
|
||||
"generated-characters",
|
||||
"generated-animations",
|
||||
@@ -29,6 +29,7 @@ pub const LEGACY_PUBLIC_PREFIXES: [&str; 12] = [
|
||||
"generated-wooden-fish-assets",
|
||||
"generated-match3d-assets",
|
||||
"generated-puzzle-assets",
|
||||
"generated-jump-hop-assets",
|
||||
"generated-custom-world-scenes",
|
||||
"generated-custom-world-covers",
|
||||
"generated-bark-battle-assets",
|
||||
@@ -52,6 +53,7 @@ pub enum LegacyAssetPrefix {
|
||||
WoodenFishAssets,
|
||||
Match3DAssets,
|
||||
PuzzleAssets,
|
||||
JumpHopAssets,
|
||||
CustomWorldScenes,
|
||||
CustomWorldCovers,
|
||||
BarkBattleAssets,
|
||||
@@ -241,6 +243,7 @@ impl LegacyAssetPrefix {
|
||||
"generated-wooden-fish-assets" => Some(Self::WoodenFishAssets),
|
||||
"generated-match3d-assets" => Some(Self::Match3DAssets),
|
||||
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
|
||||
"generated-jump-hop-assets" => Some(Self::JumpHopAssets),
|
||||
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
|
||||
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
|
||||
"generated-bark-battle-assets" => Some(Self::BarkBattleAssets),
|
||||
@@ -259,6 +262,7 @@ impl LegacyAssetPrefix {
|
||||
Self::WoodenFishAssets => "generated-wooden-fish-assets",
|
||||
Self::Match3DAssets => "generated-match3d-assets",
|
||||
Self::PuzzleAssets => "generated-puzzle-assets",
|
||||
Self::JumpHopAssets => "generated-jump-hop-assets",
|
||||
Self::CustomWorldScenes => "generated-custom-world-scenes",
|
||||
Self::CustomWorldCovers => "generated-custom-world-covers",
|
||||
Self::BarkBattleAssets => "generated-bark-battle-assets",
|
||||
|
||||
@@ -42,6 +42,15 @@ pub struct PublicUserSearchResponse {
|
||||
pub user: PublicUserSummaryPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeGuestTokenResponse {
|
||||
pub token: String,
|
||||
pub expires_at: String,
|
||||
pub subject: String,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryRequest {
|
||||
|
||||
@@ -87,6 +87,8 @@ pub struct JumpHopWorkspaceCreateRequest {
|
||||
pub struct JumpHopActionRequest {
|
||||
pub action_type: JumpHopActionType,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub work_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub work_description: Option<String>,
|
||||
@@ -102,6 +104,14 @@ pub struct JumpHopActionRequest {
|
||||
pub tile_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub end_mood_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub character_asset: Option<JumpHopCharacterAsset>,
|
||||
#[serde(default)]
|
||||
pub tile_atlas_asset: Option<JumpHopCharacterAsset>,
|
||||
#[serde(default)]
|
||||
pub tile_assets: Option<Vec<JumpHopTileAsset>>,
|
||||
#[serde(default)]
|
||||
pub cover_composite: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -226,8 +226,11 @@ impl SpacetimeClient {
|
||||
&self,
|
||||
profile_id: String,
|
||||
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
||||
self.get_jump_hop_work_profile(profile_id, String::new())
|
||||
.await
|
||||
let work = self
|
||||
.get_jump_hop_work_profile(profile_id, String::new())
|
||||
.await?;
|
||||
validate_jump_hop_runtime_ready(&work)?;
|
||||
Ok(work)
|
||||
}
|
||||
|
||||
pub async fn start_jump_hop_run(
|
||||
@@ -235,12 +238,17 @@ impl SpacetimeClient {
|
||||
payload: JumpHopStartRunRequest,
|
||||
owner_user_id: String,
|
||||
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
|
||||
let profile_id = payload.profile_id;
|
||||
let work = self
|
||||
.get_jump_hop_work_profile(profile_id.clone(), String::new())
|
||||
.await?;
|
||||
validate_jump_hop_runtime_ready(&work)?;
|
||||
let run_id = build_prefixed_uuid_id("jump-hop-run-");
|
||||
let procedure_input = JumpHopRunStartInput {
|
||||
client_event_id: format!("{run_id}:start"),
|
||||
run_id,
|
||||
owner_user_id,
|
||||
profile_id: payload.profile_id,
|
||||
profile_id,
|
||||
started_at_ms: current_unix_micros().div_euclid(1000),
|
||||
};
|
||||
self.start_jump_hop_run_with_input(procedure_input).await
|
||||
@@ -372,11 +380,91 @@ impl SpacetimeClient {
|
||||
&self,
|
||||
public_work_code: String,
|
||||
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
||||
self.get_jump_hop_work_profile(public_work_code, String::new())
|
||||
let gallery = self.list_jump_hop_gallery().await?;
|
||||
let requested_code = normalize_jump_hop_public_work_code(public_work_code.as_str());
|
||||
let card = gallery
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|item| {
|
||||
normalize_jump_hop_public_work_code(item.public_work_code.as_str()) == requested_code
|
||||
})
|
||||
.ok_or_else(|| SpacetimeClientError::Procedure("jump_hop public work 不存在".to_string()))?;
|
||||
|
||||
self.get_jump_hop_work_profile(card.profile_id, String::new())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn validate_jump_hop_runtime_ready(
|
||||
work: &JumpHopWorkProfileResponse,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
let status = work.summary.publication_status.trim().to_ascii_lowercase();
|
||||
if status != "published" {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 只能启动已发布作品",
|
||||
));
|
||||
}
|
||||
if work.summary.generation_status != JumpHopGenerationStatus::Ready {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 需要 ready 状态作品",
|
||||
));
|
||||
}
|
||||
validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?;
|
||||
validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
|
||||
if work.tile_assets.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 缺少地块资产",
|
||||
));
|
||||
}
|
||||
for (index, asset) in work.tile_assets.iter().enumerate() {
|
||||
if asset.image_src.trim().is_empty()
|
||||
|| asset.image_object_key.trim().is_empty()
|
||||
|| asset.asset_object_id.trim().is_empty()
|
||||
{
|
||||
return Err(SpacetimeClientError::validation_failed(format!(
|
||||
"jump-hop runtime 地块资产 #{index} 不完整"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if work.path.platforms.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 缺少可玩路径",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_jump_hop_character_asset_ready(
|
||||
asset: &JumpHopCharacterAsset,
|
||||
field: &str,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
if asset.image_src.trim().is_empty()
|
||||
|| asset.image_object_key.trim().is_empty()
|
||||
|| asset.asset_object_id.trim().is_empty()
|
||||
{
|
||||
return Err(SpacetimeClientError::validation_failed(format!(
|
||||
"jump-hop runtime {field} 不完整"
|
||||
)));
|
||||
}
|
||||
if asset.generation_provider.trim().is_empty()
|
||||
|| asset.generation_provider == "deterministic-placeholder"
|
||||
{
|
||||
return Err(SpacetimeClientError::validation_failed(format!(
|
||||
"jump-hop runtime {field} 不是可用真实生成资产"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_jump_hop_public_work_code(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.map(|character| character.to_ascii_uppercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
enum JumpHopActionProcedure {
|
||||
Compile(JumpHopDraftCompileInput),
|
||||
Update(JumpHopWorkUpdateInput),
|
||||
@@ -503,22 +591,61 @@ fn merge_action_into_draft(
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
||||
) && let Some(value) = payload
|
||||
.character_prompt
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.character_prompt = value.trim().to_string();
|
||||
) {
|
||||
if let Some(value) = payload
|
||||
.character_prompt
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.character_prompt = value.trim().to_string();
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
|
||||
) && let Some(value) = payload
|
||||
.tile_prompt
|
||||
) {
|
||||
if let Some(value) = payload
|
||||
.tile_prompt
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.tile_prompt = value.trim().to_string();
|
||||
}
|
||||
}
|
||||
if let Some(profile_id) = payload
|
||||
.profile_id
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
draft.tile_prompt = value.trim().to_string();
|
||||
draft.profile_id = Some(profile_id.to_string());
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
||||
) {
|
||||
if let Some(asset) = payload.character_asset.clone() {
|
||||
draft.character_asset = Some(asset);
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
|
||||
) {
|
||||
if let Some(asset) = payload.tile_atlas_asset.clone() {
|
||||
draft.tile_atlas_asset = Some(asset);
|
||||
}
|
||||
if let Some(assets) = payload.tile_assets.clone() {
|
||||
draft.tile_assets = assets;
|
||||
}
|
||||
}
|
||||
if let Some(value) = payload
|
||||
.cover_composite
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
draft.cover_composite = Some(value.to_string());
|
||||
}
|
||||
if draft.work_title.trim().is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
@@ -545,31 +672,30 @@ fn build_compile_input(
|
||||
draft.tile_atlas_asset = None;
|
||||
draft.tile_assets.clear();
|
||||
}
|
||||
let character_asset = ensure_character_asset(
|
||||
draft.character_asset.clone(),
|
||||
let character_asset = draft.character_asset.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object",
|
||||
)
|
||||
})?;
|
||||
let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object",
|
||||
)
|
||||
})?;
|
||||
let tile_assets = if draft.tile_assets.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object",
|
||||
));
|
||||
} else {
|
||||
draft.tile_assets.clone()
|
||||
};
|
||||
let cover_composite = resolve_cover_composite(
|
||||
draft,
|
||||
profile_id,
|
||||
&draft.character_prompt,
|
||||
force_character,
|
||||
refresh,
|
||||
now_micros,
|
||||
);
|
||||
let tile_atlas_asset = ensure_tile_atlas_asset(
|
||||
draft.tile_atlas_asset.clone(),
|
||||
profile_id,
|
||||
&draft.tile_prompt,
|
||||
force_tiles,
|
||||
now_micros,
|
||||
);
|
||||
let tile_assets = ensure_tile_assets(
|
||||
draft.tile_assets.clone(),
|
||||
profile_id,
|
||||
force_tiles,
|
||||
now_micros,
|
||||
);
|
||||
let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros);
|
||||
|
||||
draft.character_asset = Some(character_asset.clone());
|
||||
draft.tile_atlas_asset = Some(tile_atlas_asset.clone());
|
||||
draft.tile_assets = tile_assets.clone();
|
||||
draft.cover_composite = cover_composite.clone();
|
||||
draft.generation_status = JumpHopGenerationStatus::Ready;
|
||||
|
||||
@@ -698,8 +824,10 @@ fn ensure_character_asset(
|
||||
force_new: bool,
|
||||
now_micros: i64,
|
||||
) -> JumpHopCharacterAsset {
|
||||
if !force_new && let Some(asset) = existing {
|
||||
return asset;
|
||||
if !force_new {
|
||||
if let Some(asset) = existing {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
let revision = force_new.then_some(now_micros);
|
||||
let suffix = asset_revision_suffix(revision);
|
||||
@@ -722,8 +850,10 @@ fn ensure_tile_atlas_asset(
|
||||
force_new: bool,
|
||||
now_micros: i64,
|
||||
) -> JumpHopCharacterAsset {
|
||||
if !force_new && let Some(asset) = existing {
|
||||
return asset;
|
||||
if !force_new {
|
||||
if let Some(asset) = existing {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
let revision = force_new.then_some(now_micros);
|
||||
let suffix = asset_revision_suffix(revision);
|
||||
@@ -781,14 +911,15 @@ fn resolve_cover_composite(
|
||||
refresh: JumpHopAssetRefresh,
|
||||
now_micros: i64,
|
||||
) -> Option<String> {
|
||||
if matches!(refresh, JumpHopAssetRefresh::Preserve)
|
||||
&& let Some(value) = draft
|
||||
if matches!(refresh, JumpHopAssetRefresh::Preserve) {
|
||||
if let Some(value) = draft
|
||||
.cover_composite
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(value.to_string());
|
||||
{
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
let suffix = asset_revision_suffix(
|
||||
(!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros),
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<JumpHopGall
|
||||
jump_hop_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(|row| JumpHopGalleryCardViewRow {
|
||||
public_work_code: row.work_id.clone(),
|
||||
public_work_code: build_jump_hop_public_work_code(&row.profile_id),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
owner_user_id: row.owner_user_id,
|
||||
@@ -658,6 +658,25 @@ fn build_gallery_view_row(row: &JumpHopWorkProfileRow) -> Result<JumpHopGalleryV
|
||||
})
|
||||
}
|
||||
|
||||
fn build_jump_hop_public_work_code(profile_id: &str) -> String {
|
||||
let normalized = profile_id
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(|character| character.to_uppercase())
|
||||
.collect::<String>();
|
||||
let fallback = if normalized.is_empty() {
|
||||
"00000000".to_string()
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
let suffix = if fallback.len() > 8 {
|
||||
fallback[fallback.len() - 8..].to_string()
|
||||
} else {
|
||||
format!("{fallback:0>8}")
|
||||
};
|
||||
format!("JH-{suffix}")
|
||||
}
|
||||
|
||||
fn build_session_snapshot(
|
||||
row: &JumpHopAgentSessionRow,
|
||||
) -> Result<JumpHopAgentSessionSnapshot, String> {
|
||||
|
||||
@@ -53,6 +53,7 @@ export type CreativeImageInputPanelProps = {
|
||||
aiRedraw: boolean;
|
||||
promptReferenceImages: CreativeImageInputReferenceImage[];
|
||||
promptReferenceLimit?: number;
|
||||
imageLimitHint?: string | null;
|
||||
imageModelPicker?: ReactNode;
|
||||
error?: string | null;
|
||||
inputError?: string | null;
|
||||
@@ -96,6 +97,7 @@ export function CreativeImageInputPanel({
|
||||
aiRedraw,
|
||||
promptReferenceImages,
|
||||
promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
|
||||
imageLimitHint = null,
|
||||
imageModelPicker = null,
|
||||
error = null,
|
||||
inputError = null,
|
||||
@@ -276,6 +278,11 @@ export function CreativeImageInputPanel({
|
||||
</div>
|
||||
</div>
|
||||
{mainImageMeta ? <div className="mt-3 shrink-0">{mainImageMeta}</div> : null}
|
||||
{imageLimitHint ? (
|
||||
<div className="mt-2 shrink-0 text-center text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
||||
{imageLimitHint}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showPrompt ? (
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
@@ -61,6 +62,9 @@ type CustomWorldCreationHubProps = {
|
||||
squareHoleItems?: SquareHoleWorkSummary[];
|
||||
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
|
||||
onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null;
|
||||
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
|
||||
puzzleItems?: PuzzleWorkSummary[];
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
@@ -169,6 +173,9 @@ export function CustomWorldCreationHub({
|
||||
squareHoleItems = [],
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole = null,
|
||||
jumpHopItems = [],
|
||||
onOpenJumpHopDetail,
|
||||
onDeleteJumpHop = null,
|
||||
puzzleItems = [],
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle = null,
|
||||
@@ -201,6 +208,7 @@ export function CustomWorldCreationHub({
|
||||
bigFishItems,
|
||||
match3dItems,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
||||
jumpHopItems,
|
||||
puzzleItems,
|
||||
babyObjectMatchItems,
|
||||
barkBattleItems,
|
||||
@@ -210,6 +218,7 @@ export function CustomWorldCreationHub({
|
||||
canDeleteMatch3D: Boolean(onDeleteMatch3D),
|
||||
canDeleteSquareHole:
|
||||
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
||||
canDeleteJumpHop: Boolean(onDeleteJumpHop),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
||||
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
|
||||
@@ -223,6 +232,8 @@ export function CustomWorldCreationHub({
|
||||
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
|
||||
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
|
||||
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle: onDeletePuzzle ?? undefined,
|
||||
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
||||
@@ -249,6 +260,7 @@ export function CustomWorldCreationHub({
|
||||
onDeleteBabyObjectMatch,
|
||||
onDeleteBarkBattle,
|
||||
onDeleteVisualNovel,
|
||||
onDeleteJumpHop,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBigFishDetail,
|
||||
onOpenDraft,
|
||||
@@ -262,7 +274,9 @@ export function CustomWorldCreationHub({
|
||||
getWorkState,
|
||||
puzzleItems,
|
||||
rpgLibraryEntries,
|
||||
squareHoleItems,
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenJumpHopDetail,
|
||||
jumpHopItems,
|
||||
visualNovelItems,
|
||||
],
|
||||
);
|
||||
@@ -310,6 +324,9 @@ export function CustomWorldCreationHub({
|
||||
case 'square-hole':
|
||||
onOpenSquareHoleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'jump-hop':
|
||||
onOpenJumpHopDetail?.(item.source.item);
|
||||
return;
|
||||
case 'rpg':
|
||||
if (item.status === 'draft') {
|
||||
onOpenDraft(item.source.item);
|
||||
|
||||
@@ -59,6 +59,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
|
||||
'big-fish': '/creation-type-references/big-fish.webp',
|
||||
match3d: '/creation-type-references/match3d.webp',
|
||||
'square-hole': '/creation-type-references/square-hole.webp',
|
||||
'jump-hop': '/creation-type-references/jump-hop.webp',
|
||||
puzzle: '/creation-type-references/puzzle.webp',
|
||||
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
||||
'bark-battle': '/creation-type-references/bark-battle.webp',
|
||||
|
||||
@@ -7,12 +7,14 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildCustomWorldPublicWorkCode,
|
||||
buildBarkBattlePublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
@@ -31,6 +33,7 @@ export type CreationWorkShelfKind =
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'jump-hop'
|
||||
| 'puzzle'
|
||||
| 'baby-object-match'
|
||||
| 'bark-battle'
|
||||
@@ -83,6 +86,10 @@ export type CreationWorkShelfSource =
|
||||
kind: 'square-hole';
|
||||
item: SquareHoleWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'jump-hop';
|
||||
item: JumpHopWorkSummaryResponse;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
item: PuzzleWorkSummary;
|
||||
@@ -137,6 +144,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
bigFishItems: BigFishWorkSummary[];
|
||||
match3dItems?: Match3DWorkSummary[];
|
||||
squareHoleItems?: SquareHoleWorkSummary[];
|
||||
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
barkBattleItems?: BarkBattleWorkSummary[];
|
||||
@@ -145,6 +153,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteBigFish?: boolean;
|
||||
canDeleteMatch3D?: boolean;
|
||||
canDeleteSquareHole?: boolean;
|
||||
canDeleteJumpHop?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
canDeleteBabyObjectMatch?: boolean;
|
||||
canDeleteBarkBattle?: boolean;
|
||||
@@ -158,6 +167,8 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDeleteMatch3D?: (item: Match3DWorkSummary) => void;
|
||||
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
|
||||
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
|
||||
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
||||
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||
@@ -177,6 +188,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
bigFishItems,
|
||||
match3dItems = [],
|
||||
squareHoleItems = [],
|
||||
jumpHopItems = [],
|
||||
puzzleItems,
|
||||
babyObjectMatchItems = [],
|
||||
barkBattleItems = [],
|
||||
@@ -185,6 +197,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteBigFish = false,
|
||||
canDeleteMatch3D = false,
|
||||
canDeleteSquareHole = false,
|
||||
canDeleteJumpHop = false,
|
||||
canDeletePuzzle = false,
|
||||
canDeleteBabyObjectMatch = false,
|
||||
canDeleteBarkBattle = false,
|
||||
@@ -198,6 +211,8 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDeleteMatch3D,
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole,
|
||||
onOpenJumpHopDetail,
|
||||
onDeleteJumpHop,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
onClaimPuzzlePointIncentive,
|
||||
@@ -236,6 +251,12 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDelete: onDeleteSquareHole,
|
||||
}),
|
||||
),
|
||||
...jumpHopItems.map((item) =>
|
||||
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
|
||||
onOpen: onOpenJumpHopDetail,
|
||||
onDelete: onDeleteJumpHop,
|
||||
}),
|
||||
),
|
||||
...puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
||||
onOpen: onOpenPuzzleDetail,
|
||||
@@ -749,6 +770,51 @@ function mapSquareHoleWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
function mapJumpHopWorkToShelfItem(
|
||||
item: JumpHopWorkSummaryResponse,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<JumpHopWorkSummaryResponse>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
status === 'published' ? buildJumpHopPublicWorkCode(item.profileId) : null;
|
||||
const coverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'jump-hop',
|
||||
status,
|
||||
title: item.workTitle,
|
||||
summary: item.workDescription,
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && status === 'published'
|
||||
? buildPublicWorkStagePath('work-detail', publicWorkCode)
|
||||
: null,
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '跳一跳', tone: 'neutral' },
|
||||
],
|
||||
metrics:
|
||||
status === 'published'
|
||||
? buildPublishedMetrics({
|
||||
playCount: item.playCount,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'jump-hop', item },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function resolveAuthorDisplayName(
|
||||
...sources: Array<unknown>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user