This commit is contained in:
2026-05-25 23:09:07 +08:00
70 changed files with 4093 additions and 2115 deletions

View File

@@ -3,4 +3,14 @@ version = 1
name = "Genarrative"
[setup]
script = ""
script = '''
npm install
cp "C:\proj\Genarrative\.env.secrets.local" ".env.secrets.local"
npm run codegraph:init
npm run codegraph:index
'''
[[actions]]
name = "运行"
icon = "run"
command = "npm run dev"

View File

@@ -16,6 +16,22 @@
---
## 2026-05-25 平台首页推荐按桌面与移动断点分流
- 背景:平台首页的推荐页在桌面与移动端之间原先共用同一套推荐运行态逻辑,容易让桌面和移动两套内容同时启动,也让首页的推荐卡与桌面发现壳互相抢状态。
- 决策:`RpgEntryHomeView` 只接受同一个 `isDesktopLayout` 断点判断;桌面端首页渲染桌面发现壳(`今日游戏``推荐``作品分类` 等),不挂移动推荐嵌入运行态;移动端 `home` 才渲染推荐卡与嵌入运行态。平台壳和首页视图都必须共用 `usePlatformDesktopLayout()`,不能在不同文件里各自判断断点。推荐嵌入运行态不是登录门禁:未登录可直达匿名运行态;已登录或已有 access token 时继续使用账号 Bearer但必须用 local auth impact 防止推荐卡 401 清空全局登录态。
- 影响范围:`src/components/platform-entry/platformEntryResponsive.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryHomeView.tsx`、首页推荐相关测试与 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
- 验证方式:桌面宽度下首页应只看到桌面发现壳,窄屏下首页应只看到移动推荐流;`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation"``npm run typecheck``npm run check:encoding` 通过。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-25 新增玩法接入必须使用统一 SOP skill
- 背景:敲木鱼、跳一跳、汪汪声浪等玩法接入过程中,作品架曾经没有被作为强制闭环验收项,导致玩法可以先完成创作、发布、运行态或广场,但用户在草稿 / 已发布作品架中看不到自己的作品。
- 决策:凡是新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 的任务,开始前必须显式读取并按 `.codex/skills/genarrative-play-type-integration/SKILL.md` 执行。需要发布或试玩的玩法,作品架不是可选项,必须补齐私有 `/works` 列表、作品摘要、pending shelf 兜底、统一作品架 adapter、打开详情 / 草稿恢复、已发布分享入口和草稿 / 已发布可见性测试。
- 影响范围:`AGENTS.md``.codex/skills/genarrative-play-type-integration/SKILL.md`、玩法 PRD、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、新增玩法前后端接入流程。
- 验证方式:玩法接入 PRD 和实现验收必须列出作品架链路;若一个玩法具备发布或试玩能力,但缺少 `/api/creation/<play>/works`、前端 client `listWorks``CustomWorldCreationHub` props、`creationWorkShelf` adapter 或草稿 / 已发布作品架测试,则接入不算完成。
- 关联文档:`AGENTS.md``.codex/skills/genarrative-play-type-integration/SKILL.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-24 创作 Tab banner 轮播只展示主题赛
- 背景:创作 Tab banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致首屏出现 58000 奖池活动卡,和当前只强调拼图 / 抓大鹅主题赛的产品口径不一致。
@@ -121,6 +137,14 @@
- 验证方式:执行 `cargo test -p api-server external_api_audit --manifest-path server-rs/Cargo.toml -- --nocapture``cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-05-25 VectorEngine 图片 provider 收到 platform-image
- 背景:`api-server` 里原本同时混着 VectorEngine 创建 / 编辑协议、响应解析、远端图片下载、失败日志和审计落库逻辑Puzzle / Match3D 还各自藏着一份近似实现导致“provider 协议”和“业务编排”边界不清。
- 决策:把 VectorEngine `gpt-image-2` 图片 provider 协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志统一收口到 `server-rs/crates/platform-image``api-server` 只保留配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计桥接;旧 `openai_image_generation.rs` 只作为兼容转接层,不再承担 provider 实现。
- 影响范围:`server-rs/crates/platform-image``server-rs/crates/api-server/src/openai_image_generation.rs``server-rs/crates/api-server/src/puzzle/vector_engine.rs``server-rs/crates/api-server/src/external_api_audit.rs`、后端架构与运维文档。
- 验证方式:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml``cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture``cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml -- --nocapture``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-05-21 拼图参考图主链改为 OSS assetObjectId 与只读签名 URL
- 背景release 上拼图图生图生成草稿时,旧链路把上传图转成 Data URL/base64 放进创作 action JSON body容易先触发 Nginx `413 Request Entity Too Large`,也让外部模型调用前的 HTTP body 过大。

View File

@@ -93,6 +93,8 @@ npm run dev:admin-web
`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`
开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
查看本地 Rust/SpacetimeDB 日志:
```bash

View File

@@ -23,6 +23,29 @@
- 验证:点拼图 / 抓大鹅 / 汪汪声浪卡片后,应看到各自既有工作台内容,例如测试中的 `拼图工作区missing-session``抓大鹅工作区missing-session``汪汪声浪配置表单`并且不再出现“X 创作入口”空白页。
- 关联:`src/components/platform-entry/platformEntryTypes.ts``src/routing/appPageRoutes.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
## 首页推荐分流参数不能条件性调用 hook
- 现象:桌面首页或移动首页在 HMR、断点切换或重新渲染后直接报 React hook 顺序错误,页面停在“正在加载内容”。
- 原因:`RpgEntryHomeView` 曾经写成 `const isDesktopLayout = isDesktopLayoutProp ?? usePlatformDesktopLayout();`,当 `isDesktopLayoutProp` 存在时会跳过 hook 调用,导致 hook 顺序在不同渲染之间变化。
- 处理:先无条件调用 `usePlatformDesktopLayout()`,再用 `isDesktopLayoutProp ?? detectedDesktopLayout` 合并;不要把 hook 调用藏在条件表达式里。
- 验证:桌面与窄屏各刷新一次首页,控制台不再出现 hook 顺序错误;`npm run typecheck` 和首页推荐相关测试通过。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/platformEntryResponsive.ts`
## 泥点不足提示不要把用户退回创作入口
- 现象:拼图 / 抓大鹅 / 汪汪声浪等创作表单点击生成时,如果泥点不足,页面直接回到创作 Tab 玩法模板列表,刚填的表单内容随工作台卸载全部丢失。
- 原因:`PlatformEntryFlowShellImpl.tsx``ensureEnoughDraftGenerationPointsFromServer(...)` 曾在余额不足或余额读取失败时调用 `enterCreateTab()``setSelectionStage('platform')`,把前置校验失败当作离开工作台处理。
- 处理:泥点前置校验失败只更新独立 `UnifiedModal` 提示,不切换 stage不清表单余额读取失败也走同一弹窗口径。需要提示玩法内错误时可以保留局部错误位但不得因此退出工作台。
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 玩法入口分类字段缺失要前端兜底
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel``trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
- 处理:`normalizeCategoryId(...)``normalizeCategoryLabel(...)` 必须接收可空值,并分别回退到 `recent` / `最近创作`。前端这里是展示派生层,不能要求所有历史配置都先补齐字段。
- 验证:`npm test -- src/components/platform-entry/platformEntryCreationTypes.test.ts`,再打开本地创作页确认能正常进入创作 Tab。
- 关联:`src/components/platform-entry/platformEntryCreationTypes.ts``src/components/platform-entry/platformEntryCreationTypes.test.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 草稿页未读点不要继续用红色 literal
- 现象:草稿页底部 Tab 和作品架的未读点视觉上仍像红点,或 glow 仍带红色阴影,和平台暖棕体系不一致。
@@ -234,6 +257,14 @@
- 验证:`SELECT event_id, scope_id AS provider, metadata_json, occurred_at FROM tracking_event WHERE event_key = 'external_api_call_failure' ORDER BY occurred_at DESC LIMIT 50;`;如果查不到同时看 tracking outbox 目录权限和 sealed 文件是否堆积。
- 关联:`server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## VectorEngine 图片协议先看 platform-image不要先翻 puzzle.rs
- 现象:排查拼图或其它玩法的生图失败时,如果直接在 `api-server` 的大文件里找 `images/generations``images/edits`、base64 解码或下载逻辑,会看到很多历史 helper 和测试桥,看起来像每个玩法都自带一份 provider 实现。
- 原因:旧实现把 VectorEngine 图片 provider 协议、响应解析、下载和日志混在 `api-server` 里,后来虽然迁出到 `platform-image`,但兼容层和测试 helper 仍会让人误判真相源位置。
- 处理:先看 `server-rs/crates/platform-image/src/lib.rs` 的 provider 协议和结构化日志,再看 `server-rs/crates/api-server/src/openai_image_generation.rs` 的兼容桥和 `external_api_audit.rs` 的落库映射;`puzzle/vector_engine.rs` 只保留玩法编排,不再作为 provider 协议真相源。
- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml``cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture``cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml -- --nocapture` 通过时,排障先按 `platform-image` 的日志字段查 provider / endpoint / failure_stage。
- 关联:`server-rs/crates/platform-image/src/lib.rs``server-rs/crates/api-server/src/openai_image_generation.rs``server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/puzzle/vector_engine.rs`
## release 创作接口 413 先查是否还在提交 Data URL
- 现象release 上 `POST /api/runtime/puzzle/agent/sessions/{session_id}/actions` 携带参考图 Data URL 时返回 `413 Request Entity Too Large`access log 显示 `request_time=0.000``upstream_status=-`
@@ -825,6 +856,7 @@
- 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。
- 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。更隐蔽的是,`refreshAccessToken()` 自身曾在 refresh 失败时静默清 token即便调用方关闭了 `clearAuthOnUnauthorized`,也可能让后续 hydrate 变成未登录。
- 处理:请求层统一使用 `authImpact: 'global' | 'local'` 区分账号权威请求与局部后台请求;推荐页自动运行态、图片换签、公开拼图运行态和平台 bootstrap 私有投影刷新统一使用 `BACKGROUND_AUTH_REQUEST_OPTIONS` / `RUNTIME_BACKGROUND_AUTH_OPTIONS`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的账号动作仍保留默认全局鉴权失败处理。
- 追加处理:推荐页嵌入运行态要按真实身份分流,已登录或已有 access token 时继续走账号 Bearer + local auth impact不能误带 runtime guest token只有匿名访客才申请并透传 runtime guest token。
- 追加处理generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。
- 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。
@@ -841,9 +873,9 @@
## 推荐页未登录入口误打开公开详情
- 现象:新用户默认在发现页,但点击推荐页或推荐封面后,如果复用公开作品详情入口,可能绕过推荐页“登录后游玩”的产品门禁
- 现象:新用户默认在发现页,但点击推荐页或推荐封面后,如果复用公开作品详情入口,可能绕过推荐页沉浸运行态,打开普通公开详情页
- 原因:`RpgEntryHomeView` 曾只有 `onOpenGalleryDetail` 一个回调,同时服务发现页公开详情和推荐页作品入口;一旦为发现页保留公开浏览能力,推荐页也会跟着打开详情。
- 处理:公开详情与推荐页入口分离为 `onOpenGalleryDetail``onOpenRecommendGalleryDetail`。发现页、搜索和排行榜保留公开详情;推荐 Tab、推荐封面、推荐运行态错误重试和桌面推荐模块统一走登录门禁。未登录推荐页只显示封面,点击封面只弹登录窗,不携带登录后自动打开详情的回调
- 处理:公开详情与推荐页入口分离为 `onOpenGalleryDetail``onOpenRecommendGalleryDetail`。发现页、搜索和排行榜保留公开详情;推荐 Tab、推荐封面、推荐运行态错误重试和桌面推荐模块走推荐运行态入口不再主动弹登录窗。登录门禁只保留给创作、个人作品、删除、发布、Remix 等账号或所有权动作
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged out recommend"`
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`
@@ -982,7 +1014,7 @@
- 现象:生产发布、数据库导入导出、服务器配置、构建或 `Genarrative-Full-Build-And-Deploy` 流水线执行 `GitSCM checkout` 时,如果 Jenkins 生成的 fetch 是 `+refs/heads/*:refs/remotes/origin/*`,公网 Git 链路可能在收包阶段以 `git-remote-https died of signal 15``curl 56 GnuTLS recv error (-9)``early EOF``invalid index-pack output` 失败;发布类流水线还可能先遇到 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达。
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。即使只使用域名 Git如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`Jenkins Git 插件也会拉取所有分支。
- 处理Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback所有生产 Jenkinsfile 的首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,只有指定 commit 时才允许加深历史做分支归属校验。
- 处理Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 `linux && genarrative-build``Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` checkout 阶段,以及部署/发布类 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,只有指定 commit 时才允许加深历史做分支归属校验。
- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `<url>` 都是 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有生产 Jenkinsfile 的首次 `GitSCM checkout`,确认 `userRemoteConfigs``+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}``CloneOption``honorRefspec: true`;运行 `bash -n scripts/jenkins-checkout-source.sh`
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy``jenkins/Jenkinsfile.production-web-build``jenkins/Jenkinsfile.production-api-build``jenkins/Jenkinsfile.production-stdb-module-build``jenkins/Jenkinsfile.production-web-deploy``jenkins/Jenkinsfile.production-api-deploy``jenkins/Jenkinsfile.production-stdb-module-publish``jenkins/Jenkinsfile.production-server-provision``jenkins/Jenkinsfile.production-database-export``jenkins/Jenkinsfile.production-database-import``scripts/jenkins-checkout-source.sh``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`

View File

@@ -26,6 +26,10 @@ Use the default canonical triage labels: `needs-triage`, `needs-info`, `ready-fo
Single-context layout: read root `CONTEXT.md` when present. Current architecture and product constraints are consolidated under `docs/`.
### 新增玩法接入
- 凡是新增、补齐、迁移或重构任何玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 的任务,开始前必须显式读取并按 [$genarrative-play-type-integration](.codex\skills\genarrative-play-type-integration\SKILL.md) 执行;未先使用该 skill 的,不允许进入编码。
## 项目约束
- 代码需要有完善的中文注释
- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。

View File

@@ -20,7 +20,7 @@ server-rs + Axum + SpacetimeDB
- HTTP 服务:`api-server`
- 领域模块:`module-ai``module-assets``module-auth``module-bark-battle``module-big-fish``module-combat``module-creative-agent``module-custom-world``module-inventory``module-match3d``module-npc``module-progression``module-puzzle``module-quest``module-runtime``module-runtime-item``module-runtime-story``module-square-hole``module-story``module-visual-novel`
- 平台副作用:`platform-agent``platform-auth``platform-llm``platform-oss``platform-speech`
- 平台副作用:`platform-agent``platform-auth``platform-image``platform-llm``platform-oss``platform-speech`
- 共享层:`shared-contracts``shared-kernel``shared-logging`
- SpacetimeDB`spacetime-client``spacetime-module`
- 测试支撑:`tests-support`
@@ -98,6 +98,8 @@ npm run check:server-rs-ddd
该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。
`/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求应由前端在登录态下继续携带账号 access token匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。
抓大鹅 Match3D `api-server` 内部拆分:
- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit对外 handler 名称保持不变。
@@ -117,9 +119,10 @@ npm run check:server-rs-ddd
2. Adapter 输入应显式包含 provider、prompt、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source job、metadata 和可选透明背景后处理。
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-*` 仅作为旧请求兼容
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 字段
5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image``api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库
6. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口
7. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取
8. 系列素材图集使用 `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 变更规则
@@ -156,7 +159,7 @@ npm run check:server-rs-ddd
## 外部服务与资产
- LLM`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`
- 图片生成VectorEngine / APIMart / DashScope密钥只在后端环境变量中
- 图片生成VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底
- Match3D 物品 sheet关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2``2K 1:1` 输出 `10*10` spritesheet物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端固定从该 sheet 解析并持久化 20 个物品、每个 5 个形态;通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG背景图必须合成为全画幅不透明 PNG。
- Match3D 1:1 容器 UIVectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!``api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
@@ -164,7 +167,7 @@ npm run check:server-rs-ddd
- Hyper3D / Rodin只保留后端安全代理和旧数据兼容新 Match3D 草稿和批量新增不再生成 GLB。
- 音频:视觉小说专用音频路由保留;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`
- OSS私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`当前通用 VectorEngine `gpt-image-2-all` 图片生成 / 编辑适配器在 `request_send``response_body``upstream_status``response_parse``missing_image``image_download` 阶段失败时记录 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCountimageModel。入库优先复用 tracking outboxoutbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send``response_body``upstream_status``response_parse``missing_image``image_download` 阶段`api-server` 只把该 audit 映射成 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCountimageModel 和 rawExcerpt。入库优先复用 tracking outboxoutbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
## SpacetimeDB 表目录

View File

@@ -45,6 +45,8 @@ npm run dev:api-server
后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
开发态 `npm run dev``npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如:
@@ -59,7 +61,7 @@ spacetime sql <database> "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致。若版本错配procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml``spacetimedb = "..."`;需要切版本时执行 `spacetime version install <version> && spacetime version use <version>`,然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
本地 `.env``.env.local``.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若开局 CG 故事板在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 request_id 的 `request_body_bytes``reference_data_url_bytes``sourceChain``rootSource`;当前开局 CG 会把角色图与首幕背景图压到单边 768 的 JPEG 后再作为 generations `image` 数组发送,`/v1/images/generations` 使用默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
本地 `.env``.env.local``.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image``api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若开局 CG 故事板在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 request_id 的 `request_body_bytes``reference_data_url_bytes``sourceChain``rootSource`;当前开局 CG 会把角色图与首幕背景图压到单边 768 的 JPEG 后再作为 generations `image` 数组发送,`/v1/images/generations` 使用默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
查看本地 Rust / SpacetimeDB 日志:
@@ -142,6 +144,7 @@ Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`
后端代码修改后,按变更范围选择:
- `cargo test -p <crate> --manifest-path server-rs/Cargo.toml`
- `cargo test -p platform-image --manifest-path server-rs/Cargo.toml`
- `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`
- `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`
@@ -152,7 +155,7 @@ Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`
```bash
cargo check -p api-server --manifest-path server-rs/Cargo.toml
npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged out recommend page can enter runtime without login gate|logged out desktop recommend page renders runtime directly without login gate"
npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged out recommend tab enters runtime without login modal|logged out desktop recommend page renders runtime directly|logged out desktop recommend rail enters runtime without login modal"
```
涉及 SpacetimeDB schema 时必须补:
@@ -202,6 +205,8 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
`Genarrative-Web-Build` 的主站构建失败若出现 Rollup 报错 `"xxx" is not exported by "src/services/publicWorkCode.ts"`,优先按前端公开作品号工具缺失处理,而不是排查 Jenkins 节点环境。修复时要让 `publicWorkCode.ts``build<Play>PublicWorkCode``isSame<Play>PublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。
生产 Jenkins 的 `Pipeline script from SCM` 由 Windows controller 读取 JenkinsfileSCM URL 继续使用 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行在 `linux && genarrative-build` 构建机上的 `Genarrative-Full-Build-And-Deploy` 源码解析阶段和 `Genarrative-Web-Build` checkout 阶段,优先使用 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后回退到 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;两层 checkout 都必须保留单分支 refspec、`shallow=true``depth=1``noTags=true``honorRefspec=true`,后续二次源码确认继续走 `scripts/jenkins-checkout-source.sh`
`Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。
Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该流水线需要执行 PowerShell 逻辑时,统一通过 `bat` 显式调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,不要直接使用 Jenkins `powershell` step本地 Jenkins durable-task 曾在 `Genarrative-Stdb-Module-Build` workspace 中启动裸 `powershell` 时触发 `CreateProcess error=5, 拒绝访问`。临时 `.ps1` 由 Jenkins `writeFile` 写出后要先转成 UTF-8 with BOM 再交给 Windows PowerShell 5.1 `-File` 解析,避免中文错误消息在无 BOM UTF-8 下被当成本地 ANSI 误解码。Checkout 阶段要优先复用 Jenkins GitSCM 已经完成的结果:`COMMIT_HASH` 为空或与当前 `HEAD` 一致时,不要再额外 `git clean` / `git checkout`,只在确实需要切到别的指定 commit 时才补 fetch、校验和切换。排查时先看对应 build log、`@tmp/durable-*` 下的 `powershellWrapper.ps1`,以及日志中的 `[jenkins-powershell] user/exe`
@@ -219,7 +224,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 写入后的内存高水位。
@@ -248,7 +253,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs但本地日
- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。
- api-server 会随 metrics 发送进程级指标:`process.memory.usage``process.memory.virtual``process.cpu.time``genarrative.process.cpu.usage_percent``process.thread.count``genarrative.process.memory.private`Windows 额外发送 `process.windows.handle.count`Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight``genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2-all` 图片生成 / 编辑失败会输出 `外部 API 调用失败` trace/log记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`;同时写入 `tracking_event``event_key = external_api_call_failure``module_key = external-api``scope_kind = module``scope_id = provider`。排障时先按 provider / failureStage 聚合,再结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败`platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt`api-server`记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event``event_key = external_api_call_failure``module_key = external-api``scope_kind = module``scope_id = provider`。排障时先按 provider / failureStage 聚合,再结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
- SpacetimeDB 观测分为两类procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*``read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes请求完成日志会直接带 `request_id``http.request.method``http.route``url.scheme``url.path``http.response.status_code``status_class``latency_ms``slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。

View File

@@ -8,8 +8,12 @@
当前创作 Tab 只承载赛事 banner、玩法模板分类和两列模板卡点击模板卡后直接进入对应玩法已有的入口创作表单 stage不再经过空白占位页也不把旧表单嵌进创作 Tab 首屏。移动端创作 Tab 顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把活动奖池当作账号余额展示。首屏 banner 结构按参考图拆成横向可滑动赛事卡、主体宣传图文区、奖池胶囊、开始 / 结束时间条和卡片内分页点;轮播只保留 `拼图主题创作赛``抓大鹅主题创作赛`,两个主题赛事奖池均为 `1000` 泥点数。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作 Tab 根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作首屏字号需要对齐平台普通 UI 档位顶栏泥点组件、banner 正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px``14px`,不使用 `text-lg``text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace``big-fish-agent-workspace``match3d-agent-workspace``square-hole-agent-workspace``jump-hop-workspace``wooden-fish-workspace``puzzle-agent-workspace``bark-battle-workspace``visual-novel-agent-workspace``baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作 Tab 首屏内容。
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`
`platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recent` / `最近创作`,避免旧数据、局部 mock 或异常返回把创作入口初始化直接打崩。
移动端底部一级导航是全局平台样式,不按单一玩法分叉。当前视觉统一为米白浮动胶囊底座、浅棕分隔线、棕色线性图标、橘色选中态和底部短下划线;中间 `创作` 入口保持凸起圆形主按钮,但凸起位移只能作用在按钮内容层,不能移动承载分隔线的 Tab 按钮容器确保创作左右分隔线与其他分隔线垂直位置一致。Tab 名称和可见性仍由现有 `PlatformHomeTab` / 登录态规则决定,样式调整不得改写 Tab 文案或导航状态。
## 新增玩法创作工具平台 SOP
@@ -92,6 +96,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 结果页单关测试只能把完整草稿持久化,并通过 `levelId` 指定运行态起始关卡;不得把单关快照作为整份草稿调用 `updatePuzzleWork`,否则 source session 和作品 profile 的 `levels` 会被覆盖成单关,退出重进后其它关卡会丢失。
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回和设置按钮的点击容器只提供透明点击区,不再叠加默认白色圆形底;底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest推荐卡片的后台读写请求仍使用 local auth impact避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
@@ -124,7 +129,11 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `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/前端整条删除链路,再开放按钮。
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏``推荐``作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts``usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer但请求选项必须是 local auth impact避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token并只把它作为局部请求头传给运行态客户端不写入全局登录态、不触发 refresh也不把匿名流量伪装成普通用户。当前覆盖矩阵为跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求都必须继续按该身份分流公开读取入口仍可匿名读取创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
## 敲木鱼

View File

@@ -12,7 +12,8 @@ pipeline {
}
environment {
GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
}
parameters {
@@ -42,6 +43,8 @@ pipeline {
label 'linux && genarrative-build'
}
steps {
script {
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
@@ -50,15 +53,26 @@ pipeline {
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}", refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'

View File

@@ -10,7 +10,8 @@ pipeline {
}
environment {
GIT_REMOTE_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git'
WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts'
}
@@ -29,6 +30,8 @@ pipeline {
stages {
stage('Checkout') {
steps {
script {
def checkoutFromRemote = { String remoteUrl ->
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
@@ -37,15 +40,26 @@ pipeline {
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}", refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
])
}
try {
checkoutFromRemote(env.GIT_REMOTE_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
} catch (error) {
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
}
}
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'

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;

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 {

View File

@@ -958,16 +958,11 @@ class DevRunner {
async startApiServer(service) {
await this.ensureApiServerSpacetimeToken();
const mergedEnv = {
...this.baseEnv,
GENARRATIVE_API_HOST: this.options.apiHost,
GENARRATIVE_API_PORT: String(this.options.apiPort),
GENARRATIVE_API_LOG: this.options.apiLog,
GENARRATIVE_SPACETIME_SERVER_URL: this.state.spacetimeServer,
GENARRATIVE_SPACETIME_DATABASE: this.options.database,
GENARRATIVE_SPACETIME_TOKEN:
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
};
const mergedEnv = buildApiServerProcessEnv({
baseEnv: this.baseEnv,
options: this.options,
state: this.state,
});
const logFile = resolveApiServerLogFile(repoRoot, mergedEnv);
ensureParentDir(logFile);
@@ -1717,10 +1712,25 @@ function isSpacetimePublishPermissionError(error) {
);
}
function buildApiServerProcessEnv({baseEnv, options, state}) {
return {
...baseEnv,
// 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true',
GENARRATIVE_API_HOST: options.apiHost,
GENARRATIVE_API_PORT: String(options.apiPort),
GENARRATIVE_API_LOG: options.apiLog,
GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer,
GENARRATIVE_SPACETIME_DATABASE: options.database,
GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
};
}
export {
DevRunner,
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,

View File

@@ -8,6 +8,7 @@ import {
DevRunner,
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
@@ -89,6 +90,21 @@ describe('dev scheduler argument routing', () => {
});
});
describe('dev scheduler api-server env', () => {
test('dev 脚本默认打开密码入口自动注册', () => {
const {options} = parseArgs(['api-server', '--api-port', '9091'], {});
const env = buildApiServerProcessEnv({
baseEnv: {},
options,
state: {spacetimeServer: 'http://127.0.0.1:3199'},
});
expect(env.GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED).toBe('true');
expect(env.GENARRATIVE_API_PORT).toBe('9091');
expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199');
});
});
describe('dev scheduler spacetime reuse guard', () => {
test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));

12
server-rs/Cargo.lock generated
View File

@@ -108,6 +108,7 @@ dependencies = [
"opentelemetry",
"platform-agent",
"platform-auth",
"platform-image",
"platform-llm",
"platform-oss",
"platform-speech",
@@ -2321,6 +2322,17 @@ dependencies = [
"urlencoding",
]
[[package]]
name = "platform-image"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"reqwest 0.12.28",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "platform-llm"
version = "0.1.0"

View File

@@ -32,6 +32,7 @@ members = [
"crates/module-visual-novel",
"crates/platform-oss",
"crates/platform-auth",
"crates/platform-image",
"crates/platform-llm",
"crates/platform-speech",
"crates/platform-agent",
@@ -74,6 +75,7 @@ module-story = { path = "crates/module-story", default-features = false }
module-visual-novel = { path = "crates/module-visual-novel", default-features = false }
platform-agent = { path = "crates/platform-agent", default-features = false }
platform-auth = { path = "crates/platform-auth", default-features = false }
platform-image = { path = "crates/platform-image", default-features = false }
platform-llm = { path = "crates/platform-llm", default-features = false }
platform-oss = { path = "crates/platform-oss", default-features = false }
platform-speech = { path = "crates/platform-speech", default-features = false }

View File

@@ -34,6 +34,7 @@ module-story = { workspace = true }
module-visual-novel = { workspace = true }
platform-agent = { workspace = true }
platform-auth = { workspace = true }
platform-image = { workspace = true }
platform-llm = { workspace = true }
platform-oss = { workspace = true }
platform-speech = { workspace = true }

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,30 +146,71 @@ 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 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(authenticated);
return Ok(response);
response.extensions_mut().insert(principal);
Ok(response)
}
Ok(next.run(request).await)
fn authenticate_runtime_principal(
state: &AppState,
request: &Request,
) -> Result<Option<RuntimePrincipal>, AppError> {
if !request.headers().contains_key(AUTHORIZATION) {
return Ok(None);
}
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())
{
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) {
return Ok(None);

View File

@@ -1,4 +1,5 @@
use axum::http::StatusCode;
use platform_image::PlatformImageFailureAudit;
use module_runtime::RuntimeTrackingScopeKind;
use serde_json::{Value, json};
use time::OffsetDateTime;
@@ -109,6 +110,28 @@ impl ExternalApiFailureDraft {
}
}
pub(crate) fn build_external_api_failure_draft_from_platform_image_audit(
audit: &PlatformImageFailureAudit,
) -> ExternalApiFailureDraft {
ExternalApiFailureDraft::new(
audit.provider,
audit.endpoint.clone(),
audit.operation.clone(),
audit.failure_stage,
audit.error_message.clone(),
)
.with_status_code(audit.status_code)
.with_optional_status_class(audit.status_class)
.with_timeout(audit.timeout)
.with_retryable(audit.retryable)
.with_error_source(audit.error_source.clone())
.with_raw_excerpt(audit.raw_excerpt.clone())
.with_latency_ms(audit.latency_ms)
.with_prompt_chars(audit.prompt_chars)
.with_reference_image_count(audit.reference_image_count)
.with_image_model(audit.image_model)
}
/// 中文注释下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。
pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str {
status_class(Some(status_code.as_u16()))

View File

@@ -113,6 +113,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"),
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
StatusCode::GATEWAY_TIMEOUT => ("GATEWAY_TIMEOUT", "上游服务请求超时"),
StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"),
StatusCode::SERVICE_UNAVAILABLE => ("SERVICE_UNAVAILABLE", "服务暂不可用"),
_ if status_code.is_client_error() => ("BAD_REQUEST", "请求参数不合法"),

View File

@@ -4,33 +4,53 @@ use axum::{
http::{HeaderName, StatusCode, header},
response::Response,
};
use module_assets::{
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde_json::{Value, json};
use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopDraftResponse, JumpHopGalleryDetailResponse,
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopRestartRunRequest,
JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse,
JumpHopStartRunRequest, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType,
JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse,
JumpHopWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
http_error::AppError,
generated_asset_sheets::{
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
slice_generated_asset_sheet,
},
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
normalize_generated_image_asset_mime,
},
openai_image_generation::{
build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
work_play_tracking::{record_work_play_start_after_success, WorkPlayTrackingDraft},
};
const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = ["start", "normal", "target", "finish", "bonus", "accent"];
const JUMP_HOP_PROVIDER: &str = "jump-hop";
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
const JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID: &str = "anonymous-runtime";
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
pub async fn create_jump_hop_session(
@@ -109,6 +129,15 @@ pub async fn execute_jump_hop_action(
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let mut payload = payload;
maybe_generate_jump_hop_assets(
&state,
&request_context,
session_id.as_str(),
owner_user_id.as_str(),
&mut payload,
)
.await?;
let response = state
.spacetime_client()
.execute_jump_hop_action(session_id, owner_user_id, payload)
@@ -149,6 +178,31 @@ pub async fn publish_jump_hop_work(
))
}
pub async fn list_jump_hop_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let works = state
.spacetime_client()
.list_jump_hop_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
JumpHopWorksResponse {
items: works.into_iter().map(|work| work.summary).collect(),
},
))
}
pub async fn get_jump_hop_runtime_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -176,15 +230,13 @@ pub async fn get_jump_hop_runtime_work(
pub async fn start_jump_hop_run(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<JumpHopStartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
let authenticated = maybe_authenticated.as_ref().map(|Extension(authenticated)| authenticated);
let owner_user_id = authenticated
.map(|authenticated| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let owner_user_id = principal.subject().to_string();
let principal_kind = principal.kind().as_str();
let run = state
.spacetime_client()
.start_jump_hop_run(payload, owner_user_id.clone())
@@ -201,7 +253,7 @@ pub async fn start_jump_hop_run(
&state,
&request_context,
build_jump_hop_work_play_tracking_draft(
authenticated,
&principal,
run.profile_id.clone(),
JUMP_HOP_RUNTIME_RUNS_ROUTE,
)
@@ -210,7 +262,7 @@ pub async fn start_jump_hop_run(
.profile_id(run.profile_id.clone())
.extra(json!({
"runStatus": run.status,
"isAnonymous": maybe_authenticated.is_none(),
"principalKind": principal_kind,
})),
)
.await;
@@ -225,15 +277,12 @@ pub async fn jump_hop_run_jump(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<JumpHopJumpRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
let owner_user_id = maybe_authenticated
.as_ref()
.map(|Extension(authenticated)| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let owner_user_id = principal.subject().to_string();
let run = state
.spacetime_client()
.jump_hop_run_jump(run_id, owner_user_id, payload)
@@ -256,15 +305,12 @@ pub async fn restart_jump_hop_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<JumpHopRestartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
let owner_user_id = maybe_authenticated
.as_ref()
.map(|Extension(authenticated)| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let owner_user_id = principal.subject().to_string();
let run = state
.spacetime_client()
.restart_jump_hop_run(run_id, owner_user_id, payload)
@@ -326,19 +372,344 @@ pub async fn get_jump_hop_gallery_detail(
))
}
async fn maybe_generate_jump_hop_assets(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
payload: &mut JumpHopActionRequest,
) -> Result<(), Response> {
if !matches!(payload.action_type, JumpHopActionType::CompileDraft) {
return Ok(());
}
if payload.character_asset.is_some()
&& payload.tile_atlas_asset.is_some()
&& payload.tile_assets.as_ref().is_some_and(|assets| !assets.is_empty())
{
return Ok(());
}
let profile_id = payload
.profile_id
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-"));
payload.profile_id = Some(profile_id.clone());
let settings = require_openai_image_settings(state)
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let http_client = build_openai_image_http_client(&settings)
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let character_prompt = payload
.character_prompt
.as_deref()
.unwrap_or("俯视角可爱主角,透明背景");
let tile_prompt = payload
.tile_prompt
.as_deref()
.unwrap_or("等距立体地块图集");
let character_generated = create_openai_image_generation(
&http_client,
&settings,
character_prompt,
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
"1024*1024",
1,
&[],
"跳一跳角色资产生成失败",
)
.await
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let character_image = character_generated.images.into_iter().next().ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "跳一跳角色资产生成成功但未返回图片。",
})),
)
})?;
let character_asset = persist_jump_hop_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"character",
character_prompt,
character_image,
LegacyAssetPrefix::JumpHopAssets,
768,
768,
request_context,
)
.await?;
let sheet_prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
subject_text: tile_prompt,
item_names: &vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
grid_size: 3,
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视图"),
special_prompt: Some("每个格子对应一个 tile 类型,供跳一跳地块裁切使用。"),
})
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let tile_generated = create_openai_image_generation(
&http_client,
&settings,
sheet_prompt.as_str(),
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
"1024*1024",
1,
&[],
"跳一跳地块图集生成失败",
)
.await
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "跳一跳地块图集生成成功但未返回图片。",
})),
)
})?;
let tile_slices = slice_generated_asset_sheet(
&tile_image,
&vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
3,
)
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"tile-atlas",
tile_prompt,
tile_image,
LegacyAssetPrefix::JumpHopAssets,
1024,
1024,
request_context,
)
.await?;
let tile_assets = tile_slices
.into_iter()
.enumerate()
.map(|(index, row)| JumpHopTileAsset {
tile_type: match index {
0 => JumpHopTileType::Start,
1 => JumpHopTileType::Normal,
2 => JumpHopTileType::Target,
3 => JumpHopTileType::Finish,
4 => JumpHopTileType::Bonus,
_ => JumpHopTileType::Accent,
},
image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
asset_object_id: format!("{profile_id}-tile-{index}-object"),
source_atlas_cell: format!("cell-{index}"),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
landing_radius: 34.0,
})
.collect::<Vec<_>>();
payload.character_asset = Some(character_asset);
payload.tile_atlas_asset = Some(tile_atlas_asset);
payload.tile_assets = Some(tile_assets);
payload.cover_composite = payload
.cover_composite
.clone()
.or_else(|| Some(format!("/generated-jump-hop-assets/{profile_id}/cover-composite.png")));
Ok(())
}
async fn persist_jump_hop_generated_image_asset(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
slot: &str,
prompt: &str,
image: crate::openai_image_generation::DownloadedOpenAiImage,
prefix: LegacyAssetPrefix,
width: u32,
height: u32,
request_context: &RequestContext,
) -> Result<JumpHopCharacterAsset, Response> {
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix,
path_segments: vec![profile_id.to_string(), slot.to_string()],
file_stem: "image".to_string(),
image: GeneratedImageAssetDataUrl {
format: image_format,
bytes: image.bytes,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(format!("jump-hop-{slot}")),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some("jump_hop_work".to_string()),
entity_id: Some(profile_id.to_string()),
slot: Some(slot.to_string()),
provider: Some("vector-engine".to_string()),
task_id: None,
},
extra_metadata: BTreeMap::new(),
})
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备跳一跳图片资产上传请求失败:{error:?}"),
})),
)
})?;
let persisted_mime_type = prepared.format.mime_type.clone();
let oss_client = state.oss_client().ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
})),
)
})?;
let http_client = reqwest::Client::new();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
)
})?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
)
})?;
let now_micros = current_utc_micros();
let asset_object_input = build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key.clone(),
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
format!("jump-hop-{slot}"),
None,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
Some(profile_id.to_string()),
now_micros,
)
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
})),
)
})?;
let asset_object = state
.spacetime_client()
.confirm_asset_object(asset_object_input)
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
})),
)
})?;
let binding_input = build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id.clone(),
"jump_hop_work".to_string(),
profile_id.to_string(),
slot.to_string(),
format!("jump-hop-{slot}"),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
now_micros,
)
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
})),
)
})?;
state
.spacetime_client()
.bind_asset_object_to_entity(binding_input)
.await
.map_err(|error| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
})),
)
})?;
Ok(JumpHopCharacterAsset {
asset_id: format!("{profile_id}-{slot}-{now_micros}"),
image_src: put_result.legacy_public_path,
image_object_key: head.object_key,
asset_object_id: asset_object.asset_object_id,
generation_provider: "vector-engine".to_string(),
prompt: prompt.to_string(),
width,
height,
})
}
fn build_jump_hop_work_play_tracking_draft(
authenticated: Option<&AuthenticatedAccessToken>,
principal: &RuntimePrincipal,
work_id: impl Into<String>,
source_route: &'static str,
) -> WorkPlayTrackingDraft {
match authenticated {
Some(authenticated) => {
WorkPlayTrackingDraft::new("jump-hop", work_id, authenticated, source_route)
}
None => WorkPlayTrackingDraft::anonymous("jump-hop", work_id, source_route),
}
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
}
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),

View File

@@ -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))

View File

@@ -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))

View File

@@ -6,7 +6,7 @@ use axum::{
};
use crate::{
auth::require_bearer_auth,
auth::{require_bearer_auth, require_runtime_principal_auth},
puzzle::{
advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session,
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
@@ -130,56 +130,56 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/runtime/puzzle/runs",
post(start_puzzle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}",
get(get_puzzle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/swap",
post(swap_puzzle_pieces).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/drag",
post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/next-level",
post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/pause",
post(update_puzzle_run_pause).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/props",
post(use_puzzle_runtime_prop).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/leaderboard",
post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.with_state(PuzzleApiState::from_ref(&state))

View File

@@ -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(

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
use std::{
collections::BTreeMap,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
time::{Instant, SystemTime, UNIX_EPOCH},
};
use axum::{
@@ -76,7 +76,7 @@ use crate::{
execute_billable_asset_operation, execute_billable_asset_operation_with_cost,
should_skip_asset_operation_billing_for_connectivity,
},
auth::AuthenticatedAccessToken,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
http_error::AppError,
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
@@ -103,7 +103,7 @@ use crate::{
},
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
request_context::RequestContext,
state::PuzzleApiState,
state::{AppState, PuzzleApiState},
work_author::resolve_puzzle_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
};
@@ -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元素、移除拼图画面仅保留背景图补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容";

View File

@@ -1666,7 +1666,7 @@ pub async fn remix_puzzle_gallery_work(
pub async fn start_puzzle_run(
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<StartPuzzleRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1690,7 +1690,7 @@ pub async fn start_puzzle_run(
.spacetime_client()
.start_puzzle_run(PuzzleRunStartRecordInput {
run_id: build_prefixed_uuid_id("puzzle-run-"),
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
profile_id: payload.profile_id.clone(),
level_id: payload.level_id.clone(),
started_at_micros: current_utc_micros(),
@@ -1707,16 +1707,18 @@ pub async fn start_puzzle_run(
record_puzzle_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
WorkPlayTrackingDraft::runtime_principal(
"puzzle",
payload.profile_id.clone(),
&authenticated,
&principal,
"/api/runtime/puzzle/...",
)
.profile_id(payload.profile_id.clone())
.owner_user_id(principal.subject().to_string())
.extra(json!({
"levelId": payload.level_id,
"runId": run.run_id,
"principalKind": principal.kind().as_str(),
})),
)
.await;
@@ -1733,13 +1735,13 @@ pub async fn get_puzzle_run(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_puzzle_run(run_id, authenticated.claims().user_id().to_string())
.get_puzzle_run(run_id, principal.subject().to_string())
.await
.map_err(|error| {
puzzle_error_response(
@@ -1761,7 +1763,7 @@ pub async fn swap_puzzle_pieces(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<SwapPuzzlePiecesRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1792,7 +1794,7 @@ pub async fn swap_puzzle_pieces(
.spacetime_client()
.swap_puzzle_pieces(PuzzleRunSwapRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
first_piece_id: payload.first_piece_id,
second_piece_id: payload.second_piece_id,
swapped_at_micros: current_utc_micros(),
@@ -1818,7 +1820,7 @@ pub async fn drag_puzzle_piece_or_group(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<DragPuzzlePieceRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1843,7 +1845,7 @@ pub async fn drag_puzzle_piece_or_group(
.spacetime_client()
.drag_puzzle_piece_or_group(PuzzleRunDragRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
piece_id: payload.piece_id,
target_row: payload.target_row,
target_col: payload.target_col,
@@ -1870,7 +1872,7 @@ pub async fn advance_puzzle_next_level(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<AdvancePuzzleNextLevelRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
@@ -1897,7 +1899,7 @@ pub async fn advance_puzzle_next_level(
.spacetime_client()
.advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
target_profile_id: payload.target_profile_id,
advanced_at_micros: current_utc_micros(),
})
@@ -1922,7 +1924,7 @@ pub async fn update_puzzle_run_pause(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<UpdatePuzzleRuntimePauseRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1941,7 +1943,7 @@ pub async fn update_puzzle_run_pause(
.spacetime_client()
.update_puzzle_run_pause(PuzzleRunPauseRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
paused: payload.paused,
updated_at_micros: current_utc_micros(),
})
@@ -1966,7 +1968,7 @@ pub async fn use_puzzle_runtime_prop(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<UsePuzzleRuntimePropRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1987,7 +1989,7 @@ pub async fn use_puzzle_runtime_prop(
"propKind",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let owner_user_id = principal.subject().to_string();
let prop_kind = payload.prop_kind.trim().to_string();
let billing_asset_kind = match prop_kind.as_str() {
"hint" => "puzzle_prop_hint",
@@ -2064,7 +2066,7 @@ pub async fn submit_puzzle_leaderboard(
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<SubmitPuzzleLeaderboardRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -2084,7 +2086,7 @@ pub async fn submit_puzzle_leaderboard(
.spacetime_client()
.submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id: principal.subject().to_string(),
profile_id: payload.profile_id,
grid_size: payload.grid_size,
elapsed_ms: payload.elapsed_ms.max(1_000),

View File

@@ -1,5 +1,7 @@
use super::*;
use crate::openai_image_generation::GPT_IMAGE_2_MODEL;
use crate::openai_image_generation::{GPT_IMAGE_2_MODEL, map_platform_image_error};
use platform_image::{PlatformImageError, VECTOR_ENGINE_PROVIDER};
use std::time::Duration;
#[test]
fn puzzle_generated_image_size_is_square_1_1() {
@@ -218,45 +220,6 @@ fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() {
assert!(body.get("image").is_none());
}
#[test]
fn puzzle_vector_engine_generation_url_normalizes_base_url() {
let settings = PuzzleVectorEngineSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
};
assert_eq!(
puzzle_vector_engine_images_generation_url(&settings),
"https://vector.example/v1/images/generations"
);
}
#[test]
fn puzzle_vector_engine_edit_url_normalizes_base_url() {
let settings = PuzzleVectorEngineSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
};
assert_eq!(
puzzle_vector_engine_images_edit_url(&settings),
"https://vector.example/v1/images/edits"
);
}
#[test]
fn puzzle_vector_engine_edit_response_decodes_b64_image() {
let images = puzzle_images_from_base64(
"edit-1".to_string(),
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
1,
);
assert_eq!(images.images.len(), 1);
assert_eq!(images.images[0].mime_type, "image/png");
assert_eq!(images.images[0].extension, "png");
}
#[test]
fn puzzle_vector_engine_prompt_strongly_uses_reference_image() {
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true);
@@ -379,9 +342,18 @@ fn puzzle_asset_object_reference_requires_matching_owner() {
#[test]
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_request_error(
"创建拼图 VectorEngine 图片生成任务失败operation timed out".to_string(),
);
let error = map_platform_image_error(PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
message: "创建拼图 VectorEngine 图片生成任务失败operation timed out".to_string(),
endpoint: Some("https://vector.example/v1/images/generations".to_string()),
timeout: true,
connect: false,
request: true,
body: false,
status_code: None,
source: None,
audit: None,
});
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
@@ -389,11 +361,14 @@ fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
#[test]
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片生成任务失败",
);
let error = map_platform_image_error(PlatformImageError::Upstream {
provider: VECTOR_ENGINE_PROVIDER,
message: "VectorEngine generation endpoint timeout".to_string(),
upstream_status: reqwest::StatusCode::GATEWAY_TIMEOUT.as_u16(),
raw_excerpt: r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#
.to_string(),
audit: None,
});
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);

View File

@@ -1,4 +1,7 @@
use super::*;
use crate::openai_image_generation::{
OpenAiReferenceImage, create_openai_image_edit_with_references,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PuzzleImageModel {
@@ -26,6 +29,8 @@ impl PuzzleImageModel {
pub(crate) struct PuzzleVectorEngineSettings {
pub(crate) base_url: String,
pub(crate) api_key: String,
pub(crate) request_timeout_ms: u64,
pub(crate) external_api_audit_state: Option<AppState>,
}
pub(crate) struct PuzzleGeneratedImages {
@@ -78,6 +83,25 @@ impl PuzzleDownloadedImage {
bytes: image.bytes,
}
}
pub(crate) fn from_openai_image(image: DownloadedOpenAiImage) -> Self {
Self {
extension: image.extension,
mime_type: normalize_puzzle_downloaded_image_mime_type(image.mime_type.as_str()),
bytes: image.bytes,
}
}
}
impl PuzzleVectorEngineSettings {
fn to_openai_settings(&self) -> crate::openai_image_generation::OpenAiImageSettings {
crate::openai_image_generation::OpenAiImageSettings {
base_url: self.base_url.clone(),
api_key: self.api_key.clone(),
request_timeout_ms: self.request_timeout_ms,
external_api_audit_state: self.external_api_audit_state.clone(),
}
}
}
pub(crate) struct ParsedPuzzleImageDataUrl {
@@ -151,27 +175,18 @@ pub(crate) fn require_puzzle_vector_engine_settings(
Ok(PuzzleVectorEngineSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.vector_engine_image_request_timeout_ms().max(1),
external_api_audit_state: Some(state.root_state().clone()),
})
}
pub(crate) fn build_puzzle_image_http_client(
state: &PuzzleApiState,
image_model: PuzzleImageModel,
_image_model: PuzzleImageModel,
) -> Result<reqwest::Client, AppError> {
let provider = image_model.provider_name();
let request_timeout_ms = state.vector_engine_image_request_timeout_ms();
let settings = require_puzzle_vector_engine_settings(state)?;
reqwest::Client::builder()
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
// 中文注释:参考图走 multipart edits强制 HTTP/1.1 可降低部分网关对长耗时上传流的兼容风险。
.http1_only()
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": provider,
"message": format!("构造拼图图片生成 HTTP 客户端失败:{error}"),
}))
})
build_openai_image_http_client(&settings.to_openai_settings())
}
pub(crate) fn to_puzzle_generated_image_candidate(
@@ -213,198 +228,66 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
.await;
}
let request_body = build_puzzle_vector_engine_image_request_body(
image_model,
prompt,
negative_prompt,
size,
candidate_count,
reference_image,
);
let request_url = puzzle_vector_engine_images_generation_url(settings);
let request_started_at = Instant::now();
let response = http_client
.post(request_url.as_str())
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
.await
.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"创建拼图 VectorEngine 图片生成任务失败:{error}"
))
})?;
let status = response.status();
let upstream_elapsed_ms = request_started_at.elapsed().as_millis() as u64;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = image_model.request_model_name(),
endpoint = %request_url,
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
size,
has_reference_image = reference_image.is_some(),
elapsed_ms = upstream_elapsed_ms,
"拼图 VectorEngine 图片生成 HTTP 返回"
);
let response_text = response.text().await.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"读取拼图 VectorEngine 图片生成响应失败:{error}"
))
})?;
if !status.is_success() {
return Err(map_puzzle_vector_engine_upstream_error(
status,
response_text.as_str(),
"创建拼图 VectorEngine 图片生成任务失败",
));
}
let payload = parse_puzzle_json_payload(
response_text.as_str(),
"解析拼图 VectorEngine 图片生成响应失败",
)?;
let image_urls = extract_puzzle_image_urls(&payload);
if !image_urls.is_empty() {
let download_started_at = Instant::now();
let images = download_puzzle_images_from_urls(
let generated = create_openai_image_generation(
http_client,
format!("vector-engine-{}", current_utc_micros()),
image_urls,
&settings.to_openai_settings(),
prompt,
Some(negative_prompt),
size,
candidate_count,
&[],
"拼图 VectorEngine 图片生成失败",
)
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = image_model.request_model_name(),
image_count = images.images.len(),
elapsed_ms = download_started_at.elapsed().as_millis() as u64,
"拼图 VectorEngine 图片下载完成"
);
return Ok(images);
}
let b64_images = extract_puzzle_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(puzzle_images_from_base64(
format!("vector-engine-{}", current_utc_micros()),
b64_images,
candidate_count,
));
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图 VectorEngine 图片生成未返回图片地址",
})),
)
Ok(PuzzleGeneratedImages {
task_id: generated.task_id,
images: generated
.images
.into_iter()
.map(PuzzleDownloadedImage::from_openai_image)
.collect(),
})
}
pub(crate) async fn create_puzzle_vector_engine_image_edit(
http_client: &reqwest::Client,
settings: &PuzzleVectorEngineSettings,
image_model: PuzzleImageModel,
_image_model: PuzzleImageModel,
prompt: &str,
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: &PuzzleResolvedReferenceImage,
) -> Result<PuzzleGeneratedImages, AppError> {
let request_url = puzzle_vector_engine_images_edit_url(settings);
let task_id = format!("vector-engine-edit-{}", current_utc_micros());
let file_name = format!(
"puzzle-reference.{}",
puzzle_mime_to_extension(reference_image.mime_type.as_str())
);
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
.file_name(file_name)
.mime_str(reference_image.mime_type.as_str())
.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"构造拼图 VectorEngine 图片编辑参考图失败:{error}"
))
})?;
let form = reqwest::multipart::Form::new()
.part("image", image_part)
.text("model", image_model.request_model_name().to_string())
.text(
"prompt",
build_puzzle_vector_engine_prompt(prompt, negative_prompt),
)
.text("n", candidate_count.clamp(1, 1).to_string())
.text("size", size.to_string());
let request_started_at = Instant::now();
let response = http_client
.post(request_url.as_str())
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::ACCEPT, "application/json")
.multipart(form)
.send()
.await
.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"创建拼图 VectorEngine 图片编辑任务失败:{error}"
))
})?;
let status = response.status();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = image_model.request_model_name(),
endpoint = %request_url,
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
let generated = create_openai_image_edit_with_references(
http_client,
&settings.to_openai_settings(),
prompt,
Some(negative_prompt),
size,
reference_mime = %reference_image.mime_type,
reference_bytes = reference_image.bytes_len,
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
"拼图 VectorEngine 图片编辑 HTTP 返回"
);
let response_text = response.text().await.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"读取拼图 VectorEngine 图片编辑响应失败:{error}"
))
})?;
if !status.is_success() {
return Err(map_puzzle_vector_engine_upstream_error(
status,
response_text.as_str(),
"创建拼图 VectorEngine 图片编辑任务失败",
));
}
let payload = parse_puzzle_json_payload(
response_text.as_str(),
"解析拼图 VectorEngine 图片编辑响应失败",
)?;
let image_urls = extract_puzzle_image_urls(&payload);
if !image_urls.is_empty() {
return download_puzzle_images_from_urls(http_client, task_id, image_urls, candidate_count)
.await;
}
let b64_images = extract_puzzle_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(puzzle_images_from_base64(
task_id,
b64_images,
candidate_count,
));
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图 VectorEngine 图片编辑未返回图片",
})),
&[OpenAiReferenceImage {
bytes: reference_image.bytes.clone(),
mime_type: reference_image.mime_type.clone(),
file_name,
}],
"拼图 VectorEngine 图片编辑失败",
)
.await?;
Ok(PuzzleGeneratedImages {
task_id: generated.task_id,
images: generated
.images
.into_iter()
.map(PuzzleDownloadedImage::from_openai_image)
.collect(),
})
}
pub(crate) fn build_puzzle_downloaded_image_reference(
@@ -569,42 +452,6 @@ pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &
format!("{prompt}\n避免:{negative_prompt}")
}
pub(crate) fn puzzle_vector_engine_images_generation_url(
settings: &PuzzleVectorEngineSettings,
) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/generations", settings.base_url)
} else {
format!("{}/v1/images/generations", settings.base_url)
}
}
pub(crate) fn puzzle_vector_engine_images_edit_url(
settings: &PuzzleVectorEngineSettings,
) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/edits", settings.base_url)
} else {
format!("{}/v1/images/edits", settings.base_url)
}
}
pub(crate) async fn download_puzzle_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
image_urls: Vec<String>,
candidate_count: u32,
) -> Result<PuzzleGeneratedImages, AppError> {
let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 1) as usize)
{
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
}
Ok(PuzzleGeneratedImages { task_id, images })
}
pub(crate) fn parse_puzzle_asset_object_reference(source: &str) -> Option<&str> {
source
.trim()
@@ -643,15 +490,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!({
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图过大,请压缩后重试。",
"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 +648,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!({
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产大小不符合拼图生成要求。",
"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)
@@ -892,40 +737,6 @@ async fn download_signed_puzzle_reference_image(
})
}
pub(crate) async fn download_puzzle_remote_image(
http_client: &reqwest::Client,
image_url: &str,
) -> Result<PuzzleDownloadedImage, AppError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
map_puzzle_image_request_error(format!("下载拼图正式图片失败:{error}"))
})?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let bytes = response.bytes().await.map_err(|error| {
map_puzzle_image_request_error(format!("读取拼图正式图片内容失败:{error}"))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "puzzle-image",
"message": "下载拼图正式图片失败",
"status": status.as_u16(),
})),
);
}
let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str());
Ok(PuzzleDownloadedImage {
extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes: bytes.to_vec(),
})
}
pub(crate) async fn persist_puzzle_generated_asset(
state: &PuzzleApiState,
owner_user_id: &str,
@@ -1199,18 +1010,6 @@ pub(crate) fn build_puzzle_level_asset_metadata(
])
}
pub(crate) fn parse_puzzle_json_payload(
raw_text: &str,
fallback_message: &str,
) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{fallback_message}{error}"),
}))
})
}
pub(crate) fn parse_puzzle_image_data_url(value: &str) -> Option<ParsedPuzzleImageDataUrl> {
let body = value.strip_prefix("data:")?;
let (mime_type, data) = body.split_once(";base64,")?;
@@ -1251,49 +1050,6 @@ pub(crate) fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
Some(output)
}
pub(crate) fn extract_puzzle_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_puzzle_strings_by_key(payload, "image", &mut urls);
collect_puzzle_strings_by_key(payload, "url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
pub(crate) fn extract_puzzle_b64_images(payload: &Value) -> Vec<String> {
let mut values = Vec::new();
collect_puzzle_strings_by_key(payload, "b64_json", &mut values);
values
}
pub(crate) fn puzzle_images_from_base64(
task_id: String,
b64_images: Vec<String>,
candidate_count: u32,
) -> PuzzleGeneratedImages {
let images = b64_images
.into_iter()
.take(candidate_count.clamp(1, 1) as usize)
.filter_map(|raw| decode_puzzle_generated_image_base64(raw.as_str()))
.collect();
PuzzleGeneratedImages { task_id, images }
}
pub(crate) fn decode_puzzle_generated_image_base64(raw: &str) -> Option<PuzzleDownloadedImage> {
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
let mime_type = infer_puzzle_image_mime_type(bytes.as_slice());
Some(PuzzleDownloadedImage {
extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes,
})
}
pub(crate) fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_puzzle_strings_by_key(payload, target_key, &mut results);
@@ -1335,22 +1091,6 @@ pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec<St
}
}
pub(crate) fn infer_puzzle_image_mime_type(bytes: &[u8]) -> String {
if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
return "image/png".to_string();
}
if bytes.starts_with(b"\xFF\xD8\xFF") {
return "image/jpeg".to_string();
}
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
return "image/webp".to_string();
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return "image/gif".to_string();
}
"image/png".to_string()
}
pub(crate) fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
@@ -1389,21 +1129,6 @@ pub(crate) fn map_puzzle_image_request_error(message: String) -> AppError {
}))
}
pub(crate) fn map_puzzle_vector_engine_request_error(message: String) -> AppError {
let is_timeout = is_puzzle_request_timeout_message(message.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
"timeout": is_timeout,
}))
}
pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("timed out")
@@ -1412,64 +1137,6 @@ pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool {
|| lower.contains("deadline has elapsed")
}
pub(crate) fn map_puzzle_vector_engine_upstream_error(
upstream_status: reqwest::StatusCode,
raw_text: &str,
fallback_message: &str,
) -> AppError {
let message = parse_puzzle_api_error_message(raw_text, fallback_message);
let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800);
let is_timeout = is_puzzle_request_timeout_message(message.as_str())
|| is_puzzle_request_timeout_message(raw_excerpt.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
upstream_status = upstream_status.as_u16(),
timeout = is_timeout,
message = %message,
raw_excerpt = %raw_excerpt,
"拼图 VectorEngine 上游请求失败"
);
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"upstreamStatus": upstream_status.as_u16(),
"message": message,
"rawExcerpt": raw_excerpt,
"timeout": is_timeout,
}))
}
pub(crate) fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> String {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return fallback_message.to_string();
}
if let Ok(payload) = serde_json::from_str::<Value>(trimmed)
&& let Some(message) = find_first_puzzle_string_by_key(&payload, "message")
{
return message;
}
fallback_message.to_string()
}
pub(crate) fn trim_puzzle_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
let normalized = raw_text.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.chars().count() <= max_chars {
return normalized;
}
let keep_chars = max_chars.saturating_sub(3);
format!(
"{}...",
normalized.chars().take(keep_chars).collect::<String>()
)
}
pub(crate) fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}

View File

@@ -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

View File

@@ -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(

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();

View File

@@ -0,0 +1,12 @@
[package]
name = "platform-image"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
base64 = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["time"] }
tracing = { workspace = true }

File diff suppressed because it is too large Load Diff

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",

View File

@@ -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 {

View File

@@ -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)]

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,23 +591,62 @@ fn merge_action_into_draft(
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
) && let Some(value) = payload
) {
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
) {
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()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
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(
"jump-hop work_title 不能为空",
@@ -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,9 +824,11 @@ fn ensure_character_asset(
force_new: bool,
now_micros: i64,
) -> JumpHopCharacterAsset {
if !force_new && let Some(asset) = existing {
if !force_new {
if let Some(asset) = existing {
return asset;
}
}
let revision = force_new.then_some(now_micros);
let suffix = asset_revision_suffix(revision);
JumpHopCharacterAsset {
@@ -722,9 +850,11 @@ fn ensure_tile_atlas_asset(
force_new: bool,
now_micros: i64,
) -> JumpHopCharacterAsset {
if !force_new && let Some(asset) = existing {
if !force_new {
if let Some(asset) = existing {
return asset;
}
}
let revision = force_new.then_some(now_micros);
let suffix = asset_revision_suffix(revision);
JumpHopCharacterAsset {
@@ -781,8 +911,8 @@ 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())
@@ -790,6 +920,7 @@ fn resolve_cover_composite(
{
return Some(value.to_string());
}
}
let suffix = asset_revision_suffix(
(!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros),
);

View File

@@ -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> {

View File

@@ -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 ? (

View File

@@ -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);

View File

@@ -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',

View File

@@ -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>

View File

@@ -115,8 +115,10 @@ import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFound
import {
ApiClientError,
BACKGROUND_AUTH_REQUEST_OPTIONS,
getStoredAccessToken,
} from '../../services/apiClient';
import {
ensureRuntimeGuestToken,
getPublicAuthUserByCode,
getPublicAuthUserById,
} from '../../services/authService';
@@ -127,6 +129,7 @@ import {
publishBarkBattleWork,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
import { startBarkBattleRun } from '../../services/bark-battle-runtime';
import {
createBigFishCreationSession,
executeBigFishCreationAction,
@@ -177,9 +180,10 @@ import {
type JumpHopRunResponse,
type JumpHopSessionResponse,
type JumpHopSessionSnapshotResponse,
type JumpHopWorkProfileResponse,
type JumpHopWorkspaceCreateRequest,
JumpHopWorkProfileResponse,
JumpHopWorkspaceCreateRequest,
} from '../../services/jump-hop/jumpHopClient';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
@@ -407,6 +411,7 @@ import { PlatformFeedbackView } from './PlatformFeedbackView';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
import { usePlatformDesktopLayout } from './platformEntryResponsive';
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
import { usePlatformEntryNavigation } from './usePlatformEntryNavigation';
@@ -549,8 +554,34 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
]);
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS =
BACKGROUND_AUTH_REQUEST_OPTIONS;
const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS =
const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS =
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
async function buildRecommendRuntimeGuestOptions() {
const { token } = await ensureRuntimeGuestToken();
return {
...RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
runtimeGuestToken: token,
};
}
function shouldUseRecommendRuntimeGuestAuth(
authUi: { user?: { id?: string } | null } | null | undefined,
) {
return !authUi?.user?.id?.trim() && !getStoredAccessToken();
}
async function buildRecommendRuntimeAuthOptions(
authUi: { user?: { id?: string } | null } | null | undefined,
embedded?: boolean,
) {
if (!embedded) {
return {};
}
if (shouldUseRecommendRuntimeGuestAuth(authUi)) {
return buildRecommendRuntimeGuestOptions();
}
return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
}
const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3;
@@ -1943,7 +1974,7 @@ function hasRecoverableGeneratedPuzzleDraft(
);
}
function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) {
function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] {
switch (item.source.kind) {
case 'rpg':
return collectDraftNoticeKeys('rpg', [
@@ -1972,6 +2003,13 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) {
item.source.item.profileId,
item.source.item.sourceSessionId,
]);
case 'jump-hop':
return collectDraftNoticeKeys('jump-hop', [
item.id,
item.source.item.workId,
item.source.item.profileId,
item.source.item.sourceSessionId,
]);
case 'puzzle':
return collectDraftNoticeKeys('puzzle', [
item.id,
@@ -2057,6 +2095,39 @@ function buildPendingBigFishWorks(
}));
}
function buildPendingJumpHopWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly JumpHopWorkSummaryResponse[],
): JumpHopWorkSummaryResponse[] {
if (!pending) {
return [];
}
return Object.entries(pending)
.filter(([sessionId]) =>
existingItems.every((item) => item.sourceSessionId !== sessionId),
)
.map(([sessionId, state]) => ({
runtimeKind: 'jump-hop',
workId: `jump-hop-work-${sessionId}`,
profileId: `jump-hop-profile-${sessionId}`,
ownerUserId: '',
sourceSessionId: sessionId,
workTitle: '跳一跳草稿',
workDescription: '正在生成跳一跳玩法草稿。',
themeTags: [],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: state.updatedAt,
publishedAt: null,
publishReady: false,
generationStatus: state.status === 'generating' ? 'generating' : 'ready',
}));
}
function buildPendingMatch3DWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly Match3DWorkSummary[],
@@ -2666,7 +2737,12 @@ export function PlatformEntryFlowShellImpl({
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const isDesktopLayout = usePlatformDesktopLayout();
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [draftGenerationPointNotice, setDraftGenerationPointNotice] = useState<{
title: string;
message: string;
} | null>(null);
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] =
@@ -2727,6 +2803,9 @@ export function PlatformEntryFlowShellImpl({
const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState<
JumpHopGalleryCardResponse[]
>([]);
const [jumpHopWorks, setJumpHopWorks] = useState<
JumpHopWorkSummaryResponse[]
>([]);
const [jumpHopRuntimeReturnStage, setJumpHopRuntimeReturnStage] =
useState<JumpHopRuntimeReturnStage>('jump-hop-result');
const [jumpHopGenerationState, setJumpHopGenerationState] =
@@ -2943,6 +3022,10 @@ export function PlatformEntryFlowShellImpl({
creationEntryTypes,
'big-fish',
);
const isJumpHopCreationVisible = isPlatformCreationTypeVisible(
creationEntryTypes,
'jump-hop',
);
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationEntryTypes,
'square-hole',
@@ -3244,7 +3327,7 @@ export function PlatformEntryFlowShellImpl({
[draftGenerationNotices],
);
const ensureEnoughDraftGenerationPointsFromServer = useCallback(
async (pointsCost: number, setError: (message: string | null) => void) => {
async (pointsCost: number) => {
try {
const latestDashboard = await getPlatformProfileDashboard(
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
@@ -3252,25 +3335,26 @@ export function PlatformEntryFlowShellImpl({
platformBootstrap.setProfileDashboard(latestDashboard);
const walletBalance = resolveProfileWalletBalance(latestDashboard);
if (walletBalance >= pointsCost) {
setDraftGenerationPointNotice(null);
return true;
}
setError(
`泥点不足,本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`,
setDraftGenerationPointNotice(
{
title: '泥点不足',
message: `本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`,
},
);
enterCreateTab();
selectionStageRef.current = 'platform';
setSelectionStage('platform');
return false;
} catch {
setError('读取泥点余额失败,请稍后重试。');
enterCreateTab();
selectionStageRef.current = 'platform';
setSelectionStage('platform');
setDraftGenerationPointNotice({
title: '读取泥点余额失败',
message: '请稍后重试。',
});
return false;
}
},
[enterCreateTab, platformBootstrap, setSelectionStage],
[platformBootstrap],
);
const resolveBigFishErrorMessage = useCallback(
@@ -3293,6 +3377,11 @@ export function PlatformEntryFlowShellImpl({
resolveRpgCreationErrorMessage(error, fallback),
[],
);
const resolveBarkBattleErrorMessage = useCallback(
(error: unknown, fallback: string) =>
resolveRpgCreationErrorMessage(error, fallback),
[],
);
const refreshBigFishShelf = useCallback(async () => {
setIsBigFishLoadingLibrary(true);
@@ -3392,6 +3481,22 @@ export function PlatformEntryFlowShellImpl({
}
}, []);
const refreshJumpHopShelf = useCallback(async () => {
if (!isJumpHopCreationVisible) {
setJumpHopWorks([]);
return [];
}
try {
const worksResponse = await jumpHopClient.listWorks();
setJumpHopWorks(worksResponse.items);
return worksResponse.items;
} catch {
setJumpHopWorks([]);
return [];
}
}, [isJumpHopCreationVisible]);
const refreshWoodenFishGallery = useCallback(async () => {
try {
const galleryResponse = await woodenFishClient.listGallery();
@@ -3601,6 +3706,22 @@ export function PlatformEntryFlowShellImpl({
selectionStage,
]);
useEffect(() => {
if (!platformBootstrap.canReadProtectedData) {
setJumpHopWorks([]);
return;
}
if (platformBootstrap.platformTab === 'create' || selectionStage === 'platform') {
void refreshJumpHopShelf();
}
}, [
platformBootstrap.canReadProtectedData,
platformBootstrap.platformTab,
refreshJumpHopShelf,
selectionStage,
]);
const sessionController = useRpgCreationSessionController({
userId: authUi?.user?.id,
openLoginModal: authUi?.openLoginModal,
@@ -3948,6 +4069,16 @@ export function PlatformEntryFlowShellImpl({
],
[bigFishWorks, pendingDraftShelfItems],
);
const jumpHopShelfItems = useMemo(
() => [
...buildPendingJumpHopWorks(
pendingDraftShelfItems['jump-hop'],
jumpHopWorks,
),
...jumpHopWorks,
],
[jumpHopWorks, pendingDraftShelfItems],
);
const match3dShelfItems = useMemo(
() => [
...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks),
@@ -4023,6 +4154,13 @@ export function PlatformEntryFlowShellImpl({
...bigFishShelfItems.flatMap((item) =>
collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]),
),
...jumpHopShelfItems.flatMap((item) =>
collectDraftNoticeKeys('jump-hop', [
item.workId,
item.profileId,
item.sourceSessionId,
]),
),
...match3dShelfItems.flatMap((item) =>
collectDraftNoticeKeys('match3d', [
item.workId,
@@ -4065,6 +4203,7 @@ export function PlatformEntryFlowShellImpl({
babyObjectMatchDrafts,
barkBattleShelfItems,
bigFishShelfItems,
jumpHopShelfItems,
creationHubItems,
isSquareHoleCreationVisible,
match3dShelfItems,
@@ -5164,30 +5303,27 @@ export function PlatformEntryFlowShellImpl({
setPuzzleError(null);
return ensureEnoughDraftGenerationPointsFromServer(
PUZZLE_DRAFT_GENERATION_POINT_COST,
(message) => {
setPuzzleCreationError(message);
setPuzzleError(message);
},
);
}, [
ensureEnoughDraftGenerationPointsFromServer,
setPuzzleCreationError,
setPuzzleError,
]);
const preflightMatch3DDraftGeneration = useCallback(async () => {
setMatch3DError(null);
return ensureEnoughDraftGenerationPointsFromServer(
MATCH3D_DRAFT_GENERATION_POINT_COST,
setMatch3DError,
);
}, [ensureEnoughDraftGenerationPointsFromServer, setMatch3DError]);
}, [ensureEnoughDraftGenerationPointsFromServer]);
const preflightBarkBattleDraftGeneration = useCallback(async () => {
setBarkBattleError(null);
return ensureEnoughDraftGenerationPointsFromServer(
BARK_BATTLE_DRAFT_GENERATION_POINT_COST,
setBarkBattleError,
);
}, [ensureEnoughDraftGenerationPointsFromServer]);
const draftGenerationPointNoticeDescription = draftGenerationPointNotice
? draftGenerationPointNotice.title === '读取泥点余额失败'
? '当前表单不会丢失,关闭后可继续编辑,稍后再试。'
: '当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。'
: undefined;
const recoverCompletedPuzzleDraftGeneration = useCallback(
async ({
sessionId,
@@ -7276,11 +7412,15 @@ export function PlatformEntryFlowShellImpl({
profileId: targetProfileId,
mode: 'play' as const,
};
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const { run } = options.embedded
? await startVisualNovelRun(
targetProfileId,
startRunPayload,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
runtimeGuestOptions,
)
: await startVisualNovelRun(targetProfileId, startRunPayload);
setVisualNovelWork(workDetail);
@@ -7306,6 +7446,7 @@ export function PlatformEntryFlowShellImpl({
}
},
[
authUi,
resolvePuzzleErrorMessage,
setIsVisualNovelBusy,
setSelectionStage,
@@ -7327,9 +7468,14 @@ export function PlatformEntryFlowShellImpl({
setVisualNovelError(null);
setIsVisualNovelBusy(true);
try {
const runtimeGuestOptions =
activeRecommendRuntimeKind === 'visual-novel'
? await buildRecommendRuntimeAuthOptions(authUi, true)
: {};
const nextRun = await streamVisualNovelRuntimeAction(
visualNovelRun.runId,
payload,
runtimeGuestOptions,
);
setVisualNovelRun(nextRun);
} catch (error) {
@@ -7341,6 +7487,8 @@ export function PlatformEntryFlowShellImpl({
}
},
[
activeRecommendRuntimeKind,
authUi,
isVisualNovelBusy,
resolvePuzzleErrorMessage,
setIsVisualNovelBusy,
@@ -7551,6 +7699,22 @@ export function PlatformEntryFlowShellImpl({
setJumpHopSession(response.session);
setJumpHopWork(response.work ?? null);
setJumpHopGenerationState(readyState);
if (response.work) {
setJumpHopWorks((current) =>
[response.work!.summary, ...current.filter((item) => item.workId !== response.work!.summary.workId)],
);
markPendingDraftReady('jump-hop', created.session.sessionId, false);
markDraftReady(
'jump-hop',
[
created.session.sessionId,
response.work.summary.workId,
response.work.summary.profileId,
],
false,
);
void refreshJumpHopShelf().catch(() => undefined);
}
setSelectionStage('jump-hop-result');
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
@@ -7665,6 +7829,10 @@ export function PlatformEntryFlowShellImpl({
try {
const response = await jumpHopClient.publishWork(profileId);
setJumpHopWork(response.item);
setJumpHopWorks((current) =>
[response.item.summary, ...current.filter((item) => item.workId !== response.item.summary.workId)],
);
void refreshJumpHopShelf().catch(() => undefined);
openPublishShareModal({
title: response.item.summary.workTitle || '跳一跳',
publicWorkCode: buildJumpHopPublicWorkCode(
@@ -7729,12 +7897,13 @@ export function PlatformEntryFlowShellImpl({
setJumpHopError(null);
setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail');
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const [detail, runResponse] = await Promise.all([
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
jumpHopClient.startRun(
normalizedProfileId,
options.embedded ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS : {},
),
jumpHopClient.startRun(normalizedProfileId, runtimeGuestOptions),
]);
if (detail?.item) {
setJumpHopWork(detail.item);
@@ -7759,7 +7928,7 @@ export function PlatformEntryFlowShellImpl({
setIsJumpHopBusy(false);
}
},
[setSelectionStage],
[authUi, setSelectionStage],
);
const restartJumpHopRuntimeRun = useCallback(async () => {
@@ -8066,9 +8235,15 @@ export function PlatformEntryFlowShellImpl({
setWoodenFishError(null);
setWoodenFishRuntimeReturnStage(options.returnStage ?? 'work-detail');
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const [detail, runResponse] = await Promise.all([
woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null),
woodenFishClient.startRun(normalizedProfileId),
options.embedded
? woodenFishClient.startRun(normalizedProfileId, runtimeGuestOptions)
: woodenFishClient.startRun(normalizedProfileId),
]);
if (detail?.item) {
setWoodenFishWork(detail.item);
@@ -8093,7 +8268,7 @@ export function PlatformEntryFlowShellImpl({
setIsWoodenFishBusy(false);
}
},
[setSelectionStage],
[authUi, setSelectionStage],
);
const checkpointWoodenFishRuntimeRun = useCallback(
@@ -8496,16 +8671,23 @@ export function PlatformEntryFlowShellImpl({
profileId: item.profileId,
levelId: levelId ?? null,
};
const authMode = options.embedded
? 'isolated'
: (options.authMode ?? 'default');
const canUseRuntimeGuestAuth =
options.embedded || options.authMode === 'isolated';
const useRuntimeGuestAuth =
canUseRuntimeGuestAuth && shouldUseRecommendRuntimeGuestAuth(authUi);
const runtimeGuestOptions = useRuntimeGuestAuth
? await buildRecommendRuntimeGuestOptions()
: {};
const authMode = useRuntimeGuestAuth ? 'isolated' : 'default';
const runtimeAuthOptions = useRuntimeGuestAuth
? runtimeGuestOptions
: canUseRuntimeGuestAuth
? RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS
: {};
const { run } =
authMode === 'isolated'
? await startPuzzleRun(
startRunPayload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
: await startPuzzleRun(startRunPayload);
? await startPuzzleRun(startRunPayload, runtimeGuestOptions)
: await startPuzzleRun(startRunPayload, runtimeAuthOptions);
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeAuthMode(authMode);
@@ -8548,6 +8730,7 @@ export function PlatformEntryFlowShellImpl({
},
[
isPuzzleBusy,
authUi,
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
@@ -8600,10 +8783,12 @@ export function PlatformEntryFlowShellImpl({
runtimeProfile.generatedBackgroundAsset,
{ expireSeconds: 300 },
);
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const runtimeOptions = {
...(options.embedded
? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS
: {}),
...runtimeGuestOptions,
...(typeof options.itemTypeCountOverride === 'number'
? { itemTypeCountOverride: options.itemTypeCountOverride }
: {}),
@@ -8648,6 +8833,7 @@ export function PlatformEntryFlowShellImpl({
},
[
isMatch3DBusy,
authUi,
match3dFlow,
match3dRuntimeAdapter,
resolveMatch3DErrorMessage,
@@ -8671,11 +8857,12 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleError(null);
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const { run } = options.embedded
? await startSquareHoleRun(
profile.profileId,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
? await startSquareHoleRun(profile.profileId, runtimeGuestOptions)
: await startSquareHoleRun(profile.profileId);
setSquareHoleRun(run);
setSquareHoleRuntimeReturnStage(returnStage);
@@ -8707,6 +8894,7 @@ export function PlatformEntryFlowShellImpl({
},
[
isSquareHoleBusy,
authUi,
resolveSquareHoleErrorMessage,
setSelectionStage,
setSquareHoleError,
@@ -8827,9 +9015,14 @@ export function PlatformEntryFlowShellImpl({
bigFishInputInFlightRef.current = true;
try {
const runtimeGuestOptions =
activeRecommendRuntimeKind === 'big-fish'
? await buildRecommendRuntimeAuthOptions(authUi, true)
: {};
const { run } = await submitBigFishRuntimeInput(
bigFishRun.runId,
payload,
runtimeGuestOptions,
);
setBigFishRun(run);
} catch (error) {
@@ -8840,7 +9033,13 @@ export function PlatformEntryFlowShellImpl({
bigFishInputInFlightRef.current = false;
}
},
[bigFishRun, resolveBigFishErrorMessage, setBigFishError],
[
activeRecommendRuntimeKind,
authUi,
bigFishRun,
resolveBigFishErrorMessage,
setBigFishError,
],
);
const reportBigFishObservedPlayTime = useCallback(() => {
@@ -8853,10 +9052,9 @@ export function PlatformEntryFlowShellImpl({
setBigFishRuntimeStartedAt(null);
const reportPromise =
activeRecommendRuntimeKind === 'big-fish'
? recordBigFishPlay(
sessionId,
{ elapsedMs },
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
? buildRecommendRuntimeAuthOptions(authUi, true).then(
(runtimeAuthOptions) =>
recordBigFishPlay(sessionId, { elapsedMs }, runtimeAuthOptions),
)
: recordBigFishPlay(sessionId, { elapsedMs });
void reportPromise.catch((error) => {
@@ -8866,6 +9064,7 @@ export function PlatformEntryFlowShellImpl({
});
}, [
activeRecommendRuntimeKind,
authUi,
bigFishRun?.sessionId,
bigFishRuntimeStartedAt,
resolveBigFishErrorMessage,
@@ -9041,12 +9240,13 @@ export function PlatformEntryFlowShellImpl({
profileId: currentLevel.profileId,
levelId: resolvePuzzleRestartLevelId(currentRun, detailItem),
};
const runtimeGuestOptions =
puzzleRuntimeAuthMode === 'isolated'
? await buildRecommendRuntimeGuestOptions()
: {};
const { run } =
puzzleRuntimeAuthMode === 'isolated'
? await startPuzzleRun(
startRunPayload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
? await startPuzzleRun(startRunPayload, runtimeGuestOptions)
: await startPuzzleRun(startRunPayload);
setSelectedPuzzleDetail(detailItem);
puzzleRunRef.current = run;
@@ -9169,10 +9369,8 @@ export function PlatformEntryFlowShellImpl({
const submitLeaderboardPromise =
puzzleRuntimeAuthMode === 'isolated'
? submitPuzzleLeaderboard(
puzzleRun.runId,
payload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
? buildRecommendRuntimeGuestOptions().then((runtimeGuestOptions) =>
submitPuzzleLeaderboard(puzzleRun.runId, payload, runtimeGuestOptions),
)
: submitPuzzleLeaderboard(puzzleRun.runId, payload);
@@ -9229,6 +9427,10 @@ export function PlatformEntryFlowShellImpl({
return;
}
const runtimeGuestOptions =
puzzleRuntimeAuthMode === 'isolated'
? await buildRecommendRuntimeGuestOptions()
: {};
const targetProfileId = _target?.profileId?.trim() ?? '';
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
const itemPromise =
@@ -9244,7 +9446,7 @@ export function PlatformEntryFlowShellImpl({
{
targetProfileId,
},
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
runtimeGuestOptions,
)
: advancePuzzleNextLevel(puzzleRun.runId, {
targetProfileId,
@@ -9269,7 +9471,7 @@ export function PlatformEntryFlowShellImpl({
? await advancePuzzleNextLevel(
puzzleRun.runId,
{},
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
runtimeGuestOptions,
)
: await advancePuzzleNextLevel(puzzleRun.runId);
setPuzzleRun(run);
@@ -10354,6 +10556,43 @@ export function PlatformEntryFlowShellImpl({
[openPublicWorkDetail, setJumpHopError, setSelectionStage],
);
const openJumpHopDraft = useCallback(
async (item: JumpHopWorkSummaryResponse) => {
markDraftNoticeSeen(
collectDraftNoticeKeys('jump-hop', [
item.workId,
item.profileId,
item.sourceSessionId,
]),
);
if (item.publicationStatus === 'published') {
void openJumpHopPublicWorkDetail(item.profileId);
return;
}
setJumpHopError(null);
setPublicWorkDetailError(null);
setIsJumpHopBusy(true);
try {
const detail = await jumpHopClient.getWorkDetail(item.profileId);
setJumpHopSession(null);
setJumpHopRun(null);
setJumpHopWork(detail.item);
setJumpHopRuntimeReturnStage('jump-hop-result');
enterCreateTab();
setSelectionStage('jump-hop-result');
} catch (error) {
setJumpHopError(
resolveRpgCreationErrorMessage(error, '读取跳一跳草稿失败。'),
);
} finally {
setIsJumpHopBusy(false);
}
},
[enterCreateTab, markDraftNoticeSeen, openPublicWorkDetail, setSelectionStage],
);
const openWoodenFishPublicWorkDetail = useCallback(
async (profileId: string) => {
setIsPublicWorkDetailBusy(true);
@@ -11120,11 +11359,12 @@ export function PlatformEntryFlowShellImpl({
setBigFishRuntimeReturnStage(returnStage);
setBigFishRun(null);
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const { run } = options.embedded
? await startBigFishRuntimeRun(
sessionId,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
? await startBigFishRuntimeRun(sessionId, runtimeGuestOptions)
: await startBigFishRuntimeRun(sessionId);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRun(run);
@@ -11135,11 +11375,7 @@ export function PlatformEntryFlowShellImpl({
);
}
const recordPlayPromise = options.embedded
? recordBigFishPlay(
sessionId,
{ elapsedMs: 0 },
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
? recordBigFishPlay(sessionId, { elapsedMs: 0 }, runtimeGuestOptions)
: recordBigFishPlay(sessionId, { elapsedMs: 0 });
void recordPlayPromise.catch((error) => {
setBigFishError(
@@ -11154,13 +11390,14 @@ export function PlatformEntryFlowShellImpl({
return false;
}
},
[bigFishFlow, resolveBigFishErrorMessage, setBigFishError, setSelectionStage],
[authUi, bigFishFlow, resolveBigFishErrorMessage, setBigFishError, setSelectionStage],
);
const startBarkBattleRunFromWork = useCallback(
(
async (
item: BarkBattleWorkSummary,
returnStage: BarkBattleRuntimeReturnStage = 'work-detail',
options: { embedded?: boolean } = {},
) => {
if (item.status !== 'published') {
setBarkBattleError('汪汪声浪作品发布后才能进入正式玩法。');
@@ -11172,7 +11409,17 @@ export function PlatformEntryFlowShellImpl({
setBarkBattleRuntimeMode('published');
setBarkBattlePublishedConfig(mapBarkBattleWorkToPublishedConfig(item));
setBarkBattleRuntimeReturnStage(returnStage);
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
);
const runResponse = options.embedded
? await startBarkBattleRun(item.workId, {}, runtimeGuestOptions)
: await startBarkBattleRun(item.workId);
void runResponse;
selectionStageRef.current = 'bark-battle-runtime';
if (!options.embedded) {
setSelectionStage('bark-battle-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath(
@@ -11180,9 +11427,16 @@ export function PlatformEntryFlowShellImpl({
buildBarkBattlePublicWorkCode(item.workId),
),
);
}
return true;
} catch (error) {
setBarkBattleError(
resolveBarkBattleErrorMessage(error, '启动汪汪声浪玩法失败。'),
);
return false;
}
},
[setSelectionStage],
[authUi, resolveBarkBattleErrorMessage, setSelectionStage],
);
const startSelectedPublicWork = useCallback(() => {
@@ -11454,7 +11708,9 @@ export function PlatformEntryFlowShellImpl({
'当前汪汪声浪作品信息不完整,暂时无法进入玩法。',
);
} else {
started = startBarkBattleRunFromWork(work, 'platform');
started = await startBarkBattleRunFromWork(work, 'platform', {
embedded: true,
});
}
} else if (isEdutainmentGalleryEntry(entry)) {
started = await startBabyObjectMatchRuntimeFromEntry(
@@ -11548,6 +11804,7 @@ export function PlatformEntryFlowShellImpl({
const recommendRuntimeContent = useMemo(() => {
if (
isDesktopLayout ||
selectionStage !== 'platform' ||
platformBootstrap.platformTab !== 'home' ||
!activeRecommendRuntimeKind
@@ -11954,10 +12211,12 @@ export function PlatformEntryFlowShellImpl({
visualNovelSession,
visualNovelWork,
checkpointWoodenFishRuntimeRun,
isDesktopLayout,
]);
useEffect(() => {
if (
isDesktopLayout ||
selectionStage !== 'platform' ||
platformBootstrap.platformTab !== 'home' ||
platformBootstrap.isLoadingPlatform
@@ -12013,6 +12272,7 @@ export function PlatformEntryFlowShellImpl({
match3dRun,
platformBootstrap.isLoadingPlatform,
platformBootstrap.platformTab,
isDesktopLayout,
puzzleRun,
recommendRuntimeEntries,
selectRecommendRuntimeEntry,
@@ -13119,6 +13379,7 @@ export function PlatformEntryFlowShellImpl({
deletingWorkId={deletingCreationWorkId}
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []}
jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []}
onOpenBigFishDetail={
isBigFishCreationVisible
? (item) => {
@@ -13128,6 +13389,15 @@ export function PlatformEntryFlowShellImpl({
}
: undefined
}
onOpenJumpHopDetail={
isJumpHopCreationVisible
? (item) => {
runProtectedAction(() => {
void openJumpHopDraft(item);
});
}
: undefined
}
onDeleteBigFish={
isBigFishCreationVisible
? (item) => {
@@ -13135,6 +13405,7 @@ export function PlatformEntryFlowShellImpl({
}
: null
}
onDeleteJumpHop={null}
match3dItems={match3dShelfItems}
onOpenMatch3DDetail={(item) => {
runProtectedAction(() => {
@@ -13241,6 +13512,7 @@ export function PlatformEntryFlowShellImpl({
isLoadingPlatform={platformBootstrap.isLoadingPlatform}
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
hasUnreadDraftUpdate={hasUnreadDraftUpdates}
isDesktopLayout={isDesktopLayout}
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
platformError={
platformBootstrap.isLoadingPlatform
@@ -15466,6 +15738,29 @@ export function PlatformEntryFlowShellImpl({
}}
/>
) : null}
<UnifiedModal
open={Boolean(draftGenerationPointNotice)}
title={draftGenerationPointNotice?.title ?? '泥点提示'}
description={draftGenerationPointNoticeDescription}
onClose={() => setDraftGenerationPointNotice(null)}
closeOnBackdrop
size="sm"
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
panelClassName="platform-remap-surface rounded-[1.75rem]"
footer={
<button
type="button"
onClick={() => setDraftGenerationPointNotice(null)}
className="platform-button platform-button--primary min-h-0 rounded-full px-4 py-2 text-sm"
>
</button>
}
>
<div className="text-sm leading-6 text-[var(--platform-text-base)]">
{draftGenerationPointNotice?.message}
</div>
</UnifiedModal>
<PublishShareModal
open={Boolean(publishSharePayload)}
payload={publishSharePayload}

View File

@@ -315,3 +315,36 @@ test('groups visible platform creation types by backend category metadata', () =
]);
expect(groups[1]?.items.map((item) => item.id)).toEqual(['visual-novel']);
});
test('falls back when backend creation type category metadata is missing', () => {
const cards = derivePlatformCreationTypes([
{
id: 'legacy-entry',
title: '历史入口',
subtitle: '旧数据缺少分类字段',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 10,
categoryId: undefined as unknown as string,
categoryLabel: undefined as unknown as string,
categorySortOrder: 0,
updatedAtMicros: 1,
},
]);
expect(cards[0]).toEqual(
expect.objectContaining({
id: 'legacy-entry',
categoryId: 'recent',
categoryLabel: '最近创作',
}),
);
expect(groupVisiblePlatformCreationTypes(cards)).toEqual([
expect.objectContaining({
id: 'recent',
label: '最近创作',
}),
]);
});

View File

@@ -55,13 +55,13 @@ export function isPlatformCreationTypeOpen(
);
}
function normalizeCategoryId(value: string) {
const normalized = value.trim();
function normalizeCategoryId(value: string | null | undefined) {
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || FALLBACK_CREATION_CATEGORY_ID;
}
function normalizeCategoryLabel(value: string) {
const normalized = value.trim();
function normalizeCategoryLabel(value: string | null | undefined) {
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || FALLBACK_CREATION_CATEGORY_LABEL;
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
export const PLATFORM_DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
export function getInitialPlatformDesktopLayout() {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(PLATFORM_DESKTOP_LAYOUT_QUERY).matches;
}
export function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(
getInitialPlatformDesktopLayout,
);
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return;
}
const mediaQuery = window.matchMedia(PLATFORM_DESKTOP_LAYOUT_QUERY);
const updateLayout = (event?: MediaQueryListEvent) => {
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
};
updateLayout();
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', updateLayout);
return () => mediaQuery.removeEventListener('change', updateLayout);
}
mediaQuery.addListener(updateLayout);
return () => mediaQuery.removeListener(updateLayout);
}, []);
return isDesktopLayout;
}

View File

@@ -551,9 +551,9 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: 'first-level.png',
pictureDescription: 'first-level.png',
referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png',
referenceImageSrc: 'data:image/png;base64,uploaded-square',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-first-level.png',
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
@@ -616,22 +616,10 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
});
});
test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => {
test('puzzle workspace submits uploaded reference image as data URL when AI redraw is on', async () => {
const onCreateFromForm = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({
assetObjectId: 'asset-reference-main-1',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/main-1.png',
imageSrc: '/generated-puzzle-assets/reference/main-1.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
});
render(
<PuzzleAgentWorkspace
@@ -651,9 +639,7 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
await waitFor(() => {
expect(screen.getByAltText('拼图图片')).toBeTruthy();
});
expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({
file: expect.any(File),
});
expect(puzzleAssetClient.uploadReferenceImage).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('画面AI重绘要求提示词'), {
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
});
@@ -663,9 +649,9 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
referenceImageSrc: null,
referenceImageSrc: 'data:image/png;base64,uploaded-square',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-main-1',
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
@@ -754,12 +740,12 @@ test('puzzle workspace uploads prompt references as asset object ids', async ()
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-prompt-1',
'asset-reference-prompt-2',
referenceImageSrcs: [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -842,15 +828,15 @@ test('puzzle workspace uploads prompt reference images from the description box'
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-reference-1.png',
'asset-reference-reference-2.png',
'asset-reference-reference-3.png',
'asset-reference-reference-4.png',
'asset-reference-reference-5.png',
referenceImageSrcs: [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
'data:image/png;base64,reference-3',
'data:image/png;base64,reference-4',
'data:image/png;base64,reference-5',
],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});

View File

@@ -16,11 +16,9 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
import {
cropPuzzleReferenceImageDataUrl,
isPuzzleReferenceImageSquare,
puzzleReferenceImageDataUrlToFile,
readPuzzleReferenceImageAsDataUrl,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import {
CreativeImageInputPanel,
type CreativeImageInputReferenceImage,
@@ -409,11 +407,10 @@ export function PuzzleAgentWorkspace({
return;
}
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc || uploadImage.dataUrl,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageSrc: uploadImage.dataUrl,
referenceImageAssetObjectId: '',
referenceImageLabel: file.name.trim() || '本地拼图图片',
}));
setReferenceImageError(null);
@@ -441,18 +438,12 @@ export function PuzzleAgentWorkspace({
try {
const images = await Promise.all(
files.slice(0, remainingSlots).map(async (file, index) => {
const [imageSrc, asset] = await Promise.all([
readPuzzleReferenceImageAsDataUrl(file),
puzzleAssetClient.uploadReferenceImage({ file }),
]);
return {
files.slice(0, remainingSlots).map(async (file, index) => ({
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: asset.imageSrc || imageSrc,
assetObjectId: asset.assetObjectId,
};
}),
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
assetObjectId: null,
})),
);
setFormState((current) => ({
...current,
@@ -515,15 +506,10 @@ export function PuzzleAgentWorkspace({
cropY: currentCropState.cropRect.y,
cropSize: currentCropState.cropRect.size,
});
const file = puzzleReferenceImageDataUrlToFile(
dataUrl,
currentCropState.fileName,
);
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc || dataUrl,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageSrc: dataUrl,
referenceImageAssetObjectId: '',
referenceImageLabel: currentCropState.label,
}));
setCropState(null);
@@ -651,6 +637,7 @@ export function PuzzleAgentWorkspace({
aiRedraw={formState.aiRedraw}
promptReferenceImages={formState.referenceImageSrcs}
promptReferenceLimit={PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT}
imageLimitHint="图片≤6MB"
imageModelPicker={
<PuzzleImageModelPicker
value={formState.imageModel}

View File

@@ -840,6 +840,7 @@ function PuzzleLevelDetailDialog({
aiRedraw={aiRedraw}
promptReferenceImages={promptReferenceImages}
promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT}
imageLimitHint="图片≤6MB"
imageModelPicker={
<PuzzleImageModelPicker
value={imageModel}

View File

@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
@@ -178,6 +179,35 @@ import {
type SelectionStage,
} from './RpgEntryFlowShell';
const authServiceMocks = vi.hoisted(() => ({
ensureRuntimeGuestToken: vi.fn(async () => ({
token: 'runtime-guest-token',
expiresAt: '2099-01-01T00:00:00.000Z',
})),
getPublicAuthUserByCode: vi.fn(
async (publicUserCode: string): Promise<PublicUserSummary> => ({
id: `public-user-${publicUserCode}`,
publicUserCode,
displayName: '公开作者',
avatarUrl: null,
}),
),
getPublicAuthUserById: vi.fn(
async (userId: string): Promise<PublicUserSummary> => ({
id: userId,
publicUserCode: `code-${userId}`,
displayName: '公开作者',
avatarUrl: null,
}),
),
}));
vi.mock('../../services/authService', () => ({
ensureRuntimeGuestToken: authServiceMocks.ensureRuntimeGuestToken,
getPublicAuthUserByCode: authServiceMocks.getPublicAuthUserByCode,
getPublicAuthUserById: authServiceMocks.getPublicAuthUserById,
}));
async function clickFirstButtonByName(
user: ReturnType<typeof userEvent.setup>,
name: string | RegExp,
@@ -276,6 +306,11 @@ const ISOLATED_RUNTIME_AUTH_OPTIONS = {
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
const RECOMMEND_RUNTIME_AUTH_OPTIONS = {
...ISOLATED_RUNTIME_AUTH_OPTIONS,
runtimeGuestToken: 'runtime-guest-token',
};
const LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS = ISOLATED_RUNTIME_AUTH_OPTIONS;
function getPlatformTabPanel(tab: string) {
const panel = document.getElementById(`platform-tab-panel-${tab}`);
@@ -1085,6 +1120,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
}) => (
<div className="bark-battle-config-editor-mock">
<div></div>
<label>
<input aria-label="汪汪作品标题" defaultValue="汪汪测试杯" />
</label>
<div data-testid="bark-battle-editor-back-state">
{showBackButton ? 'back-visible' : 'back-hidden'}
</div>
@@ -2235,6 +2274,10 @@ function TestWrapper({
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(authServiceMocks.ensureRuntimeGuestToken).mockResolvedValue({
token: 'runtime-guest-token',
expiresAt: '2099-01-01T00:00:00.000Z',
});
vi.mocked(
match3dGeneratedModelCache.hasMatch3DGeneratedImageAsset,
).mockImplementation((assets) =>
@@ -3581,11 +3624,20 @@ test('bark battle form checks mud points before creating image assets', async ()
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('汪汪声浪'));
const titleInput = await screen.findByLabelText('汪汪作品标题');
await user.clear(titleInput);
await user.type(titleInput, '自定义声浪杯');
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
await screen.findByText('泥点不足,本次需要 3 泥点,当前 2 泥点。'),
within(noticeDialog).getByText('本次需要 3 泥点,当前 2 泥点。'),
).toBeTruthy();
expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect((screen.getByLabelText('汪汪作品标题') as HTMLInputElement).value).toBe(
'自定义声浪杯',
);
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled();
});
@@ -4302,11 +4354,15 @@ test('puzzle form checks mud points before creating a draft', async () => {
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
await screen.findByText('泥点不足,本次需要 2 泥点,当前 1 泥点。'),
within(noticeDialog).getByText('本次需要 2 泥点,当前 1 泥点。'),
).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(executePuzzleAgentAction).not.toHaveBeenCalled();
});
@@ -4323,14 +4379,17 @@ test('match3d form checks mud points before creating a draft', async () => {
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
await screen.findByText('泥点不足,本次需要 10 泥点,当前 9 泥点。'),
within(noticeDialog).getByText('本次需要 10 泥点,当前 9 泥点。'),
).toBeTruthy();
expect(screen.getByText('抓大鹅工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(match3dCreationClient.executeAction).not.toHaveBeenCalled();
});
@@ -6121,11 +6180,59 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
});
test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => {
const publishedPuzzleWork = {
workId: 'puzzle-work-public-2',
profileId: 'puzzle-profile-public-2',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-public-2',
authorDisplayName: '拼图作者',
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: 'puzzle-profile-public-2',
levelId: null,
},
expect.objectContaining({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
}),
);
});
expect(vi.mocked(startPuzzleRun).mock.calls[0]?.[1]).not.toHaveProperty(
'runtimeGuestToken',
);
});
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-1',
@@ -6184,7 +6291,7 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-card-1',
ISOLATED_RUNTIME_AUTH_OPTIONS,
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
await waitFor(() => {
@@ -6503,7 +6610,13 @@ test('home recommendation surfaces start failure instead of staying in loading s
expect(
await screen.findByText('作品暂时无法进入,请稍后再试。'),
).toBeTruthy();
expect(screen.queryByText('加载中...')).toBeNull();
await waitFor(() => {
expect(
within(getPlatformTabPanel('home'))
.queryByText('加载中...')
?.closest('.platform-recommend-runtime-panel'),
).toBeFalsy();
});
});
test('published big fish works stay hidden from platform home and game category channel', async () => {
@@ -7135,7 +7248,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
vi.mocked(listProfileSaveArchives).mockClear();
vi.mocked(listProfileSaveArchives).mockRejectedValueOnce(
@@ -7159,7 +7271,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
elapsedMs: 18_000,
nickname: '测试玩家',
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
@@ -7180,7 +7291,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedFirstLevel.runId,
{},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -7343,7 +7453,6 @@ test('formal puzzle similar work keeps current run level progression', async ()
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedThirdLevel.runId,
{ targetProfileId: 'puzzle-profile-similar-2' },
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(startPuzzleRun).not.toHaveBeenCalled();
@@ -7527,7 +7636,6 @@ test('recommend puzzle remix return restarts recommendation instead of stale loa
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(screen.queryByText('正在进入拼图关卡')).toBeNull();

View File

@@ -804,6 +804,7 @@ function renderLoggedOutHomeView(
>
> = {},
activeTab: RpgEntryHomeViewProps['activeTab'] = 'home',
isDesktopLayout = false,
) {
return render(
<AuthUiContext.Provider
@@ -827,6 +828,7 @@ function renderLoggedOutHomeView(
>
<RpgEntryHomeView
activeTab={activeTab}
isDesktopLayout={isDesktopLayout}
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
@@ -954,6 +956,7 @@ function renderStatefulLoggedOutHomeView(
| 'onSelectPreviousRecommendEntry'
>
> = {},
isDesktopLayout = false,
) {
const authSpies = {
openLoginModal: vi.fn(),
@@ -985,6 +988,7 @@ function renderStatefulLoggedOutHomeView(
>
<RpgEntryHomeView
activeTab={activeTab}
isDesktopLayout={isDesktopLayout}
onTabChange={setActiveTab}
hasSavedGame={false}
savedSnapshot={null}
@@ -2803,7 +2807,7 @@ test('logged out mobile shell defaults to discover tab', () => {
).toBeNull();
});
test('logged out recommend tab opens recommend runtime directly', async () => {
test('logged out recommend tab opens embedded runtime without login modal', async () => {
const user = userEvent.setup();
const { container, openLoginModal } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
@@ -2818,10 +2822,8 @@ test('logged out recommend tab opens recommend runtime directly', async () => {
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
);
expect(openLoginModal).toHaveBeenCalledTimes(1);
expect(
container.querySelector('.platform-recommend-cover-only'),
).toBeNull();
expect(openLoginModal).not.toHaveBeenCalled();
expect(container.querySelector('.platform-recommend-cover-only')).toBeNull();
expect(container.querySelector('.platform-mobile-topbar')).toBeNull();
expect(
container.querySelector('.platform-mobile-entry-shell--recommend'),
@@ -2831,7 +2833,7 @@ test('logged out recommend tab opens recommend runtime directly', async () => {
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
});
test('logged out recommend meta keeps gallery detail gated', async () => {
test('logged out recommend runtime keeps detail callback idle', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
const { openLoginModal } = renderStatefulLoggedOutHomeView({
@@ -2847,9 +2849,9 @@ test('logged out recommend meta keeps gallery detail gated', async () => {
await user.click(
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
);
await user.click(screen.getByLabelText('奇幻拼图 作品信息'));
expect(openLoginModal).toHaveBeenCalledTimes(1);
expect(openLoginModal).not.toHaveBeenCalled();
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
@@ -2858,16 +2860,15 @@ test('logged out desktop recommend page renders runtime directly', () => {
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
});
}, 'home', true);
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
expect(screen.queryByText('今日游戏')).toBeNull();
expect(screen.queryByText('作品分类')).toBeNull();
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(screen.getByText('今日游戏')).toBeTruthy();
expect(screen.getByText('作品分类')).toBeTruthy();
});
test('logged out recommend page can enter runtime without login gate', () => {
mockDesktopLayout();
const openLoginModal = vi.fn();
const onOpenGalleryDetail = vi.fn();
renderLoggedOutHomeView(openLoginModal, {
@@ -2883,6 +2884,35 @@ test('logged out recommend page can enter runtime without login gate', () => {
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
test('logged out desktop recommend rail enters runtime without login modal', async () => {
mockDesktopLayout();
const user = userEvent.setup();
const openLoginModal = vi.fn();
const { container } = renderLoggedOutHomeView(
openLoginModal,
{
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
},
'category',
true,
);
const desktopRail = container.querySelector('.platform-desktop-rail');
if (!desktopRail) {
throw new Error('缺少桌面侧边栏');
}
await user.click(
within(desktopRail as HTMLElement).getByRole('button', { name: '推荐' }),
);
expect(openLoginModal).not.toHaveBeenCalled();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(container.querySelector('.platform-desktop-shell')).toBeTruthy();
});
test('logged in recommend page uses gated recommend detail callback', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();

View File

@@ -131,6 +131,7 @@ import {
findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
@@ -165,6 +166,7 @@ export type PlatformHomeTab =
| 'profile';
export interface RpgEntryHomeViewProps {
activeTab: PlatformHomeTab;
isDesktopLayout?: boolean;
onTabChange: (tab: PlatformHomeTab) => void;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
@@ -234,7 +236,6 @@ const DESKTOP_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_DISCOVER_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'home',
'category',
@@ -416,46 +417,6 @@ const PLATFORM_RANKING_TABS: Array<{
emptyText: '公开广场暂时还没有点赞作品。',
},
];
function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(DESKTOP_LAYOUT_QUERY).matches;
});
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return;
}
const mediaQuery = window.matchMedia(DESKTOP_LAYOUT_QUERY);
const updateLayout = (event?: MediaQueryListEvent) => {
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
};
updateLayout();
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', updateLayout);
return () => mediaQuery.removeEventListener('change', updateLayout);
}
mediaQuery.addListener(updateLayout);
return () => mediaQuery.removeListener(updateLayout);
}, []);
return isDesktopLayout;
}
function ResolvedAssetBackdrop({
src,
fallbackSrc,
@@ -3978,6 +3939,7 @@ function ProfilePlayedWorksModal({
export function RpgEntryHomeView({
activeTab,
isDesktopLayout: isDesktopLayoutProp,
onTabChange,
saveEntries,
saveError,
@@ -4137,7 +4099,8 @@ export function RpgEntryHomeView({
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const isAuthenticated = Boolean(authUi?.user);
const edutainmentEntryEnabled = isEdutainmentEntryEnabled();
const isDesktopLayout = usePlatformDesktopLayout();
const [fallbackDesktopLayout] = useState(getInitialPlatformDesktopLayout);
const isDesktopLayout = isDesktopLayoutProp ?? fallbackDesktopLayout;
const openRecommendGalleryDetail =
onOpenRecommendGalleryDetail ?? onOpenGalleryDetail;
const generalFeaturedEntries = useMemo(
@@ -5494,7 +5457,7 @@ export function RpgEntryHomeView({
{recommendRuntimeError}
</button>
</section>
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
) : isStartingRecommendEntry ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">...</div>
</section>
@@ -6670,10 +6633,7 @@ export function RpgEntryHomeView({
);
const tabContentById = {
home:
!isAuthenticated || !isDesktopLayout
? mobileRecommendContent
: desktopHomeContent,
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
category: categoryContent,
create: createContent,
saves: savesContent,
@@ -6885,12 +6845,6 @@ export function RpgEntryHomeView({
return;
}
if (!isAuthenticated && tab === 'home') {
onTabChange(tab);
authUi?.openLoginModal();
return;
}
onTabChange(tab);
}}
/>
@@ -7049,12 +7003,6 @@ export function RpgEntryHomeView({
emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
onClick={() => {
if (!isAuthenticated && tab === 'home') {
onTabChange(tab);
authUi?.openLoginModal();
return;
}
onTabChange(tab);
}}
/>

View File

@@ -351,7 +351,7 @@ export function useRpgEntryBootstrap(
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
// 中文注释:新用户先进入发现页;推荐页只在用户主动点击后作为登录门禁入口
// 中文注释:新用户先进入发现页;推荐页可直接进入,真正受保护的动作再单独做登录门禁。
setPlatformTabState(isAuthenticated ? 'home' : 'category');
}
} finally {

View File

@@ -25,6 +25,7 @@ import type {
AuthWechatStartResponse,
LogoutResponse,
PublicUserSearchResponse,
RuntimeGuestTokenResponse,
} from '../../packages/shared/src/contracts/auth';
import type { RedeemProfileReferralInviteCodeResponse } from '../../packages/shared/src/contracts/runtime';
import {
@@ -61,6 +62,42 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = {
skipRefresh: true,
} satisfies ApiRequestOptions;
const runtimeGuestTokenCache: {
value: RuntimeGuestTokenResponse | null;
} = {
value: null,
};
function isRuntimeGuestTokenFresh(response: RuntimeGuestTokenResponse | null) {
if (!response?.expiresAt) {
return false;
}
const expiresAtMs = Date.parse(response.expiresAt);
return Number.isFinite(expiresAtMs) && expiresAtMs - Date.now() > 15_000;
}
export function clearRuntimeGuestTokenCache() {
runtimeGuestTokenCache.value = null;
}
export async function ensureRuntimeGuestToken() {
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
return runtimeGuestTokenCache.value!;
}
const response = await requestJson<RuntimeGuestTokenResponse>(
'/api/auth/runtime-guest-token',
{
method: 'POST',
},
'获取匿名运行态身份失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
runtimeGuestTokenCache.value = response;
return response;
}
const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone';
export function normalizePhoneInput(phoneInput: string) {

View File

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

View File

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

View File

@@ -12,15 +12,20 @@ import type {
JumpHopWorkDetailResponse,
JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse,
JumpHopWorksResponse,
JumpHopWorkspaceCreateRequest,
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import { createCreationAgentClient } from '../creation-agent';
import {
buildRuntimeGuestAuthOptions,
buildRuntimeGuestHeaders,
type RuntimeGuestRequestOptions,
} from '../runtimeGuestAuth';
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
@@ -30,14 +35,7 @@ const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
baseDelayMs: 120,
maxDelayMs: 360,
};
type JumpHopRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipAuth'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
type JumpHopRuntimeRequestOptions = RuntimeGuestRequestOptions;
export type {
JumpHopActionRequest,
@@ -53,6 +51,7 @@ export type {
JumpHopWorkDetailResponse,
JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse,
JumpHopWorksResponse,
JumpHopWorkspaceCreateRequest,
};
export type CreateJumpHopSessionRequest = {
@@ -211,6 +210,17 @@ export async function getJumpHopGalleryDetail(publicWorkCode: string) {
return normalizeJumpHopWorkDetailResponse(response);
}
export async function listJumpHopWorks() {
return requestJson<JumpHopWorksResponse>(
JUMP_HOP_WORKS_API_BASE,
{ method: 'GET' },
'读取跳一跳作品列表失败',
{
retry: JUMP_HOP_RUNTIME_READ_RETRY,
},
);
}
export async function publishJumpHopWork(profileId: string) {
const response = await requestJson<JumpHopWorkMutationResponse>(
`${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
@@ -224,22 +234,20 @@ export async function startJumpHopRuntimeRun(
profileId: string,
options: JumpHopRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<JumpHopRunResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify({ profileId }),
},
'启动跳一跳运行态失败',
{
authImpact: options.authImpact,
skipAuth: options.skipAuth,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
...requestOptions,
},
);
}
@@ -247,7 +255,9 @@ export async function startJumpHopRuntimeRun(
export async function submitJumpHopJump(
runId: string,
payload: { chargeMs: number },
options: JumpHopRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
const requestPayload = {
chargeMs: payload.chargeMs,
clientEventId: `jump-${runId}-${Date.now()}`,
@@ -259,26 +269,34 @@ export async function submitJumpHopJump(
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify(requestPayload),
},
'提交跳一跳起跳失败',
requestOptions,
);
}
export async function restartJumpHopRuntimeRun(runId: string) {
export async function restartJumpHopRuntimeRun(
runId: string,
options: JumpHopRuntimeRequestOptions = {},
) {
const requestOptions = buildRuntimeGuestAuthOptions(options);
return requestJson<JumpHopRunResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify({
clientActionId: `restart-${runId}-${Date.now()}`,
}),
},
'重新开始跳一跳失败',
requestOptions,
);
}
@@ -289,6 +307,7 @@ export const jumpHopClient = {
getGalleryDetail: getJumpHopGalleryDetail,
getWorkDetail: getJumpHopWorkDetail,
listGallery: listJumpHopGallery,
listWorks: listJumpHopWorks,
publishWork: publishJumpHopWork,
restartRun: restartJumpHopRuntimeRun,
startRun: startJumpHopRuntimeRun,

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { describe, expect, test } from 'vitest';
import {
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validatePuzzleReferenceImageFile,
} from './puzzleAssetClient';
describe('puzzle reference image upload validation', () => {
test('limits uploads to 6MB', () => {
expect(PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES).toBe(6 * 1024 * 1024);
});
test('rejects files that exceed the upload limit with a precise message', () => {
const file = new File([
'x'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES + 1),
], 'too-large.png', { type: 'image/png' });
expect(() => validatePuzzleReferenceImageFile(file)).toThrow(
'参考图过大,请压缩后再上传(当前 6.0MB,最多 6MB。',
);
});
});

View File

@@ -1,5 +1,9 @@
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
import { requestJson } from '../apiClient';
import {
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validatePuzzleReferenceImageFile,
} from '../puzzleReferenceImage';
export type PuzzleHistoryAsset = {
assetObjectId: string;
@@ -40,8 +44,6 @@ type ConfirmAssetObjectResponse = {
};
};
const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
const MIME_BY_EXTENSION: Record<string, string> = {
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
@@ -58,14 +60,9 @@ function resolvePuzzleImageContentType(file: File) {
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
}
function validatePuzzleReferenceImageFile(file: File) {
function validatePuzzleReferenceImageUploadFile(file: File) {
const contentType = resolvePuzzleImageContentType(file);
if (file.size <= 0) {
throw new Error('参考图文件为空,请重新选择。');
}
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
throw new Error('参考图过大,请压缩后再上传。');
}
validatePuzzleReferenceImageFile(file);
if (!contentType.startsWith('image/')) {
throw new Error('参考图必须是图片文件。');
}
@@ -96,7 +93,7 @@ async function postDirectUploadFile(
export async function uploadPuzzleReferenceImage(payload: {
file: File;
}): Promise<PuzzleReferenceAsset> {
validatePuzzleReferenceImageFile(payload.file);
validatePuzzleReferenceImageUploadFile(payload.file);
const contentType = resolvePuzzleImageContentType(payload.file);
const uploadedAt = Date.now();
const ticket = await requestJson<DirectUploadTicketResponse>(
@@ -157,7 +154,12 @@ export async function uploadPuzzleReferenceImage(payload: {
export const puzzleReferenceAssetTestUtils = {
maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validateFile: validatePuzzleReferenceImageFile,
validateFile: validatePuzzleReferenceImageUploadFile,
};
export {
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validatePuzzleReferenceImageUploadFile as validatePuzzleReferenceImageFile,
};
/**

View File

@@ -92,7 +92,7 @@ describe('readPuzzleReferenceImageAsDataUrl', () => {
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
expect(dataUrl).toBe(`data:image/jpeg;base64,${'C'.repeat(1000)}`);
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1536, 1152);
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1024, 768);
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.84);
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.76);
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.68);
@@ -114,7 +114,7 @@ describe('readPuzzleReferenceImageAsDataUrl', () => {
});
await expect(readPuzzleReferenceImageAsDataUrl(file)).rejects.toThrow(
'参考图过大,请换一张尺寸更小的图片。',
'参考图过大,请压缩后再上传(当前 10.0MB,最多 6MB。',
);
});
});

View File

@@ -1,8 +1,29 @@
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1024;
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
export const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 6 * 1024 * 1024;
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
const PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE = 1;
export function formatPuzzleReferenceImageUploadBytes(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
export function buildPuzzleReferenceImageTooLargeMessage(actualBytes: number) {
return `参考图过大,请压缩后再上传(当前 ${formatPuzzleReferenceImageUploadBytes(actualBytes)},最多 6MB`;
}
export function validatePuzzleReferenceImageFile(file: File) {
if (file.size <= 0) {
throw new Error('参考图文件为空,请重新选择。');
}
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
throw new Error(buildPuzzleReferenceImageTooLargeMessage(file.size));
}
if (file.type.trim() && !file.type.trim().startsWith('image/')) {
throw new Error('参考图必须是图片文件。');
}
}
type PuzzleReferenceImageSize = {
width: number;
height: number;
@@ -36,7 +57,7 @@ function readFileAsDataUrl(file: File) {
function ensureReferenceImageWithinLimit(dataUrl: string) {
if (dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
throw new Error('参考图过大,请换一张尺寸更小的图片。');
throw new Error(buildPuzzleReferenceImageTooLargeMessage(dataUrl.length));
}
return dataUrl;
}
@@ -130,6 +151,7 @@ async function compressReferenceImageDataUrl(file: File, dataUrl: string) {
}
export async function readPuzzleReferenceImageAsDataUrl(file: File) {
validatePuzzleReferenceImageFile(file);
const dataUrl = await readFileAsDataUrl(file);
try {
const compressedDataUrl = await compressReferenceImageDataUrl(
@@ -150,6 +172,7 @@ export async function readPuzzleReferenceImageAsDataUrl(file: File) {
export async function readPuzzleReferenceImageForUpload(
file: File,
): Promise<PuzzleReferenceImageReadResult> {
validatePuzzleReferenceImageFile(file);
const dataUrl = await readFileAsDataUrl(file);
const image = await loadReferenceImage(dataUrl);
const size = resolveReferenceImageNaturalSize(image);

View File

@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiClientMocks = vi.hoisted(() => ({
requestJson: vi.fn(),
}));
vi.mock('./apiClient', async () => {
const actual =
await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
requestJson: apiClientMocks.requestJson,
};
});
import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient';
import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient';
import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient';
import { startPuzzleRun } from './puzzle-runtime/puzzleRuntimeClient';
import { startSquareHoleRun } from './square-hole-runtime/squareHoleRuntimeClient';
import { startVisualNovelRun } from './visual-novel-runtime/visualNovelRuntimeClient';
describe('recommended runtime guest launch clients', () => {
beforeEach(() => {
vi.clearAllMocks();
apiClientMocks.requestJson.mockResolvedValue({ run: {} });
});
it.each([
{
name: 'jump-hop',
start: () =>
startJumpHopRuntimeRun('jump-hop-profile-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/jump-hop/runs',
},
{
name: 'visual-novel',
start: () =>
startVisualNovelRun(
'visual-novel-profile-1',
{ profileId: 'visual-novel-profile-1', mode: 'play' },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/visual-novel/works/visual-novel-profile-1/runs',
},
{
name: 'match3d',
start: () =>
startMatch3DRun('match3d-profile-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/match3d/works/match3d-profile-1/runs',
},
{
name: 'square-hole',
start: () =>
startSquareHoleRun('square-hole-profile-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/square-hole/works/square-hole-profile-1/runs',
},
{
name: 'big-fish',
start: () =>
startBigFishRun('big-fish-session-1', {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/big-fish/sessions/big-fish-session-1/runs',
},
{
name: 'bark-battle',
start: () =>
startBarkBattleRun('bark-battle-work-1', {}, {
runtimeGuestToken: 'runtime-guest-token',
}),
expectedUrl: '/api/runtime/bark-battle/works/bark-battle-work-1/runs',
},
{
name: 'puzzle',
start: () =>
startPuzzleRun(
{ profileId: 'puzzle-profile-1', levelId: 'level-1' },
{ runtimeGuestToken: 'runtime-guest-token' },
),
expectedUrl: '/api/runtime/puzzle/runs',
},
])(
'$name start request uses the runtime guest bearer token without touching login auth',
async ({ start, expectedUrl }) => {
await start();
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
expect(url).toBe(expectedUrl);
expect(init).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer runtime-guest-token',
}),
}),
);
expect(options).toEqual(
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
},
);
});

View File

@@ -0,0 +1,40 @@
import type { ApiRequestOptions } from './apiClient';
export type RuntimeGuestRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipAuth'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
> & {
runtimeGuestToken?: string;
};
export function buildRuntimeGuestHeaders(
options: Pick<RuntimeGuestRequestOptions, 'runtimeGuestToken'>,
headers: Record<string, string> = {},
) {
const runtimeGuestToken = options.runtimeGuestToken?.trim();
if (!runtimeGuestToken) {
return headers;
}
return {
...headers,
Authorization: `Bearer ${runtimeGuestToken}`,
};
}
export function buildRuntimeGuestAuthOptions<
TOptions extends RuntimeGuestRequestOptions,
>(options: TOptions) {
const runtimeGuestToken = options.runtimeGuestToken?.trim();
return {
authImpact: options.authImpact,
skipAuth: runtimeGuestToken ? true : options.skipAuth,
skipRefresh: runtimeGuestToken ? true : options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
} satisfies ApiRequestOptions;
}

View File

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

View File

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

View File

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