feat: 统一推荐页匿名游玩的 Runtime Guest 认证 #32

Merged
kdletters merged 4 commits from feat/recommend-runtime-guest into master 2026-05-25 14:17:55 +08:00
43 changed files with 1837 additions and 384 deletions
@@ -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` 个 5xx200 请求平均 `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 等账号/所有权动作仍保持普通用户鉴权。
## 敲木鱼
+7
View File
@@ -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;
+5
View File
@@ -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 {
+129 -12
View File
@@ -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(
+13 -1
View File
@@ -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(
+163
View File
@@ -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();
+5 -1
View File
@@ -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)]
+174 -43
View File
@@ -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