diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index ddc0d5c9..e142aa59 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -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" diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index e1b45c6e..b40920eb 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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//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 过大。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index bb3daa40..bd6d441b 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -93,6 +93,8 @@ npm run dev:admin-web `npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-.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 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 5dc32812..ae407fdb 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -47,6 +47,29 @@ - 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t \"puzzle draft generation auto starts trial and runtime back opens draft result\"`,确认 `window.location.pathname === '/runtime/puzzle'` 且 `window.location.search` 同时包含 `runtimeProfileId` 和 `runtimeSessionId`。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/services/puzzleRuntimeUrlState.ts`、`src/routing/appPageRoutes.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 首页推荐分流参数不能条件性调用 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 仍带红色阴影,和平台暖棕体系不一致。 @@ -258,6 +281,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=-`。 @@ -849,6 +880,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 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。 @@ -865,9 +897,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`。 @@ -1006,7 +1038,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 `` 都是 `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`。 diff --git a/AGENTS.md b/AGENTS.md index a2238a6d..01cbf619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 的,不允许进入编码。 + ## 项目约束 - 代码需要有完善的中文注释 - 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 1adf9f40..087e28b9 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 容器 UI:VectorEngine `/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、referenceImageCount 和 imageModel。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 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、referenceImageCount、imageModel 和 rawExcerpt。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 ## SpacetimeDB 表目录 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 2c59f449..62a24ea8 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -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 "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 && spacetime version use `,然后重新启动 `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 --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` 的 `buildPublicWorkCode` 与 `isSamePublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。 +生产 Jenkins 的 `Pipeline script from SCM` 由 Windows controller 读取 Jenkinsfile,SCM 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` 个 5xx,200 请求平均 `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 查看。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index f534dd58..f41bff43 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -10,8 +10,12 @@ 创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页恢复时只认当前进入页的时间作为新的 `startedAtMs`,作品摘要里的 `updatedAt` 只用于排序与摘要展示,不再作为生成进度起点。 +创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 + `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 @@ -95,6 +99,7 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > - 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work` 和 `mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query,避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。 - 结果页生成关卡图时若关卡名为空,前端必须传 `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 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 @@ -127,7 +132,11 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > 平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 -推荐页允许未登录直接游玩跳一跳运行态;`/api/runtime/jump-hop/runs`、`/jump` 和 `/restart` 采用可选鉴权,未登录时仍记录 `work_play_start`,但埋点需标记匿名语义。 +跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。 + +删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。 + +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 ## 敲木鱼 diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index 8ea42153..812a821f 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -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,23 +43,36 @@ pipeline { label 'linux && genarrative-build' } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [ - [$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}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + 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 ' diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 3d23ef02..d26c65fe 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -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,23 +30,36 @@ pipeline { stages { stage('Checkout') { steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [ - [$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}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + 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 ' diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index a6c38a51..918c4c48 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -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; diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 856e04bf..19fafe66 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -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 { diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 0e2762b6..13cbb831 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -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, diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 851e9c07..341cde80 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -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-')); diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index ff327c57..de0181aa 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -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" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 577c61bd..66f2a2db 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -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 } diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index b423be50..2844c4da 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -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 } diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index 35cf5127..1b27e0a1 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -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, + Extension(request_context): Extension, +) -> Result, 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, mut request: Request, @@ -70,29 +146,70 @@ pub async fn require_bearer_auth( Ok(response) } -pub async fn attach_optional_bearer_auth( +pub async fn require_runtime_principal_auth( State(state): State, mut request: Request, next: Next, ) -> Result { - if let Some(authenticated) = authenticate_request(&state, &request)? { - request.extensions_mut().insert(authenticated.clone()); - let mut response = next.run(request).await; - response.extensions_mut().insert(authenticated); - return Ok(response); + let Some(principal) = authenticate_runtime_principal(&state, &request)? else { + return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); + }; + request.extensions_mut().insert(principal.clone()); + + let mut response = next.run(request).await; + response.extensions_mut().insert(principal); + + Ok(response) +} + +fn authenticate_runtime_principal( + state: &AppState, + request: &Request, +) -> Result, AppError> { + if !request.headers().contains_key(AUTHORIZATION) { + return Ok(None); } - Ok(next.run(request).await) + match authenticate_request(state, request) { + Ok(Some(authenticated)) => Ok(Some(RuntimePrincipal::User(authenticated))), + Ok(None) => Ok(None), + Err(_) => { + let bearer_token = extract_bearer_token(request.headers())?; + let request_id = request + .extensions() + .get::() + .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, AppError> { - if allows_internal_forwarded_auth(request.uri().path()) - && let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) - { - return Ok(Some(AuthenticatedAccessToken::new(claims))); + if allows_internal_forwarded_auth(request.uri().path()) { + if let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) { + return Ok(Some(AuthenticatedAccessToken::new(claims))); + } } if !request.headers().contains_key(AUTHORIZATION) { diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs index 2c609792..9c531773 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -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())) diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index 32fd3fcd..85699b70 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -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", "请求参数不合法"), diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 8ad45a0a..32222b53 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -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, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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, Path(profile_id): Path, @@ -176,15 +230,13 @@ pub async fn get_jump_hop_runtime_work( pub async fn start_jump_hop_run( State(state): State, Extension(request_context): Extension, - maybe_authenticated: Option>, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, - maybe_authenticated: Option>, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, - maybe_authenticated: Option>, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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::>(); + 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 { + 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, 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(), diff --git a/server-rs/crates/api-server/src/modules/auth.rs b/server-rs/crates/api-server/src/modules/auth.rs index d3455b39..54513715 100644 --- a/server-rs/crates/api-server/src/modules/auth.rs +++ b/server-rs/crates/api-server/src/modules/auth.rs @@ -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 { 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)) diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs index 42374060..48864e8d 100644 --- a/server-rs/crates/api-server/src/modules/jump_hop.rs +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -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 { 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 { "/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)) diff --git a/server-rs/crates/api-server/src/modules/puzzle.rs b/server-rs/crates/api-server/src/modules/puzzle.rs index fc2e18cb..8cecce64 100644 --- a/server-rs/crates/api-server/src/modules/puzzle.rs +++ b/server-rs/crates/api-server/src/modules/puzzle.rs @@ -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 { "/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)) diff --git a/server-rs/crates/api-server/src/modules/wooden_fish.rs b/server-rs/crates/api-server/src/modules/wooden_fish.rs index f9ad51a3..daef33ad 100644 --- a/server-rs/crates/api-server/src/modules/wooden_fish.rs +++ b/server-rs/crates/api-server/src/modules/wooden_fish.rs @@ -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 { "/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( diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index f9422db4..1c191fb2 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -1,22 +1,30 @@ -use std::{error::Error, time::Duration}; - use axum::http::StatusCode; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use reqwest::header; -use serde_json::{Map, Value, json}; +use platform_image::{ + DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage, + VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, + build_vector_engine_image_request_body, create_vector_engine_image_edit, + create_vector_engine_image_edit_with_references, create_vector_engine_image_generation, + download_remote_image as download_platform_image_remote_image, vector_engine_images_edit_url, + vector_engine_images_generation_url, +}; +use serde_json::{Value, json}; use crate::{ external_api_audit::{ - ExternalApiFailureDraft, app_error_status_class, is_retryable_external_api_failure, + ExternalApiFailureDraft, build_external_api_failure_draft_from_platform_image_audit, record_external_api_failure, }, http_error::AppError, state::AppState, }; -pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; -pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; -const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; +pub(crate) use platform_image::GPT_IMAGE_2_MODEL; +#[cfg(test)] +use platform_image::VECTOR_ENGINE_GPT_IMAGE_2_MODEL; + +pub(crate) type OpenAiGeneratedImages = GeneratedImages; +pub(crate) type DownloadedOpenAiImage = DownloadedImage; +pub(crate) type OpenAiReferenceImage = ReferenceImage; #[derive(Clone)] pub(crate) struct OpenAiImageSettings { @@ -41,28 +49,7 @@ impl std::fmt::Debug for OpenAiImageSettings { } } -#[derive(Clone, Debug)] -pub(crate) struct OpenAiGeneratedImages { - pub task_id: String, - pub actual_prompt: Option, - pub images: Vec, -} - -#[derive(Clone, Debug)] -pub(crate) struct DownloadedOpenAiImage { - pub bytes: Vec, - pub mime_type: String, - pub extension: String, -} - -#[derive(Clone, Debug)] -pub(crate) struct OpenAiReferenceImage { - pub bytes: Vec, - pub mime_type: String, - pub file_name: String, -} - -// 中文注释:RPG、方洞等图片资产统一走后端 VectorEngine GPT-image-2,避免把密钥或供应商协议暴露到前端。 +// 中文注释:api-server 只负责配置、审计和 HTTP envelope,VectorEngine 协议细节统一由 platform-image provider 承接。 pub(crate) fn require_openai_image_settings( state: &AppState, ) -> Result { @@ -104,17 +91,8 @@ pub(crate) fn require_openai_image_settings( pub(crate) fn build_openai_image_http_client( settings: &OpenAiImageSettings, ) -> Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms)) - // 中文注释:参考图会走 multipart edits;强制 HTTP/1.1 可避开部分网关对长耗时上传流的兼容问题。 - .http1_only() - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"), - })) - }) + build_vector_engine_image_http_client(&settings.provider_settings()) + .map_err(map_platform_image_error) } pub(crate) async fn create_openai_image_generation( @@ -127,264 +105,18 @@ pub(crate) async fn create_openai_image_generation( reference_images: &[String], failure_context: &str, ) -> Result { - if !reference_images.is_empty() { - let resolved_references = - resolve_openai_reference_images(http_client, reference_images, failure_context).await?; - return create_openai_image_edit_with_references( - http_client, - settings, - prompt, - negative_prompt, - size, - candidate_count, - resolved_references.as_slice(), - failure_context, - ) - .await; - } - - let request_url = vector_engine_images_generation_url(settings); - let normalized_size = normalize_image_size(size); - let request_body = build_openai_image_request_body( + let result = create_vector_engine_image_generation( + http_client, + &settings.provider_settings(), prompt, negative_prompt, - normalized_size.as_str(), + size, candidate_count, reference_images, - ); - let started_at = std::time::Instant::now(); - let response = match http_client - .post(request_url.as_str()) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .header(header::CONTENT_TYPE, "application/json") - .json(&request_body) - .send() - .await - { - Ok(response) => response, - Err(error) => { - let latency_ms = started_at.elapsed().as_millis() as u64; - let timeout = error.is_timeout(); - let connect = error.is_connect(); - let source = error.source().map(ToString::to_string); - let message = format!("{failure_context}:创建图片生成任务失败:{error}"); - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "request_send", - None, - None, - timeout, - connect, - message.as_str(), - source, - None, - Some(latency_ms), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - ) - .await; - return Err(map_openai_image_reqwest_error( - format!("{failure_context}:创建图片生成任务失败").as_str(), - request_url.as_str(), - error, - )); - } - }; - let response_status = response.status(); - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - status = response_status.as_u16(), - prompt_chars = prompt.chars().count(), - size = %normalized_size, - reference_image_count = reference_images.len(), - elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine 图片生成 HTTP 返回" - ); - let response_text = match response.text().await { - Ok(response_text) => response_text, - Err(error) => { - let latency_ms = started_at.elapsed().as_millis() as u64; - let timeout = error.is_timeout(); - let connect = error.is_connect(); - let source = error.source().map(ToString::to_string); - let message = format!("{failure_context}:读取图片生成响应失败:{error}"); - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "response_body", - Some(response_status.as_u16()), - None, - timeout, - connect, - message.as_str(), - source, - None, - Some(latency_ms), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - ) - .await; - return Err(map_openai_image_reqwest_error( - format!("{failure_context}:读取图片生成响应失败").as_str(), - request_url.as_str(), - error, - )); - } - }; - if !response_status.is_success() { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "upstream_status", - Some(response_status.as_u16()), - None, - false, - false, - parse_api_error_message(response_text.as_str(), failure_context).as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - ) - .await; - return Err(map_openai_image_upstream_error( - response_status.as_u16(), - response_text.as_str(), - failure_context, - )); - } - - let response_json = match parse_json_payload(response_text.as_str(), failure_context) { - Ok(response_json) => response_json, - Err(error) => { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "response_parse", - Some(response_status.as_u16()), - None, - false, - false, - error.body_text().as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - ) - .await; - return Err(error); - } - }; - let generation_id = extract_generation_id(&response_json.payload) - .unwrap_or_else(|| format!("vector-engine-{}", current_utc_micros())); - let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") - .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); - let image_urls = extract_image_urls(&response_json.payload); - if !image_urls.is_empty() { - let download_started_at = std::time::Instant::now(); - let mut generated = match download_images_from_urls( - http_client, - generation_id, - image_urls, - candidate_count, - ) - .await - { - Ok(generated) => generated, - Err(error) => { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "image_download", - Some(response_status.as_u16()), - Some(app_error_status_class(error.status_code())), - false, - false, - error.body_text().as_str(), - None, - None, - Some(download_started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - ) - .await; - return Err(error); - } - }; - generated.actual_prompt = actual_prompt; - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - image_count = generated.images.len(), - elapsed_ms = download_started_at.elapsed().as_millis() as u64, - failure_context, - "VectorEngine 图片下载完成" - ); - return Ok(generated); - } - let b64_images = extract_b64_images(&response_json.payload); - if !b64_images.is_empty() { - let mut generated = images_from_base64(generation_id, b64_images, candidate_count); - generated.actual_prompt = actual_prompt; - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - image_count = generated.images.len(), - failure_context, - "VectorEngine 图片 base64 解码完成" - ); - return Ok(generated); - } - - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "missing_image", - Some(response_status.as_u16()), - None, - false, - false, - format!("{failure_context}:VectorEngine 未返回图片地址").as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), ) .await; - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:VectorEngine 未返回图片地址"), - })), - ) + map_platform_image_result(settings, result).await } pub(crate) async fn create_openai_image_edit( @@ -396,17 +128,17 @@ pub(crate) async fn create_openai_image_edit( reference_image: &OpenAiReferenceImage, failure_context: &str, ) -> Result { - create_openai_image_edit_with_references( + let result = create_vector_engine_image_edit( http_client, - settings, + &settings.provider_settings(), prompt, negative_prompt, size, - 1, - std::slice::from_ref(reference_image), + reference_image, failure_context, ) - .await + .await; + map_platform_image_result(settings, result).await } pub(crate) async fn create_openai_image_edit_with_references( @@ -419,257 +151,27 @@ pub(crate) async fn create_openai_image_edit_with_references( reference_images: &[OpenAiReferenceImage], failure_context: &str, ) -> Result { - if reference_images.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), - })), - ); - } - - let request_url = vector_engine_images_edit_url(settings); - let normalized_size = normalize_image_size(size); - - let mut form = reqwest::multipart::Form::new() - .text("model", GPT_IMAGE_2_MODEL.to_string()) - .text( - "prompt", - build_prompt_with_negative(prompt, negative_prompt), - ) - .text("n", candidate_count.clamp(1, 4).to_string()) - .text("size", normalized_size.clone()); - - for reference_image in reference_images.iter().take(5) { - let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) - .file_name(reference_image.file_name.clone()) - .mime_str(reference_image.mime_type.as_str()) - .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:构造参考图失败:{error}" - )) - })?; - form = form.part("image", image_part); - } - - let reference_image_count = reference_images.iter().take(5).count(); - let started_at = std::time::Instant::now(); - let response = match http_client - .post(request_url.as_str()) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .multipart(form) - .send() - .await - { - Ok(response) => response, - Err(error) => { - let latency_ms = started_at.elapsed().as_millis() as u64; - let timeout = error.is_timeout(); - let connect = error.is_connect(); - let source = error.source().map(ToString::to_string); - let message = format!("{failure_context}:创建图片编辑任务失败:{error}"); - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "request_send", - None, - None, - timeout, - connect, - message.as_str(), - source, - None, - Some(latency_ms), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(map_openai_image_reqwest_error( - format!("{failure_context}:创建图片编辑任务失败").as_str(), - request_url.as_str(), - error, - )); - } - }; - let response_status = response.status(); - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - status = response_status.as_u16(), - prompt_chars = prompt.chars().count(), - size = %normalized_size, - reference_image_count, - elapsed_ms = started_at.elapsed().as_millis() as u64, + let result = create_vector_engine_image_edit_with_references( + http_client, + &settings.provider_settings(), + prompt, + negative_prompt, + size, + candidate_count, + reference_images, failure_context, - "VectorEngine 图片编辑 HTTP 返回" - ); - let response_text = match response.text().await { - Ok(response_text) => response_text, - Err(error) => { - let latency_ms = started_at.elapsed().as_millis() as u64; - let timeout = error.is_timeout(); - let connect = error.is_connect(); - let source = error.source().map(ToString::to_string); - let message = format!("{failure_context}:读取图片编辑响应失败:{error}"); - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "response_body", - Some(response_status.as_u16()), - None, - timeout, - connect, - message.as_str(), - source, - None, - Some(latency_ms), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(map_openai_image_reqwest_error( - format!("{failure_context}:读取图片编辑响应失败").as_str(), - request_url.as_str(), - error, - )); - } - }; - if !response_status.is_success() { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "upstream_status", - Some(response_status.as_u16()), - None, - false, - false, - parse_api_error_message(response_text.as_str(), failure_context).as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(map_openai_image_upstream_error( - response_status.as_u16(), - response_text.as_str(), - failure_context, - )); - } - - let response_json = match parse_json_payload(response_text.as_str(), failure_context) { - Ok(response_json) => response_json, - Err(error) => { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "response_parse", - Some(response_status.as_u16()), - None, - false, - false, - error.body_text().as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(error); - } - }; - let task_id = extract_generation_id(&response_json.payload) - .unwrap_or_else(|| format!("vector-engine-edit-{}", current_utc_micros())); - let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") - .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); - let image_urls = extract_image_urls(&response_json.payload); - if !image_urls.is_empty() { - let download_started_at = std::time::Instant::now(); - let mut generated = match download_images_from_urls( - http_client, - task_id, - image_urls, - candidate_count, - ) - .await - { - Ok(generated) => generated, - Err(error) => { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "image_download", - Some(response_status.as_u16()), - Some(app_error_status_class(error.status_code())), - false, - false, - error.body_text().as_str(), - None, - None, - Some(download_started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(error); - } - }; - generated.actual_prompt = actual_prompt; - return Ok(generated); - } - let b64_images = extract_b64_images(&response_json.payload); - if !b64_images.is_empty() { - let mut generated = images_from_base64(task_id, b64_images, candidate_count); - generated.actual_prompt = actual_prompt; - return Ok(generated); - } - - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "missing_image", - Some(response_status.as_u16()), - None, - false, - false, - format!("{failure_context}:VectorEngine 未返回编辑图片").as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_image_count), - ), ) .await; - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:VectorEngine 未返回图片"), - })), - ) + map_platform_image_result(settings, result).await +} + +pub(crate) async fn download_remote_image( + http_client: &reqwest::Client, + image_url: &str, +) -> Result { + download_platform_image_remote_image(http_client, image_url) + .await + .map_err(map_platform_image_error) } pub(crate) fn build_openai_image_request_body( @@ -677,538 +179,136 @@ pub(crate) fn build_openai_image_request_body( negative_prompt: Option<&str>, size: &str, candidate_count: u32, - _reference_images: &[String], -) -> Value { - let body = Map::from_iter([ - ( - "model".to_string(), - Value::String(GPT_IMAGE_2_MODEL.to_string()), - ), - ( - "prompt".to_string(), - Value::String(build_prompt_with_negative(prompt, negative_prompt)), - ), - ("n".to_string(), json!(candidate_count.clamp(1, 4))), - ( - "size".to_string(), - Value::String(normalize_image_size(size)), - ), - ]); - - Value::Object(body) -} - -fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String { - let prompt = prompt.trim(); - let Some(negative_prompt) = negative_prompt - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return prompt.to_string(); - }; - - format!("{prompt}\n避免:{negative_prompt}") -} - -fn normalize_image_size(size: &str) -> String { - match size.trim() { - "1024*1024" | "1024x1024" | "1:1" => "1024x1024", - "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152" - | "2k" => "1536x1024", - "1024*1536" | "1024x1536" | "9:16" => "1024x1536", - value if !value.is_empty() => value, - _ => "1024x1024", - } - .to_string() -} - -async fn download_images_from_urls( - http_client: &reqwest::Client, - task_id: String, - image_urls: Vec, - candidate_count: u32, -) -> Result { - let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize); - for image_url in image_urls - .into_iter() - .take(candidate_count.clamp(1, 4) as usize) - { - images.push(download_remote_image(http_client, image_url.as_str()).await?); - } - Ok(OpenAiGeneratedImages { - task_id, - actual_prompt: None, - images, - }) -} - -fn images_from_base64( - task_id: String, - b64_images: Vec, - candidate_count: u32, -) -> OpenAiGeneratedImages { - let images = b64_images - .into_iter() - .take(candidate_count.clamp(1, 4) as usize) - .filter_map(|raw| decode_generated_image_base64(raw.as_str())) - .collect(); - - OpenAiGeneratedImages { - task_id, - actual_prompt: None, - images, - } -} - -fn decode_generated_image_base64(raw: &str) -> Option { - let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; - let mime_type = infer_image_mime_type(bytes.as_slice()); - Some(DownloadedOpenAiImage { - extension: mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes, - }) -} - -pub(crate) async fn download_remote_image( - http_client: &reqwest::Client, - image_url: &str, -) -> Result { - let response = - http_client.get(image_url).send().await.map_err(|error| { - map_openai_image_request_error(format!("下载生成图片失败:{error}")) - })?; - let status = response.status(); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/jpeg") - .to_string(); - let body = response.bytes().await.map_err(|error| { - map_openai_image_request_error(format!("读取生成图片内容失败:{error}")) - })?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "下载生成图片失败", - "status": status.as_u16(), - })), - ); - } - - let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); - Ok(DownloadedOpenAiImage { - extension: mime_to_extension(normalized_mime_type.as_str()).to_string(), - mime_type: normalized_mime_type, - bytes: body.to_vec(), - }) -} - -async fn resolve_openai_reference_images( - http_client: &reqwest::Client, reference_images: &[String], - failure_context: &str, -) -> Result, AppError> { - let mut resolved = Vec::new(); - for (index, source) in reference_images.iter().take(5).enumerate() { - let source = source.trim(); - if source.is_empty() { - continue; - } - if let Some(reference_image) = parse_openai_reference_image_data_url(source, index)? { - resolved.push(reference_image); - continue; - } - if source.starts_with("http://") || source.starts_with("https://") { - let downloaded = download_remote_image(http_client, source) - .await - .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:下载参考图失败:{}", - error.body_text() - )) - })?; - resolved.push(OpenAiReferenceImage { - bytes: downloaded.bytes, - mime_type: downloaded.mime_type.clone(), - file_name: format!( - "reference-{index}.{}", - mime_to_extension(downloaded.mime_type.as_str()) - ), - }); - continue; - } - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"), - })), - ); - } - - if resolved.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:图片编辑需要至少一张参考图。"), - })), - ); - } - - Ok(resolved) -} - -fn parse_openai_reference_image_data_url( - source: &str, - index: usize, -) -> Result, AppError> { - let Some(body) = source.strip_prefix("data:") else { - return Ok(None); - }; - let Some((mime_type, data)) = body.split_once(";base64,") else { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "参考图 Data URL 必须是 base64 图片。", - })), - ); - }; - if !mime_type.starts_with("image/") { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "参考图 Data URL 必须是图片类型。", - })), - ); - } - let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("参考图 Data URL 解码失败:{error}"), - })) - })?; - let mime_type = normalize_downloaded_image_mime_type(mime_type); - Ok(Some(OpenAiReferenceImage { - bytes, - file_name: format!( - "reference-{index}.{}", - mime_to_extension(mime_type.as_str()) - ), - mime_type, - })) -} - -fn parse_json_payload( - raw_text: &str, - failure_context: &str, -) -> Result { - serde_json::from_str::(raw_text) - .map(|payload| ParsedJsonPayload { payload }) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:解析响应失败:{error}"), - "rawExcerpt": truncate_raw(raw_text), - })) - }) -} - -fn map_openai_image_request_error(message: String) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message, - })) -} - -fn map_openai_image_reqwest_error( - context: &str, - request_url: &str, - error: reqwest::Error, -) -> AppError { - let is_timeout = error.is_timeout(); - let is_connect = error.is_connect(); - let source = error.source().map(ToString::to_string).unwrap_or_default(); - let message = format!("{context}:{error}"); - let status = if is_timeout { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - timeout = is_timeout, - connect = is_connect, - request = error.is_request(), - body = error.is_body(), - source = %source, - message = %message, - "VectorEngine 图片请求发送失败" - ); - - AppError::from_status(status).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message, - "endpoint": request_url, - "timeout": is_timeout, - "connect": is_connect, - "request": error.is_request(), - "body": error.is_body(), - "source": source, - })) -} - -fn map_openai_image_upstream_error( - upstream_status: u16, - raw_text: &str, - failure_context: &str, -) -> AppError { - let message = parse_api_error_message(raw_text, failure_context); - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - upstream_status, - raw_excerpt = %truncate_raw(raw_text), - message, - "VectorEngine 图片生成上游错误" - ); - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message, - "upstreamStatus": upstream_status, - "rawExcerpt": truncate_raw(raw_text), - })) -} - -async fn record_openai_image_failure_if_configured( - settings: &OpenAiImageSettings, - draft: ExternalApiFailureDraft, -) { - if let Some(state) = settings.external_api_audit_state.as_ref() { - record_external_api_failure(state, draft).await; - } -} - -fn build_openai_image_failure_audit_draft( - request_url: &str, - failure_context: &str, - failure_stage: &'static str, - status_code: Option, - status_class: Option<&'static str>, - timeout: bool, - connect: bool, - error_message: &str, - error_source: Option, - raw_excerpt: Option, - latency_ms: Option, - prompt_chars: Option, - reference_image_count: Option, -) -> ExternalApiFailureDraft { - ExternalApiFailureDraft::new( - VECTOR_ENGINE_PROVIDER, - request_url.to_string(), - failure_context.to_string(), - failure_stage, - error_message.to_string(), +) -> Value { + build_vector_engine_image_request_body( + prompt, + negative_prompt, + size, + candidate_count, + reference_images, ) - .with_status_code(status_code) - .with_optional_status_class(status_class) - .with_timeout(timeout) - .with_retryable(is_retryable_external_api_failure( - status_code, - timeout, - connect, - )) - .with_error_source(error_source) - .with_raw_excerpt(raw_excerpt) - .with_latency_ms(latency_ms) - .with_prompt_chars(prompt_chars) - .with_reference_image_count(reference_image_count) - .with_image_model(Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL)) } -fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { - if raw_text.trim().is_empty() { - return fallback_message.to_string(); - } - - if let Ok(parsed) = serde_json::from_str::(raw_text) { - for pointer in [ - "/error/message", - "/message", - "/output/message", - "/data/message", - ] { - if let Some(message) = parsed - .pointer(pointer) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return message.to_string(); - } - } - for pointer in ["/error/code", "/code", "/output/code", "/data/code"] { - if let Some(code) = parsed - .pointer(pointer) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return format!("{fallback_message}({code})"); - } +impl OpenAiImageSettings { + fn provider_settings(&self) -> VectorEngineImageSettings { + VectorEngineImageSettings { + base_url: self.base_url.clone(), + api_key: self.api_key.clone(), + request_timeout_ms: self.request_timeout_ms.max(1), } } - - raw_text.trim().to_string() } -fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { - match value { - Value::Array(entries) => { - for entry in entries { - collect_strings_by_key(entry, target_key, results); - } - } - Value::Object(object) => { - for (key, nested_value) in object { - if key == target_key { - match nested_value { - Value::String(text) => { - let text = text.trim(); - if !text.is_empty() { - results.push(text.to_string()); - continue; - } - } - Value::Array(entries) => { - for entry in entries { - if let Some(text) = entry - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - results.push(text.to_string()); - } - } - } - _ => {} - } - } - collect_strings_by_key(nested_value, target_key, results); - } - } - _ => {} - } -} - -fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { - let mut results = Vec::new(); - collect_strings_by_key(value, target_key, &mut results); - results.into_iter().next() -} - -fn extract_generation_id(payload: &Value) -> Option { - find_first_string_by_key(payload, "id") - .or_else(|| find_first_string_by_key(payload, "created")) - .or_else(|| find_first_string_by_key(payload, "request_id")) -} - -fn extract_image_urls(payload: &Value) -> Vec { - let mut urls = Vec::new(); - collect_strings_by_key(payload, "url", &mut urls); - collect_strings_by_key(payload, "image", &mut urls); - collect_strings_by_key(payload, "image_url", &mut urls); - let mut deduped = Vec::new(); - for url in urls { - if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { - deduped.push(url); +async fn map_platform_image_result( + settings: &OpenAiImageSettings, + result: Result, +) -> Result { + match result { + Ok(value) => Ok(value), + Err(error) => { + record_openai_image_failure_if_configured(settings, &error).await; + Err(map_platform_image_error(error)) } } - deduped } -fn extract_b64_images(payload: &Value) -> Vec { - let mut values = Vec::new(); - collect_strings_by_key(payload, "b64_json", &mut values); - values +pub(crate) async fn record_openai_image_failure_if_configured( + settings: &OpenAiImageSettings, + error: &PlatformImageError, +) { + let Some(state) = settings.external_api_audit_state.as_ref() else { + return; + }; + let Some(draft) = build_openai_image_failure_audit_draft(error) else { + return; + }; + record_external_api_failure(state, draft).await; } -fn vector_engine_images_generation_url(settings: &OpenAiImageSettings) -> 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 build_openai_image_failure_audit_draft( + error: &PlatformImageError, +) -> Option { + error + .audit() + .map(build_external_api_failure_draft_from_platform_image_audit) } -fn vector_engine_images_edit_url(settings: &OpenAiImageSettings) -> String { - if settings.base_url.ends_with("/v1") { - format!("{}/images/edits", settings.base_url) - } else { - format!("{}/v1/images/edits", settings.base_url) - } -} +pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError { + let status = match error.status_hint() { + PlatformImageStatusHint::BadRequest => StatusCode::BAD_REQUEST, + PlatformImageStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE, + PlatformImageStatusHint::BadGateway => StatusCode::BAD_GATEWAY, + PlatformImageStatusHint::GatewayTimeout => StatusCode::GATEWAY_TIMEOUT, + }; -fn normalize_downloaded_image_mime_type(content_type: &str) -> String { - let mime_type = content_type - .split(';') - .next() - .map(str::trim) - .unwrap_or("image/jpeg"); - match mime_type { - "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { - mime_type.to_string() + let mut details = json!({ + "provider": error.provider(), + "message": error.message(), + }); + + match &error { + PlatformImageError::InvalidConfig { .. } | PlatformImageError::InvalidRequest { .. } => {} + PlatformImageError::Request { + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + .. + } => { + details["endpoint"] = json!(endpoint); + details["timeout"] = json!(timeout); + details["connect"] = json!(connect); + details["request"] = json!(request); + details["body"] = json!(body); + details["status"] = json!(status_code); + details["source"] = json!(source); } - _ => "image/jpeg".to_string(), + PlatformImageError::Upstream { + upstream_status, + raw_excerpt, + .. + } => { + details["upstreamStatus"] = json!(upstream_status); + details["rawExcerpt"] = json!(raw_excerpt); + } + PlatformImageError::ResponseParse { raw_excerpt, .. } => { + details["rawExcerpt"] = json!(raw_excerpt); + } + PlatformImageError::MissingImage { .. } => {} } + + if let Some(audit) = error.audit() { + details["endpoint"] = json!(audit.endpoint); + details["failureStage"] = json!(audit.failure_stage); + details["statusClass"] = json!(audit.status_class); + details["retryable"] = json!(audit.retryable); + details["timeout"] = json!(audit.timeout); + details["latencyMs"] = json!(audit.latency_ms); + details["promptChars"] = json!(audit.prompt_chars); + details["referenceImageCount"] = json!(audit.reference_image_count); + details["imageModel"] = json!(audit.image_model); + details["rawExcerpt"] = json!(audit.raw_excerpt); + } + + AppError::from_status(status).with_details(details) } -fn mime_to_extension(mime_type: &str) -> &str { - match mime_type { - "image/png" => "png", - "image/webp" => "webp", - "image/gif" => "gif", - _ => "jpg", - } +fn vector_engine_images_generation_url_for_test(settings: &OpenAiImageSettings) -> String { + vector_engine_images_generation_url(&settings.provider_settings()) } -fn infer_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() -} - -fn truncate_raw(raw_text: &str) -> String { - raw_text.chars().take(800).collect() -} - -fn current_utc_micros() -> i64 { - use std::time::{SystemTime, UNIX_EPOCH}; - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch"); - i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") -} - -struct ParsedJsonPayload { - payload: Value, +fn vector_engine_images_edit_url_for_test(settings: &OpenAiImageSettings) -> String { + vector_engine_images_edit_url(&settings.provider_settings()) } #[cfg(test)] mod tests { use super::*; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; #[test] fn gpt_image_2_generation_request_uses_create_model_without_reference_images() { @@ -1244,11 +344,11 @@ mod tests { }; assert_eq!( - vector_engine_images_generation_url(&root_settings), + vector_engine_images_generation_url_for_test(&root_settings), "https://vector.example/v1/images/generations" ); assert_eq!( - vector_engine_images_generation_url(&v1_settings), + vector_engine_images_generation_url_for_test(&v1_settings), "https://vector.example/v1/images/generations" ); } @@ -1269,11 +369,11 @@ mod tests { }; assert_eq!( - vector_engine_images_edit_url(&root_settings), + vector_engine_images_edit_url_for_test(&root_settings), "https://vector.example/v1/images/edits" ); assert_eq!( - vector_engine_images_edit_url(&v1_settings), + vector_engine_images_edit_url_for_test(&v1_settings), "https://vector.example/v1/images/edits" ); } @@ -1306,51 +406,38 @@ mod tests { } #[test] - fn reference_data_url_resolves_to_edit_image_part() { + fn reference_data_url_stays_provider_owned() { let source = format!( "data:image/png;base64,{}", BASE64_STANDARD.encode(b"pngbytes") ); - let image = parse_openai_reference_image_data_url(source.as_str(), 2) - .expect("data url should parse") - .expect("data url should resolve image"); + let body = build_openai_image_request_body("提示词", None, "1:1", 1, &[source]); - assert_eq!(image.bytes, b"pngbytes"); - assert_eq!(image.mime_type, "image/png"); - assert_eq!(image.file_name, "reference-2.png"); - } - - #[test] - fn b64_json_response_decodes_png_image() { - let images = images_from_base64( - "task-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"); + assert!(body.get("image").is_none()); } #[test] fn vector_engine_upstream_failure_builds_tracking_ready_audit_event() { - let audit = build_openai_image_failure_audit_draft( - "https://vector.example/v1/images/generations", - "拼图 UI 背景图生成失败", - "upstream_status", - Some(429), - None, - false, - false, - "上游限流", - None, - Some("{\"error\":\"rate limited\"}".to_string()), - Some(321), - Some(42), - Some(1), + let audit = platform_image::PlatformImageFailureAudit { + provider: VECTOR_ENGINE_PROVIDER, + endpoint: "https://vector.example/v1/images/generations".to_string(), + operation: "拼图 UI 背景图生成失败".to_string(), + failure_stage: "upstream_status", + status_code: Some(429), + status_class: None, + timeout: false, + retryable: true, + error_message: "上游限流".to_string(), + error_source: None, + raw_excerpt: Some("{\"error\":\"rate limited\"}".to_string()), + latency_ms: Some(321), + prompt_chars: Some(42), + reference_image_count: Some(1), + image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + }; + let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft( + &build_external_api_failure_draft_from_platform_image_audit(&audit), ); - let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(&audit); assert_eq!( tracking.event_key, diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 84dfac4f..67453bca 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -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元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容"; diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 63be5836..46834284 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -1666,7 +1666,7 @@ pub async fn remix_puzzle_gallery_work( pub async fn start_puzzle_run( State(state): State, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, 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, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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), diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index e0a780da..d4bca634 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -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); diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 85ed78c1..6b575f6a 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -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, } 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 { - 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, + let generated = create_openai_image_generation( + http_client, + &settings.to_openai_settings(), prompt, - negative_prompt, + Some(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( - http_client, - format!("vector-engine-{}", current_utc_micros()), - image_urls, - candidate_count, - ) - .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 图片生成未返回图片地址", - })), + &[], + "拼图 VectorEngine 图片生成失败", ) + .await?; + + 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 { - 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 图片编辑未返回图片", - })), + candidate_count, + &[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, - candidate_count: u32, -) -> Result { - 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!({ - "provider": "puzzle", - "field": "referenceImageSrc", - "message": "参考图过大,请压缩后重试。", - "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, - "actualBytes": bytes_len, - })), - ); + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": build_puzzle_reference_image_too_large_message(bytes_len), + "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, + "actualBytes": bytes_len, + }))); } return Ok(PuzzleResolvedReferenceImage { mime_type: parsed.mime_type, @@ -803,16 +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!({ - "provider": "asset-object", - "field": "referenceImageAssetObjectId", - "assetObjectId": asset_object.asset_object_id, - "message": "参考图资产大小不符合拼图生成要求。", - "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, - "actualBytes": asset_object.content_length, - })), - ); + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": build_puzzle_reference_image_too_large_message( + asset_object.content_length as usize, + ), + "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, + "actualBytes": asset_object.content_length, + }))); } if let Some(expected_owner_user_id) = owner_user_id .map(str::trim) @@ -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 { - 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 { - serde_json::from_str::(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 { 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> { Some(output) } -pub(crate) fn extract_puzzle_image_urls(payload: &Value) -> Vec { - 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 { - 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, - 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 { - 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 { 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 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::(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::>().join(" "); - if normalized.chars().count() <= max_chars { - return normalized; - } - - let keep_chars = max_chars.saturating_sub(3); - format!( - "{}...", - normalized.chars().take(keep_chars).collect::() - ) -} - pub(crate) fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError { map_oss_error(error, "aliyun-oss") } diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index e254f3aa..4eedff39 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -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, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, 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 diff --git a/server-rs/crates/api-server/src/work_play_tracking.rs b/server-rs/crates/api-server/src/work_play_tracking.rs index f443b1e1..f16d5595 100644 --- a/server-rs/crates/api-server/src/work_play_tracking.rs +++ b/server-rs/crates/api-server/src/work_play_tracking.rs @@ -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, + 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( diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index da9221b6..9e2a657a 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -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 { + 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 { 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 { + 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 { let token = token.trim(); if token.is_empty() { @@ -1552,6 +1662,35 @@ pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result Result { + 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::( + 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(); diff --git a/server-rs/crates/platform-image/Cargo.toml b/server-rs/crates/platform-image/Cargo.toml new file mode 100644 index 00000000..cafad647 --- /dev/null +++ b/server-rs/crates/platform-image/Cargo.toml @@ -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 } diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs new file mode 100644 index 00000000..0c6daf44 --- /dev/null +++ b/server-rs/crates/platform-image/src/lib.rs @@ -0,0 +1,1362 @@ +use std::{error::Error, fmt, time::Duration}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use reqwest::header; +use serde_json::{Map, Value, json}; + +pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; +pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; +pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; + +#[derive(Clone, Debug)] +pub struct VectorEngineImageSettings { + pub base_url: String, + pub api_key: String, + pub request_timeout_ms: u64, +} + +#[derive(Clone, Debug)] +pub struct GeneratedImages { + pub task_id: String, + pub actual_prompt: Option, + pub images: Vec, +} + +#[derive(Clone, Debug)] +pub struct DownloadedImage { + pub bytes: Vec, + pub mime_type: String, + pub extension: String, +} + +#[derive(Clone, Debug)] +pub struct ReferenceImage { + pub bytes: Vec, + pub mime_type: String, + pub file_name: String, +} + +#[derive(Clone, Debug)] +pub struct PlatformImageFailureAudit { + pub provider: &'static str, + pub endpoint: String, + pub operation: String, + pub failure_stage: &'static str, + pub status_code: Option, + pub status_class: Option<&'static str>, + pub timeout: bool, + pub retryable: bool, + pub error_message: String, + pub error_source: Option, + pub raw_excerpt: Option, + pub latency_ms: Option, + pub prompt_chars: Option, + pub reference_image_count: Option, + pub image_model: Option<&'static str>, +} + +#[derive(Clone, Debug)] +pub enum PlatformImageError { + InvalidConfig { + provider: &'static str, + message: String, + }, + InvalidRequest { + provider: &'static str, + message: String, + }, + Request { + provider: &'static str, + message: String, + endpoint: Option, + timeout: bool, + connect: bool, + request: bool, + body: bool, + status_code: Option, + source: Option, + audit: Option, + }, + Upstream { + provider: &'static str, + message: String, + upstream_status: u16, + raw_excerpt: String, + audit: Option, + }, + ResponseParse { + provider: &'static str, + message: String, + raw_excerpt: String, + audit: Option, + }, + MissingImage { + provider: &'static str, + message: String, + audit: Option, + }, +} + +impl PlatformImageError { + pub fn provider(&self) -> &'static str { + match self { + Self::InvalidConfig { provider, .. } + | Self::InvalidRequest { provider, .. } + | Self::Request { provider, .. } + | Self::Upstream { provider, .. } + | Self::ResponseParse { provider, .. } + | Self::MissingImage { provider, .. } => provider, + } + } + + pub fn message(&self) -> &str { + match self { + Self::InvalidConfig { message, .. } + | Self::InvalidRequest { message, .. } + | Self::Request { message, .. } + | Self::Upstream { message, .. } + | Self::ResponseParse { message, .. } + | Self::MissingImage { message, .. } => message, + } + } + + pub fn audit(&self) -> Option<&PlatformImageFailureAudit> { + match self { + Self::Request { audit, .. } + | Self::Upstream { audit, .. } + | Self::ResponseParse { audit, .. } + | Self::MissingImage { audit, .. } => audit.as_ref(), + Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => None, + } + } + + pub fn status_hint(&self) -> PlatformImageStatusHint { + match self { + Self::InvalidConfig { .. } => PlatformImageStatusHint::ServiceUnavailable, + Self::InvalidRequest { .. } => PlatformImageStatusHint::BadRequest, + Self::Request { timeout, .. } if *timeout => PlatformImageStatusHint::GatewayTimeout, + Self::Upstream { message, raw_excerpt, .. } + if is_timeout_message(message) || is_timeout_message(raw_excerpt) => + { + PlatformImageStatusHint::GatewayTimeout + } + Self::Request { .. } + | Self::Upstream { .. } + | Self::ResponseParse { .. } + | Self::MissingImage { .. } => PlatformImageStatusHint::BadGateway, + } + } +} + +impl fmt::Display for PlatformImageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message()) + } +} + +impl Error for PlatformImageError {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlatformImageStatusHint { + BadRequest, + ServiceUnavailable, + BadGateway, + GatewayTimeout, +} + +pub fn build_vector_engine_image_http_client( + settings: &VectorEngineImageSettings, +) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) + .http1_only() + .build() + .map_err(|error| PlatformImageError::InvalidConfig { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"), + }) +} + +pub async fn create_vector_engine_image_generation( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[String], + failure_context: &str, +) -> Result { + if !reference_images.is_empty() { + let resolved_references = + resolve_reference_images(http_client, reference_images, failure_context).await?; + return create_vector_engine_image_edit_with_references( + http_client, + settings, + prompt, + negative_prompt, + size, + candidate_count, + resolved_references.as_slice(), + failure_context, + ) + .await; + } + + let request_url = vector_engine_images_generation_url(settings); + let normalized_size = normalize_image_size(size); + let request_body = build_vector_engine_image_request_body( + prompt, + negative_prompt, + normalized_size.as_str(), + candidate_count, + reference_images, + ); + let started_at = std::time::Instant::now(); + let response = match http_client + .post(request_url.as_str()) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .header(header::CONTENT_TYPE, "application/json") + .json(&request_body) + .send() + .await + { + Ok(response) => response, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:创建图片生成任务失败").as_str(), + request_url.as_str(), + "request_send", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + )); + } + }; + let response_status = response.status(); + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status.as_u16(), + prompt_chars = prompt.chars().count(), + size = %normalized_size, + reference_image_count = reference_images.len(), + elapsed_ms = started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片生成 HTTP 返回" + ); + let response_text = match response.text().await { + Ok(response_text) => response_text, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:读取图片生成响应失败").as_str(), + request_url.as_str(), + "response_body", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + )); + } + }; + handle_vector_engine_response( + http_client, + request_url.as_str(), + response_status.as_u16(), + response_text.as_str(), + failure_context, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + candidate_count, + "vector-engine", + ) + .await +} + +pub async fn create_vector_engine_image_edit( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + reference_image: &ReferenceImage, + failure_context: &str, +) -> Result { + create_vector_engine_image_edit_with_references( + http_client, + settings, + prompt, + negative_prompt, + size, + 1, + std::slice::from_ref(reference_image), + failure_context, + ) + .await +} + +pub async fn create_vector_engine_image_edit_with_references( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[ReferenceImage], + failure_context: &str, +) -> Result { + if reference_images.is_empty() { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), + }); + } + + let request_url = vector_engine_images_edit_url(settings); + let normalized_size = normalize_image_size(size); + + let mut form = reqwest::multipart::Form::new() + .text("model", GPT_IMAGE_2_MODEL.to_string()) + .text("prompt", build_prompt_with_negative(prompt, negative_prompt)) + .text("n", candidate_count.clamp(1, 4).to_string()) + .text("size", normalized_size.clone()); + + for reference_image in reference_images.iter().take(5) { + let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) + .file_name(reference_image.file_name.clone()) + .mime_str(reference_image.mime_type.as_str()) + .map_err(|error| PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:构造参考图失败:{error}"), + })?; + form = form.part("image", image_part); + } + + let reference_image_count = reference_images.iter().take(5).count(); + let started_at = std::time::Instant::now(); + let response = match http_client + .post(request_url.as_str()) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .multipart(form) + .send() + .await + { + Ok(response) => response, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:创建图片编辑任务失败").as_str(), + request_url.as_str(), + "request_send", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + )); + } + }; + let response_status = response.status(); + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status.as_u16(), + prompt_chars = prompt.chars().count(), + size = %normalized_size, + reference_image_count, + elapsed_ms = started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片编辑 HTTP 返回" + ); + let response_text = match response.text().await { + Ok(response_text) => response_text, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:读取图片编辑响应失败").as_str(), + request_url.as_str(), + "response_body", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + )); + } + }; + handle_vector_engine_response( + http_client, + request_url.as_str(), + response_status.as_u16(), + response_text.as_str(), + failure_context, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + candidate_count, + "vector-engine-edit", + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn handle_vector_engine_response( + http_client: &reqwest::Client, + request_url: &str, + response_status: u16, + response_text: &str, + failure_context: &str, + latency_ms: u64, + prompt_chars: Option, + reference_image_count: Option, + candidate_count: u32, + task_prefix: &str, +) -> Result { + if !(200..=299).contains(&response_status) { + let message = parse_api_error_message(response_text, failure_context); + let raw_excerpt = truncate_raw(response_text); + let audit = build_failure_audit( + request_url, + failure_context, + "upstream_status", + Some(response_status), + None, + false, + false, + message.as_str(), + None, + Some(raw_excerpt.clone()), + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + upstream_status = response_status, + timeout = is_timeout_message(message.as_str()) || is_timeout_message(raw_excerpt.as_str()), + retryable = audit.retryable, + message = %message, + raw_excerpt = %raw_excerpt, + "VectorEngine 图片生成上游错误" + ); + return Err(PlatformImageError::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message, + upstream_status: response_status, + raw_excerpt, + audit: Some(audit), + }); + } + + let response_json = match parse_json_payload(response_text, failure_context) { + Ok(response_json) => response_json, + Err(error) => { + let audit = build_failure_audit( + request_url, + failure_context, + "response_parse", + Some(response_status), + None, + false, + false, + error.message(), + None, + Some(truncate_raw(response_text)), + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status, + raw_excerpt = %truncate_raw(response_text), + message = %error.message(), + "VectorEngine 图片响应解析失败" + ); + return Err(error.with_audit(audit)); + } + }; + let task_id = extract_generation_id(&response_json.payload) + .unwrap_or_else(|| format!("{task_prefix}-{}", current_utc_micros())); + let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") + .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); + let image_urls = extract_image_urls(&response_json.payload); + if !image_urls.is_empty() { + let download_started_at = std::time::Instant::now(); + let mut generated = match download_images_from_urls( + http_client, + task_id, + image_urls, + candidate_count, + ) + .await + { + Ok(generated) => generated, + Err(error) => { + let audit = build_failure_audit( + request_url, + failure_context, + "image_download", + Some(response_status), + Some("5xx"), + false, + false, + error.message(), + None, + None, + Some(download_started_at.elapsed().as_millis() as u64), + prompt_chars, + reference_image_count, + ); + return Err(error.with_audit(audit)); + } + }; + generated.actual_prompt = actual_prompt; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + image_count = generated.images.len(), + elapsed_ms = download_started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片下载完成" + ); + return Ok(generated); + } + let b64_images = extract_b64_images(&response_json.payload); + if !b64_images.is_empty() { + let mut generated = images_from_base64(task_id, b64_images, candidate_count); + generated.actual_prompt = actual_prompt; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + image_count = generated.images.len(), + failure_context, + "VectorEngine 图片 base64 解码完成" + ); + return Ok(generated); + } + + let message = format!("{failure_context}:VectorEngine 未返回图片地址"); + let audit = build_failure_audit( + request_url, + failure_context, + "missing_image", + Some(response_status), + None, + false, + false, + message.as_str(), + None, + Some(truncate_raw(response_text)), + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status, + raw_excerpt = %truncate_raw(response_text), + "VectorEngine 图片响应未返回图片" + ); + Err(PlatformImageError::MissingImage { + provider: VECTOR_ENGINE_PROVIDER, + message, + audit: Some(audit), + }) +} + +pub fn build_vector_engine_image_request_body( + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + _reference_images: &[String], +) -> Value { + let body = Map::from_iter([ + ( + "model".to_string(), + Value::String(GPT_IMAGE_2_MODEL.to_string()), + ), + ( + "prompt".to_string(), + Value::String(build_prompt_with_negative(prompt, negative_prompt)), + ), + ("n".to_string(), json!(candidate_count.clamp(1, 4))), + ( + "size".to_string(), + Value::String(normalize_image_size(size)), + ), + ]); + + Value::Object(body) +} + +pub fn normalize_image_size(size: &str) -> String { + match size.trim() { + "1024*1024" | "1024x1024" | "1:1" => "1024x1024", + "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152" + | "2k" => "1536x1024", + "1024*1536" | "1024x1536" | "9:16" => "1024x1536", + value if !value.is_empty() => value, + _ => "1024x1024", + } + .to_string() +} + +pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String { + if settings.base_url.ends_with("/v1") { + format!("{}/images/generations", settings.base_url) + } else { + format!("{}/v1/images/generations", settings.base_url) + } +} + +pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> String { + if settings.base_url.ends_with("/v1") { + format!("{}/images/edits", settings.base_url) + } else { + format!("{}/v1/images/edits", settings.base_url) + } +} + +pub async fn download_remote_image( + http_client: &reqwest::Client, + image_url: &str, +) -> Result { + let response = http_client.get(image_url).send().await.map_err(|error| { + map_simple_request_error(format!("下载生成图片失败:{error}"), Some(image_url.to_string())) + })?; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + let body = response.bytes().await.map_err(|error| { + map_simple_request_error(format!("读取生成图片内容失败:{error}"), Some(image_url.to_string())) + })?; + if !status.is_success() { + return Err(PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: "下载生成图片失败".to_string(), + endpoint: Some(image_url.to_string()), + timeout: false, + connect: false, + request: false, + body: false, + status_code: Some(status.as_u16()), + source: None, + audit: None, + }); + } + + let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); + Ok(DownloadedImage { + extension: mime_to_extension(normalized_mime_type.as_str()).to_string(), + mime_type: normalized_mime_type, + bytes: body.to_vec(), + }) +} + +async fn download_images_from_urls( + http_client: &reqwest::Client, + task_id: String, + image_urls: Vec, + candidate_count: u32, +) -> Result { + let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize); + for image_url in image_urls + .into_iter() + .take(candidate_count.clamp(1, 4) as usize) + { + images.push(download_remote_image(http_client, image_url.as_str()).await?); + } + Ok(GeneratedImages { + task_id, + actual_prompt: None, + images, + }) +} + +async fn resolve_reference_images( + http_client: &reqwest::Client, + reference_images: &[String], + failure_context: &str, +) -> Result, PlatformImageError> { + let mut resolved = Vec::new(); + for (index, source) in reference_images.iter().take(5).enumerate() { + let source = source.trim(); + if source.is_empty() { + continue; + } + if let Some(reference_image) = parse_reference_image_data_url(source, index)? { + resolved.push(reference_image); + continue; + } + if source.starts_with("http://") || source.starts_with("https://") { + let downloaded = download_remote_image(http_client, source) + .await + .map_err(|error| PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:下载参考图失败:{error}"), + endpoint: Some(source.to_string()), + timeout: false, + connect: false, + request: false, + body: false, + status_code: None, + source: None, + audit: None, + })?; + resolved.push(ReferenceImage { + bytes: downloaded.bytes, + mime_type: downloaded.mime_type.clone(), + file_name: format!( + "reference-{index}.{}", + mime_to_extension(downloaded.mime_type.as_str()) + ), + }); + continue; + } + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"), + }); + } + + if resolved.is_empty() { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:图片编辑需要至少一张参考图。"), + }); + } + + Ok(resolved) +} + +fn parse_reference_image_data_url( + source: &str, + index: usize, +) -> Result, PlatformImageError> { + let Some(body) = source.strip_prefix("data:") else { + return Ok(None); + }; + let Some((mime_type, data)) = body.split_once(";base64,") else { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "参考图 Data URL 必须是 base64 图片。".to_string(), + }); + }; + if !mime_type.starts_with("image/") { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "参考图 Data URL 必须是图片类型。".to_string(), + }); + } + let bytes = BASE64_STANDARD + .decode(data.trim()) + .map_err(|error| PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("参考图 Data URL 解码失败:{error}"), + })?; + let mime_type = normalize_downloaded_image_mime_type(mime_type); + Ok(Some(ReferenceImage { + bytes, + file_name: format!( + "reference-{index}.{}", + mime_to_extension(mime_type.as_str()) + ), + mime_type, + })) +} + +fn images_from_base64( + task_id: String, + b64_images: Vec, + candidate_count: u32, +) -> GeneratedImages { + let images = b64_images + .into_iter() + .take(candidate_count.clamp(1, 4) as usize) + .filter_map(|raw| decode_generated_image_base64(raw.as_str())) + .collect(); + + GeneratedImages { + task_id, + actual_prompt: None, + images, + } +} + +fn decode_generated_image_base64(raw: &str) -> Option { + let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; + let mime_type = infer_image_mime_type(bytes.as_slice()); + Some(DownloadedImage { + extension: mime_to_extension(mime_type.as_str()).to_string(), + mime_type, + bytes, + }) +} + +fn parse_json_payload( + raw_text: &str, + failure_context: &str, +) -> Result { + serde_json::from_str::(raw_text) + .map(|payload| ParsedJsonPayload { payload }) + .map_err(|error| PlatformImageError::ResponseParse { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:解析响应失败:{error}"), + raw_excerpt: truncate_raw(raw_text), + audit: None, + }) +} + +fn map_reqwest_error( + context: &str, + request_url: &str, + failure_stage: &'static str, + error: reqwest::Error, + latency_ms: u64, + prompt_chars: Option, + reference_image_count: Option, +) -> PlatformImageError { + let is_timeout = error.is_timeout(); + let is_connect = error.is_connect(); + let source = error.source().map(ToString::to_string); + let message = format!("{context}:{error}"); + let audit = build_failure_audit( + request_url, + context, + failure_stage, + error.status().map(|status| status.as_u16()), + None, + is_timeout, + is_connect, + message.as_str(), + source.clone(), + None, + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + failure_stage, + timeout = is_timeout, + connect = is_connect, + request = error.is_request(), + body = error.is_body(), + status = error.status().map(|status| status.as_u16()).unwrap_or_default(), + source = %source.clone().unwrap_or_default(), + message = %message, + elapsed_ms = latency_ms, + prompt_chars, + reference_image_count, + "VectorEngine 图片请求发送失败" + ); + + PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message, + endpoint: Some(request_url.to_string()), + timeout: is_timeout, + connect: is_connect, + request: error.is_request(), + body: error.is_body(), + status_code: error.status().map(|status| status.as_u16()), + source, + audit: Some(audit), + } +} + +fn map_simple_request_error(message: String, endpoint: Option) -> PlatformImageError { + PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message, + endpoint, + timeout: false, + connect: false, + request: true, + body: false, + status_code: None, + source: None, + audit: None, + } +} + +#[allow(clippy::too_many_arguments)] +fn build_failure_audit( + request_url: &str, + operation: &str, + failure_stage: &'static str, + status_code: Option, + status_class: Option<&'static str>, + timeout: bool, + connect: bool, + error_message: &str, + error_source: Option, + raw_excerpt: Option, + latency_ms: Option, + prompt_chars: Option, + reference_image_count: Option, +) -> PlatformImageFailureAudit { + PlatformImageFailureAudit { + provider: VECTOR_ENGINE_PROVIDER, + endpoint: request_url.to_string(), + operation: operation.to_string(), + failure_stage, + status_code, + status_class, + timeout, + retryable: is_retryable_external_api_failure(status_code, timeout, connect), + error_message: error_message.to_string(), + error_source, + raw_excerpt, + latency_ms, + prompt_chars, + reference_image_count, + image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + } +} + +fn is_retryable_external_api_failure( + status_code: Option, + timeout: bool, + connect: bool, +) -> bool { + timeout || connect || status_code.is_some_and(|status| status == 429 || status == 408 || status >= 500) +} + +fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String { + let prompt = prompt.trim(); + let Some(negative_prompt) = negative_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return prompt.to_string(); + }; + + format!("{prompt}\n避免:{negative_prompt}") +} + +fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { + if raw_text.trim().is_empty() { + return fallback_message.to_string(); + } + + if let Ok(parsed) = serde_json::from_str::(raw_text) { + for pointer in [ + "/error/message", + "/message", + "/output/message", + "/data/message", + ] { + if let Some(message) = parsed + .pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return message.to_string(); + } + } + for pointer in ["/error/code", "/code", "/output/code", "/data/code"] { + if let Some(code) = parsed + .pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("{fallback_message}({code})"); + } + } + } + + raw_text.trim().to_string() +} + +fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { + match value { + Value::Array(entries) => { + for entry in entries { + collect_strings_by_key(entry, target_key, results); + } + } + Value::Object(object) => { + for (key, nested_value) in object { + if key == target_key { + match nested_value { + Value::String(text) => { + let text = text.trim(); + if !text.is_empty() { + results.push(text.to_string()); + continue; + } + } + Value::Array(entries) => { + for entry in entries { + if let Some(text) = entry + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + results.push(text.to_string()); + } + } + } + _ => {} + } + } + collect_strings_by_key(nested_value, target_key, results); + } + } + _ => {} + } +} + +fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { + let mut results = Vec::new(); + collect_strings_by_key(value, target_key, &mut results); + results.into_iter().next() +} + +fn extract_generation_id(payload: &Value) -> Option { + find_first_string_by_key(payload, "id") + .or_else(|| find_first_string_by_key(payload, "created")) + .or_else(|| find_first_string_by_key(payload, "request_id")) +} + +fn extract_image_urls(payload: &Value) -> Vec { + let mut urls = Vec::new(); + collect_strings_by_key(payload, "url", &mut urls); + collect_strings_by_key(payload, "image", &mut urls); + collect_strings_by_key(payload, "image_url", &mut urls); + let mut deduped = Vec::new(); + for url in urls { + if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { + deduped.push(url); + } + } + deduped +} + +fn extract_b64_images(payload: &Value) -> Vec { + let mut values = Vec::new(); + collect_strings_by_key(payload, "b64_json", &mut values); + values +} + +fn normalize_downloaded_image_mime_type(content_type: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or("image/jpeg"); + match mime_type { + "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { + mime_type.to_string() + } + _ => "image/jpeg".to_string(), + } +} + +fn mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "jpg", + } +} + +fn infer_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() +} + +fn is_timeout_message(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("timed out") + || lower.contains("timeout") + || lower.contains("operation timed out") + || lower.contains("deadline has elapsed") +} + +fn truncate_raw(raw_text: &str) -> String { + raw_text.chars().take(800).collect() +} + +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +impl PlatformImageError { + fn with_audit(self, audit: PlatformImageFailureAudit) -> Self { + match self { + Self::Request { + provider, + message, + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + .. + } => Self::Request { + provider, + message, + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + audit: Some(audit), + }, + Self::Upstream { + provider, + message, + upstream_status, + raw_excerpt, + .. + } => Self::Upstream { + provider, + message, + upstream_status, + raw_excerpt, + audit: Some(audit), + }, + Self::ResponseParse { + provider, + message, + raw_excerpt, + .. + } => Self::ResponseParse { + provider, + message, + raw_excerpt, + audit: Some(audit), + }, + Self::MissingImage { + provider, message, .. + } => Self::MissingImage { + provider, + message, + audit: Some(audit), + }, + Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => self, + } + } +} + +struct ParsedJsonPayload { + payload: Value, +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use serde_json::json; + + #[test] + fn request_body_normalizes_size_prompt_and_candidate_count() { + let body = build_vector_engine_image_request_body( + " 风雨夜里的街道 ", + Some(" 低清,水印 "), + " 1:1 ", + 10, + &["data:image/png;base64,AAAA".to_string()], + ); + + assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["size"], "1024x1024"); + assert_eq!(body["n"], 4); + assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印"); + assert!(body.get("image").is_none()); + } + + #[test] + fn provider_urls_normalize_root_and_v1_base_urls() { + let root_settings = VectorEngineImageSettings { + base_url: "https://vector.example".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + let v1_settings = VectorEngineImageSettings { + base_url: "https://vector.example/v1".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + + assert_eq!( + vector_engine_images_generation_url(&root_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + vector_engine_images_generation_url(&v1_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + vector_engine_images_edit_url(&root_settings), + "https://vector.example/v1/images/edits" + ); + assert_eq!( + vector_engine_images_edit_url(&v1_settings), + "https://vector.example/v1/images/edits" + ); + } + + #[test] + fn data_url_and_base64_image_decoding_preserves_image_metadata() { + let data_url = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") + ); + + let reference = parse_reference_image_data_url(&data_url, 2) + .expect("data url should parse") + .expect("image data url should be accepted"); + assert_eq!(reference.file_name, "reference-2.png"); + assert_eq!(reference.mime_type, "image/png"); + assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest"); + + let image = decode_generated_image_base64(BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str()) + .expect("base64 image should decode"); + assert_eq!(image.extension, "png"); + assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest"); + } + + #[test] + fn error_status_hints_and_audit_fields_are_structured() { + let audit = PlatformImageFailureAudit { + provider: VECTOR_ENGINE_PROVIDER, + endpoint: "https://vector.example/v1/images/generations".to_string(), + operation: "图片生成失败".to_string(), + failure_stage: "upstream_status", + status_code: Some(504), + status_class: Some("5xx"), + timeout: true, + retryable: true, + error_message: "上游超时".to_string(), + error_source: Some("read timeout".to_string()), + raw_excerpt: Some("{\"error\":\"timeout\"}".to_string()), + latency_ms: Some(987), + prompt_chars: Some(64), + reference_image_count: Some(2), + image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + }; + + let request_error = PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: "请求发送失败".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 invalid_config = PlatformImageError::InvalidConfig { + provider: VECTOR_ENGINE_PROVIDER, + message: "缺少配置".to_string(), + }; + let invalid_request = PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "请求不合法".to_string(), + }; + let upstream_timeout = PlatformImageError::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message: "upstream timeout".to_string(), + upstream_status: 502, + raw_excerpt: "deadline has elapsed".to_string(), + audit: Some(audit.clone()), + }; + + assert_eq!(invalid_config.status_hint(), PlatformImageStatusHint::ServiceUnavailable); + assert_eq!(invalid_request.status_hint(), PlatformImageStatusHint::BadRequest); + assert_eq!(request_error.status_hint(), PlatformImageStatusHint::GatewayTimeout); + assert_eq!(upstream_timeout.status_hint(), PlatformImageStatusHint::GatewayTimeout); + assert_eq!( + PlatformImageError::MissingImage { + provider: VECTOR_ENGINE_PROVIDER, + message: "缺图".to_string(), + audit: Some(audit.clone()), + } + .status_hint(), + PlatformImageStatusHint::BadGateway + ); + + let audit_ref = upstream_timeout.audit().expect("audit should be preserved"); + assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER); + assert_eq!(audit_ref.endpoint, "https://vector.example/v1/images/generations"); + assert_eq!(audit_ref.status_code, Some(504)); + assert_eq!(audit_ref.status_class, Some("5xx")); + assert!(audit_ref.timeout); + assert!(audit_ref.retryable); + assert_eq!(audit_ref.reference_image_count, Some(2)); + assert_eq!(audit_ref.image_model, Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL)); + assert!(invalid_config.audit().is_none()); + assert!(invalid_request.audit().is_none()); + } + + #[test] + fn extract_image_urls_and_b64_values_are_deduped() { + let payload = json!({ + "data": [ + {"image": "https://example.com/a.png"}, + {"url": "https://example.com/a.png"}, + {"image_url": "ftp://example.com/b.png"}, + {"url": "https://example.com/b.png"} + ], + "nested": { + "b64_json": ["YWJj", "ZGVm"] + } + }); + + assert_eq!( + extract_image_urls(&payload), + vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string() + ] + ); + assert_eq!( + extract_b64_images(&payload), + vec!["YWJj".to_string(), "ZGVm".to_string()] + ); + } +} diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 830656b5..a9b3935e 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -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", diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 4bc26c85..1e7b2f33 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -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 { diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index e4d4657d..cd2c0a51 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -87,6 +87,8 @@ pub struct JumpHopWorkspaceCreateRequest { pub struct JumpHopActionRequest { pub action_type: JumpHopActionType, #[serde(default)] + pub profile_id: Option, + #[serde(default)] pub work_title: Option, #[serde(default)] pub work_description: Option, @@ -102,6 +104,14 @@ pub struct JumpHopActionRequest { pub tile_prompt: Option, #[serde(default)] pub end_mood_prompt: Option, + #[serde(default)] + pub character_asset: Option, + #[serde(default)] + pub tile_atlas_asset: Option, + #[serde(default)] + pub tile_assets: Option>, + #[serde(default)] + pub cover_composite: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 7d798b88..2b35ba32 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -226,8 +226,11 @@ impl SpacetimeClient { &self, profile_id: String, ) -> Result { - 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 { + 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 { - self.get_jump_hop_work_profile(public_work_code, String::new()) + let gallery = self.list_jump_hop_gallery().await?; + let requested_code = normalize_jump_hop_public_work_code(public_work_code.as_str()); + let card = gallery + .items + .into_iter() + .find(|item| { + normalize_jump_hop_public_work_code(item.public_work_code.as_str()) == requested_code + }) + .ok_or_else(|| SpacetimeClientError::Procedure("jump_hop public work 不存在".to_string()))?; + + self.get_jump_hop_work_profile(card.profile_id, String::new()) .await } } + +fn validate_jump_hop_runtime_ready( + work: &JumpHopWorkProfileResponse, +) -> Result<(), SpacetimeClientError> { + let status = work.summary.publication_status.trim().to_ascii_lowercase(); + if status != "published" { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime 只能启动已发布作品", + )); + } + if work.summary.generation_status != JumpHopGenerationStatus::Ready { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime 需要 ready 状态作品", + )); + } + validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?; + validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?; + if work.tile_assets.is_empty() { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime 缺少地块资产", + )); + } + for (index, asset) in work.tile_assets.iter().enumerate() { + if asset.image_src.trim().is_empty() + || asset.image_object_key.trim().is_empty() + || asset.asset_object_id.trim().is_empty() + { + return Err(SpacetimeClientError::validation_failed(format!( + "jump-hop runtime 地块资产 #{index} 不完整" + ))); + } + } + if work.path.platforms.is_empty() { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime 缺少可玩路径", + )); + } + Ok(()) +} + +fn validate_jump_hop_character_asset_ready( + asset: &JumpHopCharacterAsset, + field: &str, +) -> Result<(), SpacetimeClientError> { + if asset.image_src.trim().is_empty() + || asset.image_object_key.trim().is_empty() + || asset.asset_object_id.trim().is_empty() + { + return Err(SpacetimeClientError::validation_failed(format!( + "jump-hop runtime {field} 不完整" + ))); + } + if asset.generation_provider.trim().is_empty() + || asset.generation_provider == "deterministic-placeholder" + { + return Err(SpacetimeClientError::validation_failed(format!( + "jump-hop runtime {field} 不是可用真实生成资产" + ))); + } + Ok(()) +} + +fn normalize_jump_hop_public_work_code(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_uppercase()) + .collect() +} + enum JumpHopActionProcedure { Compile(JumpHopDraftCompileInput), Update(JumpHopWorkUpdateInput), @@ -503,22 +591,61 @@ fn merge_action_into_draft( if matches!( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter - ) && let Some(value) = payload - .character_prompt - .as_ref() - .filter(|value| !value.trim().is_empty()) - { - draft.character_prompt = value.trim().to_string(); + ) { + if let Some(value) = payload + .character_prompt + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.character_prompt = value.trim().to_string(); + } } if matches!( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles - ) && let Some(value) = payload - .tile_prompt + ) { + if let Some(value) = payload + .tile_prompt + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.tile_prompt = value.trim().to_string(); + } + } + if let Some(profile_id) = payload + .profile_id .as_ref() - .filter(|value| !value.trim().is_empty()) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) { - draft.tile_prompt = value.trim().to_string(); + draft.profile_id = Some(profile_id.to_string()); + } + if matches!( + scope, + JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter + ) { + if let Some(asset) = payload.character_asset.clone() { + draft.character_asset = Some(asset); + } + } + if matches!( + scope, + JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles + ) { + if let Some(asset) = payload.tile_atlas_asset.clone() { + draft.tile_atlas_asset = Some(asset); + } + if let Some(assets) = payload.tile_assets.clone() { + draft.tile_assets = assets; + } + } + if let Some(value) = payload + .cover_composite + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + draft.cover_composite = Some(value.to_string()); } if draft.work_title.trim().is_empty() { return Err(SpacetimeClientError::validation_failed( @@ -545,31 +672,30 @@ fn build_compile_input( draft.tile_atlas_asset = None; draft.tile_assets.clear(); } - let character_asset = ensure_character_asset( - draft.character_asset.clone(), + let character_asset = draft.character_asset.clone().ok_or_else(|| { + SpacetimeClientError::validation_failed( + "jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object", + ) + })?; + let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| { + SpacetimeClientError::validation_failed( + "jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object", + ) + })?; + let tile_assets = if draft.tile_assets.is_empty() { + return Err(SpacetimeClientError::validation_failed( + "jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object", + )); + } else { + draft.tile_assets.clone() + }; + let cover_composite = resolve_cover_composite( + draft, profile_id, - &draft.character_prompt, - force_character, + refresh, now_micros, ); - let tile_atlas_asset = ensure_tile_atlas_asset( - draft.tile_atlas_asset.clone(), - profile_id, - &draft.tile_prompt, - force_tiles, - now_micros, - ); - let tile_assets = ensure_tile_assets( - draft.tile_assets.clone(), - profile_id, - force_tiles, - now_micros, - ); - let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros); - draft.character_asset = Some(character_asset.clone()); - draft.tile_atlas_asset = Some(tile_atlas_asset.clone()); - draft.tile_assets = tile_assets.clone(); draft.cover_composite = cover_composite.clone(); draft.generation_status = JumpHopGenerationStatus::Ready; @@ -698,8 +824,10 @@ fn ensure_character_asset( force_new: bool, now_micros: i64, ) -> JumpHopCharacterAsset { - if !force_new && let Some(asset) = existing { - return asset; + if !force_new { + if let Some(asset) = existing { + return asset; + } } let revision = force_new.then_some(now_micros); let suffix = asset_revision_suffix(revision); @@ -722,8 +850,10 @@ fn ensure_tile_atlas_asset( force_new: bool, now_micros: i64, ) -> JumpHopCharacterAsset { - if !force_new && let Some(asset) = existing { - return asset; + if !force_new { + if let Some(asset) = existing { + return asset; + } } let revision = force_new.then_some(now_micros); let suffix = asset_revision_suffix(revision); @@ -781,14 +911,15 @@ fn resolve_cover_composite( refresh: JumpHopAssetRefresh, now_micros: i64, ) -> Option { - if matches!(refresh, JumpHopAssetRefresh::Preserve) - && let Some(value) = draft + if matches!(refresh, JumpHopAssetRefresh::Preserve) { + if let Some(value) = draft .cover_composite .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) - { - return Some(value.to_string()); + { + return Some(value.to_string()); + } } let suffix = asset_revision_suffix( (!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros), diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index d84c754c..0209f748 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -46,7 +46,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec Result String { + let normalized = profile_id + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(|character| character.to_uppercase()) + .collect::(); + 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 { diff --git a/src/components/common/CreativeImageInputPanel.tsx b/src/components/common/CreativeImageInputPanel.tsx index 1c23c838..5c6a7721 100644 --- a/src/components/common/CreativeImageInputPanel.tsx +++ b/src/components/common/CreativeImageInputPanel.tsx @@ -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({ {mainImageMeta ?
{mainImageMeta}
: null} + {imageLimitHint ? ( +
+ {imageLimitHint} +
+ ) : null} {showPrompt ? ( diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 1d7aa357..ccd20cd5 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -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); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 7358741e..24fde099 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -59,6 +59,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record = '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', diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index f99727cd..e1fecab8 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -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, +): 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 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 773e7cde..c6359f6b 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -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, @@ -190,9 +193,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 { @@ -423,6 +427,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'; @@ -565,8 +570,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; @@ -2190,7 +2221,7 @@ function hasRecoverableGeneratedPuzzleDraft( ); } -function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) { +function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] { switch (item.source.kind) { case 'rpg': return collectDraftNoticeKeys('rpg', [ @@ -2219,6 +2250,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, @@ -2304,6 +2342,39 @@ function buildPendingBigFishWorks( })); } +function buildPendingJumpHopWorks( + pending: Record | 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 | undefined, existingItems: readonly Match3DWorkSummary[], @@ -2913,7 +2984,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 | null>(null); const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] = @@ -2974,6 +3050,9 @@ export function PlatformEntryFlowShellImpl({ const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState< JumpHopGalleryCardResponse[] >([]); + const [jumpHopWorks, setJumpHopWorks] = useState< + JumpHopWorkSummaryResponse[] + >([]); const [jumpHopRuntimeReturnStage, setJumpHopRuntimeReturnStage] = useState('jump-hop-result'); const [jumpHopGenerationState, setJumpHopGenerationState] = @@ -3190,6 +3269,10 @@ export function PlatformEntryFlowShellImpl({ creationEntryTypes, 'big-fish', ); + const isJumpHopCreationVisible = isPlatformCreationTypeVisible( + creationEntryTypes, + 'jump-hop', + ); const isSquareHoleCreationVisible = isPlatformCreationTypeVisible( creationEntryTypes, 'square-hole', @@ -3497,7 +3580,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, @@ -3505,25 +3588,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( @@ -3546,6 +3630,11 @@ export function PlatformEntryFlowShellImpl({ resolveRpgCreationErrorMessage(error, fallback), [], ); + const resolveBarkBattleErrorMessage = useCallback( + (error: unknown, fallback: string) => + resolveRpgCreationErrorMessage(error, fallback), + [], + ); const refreshBigFishShelf = useCallback(async () => { setIsBigFishLoadingLibrary(true); @@ -3645,6 +3734,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(); @@ -3854,6 +3959,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, @@ -4201,6 +4322,16 @@ export function PlatformEntryFlowShellImpl({ ], [bigFishWorks, pendingDraftShelfItems], ); + const jumpHopShelfItems = useMemo( + () => [ + ...buildPendingJumpHopWorks( + pendingDraftShelfItems['jump-hop'], + jumpHopWorks, + ), + ...jumpHopWorks, + ], + [jumpHopWorks, pendingDraftShelfItems], + ); const match3dShelfItems = useMemo( () => [ ...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks), @@ -4276,6 +4407,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, @@ -4318,6 +4456,7 @@ export function PlatformEntryFlowShellImpl({ babyObjectMatchDrafts, barkBattleShelfItems, bigFishShelfItems, + jumpHopShelfItems, creationHubItems, isSquareHoleCreationVisible, match3dShelfItems, @@ -5440,30 +5579,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, @@ -7575,11 +7711,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); @@ -7605,6 +7745,7 @@ export function PlatformEntryFlowShellImpl({ } }, [ + authUi, resolvePuzzleErrorMessage, setIsVisualNovelBusy, setSelectionStage, @@ -7626,9 +7767,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) { @@ -7640,6 +7786,8 @@ export function PlatformEntryFlowShellImpl({ } }, [ + activeRecommendRuntimeKind, + authUi, isVisualNovelBusy, resolvePuzzleErrorMessage, setIsVisualNovelBusy, @@ -7859,6 +8007,22 @@ export function PlatformEntryFlowShellImpl({ }), ); 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( @@ -7985,6 +8149,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( @@ -8049,12 +8217,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); @@ -8079,7 +8248,7 @@ export function PlatformEntryFlowShellImpl({ setIsJumpHopBusy(false); } }, - [setSelectionStage], + [authUi, setSelectionStage], ); const restartJumpHopRuntimeRun = useCallback(async () => { @@ -8413,9 +8582,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); @@ -8440,7 +8615,7 @@ export function PlatformEntryFlowShellImpl({ setIsWoodenFishBusy(false); } }, - [setSelectionStage], + [authUi, setSelectionStage], ); const checkpointWoodenFishRuntimeRun = useCallback( @@ -8858,16 +9033,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); @@ -8909,6 +9091,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isPuzzleBusy, + authUi, resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, @@ -8961,10 +9144,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 } : {}), @@ -9009,6 +9194,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isMatch3DBusy, + authUi, match3dFlow, match3dRuntimeAdapter, resolveMatch3DErrorMessage, @@ -9032,11 +9218,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); @@ -9068,6 +9255,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isSquareHoleBusy, + authUi, resolveSquareHoleErrorMessage, setSelectionStage, setSquareHoleError, @@ -9191,9 +9379,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) { @@ -9204,7 +9397,13 @@ export function PlatformEntryFlowShellImpl({ bigFishInputInFlightRef.current = false; } }, - [bigFishRun, resolveBigFishErrorMessage, setBigFishError], + [ + activeRecommendRuntimeKind, + authUi, + bigFishRun, + resolveBigFishErrorMessage, + setBigFishError, + ], ); const reportBigFishObservedPlayTime = useCallback(() => { @@ -9217,10 +9416,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) => { @@ -9230,6 +9428,7 @@ export function PlatformEntryFlowShellImpl({ }); }, [ activeRecommendRuntimeKind, + authUi, bigFishRun?.sessionId, bigFishRuntimeStartedAt, resolveBigFishErrorMessage, @@ -9551,12 +9750,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; @@ -9679,10 +9879,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); @@ -9739,6 +9937,10 @@ export function PlatformEntryFlowShellImpl({ return; } + const runtimeGuestOptions = + puzzleRuntimeAuthMode === 'isolated' + ? await buildRecommendRuntimeGuestOptions() + : {}; const targetProfileId = _target?.profileId?.trim() ?? ''; if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) { const itemPromise = @@ -9754,7 +9956,7 @@ export function PlatformEntryFlowShellImpl({ { targetProfileId, }, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + runtimeGuestOptions, ) : advancePuzzleNextLevel(puzzleRun.runId, { targetProfileId, @@ -9780,7 +9982,7 @@ export function PlatformEntryFlowShellImpl({ ? await advancePuzzleNextLevel( puzzleRun.runId, {}, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + runtimeGuestOptions, ) : await advancePuzzleNextLevel(puzzleRun.runId); setPuzzleRun(run); @@ -10865,6 +11067,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); @@ -11896,11 +12135,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); @@ -11911,11 +12151,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( @@ -11930,13 +12166,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('汪汪声浪作品发布后才能进入正式玩法。'); @@ -11948,17 +12185,34 @@ export function PlatformEntryFlowShellImpl({ setBarkBattleRuntimeMode('published'); setBarkBattlePublishedConfig(mapBarkBattleWorkToPublishedConfig(item)); setBarkBattleRuntimeReturnStage(returnStage); - selectionStageRef.current = 'bark-battle-runtime'; - setSelectionStage('bark-battle-runtime'); - pushAppHistoryPath( - buildPublicWorkStagePath( - 'bark-battle-runtime', - buildBarkBattlePublicWorkCode(item.workId), - ), - ); - return true; + 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( + 'bark-battle-runtime', + buildBarkBattlePublicWorkCode(item.workId), + ), + ); + } + return true; + } catch (error) { + setBarkBattleError( + resolveBarkBattleErrorMessage(error, '启动汪汪声浪玩法失败。'), + ); + return false; + } }, - [setSelectionStage], + [authUi, resolveBarkBattleErrorMessage, setSelectionStage], ); const startSelectedPublicWork = useCallback(() => { @@ -12230,7 +12484,9 @@ export function PlatformEntryFlowShellImpl({ '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', ); } else { - started = startBarkBattleRunFromWork(work, 'platform'); + started = await startBarkBattleRunFromWork(work, 'platform', { + embedded: true, + }); } } else if (isEdutainmentGalleryEntry(entry)) { started = await startBabyObjectMatchRuntimeFromEntry( @@ -12324,6 +12580,7 @@ export function PlatformEntryFlowShellImpl({ const recommendRuntimeContent = useMemo(() => { if ( + isDesktopLayout || selectionStage !== 'platform' || platformBootstrap.platformTab !== 'home' || !activeRecommendRuntimeKind @@ -12730,10 +12987,12 @@ export function PlatformEntryFlowShellImpl({ visualNovelSession, visualNovelWork, checkpointWoodenFishRuntimeRun, + isDesktopLayout, ]); useEffect(() => { if ( + isDesktopLayout || selectionStage !== 'platform' || platformBootstrap.platformTab !== 'home' || platformBootstrap.isLoadingPlatform @@ -12789,6 +13048,7 @@ export function PlatformEntryFlowShellImpl({ match3dRun, platformBootstrap.isLoadingPlatform, platformBootstrap.platformTab, + isDesktopLayout, puzzleRun, recommendRuntimeEntries, selectRecommendRuntimeEntry, @@ -13895,6 +14155,7 @@ export function PlatformEntryFlowShellImpl({ deletingWorkId={deletingCreationWorkId} rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []} + jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []} onOpenBigFishDetail={ isBigFishCreationVisible ? (item) => { @@ -13904,6 +14165,15 @@ export function PlatformEntryFlowShellImpl({ } : undefined } + onOpenJumpHopDetail={ + isJumpHopCreationVisible + ? (item) => { + runProtectedAction(() => { + void openJumpHopDraft(item); + }); + } + : undefined + } onDeleteBigFish={ isBigFishCreationVisible ? (item) => { @@ -13911,6 +14181,7 @@ export function PlatformEntryFlowShellImpl({ } : null } + onDeleteJumpHop={null} match3dItems={match3dShelfItems} onOpenMatch3DDetail={(item) => { runProtectedAction(() => { @@ -14017,6 +14288,7 @@ export function PlatformEntryFlowShellImpl({ isLoadingPlatform={platformBootstrap.isLoadingPlatform} isLoadingDashboard={platformBootstrap.isLoadingDashboard} hasUnreadDraftUpdate={hasUnreadDraftUpdates} + isDesktopLayout={isDesktopLayout} isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey} platformError={ platformBootstrap.isLoadingPlatform @@ -16242,6 +16514,29 @@ export function PlatformEntryFlowShellImpl({ }} /> ) : null} + setDraftGenerationPointNotice(null)} + closeOnBackdrop + size="sm" + overlayClassName={`platform-theme ${platformThemeClass} !items-center`} + panelClassName="platform-remap-surface rounded-[1.75rem]" + footer={ + + } + > +
+ {draftGenerationPointNotice?.message} +
+
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: '最近创作', + }), + ]); +}); diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index 3aad4e59..11c01c12 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -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; } diff --git a/src/components/platform-entry/platformEntryResponsive.ts b/src/components/platform-entry/platformEntryResponsive.ts new file mode 100644 index 00000000..8acd0ec6 --- /dev/null +++ b/src/components/platform-entry/platformEntryResponsive.ts @@ -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; +} diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index ee7704ea..3785425c 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -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( { 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, }); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx index d941464a..da06f2b1 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx @@ -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 { - id: `prompt-upload:${Date.now()}:${index}:${file.name}`, - label: file.name.trim() || `参考图 ${index + 1}`, - imageSrc: asset.imageSrc || imageSrc, - assetObjectId: asset.assetObjectId, - }; - }), + files.slice(0, remainingSlots).map(async (file, index) => ({ + id: `prompt-upload:${Date.now()}:${index}:${file.name}`, + label: file.name.trim() || `参考图 ${index + 1}`, + 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={ ({ + ensureRuntimeGuestToken: vi.fn(async () => ({ + token: 'runtime-guest-token', + expiresAt: '2099-01-01T00:00:00.000Z', + })), + getPublicAuthUserByCode: vi.fn( + async (publicUserCode: string): Promise => ({ + id: `public-user-${publicUserCode}`, + publicUserCode, + displayName: '公开作者', + avatarUrl: null, + }), + ), + getPublicAuthUserById: vi.fn( + async (userId: string): Promise => ({ + 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, name: string | RegExp, @@ -279,6 +309,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}`); @@ -1089,6 +1124,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({ }) => (
汪汪声浪配置表单
+
{showBackButton ? 'back-visible' : 'back-hidden'}
@@ -2245,6 +2284,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) => @@ -3587,11 +3630,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(); }); @@ -4310,11 +4362,15 @@ test('puzzle form checks mud points before creating a draft', async () => { render(); 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(); }); @@ -4331,14 +4387,17 @@ test('match3d form checks mud points before creating a draft', async () => { render(); 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(); }); @@ -6133,11 +6192,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(); + + 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', @@ -6196,7 +6303,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(() => { @@ -6514,7 +6621,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 () => { @@ -7208,7 +7321,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( @@ -7232,7 +7344,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa elapsedMs: 18_000, nickname: '测试玩家', }, - ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); @@ -7253,7 +7364,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa expect(advancePuzzleNextLevel).toHaveBeenCalledWith( clearedFirstLevel.runId, {}, - ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); expect( @@ -7416,7 +7526,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(); @@ -7600,7 +7709,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(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index a7c4e5a4..83c09957 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -804,6 +804,7 @@ function renderLoggedOutHomeView( > > = {}, activeTab: RpgEntryHomeViewProps['activeTab'] = 'home', + isDesktopLayout = false, ) { return render( > = {}, + isDesktopLayout = false, ) { const authSpies = { openLoginModal: vi.fn(), @@ -985,6 +988,7 @@ function renderStatefulLoggedOutHomeView( > { ).toBeNull(); }); -test('logged out recommend tab opens login modal and shows cover only', async () => { +test('logged out recommend tab opens embedded runtime without login modal', async () => { const user = userEvent.setup(); const { container, openLoginModal } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], @@ -2712,20 +2716,18 @@ test('logged out recommend tab opens login modal and shows cover only', async () within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), ); - expect(openLoginModal).toHaveBeenCalledTimes(1); - expect( - container.querySelector('.platform-recommend-cover-only'), - ).toBeTruthy(); + 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'), ).toBeTruthy(); - expect(screen.queryByTestId('recommend-runtime')).toBeNull(); - expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull(); + expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); + expect(screen.getByLabelText('奇幻拼图 作品信息')).toBeTruthy(); expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); }); -test('logged out recommend cover opens login modal again', async () => { +test('logged out recommend runtime keeps detail callback idle', async () => { const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); const { openLoginModal } = renderStatefulLoggedOutHomeView({ @@ -2741,12 +2743,9 @@ test('logged out recommend cover opens login modal again', async () => { await user.click( within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), ); - await user.click( - screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }), - ); - expect(openLoginModal).toHaveBeenCalledTimes(2); - expect(openLoginModal).toHaveBeenLastCalledWith(); + expect(openLoginModal).not.toHaveBeenCalled(); + expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); @@ -2755,16 +2754,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, { @@ -2780,6 +2778,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(); @@ -3082,7 +3109,7 @@ test('mobile recommend meta loads real author avatar from public user summary', await waitFor(() => { expect( document - .querySelector('.platform-recommend-cover-only__author img') + .querySelector('.platform-recommend-work-meta__avatar img') ?.getAttribute('src'), ).toBe('data:image/png;base64,AUTHOR'); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 30017806..b9983cb7 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -131,6 +131,7 @@ import { findPublicWorkForHistoryEntry, isEdutainmentEntryEnabled, } from '../platform-entry/platformEdutainmentVisibility'; +import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; @@ -166,6 +167,7 @@ export type PlatformHomeTab = | 'profile'; export interface RpgEntryHomeViewProps { activeTab: PlatformHomeTab; + isDesktopLayout?: boolean; onTabChange: (tab: PlatformHomeTab) => void; hasSavedGame: boolean; savedSnapshot: HydratedSavedGameSnapshot | null; @@ -233,7 +235,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', @@ -381,46 +382,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, @@ -3852,6 +3813,7 @@ function ProfilePlayedWorksModal({ export function RpgEntryHomeView({ activeTab, + isDesktopLayout: isDesktopLayoutProp, onTabChange, saveEntries, saveError, @@ -4008,7 +3970,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( @@ -5372,7 +5335,7 @@ export function RpgEntryHomeView({ {recommendRuntimeError} - ) : isStartingRecommendEntry || !recommendRuntimeContent ? ( + ) : isStartingRecommendEntry ? (
加载中...
@@ -6581,10 +6544,7 @@ export function RpgEntryHomeView({ ); const tabContentById = { - home: - !isAuthenticated || !isDesktopLayout - ? mobileRecommendContent - : desktopHomeContent, + home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent, category: categoryContent, create: createContent, saves: savesContent, @@ -6761,12 +6721,6 @@ export function RpgEntryHomeView({ return; } - if (!isAuthenticated && tab === 'home') { - onTabChange(tab); - authUi?.openLoginModal(); - return; - } - onTabChange(tab); }} /> @@ -6924,12 +6878,6 @@ export function RpgEntryHomeView({ emphasized={tab === 'create'} showDot={tab === 'saves' && hasUnreadDraftUpdate} onClick={() => { - if (!isAuthenticated && tab === 'home') { - onTabChange(tab); - authUi?.openLoginModal(); - return; - } - onTabChange(tab); }} /> diff --git a/src/components/rpg-entry/useRpgEntryBootstrap.ts b/src/components/rpg-entry/useRpgEntryBootstrap.ts index 9aec011f..ded70c37 100644 --- a/src/components/rpg-entry/useRpgEntryBootstrap.ts +++ b/src/components/rpg-entry/useRpgEntryBootstrap.ts @@ -351,7 +351,7 @@ export function useRpgEntryBootstrap( !hasInitialAgentSession && !hasExplicitPlatformTabSelectionRef.current ) { - // 中文注释:新用户先进入发现页;推荐页只在用户主动点击后作为登录门禁入口。 + // 中文注释:新用户先进入发现页;推荐页可直接进入,真正受保护的动作再单独做登录门禁。 setPlatformTabState(isAuthenticated ? 'home' : 'category'); } } finally { diff --git a/src/services/authService.ts b/src/services/authService.ts index 0ea3c820..e7002375 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -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( + '/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) { diff --git a/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts b/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts index 9e3ddde2..211cfdad 100644 --- a/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts +++ b/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts @@ -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( `/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 = {}, options: BarkBattleRuntimeRequestOptions = {}, ) { + const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( `/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( `/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( `/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, }, ); } diff --git a/src/services/big-fish-runtime/bigFishRuntimeClient.ts b/src/services/big-fish-runtime/bigFishRuntimeClient.ts index 204be416..16b02528 100644 --- a/src/services/big-fish-runtime/bigFishRuntimeClient.ts +++ b/src/services/big-fish-runtime/bigFishRuntimeClient.ts @@ -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( `/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( `/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( `/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, }, ); } diff --git a/src/services/jump-hop/jumpHopClient.ts b/src/services/jump-hop/jumpHopClient.ts index 02c39091..d1e7fe13 100644 --- a/src/services/jump-hop/jumpHopClient.ts +++ b/src/services/jump-hop/jumpHopClient.ts @@ -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( + JUMP_HOP_WORKS_API_BASE, + { method: 'GET' }, + '读取跳一跳作品列表失败', + { + retry: JUMP_HOP_RUNTIME_READ_RETRY, + }, + ); +} + export async function publishJumpHopWork(profileId: string) { const response = await requestJson( `${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( `${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( `${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, diff --git a/src/services/match3d-runtime/match3dRuntimeClient.ts b/src/services/match3d-runtime/match3dRuntimeClient.ts index 5167093d..f1b0b5ec 100644 --- a/src/services/match3d-runtime/match3dRuntimeClient.ts +++ b/src/services/match3d-runtime/match3dRuntimeClient.ts @@ -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, }, ); } diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index 92d46f70..53414f3a 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -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( 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( `${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, }, ); } diff --git a/src/services/puzzle-works/puzzleAssetClient.test.ts b/src/services/puzzle-works/puzzleAssetClient.test.ts new file mode 100644 index 00000000..c8ad9db5 --- /dev/null +++ b/src/services/puzzle-works/puzzleAssetClient.test.ts @@ -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)。', + ); + }); +}); diff --git a/src/services/puzzle-works/puzzleAssetClient.ts b/src/services/puzzle-works/puzzleAssetClient.ts index 30c90ddf..7583ced6 100644 --- a/src/services/puzzle-works/puzzleAssetClient.ts +++ b/src/services/puzzle-works/puzzleAssetClient.ts @@ -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 = { 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 { - validatePuzzleReferenceImageFile(payload.file); + validatePuzzleReferenceImageUploadFile(payload.file); const contentType = resolvePuzzleImageContentType(payload.file); const uploadedAt = Date.now(); const ticket = await requestJson( @@ -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, }; /** diff --git a/src/services/puzzleReferenceImage.test.ts b/src/services/puzzleReferenceImage.test.ts index 4bfb71bf..a09c30c2 100644 --- a/src/services/puzzleReferenceImage.test.ts +++ b/src/services/puzzleReferenceImage.test.ts @@ -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)。', ); }); }); diff --git a/src/services/puzzleReferenceImage.ts b/src/services/puzzleReferenceImage.ts index 1eac5862..4a9eaa0f 100644 --- a/src/services/puzzleReferenceImage.ts +++ b/src/services/puzzleReferenceImage.ts @@ -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 { + validatePuzzleReferenceImageFile(file); const dataUrl = await readFileAsDataUrl(file); const image = await loadReferenceImage(dataUrl); const size = resolveReferenceImageNaturalSize(image); diff --git a/src/services/recommendedRuntimeGuestLaunch.test.ts b/src/services/recommendedRuntimeGuestLaunch.test.ts new file mode 100644 index 00000000..9960d6fe --- /dev/null +++ b/src/services/recommendedRuntimeGuestLaunch.test.ts @@ -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('./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, + }), + ); + }, + ); +}); diff --git a/src/services/runtimeGuestAuth.ts b/src/services/runtimeGuestAuth.ts new file mode 100644 index 00000000..a8c45c26 --- /dev/null +++ b/src/services/runtimeGuestAuth.ts @@ -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, + headers: Record = {}, +) { + 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; +} diff --git a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts index ff32786f..083c9dec 100644 --- a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts +++ b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts @@ -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( `/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, }, ); } diff --git a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts index 338cc1ab..b2210823 100644 --- a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts +++ b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts @@ -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 & { - onEvent?: (event: VisualNovelRuntimeStreamEvent) => void; -}; -type VisualNovelRuntimeRequestOptions = Pick< - ApiRequestOptions, - | 'authImpact' - | 'skipRefresh' - | 'notifyAuthStateChange' - | 'clearAuthOnUnauthorized' ->; +export type VisualNovelRuntimeStreamOptions = TextStreamOptions & + RuntimeGuestRequestOptions & { + onEvent?: (event: VisualNovelRuntimeStreamEvent) => void; + }; +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, { - ...buildJsonInit('POST', payload), - signal, - }); + 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( `${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, { diff --git a/src/services/wooden-fish/woodenFishClient.ts b/src/services/wooden-fish/woodenFishClient.ts index 55859167..8aa08ef4 100644 --- a/src/services/wooden-fish/woodenFishClient.ts +++ b/src/services/wooden-fish/woodenFishClient.ts @@ -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( `${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, + 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, + 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, + }, ); }