diff --git a/.cloudbase/container/debug.json b/.cloudbase/container/debug.json new file mode 100644 index 00000000..0d444581 --- /dev/null +++ b/.cloudbase/container/debug.json @@ -0,0 +1 @@ +{"containers":[],"config":{}} \ No newline at end of file diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index ddc0d5c9..a9070d7d 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -3,4 +3,14 @@ version = 1 name = "Genarrative" [setup] -script = "" +script = ''' +cp "$env:CODEX_SOURCE_TREE_PATH\.env.secrets.local" "$env:CODEX_WORKTREE_PATH\.env.secrets.local" +npm install +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 2c35065f..75b0bcff 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -32,6 +32,86 @@ - 验证方式:运行态页面不再渲染“打开抓大鹅设置”,顶部仍显示关卡名和倒计时,底部槽位和道具按钮 class 中不含旧白底视觉;相关测试通过后保持该口径。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 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 奖池活动卡,和当前只强调拼图 / 抓大鹅主题赛的产品口径不一致。 +- 决策:创作 Tab 首屏 banner 轮播只展示 `拼图主题创作赛` 与 `抓大鹅主题创作赛` 两张主题卡;后端返回的 `eventBanner` 仅作为开始时间、结束时间等公共字段来源,不再直接作为一张轮播卡渲染。banner 底部顺序固定为开始 / 结束时间条在上、分页点在下,且二者都在封面内容底部。 +- 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/components/custom-world-home/CustomWorldCreationHub.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`CustomWorldCreationHub.test.tsx` 应断言默认活动标题不出现在 start-only 创作页,且 `creation-event-banner__timebar` 位于 `creation-event-banner__pager` 前。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-24 创作 Tab 首屏字号收敛到普通 UI 档位 + +- 背景:创作 Tab 的右上角泥点胶囊、赛事 banner、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明曾经偏向展示级字号,和其它页面的常规 UI 字号不一致。 +- 决策:创作首屏优先使用 `11px` 到 `14px` 的普通 UI 字号档位;仅在数字本体或强调值上做局部加粗,不使用 `text-lg`、`text-xl` 或更大的展示级字号来撑首屏。 +- 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、创作 Tab 相关测试、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`CustomWorldCreationHub.test.tsx` 的字号快照测试和本地浏览器检查都应确认右上组件、banner、分类 Tab、模板卡标题 / 副标题 / 消耗说明没有回到大字号。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-24 草稿页未读点统一使用暖棕色 + +- 背景:草稿页底部 Tab 和作品架的未读点之前仍用固定红色和红色 glow,和平台暖白/陶土橙体系不一致,也会让草稿未读态显得像危险告警。 +- 决策:`platform-nav-unread-dot` 与 `creation-work-card__unread-dot` 统一改用平台暖棕色 token,并把 glow 也切到暖棕色,不再直接写红色 literal 或红色阴影。 +- 影响范围:`src/index.css`、草稿页底部导航、草稿页作品架、相关 CSS 回归测试。 +- 验证方式:`src/index.test.ts` 需要断言两个 unread dot block 都不再包含 `#b64a35` 或 `rgba(239, 68, 68, ...)`,并且仍引用 `--platform-unread-dot-fill` / `--platform-unread-dot-glow`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-24 创作 Tab 模板卡点击直达已有玩法入口表单 + +- 背景:创作 Tab 首屏需要对齐参考图,展示赛事 banner、玩法模板分类和两列模板卡;点击模板卡时,空白入口页会让用户多走一层,占位感也会让人误以为功能未接好。 +- 决策:`/creation/` 直达对应玩法已有的入口创作表单 stage,不再保留空白创作入口页。RPG、拼图、抓大鹅、汪汪声浪、敲木鱼、视觉小说、宝贝识物等都直接进入既有工作台,继续承接草稿恢复和后续编排。创作 Tab 首屏 banner 按参考图拆成右上泥点胶囊、主体宣传封面图文、底部开始/结束时间条和分页点;玩法模板卡使用独立 `creation-template-card` 白底信息区,不复用暗图蒙版 `platform-creation-reference-card`,确保标题、描述和“预计消耗 10-20 泥点”可见。 +- 影响范围:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、创作大厅交互测试与平台入口文档。 +- 验证方式:`npm test -- src/routing/appPageRoutes.test.ts`、`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t \"create tab opens match3d entry form from the template card|create tab opens puzzle entry form from the template card|create tab opens bark battle entry form from the template card\"`、`npm run typecheck`、`npm run check:encoding` 通过;创作卡片点击后应进入对应工作台,不再出现空白入口页。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-24 创作 Tab 顶栏余额与赛事奖池分离展示 + +- 背景:创作页顶部、banner 奖池和玩法卡消耗口径曾经混在一起,容易把活动奖池误认成账号余额,也让横向空间被外部边框和过大的卡片高度挤占。 +- 决策:移动端创作 Tab 顶栏与 `陶泥儿` 品牌同一行只显示真实账户泥点数,数据直接取 `profileDashboard.walletBalance`;banner 内只展示赛事奖池,新增拼图主题创作赛和抓大鹅主题创作赛,两个主题奖池各 `1000` 泥点数;玩法卡封面右下角固定展示 `10-20泥点数`,列表外框取消,卡片高度和横向间距一起收紧。 +- 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、创作页相关测试和玩法链路文档。 +- 验证方式:移动端浏览器检查应看到创作顶栏余额、卡内分页点、内嵌横向 banner 和更紧凑的玩法卡;`CustomWorldCreationHub.test.tsx` 与 `RpgEntryHomeView.recharge.test.tsx` 的定向断言应保持通过。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-24 发现 / 创作 / 草稿三页去掉外层全局卡片壳 + +- 背景:发现 Tab、创作 Tab 和草稿 Tab 的页面根区原本都套着 `platform-page-stage`,导致全局内容卡片壳把横向空间吃掉,也让创作页和草稿页与发现页的频道标签 / 列表卡风格拉不开。 +- 决策:这三页的根内容区不再使用 `platform-page-stage` 作为外层全局卡片壳,只保留 `platform-remap-surface` 作为主题与输入框样式钩子;草稿页顶部 `全部 / 草稿 / 已发布` 切换复用发现页的 `platform-mobile-home-channel` 频道标签样式。 +- 影响范围:`src/components/custom-world-home/CustomWorldCreationHub.tsx`、`src/components/custom-world-home/CustomWorldWorkTabs.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/index.css`、相关创作 / 发现 / 草稿测试。 +- 验证方式:创作 Hub 和发现页定向测试通过;浏览器里这三页的根区不再出现 `platform-page-stage`,但仍保留 `platform-remap-surface` 命中。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-23 拼图生成页按后端真实进度推进阶段 + +- 背景:拼图生成页原先会按本地耗时自动推进步骤,容易在后端真实生成尚未完成时跳到后续阶段,导致页面状态和会话进度脱节。 +- 决策:拼图生成页的跨步骤推进只认后端会话 `progressPercent` 的真实里程碑,当前步骤内部再用本地耗时假进度平滑展示;总进度初始必须为 `0%`,之后按 `0-88`、`88-94`、`94-96`、`96-98` 的真实里程碑区间平滑推进。只要当前步骤生成内容未完成,就必须停留在当前步骤。页面只展示当前步骤标题和进度,不展示步骤详细描述。`生成拼图首图` 单独按 4 分钟估算,完整 AI 重绘路径约 448 秒;上传图且关闭 AI 重绘路径跳过首图生成,仍约 208 秒。 +- 影响范围:`src/services/miniGameDraftGenerationProgress.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/CustomWorldGenerationView.tsx`、拼图生成页相关测试与玩法链路文档。 +- 验证方式:拼图生成页恢复、轮询和测试都应以 `puzzleProgressPercent` 驱动阶段推进;`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts src/components/CustomWorldGenerationView.test.tsx`、`npm run typecheck`、`npm run check:encoding` 通过。 +- 关联文档:`docs/【玩法创作】拼图生成页进度口径-2026-05-23.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-23 所有玩法生成页统一圆环主视觉 + +- 背景:多个玩法生成页分别展示横向总进度条、步骤列表或三槽位列表,和最新参考图里的陶泥儿圆环等待态不一致,也让移动端信息密度偏高。 +- 决策:`media/create_bg_video.mp4` 作为固定全屏背景层循环静音播放,主进度统一改为居中大圆弧,正下方保留 90 度留空;生成页顶部只保留返回入口和状态胶囊,圆弧左右悬浮半透明“预计等待 / 已耗时”时间卡,下方保留半透明当前步骤单卡和当前作品信息卡。生成页不再列表展示每个步骤块,只显示当前步骤名称和当前步骤进度;圆弧和当前步骤卡不再被独立大面板嵌套出双层卡片感。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted`。顶部返回使用 `text-xs-sm`,右上状态使用 `11px-12px`,时间卡标签使用 `9px-10px`,时间值只展示纯时间,不重复拼“预计还需 / 已耗时”前缀;当前步骤标签使用 `10px-11px`,步骤名使用 `14px-15px`,步骤状态使用 `11px-12px`,底部玩法信息标题固定使用 `13px`,避免生成页 UI 字号大于其它页面。`CustomWorldGenerationView` 承接 RPG、拼图、抓大鹅、大鱼吃小鱼、方洞、跳一跳、敲木鱼、宝贝识物、视觉小说等共用生成页;汪汪声浪独立 `BarkBattleGeneratingView` 也对齐同一垂直布局。 +- 影响范围:`src/components/GenerationProgressHero.tsx`、`src/components/CustomWorldGenerationView.tsx`、`src/components/bark-battle-creation/BarkBattleGeneratingView.tsx` 和玩法链路文档。 +- 验证方式:执行 `npm run test -- src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx`,并用桌面 / 移动端视口检查生成页只出现圆环和当前步骤卡。 +- 关联文档:`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-23 寓教于乐玩法入口收敛为马路街区式横向延展 - 背景:参考图和视频表明,寓教于乐板块的图形化入口更接近 Toca Life World 式的“中央马路串联主题小建筑群街区”,而不是乐园分区、环形岛屿或世界球体结构。 @@ -73,6 +153,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 过大。 @@ -295,10 +383,10 @@ - 验证方式:创作 Tab 中点击汪汪声浪后直接看到内嵌表单,不应再出现单独配置页;发布进入 runtime 后退出应回到创作页的汪汪声浪模板。 - 关联文档:`docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md`。 -## 2026-05-14 拼图与抓大鹅生成页移动端收口为等待与计时双栏 +## 2026-05-14 拼图与抓大鹅生成页移动端收口为等待与计时双栏(历史) - 背景:拼图与抓大鹅的草稿生成页在移动端同时展示“当前批次”“预计等待”“计时”时,模型执行视角过重,信息也显得散。 -- 决策:这两类轻量玩法的生成页隐藏“当前批次”模块,只保留“预计等待”和“计时”并排展示;生成步骤进入页面时按顺序从左侧滑入,强化推进感。 +- 决策:这两类轻量玩法的生成页隐藏“当前批次”模块,只保留“预计等待”和“计时”并排展示;生成步骤进入页面时按顺序从左侧滑入,强化推进感。2026-05-23 起已被“所有玩法生成页统一圆环主视觉”取代,步骤列表不再作为当前口径。 - 影响范围:`CustomWorldGenerationView`、拼图与抓大鹅创作入口调用处、移动端生成页体验文档。 - 验证方式:拼图与抓大鹅生成页在手机竖屏下只显示等待与计时双栏,步骤卡按顺序滑入;其它未传入隐藏参数的生成页继续保留原批次模块。 - 关联文档:`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 @@ -399,7 +487,7 @@ - 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。 - 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 - 2026-05-18 追加:为缩短首版草稿等待,`compile_puzzle_draft` 在首关命名和 `uiBackgroundPrompt` 稳定后并行启动首关关卡图生成与 UI 背景生成;上传主图且关闭 AI 重绘时,并行执行上传图持久化与 UI 背景生成。生成页预计完成时间按 5 分钟展示。 -- 2026-05-21 追加:拼图结果页独立“素材配置”Tab 已移除,UI spritesheet 与关卡纯背景收口到每关图片生成资产包。每次 `gpt-image-2` 预计 90 秒;草稿完整 AI 重绘路径约 298 秒,上传图且关闭 AI 重绘路径跳过首图生成约 208 秒。结果页关卡详情继续复用 `CreativeImageInputPanel`,本次上传/历史选择图优先成为主图卡片,正式图只作为无新参考图时的预览;仅有正式图时仍允许在画面描述框上传多张参考图。 +- 2026-05-21 追加:拼图结果页独立“素材配置”Tab 已移除,UI spritesheet 与关卡纯背景收口到每关图片生成资产包。每次 `gpt-image-2` 预计 90 秒;2026-05-24 起草稿首图生成单独按 4 分钟展示,草稿完整 AI 重绘路径约 448 秒,上传图且关闭 AI 重绘路径跳过首图生成约 208 秒。结果页关卡详情继续复用 `CreativeImageInputPanel`,本次上传/历史选择图优先成为主图卡片,正式图只作为无新参考图时的预览;仅有正式图时仍允许在画面描述框上传多张参考图。 - 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。 - 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。 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 22f0c5df..3f4ecd5a 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -15,6 +15,93 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 创作卡片点击要直达已有入口表单,别再保留空白入口页 + +- 现象:创作 Tab 模板卡点击后如果仍然停留在创作大厅,或者先进入“X 创作入口”这种空白页,就会让用户多走一层,还可能被错误的 stage 白名单拉回平台。 +- 原因:`/creation/` 一度被接成空白创作入口页,导致 `SelectionStage`、`appPageRoutes` 和卡片点击分流被旧占位 stage 污染。 +- 处理:把 `/creation/` 重新指向已有入口表单 stage,例如 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`;平台壳层和测试同步清理空白入口页相关 helper。 +- 验证:点拼图 / 抓大鹅 / 汪汪声浪卡片后,应看到各自既有工作台内容,例如测试中的 `拼图工作区:missing-session`、`抓大鹅工作区:missing-session` 或 `汪汪声浪配置表单`,并且不再出现“X 创作入口”空白页。 +- 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 + +## 创作流程刷新恢复必须写私有 query + +- 现象:创作生成页或结果页刷新后回到空白工作区、平台首页,或者从作品详情返回时错误复用了别的玩法草稿。 +- 原因:部分创作流程只把 `sessionId` / `profileId` / `draftId` / `workId` 放在前端内存里,没有写进 URL;也曾把写 URL 放在 stage 切换前,`writeCreationUrlState` 因为还停在非创作路径而直接跳过。若跨玩法或公开详情继续保留私有 query,还会污染 `/works/detail?work=...`。 +- 处理:创作页只使用私有 query `sessionId`、`profileId`、`draftId`、`workId` 做刷新恢复,不复用公开 `work` 参数;`pushAppHistoryPath` 只在同一创作流内保留这些 query,离开创作流或切到另一个玩法必须清掉;手动 draft 打开、生成完成和保存回调要在路由已经切到 `/creation/` 后再调用 `writeCreationUrlState`。 +- 验证:`npm run test -- src/services/creationUrlState.test.ts src/routing/appPageRoutes.test.ts src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx`;手测生成页 / 结果页刷新仍恢复同一草稿,打开公开作品详情 URL 不带私有恢复参数。 +- 关联:`src/services/creationUrlState.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 拼图生成页轮询不要绑展示 phase 或不稳定 setter + +- 现象:拼图创作进入生成中页后,`/api/runtime/puzzle/agent/sessions/{sessionId}` 会在 0.3 到 0.5 秒内被反复 GET,看起来像轮询风暴,而不是 3 秒一次的正常刷新。 +- 原因:轮询 `useEffect` 同时依赖了拼图展示 phase 和会随父组件渲染变化的 `setSession` 函数,导致 `puzzleGenerationState` 的进度合并或页面重渲染就会重挂 effect;effect 里又会立即先请求一次 session,于是请求被放大成密集循环。 +- 处理:拼图轮询只绑定 `selectionStage`、`activePuzzleGenerationSessionId` 和“是否仍在生成中”这个布尔条件;`setSession` 通过 ref 保持稳定,不让父组件重新渲染改变轮询器身份。进度 phase 变化只更新展示,不重建轮询。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`,并确认恢复生成中草稿后 `getPuzzleAgentSession` 不会因为进度刷新继续连发。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/usePlatformCreationAgentFlowController.ts`、`src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx`。 + +## 拼图试玩恢复 query 必须先切到运行态路径再写 + +- 现象:拼图试玩或正式运行态打开后,刷新会停在“正在进入拼图关卡”,或地址栏只有 `runtimeProfileId`,缺少草稿 `runtimeSessionId`。 +- 原因:`writePuzzleRuntimeUrlState` 只会在当前路径已经是 `/runtime/puzzle` 时写入;如果先触发阶段切换再写 query,或者草稿作品摘要缺少 `sourceSessionId`,就会把恢复参数写丢。`App.tsx` 的 stage 同步也会改 pathname,所以顺序不对时容易只留下部分 query。 +- 处理:进入拼图 runtime 时先 `pushAppHistoryPath('/runtime/puzzle')`,再 `setSelectionStage('puzzle-runtime')`,最后写 `runtimeProfileId`、`runtimeSessionId`、`runtimeLevelId`、`work`、`mode`;草稿 runtime URL state 允许从 `profileId` 反推 `puzzle-session-*`,作为 `sourceSessionId` 的兜底。 +- 验证:`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 仍带红色阴影,和平台暖棕体系不一致。 +- 原因:`platform-nav-unread-dot`、`creation-work-card__unread-dot` 直接写了 `#b64a35` 和 `rgba(239, 68, 68, ...)`,没有收口到统一 token。 +- 处理:未读点颜色统一走 `--platform-unread-dot-fill` / `--platform-unread-dot-glow`,桌面/移动端共用同一口径;不要把红色 literal 再写回样式。 +- 验证:`src/index.test.ts` 断言两个 unread dot block 都只引用未读点 token,不再出现红色 literal 或红色 glow。 +- 关联:`src/index.css`、`src/index.test.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 创作 Tab 模板卡不要复用暗图蒙版参考卡样式 + +- 现象:创作 Tab 两列玩法卡上图能看到,但标题、描述或预计消耗泥点在白底信息区里看不见,或只剩泥点小图标。 +- 原因:旧 `platform-creation-reference-card` 是给暗图蒙版卡用的全局样式,会把卡片及全部子元素强制成白色文字;参考图要求的是“上图 + 下方白底信息区”,继续复用旧类会让白底上的文字消失。 +- 处理:创作 Tab 首屏模板卡使用独立 `creation-template-card`、`creation-template-card__body`、`creation-template-card__title`、`creation-template-card__subtitle` 和 `creation-template-card__cost` 结构,不挂 `platform-creation-reference-card`;旧弹层如果仍是暗图蒙版卡,可以继续保留旧类。 +- 验证:浏览器创作 Tab 中每张卡都应显示标题、描述和“预计消耗 10-20 泥点”;`npm test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx -t "creation start card renders reference-aligned banner and template metadata"` 应通过。 +- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/index.css`、`src/components/custom-world-home/CustomWorldCreationHub.test.tsx`。 + +## 创作首屏开放态卡片不要再显示左上状态标签 + +- 现象:创作 Tab 的开放态玩法卡左上角会重复显示“可创建”或“可创作”,视觉上比其它状态更吵,还会和封面图抢注意力。 +- 原因:卡片渲染层默认把 `badge` 当成所有状态都要展示的左上角标签,没有区分开放态与非开放态。 +- 处理:开放态卡片不渲染左上标签,仅保留标题、描述和右下角消耗信息;`敬请期待`、`即将开放` 等非开放态标签继续保留。 +- 验证:创作首屏 HTML 中不应包含 `可创建` / `可创作`,但仍应包含 `即将开放` 等非开放态状态。 +- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 发现 / 创作 / 草稿页不要把根内容区再包成全局卡片壳 + +- 现象:发现页、创作页或草稿页根区一旦套回 `platform-page-stage`,页面边缘会立刻变得更厚,频道标签、列表和模板卡的横向空间都被挤窄,看起来像回到了旧全局卡片壳。 +- 原因:`platform-page-stage` 本身是全局内容卡片壳,适合推荐页、我的页和其它页面,但这三页已经有自己的视觉结构;草稿页顶部筛选若继续用旧 `platform-tab`,还会和发现页频道标签不一致。 +- 处理:这三页的根内容区只保留 `platform-remap-surface`,不要再加 `platform-page-stage`;草稿页顶部筛选复用发现页的 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`。 +- 验证:浏览器里这三页的根区应仍保留 `platform-remap-surface`,但不再出现 `platform-page-stage`;草稿页顶部筛选样式应和发现页频道标签一致。 +- 关联:`src/components/custom-world-home/CustomWorldCreationHub.tsx`、`src/components/custom-world-home/CustomWorldWorkTabs.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/index.css`。 + ## SpacetimeDB 入口迁移 helper 合并时不要只保留调用 - 现象:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 或 Jenkins `Genarrative-Stdb-Module-Build` 报 `E0425 cannot find function migrate_rpg_entry_from_old_hidden_default in this scope`,位置在 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 的默认入口配置播种流程。 @@ -194,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=-`。 @@ -785,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 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。 @@ -801,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`。 @@ -942,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`。 @@ -1292,6 +1388,14 @@ - 验证:Jenkins 目标机日志不再出现 `unexpected argument '-y'`、`unknown command name for spacetimedb-update multicall binary`,后续应继续检查 `bin/current/spacetimedb-cli` 和 `bin/current/spacetimedb-standalone` 是否生成。 - 关联:`scripts/prepare-server-provision-tools.sh`、`jenkins/Jenkinsfile.production-server-provision`。 +## 清库重建后先查 schema 兼容再重启 + +- 现象:`npm run dev -- --clear-database --no-interactive` 之后,api-server 仍在 `GET /api/creation-entry/config` 或订阅恢复阶段报 `No such procedure` / schema guard 失败。 +- 原因:本地重建只会重发当前 `spacetime-module`,不会自动修正旧迁移 JSON 的字段兼容;如果 `migration.rs` 没把新字段补成 `None` / 默认值,清库后重建仍会卡在 schema 同步。 +- 处理:先让 `server-rs/crates/spacetime-module/src/migration.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 和生成绑定对齐,再执行清库重建。 +- 验证:`npm run check:spacetime-schema` 先通过,再重启 `npm run dev -- --clear-database --no-interactive`,最后检查 `/v1/ping`、`/healthz` 和 `GET /api/creation-entry/config`。 +- 关联:`server-rs/crates/spacetime-module/src/migration.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`scripts/dev.mjs`。 + ## QQ 浏览器发现页推荐封面全不显示先查 aspect-ratio 兜底 - 现象:发现页的“推荐”子频道作品卡标题、作者和数据正常,但所有封面图不显示,常见于 QQ 浏览器 / X5 等旧移动内核。 @@ -1300,13 +1404,13 @@ - 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。 - 关联:`src/index.css`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/rpgEntryWorldPresentation.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 生成中草稿刷新后不要只恢复作品架遮罩 +## 生成中草稿刷新后不要复用旧 updatedAt 当展示起点 -- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但点击卡片会走普通草稿恢复,可能进入空白结果页或未完成工作区。 -- 原因:前端只把内存 notice 当作“生成中点击恢复”的判断条件,没有把后端摘要里的 `generationStatus=generating` 纳入同一路径。 -- 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice,恢复对应玩法生成进度页;恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`。 -- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`。 -- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但进入生成页时总进度首帧直接跳到 80%+,看起来像已经跑了一大半。 +- 原因:前端只把持久化 `generationStatus=generating` 当作恢复生成页的条件,但恢复展示时仍沿用了作品摘要 `updatedAt` 作为伪 `startedAtMs`;同时拼图总进度又把后端 `progressPercent` 直接当作 floor,导致 `86%` 之类未到首个里程碑的会话一进页就抬到 80%+。 +- 处理:恢复生成中的草稿时,展示起点改用“进入生成页的当前时间”;`updatedAt` 只保留给作品架排序和摘要,不再参与生成页假进度起算。拼图总进度还要忽略 `88` 以下的后端进度 floor,拼图保留后端里程碑推进,抓大鹅等非拼图玩法则从 `0%` 平滑起步,避免刚进页就看到 4% / 88% / 80%+。 +- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`、`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts -t "match3d draft generation starts total progress from zero"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`src/services/miniGameDraftGenerationProgress.ts`、`docs/【玩法创作】拼图生成页进度口径-2026-05-23.md`。 ## 汪汪声浪草稿试玩不要写正式 run @@ -1364,6 +1468,18 @@ - 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;契约测试应断言前端 JSON 自带的 `hitObjectAsset` 会被忽略,spacetime-client 定向测试应断言缺少服务端注入的真实 `hitObjectAsset` 时不能编译;浏览器 Network 中 generated 图片应先换签,签名 URL 指向已存在对象。 - 关联:`server-rs/crates/api-server/src/wooden_fish.rs`、`server-rs/crates/spacetime-client/src/wooden_fish.rs`、`src/components/ResolvedAssetImage.tsx`、`src/services/assetReadUrlService.ts`。 +## 生成页背景视频要固定全屏并显式触发播放 + +- 现象:生成页明明带了 `media/create_bg_video.mp4`,但移动端或某些内核里只看到静态首帧,或视频层跟着局部容器滚动,被白色面板压住后看起来像没加载。 +- 原因:仅靠 `autoPlay/loop/muted/playsInline` 并不稳定;视频如果仍挂在局部容器里,还会被页面面板和遮罩吞掉。某些浏览器初始化后也会停在 `paused=true`。 +- 处理:背景视频必须放到 `fixed inset-0` 的全屏底层容器里,外层页面用 `isolate` / 透明底控制叠层;挂载后显式尝试 `play()`,并在 `loadeddata`、`canplay` 和页面聚焦时再次触发,避免只停首帧。 +- 验证:移动端视口检查视频 `rect` 应覆盖整个视口,`paused` 应最终变为 `false`,`currentTime` 应持续前进。 +- 关联:`src/components/GenerationProgressHero.tsx`、`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`。 + +2026-05-24 补充:`GenerationPageBackdrop` 不要通过 portal 挂到 `document.body`。body 级 fixed 背景会逃离生成页自己的 stacking context,即使业务内容有局部 `z-10`,真实浏览器里也可能把整页 UI 压住。背景视频应作为生成页根容器子节点保留 `fixed inset-0 z-0`,生成页内容保持 `relative z-10`;相关测试应同时断言背景容器低层级、生成页根容器高层级,以及视频节点仍在生成页 DOM 内部。视觉调整时还要记住:空心圆环的中心块要抽掉,时间卡与总进度标题都应缩小,不要让生成页再回到“纯色底 + 大字号说明卡”的状态。顶部返回和右上状态也不能沿用 `text-lg` / `sm:text-2xl` 这类展示级字号;当前步骤名、步骤状态和底部玩法信息标题要维持普通 UI 字号档位,优先保持 `text-xs` 到 `text-sm` 区间。 + +2026-05-24 补充:生成页“预计等待 / 已耗时”卡片本身已经有标签,传给 `GenerationProgressHero` 的值只能是纯时间,例如 `4 分钟`、`1 分 15 秒`,不要再拼接“预计还需”或“已耗时”;两张时间卡也要和当前步骤卡一样保持半透明。拼图总进度初始帧必须允许显示 `0%`,不要再用 `Math.max(1, nextProgress)` 之类的保护把启动态抬到 `1%`。 + ## `dev:spacetime` 启动后 3101 又断开先查 publish 是否被 spacetime.json 干扰 - 现象:浏览器报 `Failed to initiate WebSocket connection`,目标为 `ws://127.0.0.1:3101/v1/database//subscribe`,端口检查发现 `3101` 没有长期监听;手动运行 `npm run dev:spacetime` 可看到 standalone 短暂启动后退出,发布阶段报 `No database target matches ''`。 @@ -1404,10 +1520,10 @@ - 验证:定向测试 `cargo test -p api-server generated_asset_sheet_two_items_per_row --manifest-path server-rs/Cargo.toml -- --nocapture` 应通过,且错位透明样本应按连通域切出完整视图。 - 关联:`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`server-rs/crates/api-server/src/match3d/item_assets.rs`。 -## 存档选择入口不要只藏在“玩过”弹窗里 +## 个人中心不再保留直达“存档”按钮入口 -- 现象:用户有 RPG / 拼图运行态存档,但平台底部 `草稿` Tab 只展示作品架,个人中心只有点击 `玩过` 后才可能看到“可继续”,导致看起来没有存档选择入口。 -- 原因:`/api/profile/save-archives` 已在入口 bootstrap 加载,但前端只把 `saveEntries` 注入 `ProfilePlayedWorksModal`;没有独立的存档入口。 -- 处理:个人中心 `常用功能` 必须保留 `存档` 快捷入口,点击后打开独立存档选择弹窗并复用 `SaveArchiveCard`;恢复仍走 `/api/profile/save-archives/{worldKey}`,拼图存档继续走拼图 resume 分支,RPG 走 `handleContinueGame(snapshot)`。 -- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile page exposes save archive picker"`。 -- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/useRpgEntryBootstrap.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 现象:2026-05-25 起,移动端“我的”页顶部改为品牌行 + 扫码 / 设置按钮,设置区和次级入口不再提供独立的 `存档` 按钮;用户仍可在“玩过”弹窗里查看可继续存档。 +- 原因:产品布局收口后,个人中心只保留设置、扫码、常用功能和条件性次级入口,存档恢复继续以后端 `/api/profile/save-archives` 真相为准,但不再作为页面直达入口。 +- 处理:后续如果需要重新暴露存档入口,优先评估是否应回到“玩过”或别的独立弹窗流程,不要默认把存档再塞回常用功能宫格或设置列表。 +- 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.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/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 43bc2e03..00a2a4df 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -157,6 +157,9 @@ export interface AdminCreationEntryTypeConfigPayload { visible: boolean; open: boolean; sortOrder: number; + categoryId: string; + categoryLabel: string; + categorySortOrder: number; updatedAtMicros: number; } @@ -169,6 +172,9 @@ export interface AdminUpsertCreationEntryTypeConfigRequest { visible: boolean; open: boolean; sortOrder: number; + categoryId: string; + categoryLabel: string; + categorySortOrder: number; } export interface AdminUpsertProfileRedeemCodeRequest { diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx index 2b1b2995..fb817c65 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx @@ -27,6 +27,9 @@ export function AdminCreationEntrySwitchPage({ const [visible, setVisible] = useState(true); const [open, setOpen] = useState(true); const [sortOrder, setSortOrder] = useState('30'); + const [categoryId, setCategoryId] = useState('recent'); + const [categoryLabel, setCategoryLabel] = useState('最近创作'); + const [categorySortOrder, setCategorySortOrder] = useState('10'); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [listErrorMessage, setListErrorMessage] = useState(''); @@ -82,6 +85,9 @@ export function AdminCreationEntrySwitchPage({ visible, open, sortOrder: parseInteger(sortOrder), + categoryId: categoryId.trim(), + categoryLabel: categoryLabel.trim(), + categorySortOrder: parseInteger(categorySortOrder), }); const nextEntries = sortEntries(response.entries); setEntries(nextEntries); @@ -105,6 +111,9 @@ export function AdminCreationEntrySwitchPage({ setVisible(entry.visible); setOpen(entry.open); setSortOrder(String(entry.sortOrder)); + setCategoryId(entry.categoryId); + setCategoryLabel(entry.categoryLabel); + setCategorySortOrder(String(entry.categorySortOrder)); } return ( @@ -189,6 +198,32 @@ export function AdminCreationEntrySwitchPage({ /> +
+ + +
+ + + {errorMessage ? (
{errorMessage} @@ -211,6 +246,7 @@ export function AdminCreationEntrySwitchPage({ 入口 展示 开放 + 分类 排序 @@ -228,6 +264,7 @@ export function AdminCreationEntrySwitchPage({ {entry.visible ? '是' : '否'} {entry.open ? '是' : '否'} + {entry.categoryLabel || entry.categoryId} {entry.sortOrder} ))} diff --git a/docs/README.md b/docs/README.md index 36689c78..39af032a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,10 @@ - [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。 - [PRD](./prd/README.md):产品需求与阶段计划;参考 MOKU / 幕间类 AI 文游的陶泥儿 `text-game` 模板口径见 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md),视觉小说模板 TXT 玩法平台化接入口径见 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md),创意互动内容 Agent Phase 1 的 LangChain-Rust PoC、拼图闭环和并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md),幸存者类模板闭环见 [AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md](./prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md),后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md),跳一跳俯视角玩法模板 PRD 见 [【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md](./prd/%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E8%B7%B3%E4%B8%80%E8%B7%B3%E4%BF%AF%E8%A7%86%E8%A7%92%E7%8E%A9%E6%B3%95%E6%A8%A1%E6%9D%BFPRD-2026-05-19.md)。 +拼图生成页步骤真进度、步骤内假进度和精简展示口径见 [【玩法创作】拼图生成页进度口径-2026-05-23.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E6%8B%BC%E5%9B%BE%E7%94%9F%E6%88%90%E9%A1%B5%E8%BF%9B%E5%BA%A6%E5%8F%A3%E5%BE%84-2026-05-23.md)。 + +从文字需求生成高一致性美术素材流程抽象出的发明专利交底稿见 [【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md](./%E3%80%90%E4%B8%93%E5%88%A9%E4%BA%A4%E5%BA%95%E3%80%91%E4%B8%80%E7%A7%8D%E6%9E%81%E4%BD%8E%E6%88%90%E6%9C%AC%E5%BF%AB%E9%80%9F%E7%94%9F%E6%88%90%E9%AB%98%E8%B4%A8%E9%87%8F2D%E5%B0%8F%E6%B8%B8%E6%88%8F%E9%AB%98%E4%B8%80%E8%87%B4%E6%80%A7%E7%BE%8E%E6%9C%AF%E7%B4%A0%E6%9D%90%E7%9A%84%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88-2026-05-25.md)。 + 生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。 SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。 diff --git a/docs/superpowers/plans/2026-05-24-create-tab-blank-entry-page.md b/docs/superpowers/plans/2026-05-24-create-tab-blank-entry-page.md new file mode 100644 index 00000000..582d8ed6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-create-tab-blank-entry-page.md @@ -0,0 +1,33 @@ +# 创作 Tab 入口表单回切记录 + +> 说明:本文件原本记录“空白入口页”实施计划。2026-05-24 已按产品反馈回切为点击玩法模板卡后直达既有入口创作表单,保留此文件作为历史回顾,避免后续误按旧计划继续实现空白页。 + +**Goal:** 创作 Tab 只展示赛事 banner、玩法模板分类和两列玩法卡;点击卡片后直接进入对应玩法已有的入口创作表单,而不是继续展示空白占位页。 + +**Architecture:** 保留现有创作大厅和入口配置事实源。创作大厅负责参考图 banner、分类 tabs 和入口卡片;平台壳层负责把卡片点击路由到对应玩法的既有入口表单 stage。入口表单继续承接各玩法自己的表单、草稿恢复和后续编排,不再多套一层空白入口页。 + +**Current Route Mapping:** + +- `/creation/rpg` -> `agent-workspace` +- `/creation/big-fish` -> `big-fish-agent-workspace` +- `/creation/match3d` -> `match3d-agent-workspace` +- `/creation/square-hole` -> `square-hole-agent-workspace` +- `/creation/jump-hop` -> `jump-hop-workspace` +- `/creation/wooden-fish` -> `wooden-fish-workspace` +- `/creation/puzzle` -> `puzzle-agent-workspace` +- `/creation/bark-battle` -> `bark-battle-workspace` +- `/creation/visual-novel` -> `visual-novel-agent-workspace` +- `/creation/baby-object-match` -> `baby-object-match-workspace` + +**Verification:** + +- `npm test -- src/routing/appPageRoutes.test.ts` +- `npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab opens match3d entry form from the template card|create tab opens puzzle entry form from the template card|create tab opens bark battle entry form from the template card"` +- `npm run typecheck` +- `npm run check:encoding` + +**Notes:** + +- 不再新增或保留 `*-workspace-entry` 空白 stage。 +- `/creation/` 是用户可直达的入口表单 URL。 +- 旧的空白入口页方案已废弃;如需重新引入,必须先更新平台入口文档和本记录。 diff --git a/docs/superpowers/plans/2026-05-24-profile-tab-mobile-layout.md b/docs/superpowers/plans/2026-05-24-profile-tab-mobile-layout.md new file mode 100644 index 00000000..bd2864e6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-profile-tab-mobile-layout.md @@ -0,0 +1,59 @@ +# Profile Tab Mobile Layout Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Tighten the mobile `我的` Tab layout so typography, spacing, and fixed-height controls align with the rest of the platform UI and do not overlap the bottom dock. + +**Architecture:** Keep the existing `RpgEntryHomeView` structure and update only page-specific class names plus CSS rules. Treat `src/index.css` as the platform token surface; document the mobile profile Tab acceptance criteria in the existing product / play-flow docs instead of creating a parallel doc. + +**Tech Stack:** React, TypeScript, Tailwind utility classes, project CSS in `src/index.css`, Vitest, Vite local browser smoke. + +--- + +### Task 1: Add Mobile Profile Layout Assertions + +**Files:** +- Modify: `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` + +- [ ] Add expectations in `mobile profile page matches the reference layout sections` that the profile page, header, stats grid, daily task card, shortcut grid, and bottom dock expose the stable class hooks used by the mobile CSS. + +- [ ] Run `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections"` and verify the test covers the hooks before CSS edits. + +### Task 2: Tighten Profile Tab Markup Hooks + +**Files:** +- Modify: `src/components/rpg-entry/RpgEntryHomeView.tsx` + +- [ ] Add focused class hooks for profile identity text, stat values, shortcut labels, membership copy, and daily task copy without changing user-facing Chinese text. + +- [ ] Keep the current page order: profile header, membership card, three stats, daily task, five shortcut buttons, settings, secondary shortcuts, legal strip. + +### Task 3: Implement Mobile CSS + +**Files:** +- Modify: `src/index.css` + +- [ ] Under the existing `@media (max-width: 639px)` block, reduce `我的` Tab fixed sizes to ordinary UI scale: profile title 16-17px, body copy 11-13px, stats values 13-14px, shortcut labels 11-12px. + +- [ ] Make narrow screens robust: stats grid uses three min-width-safe columns, shortcut grid becomes `repeat(5, minmax(0, 1fr))` above 360px and a stable `repeat(3, minmax(0, 1fr))` below 360px, identity text wraps safely, legal links wrap when needed. + +- [ ] Keep the bottom dock fixed but add enough profile page bottom padding so the final legal / secondary section can scroll above it. + +### Task 4: Update Docs + +**Files:** +- Modify: `docs/【项目基线】当前产品与工程约束-2026-05-15.md` +- Modify: `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` + +- [ ] Add the acceptance rule that mobile `我的` Tab typography stays within normal UI sizes and all functional blocks must scroll clear of the bottom dock on narrow screens. + +### Task 5: Verify + +**Files:** +- No new files. + +- [ ] Run `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections"`. + +- [ ] Run `npm run check:encoding`. + +- [ ] Run a local mobile viewport smoke check for the profile tab if the dev server starts cleanly. diff --git a/docs/【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md b/docs/【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md new file mode 100644 index 00000000..391e1d69 --- /dev/null +++ b/docs/【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md @@ -0,0 +1,184 @@ +# 一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案 + +更新时间:`2026-05-25` + +> 本文为内部发明专利交底稿,目标是把“文字需求 -> 画面图 -> 透明 spritesheet -> 自动边界检测 -> 元素绑定”这一条高一致性美术素材生成链路抽象为可申请的通用技术方案。 + +## 摘要 + +本发明涉及一种极低成本快速生成高质量 2D 小游戏高一致性美术素材的解决方案。该方案接收文字形态的需求描述,调用图片生成模型生成一张用于表达整体视觉关系的游戏画面图;再以所述游戏画面图作为参考,继续调用图片生成模型生成一张透明背景的 spritesheet 图片,所述 spritesheet 图片承载需要随不同设备分辨率自适应调整位置的素材;随后基于自动边界检测算法对所述 spritesheet 图片中的素材进行逐一解析,按照从上到下、从左到右的顺序,将解析出的素材与文字形态需求描述中的画面元素一一对应,并将代码中的元素标识与对应素材绑定。该方案通过单次文字输入驱动画面图生成、基于画面图派生透明 spritesheet、自动边界检测替代人工切图、顺序映射替代手工命名和手工对图,从而在较少人工干预和较低重复生成成本下,快速得到风格统一、可直接绑定代码的高一致性美术素材。 + +## 技术领域 + +本发明属于人工智能图像生成、2D 小游戏美术素材生产、图像分割解析和元素绑定技术领域,具体涉及一种根据文字需求描述自动生成游戏画面图、spritesheet 美术素材和元素映射关系的方法及系统。 + +## 背景技术 + +现有 2D 小游戏的美术素材生产通常包含以下步骤:先由设计人员撰写文字需求,再由美术人员分别绘制画面图、按钮图、状态图和装饰图,之后由前端或游戏程序员进行切图、命名、排布和代码绑定。该流程存在如下问题: + +1. 素材往往分散生成,整体风格不统一。 +2. 多分辨率适配时,需要人工调整大量元素位置,维护成本高。 +3. 切图和命名依赖人工,容易出现遗漏、错位和绑定错误。 +4. 文字需求与最终代码元素之间缺少稳定映射,后续修改代价大。 +5. 若每个素材分别生成,会增加生成次数和等待成本。 + +因此,需要一种能够把文字需求直接转化为成套美术素材,并且能够自动解析、自动映射、自动绑定到代码中的方法。 + +## 发明内容 + +### 要解决的技术问题 + +本发明主要解决以下技术问题: + +1. 如何根据文字形态的需求描述快速生成一张完整游戏画面图。 +2. 如何基于该画面图进一步生成透明背景的 spritesheet 图片。 +3. 如何基于自动边界检测算法逐一解析 spritesheet 中的素材。 +4. 如何按照从上到下、从左到右的顺序将素材与文字描述中的画面元素一一对应。 +5. 如何将映射结果稳定绑定到代码,减少人工切图和手工配置成本。 + +### 技术方案 + +本发明提供一种高一致性美术素材生成方法,包括如下步骤: + +```text +文字形态需求描述 + -> 游戏画面图生成 + -> 透明背景 spritesheet 生成 + -> 自动边界检测解析素材 + -> 顺序映射文字元素 + -> 代码绑定 +``` + +其中,所述文字形态需求描述至少包含画面元素名称、语义说明、布局意图和顺序信息。所述游戏画面图用于表达整体视觉风格和元素关系;所述 spritesheet 图片用于承载需要随不同设备分辨率自适应调整位置的素材;所述自动边界检测算法用于把 spritesheet 中的独立素材一一切分出来;所述顺序映射用于将解析结果与文字描述中的元素一一对应;所述代码绑定用于将元素标识、资源地址、边界框或布局参数写入代码配置或元素表。 + +### 有益效果 + +与现有技术相比,本发明至少具有以下效果: + +1. 降低人工切图成本。 +2. 降低人工命名和代码绑定成本。 +3. 提升整体美术素材一致性。 +4. 提升多分辨率适配效率。 +5. 减少重复生成和重复调整次数。 +6. 让文字需求到代码元素的映射更稳定、更可维护。 + +## 附图说明 + +图 1 为本发明从文字需求描述到元素绑定的总体流程图。 + +图 2 为游戏画面图生成与 spritesheet 生成的派生关系示意图。 + +图 3 为自动边界检测算法解析透明背景 spritesheet 的流程图。 + +图 4 为素材顺序与文字形态需求描述中的画面元素一一对应的映射关系示意图。 + +## 具体实施方式 + +### 一、系统组成 + +本发明的系统可以包括如下模块: + +1. 输入采集模块:用于接收文字形态需求描述。 +2. 图像生成模块:用于根据文字形态需求描述生成游戏画面图。 +3. 图集生成模块:用于根据游戏画面图生成透明背景 spritesheet 图片。 +4. 边界检测模块:用于对 spritesheet 图片执行自动边界检测算法。 +5. 顺序映射模块:用于将解析出的素材按照从上到下、从左到右的顺序与文字描述中的画面元素对应。 +6. 代码绑定模块:用于将元素标识与对应素材绑定。 + +### 二、方法步骤 + +#### S100:接收文字形态需求描述 + +系统接收用户输入的文字形态需求描述。该需求描述可写成一段自然语言,也可写成按元素顺序排列的结构化文本。需求描述中应至少能够识别出画面元素名称、语义含义和布局顺序。 + +#### S200:生成游戏画面图 + +图像生成模块调用图片生成模型,根据所述文字形态需求描述生成一张完整游戏画面图。所述游戏画面图用于表达整体视觉关系、主次层级和风格基调,为后续 spritesheet 生成提供统一参考。 + +#### S300:生成透明背景 spritesheet 图片 + +图集生成模块以所述游戏画面图为参考,再次调用图片生成模型,生成一张透明背景的 spritesheet 图片。所述 spritesheet 图片中包含需要随不同设备分辨率自适应调整位置的素材,例如按钮、状态条、提示气泡、装饰元素或其他需要由代码控制位置的元素。 + +#### S400:自动边界检测解析素材 + +边界检测模块对所述 spritesheet 图片执行自动边界检测算法,对透明背景中的每个独立素材进行逐一解析,输出素材边界框、素材索引和必要的资源属性。所述自动边界检测算法优选采用 alpha 通道连通域检测、边界矩形检测或二者组合;在一个优选实施方式中,可复用拼图场景中已验证的自动边界检测思路,以提高解析稳定性。 + +#### S500:按照顺序映射文字元素 + +顺序映射模块将解析出的素材按照从上到下、从左到右的顺序进行排列,并与文字形态需求描述中的画面元素内容一一对应。若需求描述中已显式给出元素顺序,则优先按该顺序映射;若仅给出自然语言描述,则可先抽取元素列表,再按布局顺序排序。由此形成元素索引与语义名称之间的稳定映射关系。 + +#### S600:代码绑定 + +代码绑定模块将所述映射关系写入代码配置、元素表或资源清单中。代码侧只需读取元素标识,即可找到对应素材的资源地址、边界框和布局参数,从而完成美术素材与程序逻辑之间的直接绑定。 + +#### S700:输出美术素材包 + +系统最终输出至少包括游戏画面图、透明背景 spritesheet 图片、素材映射表和代码绑定结果。由于 spritesheet 图片与游戏画面图来自同一视觉链路,且素材顺序与文字描述顺序一一对应,因此可得到风格统一、可直接绑定、可适配多分辨率的高一致性美术素材包。 + +### 三、核心机制 + +1. 文字驱动:一次文字描述即可驱动画面图和 spritesheet 生成。 +2. 单图派生:spritesheet 以游戏画面图为参考生成,减少风格漂移。 +3. 自动解析:边界检测算法替代人工切图。 +4. 顺序对应:素材顺序与文字元素顺序一致,减少命名和对图错误。 +5. 代码绑定:映射结果可直接进入代码配置或资源表。 + +### 四、实施例 + +#### 实施例一:界面型 2D 小游戏素材生成 + +用户输入“科技实验室界面,顶部标题栏,中部主角色,底部三个操作按钮,右侧状态提示”。系统先生成一张完整游戏画面图,再生成一张透明背景 spritesheet 图片。边界检测模块解析出标题栏、主角色、操作按钮和状态提示等素材,顺序映射模块按从上到下、从左到右的顺序将其与文字描述对应,代码绑定模块将这些元素写入代码配置,最终形成可直接用于界面装配的素材包。 + +#### 实施例二:需要自适应位置的素材生成 + +用户输入“横版战斗界面,血条、技能按钮、提示气泡、道具栏”。系统将这些需要随设备分辨率自适应调整位置的元素集中生成到同一张 spritesheet 图片中。运行时,代码根据元素绑定结果对血条、按钮和提示元素进行位置调整,而不改变它们对应的语义关系。 + +#### 实施例三:代码与元素一一绑定 + +系统为解析出的每个素材分配唯一元素标识,例如 `top_title_bar`、`center_character`、`bottom_actions`、`right_status_hint`。代码侧通过元素标识直接读取对应素材的边界框和资源路径,从而消除人工对图和人工命名的步骤。 + +## 权利要求书草案 + +1. 一种极低成本快速生成高质量 2D 小游戏高一致性美术素材的方法,其特征在于,包括:接收文字形态需求描述;根据所述文字形态需求描述调用图片生成模型生成游戏画面图;以所述游戏画面图为参考图再次调用图片生成模型生成透明背景的 spritesheet 图片;对所述 spritesheet 图片执行自动边界检测算法,逐一解析素材边界;按照从上到下、从左到右的顺序将解析出的素材与所述文字形态需求描述中的画面元素一一对应;将代码中的元素标识与对应素材绑定。 + +2. 根据权利要求 1 所述的方法,其特征在于,所述文字形态需求描述至少包括画面元素名称、语义说明和布局顺序。 + +3. 根据权利要求 1 所述的方法,其特征在于,所述游戏画面图用于表达整体视觉风格和元素关系,所述 spritesheet 图片用于承载需要随不同设备分辨率自适应调整位置的素材。 + +4. 根据权利要求 1 所述的方法,其特征在于,所述 spritesheet 图片具有透明背景,且素材之间通过透明区域分隔。 + +5. 根据权利要求 1 所述的方法,其特征在于,所述自动边界检测算法包括基于 alpha 通道的连通域检测、边界矩形检测或二者组合。 + +6. 根据权利要求 5 所述的方法,其特征在于,所述自动边界检测算法复用拼图场景中已验证的素材边界解析思路。 + +7. 根据权利要求 1 所述的方法,其特征在于,所述从上到下、从左到右的顺序用于建立元素索引与语义名称之间的映射表。 + +8. 根据权利要求 1 所述的方法,其特征在于,所述代码绑定包括为每一素材写入唯一元素标识,并在代码中通过所述元素标识读取对应素材的资源地址、边界框或布局参数。 + +9. 根据权利要求 1 所述的方法,其特征在于,所述 spritesheet 图片中的素材包括按钮、状态条、提示元素、装饰元素或其他需要自适应布局的画面元素。 + +10. 根据权利要求 1 所述的方法,其特征在于,所述游戏画面图与所述 spritesheet 图片由同一视觉链路生成,以保持美术素材的一致性。 + +11. 根据权利要求 1 所述的方法,其特征在于,所述元素绑定结果用于在不同设备分辨率下动态调整素材位置,而不改变元素语义对应关系。 + +12. 一种极低成本快速生成高质量 2D 小游戏高一致性美术素材的系统,其特征在于,包括输入采集模块、图像生成模块、图集生成模块、边界检测模块、顺序映射模块和代码绑定模块;所述各模块被配置为执行权利要求 1 至 11 任一项所述的方法。 + +13. 一种电子设备,包括处理器和存储器,所述存储器中存储有计算机程序,其特征在于,所述计算机程序被所述处理器执行时实现权利要求 1 至 11 任一项所述的方法。 + +14. 一种计算机可读存储介质,其上存储有计算机程序,其特征在于,所述计算机程序被处理器执行时实现权利要求 1 至 11 任一项所述的方法。 + +## 可重点保护的创新点 + +1. 文字需求直接驱动一张游戏画面图。 +2. 基于该画面图再生成透明背景 spritesheet。 +3. 自动边界检测替代人工切图。 +4. 按从上到下、从左到右的顺序把素材与文字元素一一对应。 +5. 通过元素标识直接绑定代码与素材,减少人工命名和对图成本。 + +## 正式申请前建议 + +1. 检索是否已有“文字生成画面图 + spritesheet 自动解析 + 元素绑定”的相近专利,再确定独立权利要求的保护重心。 +2. 将“极低成本”“高质量”等效果性表述尽量放在说明书效果部分,权利要求中改写为“减少人工切图”“减少重复生成”“提高一致性”等技术特征。 +3. 避免在权利要求中绑定特定供应商或模型名称;模型名称可保留在实施例中。 +4. 如需扩大保护范围,可将“2D 小游戏”进一步上位为“交互式图像驱动应用”的美术素材生成方法。 +5. 如需增强授权稳定性,可将“文字驱动生成 + 透明 spritesheet + 自动边界检测 + 顺序映射 + 代码绑定”组合为主权利要求的必要技术特征。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a66c633a..4e0ded6e 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`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 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 表目录 @@ -312,11 +315,15 @@ npm run check:server-rs-ddd - Rust 结构体:`CreationEntryConfig` - 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` +- 字段:`config_id`、`start_title`、`start_description`、`start_idle_badge`、`start_busy_badge`、`modal_title`、`modal_description`、`updated_at`、`event_title`、`event_description`、`event_cover_image_src`、`event_prize_pool_mud_points`、`event_starts_at_text`、`event_ends_at_text`。 +- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;运行态读取层再按 `module-runtime` 默认横幅归一,不覆盖后台已保存配置。 ### `creation_entry_type_config` - Rust 结构体:`CreationEntryTypeConfig` - 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` +- 字段:`id`、`title`、`subtitle`、`badge`、`image_src`、`visible`、`open`、`sort_order`、`updated_at`、`category_id`、`category_label`、`category_sort_order`。 +- 迁移兼容:旧迁移包缺少入口分类字段时,由 `migration.rs` 写入 `None` / `0` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费。 ### `custom_world_agent_message` @@ -639,6 +646,7 @@ npm run check:server-rs-ddd 拼图、自定义世界、抓大鹅、方洞挑战、视觉小说和大鱼吃小鱼的公开列表 HTTP 路由都从订阅 cache 读取公开 read model / view。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。 `GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。 +入口配置快照包含 start card、类型弹窗、活动横幅和入口类型列表;入口类型列表新增 `category_id`、`category_label`、`category_sort_order` 后,后台 upsert、`shared-contracts`、`module-runtime` 和 `spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。 RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`;历史 `custom-world` 路由仍是 RPG 的工程域与运行态源类型。入口熔断把 `/api/runtime/custom-world*`、`/api/story/*` 和 `/api/runtime/chat/*` 统一映射到 `rpg`,不要新增平行 `airp` 路由或用 `airp` 接管当前文字冒险链路。 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 29d7c8ee..9560a51a 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -6,10 +6,16 @@ 创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。 -当前创作 Tab 固定为智能创作首页与模板入口,草稿 Tab 承接作品架。点击独立入口后应切换到对应内嵌创作表单或生成页;不要额外做一张平行配置页,除非玩法本身需要完整独立工作台。 +当前创作 Tab 只承载赛事 banner、玩法模板分类和两列模板卡;点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作 Tab 首屏。移动端创作 Tab 顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把活动奖池当作账号余额展示。首屏 banner 结构按参考图拆成横向可滑动赛事卡、主体宣传图文区、奖池胶囊、开始 / 结束时间条和卡片内分页点;轮播只保留 `拼图主题创作赛` 和 `抓大鹅主题创作赛`,两个主题赛事奖池均为 `1000` 泥点数。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作 Tab 根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作首屏字号需要对齐平台普通 UI 档位:顶栏泥点组件、banner 正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作 Tab 首屏内容。 + +创作恢复参数只保留 `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 @@ -26,16 +32,20 @@ `api-server` 的 `generated_asset_sheets` 是当前通用系列素材图集模块:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。 +当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。 + ## 草稿与作品架 1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。 -2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。 -3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。 +2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。 +3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作继续收口到左滑或长按操作层。 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。 -6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。 +6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。 7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 +发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、次级入口带和法律信息组织,但字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 + ## RPG / 自定义世界 当前 RPG 创作入口使用 `playId = rpg`,工程域和运行态源类型沿用历史 `custom-world`。默认入口状态为 `visible=true`、`open=true`,对外展示为“文字冒险”;`airp` 仍是独立的“AI RPG”占位入口,保持 `open=false`,不要把它当作当前 RPG 创作链路开放。 @@ -62,7 +72,7 @@ RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口 RPG 运行态的战斗终局、继续冒险、继续探索和切场景都属于服务端 runtime 快照真相:`module-runtime-story` 必须在终局战斗 action 后调用 post-battle finalization,持久写入 `story_continue_adventure`、`deferredOptions`、`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清理后的战斗状态;`idle_travel_next_scene` / `camp_travel_home_scene` 必须由后端写入新的 `currentScenePreset`、`currentSceneActState`、`currentEncounter` 和 `runtimeStats.scenesTraveled`。前端只播放退场、进场和继续按钮表现,不能用默认 `观察/试探/调息` fallback 或本地动画假装推进剧情。旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,后端生成战后旅行选项时必须兼容这些字段。 -RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > 存档` 暴露为独立弹窗;“玩过”弹窗可以继续合并展示可继续存档,但不能成为唯一入口。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 +RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列表为真相,恢复动作继续走对应恢复接口,但移动端“我的”页已经不再提供独立的 `次级入口 > 存档` 和设置入口存档按钮;“玩过”弹窗可以继续合并展示可继续存档,个人中心只保留设置、扫码、常用功能和条件性次级入口。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 ## 拼图 @@ -77,17 +87,19 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > - 图像输入复用 `CreativeImageInputPanel`。 - 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab,不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。 - 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。 -- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页进度不再按固定 5 分钟展示,而按实际开始时间和当前路径的分步骤预计时长推进;任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。 +- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进,总进度按 `0-88`、`88-94`、`94-96`、`96-98` 的真实里程碑区间平滑推进。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。 - 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。 -- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次 `gpt-image-2` 调用的预期用时按 90 秒计算;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 90 秒、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 298 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。未收到 action 回包前,总进度仍最多停在 98%,但当预计写入时长耗尽且仍处于 `写入正式草稿` 时,该步骤自身应显示已完成,不能出现“进行中 100%”。 +- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次 `gpt-image-2` 调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用进入生成页的当前时间作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。 - 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。 - 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`;无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口。 - 每次新建关卡生成或重新生成关卡图都必须由 `api-server` 串起当前关卡资产包:AI 重绘开启时第一段沿用草稿生成第一关的拼图主图提示词配置和模型 / 尺寸 / 参考图规则生成 `coverImageSrc/coverAssetId` 作为关卡拼图画面和结果页预览图,提示词来源同样按显式画面描述、关卡画面描述、草稿摘要顺序回退,且固定要求输出画面比例为 `1:1`;上传图且关闭 AI 重绘时跳过这一段,把上传图或历史图持久化为 `sourceType=uploaded` 的正式候选。随后用正式候选图作为参考,`9:16` 生成完整拼图游戏关卡画面并写入 `levelSceneImageSrc/levelSceneImageObjectKey`,提示词必须要求道具按钮上不要显示次数标注,且返回按钮和设置按钮旁禁止标注文字;UI spritesheet 与关卡纯背景在关卡画面完成后并发生成,spritesheet 用 `1:1`、`1k` 先生成纯绿色绿幕背景图,后端上传 OSS 前必须把绿幕扣成透明 PNG,再写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,按钮顺序固定为返回、设置、下一关、提示、原图、冻结,按钮素材自身保留对应中文文字,返回和设置按钮不得额外生成白色外圈、白底圆环或浮雕外框;纯背景用 `9:16`、`1k` 写入 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,提示词必须包含“禁止在背景中出现人像或和拼图画面中主体一致的内容”。运行态不直接使用第二段完整关卡画面,但必须持久化它用于追踪和后续再生成。结果页局部关卡生成进度按 AI 重绘开启约 270 秒、关闭 AI 重绘约 180 秒展示。 - 结果页允许多关卡并行编辑和生成;某一关卡图片生成完成回包只静默更新该关卡素材与生成态,不得自动打开或切换关卡详情面板,避免打断用户正在编辑的其它关卡。 - 结果页关卡图片生成只标记对应关卡的局部生成进度,不禁用“新增关卡”、其它关卡详情编辑和结果页导航。 - 结果页单关测试只能把完整草稿持久化,并通过 `levelId` 指定运行态起始关卡;不得把单关快照作为整份草稿调用 `updatePuzzleWork`,否则 source session 和作品 profile 的 `levels` 会被覆盖成单关,退出重进后其它关卡会丢失。 +- 拼图试玩和正式运行态刷新恢复不复用创作私有 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 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 @@ -120,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 等账号/所有权动作仍保持普通用户鉴权。 ## 敲木鱼 @@ -240,10 +256,10 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > - 后端裁决结果:后端根据 start run 与 finish 派生指标校验后的正式单局结果。 - 基础统计:只记录正式 `published` run 的开始、结算和派生指标,草稿试玩不写正式统计。 - 公开广场:统一读取 `bark_battle_gallery_view` 这类 read model,不再由前端自己拼公开列表。 -- 创作者信息:草稿架、已发布作品架、统一作品详情和公开广场都必须展示后端返回的 `authorDisplayName`,不得只在详情页内层可见。 +- 创作者信息:统一作品详情和公开广场都必须展示后端返回的 `authorDisplayName`,不得只在详情页内层可见;草稿 Tab 作品架遵循平台作品架统一口径,无论草稿 / 已发布都不外露作者信息。 - 拟声词:配置字段为 `onomatopoeia`。创作者未手动编辑时,前端根据主题 / 竞技背景描述、玩家形象描述和对手形象描述生成高能词池;创作者手动编辑后按自定义词池发布。默认词池只在命中狗相关主题时加入狗叫词,不能把非狗主题强行带回狗语义。 -当前入口默认开放:`visible=true`、`open=true`、`badge=可创建`,入口参考图使用 `/creation-type-references/bark-battle.webp`。创作入口使用 7 字段表单(作品标题、简介、主题 / 竞技背景描述 `themeDescription`、玩家形象描述、对手形象描述、拟声词、难度);提交后先进入 `bark-battle-generating` 独立生成页,自动生成玩家形象、对手形象和竞技背景三图。生成页即使部分槽位失败也要继续落到结果页,失败槽位保留错误态和单槽重试入口,不在生成页停留。结果页只保留单槽重试、重新生成和上传,不再展示一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布成功后先跳统一作品详情页 `/works/detail?work=BB-xxxxxxxx`,正式 `published` runtime 从作品详情页进入并必须使用真实麦克风;`draft` 可试玩,可使用 mock 输入,且不写正式统计。草稿与已发布作品在外部卡片、作品架和广场列表都展示创作者名称。 +当前入口默认开放:`visible=true`、`open=true`、`badge=可创建`,入口参考图使用 `/creation-type-references/bark-battle.webp`。创作入口使用 7 字段表单(作品标题、简介、主题 / 竞技背景描述 `themeDescription`、玩家形象描述、对手形象描述、拟声词、难度);提交后先进入 `bark-battle-generating` 独立生成页,自动生成玩家形象、对手形象和竞技背景三图。生成页即使部分槽位失败也要继续落到结果页,失败槽位保留错误态和单槽重试入口,不在生成页停留。结果页只保留单槽重试、重新生成和上传,不再展示一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布成功后先跳统一作品详情页 `/works/detail?work=BB-xxxxxxxx`,正式 `published` runtime 从作品详情页进入并必须使用真实麦克风;`draft` 可试玩,可使用 mock 输入,且不写正式统计。统一作品详情和广场列表展示创作者名称;草稿 Tab 作品架不外露创作者名称,已发布作品只在右上角常驻分享入口。 移动端创作 Tab 内嵌 Bark Battle 表单时,只保留外层 Tab 面板承担纵向滚动;表单自身移动端不再创建独立纵向滚动容器,底部“生成草稿”按钮作为普通表单尾部并保留 safe-area 底部间距,避免与最后一组输入框、移动端键盘或底部 TabBar 形成套滚动 / 遮挡。 @@ -251,7 +267,7 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > - 创作 Tab 表单:填写作品标题、简介、主题 / 竞技背景描述、玩家形象描述、对手形象描述、拟声词和难度。拟声词支持换行、逗号、顿号、斜杠或竖线分隔;未手动编辑时随主题 / 形象描述自动重算,手动编辑后保持创作者自定义。 - 草稿编译:`POST /api/creation/bark-battle/drafts` 写入配置 JSON,返回包含 `draftId`、稳定 `workId`、`configVersion` 和 `rulesetVersion` 的草稿结果。 -- 生成页:`bark-battle-generating` 自动并行产出玩家形象、对手形象和竞技背景三图;前端按槽位实时显示生成中 / 已生成 / 失败状态,三图都走 Bark Battle 专用后端生图接口 `POST /api/creation/bark-battle/images/generate`,由后端按 `player-character`、`opponent-character`、`ui-background` 分别拼装正式提示词、写入 `generated-bark-battle-assets` 私有资产前缀并返回实际 prompt。玩家 / 对手形象提示词必须保持用户形象描述,不强行注入狗相关主体,并要求正面、单个完整形象和透明背景。部分失败也继续进入结果页。 +- 生成页:`bark-battle-generating` 自动并行产出玩家形象、对手形象和竞技背景三图;前端生成页 UI 和其它玩法保持同一圆环主视觉,`media/create_bg_video.mp4` 作为固定全屏页面背景层循环静音播放,主进度圆环居中展示总进度,只保留当前步骤名称和当前步骤进度,不再渲染三行槽位列表。视频层需要显式触发播放。三图都走 Bark Battle 专用后端生图接口 `POST /api/creation/bark-battle/images/generate`,由后端按 `player-character`、`opponent-character`、`ui-background` 分别拼装正式提示词、写入 `generated-bark-battle-assets` 私有资产前缀并返回实际 prompt。玩家 / 对手形象提示词必须保持用户形象描述,不强行注入狗相关主体,并要求正面、单个完整形象和透明背景。部分失败也继续进入结果页。 - 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置。 - 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets` 与 `/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。 - 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`;SpacetimeDB 发布态的 `config_json` 必须使用该最终快照,works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime;缺少 `workId` 的旧草稿状态需要重新生成草稿。 diff --git a/docs/【玩法创作】拼图生成页进度口径-2026-05-23.md b/docs/【玩法创作】拼图生成页进度口径-2026-05-23.md new file mode 100644 index 00000000..bc0ae2a0 --- /dev/null +++ b/docs/【玩法创作】拼图生成页进度口径-2026-05-23.md @@ -0,0 +1,29 @@ +# 拼图生成页进度口径 + +更新时间:`2026-05-24` + +## 目标 + +拼图草稿生成页的步骤推进只跟随真实生成结果;步骤内部允许使用本地假进度增强等待反馈。未收到当前步骤完成信号前,生成页必须停留在当前步骤,不得按预计耗时自动跳到后续步骤。 + +## 落地口径 + +- 总进度和当前步骤内百分比可以按已耗时平滑增长,但进入生成页的初始帧必须从 `0%` 开始,非完成态最多停在 `98%`。 +- 未收到首个真实里程碑前,页面仍停留在当前步骤,总进度在 `0-88` 区间内平滑推进;收到 `88/94/96` 里程碑后,分别在 `88-94`、`94-96`、`96-98` 区间内推进,避免步骤不跳时总进度也停死。 +- 后端 `progressPercent` 低于 `88` 只作为当前会话状态记录,不得把生成页阶段推到首个图片里程碑;低于首个里程碑时页面仍按当前视图进入时间从 `0%` 平滑展示。 +- 步骤状态以真实阶段为准:`phase` / 后端会话进度 / 最终完成或失败回包才允许跨步。 +- 拼图生成页恢复持久化 `generationStatus=generating` 草稿时,展示进度使用“进入生成页的当前时间”作为 `startedAtMs`;不得再用作品摘要 `updatedAt` 推导展示起点,避免刷新后首帧直接跳到 `80%+`。 +- 拼图和抓大鹅等生成页从作品架 / 刷新恢复进入时,前端应把展示态生成状态重基准到进入页面的当前时间;后台 session 的 `progressPercent` 与历史里程碑只保留为状态事实,不得直接作为首帧总进度。 +- 当前步骤未完成时,后续步骤保持待处理;即使预计时间耗尽,也只能让当前步骤内部进度接近或达到上限,不能自动完成后续步骤。 +- 抓大鹅等非拼图小游戏的生成页也遵守初始帧 `0%`:没有后端资产计数时,当前步骤内假进度按玩法预计等待总时长从 `0` 平滑推进,不使用固定 `0.5` 这类常量起步。 +- 步骤卡片只展示标题和进度,不展示详细描述。 +- 生成拼图首图步骤按 4 分钟预估;完整 AI 重绘路径总预计时长为 448 秒,上传图且关闭 AI 重绘时跳过首图生成,仍为 208 秒。 + +## 验收 + +- `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖拼图步骤不会单纯按时间推进。 +- `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖后端 `progressPercent < 88` 时不会抬高进入生成页的初始总进度。 +- `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖抓大鹅等非拼图生成页初始总进度为 `0%`。 +- `src/components/CustomWorldGenerationView.test.tsx` 覆盖步骤详情不在生成页渲染。 +- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"` 覆盖刷新后继续生成中拼图 / 抓大鹅草稿不会继承旧 `updatedAt` 导致总进度首帧过高。 +- 文档主图谱的拼图章节同步保留该口径。 diff --git a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md new file mode 100644 index 00000000..8b5730a9 --- /dev/null +++ b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md @@ -0,0 +1,28 @@ +# 生成页圆环布局口径 + +更新时间:`2026-05-24` + +## 目标 + +所有玩法的生成页统一收敛为参考图同款等待态:顶部只保留返回入口和生成状态胶囊,页面中段用大圆环展示总进度,圆环左右悬浮“预计等待 / 已耗时”,下方只保留当前步骤单卡和当前作品信息卡,不再渲染步骤列表块。 + +## 落地口径 + +- 共用生成页 `CustomWorldGenerationView` 的主进度条使用居中 SVG 大圆弧,默认保留正下方 90 度留空,`media/create_bg_video.mp4` 作为固定全屏背景层循环静音播放;圆弧覆盖在背景之上展示总进度。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。 +- 生成页背景视频必须留在生成页容器内部,直接作为 `fixed inset-0` 的底层背景,不要再通过 portal 挂到 `document.body`;页面根容器使用 `z-[1]`、背景容器使用 `z-0`,确保顶部导航、圆环和当前步骤卡都稳定覆盖在视频之上。 +- 预计等待 / 已耗时信息卡要压缩为更轻的半透明窄卡,标签使用 `9px-10px`,数值使用 `12px-13px`,字号对齐其他生成页 UI 的小字号,不再使用偏大的提示文本;卡片标题和时间值都居中显示,两个数值只展示时间本身,调用侧不要再拼接“预计还需”或“已耗时”前缀。圆环中心不再保留独立白底块,空心圆环只保留条状进度,圆弧半径继续加大,进度数字与“总进度”标题整体上移,靠近圆环上半区。 +- 顶部导航区采用“返回创作中心 / 状态胶囊”结构,返回按钮使用左箭头图标,字号使用 `text-xs-sm`,状态胶囊使用 `11px-12px`,展示 `素材生成中`、`草稿生成中` 等调用侧传入文案。 +- 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。当前圆环外径以 `w-[min(35rem,94vw)] sm:w-[52rem]` 为基准,圆弧使用 `r=166`、`strokeWidth=18` 的 SVG 描边,不再使用 `conic-gradient + mask`,避免进度条边缘模糊。 +- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间;后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`。 +- 生成页只展示半透明“当前步骤”单卡,卡片内只保留步骤名称、步骤状态、步骤进度条和轻量加载指示;“当前步骤”标签使用 `10px-11px`,步骤名称使用 `14px-15px`,状态使用 `11px-12px`,不再渲染步骤列表或步骤详情。 +- 当前作品信息放在圆角信息卡中,标题固定使用 `13px`;有结构化字段时以两列信息块展示,例如“题材 / 素材数量”,无结构化字段时才展示纯文本设定。 +- 汪汪声浪生成页 `BarkBattleGeneratingView` 也必须对齐同一垂直布局,不再继续展示三行槽位列表或左右分栏抢占主视觉。 +- 汪汪声浪的总进度按三槽位已完成数量换算;当前步骤只显示第一个未完成槽位的名称与进度。 + +## 验收 + +- `src/components/CustomWorldGenerationView.test.tsx` 覆盖圆环主视觉和单步卡片。 +- `src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx` 覆盖汪汪声浪生成页对齐后的圆环布局。 +- 两个生成页都应在测试里断言页面根容器层级高于背景视频容器,且背景视频确实是页面子节点,避免 portal 背景把业务 UI 压住。 +- 还应断言圆弧正下方留空、圆环中心没有独立底色块,时间卡和总进度字号缩小后仍能在桌面与移动端正常排版;同时断言时间卡 `text-center`、标题行 `justify-center`、总进度内容区上移到 `pt-[4%]`,圆弧 DOM 为 SVG,包含清晰的 track/fill circle 描边。 +- 页面在桌面和移动端都不应再出现生成步骤列表块,圆环和当前步骤卡不能被外层卡片嵌套出双层面板感。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index e19cc841..0007216b 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -93,9 +93,11 @@ server-rs + Axum + SpacetimeDB 7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 -10. “我的”页泥点、游戏时长、玩过三张统计卡只展示各自标签和值,内容居中且不换行,不在统计区底部展示“更新于”时间。 -11. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 -11. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 +10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、可选次级入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,填邀请码仅在新用户可填写窗口内展示为次级入口。 +11. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 +12. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能、可选次级入口和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 +13. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 +14. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 ## 文案与编码 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/media/create_bg_video.mp4 b/media/create_bg_video.mp4 new file mode 100644 index 00000000..d0c2095c Binary files /dev/null and b/media/create_bg_video.mp4 differ diff --git a/media/profile/_Image (1).png b/media/profile/_Image (1).png new file mode 100644 index 00000000..f86816b9 Binary files /dev/null and b/media/profile/_Image (1).png differ diff --git a/media/profile/_Image (2).png b/media/profile/_Image (2).png new file mode 100644 index 00000000..93dd45a3 Binary files /dev/null and b/media/profile/_Image (2).png differ diff --git a/media/profile/_Image (3).png b/media/profile/_Image (3).png new file mode 100644 index 00000000..33d971f2 Binary files /dev/null and b/media/profile/_Image (3).png differ diff --git a/media/profile/_Image (4).png b/media/profile/_Image (4).png new file mode 100644 index 00000000..7e62a727 Binary files /dev/null and b/media/profile/_Image (4).png differ diff --git a/media/profile/_Image (5).png b/media/profile/_Image (5).png new file mode 100644 index 00000000..deb8e0b5 Binary files /dev/null and b/media/profile/_Image (5).png differ diff --git a/media/profile/_Image (6).png b/media/profile/_Image (6).png new file mode 100644 index 00000000..d4d81c1d Binary files /dev/null and b/media/profile/_Image (6).png differ diff --git a/media/profile/_Image (7).png b/media/profile/_Image (7).png new file mode 100644 index 00000000..e806455b Binary files /dev/null and b/media/profile/_Image (7).png differ diff --git a/media/profile/_Image (8).png b/media/profile/_Image (8).png new file mode 100644 index 00000000..8348e99b Binary files /dev/null and b/media/profile/_Image (8).png differ diff --git a/media/profile/_Image (9).png b/media/profile/_Image (9).png new file mode 100644 index 00000000..4b22f1cf Binary files /dev/null and b/media/profile/_Image (9).png differ diff --git a/media/profile/_Image.png b/media/profile/_Image.png new file mode 100644 index 00000000..0b9d446c Binary files /dev/null and b/media/profile/_Image.png differ diff --git a/miniprogram/config.js b/miniprogram/config.js index 20ddf987..2c24a054 100644 --- a/miniprogram/config.js +++ b/miniprogram/config.js @@ -1,17 +1,17 @@ // 中文注释:这里填写已经在“小程序后台-开发-开发设置-业务域名”配置过的 H5 入口。 // 示例:https://game.example.com/ // 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。 -const WEB_VIEW_ENTRY_URL = 'https://dev.genarrative.world/'; +const WEB_VIEW_ENTRY_URL = 'https://www.genarrative.world/'; // 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。 // 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。 -const API_BASE_URL = 'https://dev.genarrative.world/'; +const API_BASE_URL = 'https://www.genarrative.world/'; // 中文注释:这里填写微信小程序 AppID,用于后端记录会话来源;project.config.json 里的 appid 也要保持一致。 const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65'; // 中文注释:按当前上传版本填写 develop / trial / release,后端会写入会话来源快照。 -const MINI_PROGRAM_ENV = 'develop'; +const MINI_PROGRAM_ENV = 'release'; // 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。 const WEB_VIEW_SOURCE_QUERY = { diff --git a/output/generation-page-mobile-check.png b/output/generation-page-mobile-check.png new file mode 100644 index 00000000..698572e9 Binary files /dev/null and b/output/generation-page-mobile-check.png differ diff --git a/output/rpg-profile-test-report.json b/output/rpg-profile-test-report.json new file mode 100644 index 00000000..485b4cec --- /dev/null +++ b/output/rpg-profile-test-report.json @@ -0,0 +1,652 @@ +{ + "numTotalTestSuites": 1, + "numPassedTestSuites": 1, + "numFailedTestSuites": 0, + "numPendingTestSuites": 0, + "numTotalTests": 58, + "numPassedTests": 50, + "numFailedTests": 8, + "numPendingTests": 0, + "numTodoTests": 0, + "startTime": 1779633396424, + "success": false, + "testResults": [ + { + "assertionResults": [ + { + "ancestorTitles": [ + "" + ], + "fullName": " opens wallet ledger modal from narrative coin card", + "status": "passed", + "title": "opens wallet ledger modal from narrative coin card", + "duration": 157, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal shows native qr code on desktop web by default", + "status": "passed", + "title": "profile recharge modal shows native qr code on desktop web by default", + "duration": 183, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal jumps to h5 payment on mobile web by default", + "status": "passed", + "title": "profile recharge modal jumps to h5 payment on mobile web by default", + "duration": 203, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal trusts per-product first bonus display after points recharge", + "status": "passed", + "title": "profile recharge modal trusts per-product first bonus display after points recharge", + "duration": 120, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal posts requestPayment params in mini program web-view", + "status": "passed", + "title": "profile recharge modal posts requestPayment params in mini program web-view", + "duration": 237, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal waits for paid confirmation before refreshing dashboard", + "status": "passed", + "title": "profile recharge modal waits for paid confirmation before refreshing dashboard", + "duration": 1059, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal loads wechat js sdk before mini program payment bridge", + "status": "passed", + "title": "profile recharge modal loads wechat js sdk before mini program payment bridge", + "duration": 374, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal releases submitting state after cancelled wechat pay result", + "status": "passed", + "title": "profile recharge modal releases submitting state after cancelled wechat pay result", + "duration": 388, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile native qr confirmation refreshes only after server reports paid", + "status": "passed", + "title": "profile native qr confirmation refreshes only after server reports paid", + "duration": 421, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " non-wechat profile shows reward code instead of recharge entry", + "status": "failed", + "title": "non-wechat profile shows reward code instead of recharge entry", + "duration": 63, + "failureMessages": [ + "expected to be null" + ], + "location": { + "line": 1805, + "column": 5 + } + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile daily task shortcut opens task center and claims reward", + "status": "passed", + "title": "profile daily task shortcut opens task center and claims reward", + "duration": 392, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile total play time card always uses hours", + "status": "passed", + "title": "profile total play time card always uses hours", + "duration": 83, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile played works card shows count unit", + "status": "passed", + "title": "profile played works card shows count unit", + "duration": 73, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile stats cards are centered without update timestamp", + "status": "passed", + "title": "profile stats cards are centered without update timestamp", + "duration": 113, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " mobile profile page matches the reference layout sections", + "status": "failed", + "title": "mobile profile page matches the reference layout sections", + "duration": 1045, + "failureMessages": [ + "expected \"spy\" to be called 1 times, but got 0 times\n\nIgnored nodes: comments, script, style\n\u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m搜索\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m测\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m测试玩家\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m100001\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m邀请好友\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m双方得 30 泥点\u001b[0m\n \u001b[36m\u001b[39m\n\u001b[36m\u001b[39m" + ], + "location": { + "line": 37, + "column": 19 + } + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile redeem invite shortcut sits between invite and community for fresh accounts", + "status": "failed", + "title": "profile redeem invite shortcut sits between invite and community for fresh accounts", + "duration": 209, + "failureMessages": [ + "expected +0 to be truthy" + ], + "location": { + "line": 2058, + "column": 5 + } + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile redeem invite shortcut hides after redeemed or one day old", + "status": "passed", + "title": "profile redeem invite shortcut hides after redeemed or one day old", + "duration": 226, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " invite query opens login modal for logged out users", + "status": "passed", + "title": "invite query opens login modal for logged out users", + "duration": 20, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " invite query opens redeem modal directly for logged in users", + "status": "passed", + "title": "invite query opens redeem modal directly for logged in users", + "duration": 154, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile redeem invite modal reads query invite code after login", + "status": "passed", + "title": "profile redeem invite modal reads query invite code after login", + "duration": 71, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile redeem invite modal submits code and hides shortcut after success", + "status": "failed", + "title": "profile redeem invite modal submits code and hides shortcut after success", + "duration": 1054, + "failureMessages": [ + "Unable to find role=\"button\" and name `/填邀请码/u`\n\nIgnored nodes: comments, script, style\n\u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m搜索\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m测\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m测试玩家\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m100001\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m { }); }); +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/scripts/generate-spacetime-bindings.mjs b/scripts/generate-spacetime-bindings.mjs new file mode 100644 index 00000000..a49f1e60 --- /dev/null +++ b/scripts/generate-spacetime-bindings.mjs @@ -0,0 +1,334 @@ +import {spawn} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {cp, mkdir, readdir, rm, stat} from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); +const MODULE_PATH = path.join(REPO_ROOT, 'server-rs', 'crates', 'spacetime-module'); + +const TARGETS = [ + { + name: 'Rust', + lang: 'rust', + tempName: 'rs', + outDir: path.join( + REPO_ROOT, + 'server-rs', + 'crates', + 'spacetime-client', + 'src', + 'module_bindings', + ), + entryFile: path.join( + REPO_ROOT, + 'server-rs', + 'crates', + 'spacetime-client', + 'src', + 'module_bindings.rs', + ), + }, +]; + +const args = new Set(process.argv.slice(2)); +const KNOWN_ARGS = new Set(['--rust-only']); + +for (const arg of args) { + if (!KNOWN_ARGS.has(arg)) { + console.error(`[spacetime:generate] 未知参数: ${arg}`); + process.exit(1); + } +} + +if (!existsSync(path.join(MODULE_PATH, 'Cargo.toml'))) { + console.error(`[spacetime:generate] 未找到模块: ${MODULE_PATH}`); + process.exit(1); +} + +const tempRoot = resolveTempRoot(); +assertSafeTempRoot(tempRoot); +const selectedTargets = TARGETS.filter((target) => shouldRunTarget(target.lang)); + +if (selectedTargets.length === 0) { + console.error('[spacetime:generate] 没有需要生成的目标。'); + process.exit(1); +} + +await mkdir(tempRoot, {recursive: true}); + +for (const target of selectedTargets) { + const tempOutDir = path.join(tempRoot, target.tempName); + await recreateTempDir(tempOutDir); + + console.log(`[spacetime:generate] 生成 ${target.name} bindings 到短路径: ${tempOutDir}`); + await generateBindings(target, tempOutDir); + + const fileCount = await countFiles(tempOutDir); + if (fileCount === 0) { + throw new Error(`${target.name} bindings 未生成任何文件。`); + } + + console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`); + await replaceGeneratedDir(tempOutDir, target.outDir); + await moveGeneratedEntryFile(target); +} + +await rm(tempRoot, {recursive: true, force: true}); +console.log('[spacetime:generate] bindings 生成完成。'); + +function shouldRunTarget(lang) { + if (args.has('--rust-only')) { + return lang === 'rust'; + } + + return true; +} + +function resolveTempRoot() { + if (process.env.GENARRATIVE_BINDGEN_TEMP_ROOT) { + return path.resolve(process.env.GENARRATIVE_BINDGEN_TEMP_ROOT); + } + + // Windows 下 SpacetimeDB CLI 2.1.0 会把所有生成文件路径一次性传给 formatter; + // Rust bindings 文件数较多,输出到仓库深目录时容易触发 CreateProcess 路径总长限制。 + if (process.platform === 'win32') { + return path.join(path.parse(REPO_ROOT).root, '.genarrative-bindgen'); + } + + return path.join(REPO_ROOT, 'tmp', 'spacetime-bindgen'); +} + +async function recreateTempDir(dir) { + assertInside(dir, tempRoot, '临时生成目录'); + await rm(dir, {recursive: true, force: true}); + await mkdir(dir, {recursive: true}); +} + +async function replaceGeneratedDir(fromDir, toDir) { + assertInside(toDir, REPO_ROOT, '仓库生成目录'); + await rm(toDir, {recursive: true, force: true}); + await mkdir(toDir, {recursive: true}); + const entries = await readdir(fromDir, {withFileTypes: true}); + + for (const entry of entries) { + await cp(path.join(fromDir, entry.name), path.join(toDir, entry.name), { + recursive: true, + force: true, + }); + } +} + +async function moveGeneratedEntryFile(target) { + if (!target.entryFile) { + return; + } + + assertInside(target.entryFile, REPO_ROOT, '生成入口文件'); + + const generatedEntryFile = + findExistingFile(path.join(target.outDir, 'module_bindings.rs')) ?? + findExistingFile(path.join(target.outDir, 'mod.rs')); + + if (!generatedEntryFile) { + throw new Error( + `${target.name} bindings 缺少入口文件: ${path.join(target.outDir, 'module_bindings.rs')} / ${path.join(target.outDir, 'mod.rs')}`, + ); + } + + await rm(target.entryFile, {force: true}); + await cp(generatedEntryFile, target.entryFile, {force: true}); + if (path.resolve(generatedEntryFile) !== path.resolve(target.entryFile)) { + await rm(generatedEntryFile, {force: true}); + } +} + +function findExistingFile(candidate) { + return existsSync(candidate) ? candidate : undefined; +} + +function assertInside(candidate, parent, label) { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`${label} 不在预期目录内: ${candidate}`); + } +} + +function assertSafeTempRoot(dir) { + const resolved = path.resolve(dir); + const parsed = path.parse(resolved); + const basename = path.basename(resolved).toLowerCase(); + + if (resolved === path.resolve(REPO_ROOT) || resolved === parsed.root) { + throw new Error(`临时根目录不允许指向仓库或磁盘根目录: ${resolved}`); + } + + if (!basename.includes('bindgen')) { + throw new Error(`临时根目录必须是明确的 bindings 生成目录: ${resolved}`); + } +} + +function buildGenerateArgs(target, outDir) { + const generateArgs = [ + 'generate', + '--no-config', + '--lang', + target.lang, + '--out-dir', + outDir, + '--module-path', + MODULE_PATH, + '--include-private', + '--yes', + ]; + + return generateArgs; +} + +async function generateBindings(target, outDir) { + const result = await run('spacetime', buildGenerateArgs(target, outDir), { + allowGeneratedFormatFailure: target.lang === 'rust', + }); + + if (result.generatedFormatFailed) { + // Windows 下 SpacetimeDB CLI 2.1.0 会把所有 Rust 文件一次性传给 formatter; + // 这里只接管“文件已生成但 CLI 格式化失败”的尾段,并仍然只同步生成目录。 + console.warn( + `[spacetime:generate] ${target.name} bindings 已生成,但 SpacetimeDB CLI 自带格式化失败;改用短路径分批 rustfmt。`, + ); + await formatRustBindings(outDir); + } +} + +async function formatRustBindings(outDir) { + const rustFiles = await collectRustFiles(outDir); + if (rustFiles.length === 0) { + throw new Error(`Rust bindings 未生成任何 .rs 文件,无法格式化: ${outDir}`); + } + + for (const chunk of chunkCommandArgs(rustFiles)) { + await run('rustfmt', ['--edition', '2024', ...chunk]); + } +} + +async function collectRustFiles(dir) { + const files = []; + const entries = await readdir(dir, {withFileTypes: true}); + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...(await collectRustFiles(entryPath))); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.rs')) { + files.push(entryPath); + } + } + + return files; +} + +function chunkCommandArgs(argsToChunk) { + // Windows CreateProcess 受命令行长度限制;分批能避免 bindings 文件变多后再次失败。 + const maxCommandLineChars = process.platform === 'win32' ? 20_000 : 100_000; + const chunks = []; + let current = []; + let currentLength = 0; + + for (const arg of argsToChunk) { + const argLength = arg.length + 3; + if (current.length > 0 && currentLength + argLength > maxCommandLineChars) { + chunks.push(current); + current = []; + currentLength = 0; + } + + current.push(arg); + currentLength += argLength; + } + + if (current.length > 0) { + chunks.push(current); + } + + return chunks; +} + +function run(command, commandArgs, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { + cwd: REPO_ROOT, + env: process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let output = ''; + + child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + output += text; + process.stdout.write(text); + }); + + child.stderr.on('data', (chunk) => { + const text = chunk.toString(); + output += text; + process.stderr.write(text); + }); + + child.on('error', reject); + child.on('exit', (code, signal) => { + if (signal) { + reject(new Error(`${command} 被信号中断: ${signal}`)); + return; + } + + const generatedFormatFailed = output.includes('Could not format generated files'); + + if (generatedFormatFailed && options.allowGeneratedFormatFailure) { + console.warn( + `[spacetime:generate] ${command} generated files but formatting failed; continuing with validation.`, + ); + resolve({generatedFormatFailed}); + return; + } + + if (generatedFormatFailed) { + reject(new Error(`${command} generated files but formatting failed.`)); + return; + } + + if (code === 0) { + resolve({generatedFormatFailed: false}); + return; + } + + reject(new Error(`${command} 退出码: ${code ?? 'unknown'}`)); + }); + }); +} + +async function countFiles(dir) { + let count = 0; + const entries = await readdir(dir, {withFileTypes: true}); + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + count += await countFiles(entryPath); + continue; + } + + if (entry.isFile() || (await stat(entryPath)).isFile()) { + count += 1; + } + } + + return count; +} 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/admin.rs b/server-rs/crates/api-server/src/admin.rs index cd72a6d0..80ab9045 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -251,6 +251,9 @@ fn map_admin_creation_entry_type_config( visible: entry.visible, open: entry.open, sort_order: entry.sort_order, + category_id: entry.category_id, + category_label: entry.category_label, + category_sort_order: entry.category_sort_order, updated_at_micros: entry.updated_at_micros, } } @@ -275,6 +278,9 @@ fn validate_admin_creation_entry_config( visible: payload.visible, open: payload.open, sort_order: payload.sort_order, + category_id: payload.category_id.trim().to_string(), + category_label: payload.category_label.trim().to_string(), + category_sort_order: payload.category_sort_order, }) } 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/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index fda55d57..166225d1 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -143,6 +143,15 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(), description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(), }, + event_banner: module_runtime::CreationEntryEventBannerSnapshot { + title: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string(), + description: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string(), + cover_image_src: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC + .to_string(), + prize_pool_mud_points: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS, + starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(), + ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(), + }, creation_types: module_runtime::default_creation_entry_type_snapshots(0), updated_at_micros: 0, }) @@ -259,5 +268,8 @@ mod tests { assert!(baby_object_match.open); assert_eq!(baby_object_match.badge, "\u{53ef}\u{521b}\u{5efa}"); assert_eq!(baby_object_match.sort_order, 90); + assert_eq!(baby_object_match.category_id, "character"); + assert_eq!(baby_object_match.category_label, "\u{89d2}\u{8272}\u{521b}\u{4f5c}"); + assert_eq!(baby_object_match.category_sort_order, 40); } } 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/state.rs b/server-rs/crates/api-server/src/state.rs index 6c6d1c60..bfda79d0 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -516,6 +516,10 @@ impl AppState { visible: enabled, open: enabled, sort_order: i32::try_from(config.creation_types.len()).unwrap_or(i32::MAX), + category_id: module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string(), + category_label: module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL + .to_string(), + category_sort_order: 0, updated_at_micros: 0, }, ); 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/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index dec7f729..2d997da6 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -10,8 +10,8 @@ use crate::domain::*; use crate::errors::RuntimeProfileFieldError; use crate::format_utc_micros; use shared_contracts::creation_entry_config::{ - CreationEntryConfigResponse, CreationEntryStartCardResponse, CreationEntryTypeModalResponse, - CreationEntryTypeResponse, + CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse, + CreationEntryTypeModalResponse, CreationEntryTypeResponse, }; pub fn build_creation_entry_config_response( @@ -28,6 +28,14 @@ pub fn build_creation_entry_config_response( title: snapshot.type_modal.title, description: snapshot.type_modal.description, }, + event_banner: CreationEntryEventBannerResponse { + title: snapshot.event_banner.title, + description: snapshot.event_banner.description, + cover_image_src: snapshot.event_banner.cover_image_src, + prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points, + starts_at_text: snapshot.event_banner.starts_at_text, + ends_at_text: snapshot.event_banner.ends_at_text, + }, creation_types: snapshot .creation_types .into_iter() @@ -40,6 +48,9 @@ pub fn build_creation_entry_config_response( visible: item.visible, open: item.open, sort_order: item.sort_order, + category_id: item.category_id, + category_label: item.category_label, + category_sort_order: item.category_sort_order, updated_at_micros: item.updated_at_micros, }) .collect(), @@ -59,6 +70,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 10, + "recent", + "最近创作", + 10, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -70,6 +84,9 @@ pub fn default_creation_entry_type_snapshots( false, true, 20, + "recommended", + "热门推荐", + 20, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -81,6 +98,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 30, + "recent", + "最近创作", + 10, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -92,6 +112,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 40, + "recent", + "最近创作", + 10, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -103,6 +126,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 45, + "recommended", + "热门推荐", + 20, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -114,6 +140,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 47, + "festival", + "节日主题", + 30, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -125,6 +154,9 @@ pub fn default_creation_entry_type_snapshots( false, true, 50, + "material", + "材质工艺", + 60, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -136,6 +168,9 @@ pub fn default_creation_entry_type_snapshots( true, false, 60, + "scene", + "生活场景", + 50, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -147,6 +182,9 @@ pub fn default_creation_entry_type_snapshots( true, false, 70, + "character", + "角色创作", + 40, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -158,6 +196,9 @@ pub fn default_creation_entry_type_snapshots( false, true, 80, + "recommended", + "热门推荐", + 20, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -169,6 +210,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 85, + "recommended", + "热门推荐", + 20, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -180,6 +224,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 90, + "character", + "角色创作", + 40, updated_at_micros, ), ] @@ -195,6 +242,9 @@ fn build_default_creation_entry_type_snapshot( visible: bool, open: bool, sort_order: i32, + category_id: &str, + category_label: &str, + category_sort_order: i32, updated_at_micros: i64, ) -> CreationEntryTypeSnapshot { CreationEntryTypeSnapshot { @@ -206,6 +256,9 @@ fn build_default_creation_entry_type_snapshot( visible, open, sort_order, + category_id: category_id.to_string(), + category_label: category_label.to_string(), + category_sort_order, updated_at_micros, } } diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 4d1da0bc..bc7dff76 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -50,6 +50,15 @@ pub const DEFAULT_CREATION_ENTRY_START_IDLE_BADGE: &str = "模板 Tab"; pub const DEFAULT_CREATION_ENTRY_START_BUSY_BADGE: &str = "正在开启"; pub const DEFAULT_CREATION_ENTRY_MODAL_TITLE: &str = "选择创作类型"; pub const DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION: &str = "先选玩法类型,再进入对应创作工作台。"; +pub const DEFAULT_CREATION_ENTRY_CATEGORY_ID: &str = "recent"; +pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "最近创作"; +pub const DEFAULT_CREATION_ENTRY_EVENT_TITLE: &str = "主题创作赛"; +pub const DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION: &str = "用温暖的色彩,捏出秋天的故事。"; +pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str = + "/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png"; +pub const DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS: u64 = 58_000; +pub const DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT: &str = "2024.10.20 10:00"; +pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59"; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -67,6 +76,17 @@ pub struct CreationEntryTypeModalSnapshot { pub description: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreationEntryEventBannerSnapshot { + pub title: String, + pub description: String, + pub cover_image_src: String, + pub prize_pool_mud_points: u64, + pub starts_at_text: String, + pub ends_at_text: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CreationEntryTypeSnapshot { @@ -78,6 +98,9 @@ pub struct CreationEntryTypeSnapshot { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, pub updated_at_micros: i64, } @@ -87,6 +110,7 @@ pub struct CreationEntryConfigSnapshot { pub config_id: String, pub start_card: CreationEntryStartCardSnapshot, pub type_modal: CreationEntryTypeModalSnapshot, + pub event_banner: CreationEntryEventBannerSnapshot, pub creation_types: Vec, pub updated_at_micros: i64, } @@ -102,6 +126,9 @@ pub struct CreationEntryTypeAdminUpsertInput { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 5bbcd1b8..596ef888 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -230,6 +230,9 @@ mod tests { assert!(baby_object_match.open); assert_eq!(baby_object_match.badge, "可创建"); assert_eq!(baby_object_match.sort_order, 90); + assert_eq!(baby_object_match.category_id, "character"); + assert_eq!(baby_object_match.category_label, "角色创作"); + assert_eq!(baby_object_match.category_sort_order, 40); assert_eq!( baby_object_match.image_src, "/child-motion-demo/picture-book-grass-stage.png" @@ -250,6 +253,8 @@ mod tests { assert!(rpg.open); assert_eq!(rpg.badge, "可创建"); assert_eq!(rpg.sort_order, 10); + assert_eq!(rpg.category_id, "recent"); + assert_eq!(rpg.category_label, "最近创作"); assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp"); } 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/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 8d0cb19e..b776f28c 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -30,6 +30,9 @@ pub struct AdminCreationEntryTypeConfigPayload { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, pub updated_at_micros: i64, } @@ -45,6 +48,9 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] 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/creation_entry_config.rs b/server-rs/crates/shared-contracts/src/creation_entry_config.rs index 1cebef29..c6664cbb 100644 --- a/server-rs/crates/shared-contracts/src/creation_entry_config.rs +++ b/server-rs/crates/shared-contracts/src/creation_entry_config.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; pub struct CreationEntryConfigResponse { pub start_card: CreationEntryStartCardResponse, pub type_modal: CreationEntryTypeModalResponse, + pub event_banner: CreationEntryEventBannerResponse, pub creation_types: Vec, } @@ -24,6 +25,17 @@ pub struct CreationEntryTypeModalResponse { pub description: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreationEntryEventBannerResponse { + pub title: String, + pub description: String, + pub cover_image_src: String, + pub prize_pool_mud_points: u64, + pub starts_at_text: String, + pub ends_at_text: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CreationEntryTypeResponse { @@ -35,5 +47,8 @@ pub struct CreationEntryTypeResponse { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, pub updated_at_micros: i64, } 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-client/src/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/mapper/runtime.rs index 1a96e2c2..af558240 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -11,6 +11,9 @@ impl From for CreationEntryTy visible: input.visible, open: input.open, sort_order: input.sort_order, + category_id: input.category_id, + category_label: input.category_label, + category_sort_order: input.category_sort_order, } } } @@ -151,6 +154,29 @@ pub(crate) fn build_creation_entry_config_record_from_rows( title: header.modal_title, description: header.modal_description, }, + event_banner: module_runtime::CreationEntryEventBannerSnapshot { + title: creation_entry_text_or_default( + header.event_title, + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE, + ), + description: creation_entry_text_or_default( + header.event_description, + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION, + ), + cover_image_src: creation_entry_text_or_default( + header.event_cover_image_src, + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC, + ), + prize_pool_mud_points: header.event_prize_pool_mud_points, + starts_at_text: creation_entry_text_or_default( + header.event_starts_at_text, + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT, + ), + ends_at_text: creation_entry_text_or_default( + header.event_ends_at_text, + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT, + ), + }, creation_types: creation_types .into_iter() .map(|item| module_runtime::CreationEntryTypeSnapshot { @@ -162,6 +188,15 @@ pub(crate) fn build_creation_entry_config_record_from_rows( visible: item.visible, open: item.open, sort_order: item.sort_order, + category_id: creation_entry_text_or_default( + item.category_id, + module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID, + ), + category_label: creation_entry_text_or_default( + item.category_label, + module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL, + ), + category_sort_order: item.category_sort_order, updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), }) .collect(), @@ -185,6 +220,14 @@ fn map_creation_entry_config_snapshot( title: snapshot.type_modal.title, description: snapshot.type_modal.description, }, + event_banner: module_runtime::CreationEntryEventBannerSnapshot { + title: snapshot.event_banner.title, + description: snapshot.event_banner.description, + cover_image_src: snapshot.event_banner.cover_image_src, + prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points, + starts_at_text: snapshot.event_banner.starts_at_text, + ends_at_text: snapshot.event_banner.ends_at_text, + }, creation_types: snapshot .creation_types .into_iter() @@ -197,6 +240,9 @@ fn map_creation_entry_config_snapshot( visible: item.visible, open: item.open, sort_order: item.sort_order, + category_id: item.category_id, + category_label: item.category_label, + category_sort_order: item.category_sort_order, updated_at_micros: item.updated_at_micros, }) .collect(), @@ -204,6 +250,13 @@ fn map_creation_entry_config_snapshot( } } +fn creation_entry_text_or_default(value: Option, default_value: &str) -> String { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default_value.to_string()) +} + pub(crate) fn map_runtime_setting_procedure_result( result: RuntimeSettingProcedureResult, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 5f9f3392..3aa8dc89 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -234,6 +234,7 @@ pub mod creation_entry_config_procedure_result_type; pub mod creation_entry_config_snapshot_type; pub mod creation_entry_config_table; pub mod creation_entry_config_type; +pub mod creation_entry_event_banner_snapshot_type; pub mod creation_entry_start_card_snapshot_type; pub mod creation_entry_type_admin_upsert_input_type; pub mod creation_entry_type_config_table; @@ -1262,6 +1263,7 @@ pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedur pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot; pub use creation_entry_config_table::*; pub use creation_entry_config_type::CreationEntryConfig; +pub use creation_entry_event_banner_snapshot_type::CreationEntryEventBannerSnapshot; pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot; pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput; pub use creation_entry_type_config_table::*; @@ -3285,10 +3287,6 @@ impl __sdk::DbUpdate for DbUpdate { &self.visual_novel_work_profile, ) .with_updates_by_pk(|row| &row.profile_id); - diff.bark_battle_gallery_view = cache.apply_diff_to_table::( - "bark_battle_gallery_view", - &self.bark_battle_gallery_view, - ); diff.wooden_fish_agent_session = cache .apply_diff_to_table::( "wooden_fish_agent_session", @@ -3310,6 +3308,10 @@ impl __sdk::DbUpdate for DbUpdate { &self.wooden_fish_work_profile, ) .with_updates_by_pk(|row| &row.profile_id); + diff.bark_battle_gallery_view = cache.apply_diff_to_table::( + "bark_battle_gallery_view", + &self.bark_battle_gallery_view, + ); diff.big_fish_gallery_view = cache.apply_diff_to_table::( "big_fish_gallery_view", &self.big_fish_gallery_view, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs index 424caf89..0679a45a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::creation_entry_event_banner_snapshot_type::CreationEntryEventBannerSnapshot; use super::creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot; use super::creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot; use super::creation_entry_type_snapshot_type::CreationEntryTypeSnapshot; @@ -14,6 +15,7 @@ pub struct CreationEntryConfigSnapshot { pub config_id: String, pub start_card: CreationEntryStartCardSnapshot, pub type_modal: CreationEntryTypeModalSnapshot, + pub event_banner: CreationEntryEventBannerSnapshot, pub creation_types: Vec, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs index e06f35f4..c3234d1d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs @@ -15,6 +15,12 @@ pub struct CreationEntryConfig { pub modal_title: String, pub modal_description: String, pub updated_at: __sdk::Timestamp, + pub event_title: Option, + pub event_description: Option, + pub event_cover_image_src: Option, + pub event_prize_pool_mud_points: u64, + pub event_starts_at_text: Option, + pub event_ends_at_text: Option, } impl __sdk::InModule for CreationEntryConfig { @@ -33,6 +39,12 @@ pub struct CreationEntryConfigCols { pub modal_title: __sdk::__query_builder::Col, pub modal_description: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub event_title: __sdk::__query_builder::Col>, + pub event_description: __sdk::__query_builder::Col>, + pub event_cover_image_src: __sdk::__query_builder::Col>, + pub event_prize_pool_mud_points: __sdk::__query_builder::Col, + pub event_starts_at_text: __sdk::__query_builder::Col>, + pub event_ends_at_text: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for CreationEntryConfig { @@ -47,6 +59,21 @@ impl __sdk::__query_builder::HasCols for CreationEntryConfig { modal_title: __sdk::__query_builder::Col::new(table_name, "modal_title"), modal_description: __sdk::__query_builder::Col::new(table_name, "modal_description"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + event_title: __sdk::__query_builder::Col::new(table_name, "event_title"), + event_description: __sdk::__query_builder::Col::new(table_name, "event_description"), + event_cover_image_src: __sdk::__query_builder::Col::new( + table_name, + "event_cover_image_src", + ), + event_prize_pool_mud_points: __sdk::__query_builder::Col::new( + table_name, + "event_prize_pool_mud_points", + ), + event_starts_at_text: __sdk::__query_builder::Col::new( + table_name, + "event_starts_at_text", + ), + event_ends_at_text: __sdk::__query_builder::Col::new(table_name, "event_ends_at_text"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_event_banner_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_event_banner_snapshot_type.rs new file mode 100644 index 00000000..9d60ef30 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_event_banner_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreationEntryEventBannerSnapshot { + pub title: String, + pub description: String, + pub cover_image_src: String, + pub prize_pool_mud_points: u64, + pub starts_at_text: String, + pub ends_at_text: String, +} + +impl __sdk::InModule for CreationEntryEventBannerSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs index b2e7eccc..ae561402 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs @@ -15,6 +15,9 @@ pub struct CreationEntryTypeAdminUpsertInput { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, } impl __sdk::InModule for CreationEntryTypeAdminUpsertInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs index c6290d03..6fa8c5ed 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs @@ -16,6 +16,9 @@ pub struct CreationEntryTypeConfig { pub open: bool, pub sort_order: i32, pub updated_at: __sdk::Timestamp, + pub category_id: Option, + pub category_label: Option, + pub category_sort_order: i32, } impl __sdk::InModule for CreationEntryTypeConfig { @@ -35,6 +38,9 @@ pub struct CreationEntryTypeConfigCols { pub open: __sdk::__query_builder::Col, pub sort_order: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub category_id: __sdk::__query_builder::Col>, + pub category_label: __sdk::__query_builder::Col>, + pub category_sort_order: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig { @@ -50,6 +56,12 @@ impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig { open: __sdk::__query_builder::Col::new(table_name, "open"), sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + category_id: __sdk::__query_builder::Col::new(table_name, "category_id"), + category_label: __sdk::__query_builder::Col::new(table_name, "category_label"), + category_sort_order: __sdk::__query_builder::Col::new( + table_name, + "category_sort_order", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_snapshot_type.rs index 814edd78..51d87596 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_snapshot_type.rs @@ -15,6 +15,9 @@ pub struct CreationEntryTypeSnapshot { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, pub updated_at_micros: i64, } 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/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 18e22641..443133f5 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1159,6 +1159,43 @@ where fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value { let mut next_value = value.clone(); + if table_name == "creation_entry_config" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:入口活动横幅字段晚于创作入口配置表加入,旧迁移包按运行态默认横幅兼容。 + object + .entry("event_title".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("event_description".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("event_cover_image_src".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("event_prize_pool_mud_points".to_string()) + .or_insert_with(|| serde_json::Value::from(58_000)); + object + .entry("event_starts_at_text".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("event_ends_at_text".to_string()) + .or_insert(serde_json::Value::Null); + } + } + if table_name == "creation_entry_type_config" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:入口分类字段晚于入口类型配置表加入,旧迁移包按未分类兼容。 + object + .entry("category_id".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("category_label".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("category_sort_order".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + } + } if table_name == "user_account" { if let Some(object) = next_value.as_object_mut() { // 中文注释:头像字段晚于认证拆表加入,旧迁移包按未设置头像兼容。 diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index 0ce27567..3ee5e0cf 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -11,6 +11,18 @@ pub struct CreationEntryConfig { pub(crate) modal_title: String, pub(crate) modal_description: String, pub(crate) updated_at: Timestamp, + #[default(None::)] + pub(crate) event_title: Option, + #[default(None::)] + pub(crate) event_description: Option, + #[default(None::)] + pub(crate) event_cover_image_src: Option, + #[default(DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS)] + pub(crate) event_prize_pool_mud_points: u64, + #[default(None::)] + pub(crate) event_starts_at_text: Option, + #[default(None::)] + pub(crate) event_ends_at_text: Option, } #[spacetimedb::table( @@ -28,6 +40,12 @@ pub struct CreationEntryTypeConfig { pub(crate) open: bool, pub(crate) sort_order: i32, pub(crate) updated_at: Timestamp, + #[default(None::)] + pub(crate) category_id: Option, + #[default(None::)] + pub(crate) category_label: Option, + #[default(0)] + pub(crate) category_sort_order: i32, } #[spacetimedb::procedure] @@ -88,6 +106,9 @@ fn upsert_creation_entry_type_config_in_tx( open: input.open, sort_order: input.sort_order, updated_at: now, + category_id: Some(normalize_category_id(&input.category_id)), + category_label: Some(normalize_category_label(&input.category_label)), + category_sort_order: input.category_sort_order, }; if ctx.db.creation_entry_type_config().id().find(&id).is_some() { ctx.db.creation_entry_type_config().id().update(row); @@ -120,6 +141,9 @@ fn get_or_seed_creation_entry_config_snapshot( visible: row.visible, open: row.open, sort_order: row.sort_order, + category_id: normalize_optional_category_id(row.category_id.as_deref()), + category_label: normalize_optional_category_label(row.category_label.as_deref()), + category_sort_order: row.category_sort_order, updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), }) .collect::>(); @@ -141,6 +165,29 @@ fn get_or_seed_creation_entry_config_snapshot( title: header.modal_title, description: header.modal_description, }, + event_banner: CreationEntryEventBannerSnapshot { + title: normalize_optional_text( + header.event_title.as_deref(), + DEFAULT_CREATION_ENTRY_EVENT_TITLE, + ), + description: normalize_optional_text( + header.event_description.as_deref(), + DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION, + ), + cover_image_src: normalize_optional_text( + header.event_cover_image_src.as_deref(), + DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC, + ), + prize_pool_mud_points: header.event_prize_pool_mud_points, + starts_at_text: normalize_optional_text( + header.event_starts_at_text.as_deref(), + DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT, + ), + ends_at_text: normalize_optional_text( + header.event_ends_at_text.as_deref(), + DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT, + ), + }, creation_types, updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), }) @@ -164,6 +211,12 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { modal_title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(), modal_description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(), updated_at: now, + event_title: Some(DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string()), + event_description: Some(DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string()), + event_cover_image_src: Some(DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC.to_string()), + event_prize_pool_mud_points: DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS, + event_starts_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string()), + event_ends_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string()), }); } @@ -348,6 +401,43 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec String { + let normalized = value.trim(); + if normalized.is_empty() { + DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string() + } else { + normalized.to_string() + } +} + +fn normalize_category_label(value: &str) -> String { + let normalized = value.trim(); + if normalized.is_empty() { + DEFAULT_CREATION_ENTRY_CATEGORY_LABEL.to_string() + } else { + normalized.to_string() + } +} + +fn normalize_optional_category_id(value: Option<&str>) -> String { + normalize_optional_text(value, DEFAULT_CREATION_ENTRY_CATEGORY_ID) +} + +fn normalize_optional_category_label(value: Option<&str>) -> String { + normalize_optional_text(value, DEFAULT_CREATION_ENTRY_CATEGORY_LABEL) +} + +fn normalize_optional_text(value: Option<&str>, fallback: &str) -> String { + value + .map(str::trim) + .filter(|normalized| !normalized.is_empty()) + .unwrap_or(fallback) + .to_string() +} diff --git a/src/components/CustomWorldGenerationView.test.tsx b/src/components/CustomWorldGenerationView.test.tsx index 0e589b13..82ee365c 100644 --- a/src/components/CustomWorldGenerationView.test.tsx +++ b/src/components/CustomWorldGenerationView.test.tsx @@ -52,9 +52,9 @@ function createProgress( describe('CustomWorldGenerationView', () => { test.each(['拼图草稿生成进度', '抓大鹅草稿生成进度'])( - 'hides batch module and keeps wait/timer in one row for %s', + 'renders the circular hero and only the current step summary for %s', (progressTitle) => { - render( + const { container } = render( { onBack={() => {}} onEditSetting={() => {}} onRetry={() => {}} + backLabel="返回创作中心" settingDescription={null} settingActionLabel={null} progressTitle={progressTitle} />, ); + expect(container.firstChild).toBeTruthy(); + expect((container.firstChild as HTMLElement).className).toContain( + 'z-[1]', + ); + + const pageVideo = screen.getByTestId( + 'generation-page-background-video', + ) as HTMLVideoElement; + expect(pageVideo.parentElement?.className).toContain('z-0'); + expect(pageVideo.parentElement?.className).toContain('bg-transparent'); + expect(pageVideo.parentElement?.className).not.toContain('bg-[#fff4ea]'); + expect((container.firstChild as HTMLElement).contains(pageVideo)).toBe( + true, + ); + expect(pageVideo.autoplay).toBe(true); + expect(pageVideo.loop).toBe(true); + expect(pageVideo.muted).toBe(true); + expect(pageVideo.playsInline).toBe(true); + expect(pageVideo.getAttribute('preload')).toBe('auto'); + expect( + document.querySelector( + 'video[data-testid="generation-page-background-video"] source[type="video/mp4"]', + ), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: '返回创作中心' }), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: '返回创作中心' }).className, + ).toContain('text-xs'); + expect(screen.getByText('世界建设中')).toBeTruthy(); + expect(screen.getByText('世界建设中').className).toContain('text-xs'); + expect(screen.getByTestId('generation-hero-wait-card').className).toContain( + 'text-center', + ); + expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain( + 'text-center', + ); + expect(screen.getByTestId('generation-hero-wait-card').className).toContain( + 'bg-white/58', + ); + expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain( + 'bg-white/58', + ); + expect(screen.getByText('预计等待').className).toContain('text-[9px]'); + expect(screen.getByText('已耗时').className).toContain('text-[9px]'); + expect(screen.getByText('预计等待').parentElement?.className).toContain( + 'justify-center', + ); + expect(screen.getByText('已耗时').parentElement?.className).toContain( + 'justify-center', + ); + expect(screen.getByText('1 分 15 秒')).toBeTruthy(); + expect(screen.getByText('2 分 5 秒')).toBeTruthy(); + expect(screen.queryByText('预计还需 1 分 15 秒')).toBeNull(); + expect(screen.queryByText('已耗时 2 分 5 秒')).toBeNull(); + expect(screen.queryByText('计时')).toBeNull(); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'justify-start', + ); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'pt-[4%]', + ); + expect(screen.getByText('总进度').className).toContain('text-[9px]'); + expect(screen.getByText('42%').className).toContain('text-[1.15rem]'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .className, + ).toContain('w-[min(35rem,94vw)]'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .className, + ).toContain('sm:w-[52rem]'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('data-ring-start-degrees'), + ).toBe('225'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('data-ring-sweep-degrees'), + ).toBe('270'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('data-ring-gap-degrees'), + ).toBe('90'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('data-ring-fill-degrees'), + ).toBe('113'); + expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe( + 'svg', + ); + expect( + screen + .getByTestId('generation-hero-progress-ring') + .getAttribute('viewBox'), + ).toBe('0 0 400 400'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('r'), + ).toBe('166'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('stroke-width'), + ).toBe('18'); + expect( + screen + .getByTestId('generation-hero-progress-ring-fill') + .getAttribute('stroke-dasharray'), + ).toMatch(/^328\.\d{2} 1043\.\d{2}$/u); + expect( + screen.getByRole('progressbar', { name: progressTitle }), + ).toBeTruthy(); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('aria-valuenow'), + ).toBe('42'); + expect(screen.getByText('当前步骤')).toBeTruthy(); + expect(screen.getByText('当前步骤').className).toContain('text-[10px]'); + expect(screen.getByText('编译草稿')).toBeTruthy(); + expect(screen.getByText('编译草稿').className).toContain('text-[14px]'); + expect(screen.getByText('进行中 50%')).toBeTruthy(); + expect(screen.getByText('进行中 50%').className).toContain('text-[11px]'); + expect( + screen.getByTestId('generation-current-step-card').className, + ).toContain('bg-white/58'); + expect( + screen.getByRole('progressbar', { name: '编译草稿 进度' }), + ).toBeTruthy(); + expect(screen.queryByText('收集设定')).toBeNull(); + expect(screen.queryByText('写回结果')).toBeNull(); expect(screen.queryByText('当前批次')).toBeNull(); - expect(screen.getByText('预计等待')).toBeTruthy(); - expect(screen.getByText('计时')).toBeTruthy(); - - const statsNode = screen - .getByText('预计等待') - .closest('.custom-world-generation-stats'); - expect(statsNode?.className).toContain( - 'custom-world-generation-stats--two-column', - ); - expect(statsNode?.getAttribute('style')).toContain( - 'grid-template-columns: repeat(2, minmax(0, 1fr))', - ); - - const stepNodes = [ - screen.getByText('收集设定'), - screen.getByText('编译草稿'), - screen.getByText('写回结果'), - ].map((node) => node.closest('.custom-world-generation-step')); - - expect(stepNodes.every(Boolean)).toBe(true); - expect(stepNodes[0]?.getAttribute('style')).toContain( - '--generation-step-delay: 0ms', - ); - expect(stepNodes[1]?.getAttribute('style')).toContain( - '--generation-step-delay: 90ms', - ); - expect(stepNodes[2]?.getAttribute('style')).toContain( - '--generation-step-delay: 180ms', - ); + expect(screen.queryByText('正在整理当前设定步骤')).toBeNull(); }, ); - test('keeps batch module for other generation pages', () => { + test('keeps the setting information panel as compact information cards', () => { render( {}} onEditSetting={() => {}} onRetry={() => {}} + backLabel="返回创作中心" settingDescription={null} settingActionLabel={null} + settingTitle="当前大鱼吃小鱼信息" progressTitle="大鱼吃小鱼草稿生成进度" />, ); - expect(screen.getByText('当前批次')).toBeTruthy(); - expect( - screen - .getByText('预计等待') - .closest('.custom-world-generation-stats') - ?.className, - ).not.toContain('custom-world-generation-stats--two-column'); + expect(screen.getByText('当前大鱼吃小鱼信息')).toBeTruthy(); + expect(screen.getByText('当前大鱼吃小鱼信息').className).toContain('text-[13px]'); + expect(screen.getByText('题材')).toBeTruthy(); + expect(screen.getByText('题材').className).toContain('text-[9px]'); + expect(screen.getByText('火锅')).toBeTruthy(); + expect(screen.getByText('火锅').className).toContain('text-[13px]'); + expect(screen.getByText('素材数量')).toBeTruthy(); + expect(screen.getByText('20 种素材')).toBeTruthy(); + expect(screen.queryByText('大鱼吃小鱼题材')).toBeNull(); + expect(screen.getByTestId('generation-page-background-video')).toBeTruthy(); }); }); diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index b540a4f0..9a00c842 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -1,9 +1,12 @@ -import { motion } from 'motion/react'; -import type { CSSProperties } from 'react'; -import { useEffect, useState } from 'react'; +import { ArrowLeft } from 'lucide-react'; import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress'; +import { + GenerationCurrentStepCard, + GenerationPageBackdrop, + GenerationProgressHero, +} from './GenerationProgressHero'; interface CustomWorldGenerationViewProps { settingText: string; @@ -81,6 +84,19 @@ function getStepStatusLabel(step: { status: string }) { return '待处理'; } +function resolveCurrentGenerationStep( + progress: CustomWorldGenerationProgress | null, +) { + const steps = progress?.steps ?? []; + return ( + steps.find((step) => step.status === 'active') ?? + steps[progress?.activeStepIndex ?? -1] ?? + steps.find((step) => step.status === 'pending') ?? + steps.at(-1) ?? + null + ); +} + function buildFallbackRenderKey( value: string | null | undefined, fallback: string, @@ -89,49 +105,6 @@ function buildFallbackRenderKey( return normalizedValue ? normalizedValue : fallback; } -function useIsMobileGenerationLayout() { - const [isMobile, setIsMobile] = useState(() => { - if ( - typeof window === 'undefined' || - typeof window.matchMedia !== 'function' - ) { - return false; - } - - return window.matchMedia('(max-width: 639px)').matches; - }); - - useEffect(() => { - if ( - typeof window === 'undefined' || - typeof window.matchMedia !== 'function' - ) { - return undefined; - } - - const mediaQuery = window.matchMedia('(max-width: 639px)'); - const syncMobileLayout = () => { - setIsMobile(mediaQuery.matches); - }; - - syncMobileLayout(); - - if (typeof mediaQuery.addEventListener === 'function') { - mediaQuery.addEventListener('change', syncMobileLayout); - return () => { - mediaQuery.removeEventListener('change', syncMobileLayout); - }; - } - - mediaQuery.addListener(syncMobileLayout); - return () => { - mediaQuery.removeListener(syncMobileLayout); - }; - }, []); - - return isMobile; -} - export function CustomWorldGenerationView({ settingText, anchorEntries = [], @@ -155,42 +128,47 @@ export function CustomWorldGenerationView({ structuredEmptyText = '正在整理当前设定结构,请稍后。', hideBatchModule = false, }: CustomWorldGenerationViewProps) { - const isMobileGenerationLayout = useIsMobileGenerationLayout(); + void hideBatchModule; const progressValue = getProgressPercentage(progress); - const steps = progress?.steps ?? []; + const currentStep = resolveCurrentGenerationStep(progress); + const currentStepProgress = currentStep + ? getStepProgressPercentage(currentStep) + : progressValue; + const currentStepLabel = currentStep?.label ?? progress?.phaseLabel ?? '准备生成'; + const currentStepStatusLabel = currentStep + ? getStepStatusLabel(currentStep) + : isGenerating + ? '进行中' + : '待处理'; const hasStructuredAnchors = anchorEntries.length > 0; // 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。 const normalizedSettingActionLabel = settingActionLabel?.trim() ?? ''; const normalizedSettingDescription = settingDescription?.trim() ?? ''; const hasSettingActionLabel = normalizedSettingActionLabel.length > 0; const hasSettingDescription = normalizedSettingDescription.length > 0; - const shouldHideBatchModule = - hideBatchModule || - progressTitle === '拼图草稿生成进度' || - progressTitle === '抓大鹅草稿生成进度'; const estimatedWaitText = progress?.estimatedRemainingMs != null - ? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}` - : '正在校准预计等待时间'; + ? formatDuration(progress.estimatedRemainingMs) + : '校准中'; const elapsedText = - progress != null - ? `已耗时 ${formatDuration(progress.elapsedMs)}` - : '正在启动世界生成'; + progress != null ? formatDuration(progress.elapsedMs) : '启动中'; return (
-
+ +
-
+
{isGenerating ? activeBadgeLabel : error @@ -199,143 +177,26 @@ export function CustomWorldGenerationView({
-
-
-
-
-
- {progressTitle} -
-
- {progress?.phaseLabel ?? '正在启动世界生成'} -
-
- {progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'} -
-
-
-
- 总进度 -
-
- {progressValue}% -
-
-
+
+
+ -
- +
-
- {shouldHideBatchModule ? null : ( -
-
- 当前批次 -
-
- {progress?.batchLabel ?? '准备中'} -
-
- )} -
-
- 预计等待 -
-
- {estimatedWaitText} -
-
-
-
- 计时 -
-
- {elapsedText} -
-
-
- -
- {steps.map((step, index) => { - const stepProgress = getStepProgressPercentage(step); - - return ( - -
-
- {step.label} -
-
- {getStepStatusLabel(step)} {stepProgress}% -
-
-
- -
-
- {step.detail} -
-
- ); - })} -
- {error ? ( -
+
{error}
) : null} @@ -372,14 +233,14 @@ export function CustomWorldGenerationView({
-
-
+
+
-
+
{settingTitle}
{hasSettingDescription ? ( -
+
{normalizedSettingDescription}
) : null} @@ -396,26 +257,26 @@ export function CustomWorldGenerationView({ ) : null}
{hasStructuredAnchors ? ( -
+
{anchorEntries.map((entry, index) => (
-
+
{entry.label}
-
+
{entry.value}
))}
) : ( -
+
{settingText || structuredEmptyText}
)} diff --git a/src/components/GenerationProgressHero.tsx b/src/components/GenerationProgressHero.tsx new file mode 100644 index 00000000..9883d96c --- /dev/null +++ b/src/components/GenerationProgressHero.tsx @@ -0,0 +1,289 @@ +import { Clock3, Hourglass } from 'lucide-react'; +import { motion } from 'motion/react'; +import { useEffect, useId, useRef } from 'react'; + +import generationHeroVideo from '../../media/create_bg_video.mp4'; + +const GENERATION_PROGRESS_RING_START_DEGREES = 225; +const GENERATION_PROGRESS_RING_SWEEP_DEGREES = 270; +const GENERATION_PROGRESS_RING_VIEWBOX = 400; +const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2; +const GENERATION_PROGRESS_RING_RADIUS = 166; +const GENERATION_PROGRESS_RING_STROKE_WIDTH = 18; +const GENERATION_PROGRESS_RING_SWEEP_RATIO = + GENERATION_PROGRESS_RING_SWEEP_DEGREES / 360; + +type GenerationProgressHeroProps = { + title: string; + phaseLabel: string; + progressValue: number; + estimatedWaitText: string; + elapsedText: string; +}; + +type GenerationCurrentStepCardProps = { + label: string; + statusLabel: string; + progressValue: number; +}; + +function clampGenerationProgress(value: number) { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function buildGenerationRingMetrics(progressValue: number) { + const circumference = 2 * Math.PI * GENERATION_PROGRESS_RING_RADIUS; + const sweepLength = circumference * GENERATION_PROGRESS_RING_SWEEP_RATIO; + const progressLength = sweepLength * (progressValue / 100); + + return { + circumference, + progressLength, + sweepLength, + }; +} + +export function GenerationPageBackdrop() { + const videoRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video) { + return undefined; + } + + video.defaultMuted = true; + video.muted = true; + video.volume = 0; + + const isJsdom = + window.navigator.userAgent.toLowerCase().includes('jsdom'); + const tryPlay = () => { + if (isJsdom) { + return; + } + try { + const playPromise = video.play(); + if (playPromise && typeof playPromise.then === 'function') { + void playPromise.catch(() => {}); + } + } catch { + // 中文注释:测试环境和某些内核可能同步拒绝 play;失败时保留静音背景层,不阻断页面渲染。 + } + }; + + tryPlay(); + video.addEventListener('loadeddata', tryPlay); + video.addEventListener('canplay', tryPlay); + video.addEventListener('playing', tryPlay); + window.addEventListener('focus', tryPlay); + document.addEventListener('visibilitychange', tryPlay); + + return () => { + video.removeEventListener('loadeddata', tryPlay); + video.removeEventListener('canplay', tryPlay); + video.removeEventListener('playing', tryPlay); + window.removeEventListener('focus', tryPlay); + document.removeEventListener('visibilitychange', tryPlay); + }; + }, []); + + return ( +
+ +
+
+ ); +} + +export function GenerationProgressHero({ + title, + phaseLabel, + progressValue, + estimatedWaitText, + elapsedText, +}: GenerationProgressHeroProps) { + const safeProgress = clampGenerationProgress(progressValue); + const ringGradientId = useId().replace(/:/g, ''); + const ringMetrics = buildGenerationRingMetrics(safeProgress); + const ringDegrees = Math.round((safeProgress / 100) * 270); + const ringTrackDasharray = `${ringMetrics.sweepLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`; + const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`; + + return ( +
+
+ {title} + {phaseLabel ? ` ${phaseLabel}` : ''} +
+
+
+
+ +
+ 预计等待 +
+
+
+ {estimatedWaitText} +
+
+ +
+
+
+ 已耗时 +
+ +
+
+ {elapsedText} +
+
+ +
+ +
+
+ 总进度 +
+
+ {safeProgress}% +
+
+
+
+
+ ); +} + +export function GenerationCurrentStepCard({ + label, + statusLabel, + progressValue, +}: GenerationCurrentStepCardProps) { + const safeProgress = clampGenerationProgress(progressValue); + const isActive = statusLabel === '进行中'; + + return ( +
+
+
+
+ 当前步骤 +
+
+ {label} +
+
+
+
+ {statusLabel} {safeProgress}% +
+ {isActive ? ( +
+
+
+ +
+
+ ); +} diff --git a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx index 9a341f2b..02af134b 100644 --- a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx @@ -50,7 +50,7 @@ describe('BarkBattleGeneratingView', () => { updatedAt: '2026-05-14T10:01:00.000Z', }); - render( + const { container } = render( {}} @@ -59,9 +59,142 @@ describe('BarkBattleGeneratingView', () => { />, ); + expect(container.firstChild).toBeTruthy(); + expect((container.firstChild as HTMLElement).className).toContain('z-[1]'); + expect(screen.getByText('总进度')).toBeTruthy(); + expect(screen.getByText('总进度').className).toContain('text-[9px]'); + const pageVideo = screen.getByTestId( + 'generation-page-background-video', + ) as HTMLVideoElement; + expect(pageVideo.parentElement?.className).toContain('z-0'); + expect(pageVideo.parentElement?.className).toContain('bg-transparent'); + expect(pageVideo.parentElement?.className).not.toContain('bg-[#fff4ea]'); + expect((container.firstChild as HTMLElement).contains(pageVideo)).toBe( + true, + ); + expect(pageVideo.autoplay).toBe(true); + expect(pageVideo.loop).toBe(true); + expect(pageVideo.muted).toBe(true); + expect(pageVideo.playsInline).toBe(true); + expect(pageVideo.getAttribute('preload')).toBe('auto'); + expect( + document.querySelector( + 'video[data-testid="generation-page-background-video"] source[type="video/mp4"]', + ), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: '返回编辑' }).className).toContain( + 'text-xs', + ); + expect(screen.getByText('生成中').className).toContain('text-[11px]'); + expect(screen.getByText('当前步骤')).toBeTruthy(); + expect(screen.getByText('当前步骤').className).toContain('text-[10px]'); + expect(screen.getByTestId('generation-hero-wait-card').className).toContain( + 'text-center', + ); + expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain( + 'text-center', + ); + expect(screen.getByTestId('generation-hero-wait-card').className).toContain( + 'bg-white/58', + ); + expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain( + 'bg-white/58', + ); + expect(screen.getByText('预计等待').className).toContain('text-[9px]'); + expect(screen.getByText('已耗时').className).toContain('text-[9px]'); + expect(screen.getByText('预计等待').parentElement?.className).toContain( + 'justify-center', + ); + expect(screen.getByText('已耗时').parentElement?.className).toContain( + 'justify-center', + ); + expect(screen.getByText('3 分钟')).toBeTruthy(); + expect(screen.getByText('1 秒')).toBeTruthy(); + expect(screen.queryByText('预计还需 3 分钟')).toBeNull(); + expect(screen.queryByText('已耗时 1 秒')).toBeNull(); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'justify-start', + ); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'pt-[4%]', + ); expect(screen.getByText('玩家形象')).toBeTruthy(); - expect(screen.getByText('对手形象')).toBeTruthy(); - expect(screen.getByText('竞技背景')).toBeTruthy(); + expect(screen.getByText('进行中 36%')).toBeTruthy(); + expect(screen.getByText('进行中 36%').className).toContain('text-[11px]'); + expect(screen.getByText('总进度').className).toContain('text-[9px]'); + expect(screen.getByText('0%').className).toContain('text-[1.15rem]'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .className, + ).toContain('w-[min(35rem,94vw)]'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .className, + ).toContain('sm:w-[52rem]'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('aria-valuenow'), + ).toBe('0'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('data-ring-start-degrees'), + ).toBe('225'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('data-ring-sweep-degrees'), + ).toBe('270'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('data-ring-gap-degrees'), + ).toBe('90'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('data-ring-fill-degrees'), + ).toBe('0'); + expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe( + 'svg', + ); + expect( + screen + .getByTestId('generation-hero-progress-ring') + .getAttribute('viewBox'), + ).toBe('0 0 400 400'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('r'), + ).toBe('166'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('stroke-width'), + ).toBe('18'); + expect( + screen + .getByTestId('generation-hero-progress-ring-fill') + .getAttribute('stroke-dasharray'), + ).toMatch(/^0\.00 1043\.\d{2}$/u); + expect( + screen.getByRole('progressbar', { name: '玩家形象 进度' }), + ).toBeTruthy(); + expect( + screen + .getByRole('progressbar', { name: '玩家形象 进度' }) + .getAttribute('aria-valuenow'), + ).toBe('36'); + expect( + screen.getByTestId('generation-current-step-card').className, + ).toContain('bg-white/58'); + expect(screen.getByText('预览信息').className).toContain('text-[13px]'); + expect(screen.queryByText('对手形象')).toBeNull(); + expect(screen.queryByText('竞技背景')).toBeNull(); expect(onComplete).not.toHaveBeenCalled(); resolveGeneration({ diff --git a/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx b/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx index b1402bea..2a1a293f 100644 --- a/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx +++ b/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Sparkles } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle'; @@ -12,6 +12,11 @@ import { generateAllBarkBattleImageAssets, updateBarkBattleDraftConfig, } from '../../services/bark-battle-creation'; +import { + GenerationCurrentStepCard, + GenerationPageBackdrop, + GenerationProgressHero, +} from '../GenerationProgressHero'; import { BarkBattlePreviewCard } from './BarkBattlePreviewCard'; type BarkBattleGeneratingViewProps = { @@ -110,6 +115,56 @@ function buildDraftGenerationKey(draft: BarkBattleDraftConfig) { ].join('|'); } +function getSlotStatusLabel(status: BarkBattleGeneratingSlotStatus) { + if (status === 'ready') { + return '完成'; + } + if (status === 'failed') { + return '失败'; + } + return '进行中'; +} + +function formatGenerationDuration(ms: number) { + const totalSeconds = Math.max(1, Math.ceil(Math.max(0, ms) / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes <= 0) { + return `${seconds} 秒`; + } + + if (seconds === 0) { + return `${minutes} 分钟`; + } + + return `${minutes} 分 ${seconds} 秒`; +} + +function resolveBarkBattleProgressValue( + slotStatuses: Partial< + Record + >, +) { + const readyCount = GENERATION_STEPS.filter( + (step) => slotStatuses[step.slot] === 'ready', + ).length; + return Math.round((readyCount / GENERATION_STEPS.length) * 100); +} + +function resolveCurrentBarkBattleStep( + slotStatuses: Partial< + Record + >, +) { + return ( + GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'generating') ?? + GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'failed') ?? + GENERATION_STEPS.find((step) => slotStatuses[step.slot] !== 'ready') ?? + GENERATION_STEPS[GENERATION_STEPS.length - 1] + ); +} + export function BarkBattleGeneratingView({ draft, isBusy = false, @@ -125,10 +180,33 @@ export function BarkBattleGeneratingView({ const [slotStatuses, setSlotStatuses] = useState< Partial> >({}); + const [elapsedMs, setElapsedMs] = useState(0); const primaryFailureMessage = useMemo( () => resolvePrimaryFailureMessage(slotFailures), [slotFailures], ); + const progressValue = resolveBarkBattleProgressValue(slotStatuses); + const currentStep = resolveCurrentBarkBattleStep(slotStatuses); + const currentStepStatus = currentStep + ? (slotStatuses[currentStep.slot] ?? + (hasSlotAsset(previewDraft, currentStep.slot) ? 'ready' : 'generating')) + : 'generating'; + const currentStepProgress = + currentStepStatus === 'ready' ? 100 : currentStepStatus === 'failed' ? 100 : 36; + const currentStepLabel = currentStep?.label ?? '竞技素材'; + const currentStepStatusLabel = getSlotStatusLabel(currentStepStatus); + + useEffect(() => { + const startedAtMs = Date.now(); + const timerId = window.setInterval(() => { + setElapsedMs(Date.now() - startedAtMs); + }, 1000); + setElapsedMs(0); + + return () => { + window.clearInterval(timerId); + }; + }, [draft.draftId]); useEffect(() => { setPreviewDraft(draft); @@ -277,76 +355,54 @@ export function BarkBattleGeneratingView({ }, [draft, onComplete, onError]); return ( -
-
-
+
+ +
+
- + 生成中
-
-
-
-
- - 自动生成素材 -
-

- {draft.title || '未命名声浪竞技场'} -

-
+
+
+ -
- {GENERATION_STEPS.map((step) => { - const status = - slotStatuses[step.slot] ?? - (hasSlotAsset(previewDraft, step.slot) ? 'ready' : 'generating'); - const ready = status === 'ready'; - const failed = - status === 'failed' || Boolean(slotFailures[step.slot]); - const statusLabel = ready - ? `${step.label}已生成` - : failed - ? `${step.label}生成失败` - : `${step.label}生成中`; - return ( -
- - {step.label} - - {ready ? ( - - ) : failed ? ( - - ) : ( - - )} -
- ); - })} +
+
{error || primaryFailureMessage ? ( -
+
{error ?? primaryFailureMessage}
) : null}
- +
+
+ 预览信息 +
+ +
@@ -354,4 +410,3 @@ export function BarkBattleGeneratingView({ } export default BarkBattleGeneratingView; - 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.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 44add0ee..ff231fa7 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -25,6 +25,14 @@ const testEntryConfig = { title: '选择创作类型', description: '先选玩法类型,再进入对应创作工作台。', }, + eventBanner: { + title: '泥点挑战', + description: '创作活动测试横幅。', + coverImageSrc: '/creation-type-references/puzzle.webp', + prizePoolMudPoints: 1000, + startsAtText: '2026-05-01', + endsAtText: '2026-05-31', + }, creationTypes: [ { id: 'rpg', @@ -35,6 +43,9 @@ const testEntryConfig = { visible: true, open: true, sortOrder: 10, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -46,6 +57,9 @@ const testEntryConfig = { visible: true, open: true, sortOrder: 30, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -57,6 +71,9 @@ const testEntryConfig = { visible: true, open: true, sortOrder: 40, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -68,6 +85,9 @@ const testEntryConfig = { visible: false, open: true, sortOrder: 50, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -79,6 +99,9 @@ const testEntryConfig = { visible: false, open: false, sortOrder: 60, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -90,6 +113,9 @@ const testEntryConfig = { visible: true, open: false, sortOrder: 70, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ], @@ -665,17 +691,17 @@ test('creation hub works-only tab filters bark battle draft and published works' />, ); - expect(screen.getByRole('button', { name: '全部 2' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '草稿 1' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '已发布 1' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '全部 2' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '草稿 1' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '已发布 1' })).toBeTruthy(); expect(screen.getByText('竖屏声浪草稿')).toBeTruthy(); expect(screen.getByText('竖屏声浪已发布')).toBeTruthy(); - await user.click(screen.getByRole('button', { name: '草稿 1' })); + await user.click(screen.getByRole('tab', { name: '草稿 1' })); expect(screen.getByText('竖屏声浪草稿')).toBeTruthy(); expect(screen.queryByText('竖屏声浪已发布')).toBeNull(); - await user.click(screen.getByRole('button', { name: '已发布 1' })); + await user.click(screen.getByRole('tab', { name: '已发布 1' })); expect(screen.queryByText('竖屏声浪草稿')).toBeNull(); expect(screen.getByText('竖屏声浪已发布')).toBeTruthy(); @@ -880,6 +906,38 @@ test('creation hub published share icon is shown directly on the card header', ( expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); }); +test('creation hub shows RPG published share icon without library entry', () => { + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + />, + ); + + expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy(); + expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); + expect(screen.queryByText('作者:玩家')).toBeNull(); +}); + test('creation hub left swipe draft reveals delete without opening card', () => { const onDeletePublished = vi.fn(); const onOpenDraft = vi.fn(); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index ced0e82c..9f71189a 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -18,6 +18,14 @@ const testEntryConfig = { title: '选择创作类型', description: '先选玩法类型,再进入对应创作工作台。', }, + eventBanner: { + title: '泥点挑战', + description: '创作活动测试横幅。', + coverImageSrc: '/creation-type-references/puzzle.webp', + prizePoolMudPoints: 1000, + startsAtText: '2026-05-01', + endsAtText: '2026-05-31', + }, creationTypes: [ { id: 'rpg', @@ -28,6 +36,9 @@ const testEntryConfig = { visible: true, open: true, sortOrder: 10, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -39,17 +50,23 @@ const testEntryConfig = { visible: true, open: true, sortOrder: 30, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { id: 'match3d', title: '抓大鹅', subtitle: '3D 消除关卡', - badge: '可创建', + badge: '可创作', imageSrc: '/creation-type-references/match3d.webp', visible: true, open: true, sortOrder: 40, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -61,6 +78,9 @@ const testEntryConfig = { visible: false, open: true, sortOrder: 50, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -72,6 +92,9 @@ const testEntryConfig = { visible: false, open: false, sortOrder: 60, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -83,6 +106,9 @@ const testEntryConfig = { visible: true, open: false, sortOrder: 70, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ], @@ -140,6 +166,96 @@ test('creation hub draft card renders compiled work summary fields', () => { expect(html).not.toContain('大鱼吃小鱼'); }); +test('creation start card renders reference-aligned banner and template metadata', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + mode="start-only" + />, + ); + + expect(html).toContain('creation-event-banner'); + expect(html).toContain('creation-event-banner__track'); + expect(html).toContain('creation-event-banner__slide'); + expect(html).toContain('creation-event-banner__timebar'); + expect(html).toContain('拼图主题创作赛'); + expect(html).toContain('抓大鹅主题创作赛'); + expect(html).toContain('1,000'); + expect(html).toContain('泥点数'); + expect(html).not.toContain('泥点挑战'); + expect(html).toMatch( + /creation-event-banner__timebar[\s\S]*creation-event-banner__pager[\s\S]*creation-template-card/u, + ); + expect(html).toContain('creation-template-card__body'); + expect(html).toContain('creation-template-card__cost-badge'); + expect(html).toContain('拼图关卡创作'); + expect(html).toContain('10-20泥点数'); + expect(html).toContain('即将开放'); + expect(html).not.toContain('可创建'); + expect(html).not.toContain('可创作'); + expect(html).not.toContain('creation-event-banner__counter'); + expect(html).not.toContain('预计消耗 10-20 泥点'); + expect(html).not.toContain('platform-creation-reference-card'); +}); + +test('creation start card keeps typography in compact UI scale', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + mode="start-only" + />, + ); + + expect(html).toMatch(/creation-template-card__title[^"]*\btext-sm\b/u); + expect(html).toMatch(/creation-template-card__subtitle[^"]*\btext-xs\b/u); + expect(html).toMatch( + /creation-template-card__cost-badge[^"]*\btext-\[11px\](?:\s|")/u, + ); + expect(html).not.toMatch( + /\b(text-lg|text-xl|sm:text-base|sm:text-lg|sm:text-xl|text-\[1\.08rem\])\b/u, + ); +}); + +test('creation start card removes the outer template list frame and tightens card grid', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + mode="start-only" + />, + ); + + expect(html).toContain('creation-template-list'); + expect(html).toMatch(/creation-template-list__grid[^"]*\bgap-2\b/u); + expect(html).toMatch(/creation-template-card[^"]*\bmin-h-\[12\.5rem\]/u); + expect(html).not.toMatch( + /creation-template-list[^"]*\bborder\b[^"]*\bborder-\[#f0dfd6\]/u, + ); +}); + test('creation hub renders puzzle works in the same unified list with puzzle tag', () => { const html = renderToStaticMarkup( 查看详情<'); }); + +test('creation hub root keeps the remap theme hook without the page card shell', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + mode="works-only" + />, + ); + + expect(html).toContain('platform-remap-surface'); + expect(html).not.toContain('platform-page-stage'); +}); + +test('creation hub draft tabs use discover-style channel labels', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + puzzleItems={[ + { + workId: 'puzzle:works-tab', + profileId: 'puzzle-profile-works-tab', + ownerUserId: 'user-1', + authorDisplayName: '测试作者', + levelName: '测试草稿', + summary: '测试草稿', + themeTags: [], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-05-07T00:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + }, + ]} + onOpenPuzzleDetail={() => {}} + />, + ); + + expect(html).toContain('platform-mobile-home-channel'); + expect(html).toContain('platform-mobile-home-channel--active'); + expect(html).not.toContain('platform-tab--active'); +}); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 9e037b1d..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); @@ -338,7 +355,7 @@ export function CustomWorldCreationHub({ const showWorkShelf = mode !== 'start-only'; return ( -
+
{showStartCard ? ( void; }; +type CreationEventBannerCard = CreationEntryConfig['eventBanner']; + +function shouldShowCreationBadge(badge: string) { + const normalizedBadge = badge.trim(); + return normalizedBadge !== '可创建' && normalizedBadge !== '可创作'; +} + export function CustomWorldCreationStartCard({ busy = false, error = null, @@ -22,30 +30,161 @@ export function CustomWorldCreationStartCard({ creationTypes, onCreateType, }: CustomWorldCreationStartCardProps) { - // 创作首页首屏卡带与创作类型弹层保持同一份展示口径, - // 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。 - const visibleCreationTypes = getVisiblePlatformCreationTypes(creationTypes); + const creationTypeGroups = useMemo( + () => groupVisiblePlatformCreationTypes(creationTypes), + [creationTypes], + ); + const [activeCategoryId, setActiveCategoryId] = useState(null); + const activeGroup = + creationTypeGroups.find((group) => group.id === activeCategoryId) ?? + creationTypeGroups[0] ?? + null; + const visibleCreationTypes = activeGroup?.items ?? []; + const eventBanners = useMemo( + () => [ + { + ...entryConfig.eventBanner, + title: '拼图主题创作赛', + description: '用拼图关卡接住本周主题。', + coverImageSrc: '/creation-type-references/puzzle.webp', + prizePoolMudPoints: 1000, + }, + { + ...entryConfig.eventBanner, + title: '抓大鹅主题创作赛', + description: '把抓大鹅关卡做成主题挑战。', + coverImageSrc: '/creation-type-references/match3d.webp', + prizePoolMudPoints: 1000, + }, + ], + [entryConfig.eventBanner], + ); + const [activeBannerIndex, setActiveBannerIndex] = useState(0); + + function handleBannerScroll(event: UIEvent) { + const { clientWidth, scrollLeft } = event.currentTarget; + if (clientWidth <= 0) { + return; + } + + const nextIndex = Math.max( + 0, + Math.min(eventBanners.length - 1, Math.round(scrollLeft / clientWidth)), + ); + setActiveBannerIndex((currentIndex) => + currentIndex === nextIndex ? currentIndex : nextIndex, + ); + } return ( - // 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。 -
-
-
-
-
- {entryConfig.startCard.title} -
-
- {entryConfig.startCard.description} -
- - {busy - ? entryConfig.startCard.busyBadge - : entryConfig.startCard.idleBadge} - +
+
+
+ {eventBanners.map((banner, index) => { + const prizePoolText = + banner.prizePoolMudPoints.toLocaleString('zh-CN'); + + return ( +
+ +
+
+
+
+ + {banner.title} +
+
+ {banner.description} +
+
+ + + + 奖池 + + {prizePoolText} + + 泥点数 +
+
+ +
+
+ + 开始时间  {banner.startsAtText} + + | + + 结束时间  {banner.endsAtText} + +
+ +
+
+
+ ); + })} +
+
+ +
+
+ {creationTypeGroups.map((group) => { + const selected = group.id === activeGroup?.id; + return ( + + ); + })}
-
+
{visibleCreationTypes.map((item) => { const disabled = item.locked || busy; @@ -57,47 +196,35 @@ export function CustomWorldCreationStartCard({ onClick={() => { onCreateType(item.id); }} - className={`platform-creation-reference-card platform-interactive-card relative flex min-h-[4.6rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border p-0 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] xl:min-h-[6.4rem] ${ + className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${ item.locked - ? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70' - : 'border-white/18 bg-white/16 text-white' + ? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72' + : 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]' } ${busy && !item.locked ? 'opacity-70' : ''}`} > - -
-
- {item.locked ? ( - +
+ + {shouldShowCreationBadge(item.badge) ? ( + {item.badge} ) : null} - {item.locked ? ( - · - ) : ( - - )} + + + 10-20泥点数 +
-
-
+
+
{item.title}
-
+
{item.subtitle}
@@ -107,11 +234,11 @@ export function CustomWorldCreationStartCard({
{error ? ( -
+
{error}
) : null} -
+
); } diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 3bccf3b5..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', @@ -728,8 +729,6 @@ export function CustomWorldWorkCard({ {item.summary}
-
作者:{item.authorDisplayName}
- {isPublished ? (
{item.pointIncentive ? ( diff --git a/src/components/custom-world-home/CustomWorldWorkTabs.tsx b/src/components/custom-world-home/CustomWorldWorkTabs.tsx index 1b928cb7..1eb6cc92 100644 --- a/src/components/custom-world-home/CustomWorldWorkTabs.tsx +++ b/src/components/custom-world-home/CustomWorldWorkTabs.tsx @@ -23,7 +23,11 @@ export function CustomWorldWorkTabs({ onChange, }: CustomWorldWorkTabsProps) { return ( -
+
{FILTER_OPTIONS.map((option) => { const count = option.id === 'draft' @@ -36,10 +40,10 @@ export function CustomWorldWorkTabs({ diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index 767fa893..1db7d6d6 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -175,6 +175,39 @@ test('buildCreationWorkShelfItems keeps separate bark battle draft and published ); }); +test('buildCreationWorkShelfItems falls back to deterministic RPG public work code when library entry is missing', () => { + const items = buildCreationWorkShelfItems({ + rpgItems: [ + { + workId: 'rpg-work-published', + sourceType: 'published_profile', + status: 'published', + title: '潮雾列岛已发布版', + subtitle: '旧灯塔与失控航路', + summary: '已经发布的群岛世界作品。', + coverImageSrc: null, + updatedAt: '2026-04-20T10:00:00.000Z', + publishedAt: '2026-04-20T10:00:00.000Z', + stage: 'published', + stageLabel: '已发布', + playableNpcCount: 3, + landmarkCount: 4, + sessionId: null, + profileId: 'world-public-1', + canResume: false, + canEnterWorld: true, + }, + ], + bigFishItems: [], + puzzleItems: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.publicWorkCode).toBe('CW-00000001'); + expect(items[0]?.sharePath).toContain('/works/detail?work=CW-00000001'); + expect(items[0]?.canShare).toBe(true); +}); + test('buildCreationWorkShelfItems gives bark battle draft cover from character or reference fallback', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], @@ -1009,7 +1042,7 @@ test('bark battle draft generating state follows pending assets or missing three }); -test('CustomWorldWorkCard renders author for draft and published works', () => { +test('CustomWorldWorkCard hides author on shelf draft and published cards', () => { const buildItem = ( status: CreationWorkShelfItem['status'], authorDisplayName: string, @@ -1074,8 +1107,8 @@ test('CustomWorldWorkCard renders author for draft and published works', () => { }), ); - expect(draftHtml).toContain('作者:草稿作者'); - expect(publishedHtml).toContain('作者:发布作者'); + expect(draftHtml).not.toContain('作者:草稿作者'); + expect(publishedHtml).not.toContain('作者:发布作者'); }); test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => { diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index e39c5df9..e1fecab8 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -7,11 +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, @@ -30,6 +33,7 @@ export type CreationWorkShelfKind = | 'big-fish' | 'match3d' | 'square-hole' + | 'jump-hop' | 'puzzle' | 'baby-object-match' | 'bark-battle' @@ -82,6 +86,10 @@ export type CreationWorkShelfSource = kind: 'square-hole'; item: SquareHoleWorkSummary; } + | { + kind: 'jump-hop'; + item: JumpHopWorkSummaryResponse; + } | { kind: 'puzzle'; item: PuzzleWorkSummary; @@ -136,6 +144,7 @@ export function buildCreationWorkShelfItems(params: { bigFishItems: BigFishWorkSummary[]; match3dItems?: Match3DWorkSummary[]; squareHoleItems?: SquareHoleWorkSummary[]; + jumpHopItems?: JumpHopWorkSummaryResponse[]; puzzleItems: PuzzleWorkSummary[]; babyObjectMatchItems?: BabyObjectMatchDraft[]; barkBattleItems?: BarkBattleWorkSummary[]; @@ -144,6 +153,7 @@ export function buildCreationWorkShelfItems(params: { canDeleteBigFish?: boolean; canDeleteMatch3D?: boolean; canDeleteSquareHole?: boolean; + canDeleteJumpHop?: boolean; canDeletePuzzle?: boolean; canDeleteBabyObjectMatch?: boolean; canDeleteBarkBattle?: boolean; @@ -157,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; @@ -176,6 +188,7 @@ export function buildCreationWorkShelfItems(params: { bigFishItems, match3dItems = [], squareHoleItems = [], + jumpHopItems = [], puzzleItems, babyObjectMatchItems = [], barkBattleItems = [], @@ -184,6 +197,7 @@ export function buildCreationWorkShelfItems(params: { canDeleteBigFish = false, canDeleteMatch3D = false, canDeleteSquareHole = false, + canDeleteJumpHop = false, canDeletePuzzle = false, canDeleteBabyObjectMatch = false, canDeleteBarkBattle = false, @@ -197,6 +211,8 @@ export function buildCreationWorkShelfItems(params: { onDeleteMatch3D, onOpenSquareHoleDetail, onDeleteSquareHole, + onOpenJumpHopDetail, + onDeleteJumpHop, onOpenPuzzleDetail, onDeletePuzzle, onClaimPuzzlePointIncentive, @@ -235,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, @@ -332,7 +354,10 @@ function mapRpgWorkToShelfItem( ? libraryEntries.find((entry) => entry.profileId === item.profileId) : null; const publicWorkCode = - item.status === 'published' ? (libraryEntry?.publicWorkCode ?? null) : null; + item.status === 'published' + ? (libraryEntry?.publicWorkCode?.trim() || + (item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null)) + : null; const badges: CreationWorkShelfBadge[] = [ buildStatusBadge(item.status), { id: 'type', label: 'RPG', tone: 'neutral' }, @@ -745,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/PlatformEntryCreationTypeModal.test.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx index 16e1b08e..583c19ad 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx @@ -18,6 +18,14 @@ const entryConfig = { title: '选择创作类型', description: '', }, + eventBanner: { + title: '泥点挑战', + description: '创作活动测试横幅。', + coverImageSrc: '/creation-type-references/puzzle.webp', + prizePoolMudPoints: 1000, + startsAtText: '2026-05-01', + endsAtText: '2026-05-31', + }, creationTypes: [ { id: 'wooden-fish', @@ -28,6 +36,9 @@ const entryConfig = { visible: true, open: true, sortOrder: 10, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ], diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 3fb908e4..91ecf6cf 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Loader2 } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { type Dispatch, @@ -121,8 +121,10 @@ import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFound import { ApiClientError, BACKGROUND_AUTH_REQUEST_OPTIONS, + getStoredAccessToken, } from '../../services/apiClient'; import { + ensureRuntimeGuestToken, getPublicAuthUserByCode, getPublicAuthUserById, } from '../../services/authService'; @@ -133,6 +135,7 @@ import { publishBarkBattleWork, updateBarkBattleDraftConfig, } from '../../services/bark-battle-creation'; +import { startBarkBattleRun } from '../../services/bark-battle-runtime'; import { createBigFishCreationSession, executeBigFishCreationAction, @@ -164,6 +167,19 @@ import { streamCreativeAgentMessage, streamCreativeDraftEdit, } from '../../services/creative-agent'; +import { + clearCreationUrlState, + type CreationUrlState, + isCreationRestorePath, + readCreationUrlState, + writeCreationUrlState, +} from '../../services/creationUrlState'; +import { + clearPuzzleRuntimeUrlState, + readPuzzleRuntimeUrlState, + writePuzzleRuntimeUrlState, + type PuzzleRuntimeUrlState, +} from '../../services/puzzleRuntimeUrlState'; import { readCustomWorldAgentUiState, shouldRestoreCustomWorldAgentUiState, @@ -183,9 +199,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 { createLocalMatch3DRuntimeAdapter, @@ -214,6 +231,7 @@ import { buildWoodenFishGenerationAnchorEntries, createMiniGameDraftGenerationState, type MiniGameDraftGenerationKind, + type MiniGameDraftGenerationPhase, type MiniGameDraftGenerationState, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; @@ -393,7 +411,6 @@ import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import { derivePlatformCreationTypes, - getVisiblePlatformCreationTypes, isPlatformCreationTypeOpen, isPlatformCreationTypeVisible, } from './platformEntryCreationTypes'; @@ -410,12 +427,16 @@ import { buildCreationHubFallbackItems, resolveRpgCreationErrorMessage, } from './platformEntryShared'; -import type { PlatformEntryFlowShellProps } from './platformEntryTypes'; +import type { + PlatformEntryFlowShellProps, + SelectionStage, +} from './platformEntryTypes'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; 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'; @@ -558,8 +579,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; @@ -1709,6 +1756,237 @@ function buildPuzzleResultWorkId(sessionId: string | null | undefined) { return `puzzle-work-${stableSuffix}`; } +function buildPuzzleSessionIdFromProfileId( + profileId: string | null | undefined, +) { + const normalizedProfileId = profileId?.trim(); + if (!normalizedProfileId?.startsWith('puzzle-profile-')) { + return null; + } + + const stableSuffix = normalizedProfileId.slice('puzzle-profile-'.length); + return stableSuffix ? `puzzle-session-${stableSuffix}` : null; +} + +function normalizeCreationUrlValue(value: string | null | undefined) { + return value?.trim() || null; +} + +function hasCreationUrlStateValue(state: CreationUrlState) { + return Boolean( + normalizeCreationUrlValue(state.sessionId) || + normalizeCreationUrlValue(state.profileId) || + normalizeCreationUrlValue(state.draftId) || + normalizeCreationUrlValue(state.workId), + ); +} + +function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) { + return Boolean( + normalizeCreationUrlValue(state.runtimeSessionId) || + normalizeCreationUrlValue(state.runtimeProfileId) || + normalizeCreationUrlValue(state.runtimeLevelId) || + normalizeCreationUrlValue(state.publicWorkCode) || + normalizeCreationUrlValue(state.mode), + ); +} + +function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) { + return [ + normalizeCreationUrlValue(state.mode), + normalizeCreationUrlValue(state.runtimeSessionId), + normalizeCreationUrlValue(state.runtimeProfileId), + normalizeCreationUrlValue(state.runtimeLevelId), + normalizeCreationUrlValue(state.publicWorkCode), + ].join('|'); +} + +function buildBigFishCreationUrlState( + session: BigFishSessionSnapshotResponse | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + return { + sessionId, + workId: sessionId ? `big-fish-work-${sessionId}` : null, + }; +} + +function buildMatch3DCreationUrlState( + session: Match3DAgentSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue( + session?.draft?.profileId ?? session?.publishedProfileId, + ); + return { + sessionId, + profileId, + workId: profileId, + }; +} + +function buildSquareHoleCreationUrlState( + session: SquareHoleSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue( + session?.draft?.profileId ?? session?.publishedProfileId, + ); + return { + sessionId, + profileId, + workId: profileId, + }; +} + +function buildPuzzleCreationUrlState( + session: PuzzleAgentSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue( + session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId), + ); + return { + sessionId, + profileId, + workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null, + }; +} + +function buildPuzzleDraftRuntimeUrlState( + item: PuzzleWorkSummary, + levelId?: string | null, +): PuzzleRuntimeUrlState { + const runtimeSessionId = + normalizeCreationUrlValue(item.sourceSessionId) ?? + buildPuzzleSessionIdFromProfileId(item.profileId); + + return { + mode: 'draft', + runtimeSessionId, + runtimeProfileId: normalizeCreationUrlValue(item.profileId), + runtimeLevelId: normalizeCreationUrlValue(levelId), + }; +} + +function buildPuzzlePublishedRuntimeUrlState( + item: PuzzleWorkSummary, + levelId?: string | null, +): PuzzleRuntimeUrlState { + return { + mode: 'published', + runtimeProfileId: normalizeCreationUrlValue(item.profileId), + runtimeLevelId: normalizeCreationUrlValue(levelId), + publicWorkCode: buildPuzzlePublicWorkCode(item.profileId), + }; +} + +function openPuzzleRuntimeStage( + setSelectionStage: (stage: SelectionStage) => void, + state: PuzzleRuntimeUrlState, +) { + pushAppHistoryPath('/runtime/puzzle'); + setSelectionStage('puzzle-runtime'); + writePuzzleRuntimeUrlState(state); +} + +function buildPuzzleRuntimeWorkFromSession( + session: PuzzleAgentSessionSnapshot, + owner: { userId?: string | null; displayName?: string | null }, +): PuzzleWorkSummary | null { + const draft = session.draft; + const profileId = + session.publishedProfileId ?? buildPuzzleResultProfileId(session.sessionId); + if (!draft || !profileId || !draft.coverImageSrc?.trim()) { + return null; + } + + return { + workId: buildPuzzleResultWorkId(session.sessionId) ?? profileId, + profileId, + ownerUserId: owner.userId ?? 'current-user', + sourceSessionId: session.sessionId, + authorDisplayName: owner.displayName ?? '玩家', + workTitle: draft.workTitle, + workDescription: draft.workDescription, + levelName: draft.levelName, + summary: draft.summary, + themeTags: draft.themeTags, + coverImageSrc: draft.coverImageSrc, + coverAssetId: draft.coverAssetId, + publicationStatus: 'draft', + updatedAt: session.updatedAt, + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: Boolean(session.resultPreview?.publishReady), + levels: draft.levels, + }; +} + +function buildVisualNovelCreationUrlState( + session: VisualNovelAgentSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue(session?.draft?.profileId); + return { + sessionId, + profileId, + workId: profileId ?? sessionId, + }; +} + +function buildJumpHopCreationUrlState(params: { + session?: JumpHopSessionSnapshotResponse | null; + work?: JumpHopWorkProfileResponse | null; +}): CreationUrlState { + const sessionId = normalizeCreationUrlValue(params.session?.sessionId); + const profileId = normalizeCreationUrlValue( + params.work?.summary.profileId ?? params.session?.draft?.profileId, + ); + return { + sessionId, + profileId, + workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), + }; +} + +function buildWoodenFishCreationUrlState(params: { + session?: WoodenFishSessionSnapshotResponse | null; + work?: WoodenFishWorkProfileResponse | null; +}): CreationUrlState { + const sessionId = normalizeCreationUrlValue(params.session?.sessionId); + const profileId = normalizeCreationUrlValue( + params.work?.summary.profileId ?? params.session?.draft?.profileId, + ); + return { + sessionId, + profileId, + workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), + }; +} + +function buildBarkBattleCreationUrlState( + draft: BarkBattleDraftConfig | null, +): CreationUrlState { + return { + draftId: normalizeCreationUrlValue(draft?.draftId), + workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId), + }; +} + +function buildBabyObjectMatchCreationUrlState( + draft: BabyObjectMatchDraft | null, +): CreationUrlState { + const profileId = normalizeCreationUrlValue(draft?.profileId); + return { + profileId, + draftId: normalizeCreationUrlValue(draft?.draftId), + workId: profileId, + }; +} + function buildDraftNoticeKey(kind: CreationWorkShelfKind, id: string) { return `${kind}:${id}`; } @@ -1743,30 +2021,120 @@ function createPendingDraftShelfState( }; } -function parseDraftGenerationStartedAtMs(value: string | null | undefined) { - const parsedMs = value ? Date.parse(value) : Number.NaN; - return Number.isFinite(parsedMs) ? parsedMs : Date.now(); -} - -function createMiniGameDraftGenerationStateFromStartedAt( +function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, - startedAtMs: number, metadata?: MiniGameDraftGenerationState['metadata'], ): MiniGameDraftGenerationState { return { ...createMiniGameDraftGenerationState(kind), - startedAtMs, ...(metadata ? { metadata } : {}), }; } +function rebaseMiniGameDraftGenerationStateForDisplay( + state: MiniGameDraftGenerationState, +): MiniGameDraftGenerationState { + const rebasedStartedAtMs = Date.now(); + + if (state.kind === 'puzzle') { + const puzzleAiRedraw = state.metadata?.puzzleAiRedraw; + return { + ...state, + startedAtMs: rebasedStartedAtMs, + finishedAtMs: undefined, + metadata: + typeof puzzleAiRedraw === 'boolean' ? { puzzleAiRedraw } : undefined, + }; + } + + return { + ...state, + startedAtMs: rebasedStartedAtMs, + finishedAtMs: undefined, + }; +} + +function rebaseMiniGameDraftBackgroundCompileTaskForDisplay< + T extends PuzzleBackgroundCompileTask | Match3DBackgroundCompileTask, +>( + task: T, +): T { + return { + ...task, + generationState: rebaseMiniGameDraftGenerationStateForDisplay( + task.generationState, + ), + }; +} + function createPuzzleDraftGenerationStateFromPayload( payload: CreatePuzzleAgentSessionRequest | null | undefined, + session: PuzzleAgentSessionSnapshot | null | undefined = null, ): MiniGameDraftGenerationState { + const puzzleProgressPercent = + session?.draft && !session.draft.formDraft + ? session.progressPercent + : undefined; + return { ...createMiniGameDraftGenerationState('puzzle'), metadata: { puzzleAiRedraw: payload?.aiRedraw ?? true, + puzzleActivePhaseId: + typeof puzzleProgressPercent === 'number' ? 'compile' : undefined, + puzzleActiveStepStartedAtMs: + typeof puzzleProgressPercent === 'number' ? Date.now() : undefined, + puzzleProgressPercent, + }, + }; +} + +function resolvePuzzlePhaseFromSessionProgress( + state: MiniGameDraftGenerationState, + session: PuzzleAgentSessionSnapshot, +): MiniGameDraftGenerationPhase { + if (session.progressPercent >= 96) { + return 'puzzle-select-image'; + } + if (session.progressPercent >= 94) { + return 'puzzle-ui-assets'; + } + if (session.progressPercent >= 88) { + return state.metadata?.puzzleAiRedraw === false + ? 'puzzle-level-scene' + : 'puzzle-cover-image'; + } + + return 'compile'; +} + +function mergePuzzleSessionProgressIntoGenerationState( + state: MiniGameDraftGenerationState, + session: PuzzleAgentSessionSnapshot, +): MiniGameDraftGenerationState { + const isCompiledGenerationSession = Boolean( + session.draft && !session.draft.formDraft, + ); + + const nextPhaseId = isCompiledGenerationSession + ? resolvePuzzlePhaseFromSessionProgress(state, session) + : state.metadata?.puzzleActivePhaseId; + const shouldResetActiveStepStart = + isCompiledGenerationSession && + nextPhaseId != null && + nextPhaseId !== state.metadata?.puzzleActivePhaseId; + + return { + ...state, + metadata: { + ...state.metadata, + puzzleActivePhaseId: nextPhaseId, + puzzleActiveStepStartedAtMs: shouldResetActiveStepStart + ? Date.now() + : state.metadata?.puzzleActiveStepStartedAtMs, + puzzleProgressPercent: isCompiledGenerationSession + ? session.progressPercent + : state.metadata?.puzzleProgressPercent, }, }; } @@ -1862,7 +2230,7 @@ function hasRecoverableGeneratedPuzzleDraft( ); } -function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) { +function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] { switch (item.source.kind) { case 'rpg': return collectDraftNoticeKeys('rpg', [ @@ -1891,6 +2259,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, @@ -1976,6 +2351,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[], @@ -2585,7 +2993,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] = @@ -2646,6 +3059,9 @@ export function PlatformEntryFlowShellImpl({ const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState< JumpHopGalleryCardResponse[] >([]); + const [jumpHopWorks, setJumpHopWorks] = useState< + JumpHopWorkSummaryResponse[] + >([]); const [jumpHopRuntimeReturnStage, setJumpHopRuntimeReturnStage] = useState('jump-hop-result'); const [jumpHopGenerationState, setJumpHopGenerationState] = @@ -2759,8 +3175,6 @@ export function PlatformEntryFlowShellImpl({ useState(() => Date.now()); const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] = useState(null); - const [activeCreationFormType, setActiveCreationFormType] = - useState('puzzle'); const [puzzleOnboardingPrompt, setPuzzleOnboardingPrompt] = useState(''); const [puzzleOnboardingPhase, setPuzzleOnboardingPhase] = useState('input'); @@ -2864,6 +3278,10 @@ export function PlatformEntryFlowShellImpl({ creationEntryTypes, 'big-fish', ); + const isJumpHopCreationVisible = isPlatformCreationTypeVisible( + creationEntryTypes, + 'jump-hop', + ); const isSquareHoleCreationVisible = isPlatformCreationTypeVisible( creationEntryTypes, 'square-hole', @@ -2897,6 +3315,12 @@ export function PlatformEntryFlowShellImpl({ useState({}); const [pendingDraftShelfItems, setPendingDraftShelfItems] = useState({}); + const [initialCreationUrlState] = useState(() => readCreationUrlState()); + const handledInitialCreationUrlStateRef = useRef(false); + const [initialPuzzleRuntimeUrlState] = useState(() => + readPuzzleRuntimeUrlState(), + ); + const handledPuzzleRuntimeUrlStateKeyRef = useRef(null); useEffect(() => { selectionStageRef.current = selectionStage; @@ -3172,7 +3596,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, @@ -3180,25 +3604,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( @@ -3221,6 +3646,11 @@ export function PlatformEntryFlowShellImpl({ resolveRpgCreationErrorMessage(error, fallback), [], ); + const resolveBarkBattleErrorMessage = useCallback( + (error: unknown, fallback: string) => + resolveRpgCreationErrorMessage(error, fallback), + [], + ); const refreshBigFishShelf = useCallback(async () => { setIsBigFishLoadingLibrary(true); @@ -3320,6 +3750,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(); @@ -3529,6 +3975,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, @@ -3884,6 +4346,16 @@ export function PlatformEntryFlowShellImpl({ ], [bigFishWorks, pendingDraftShelfItems], ); + const jumpHopShelfItems = useMemo( + () => [ + ...buildPendingJumpHopWorks( + pendingDraftShelfItems['jump-hop'], + jumpHopWorks, + ), + ...jumpHopWorks, + ], + [jumpHopWorks, pendingDraftShelfItems], + ); const match3dShelfItems = useMemo( () => [ ...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks), @@ -3959,6 +4431,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, @@ -4001,6 +4480,7 @@ export function PlatformEntryFlowShellImpl({ babyObjectMatchDrafts, barkBattleShelfItems, bigFishShelfItems, + jumpHopShelfItems, creationHubItems, isSquareHoleCreationVisible, match3dShelfItems, @@ -4112,6 +4592,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleOnboardingPhase('input'); platformBootstrap.setPlatformTab('home'); setSelectionStage('platform'); + clearPuzzleRuntimeUrlState(); void refreshPuzzleShelf(); } catch (error) { setPuzzleOnboardingError( @@ -4149,6 +4630,7 @@ export function PlatformEntryFlowShellImpl({ setSelectedPuzzleDetail(null); platformBootstrap.setPlatformTab(authUi?.user ? 'home' : 'category'); setSelectionStage('platform'); + clearPuzzleRuntimeUrlState(); }, [authUi?.user, platformBootstrap, setSelectionStage]); useEffect(() => { @@ -4201,7 +4683,10 @@ export function PlatformEntryFlowShellImpl({ setPuzzleRun(startLocalPuzzleRun(item)); setPuzzleRuntimeAuthMode('default'); setPuzzleRuntimeReturnStage('platform'); - setSelectionStage('puzzle-runtime'); + openPuzzleRuntimeStage( + setSelectionStage, + buildPuzzleDraftRuntimeUrlState(item, null), + ); }, PUZZLE_ONBOARDING_GENERATED_DELAY_MS); } catch (error) { setPuzzleOnboardingPhase('input'); @@ -4301,6 +4786,9 @@ export function PlatformEntryFlowShellImpl({ onSessionOpened: () => { setShowCreationTypeModal(false); }, + onSessionChanged: (session) => { + writeCreationUrlState(buildBigFishCreationUrlState(session)); + }, onActionComplete: ({ payload, response, setSession }) => { setSession(response.session); if (payload.action === 'big_fish_publish_game') { @@ -4414,9 +4902,11 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab, setSelectionStage, onSessionOpened: () => { - setActiveCreationFormType('match3d'); setShowCreationTypeModal(false); }, + onSessionChanged: (session) => { + writeCreationUrlState(buildMatch3DCreationUrlState(session)); + }, onActionComplete: async ({ payload, response, setSession }) => { setSession(response.session); if (payload.action !== 'match3d_compile_draft') { @@ -4563,6 +5053,9 @@ export function PlatformEntryFlowShellImpl({ onSessionOpened: () => { setShowCreationTypeModal(false); }, + onSessionChanged: (session) => { + writeCreationUrlState(buildSquareHoleCreationUrlState(session)); + }, beforeExecuteAction: ({ payload, session }) => { if (payload.action === 'square_hole_compile_draft') { markDraftGenerating('square-hole', [ @@ -4813,11 +5306,13 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab, setSelectionStage, onSessionOpened: () => { - setActiveCreationFormType('puzzle'); sessionController.setCreationTypeError(null); setPuzzleCreationError(null); setShowCreationTypeModal(false); }, + onSessionChanged: (session) => { + writeCreationUrlState(buildPuzzleCreationUrlState(session)); + }, onOpenError: ({ errorMessage }) => { sessionController.setCreationTypeError(errorMessage); setPuzzleCreationError(errorMessage); @@ -4895,7 +5390,10 @@ export function PlatformEntryFlowShellImpl({ setPuzzleRun(run); setPuzzleRuntimeAuthMode('default'); setPuzzleRuntimeReturnStage('puzzle-result'); - setSelectionStage('puzzle-runtime'); + openPuzzleRuntimeStage( + setSelectionStage, + buildPuzzleDraftRuntimeUrlState(item, null), + ); } catch (error) { setPuzzleError( resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'), @@ -4951,9 +5449,11 @@ export function PlatformEntryFlowShellImpl({ ]); markPendingDraftGenerating('puzzle', session.sessionId); selectionStageRef.current = 'puzzle-generating'; + activePuzzleGenerationSessionIdRef.current = session.sessionId; setSelectionStage('puzzle-generating'); const nextGenerationState = createPuzzleDraftGenerationStateFromPayload( formPayload ?? buildPuzzleFormPayloadFromSession(session), + session, ); setPuzzleGenerationState(nextGenerationState); setPuzzleBackgroundCompileTasks((current) => ({ @@ -4977,7 +5477,7 @@ export function PlatformEntryFlowShellImpl({ const generationState = puzzleBackgroundCompileTasks[session.sessionId]?.generationState ?? puzzleGenerationState ?? - createPuzzleDraftGenerationStateFromPayload(formPayload); + createPuzzleDraftGenerationStateFromPayload(formPayload, session); const recovered = await recoverCompletedPuzzleDraftGeneration({ sessionId: session.sessionId, payload: formPayload, @@ -5044,6 +5544,9 @@ export function PlatformEntryFlowShellImpl({ onSessionOpened: () => { setShowCreationTypeModal(false); }, + onSessionChanged: (session) => { + writeCreationUrlState(buildVisualNovelCreationUrlState(session)); + }, onActionComplete: ({ response, setSession }) => { setSession(response.session); const openResult = @@ -5089,6 +5592,7 @@ export function PlatformEntryFlowShellImpl({ const puzzleSession = puzzleFlow.session; const puzzleError = puzzleFlow.error; + const setPuzzleSession = puzzleFlow.setSession; const setPuzzleError = puzzleFlow.setError; puzzleErrorSetterRef.current = setPuzzleError; const isPuzzleBusy = puzzleFlow.isBusy; @@ -5114,30 +5618,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, @@ -5237,7 +5738,10 @@ export function PlatformEntryFlowShellImpl({ setPuzzleRun(run); setPuzzleRuntimeAuthMode('default'); setPuzzleRuntimeReturnStage('puzzle-result'); - setSelectionStage('puzzle-runtime'); + openPuzzleRuntimeStage( + setSelectionStage, + buildPuzzleDraftRuntimeUrlState(item, null), + ); } catch (error) { puzzleErrorSetterRef.current( resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'), @@ -5302,6 +5806,73 @@ export function PlatformEntryFlowShellImpl({ isMiniGameDraftGenerating( activePuzzleBackgroundCompileTask?.generationState ?? null, ); + const shouldPollPuzzleGenerationSession = + selectionStage === 'puzzle-generating' && + activePuzzleGenerationSessionId != null && + isMiniGameDraftGenerating(puzzleGenerationViewState); + + useEffect(() => { + if (!shouldPollPuzzleGenerationSession || !activePuzzleGenerationSessionId) { + return undefined; + } + + let isDisposed = false; + + const pollPuzzleDraftGenerationSession = async () => { + try { + const { session: latestSession } = await getPuzzleAgentSession( + activePuzzleGenerationSessionId, + ); + if (isDisposed) { + return; + } + + setPuzzleSession(latestSession); + setPuzzleBackgroundCompileTasks((current) => { + const task = current[activePuzzleGenerationSessionId]; + if (!task) { + return current; + } + + return { + ...current, + [activePuzzleGenerationSessionId]: { + ...task, + session: latestSession, + generationState: mergePuzzleSessionProgressIntoGenerationState( + task.generationState, + latestSession, + ), + }, + }; + }); + setPuzzleGenerationState((current) => + current + ? mergePuzzleSessionProgressIntoGenerationState( + current, + latestSession, + ) + : current, + ); + } catch { + // 中文注释:拼图长 action 仍以主请求结果为准;轮询失败只保留当前进度展示。 + } + }; + + void pollPuzzleDraftGenerationSession(); + const timerId = window.setInterval(() => { + void pollPuzzleDraftGenerationSession(); + }, 3_000); + + return () => { + isDisposed = true; + window.clearInterval(timerId); + }; + }, [ + activePuzzleGenerationSessionId, + shouldPollPuzzleGenerationSession, + setPuzzleSession, + ]); const match3DGeneratingSessionId = selectionStage === 'match3d-generating' ? match3dSession?.sessionId : null; @@ -5397,6 +5968,103 @@ export function PlatformEntryFlowShellImpl({ squareHoleFlow, ]); + const openMatch3DWorkspace = useCallback(() => { + setMatch3DRun(null); + setMatch3DProfile(null); + setMatch3DRuntimeProfile(null); + setMatch3DGenerationState(null); + setMatch3DError(null); + setStreamingMatch3DReplyText(''); + setIsStreamingMatch3DReply(false); + enterCreateTab(); + setShowCreationTypeModal(false); + setSelectionStage('match3d-agent-workspace'); + }, [ + enterCreateTab, + setIsStreamingMatch3DReply, + setMatch3DError, + setSelectionStage, + setStreamingMatch3DReplyText, + ]); + + const openJumpHopWorkspace = useCallback(() => { + setJumpHopError(null); + setJumpHopSession(null); + setJumpHopWork(null); + setJumpHopRun(null); + setJumpHopGenerationState(null); + enterCreateTab(); + setShowCreationTypeModal(false); + setSelectionStage('jump-hop-workspace'); + }, [enterCreateTab, setSelectionStage]); + + const openWoodenFishWorkspace = useCallback(() => { + setWoodenFishError(null); + setWoodenFishSession(null); + setWoodenFishWork(null); + setWoodenFishRun(null); + setWoodenFishGenerationState(null); + enterCreateTab(); + setShowCreationTypeModal(false); + setSelectionStage('wooden-fish-workspace'); + }, [enterCreateTab, setSelectionStage]); + + const openPuzzleWorkspace = useCallback(() => { + enterCreateTab(); + setShowCreationTypeModal(false); + setPuzzleCreationError(null); + setPuzzleError(null); + setSelectionStage('puzzle-agent-workspace'); + }, [ + enterCreateTab, + setPuzzleCreationError, + setPuzzleError, + setSelectionStage, + ]); + + const openBarkBattleWorkspace = useCallback(() => { + setBarkBattleDraftConfig(null); + setBarkBattlePublishedConfig(null); + setBarkBattleRuntimeMode('draft'); + setBarkBattleRuntimeReturnStage('platform'); + setBarkBattleError(null); + setBarkBattleGenerationPartialFailed(false); + setIsBarkBattleBusy(false); + enterCreateTab(); + setShowCreationTypeModal(false); + selectionStageRef.current = 'bark-battle-workspace'; + setSelectionStage('bark-battle-workspace'); + }, [enterCreateTab, setSelectionStage]); + + const openVisualNovelWorkspace = useCallback(() => { + enterCreateTab(); + setShowCreationTypeModal(false); + setVisualNovelError(null); + setSelectionStage('visual-novel-agent-workspace'); + }, [ + enterCreateTab, + setSelectionStage, + setVisualNovelError, + ]); + + const openBabyObjectMatchWorkspace = useCallback(() => { + if (!isBabyObjectMatchVisible) { + sessionController.setCreationTypeError(EDUTAINMENT_HIDDEN_MESSAGE); + return; + } + + enterCreateTab(); + setShowCreationTypeModal(false); + setBabyObjectMatchError(null); + setSelectionStage('baby-object-match-workspace'); + }, [ + enterCreateTab, + isBabyObjectMatchVisible, + sessionController, + setBabyObjectMatchError, + setSelectionStage, + ]); + const leaveCreativeAgentWorkspace = useCallback(() => { const sessionId = creativeAgentSession?.sessionId?.trim(); if (sessionId && creativeAgentSession?.stage !== 'target_ready') { @@ -5479,7 +6147,10 @@ export function PlatformEntryFlowShellImpl({ return; } - const generationState = createPuzzleDraftGenerationStateFromPayload(payload); + const generationState = createPuzzleDraftGenerationStateFromPayload( + payload, + nextSession, + ); setPuzzleBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -5579,7 +6250,10 @@ export function PlatformEntryFlowShellImpl({ setPuzzleRun(run); setPuzzleRuntimeAuthMode('default'); setPuzzleRuntimeReturnStage('puzzle-result'); - setSelectionStage('puzzle-runtime'); + openPuzzleRuntimeStage( + setSelectionStage, + buildPuzzleDraftRuntimeUrlState(item, null), + ); } catch (error) { setPuzzleError( resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'), @@ -5948,6 +6622,9 @@ export function PlatformEntryFlowShellImpl({ try { const response = await createBabyObjectMatchDraft(payload); setBabyObjectMatchDraft(response.draft); + writeCreationUrlState( + buildBabyObjectMatchCreationUrlState(response.draft), + ); void refreshBabyObjectMatchShelf(); setBabyObjectMatchGenerationPhase('ready'); setBabyObjectMatchGenerationState((current) => @@ -6063,7 +6740,6 @@ export function PlatformEntryFlowShellImpl({ setMatch3DFormDraftPayload(null); setMatch3DBackgroundCompileTasks({}); activeMatch3DGenerationSessionIdRef.current = null; - setActiveCreationFormType('puzzle'); setMatch3DWorks([]); setMatch3DGalleryEntries([]); setMatch3DRun(null); @@ -6138,6 +6814,16 @@ export function PlatformEntryFlowShellImpl({ selectionStage !== 'platform' && selectionStage !== 'work-detail' && selectionStage !== 'detail' && + selectionStage !== 'agent-workspace' && + selectionStage !== 'big-fish-agent-workspace' && + selectionStage !== 'match3d-agent-workspace' && + selectionStage !== 'square-hole-agent-workspace' && + selectionStage !== 'jump-hop-workspace' && + selectionStage !== 'wooden-fish-workspace' && + selectionStage !== 'puzzle-agent-workspace' && + selectionStage !== 'bark-battle-workspace' && + selectionStage !== 'visual-novel-agent-workspace' && + selectionStage !== 'baby-object-match-workspace' && selectionStage !== 'creative-agent-workspace' && selectionStage !== 'puzzle-gallery-detail' ) { @@ -6150,7 +6836,6 @@ export function PlatformEntryFlowShellImpl({ resetAutoSaveTrackingToIdle, resetRpgSessionViewState, selectionStage, - setActiveCreationFormType, setBigFishError, setIsStreamingMatch3DReply, setIsStreamingSquareHoleReply, @@ -6179,6 +6864,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (type === 'baby-object-match' && !isBabyObjectMatchVisible) { + sessionController.setCreationTypeError(EDUTAINMENT_HIDDEN_MESSAGE); + return; + } + if (type === 'rpg') { runProtectedAction(() => { void sessionController.openRpgAgentWorkspace(); @@ -6194,10 +6884,9 @@ export function PlatformEntryFlowShellImpl({ } if (type === 'match3d') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('match3d'); - setMatch3DError(null); + runProtectedAction(() => { + void openMatch3DWorkspace(); + }); return; } @@ -6209,91 +6898,60 @@ export function PlatformEntryFlowShellImpl({ } if (type === 'jump-hop') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('jump-hop'); - setJumpHopError(null); - setJumpHopSession(null); - setJumpHopWork(null); - setJumpHopRun(null); - setJumpHopGenerationState(null); + runProtectedAction(() => { + void openJumpHopWorkspace(); + }); return; } if (type === 'wooden-fish') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('wooden-fish'); - setWoodenFishError(null); - setWoodenFishSession(null); - setWoodenFishWork(null); - setWoodenFishRun(null); - setWoodenFishGenerationState(null); + runProtectedAction(() => { + void openWoodenFishWorkspace(); + }); return; } if (type === 'puzzle') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('puzzle'); - setPuzzleCreationError(null); - setPuzzleError(null); + runProtectedAction(() => { + void openPuzzleWorkspace(); + }); return; } if (type === 'bark-battle') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('bark-battle'); - setBarkBattleError(null); + runProtectedAction(() => { + void openBarkBattleWorkspace(); + }); return; } if (type === 'visual-novel') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('visual-novel'); - setVisualNovelError(null); + runProtectedAction(() => { + void openVisualNovelWorkspace(); + }); return; } if (type === 'baby-object-match') { - if (!isBabyObjectMatchVisible) { - sessionController.setCreationTypeError(EDUTAINMENT_HIDDEN_MESSAGE); - return; - } - - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('baby-object-match'); - setBabyObjectMatchError(null); - return; + runProtectedAction(() => { + void openBabyObjectMatchWorkspace(); + }); } }, [ - openBigFishAgentWorkspace, - enterCreateTab, isBabyObjectMatchVisible, - openSquareHoleAgentWorkspace, + openBarkBattleWorkspace, + openBigFishAgentWorkspace, + openBabyObjectMatchWorkspace, + openJumpHopWorkspace, + openMatch3DWorkspace, prepareCreationLaunch, - runProtectedAction, + openPuzzleWorkspace, + openSquareHoleAgentWorkspace, + openVisualNovelWorkspace, + openWoodenFishWorkspace, sessionController, - setActiveCreationFormType, - setBarkBattleError, - setMatch3DError, - setPuzzleCreationError, - setPuzzleError, - setJumpHopError, - setJumpHopGenerationState, - setJumpHopRun, - setJumpHopSession, - setJumpHopWork, - setWoodenFishError, - setWoodenFishGenerationState, - setWoodenFishRun, - setWoodenFishSession, - setWoodenFishWork, - setVisualNovelError, + runProtectedAction, ], ); @@ -6303,6 +6961,7 @@ export function PlatformEntryFlowShellImpl({ setBigFishRuntimeStartedAt(null); setBigFishRuntimeReturnStage('platform'); setBigFishGenerationState(null); + clearCreationUrlState(); bigFishFlow.leaveFlow(); }, [bigFishFlow]); @@ -6312,6 +6971,7 @@ export function PlatformEntryFlowShellImpl({ setMatch3DFormDraftPayload(null); setMatch3DGenerationState(null); setMatch3DRuntimeReturnStage('match3d-result'); + clearCreationUrlState(); match3dFlow.leaveFlow(); }, [match3dFlow, setMatch3DFormDraftPayload]); @@ -6319,6 +6979,7 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleRun(null); setSquareHoleRuntimeReturnStage('square-hole-result'); setSquareHoleGenerationState(null); + clearCreationUrlState(); squareHoleFlow.leaveFlow(); }, [squareHoleFlow]); @@ -6329,6 +6990,7 @@ export function PlatformEntryFlowShellImpl({ setJumpHopGenerationState(null); setJumpHopSession(null); setJumpHopError(null); + clearCreationUrlState(); setSelectionStage('platform'); }, [setSelectionStage]); @@ -6339,6 +7001,7 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishGenerationState(null); setWoodenFishSession(null); setWoodenFishError(null); + clearCreationUrlState(); setSelectionStage('platform'); }, [setSelectionStage]); @@ -6368,6 +7031,7 @@ export function PlatformEntryFlowShellImpl({ setBarkBattleError(null); setBarkBattleGenerationPartialFailed(false); setIsBarkBattleBusy(false); + clearCreationUrlState(); selectionStageRef.current = 'platform'; setSelectionStage('platform'); }, [setSelectionStage]); @@ -6384,6 +7048,7 @@ export function PlatformEntryFlowShellImpl({ try { const draft = await createBarkBattleDraft(payload); setBarkBattleDraftConfig(draft); + writeCreationUrlState(buildBarkBattleCreationUrlState(draft)); setBarkBattlePublishedConfig(null); markDraftGenerating('bark-battle', [draft.workId, draft.draftId]); markPendingDraftGenerating('bark-battle', draft.workId ?? draft.draftId); @@ -6420,6 +7085,7 @@ export function PlatformEntryFlowShellImpl({ const handleBarkBattleGenerationComplete = useCallback( (draft: BarkBattleDraftConfig, partialFailed: boolean) => { setBarkBattleDraftConfig(draft); + writeCreationUrlState(buildBarkBattleCreationUrlState(draft)); setBarkBattlePublishedConfig(null); setBarkBattleGenerationPartialFailed(partialFailed); const generationStatus = resolveBarkBattleDraftGenerationStatus( @@ -6485,6 +7151,7 @@ export function PlatformEntryFlowShellImpl({ draft.uiBackgroundImageSrc ?? persistedDraft.uiBackgroundImageSrc, }; setBarkBattleDraftConfig(nextDraft); + writeCreationUrlState(buildBarkBattleCreationUrlState(nextDraft)); updateBarkBattleWorkCaches( buildBarkBattleWorkSummaryFromDraft( nextDraft, @@ -6648,6 +7315,8 @@ export function PlatformEntryFlowShellImpl({ setActiveCreativeAgentSessionId(null); setCreativeDraftEditError(null); resetRecommendRuntimeSelection(); + clearCreationUrlState(); + clearPuzzleRuntimeUrlState(); puzzleFlow.leaveFlow(); }, [puzzleFlow, resetRecommendRuntimeSelection]); @@ -6658,6 +7327,7 @@ export function PlatformEntryFlowShellImpl({ setVisualNovelFormDraftPayload(null); setVisualNovelGenerationStartedAtMs(null); setVisualNovelGenerationPhase('generating'); + clearCreationUrlState(); visualNovelFlow.leaveFlow(); }, [visualNovelFlow]); @@ -6667,6 +7337,7 @@ export function PlatformEntryFlowShellImpl({ setBabyObjectMatchGenerationState(null); setBabyObjectMatchGenerationPhase('generating'); setBabyObjectMatchError(null); + clearCreationUrlState(); enterCreateTab(); selectionStageRef.current = 'platform'; setSelectionStage('platform'); @@ -6679,6 +7350,9 @@ export function PlatformEntryFlowShellImpl({ try { const response = await saveBabyObjectMatchDraft({ draft }); setBabyObjectMatchDraft(response.draft); + writeCreationUrlState( + buildBabyObjectMatchCreationUrlState(response.draft), + ); void refreshBabyObjectMatchShelf(); } catch (error) { setBabyObjectMatchError( @@ -7076,11 +7750,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); @@ -7106,6 +7784,7 @@ export function PlatformEntryFlowShellImpl({ } }, [ + authUi, resolvePuzzleErrorMessage, setIsVisualNovelBusy, setSelectionStage, @@ -7127,9 +7806,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) { @@ -7141,6 +7825,8 @@ export function PlatformEntryFlowShellImpl({ } }, [ + activeRecommendRuntimeKind, + authUi, isVisualNovelBusy, resolvePuzzleErrorMessage, setIsVisualNovelBusy, @@ -7318,6 +8004,9 @@ export function PlatformEntryFlowShellImpl({ const generationState = createMiniGameDraftGenerationState('jump-hop'); setJumpHopError(null); setJumpHopSession(created.session); + writeCreationUrlState( + buildJumpHopCreationUrlState({ session: created.session }), + ); setJumpHopWork(null); setJumpHopRun(null); setJumpHopGenerationState(generationState); @@ -7350,7 +8039,29 @@ export function PlatformEntryFlowShellImpl({ const readyState = createReadyJumpHopGenerationState(generationState); setJumpHopSession(response.session); setJumpHopWork(response.work ?? null); + writeCreationUrlState( + buildJumpHopCreationUrlState({ + session: response.session, + work: response.work, + }), + ); 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( @@ -7371,9 +8082,15 @@ export function PlatformEntryFlowShellImpl({ ); setJumpHopSession(latest.session); setJumpHopWork(null); + writeCreationUrlState( + buildJumpHopCreationUrlState({ session: latest.session }), + ); } catch { setJumpHopSession(created.session); setJumpHopWork(null); + writeCreationUrlState( + buildJumpHopCreationUrlState({ session: created.session }), + ); } } finally { setIsJumpHopBusy(false); @@ -7420,6 +8137,12 @@ export function PlatformEntryFlowShellImpl({ ); setJumpHopSession(response.session); setJumpHopWork(response.work ?? jumpHopWork); + writeCreationUrlState( + buildJumpHopCreationUrlState({ + session: response.session, + work: response.work ?? jumpHopWork, + }), + ); setJumpHopGenerationState( createReadyJumpHopGenerationState(generationState), ); @@ -7465,6 +8188,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( @@ -7529,12 +8256,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); @@ -7559,7 +8287,7 @@ export function PlatformEntryFlowShellImpl({ setIsJumpHopBusy(false); } }, - [setSelectionStage], + [authUi, setSelectionStage], ); const restartJumpHopRuntimeRun = useCallback(async () => { @@ -7609,6 +8337,9 @@ export function PlatformEntryFlowShellImpl({ const generationState = createMiniGameDraftGenerationState('wooden-fish'); setWoodenFishError(null); setWoodenFishSession(created.session); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ session: created.session }), + ); setWoodenFishWork(null); setWoodenFishRun(null); setWoodenFishGenerationState(generationState); @@ -7639,6 +8370,12 @@ export function PlatformEntryFlowShellImpl({ ); setWoodenFishSession(response.session); setWoodenFishWork(response.work ?? null); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ + session: response.session, + work: response.work, + }), + ); setWoodenFishGenerationState( createReadyWoodenFishGenerationState(generationState), ); @@ -7662,9 +8399,15 @@ export function PlatformEntryFlowShellImpl({ ); setWoodenFishSession(latest.session); setWoodenFishWork(null); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ session: latest.session }), + ); } catch { setWoodenFishSession(created.session); setWoodenFishWork(null); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ session: created.session }), + ); } } finally { setIsWoodenFishBusy(false); @@ -7711,6 +8454,12 @@ export function PlatformEntryFlowShellImpl({ ); setWoodenFishSession(response.session); setWoodenFishWork(response.work ?? woodenFishWork); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ + session: response.session, + work: response.work ?? woodenFishWork, + }), + ); setWoodenFishGenerationState( createReadyWoodenFishGenerationState(generationState), ); @@ -7770,6 +8519,12 @@ export function PlatformEntryFlowShellImpl({ }); setWoodenFishSession(response.session); setWoodenFishWork(response.work ?? woodenFishWork); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ + session: response.session, + work: response.work ?? woodenFishWork, + }), + ); return true; } catch (error) { setWoodenFishError( @@ -7866,9 +8621,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); @@ -7893,7 +8654,7 @@ export function PlatformEntryFlowShellImpl({ setIsWoodenFishBusy(false); } }, - [setSelectionStage], + [authUi, setSelectionStage], ); const checkpointWoodenFishRuntimeRun = useCallback( @@ -8115,7 +8876,12 @@ export function PlatformEntryFlowShellImpl({ if (selectionStage === 'big-fish-runtime' && !bigFishRun) { setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform'); } - }, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]); + }, [ + bigFishRun, + bigFishSession, + selectionStage, + setSelectionStage, + ]); useEffect(() => { if (selectionStage === 'match3d-result' && !match3dSession?.draft) { @@ -8126,7 +8892,12 @@ export function PlatformEntryFlowShellImpl({ if (selectionStage === 'match3d-runtime' && !match3dRun) { setSelectionStage(match3dSession?.draft ? 'match3d-result' : 'platform'); } - }, [match3dRun, match3dSession, selectionStage, setSelectionStage]); + }, [ + match3dRun, + match3dSession, + selectionStage, + setSelectionStage, + ]); useEffect(() => { if (selectionStage === 'square-hole-result' && !squareHoleSession?.draft) { @@ -8139,7 +8910,12 @@ export function PlatformEntryFlowShellImpl({ squareHoleSession?.draft ? 'square-hole-result' : 'platform', ); } - }, [selectionStage, setSelectionStage, squareHoleRun, squareHoleSession]); + }, [ + selectionStage, + setSelectionStage, + squareHoleRun, + squareHoleSession, + ]); useEffect(() => { if ( @@ -8296,27 +9072,33 @@ 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); setPuzzleRuntimeReturnStage(returnStage); if (!options.embedded) { - setSelectionStage('puzzle-runtime'); - pushAppHistoryPath( - buildPublicWorkStagePath( - 'puzzle-runtime', - buildPuzzlePublicWorkCode(item.profileId), - ), + openPuzzleRuntimeStage( + setSelectionStage, + authMode === 'isolated' + ? buildPuzzlePublishedRuntimeUrlState(item, levelId) + : buildPuzzleDraftRuntimeUrlState(item, levelId), ); } return true; @@ -8347,6 +9129,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isPuzzleBusy, + authUi, resolvePuzzleErrorMessage, returnPlatformHomeAfterMissingWork, setIsPuzzleBusy, @@ -8402,10 +9185,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 } : {}), @@ -8454,6 +9239,7 @@ export function PlatformEntryFlowShellImpl({ [ isMatch3DBusy, match3dDemoProfile, + authUi, match3dFlow, resolveMatch3DErrorMessage, resolveMatch3DRuntimeAdapter, @@ -8477,11 +9263,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); @@ -8513,6 +9300,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isSquareHoleBusy, + authUi, resolveSquareHoleErrorMessage, setSelectionStage, setSquareHoleError, @@ -8601,7 +9389,10 @@ export function PlatformEntryFlowShellImpl({ setPuzzleRun(run); setPuzzleRuntimeAuthMode('default'); setPuzzleRuntimeReturnStage('puzzle-result'); - setSelectionStage('puzzle-runtime'); + openPuzzleRuntimeStage( + setSelectionStage, + buildPuzzleDraftRuntimeUrlState(item, options.levelId ?? null), + ); return true; } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图试玩失败。')); @@ -8633,9 +9424,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) { @@ -8646,7 +9442,13 @@ export function PlatformEntryFlowShellImpl({ bigFishInputInFlightRef.current = false; } }, - [bigFishRun, resolveBigFishErrorMessage, setBigFishError], + [ + activeRecommendRuntimeKind, + authUi, + bigFishRun, + resolveBigFishErrorMessage, + setBigFishError, + ], ); const reportBigFishObservedPlayTime = useCallback(() => { @@ -8659,10 +9461,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) => { @@ -8672,6 +9473,7 @@ export function PlatformEntryFlowShellImpl({ }); }, [ activeRecommendRuntimeKind, + authUi, bigFishRun?.sessionId, bigFishRuntimeStartedAt, resolveBigFishErrorMessage, @@ -8764,6 +9566,152 @@ export function PlatformEntryFlowShellImpl({ return () => window.clearInterval(timerId); }, [puzzleRun, selectionStage]); + useEffect(() => { + if (selectionStage !== 'puzzle-runtime' || puzzleRun) { + return; + } + if (!hasPuzzleRuntimeUrlStateValue(initialPuzzleRuntimeUrlState)) { + return; + } + const runtimeUrlStateKey = buildPuzzleRuntimeUrlStateKey( + initialPuzzleRuntimeUrlState, + ); + if ( + handledPuzzleRuntimeUrlStateKeyRef.current === runtimeUrlStateKey || + isPuzzleBusy + ) { + return; + } + + handledPuzzleRuntimeUrlStateKeyRef.current = runtimeUrlStateKey; + let cancelled = false; + + const restorePuzzleRuntime = async () => { + const runtimeProfileId = normalizeCreationUrlValue( + initialPuzzleRuntimeUrlState.runtimeProfileId, + ); + const runtimeSessionId = normalizeCreationUrlValue( + initialPuzzleRuntimeUrlState.runtimeSessionId, + ); + const runtimeLevelId = normalizeCreationUrlValue( + initialPuzzleRuntimeUrlState.runtimeLevelId, + ); + const publicWorkCode = normalizeCreationUrlValue( + initialPuzzleRuntimeUrlState.publicWorkCode, + ); + const runtimeMode = initialPuzzleRuntimeUrlState.mode ?? null; + const isPublishedRuntime = runtimeMode === 'published'; + if ( + !isPublishedRuntime && + (platformBootstrap.isLoadingPlatform || + !platformBootstrap.canReadProtectedData) + ) { + handledPuzzleRuntimeUrlStateKeyRef.current = null; + return; + } + const fallbackStage: PuzzleRuntimeReturnStage = + runtimeMode === 'published' ? 'work-detail' : 'puzzle-result'; + + const candidateItems = isPublishedRuntime + ? puzzleGalleryEntries.length > 0 + ? puzzleGalleryEntries + : await refreshPuzzleGallery() + : ( + puzzleWorks.length > 0 + ? puzzleWorks + : (await listPuzzleWorks().catch(() => ({ items: [] }))).items + ); + const targetItem = + runtimeProfileId || runtimeSessionId || publicWorkCode + ? candidateItems.find( + (item) => + item.profileId === runtimeProfileId || + item.sourceSessionId === runtimeSessionId || + (publicWorkCode + ? isSamePuzzlePublicWorkCode(publicWorkCode, item.profileId) + : false), + ) ?? null + : null; + + if (!targetItem) { + if (runtimeSessionId) { + try { + const { session } = await getPuzzleAgentSession(runtimeSessionId); + const restoredWork = buildPuzzleRuntimeWorkFromSession(session, { + userId: authUi?.user?.id ?? 'current-user', + displayName: authUi?.user?.displayName ?? '玩家', + }); + if (restoredWork && !cancelled) { + setSelectedPuzzleDetail(restoredWork); + setPuzzleRun( + startLocalPuzzleRun(restoredWork, runtimeLevelId ?? null), + ); + setPuzzleRuntimeAuthMode('default'); + setPuzzleRuntimeReturnStage(fallbackStage); + openPuzzleRuntimeStage( + setSelectionStage, + buildPuzzleDraftRuntimeUrlState(restoredWork, runtimeLevelId), + ); + return; + } + } catch { + // 读不到草稿时继续回退到平台页,避免卡在空运行态。 + } + } + + if (!cancelled) { + setPuzzleError( + runtimeMode === 'published' + ? '这份拼图运行态缺少可恢复作品,请返回作品详情重新进入。' + : '这份拼图试玩缺少可恢复草稿,请返回结果页重新进入。', + ); + setPuzzleRuntimeAuthMode('default'); + setPuzzleRun(null); + setSelectionStage('platform'); + pushAppHistoryPath('/'); + } + return; + } + + if (cancelled) { + return; + } + + setSelectedPuzzleDetail(targetItem); + setPuzzleRun( + startLocalPuzzleRun(targetItem, runtimeLevelId ?? null), + ); + setPuzzleRuntimeAuthMode(isPublishedRuntime ? 'isolated' : 'default'); + setPuzzleRuntimeReturnStage(fallbackStage); + openPuzzleRuntimeStage( + setSelectionStage, + isPublishedRuntime + ? buildPuzzlePublishedRuntimeUrlState(targetItem, runtimeLevelId) + : buildPuzzleDraftRuntimeUrlState(targetItem, runtimeLevelId), + ); + }; + + void restorePuzzleRuntime(); + + return () => { + cancelled = true; + }; + }, [ + authUi?.user?.displayName, + authUi?.user?.id, + initialPuzzleRuntimeUrlState, + isPuzzleBusy, + platformBootstrap.canReadProtectedData, + platformBootstrap.isLoadingPlatform, + puzzleGalleryEntries, + puzzleRun, + puzzleRuntimeReturnStage, + puzzleWorks, + refreshPuzzleGallery, + selectionStage, + setSelectionStage, + ]); + const setPuzzleRuntimePaused = useCallback( async (paused: boolean) => { if (!puzzleRun?.currentLevel) { @@ -8847,12 +9795,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; @@ -8975,10 +9924,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); @@ -9035,6 +9982,10 @@ export function PlatformEntryFlowShellImpl({ return; } + const runtimeGuestOptions = + puzzleRuntimeAuthMode === 'isolated' + ? await buildRecommendRuntimeGuestOptions() + : {}; const targetProfileId = _target?.profileId?.trim() ?? ''; if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) { const itemPromise = @@ -9050,7 +10001,7 @@ export function PlatformEntryFlowShellImpl({ { targetProfileId, }, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + runtimeGuestOptions, ) : advancePuzzleNextLevel(puzzleRun.runId, { targetProfileId, @@ -9061,10 +10012,11 @@ export function PlatformEntryFlowShellImpl({ ]); setSelectedPuzzleDetail(item); setPuzzleRun(run); - pushAppHistoryPath( - buildPublicWorkStagePath( - 'puzzle-runtime', - buildPuzzlePublicWorkCode(item.profileId), + openPuzzleRuntimeStage( + setSelectionStage, + buildPuzzlePublishedRuntimeUrlState( + item, + run.currentLevel?.levelId ?? null, ), ); return; @@ -9075,7 +10027,7 @@ export function PlatformEntryFlowShellImpl({ ? await advancePuzzleNextLevel( puzzleRun.runId, {}, - PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + runtimeGuestOptions, ) : await advancePuzzleNextLevel(puzzleRun.runId); setPuzzleRun(run); @@ -10162,6 +11114,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); @@ -10348,9 +11337,24 @@ export function PlatformEntryFlowShellImpl({ item.sourceSessionId === puzzleSession?.sessionId && isMiniGameDraftGenerating(activeGenerationState) ) { + if (!activeGenerationState) { + return; + } + const rebasedGenerationState = + rebaseMiniGameDraftGenerationStateForDisplay(activeGenerationState); enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + setPuzzleGenerationState(rebasedGenerationState); + if (backgroundTask) { + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [backgroundTask.session.sessionId]: { + ...backgroundTask, + generationState: rebasedGenerationState, + }, + })); + } setSelectionStage('puzzle-generating'); return; } @@ -10359,9 +11363,15 @@ export function PlatformEntryFlowShellImpl({ backgroundTask && isMiniGameDraftGenerating(backgroundTask.generationState) ) { - puzzleFlow.setSession(backgroundTask.session); - setPuzzleFormDraftPayload(backgroundTask.payload); - setPuzzleGenerationState(backgroundTask.generationState); + const rebasedTask = + rebaseMiniGameDraftBackgroundCompileTaskForDisplay(backgroundTask); + puzzleFlow.setSession(rebasedTask.session); + setPuzzleFormDraftPayload(rebasedTask.payload); + setPuzzleGenerationState(rebasedTask.generationState); + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [rebasedTask.session.sessionId]: rebasedTask, + })); if (backgroundTask.error) { setPuzzleError(backgroundTask.error); } @@ -10377,21 +11387,32 @@ export function PlatformEntryFlowShellImpl({ const { session: latestSession } = await getPuzzleAgentSession( item.sourceSessionId, ); + const payload = buildPuzzleFormPayloadFromSession(latestSession); + const generationState = createMiniGameDraftGenerationStateForRestoredDraft( + 'puzzle', + { + puzzleAiRedraw: payload.aiRedraw ?? true, + puzzleProgressPercent: + latestSession.draft && !latestSession.draft.formDraft + ? latestSession.progressPercent + : undefined, + }, + ); puzzleFlow.setSession(latestSession); - setPuzzleFormDraftPayload( - buildPuzzleFormPayloadFromSession(latestSession), - ); + setPuzzleFormDraftPayload(payload); setPuzzleGenerationState( - createMiniGameDraftGenerationStateFromStartedAt( - 'puzzle', - parseDraftGenerationStartedAtMs(item.updatedAt), - { - puzzleAiRedraw: - buildPuzzleFormPayloadFromSession(latestSession).aiRedraw ?? - true, - }, - ), + rebaseMiniGameDraftGenerationStateForDisplay(generationState), ); + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [latestSession.sessionId]: { + session: latestSession, + payload, + generationState: + rebaseMiniGameDraftGenerationStateForDisplay(generationState), + error: null, + }, + })); enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; @@ -10521,9 +11542,24 @@ export function PlatformEntryFlowShellImpl({ item.sourceSessionId === match3dSession?.sessionId && isMiniGameDraftGenerating(activeGenerationState) ) { + if (!activeGenerationState) { + return; + } + const rebasedGenerationState = + rebaseMiniGameDraftGenerationStateForDisplay(activeGenerationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + setMatch3DGenerationState(rebasedGenerationState); + if (backgroundTask) { + setMatch3DBackgroundCompileTasks((current) => ({ + ...current, + [backgroundTask.session.sessionId]: { + ...backgroundTask, + generationState: rebasedGenerationState, + }, + })); + } setSelectionStage('match3d-generating'); return; } @@ -10532,9 +11568,15 @@ export function PlatformEntryFlowShellImpl({ backgroundTask && isMiniGameDraftGenerating(backgroundTask.generationState) ) { - setMatch3DSession(backgroundTask.session); - setMatch3DFormDraftPayload(backgroundTask.payload); - setMatch3DGenerationState(backgroundTask.generationState); + const rebasedTask = + rebaseMiniGameDraftBackgroundCompileTaskForDisplay(backgroundTask); + setMatch3DSession(rebasedTask.session); + setMatch3DFormDraftPayload(rebasedTask.payload); + setMatch3DGenerationState(rebasedTask.generationState); + setMatch3DBackgroundCompileTasks((current) => ({ + ...current, + [rebasedTask.session.sessionId]: rebasedTask, + })); if (backgroundTask.error) { setMatch3DError(backgroundTask.error); } @@ -10552,12 +11594,11 @@ export function PlatformEntryFlowShellImpl({ setMatch3DSession(latestSession); setMatch3DFormDraftPayload(null); setMatch3DProfile(null); - setMatch3DGenerationState( - createMiniGameDraftGenerationStateFromStartedAt( - 'match3d', - parseDraftGenerationStartedAtMs(item.updatedAt), - ), - ); + const generationState = + rebaseMiniGameDraftGenerationStateForDisplay( + createMiniGameDraftGenerationStateForRestoredDraft('match3d'), + ); + setMatch3DGenerationState(generationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; @@ -10736,7 +11777,7 @@ export function PlatformEntryFlowShellImpl({ return; } - setBarkBattleDraftConfig({ + const nextDraft: BarkBattleDraftConfig = { draftId: item.draftId ?? item.workId, workId: item.workId, title: item.title, @@ -10752,13 +11793,15 @@ export function PlatformEntryFlowShellImpl({ configVersion: 1, rulesetVersion: 'bark-battle-ruleset-v1', updatedAt: item.updatedAt, - }); + }; + setBarkBattleDraftConfig(nextDraft); enterCreateTab(); selectionStageRef.current = isPersistedBarkBattleDraftGenerating(item) ? 'bark-battle-generating' : 'bark-battle-result'; setSelectionStage(selectionStageRef.current); + writeCreationUrlState(buildBarkBattleCreationUrlState(nextDraft)); }, [enterCreateTab, markDraftNoticeSeen, openPublicWorkDetail, setSelectionStage], ); @@ -10846,10 +11889,273 @@ export function PlatformEntryFlowShellImpl({ setBabyObjectMatchError(null); enterCreateTab(); setSelectionStage('baby-object-match-result'); + writeCreationUrlState(buildBabyObjectMatchCreationUrlState(draft)); }, [enterCreateTab, markDraftNoticeSeen, setSelectionStage], ); + useEffect(() => { + if (handledInitialCreationUrlStateRef.current) { + return; + } + if (!isCreationRestorePath(window.location.pathname)) { + handledInitialCreationUrlStateRef.current = true; + return; + } + if (!hasCreationUrlStateValue(initialCreationUrlState)) { + handledInitialCreationUrlStateRef.current = true; + return; + } + if (platformBootstrap.isLoadingPlatform) { + return; + } + if (!platformBootstrap.canReadProtectedData) { + return; + } + + handledInitialCreationUrlStateRef.current = true; + + const restoreCreationUrlState = async () => { + const path = window.location.pathname; + const sessionId = normalizeCreationUrlValue( + initialCreationUrlState.sessionId, + ); + const profileId = normalizeCreationUrlValue( + initialCreationUrlState.profileId, + ); + const draftId = normalizeCreationUrlValue(initialCreationUrlState.draftId); + const workId = normalizeCreationUrlValue(initialCreationUrlState.workId); + + if (path.startsWith('/creation/big-fish')) { + const targetSessionId = sessionId ?? workId?.replace(/^big-fish-work-/u, ''); + if (targetSessionId) { + const matchedWork = + ( + bigFishWorks.length > 0 + ? bigFishWorks + : (await listBigFishWorks().catch(() => ({ items: [] }))).items + ).find( + (item) => + item.sourceSessionId === targetSessionId || + item.workId === workId, + ) ?? null; + if (matchedWork) { + await openBigFishDraft(matchedWork); + return; + } + await bigFishFlow.restoreDraft(targetSessionId); + } + return; + } + + if (path.startsWith('/creation/match3d')) { + const matchedWork = + ( + match3dWorks.length > 0 + ? match3dWorks + : mapMatch3DWorksForRuntimeUi( + (await listMatch3DWorks().catch(() => ({ items: [] }))).items, + ) + ).find( + (item) => + item.sourceSessionId === sessionId || + item.profileId === profileId || + item.workId === workId, + ) ?? null; + if (matchedWork) { + await openMatch3DDraft(matchedWork, { forceDraft: true }); + return; + } + if (sessionId) { + await match3dFlow.restoreDraft(sessionId); + } + return; + } + + if (path.startsWith('/creation/square-hole')) { + const matchedWork = + ( + squareHoleWorks.length > 0 + ? squareHoleWorks + : (await listSquareHoleWorks().catch(() => ({ items: [] }))).items + ).find( + (item) => + item.sourceSessionId === sessionId || + item.profileId === profileId || + item.workId === workId, + ) ?? null; + if (matchedWork) { + await openSquareHoleDraft(matchedWork, { forceDraft: true }); + return; + } + if (sessionId) { + await squareHoleFlow.restoreDraft(sessionId); + } + return; + } + + if (path.startsWith('/creation/puzzle')) { + const matchedWork = + ( + puzzleWorks.length > 0 + ? puzzleWorks + : (await listPuzzleWorks().catch(() => ({ items: [] }))).items + ).find( + (item) => + item.sourceSessionId === sessionId || + item.profileId === profileId || + item.workId === workId, + ) ?? null; + if (matchedWork) { + await openPuzzleDraft(matchedWork); + return; + } + if (sessionId) { + await puzzleFlow.restoreDraft(sessionId); + } + return; + } + + if (path.startsWith('/creation/visual-novel')) { + const matchedWork = + ( + visualNovelWorks.length > 0 + ? visualNovelWorks + : (await listVisualNovelWorks().catch(() => ({ works: [] }))).works + ).find((item) => item.profileId === profileId) ?? null; + if (matchedWork) { + await openVisualNovelDraft(matchedWork, { forceDraft: true }); + return; + } + if (sessionId) { + await visualNovelFlow.restoreDraft(sessionId); + } + return; + } + + if (path.startsWith('/creation/bark-battle')) { + const matchedWork = + ( + barkBattleWorks.length > 0 + ? barkBattleWorks + : (await listBarkBattleWorks().catch(() => ({ items: [] }))).items + ).find( + (item) => item.workId === workId || item.draftId === draftId, + ) ?? null; + if (matchedWork) { + openBarkBattleDraft(matchedWork, { forceDraft: true }); + } + return; + } + + if (path.startsWith('/creation/baby-object-match')) { + const matchedDraft = + ( + babyObjectMatchDrafts.length > 0 + ? babyObjectMatchDrafts + : await listLocalBabyObjectMatchDrafts().catch(() => []) + ).find( + (item) => + item.profileId === profileId || + item.draftId === draftId || + item.profileId === workId, + ) ?? null; + if (matchedDraft) { + openBabyObjectMatchDraft(matchedDraft); + } + return; + } + + if (path.startsWith('/creation/jump-hop')) { + if (!sessionId) { + return; + } + try { + const { session } = await jumpHopClient.getSession(sessionId); + let work: JumpHopWorkProfileResponse | null = null; + if (profileId) { + work = (await jumpHopClient.getWorkDetail(profileId)).item; + } + setJumpHopSession(session); + setJumpHopWork(work); + writeCreationUrlState(buildJumpHopCreationUrlState({ session, work })); + enterCreateTab(); + setSelectionStage( + path.includes('/generating') + ? 'jump-hop-generating' + : session.draft + ? 'jump-hop-result' + : 'jump-hop-workspace', + ); + } catch (error) { + setJumpHopError( + resolveRpgCreationErrorMessage(error, '读取跳一跳创作草稿失败。'), + ); + } + return; + } + + if (path.startsWith('/creation/wooden-fish')) { + if (!sessionId) { + return; + } + try { + const { session } = await woodenFishClient.getSession(sessionId); + let work: WoodenFishWorkProfileResponse | null = null; + if (profileId) { + work = (await woodenFishClient.getWorkDetail(profileId)).item; + } + setWoodenFishSession(session); + setWoodenFishWork(work); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ session, work }), + ); + enterCreateTab(); + setSelectionStage( + path.includes('/generating') + ? 'wooden-fish-generating' + : session.draft + ? 'wooden-fish-result' + : 'wooden-fish-workspace', + ); + } catch (error) { + setWoodenFishError( + resolveRpgCreationErrorMessage(error, '读取敲木鱼创作草稿失败。'), + ); + } + } + }; + + void restoreCreationUrlState(); + }, [ + babyObjectMatchDrafts, + barkBattleWorks, + bigFishFlow, + bigFishWorks, + enterCreateTab, + initialCreationUrlState, + jumpHopClient, + match3dFlow, + match3dWorks, + openBabyObjectMatchDraft, + openBarkBattleDraft, + openBigFishDraft, + openMatch3DDraft, + openPuzzleDraft, + openSquareHoleDraft, + openVisualNovelDraft, + platformBootstrap.canReadProtectedData, + platformBootstrap.isLoadingPlatform, + puzzleFlow, + puzzleWorks, + setSelectionStage, + squareHoleFlow, + squareHoleWorks, + visualNovelFlow, + visualNovelWorks, + woodenFishClient, + ]); + const startBigFishRunFromWork = useCallback( async ( item: BigFishWorkSummary, @@ -10875,11 +12181,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); @@ -10890,11 +12197,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( @@ -10909,13 +12212,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('汪汪声浪作品发布后才能进入正式玩法。'); @@ -10927,17 +12231,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(() => { @@ -11209,7 +12530,9 @@ export function PlatformEntryFlowShellImpl({ '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', ); } else { - started = startBarkBattleRunFromWork(work, 'platform'); + started = await startBarkBattleRunFromWork(work, 'platform', { + embedded: true, + }); } } else if (isEdutainmentGalleryEntry(entry)) { started = await startBabyObjectMatchRuntimeFromEntry( @@ -11303,6 +12626,7 @@ export function PlatformEntryFlowShellImpl({ const recommendRuntimeContent = useMemo(() => { if ( + isDesktopLayout || selectionStage !== 'platform' || platformBootstrap.platformTab !== 'home' || !activeRecommendRuntimeKind @@ -11715,10 +13039,12 @@ export function PlatformEntryFlowShellImpl({ visualNovelSession, visualNovelWork, checkpointWoodenFishRuntimeRun, + isDesktopLayout, ]); useEffect(() => { if ( + isDesktopLayout || selectionStage !== 'platform' || platformBootstrap.platformTab !== 'home' || platformBootstrap.isLoadingPlatform @@ -11774,6 +13100,7 @@ export function PlatformEntryFlowShellImpl({ match3dRun, platformBootstrap.isLoadingPlatform, platformBootstrap.platformTab, + isDesktopLayout, puzzleRun, recommendRuntimeEntries, selectRecommendRuntimeEntry, @@ -12781,7 +14108,8 @@ export function PlatformEntryFlowShellImpl({ puzzleShelfError ?? puzzleError ?? (isVisualNovelCreationOpen ? visualNovelError : null) ?? - babyObjectMatchError) + babyObjectMatchError ?? + barkBattleError) } onRetry={() => { platformBootstrap.setPlatformError(null); @@ -12833,7 +14161,8 @@ export function PlatformEntryFlowShellImpl({ puzzleCreationError ?? puzzleError ?? (isVisualNovelCreationOpen ? visualNovelError : null) ?? - babyObjectMatchError + babyObjectMatchError ?? + barkBattleError } createBusy={ !creationEntryConfig || @@ -12879,6 +14208,7 @@ export function PlatformEntryFlowShellImpl({ deletingWorkId={deletingCreationWorkId} rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []} + jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []} onOpenBigFishDetail={ isBigFishCreationVisible ? (item) => { @@ -12888,6 +14218,15 @@ export function PlatformEntryFlowShellImpl({ } : undefined } + onOpenJumpHopDetail={ + isJumpHopCreationVisible + ? (item) => { + runProtectedAction(() => { + void openJumpHopDraft(item); + }); + } + : undefined + } onDeleteBigFish={ isBigFishCreationVisible ? (item) => { @@ -12895,6 +14234,7 @@ export function PlatformEntryFlowShellImpl({ } : null } + onDeleteJumpHop={null} match3dItems={match3dShelfItems} onOpenMatch3DDetail={(item) => { runProtectedAction(() => { @@ -12966,208 +14306,9 @@ export function PlatformEntryFlowShellImpl({ ) : null} ); - const creationStartContent = ( -
-
-
-
- {getVisiblePlatformCreationTypes(creationEntryTypes).map((item) => { - const selected = item.id === activeCreationFormType; - const disabled = item.locked; - - return ( - - ); - })} -
-
- -
- {activeCreationFormType === 'match3d' ? ( - } - > - { - void executeMatch3DAction(payload); - }} - initialFormPayload={match3dFormDraftPayload} - onCreateFromForm={(payload) => { - runProtectedAction(() => { - void createMatch3DDraftFromForm(payload); - }); - }} - showBackButton={false} - title={null} - /> - - ) : activeCreationFormType === 'visual-novel' ? ( - } - > - { - runProtectedAction(() => { - void createVisualNovelDraftFromForm(payload); - }); - }} - showBackButton={false} - title={null} - /> - - ) : activeCreationFormType === 'baby-object-match' ? ( - } - > - { - void createBabyObjectMatchDraftFromForm(payload); - }} - showBackButton={false} - title={null} - /> - - ) : activeCreationFormType === 'bark-battle' ? ( - } - > - { - void createBarkBattleGeneratingDraft(payload); - }} - showBackButton={false} - title={null} - /> - - ) : activeCreationFormType === 'jump-hop' ? ( - } - > - { - void compileJumpHopSession(result, payload); - }} - /> - - ) : activeCreationFormType === 'wooden-fish' ? ( - } - > - { - void compileWoodenFishSession(result, payload); - }} - /> - - ) : ( - } - > - { - void submitPuzzleMessage(payload); - }} - onExecuteAction={(payload) => { - executePuzzleWorkspaceAction(payload); - }} - initialFormPayload={puzzleFormDraftPayload} - onCreateFromForm={(payload) => { - runProtectedAction(() => { - void createPuzzleDraftFromForm(payload); - }); - }} - onAutoSaveForm={(payload) => { - void savePuzzleFormDraft(payload); - }} - showBackButton={false} - title={null} - /> - - )} -
-
-
+ const creationStartContent = renderCreationHubContent( + 'start-only', + '正在加载创作大厅...', ); const draftHubContent = renderCreationHubContent( 'works-only', @@ -13200,6 +14341,7 @@ export function PlatformEntryFlowShellImpl({ isLoadingPlatform={platformBootstrap.isLoadingPlatform} isLoadingDashboard={platformBootstrap.isLoadingDashboard} hasUnreadDraftUpdate={hasUnreadDraftUpdates} + isDesktopLayout={isDesktopLayout} isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey} platformError={ platformBootstrap.isLoadingPlatform @@ -13652,7 +14794,7 @@ export function PlatformEntryFlowShellImpl({ )} - {selectionStage === 'match3d-agent-workspace' && match3dSession && ( + {selectionStage === 'match3d-agent-workspace' && ( { void executeMatch3DAction(payload); }} + initialFormPayload={match3dFormDraftPayload} + onCreateFromForm={(payload) => { + runProtectedAction(() => { + void createMatch3DDraftFromForm(payload); + }); + }} /> @@ -14058,6 +15206,29 @@ export function PlatformEntryFlowShellImpl({ )} + {selectionStage === 'bark-battle-workspace' && ( + + } + > + { + void createBarkBattleGeneratingDraft(payload); + }} + /> + + + )} + {selectionStage === 'square-hole-agent-workspace' && ( { enterCreateTab(); - setActiveCreationFormType('bark-battle'); selectionStageRef.current = 'platform'; setSelectionStage('platform'); }} @@ -15073,7 +16243,6 @@ export function PlatformEntryFlowShellImpl({ } onBack={() => { enterCreateTab(); - setActiveCreationFormType('bark-battle'); selectionStageRef.current = 'platform'; setSelectionStage('platform'); }} @@ -15113,7 +16282,6 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('bark-battle-result'); } else { enterCreateTab(); - setActiveCreationFormType('bark-battle'); setSelectionStage('platform'); } }} @@ -15407,6 +16575,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} +
+
{ + const cards = derivePlatformCreationTypes([ + { + id: 'puzzle', + title: '秋日暖阳', + subtitle: '记录秋日的温暖时光', + badge: '热门', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: true, + sortOrder: 30, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, + updatedAtMicros: 1, + }, + { + id: 'match3d', + title: '秋日小屋', + subtitle: '打造专属的秋日小屋', + badge: '精选', + imageSrc: '/creation-type-references/match3d.webp', + visible: true, + open: true, + sortOrder: 40, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, + updatedAtMicros: 1, + }, + { + id: 'visual-novel', + title: '视觉小说', + subtitle: '分支叙事体验', + badge: '敬请期待', + imageSrc: '/creation-type-references/visual-novel.webp', + visible: true, + open: false, + sortOrder: 60, + categoryId: 'festival', + categoryLabel: '节日主题', + categorySortOrder: 30, + updatedAtMicros: 1, + }, + { + id: 'hidden', + title: '隐藏入口', + subtitle: '隐藏', + badge: '隐藏', + imageSrc: '/creation-type-references/hidden.webp', + visible: false, + open: true, + sortOrder: 10, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, + updatedAtMicros: 1, + }, + ]); + + const groups = groupVisiblePlatformCreationTypes(cards); + + expect(groups.map((group) => group.label)).toEqual([ + '最近创作', + '节日主题', + ]); + expect(groups[0]?.items.map((item) => item.id)).toEqual([ + 'puzzle', + 'match3d', + ]); + expect(groups[1]?.items.map((item) => item.id)).toEqual(['visual-novel']); +}); + +test('falls back when backend creation type category metadata is missing', () => { + const cards = derivePlatformCreationTypes([ + { + id: 'legacy-entry', + title: '历史入口', + subtitle: '旧数据缺少分类字段', + badge: '可创建', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: true, + sortOrder: 10, + categoryId: undefined as unknown as string, + categoryLabel: undefined as unknown as string, + categorySortOrder: 0, + updatedAtMicros: 1, + }, + ]); + + expect(cards[0]).toEqual( + expect.objectContaining({ + id: 'legacy-entry', + categoryId: 'recent', + categoryLabel: '最近创作', + }), + ); + expect(groupVisiblePlatformCreationTypes(cards)).toEqual([ + expect.objectContaining({ + id: 'recent', + label: '最近创作', + }), + ]); +}); diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index e52faf99..11c01c12 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -10,9 +10,23 @@ export type PlatformCreationTypeCard = { badge: string; imageSrc: string; locked: boolean; + categoryId: string; + categoryLabel: string; + categorySortOrder: number; + sortOrder: number; hidden?: boolean; }; +export type PlatformCreationTypeGroup = { + id: string; + label: string; + sortOrder: number; + items: PlatformCreationTypeCard[]; +}; + +const FALLBACK_CREATION_CATEGORY_ID = 'recent'; +const FALLBACK_CREATION_CATEGORY_LABEL = '最近创作'; + export function getVisiblePlatformCreationTypes( creationTypes: readonly PlatformCreationTypeCard[], ) { @@ -41,6 +55,50 @@ export function isPlatformCreationTypeOpen( ); } +function normalizeCategoryId(value: string | null | undefined) { + const normalized = typeof value === 'string' ? value.trim() : ''; + return normalized || FALLBACK_CREATION_CATEGORY_ID; +} + +function normalizeCategoryLabel(value: string | null | undefined) { + const normalized = typeof value === 'string' ? value.trim() : ''; + return normalized || FALLBACK_CREATION_CATEGORY_LABEL; +} + +export function groupVisiblePlatformCreationTypes( + creationTypes: readonly PlatformCreationTypeCard[], +): PlatformCreationTypeGroup[] { + const groups = new Map(); + + for (const item of getVisiblePlatformCreationTypes(creationTypes)) { + const categoryId = normalizeCategoryId(item.categoryId); + const categoryLabel = normalizeCategoryLabel(item.categoryLabel); + const existing = groups.get(categoryId); + + if (existing) { + existing.items.push(item); + if (item.categorySortOrder < existing.sortOrder) { + existing.sortOrder = item.categorySortOrder; + } + continue; + } + + groups.set(categoryId, { + id: categoryId, + label: categoryLabel, + sortOrder: item.categorySortOrder, + items: [item], + }); + } + + return [...groups.values()].sort((left, right) => { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + return left.label.localeCompare(right.label, 'zh-Hans-CN'); + }); +} + /** * 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB,前端不再保留入口默认配置。 */ @@ -56,6 +114,10 @@ export function derivePlatformCreationTypes( badge: item.badge, imageSrc: item.imageSrc, locked: !item.open, + categoryId: normalizeCategoryId(item.categoryId), + categoryLabel: normalizeCategoryLabel(item.categoryLabel), + categorySortOrder: item.categorySortOrder, + sortOrder: item.sortOrder, hidden: !item.visible || (item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()), 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/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 7820728f..e34c9403 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -36,6 +36,7 @@ export type SelectionStage = | 'jump-hop-result' | 'jump-hop-runtime' | 'jump-hop-gallery-detail' + | 'bark-battle-workspace' | 'bark-battle-generating' | 'bark-battle-result' | 'bark-battle-runtime' diff --git a/src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx b/src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx index 68f9039b..ded82926 100644 --- a/src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx +++ b/src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx @@ -300,6 +300,161 @@ function ActionCompleteHarness({ ); } +function SessionChangeHarness({ + onSessionChanged, +}: { + onSessionChanged: (session: TestSession | null) => void; +}) { + const flow = usePlatformCreationAgentFlowController< + TestSession, + Record, + { session: TestSession }, + TestMessagePayload, + { action: string }, + { session: TestSession } + >({ + client: { + createSession: async () => ({ + session: { + sessionId: 'session-open', + messages: [], + }, + }), + getSession: async () => ({ + session: { + sessionId: 'session-restore', + messages: [], + }, + }), + streamMessage: async () => ({ + sessionId: 'session-open', + messages: [], + }), + executeAction: async () => ({ + session: { + sessionId: 'session-compile', + messages: [], + }, + }), + selectSession: (response) => response.session, + }, + createPayload: {}, + workspaceStage: 'match3d-agent-workspace', + resultStage: 'match3d-result', + platformStage: 'platform', + isCompileAction: (payload) => payload.action === 'match3d_compile_draft', + resolveErrorMessage: (error, fallback) => + error instanceof Error ? error.message : fallback, + errorMessages: { + open: '打开失败', + restoreMissingSession: '缺少会话', + restore: '恢复失败', + submit: '发送失败', + execute: '执行失败', + }, + enterCreateTab: () => {}, + setSelectionStage: () => {}, + onSessionChanged, + onActionComplete: ({ response, setSession }) => { + setSession(response.session); + }, + }); + + return ( +
+ + + +
+ ); +} + +function SessionSetterIdentityHarness({ + onSetterIdentity, +}: { + onSetterIdentity: (setter: unknown) => void; +}) { + const [renderCount, setRenderCount] = useState(0); + const flow = usePlatformCreationAgentFlowController< + TestSession, + Record, + { session: TestSession }, + TestMessagePayload, + { action: string }, + { session: TestSession } + >({ + client: { + createSession: async () => ({ + session: { + sessionId: 'session-open', + messages: [], + }, + }), + getSession: async () => ({ + session: { + sessionId: 'session-restore', + messages: [], + }, + }), + streamMessage: async () => ({ + sessionId: 'session-open', + messages: [], + }), + executeAction: async () => ({ + session: { + sessionId: 'session-compile', + messages: [], + }, + }), + selectSession: (response) => response.session, + }, + createPayload: {}, + workspaceStage: 'match3d-agent-workspace', + resultStage: 'match3d-result', + platformStage: 'platform', + isCompileAction: () => false, + resolveErrorMessage: (error, fallback) => + error instanceof Error ? error.message : fallback, + errorMessages: { + open: '打开失败', + restoreMissingSession: '缺少会话', + restore: '恢复失败', + submit: '发送失败', + execute: '执行失败', + }, + enterCreateTab: () => {}, + setSelectionStage: () => {}, + onSessionChanged: () => {}, + }); + + useEffect(() => { + onSetterIdentity(flow.setSession); + }); + + return ( + + ); +} + test('creation agent flow preserves streamed assistant text when stream fails', async () => { const streamMessage = vi.fn(async (_sessionId, _payload, options) => { options?.onUpdate?.('先把方洞万能的反差定住。'); @@ -391,3 +546,48 @@ test('creation agent flow suppresses compile result stage for background complet 'match3d-agent-workspace', ); }); + +test('creation agent flow notifies session changes after open restore and compile', async () => { + const onSessionChanged = vi.fn(); + + render(); + + await act(async () => { + screen.getByRole('button', { name: '打开' }).click(); + }); + await act(async () => { + screen.getByRole('button', { name: '恢复' }).click(); + }); + await act(async () => { + screen.getByRole('button', { name: '编译' }).click(); + }); + + await waitFor(() => { + expect(onSessionChanged).toHaveBeenCalledTimes(3); + }); + + expect( + onSessionChanged.mock.calls.map(([session]) => session?.sessionId), + ).toEqual(['session-open', 'session-restore', 'session-compile']); +}); + +test('creation agent flow keeps session setter stable across parent rerenders', async () => { + const onSetterIdentity = vi.fn(); + + render(); + + await waitFor(() => { + expect(onSetterIdentity).toHaveBeenCalledTimes(1); + }); + const initialSetter = onSetterIdentity.mock.calls[0]?.[0]; + + await act(async () => { + screen.getByRole('button', { name: /重渲染/u }).click(); + }); + + await waitFor(() => { + expect(onSetterIdentity).toHaveBeenCalledTimes(2); + }); + + expect(onSetterIdentity.mock.calls[1]?.[0]).toBe(initialSetter); +}); diff --git a/src/components/platform-entry/usePlatformCreationAgentFlowController.ts b/src/components/platform-entry/usePlatformCreationAgentFlowController.ts index ba7ab67b..f89b778e 100644 --- a/src/components/platform-entry/usePlatformCreationAgentFlowController.ts +++ b/src/components/platform-entry/usePlatformCreationAgentFlowController.ts @@ -1,4 +1,5 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; import type { TextStreamOptions } from '../../services/aiTypes'; import type { SelectionStage } from './platformEntryTypes'; @@ -75,12 +76,13 @@ type PlatformCreationAgentFlowControllerOptions< enterCreateTab: () => void; setSelectionStage: (stage: SelectionStage) => void; onSessionOpened?: () => void; + onSessionChanged?: (session: TSession | null) => void; onOpenError?: (params: { error: unknown; errorMessage: string }) => void; onActionComplete?: (params: { payload: TActionPayload; response: TActionResponse; session: TSession; - setSession: (session: TSession) => void; + setSession: Dispatch>; }) => | Promise<{ openResult?: boolean } | void> | { openResult?: boolean } @@ -94,7 +96,7 @@ type PlatformCreationAgentFlowControllerOptions< error: unknown; errorMessage: string; session: TSession; - setSession: (session: TSession) => void; + setSession: Dispatch>; }) => void | Promise; }; @@ -141,12 +143,27 @@ export function usePlatformCreationAgentFlowController< TActionResponse >, ) { - const [session, setSession] = useState(null); + const [session, rawSetSession] = useState(null); const [error, setError] = useState(null); const [isBusy, setIsBusy] = useState(false); const [streamingReplyText, setStreamingReplyText] = useState(''); const [isStreamingReply, setIsStreamingReply] = useState(false); const latestStreamingReplyTextRef = useRef(''); + const onSessionChangedRef = useRef(options.onSessionChanged); + + useEffect(() => { + onSessionChangedRef.current = options.onSessionChanged; + }, [options.onSessionChanged]); + + const setSession = useCallback( + (nextSessionOrUpdater: SetStateAction) => { + rawSetSession(nextSessionOrUpdater); + if (typeof nextSessionOrUpdater !== 'function') { + onSessionChangedRef.current?.(nextSessionOrUpdater); + } + }, + [], + ); const updateStreamingReplyText = useCallback((text: string) => { latestStreamingReplyTextRef.current = text; @@ -174,10 +191,10 @@ export function usePlatformCreationAgentFlowController< createPayload ?? options.createPayload, ); const nextSession = options.client.selectSession(response); - setSession(nextSession); options.enterCreateTab(); options.onSessionOpened?.(); options.setSelectionStage(options.workspaceStage); + setSession(nextSession); return nextSession; } catch (caughtError) { const errorMessage = options.resolveErrorMessage( @@ -212,11 +229,11 @@ export function usePlatformCreationAgentFlowController< try { const response = await options.client.getSession(normalizedSessionId); const nextSession = options.client.selectSession(response); - setSession(nextSession); options.enterCreateTab(); options.setSelectionStage( nextSession.draft ? options.resultStage : options.workspaceStage, ); + setSession(nextSession); return nextSession; } catch (caughtError) { setError( 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, @@ -199,9 +226,45 @@ async function clickFirstAsyncButtonByName( async function openCreateTemplateHub(user: ReturnType) { await clickFirstButtonByName(user, '创作'); - expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy(); - expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy(); - expect(screen.getByText('拼图工作区:missing-session')).toBeTruthy(); + const panel = getPlatformTabPanel('create'); + await waitFor(() => { + expect(panel.getAttribute('aria-hidden')).toBe('false'); + }); + expect( + await within(panel).findByRole('tablist', { name: '玩法模板分类' }), + ).toBeTruthy(); + expect( + await within(panel).findByRole('button', { name: /拼图/u }), + ).toBeTruthy(); + expect(within(panel).queryByText('拼图工作区:missing-session')).toBeNull(); + return panel; +} + +async function findCreationTypeButton(name: string | RegExp) { + const matcher = + typeof name === 'string' + ? new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u') + : name; + return within(getPlatformTabPanel('create')).findByRole('button', { + name: matcher, + }); +} + +function queryCreationTypeButton(name: string | RegExp) { + const matcher = + typeof name === 'string' + ? new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u') + : name; + return within(getPlatformTabPanel('create')).queryByRole('button', { + name: matcher, + }); +} + +async function openPuzzleFormFromCreateHub( + user: ReturnType, +) { + await user.click(await findCreationTypeButton('拼图')); + await screen.findByText(/拼图工作区:/u); } async function openDraftHub(user: ReturnType) { @@ -210,9 +273,7 @@ async function openDraftHub(user: ReturnType) { await waitFor(() => { expect(panel.getAttribute('aria-hidden')).toBe('false'); }); - expect( - await within(panel).findByRole('button', { name: /全部/u }), - ).toBeTruthy(); + expect(await within(panel).findByRole('tab', { name: /全部/u })).toBeTruthy(); } async function expectDraftHubGeneratingBadgeCountAtLeast(count: number) { @@ -240,7 +301,9 @@ async function openProfilePlayedWorks( user: ReturnType, ) { await clickFirstButtonByName(user, '我的'); - await user.click(await screen.findByRole('button', { name: /玩过/u })); + await user.click( + await screen.findByRole('button', { name: /已玩游戏数量/u }), + ); expect(await screen.findByText('可继续')).toBeTruthy(); } @@ -258,6 +321,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}`); @@ -279,6 +347,14 @@ const testCreationEntryConfig = { title: '选择创作类型', description: '先选玩法类型,再进入对应创作工作台。', }, + eventBanner: { + title: '泥点挑战', + description: '创作活动测试横幅。', + coverImageSrc: '/creation-type-references/puzzle.webp', + prizePoolMudPoints: 1000, + startsAtText: '2026-05-01', + endsAtText: '2026-05-31', + }, creationTypes: [ { id: 'rpg', @@ -289,6 +365,9 @@ const testCreationEntryConfig = { visible: true, open: true, sortOrder: 10, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -300,6 +379,9 @@ const testCreationEntryConfig = { visible: true, open: true, sortOrder: 30, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -311,6 +393,9 @@ const testCreationEntryConfig = { visible: true, open: true, sortOrder: 40, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -322,6 +407,9 @@ const testCreationEntryConfig = { visible: true, open: true, sortOrder: 45, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -333,6 +421,9 @@ const testCreationEntryConfig = { visible: false, open: true, sortOrder: 50, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -344,6 +435,9 @@ const testCreationEntryConfig = { visible: false, open: false, sortOrder: 60, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -355,6 +449,9 @@ const testCreationEntryConfig = { visible: true, open: false, sortOrder: 70, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -366,6 +463,9 @@ const testCreationEntryConfig = { visible: false, open: true, sortOrder: 80, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -377,6 +477,9 @@ const testCreationEntryConfig = { visible: true, open: true, sortOrder: 90, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ], @@ -553,7 +656,12 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({ ( primaryAssets: Match3DWorkSummary['generatedItemAssets'], fallbackAssets: Match3DWorkSummary['generatedItemAssets'], - ) => (primaryAssets ? [...primaryAssets] : fallbackAssets ? [...fallbackAssets] : []), + ) => + primaryAssets + ? [...primaryAssets] + : fallbackAssets + ? [...fallbackAssets] + : [], ), preloadMatch3DGeneratedRuntimeAssets: vi.fn(() => Promise.resolve()), })); @@ -997,20 +1105,16 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({ }
- { - generatedBackgroundAsset?.imageSrc?.trim() || - generatedBackgroundAsset?.imageObjectKey?.trim() - ? 1 - : 0 - } + {generatedBackgroundAsset?.imageSrc?.trim() || + generatedBackgroundAsset?.imageObjectKey?.trim() + ? 1 + : 0}
- { - generatedBackgroundAsset?.containerImageSrc?.trim() || - generatedBackgroundAsset?.containerImageObjectKey?.trim() - ? 1 - : 0 - } + {generatedBackgroundAsset?.containerImageSrc?.trim() || + generatedBackgroundAsset?.containerImageObjectKey?.trim() + ? 1 + : 0}
- ); -} - function CreationLibraryCard({ entry, onClick, @@ -2385,12 +2334,14 @@ function ProfileStatCard({ value, onClick, icon, + imageSrc, }: { cardKey: ProfileDashboardCardKey; label: string; value: string; onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null; icon: ComponentType<{ className?: string }>; + imageSrc?: string; }) { const Icon = icon; @@ -2398,16 +2349,23 @@ function ProfileStatCard({ ); @@ -2427,11 +2385,13 @@ function ProfileShortcutButton({ subLabel, icon, onClick, + imageSrc, }: { label: string; subLabel?: ReactNode; icon: ComponentType<{ className?: string }>; onClick?: (() => void) | null; + imageSrc?: string; }) { const Icon = icon; @@ -2439,16 +2399,20 @@ function ProfileShortcutButton({ + ); +} + +function ProfileSecondaryShortcutButton({ + label, + subLabel, + icon, + onClick, +}: { + label: string; + subLabel?: string; + icon: ComponentType<{ className?: string }>; + onClick: () => void; +}) { + const Icon = icon; + + return ( + + ); +} + function ProfileLegalSection({ onOpenDocument, }: { @@ -2463,33 +2493,21 @@ function ProfileLegalSection({ }) { return (
-
- 法律信息 -
-
+
{LEGAL_DOCUMENTS.map((document, index) => ( ))}
@@ -2497,7 +2515,7 @@ function ProfileLegalSection({ href={ICP_RECORD_URL} target="_blank" rel="noreferrer" - className="mt-3 block text-center text-xs font-semibold text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)]" + className="platform-profile-legal-strip__record" > {ICP_RECORD_NUMBER} @@ -3199,7 +3217,7 @@ function ProfileTaskCenterModal({ onRetry: () => void; onClaim: (taskId: string) => void; }) { - const tasks = center?.tasks ?? []; + const tasks = selectProfileTaskCenterTasks(center?.tasks ?? []); const walletBalance = center?.walletBalance ?? fallbackBalance; return ( @@ -3375,6 +3393,160 @@ function RewardCodeRedeemModal({ ); } +function ProfileQrScannerModal({ + error, + result, + onClose, + onError, + onResult, +}: { + error: string | null; + result: string | null; + onClose: () => void; + onError: (message: string) => void; + onResult: (value: string) => void; +}) { + const videoRef = useRef(null); + const streamRef = useRef(null); + + useEffect(() => { + const videoElement = videoRef.current; + if (!videoElement) { + return; + } + + let isMounted = true; + let scanTimer: number | null = null; + const detectorCtor = getBarcodeDetectorConstructor(); + const detector = detectorCtor + ? new detectorCtor({ formats: ['qr_code'] }) + : null; + + const clearScanTimer = () => { + if (scanTimer !== null) { + window.clearTimeout(scanTimer); + scanTimer = null; + } + }; + const stopCamera = () => { + const stream = streamRef.current; + streamRef.current = null; + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + } + videoElement.srcObject = null; + }; + + const scanVideo = async () => { + if (!isMounted || !detector || videoElement.readyState < 2) { + if (isMounted && detector) { + scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS); + } + return; + } + + try { + const codes = await detector.detect(videoElement); + const rawValue = codes[0]?.rawValue?.trim(); + if (rawValue) { + clearScanTimer(); + stopCamera(); + onResult(rawValue); + return; + } + } catch { + onError('扫码识别失败,请调整二维码位置'); + } + + if (isMounted) { + scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS); + } + }; + + const startCamera = async () => { + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { + onError('当前浏览器不支持摄像头扫码'); + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { facingMode: { ideal: 'environment' } }, + }); + + if (!isMounted) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = stream; + videoElement.srcObject = stream; + await videoElement.play(); + if (!detector) { + onError('当前浏览器暂不支持二维码识别'); + return; + } + scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS); + } catch { + onError('无法打开摄像头,请检查权限'); + } + }; + + void startCamera(); + + return () => { + isMounted = false; + clearScanTimer(); + stopCamera(); + }; + }, [onError, onResult]); + + return ( +
+
+
+
扫码
+ +
+
+
+
+ {result ? ( +
+ 已识别:{result} +
+ ) : error ? ( +
+ {error} +
+ ) : null} +
+
+
+ ); +} + function ProfileReferralModal({ panel, center, @@ -3768,6 +3940,7 @@ function ProfilePlayedWorksModal({ export function RpgEntryHomeView({ activeTab, + isDesktopLayout: isDesktopLayoutProp, onTabChange, saveEntries, saveError, @@ -3852,6 +4025,9 @@ export function RpgEntryHomeView({ const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false); const [claimingTaskId, setClaimingTaskId] = useState(null); const [taskClaimSuccess, setTaskClaimSuccess] = useState(null); + const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); + const [qrScannerError, setQrScannerError] = useState(null); + const [qrScannerResult, setQrScannerResult] = useState(null); const [profilePopupPanel, setProfilePopupPanel] = useState(null); const [referralCenter, setReferralCenter] = @@ -3925,7 +4101,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( @@ -4636,6 +4813,16 @@ export function RpgEntryHomeView({ setTaskClaimSuccess(null); loadTaskCenter(); }; + const openQrScannerPanel = () => { + if (!authUi?.user) { + authUi?.openLoginModal(); + return; + } + + setQrScannerError(null); + setQrScannerResult(null); + setIsQrScannerOpen(true); + }; const loadReferralCenter = useCallback(() => { setIsLoadingReferral(true); setIsReferralCenterInitialized(false); @@ -5198,23 +5385,6 @@ export function RpgEntryHomeView({ }, [], ); - const openActiveRecommendEntry = useCallback(() => { - if (!activeRecommendEntry) { - return; - } - - if (!isAuthenticated) { - authUi?.openLoginModal(); - return; - } - - openRecommendGalleryDetail(activeRecommendEntry); - }, [ - activeRecommendEntry, - authUi, - isAuthenticated, - openRecommendGalleryDetail, - ]); const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null; const openLeadPublicEntry = () => { if (leadPublicEntry) { @@ -5306,7 +5476,7 @@ export function RpgEntryHomeView({ {recommendRuntimeError}
- ) : isStartingRecommendEntry || !recommendRuntimeContent ? ( + ) : isStartingRecommendEntry ? (
加载中...
@@ -5388,7 +5558,7 @@ export function RpgEntryHomeView({ ); const mobileDiscoverContent: ReactNode = ( -
+
+
{visibleDiscoverChannels.map((channel) => { const active = discoverChannel === channel.id; @@ -5864,30 +6034,37 @@ export function RpgEntryHomeView({ const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent; const profileContent: ReactNode = ( -
+
{authUi?.user ? ( <> -
-
-
+
+ +
+
@@ -5902,28 +6079,27 @@ export function RpgEntryHomeView({ } /> -
+
-
+
{authUi.user.displayName}
-
- 陶泥号 {publicUserCode} +
+ 陶泥号: {publicUserCode}
- -
-
-
+ + +
+
{isLoadingDashboard ? ( <> @@ -5969,23 +6146,26 @@ export function RpgEntryHomeView({ <> @@ -5993,23 +6173,26 @@ export function RpgEntryHomeView({ <> @@ -6017,103 +6200,112 @@ export function RpgEntryHomeView({
+ +
-
+
- 领10 - - - } - icon={Star} - onClick={openTaskCenterPanel} - /> - - 0 - ? `${saveEntries.length}个可继续` - : '继续游玩' - } - icon={Archive} - onClick={() => setProfilePopupPanel('saveArchives')} - /> - {showRechargeEntry ? ( - - ) : null} - 双方得30 - - - } + subLabel="双方得 30 泥点" icon={UserPlus} + imageSrc={profileInviteImage} onClick={() => openProfilePopupPanel('invite')} /> - {canShowReferralRedeemShortcut ? ( - openProfilePopupPanel('redeem')} - /> - ) : null} + openProfilePopupPanel('community')} />
-
- + />
+ {canShowReferralRedeemShortcut ? ( +
+ openProfilePopupPanel('redeem')} + /> +
+ ) : null} + ) : ( @@ -6469,10 +6661,7 @@ export function RpgEntryHomeView({ ); const tabContentById = { - home: - !isAuthenticated || !isDesktopLayout - ? mobileRecommendContent - : desktopHomeContent, + home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent, category: categoryContent, create: createContent, saves: savesContent, @@ -6583,6 +6772,22 @@ export function RpgEntryHomeView({ onClose={() => setIsCategoryFilterPanelOpen(false)} /> ) : null; + const qrScannerModal: ReactNode = isQrScannerOpen ? ( + { + setIsQrScannerOpen(false); + setQrScannerError(null); + setQrScannerResult(null); + }} + onError={setQrScannerError} + onResult={(value) => { + setQrScannerError(null); + setQrScannerResult(value); + }} + /> + ) : null; if (!isDesktopLayout) { const isMobileRecommendTab = activeTab === 'home'; @@ -6594,7 +6799,38 @@ export function RpgEntryHomeView({ {!isMobileRecommendTab ? (
- {!isAuthenticated ? ( + {isAuthenticated && activeTab === 'profile' ? ( +
+ + +
+ ) : isAuthenticated && activeTab === 'create' ? ( + + ) : !isAuthenticated ? ( + ) : null}