diff --git a/.dockerignore b/.dockerignore index 35a52aba6..60e59f317 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,8 +30,3 @@ server-rs/.data server-rs/.spacetimedb public/generated-* - -scripts/loadtest/data/*.local.json -scripts/loadtest/data/k6-*.log -scripts/loadtest/data/k6-*summary*.md -scripts/loadtest/data/latest-*-prefix.txt diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 6368883c8..cf2f89177 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -365,7 +365,6 @@ module.exports = { 'src/types.ts', 'src/types/**', 'src/uiAssets.ts', - 'scripts/loadtest/**', 'packages/shared/src/contracts/jumpHop.ts', 'packages/shared/src/contracts/match3dAgent.ts', 'packages/shared/src/contracts/match3dRuntime.ts', diff --git a/.gitignore b/.gitignore index 8d722daa7..c278ff3fa 100644 --- a/.gitignore +++ b/.gitignore @@ -88,11 +88,3 @@ nohup.out spacetime.local.json deploy/container/api-server.env deploy/container/worker-smoke/ - -# Local load-test data extracted from private migration files -scripts/loadtest/data/*.local.json - -# Local load-test run artifacts -scripts/loadtest/data/k6-*.log -scripts/loadtest/data/k6-*summary*.md -scripts/loadtest/data/latest-*-prefix.txt diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts index 30b7c46e3..1aa2f6dcc 100644 --- a/apps/admin-web/src/app/adminRoutes.test.ts +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -59,12 +59,6 @@ test('后台灰度发布路由可通过导航和 hash 访问', () => { expect(routeHash('gray-release')).toBe('#gray-release'); }); -test('后台不再暴露旧创作模板管理路由', () => { - expect(resolveAdminRoute('#creation-entry')).toBe('dashboard'); - expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard'); - expect(resolveAdminRoute('#work-visibility')).toBe('dashboard'); -}); - test('后台素材查询路由可通过导航和 hash 访问', () => { expect(adminRoutes).toContainEqual({ id: 'editor-assets', diff --git a/deploy/container/README.md b/deploy/container/README.md index 7efe511d9..2e7c27e95 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -303,11 +303,7 @@ npm run container:down npm run container:down -- -v ``` -## 历史压测 - -旧作品列表 / gallery 的 compose `k6` profile 与 `container:k6` 命令已经随模板业务退役。`scripts/loadtest/` 仅保留历史脚本和脱敏样例,不进入当前运行入口;新的容量验收必须针对现役编辑器、项目或素材接口单独设计,不能复用旧作品接口结论。 - -### 内存采样 +## 内存采样 排查 API 容器内存时,优先对比压测前后的 `/proc/$pid/smaps_rollup` 和 cgroup 当前/峰值,不把 Windows 任务管理器总占用当成单进程结论: diff --git a/docs/README.md b/docs/README.md index b1eea2e71..f311df3de 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ ## 当前产品与平台 - [当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md):现役入口、账号钱包、UI 和后端分层。 -- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):只描述现役平台壳与旧模板退役边界。 +- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):只描述现役平台壳与图片画布编辑器链路。 - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。 @@ -129,5 +129,5 @@ - 文档现行/历史/待复核状态以[文档生命周期与现状索引](./【协作规范】文档生命周期与现状索引-2026-09-12.md)为准;未列入当前入口的文档不能直接作为实现依据。 - `docs/project-memory/shared-memory/` 只保存长期有效的概览、决策、流程和踩坑;`plans/`、`todos/` 仅保存仍开放且有明确下一门禁的事项。 - 修改 `/api/external/v1` 必须同步 OpenAPI 和契约测试;修改 SpacetimeDB schema 必须同步 migration、表目录、绑定和 schema 检查。 -- 旧模板、旧公开作品、旧运行态和旧后端路线不因历史源码或数据表仍存在而恢复入口。 +- 当前入口、公开契约和数据对象只以现役代码、路由树、shared-contracts 与 SpacetimeDB schema / bindings 为准;未登记对象不得作为兼容目标恢复。 - 新增 Markdown 使用 `【标签名】中文标题-YYYY-MM-DD.md` 命名;不要把个人配置、密钥、Token、日志或构建产物写入文档。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4d95ac128..e35ac4bff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -811,11 +811,11 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和 ## 2026-09-03 server-rs workspace 保留独立 platform-agent 排除边界 -- 背景:主站 #251 合并清理旧玩法表后,`server-rs/Cargo.toml` 的旧 crate 排除清单被收窄;`platform-agent` 仍位于 `server-rs/crates/` 下,但实际由 AGC 独立 Cargo workspace 通过路径依赖使用。若不显式排除,主 workspace 的 `cargo fmt --all` 会把它识别为“位于 workspace 内但不是 member”的非法包并直接失败。 +- 背景:`platform-agent` 位于 `server-rs/crates/` 下,但实际由 AGC 独立 Cargo workspace 通过路径依赖使用。若不显式排除,主 workspace 的 `cargo fmt --all` 会把它识别为“位于 workspace 内但不是 member”的非法包并直接失败。 - 决策:继续将 `crates/platform-agent` 放在 `server-rs` workspace 的 `exclude` 中。它不加入主 workspace,也不在其 manifest 中新增平行 `[workspace]`;AGC 的独立 Cargo manifest 继续负责该 crate 的构建边界。 - 影响范围:`server-rs/Cargo.toml` 与仓库 Rust 格式检查;不改变 `platform-agent` 源码、AGC 依赖关系或主站运行时。 - 验证方式:`npm run check:rustfmt` 通过;`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 不再报告 `platform-agent` workspace 错误。 -- 关联材料:Repository checks #5755、主站合并提交 `025f62729`、`server-rs/Cargo.toml`、AGC `apps/ai-game-creator-shell/src-tauri/Cargo.toml`。 +- 关联材料:Repository checks #5755、`server-rs/Cargo.toml`、AGC `apps/ai-game-creator-shell/src-tauri/Cargo.toml`。 ## 2026-09-03 Native shell 检查清单只维护现役 HostBridge 文件 @@ -845,25 +845,6 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和 - 验证方式:运行评论弹层恢复竞态回归、完整 `appSurface.test.ts`,并执行类型、编码和 diff 检查。 - 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`、`apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx`。 -## 2026-09-22 旧创作模板历史表从 schema 与生成绑定中删除 - -- 背景:旧创作模板的 63 张历史玩法表已经完成业务代码退役;release 在 2026-09-22 apply 前仍有 26 张表、5922 行历史数据。维护窗口内先完成可恢复备份,再执行固定清单清空,继续保留 table 定义、迁移白名单、旧行兼容分支和生成绑定会让当前 schema、客户端类型和迁移合同长期停留在退役状态。 -- 决策:从 `spacetime-module` 可达源码删除旧 gameplay、自定义世界、Puzzle / Puzzle Clear、Bark Battle、Match3D、Jump Hop、Wooden Fish、Square Hole、Visual Novel 与 Big Fish 的 63 张表定义及专属类型;同步删除只服务这些表的 `clear_retired_database_tables` procedure、固定清单、migration 导入导出项和旧行归一化分支,并重新生成 `spacetime-client` bindings。`runtime_setting`、`runtime_snapshot`、`user_browse_history`、`creation_entry_config` 等现役表和通用迁移能力保持不变。 -- 门禁处理:SpacetimeDB schema guard 只对本轮 63 个已确认 accessor 的从基线删除放行,其他表删除/改名以及字段级破坏性变更仍失败;该一次性白名单在删除结果进入后续主线基线后移除。生产发布仍必须单独完成冷备份、客户端兼容性和运行态确认,禁止用 SQL `DROP TABLE`、`--delete-data=always` 或系统表写入代替受控发布。 -- 影响范围:`server-rs/crates/spacetime-module/src/{active.rs,migration.rs}`、旧表专属 module/legacy schema 文件、`server-rs/crates/spacetime-client/src/module_bindings*`、schema guard 及后端数据契约。 -- 生产数据与发布闭环:`genarrative-prod` 在维护态、API/controller/worker 已停止时,以已授权 migration operator 执行 `clear_retired_database_tables`;apply 返回 63 张表且每张 `cleared_row_count == row_count_before`,共清空 5922 行。apply 前后两次 `dry_run=true` 均为 63/63 全零,第二次在 10 分钟观察窗口结束后执行。清空前完成 files-minimal OSS 备份、latest/catalog 验真和 restore dry-run;随后将 release 从 `2.7.0` 直接切换到本地锁定并有 commit 校验的 `2.8.3 / 8e410d…` 二进制,再用 source commit `05a5ea4d8534b3f96d4d462c6cfda9b0775073df` 的 wasm 发布。发布后 schema 为 84 张表、旧表为 0、`clear_retired_database_tables` 为 0;API/controller/worker 已恢复,维护退出,`genarrative.world` 与 `www.genarrative.world` 返回 200。 -- 验证方式:`npm run spacetime:generate` 生成 766 个 binding 文件;`cargo check -p spacetime-client -p api-server`、`cargo test -p spacetime-module migration`(21 passed)、`npm run check:server-rs-ddd`、`npm run check:spacetime-schema`(84 tables)、`npm run check:spacetime-runtime-access`、schema guard 单测(9 passed)、`npm run check:encoding`、`npm run check:doc-index` 与 `git diff --check` 通过。 - -## 2026-09-02 旧玩法表采用两阶段退役清理 - -- 背景:旧创作模板的业务代码已退出现役编译链,但 SpacetimeDB 中的历史表仍需先完成数据清理;直接删除表定义会扩大 schema 迁移和客户端兼容风险。 -- 决策:阶段一只在 `spacetime-module/src/migration.rs` 增加受 `database_migration_operator` 保护的 `clear_retired_database_tables` procedure。procedure 使用固定的 63 张旧玩法表清单,不接受动态表名;`dry_run=true` 只返回逐表行数统计,`dry_run=false` 在同一事务内逐表清空,任一失败整体回滚。阶段一不删除表定义、不修改 `legacy_schema/**`、migration 导入导出白名单或生成 bindings。 -- 阶段边界:清理清单包含旧 gameplay、`custom_world`、Puzzle / Puzzle Clear、Bark Battle、Match3D、Jump Hop、Wooden Fish、Square Hole、Visual Novel 和 Big Fish 表;`runtime_setting`、`runtime_snapshot`、`user_browse_history`、`creation_entry_config` 等现役表明确排除。阶段二只有在备份、客户端兼容性和运行态确认完成后,才评估从 module 定义与 migration 白名单移除空表,并按 schema / bindings 流程发布;固定清单旁保留 TODO。 -- 影响范围:SpacetimeDB migration procedure、`spacetime-client` 生成 bindings、后端数据契约和本决策记录;禁止新增 SQL `DROP TABLE`、`--delete-data=always` 或直接写系统表的实现。 -- 验证方式:固定清单测试确认数量为 63 且不含现役表;本地数据库以已授权 operator 执行 dry-run,确认 63 张表均返回 0 行且未写入;apply 的单事务回滚由 procedure 实现,实际 apply 仅在另行授权的维护窗口执行。另运行 bindings 生成、SpacetimeDB schema / runtime 检查、编码和 diff 门禁。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`server-rs/crates/spacetime-module/src/migration.rs`。 -- 补充落地:阶段一同时删除此前仅因退役而保留的旧玩法业务实现、未挂载 API handler/router/worker、旧客户端 facade/mapper、旧领域 crate、`retired/legacy-creation-templates/**` 归档及 `packages/shared/src/contracts/**` 中已无仓库内消费者的旧玩法公共契约;`legacy_schema/**`、生成表 bindings、现役 shared contracts 和持久化 schema 不在本次删除范围。 - ## 2026-08-31 DirectProject 客户端扩展按独立 Skill/MCP 导入 - 背景:DirectProject 需要使用用户在 AGC 客户端导入的市面原生 Skill、MCP 和 Plugin 内容,但第三方内容不应直接安装到运行时 Codex,也不应要求用户转换为 AGC 自定义格式。 @@ -2989,7 +2970,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 2026-06-20 桌面本地通知契约镜像:Tauri `notification.showLocal` 的 title / body 归一化、长度上限和成功结果 action 必须镜像共享 HostBridge 契约;Rust 侧常量使用 `HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH`、`HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH` 和 `HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION` 命名,桌面单端配置检查会与 `packages/shared/src/contracts/hostBridge.ts` 比对数值并反查成功结果由该 action 常量组装,避免通知 payload 边界变成桌面壳本地规则。 - 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5,WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。 -> 2026-07-18 覆盖说明:本段后续关于微信 `navigation.openNativePage`、生成结果订阅页、`[subscribe-message]` 日志和订阅页路由门禁的 2026-06 决策均已由旧创作模板退役决策废止,只作为历史记录。Expo / Tauri 的同源 H5 受控导航及微信登录、支付、分享能力继续有效。 - 2026-06-20 H5 原生导航预校验:`navigateHostNativePage()` 在 `native_app` 下发送 `navigation.openNativePage` 前必须先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续交给 Expo / Tauri 壳二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。根级 `npm run check:native-shells` 会反查 H5 facade 仍使用 `normalizeNativeAppPageUrl(...)` 且发送归一后的 URL,避免明显不安全目标触达原生壳。 - 2026-06-20 微信受控原生页能力声明:微信小程序壳真实 capability profile 声明 `navigation.openNativePage`,用于承接已经登记并测试的小程序原生页 flow;当前订阅生成结果通知页通过 H5 `requestGenerationResultSubscribePermission()` 调用 `navigateHostNativePage()` 打开 `/pages/subscribe-message/index`,小程序页再调用真实 `wx.requestSubscribeMessage` 并按既有结果协议回灌。根级 `npm run check:native-shells` 必须把该能力反查到共享 profile、微信 `WECHAT_HOST_CAPABILITIES` 镜像、订阅页协议常量、H5 入口、小程序 host-bridge / shell / page 文件和相关测试;该能力不代表开放任意小程序页面跳转。 @@ -3119,7 +3099,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-06-17 H5 宿主壳能力统一走 HostBridge -> 2026-07-18 覆盖说明:以下订阅授权、订阅页和旧玩法导航部分已退役;登录、支付、分享、九宫切图与通用 HostBridge 分层仍有效。 - 背景:主站同时运行在普通浏览器、微信小程序 `web-view` 和未来可能出现的原生 App WebView 中;登录、支付、分享、订阅授权和运行态分享目标同步曾散落在业务组件与服务文件里,后续新增宿主壳会导致同一业务重复分叉。 - 决策:前端宿主运行态识别、微信小程序 JS SDK 加载、原生页跳转、支付跳转、登录跳转、九宫切图和 `postMessage` 统一收口到 `src/services/host-bridge/hostBridge.ts`,业务层优先调用 `getHostRuntime`、`requestHostLogin`、`requestHostPayment`、`navigateHostNativePage`、`setHostShareTarget` 和 `openHostShareGrid`。`authService`、分享服务、订阅授权和个人中心充值可保留兼容导出或业务编排,但不再自行加载微信 JS SDK 或直接判断 `wx.miniProgram`。固定内置玩法不走代码包下载流程;AI 生成 H5 沙箱后续单独定义受限 `GameBridge`,不得直接暴露完整 `HostBridge`。 @@ -3177,14 +3156,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`cargo check -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`cargo test -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 - 关联文档:`docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md`。 -## 2026-06-10 公开作品互动能力进入后台全局配置 - -- 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。 -- 决策:公开作品点赞 / 改造能力作为 `creation_entry_config.public_work_interactions_json` 的全局矩阵保存,不进入单个 `creation_entry_type_config`。`GET /api/creation-entry/config` 下发 `publicWorkInteractions`;后台通过 `/admin/api/creation-entry/config/interactions` 按 `sourceType` 保存点赞、改造开关和关闭提示;api-server 只对已经接入后端动作的 RPG / custom-world、大鱼吃小鱼和拼图 like / remix 路由做同源熔断,公开列表、详情读取、已发布作品启动和运行态请求不受影响。 -- 影响范围:`CreationEntryConfigResponse`、`AdminCreationEntryConfigResponse`、`module-runtime` 默认矩阵、`spacetime-module` 表字段和 procedure、`spacetime-client` 绑定、后台入口开关页、平台作品详情点赞 / 改造意图解析。 -- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime public_work_interaction_config_defaults_and_overrides --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server public_work_interactions --manifest-path server-rs/Cargo.toml`、后台和前台作品详情互动相关前端测试。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - ## 2026-06-19 Jenkins Git 源统一为内网 SSH - 背景:本地和 Jenkins 流水线改用 Gitea 的 `/git` 前缀内网入口后,继续在 Jenkinsfile 内保留 `http://127.0.0.1:3000/...` 主地址和 `https://git.genarrative.world/...` fallback 会让构建节点误走 localhost 或公网链路。 @@ -3658,14 +3629,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`cargo test -p module-auth logout_current_session --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth refresh_from_snapshot_json_merges_session_created_by_another_process --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server logout_current_device_keeps_other_device_session_alive --manifest-path server-rs/Cargo.toml`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 -## 2026-06-07 跳一跳排行榜展示名禁止泄露内部身份键 - -- 背景:跳一跳排行榜曾在结果页和运行态失败弹窗里直接展示 `playerId` / `user_id`,用户可见内容暴露了内部身份键。 -- 决策:`jump_hop_leaderboard_entry.player_id` 只作为 SpacetimeDB read model 的去重和 `viewerBest` 匹配字段,HTTP 契约新增并强制使用 `displayName` 作为排行榜展示字段。api-server 出口按账号 `displayName` 补齐展示名;匿名 runtime guest 固定展示“游客玩家”;账号失效或不可解析时展示“失效玩家”;前端排行榜 UI 禁止兜底展示 `playerId` / `user_id`。 -- 影响范围:`packages/shared/src/contracts/jumpHop.ts`、`server-rs/crates/shared-contracts/src/jump_hop.rs`、`server-rs/crates/api-server/src/jump_hop.rs`、跳一跳结果页和运行态排行榜组件、跳一跳 PRD 与后端契约文档。 -- 验证方式:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx -t "排行榜"`、`npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx -t "排行榜"`、`cargo test -p api-server jump_hop_leaderboard_display_name_never_falls_back_to_player_id --manifest-path server-rs/Cargo.toml`。 -- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - ## 2026-06-07 generated 图片读取坚持 OSS 源站与签名缓存链路 - 背景:生成图片如果以完整 OSS 私有 bucket URL 进入前端,浏览器会裸连 OSS 并遇到 403 或绕过现有 `/api/assets/read-url` 签名缓存;同时旧对象缺少 `Cache-Control` 时只能走 `ETag` / `Last-Modified` 协商缓存,容易被误解为需要 api-server 本地磁盘缓存。 @@ -3692,16 +3655,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。 - 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 -## 2026-05-30 拼消消按独立玩法公开闭环接入 - -- 背景:拼消消以拼图交换手感为基础,但核心规则从“拼完整单图过关”变为“拼成多个复合图案组后逐个消除”,同时需要顶部补牌、防死局、半锁定局部拼接组和正式统计,不能继续复用拼图运行态规则本体。 -- 决策:`puzzle-clear` 作为独立玩法域接入,公开作品码前缀固定为 `PC-`;创作链路采用表单 / 图片输入工作台 -> 独立生成页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式 runtime。领域规则落在 `module-puzzle-clear`,SpacetimeDB 新增 `puzzle_clear_*` 表 / procedure / view,并接入统一 `public_work_gallery_entry` / `public_work_detail_entry`;前端只表现后端 snapshot/action 结果,不把胜负、补牌或消除裁决做成前端事实源。 -- 补充约束:草稿编译和发布都必须拒绝缺失或 `placeholder` atlas / card assets,不允许后端 facade 或 SpacetimeDB 合成临时素材;当前单关正式 runtime 终态事件使用 `run-finished`、`level-failed`,并写入包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs` 的结果 JSON。 -- 补充约束:拼消消结果页草稿试玩使用前端本地 `runtimeMode=draft` snapshot,不调用 `/api/runtime/puzzle-clear/runs`,不写正式 run 统计;公开详情和推荐流正式运行继续走后端 `/api/runtime/puzzle-clear/*`,客户端需要区分创作详情 `/api/creation/puzzle-clear/works/{profileId}` 与公开运行态详情 `/api/runtime/puzzle-clear/works/{profileId}`。 -- 影响范围:`CONTEXT.md`、拼消消 PRD / 技术方案、平台玩法链路文档、`shared-contracts` / `packages/shared`、`api-server`、`spacetime-module`、`spacetime-client`、作品架 / 广场 / 统一作品详情 / runtime 前端分流。 -- 验证方式:PRD 和技术方案必须覆盖资产槽位、素材工作表风险、切片验证、恢复语义、API 命名空间和验证命令;实现侧至少运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:server-rs-ddd`、`npm run typecheck`、`npm run check:encoding`、相关前端测试和 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`。 -- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - ## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链 - 背景:`Genarrative-Server-Provision` 的 `DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。 @@ -3838,20 +3791,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`npm run test -- src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md`。 -## 2026-06-03 平台入口公开作品详情 Strategy 收口 - -- 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。 -- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用不搬入 Module。 -- 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。 -- 追加决策:拼图公开详情封面解锁数由 `resolveVisiblePuzzleDetailCoverCount(entry, run)` 收口;非拼图、无当前 run 或 run 不匹配当前公开详情时只展示首图,匹配当前公开详情时按 `clearedLevelCount + 1` 解锁且至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。 -- 追加决策:公开详情点赞能力矩阵由 `resolvePlatformPublicWorkLikeIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、API 调用、缓存同步、错误展示和 busy 状态。 -- 追加决策:公开详情改造能力矩阵由 `resolvePlatformPublicWorkRemixIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、remix API、session / 缓存写入、stage 切换、错误展示和 busy 状态。 -- 追加决策:公开详情启动分流由 `resolvePlatformPublicWorkStartIntent(entry, deps)` 收口;Module 只返回大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪、宝贝识物或旧 RPG gallery 记录游玩的 intent。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示;抓大鹅 public detail -> work mapper 作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护素材归一与背景资产提升。 -- 追加决策:自有公开作品编辑分流由 `resolvePlatformPublicWorkEditIntent(entry, deps)` 收口;Module 只返回可编辑草稿目标、需解析宝贝识物本地草稿 intent、旧 RPG gallery 编辑 intent 或原阻断文案。壳层仍执行登录保护、草稿恢复、宝贝识物异步草稿解析、RPG 编辑导航和错误展示;抓大鹅 public detail -> work mapper 仍作为 Adapter 注入,不复制 Match3D 素材归一规则。 -- 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 -- 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 -- 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 - ## 2026-06-03 平台入口弹窗状态规则收口 - 背景:`PlatformEntryFlowShellImpl.tsx` 曾同时持有平台级错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key、后台生成 still-running 识别和任务完成文案,导致壳层 Interface 偏浅,测试面不稳定。 @@ -3868,14 +3807,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/llmClient.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。 - 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`。 -## 2026-06-03 平台入口公开作品流身份规则收口 - -- 背景:平台入口公开作品推荐流需要同时处理 RPG、拼图、抓大鹅、跳一跳、敲木鱼、视觉小说、Bark Battle、宝贝识物等卡片,公开作品身份、跨玩法去重、排序和推荐运行态 kind 判定曾放在 `PlatformEntryFlowShellImpl.tsx` 巨型实现里。 -- 决策:公开作品身份、排序规则、公开作品流聚合矩阵、推荐 runtime 启动意图和 ready 判定统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`buildPlatformPublicGalleryFeeds`、`resolvePlatformRecommendRuntimeStartIntent`、`isPlatformRecommendRuntimeReadyForEntry`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。公开作品流聚合负责 featured / latest、玩法可见性 gate、汪汪声浪 works fallback 和首屏 `slice(0, 6)`;推荐 runtime 启动 intent 只返回启动目标、`embedded` / `returnStage` 参数、阻断文案和错误落点;ready 判定只接布尔值与拼图 profile id,避免把各玩法 run snapshot 类型拖入 Module。壳层仍执行 request key、运行态 API、错误 setter 与 UI 状态。 -- 影响范围:平台入口推荐流、最新公开作品流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。 -- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 -- 关联文档:`docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md`。 - ## 2026-06-03 Work Shelf 打开动作交由 item Adapter - 背景:`creationWorkShelf.ts` 已经为每个 `CreationWorkShelfItem` 生成 `actions.open`,但 `CustomWorldCreationHub.tsx` 点击卡片后仍按 `item.source.kind` 重复分发 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物的打开逻辑。 @@ -3895,14 +3826,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`。 -## 2026-06-03 Public Gallery ViewModel 收口 - -- 背景:`RpgEntryHomeView.tsx` 巨型页面内混合了公开作品分类、跨来源去重、搜索归一化、作品号匹配、时间戳解析和排序规则,新增玩法时页面与 ViewModel 规则容易纠缠。 -- 决策:新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,把 `buildPublicGalleryCardKey`、`buildPublicCategoryGroups`、`getPlatformPublicEntries`、`getAllPlatformPublicEntries`、`getPlatformSearchableWorkIds`、`filterPlatformWorkSearchResults`、`isExactPublicWorkCodeSearch`、`filterTodayPublishedEntries`、公开卡片指标 getter、`buildPlatformRankingEntries`、`getPlatformRankingMetricValue`、`getPlatformCategoryKindFilter`、`matchesPlatformCategoryKindFilter`、`sortPlatformCategoryEntries`、`getPlatformCategoryPrimaryMetric`、`parsePlatformEntryTimestamp` 和 `getPlatformWorldTimestamp` 收口为公开作品 ViewModel Interface。公开作品 key 复用平台入口身份规则,补齐 jump-hop / wooden-fish 等玩法区分。 -- 影响范围:RPG 首页公开作品发现、分类、搜索、排行数据准备,以及后续新增玩法公开卡片接入。 -- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 -- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`。 - ## 2026-06-03 Profile Task ViewModel 收口 - 背景:`RpgEntryHomeView.tsx` 同时持有每日任务卡片和任务中心弹窗的任务选择、进度 clamp、奖励兜底、状态标签和按钮文案,导致任务展示规则和 JSX 缠在一起。 @@ -3959,15 +3882,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop result route"`;手测 `/creation/jump-hop/result` 和 `/creation/jump-hop/result?profileId=`。 - 关联文档:`docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md`、`quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 2026-05-29 一期统一创作页必须提供可见统一外壳 - -- 背景:`UnifiedCreationPage` 首版只暴露隐藏 spec 元数据并包裹旧玩法工作台,用户打开拼图创作页时仍只能看到旧工作台外观,无法验收“统一创作页”。 -- 决策:一期统一创作页(拼图、抓大鹅、敲木鱼)必须由 `UnifiedCreationPage` 提供统一标题栏、内容区、页面级纵向滚动和隐藏字段契约;字段元信息只留给测试和代码,不再额外作为可见 chip 占用首屏。玩法工作台只承载具体输入控件、上传、历史素材、校验和提交,不再各自渲染巨大入口标题。拼图、抓大鹅与敲木鱼的实现已经统一收口到 `src/components/unified-creation/workspaces/`,统一壳只依赖 `UnifiedCreationWorkspace`。敲木鱼右侧音效和功德面板不得再套内部滚动容器,移动端应自然跟随页面滚动。 -- 追加决策:`UnifiedCreationPage` 自己负责页面级滚动;拼图、抓大鹅、跳一跳和敲木鱼四条统一创作入口必须在同一页面壳内从统一标题、表单控件一路滑到提交按钮,避免工作台内部或右侧面板形成套滚动。 -- 影响范围:`src/components/unified-creation/UnifiedCreationPage.tsx`、`src/components/unified-creation/UnifiedCreationWorkspace.tsx`、`src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx`、`src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx`、`src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、玩法链路文档。 -- 验证方式:`UnifiedCreationPage` 测试应断言隐藏契约仍在但 UI 不再出现字段 chip;拼图和抓大鹅工作台测试应断言 `unifiedChrome=true` 时不再渲染旧巨大标题且仍保留表单输入;木鱼工作台测试或手测应确认敲击音效和功德词条不再停留在独立滚动窗内。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - ## 2026-05-31 统一创作壳扩展到跳一跳并接管页面级滚动 - 背景:最初的统一创作页只收口拼图、抓大鹅和敲木鱼,跳一跳仍通过独立工作台壳与独立生成壳渲染,导致用户在 `/creation/jump-hop` 看到的可见外壳与其它统一入口不一致。 @@ -4059,14 +3973,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:玩法接入 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-26 统一公开作品主读模型收口 - -- 背景:各玩法原有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 已经足够承载各自 source 投影,但公开列表 / 详情在 `api-server` 侧分散拼装会继续放大重复逻辑和契约漂移。 -- 决策:新增跨玩法统一公开主读模型 `public_work_gallery_entry` 与 `public_work_detail_entry`。各玩法旧公开 view 不删除,退为 source / 兼容路径;`api-server` 公开列表与详情主路径统一读 public view cache,再映射回现有 HTTP DTO。前端首期仍不直接订阅 SpacetimeDB,只走 BFF HTTP。 -- 影响范围:`server-rs/crates/spacetime-module`、`server-rs/crates/spacetime-client`、`server-rs/crates/api-server`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。 -- 验证方式:`SELECT * FROM public_work_gallery_entry` 与 `SELECT * FROM public_work_detail_entry` 可作为 `api-server` 长期订阅目标;`/api/public-works` 与 `/api/public-works/{publicWorkCode}` 走统一 cache;旧 `/api/runtime//gallery` 响应 shape 保持兼容。 -- 关联文档:`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - ## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态 - 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。 @@ -4155,14 +4061,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:新生成概念图必须满足“马路是主脊线、建筑群成街区聚合、左右边缘可延展、无品牌乐园元素”四项约束;若图面再跑回环形乐园或漂浮岛,需要重新收敛 prompt。 - 关联文档:`docs/design/【前端体验】寓教于乐Toca式横向世界地图入口概念图-2026-05-23.md`、`scripts/generate-edutainment-road-town-map-concepts.mjs`、`output/imagegen/edutainment-road-town-map-concepts-20260523/`。 -## 2026-05-22 敲木鱼图片创作采用三图 image2 链路 - -- 背景:敲木鱼自定义题材只生成中央敲击物时,运行态缺少与新主题匹配的竖屏背景和主题化返回按钮;若直接让背景 prompt 自由发挥,又容易把敲击物或木槌画进背景里。 -- 决策:敲木鱼 `compile-draft` / `regenerate-hit-object` 图片链路固定为三步 image2 edits。第一步调用 VectorEngine `/v1/images/edits` + `gpt-image-2`,以默认木鱼图作为结构和画风参考,用户上传参考图只作为同次请求的新主题参考,结合用户题材关键词或参考图主题生成 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图;`api-server` 先对这张绿幕图执行去绿背景处理并写回 `hitObjectAsset`。第二步必须以第一步抠图完成后的透明敲击物图作为参考,结合用户原始题材生成 `9:16` 背景环境图并写回 `backgroundAsset`,避免背景图继承绿幕或纯绿色画布。第三步必须以去绿后的敲击物主体图和背景环境图为参考,生成 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景返回按钮图,服务端去绿后写回 `backButtonAsset`。三步 prompt 使用 PRD 中固定隐藏关键词,不追加额外 negative prompt;返回按钮只允许参考图约束圆形底色和箭头配色,不允许继承复杂造型、花纹、浮雕边、异形外框或装饰图案,主体视觉尺寸比当前模板再放大约 50%,并带主题色外描边;背景图不得包含敲击物本体或木槌互动物品,返回按钮图不得包含文字、数字、水印或额外 UI 面板。 -- 影响范围:`api-server` 木鱼图片生成编排、`wooden_fish_work_profile.background_asset_json`、`wooden_fish_work_profile.back_button_asset_json`、shared contracts、前端结果页 / 运行态背景与返回按钮展示、敲木鱼 PRD 和平台链路文档。 -- 验证方式:执行 `cargo test -p api-server wooden_fish --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-client wooden_fish --manifest-path server-rs/Cargo.toml`、`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run typecheck`。 -- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - ## 2026-05-25 通用系列素材图集实现下沉到 platform-image - 背景:`generated_asset_sheets` 同时承载 sheet prompt、切图、绿幕去背、边缘 matte 清理和 OSS 持久化准备,长期放在 `api-server` 会把多个玩法的图片 seam 继续绑死在 HTTP crate 上。 @@ -4304,7 +4202,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:生产和容器环境需要默认把 OTLP 接到本机 Collector,但压测或排障时也要能显式关闭。 - 决策:生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;生产 endpoint 用 `http://127.0.0.1:4318`,容器 endpoint 用 `http://otelcol:4318`;`OTEL_EXPORTER_OTLP_ENDPOINT` 只填 Collector HTTP base endpoint,不填 gRPC `4317` 或 Rider 端口;本地日志、Nginx 日志和 `GENARRATIVE_API_LOG` / `RUST_LOG` 仍保留。 -- 影响范围:`deploy/env/api-server.env.example`、`deploy/container/api-server.env.example`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/loadtest/README.md`。 +- 影响范围:`deploy/env/api-server.env.example`、`deploy/container/api-server.env.example`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - 验证方式:检查 env 模板默认值与端点口径;压测若要关闭 OTLP,必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`。 - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/run-otelcol.mjs`。 @@ -4314,7 +4212,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:`deploy/container/docker-compose.loadtest.yml` 里的 `otelcol` 支持通过 `GENARRATIVE_CONTAINER_OTELCOL_CONFIG=./otelcol.grafana.yaml` 切换配置;`deploy/container/otelcol.grafana.yaml` 同时保留 debug exporter,并通过 `GRAFANA_CLOUD_OTLP_ENDPOINT` 和 `GRAFANA_CLOUD_BASIC_AUTH_HEADER` 转发到 Grafana Cloud。 - 影响范围:`deploy/container/docker-compose.loadtest.yml`、`deploy/container/otelcol.grafana.yaml`、`deploy/container/README.md`。 - 验证方式:容器 `otelcol` 启动日志应能看到 OTLP receiver ready,debug exporter 仍可输出本地链路;Grafana Cloud 转发凭据只通过当前 shell 环境变量传入,不写入 Git。 -- 关联文档:`deploy/container/README.md`、`scripts/loadtest/README.md`。 +- 关联文档:`deploy/container/README.md`。 ## 2026-05-17 容器化方案只作为隔离压测与预发模拟路径 @@ -4338,14 +4236,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:Jenkins 日志应先出现 Windows 节点的 `[jenkins-powershell] workspace:`、`[jenkins-powershell] loaded bytes:` 和 `[prepare-provision-downloads]` 下载日志,再在 `genarrative-build-01` 上出现“使用已下载的 ...”日志;目标机不应出现直接访问 `install.spacetimedb.com` 或 OpenTelemetry GitHub release 下载地址的回退日志,且不再需要 `spacetimedb-update-*` 作为离线交付包。 - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 -## 2026-05-19 公开 gallery 入口发布限流以快拒绝保护后端 - -- 背景:容器 2C / 2G 压测中,公开作品列表在约 5000 HTTP req/s 目标下可以保持 200 请求低延迟,但 SpacetimeDB 内存会随 api-server 重连和高压请求累积到容器上限附近。 -- 决策:发布配置采用公开 gallery list 专用入口限流:Nginx `genarrative_gallery_rps rate=5000r/s`、`burst=4096`、gallery list `limit_conn=320`;api-server 对应 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`,公开详情维持更低的 `GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`。超过容量时接受明确 `429`,不继续扩大入口并发。 -- 影响范围:`deploy/nginx/` 发布模板、`deploy/env/api-server.env.example`、`deploy/container/` 隔离压测模板和生产运维文档。 -- 验证方式:容器连续 10 轮不重启 SpacetimeDB 压测,`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,总计 `0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;同时观察 SpacetimeDB 内存高水位,后续优化先处理连接 / 订阅 / tracking 下游状态。 -- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`deploy/container/README.md`。 - ## 2026-05-19 新增玩法创作工具平台 SOP 冻结 - 背景:新增玩法的创作工具如果默认复制既有玩法的聊天式 Agent、轻输入 Agent 或专属素材模型,平台会不断复制出不可控分支,后续接入、测试和恢复语义都会漂移。 @@ -4386,14 +4276,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`npm test -- src/services/wooden-fish/woodenFishClient.test.ts`,并在本地敲木鱼创作时不再提前触发 15 秒超时。 - 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 2026-05-21 RPG publish_world 设定文本以后端草稿真相派生 - -- 背景:RPG 结果页发布动作只保证提交 `{ action: 'publish_world' }`;旧 agent 会话可能没有 `seed_text`,但 `draft_profile_json` 已经通过 `publish_gate` 并可发布。 -- 决策:发布正式世界时,`spacetime-module` 不再把 `session.seed_text` 当作唯一 `setting_text` 兜底,而是调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)` 从 payload、当前草稿 profile 和 seed 依次派生。 -- 影响范围:RPG / custom-world agent 发布链路、`custom_world_profile` 编译入库、公开 gallery 投影。 -- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/project-memory/shared-memory/pitfalls.md`。 - ## 2026-05-19 系列素材 n\*n 图集抽为 api-server 通用模块 - 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。 @@ -4402,31 +4284,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`cargo test -p api-server generated_asset_sheets --manifest-path server-rs\Cargo.toml -- --nocapture` 覆盖通用 prompt、切片、`n` 校验和 prompt 元数据;玩法侧执行对应素材流水线定向测试。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 -## 2026-05-19 跳一跳玩法采用正式 scoring DTO 与 public view 投影 - -- 背景:跳一跳玩法新增后,前端、shared-contracts、SpacetimeDB 生成绑定和后端 mapper 对 scoring 字段口径不一致,schema guard 也要求 table / view 目录与 `migration.rs` 同步。 -- 决策:跳一跳的 `JumpHopScoring` 统一采用 `chargeToDistanceRatio/maxChargeMs/hitBonus/perfectBonus`,公开广场优先使用 `jump_hop_gallery_card_view`,详情兼容投影保留 `jump_hop_gallery_view`。`spacetime-module` 新增的 `jump_hop_*` table 必须同步进入 `migration.rs` 和后端架构文档。 -- 影响范围:`packages/shared/src/contracts/jumpHop.ts`、`server-rs/crates/shared-contracts/src/jump_hop.rs`、`server-rs/crates/spacetime-client/src/mapper/jump_hop.rs`、`server-rs/crates/spacetime-module/src/migration.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 -- 验证方式:`cargo check -p shared-contracts --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`。 -- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - -## 2026-05-16 公开作品列表短期由 BFF 订阅读模型缓存 - -- 背景:作品列表压测和实时性讨论中,曾考虑让浏览器前端直接订阅公开作品列表,减少 HTTP 拉取和 BFF 压力。 -- 决策:本轮不直接把作品列表整体交给前端订阅。短期继续由 `api-server` / BFF 通过 `spacetime-client` 长期订阅 SpacetimeDB 公开 read model 并读取本地 cache,维持首屏、排序、字段归一、权限降级和 HTTP fallback。中期可以新增或统一稳定的专用公开作品列表 read model,例如 `public_work_gallery_entry`,作为前端可选直连订阅对象。 -- 边界:未来前端直订阅只允许面向稳定、低基数、公开的专用 read model。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得在前端自行 join、聚合或执行公开权限逻辑;这些逻辑必须先沉到后端投影 / read model。 -- 后续准入:若要落地前端直订阅,必须先完成并验收权限边界、字段契约、排序 / 分页、埋点和 BFF 回退策略;缺任一项时继续走 `api-server` / BFF 订阅缓存方案。 -- 影响范围:发现页、推荐流、各玩法公开广场、`api-server` 公开列表缓存、SpacetimeDB public view / public 读模型设计。 -- 验证方式:新增公开作品列表订阅能力时,检查前端只消费专用 public read model 或 BFF HTTP DTO;检查源表 row shape、权限判断和跨玩法聚合没有下沉到前端页面。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - -- 背景:压测与运行观测需要把 HTTP、SpacetimeDB 调用和应用日志串起来,同时保留本地 `journalctl` / 文件日志做故障排障。 -- 决策:`api-server` 通过 OTLP HTTP base endpoint 发送 traces、metrics 和 logs;Collector 统一用 `otelcol-contrib`,`npm run otel:debug` 负责 debug 采集,`npm run otel:rider` 负责转发到 Rider;Rider 只是接收与可视化端,不直接替代 Collector。 -- 日志口径:Rider Logs 面板只展示 log event 自身字段,请求完成日志需要直接携带 `request_id`、HTTP method、规范化 route、scheme、path、status、status_class、latency 和 slow_request;更完整的 request attributes 仍以 trace/span 为准。 -- 影响范围:`server-rs/crates/shared-logging`、`server-rs/crates/api-server`、`scripts/run-otelcol.mjs`、压测与运维文档。 -- 验证方式:`cargo test -p shared-logging --manifest-path server-rs/Cargo.toml generic_otlp_http_endpoint_expands_to_signal_paths`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml observability_route_keeps_metrics_labels_low_cardinality`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml resolve_request_scheme_uses_forwarded_proto_first_value`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 -- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/loadtest/README.md`。 - ## 2026-05-14 创作页图像输入统一封装为图像组件 - 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。 @@ -4622,14 +4479,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:执行拼图/抓大鹅结果页定向测试、`npm run typecheck`、`cargo test -p api-server vector_engine_audio_generation`、`cargo test -p shared-contracts creation_audio`、`cargo check -p api-server`,真实生成需配置 VectorEngine 与 OSS 私密环境。 - 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`。 -## 2026-05-11 寓教于乐公开作品使用独立 `edutainment` 来源接入 - -- 背景:`宝贝识物` 首关需要通过创作模板发布后进入寓教于乐板块,同时关闭入口时必须从发现页、搜索、详情深链、作品号和历史入口完全不可见;若继续落入 RPG 默认公共作品链路,容易出现误启动、误改造或近似标签误归类。 -- 决策:寓教于乐公开作品在前端公共作品模型中使用 `sourceType = edutainment`,当前只承接 `templateId = baby-object-match`、`templateName = 宝贝识物`;进入“发现 / 寓教于乐”频道仍必须携带精确等于 `寓教于乐` 的公开标签,不因模板名或近似标签自动归类。公开详情、推荐运行态、改造、编辑、点赞和分享链路都必须显式识别 `edutainment`,不得回落到 RPG 默认处理。 -- 影响范围:公开作品卡、发现页频道、作品号搜索、公开详情深链、分享、作品架聚合、后续儿童动作 Demo 模板的发布结果展示。 -- 验证方式:执行第4线程定向单测、前端类型检查、ESLint 与编码检查;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时确认精确 `寓教于乐` 作品不可通过任何公开入口访问。 -- 关联文档:`docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`、`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。 - ## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 - 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。 @@ -4791,14 +4640,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:后续落地时扫描前端、后端、契约、表和文档,确认不存在 `replay` 能力;确认视觉小说没有迁入外部平台账号、订单、会员、促销、后台、公开市场或私有存档系统;确认后端落在 `server-rs + Axum + SpacetimeDB`。 - 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md`、`docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md`。 -## 2026-05-05 视觉小说 VN-02 表与 spacetime-client facade 收口 - -- 背景:`visual-novel` 后续 API、创作工作台和运行时需要稳定的 SpacetimeDB schema 与 Rust facade,且必须延续“无回放、无私有存档”的产品边界。 -- 决策:视觉小说首批数据库只落六张表:`visual_novel_agent_session`、`visual_novel_agent_message`、`visual_novel_work_profile`、`visual_novel_runtime_run`、`visual_novel_runtime_history_entry`、`visual_novel_runtime_event`;`visual_novel_runtime_event` 是 `public event` 审计事件表,不是 replay 数据源;运行历史只保存继续体验与历史重生成需要的 typed step 和快照哈希。`api-server` 后续接入必须经 `spacetime-client/src/visual_novel.rs` typed facade,不直接依赖生成 bindings。 -- 影响范围:`server-rs/crates/spacetime-module/src/visual_novel.rs`、`migration.rs`、`server-rs/crates/spacetime-client/src/visual_novel.rs`、`module_bindings/`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`、VN-05 API 联调。 -- 验证方式:执行 `npm run spacetime:generate -- --rust-only`、`cargo check -p spacetime-module`、`cargo check -p spacetime-client`、`npm run check:encoding`;扫描视觉小说 schema / facade / 表目录确认没有 `replay` 表、路由或私有 save 表。 -- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 - ## 2026-05-05 视觉小说 VN-07 前端创作闭环按阶段边界落地 - 背景:`visual-novel` 模板需要先完成创作工作台与结果页,真实生成和正式玩家 runtime 仍依赖 VN-05 后端路由。 @@ -4847,14 +4688,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:移动端推荐页首屏应明显看到更大的作品内容区,底部作者信息区只保留紧凑一条,不再明显挤压运行态。 - 关联文档:`docs/technical/PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md`。 -## 2026-05-05 创作 Tab 固定为智能创作首页,草稿 Tab 承接旧作品架 - -- 背景:创作首页需要变成面向对话式生成的智能创作页,旧模板卡和作品架继续保留但不应再占据创作首屏。 -- 决策:`create` 只承载 `CreativeAgentHome` 智能创作首页与会话流,顶部品牌栏、问候、快捷胶囊、底部输入框和左侧抽屉是主结构;旧的新建作品类型卡不再在 `create` 里展示。原本的 RPG / 拼图 / 大鱼 / Match3D / 方洞 / 视觉小说作品架统一归到 `saves` 草稿 Tab。 -- 影响范围:平台创作页布局、创作首页抽屉、草稿页作品架、相关交互测试、旧创作入口 helper。 -- 验证方式:移动端点击“创作”直接看到智能创作首页;点击“草稿”看到旧作品架;旧模板入口不再从创作页出现。 -- 关联文档:`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`。 - ## 2026-05-05 创意互动内容生成 Agent 采用 LangChain-Rust 六模块闭环 - 背景:需要支持用户输入文字、图片或文档后,先理解创作意图,再从多个模板候选中选择一个,并把内容填入拼图等目标玩法草稿契约中。 @@ -4871,14 +4704,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:`cargo check -p platform-agent`、`cargo test -p platform-agent`、`cargo test -p platform-llm responses_multimodal` 已通过。 - 关联文档:`docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md`。 -## 2026-05-05 creative-agent Task E API / SSE facade 已落地 - -- 背景:Phase 1 需要先把创意 Agent 的 HTTP/SSE 门面接入 Rust `api-server`,用于前端工作区调用和拼图模板确认闭环。 -- 决策:`api-server` 挂载 `/api/runtime/creative-agent/*` 六个鉴权路由;creative session 在 Task D 表未收口前暂存在 `api-server` 运行态并按 authenticated user 校验 owner;未确认模板前不创建拼图 session,`confirm-template` 后才通过既有 `spacetime-client` 创建/编译 `puzzle_agent_session`;当时 `gpt-5` 请求只从 `APIMART_BASE_URL` / `APIMART_API_KEY` 构造专用 Responses client,不复用通用 `GENARRATIVE_LLM_API_KEY`。该 LLM 来源已被 2026-07-05 VectorEngine Chat Completions `gpt-5.4-mini` 决策覆盖。 -- 影响范围:`server-rs/crates/api-server/src/creative_agent.rs`、`creative_agent_sse.rs`、`app.rs`、`state.rs`、`module-puzzle` creative template/tool、Phase 1 PRD。 -- 验证方式:`cargo check -p api-server`、`cargo test -p module-puzzle creative`、`cargo test -p api-server creative_agent`、`npm run dev:api-server` 后检查 `/healthz`、`POST /api/runtime/creative-agent/sessions`、`POST /api/runtime/creative-agent/sessions/{sessionId}/messages/stream`。 -- 关联文档:`docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`。 - ## 2026-05-10 视觉小说入口收敛为单句创作 + 画风选择 - 背景:视觉小说入口页要对齐抓大鹅式的线性创作入口,只保留最小可用输入,避免再暴露文档 / 空白 / 对话式工作台。 @@ -5082,14 +4907,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证方式:优先检查 `src/index.css` 与 `apps/admin-web/src/styles/admin.css` 是否还存在旧粉色主色;再用编码检查和可执行的本地 typecheck / build 验证。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 -## 2026-05-20 汪汪声浪 v1 公开闭环计划 - -- 背景:Bark Battle v1 需要把创作、生成、结果、发布、详情和正式运行态收成一条闭环,避免把草稿试玩、公开广场和正式成绩混在一起。 -- 决策:`bark-battle` 入口改为 6 字段表单(作品标题、简介、主题 / 竞技背景描述 `themeDescription`、玩家形象描述、对手形象描述、难度);提交后进入 `bark-battle-generating` 独立生成页,自动生成玩家形象、对手形象和竞技背景三图,部分失败也继续进入结果页。旧“角色设定 / 狗狗皮肤预设 / themePreset”统一退场,配置和文档只使用“形象描述 / themeDescription”。结果页只保留单槽重试、重新生成和上传,不再保留一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布后先跳统一作品详情页 `/works/detail?work=BB-xxxxxxxx`,再由详情页进入正式 `published` runtime;正式 runtime 必须真实麦克风,`draft` 可试玩、可 mock 且不写正式统计。公开广场统一读取 `bark_battle_gallery_view` read model。 -- 影响范围:`BarkBattleConfigEditor`、`BarkBattleGeneratingView`、`BarkBattleResultView`、`BarkBattleRuntimeShell`、`PlatformEntryFlowShellImpl`、`appPageRoutes`、Bark Battle creation/runtime client、公开广场聚合与相关交互测试。 -- 验证方式:提交表单后先进入生成页;生成页部分失败仍能落到结果页;结果页只出现单槽重试 / 重新生成 / 上传;发布后先到 `/works/detail?work=BB-xxxxxxxx` 再进正式 runtime;正式 runtime 会要求麦克风并写基础统计,草稿试玩可 mock 且不写正式 run;公开广场读取 `bark_battle_gallery_view`。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - ## 2026-05-22 汪汪声浪运行态与作品外显信息收口 - 背景:Bark Battle v1 在正式运行态、图片生成提示词和作品外部卡片上仍存在体验漂移:能量条推满后还要等计时结束、进入正式 runtime 后还要二次点击声控、角色形象 prompt 会默认注入狗主体、草稿 / 已发布卡片外部看不到创作者。 @@ -5104,12 +4921,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:默认入口改为 `visible=true`、`open=true`、`badge=可创建`,参考图固定为 `/creation-type-references/bark-battle.webp`。系统默认迁移只纠偏未被后台人工改过的汪汪声浪入口。发布后先进入统一作品详情页 `/works/detail?work=BB-xxxxxxxx`;正式 runtime 使用 `runtimeMode=published` 并必须真实麦克风,调用 `startBarkBattleRun` / `finishBarkBattleRun` 写正式 run;草稿结果页试玩仍使用 `runtimeMode=draft`,允许 mock 且不写正式 run。 - 验证方式:入口配置响应应返回汪汪声浪可创建和专属参考图;发布后地址应为 `/works/detail?work=BB-xxxxxxxx`;草稿试玩不调用 runtime run API;正式 runtime 无麦克风时不登记正式 run,结算后提交派生指标。 -## 2026-05-20 汪汪声浪生成页负责三图自动生成 - -- 背景:结果页承载预览、修补和发布,若继续放“一次生成”按钮会把初始生成和结果修补职责混在一起。 -- 决策:初始三图生成改由 `bark-battle-generating` 独立生成页自动执行,目标槽位只有玩家形象、对手形象和竞技背景;表单术语统一为 `themeDescription`、玩家形象描述和对手形象描述,不再回退 `themePreset`、狗狗皮肤预设或“角色设定”。部分失败也进入结果页。结果页不再提供一次生成按钮,音频配置和排名配置不进入 v1 公开闭环;结果页只保留单槽重试、重新生成和上传。发布时 SpacetimeDB `bark_battle_published_config.config_json` 使用规范化后的最终 `publishedSnapshot`,`published_snapshot_json` 同步保存同一份快照。 -- 验证方式:表单提交后进入 `bark-battle-generating`;结果页不会出现一次生成按钮、音频槽、皮肤预设入口或排名配置;Bark Battle 发布后正式 runtime 应读取结果页最终图片素材而不是初始草稿素材。 - ## 2026-05-24 敲木鱼结果页先补录作品信息再试玩 / 发布 - 背景:敲木鱼工作台只应保留生成所需输入,作品标题、简介和主题标签适合放在生成草稿后的补录阶段。 @@ -5436,10 +5247,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-06-11 资产计费边界改为 fail-closed 并补偿退款 -- 背景:图片 / 资产生成入口曾在钱包或 SpacetimeDB 预扣费连通性异常时允许继续生成,且失败后同步退款如果遇到 SpacetimeDB 短暂不可用缺少本地补偿;拼图首图后台任务还使用 api-server 进程内 HashSet 互斥,多实例下不能防重复。 -- 决策:暂不实现 token 限流。所有资产生成预扣费改为 fail-closed,预扣费失败直接返回错误;支持 retry 的计费 ledger id 统一包含 HTTP `request_id`,前端静默刷新重试复用同一个 `x-request-id`。生成失败后的退款先同步调用 SpacetimeDB,失败则写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。拼图首图后台生成互斥改为 SpacetimeDB `puzzle_background_compile_task` 表,使用 `task_id + request_id` 作为 claim id,释放时校验 claim id,避免旧任务误删新租约。 -- 影响范围:`api-server` 资产计费包裹、钱包退款补偿、拼图首图后台生成、`spacetime-module` 拼图 task 表、`spacetime-client` bindings/facade、前端 API request id 复用和后端架构文档。 -- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`node scripts/check-server-rs-ddd-boundaries.mjs`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml wallet_refund_outbox`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml asset_operation`、`npm run test -- src/services/apiClient.test.ts`、`npm run check:encoding`。 +- 背景:图片 / 资产生成入口曾在钱包或 SpacetimeDB 预扣费连通性异常时允许继续生成,且失败后同步退款如果遇到 SpacetimeDB 短暂不可用缺少本地补偿。 +- 决策:暂不实现 token 限流。所有资产生成预扣费改为 fail-closed,预扣费失败直接返回错误;支持 retry 的计费 ledger id 统一包含 HTTP `request_id`,前端静默刷新重试复用同一个 `x-request-id`。生成失败后的退款先同步调用 SpacetimeDB,失败则写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。 +- 影响范围:`api-server` 资产计费包裹、钱包退款补偿、`spacetime-client` 调用边界、前端 API request id 复用和后端架构文档。 +- 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml wallet_refund_outbox`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml asset_operation`、`npm run test -- src/services/apiClient.test.ts`、`npm run check:encoding`。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 ## 2026-06-11 图片画布编辑器作为独立画布工程接入 @@ -6714,17 +6525,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 响应式边界:小于等于 `980px` 时仍隐藏桌面侧边栏,主内容在视口高度内滚动,底部导航继续固定。 - 验证方式:`apps/admin-web/src/styles/admin.test.ts` 锁定壳层滚动契约;桌面浏览器滚动后应保持 `window.scrollY = 0`、侧边栏 `top = 0`,只改变 `.admin-content.scrollTop`;移动视口继续由 `.admin-content` 滚动。 -## 2026-07-13 公开作品资产使用派生精确读授权 - -- 背景:资产 ACL 严格执行后,已登记为 `private` 的作品封面和正式资产不能再依赖 generated 前缀匿名读取;但公开作品仍需要允许访客读取它实际展示和运行的资产。 -- 决策:已登记 `asset_object` 继续保持 `private`,新增匿名派生 view `public_work_asset_read_grant`。view 只从 `Published + visible` 作品(`custom-world` 另要求未删除)正式发布快照中收集实际使用的资产,历史作品随 view 计算自动补齐;资产读取 procedure 在同一事务快照内先取资产 owner,再使用各玩法 owner 索引定向计算该作者的 grant,不为每张图片执行全站 view,也不从连接级长期订阅 cache 判断 ACL。 -- 授权边界:grant 携带作品 owner,API 只有在它与 `asset_object.owner_user_id` 一致,且 `asset_object_id` 或精确 `object_key` 命中时才允许匿名读取。隐藏、删除或取消发布会使 grant 自动消失;参考图、未选中候选图和 `generationInputs` 明确排除。Custom World 只遍历角色、地标、营地、章节和 opening CG 等已知正式根,不能递归 legacy payload 的未知预览 / 编辑字段。 -- 禁止项:不得通过放开 `generated-*` 前缀或批量把历史对象改为 `PublicRead` 修复公开作品,两种方式都会让作品可见性生命周期与资产授权脱节,并重新引入跨账号读取。 -- 影响范围:`module-assets` 公开资产授权判定、`spacetime-module` 跨玩法公开资产 view 与权威读取 procedure、`spacetime-client` facade 和 `api-server` 资产读取 ACL。 -- 权威查询:`asset_object` 不进入 client 长期订阅。API 通过仅 runtime service identity 可调用的 procedure,按主键或 `(bucket, object_key)` 服务端索引读取事务内 metadata;只有位置查询明确返回不存在时才允许进入 legacy curated 前缀兼容,procedure 失败、超时或重复位置一律失败关闭。 -- 一致性:隐藏、删除或取消发布提交后,后续读取 procedure 的事务快照立即按新状态判断,不等待任意池连接追上订阅水位。公开派生授权、`PublicRead` 和 legacy 兼容读取签名 URL 的有效期最多 600 秒,因此该能力仍不是对既有签名的瞬时吊销机制;owner / admin 读取保持原有效期口径。 -- 验证方式:公开可见作品的正式资产可匿名读取;未选候选图、参考图、跨 owner 伪造 key 仍返回不存在;隐藏、删除或取消发布后新的读取请求立即拒绝,再恢复公开可见时新的读取请求立即恢复;超长公开 `expireSeconds` 被截断为 600 秒。 - ## 2026-07-13 AI 游戏创作 Agent Runtime V1.4 Git 工作树审阅 - 决策:新增一等只读 `git.inspect`,共享 command id 为 `project.git_inspect`且默认 `auto`。工具只接受 `includeDiff / maxFiles / maxChars`,返回精确 Git top-level 的 HEAD / branch、staged / unstaged / untracked 安全路径和有界 staged / unstaged unified diff;不改项目 revision 或 verification gate。 @@ -7172,12 +6972,6 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验收边界:确定性测试必须分别覆盖 base handoff 与 repair handoff 在 lifecycle completed 前停止,关闭 mock Provider 后恢复零网络、原 requestId 唯一闭合、repair/protocol audit 幂等、唯一 assistant/completed/committed stream和终局零 sidecar。独立非默认真实 suite `supervisor-swarm-tool-plan-handoff-runner-kill` 已实现并完成 Shell/Root 两级注册;它只使用 sentinel-owned sibling AppData 与 metadata-only zero-fault proxy,每轮随机 capability 严格绑定 project/Agent/run/实际 request slot。断点只能在 handoff 原子落盘并回读一致后、同一实际 requestId lifecycle `completed` 前 ACK,ACK 后才通过 pidfd `SIGKILL` 强杀 suite 自有 Runner。恢复必须在同一轮证明同一 requestId 唯一闭合、`networkReplayCount=0`、protocol/repair audit 幂等、handoff 与 durable batch plan fingerprint 对应、恢复消费前 action/pending/delivery/claim 等副作用为 `0`,并在终局得到零 sidecar、零重复、零临时资源残留和零正文/凭据/URL/绝对路径泄漏。2026-07-20 的真实单轮已经到达并通过上述 checkpoint,但随后因专业 Agent 连续连接失败而整轮 FAIL;另一独立轮因首批工具数不满足 fixture 也未通过,不能拼接为 PASS。Provider 成功到 handoff 原子落盘回读前的 unknown-result 及手动 context-compaction 仍不在本决策承诺内。 - 当前证据:`tool_plan_`、`tool_plan_handoff_`、`provider_handoff_`、`provider_retry_`、`response_stream_`、`finalization_` 与 `finalization_resume_` 定向门禁均保持通过;本轮 `tool_plan_handoff_` 为 `44/44`,Supervisor collaboration 相关过滤为 `55/55`,权威返工合同用例为 `1/1`。Tauri/Rust 串行全量 1058 tests 为 `1054 passed / 4 ignored / 0 failed`,Linux `cargo check --tests` 与 `x86_64-pc-windows-gnu cargo check --tests` 均通过;E2E self-test、typecheck、变更脚本 ESLint、encoding 和 `git diff --check` 通过。默认并发全量只作竞态诊断,不替代 `--test-threads=1`。实现过程中发现并修复 thinking 归一化、源码路径误判、repair 漂移删账本、durable control 清理遗漏后继 repair lifecycle、复数敏感 key/Provider ID 泄漏、malformed JSON trivia 路径绕过、Agent DB 审计字段扩张、PID 复用 temp 误判、中间目录/文件名称换绑 TOCTOU、Windows 路径枚举 ABA 和审计尾部近似去重问题。真实 suite 的 checkpoint 已有单轮外部证据,但整轮仍无 PASS。 -## 2026-07-17 旧创作模板与入口停止维护 - -- 决策:旧创作模板、旧创作入口及其专属运行服务进入下线范围,不再为跳一跳、抓大鹅 Match3D、儿童动作 Demo 等旧链路修复兼容问题、补生成脚本或维持专属门禁。 -- 边界:共享账号、钱包、资产、图片编辑器、公开作品、通用 HostBridge、API、SpacetimeDB、发布运维和安全能力不属于旧链路,仍需维持正式门禁。历史文档只作为背景材料,不再作为旧入口继续运行的依据。 -- 清理方式:允许直接删除已经失效的旧素材生成命令、入口路由、专属服务和对应测试;删除工程链路时仍需核对是否被当前共享能力引用,不能连带移除仍在使用的公共契约或持久化事实。 - ## 2026-07-20 开发态 Project Supervisor 纯聊天独立窗口 - 背景:开发人员需要一个不依赖正式产品布局的最小 GUI,用于直接验证 `project-supervisor` 的持久多轮对话与 Runtime 行为。 @@ -7401,27 +7195,20 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 并发补验:确定性 Provider 只在 Runtime 明确返回 revision blocker、专业 verification-only repair、成功验证 observation,或项目锁 / repository context drift 这两类可恢复 observation 时重放终态;每个 logical run 最多 16 次。只读职责不得借补验调用未授权命令,验证失败或缺少 `ok` observation 不能交付,Provider completion 计数始终 exactly-once。 - 画布审计:资源 manifest 可以保留生成 prompt 作为本地来源元数据,但公开 `asset.register / asset.update` 审计记录必须移除 `source.prompt`,只保留 canvas/resource/task/model 等身份字段,避免完整生成正文进入公开 Agent DB 表面。 - 当前测试事实:已有回归覆盖固定画布合同不允许被模型改写、已登记 spritesheet 禁止先删除、只有静态 repair 可原位替换、替换期间原文件 fingerprint 漂移时拒绝覆盖,以及 `design-foundation` 对 `game/index.html` 的 write / patchset / delete 和预览工具均被 Runtime 策略阻断。2026-07-27 的独立 75 分钟上限外部真实 E2E 已按上一节单轮证据完整 **PASS**;后续合同变化仍须新起独立轮次,不能复用这次结果替代未来验收。 -- 最终落地:本次退役范围覆盖整个旧创作模板体系,包括 RPG / 自定义世界、拼图、拼消消、大鱼吃小鱼、敲木鱼、方洞挑战、视觉小说、汪汪声浪、寓教于乐、Creative Agent、Match3D、跳一跳和儿童动作 Demo。全部相关历史表继续作为数据壳参与 `spacetime-module` 编译,`migration.rs` 白名单与历史数据不变;旧 reducer/procedure/view、API 路由/handler/worker、前端页面/工作台/运行态、共享业务 DTO、纯业务 crate、未挂载旧实现和归档源码均删除,现役公共能力与历史表 schema 保留。 -- 兼容读取:只保留历史审计、迁移和资产归属核对所需的最小读取定义;旧 `worldType`、公开作品号、URL、详情页和专属运行态均不再形成用户可访问入口。 -- 方案文档:`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 -## 2026-07-18 恢复现役平台公共壳但禁止旧业务依赖回流 +## 2026-07-18 平台公共壳与现役依赖边界 -- 背景:旧创作模板退役时误把新版 `/creation`、桌面公共侧边栏和“我的”完整资料页一起缩减;只恢复视觉后,现役 profile client 又经 `rpg-entry` barrel 把旧作品库、旧 runtime request 和展示模型重新带入 Vite 与 TypeScript 图。 -- 决策:桌面端继续使用原平台公共结构,一级导航固定为 `创作 / 项目 / 我的`;顶栏保留编辑器项目 / 素材搜索、泥点入口和账号胶囊;“我的”全宽保留资料编辑、陶泥号、三项统计、充值、兑换码、社区、反馈、通用设置、API Key 和法律信息。搜索只面向编辑器项目与公开编辑器素材,不恢复旧公开作品搜索。 -- 依赖边界:公共 dashboard、钱包、充值、兑换码、邀请码、API Key 和设置请求迁入 `services/platform-entry`,公共账单展示迁入现役 profile model。Vite 新增退役模块 graph 门禁,ESLint 对现役源码禁止导入旧目录;目录 watch ignore、Tailwind source、tsconfig include 和 tree-shaking 都不能作为依赖隔离证明。 -- 路由与响应式边界(2026-08-03 纠正):`/creation`、`/project`、`/profile` 都是稳定路由,但旧模板退役不授权扩大移动端创作范围。桌面端使用 `创作 / 项目 / 我的` 侧边栏;移动端底部 dock 只保留“我的”,直达 `/creation`、`/project`、`/editor/canvas` 或从首页触发项目 / 画布动作时统一显示桌面端提示,不挂载创作主页、项目列表或图片画布。2026-07-18 同批加入的移动端三入口口径无效,不作为产品决策依据。 -- 公共设置边界:`runtime_setting` 保持原表结构与历史数据,但它是音乐音量和平台主题的现役账号级公共能力,不归入旧玩法数据壳。鉴权后的 `GET/PUT /api/runtime/settings` 必须经 `spacetime-client` 调用 `get_runtime_setting_or_default` / `upsert_runtime_setting_and_return`;保留该路由不构成恢复旧 runtime API 的先例。 -- 编译门禁:除旧业务目录外,`src/uiAssets.ts`、`src/types.ts`、`src/types/**`、`src/services/runtimeAudioFeedback.ts` 和 `src/services/publicWorkCode.ts` 也是顶层退役 module,必须同时退出 Vite module graph、TypeScript、ESLint 和 Vitest;`/audio/**`、`/chat.png`、`/fusion-pixel.ttf` 及旧 pixel / story-tab / 玩法 CSS 不得进入 dev 服务或生产产物。验收时必须同时检查 `tsc --listFilesOnly`、Vite 依赖图 / 产物和退役资产路径,不能只依赖 tree-shaking。 -- Rust 产物边界:`module-runtime` 继续承载账号、钱包、公共设置、追踪和 feature gate,但 `CreationEntry*`、旧公开作品、存档、浏览历史与游玩统计 DTO / command / mapper / 规则必须退出实际 rlib;只保留历史表需要的 `RuntimeBrowseHistoryThemeMode`、完整保序的钱包流水来源枚举等持久化 ABI。`check:server-rs-ddd` 必须执行 `check:module-runtime-artifact`,同时验证旧符号和字面量为零、必要 ABI 仍存在,不能以源码存在 `#[cfg(any())]` 或路由未挂载代替产物证明。 -- 外围编译边界:`platform-auth` 不再编译 runtime guest token,`platform-wechat` 不再编译旧玩法生成结果订阅消息,小程序不再注册订阅授权页;旧公开作品资产授权 view 退出 SpacetimeDB module,匿名素材读取只保留现役 editor showcase 派生授权。 -- 历史队列边界:现役 external generation worker 只领取 `source_module = editor-canvas` 的任务,历史旧玩法 pending / running 行保持原状态,不得被新 worker 领取后改写为失败。 -- Agent crate 边界:`platform-agent` 的执行器、工具注册表、回调和拼图 Phase 1 输入均属于已退役 Creative Agent 业务,不得因现役编辑器 Agent 共用一个模型名常量而留在 workspace 或 `api-server` 依赖图。该常量收口到 `platform-llm`,`platform-agent` 与仅由它引入的 `langchainrust` 退出在运 Cargo resolve graph,相关源码已删除。 -- AI 游戏创作兼容边界:独立 AGC Tauri 壳仍复用 `platform-agent::game_creation` 的任务图与隔离协作数据模型。`platform-agent` 继续排除在 `server-rs` workspace 之外,但其独立 manifest 默认只编译 `game_creation` / `error`,旧执行器、工具注册表、回调、拼图 Phase 1 与 `langchainrust` 统一受关闭的 `legacy-creative-agent` feature 隔离;AGC lock 不得重新引入这些退役依赖。 -- 防回流补充:顶层 `creationEntryConfigService`、`creationUrlState`、`customWorld*`、`runtimeGuestAuth`、`runtimeRequest`、`input-devices`、`useCombatFlow`、`useStoryOptions`、`useMocapInput` 和微信生成订阅 facade 同样属于退役前端模块;Vite dev 对旧 `/api/creation*` 与 `/api/public-works*` 前缀直接返回 404,不能回落 SPA HTML。 -- Vite 全量边界补充:`src/games/**`、`src/data/**`、`src/prompts/**`、旧顶层 App / Playground、旧路由和 `services/ai.ts` 必须由 pre-transform 门禁直接拒绝;所有同源 `/generated-*` 裸读在 dev 与生产统一为空 `404`,历史对象只经现役签名读取接口兼容,不允许 SPA fallback 伪装成资产成功响应。 -- 前端混合根目录补充:`src/components`、`src/hooks`、`src/persistence`、`src/routing`、`src/services` 的根级文件实行现役白名单,Vite 与 ESLint 使用同一口径阻断旧 RPG / 玩法根文件;子目录仍按现役目录和退役目录分别管理,新增公共根文件必须显式登记。 -- 影响范围:`PlatformEntryActiveFlowShell`、`PlatformActiveProfileView`、编辑器 / 项目搜索、平台 profile clients、`module-runtime`、`platform-llm`、Cargo workspace / resolve graph、Vite / ESLint / Rust 产物门禁及旧业务退役方案。 +- 决策:桌面端继续使用原平台公共结构,一级导航固定为 `创作 / 项目 / 我的`;顶栏保留编辑器项目 / 素材搜索、泥点入口和账号胶囊;“我的”全宽保留资料编辑、陶泥号、三项统计、充值、兑换码、社区、反馈、通用设置、API Key 和法律信息。搜索只面向编辑器项目与公开编辑器素材。 +- 依赖边界:公共 dashboard、钱包、充值、兑换码、邀请码、API Key 和设置请求统一放在 `services/platform-entry`,公共账单展示使用现役 profile model。Vite、ESLint、TypeScript 和 Vitest 只加载现役白名单模块;watch ignore、source scan 和 tree-shaking 都不能作为依赖隔离证明。 +- 路由与响应式边界:`/creation`、`/project`、`/profile` 都是稳定路由。桌面端使用 `创作 / 项目 / 我的` 侧边栏;移动端底部 dock 只保留“我的”,直达 `/creation`、`/project`、`/editor/canvas` 或从首页触发项目 / 画布动作时统一显示桌面端提示,不挂载创作主页、项目列表或图片画布。 +- 公共设置边界:`runtime_setting` 是音乐音量和平台主题的现役账号级公共能力。鉴权后的 `GET/PUT /api/runtime/settings` 必须经 `spacetime-client` 调用 `get_runtime_setting_or_default` / `upsert_runtime_setting_and_return`。 +- 编译门禁:`tsc --listFilesOnly`、Vite 模块依赖图和正式产物必须只包含现役白名单模块与资源;未登记的服务、路由、组件、类型和静态资源不得进入 dev 服务或生产产物。 +- Rust 产物边界:`module-runtime` 只承载账号、钱包、公共设置、追踪和 feature gate,其他领域 DTO / command / mapper / 规则不得进入实际 rlib。`check:server-rs-ddd` 必须执行 `check:module-runtime-artifact`,以实际产物而不是 `#[cfg(any())]`、源码残留或未挂载路由判断边界。 +- 外围编译边界:`platform-auth` 只编译现役认证能力,`platform-wechat` 只编译现役登录、支付和分享能力,小程序只注册现役页面。 +- 外部任务边界:external generation worker 只领取 `source_module = editor-canvas` 的任务,其他 pending / running 行不得被改写。 +- Agent crate 边界:`platform-agent` 只服务 AGC 独立 workspace;编辑器 Agent 依赖 `platform-editor-agent` 与 `platform-agent-harness`,不得把 AGC 执行器、工具注册表或回调带回主站依赖图。 +- 前端根目录边界:`src/components`、`src/hooks`、`src/persistence`、`src/routing`、`src/services` 的根级文件实行现役白名单,新增公共根文件必须显式登记。 +- 影响范围:`PlatformEntryActiveFlowShell`、`PlatformActiveProfileView`、编辑器 / 项目搜索、平台 profile clients、`module-runtime`、`platform-llm`、Cargo workspace / resolve graph、Vite / ESLint / Rust 产物门禁。 ## 2026-07-20 VectorEngine 图片任务预算收口到 worker deadline @@ -7485,9 +7272,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-07-23 恢复通用灰度发布后台控制面 -- 背景:旧创作模板退役时,后台灰度页因同时加载 `creation-entry:*` 动态目标与现役 `image-editor:agent-sidebar` 固定目标,被整页从路由、TypeScript、ESLint 和 Vitest 编译链摘除;通用 feature gate 后端、权限和现役画布 Agent 判定仍在,形成有 API 无正式控制面的不一致。 -- 决策:恢复后台 `#gray-release` 导航、member Tab 权限展示、前端 DTO/client、页面渲染和页面测试;页面只读取和写入 `GET/PUT /admin/api/feature-gates`,不再请求已退役 `/admin/api/creation-entry/config`。 -- 目标边界:固定目标列表只登记现役 `image-editor:agent-sidebar`;管理员仍可直接输入其他通用 Gate Key。不得恢复 `creation-entry:*` 动态目标、入口公告、入口开关、旧作品可见性页面或任何旧模板接口。 +- 决策:恢复后台 `#gray-release` 导航、member Tab 权限展示、前端 DTO/client、页面渲染和页面测试;页面只读取和写入 `GET/PUT /admin/api/feature-gates`。 +- 目标边界:固定目标列表只登记现役功能;管理员仍可直接输入其他通用 Gate Key。通用页面不得依赖业务入口配置接口或业务 target 列表。 - 运行语义:环境变量继续是画布 Agent 总开关,feature gate 只在总开关开启后做黑名单、白名单、标签和稳定百分比受众限制;本次不修改 SpacetimeDB schema、灰度优先级或后端契约。 - 验证方式:后台路由与灰度页面 Vitest、`npm run admin-web:typecheck`、定向 ESLint、`npm run check:encoding`、`git diff --check`。 @@ -7534,7 +7320,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-07-28 画布 Agent 的通用 function-calling harness 与画布 prompt 分层 - 背景:画布 Agent 的 JSON 输出协议、tool schema 注入、memory / hook、轮次保护和“全部工具待确认即结束回合”原先位于 `platform-editor-agent/src/framework`,与规范展板、已有图编辑路由、模型超时和画布工具混在同一 crate;八类工具还重复携带待确认控制话术。旧 `platform-agent` 已随 Creative Agent 退役,不能作为新公共层复活。 -- 决策:新增无旧玩法依赖的现役 `platform-agent-harness`,只承载业务中立的 function-calling 执行协议;`platform-editor-agent` 通过兼容 re-export 复用该 crate,并继续承载画布 LLM profile、角色 prompt、公共美术工具路由策略、图片上下文和工具实现。无工具场景同样注入 JSON 响应格式;prompt 不再宣称工具并发执行;request 级 system prompt 必须真实进入本轮请求。待确认卡片的对话路由必须使用正向、条件化语义:只在当前意图匹配一条现存 pending 调用时引导用户点击该卡片,该确认 / 取消意图不产生新 tool call;不在 prompt 中写“不得重新发起相同工具调用”一类全局否定句,因为实测证明模型会将其过度泛化为拒绝后续明确的新生成、修改或重做请求。cancelled 调用不再确认,pending 调用不阻塞无关新任务。 +- 决策:新增业务中立的现役 `platform-agent-harness`,只承载业务中立的 function-calling 执行协议;`platform-editor-agent` 通过兼容 re-export 复用该 crate,并继续承载画布 LLM profile、角色 prompt、公共美术工具路由策略、图片上下文和工具实现。无工具场景同样注入 JSON 响应格式;prompt 不再宣称工具并发执行;request 级 system prompt 必须真实进入本轮请求。待确认卡片的对话路由必须使用正向、条件化语义:只在当前意图匹配一条现存 pending 调用时引导用户点击该卡片,该确认 / 取消意图不产生新 tool call;不在 prompt 中写“不得重新发起相同工具调用”一类全局否定句,因为实测证明模型会将其过度泛化为拒绝后续明确的新生成、修改或重做请求。cancelled 调用不再确认,pending 调用不阻塞无关新任务。 - 执行与失败决策:prompt 每轮通过 `AgentMemory::begin_staged` 使用与调用方 memory 行为等价、写入隔离的 `StagedAgentMemory` 事务;成功或已有工具活动时显式 `commit()`,直接 drop 表示回滚。无工具活动失败时回滚本轮 staged 增量,已发生工具活动后失败时提交已发生工具事实并追加 terminal error closure。外部 future drop / abort 若发生在工具完成后,提交工具结果与取消闭环;若发生在工具执行中,提交“已启动、结果未知”与取消闭环,后续先 reconcile,不能假装副作用未发生。harness 通过 `PromptRunError { error, partial_outputs }` 显式返回终态错误和失败前输出;结构化工具失败还必须向调用方保留 `ToolFailure.kind/retryable/fatal` 与原始 `output`,不在 harness 内压成单一字符串。api-server 的 18 分钟总 deadline 以 runtime future 下沉到 runner:completion 可被 deadline 终止,工具在开始前检查、开始后等待返回、返回后携带结果收口;禁止外层 timeout drop prompt 或中途取消 effectful tool 后伪造空 partial。 - 保留边界:会话幂等、OSS 消息、120 秒前端软提示、20 分钟 transport、18 分钟 handler 总 deadline、1024 tokens、8 分钟 provider attempt、泥点计费、确认入队和 external job 懒回填均不进入公共 harness。SpacetimeDB schema、前端 wire DTO 和侧边栏 UI 不变。 - 验证方式:`cargo test -p platform-agent-harness`、`cargo test -p platform-editor-agent`、`cargo test -p api-server editor_agent`、`cargo check -p api-server --locked`、DDD 边界检查、Rustfmt、编码检查和 `git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 7e6bb8215..65695d13b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -49,7 +49,6 @@ 按上游 [#5555](https://github.com/clockworklabs/SpacetimeDB/pull/5555) 的 retention 语义,只有最近 `retain-snapshots`(默认 2)份 snapshot 与覆盖它之后的 commitlog 段是重启所需,其余历史可丢;因此备份改为 `files + full + --minimal --retain-snapshots 2`(release 实测 40G → 3.6G,热备不停服),不再做增量差异计算,也不需要 44.7G 冷备空间。 -2026-09-22 的 release 清表窗口再次验证:41.2GiB 数据目录执行 `archive` 会因根盘不足失败;尝试非 minimal 的 `files + full + --stop-service` 虽通过空间预检,但在扫描 43GiB 后于 catalog 序列化阶段报 `RangeError: Invalid string length`。生产中等数据目录继续使用可验真的 `files + full + --minimal` 备份,并在 apply 前执行 `restore-files-state --dry-run`;非 minimal files 大库备份需要先修复 catalog 序列化上限,不能把失败备份当作通过。 ## copyArtifacts 报「Unable to find project for artifact copy」的用户触发构建差异 @@ -1646,7 +1645,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` ## SpacetimeDB 45 秒超时要看 api-server 记录的阶段 -- 现象:release 上 Nginx 能立刻连到 `api-server`,但 `/api/runtime/*/gallery`、`/api/creation-entry/config` 等请求在约 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 后返回 `502` / `504`。 +- 现象:release 上 Nginx 能立刻连到 `api-server`,但关键业务请求在约 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 后返回 `502` / `504`。 - 原因:旧日志只能看到 HTTP 总耗时和最终状态,无法区分卡在连接池、SDK 建连、等待 `on_connect`、订阅 read model、等待 procedure / reducer 回调还是本地订阅 cache 读取。 - 处理:`spacetime-client` 内置阶段化健康检查和失败日志;`/readyz` 用 `GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS` 短窗口检查 SpacetimeDB 连接租约,业务失败日志包含 `operation_kind`、`operation_name`、`spacetime_stage`、`elapsed_ms`。 - 验证:`/readyz` 失败时看 `details.spacetime.stage`;业务请求超时时查 `journalctl -u genarrative-api.service` 中同一时间窗口的 `SpacetimeDB client operation failed`,优先按 `pool_acquire`、`connect_build`、`connect_handshake`、`read_model_subscribe`、`procedure_result`、`reducer_result`、`read_cache` 分阶段处理。 @@ -1693,22 +1692,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 自定义世界旧公开作品不要用 published_at 判断是否存在 - -- 现象:RPG / 自定义世界作品详情能打开,但点赞时报 `custom_world 已发布作品不存在,无法点赞`,错误来源是 `作品详情 CW-*` 或其它自定义世界历史公开号。 -- 原因:部分历史 `custom_world_profile` 已是 `publication_status=Published`,但 `published_at` 为空;统一公开详情会用 `updated_at` 兜底展示,旧点赞 / 游玩 / Remix 判断却额外要求 `published_at.is_some()`。 -- 处理:公开互动存在性统一按 `Published + deleted_at=None + visible=true` 判断;`custom_world_gallery_entry` 同步和公开展示时间在 `published_at` 缺失时回退 `updated_at`。 -- 验证:`cargo test -p spacetime-module custom_world_public_interactions_accept_legacy_missing_published_at --manifest-path server-rs/Cargo.toml`。 -- 关联:`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。 - -## 拼图公开推荐不要只按 Published 判断 - -- 现象:后台把拼图作品隐藏后,作品不在公开列表里显示,但玩家通关其它拼图后的推荐下一作品仍可能出现这条隐藏作品。 -- 原因:拼图隐藏只把 `puzzle_work_profile.visible` 置为 `false`,不会把 `publication_status` 从 `Published` 改走;通关推荐候选曾只通过 `by_puzzle_work_publication_status().filter(Published)` 取数,漏掉可见性判断。 -- 处理:拼图公开消费路径统一使用 `Published + visible=true`,范围包括 `puzzle_gallery_view`、`puzzle_gallery_card_view`、兼容 gallery/detail procedure、公开点赞 / Remix、正式公开 runtime 启动和通关后的 `recommended_next_works` 候选。 -- 验证:`cargo test -p spacetime-module hidden_published_puzzle_work_is_not_public_visible_candidate --manifest-path server-rs/Cargo.toml`,并在需要时用后台隐藏一个已发布拼图后重试通关推荐。 -- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - ## 推荐页 WF 点赞不要落到 RPG / custom-world - 现象:推荐页里给 `WF-*` 敲木鱼作品点赞时,平台错误弹窗显示 `custom_world 已发布作品不存在,无法点赞`。 @@ -2231,13 +2214,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:点击汪汪声浪后直接看到创作页内嵌表单,不再出现独立配置页;测试应覆盖内嵌表单与 runtime 返回路径。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 -## 汪汪声浪发布态不要丢失结果页最终素材 - -- 现象:结果页上传或批量生成玩家形象、对手形象、UI 背景后,发布进入正式 runtime 仍可能显示初始草稿素材或兜底视觉。 -- 原因:`publish_bark_battle_work` 如果只把结果页最终状态保存到 `published_snapshot_json`,但正式 runtime 读取的 `config_json` 仍来自草稿行旧值,就会丢失结果页局部替换。 -- 处理:发布时把最终 `publishedSnapshot` 解析为 `BarkBattleEditorConfigSnapshot`、规范化后同时写入 `bark_battle_published_config.config_json` 和 `published_snapshot_json`;首轮自动生成只由 `bark-battle-generating` 负责,结果页仅覆盖已接入的玩家形象、对手形象和竞技背景图片槽位,不再提供音频配置入口。 -- 验证:发布后 runtime config 应包含结果页最终 `playerCharacterImageSrc`、`opponentCharacterImageSrc` 和 `uiBackgroundImageSrc`。 - ## 汪汪声浪 v1 生成页和正式运行态要分开 - 现象:如果把初始三图自动生成、结果页修补、公开发布和正式运行态混在一页,创作者容易误以为一次生成和正式运行是同一职责。 @@ -2245,14 +2221,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 处理:表单提交后先进入独立生成页,部分失败仍进结果页;结果页只保留单槽重试、重新生成和上传,不再保留一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布后先到统一作品详情页,再进正式 runtime;草稿试玩允许 mock,不写正式 run。 - 验证:生成页负责首轮自动产出三图;结果页不出现一次生成按钮、音频配置入口、皮肤预设入口或排名配置;正式 runtime 必须麦克风可用且会写正式 run,草稿试玩不写正式统计。 -## 汪汪声浪生成页不要只停留在前端内存草稿 - -- 现象:点击“生成草稿”后生成页一直转圈,或刷新 / 回到草稿架后看不到三图素材。 -- 原因:生成页只在前端内存里合并玩家形象、对手形象和竞技背景,没有把生成结果写回 `bark_battle_draft_config.config_json`;另外 BFF 若在刚创建草稿后先读 `spacetime-client` 订阅 cache 再保存,cache 可能短暂落后,导致保存失败或返回旧快照。 -- 处理:生成页三图完成后调用 `POST /api/creation/bark-battle/drafts/{draftId}/config` 持久化;保存接口直接把请求快照交给 SpacetimeDB procedure,由模块事务校验 owner / work,并在 HTTP 回包用本次请求里的三图字段覆盖,避免订阅 cache 滞后;保存请求必须设置前端超时,保存失败也进入结果页并标记部分失败。 -- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx src/services/bark-battle-creation/barkBattleCreationClient.test.ts src/components/bark-battle-creation/BarkBattleResultView.test.tsx packages/shared/src/contracts/barkBattle.test.ts`;`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "bark battle"`;`cargo check --manifest-path server-rs\Cargo.toml -p api-server`。 -- 关联:`src/components/bark-battle-creation/BarkBattleGeneratingView.tsx`、`src/services/bark-battle-creation/barkBattleCreationClient.ts`、`server-rs/crates/api-server/src/bark_battle.rs`、`server-rs/crates/spacetime-module/src/bark_battle.rs`。 - ## 汪汪声浪三图不要复用 RPG 场景图链路 - 现象:玩家形象和对手形象看起来走了场景图片 prompt;生成页三个槽位同时转圈,但只有第一个真实生成,首图返回后三个槽位一起停止或只显示首图。 @@ -2322,22 +2290,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:执行 `npm run check:spacetime-runtime-access`、`npm run check:server-rs-ddd`,涉及绑定变化时先执行 `npm run spacetime:generate` 和 `npm run check:spacetime-schema`。 - 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`scripts/check-spacetime-runtime-access.mjs`、`server-rs/crates/spacetime-module/src/*`、`server-rs/crates/spacetime-client/src/mapper.rs`。 -## 拼图广场列表不要每次 HTTP 请求调用 SpacetimeDB procedure - -- 现象:`/api/runtime/puzzle/gallery` 每个请求都走 `spacetime-client.list_puzzle_gallery()` 调用 SpacetimeDB procedure,导致 SpacetimeDB WASM 侧重复组装全量列表,客户端再映射一遍;历史实现还出现过 procedure JSON 字符串往返。 -- 原因:`api-server` 的服务器端 `spacetime-client` 没有订阅可公开读取的 gallery 投影,虽然 SDK 支持 client cache,但请求路径仍把列表读取当作 procedure 调用。 -- 处理:`spacetime-module` 中用 public view `puzzle_gallery_card_view` 暴露已发布拼图作品的列表卡片字段,不携带 `levels` / `anchor_pack` 等详情级载荷;`spacetime-client` 建连接后订阅 `SELECT * FROM puzzle_gallery_card_view` 和 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 并等待 `on_applied`。HTTP gallery 通过 `PuzzleGalleryCache` 缓存最终 `PuzzleGalleryResponse` DTO:`items` 返回前 10 个完整卡片,`previewRefs` 返回后 10 个作品号引用,cache miss / TTL 过期时单飞重建,后台 cleanup task 周期清理旧响应。旧 `list_puzzle_gallery` procedure 只作兼容,不再作为 HTTP gallery 主路径。 -- 验证:搜索 `server-rs/crates/spacetime-client/src/puzzle.rs` 不应再出现 gallery 主路径调用 `list_puzzle_gallery_then`;搜索 `server-rs/crates/spacetime-client/src/lib.rs` 应订阅 `puzzle_gallery_card_view`;执行 `npm run spacetime:generate`、`cargo check --manifest-path server-rs/Cargo.toml -p spacetime-client`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server` 和 schema/runtime access 检查。 -- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/puzzle.rs`、`server-rs/crates/api-server/src/puzzle_gallery_cache.rs`、`/api/runtime/puzzle/gallery`。 - -## Windows 本地直连高 VU 压测不要误判成业务内存泄漏 - -- 现象:本地 Windows release `api-server` 直连 K6 压测时,250 RPS、`PREALLOCATED_VUS=300` 能把进程 private memory 瞬时推到约 7GB;同样配置打 `/healthz` 小响应也能复现,压测结束后回落到 100MB 级。 -- 原因:高水位主要来自本机直连的 K6 VU / 长连接 / Hyper 发送链路和 Windows 连接缓冲,不是 SpacetimeDB procedure、拼图 JSON 缓存或 OTEL exporter。降低到接近真实并发的 VU 后,同样 250 RPS 拼图广场 p95 约 9ms,峰值约 600MB。 -- 处理:本地容量判断时让 `PREALLOCATED_VUS` / `MAX_VUS` 接近真实并发,不要把过高 VU 预分配当作默认吞吐测试;同时观察 `process.memory.*`、`process.windows.handle.count`、`genarrative.http.server.response_bodies.in_flight`、`genarrative.http.server.request_permits.available`、`genarrative.puzzle_gallery.cache.*` 和 `genarrative.spacetime.read.*`。如果内存高但 body in-flight、背压 permit、cache rebuild 和 SpacetimeDB read 都不显示积压,优先按连接 / 发送链路高水位处理。 -- 验证:对照打 `/api/runtime/puzzle/gallery` 与 `/healthz`;对比 `PREALLOCATED_VUS=300 MAX_VUS=800` 和 `PREALLOCATED_VUS=20 MAX_VUS=40`;压测结束后继续采样 10 秒确认 private memory 回落。 -- 关联:`scripts/loadtest/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/api-server/src/process_metrics.rs`、`server-rs/crates/api-server/src/telemetry.rs`。 - ## 容器高 VU 下 `/healthz` RSS 尖峰先查 Axum state 深拷贝 - 现象:容器 Linux release `api-server` 打 `/healthz`,500 HTTP req/s、`PREALLOCATED_VUS=100` 只跑 1 秒也能把 RSS 推到约 1 GiB;同样问题与作品列表、SpacetimeDB procedure、业务 cache 和请求日志等级无关。 @@ -2346,29 +2298,13 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:用容器内 k6 直连 `api-server:8082/healthz`,500 HTTP req/s、`PREALLOCATED_VUS=100`、30 秒压测后采样 `/proc/$pid/status`、`/proc/$pid/smaps_rollup` 和 cgroup `memory.current/memory.peak`。2026-05-18 修复后结果为 `15001` 请求、`http_req_failed=0`、`dropped_iterations=0`,RSS 约 18 MiB -> 52 MiB,cgroup peak 约 47 MiB。 - 关联:`server-rs/crates/api-server/src/state.rs`、`deploy/container/README.md`、`deploy/container/api-server.Dockerfile`。 -## Gallery 压测延迟升高先查入口过量放行和 TTL 边界刷新 +## 创作入口配置不要每次 HTTP 请求调用只读 procedure -- 现象:公开作品列表在 500-1000 HTTP req/s 附近可能吞吐没有明显提升,但 p95 变高、VU 上升,甚至出现排队和 dropped iterations。 -- 原因:Nginx、Axum 和缓存刷新边界如果同时允许过多请求进入,压力会先堆在连接、service 和 cache rebuild 周围;这类延迟不等同于数据库连接池不足。 -- 处理:Nginx 按 endpoint 使用 `limit_req` 快拒绝,api-server 按 `default/gallery/detail/admin` 分组 semaphore 快拒绝;拼图广场 TTL 过期时已有缓存先返回 stale 响应,只允许一个后台 refresh 任务重建,冷启动无缓存时才同步构建。 -- 验证:OTLP 看 `genarrative.http.server.request_permits.available{pool=...}`、`genarrative.puzzle_gallery.cache.stale_hits`、`refreshes_started`、`refreshes_failed`,Nginx access log 看 `request_time` 与 `upstream_response_time` 是否同步收敛;超过容量时应明确 429,而不是长时间排队或新增 502。 -- 关联:`deploy/nginx/genarrative.conf`、`deploy/container/nginx.conf`、`server-rs/crates/api-server/src/backpressure.rs`、`server-rs/crates/api-server/src/puzzle_gallery_cache.rs`。 - -## 多玩法公开广场列表优先订阅 public view / read model - -- 现象:抓大鹅、方洞挑战、视觉小说、大鱼吃小鱼等公开列表如果沿用 `list_*_works` procedure,即使只读已发布作品,也会在每个 HTTP 请求里回到 SpacetimeDB WASM 侧扫描、反序列化配置并组装列表,50RPS 以上容易变成热点。 -- 原因:个人作品列表和公开广场列表复用了同一套 procedure 输入,导致公开列表为了通过 owner 校验传固定占位 owner,并把可长期同步的公开读模型当成请求期查询。 -- 处理:每个公开广场新增或复用专用 public view / public read model:`match_3_d_gallery_view`、`square_hole_gallery_view`、`visual_novel_gallery_view`、`big_fish_gallery_view`。`spacetime-client` 建连接后订阅这些 view 和对应 `public_work_play_daily_stat` source_type 桶,HTTP gallery 只读本地 cache。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍走原有 procedure / reducer。 -- 验证:搜索 `server-rs/crates/spacetime-client/src/{match3d,square_hole,visual_novel,big_fish}.rs`,公开 gallery 主路径应读取 `connection.db().*_gallery_view()`,不应调用 `list_*_works_with_input`;执行 `npm run spacetime:generate`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`。 -- 关联:`server-rs/crates/spacetime-module/src/match3d.rs`、`server-rs/crates/spacetime-module/src/square_hole.rs`、`server-rs/crates/spacetime-module/src/visual_novel.rs`、`server-rs/crates/spacetime-module/src/big_fish/session.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - -## 自定义世界广场和创作入口配置不要每次 HTTP 请求调用只读 procedure - -- 现象:`/api/runtime/custom-world-gallery` 每次请求调用 `list_custom_world_gallery_entries` procedure;入口熔断中间件每个玩法请求调用 `get_creation_entry_config` procedure,50RPS 以上会把 SpacetimeDB procedure 调用变成热点。 -- 原因:`custom_world_gallery_entry`、`creation_entry_config` 和 `creation_entry_type_config` 已经是可订阅读模型或配置表,但 HTTP 路径仍按“请求到来再查 procedure”处理。 -- 处理:`spacetime-client` 长连接订阅 `custom_world_gallery_entry`、`public_work_play_daily_stat` 的 `custom-world` 桶、`creation_entry_config` 和 `creation_entry_type_config`;custom-world gallery 从本地 cache 排序并聚合 7 日播放数;入口配置优先读订阅 cache,cache 缺失时用最近一次成功内存快照,再兜底调用 `get_creation_entry_config` 完成旧库兼容。旧 `list_custom_world_gallery_entries` procedure 只允许作为旧库缺少 gallery 行时的一次性同步兜底。 -- 验证:搜索 `server-rs/crates/spacetime-client/src/custom_world.rs`,gallery 主路径应是 `read_after_connect` 读取 `custom_world_gallery_entry()`;搜索 `server-rs/crates/spacetime-client/src/runtime.rs`,`get_creation_entry_config` 应优先读取 `creation_entry_config()` 和 `creation_entry_type_config()`。执行 `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 -- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/custom_world.rs`、`server-rs/crates/spacetime-client/src/runtime.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 现象:入口熔断中间件每个请求都调用 `get_creation_entry_config` procedure,50 RPS 以上会把 SpacetimeDB procedure 调用变成热点。 +- 原因:`creation_entry_config` 和 `creation_entry_type_config` 是可订阅配置表,HTTP 路径却按“请求到来再查 procedure”处理。 +- 处理:`spacetime-client` 长连接订阅 `creation_entry_config` 和 `creation_entry_type_config`;入口配置优先读取订阅 cache,cache 缺失时使用最近一次成功内存快照,再兜底调用 `get_creation_entry_config`。 +- 验证:搜索 `server-rs/crates/spacetime-client/src/runtime.rs`,`get_creation_entry_config` 应优先读取 `creation_entry_config()` 和 `creation_entry_type_config()`;执行 `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/runtime.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 ## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行 @@ -3059,14 +2995,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`PuzzleResultView` 单测覆盖发布弹窗内展示 `泥点余额不足`。 - 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md`、`docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md`。 -## 拼图发布检查阶段会在事件落库时炸 wasm - -- 现象:拼图发布在“发布检查”环节直接报 `The module instance encountered a fatal error`,wasm backtrace 指向 `spacetime_module::puzzle::publish_puzzle_work`,并停在 `procedure_commit_mut_tx` 的 commit 阶段。 -- 原因:`publish_puzzle_work_tx` 会无条件调用 `emit_puzzle_work_published_event` 写入 `puzzle_event`;该表的 `event_id` 是主键,而事件 ID 由 `profile_id + published_at_micros` 组成。只要同一发布动作被重复执行、重放,或极端情况下发生时间戳碰撞,commit 时就会因主键冲突触发 fatal error。 -- 处理:待修复。发布事件写入需要改成幂等,或在重复发布时显式跳过已存在的 `event_id`;发布动作本身也应补一层更明确的幂等键,避免把重复提交直接推到事务提交阶段。 -- 验证:对同一 `session_id/profile_id/published_at_micros` 重复调用 `publish_puzzle_work` 时,不应再在 commit 阶段炸 wasm;正常发布仍应生成作品、更新 session,并可进入公开详情。 -- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/spacetime-client/src/module_bindings/puzzle_event_table.rs`。 - ## 拼图会过早进入待发布态,结果页可能空图但仍显示可发布 - 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。 @@ -3107,14 +3035,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:后端单测覆盖中文名路径唯一,前端运行态测试覆盖 generated 图片源解析。 - 关联:`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-result/Match3DResultView.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 -## 抓大鹅生成素材不能只挂在 compile response - -- 现象:抓大鹅草稿生成完成后停留在结果页能看到切割好的物品图片;退出后从草稿 Tab 重新进入同一草稿,素材列表变回默认占位或为空,已生成的物品名称和图片丢失。 -- 原因:`generatedItemAssets` 如果只附加在 `match3d_compile_draft` 的 HTTP response draft 上,刷新或重进时 `getMatch3DWorkDetail` 只能读取 SpacetimeDB 中的 `match3d_work_profile`;旧 mapper 返回空数组,自然无法恢复素材。拼图链路已经通过 `save_puzzle_generated_images` 把候选图和 levels 写回 work profile,抓大鹅也必须同样写持久字段。 -- 处理:compile 成功时把独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json`;`update_match3d_work` / `publish_match3d_work` 保留该字段;API work summary/detail 映射反序列化为 `generatedItemAssets`。前端保持“本次 draft 优先,重进 profile 兜底”的读取顺序。 -- 验证:`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`。 -- 关联:`server-rs/crates/spacetime-module/src/match3d/*`、`server-rs/crates/spacetime-client/src/mapper.rs`、`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-result/Match3DResultView.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 - ## 抓大鹅试玩和正式运行态不要只读草稿页本地素材预览 - 现象:结果页能看到生成的物品图片,但点击试玩或从推荐 / 公开作品进入正式抓大鹅时,局内仍显示默认积木素材。 @@ -3187,14 +3107,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:执行 `cargo test -p api-server jsapi_order_request_sets_wechat_required_http_headers --manifest-path server-rs/Cargo.toml`。 - 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 -## 容器公开列表压测不要靠继续抬并发吃满 CPU - -- 现象:2C / 2G 容器压测公开 gallery list 时,`api-server` CPU 仍有余量,看起来像可以继续提高 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS` 或 Nginx `limit_conn`。 -- 原因:当前瓶颈不是 Tokio worker 线程数。`/api/runtime/puzzle/gallery` 和 `/api/runtime/custom-world-gallery` 成功响应后会走全局 route tracking,继续向 SpacetimeDB 写 `record_tracking_event_and_return`;入口并发从 320 抬到 336 / 352 时,SpacetimeDB 内存先逼近 `896m` 容器上限,200 请求 p95 变差,429 比例没有改善。 -- 处理:2C / 2G 容器模拟里公开 gallery list 暂以 `limit_conn=320`、`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320` 作为稳定上限。若要继续提升吞吐,优先减少高频公开 GET 的 tracking 写入、做采样或改成批量/异步聚合;不要单纯放大入口并发。 -- 验证:宿主机 k6 打 `http://127.0.0.1:18080`,`PEAK_RPS=1000` 等价约 2000 HTTP req/s;320 档无 dropped iterations、无 5xx、无 OOM,200 请求 `request_time p95` 约 0.292s。336 / 352 档 p95 升到约 0.31s / 0.32s,SpacetimeDB 内存尾部可到约 `880MiB / 896MiB`。 -- 关联:`deploy/container/nginx.conf`、`deploy/container/api-server.env.example`、`deploy/container/README.md`、`server-rs/crates/api-server/src/tracking.rs`。 - ## tracking outbox 成功入库后删除 sealed 文件 - 现象:普通 route tracking 改为本机 outbox 后,容易误以为入库成功只需要清空文件内容。 @@ -3314,14 +3226,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`npm run spacetime:generate -- --rust-only` 能通过;`user_account` 旧迁移 JSON 缺字段时能导入,`profile_invite_code` 缺 `metadata_json` 时按 `{}` 兼容。 - 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`server-rs/crates/spacetime-module/src/`。 -## 公开作品详情深链找不到作品不能停在空详情页 - -- 现象:直接访问 `/works/detail?work=PZ-...`,作品不存在或已下架时会弹出“作品不存在或已下架,将返回首页。”;关闭提示后仍可能停在大白屏。 -- 原因:旧恢复逻辑只覆盖 `/runtime/...`,没有覆盖 `/works/detail`。同时 `selectionStage === 'work-detail'` 且 `selectedPublicWorkDetail === null` 时没有兜底渲染,详情数据为空就只剩空页面。 -- 处理:公开详情失效统一走 `resolveWorkNotFoundRecoveryAction(...)`,覆盖 `/works/detail`、`/gallery/puzzle/detail` 和 `/gallery/visual-novel/detail`;搜索失败和拼图详情 404 分支清理详情/运行态临时状态并回首页;`work-detail` 空数据阶段显示轻量读取态,避免异步间隙白屏。 -- 验证:`npm run test -- src/routing/runtimeNotFoundRecovery.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail alert returns to platform home"`。 -- 关联:`docs/technical/PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md`、`src/routing/runtimeNotFoundRecovery.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。 - ## 拼图 UI 背景只有 objectKey 时不要回退默认 UI - 现象:拼图草稿页、试玩和正式运行态都显示默认 UI,或者只在结果页看到生成图,进入试玩后又回到默认背景。 @@ -3378,20 +3282,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_uploaded_cover_can_reuse_resolved_history_image --manifest-path server-rs\Cargo.toml`、`npm run dev:api-server` 后检查 `/healthz`。 - 关联:`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`、`src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`。 -## 拼图结果页局部生图不要污染草稿生成态 - -- 现象:拼图草稿已经生成完成后,在结果页重新生成关卡图片或追加关卡生成图片,草稿页仍显示整卡“生成中”,点击草稿会回到生成过程页,无法查看已有结果;关卡图片生成中还会禁用“新增关卡”和其它关卡详情编辑。 -- 原因:结果页局部 action 复用了全局 `isPuzzleBusy` / 持久化 `generationStatus=generating` 语义,作品架没有区分“初始草稿不可查看”和“已有结果上的局部关卡生成”。 -- 处理:作品架只在拼图没有可用封面、首关候选图或任一可查看关卡时才把 `generationStatus=generating` 解释为初始草稿生成;结果页关卡图走 background action,不设置全局 busy,只标记对应关卡局部生成进度;SpacetimeDB/API mapper 读写时把已有图片但状态仍是 `generating` 的历史关卡归一为 `ready`。 -- 验证:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle --manifest-path server-rs\Cargo.toml`。 -- 关联:`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`server-rs/crates/api-server/src/puzzle/mappers.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`。 - -2026-05-22 补充:结果页关卡详情的“关卡测试”不能把单关 `draft` 传给父级再调用 `updatePuzzleWork`。`updatePuzzleWork` 会同步 `puzzle_work_profile.levels_json` 和 source session 草稿,单关快照会把整份多关卡草稿覆盖成一个关卡,退出重进后只剩最后测试的关卡且序号表现为第一关。修复口径是 `PuzzleResultView` 始终传完整 `syncedDraft`,额外用 `{ levelId }` 指定起始关卡;父级持久化完整 levels 后调用 `startLocalPuzzleRun(item, levelId)`。 - -2026-06-18 补充:结果页点击“新增关卡”只是在本地打开一个空白占位关卡,不应立刻进入自动保存。空白占位如果被写入 `/api/runtime/puzzle/works/{profile_id}`,在作品 profile 投影尚未稳定存在时会触发 `update_puzzle_work` 404,并且后续 session/draft 回读可能把当前详情弹窗关闭。修复口径是自动保存比较和 payload 过滤掉“后端基线中不存在且完全空白”的本地关卡;用户填写名称、描述、参考图或开始生成后再保存。`mergeDraftEditStateWithIncomingState(...)` 还要保留本地空白占位,避免 incoming draft 刷新时移除正在编辑的弹窗。 - -2026-06-18 补充:改造流的 `creative_agent` 草稿写回会用 `puzzle-session-*` 派生出的 `puzzle-profile-*` 调用 `update_puzzle_work`;如果前置 `create_puzzle_agent_session` 已写入 `puzzle_agent_session`,但派生的 `puzzle_work_profile` 草稿投影缺失,写回会报“拼图作品不存在”。首图生成或结果页保存也可能踩到同一缺口。修复口径是在 SpacetimeDB `update_puzzle_work_tx` 里只对稳定 `puzzle-profile-*` 反推同源 `puzzle-session-*`,确认 owner 匹配、session 未发布且有 draft 后恢复 draft profile,再继续更新;不要在前端重试或凭空创建任意 profile,也不要恢复已发布 session。 - ## 拼图上传图关闭 AI 重绘不要走首图生图 - 现象:用户在拼图入口页或结果页关卡详情上传图片并关闭 AI 重绘后,生成页仍显示“生成拼图首图”,或者后端仍调用 `generate_puzzle_image_candidates` 生成第一张 1:1 候选图。 @@ -3458,14 +3348,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`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`。 -## 公开作品卡作者行不要拼手机号或陶泥号 - -- 现象:发现页 / 推荐页公开作品卡作者行显示 `158****3533 · SY-00000003` 这类手机号掩码和陶泥号组合,列表卡片看起来像暴露账号标识。 -- 原因:`resolvePlatformWorkAuthorDisplayName(...)` 曾把公开昵称和 `publicUserCode` 拼接为 `昵称 · SY-*`,并在无法解析公开昵称时直接回退后端卡片里的 `authorDisplayName`;当后端或旧投影把手机号掩码写进展示名时,卡片会原样外露。 -- 处理:公开卡片作者名只取可读公开昵称;识别手机号掩码、单独 `SY-*` 或 `手机号掩码 · SY-*` 时回退为 `玩家`。作品号复制、陶泥号搜索和完整身份展示只放在详情页、搜索或明确复制入口,不塞进卡片作者行。 -- 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/platform-entry/PlatformWorkDetailView.test.tsx`。 -- 关联:`src/components/rpg-entry/rpgEntryWorldPresentation.ts`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformWorkDetailView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - ## 生成中草稿恢复要按后端时间戳计时 - 现象:拼图或抓大鹅草稿生成中刷新网页后,进入生成页的“已耗时”从 `0 秒` 重新开始;另一类旧问题是后端 `progressPercent=88` 时总进度首帧直接跳到 `88%`。 @@ -3521,14 +3403,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/games/bark-battle/ui/__tests__/BarkBattleRuntimeShell.test.tsx`,并确认非狗主题的拟声词不含 `汪`。 - 关联:`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx`、`src/games/bark-battle/application/BarkBattleConfig.ts`、`src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`。 -## Jenkins Web 构建公开作品号导出缺失优先补 publicWorkCode - -- 现象:`Genarrative-Web-Build` 在 `npm run build:production-release -- --component web` 阶段失败,Rollup 报 `"buildJumpHopPublicWorkCode" is not exported by "src/services/publicWorkCode.ts"`,但导入方 `rpgEntryWorldPresentation.ts` 或 `PlatformEntryFlowShellImpl.tsx` 已经引用该玩法公开码函数。 -- 原因:玩法分支合并时容易只带入新玩法的 `publicWorkCode.ts` 导出,覆盖或遗漏另一个玩法的公开码 builder / matcher,Vite 构建会在静态导出检查阶段直接失败。 -- 处理:在 `src/services/publicWorkCode.ts` 中保持每个玩法的 `buildPublicWorkCode` 与 `isSamePublicWorkCode` 成对导出;跳一跳使用 `JH-` 前缀和 profileId 后 8 位规范化后缀。补 `src/services/publicWorkCode.test.ts` 覆盖 builder 和 matcher,避免后续合并再次丢失导出。 -- 验证:`npm test -- src/services/publicWorkCode.test.ts`,并用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。若 `npm run typecheck` 仍报 JumpHop 阶段或状态变量缺口,那是远端当前 JumpHop 接线未收齐的独立问题,不等同于该 Rollup 导出失败。 -- 关联:`src/services/publicWorkCode.ts`、`src/components/rpg-entry/rpgEntryWorldPresentation.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - ## 跳一跳前端壳层接线不要只合渲染分支 - 现象:`npm run typecheck` 大量报 `setJumpHopSession`、`jumpHopRun`、`jumpHopGalleryEntries`、`mapJumpHopWorkToPublicWorkDetail` 不存在,以及 `"jump-hop-runtime" is not assignable to SelectionStage`;即使 typecheck 过了,分享或刷新 `/runtime/jump-hop?work=...` 仍可能掉回首页。 @@ -3540,7 +3414,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` ## 跳一跳地块图集固定走 18 个 UV 大单元 - 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者生成完成后只有 atlas 预览路径,地块切片没有真正落盘。 -- 原因:旧模板先后尝试过通用系列素材 helper、`2x3` 六格固定 tileType 和 `5x5` 单贴图池,但当前跳一跳已经重设计为“主题 -> 一张 `1024x1536` 图集 -> 18 个 `3列*6行` UV 大单元 -> 每格 `4列*3行` 六面贴图 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏。 +- 原因:早期方案先后尝试过通用系列素材 helper、`2x3` 六格固定 tileType 和 `5x5` 单贴图池,但当前跳一跳已经重设计为“主题 -> 一张 `1024x1536` 图集 -> 18 个 `3列*6行` UV 大单元 -> 每格 `4列*3行` 六面贴图 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏。 - 处理:跳一跳地块固定只生成一张 `1024x1536` 主题 UV 展开图集,后端先切出 18 个大单元,再从每格固定 UV 网切出 top/front/right/back/left/bottom 六张 `256x256` 不透明 PNG,并对 108 张面贴图各自走 OSS 上传、asset_object 确认和 entity bind;不要再恢复 `2行*3列`、`5x5` 单贴图、`start / normal / target / finish / bonus / accent` 六格口径。 - 验证:`jump_hop.rs` 不应再调用通用物品行数模型处理地块图集;公开结果里应能拿到 18 个独立 `JumpHopTileAsset` 且每个新资产包含 `faceAssets` 六面贴图,运行态无限路径从地块池随机取材;旧资产没有 `faceAssets` 时仍能用 `imageSrc` 单贴图 fallback。 - 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -3638,13 +3512,13 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`npm run test -- scripts/dev.test.ts`;重新运行 `npm run dev` 后 api-server 启动日志不再出现上述 subscribe 401,`/healthz` 返回 200。 - 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 -## 创作作品架或公开列表异常先查本地 SpacetimeDB schema 漂移 +## 本地 SpacetimeDB schema 漂移会表现为过程或订阅缺失 -- 现象:本地 `http://127.0.0.1:3000/` 启动后,`api-server` 日志反复出现 `Host returned error when processing subscription query: no such table: puzzle_gallery_card_view`;或创作中心草稿 / 已发布作品整块消失,`GET /api/creation-entry/config` 返回 `502` 且 details 为 `No such procedure`。 -- 原因:本地 `.env.local` 或 `spacetime.local.json` 指向的 SpacetimeDB 库没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库;例如旧 `xushi-p4wfr` 库缺 `get_creation_entry_config` / `puzzle_gallery_card_view`,但当前代码的 `spacetime-client` 启动时会长期订阅这些公开 read model。 -- 处理:先用 `spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --server http://127.0.0.1:3101` 确认目标库是否有当前 view;若只是本地验证,可用 gitignored 的 `spacetime.local.json` 指向可发布且已包含当前 schema 的库,例如 `{"database":"genarrative-dev-codex"}`。该 JSON 必须无 UTF-8 BOM,否则 `scripts/dev.mjs` 会忽略它。修改后用 `npm run dev:api-server -- --database --spacetime-port 3101 --api-port 8082 --no-interactive` 重启。 -- 验证:`curl.exe -i http://127.0.0.1:8082/healthz` 返回 `200`;`curl.exe -i http://127.0.0.1:8082/api/runtime/puzzle/gallery` 返回 `200`;浏览器打开 `http://127.0.0.1:3000/` 无 `puzzle_gallery_card_view` 控制台或后端日志错误。 -- 关联:`scripts/dev.mjs`、`server-rs/crates/spacetime-client/src/lib.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 现象:本地 `http://127.0.0.1:3000/` 启动后,`api-server` 日志出现 `Host returned error when processing subscription query: no such table`,或业务接口返回 `No such procedure`。 +- 原因:本地 `.env.local` 或 `spacetime.local.json` 指向的数据库没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。 +- 处理:先用 `spacetime sql "SELECT * FROM LIMIT 1" --server http://127.0.0.1:3101` 确认目标库 schema;若只是本地验证,可用 gitignored 的 `spacetime.local.json` 指向可发布且已包含当前 schema 的库,例如 `{"database":"genarrative-dev-codex"}`。该 JSON 必须无 UTF-8 BOM,否则 `scripts/dev.mjs` 会忽略它。修改后用 `npm run dev:api-server -- --database --spacetime-port 3101 --api-port 8082 --no-interactive` 重启。 +- 验证:`curl.exe -i http://127.0.0.1:8082/healthz` 返回 `200`;重放失败的 `spacetime-client` 操作时不再出现缺表或缺 procedure;浏览器打开 `http://127.0.0.1:3000/` 无对应后端日志错误。 +- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`scripts/dev.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## 创作作品架消失先查入口配置 procedure 与本地库权限 @@ -3690,14 +3564,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`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`。 -## 旧创作入口先确认是不是旧 worktree 在响应 - -- 现象:浏览器里明明还看到跳一跳旧入口,比如 `俯视角跳跃闯关` 和 `puzzle.webp`,但当前 worktree 里已经改成了 `主题驱动平台跳跃` 和 `jump-hop.webp`。 -- 原因:本机常同时存在两个开发栈,旧 worktree 可能还在占用 `3000/8082/3101/3102`,而当前 worktree 可能跑在另一组端口。只看页面文案就下结论,容易把旧进程误认成当前改动没生效。 -- 处理:先用 `Get-NetTCPConnection` / `Get-CimInstance Win32_Process` 确认端口对应的可执行文件和命令行,再分别请求 `/api/creation-entry/config` 比对旧端口与当前 worktree 端口。必要时以当前 worktree 的实际端口为准重新打开页面。 -- 验证:旧端口返回旧跳一跳入口,当前 worktree 端口返回新跳一跳入口;两边的 `api-server` / `vite-cli` 命令行应指向不同仓库路径。 -- 关联:`scripts/dev.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - ## 3001 无法访问先查旧 worktree 占端口和 SpacetimeDB 版本 - 现象:`http://127.0.0.1:3001/` 打不开,但 `3000 / 3101 / 8082` 仍有进程;`npm run dev` 直接退出,没有把新栈拉起来。 @@ -3985,26 +3851,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedCreationWorkspace.test.tsx src/mobileViewportKeyboardFocus.test.ts src/index.test.ts miniprogram/pages/web-view/index.style.test.js`;移动端点击拼图、敲木鱼、跳一跳输入框时,页面不应整体弹起,键盘上方应持续显示平台浅色背景。 - 关联:`src/components/unified-creation/UnifiedCreationPage.tsx`、`src/mobileViewportKeyboardFocus.ts`、`src/index.css`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 小程序订阅消息授权不要依赖 web-view bindmessage - -> 2026-07-18:本节及下一节只作为历史记录。生成结果订阅页、H5 service、HostBridge capability 和后端发送链路已随旧创作模板业务退役,不得按这些排障步骤恢复。 - -- 现象:拼图点击生成后,H5 以为已经请求了生成结果订阅授权,但小程序没有弹出 `wx.requestSubscribeMessage` 授权框。 -- 原因:`web-view bindmessage` / `wx.miniProgram.postMessage` 不适合承接“当前用户点击后立刻请求授权”的时序,消息可能等到 web-view 后退、分享或销毁时才派发,导致授权请求没有发生在 `compile_puzzle_draft` 前。 -- 处理:不要在原生页 `onLoad` 自动触发 `wx.requestSubscribeMessage`,真机会闪页返回且不弹授权框。H5 在 `compile_puzzle_draft` 前应先进入生成进度态并立即发起生成 action,再通过微信 JS SDK `miniProgram.navigateTo` 非阻塞跳转到小程序原生订阅页尝试请求授权;用户接受、拒绝或返回都不能阻塞生成。原生页不要改写上一页 `webViewUrl`,否则 web-view 可能重新加载首页并丢失进度页状态。后端发送订阅消息仍只允许在拼图资产成功或失败终态后执行。 -- 验证:`npm run test -- src/services/wechatMiniProgramSubscribe.test.ts miniprogram/pages/subscribe-message/index.test.js`。 -- 关联:`src/services/wechatMiniProgramSubscribe.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`miniprogram/pages/subscribe-message/index.shared.js`、`miniprogram/pages/web-view/index.js`。 - -## 微信订阅消息 time 字段不能用内部时间戳 - -> 2026-07-18:该能力已退役,本节不再作为现役排障入口。 - -- 现象:dev 服务器拼图资产生成终态后已经调用订阅消息发送,但日志出现 `微信订阅消息发送失败:argument invalid! data.time4.value invalid`,用户收不到生成结果通知。 -- 原因:微信模板 `time` 字段不接受内部微秒时间戳、秒级时间戳或带 `Z` / 时区后缀的字符串;发送 `1713686401.234567Z` 或类似 `2026-06-08 08:09:18Z` 会被微信拒绝。 -- 处理:`api-server` 构造生成结果订阅消息时,`time4` 固定格式化为北京时间 `YYYY-MM-DD HH:mm`;不要复用 `shared_kernel::format_timestamp_micros`。 -- 验证:`cargo test --manifest-path server-rs\Cargo.toml -p api-server generation_result_template -- --nocapture`;dev 日志中不应再出现 `data.time4.value invalid`。 -- 关联:`server-rs/crates/api-server/src/wechat_subscribe_message.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - ## 待解决:跳一跳生成超时后可能后台继续成功 - 风险程度:高。 @@ -4150,16 +3996,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:回归测试同时覆盖“弹窗内按下、遮罩松开不关闭”和“遮罩按下、遮罩松开正常关闭”。 - 关联:`src/components/common/UnifiedModal.tsx`、`src/components/common/UnifiedModal.test.tsx`、`src/components/auth/PlatformAuthModalShell.test.tsx`。 -## 公开作品资产不能用 generated 前缀或 PublicRead 批量放行 - -- 现象:资产 ACL 收紧后,公开页面读取其他作者作品资产集中返回 `404`;对象在 OSS 中真实存在,但已登记 `asset_object.access_policy = private`。 -- 原因:“作品公开”不等于“作者账号下所有 generated 对象永久公开”。只按 profile / session 关联也会误公开同会话的未选候选图、参考图或生成输入;批量改 `PublicRead` 则无法随作品隐藏、删除或取消发布自动撤销。 -- 处理:已登记对象继续保持 `private`,通过 `public_work_asset_read_grant` 只派生 `Published + visible`(`custom-world` 还必须未删除)正式发布快照实际使用资产的匿名读授权。API 必须同时校验 grant owner 与资产 owner 一致,以及 `asset_object_id` 或精确 `object_key` 命中;明确排除参考图、未选候选图和 `generationInputs`。Custom World 只能扫描角色、地标、营地、章节和 opening CG 等正式根,不能遍历 legacy payload 的未知根。历史作品交给 view 现算补齐,不做永久 ACL 数据补丁。 -- 权威查询边界:不能从 `asset_object` 或 `public_work_asset_read_grant` 的连接级订阅 cache 推断当前 ACL;池连接水位不一致会让刚撤销的 grant 继续签发 URL,也会让刚公开的作品短暂 404。资产定位和公开授权必须通过受 runtime service identity 限制的 procedure 在同一事务快照中计算,失败时拒绝读取;procedure 先按 asset owner 使用各玩法 owner 索引缩小到该作者作品,再匹配候选 `asset_object_id` / 精确 key,不能每张图都执行全站公开 view,也不要在每个池连接订阅复制全量 private 资产表。公开派生授权、`PublicRead` 和 legacy 兼容读取的签名 URL 最长 600 秒,owner / admin 不受该公开上限影响。 -- Remix 边界:拼图、Custom World 和大鱼现有 Remix 会把源资产引用复制到新 owner,但没有持久化不可伪造的资产来源。不得因此放宽跨 owner grant;源作品隐藏后仍公开的 Remix 资产,需要后续通过 Remix 时复制资产或持久化 provenance 解决。 -- 验证:资产 owner 本人仍可读;公开可见作品的正式资产可匿名读;跨 owner、只命中前缀、参考图、未选候选图和 `generationInputs` 仍返回不存在;作品隐藏、删除或取消发布后 grant 消失。 -- 关联:`server-rs/crates/spacetime-module/src/public_asset_access.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`server-rs/crates/api-server/src/assets.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - ## 持久进程恢复不能重放 start 或按 PID 重连 - 现象:Runner 强杀或重启后,同一个 run 又启动了一份开发服务器,或者新 Runner 根据旧 PID 把宿主上的同号进程误认成原 PTY 会话。 @@ -4735,21 +4571,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`platform-llm` 回归用例锁定第二次 attempt 成功响应头后的 body timeout 仍报累计 2 次;`api-server` 测试锁定专用 client retry、18 分钟整体 deadline 与中文直达错误。运行态排障按同一 request id 对齐 `platform_llm` failure stage 与 `/messages` 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。 - 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs`、`server-rs/crates/platform-agent-harness/src/error.rs`、`server-rs/crates/api-server/src/state.rs`、`src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts`、`src/components/image-editor/EditorAgentConversation/MessageBubble.tsx`、`src/services/image-editor/editorAgentClient.ts`。 -## 前端退役目录不能只靠扫描和 ignore 隔离 - -- 现象:Tailwind `@source`、TypeScript 根 `include`、ESLint ignore 和 Vitest include 都排除了旧创作目录,但干净打开新版页面时,Vite 仍转换 `services/rpg-entry/index.ts`,构建产物也包含旧作品库和旧 profile 逻辑。 -- 原因:现役模块的静态 import 会让 Vite、TypeScript 和打包器递归解析依赖;watch ignore 只停止监听,Tailwind source 只控制 class 扫描,tree-shaking 也发生在模块已经加载之后。经 barrel 只取一个公共函数尤其容易把同文件的旧导出一起带回图中。 -- 处理:把仍在用的公共账号 / 钱包 / 设置能力迁到明确的现役 client 与 presentation model;Vite `pre` transform 对退役模块真实路径直接失败,ESLint 在现役源上增加 restricted imports。每次恢复公共 UI 后用 `tsc --listFilesOnly` 和全新浏览器 context 复核,不能用已有 HMR 会话判绿。 -- 关联:`vite.config.ts`、`.eslintrc.cjs`、`src/services/platform-entry/`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 - -## SpacetimeDB schema guard 的基线不能递归扫描保留源码 - -- 现象:旧业务按“数据壳保留、业务实现退役”落地后,`check:spacetime-schema` 报几十个 `legacy_schema` 与原路径 accessor 重复;同一提交对自身比较也失败,但 `cargo` 实际可以正常编译 module。 -- 原因:当前工作树按 `Cargo.toml [lib].path` 的 crate root 可达模块扫描,基线提交却通过 `git ls-tree -r` 扫描整个 `spacetime-module/src`。原 `src/lib.rs` 和旧业务源码只供追溯、不进入 active crate,但基线全目录扫描仍会把它们与 `#[path]` 引入的历史数据壳同时解析。 -- 处理:current 与 base 必须各自读取所在快照的 Cargo manifest,并沿各自 `mod` / `#[path]` 图扫描;base 文件存在性和内容从该 Git tree 读取,不能复用当前工作树。不要忽略 `legacy_schema`、删除历史源码或吞掉 base duplicate,因为历史数据壳正是正式 schema,真实可达重复仍须失败。 -- 验证:回归测试同时覆盖“不可达旧源码同 accessor 不报错”和“两个可达模块同 accessor 仍失败”;再运行 `npm run check:spacetime-schema -- --base-ref HEAD`,确认 self-base 按当前 136 张表通过。 -- 关联:`scripts/check-spacetime-schema-guard.mjs`、`scripts/check-spacetime-schema-guard.test.ts`、`server-rs/crates/spacetime-module/Cargo.toml`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 - ## VectorEngine 请求超时不能脱离 worker 绝对预算(2026-07-20) - 现象:VectorEngine 单次请求超时大于 worker job 执行预算时,worker 已停止续租,provider 才超时或开始重试;最终 lease 过期、任务失败并退款,上游却可能继续消耗资源或迟到成功。 @@ -4828,13 +4649,13 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:部署 guard 使用临时自定义绝对路径,直接读取实际安装目录中的三个 unit,核对 `WorkingDirectory`、`ExecStart`、共享 API env 与角色 env,不能只用 fake `systemctl is-active` 判绿。 - 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/check-production-api-deploy.mjs`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 -## 通用灰度后台页不能依赖已退役业务配置 +## 通用灰度后台页只依赖通用 feature gate 数据源 -- 现象:通用 `feature_gate_config`、`/admin/api/feature-gates` 和现役功能 gate 仍在,但后台“灰度发布”Tab 随旧创作模板入口一起消失;Rust 权限仍可授予 `gray-release`,前端却没有对应路由。 -- 原因:灰度页同时请求通用 gate 与旧 `/admin/api/creation-entry/config`,并把 `creation-entry:*` 动态目标和现役固定目标混在同一页面;按页面清理旧入口时连带摘除了通用控制面。 -- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。 +- 现象:`feature_gate_config`、`/admin/api/feature-gates` 和现役功能 gate 仍在,但后台“灰度发布”Tab 没有对应路由。 +- 原因:灰度页把通用 gate 与业务入口配置混在同一页面,页面生命周期被业务入口耦合并连带摘除。 +- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。 - 验证:`adminRoutes` 必须包含 `gray-release`,admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。 -- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/app/adminRoutes.ts`、`server-rs/crates/api-server/src/modules/admin.rs`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 +- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/app/adminRoutes.ts`、`server-rs/crates/api-server/src/modules/admin.rs`。 ## 角色动作不能靠素材主图或通用生成输入恢复 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 2729c69de..33a70abbc 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -4,7 +4,7 @@ ## 一句话定位 -Genarrative / 陶泥儿当前主站聚焦图片画布创作、编辑器项目与素材管理,以及账号、钱包、公共设置、后台管理和小程序 WebView 外壳。AI Game Creator(AGC)是独立的桌面创作 App,按 DirectProject Runtime 合同执行,不复用主站旧玩法入口。 +Genarrative / 陶泥儿当前主站聚焦图片画布创作、编辑器项目与素材管理,以及账号、钱包、公共设置、后台管理和小程序 WebView 外壳。AI Game Creator(AGC)是独立的桌面创作 App,按 DirectProject Runtime 合同执行,与主站创作入口、后端契约和运行态保持独立。 产品 IP / 产品形象统一为 `public/branding/taonier-product-ip.png`:陶罐中探出的橙色耳朵形象。 @@ -70,9 +70,9 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 - `ui-prototype`(设计图片)与 UI 编辑器 `UI` JSON 是不同资源。白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`,由 provider-backed 识别、合并和组件绑定持久化 State/revision,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 投影到 manifest。Provider 缺失、请求失败、工具缺失、结果不匹配或仍有待审节点时保留真实阶段并返回 blocker,不得用 deterministic seed 伪造完成。 - UI workflow 的资源桥接与 Runtime 边界以 `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` 和 AGC 实施计划的 2026-08-24 覆盖段为准;只生成图片、登记空 JSON 或进入普通图片画布都不构成 workflow 完成。 -## 退役边界 +## 当前产品边界 -全部旧创作模板及其创作、生成、发布、公开业务详情与专属运行态已于 `2026-07-17` 退役。历史表和迁移仅按最小 schema 数据壳保留;旧前端、API、worker、procedure / reducer 和纯业务 crate 不属于当前编译或运行能力,不能重新作为兼容目标。 +当前主站只提供图片画布创作、编辑器项目与素材、账号与钱包、公共设置、后台管理及小程序 WebView 外壳;AGC 独立承载桌面端游戏创作。不在当前路由树、Cargo workspace 和 SpacetimeDB schema / bindings 中的入口与数据对象,不属于当前产品能力。 ## 当前文档入口 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 3df5bb9ef..42fc410fd 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -1,13 +1,10 @@ # 外部生成 Worker 化方案 > 文档状态:`historical` -本文仅用于历史追溯,不作为当前实现依据。 - - -> 2026-07-18 退役覆盖:旧创作模板 job 类型、玩法写回和玩法恢复链路均已退出现役 worker。当前 worker 只领取 `source_module = editor-canvas` 的任务;本文涉及拼图、跳一跳、拼消消、敲木鱼等玩法的内容仅作为历史设计记录,历史队列行不得被领取或改写。 +本文记录 external generation worker 的稳定设计边界;当前实现细节以代码和现行架构文档为准。 > 2026-07-21 已实施、待生产压测专题:BgFilter 作为受限内部资源,仍遵守“单用户动作一个外部生成 job”;用户可见层与调度层都只有父 `external_generation_job`。父 future 保持原 lease 和 attempt,在当前调用栈内同步请求唯一 `bgfilter-worker` 的内部 HTTP,成功图片字节直接返回父流程。首版不新增 SpacetimeDB 子任务表、父 checkpoint / continuation 或 raw 中间结果 OSS。完整边界见 [`BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)`](./【后端架构】BgFilter受限资源调度方案-2026-07-21.md)。 -更新时间:`2026-07-31` +更新时间:`2026-09-23` ## 背景 @@ -20,8 +17,8 @@ VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部 - 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。 - 本地或小流量站内同步排查可显式启用 `inline` 模式,由站内 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。External v1 不继承此例外,始终异步入队。 - SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。 -- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、手动去背景、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。 -- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。 +- 已接入图片画布编辑器的图片、改图、手动去背景、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。外部生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。 +- 外部生成队列粒度固定为“单个用户动作对应单个 job”。例如一次图片生成、改图、视频生成或角色动作生成各自入一个 job;job 内部可以串行或并行调用 provider、OSS 和 SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 处理 / 持久化 / 回写”等阶段 job。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。 - 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。 ## Module 与 Interface @@ -45,19 +42,19 @@ External API job 复用同一个 `result_payload_json` 列,但只额外保存 不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。 -这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。 +这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿和编辑器业务结果回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。 ## BFF 状态接口 队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table: -- `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。 +- `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于轻量状态读取。返回 pending、running、未确认终态数量和更新时间。 - `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、可选 `warning`、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。 -- 任务被 claim 后默认处于 `generating`,BFF 显示“正在生成”;真实进入 BgFilter、逐帧抠图或手动去背景时切换为 `processing`,BFF 显示“正在处理”。旧任务 `phase=None` 按 `generating` 兼容,前端不得按耗时或 job kind 推断阶段。 +- 任务被 claim 后默认处于 `generating`,BFF 显示“正在生成”;真实进入 BgFilter、逐帧抠图或手动去背景时切换为 `processing`,BFF 显示“正在处理”。`phase=None` 按 `generating` 兼容,前端不得按耗时或 job kind 推断阶段。 - `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 - `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `operationId`(即任务 ID)、`status`、`phaseLabel`、`phaseDetail`、`progress`、`error`、`updatedAtMicros`,以及可选、可直接展示的 `warning` 完整文案。生成页轮询只依赖状态、阶段、进度、错误和警告;`jobKind`、source 和完整时间信息继续由任务列表接口或业务快照提供。`attempt` / `maxAttempts` 属于 worker 调度事实,不向该前端契约暴露;若未来需要面向用户展示,必须单独完成产品、契约和摘要投影设计。 -BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、lease、执行和计费事实仍以 `external_generation_job` 为准,用户可见任务列表、单任务状态、执行阶段和通知确认的正式读取事实源为 `external_generation_job_summary`,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。 +BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、lease、执行和计费事实仍以 `external_generation_job` 为准,用户可见任务列表、单任务状态、执行阶段和通知确认的正式读取事实源为 `external_generation_job_summary`,编辑器业务结果以 `editor_project`、`editor_canvas`、`editor_project_resource` 与 `editor_asset` 为准。用户可见任务列表放在 `我的` 页签,单 job 状态用于补充轮询和排障信息,最终结果仍以编辑器项目快照收敛。队列接口不替代编辑器结果接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。 ## 任务表 @@ -66,11 +63,11 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、 | 字段 | 说明 | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `job_id` | 主键,`extgen-` 前缀 UUID | -| `dedupe_key` | 唯一键,建议为 `play/action/session/scope` | -| `job_kind` | 执行类型,当前覆盖 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background`、跳一跳 / 拼消消 / 敲木鱼生成动作,以及 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation`、`editor_background_music_generation` | +| `dedupe_key` | 唯一键,按 owner、job kind 与稳定业务键派生 | +| `job_kind` | 执行类型,当前覆盖 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation`、`editor_background_music_generation` | | `owner_user_id` | 触发用户 | -| `source_module` | 玩法或能力名,例如 `puzzle` | -| `source_entity_id` | session/profile/work 等作用域 | +| `source_module` | 来源模块,当前为 `editor-canvas` | +| `source_entity_id` | 编辑器项目等稳定业务作用域 | | `request_label` | 排障标签 | | `request_payload_json` | worker 执行入参 JSON | | `status` | `pending/running/completed/failed/cancelled` | @@ -91,7 +88,7 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、 新增私有审计表 `external_generation_job_event`,记录 `enqueued/claimed/lease_renewed/completed/failed/acknowledged` 等事件。事件表只追加状态转换事实,不作为当前状态源;排障时先看 `external_generation_job` 当前状态,再按 `job_id` 追 `external_generation_job_event` 时间线。 -另新增私有 `editor_generation_operation` durable commit receipt。它不与 `external_generation_job` 争抢任务状态:job 仍负责队列、lease、计费和通知,receipt 只固化某个 owner/kind/operation 的 request fingerprint、整笔 commit SHA-256、可选 project 以及 queue 的 job/worker/lease/result 绑定。inline 虽没有 job,也必须写 receipt;否则 API 进程重启后无法安全区分“完整提交”与“稳定 ID 巧合/历史部分记录”。 +另新增私有 `editor_generation_operation` durable commit receipt。它不与 `external_generation_job` 争抢任务状态:job 仍负责队列、lease、计费和通知,receipt 只固化某个 owner/kind/operation 的 request fingerprint、整笔 commit SHA-256、可选 project 以及 queue 的 job/worker/lease/result 绑定。inline 虽没有 job,也必须写 receipt;否则 API 进程重启后无法安全区分“完整提交”与“稳定 ID 巧合或不完整记录”。 索引: @@ -111,7 +108,7 @@ pending/running -> cancelled (预留) `claim` 只领取 `pending` 且 `available_at <= now` 的任务,或 `running` 且 `lease_expires_at <= now` 的任务。领取时递增 `attempt`、写入 `worker_id`、`started_at`、新的 `lease_expires_at` 和 `lease_token`。SpacetimeDB procedure 使用 `ctx.timestamp` 作为状态流转时间,只从 worker 入参读取“时长差值”,不信任 worker 本机绝对时间。worker 每次执行只处理自己 claim 到的任务;续租、完成或失败时必须带同一个 `worker_id + lease_token`,且当前 lease 尚未过期,防止过期 worker 覆盖新 lease。 -玩法业务写回也必须在 SpacetimeDB 同一事务里校验 lease fencing。拼图的 `compile_puzzle_agent_draft` worker 调用、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 在 `queue` 模式下会带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind`、`owner_user_id`、`source_module` 和 `source_entity_id` 均匹配后才写 session / work profile。`inline` 模式不创建 `external_generation_job`,因此这三个 guard 字段必须同时为空;transaction 只把三项全空识别为 api-server 受控同步写回,三项半空仍按非法请求拒绝。worker 路径的核心业务写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领,避免队列状态和真实 session 脱节。api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,避免旧 worker 冲掉后续合法 worker 的同一账本扣费。 +编辑器业务写回也必须在 SpacetimeDB 同一事务里校验 lease fencing。`queue` 模式下,`persist_editor_generation_result_and_return` 会携带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind`、`owner_user_id`、`source_module` 和 `source_entity_id` 均匹配后才写正式结果。`inline` 模式不创建 `external_generation_job`,这三个 guard 字段必须同时为空;transaction 只把三项全空识别为 api-server 受控同步写回,三项半空仍按非法请求拒绝。worker 路径的结果写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领。api-server 的资产扣费包装遇到 stale worker lease guard 错误时不执行补偿退款,避免过期 worker 冲掉后续合法 worker 的同一账本扣费。 ## 执行模式与进程角色 @@ -154,51 +151,7 @@ controller 配置: 动态缩扩容方式:生产默认由 `deploy/systemd/genarrative-external-generation-controller.service` 启动 `GENARRATIVE_PROCESS_ROLE=external-generation-controller`,controller 读取 `get_external_generation_queue_stats_and_return` 后对 `genarrative-external-generation-worker@N.service` 执行精确 `systemctl start/stop`;无需改变 HTTP 进程数。controller 只操作 `@1..@MAX` 中的缺口或最高编号多余实例,保留 `@1` 作为保底 worker。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。VectorEngine 图片链路会先于整个 job 执行预算停止 provider 发送 / 重试,以便 worker 在有效 lease 内完成终态写回;若其他业务 future 仍长时间无返回,执行预算到期后 worker 会停止续租并释放槽位,在途 future 继续运行至租约仲裁窗口;有效租约内的写回仍可完成,租约过期后才会由其它 worker 重新认领,避免客户端取消与服务端写回竞态。本次预算收口不改变 lease 续租 / fencing、迟到写回仲裁、attempt 耗尽收口和原子退款语义。容器链路已有独立 `external-generation-worker` compose service;扩 worker 必须扩这个 worker service,不能只扩 `api-server` HTTP service。 -## 已接入的拼图纵切 - -### 拼图 - -`compile_puzzle_draft`: - -1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。 -2. `queue` 模式下 HTTP handler 入队 `puzzle_compile_draft`,返回 `operation.status = queued` 和当前 session。拼图 dedupe key 包含本次 `extgen-` job id,只保证同一任务行唯一,不把同一 session 后续重新生成吞掉。`inline` 模式下 HTTP handler 复用同一 executor 同步执行,成功后直接返回 `completed` 和最新 session。 -3. 前端保持 `puzzle-generating`,继续轮询 `getPuzzleAgentSession`;首期不把 `queued/running` 写回 `puzzle_agent_session`,因此刷新或跨设备恢复生成中状态仍是后续 read model 工作。 -4. worker claim 后执行原有 `compile_puzzle_draft_with_initial_cover` 或 `compile_puzzle_draft_with_uploaded_cover`;前置 `compile_puzzle_agent_draft` 也必须携带本次 `job_id / worker_id / lease_token`,防止过期 worker 先把草稿卡和 session 写到 ready。 -5. 成功后沿原有 SpacetimeDB 拼图会话/作品写回,前端轮询看到 `progressPercent >= 94/96/100` 和 ready 草稿。 -6. 失败后调用 `mark_puzzle_draft_generation_failed`,拼图首期业务失败直接进入 failed;只有失败态写回成功才把队列 job 标为 failed,失败态写回失败则保留租约等待重领。队列仍保留 lease 过期后的崩溃重领,避免 worker 退款后再次成功导致钱包账本漂移。前端通过现有失败草稿/弹窗机制展示来源错误。 - -`generate_puzzle_images`: - -1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_images` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。 -2. worker 执行原结果页关卡图链路:自动命名、VectorEngine / 上传图直用、关卡场景图、UI spritesheet、关卡背景资产包、OSS 持久化和 SpacetimeDB 回写。 -3. 成功后 `save_puzzle_generated_images` 写回目标关卡和草稿卡;失败后 `mark_puzzle_level_generation_failed` 只标记目标关卡 `failed`,不污染已 ready 的其它关卡。队列 job 只有在目标关卡失败态写回成功后才进入 failed。 -4. 前端结果页对 `queued/running` 操作继续轮询 `getPuzzleAgentSession`,目标关卡变为 ready 或 failed 后收敛。 - -`generate_puzzle_ui_background`: - -1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_ui_background` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。 -2. worker 执行原结果页 UI 背景链路:归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。 -3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job,让前端轮询能收敛。 - -### 跳一跳、拼消消和敲木鱼扩展范围 - -以下动作按同一 worker 模式迁移。命名以现有玩法 action 为准,队列 `job_kind` 采用后端稳定 snake_case,不新增平行队列: - -- 跳一跳 `jump-hop` - - `compile-draft`:草稿编译阶段需要生成地块 / 视觉资产时入队,例如 `jump_hop_compile_draft`。 - - `regenerate-tiles`:结果页地块图集重生入队,例如 `jump_hop_regenerate_tiles`。 -- 拼消消 `puzzle-clear` - - `compile-draft`:草稿编译阶段需要生成场地底图和卡片 atlas 时入队,例如 `puzzle_clear_compile_draft`。 - - `regenerate-atlas`:结果页素材 atlas 重生入队,例如 `puzzle_clear_regenerate_atlas`。 -- 敲木鱼 `wooden-fish` - - `compile-draft`:草稿编译阶段需要生成背景、敲击物或其它图片资产时入队,例如 `wooden_fish_compile_draft`。 - - `regenerate-hit-object`:结果页敲击物图片重生入队,例如 `wooden_fish_regenerate_hit_object`。 - -这些动作首版都保持“单动作单 job”:一次 `compile-draft` 或一次 `regenerate-*` 请求只创建一个 job,worker 内部负责该动作所需的 provider 调用、素材处理、OSS 持久化、失败态写回和业务成功写回。非外部图片生成动作,例如纯元信息保存、标签编辑、发布、试玩启动、运行态动作、删除和公开 read model 读取,继续 inline 执行。 - -每个玩法迁移时必须同时接入业务写回 lease guard:worker 路径带 `external_generation_job_id / worker_id / lease_token`,inline 路径三项同时为空。过期 worker 不得写 session / work profile;业务失败态写回成功后才允许 job 进入 `failed`。 - -### 图片画布编辑器 +## 图片画布编辑器 图片画布 `/editor/canvas` 下所有会调用外部生成 provider 的入口在 `queue` 模式下入 `external_generation_job`,HTTP handler 只返回 `queueState`: @@ -252,8 +205,6 @@ cargo test -p spacetime-module level_generation_failure --manifest-path server-r cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml cargo test -p api-server external_editor_generation --manifest-path server-rs/Cargo.toml cargo test -p api-server external_mcp --manifest-path server-rs/Cargo.toml -npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps generation progress visible" -npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "compile_puzzle_draft" ``` 本地 smoke: @@ -265,7 +216,7 @@ curl -f http://127.0.0.1:/healthz 本地 `npm run dev` 与 `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。需要验证生产式拆分角色、lease 重领或扩缩容时,再分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`,也可以使用隔离容器 smoke。 -生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。`genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。External smoke 还必须证明生成 POST 返回 `202`、同幂等键不重复创建任务、统一查询能读到 compact completed result、跨 owner 返回 `404`,并通过托管 MCP 调用同一提交/查询工具链。 +生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。`genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到编辑器项目快照中的 completed 或 failed。External smoke 还必须证明生成 POST 返回 `202`、同幂等键不重复创建任务、统一查询能读到 compact completed result、跨 owner 返回 `404`,并通过托管 MCP 调用同一提交/查询工具链。 systemd 生产 controller 与手动兜底示例: diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index adb2590e5..8c77b20fe 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -1,8 +1,8 @@ # server-rs 与 SpacetimeDB 数据契约 -> 2026-08-25 状态更新:旧创作入口、模板业务 API、worker 和运行态已退役。本文逐玩法路由、流程和 DTO 章节仅作为历史设计记录;相关持久化表仍按原结构作为最小 schema 数据壳编译。当前编译与运行边界以 `server-rs/Cargo.toml`、`server-rs/crates/api-server/src/app.rs`、[当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md)和[平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md)为准。 +> 2026-09-23 状态更新:本文描述当前 server-rs、api-server 与 SpacetimeDB 的正式契约、表目录、路由边界和迁移门禁。未在当前 Cargo workspace、api-server 路由树或 SpacetimeDB module / bindings 中登记的入口与数据对象,不属于本文契约。当前编译与运行边界以 `server-rs/Cargo.toml`、`server-rs/crates/api-server/src/app.rs`、[当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md)和[平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md)为准。 -更新时间:`2026-08-25` +更新时间:`2026-09-23` ## 后端主线 @@ -23,13 +23,12 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk` 当前主要 crate: - HTTP 与运维入口:`api-server`、`pingora-gateway`、`server-manager-panel`。 -- 现役领域模块:`module-ai`、`module-assets`、`module-auth`、`module-editor-agent`、`module-runtime`。`module-runtime` 继续承载账号、钱包、公共设置、追踪、功能门禁等现役平台领域能力;该 crate 名称不代表旧玩法 runtime 路由仍在运行。 -- 平台副作用:`platform-agent-harness`、`platform-editor-agent`、`platform-auth`、`platform-audio`、`platform-hyper3d`、`platform-image`、`platform-llm`、`platform-matting`、`platform-oss`、`platform-speech`、`platform-wechat`。已退役 Creative Agent 的旧 `platform-agent` 不属于现役 workspace 或依赖图,旧实现已删除。 +- 现役领域模块:`module-ai`、`module-assets`、`module-auth`、`module-editor-agent`、`module-runtime`。`module-runtime` 承载账号、钱包、公共设置、追踪和功能门禁等现役平台领域能力。 +- 平台副作用:`platform-agent-harness`、`platform-editor-agent`、`platform-auth`、`platform-audio`、`platform-hyper3d`、`platform-image`、`platform-llm`、`platform-matting`、`platform-oss`、`platform-speech`、`platform-wechat`。 - 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。 - SpacetimeDB:`spacetime-client`、`spacetime-module`。 - 测试支撑:`tests-support`。 -旧玩法纯业务 crate 已从 `server-rs` workspace 配置和仓库删除,不属于 workspace member、default member 或现役依赖。 ## DDD 边界 @@ -39,7 +38,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk` 4. 后端访问 SpacetimeDB 必须经 `spacetime-client` facade。 5. HTTP 鉴权、BFF 聚合、SSE、外部模型编排、OSS 上传和第三方回调在 `api-server`。 6. 前端共享 DTO 通过 `shared-contracts` 和 `packages/shared` 对齐,不在页面内重新发明旧接口;`packages/shared` 还可复用无业务真相的 UI 组件和纯工具,领域规则、后端副作用与正式状态仍留在各自分层。 -7. 微信能力按两层收口:`server-rs/crates/platform-wechat` 承载现役微信支付 V3 / 虚拟支付查单和发货确认的 HTTP header、签名、验签、解密、mock 响应和协议 payload 解析;`server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*` 承载 Axum handler、AppConfig 到平台配置的映射、Genarrative 用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。`platform-auth` 当前仍承载微信 OAuth / 小程序登录 provider 协议,`api-server::wechat::provider` 只作为组合根 adapter,不在业务 handler 内散落 provider 构造。旧玩法生成结果订阅消息服务不再参与编译。 +7. 微信能力按两层收口:`server-rs/crates/platform-wechat` 承载现役微信支付 V3 / 虚拟支付查单和发货确认的 HTTP header、签名、验签、解密、mock 响应和协议 payload 解析;`server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*` 承载 Axum handler、AppConfig 到平台配置的映射、Genarrative 用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。`platform-auth` 当前仍承载微信 OAuth / 小程序登录 provider 协议,`api-server::wechat::provider` 只作为组合根 adapter,不在业务 handler 内散落 provider 构造。 验证: @@ -60,7 +59,7 @@ npm run check:server-rs-ddd `spacetime-client` 的 Cargo `lib.path` 指向 `src/active.rs`,现役 mapper 聚合入口是 `src/active/mapper.rs`;原旧 facade 和 mapper 已删除。 -当前 mapper 按现役调用领域拆分为 `admin_account.rs`、`admin_dashboard.rs`、`ai.rs`、`assets.rs`、`auth.rs`、`editor_agent.rs`、`editor_project.rs`、`external_api_key.rs`、`external_generation.rs`、`runtime.rs` 和 `runtime_profile.rs`;`story.rs` 只承接必要的历史资产记录兼容映射,不恢复旧故事业务 facade。跨领域轻量 helper 和共享 record 放在 `common.rs`;不得重新引入旧玩法 mutation 或跨层 JSON 兼容结构。 +当前 mapper 按现役调用领域拆分为 `admin_account.rs`、`admin_dashboard.rs`、`ai.rs`、`assets.rs`、`auth.rs`、`editor_agent.rs`、`editor_project.rs`、`error_reports.rs`、`external_api_key.rs`、`external_generation.rs`、`game_distribution.rs`、`llm_router_account.rs`、`runtime.rs`、`runtime_profile.rs` 和 `story.rs`;每个模块只承接对应现役 procedure / result 类型的映射。跨领域轻量 helper 和共享 record 放在 `common.rs`;不得把领域聚合或跨层 JSON 兼容结构放入 mapper。 ## API 路由分组 @@ -73,20 +72,20 @@ npm run check:server-rs-ddd 路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组: - 健康检查:`GET /healthz`、`GET /readyz`。 -- 后台管理:`/admin/api/*`,现役路由包括登录与账号管理、Dashboard / 概览、HTTP debug、埋点、表查询、通用 feature gate、编辑器定价与素材 / 精选管理,以及账号侧兑换码、邀请码、任务、钱包、充值与退款管理;不再挂载旧创作入口配置、旧作品互动或旧玩法运营路由。环境变量管理员固定作为 owner,持久化 member 每次请求按当前 `enabled`、`token_version` 和一级 Tab 权限实时校验;账号管理仅 owner 可访问,未登记权限映射的新后台路由对 member 默认拒绝。完整权限矩阵见 [`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md),Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。 +- 后台管理:`/admin/api/*`,现役路由包括登录与账号管理、Dashboard / 概览、HTTP debug、埋点、表查询、通用 feature gate、编辑器定价与素材 / 精选管理,以及账号侧兑换码、邀请码、任务、钱包、充值与退款管理。环境变量管理员固定作为 owner,持久化 member 每次请求按当前 `enabled`、`token_version` 和一级 Tab 权限实时校验;账号管理仅 owner 可访问,未登记权限映射的新后台路由对 member 默认拒绝。完整权限矩阵见 [`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md),Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。 - 通用灰度控制面固定为后台 `#gray-release` 与 `GET/PUT /admin/api/feature-gates`;页面固定目标只登记现役功能,不读取旧 `/admin/api/creation-entry/config`,也不恢复 `creation-entry:*` 动态目标。 - AGC 模板库使用 `agc:template-library`,不新增 schema;`GET /api/runtime/frontend-config` 追加账号相关的 `agcTemplateLibraryEnabled`,匿名和 Gate 缺失为 false,存在配置时复用通用灰度规则。响应设置 `Cache-Control: no-store` 与 `Vary: Authorization`,客户端原生操作独立检查权限。官网公开 `GET /api/client-downloads` 则读取服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)对应的 Windows/macOS 分区,渠道与系统独立,不从请求 query 接受 URL 或渠道覆盖。 - 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。 - 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。 - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 -- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算现役编辑器精选素材派生授权;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 且已通过、已展示、返还完成的 `editor_showcase_asset` 顶层媒体 / 冻结角色动作帧精确授权读取;旧创作模板作品不再形成资产读取授权。动作帧只按快照中的 `assetObjectId` / `objectKey` 逐对象授权,不从 `imageSrc` 或 `generated-*` 前缀推导宽泛权限;动作快照损坏、隐藏、拒绝或不再满足返还条件时不形成帧授权。只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开精选只获得与正式展示快照生命周期一致的精确派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;精选派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。 +- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算现役编辑器精选素材派生授权;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 且已通过、已展示、返还完成的 `editor_showcase_asset` 顶层媒体 / 冻结角色动作帧精确授权读取;动作帧只按快照中的 `assetObjectId` / `objectKey` 逐对象授权,不从 `imageSrc` 或 `generated-*` 前缀推导宽泛权限;动作快照损坏、隐藏、拒绝或不再满足返还条件时不形成帧授权。只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开精选只获得与正式展示快照生命周期一致的精确派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;精选派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。 - 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。 - 编辑器与素材生成:`/api/editor/projects*`、`/api/editor/assets*`、`/api/editor/showcase/*`、`/api/editor/*/generations`、`/api/editor/images/*`、`/api/editor/icon-spritesheets/*`、`/api/editor/ui-designs/*`,以及编辑器 Agent 会话路由。通用任务与素材支撑另保留 `/api/ai/tasks*`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*` 和 `/api/assets/hyper3d/*`。 -- 现役 runtime 前缀公共能力:`GET /api/runtime/frontend-config`、鉴权的 `GET/PUT /api/runtime/settings` 以及鉴权的 `/api/runtime/external-generation/*`。这些分别承载非敏感前端开关、账号级公共设置和外部生成队列观测 / 确认,路径保留 `runtime` 前缀不代表旧玩法运行态恢复。`/api/runtime/frontend-config` 由 `api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭。 -- 公共设置数据链:`GET/PUT /api/runtime/settings` 从 access token 取得 `user_id`,经 `spacetime-client` facade 调用 `get_runtime_setting_or_default` / `upsert_runtime_setting_and_return`,读写现役 `runtime_setting` 表的 `music_volume` 和 `platform_theme`。该表不是历史数据壳,也不得为保留它而恢复任何旧存档、游玩历史或玩法 settings 路由。 +- 现役 runtime 前缀公共能力:`GET /api/runtime/frontend-config`、鉴权的 `GET/PUT /api/runtime/settings` 以及鉴权的 `/api/runtime/external-generation/*`。这些分别承载非敏感前端开关、账号级公共设置和外部生成队列观测 / 确认,路径保留 `runtime` 前缀只承载这些公共能力,不承载游戏运行态。`/api/runtime/frontend-config` 由 `api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭。 +- 公共设置数据链:`GET/PUT /api/runtime/settings` 从 access token 取得 `user_id`,经 `spacetime-client` facade 调用 `get_runtime_setting_or_default` / `upsert_runtime_setting_and_return`,读写现役 `runtime_setting` 表的 `music_volume` 和 `platform_theme`。 - 后台素材查询:`GET /admin/api/editor-assets` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset` 中 `source_type = 'generated'` 的素材,支持 `ownerUserId`、`keyword`、`createdAfter`、`createdBefore`、`cursor` 和 `limit`;`ownerUserId` 接受内部用户 ID 或精确陶泥号,精确 `SY-*` keyword 也会在调用 procedure 前经认证服务解析成内部 `user_id`,未知陶泥号直接返回空列表。带 owner 条件时 procedure 直接走 `by_editor_asset_owner_user_id`,再按任务分组并以最终产物父项分页;响应中每个父项通过 `children` 携带可展开的中间产物,子项各占一行而不单独占分页名额。手动重拆图集保留真实 `editor-atlas-split-*` `task_id`;来源任务只能通过私有 provenance 由服务端生成账号素材的 source resource / asset object / Object Key 推导,再写入 `group_task_id`,不能信任素材库兼容推断值或普通资源创建接口可提交的 `task_id/asset_kind`;procedure 只有当前 editor generation runtime service identity 才能写入归组与完成事实。没有可信来源的现代拆分显式归到自身任务,不进入 legacy 回溯。每个切片同时写入 `group_task_expected_asset_count`,全部预期 asset ID 成功落库并逐行校验后写入不可逆 cohort 完成事实;read model 只让具有完成事实的一个拆分批次并入原图集父项,用户后来删除单片不会改变初始完成状态,部分失败批次与后续重复拆分按真实任务独立分页,避免残缺批次抢占根任务、单组无限增长或丢素材。历史行兼容沿项目资源链回溯,删除项目时只固化直接引用待删资源且尚未固化的历史行,持久化真实来源任务,不把有界展示 ID 反写覆盖来源。父项返回任务生成器和任务总成本,子项返回阶段生成器和阶段成本。provider 原图 / 角色动作预览承载模型生成成本,抠图、逐帧处理、透明图集和切片成本为 0;中间产物使用所属任务真实 `asset_kind`,不再新写 `editor_green_screen_source`,历史旧值只在 read model 中按 Object Key 映射为 `character`、`icon-spritesheet` 或 `character-animation` 并把旧任务成本只读归回 provider 原始产物。游标固定由父任务微秒时间和素材 ID 组成,API 必须同时识别整数微秒、`seconds.microsZ` 与 RFC3339 时间文本;内部时间无法编码时返回服务错误,不得静默返回 `nextCursor = null`。接口只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。图片放大、视频和音频预览统一调用仅后台管理员会话可访问的 `GET /admin/api/assets/read-url`;该入口仅为预览允许后台跨 owner 签名,不改变主站 `/api/assets/read-*` 或 External API 的 owner 边界。成功换签后必须写入 `event_key = admin_asset_read_url` 的 `tracking_event`,记录管理员 subject、Object Key / legacy path 和有效期,不记录 signed URL。 - 后台精选审核:`GET /admin/api/editor-showcase/assets` 的素材 payload 与素材查询共同返回稳定媒体引用,并只透传底层正式 `thumbnailSrc`、`imageSequenceFrames` 与 `imageSequenceDurationMs`;缺少正式动作字段的存量数据必须先经角色动作规范化 procedure 迁移,不在 admin mapper 回读旧 `generationInputs`。两页前端共用同一缩略图、媒体类型、管理员换签和预览弹窗:动作列表缩略图只换签首帧,打开弹窗后才逐帧换签并按完整时长播放,不得在精选审核中复制一套立即全量换签或把绝对 OSS 私有地址直接交给浏览器的简化实现。角色动作弹窗由预览父组件持有当前序列有界的 signed URL、`expiresAt`、解析中、已加载和失败状态,帧元素只消费缓存 URL;缓存沿用主站 signed URL 读取缓存的 30 秒过期安全窗口,当前挂载帧进入窗口时自动换签,已经离开 DOM 的帧只在未临近过期时复用 URL,过期后再次进入窗口必须先换签。提前换签失败时必须在真实过期前继续展示已有已就绪 URL,并按有界退避自动重试;只有当前不存在可用 URL 或图片解码失败时才把该帧标记为失败。缓存最多覆盖当前序列帧,DOM 仍最多挂载当前可见窗口的 3 帧,切换序列、管理员 token 或关闭弹窗时中止在途换签并释放缓存;换签期间继续展示上一已就绪帧,不得在新地址尚未加载完成时提前隐藏可见帧。 - 现役路由只以 `app.rs::build_router` 实际 `.merge(...)` 的 module 和支付回调为准。旧玩法未挂载的 router、handler、worker 与 `play_flow` 实现已删除,不得由新的 `.merge(...)`、handler 转发或兼容 router 重新挂载。`/api/creation-entry/config`、`/admin/api/creation-entry/config*`、旧 `/api/creation/*`、旧 `/api/runtime//*`、旧存档 / 游玩历史 / 公开作品路由均不是现役 API。新增公共路由仍必须经 `app.rs` 的统一中间件、鉴权、背压、埋点与可观测边界。 + 现役路由只以 `app.rs::build_router` 实际注册的路径为准。未登记的 creation、runtime、story 或兼容 router 不属于当前 API;新增公共路由必须经过 `app.rs` 的统一中间件、鉴权、背压、埋点与可观测边界。 ### 图片画布 Agent 对话 @@ -108,20 +107,6 @@ npm run check:server-rs-ddd - 会话详情的终态懒回填必须在单次 GET 和同一 conversation lock 内完成有界重试:任务结果读取、completed payload 解析或工具 formatter 首次失败后最多重试 3 次,每次等待 100ms 并重新读取主任务。任务读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后仍保留 OSS 工具消息的 `not_completed + externalJobId`,由下次会话读取继续 reconcile;JSON 损坏、结果结构不兼容或 formatter 失败等确定性致命错误在重试耗尽后原子写为 `failed`,保存“重试 3 次后仍失败”的最后错误,避免永久循环。 - 画布 Agent 是“正式任务 payload 不进入通用用户 read model”规则的窄例外消费者:`GET /api/editor/agent-conversations/{conversationId}` 只按会话中已有的 `externalJobId` 定向读取主任务,完成后由对应工具 formatter 从 `result_payload_json` 提取并归一有界的图片 / 视频 / 音频引用,写入 OSS 工具消息后返回。前端仍不得通过通用任务列表 / 状态接口读取或解析 `request_payload_json` / `result_payload_json`;OSS 轻量媒体引用只是会话展示与后续 Agent 上下文,不替代 `editor_project_resource`、`editor_asset`、结构化画布表或 `external_generation_job` 的业务真相。未激活结构化存储的 canvas 才继续以 `editor_canvas.layers_json` 作为 legacy 布局真相。 -### 创作 / 游玩统一流程主干 - -> 历史设计记录:本节所述 `play_flow` 和逐玩法 Adapter 已退役,不代表当前 `app.rs` 路由树。 - -`modules/play_flow.rs` 是后端创作与游玩流程的统一入口。现有外部 URL、DTO、错误 envelope、鉴权方式、入口开关语义和 SpacetimeDB schema 默认不变,但路由组织必须遵循: - -1. `app.rs` 只合并 `modules::play_flow::router(state)`,不直接合并 RPG、拼图、抓大鹅、跳一跳、敲木鱼、拼消消、汪汪声浪、视觉小说或儿童向创作等逐玩法模块。 -2. `play_flow` 统一注册每个玩法的 `playId`、领域模块 key、创作路由前缀和运行态路由前缀;后续新增玩法或迁移旧玩法时,先补这个注册表,再挂具体领域模块路由。 -3. 新建创作、首次生成和 Remix 成草稿等会产生新创作的入口开关匹配规则同样归 `play_flow` 管理;`creation_entry_config.rs` 只复用该规则执行 `open=false` 熔断,不再维护第二份路径判断。 -4. `play_flow` 在进入领域 handler 前先解析并挂载 `PlayFlowRequestContext`,统一标记请求处于 `Creation`、`Runtime`、`CreationEntryConfig`、`CreationSupport`、`RuntimeSupport`、`AiTask`、`PublicReadModel` 或 `RuntimeInventory` 阶段,并记录目标 `playId` / 领域模块 key;领域 handler 可以读取该上下文做后续收口,但不能绕过主干自建平行流程。 -5. `play_flow` 只做平台共性编排和领域 Adapter 组合,不下沉玩法规则;最后一步的草稿编译、资产生成、发布、运行态 start/action/finish、计分和排行榜仍交给对应 `module-*`、`spacetime-module` procedure 和玩法 HTTP handler 处理。 -6. 公开作品聚合、作品详情、运行态库存、运行态设置 / 存档、游玩历史、存档归档、游玩统计、历史素材、AI task、runtime chat、文档解析、角色资产工坊、角色图像 / 动画生成和 Hyper3D 代理属于跨玩法或玩法支撑流程,也从 `play_flow` 主干挂入;`modules/platform.rs` 只保留通用 LLM / 语音代理,不再承接创作 / 游玩支撑路由。 -7. 如果某个旧玩法仍使用历史 `/api/runtime//agent/*` 作为创作命名空间,只保留外部兼容路径;新增实现和文档仍按“统一主干 -> 领域 Adapter”的语义描述,不把历史路径当新架构模板。 - ### 认证态用户与会话摘要下发口径 - `/api/auth/entry`、`/api/auth/phone/*`、`/api/auth/password/reset` 与 `/api/auth/wechat/bind-phone` 的普通手机号请求统一使用 `purePhoneNumber` 与可选 `countryCode`,不再接受旧 `phone` 字段;`countryCode` 未提供时默认中国大陆 `86`,显式值必须使用微信同口径的无加号国家码并且当前只允许 `86`。`module-auth` 先校验国家码,再复用纯手机号规范化规则,最终统一以 `+86` E.164 写入认证投影。微信小程序 `getPhoneNumber` 链路仍只接收客户端 `wechatPhoneCode`,后端必须要求微信 provider 成功响应中的 `phoneNumber`、`purePhoneNumber` 与 `countryCode` 均存在且非空,并只使用后两项执行国家码校验和 E.164 构造;腾讯未承诺 `phoneNumber` 为 E.164,不得依赖其前缀格式,也不得在微信链路默认 `86`。 @@ -130,65 +115,17 @@ npm run check:server-rs-ddd - 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。 - 多端登录语义以 `refresh_session` 为粒度:同一账号可保留多个 active session,普通登录不会撤销旧设备;`POST /api/auth/logout` 只撤销当前 refresh session,不提升 `token_version`;`POST /api/auth/logout-all`、改密、重置密码才吊销全端 session 并提升 `token_version`。鉴权中间件仍校验 Bearer `sid` 对应的 refresh session 是否 active,单独踢下线或当前设备退出可以让目标设备立即失效而不误伤其它设备。 -## api-server 模块化演进规则 +## api-server 模块化规则 -`api-server` 的长期方向是从超大 `app.rs` 和超大 handler 文件收敛为按能力组织的 HTTP/BFF Module。后续改造必须保持 HTTP route、DTO、error envelope、SpacetimeDB schema、前端行为和计费语义默认不变;任何 contract 或 schema 变化都要先在当前文档中写清影响范围和迁移计划。 +`api-server` 按能力组织 HTTP/BFF Module,当前路由只以 `app.rs::build_router` 实际合并的 module 和支付回调为准。未在当前 router 注册的路径不属于 API contract。 -路由模块化规则: - -1. 每个能力 Module 只暴露 `router(state) -> Router`;平台创作 / 游玩相关 Module 和支撑能力由 `modules/play_flow.rs` 统一 `.merge(...)` 或在支撑 router 内挂载,其它账号、资产基础、后台和平台基础能力再由 `app.rs` 直接合并。 -2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue;不得重新恢复逐玩法 creation/runtime merge 列表。 -3. 能力 Module 可在路由内部用 `FromRef` 派生自己的 Feature State,例如 `PuzzleApiState`。全局 `AppState` 仍作为进程组合根、鉴权层和全局中间件状态,但业务 handler 优先只提取对应 Feature State,不直接暴露完整 `AppState`。 -4. Feature State 只暴露该能力实际需要的 facade / adapter / 配置快照;若必须复用仍要求 `AppState` 的横切 helper(例如计费、外部失败审计或通用 tracking),应通过 Feature State 的窄方法或显式 `root_state()` 过渡,并在后续继续收窄。 -5. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现,再收窄 handler 可见状态。 -6. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。 -7. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs`、`match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。 - -拼图 `api-server` 内部拆分: - -- `server-rs/crates/api-server/src/modules/puzzle.rs` 只负责路由装配、鉴权层和参考图 body limit;对外继续引用同一批 handler 名称。 -- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State,集中暴露 `SpacetimeClient`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State`,不得重新改回 `State`。 -- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。 -- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。 -- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt 和初始资产就绪校验。 -- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。 -- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。 -- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。 -- `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。 - -拼图发布 / 待发布门槛必须同时要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整;`module-puzzle::validate_publish_requirements` 与 `api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 使用同一资产语言,不得只凭 cover、标题、描述和标签把半成品标为 `publishReady` 或 `ready_to_publish`。 - -该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema 或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。 - -`/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。 - -公开正式 runtime 的启动与局内同步动作统一接受 `RuntimePrincipal`,包括拼图、拼消消、跳一跳、敲木鱼、抓大鹅 Match3D、方洞挑战、视觉小说、大鱼吃小鱼和汪汪声浪。登录用户仍使用账号 Bearer;未登录推荐页或公开运行态使用 Runtime Guest Token,后端以 `principal.subject()` 作为本局 owner / player subject,并用 `WorkPlayTrackingDraft::runtime_principal(...)` 记录游玩。创作、个人作品、删除、发布、Remix、点赞等账号或所有权动作不得改成 runtime guest 鉴权。 - -抓大鹅 Match3D `api-server` 内部拆分: - -- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。 -- `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。 -- `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP 响应。 -- `/api/runtime/match3d/works/{profile_id}/runs`、`/api/runtime/match3d/runs/{run_id}`、`/click`、`/stop`、`/restart` 与 `/time-up` 属于正式运行态局部请求,必须接受 `RuntimePrincipal`;登录用户使用账号 Bearer,推荐页匿名游客使用 runtime guest token,后端以 principal subject 作为本局 owner,不得退回只认普通 Bearer 的路由。 -- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。 -- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。 -- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品生成批次编排、append / replace / delete / sort / merge、计费外层和草稿素材映射;sheet prompt、绿幕 / 近白底透明化、切图和切片持久化复用 `generated_asset_sheets` 通用模块。 -- `server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs` 仅保留历史 VectorEngine Gemini 物品 sheet helper;当前草稿物品 spritesheet 以关卡整图为参考走 `gpt-image-2` 编辑链路,提示词、绿幕透明化和 OSS 持久化由 `item_assets.rs` / `works.rs` 约束。 -- `server-rs/crates/api-server/src/match3d/runtime.rs` 保留运行态轻量归一 helper;`mappers.rs` / `tags.rs` / `tests.rs` 分别承接 DTO 映射、标签 / 通用错误 helper 和原有单测。 - -该拆分只改变 `api-server` 文件组织,不改变 `/api/creation/match3d/*`、`/api/runtime/match3d/*` route、DTO、error envelope、SpacetimeDB schema、VectorEngine / OSS 副作用边界或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉到 `module-match3d`。 - -生成资产 Adapter 规则: - -1. 稳定单图链路可收敛到 `api-server` 内部生成资产 Adapter:provider 生成、下载/base64 解码、MIME/extension 归一、OSS private upload、HEAD、asset object confirm、entity binding。 -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. 图片 provider 协议不再放在玩法模块里实现。产品、计费、DTO、持久化和 VectorEngine 创建 / 编辑首选请求统一使用 `gpt-image-2`;只有符合回退条件时,provider 边界才切到兜底模型 `gpt-image-2-c`。URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 -6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。generated 私有对象上传时必须由 OSS 对象头承载浏览器 / CDN 缓存策略,默认写入 `Cache-Control: public, max-age=31536000, immutable`,不得改成 api-server 本地磁盘静态资源兜底。 -7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 -8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 -9. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 +1. 每个能力 Module 只暴露 `router(state) -> Router`。 +2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、鉴权入口和少量顶层 glue。 +3. 能力 Module 可用 `FromRef` 派生窄 Feature State;handler 只提取该能力实际需要的 facade、adapter 和配置快照。 +4. handler 只做 Axum extract、鉴权和 request/response 映射,业务规则下沉到对应 `module-*`。 +5. 大 handler 按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。 +6. 手写 Rust module 入口统一使用同名 `.rs` 文件和同名子目录,不新增 `mod.rs`;生成的 SpacetimeDB bindings 使用 `module_bindings.rs` 与 `module_bindings/*.rs`。 +7. 新增或修改公共路由必须经过 `app.rs` 的统一 middleware、鉴权、背压、埋点与可观测边界。 ## SpacetimeDB schema 变更规则 @@ -198,12 +135,11 @@ npm run check:server-rs-ddd 4. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。 5. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option>` 加 `#[default(None::>)]`,业务层归一为空数组。 6. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。 -7. 面向公开列表的只读投影必须先用独立 public view / read model 固化数据契约,再由 `spacetime-client` 通过明确 procedure / query 边界读取;当前连接池不维护公开作品长期订阅缓存。旧创作模板的逐玩法 gallery view、`public_work_gallery_entry` / `public_work_detail_entry` / `public_work_asset_read_grant` 及其兼容路径均已随公开作品基础设施退役,不得作为现役 read model 或 fallback 恢复。需要请求时间窗口的轻量统计可读取 `public_work_play_daily_stat`,需要写入副作用的详情、点赞、游玩记录仍走现役 procedure / reducer。前端不得直接订阅领域源表,也不得自己做 join、聚合或权限逻辑。排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 用当前正式契约处理。 +7. 面向公开列表的只读投影必须先用独立 public view / read model 固化数据契约,再由 `spacetime-client` 通过明确 procedure / query 边界读取;当前连接池不维护公开作品长期订阅缓存。需要请求时间窗口的轻量统计读取 `public_work_play_daily_stat`,需要写入副作用的详情、点赞和游玩记录走现役 procedure / reducer。前端不得直接订阅领域源表,也不得自己做 join、聚合或权限逻辑。排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 用当前正式契约处理。 8. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。 9. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。 10. procedure 需要按调用者 identity 鉴权时,必须先从外层 `ProcedureContext::sender()` 捕获 caller,再把 caller 显式传入 `try_with_tx` 闭包内的事务函数。当前 workspace 锁定 SpacetimeDB `2.8.3`;鉴权边界不得依赖事务上下文的隐式 sender 语义,即使 SDK 升级也继续保持显式 caller 参数。 -11. 旧创作模板的 63 张历史玩法表已从 SpacetimeDB module、migration 导入导出白名单和生成 bindings 中退役,当前 schema 不再包含 `player_progression`、`custom_world_*`、Puzzle / Puzzle Clear、Bark Battle、Match3D、Jump Hop、Wooden Fish、Square Hole、Visual Novel 与 Big Fish 的历史表。清理后不得保留 `clear_retired_database_tables` 这类只服务于空表的数据操作入口,也不得以 SQL `DROP TABLE`、`--delete-data=always` 或系统表写入替代受控 schema 发布。`runtime_setting`、`runtime_snapshot`、`user_browse_history`、`creation_entry_config` 等现役表继续保留并参与迁移。生产发布前仍必须完成数据库冷备份、客户端兼容性和运行态确认,确认后按 SpacetimeDB schema 发布门禁整体更新 module、migration 与 bindings。 -12. 修改后运行: +11. 修改后运行: ```bash npm run spacetime:generate @@ -242,14 +178,6 @@ npm run check:server-rs-ddd 24. 退款欠账继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 为唯一真相;不新增平行 debt 累计。`profile_wallet_manual_restriction` 只保存人工冻结,普通消费同时检查人工冻结与退款欠账。后续永久泥点到账后继续偿还欠账,每日免费与会员周期泥点不参与;解除人工冻结不得清除退款欠账限制。 25. 管理员充值订单、用户详情、历史花费手动对账、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。用户详情中的历史花费泥点数读取 `profile_wallet_consumption_total` 投影;已有投影时,每次 `asset_operation_consume` 负向流水落账在同一事务内按主键 O(1) 原子累加,退款不回减,充值退款追回、余额重置、赠送和 hold 均不计入。首次上线必须在停止业务写入的维护窗口内,由 owner 调用 `POST /admin/api/profile/users/initialize-consumption-projections`,一次扫描全部权威钱包流水,为每个已有钱包流水的用户初始化存量投影;接口成功后才能恢复流量。维护遗漏或新用户缺行时,首次消费按该用户索引一次性重建(当前消费流水已经在同一事务中,不能重复加本次金额);钱包详情首次读取也保留同一按用户兜底。`POST /admin/api/profile/users/reconcile-consumption` 是显式手动对账入口:owner 始终可用,member 必须单独持有 `profile-wallet-consumption-reconcile` 操作权限,任何一级 Tab 都不自动附带;用户详情只在后端返回 `canReconcileConsumption=true` 时展示按钮。操作经二次确认后扫描该用户全部权威钱包流水、比较并校准投影,同时记录管理员和对账时间。退款人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,返回微信退款交易号、订单总额以及获批错误码等正式审计字段,并调用 runtime service identity 受限 procedure;后台确认面板必须展示这些后端事实,前端不得自行改 settlement、钱包冻结或消费累计。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。 -## 创作入口泥点扣费契约 - -1. `creation_entry_type_config.unified_creation_spec_json` 内的 `mudPointCost` 是玩法新建草稿初始生成的泥点成本真相源,同时供入口卡展示和前端余额前置校验使用;旧契约缺失时允许按代码默认成本兜底。 -2. `api-server` 执行拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成时,必须通过 `GET /api/creation-entry/config` 同源配置解析对应玩法成本后再调用钱包扣费 procedure,不得继续使用前端或后端硬编码常量作为实际扣费真相。 -3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。 -4. 资产操作的预扣费必须 fail-closed:钱包或 SpacetimeDB 预扣费不可达、超时或返回业务错误时,`api-server` 直接返回错误,不允许继续调用图片、音频、GLB 等外部生成 provider。 -5. 需要支持 HTTP retry 的计费 ledger id 必须包含当前请求的 `request_id`;前端 `fetchWithApiAuth` 同一次业务请求的静默刷新重试复用同一个 `x-request-id`,后端不得再使用 prompt 指纹或随机 asset id 作为扣费幂等键。 -6. 外部生成已预扣费但后续失败时,失败/任务状态变更事务必须在 SpacetimeDB 内按 refund ledger id 幂等写入 `profile_wallet_refund_outbox` pending 行;跨节点 worker 从库内 pending 行批量处理并在库内事务执行退款,成功后删除 outbox 行,失败按 `available_at` 和 `attempts` 重试。当前 attempt 若 consume 尚不可见,事务仍必须先写 `asset_operation_wallet_settlement` 取消 intent,阻止迟到扣费。普通 inline 资产失败也先调用同一 DB outbox procedure;只有 SpacetimeDB 完全不可达时才写 `wallet-refund-outbox` 本机 emergency spool。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED`、`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR`、`GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE`、`GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS` 和 `GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。本机文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`,不能替代库内 outbox。外部生成任务触发的扣费和退款必须在 `profile_wallet_ledger.metadata_json` 与两类 outbox 中保留 `externalGenerationJobId` 和 `externalGenerationClaimAttempt`,便于从退款记录追溯到具体 attempt。 ## 用户钱包与编辑器生成扣费契约 1. `profile_wallet_config` 是账号初始泥点和每日免费泥点基础发放量的统一真相源;后台通过 `/admin/api/profile/wallet-config` 一次读写 `initialMudPoints` 和 `dailyFreePointsPerDay`。新用户账号完成注册并成功同步正式认证表后,注册赠送金额读取 `initial_mud_points`;未写入配置时默认为 `100`。每日免费基础发放量未写入时默认为 `20`。注册赠送流水原因仍使用 `new_user_registration_reward`,流水 ID 继续保持幂等,重复发放请求不得叠加余额。 @@ -261,7 +189,7 @@ npm run check:server-rs-ddd 7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。 8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。 9. 编辑器进入外部生成持久队列的图片生成、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选稳定引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。图片修改主来源是明确例外:站内 `/api/editor/images/edits` 与 External v1 API 调用方只提交必填 `sourceReferenceId`,且只接受当前账号已登记的 `editor_project_resource.resource_id` 或 `editor_asset.asset_id`;objectKey、URL、Data URL、Blob URL、未登记 ID 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段必须在入队前返回 `400`。api-server 必须通过共享 SpacetimeDB 窄查询分别按资源 ID、素材 ID 主键定点解析,双表同时命中、未命中、跨 owner、缺失或越权 asset object、禁止或未知类型均失败关闭;objectKey 与素材类型只能来自服务端解析结果。提供 `targetLayerId` 时必须同步读取指定 `projectId`,校验目标图层关联资源、双方权威对象和默认类型;双方都有 `assetObjectId` 时必须比较 ID,任一方缺失时才回退 canonical `(bucket, objectKey)`。来源默认类型只参与同一权威对象的绑定一致性校验;快速编辑准入必须在解析目标图层后按 `assetKindOverride ?? resource.assetKind` 的有效类型判断,不能先按来源默认类型拒绝,因此默认 `icon` 资源覆盖为 `scene/spec` 时允许,未覆盖时仍拒绝。图片修改入队载荷保存版本化权威来源快照,worker 调用 provider 前必须按同一 `sourceReferenceId` 再次定点解析并拒绝身份或类型漂移;历史载荷只把非空旧资源 ID 或旧来源字符串本身作为业务 ID 尝试迁移,禁止按 objectKey 反查或信任旧 `assetKind`。去背景入口必须在入队前把 `assetKind` 收口为来源项目资源或素材库解析出的静态图片语义类型,资产对象存储类型只参与非静态媒体门禁:显式项目资源 ID / 素材 ID 必须优先于 objectKey 回退;纯 objectKey 对应多条且权威元数据不一致时必须返回 `400` 并要求 `sourceResourceId` 或业务 ID 消歧,不能按列表首条选择。请求值与权威类型冲突,或任一记录表示视频、音频、动画、图片序列时失败关闭;无 `canvasCompletion` 的 `targetLayerId` 还必须与来源优先按 `assetObjectId`、缺失时按 canonical `(bucket, objectKey)` 证明为同一对象,且默认类型一致,禁止用来源 A 覆盖目标 B。原位替换使用纯 objectKey 且省略 `sourceResourceId` 时,入队与 Worker 都以目标图层当前资源作为显式来源绑定并持续复验。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。其它候选引用的 objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再作为图片修改的辅助 `referenceImageSrcs` 入队,主来源仍使用原图业务 ID;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 - - 图标规范结构化分析里位于 `` / `` XML 元素内的数据必须转义 `& < > " '`;玩法润色、美术风格润色、规范图生图和图标 spritesheet 等自然语言 prompt 必须保留已经过边界校验的原文。图标 spritesheet 的 `iconDescriptions` 在请求边界执行独立合同:原始数组满足 OpenAPI `1..100`,去空后至少保留 1 条;单条最多 `200` 个 Unicode 字符、拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builder,External v1 超限同步返回 `400`。 + - 图标规范结构化分析里位于 `` / `` XML 元素内的数据必须转义 `& < > " '`;编辑器润色、美术风格润色、规范图生图和图标 spritesheet 等自然语言 prompt 必须保留已经过边界校验的原文。图标 spritesheet 的 `iconDescriptions` 在请求边界执行独立合同:原始数组满足 OpenAPI `1..100`,去空后至少保留 1 条;单条最多 `200` 个 Unicode 字符、拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builder,External v1 超限同步返回 `400`。 10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。 12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,客户端仅以 GET-only 项目对账判定权威结果,不复用既存 metadata 作 exact compare-and-return。 @@ -272,8 +200,7 @@ npm run check:server-rs-ddd - 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 - 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,并由客户端 GET-only 对账;本次请求不得附加 `resultPersistenceStarted`。`AlreadyApplied` 仅在 early guard 与最终 procedure 并发相遇时作为底层幂等兜底,复用既有 commit 且不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 - 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 -- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent 文本链路使用 Tiantoken,使用 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 Tiantoken base URL 规范化到 `/v1`。VectorEngine 只保留给 Suno 音乐任务;后续排障时优先确认 Tiantoken `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 -- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`,通用 `/api/llm/chat/completions` 代理可使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时由 Tiantoken 凭据承接。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent 客户端来源。 +- LLM 与 provider 路由:通用 LLM 门面使用 `GENARRATIVE_LLM_*`,`platform-llm` 支持 `OpenAiChat`、`OpenAiResponses` 与 `Anthropic`;Tiantoken 使用 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`,VectorEngine 凭据只服务 Suno 背景音乐与音效,编辑器 SFX V2 使用 ElevenLabs。 - 当前 provider 路由:`TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 承载原 VectorEngine 的文本和图片能力;旧版 Vidu 音效代码已移除;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐及 Suno 音效任务;新编辑器 SFX V2 继续独立使用 ElevenLabs。 ### 平台适配器:`platform-llm` 公共能力与三协议工具契约 @@ -323,15 +250,9 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一通过唯一 loopback `bgfilter-worker` 调用 BgFilter provider。provider 配置继续使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 与 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,默认 base URL 为 `http://58.87.105.82/bgfilter`;单次 provider attempt 上限不再独立配置,由公式 `N × est × 2` 运行时派生,其中 `est = GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS`(默认 `5000`,依据为服务端高并发单图处理约 1-3s、网络约 3-5s),旧 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 已删除;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。父流程先把候选 `objectKey`、`resourceId` 或 `assetId` 解析为当前 owner 已登记的私有 OSS object key;BFF 入队前统一拒绝 `data:` / `blob:`,底层 resolver 在解析引用前再次拒绝内联媒体并完成登记状态与 owner 校验。父流程只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 与模式参数,不传图片字节或签名 URL,并同步等待子 worker 返回的受限图片二进制 body。子 worker 在每次真实 provider attempt 前签发短期 OSS URL,承担 admission 保险丝 `Q`(默认 `2048`,仅防连接风暴)、provider 并发 `N`(生产 `16`);排队 deadline 从 `Q` admission 时刻起算,完成 JSON 校验并进入 provider permit 等待队列时再取得队长快照,按 `min((队长+5)×est×2, maxQueueWaitMs)` 约束排队等待。子 worker 还负责严格最多两次顺序 attempt、结果校验和按 flat / complex 隔离的进程级熔断;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式,且只由子 worker 读写。手动去背景固定使用 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;complex provider 失败累计自身熔断,任意失败或自身熔断都直接返回父流程失败,不接 flat fallback,也不影响 flat 熔断。标准纯色背景四条链路固定使用 `background_mode=flat`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成、图标 spritesheet 生成和角色动作逐帧去背传 `cross_check=on`,UI 设计图素材提取传 `cross_check=off`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求不展示抠图模型选择,但自动提交默认 `segModel=birefnet`;api-server 负责 allowlist 校验并在字段缺失时回落默认值,角色动作逐帧去背的 `seg_model` 则由后端固定。`background_mode` 与 `cross_check` 只存在于 api-server 到 worker 的内部 RPC;请求中的 `segModel` 不进入 `generationInputs`、普通用户响应、搜索、详情、导出或错误详情,External OpenAPI 是否接受该字段按独立契约决定。父侧不重试已建立连接的内部 RPC,仅对 TCP 连接从未建立的失败按父预算有界退避重试(跨过 worker 重启与开机排序窗口,收到任何 HTTP 响应即停止);flat 两次 provider attempt 失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才继续“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以 object key 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路中,子 worker 已发出的 BgFilter provider 失败(含被剩余预算截短后发生的 timeout 与 response 阶段超时,这类失败不计入熔断但仍是审计候选)由进程级 `1024` 个审计任务硬上限保护,获准任务写入共享 tracking outbox 根目录下独立的 `bgfilter-worker/` 子目录并批量落库;满载、outbox 缺失、达到磁盘保护阈值或写盘失败时允许丢弃并记录指标,不回退逐条同步直写 SpacetimeDB。父侧阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)继续按通用外部 API 审计策略处理。真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。成功图片字节返回后,最终 Alpha / 尺寸恢复、OSS / asset object、画布写回、计费和父任务终态仍全部由父流程负责。 - 请求与可见性边界:同源画布 BFF 的角色、图标 spritesheet 和 UI 设计图素材提取 request DTO 保留 `segModel`;前端只自动提交默认值,不提供用户选择控件。api-server 对该字段执行 allowlist 校验并提供缺省回退,再按链路固定 `background_mode=flat` 与 `cross_check` 后调用 loopback worker。普通用户与 External API 的响应、资源 read model、`generationInputs`、搜索、详情、导出和错误文本均不返回该请求控制字段或实际抠图模型;后台管理与服务端 raw 审计仍保留实际执行信息。 - BgFilter 连接复用、超时与动作帧流水线:`AppState` 分别复用父侧内部 worker HTTP Client 和子 worker 专用 BgFilter provider HTTP Client;父侧对一次逻辑调用至多让 worker 接收一次内部 RPC,不重试已建立连接后的失败;仅 TCP 连接从未建立时(worker 重启 / 开机排序窗口)按每轮重算 `maxQueueWaitMs` 的有界退避序列重连——增加的只是连接尝试次数,不产生第二次被接收的 RPC。重连配额按本进程是否已连通过 worker 分档:首连前(冷启动)flat 22.5s / complex 约 62.5s,首连后 flat ≤1.5s / complex 22.5s;每次重连计 `bgfilter_internal_connect_retry_total` 指标。子 worker 在同一个 `N` permit 内严格最多执行两次顺序 provider attempt。唯一子 worker 使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=N`(生产 `16`)限制真实 provider 在途数;`GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=Q` 降级为可选 admission 保险丝(默认 `2048`,仅防连接风暴,显式配置时必须 `>= N`)。超时全部由 `N` 与 `est` 运行时派生:单 attempt 上限 `N × est × 2`、调用预算 `callBudgetMs = 2 × attempt + 1s`(自取得 `N` permit 起算)、排队等待受 `min((provider 等待队列队长+5)×est×2, maxQueueWaitMs)` 双重上界(动态项充当自适应过载探测,超时带 `bound = estimate | parent` 标记),排队不侵蚀调用预算;`N` 与 `est` 必须同放共享 API 基础环境;请求携带的 `callBudgetMs` 只是父侧配置指纹,worker 比对后不一致只告警并计 `bgfilter_internal_call_budget_drift_total` 指标、始终以本进程公式值执行——发布调优 N / est 的新旧进程共存窗口不得误伤在途任务,持久漂移由部署脚本共享 env 对齐校验在启动前拦截。角色动作不再增加 `2000ms × 本次实际帧数`,`32 / 40 / 48` 帧使用相同公式。父侧按剩余绝对预算派生 `maxQueueWaitMs`(flat 扣除 `39s` 父侧预留(`37s` fallback + `2s` 传输窗),complex 只留 `2s` 传输窗;`<= 0` 时不发请求直接降级 / 失败),client timeout 取 `maxQueueWaitMs + callBudgetMs + 2s`;每次 attempt 前重新签发短期 OSS URL,剩余时间不足时不开始新的 attempt。父侧成功响应解码槽 `P = 8`。角色动作继续以 `buffer_unordered(frame_count.max(1))` 将全部单帧逻辑调用加入无序在途集合;返回结果携带原始帧序并在最终 collect / drain 全部已提交 Future 后排序,任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。单帧按“绿幕源图 owned 上传 OSS 并释放原帧 → 以 object key 调内部 worker / 按 object key 由父侧降级 → 父侧处理透明帧并落 OSS”流水化。角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 仍统一复用 `AppState` 内初始化一次的 OSS HTTP Client(连接池参数为 connect 30 秒、request 60 秒、idle 300 秒、每 host 8 个 idle 连接、TCP keepalive 60 秒),并受进程级 8 路 OSS semaphore 限制;BgFilter provider 的 `N` 不占该 OSS permit,阿里云和本地处理既不占 OSS permit,也不受 `N / Q` 限制。每个 OSS 网络 attempt 单独获取 permit,退避期间释放;PUT/HEAD 动画帧请求最多 3 次(250ms、500ms 退避),只重试无 HTTP 响应的传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT `400` 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx。动作帧 PUT 只在 400 响应中有界读取最多 16 KiB OSS 错误 XML,并保留 `Code` 与响应头优先的 `x-oss-request-id`;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效,未解析出 `Code` 则按 timeout/transport 归类重试;除 `RequestTimeout` 与该错误体读取失败情形外的其他 400、401/403/404、配置、URL/签名和空请求体错误不重试。最终帧 HEAD 失败只重试 HEAD,不重复 PUT。 -- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 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 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 -- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 -- 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图,并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物和返回按钮上传 OSS 前只做服务端绿幕去背后处理,避免泛抠图误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 -- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 -- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一,以及 ElevenLabs SFX 单次同步二进制请求、`40 MiB` 有界读取、MP3 验证和 `600s` 技术异常上限内的实际时长探测均归属 `platform-audio`。ElevenLabs 直接 adapter 不进入 Suno/Vidu 的 submit + poll 枚举,OSS put 请求准备以显式 provider / file stem 描述来源。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;SFX Worker 在该模块内以同一编排函数串联计费、翻译、ElevenLabs、OSS、项目资源 / 账号素材 / 画布写回,生产 adapter 复用正式边界、测试 adapter 只注入 mock。内部失败分类固定为 `translation_invalid / translation_upstream_failed / translation_budget_exhausted / elevenlabs_http_failed / invalid_audio / duration_probe_failed / oss_failed / writeback_failed`,普通用户读取边界继续返回稳定短文案。拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 - OSS:私有 generated path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。请求参数的安全语义不能混用:`legacyPublicPath` 是历史公开作品兼容口,只允许 `platform_oss::LEGACY_PUBLIC_PREFIXES` 中的 curated 前缀匿名换签;`objectKey` 是正式对象引用,绝不能复用该前缀旁路,必须查询 `asset_object` 并校验配置 bucket、精确 key、`PublicRead` 或当前 owner。External OpenAPI 的 `/api/external/v1/assets/read-url` 还必须有 `editor:asset` scope,并始终以 API Key 绑定的 `owner_user_id` 执行同一 owner 校验;后台跨账号预览只能走管理员鉴权后的 `/admin/api/assets/read-url`。`/api/assets/read-bytes` 与主站 read-url 共用完全相同的授权,默认仍应由浏览器使用 signed URL 直读,bytes 只作跨域字节读取 fallback。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。`editor-agent/` 前缀只用于服务端内部读写画布 Agent 会话消息文档,不属于浏览器直传 legacy public prefix;`/api/assets/direct-upload-tickets` 必须拒绝 `legacyPrefix=editor-agent`,内部读取只允许 `editor-agent/{conversationId}.json` 形态。 - 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;编辑器 `screenColor=auto` 的 gpt-5-mini 背景色决策同样必须审计每次已发出的 LLM 调用失败,包括传输 / 超时、上游拒绝、响应体解析、空响应和返回候选外颜色;即使随后降级默认背景色并继续主流程也不得只记 warning。`api-server` 将这些失败映射成 `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,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。普通调用入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。`bgfilter-worker` 是受限资源例外:它使用共享 tracking outbox 基础目录下独立的 `bgfilter-worker/` 子目录,provider 失败审计在 spawn 前受进程级 `1024` 硬上限保护并由 shutdown tracker 跟踪;满载、outbox 缺失、保护阈值拒绝或写盘失败时直接丢弃并观测,不回退同步直写 SpacetimeDB。优雅退出先排空已获准任务的 enqueue,再封存并尽力 flush;进程被强杀时只有已 enqueue 记录可在下次启动重放。 -- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。 +- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,正式任务状态仍以 `external_generation_job` 与摘要投影为准。 ## SpacetimeDB 表目录 @@ -363,10 +284,10 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 现役覆盖:worker claim 只允许 `source_module = editor-canvas`;下述逐玩法生成和写回描述均为退役前历史。历史 pending / running 行继续保留原状态,不得领取、失败收口或改写 payload。 -- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 legacy `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 +- 现役覆盖:worker claim 只允许 `source_module = editor-canvas`;任务领取、续租、阶段更新和结果完成必须携带并校验同一 `worker_id + lease_token`。 +- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 - 2026-08-06 收口覆盖:上一条用途描述中“当前先 CAS、单事务仍是目标”的旧句已作废。现役编辑器生成不再组合调用 object confirm、resource create、asset create、canvas save 和 job complete。`api-server` 只准备稳定候选,再经 `spacetime-client` 调用 `persist_editor_generation_result_and_return`;procedure 在同一 `try_with_tx` 内写入可选 `asset_object`、全部 `editor_project_resource`、`editor_asset`、可选 `asset_entity_binding`、可选 canvas V2 CAS、queue job 终态和 `editor_generation_operation` receipt。结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 仍经既有 V2 布局验证分流,前端不直接发明正式完成态。queue 首次提交在同一快照验证 owner、job kind、request fingerprint 和有效 `job_id + worker_id + lease_token`;统一 procedure 已完成 job 后 worker 不得再单独 complete。 -- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 +- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。未登记的 source module 不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 - 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥,风格归一化或像素规整产生的通用 `warning` 可与 `sliceWarning` 并存。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`,两条并存时按“通用在前、拆分在后”拼接且 `code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),不允许任何一条被丢弃;`sliceWarning.reason` 无论是否并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。 - 2026-07-29 收口补充:上条 source-only 的“透明背景处理最终失败”同时包含 Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败和透明图完整解码失败;三条链路共用 helper,只写已保存 provider 原图画布层,图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不得写透明图、派生资源或切片。provider 原图本身解码失败时在首次持久化前失败,不允许 `512×512` 元数据兜底。图标自动拆分、手动拆分与 UI 提取先在受 2 路 CPU semaphore、30 秒 / 请求 deadline 保护的 blocking prepare 中完成解码、透明化、连通域和 bounds 排序;platform 对全部原始连通域设置 `4096` 硬上限、用空间网格查询邻近辅助候选,并在首片 PNG 编码前同时执行 `maxOutputSlices=256` 与全部 padding crop 总像素预算。prepare 返回共享 RGBA + bounds 计划,api-server 再以容量 `2` 的有界管线按需编码、共享 HTTP client 并发 OSS `PUT + HEAD`,OSS 连接 / 单请求超时固定为 `10s / 60s`;手动入口在下载最大 `32 MiB` 来源对象前取得独立内存 admission,同一 admission 覆盖下载、prepare 到最后一片上传结束并在数据库调用前释放,CPU permit 只覆盖实际 CPU 阶段。全部对象上传验证成功后,切片的 `asset_object + editor_project_resource + editor_asset + editor_asset_group_cohort` 由单个受 editor generation runtime service identity 保护的 SpacetimeDB procedure 在一次 `try_with_tx` 中原子写入;resource / asset ID 由 owner + task + 序号稳定派生,已有同 ID 素材仅在内容完全一致时幂等复用,来源资源必须存在且与派生资源同 owner / project;上传中途失败不得写部分资源、素材或 cohort,不确定结果重放不得复制整批素材。自动超限只保留整张可信透明图并返回稳定 `sliceWarning`,不写切片;手动超限在首次持久化前返回 `422`。 @@ -379,7 +300,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` - 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、可选 `phase`、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、phase update、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure;`running + processing` 映射为“正在处理”,其它 running(含旧行 `phase=None`)映射为“正在生成”。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。 - 非阻断告警:摘要字段 `warning_message` 是展示投影,由完成任务的轻量 `result_payload_json.warning.reason` 原样提取,不等同于公开 inline / external v1 的原始结构化诊断字段。complete 和历史 backfill 共用同一构建路径;历史任务按其结果载荷中已写入的 `reason` 快照投影,不为格式升级重写或补前缀。单 job 状态和任务列表 BFF 以 `warning: string` 返回该可直接展示的完整文案,不再返回结构化 code,Web 不得再次补前缀或按字符串推断告警类型。错误与告警摘要都不复制内联媒体并限制为 2048 字符。`phase` 与 `warning_message` 分别表示当前执行阶段和成功降级提示,不得混用;worker / BFF / Web 必须同版本协调发布,不保证滚动混部或旧 Web 缓存下的字符串语义兼容。 -- 正式读取 procedure 为 `get_external_generation_job_summary_and_return`、`list_external_generation_job_summaries_and_return` 和 `acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return`、`backfill_external_generation_job_summaries_and_return` 与 `prune_external_generation_job_history_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。历史清理默认使用 `--prune-history`、`source_module = editor-canvas` 和 30 天保留期;只有主任务与摘要状态一致且属于 completed / failed / cancelled、摘要已有 `notification_acknowledged_at`、终态时间不晚于 cutoff 的记录才是候选。apply 在同一事务内按事件 → 摘要 → 主任务顺序删除,事件不得独立清理;每次事务最多删除 256 条事件,若同一任务仍有事件则保留任务与摘要并返回同一个 `next_cursor_job_id`,下一次继续该任务,避免单个任务形成无界事务写集;pending / running、未确认通知、摘要缺失或状态不一致的记录永不删除。清理不触碰资产对象或钱包流水,其他 source module 必须显式指定并单独评估。 +- 正式读取 procedure 为 `get_external_generation_job_summary_and_return`、`list_external_generation_job_summaries_and_return` 和 `acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return`、`backfill_external_generation_job_summaries_and_return` 与 `prune_external_generation_job_history_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它 source module 的历史任务。历史清理默认使用 `--prune-history`、`source_module = editor-canvas` 和 30 天保留期;只有主任务与摘要状态一致且属于 completed / failed / cancelled、摘要已有 `notification_acknowledged_at`、终态时间不晚于 cutoff 的记录才是候选。apply 在同一事务内按事件 → 摘要 → 主任务顺序删除,事件不得独立清理;每次事务最多删除 256 条事件,若同一任务仍有事件则保留任务与摘要并返回同一个 `next_cursor_job_id`,下一次继续该任务,避免单个任务形成无界事务写集;pending / running、未确认通知、摘要缺失或状态不一致的记录永不删除。清理不触碰资产对象或钱包流水,其他 source module 必须显式指定并单独评估。 ### `external_generation_job_event` @@ -414,14 +335,9 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`AssetObject` - 源码:`server-rs/crates/spacetime-module/src/asset_metadata/objects.rs` -- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话、后台管理员会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询、现役 `editor_showcase_asset` 精选素材派生授权和当前已启用 `editor_showcase_campaign_config` 的活动卡专用目录 exact-key 派生授权,procedure 只允许 runtime service identity 调用。旧创作模板作品授权 view 和逐玩法资产采集器已经退出 module;`spacetime-client` 不订阅全量 `asset_object`,也不把连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。普通对象只有权威位置查询返回不存在且显式使用 curated `legacyPublicPath` 时才能进入白名单兼容;历史活动卡上传缺少 metadata 时,可由“`global` 配置已启用 + `image_object_key` 精确匹配 + key 位于 `generated-character-drafts/editor/showcase-campaign/`”这一窄授权继续换签,禁用或替换活动卡后旧 key 立即失效。新上传活动卡仍必须完成 `asset_object` confirm,不能把该历史兼容当作跳过正式登记的常规路径。 +- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话、后台管理员会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询、现役 `editor_showcase_asset` 精选素材派生授权和当前已启用 `editor_showcase_campaign_config` 的活动卡专用目录 exact-key 派生授权,procedure 只允许 runtime service identity 调用。`spacetime-client` 不订阅全量 `asset_object`,也不把连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。普通对象只有权威位置查询返回不存在且显式使用 curated `legacyPublicPath` 时才能进入白名单兼容;历史活动卡上传缺少 metadata 时,可由“`global` 配置已启用 + `image_object_key` 精确匹配 + key 位于 `generated-character-drafts/editor/showcase-campaign/`”这一窄授权继续换签,禁用或替换活动卡后旧 key 立即失效。新上传活动卡仍必须完成 `asset_object` confirm,不能把该历史兼容当作跳过正式登记的常规路径。 - 编辑器生成结果例外不允许先单独 confirm 再分段创建业务记录:OSS `PUT / HEAD` 仍在事务外,但 `AssetObjectUpsertInput` 必须交由统一结果 procedure 在同一事务内与 resource、asset、binding、canvas、job 和 receipt 原子写入。候选省略 object 时,procedure 必须在同一事务快照中验证已登记 object 的 owner、位置和媒体身份;`HEAD` 成功不能代替正式登记。 -### 退役记录:SpacetimeDB view `public_work_asset_read_grant` - -- 状态:已退役,不再注册到 `spacetime-module`,也不再生成客户端绑定。 -- 历史源码:`server-rs/crates/spacetime-module/src/public_asset_access.rs`,仅供追溯,不参与现役 crate 根。 -- 现役替代:资产读取 procedure 只计算 `editor_showcase_asset` 精选素材的精确授权。历史作品表与对应 view 已从 module、migration 和 bindings 删除,不再保留 schema 数据壳;不得用订阅 cache 或历史源码恢复公开作品资产授权。 ### `auth_identity` @@ -927,49 +843,17 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### api-server 长期订阅读模型 -> 现役覆盖:`spacetime-client` 的连接池不订阅旧创作模板 schema、逐玩法 gallery view 或公开作品聚合缓存。`REQUIRED_CACHED_READ_MODEL_QUERIES` 当前为空,`user_account` 仅作为认证兼容所需的可选缓存。新版 `/creation` 的公开内容通过可选鉴权的 `GET /api/editor/showcase/resources` 读取编辑器精选 read model;登录态的 viewer like 投影在请求事务内读取,不进入共享长期订阅 cache,HTTP 响应固定 `private, no-store` 并按 `Authorization` 区分。旧创作模板作品、旧玩法 gallery view 及公开作品聚合缓存已从当前 schema 退役,不再形成订阅 cache、BFF 路由或前端契约。 +> 现役覆盖:`REQUIRED_CACHED_READ_MODEL_QUERIES` 当前为空,`user_account` 仅作为认证兼容所需的可选缓存。`/creation` 的公开内容通过可选鉴权的 `GET /api/editor/showcase/resources` 读取编辑器精选 read model;登录态的 viewer like 投影在请求事务内读取,不进入共享长期订阅 cache,HTTP 响应固定 `private, no-store` 并按 `Authorization` 区分。 -#### 退役前历史订阅清单 - -以下仅记录退役前历史,不代表当前 schema 或订阅契约;`spacetime-client` 当时建立每个池连接会等待这些订阅初始同步: - -- `SELECT * FROM public_work_gallery_entry` -- `SELECT * FROM public_work_detail_entry` -- `SELECT * FROM bark_battle_gallery_view` -- `SELECT * FROM puzzle_gallery_card_view` -- `SELECT * FROM puzzle_clear_gallery_card_view` -- `SELECT * FROM jump_hop_gallery_card_view` -- `SELECT * FROM wooden_fish_gallery_card_view` -- `SELECT * FROM custom_world_gallery_entry` -- `SELECT * FROM match_3_d_gallery_view` -- `SELECT * FROM square_hole_gallery_view` -- `SELECT * FROM visual_novel_gallery_view` -- `SELECT * FROM big_fish_gallery_view` -- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` -- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'` -- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'wooden-fish'` -- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'` -- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'` -- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'` -- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'` -- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'` -- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'` -- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'` -- `SELECT * FROM creation_entry_config` -- `SELECT * FROM creation_entry_type_config` private `asset_object` 不进入长期订阅;安全判断通过受限 procedure 在服务端按主键或 `(bucket, object_key)` 索引读取事务内真相,避免全表复制、订阅失败和增量同步延迟把已登记私有对象误判为 legacy 未登记对象。 -旧创作模板的逐玩法 gallery 兼容路径已退役;当前不存在 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 订阅或 BFF fallback。若未来重新开放统一公开作品能力,必须先新增并冻结当时正式 read model、schema、mapper 和 HTTP 契约,不能从历史 view 或源码恢复。 -`GET /api/creation-entry/config`、入口熔断和公开作品互动熔断通过现役 `get_creation_entry_config` procedure 读取权威配置;不得把已退役连接池订阅 cache 当作现役实现。 +`GET /api/creation-entry/config`、入口熔断和作品互动熔断通过现役 `get_creation_entry_config` procedure 读取权威配置;连接池不缓存该配置。 入口配置快照包含 start card、类型弹窗、公告位兼容字段、入口类型列表和 `publicWorkInteractions` 作品互动矩阵;入口类型列表新增 `category_id`、`category_label`、`category_sort_order` 后,后台 upsert、`shared-contracts`、`module-runtime` 和 `spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。作品互动矩阵是全局公开作品详情能力配置,不属于单个 `creation_entry_type_config`;后台通过 `/admin/api/creation-entry/config/interactions` 保存,前端据此隐藏或拦截已接入的点赞 / Remix 入口,api-server 同时对已接入后端动作执行 `public_work_interaction_disabled` 熔断。 -RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`。历史 `custom-world`、`story` 与 `/api/runtime/chat/*` 请求链路均未挂载,不属于当前 api-server 路由或 schema 真相;不得为这些历史源码恢复路由、领域表或用平行 `airp` 链路接管。 -旧结构化创作和 RPG 的 LLM JSON 链路及其 Responses `web_search` 配置已退出 api-server 编译目标;不得为历史源码恢复 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED`。 -统一公开作品 BFF 的历史契约曾使用 `GET /api/public-works` 与 `GET /api/public-works/{publicWorkCode}`;这些旧页面与旧 read model 当前不再挂载。未来若重新开放公开 read model,必须先新增当前正式 schema 与契约,不能订阅已退役 view 或领域源表后自行拼装列表。 ### `refresh_session` @@ -981,7 +865,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`。 - Rust 结构体:`RuntimeSetting` - 源码:`server-rs/crates/spacetime-module/src/runtime/active/settings.rs` -- 说明:这是现役账号级公共设置表,保存 `music_volume` 与 `platform_theme`,不是旧玩法运行态数据壳。表结构与历史数据保持不变;鉴权后的 `GET/PUT /api/runtime/settings` 只能经 `spacetime-client` facade 调用 `get_runtime_setting_or_default` 与 `upsert_runtime_setting_and_return` procedure 读写,不从连接订阅 cache 或前端本地状态伪造正式设置事实。 +- 说明:这是现役账号级公共设置表,保存 `music_volume` 与 `platform_theme`。鉴权后的 `GET/PUT /api/runtime/settings` 只能经 `spacetime-client` facade 调用 `get_runtime_setting_or_default` 与 `upsert_runtime_setting_and_return` procedure 读写,不从连接订阅 cache 或前端本地状态伪造正式设置事实。 ### `runtime_snapshot` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 767ad823b..d9e05e6f5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -113,7 +113,7 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg 开发态 `npm run dev` 与 `npm run dev:api-server` 都会注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号。完整 `npm run dev` 会强制父 API 使用 `GENARRATIVE_PROCESS_ROLE=all`,忽略外层显式角色,确保本地 `api-server` 同时监听 HTTP 并消费外部生成队列;只有单模块 `npm run dev:api-server` 会保留显式 `GENARRATIVE_PROCESS_ROLE`,未设置时默认为 `all`。`all` 不内嵌 BgFilter worker;启动器总是先启动并验活独立 `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程,再启动父 API,并向两者注入同一个内部 base URL / Token。Linux 本地默认 `all` 角色启动前,dev 脚本会停止当前仓库、同一个 SpacetimeDB server / database 下遗留的 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程,避免旧 worker 二进制继续抢同一条队列并在业务写回时制造 procedure 超时;显式拆分 `api` / `external-generation-worker` 做生产式验证时不会触发这项清理。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。 -本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前 worker 只领取 `source_module = editor-canvas` 的图片画布任务,包括 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation`。旧玩法历史任务即使仍为 pending / running 也不领取、不改状态;显式把本地进程角色设为 `api` 且没有 worker 时,现役编辑器生成请求只返回 queued/running,不会兜底执行外部 provider。 +本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前 worker 只领取 `source_module = editor-canvas` 的图片画布任务,包括 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation`。非 `editor-canvas` 的 source module 任务即使仍为 pending / running 也不领取、不改状态;显式把本地进程角色设为 `api` 且没有 worker 时,现役编辑器生成请求只返回 queued/running,不会兜底执行外部 provider。 HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer 调用池大小;池连接不订阅 read model。HTTP 角色会额外创建 1 条共享缓存读连接,当前只保留可选的 `user_account` 读取;配置为 `8` 时基础连接拓扑是 8 条调用连接加 1 条缓存读连接。`/readyz` 同时检查调用池与缓存读连接,缓存连接未准备好时不能放量。 @@ -222,9 +222,8 @@ curl -fsS -X POST \ 正式落账上线前已经被旧 debug handler 返回成功的退款回调不会因部署新版本自动重放。已知 `out_refund_no` 的历史退款应由具备真实商户凭据的受控服务端操作先调用单笔退款查询,验签后写入同一 observation 事务;未知的商户平台退款等待次日交易账单发现。自动账单按分片补扫微信 API 可查询的近 90 天,超出窗口的历史退款需从商户平台导出核对后逐笔受控查单补录,不能直接把商户平台截图或 CSV 行当作退款终态,也不能开放匿名或普通用户补录 / 退款入口。 -旧玩法生成结果订阅通知已随创作模板业务退役:小程序不再注册 `pages/subscribe-message`,api-server 不再读取生成结果模板或订阅消息配置,`platform-wechat` 只保留现役登录、普通支付和虚拟支付所需能力。历史部署环境中遗留的同名变量可直接删除。 -如果本地 `GET /api/editor/showcase/resources` 或鉴权后的 `GET /api/runtime/settings` 报告缺少现役 table / procedure,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。切换到可发布的本地库后重新运行 `npm run dev` 完成发布;不要用恢复 `/api/creation-entry/config`、旧 gallery view 或模板默认配置兜底来修复。 +如果本地 `GET /api/editor/showcase/resources` 或鉴权后的 `GET /api/runtime/settings` 报告缺少现役 table / procedure,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。切换到可发布的本地库后重新运行 `npm run dev` 完成发布。 本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如: @@ -275,7 +274,7 @@ npm run test npm run build ``` -修改 dev-stack 启动、端口探测、状态文件或退出清理时,先执行 `quality-gates/README.md` 列出的专项门禁;其它改动按本节和仓库入口规范选择现役定向测试,不再维护按历史玩法阶段冻结的平行门禁矩阵。 +修改 dev-stack 启动、端口探测、状态文件或退出清理时,先执行 `quality-gates/README.md` 列出的专项门禁;其它改动按本节和仓库入口规范选择现役定向测试,不再维护按历史阶段冻结的平行门禁矩阵。 综合检查: @@ -429,7 +428,7 @@ workspace,必须分别执行以下格式化命令: `npm run check:rustfmt` 会按同样顺序对两个 manifest 执行 `-- --check`。Codex 提交前门禁、API 生产构建和 SpacetimeDB module 生产构建都会执行同一检查,避免 不同开发机或构建节点反复产生格式差异。Web 生产构建还会执行 production-ops、 -ESLint、主站与后台类型检查,以及排除已下线旧玩法后的当前 Vitest;API 生产构建 +ESLint、主站与后台类型检查,以及当前 Vitest;API 生产构建 追加 production-ops、DDD/schema/runtime-access 和 api-server 全 target 编译检查; SpacetimeDB module 生产构建追加 production-ops、DDD/schema/runtime-access 和管理员 procedure smoke。上述门禁由 `npm run check:production-ops` 反查,不能只保留在本地 @@ -695,7 +694,7 @@ journalctl -u genarrative-api -o cat | grep 'operation="release_rejected"' - API 直连 `/healthz`、`/readyz`。 - BgFilter worker 直连 `http://127.0.0.1:8083/readyz`;可通过 `GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL` 覆盖探测地址。 - SpacetimeDB 直连 `/v1/ping`。 -- 默认通过本机公网网关入口检查 `/`;需要增加现役公开 API 时,用可重复的 `--public-path` 或对应环境配置显式追加 `/api/editor/showcase/resources`。如需走正式域名,在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL=https://<域名>`。若在目标机本机打 `https://127.0.0.1` 或 `http://127.0.0.1`,同时配置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`,确保 public probe 命中正确 vhost / Host 语义;不得把已退役模板 API 重新加入健康探针。 +- 默认通过本机公网网关入口检查 `/`;需要增加现役公开 API 时,用可重复的 `--public-path` 或对应环境配置显式追加 `/api/editor/showcase/resources`。如需走正式域名,在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL=https://<域名>`。若在目标机本机打 `https://127.0.0.1` 或 `http://127.0.0.1`,同时配置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`,确保 public probe 命中正确 vhost / Host 语义;不得把未挂载接口重新加入健康探针。 - Pingora 影子网关只在同时配置 `GENARRATIVE_HEALTH_PATROL_PINGORA_BASE_URL` 与 `GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN` 时纳入巡检;脚本会访问 `GET /__genarrative_pingora/healthz` 并校验返回 `gateway=pingora-shadow`,未配置时不影响现有生产巡检。 - 最近 15 分钟对应 gateway mode 下 `genarrative-api.service`、`genarrative-bgfilter-worker.service`、`genarrative-external-generation-controller.service`、`genarrative-external-generation-worker@*.service`、`spacetimedb.service` 和 `nginx.service` 或 `genarrative-pingora-gateway.service` 的 `err..alert` 日志。 @@ -780,7 +779,6 @@ cat /var/lib/genarrative/health-patrol/status.json Jenkins Copy Artifact 必须保持 `Production` 权限模式;产物生产者要在 Jenkinsfile 中用 `copyArtifactPermission` 精确授权消费者,不能依赖 Migration 模式或全局 `Job/Read`。固定映射为 `Genarrative-Stdb-Module-Build` → `Genarrative-Stdb-Module-Publish`、`Genarrative-Api-Build` → `Genarrative-Api-Deploy`、`Genarrative-Web-Build` → `Genarrative-Web-Deploy`、`Genarrative-Database-Export` → `Genarrative-Database-Import`、`Genarrative-Agc-Global-Version-Issue` → `Genarrative-Scheduled-Revision-Trigger` / `Genarrative-Scheduled-Release-Trigger`(发号 Job 必须授权两条调度器读取 `agc-global-version.txt`;缺这条会让整轮调度在发号之后失败——号已烧、渠道构建没被触发,2026-09-20 #103 即为此)。如果 `copyArtifacts` 报 `Unable to find project for artifact copy`,但来源 Job、指定构建号和归档都实际存在,先检查来源 Job 的 `CopyArtifactPermissionProperty`;修复 Jenkinsfile 后必须先运行一次产物生产者,让 Declarative Pipeline 把 Job property 写回 Jenkins,再重跑 Deploy / Publish / Import。`npm run check:production-ops` 会防止这些 Copy Artifact 授权再次丢失。 -`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 构建路径。 `Genarrative-Web-Build` 先通过 `scripts/jenkins-prepare-npm-env.sh` 在 Jenkins 运行用户的持久版本目录准备 npm `10.9.7`,显式提升该 `bin` 后校验真实版本,不依赖系统 `/usr/bin/npm` 或 `packageManager` 声明自动切版。在运行根 Vitest 前必须按唯一根 `package-lock.json` 执行一次干净的 `npm ci`。根 lock 聚合全部 workspace,因此会安装 AI 游戏创作壳合法声明的 `@tauri-apps/api`、`@tauri-apps/plugin-http` 等 Tauri guest;这些依赖仍只归属 AGC workspace,不得加入根 H5 或 Desktop manifest。排查收集失败时先运行 `npm run check:npm-workspaces` 并核对根 lock 的 workspace entry,禁止恢复第二份 lock 或子目录安装。 @@ -890,7 +888,6 @@ worker 被硬杀或断电后,lease 过期任务只有尚未耗尽 `max_attempt - 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源,目标部署 agent 也不再需要访问源码 Git remote。 - `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`。该服务必须存在系统用户 / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,再补齐 `/etc/otelcol` 配置目录并重启服务。 - Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`。通用 `/api` location 保留 `client_max_body_size 64m` 作为编辑器图片、视频和文档请求的反代兜底,真实大小仍由路由与业务校验负责。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模板与实际媒体大小。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效。 -- 旧作品列表 K6 脚本、gallery 专属限流分组和对应容量结论已经退役;源码只作历史记录,不得作为当前发布门禁。新的容量验收必须针对现役编辑器、项目和素材 API 单独建立数据、负载与指标口径。 容器化隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟现有的 Linux release + Nginx + OTLP Collector 非 BgFilter 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前 compose 没有 `bgfilter-worker`,不构成完整 BgFilter 预发拓扑,也不覆盖任何会触发 BgFilter 的现役任务;它只用于非 BgFilter 路径,或通过下述 unsupported job smoke 验证外部生成队列的 claim / fail 回写和 API-only 更新。当前容器模拟参数保留 `genarrative-release` 的 CPU、`nofile=4096` 与 `worker_connections=768` 采样口径,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=2g`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`。完整模块首次实例化会超过旧 `896m` cgroup 上限,因此 SpacetimeDB 必须使用 `2g`;这不改变生产服务资源合同。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额;容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,可用 `npm run container:up -- --scale external-generation-worker=N external-generation-worker` 验证不经过 BgFilter 的外部生成 worker 动态扩缩容,`inline` 模式不参与该验证: @@ -902,7 +899,7 @@ npm run container:up npm run container:down ``` -容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。旧 gallery K6 profile 已退役;当前容器拓扑(明确不含 BgFilter worker)、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。 +容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。当前容器拓扑(明确不含 BgFilter worker)、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。 `npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。 多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 Jenkins,Jenkins checkout 仍保留最终复核。每个分支使用稳定的内部 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放;页面记录 ID 使用 Jenkins 构建编号。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康;构建详情链接固定使用局域网 Jenkins 地址,不暴露 loopback 地址。失败/取消且不可卸载的记录保留 7 天,停止记录保留 30 天,仍可卸载的失败记录不会自动清理。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。 @@ -934,7 +931,6 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 - `platform-llm` 的普通与流式调用统一生成 `llm.request` 子 span,字段白名单为 `provider`、`operation`、`api_kind`、`model`,继承调用方当前 span;范围覆盖响应读取、流式回调和异步等待,取消释放 Future 时结束。完整 client/config、API Key、请求/响应正文不进入该 span;既有 Provider 错误和重试策略保持原样,不能用遥测包装吞掉失败或自动重放。 - 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、实际 provider `image_model`、request_params 和 raw_excerpt;发生模型回退时另带 `fallback_from_model` / `fallback_to_model`。图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`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`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage / imageModel 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是模型不可用、限流、超时、解析失败还是未返回图片。 - OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;角色动画逐帧额外按 `frame_index`、`operation=source_put|final_put|final_head`、`attempt/max_attempts`、`will_retry`、`oss_code` 和 `oss_request_id` 对齐同一对象的请求尝试。`请求 OSS 失败` 时,`timeout/connect/transport=true` 表示传输类失败,OSS PutObject 的 `status=400, oss_code=RequestTimeout, timeout=true`、`status=429` 或 `500–599` 表示暂时性失败,PUT 的 `status=400`、`oss_code` 为空且 `timeout=true` 或 `transport=true`(message 含「错误响应体读取失败」,即 400 错误体读取超时/断流)也会重试;除这两类例外外,其他 400、401/403/404、配置、URL 和签名错误是确定性失败,不会重试。最终帧 HEAD 失败只会重试 HEAD,不会重复 PUT。日志不得包含 AccessKey、policy、signature、Authorization header、完整 signed URL 或 OSS 错误响应体;`oss_request_id` 只用于关联 OSS 服务端排障。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL,且同一路径、同一 `refreshKey` 版本和未临近过期的 signed URL 应复用。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。 -- 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 查看。 - 指标 label 只允许低基数字段:HTTP 使用 `http.request.method`、`http.route`、`status_class`,SpacetimeDB 调用使用 `procedure`、`status_class`;`request_id` 只进入 trace/log attribute,不进入 metric label。HTTP trace、完成日志与指标共用 Axum `MatchedPath` 路由模板,例如 `/api/editor/projects/{project_id}`;未匹配路由或无路由模板的降级请求只使用 `/api/*`、`/admin/api/*`、`other` 三种固定兜底,不把实际 ID 或 query 写入 route 标签。原来按 `/api/*` 聚合的监控查询应改为按路由模板汇总。 @@ -1024,7 +1020,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms 后台“账号配置”通过 `GET/POST /admin/api/profile/wallet-config` 一次读写账号初始泥点数和每日免费泥点数。修改每日免费额度不重算已初始化的当日余额;尚未初始化的当日额度或下一次北京时间跨日重置使用最新配置。 -个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。认证成功后的 `daily_login` 必须通过 `SpacetimeClient::record_daily_login_tracking_event(...)` 调用 SpacetimeDB 专用 `record_daily_login_tracking_event_and_return` procedure,由数据库事务时间生成当日幂等事件并推进任务进度;不要改回普通 `record_tracking_event_after_success`、tracking outbox 或旧 `profile.login.daily` 事件键。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。 +个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。认证成功后的 `daily_login` 必须通过 `SpacetimeClient::record_daily_login_tracking_event(...)` 调用 SpacetimeDB 专用 `record_daily_login_tracking_event_and_return` procedure,由数据库事务时间生成当日幂等事件并推进任务进度;不要改回普通 `record_tracking_event_after_success`、tracking outbox 或旧 `profile.login.daily` 事件键。后台与同步辅助链路按 tracking 中间件排除规则处理。 外部 API 失败审计复用 `tracking_event`,不新增表。普通 API / external-generation 调用的失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,仍回退同步直写 SpacetimeDB。BgFilter worker 是受限资源例外:provider 失败审计在 spawn 前受进程级 `1024` 硬上限保护,获准任务写入 `GENARRATIVE_TRACKING_OUTBOX_DIR/bgfilter-worker/` 独立目录;任务满载、outbox 缺失、达到保护阈值或写盘失败时直接丢弃并记录指标,不同步直写。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId、profileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询: @@ -1138,10 +1134,9 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC; ## 微信登录与孤儿作品归属处理 - 微信新用户的 `username` 统一拼为 `名字_openid`;若昵称或 openid 为空,则分别回退到 `微信旅人` 和 `openid`。 -- 微信新用户的内部 `user_id` 改为不可复用的 `user_` 前缀 UUID 风格,避免清库后旧作品被后来的顺序号账号顶替。 +- 微信新用户的内部 `user_id` 改为不可复用的 `user_` 前缀 UUID 风格,避免清库后既有作品被后来的顺序号账号顶替。 - 当作品作者的 `owner_user_id` 找不到真实账号时,作品统一显示为占位作者:`失效作者`,公开陶泥号固定为 `SY-00000000`,占位账号 ID 为 `wx-openid-placeholder`。 - 该占位账号只用于作品作者域,不扩展到全站其它身份域。 -- 旧作品表退役后,迁移 JSON 回填工具 `scripts/rebind-orphan-work-owners.mjs` 已删除,不再提供回填脚本用法或当前操作入口;占位作者的显示与身份域语义继续保持上述规则。 ## 维护页目标文件安全边界(2026-08-05) diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 4c4858c85..80ee94562 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -1,8 +1,8 @@ # 平台入口与玩法链路 -> 更新时间:`2026-08-25` +> 更新时间:`2026-09-23` > -> 本文只描述现役主站平台壳和图片画布编辑器链路。旧模板创作、生成、发布、公开业务详情及专属运行态不属于现役入口;历史持久化表仅按迁移白名单保留 schema 数据壳,不恢复旧业务行为。 +> 本文只描述现役主站平台壳、图片画布编辑器和对应后端契约。未出现在现役路由树、shared-contracts 或 SpacetimeDB module / bindings 中的入口与数据对象不属于本文范围。 ## 现役平台入口 @@ -13,7 +13,7 @@ - `/profile`:桌面端和移动端共用的“我的”稳定路由,承载账号、钱包、统计、充值、兑换码、反馈、通用设置、开发者 API Key 与法律信息等公共能力。 - `/editor/canvas`:图片画布工作台;必须携带 `projectid` query。缺少项目 ID 时回到 `/project`,不创建隐式项目。 -根路径 `/` 仍是平台壳入口;未知的旧玩法路径、旧运行态路径和旧作品详情路径按平台壳 fallback 处理,不再视为现役业务路由。 +根路径 `/` 仍是平台壳入口;未列入现役路由的路径按平台壳 fallback 处理,不构成业务入口。 ## 桌面端与移动端边界 @@ -32,7 +32,7 @@ - 项目内资产库:`GET /api/editor/assets/library`,以及 `/api/editor/assets*` 的现役增删改和精选提交路由。 - 资产上传和读取:先用 `/api/assets/direct-upload-tickets`、`/api/assets/objects/confirm` 完成直传登记,再通过 `/api/assets/read-url` 获取换签读取地址;前端不得把私有裸 path 当公开静态 URL。 -`/creation` 的数据只由 `CreationLandingView` 调用编辑器项目与精选素材服务;不再读取旧玩法入口配置、旧模板卡、旧公开作品列表或旧运行态状态。前端只负责展示、交互和临时 UI 状态,项目、资源、生成任务与发布状态以后端 read model、资源清单和 revision 为准。 +`/creation` 的数据只由 `CreationLandingView` 调用编辑器项目与精选素材服务。前端只负责展示、交互和临时 UI 状态,项目、资源、生成任务与发布状态以后端 read model、资源清单和 revision 为准。 ## 创建、打开与编辑链路 @@ -42,9 +42,9 @@ 4. 生成或上传的资产先完成后端登记,再以换签 URL 回显;前端不自行推断项目状态,也不以本地摘要覆盖后端 revision。 5. `/profile` 仅承载账号及平台公共能力,不创建平行的项目或玩法链路。 -## 退役与历史 schema 边界 +## 路由与 schema 边界 -旧玩法入口、模板工作台、旧创作/生成/发布 API、公开业务详情和专属运行态都不是当前契约;新代码和文档不得重新接入这些路径。历史表、迁移和必要兼容读取只为保留 schema 与既有数据,不构成旧功能可用性的承诺;任何 schema 删除、改名、重排或类型变更仍须先确认迁移计划。 +现役入口只以上述路由和后端当前正式契约为准。新代码不得接入未出现在现役路由树中的入口;表结构、索引和迁移按当前 `spacetime-module`、`shared-contracts` 与受控迁移规则维护,删除、改名、重排或类型变更必须先确认迁移计划。 ## 资源安全规则 @@ -134,7 +134,7 @@ 除显式 `/admin/api/...` 外,表内路径均相对 `/api/game-distribution`。错误采用现有平台 envelope,覆盖 400 格式错误、401 未登录、403 owner/审核权限错误、404 不可见、409 幂等/状态/并发冲突、413 大小上限、422 包或资料校验失败、429 限流和明确的可重试 5xx;服务端响应不包含存储凭据和本地绝对路径。 -领域规则进入 `module-*`,游戏/版本/审核/操作账本和事务进入 `spacetime-module`,访问统一通过 `spacetime-client`,HTTP 与上传编排进入 `api-server`,对象存储副作用复用 `platform-*`,跨端 DTO 同步 Rust `shared-contracts` 与 `packages/shared`。新业务不复用退役玩法表或私有源码快照作为公开事实;实际表字段、索引、受信服务身份及迁移清单在持久化里程碑评审时冻结。已有表若确需加字段,只能末尾追加并给明确默认值;删除/改名/重排/改类型必须另行确认迁移计划。 +领域规则进入 `module-*`,游戏/版本/审核/操作账本和事务进入 `spacetime-module`,访问统一通过 `spacetime-client`,HTTP 与上传编排进入 `api-server`,对象存储副作用复用 `platform-*`,跨端 DTO 同步 Rust `shared-contracts` 与 `packages/shared`。新业务必须使用当前正式表与契约,不得以未挂载源码或非正式私有快照作为公开事实;实际表字段、索引、受信服务身份及迁移清单在持久化里程碑评审时冻结。已有表若确需加字段,只能末尾追加并给明确默认值;删除/改名/重排/改类型必须另行确认迁移计划。 ### 发行域名、沙箱与网络能力 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 68e502148..f9157429a 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -1,12 +1,12 @@ # 当前产品与工程约束 -> 状态更新:`2026-08-25`。当前主站只描述图片画布编辑器、编辑器项目/素材、账号与平台公共能力;旧创作模板、旧公开作品详情和专属运行态不属于现役入口。 +> 状态更新:`2026-08-25`。当前主站只描述图片画布编辑器、编辑器项目/素材、账号与平台公共能力。 更新时间:`2026-08-25` ## 项目定位 -Genarrative / 陶泥儿当前主站聚焦图片画布创作、编辑器项目与素材管理,以及账号、钱包、公共设置、后台管理和小程序外壳。AI Game Creator 独立 App 按其最新 Runtime 与客户端合同运行,不复用旧模板入口。 +Genarrative / 陶泥儿当前主站聚焦图片画布创作、编辑器项目与素材管理,以及账号、钱包、公共设置、后台管理和小程序外壳。AI Game Creator 独立 App 按其最新 Runtime 与客户端合同运行。 当前主要能力: diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs index 528ecb6a2..03293742e 100644 --- a/scripts/check-spacetime-schema-guard.mjs +++ b/scripts/check-spacetime-schema-guard.mjs @@ -13,79 +13,6 @@ const tableCatalogPath = const bindingsRoot = 'server-rs/crates/spacetime-client/src/module_bindings/'; const allowBreaking = process.env.SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING === '1'; -// 本次历史表退役的一次性迁移白名单。该清单只允许这些已确认表从基线消失; -// 待删除结果进入后续主线基线后必须删除本清单,其他表仍按既有破坏性变更路径失败。 -export const APPROVED_RETIRED_TABLE_DELETIONS = Object.freeze([ - 'player_progression', - 'chapter_progression', - 'npc_state', - 'story_session', - 'story_event', - 'inventory_slot', - 'battle_state', - 'treasure_record', - 'quest_record', - 'quest_log', - 'custom_world_profile', - 'custom_world_session', - 'custom_world_agent_session', - 'custom_world_agent_message', - 'custom_world_agent_operation', - 'custom_world_draft_card', - 'custom_world_gallery_entry', - 'puzzle_agent_session', - 'puzzle_background_compile_task', - 'puzzle_agent_message', - 'puzzle_work_profile', - 'puzzle_event', - 'puzzle_runtime_run', - 'puzzle_leaderboard_entry', - 'puzzle_clear_agent_session', - 'puzzle_clear_work_profile', - 'puzzle_clear_runtime_run', - 'puzzle_clear_event', - 'bark_battle_draft_config', - 'bark_battle_published_config', - 'bark_battle_runtime_run', - 'bark_battle_score_record', - 'bark_battle_leaderboard_entry', - 'bark_battle_work_stats_projection', - 'bark_battle_personal_best_projection', - 'match3d_agent_session', - 'match3d_agent_message', - 'match_3_d_work_profile', - 'match3d_runtime_run', - 'jump_hop_agent_session', - 'jump_hop_work_profile', - 'jump_hop_runtime_run', - 'jump_hop_event', - 'jump_hop_leaderboard_entry', - 'wooden_fish_agent_session', - 'wooden_fish_work_profile', - 'wooden_fish_runtime_run', - 'wooden_fish_event', - 'square_hole_agent_session', - 'square_hole_agent_message', - 'square_hole_work_profile', - 'square_hole_runtime_run', - 'visual_novel_agent_session', - 'visual_novel_agent_message', - 'visual_novel_work_profile', - 'visual_novel_runtime_run', - 'visual_novel_runtime_history_entry', - 'visual_novel_runtime_event', - 'big_fish_creation_session', - 'big_fish_agent_message', - 'big_fish_asset_slot', - 'big_fish_runtime_run', - 'big_fish_event', -]); -const approvedRetiredTableDeletions = new Set(APPROVED_RETIRED_TABLE_DELETIONS); - -export function isApprovedRetiredTableDeletion(accessor) { - return approvedRetiredTableDeletions.has(accessor); -} - function normalizePath(path) { return path.replace(/\\/gu, '/'); } @@ -713,7 +640,7 @@ function fieldDescription(field) { return `${field.name}: ${field.type}`; } -export function compareTables(baseTables, currentTables) { +function compareTables(baseTables, currentTables) { const failures = []; let schemaChanged = false; let breakingChanged = false; @@ -722,9 +649,6 @@ export function compareTables(baseTables, currentTables) { const currentTable = currentTables.get(accessor); if (!currentTable) { schemaChanged = true; - if (isApprovedRetiredTableDeletion(accessor)) { - continue; - } breakingChanged = true; failures.push( `${baseTable.path}:${baseTable.line}: SpacetimeDB 表 ${accessor} 被删除或改名。表删除/改名必须先询问用户并确认迁移计划。`, diff --git a/scripts/check-spacetime-schema-guard.test.ts b/scripts/check-spacetime-schema-guard.test.ts index 5111c139d..5265fe9f9 100644 --- a/scripts/check-spacetime-schema-guard.test.ts +++ b/scripts/check-spacetime-schema-guard.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from 'vitest'; import { - APPROVED_RETIRED_TABLE_DELETIONS, collectTablesFromSources, - compareTables, isFormalGateEnvironment, listReachableRustFiles, resolveBaseRef, @@ -38,15 +36,6 @@ function createGitResolver(entries: Record) { return (args: string[]) => entries[args.join(' ')] ?? null; } -function collectTables(accessors: string[]) { - return collectTablesFromSources( - accessors.map((accessor, index) => ({ - path: `${sourceRoot}/schema.rs`, - text: tableSource(`Table${index}`, accessor), - })), - ).tables; -} - describe('SpacetimeDB schema guard base resolution', () => { it('prefers an explicit Jenkins-provided base ref', () => { expect( @@ -136,61 +125,3 @@ describe('SpacetimeDB schema guard module reachability', () => { expect(result.failures[0]).toMatch(/table accessor example 重复定义/u); }); }); - -describe('SpacetimeDB schema guard retired table deletion allowlist', () => { - it('allows exactly the 63 approved historical tables to disappear', () => { - expect(APPROVED_RETIRED_TABLE_DELETIONS).toHaveLength(63); - expect(new Set(APPROVED_RETIRED_TABLE_DELETIONS).size).toBe(63); - - const result = compareTables( - collectTables([...APPROVED_RETIRED_TABLE_DELETIONS]), - new Map(), - ); - - expect(result.failures).toEqual([]); - expect(result.schemaChanged).toBe(true); - expect(result.breakingChanged).toBe(false); - }); - - it('still rejects deletion of a table outside the approved list', () => { - const result = compareTables( - collectTables([APPROVED_RETIRED_TABLE_DELETIONS[0], 'active_table']), - new Map(), - ); - - expect(result.failures).toHaveLength(1); - expect(result.failures[0]).toMatch(/active_table.*被删除或改名/u); - expect(result.schemaChanged).toBe(true); - expect(result.breakingChanged).toBe(true); - }); - - it('does not suppress retained-table field deletion or rename checks', () => { - const baseTables = collectTablesFromSources([ - { - path: `${sourceRoot}/ranks.rs`, - text: `#[spacetimedb::table(accessor = retained_deleted_field)]\npub struct RetainedDeletedField {\n pub id: u64,\n pub title: String,\n}\n`, - }, - { - path: `${sourceRoot}/titles.rs`, - text: `#[spacetimedb::table(accessor = retained_renamed_field)]\npub struct RetainedRenamedField {\n pub id: u64,\n pub title: String,\n}\n`, - }, - ]).tables; - const currentTables = collectTablesFromSources([ - { - path: `${sourceRoot}/ranks.rs`, - text: `#[spacetimedb::table(accessor = retained_deleted_field)]\npub struct RetainedDeletedField {\n pub id: u64,\n}\n`, - }, - { - path: `${sourceRoot}/titles.rs`, - text: `#[spacetimedb::table(accessor = retained_renamed_field)]\npub struct RetainedRenamedField {\n pub id: u64,\n pub name: String,\n}\n`, - }, - ]).tables; - - const result = compareTables(baseTables, currentTables); - - expect(result.failures).toHaveLength(2); - expect(result.failures.join('\n')).toMatch(/字段数量减少/u); - expect(result.failures.join('\n')).toMatch(/字段被删除或改名/u); - expect(result.breakingChanged).toBe(true); - }); -}); diff --git a/scripts/loadtest/README.md b/scripts/loadtest/README.md deleted file mode 100644 index 0e1dbb41e..000000000 --- a/scripts/loadtest/README.md +++ /dev/null @@ -1,353 +0,0 @@ -# Genarrative 作品列表 K6 压测 - -> 2026-07-18 退役:本文与本目录只保留旧公开作品 / gallery 压测历史,`container:k6` 和 compose `k6` 运行目标已经下线;不得把这些脚本用于当前容量验收或恢复旧业务接口。 - -本目录用于对“作品列表/公开广场”读接口做本地压测。数据源来自私有 SpacetimeDB migration,但提取脚本只输出作品 profile 白名单表,并对用户、作者、作品号、asset id 等标识做稳定映射。 - -## 文件 - -- `extract-works-list-data.mjs`:从 migration JSON 提取作品列表压测数据;本地输出也会脱敏路由 ID,因此默认用于列表接口压测,详情接口需先把同一份脱敏数据导入目标环境。 -- `k6-works-list.js`:K6 压测脚本。 -- `data/spacetime-migration-7.local.json`:本地私有原始数据副本,已被 `.gitignore` 忽略,不要提交。 -- `data/works-list.local.json`:本地脱敏压测数据,已被 `.gitignore` 忽略,不要提交。 -- `data/works-list.sample.json`:可提交的少量脱敏样例。 - -## 数据边界 - -允许导入的表: - -- `puzzle_work_profile` -- `custom_world_profile` -- `match3d_work_profile` -- `square_hole_work_profile` -- `big_fish_work_profile` -- `visual_novel_work_profile` - -明确不导入: - -- 账号/认证:`user_account`、`auth_identity`、`refresh_session`、`auth_store_snapshot` -- 钱包/邀请:`profile_wallet_ledger`、`profile_redeem_*`、`profile_invite_*` -- 游玩历史/埋点/存档:`public_work_play_daily_stat`、`profile_played_world`、`puzzle_runtime_run`、`profile_save_archive`、`runtime_snapshot` -- AI 任务过程:`ai_task`、`ai_task_stage`、`ai_text_chunk` -- asset 二进制:`asset_object`、`asset_entity_binding` - -提取脚本会移除 `source_session_id` / `source_agent_session_id` 等会话派生字段;这些字段不属于作品列表卡片压测必要字段。 - -## 重新提取数据 - -从仓库根目录执行: - -```bash -npm run loadtest:extract-works -- \ - --input scripts/loadtest/data/spacetime-migration-7.local.json \ - --output scripts/loadtest/data/works-list.local.json \ - --sample-output scripts/loadtest/data/works-list.sample.json -``` - -也可以直接执行: - -```bash -node scripts/loadtest/extract-works-list-data.mjs \ - --input scripts/loadtest/data/spacetime-migration-7.local.json \ - --output scripts/loadtest/data/works-list.local.json \ - --sample-output scripts/loadtest/data/works-list.sample.json -``` - -当前 local 全量提取结果: - -- `puzzle_work_profile`: 80 -- `custom_world_profile`: 1 -- `match3d_work_profile`: 0 -- `normalizedWorks`: 81 - -当前可提交 sample 结果: - -- `puzzle_work_profile`: 3 -- `custom_world_profile`: 1 -- `match3d_work_profile`: 0 -- `normalizedWorks`: 4 - -## 真实接口 - -已从 `server-rs/crates/api-server/src/app.rs` 确认的读接口: - -公开接口,无需 Bearer token: - -- `GET /api/runtime/puzzle/gallery` -- `GET /api/runtime/puzzle/gallery/{profile_id}` -- `GET /api/runtime/custom-world-gallery` -- `GET /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}` -- `GET /api/runtime/custom-world-gallery/by-code/{code}` - -需要 Bearer token 的个人作品列表接口: - -- `GET /api/runtime/puzzle/works` -- `GET /api/runtime/puzzle/works/{profile_id}` -- `GET /api/runtime/custom-world/works` - -K6 脚本默认只跑公开列表接口;传入 `AUTH_TOKEN` 后会额外跑需要登录态的个人作品列表接口。当前真实列表 handler 未暴露分页/排序 query 参数,因此脚本不追加 `limit/offset`;若后续接口增加分页参数,再在 K6 中补随机分页。 - -详情接口默认不压测,因为本地数据中的 `profile_id` / `owner_user_id` 已脱敏,直接请求未导入脱敏数据的目标服务会 404。只有在目标环境已导入同一份脱敏数据,或改用真实 ID 本地文件时,才设置 `DETAIL_RATIO` 大于 0;详情请求不把 404 视为成功。 - -## 启动服务 - -按项目约定启动本地 dev 栈: - -```bash -npm run dev -``` - -注意端口可能漂移。以启动日志中的实际 api-server 端口为准,然后传给 K6。 - -注意:K6 的 `open()` 会按 `k6-works-list.js` 所在目录解析相对路径,因此 `WORKS_DATA` 应写成 `data/works-list.local.json`,不要写成 `scripts/loadtest/data/works-list.local.json`。 - -Bash / Git Bash: - -```bash -BASE_URL=http://127.0.0.1: WORKS_DATA=data/works-list.local.json npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max" -``` - -PowerShell: - -```powershell -$env:BASE_URL="http://127.0.0.1:" -$env:WORKS_DATA="data/works-list.local.json" -npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max" -``` - -## 50 HTTP req/s 口径 - -`k6-works-list.js` 默认一次 iteration 会依次请求两个公开列表接口:`/api/runtime/puzzle/gallery` 和 `/api/runtime/custom-world-gallery`。因此目标约 50 HTTP req/s 时,`ramping-arrival-rate` 的 `PEAK_RPS` 应设置为 `25`。如果传入 `AUTH_TOKEN` 或把 `DETAIL_RATIO` 设为大于 0,每次 iteration 的请求数会增加,需要重新折算。 - -验收目标: - -- `http_req_failed < 1%` -- `http_req_duration p95 < 2000ms` -- `dropped_iterations = 0` -- 压测窗口内 Nginx 无新增 502 - -## Smoke - -```bash -BASE_URL=http://127.0.0.1:8787 \ -WORKS_DATA=data/works-list.local.json \ -SCENARIO=smoke \ -DETAIL_RATIO=0 \ -npm run loadtest:k6:works -``` - -默认:1 VU / 30s。 - -## Baseline - -```bash -BASE_URL=http://127.0.0.1:8787 \ -WORKS_DATA=data/works-list.local.json \ -SCENARIO=baseline \ -VUS=10 \ -DURATION=3m \ -DETAIL_RATIO=0 \ -npm run loadtest:k6:works -``` - -默认阈值: - -- `http_req_failed < 1%` -- `http_req_duration p95 < 800ms` -- `http_req_duration p99 < 1500ms` -- `works_list_shape_error_rate < 1%` - -## Spike - -```bash -BASE_URL=http://127.0.0.1:8787 \ -WORKS_DATA=data/works-list.local.json \ -SCENARIO=spike \ -START_RPS=5 \ -PEAK_RPS=25 \ -HOLD=60s \ -DETAIL_RATIO=0 \ -npm run loadtest:k6:works -``` - -默认阈值: - -- `http_req_failed < 1%` -- `http_req_duration p95 < 2000ms` -- `dropped_iterations = 0` -- `works_list_shape_error_rate < 1%` - -PowerShell: - -```powershell -$env:BASE_URL="https://genarrative.world" -$env:WORKS_DATA="data/works-list.local.json" -$env:SCENARIO="spike" -$env:START_RPS="5" -$env:PEAK_RPS="25" -$env:HOLD="60s" -$env:END_RPS="5" -$env:DETAIL_RATIO="0" -npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max" -``` - -线上 release 回归可使用同一组环境变量: - -```bash -SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works -``` - -## 带登录态压测个人作品列表 - -先通过本地登录或接口获取 access token,然后传入: - -```bash -BASE_URL=http://127.0.0.1:8787 \ -AUTH_TOKEN='' \ -SCENARIO=smoke \ -DETAIL_RATIO=0 \ -npm run loadtest:k6:works -``` - -不要把 token 写入仓库文件、README 或 shell history 中可共享的位置。 - -## 详情接口压测 - -仅当目标环境存在 `WORKS_DATA` 中的同一批 `profileId/ownerUserId` 时启用: - -```bash -BASE_URL=http://127.0.0.1:8787 \ -WORKS_DATA=data/works-list.local.json \ -SCENARIO=smoke \ -DETAIL_RATIO=0.35 \ -npm run loadtest:k6:works -``` - -如果详情请求返回 404,说明压测数据 ID 未导入目标环境或目标服务数据不一致,应先修正数据源,不要把 404 当成功。 - -## 排障 - -- 如果公开 gallery 返回 `creation_entry_disabled` 或 503,检查本地 creation entry 配置是否禁用了对应入口。 -- 如果高压下返回 429,优先确认目标环境是否设置了 `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS` 以及 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS`。429 表示 Nginx 或 api-server 背压已生效,不等同于业务错误;继续看内存、p95、`http_req_failed` 和 OTLP / Nginx timing 判断阈值是否偏低。 -- 如果直连 `api-server` 压测出现 `connection refused` 或 status 0,说明压力已经打到 TCP 监听 / accept 层;此时同时检查 `GENARRATIVE_API_LISTEN_BACKLOG`、Nginx upstream keepalive 和是否需要在 Nginx 前置限流,不能只靠应用层背压解释。 -- 如果个人作品列表返回 401,确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。 -- 如果详情全部 404,确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。 - -## 压测窗口采集 - -Nginx upstream timing: - -```bash -sudo tail -f /var/log/nginx/genarrative.access.log -sudo tail -f /var/log/nginx/genarrative.error.log -``` - -api-server 与 SpacetimeDB 日志: - -```bash -sudo journalctl -u genarrative-api.service -f -sudo journalctl -u spacetimedb.service -f -``` - -api-server 的 OpenTelemetry 在生产与容器模板里默认开启。需要临时关闭时,显式把 `GENARRATIVE_OTEL_ENABLED=false`;需要验证 OTLP traces / metrics / logs 时,先在服务器本机启动只监听 `127.0.0.1` 的 `otelcol-contrib` debug exporter: - -```bash -npm run otel:debug -``` - -如果要把本机数据转发给 Rider OpenTelemetry 面板,先在 Rider 的 OpenTelemetry 设置中启用固定 OTLP server port,例如 `17011`,再运行: - -```bash -RIDER_OTLP_GRPC_ENDPOINT=127.0.0.1:17011 npm run otel:rider -``` - -脚本会在 `.codex-temp/otelcol/` 生成临时 collector 配置,默认接收 api-server 发到 `http://127.0.0.1:4318` 的 OTLP HTTP 数据。需要改端口时可设置: - -- `OTELCOL_OTLP_HTTP_ENDPOINT`,默认 `127.0.0.1:4318` -- `OTELCOL_OTLP_GRPC_ENDPOINT`,默认 `127.0.0.1:4317` -- `RIDER_OTLP_GRPC_ENDPOINT`,默认 `127.0.0.1:17011` -- `OTELCOL_BIN`,默认 `otelcol-contrib` - -等价的 debug collector 配置如下: - -```yaml -receivers: - otlp: - protocols: - grpc: - endpoint: 127.0.0.1:4317 - http: - endpoint: 127.0.0.1:4318 - -exporters: - debug: - verbosity: detailed - -service: - pipelines: - traces: - receivers: [otlp] - exporters: [debug] - metrics: - receivers: [otlp] - exporters: [debug] - logs: - receivers: [otlp] - exporters: [debug] -``` - -```bash -otelcol-contrib --config /etc/otelcol-contrib/genarrative-debug.yaml -``` - -然后在 `/etc/genarrative/api-server.env` 中打开: - -```env -GENARRATIVE_OTEL_ENABLED=true -OTEL_SERVICE_NAME=genarrative-api -OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 -``` - -注意 `api-server` 当前使用 OTLP HTTP exporter,`OTEL_EXPORTER_OTLP_ENDPOINT` 必须指向 Collector 的 HTTP base endpoint `http://127.0.0.1:4318`。不要把它改成 Collector gRPC 端口 `4317`,也不要直接指向 Rider 的 gRPC 端口;Rider 只由 `npm run otel:rider` 启动的 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。 - -OTLP logs 是远端观测增量,不替代本地日志;api-server 日志仍看 `journalctl` / `logs/api-server/`,Nginx 日志仍看文件。日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`。 - -Rider 的 Logs 面板展示的是 OTLP 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 关联查看。 - -压测期间可在 Metrics 面板或 debug exporter 中观察进程内存指标: - -- `process.memory.usage`:进程常驻内存 / RSS。 -- `process.memory.virtual`:进程虚拟内存;Windows 当前按 `PrivateUsage` 上报,Linux 取 `VmSize`。 -- `genarrative.process.memory.private`:进程私有内存,Windows 来自 `PrivateUsage`,Linux 近似取 `/proc/self/status` 的 `VmData`。 -- `process.cpu.time`:进程 user + system 累计 CPU 秒数。 -- `genarrative.process.cpu.usage_percent`:两次指标采集之间的进程 CPU 使用率;100% 约等于占满 1 个 CPU core。 -- `process.thread.count`:线程数。 -- `process.windows.handle.count`:Windows 句柄数。 -- `process.unix.file_descriptor.count`:Linux 文件描述符数。 -- `genarrative.http.server.response_bodies.in_flight`:Axum / Hyper 仍持有的响应 body 数;如果内存高但该值很低,说明热点不在业务 handler 生命周期内。 -- `genarrative.http.server.request_permits.available`:应用层 HTTP 背压剩余 permit 数,带 `pool=default|gallery|detail|admin`;如果目标 pool 未接近 0,说明没有打满对应 `GENARRATIVE_API_*_MAX_CONCURRENT_REQUESTS`。 -- `genarrative.puzzle_gallery.cache.hits` / `genarrative.puzzle_gallery.cache.stale_hits` / `genarrative.puzzle_gallery.cache.misses` / `genarrative.puzzle_gallery.cache.refreshes_started` / `genarrative.puzzle_gallery.cache.refreshes_failed` / `genarrative.puzzle_gallery.cache.rebuilds`:拼图广场响应缓存 fresh 命中、stale 命中、未命中、后台刷新和重建次数。 -- `genarrative.puzzle_gallery.cache.rebuild.duration`:拼图广场缓存重建耗时。 -- `genarrative.puzzle_gallery.cache.data_json_bytes`:拼图广场缓存内预序列化 data JSON 大小。 -- `genarrative.spacetime.read.calls` / `genarrative.spacetime.read.duration_ms`:SpacetimeDB 订阅本地 cache 读次数和耗时;`read=list_puzzle_gallery` 表示当前路径走 view / local cache,不是 procedure。 - -若 `/api/runtime/puzzle/gallery` 单接口压测出现 GB 级瞬时内存峰值,先区分“持续泄漏”和“请求期分配峰值”:关闭 OTEL 后若峰值仍复现且压测结束后回落,主因通常不是 Collector / exporter。当前拼图广场列表命中缓存时应复用 `PuzzleGalleryCache` 中的预序列化 data JSON,只按请求拼接 envelope meta,不应每个请求重新深拷贝 `PuzzleGalleryResponse` 或构造完整 `serde_json::Value`。 - -本地 Windows 直连 `api-server` 压测还要单独看 K6 的 VU / 连接模型。已验证在 250 RPS、`PREALLOCATED_VUS=300` 时,哪怕打 `/healthz` 这种小响应,也可能因为本地 300 个 Established 连接触发 `api-server` private memory 瞬时升到约 7GB,压测结束后回落到 100MB 级;同样 250 RPS 改成 `PREALLOCATED_VUS=20 MAX_VUS=40` 后,拼图广场 p95 约 9ms,峰值降到约 600MB。这个现象说明高水位主要来自本机直连连接 / 发送链路,不等价于 SpacetimeDB 或拼图 JSON 缓存泄漏。做本地容量判断时优先让 VU 接近真实并发,避免用过高预分配 VU 把测试变成 Windows 本机连接缓冲压力测试;生产仍以 Nginx upstream keepalive、系统内存和 OTLP 指标一起判断。 - -线上回归辅助命令: - -```bash -systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -cat /proc/$(pidof api-server)/limits -tr '\0' '\n' < /proc/$(pidof api-server)/environ | grep 'GENARRATIVE_API_.*MAX_CONCURRENT_REQUESTS' -ss -ltnp | grep 8082 -curl -sS http://127.0.0.1:8082/healthz -``` - -## 验证命令 - -```bash -npx vitest run scripts/loadtest/extract-works-list-data.test.ts -npx eslint scripts/loadtest/extract-works-list-data.mjs scripts/loadtest/extract-works-list-data.test.ts scripts/loadtest/k6-works-list.js -``` diff --git a/scripts/loadtest/data/works-list.sample.from-migration-1.json b/scripts/loadtest/data/works-list.sample.from-migration-1.json deleted file mode 100644 index 58c525043..000000000 --- a/scripts/loadtest/data/works-list.sample.from-migration-1.json +++ /dev/null @@ -1,208 +0,0 @@ -{ - "source": "spacetime-migration-1.json", - "generatedAt": "2026-05-16T13:35:40.282Z", - "counts": { - "puzzle_work_profile": 3, - "custom_world_profile": 1, - "match3d_work_profile": 0, - "square_hole_work_profile": 0, - "visual_novel_work_profile": 0 - }, - "tables": { - "puzzle_work_profile": [ - { - "profile_id": "profile-001", - "work_id": "work-001", - "owner_user_id": "user-001", - "author_display_name": "author-001", - "cover_asset_id": "asset-001", - "cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png", - "work_title": "化学家", - "level_name": "文学家", - "summary": "几个文学家正站在山上面对着瀑布侃侃而谈", - "work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室", - "levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…", - "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…", - "theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]", - "publication_status": { - "Published": [] - }, - "play_count": 1, - "like_count": 0, - "remix_count": 1, - "updated_at": { - "__timestamp_micros_since_unix_epoch__": 1777703338322544 - }, - "created_at": { - "__timestamp_micros_since_unix_epoch__": 1777648804043558 - }, - "published_at": { - "__timestamp_micros_since_unix_epoch__": 1777649364112270 - } - }, - { - "profile_id": "profile-002", - "work_id": "work-002", - "owner_user_id": "user-002", - "author_display_name": "author-002", - "work_title": "我不知道", - "level_name": "", - "summary": "你猜我是谁", - "work_description": "你猜我是谁", - "levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]", - "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}", - "theme_tags_json": "[\"我不知道\"]", - "publication_status": { - "Draft": [] - }, - "play_count": 0, - "like_count": 0, - "remix_count": 0, - "updated_at": { - "__timestamp_micros_since_unix_epoch__": 1777619351714201 - }, - "created_at": { - "__timestamp_micros_since_unix_epoch__": 1777619336673245 - } - }, - { - "profile_id": "profile-003", - "work_id": "work-003", - "owner_user_id": "user-003", - "author_display_name": "author-002", - "work_title": "", - "level_name": "", - "summary": "", - "work_description": "", - "levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]", - "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}", - "theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]", - "publication_status": { - "Draft": [] - }, - "play_count": 0, - "like_count": 0, - "remix_count": 0, - "updated_at": { - "__timestamp_micros_since_unix_epoch__": 1777622285252380 - }, - "created_at": { - "__timestamp_micros_since_unix_epoch__": 1777622285252380 - } - } - ], - "custom_world_profile": [ - { - "profile_id": "profile-081", - "owner_user_id": "user-002", - "author_display_name": "author-012", - "author_public_user_code": "author-code-001", - "world_name": "青春飞扬校园", - "summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长", - "subtitle": "反内卷的自由学习之旅", - "profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…", - "publication_status": { - "Draft": [] - }, - "play_count": 0, - "like_count": 0, - "remix_count": 0, - "updated_at": { - "__timestamp_micros_since_unix_epoch__": 1777532006629209 - }, - "created_at": { - "__timestamp_micros_since_unix_epoch__": 1777531745887256 - } - } - ], - "match3d_work_profile": [], - "square_hole_work_profile": [], - "visual_novel_work_profile": [] - }, - "profileIds": { - "puzzle": ["profile-001", "profile-002", "profile-003"], - "customWorld": ["profile-081"], - "match3d": [], - "squareHole": [], - "bigFish": [], - "visualNovel": [] - }, - "workIds": { - "puzzle": ["work-001", "work-002", "work-003"], - "customWorld": [], - "match3d": [], - "squareHole": [], - "bigFish": [], - "visualNovel": [] - }, - "normalizedWorks": [ - { - "type": "puzzle", - "workId": "work-001", - "profileId": "profile-001", - "ownerUserId": "user-001", - "title": "化学家", - "subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈", - "publicationStatus": { - "Published": [] - }, - "playCount": 1, - "likeCount": 0, - "remixCount": 1, - "coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png", - "updatedAt": { - "__timestamp_micros_since_unix_epoch__": 1777703338322544 - } - }, - { - "type": "puzzle", - "workId": "work-002", - "profileId": "profile-002", - "ownerUserId": "user-002", - "title": "我不知道", - "subtitle": "你猜我是谁", - "publicationStatus": { - "Draft": [] - }, - "playCount": 0, - "likeCount": 0, - "remixCount": 0, - "updatedAt": { - "__timestamp_micros_since_unix_epoch__": 1777619351714201 - } - }, - { - "type": "puzzle", - "workId": "work-003", - "profileId": "profile-003", - "ownerUserId": "user-003", - "title": "", - "subtitle": "", - "publicationStatus": { - "Draft": [] - }, - "playCount": 0, - "likeCount": 0, - "remixCount": 0, - "updatedAt": { - "__timestamp_micros_since_unix_epoch__": 1777622285252380 - } - }, - { - "type": "customWorld", - "profileId": "profile-081", - "ownerUserId": "user-002", - "title": "青春飞扬校园", - "subtitle": "反内卷的自由学习之旅", - "publicationStatus": { - "Draft": [] - }, - "playCount": 0, - "likeCount": 0, - "remixCount": 0, - "updatedAt": { - "__timestamp_micros_since_unix_epoch__": 1777532006629209 - } - } - ] -} diff --git a/scripts/loadtest/data/works-list.sample.json b/scripts/loadtest/data/works-list.sample.json deleted file mode 100644 index 437d517a4..000000000 --- a/scripts/loadtest/data/works-list.sample.json +++ /dev/null @@ -1,208 +0,0 @@ -{ - "source": "spacetime-migration-1.json", - "generatedAt": "2026-05-18T11:54:04.280Z", - "counts": { - "puzzle_work_profile": 3, - "custom_world_profile": 1, - "match3d_work_profile": 0, - "square_hole_work_profile": 0, - "visual_novel_work_profile": 0 - }, - "tables": { - "puzzle_work_profile": [ - { - "profile_id": "profile-001", - "work_id": "work-001", - "owner_user_id": "user-001", - "author_display_name": "author-001", - "cover_asset_id": "asset-001", - "cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png", - "work_title": "化学家", - "level_name": "文学家", - "summary": "几个文学家正站在山上面对着瀑布侃侃而谈", - "work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室", - "levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…", - "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…", - "theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]", - "publication_status": { - "Published": [] - }, - "play_count": 1, - "like_count": 0, - "remix_count": 1, - "updated_at": { - "__timestamp_micros_since_unix_epoch__": 1777703338322544 - }, - "created_at": { - "__timestamp_micros_since_unix_epoch__": 1777648804043558 - }, - "published_at": { - "__timestamp_micros_since_unix_epoch__": 1777649364112270 - } - }, - { - "profile_id": "profile-002", - "work_id": "work-002", - "owner_user_id": "user-002", - "author_display_name": "author-002", - "work_title": "我不知道", - "level_name": "", - "summary": "你猜我是谁", - "work_description": "你猜我是谁", - "levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]", - "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}", - "theme_tags_json": "[\"我不知道\"]", - "publication_status": { - "Draft": [] - }, - "play_count": 0, - "like_count": 0, - "remix_count": 0, - "updated_at": { - "__timestamp_micros_since_unix_epoch__": 1777619351714201 - }, - "created_at": { - "__timestamp_micros_since_unix_epoch__": 1777619336673245 - } - }, - { - "profile_id": "profile-003", - "work_id": "work-003", - "owner_user_id": "user-003", - "author_display_name": "author-002", - "work_title": "", - "level_name": "", - "summary": "", - "work_description": "", - "levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]", - "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}", - "theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]", - "publication_status": { - "Draft": [] - }, - "play_count": 0, - "like_count": 0, - "remix_count": 0, - "updated_at": { - "__timestamp_micros_since_unix_epoch__": 1777622285252380 - }, - "created_at": { - "__timestamp_micros_since_unix_epoch__": 1777622285252380 - } - } - ], - "custom_world_profile": [ - { - "profile_id": "profile-081", - "owner_user_id": "user-002", - "author_display_name": "author-012", - "author_public_user_code": "author-code-001", - "world_name": "青春飞扬校园", - "summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长", - "subtitle": "反内卷的自由学习之旅", - "profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…", - "publication_status": { - "Draft": [] - }, - "play_count": 0, - "like_count": 0, - "remix_count": 0, - "updated_at": { - "__timestamp_micros_since_unix_epoch__": 1777532006629209 - }, - "created_at": { - "__timestamp_micros_since_unix_epoch__": 1777531745887256 - } - } - ], - "match3d_work_profile": [], - "square_hole_work_profile": [], - "visual_novel_work_profile": [] - }, - "profileIds": { - "puzzle": ["profile-001", "profile-002", "profile-003"], - "customWorld": ["profile-081"], - "match3d": [], - "squareHole": [], - "bigFish": [], - "visualNovel": [] - }, - "workIds": { - "puzzle": ["work-001", "work-002", "work-003"], - "customWorld": [], - "match3d": [], - "squareHole": [], - "bigFish": [], - "visualNovel": [] - }, - "normalizedWorks": [ - { - "type": "puzzle", - "workId": "work-001", - "profileId": "profile-001", - "ownerUserId": "user-001", - "title": "化学家", - "subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈", - "publicationStatus": { - "Published": [] - }, - "playCount": 1, - "likeCount": 0, - "remixCount": 1, - "coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png", - "updatedAt": { - "__timestamp_micros_since_unix_epoch__": 1777703338322544 - } - }, - { - "type": "puzzle", - "workId": "work-002", - "profileId": "profile-002", - "ownerUserId": "user-002", - "title": "我不知道", - "subtitle": "你猜我是谁", - "publicationStatus": { - "Draft": [] - }, - "playCount": 0, - "likeCount": 0, - "remixCount": 0, - "updatedAt": { - "__timestamp_micros_since_unix_epoch__": 1777619351714201 - } - }, - { - "type": "puzzle", - "workId": "work-003", - "profileId": "profile-003", - "ownerUserId": "user-003", - "title": "", - "subtitle": "", - "publicationStatus": { - "Draft": [] - }, - "playCount": 0, - "likeCount": 0, - "remixCount": 0, - "updatedAt": { - "__timestamp_micros_since_unix_epoch__": 1777622285252380 - } - }, - { - "type": "customWorld", - "profileId": "profile-081", - "ownerUserId": "user-002", - "title": "青春飞扬校园", - "subtitle": "反内卷的自由学习之旅", - "publicationStatus": { - "Draft": [] - }, - "playCount": 0, - "likeCount": 0, - "remixCount": 0, - "updatedAt": { - "__timestamp_micros_since_unix_epoch__": 1777532006629209 - } - } - ] -} diff --git a/scripts/loadtest/extract-works-list-data.mjs b/scripts/loadtest/extract-works-list-data.mjs deleted file mode 100644 index fc817b514..000000000 --- a/scripts/loadtest/extract-works-list-data.mjs +++ /dev/null @@ -1,428 +0,0 @@ -#!/usr/bin/env node -import { readFile, writeFile } from 'node:fs/promises'; -import { basename } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const ALLOWED_TABLES = new Set([ - 'puzzle_work_profile', - 'custom_world_profile', - 'match3d_work_profile', - 'square_hole_work_profile', - 'big_fish_work_profile', - 'visual_novel_work_profile', -]); - -const WORK_TABLE_TYPES = { - puzzle_work_profile: 'puzzle', - custom_world_profile: 'customWorld', - match3d_work_profile: 'match3d', - square_hole_work_profile: 'squareHole', - big_fish_work_profile: 'bigFish', - visual_novel_work_profile: 'visualNovel', -}; - -const TABLE_OUTPUT_ORDER = [ - 'puzzle_work_profile', - 'custom_world_profile', - 'match3d_work_profile', - 'square_hole_work_profile', - 'big_fish_work_profile', - 'visual_novel_work_profile', -]; - -const WORK_TYPES = [ - 'puzzle', - 'customWorld', - 'match3d', - 'squareHole', - 'bigFish', - 'visualNovel', -]; -const SHORT_TEXT_LIMIT = 120; -const LONG_TEXT_LIMIT = 500; -const SENSITIVE_PATTERN = - /(token|secret|password|passwd|phone|wallet|credential|authorization|auth[_-]?key|api[_-]?key)/giu; - -class StableMapper { - constructor(prefix) { - this.prefix = prefix; - this.values = new Map(); - } - - map(value) { - if (value === undefined || value === null || value === '') return value; - const key = String(value); - if (!this.values.has(key)) { - this.values.set( - key, - `${this.prefix}-${String(this.values.size + 1).padStart(3, '0')}`, - ); - } - return this.values.get(key); - } -} - -function createContext() { - return { - user: new StableMapper('user'), - session: new StableMapper('session'), - author: new StableMapper('author'), - authorCode: new StableMapper('author-code'), - publicWorkCode: new StableMapper('public-work-code'), - coverAsset: new StableMapper('asset'), - work: new StableMapper('work'), - profile: new StableMapper('profile'), - }; -} - -function createWorkTypeBuckets() { - return Object.fromEntries(WORK_TYPES.map((type) => [type, []])); -} - -function unwrapSpacetimeOption(value) { - if ( - value && - typeof value === 'object' && - !Array.isArray(value) && - Object.keys(value).length === 1 - ) { - if (Object.prototype.hasOwnProperty.call(value, 'some')) return value.some; - if (Object.prototype.hasOwnProperty.call(value, 'none')) return undefined; - } - return value; -} - -function truncateText(value, limit) { - if (value === undefined || value === null) return value; - const text = String(value).replace(/\s+/g, ' ').trim(); - if (text.length <= limit) return text; - return `${text.slice(0, limit)}…`; -} - -function redactSensitiveText(value) { - if (value === undefined || value === null) return value; - return String(value).replace(SENSITIVE_PATTERN, '[redacted]'); -} - -function sanitizeCoverImageSrc(value) { - const unwrapped = unwrapSpacetimeOption(value); - if (unwrapped === undefined || unwrapped === null || unwrapped === '') - return unwrapped; - const text = String(unwrapped); - if (text.startsWith('data:image/')) return '[redacted-data-image]'; - let withoutQuery = text.split('?')[0].split('#')[0]; - if (withoutQuery.length > 180) - withoutQuery = `${withoutQuery.slice(0, 180)}…`; - return withoutQuery; -} - -function sanitizeLargeJson(value) { - const unwrapped = unwrapSpacetimeOption(value); - if (unwrapped === undefined || unwrapped === null) return unwrapped; - if (typeof unwrapped === 'string') { - return truncateText(redactSensitiveText(unwrapped), LONG_TEXT_LIMIT); - } - try { - return truncateText( - redactSensitiveText(JSON.stringify(unwrapped)), - LONG_TEXT_LIMIT, - ); - } catch { - return truncateText( - redactSensitiveText(String(unwrapped)), - LONG_TEXT_LIMIT, - ); - } -} - -function firstDefined(row, keys) { - for (const key of keys) { - if (row[key] !== undefined && row[key] !== null) return row[key]; - } - return undefined; -} - -function sanitizeShortField(row, sanitized, key) { - if (row[key] !== undefined) { - sanitized[key] = truncateText( - unwrapSpacetimeOption(row[key]), - SHORT_TEXT_LIMIT, - ); - } -} - -function sanitizeWorkRow(row, ctx) { - const sanitized = {}; - const profileId = unwrapSpacetimeOption( - firstDefined(row, ['profile_id', 'profileId']), - ); - const workId = unwrapSpacetimeOption( - firstDefined(row, ['work_id', 'workId']), - ); - - if (profileId !== undefined) - sanitized.profile_id = ctx.profile.map(profileId); - if (workId !== undefined) sanitized.work_id = ctx.work.map(workId); - if (row.owner_user_id !== undefined) { - sanitized.owner_user_id = ctx.user.map( - unwrapSpacetimeOption(row.owner_user_id), - ); - } - if (row.user_id !== undefined) - sanitized.user_id = ctx.user.map(unwrapSpacetimeOption(row.user_id)); - - if (row.author_display_name !== undefined) { - sanitized.author_display_name = ctx.author.map( - unwrapSpacetimeOption(row.author_display_name), - ); - } - if (row.public_work_code !== undefined) { - sanitized.public_work_code = ctx.publicWorkCode.map( - unwrapSpacetimeOption(row.public_work_code), - ); - } - if (row.author_public_user_code !== undefined) { - sanitized.author_public_user_code = ctx.authorCode.map( - unwrapSpacetimeOption(row.author_public_user_code), - ); - } - if (row.cover_asset_id !== undefined) { - sanitized.cover_asset_id = ctx.coverAsset.map( - unwrapSpacetimeOption(row.cover_asset_id), - ); - } - if (row.cover_image_src !== undefined) - sanitized.cover_image_src = sanitizeCoverImageSrc(row.cover_image_src); - - for (const key of [ - 'title', - 'work_title', - 'level_name', - 'world_name', - 'summary', - 'summary_text', - 'description', - 'work_description', - 'subtitle', - ]) { - sanitizeShortField(row, sanitized, key); - } - - for (const key of [ - 'levels_json', - 'profile_payload_json', - 'anchor_pack_json', - 'theme_tags_json', - ]) { - if (row[key] !== undefined) sanitized[key] = sanitizeLargeJson(row[key]); - } - - const passthroughKeys = [ - 'publication_status', - 'publicationStatus', - 'play_count', - 'playCount', - 'like_count', - 'likeCount', - 'remix_count', - 'remixCount', - 'updated_at', - 'created_at', - 'published_at', - 'visibility', - 'status', - 'category', - 'tags', - ]; - for (const key of passthroughKeys) { - if (row[key] !== undefined) - sanitized[key] = unwrapSpacetimeOption(row[key]); - } - - return sanitized; -} - -function normalizeWork(tableName, row) { - const type = WORK_TABLE_TYPES[tableName]; - return { - type, - workId: row.work_id, - profileId: row.profile_id, - ownerUserId: row.owner_user_id, - publicWorkCode: row.public_work_code, - title: row.title ?? row.work_title ?? row.level_name ?? row.world_name, - subtitle: - row.subtitle ?? - row.summary_text ?? - row.summary ?? - row.work_description ?? - row.description, - publicationStatus: - row.publicationStatus ?? row.publication_status ?? row.status, - playCount: row.playCount ?? row.play_count ?? 0, - likeCount: row.likeCount ?? row.like_count ?? 0, - remixCount: row.remixCount ?? row.remix_count ?? 0, - coverImageSrc: row.cover_image_src, - updatedAt: row.updated_at, - }; -} - -function toRowsByTable(input) { - const tables = Array.isArray(input?.tables) ? input.tables : []; - const result = new Map(); - for (const table of tables) { - if (!ALLOWED_TABLES.has(table?.name)) continue; - result.set(table.name, Array.isArray(table.rows) ? table.rows : []); - } - return result; -} - -export function extractWorksListData(input, options = {}) { - const ctx = createContext(); - const rowsByTable = toRowsByTable(input); - const outputTables = {}; - const counts = {}; - const profileIds = createWorkTypeBuckets(); - const workIds = createWorkTypeBuckets(); - const normalizedWorks = []; - - for (const tableName of TABLE_OUTPUT_ORDER) { - const sourceRows = rowsByTable.get(tableName); - if (!sourceRows) continue; - const sanitizedRows = sourceRows.map((row) => sanitizeWorkRow(row, ctx)); - outputTables[tableName] = sanitizedRows; - counts[tableName] = sanitizedRows.length; - - const type = WORK_TABLE_TYPES[tableName]; - if (type) { - for (const row of sanitizedRows) { - if (row.profile_id) profileIds[type].push(row.profile_id); - if (row.work_id) workIds[type].push(row.work_id); - normalizedWorks.push(normalizeWork(tableName, row)); - } - } - } - - return { - source: options.source ?? 'unknown', - generatedAt: options.generatedAt ?? new Date().toISOString(), - counts, - tables: outputTables, - profileIds, - workIds, - normalizedWorks, - }; -} - -function createSampleOutput(output, maxRowsPerTable = 3) { - const tables = {}; - const counts = {}; - const allowedWorkIds = new Set(); - const allowedProfileIds = new Set(); - - for (const [tableName, rows] of Object.entries(output.tables)) { - tables[tableName] = rows.slice(0, maxRowsPerTable); - counts[tableName] = tables[tableName].length; - const type = WORK_TABLE_TYPES[tableName]; - if (type) { - for (const row of tables[tableName]) { - if (row.work_id) allowedWorkIds.add(row.work_id); - if (row.profile_id) allowedProfileIds.add(row.profile_id); - } - } - } - - const profileIds = Object.fromEntries( - Object.entries(output.profileIds).map(([type, ids]) => [ - type, - ids.filter((id) => allowedProfileIds.has(id)).slice(0, maxRowsPerTable), - ]), - ); - const workIds = Object.fromEntries( - Object.entries(output.workIds).map(([type, ids]) => [ - type, - ids.filter((id) => allowedWorkIds.has(id)).slice(0, maxRowsPerTable), - ]), - ); - const normalizedWorks = output.normalizedWorks - .filter( - (work) => - allowedWorkIds.has(work.workId) || - allowedProfileIds.has(work.profileId), - ) - .slice(0, maxRowsPerTable * 6); - - return { - ...output, - counts, - tables, - profileIds, - workIds, - normalizedWorks, - }; -} - -function parseArgs(argv) { - const args = {}; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === '--input' || arg === '--output' || arg === '--sample-output') { - const value = argv[index + 1]; - if (!value || value.startsWith('--')) - throw new Error(`${arg} requires a value`); - args[arg.slice(2)] = value; - index += 1; - } else if (arg === '--help' || arg === '-h') { - args.help = true; - } else { - throw new Error(`Unknown argument: ${arg}`); - } - } - return args; -} - -function usage() { - return 'Usage: node scripts/loadtest/extract-works-list-data.mjs --input --output [--sample-output ]'; -} - -export async function runCli(argv = process.argv.slice(2)) { - const args = parseArgs(argv); - if (args.help) { - console.log(usage()); - return; - } - if (!args.input) throw new Error('Missing required --input. ' + usage()); - if (!args.output) throw new Error('Missing required --output. ' + usage()); - - const raw = await readFile(args.input, 'utf8'); - const migration = JSON.parse(raw); - const output = extractWorksListData(migration, { - source: basename(args.input), - }); - await writeFile(args.output, `${JSON.stringify(output, null, 2)}\n`, 'utf8'); - - if (args['sample-output']) { - const sample = createSampleOutput(output); - await writeFile( - args['sample-output'], - `${JSON.stringify(sample, null, 2)}\n`, - 'utf8', - ); - } - - console.log( - `works-list extracted: source=${output.source}, tables=${Object.keys(output.tables).length}, normalizedWorks=${output.normalizedWorks.length}`, - ); - for (const [tableName, count] of Object.entries(output.counts)) { - console.log(` ${tableName}: ${count}`); - } -} - -const isDirectRun = - process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; -if (isDirectRun) { - runCli().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - }); -} diff --git a/scripts/loadtest/extract-works-list-data.test.ts b/scripts/loadtest/extract-works-list-data.test.ts deleted file mode 100644 index 8cdceb3e6..000000000 --- a/scripts/loadtest/extract-works-list-data.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { execFile } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; - -import { describe, expect, it } from 'vitest'; - -import { extractWorksListData } from './extract-works-list-data.mjs'; - -const execFileAsync = promisify(execFile); -const scriptPath = fileURLToPath( - new URL('./extract-works-list-data.mjs', import.meta.url), -); - -const fixtureMigration = { - schema_version: 7, - tables: [ - { - name: 'puzzle_work_profile', - rows: [ - { - profile_id: 'profile-real-aaa', - work_id: 'work-real-aaa', - owner_user_id: 'owner-secret-123', - author_display_name: 'Alice Secret', - author_public_user_code: 'author-code-secret', - public_work_code: 'public-code-secret', - title: '超长标题'.repeat(20), - summary: 'summary '.repeat(80), - description: 'description '.repeat(120), - publication_status: 'published', - play_count: 42, - like_count: 7, - cover_asset_id: { some: 'asset-secret-cover' }, - cover_image_src: { - some: 'https://cdn.example.test/cover.png?token=***&sig=abc', - }, - levels_json: JSON.stringify({ - secret: 'level-token-value', - data: 'x'.repeat(2000), - }), - theme_tags_json: JSON.stringify(['化学家', '实验室']), - remix_count: 2, - updated_at: '2026-05-01T00:00:00Z', - }, - { - profile_id: 'profile-real-bbb', - work_id: 'work-real-bbb', - owner_user_id: 'owner-secret-123', - author_display_name: 'Alice Secret', - publication_status: 'draft', - play_count: 3, - }, - ], - }, - { - name: 'custom_world_profile', - rows: [ - { - profile_id: 'world-profile-secret', - work_id: 'world-work-secret', - owner_user_id: 'world-owner-secret', - title: '世界作品', - profile_payload_json: '{"large":"' + 'y'.repeat(2000) + '"}', - }, - ], - }, - { - name: 'public_work_play_daily_stat', - rows: [ - { - source_type: 'puzzle', - profile_id: 'profile-real-aaa', - owner_user_id: 'owner-secret-123', - user_id: 'player-secret-456', - source_session_id: 'session-secret-789', - played_day: '2026-05-01', - play_count: 12, - updated_at: '2026-05-02T00:00:00Z', - }, - ], - }, - { - name: 'user_account', - rows: [ - { - user_id: 'owner-secret-123', - phone: '+8613800138000', - auth_token: 'auth-token-secret', - wallet_balance: 999, - }, - ], - }, - { - name: 'refresh_session', - rows: [ - { - token: 'refresh-token-secret', - source_session_id: 'session-secret-789', - }, - ], - }, - { - name: 'profile_wallet_ledger', - rows: [{ wallet_id: 'wallet-secret', amount: 100 }], - }, - ], -}; - -async function withTempDir(fn) { - const dir = await mkdtemp(path.join(tmpdir(), 'works-list-test-')); - try { - return await fn(dir); - } finally { - await rm(dir, { recursive: true, force: true }); - } -} - -describe('extractWorksListData', () => { - it('只保留作品 profile 白名单表,禁用的行为/敏感表不会出现在输出 JSON 字符串中', () => { - const output = extractWorksListData(fixtureMigration, { - source: 'fixture.local.json', - }); - const serialized = JSON.stringify(output); - - expect(Object.keys(output.tables).sort()).toEqual([ - 'custom_world_profile', - 'puzzle_work_profile', - ]); - expect(serialized).not.toContain('public_work_play_daily_stat'); - expect(serialized).not.toContain('user_account'); - expect(serialized).not.toContain('refresh_session'); - expect(serialized).not.toContain('profile_wallet_ledger'); - expect(serialized).not.toContain('+8613800138000'); - expect(serialized).not.toContain('auth-token-secret'); - expect(serialized).not.toContain('wallet-secret'); - }); - - it('不会输出 owner/user/session/auth/token/phone/wallet 等敏感原值,owner 稳定映射', () => { - const output = extractWorksListData(fixtureMigration, { - source: 'fixture.local.json', - }); - const serialized = JSON.stringify(output); - - for (const secret of [ - 'owner-secret-123', - 'player-secret-456', - 'session-secret-789', - 'Alice Secret', - 'author-code-secret', - 'public-code-secret', - 'asset-secret-cover', - 'SECRET_TOKEN', - ]) { - expect(serialized).not.toContain(secret); - } - - expect(output.tables.puzzle_work_profile[0].owner_user_id).toBe('user-001'); - expect(output.tables.puzzle_work_profile[1].owner_user_id).toBe('user-001'); - expect(output.tables.puzzle_work_profile[0].author_display_name).toBe( - 'author-001', - ); - expect(serialized).not.toContain('level-token-value'); - }); - - it('puzzle 数据生成 profileIds/workIds 和 normalizedWorks,并保留列表展示字段', () => { - const output = extractWorksListData(fixtureMigration, { - source: 'fixture.local.json', - }); - - expect(output.source).toBe('fixture.local.json'); - expect(output.generatedAt).toEqual(expect.any(String)); - expect(output.counts.puzzle_work_profile).toBe(2); - expect(output.profileIds.puzzle).toEqual(['profile-001', 'profile-002']); - expect(output.workIds.puzzle).toEqual(['work-001', 'work-002']); - expect(output.normalizedWorks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'puzzle', - workId: 'work-001', - profileId: 'profile-001', - publicationStatus: 'published', - playCount: 42, - title: expect.any(String), - remixCount: 2, - }), - ]), - ); - expect(output.tables.puzzle_work_profile[0].cover_image_src).toBe( - 'https://cdn.example.test/cover.png', - ); - expect(output.tables.puzzle_work_profile[0].theme_tags_json).toBe( - '["化学家","实验室"]', - ); - }); - - it('data image、URL token 和绝对输入路径不会泄露到输出', async () => { - await withTempDir(async (dir) => { - const input = path.join(dir, 'migration.local.json'); - const output = path.join(dir, 'works-list.local.json'); - await writeFile( - input, - JSON.stringify({ - tables: [ - { - name: 'puzzle_work_profile', - rows: [ - { - profile_id: 'profile-real', - work_id: 'work-real', - cover_image_src: { - some: 'data:image/png;base64,SECRET_IMAGE_BYTES', - }, - levels_json: JSON.stringify({ - token: 'SECRET_TOKEN_VALUE', - title: 'safe', - }), - }, - ], - }, - ], - }), - 'utf8', - ); - - await execFileAsync(process.execPath, [ - scriptPath, - '--input', - input, - '--output', - output, - ]); - const extracted = JSON.parse(await readFile(output, 'utf8')); - const serialized = JSON.stringify(extracted); - - expect(extracted.source).toBe('migration.local.json'); - expect(serialized).not.toContain(dir); - expect(serialized).not.toContain('SECRET_IMAGE_BYTES'); - expect(serialized).not.toContain('SECRET_TOKEN_VALUE'); - expect(extracted.tables.puzzle_work_profile[0].cover_image_src).toBe( - '[redacted-data-image]', - ); - }); - }); - - it('sample-output 只输出少量脱敏样例', async () => { - await withTempDir(async (dir) => { - const input = path.join(dir, 'migration.local.json'); - const output = path.join(dir, 'works-list.local.json'); - const sampleOutput = path.join(dir, 'works-list.sample.json'); - const manyRows = Array.from({ length: 5 }, (_, index) => ({ - profile_id: `profile-real-${index}`, - work_id: `work-real-${index}`, - owner_user_id: `owner-secret-${index}`, - title: `作品 ${index}`, - publication_status: 'published', - play_count: index, - })); - await writeFile( - input, - JSON.stringify({ - tables: [{ name: 'puzzle_work_profile', rows: manyRows }], - }), - 'utf8', - ); - - await execFileAsync(process.execPath, [ - scriptPath, - '--input', - input, - '--output', - output, - '--sample-output', - sampleOutput, - ]); - const sample = JSON.parse(await readFile(sampleOutput, 'utf8')); - const serialized = JSON.stringify(sample); - - expect(sample.tables.puzzle_work_profile).toHaveLength(3); - expect(sample.normalizedWorks).toHaveLength(3); - expect(serialized).not.toContain('owner-secret-0'); - expect(serialized).not.toContain('work-real-0'); - }); - }); - - it('CLI 参数缺失时退出非 0 并输出清晰错误', async () => { - await expect( - execFileAsync(process.execPath, [scriptPath, '--input', 'missing.json']), - ).rejects.toMatchObject({ - code: 1, - stderr: expect.stringContaining('--output'), - }); - }); -}); diff --git a/scripts/loadtest/k6-works-list.js b/scripts/loadtest/k6-works-list.js deleted file mode 100644 index 3cdf1b990..000000000 --- a/scripts/loadtest/k6-works-list.js +++ /dev/null @@ -1,278 +0,0 @@ -/* global __ENV */ -import { check, sleep } from 'k6'; -import { SharedArray } from 'k6/data'; -import http from 'k6/http'; -import { Rate, Trend } from 'k6/metrics'; - -// k6 resolves open() paths relative to this script file, not the shell cwd. -const DEFAULT_WORKS_DATA = 'data/works-list.local.json'; -const WORKS_DATA = __ENV.WORKS_DATA || DEFAULT_WORKS_DATA; -const BASE_URL = (__ENV.BASE_URL || 'http://127.0.0.1:8787').replace( - /\/+$/u, - '', -); -const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; -const SCENARIO = __ENV.SCENARIO || 'smoke'; -const REQUEST_TIMEOUT = __ENV.REQUEST_TIMEOUT || '30s'; -const SLEEP_MIN_SECONDS = Number(__ENV.SLEEP_MIN_SECONDS || '0.5'); -const SLEEP_MAX_SECONDS = Number(__ENV.SLEEP_MAX_SECONDS || '2'); -const DETAIL_RATIO = Number(__ENV.DETAIL_RATIO || '0'); - -const worksListShapeErrorRate = new Rate('works_list_shape_error_rate'); -const worksDetailShapeErrorRate = new Rate('works_detail_shape_error_rate'); -const worksListDuration = new Trend('works_list_duration'); -const worksDetailDuration = new Trend('works_detail_duration'); - -const data = new SharedArray('works-list-data', () => [ - JSON.parse(open(WORKS_DATA)), -])[0]; -const normalizedWorks = Array.isArray(data.normalizedWorks) - ? data.normalizedWorks - : []; - -const scenarioOptions = { - smoke: { - scenarios: { - smoke: { - executor: 'constant-vus', - vus: Number(__ENV.VUS || 1), - duration: __ENV.DURATION || '30s', - }, - }, - thresholds: { - http_req_failed: ['rate<0.01'], - http_req_duration: ['p(95)<800'], - works_list_shape_error_rate: ['rate<0.01'], - }, - }, - baseline: { - scenarios: { - baseline: { - executor: 'constant-vus', - vus: Number(__ENV.VUS || 10), - duration: __ENV.DURATION || '3m', - }, - }, - thresholds: { - http_req_failed: ['rate<0.01'], - http_req_duration: ['p(95)<800', 'p(99)<1500'], - works_list_shape_error_rate: ['rate<0.01'], - }, - }, - spike: { - scenarios: { - spike: { - executor: 'ramping-arrival-rate', - startRate: Number(__ENV.START_RPS || 5), - preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50), - maxVUs: Number(__ENV.MAX_VUS || 200), - timeUnit: '1s', - stages: [ - { - target: Number(__ENV.PEAK_RPS || 25), - duration: __ENV.RAMP_UP || '30s', - }, - { - target: Number(__ENV.PEAK_RPS || 25), - duration: __ENV.HOLD || '2m', - }, - { - target: Number(__ENV.END_RPS || 5), - duration: __ENV.RAMP_DOWN || '30s', - }, - ], - }, - }, - thresholds: { - http_req_failed: ['rate<0.01'], - http_req_duration: ['p(95)<2000'], - dropped_iterations: ['count==0'], - works_list_shape_error_rate: ['rate<0.01'], - }, - }, -}; - -export const options = scenarioOptions[SCENARIO] || scenarioOptions.smoke; - -const PUBLIC_ENDPOINTS = [ - { - name: 'puzzle_gallery_list', - method: 'GET', - path: '/api/runtime/puzzle/gallery', - expectCollectionKeys: ['items', 'works', 'entries'], - }, - { - name: 'custom_world_gallery_list', - method: 'GET', - path: '/api/runtime/custom-world-gallery', - expectCollectionKeys: ['entries', 'items', 'works'], - }, -]; - -const AUTH_ENDPOINTS = [ - { - name: 'puzzle_works_list', - method: 'GET', - path: '/api/runtime/puzzle/works', - expectCollectionKeys: ['items', 'works'], - }, - { - name: 'custom_world_works_list', - method: 'GET', - path: '/api/runtime/custom-world/works', - expectCollectionKeys: ['items', 'entries', 'works'], - }, -]; - -function requestParams(endpointName) { - const headers = { 'x-genarrative-response-envelope': 'v1' }; - if (AUTH_TOKEN) headers.Authorization = `Bearer ${AUTH_TOKEN}`; - return { - headers, - timeout: REQUEST_TIMEOUT, - tags: { endpoint: endpointName }, - }; -} - -function buildUrl(path) { - return `${BASE_URL}${path}`; -} - -function parseJson(response) { - try { - return response.json(); - } catch (_) { - return null; - } -} - -function unwrapPayload(json) { - if (!json || typeof json !== 'object') return null; - if (json.data && typeof json.data === 'object') return json.data; - return json; -} - -function hasCollection(payload, keys) { - return Boolean(payload) && keys.some((key) => Array.isArray(payload[key])); -} - -function firstCollection(payload, keys) { - for (const key of keys) { - if (payload && Array.isArray(payload[key])) return payload[key]; - } - return []; -} - -function hasListItemShape(payload, keys) { - const collection = firstCollection(payload, keys); - if (collection.length === 0) return true; - const item = collection[0]; - const hasId = Boolean( - item && - (item.profileId || - item.profile_id || - item.workId || - item.work_id || - item.publicWorkCode), - ); - const hasTitle = Boolean( - item && - (item.title || - item.workTitle || - item.work_title || - item.levelName || - item.worldName), - ); - return hasId && hasTitle; -} - -function randomItem(items) { - if (!items.length) return null; - return items[Math.floor(Math.random() * items.length)]; -} - -function listEndpoints() { - return AUTH_TOKEN - ? PUBLIC_ENDPOINTS.concat(AUTH_ENDPOINTS) - : PUBLIC_ENDPOINTS; -} - -function detailEndpointFor(work) { - if (!work || !work.profileId) return null; - if (work.type === 'puzzle') { - return { - name: 'puzzle_gallery_detail', - path: `/api/runtime/puzzle/gallery/${encodeURIComponent(work.profileId)}`, - expectKeys: ['item', 'work', 'entry'], - }; - } - if (work.type === 'customWorld' && work.profileId && work.ownerUserId) { - return { - name: 'custom_world_gallery_detail', - path: `/api/runtime/custom-world-gallery/${encodeURIComponent(work.ownerUserId)}/${encodeURIComponent(work.profileId)}`, - expectKeys: ['entry', 'item', 'work'], - }; - } - return null; -} - -function performListRequest(endpoint) { - const url = buildUrl(endpoint.path); - const response = http.request( - endpoint.method, - url, - null, - requestParams(endpoint.name), - ); - worksListDuration.add(response.timings.duration, { endpoint: endpoint.name }); - const json = parseJson(response); - const payload = unwrapPayload(json); - const ok = check(response, { - [`${endpoint.name} status is 200`]: (res) => res.status === 200, - [`${endpoint.name} returns json object`]: () => Boolean(payload), - [`${endpoint.name} has collection`]: () => - hasCollection(payload, endpoint.expectCollectionKeys), - [`${endpoint.name} list item shape`]: () => - hasListItemShape(payload, endpoint.expectCollectionKeys), - }); - worksListShapeErrorRate.add(!ok, { endpoint: endpoint.name }); -} - -function performDetailRequest() { - const endpoint = detailEndpointFor(randomItem(normalizedWorks)); - if (!endpoint) return; - - const response = http.get( - buildUrl(endpoint.path), - requestParams(endpoint.name), - ); - worksDetailDuration.add(response.timings.duration, { - endpoint: endpoint.name, - }); - const json = parseJson(response); - const payload = unwrapPayload(json); - const ok = check(response, { - [`${endpoint.name} status is 200`]: (res) => res.status === 200, - [`${endpoint.name} has detail payload`]: () => - Boolean(payload) && endpoint.expectKeys.some((key) => payload[key]), - }); - worksDetailShapeErrorRate.add(!ok, { endpoint: endpoint.name }); -} - -export default function () { - for (const endpoint of listEndpoints()) { - performListRequest(endpoint); - } - if ( - normalizedWorks.length && - DETAIL_RATIO > 0 && - Math.random() < DETAIL_RATIO - ) { - performDetailRequest(); - } - - const jitter = - SLEEP_MIN_SECONDS + - Math.random() * Math.max(0, SLEEP_MAX_SECONDS - SLEEP_MIN_SECONDS); - sleep(jitter); -} diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 18b7566bf..467f0de1c 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -44,24 +44,17 @@ 22. 接入 `POST /api/auth/wechat/bind-phone` 微信待绑定账号补绑手机号链路 23. 接入 `POST /api/assets/objects/bind` 已确认对象绑定业务实体槽位链路 24. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract -25. 接入 `custom-world-library`、`custom-world-gallery` 与 agent `publish_world` 首批 Axum facade -26. 接入 custom world agent `session create / session snapshot` Axum facade -27. 旧 `runtime story` 兼容接口已下线并保持未挂载;运行时故事写链路改为: - - `POST /api/story/sessions/runtime` - - `GET /api/story/sessions/{story_session_id}/runtime-projection` - - `POST /api/story/sessions/{story_session_id}/actions/resolve` -28. 接入 `POST /api/assets/character-visual/generate` -29. 接入 `GET /api/assets/character-visual/jobs/{task_id}` -30. 接入 `POST /api/assets/character-visual/publish` -31. 接入 `GET /api/assets/character-animation/templates` -32. 接入 `POST /api/assets/character-animation/import-video` -33. 接入 `GET /api/assets/character-workflow-cache/{character_id}` -34. 接入 `POST /api/assets/character-workflow-cache` -35. 接入 `POST /api/assets/character-animation/generate` -36. 接入 `GET /api/assets/character-animation/jobs/{task_id}` -37. 接入 `POST /api/assets/character-animation/publish` -38. 下线旧 `/generated-*` 资产直读代理;生成资产读取统一走 `/api/assets/read-url` 或 asset object projection -39. 下线旧 `/api/custom-world/*` 非 runtime 前缀和 `/api/runtime/puzzle/runs/local-next-level`,并用路由回归测试确认这些旧入口保持 `404` +25. 接入 `POST /api/assets/character-visual/generate` +26. 接入 `GET /api/assets/character-visual/jobs/{task_id}` +27. 接入 `POST /api/assets/character-visual/publish` +28. 接入 `GET /api/assets/character-animation/templates` +29. 接入 `POST /api/assets/character-animation/import-video` +30. 接入 `GET /api/assets/character-workflow-cache/{character_id}` +31. 接入 `POST /api/assets/character-workflow-cache` +32. 接入 `POST /api/assets/character-animation/generate` +33. 接入 `GET /api/assets/character-animation/jobs/{task_id}` +34. 接入 `POST /api/assets/character-animation/publish` +35. 生成资产读取统一走 `/api/assets/read-url` 或 asset object projection 后续与本 crate 直接相关的任务包括: @@ -84,15 +77,11 @@ 17. [x] 接入 `/api/auth/wechat/bind-phone` 18. [x] 接入 `/api/assets/objects/bind` 19. [x] 接入 `/api/assets/sts-upload-credentials` -20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade -21. [x] 接入 `custom world agent session create / snapshot` facade -22. [x] 下线旧 `runtime story` compat facade,并接入 story session scoped runtime story 写读入口 -23. [x] 接入 `character-visual generate / jobs / publish` 第一批 OSS 主链兼容 facade -24. [x] 接入 `character-animation templates / import-video` 第一批 OSS 草稿兼容 facade -25. [x] 接入 `character-workflow-cache get / save` 第一批 OSS JSON 草稿兼容 facade -26. [x] 接入 `character-animation generate / jobs / publish` 第一批 OSS 主链兼容 facade -27. [x] 下线旧 `/generated-*` 路径 OSS 私有读同源代理 -28. [x] 下线旧 `/api/custom-world/*` 非 runtime 前缀和 Puzzle `local-next-level` 兼容入口 +20. [x] 接入 `character-visual generate / jobs / publish` 第一批 OSS 主链 +21. [x] 接入 `character-animation templates / import-video` 第一批 OSS 草稿链路 +22. [x] 接入 `character-workflow-cache get / save` 第一批 OSS JSON 草稿链路 +23. [x] 接入 `character-animation generate / jobs / publish` 第一批 OSS 主链 +24. [x] 生成资产读取统一走 `/api/assets/read-url` 或 asset object projection 当前 tracing 约定: @@ -161,13 +150,9 @@ 11. 当前手机号登录与微信登录都复用 `module-auth` 的进程内认证仓储,`api-server` 负责请求解析、场景判定、系统 JWT 签发与 refresh cookie 写回。 12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。 13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。 -14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。 -15. 当前 `/api/runtime/story/*` 不再挂载;runtime story 开局、读取和动作结算通过 `/api/story/sessions/runtime`、`/api/story/sessions/{story_session_id}/runtime-projection` 与 `/api/story/sessions/{story_session_id}/actions/resolve` 进入 BFF,并由 `module-runtime-story`、story session 和 runtime snapshot 投影共同闭合。 -16. 当前 `/api/assets/character-visual/*` 第一批只保证旧接口 contract、OSS 草稿/正式对象、`asset_object` 与 `asset_entity_binding` 主链可用;真实图片模型、workflow cache 与本地角色覆盖写回仍在后续阶段。 -17. 当前 `/api/assets/character-animation/import-video` 第一批只接受 `data:video/*;base64,...` 并写入 OSS 草稿区,不读取旧本地 `public/` 路径,也不创建正式 `asset_object`。 -18. 当前 `/api/assets/character-workflow-cache/*` 第一批只把工作流 JSON 草稿写入 OSS,不迁移历史本地缓存,也不创建正式 `asset_object`。 -19. 当前 `/api/assets/character-animation/generate` 第一批只用 Rust 占位产物打通 `AiTaskService + OSS` 草稿链;`image-sequence` 写 SVG 帧,视频类策略优先复用参考视频或仓库内可播放占位视频,不代表真实上游视频模型已完成迁移。 -20. 当前 `/api/assets/character-animation/publish` 会把前端提交帧、动作级 manifest 与总 manifest 写入 OSS,并只把总 manifest 确认为 `asset_object` 后绑定到 `character / animation_set`。 -21. 当前旧 `/generated-*` 直读兼容层已下线;`/generated-*` 只作为 `legacyPublicPath` / OSS object key 标识,读取必须通过 `/api/assets/read-url` 或业务投影中的签名读 URL。 -22. 当前旧 `/api/custom-world/entity`、`/api/custom-world/scene-npc`、`/api/custom-world/scene-image`、`/api/custom-world/cover-image`、`/api/custom-world/cover-upload` 不再挂载,RPG 创作资产入口统一使用 `/api/runtime/custom-world/*`。 -23. 当前旧 `/api/runtime/puzzle/runs/local-next-level` 不再挂载,正式 Puzzle 运行态只通过 `/api/runtime/puzzle/runs/{run_id}/next-level` 进入后端真相源。 +14. 当前 `/api/assets/character-visual/*` 第一批只保证现役接口 contract、OSS 草稿/正式对象、`asset_object` 与 `asset_entity_binding` 主链可用;真实图片模型、workflow cache 与本地角色覆盖写回仍在后续阶段。 +15. 当前 `/api/assets/character-animation/import-video` 第一批只接受 `data:video/*;base64,...` 并写入 OSS 草稿区,不读取旧本地 `public/` 路径,也不创建正式 `asset_object`。 +16. 当前 `/api/assets/character-workflow-cache/*` 第一批只把工作流 JSON 草稿写入 OSS,不迁移历史本地缓存,也不创建正式 `asset_object`。 +17. 当前 `/api/assets/character-animation/generate` 第一批只用 Rust 占位产物打通 `AiTaskService + OSS` 草稿链;`image-sequence` 写 SVG 帧,视频类策略优先复用参考视频或仓库内可播放占位视频,不代表真实上游视频模型已完成迁移。 +18. 当前 `/api/assets/character-animation/publish` 会把前端提交帧、动作级 manifest 与总 manifest 写入 OSS,并只把总 manifest 确认为 `asset_object` 后绑定到 `character / animation_set`。 +19. `/generated-*` 只作为 `legacyPublicPath` / OSS object key 标识,读取必须通过 `/api/assets/read-url` 或业务投影中的签名读 URL。 diff --git a/server-rs/crates/api-server/src/modules/story.rs b/server-rs/crates/api-server/src/modules/story.rs deleted file mode 100644 index 34d852557..000000000 --- a/server-rs/crates/api-server/src/modules/story.rs +++ /dev/null @@ -1,154 +0,0 @@ -use axum::{ - Router, - extract::DefaultBodyLimit, - middleware, - routing::{get, post}, -}; - -use crate::{ - auth::require_bearer_auth, - creative_agent::{ - cancel_creative_agent_session, confirm_creative_puzzle_template, - create_creative_agent_session, get_creative_agent_session, stream_creative_agent_message, - stream_creative_draft_edit, - }, - state::AppState, - story_battles::{ - create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle, - }, - story_sessions::{ - begin_story_runtime_session, begin_story_session, continue_story, - get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action, - }, -}; - -const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; - -pub fn router(state: AppState) -> Router { - Router::new() - .route( - "/api/story/sessions", - post(begin_story_session).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/story/sessions/runtime", - post(begin_story_runtime_session).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/story/sessions/{story_session_id}/state", - get(get_story_session_state).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/story/sessions/{story_session_id}/runtime-projection", - get(get_story_runtime_projection).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/story/sessions/{story_session_id}/actions/resolve", - post(resolve_story_runtime_action).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/story/sessions/continue", - post(continue_story).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/story/battles", - post(create_story_battle).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/story/battles/{battle_state_id}", - get(get_story_battle_state).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/story/npc/battle", - post(create_story_npc_battle).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/story/battles/resolve", - post(resolve_story_battle).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/creative-agent/sessions", - post(create_creative_agent_session) - // 中文注释:创意 Agent 首轮允许携带参考图 URL/Data URL,沿用拼图参考图入口上限。 - .layer(DefaultBodyLimit::max( - PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/creative-agent/sessions/{session_id}", - get(get_creative_agent_session).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/creative-agent/sessions/{session_id}/messages/stream", - post(stream_creative_agent_message) - // 中文注释:message stream 同样可能带图片素材,避免默认 JSON limit 过早拒绝。 - .layer(DefaultBodyLimit::max( - PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/creative-agent/sessions/{session_id}/confirm-template", - post(confirm_creative_puzzle_template).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream", - post(stream_creative_draft_edit) - // 中文注释:草稿编辑会携带当前 puzzle draft JSON,保持和拼图草稿入口一致的 body 上限。 - .layer(DefaultBodyLimit::max( - PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), - ) - .route( - "/api/runtime/creative-agent/sessions/{session_id}/cancel", - post(cancel_creative_agent_session) - .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)), - ) -} diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index f40b8388a..8e7bd07c8 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -2376,154 +2376,6 @@ pub fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool { ) } -#[cfg(any())] -pub fn resolve_runtime_profile_world_snapshot_meta( - game_state: Option<&serde_json::Map>, -) -> Option { - let game_state = game_state?; - let custom_world_profile = game_state - .get("customWorldProfile") - .and_then(Value::as_object); - - if let Some(custom_world_profile) = custom_world_profile { - let profile_id = read_runtime_json_string_field_from_map(custom_world_profile, "id"); - let world_title = read_runtime_json_string_field_from_map(custom_world_profile, "name") - .or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title")); - if profile_id.is_some() || world_title.is_some() { - let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string()); - return Some(RuntimeProfileWorldSnapshotMeta { - world_key: profile_id - .as_ref() - .map(|profile_id| format!("custom:{profile_id}")) - .unwrap_or_else(|| format!("custom:{world_title}")), - owner_user_id: None, - profile_id, - world_type: Some("CUSTOM".to_string()), - world_title, - world_subtitle: read_runtime_json_string_field_from_map( - custom_world_profile, - "summary", - ) - .or_else(|| { - read_runtime_json_string_field_from_map(custom_world_profile, "settingText") - }) - .unwrap_or_default(), - }); - } - } - - let world_type = read_runtime_json_string_field_from_map(game_state, "worldType")?; - let current_scene_preset = game_state - .get("currentScenePreset") - .and_then(Value::as_object); - - Some(RuntimeProfileWorldSnapshotMeta { - world_key: format!("builtin:{world_type}"), - owner_user_id: None, - profile_id: None, - world_type: Some(world_type.clone()), - world_title: current_scene_preset - .and_then(|preset| read_runtime_json_string_field_from_map(preset, "name")) - .unwrap_or_else(|| build_runtime_builtin_world_title(&world_type)), - world_subtitle: current_scene_preset - .and_then(|preset| { - read_runtime_json_string_field_from_map(preset, "summary") - .or_else(|| read_runtime_json_string_field_from_map(preset, "description")) - }) - .unwrap_or_default(), - }) -} - -#[cfg(any())] -pub fn resolve_runtime_profile_save_archive_meta( - game_state: &Value, - current_story_json: Option<&str>, -) -> Option { - if is_non_persistent_runtime_snapshot(game_state) { - return None; - } - - let game_state_object = game_state.as_object(); - let world_meta = resolve_runtime_profile_world_snapshot_meta(game_state_object)?; - let story_engine_memory = game_state_object - .and_then(|state| state.get("storyEngineMemory")) - .and_then(Value::as_object); - let continue_game_digest = story_engine_memory - .and_then(|memory| read_runtime_json_string_field_from_map(memory, "continueGameDigest")); - let current_story_text = parse_optional_json_value( - current_story_json, - RuntimeProfileFieldError::InvalidCurrentStoryJson, - ) - .ok() - .flatten() - .and_then(|story| story.as_object().cloned()) - .and_then(|story| read_runtime_json_string_field_from_map(&story, "text")); - let custom_world_profile = game_state_object - .and_then(|state| state.get("customWorldProfile")) - .and_then(Value::as_object); - - if let Some(custom_world_profile) = custom_world_profile { - let world_name = read_runtime_json_string_field_from_map(custom_world_profile, "name") - .or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title")) - .unwrap_or_else(|| world_meta.world_title.clone()); - let subtitle = read_runtime_json_string_field_from_map(custom_world_profile, "summary") - .or_else(|| { - read_runtime_json_string_field_from_map(custom_world_profile, "settingText") - }) - .unwrap_or_else(|| world_meta.world_subtitle.clone()); - let summary_text = continue_game_digest - .or(current_story_text) - .or_else(|| { - if subtitle.is_empty() { - None - } else { - Some(subtitle.clone()) - } - }) - .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); - - return Some(RuntimeProfileSaveArchiveMeta { - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_name, - subtitle, - summary_text, - cover_image_src: read_runtime_json_string_field_from_map( - custom_world_profile, - "coverImageSrc", - ), - }); - } - - let summary_text = continue_game_digest - .or(current_story_text) - .or_else(|| { - if world_meta.world_subtitle.is_empty() { - None - } else { - Some(world_meta.world_subtitle.clone()) - } - }) - .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); - let current_scene_preset = game_state_object - .and_then(|state| state.get("currentScenePreset")) - .and_then(Value::as_object); - - Some(RuntimeProfileSaveArchiveMeta { - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_name: world_meta.world_title, - subtitle: world_meta.world_subtitle, - summary_text, - cover_image_src: current_scene_preset - .and_then(|preset| read_runtime_json_string_field_from_map(preset, "imageSrc")), - }) -} - #[cfg(any())] pub fn read_runtime_json_non_negative_u64(value: Option<&Value>) -> u64 { match value { diff --git a/server-rs/crates/shared-contracts/src/bark_battle.rs b/server-rs/crates/shared-contracts/src/bark_battle.rs deleted file mode 100644 index 82161f84b..000000000 --- a/server-rs/crates/shared-contracts/src/bark_battle.rs +++ /dev/null @@ -1,966 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum BarkBattleDifficultyPreset { - Easy, - Normal, - Hard, -} - -impl Default for BarkBattleDifficultyPreset { - fn default() -> Self { - Self::Normal - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum BarkBattleServerResult { - PlayerWin, - OpponentWin, - Draw, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum BarkBattleFinishStatus { - Accepted, - AcceptedWithFlags, - Rejected, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum BarkBattleAssetSlot { - PlayerCharacter, - OpponentCharacter, - UiBackground, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleReplacementConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub player_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opponent_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ui_background_image_src: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleConfigEditorPayload { - pub title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - pub theme_description: String, - pub player_image_description: String, - pub opponent_image_description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub onomatopoeia: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub player_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opponent_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ui_background_image_src: Option, - #[serde(default)] - pub difficulty_preset: BarkBattleDifficultyPreset, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleDraftCreateRequest { - pub title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - pub theme_description: String, - pub player_image_description: String, - pub opponent_image_description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub onomatopoeia: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub player_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opponent_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ui_background_image_src: Option, - #[serde(default)] - pub difficulty_preset: BarkBattleDifficultyPreset, -} - -impl From for BarkBattleConfigEditorPayload { - fn from(value: BarkBattleDraftCreateRequest) -> Self { - Self { - title: value.title, - description: value.description, - theme_description: value.theme_description, - player_image_description: value.player_image_description, - opponent_image_description: value.opponent_image_description, - onomatopoeia: value.onomatopoeia, - player_character_image_src: value.player_character_image_src, - opponent_character_image_src: value.opponent_character_image_src, - ui_background_image_src: value.ui_background_image_src, - difficulty_preset: value.difficulty_preset, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleDraftConfigUpdateRequest { - pub draft_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub work_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ruleset_version: Option, - pub title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - pub theme_description: String, - pub player_image_description: String, - pub opponent_image_description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub onomatopoeia: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub player_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opponent_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ui_background_image_src: Option, - #[serde(default)] - pub difficulty_preset: BarkBattleDifficultyPreset, -} - -impl From for BarkBattleConfigEditorPayload { - fn from(value: BarkBattleDraftConfigUpdateRequest) -> Self { - Self { - title: value.title, - description: value.description, - theme_description: value.theme_description, - player_image_description: value.player_image_description, - opponent_image_description: value.opponent_image_description, - onomatopoeia: value.onomatopoeia, - player_character_image_src: value.player_character_image_src, - opponent_character_image_src: value.opponent_character_image_src, - ui_background_image_src: value.ui_background_image_src, - difficulty_preset: value.difficulty_preset, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleWorkPublishRequest { - pub draft_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub work_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub published_snapshot: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleImageAssetGenerateRequest { - pub slot: BarkBattleAssetSlot, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub draft_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub billing_purpose: Option, - pub config: BarkBattleConfigEditorPayload, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleGeneratedImageAsset { - pub image_src: String, - pub asset_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_type: Option, - pub model: String, - pub size: String, - pub task_id: String, - pub prompt: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub actual_prompt: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleDraftConfig { - pub draft_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub work_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ruleset_version: Option, - pub title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - pub theme_description: String, - pub player_image_description: String, - pub opponent_image_description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub onomatopoeia: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub player_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opponent_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ui_background_image_src: Option, - #[serde(default)] - pub difficulty_preset: BarkBattleDifficultyPreset, - pub updated_at: String, -} - -impl Default for BarkBattleDraftConfig { - fn default() -> Self { - Self { - draft_id: String::new(), - work_id: None, - config_version: None, - ruleset_version: None, - title: String::new(), - description: None, - theme_description: String::new(), - player_image_description: String::new(), - opponent_image_description: String::new(), - onomatopoeia: None, - player_character_image_src: None, - opponent_character_image_src: None, - ui_background_image_src: None, - difficulty_preset: BarkBattleDifficultyPreset::Normal, - updated_at: String::new(), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattlePublishedConfig { - pub work_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub draft_id: Option, - pub config_version: u32, - pub ruleset_version: String, - pub play_type_id: String, - pub title: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - pub theme_description: String, - pub player_image_description: String, - pub opponent_image_description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub onomatopoeia: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub player_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opponent_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ui_background_image_src: Option, - pub difficulty_preset: BarkBattleDifficultyPreset, - pub updated_at: String, - pub published_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleRuntimeConfig { - pub work_id: String, - pub config_version: u32, - pub ruleset_version: String, - pub play_type_id: String, - pub duration_ms: u64, - pub energy_min: f32, - pub energy_max: f32, - pub draw_threshold: f32, - pub min_bark_gap_ms: u64, - pub difficulty_preset: BarkBattleDifficultyPreset, - pub theme_description: String, - pub player_image_description: String, - pub opponent_image_description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub onomatopoeia: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub player_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opponent_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ui_background_image_src: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleWorkSummary { - pub work_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub draft_id: Option, - pub owner_user_id: String, - pub author_display_name: String, - pub title: String, - pub summary: String, - pub theme_description: String, - pub player_image_description: String, - pub opponent_image_description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub onomatopoeia: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub player_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opponent_character_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ui_background_image_src: Option, - pub difficulty_preset: BarkBattleDifficultyPreset, - pub status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub generation_status: Option, - pub publish_ready: bool, - pub play_count: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub finish_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub win_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub draw_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub loss_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub recent_play_count_7d: Option, - pub updated_at: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub published_at: Option, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleWorksResponse { - #[serde(default)] - pub items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleWorkDetailResponse { - pub item: BarkBattleWorkSummary, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleRunStartRequest { - pub work_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_route: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_runtime_version: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleRunStartResponse { - pub run_id: String, - pub run_token: String, - pub work_id: String, - pub config_version: u32, - pub ruleset_version: String, - pub difficulty_preset: BarkBattleDifficultyPreset, - pub runtime_config: BarkBattleRuntimeConfig, - pub server_started_at: String, - pub expires_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleDerivedMetrics { - pub trigger_count: u32, - pub max_volume: f32, - pub average_volume: f32, - pub final_energy: f32, - pub combo_max: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleRunFinishRequest { - pub work_id: String, - pub run_id: String, - pub run_token: String, - pub config_version: u32, - pub ruleset_version: String, - pub difficulty_preset: BarkBattleDifficultyPreset, - pub client_started_at: String, - pub client_finished_at: String, - pub duration_ms: u64, - pub derived_metrics: BarkBattleDerivedMetrics, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_result: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_digest: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_runtime_version: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleScoreSummary { - pub duration_ms: u64, - pub trigger_count: u32, - pub max_volume: f32, - pub average_volume: f32, - pub final_energy: f32, - pub combo_max: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleRunFinishResponse { - pub status: BarkBattleFinishStatus, - pub run_id: String, - pub work_id: String, - pub config_version: u32, - pub ruleset_version: String, - pub difficulty_preset: BarkBattleDifficultyPreset, - pub server_result: BarkBattleServerResult, - pub score_summary: BarkBattleScoreSummary, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub leaderboard_score: Option, - #[serde(default)] - pub anti_cheat_flags: Vec, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleLeaderboardEntry { - pub rank: u32, - pub run_id: String, - pub work_id: String, - pub config_version: u32, - pub ruleset_version: String, - pub difficulty_preset: BarkBattleDifficultyPreset, - pub display_name: String, - pub server_result: BarkBattleServerResult, - pub score_summary: BarkBattleScoreSummary, - pub leaderboard_score: u64, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleLeaderboardResponse { - pub work_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_version: Option, - pub ruleset_version: String, - pub difficulty_preset: BarkBattleDifficultyPreset, - #[serde(default)] - pub entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub viewer_best: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattlePersonalHistoryItem { - pub run_id: String, - pub work_id: String, - pub config_version: u32, - pub ruleset_version: String, - pub difficulty_preset: BarkBattleDifficultyPreset, - pub server_result: BarkBattleServerResult, - pub score_summary: BarkBattleScoreSummary, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub leaderboard_score: Option, - #[serde(default)] - pub anti_cheat_flags: Vec, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattlePersonalBestSummary { - pub work_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_version: Option, - pub ruleset_version: String, - pub difficulty_preset: BarkBattleDifficultyPreset, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub best_leaderboard_score: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub best_final_energy: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub best_trigger_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub best_max_volume: Option, - pub win_count: u64, - pub draw_count: u64, - pub loss_count: u64, - pub finish_count: u64, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattlePersonalHistoryResponse { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub work_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub difficulty_preset: Option, - #[serde(default)] - pub items: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub best_summary: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BarkBattleWorkStats { - pub work_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_version: Option, - pub ruleset_version: String, - pub difficulty_preset: BarkBattleDifficultyPreset, - pub play_start_count: u64, - pub finish_count: u64, - pub win_count: u64, - pub draw_count: u64, - pub loss_count: u64, - pub flagged_count: u64, - pub leaderboard_entry_count: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub best_leaderboard_score: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub best_final_energy: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub average_final_energy: Option, - pub updated_at: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn editor_and_runtime_contract_use_description_fields_only() { - let editor = BarkBattleConfigEditorPayload { - title: "周末狗狗杯".to_string(), - description: Some("轻配置草稿".to_string()), - theme_description: "霓虹公园里的欢乐擂台".to_string(), - player_image_description: "戴红围巾的柴犬主角".to_string(), - opponent_image_description: "蓝色护目镜哈士奇对手".to_string(), - onomatopoeia: Some(vec!["轰汪!".to_string(), "炸场!".to_string()]), - player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), - opponent_character_image_src: Some("https://example.test/opponent.png".to_string()), - ui_background_image_src: Some("/generated-bark-battle/ui.png".to_string()), - difficulty_preset: BarkBattleDifficultyPreset::Hard, - }; - let payload = serde_json::to_value(editor).expect("config should serialize"); - - assert_eq!(payload["themeDescription"], json!("霓虹公园里的欢乐擂台")); - assert_eq!( - payload["playerImageDescription"], - json!("戴红围巾的柴犬主角") - ); - assert_eq!( - payload["opponentImageDescription"], - json!("蓝色护目镜哈士奇对手") - ); - assert_eq!(payload["onomatopoeia"], json!(["轰汪!", "炸场!"])); - for removed in [ - "themePreset", - "playerDogSkinPreset", - "opponentDogSkinPreset", - "barkSoundSrc", - "leaderboardEnabled", - ] { - assert!( - !payload.as_object().unwrap().contains_key(removed), - "{removed} must not remain in v1 public config payload" - ); - } - - let runtime = BarkBattleRuntimeConfig { - work_id: "bark-battle-work-1".to_string(), - config_version: 1, - ruleset_version: "bark-battle-ruleset-v1".to_string(), - play_type_id: "bark-battle".to_string(), - duration_ms: 30_000, - energy_min: 0.0, - energy_max: 100.0, - draw_threshold: 5.0, - min_bark_gap_ms: 220, - difficulty_preset: BarkBattleDifficultyPreset::Normal, - theme_description: "阳光草坪".to_string(), - player_image_description: "小柴犬".to_string(), - opponent_image_description: "大金毛".to_string(), - onomatopoeia: Some(vec!["轰汪!".to_string(), "燃起来!".to_string()]), - player_character_image_src: None, - opponent_character_image_src: None, - ui_background_image_src: None, - updated_at: "2026-05-20T00:00:00Z".to_string(), - }; - let payload = serde_json::to_value(runtime).expect("runtime should serialize"); - assert_eq!(payload["themeDescription"], json!("阳光草坪")); - assert!( - !payload - .as_object() - .unwrap() - .contains_key("leaderboardEnabled") - ); - assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); - } - - #[test] - fn work_summary_responses_use_public_gallery_contract() { - let response = BarkBattleWorksResponse { - items: vec![BarkBattleWorkSummary { - work_id: "bark-battle-work-1".to_string(), - draft_id: Some("bark-battle-draft-1".to_string()), - owner_user_id: "user-1".to_string(), - author_display_name: "玩家".to_string(), - title: "汪汪测试杯".to_string(), - summary: "轻量公开卡片".to_string(), - theme_description: "阳光草坪".to_string(), - player_image_description: "小柴犬".to_string(), - opponent_image_description: "大金毛".to_string(), - onomatopoeia: Some(vec!["轰汪!".to_string(), "燃起来!".to_string()]), - player_character_image_src: None, - opponent_character_image_src: None, - ui_background_image_src: None, - difficulty_preset: BarkBattleDifficultyPreset::Normal, - status: "published".to_string(), - generation_status: Some("ready".to_string()), - publish_ready: true, - play_count: 3, - finish_count: Some(2), - win_count: Some(1), - draw_count: Some(1), - loss_count: Some(0), - recent_play_count_7d: Some(2), - updated_at: "2026-05-20T00:00:00Z".to_string(), - published_at: Some("2026-05-20T00:00:00Z".to_string()), - }], - }; - - let payload = serde_json::to_value(response).expect("works response should serialize"); - - assert_eq!(payload["items"][0]["themeDescription"], json!("阳光草坪")); - assert_eq!(payload["items"][0]["recentPlayCount7d"], json!(2)); - assert_eq!(payload["items"][0]["status"], json!("published")); - } - - #[test] - fn draft_config_defaults_to_normal_difficulty() { - let config = BarkBattleDraftConfig::default(); - - assert_eq!(config.difficulty_preset, BarkBattleDifficultyPreset::Normal); - } - - #[test] - fn run_requests_carry_config_and_ruleset_identity() { - let start = BarkBattleRunStartRequest { - work_id: "work-1".to_string(), - config_version: Some(3), - source_route: Some("gallery".to_string()), - client_runtime_version: Some("runtime-v1".to_string()), - }; - let start_payload = serde_json::to_value(start).expect("start request should serialize"); - assert_eq!(start_payload["workId"], json!("work-1")); - assert_eq!(start_payload["configVersion"], json!(3)); - assert_eq!(start_payload["sourceRoute"], json!("gallery")); - - let finish = BarkBattleRunFinishRequest { - work_id: "work-1".to_string(), - run_id: "run-1".to_string(), - run_token: "token-1".to_string(), - config_version: 3, - ruleset_version: "bark-battle-ruleset-v1".to_string(), - difficulty_preset: BarkBattleDifficultyPreset::Hard, - client_started_at: "2026-05-13T11:00:00Z".to_string(), - client_finished_at: "2026-05-13T11:00:30Z".to_string(), - duration_ms: 30_000, - derived_metrics: BarkBattleDerivedMetrics { - trigger_count: 12, - max_volume: 0.95, - average_volume: 0.62, - final_energy: 88.5, - combo_max: 7, - }, - client_result: Some(BarkBattleServerResult::PlayerWin), - sample_digest: Some("digest-1".to_string()), - client_runtime_version: None, - }; - let finish_payload = serde_json::to_value(finish).expect("finish request should serialize"); - assert_eq!(finish_payload["configVersion"], json!(3)); - assert_eq!( - finish_payload["rulesetVersion"], - json!("bark-battle-ruleset-v1") - ); - assert_eq!(finish_payload["difficultyPreset"], json!("hard")); - } - - #[test] - fn optional_fields_are_omitted_when_absent() { - let draft = BarkBattleDraftConfig::default(); - let payload = serde_json::to_value(draft).expect("draft should serialize"); - assert!(!payload.as_object().unwrap().contains_key("workId")); - assert!(!payload.as_object().unwrap().contains_key("configVersion")); - assert!(!payload.as_object().unwrap().contains_key("rulesetVersion")); - assert!(!payload.as_object().unwrap().contains_key("description")); - assert!( - !payload - .as_object() - .unwrap() - .contains_key("playerCharacterImageSrc") - ); - assert!( - !payload - .as_object() - .unwrap() - .contains_key("uiBackgroundImageSrc") - ); - - let response = BarkBattlePersonalHistoryResponse { - work_id: None, - difficulty_preset: None, - items: Vec::new(), - best_summary: None, - updated_at: "2026-05-13T11:00:00Z".to_string(), - }; - let payload = serde_json::to_value(response).expect("history response should serialize"); - assert!(!payload.as_object().unwrap().contains_key("workId")); - assert!( - !payload - .as_object() - .unwrap() - .contains_key("difficultyPreset") - ); - assert!(!payload.as_object().unwrap().contains_key("bestSummary")); - } - - #[test] - fn draft_config_serializes_persistent_identity_fields() { - let draft = BarkBattleDraftConfig { - draft_id: "bark-battle-draft-1".to_string(), - work_id: Some("bark-battle-work-1".to_string()), - config_version: Some(2), - ruleset_version: Some("bark-battle-ruleset-v1".to_string()), - title: "汪汪测试杯".to_string(), - description: None, - theme_description: "阳光草坪".to_string(), - player_image_description: "主角".to_string(), - opponent_image_description: "对手".to_string(), - onomatopoeia: None, - player_character_image_src: None, - opponent_character_image_src: None, - ui_background_image_src: None, - difficulty_preset: BarkBattleDifficultyPreset::Normal, - updated_at: "2026-05-14T10:00:00.000Z".to_string(), - }; - - let payload = serde_json::to_value(draft).expect("draft should serialize"); - - assert_eq!(payload["draftId"], json!("bark-battle-draft-1")); - assert_eq!(payload["workId"], json!("bark-battle-work-1")); - assert_eq!(payload["configVersion"], json!(2)); - assert_eq!(payload["rulesetVersion"], json!("bark-battle-ruleset-v1")); - } - - #[test] - fn draft_config_update_request_serializes_generated_assets() { - let update = BarkBattleDraftConfigUpdateRequest { - draft_id: "bark-battle-draft-1".to_string(), - work_id: Some("BB-12345678".to_string()), - config_version: Some(2), - ruleset_version: Some("bark-battle-ruleset-v1".to_string()), - title: "汪汪测试杯".to_string(), - description: None, - theme_description: "阳光草坪".to_string(), - player_image_description: "主角".to_string(), - opponent_image_description: "对手".to_string(), - onomatopoeia: Some(vec!["炸场!".to_string(), "破阵!".to_string()]), - player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), - opponent_character_image_src: Some("/generated-bark-battle/opponent.png".to_string()), - ui_background_image_src: Some("/generated-bark-battle/background.png".to_string()), - difficulty_preset: BarkBattleDifficultyPreset::Normal, - }; - - let payload = serde_json::to_value(update).expect("draft update should serialize"); - - assert_eq!(payload["draftId"], json!("bark-battle-draft-1")); - assert_eq!(payload["workId"], json!("BB-12345678")); - assert_eq!( - payload["playerCharacterImageSrc"], - json!("/generated-bark-battle/player.png") - ); - assert_eq!( - payload["opponentCharacterImageSrc"], - json!("/generated-bark-battle/opponent.png") - ); - assert_eq!( - payload["uiBackgroundImageSrc"], - json!("/generated-bark-battle/background.png") - ); - assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); - assert!( - !payload - .as_object() - .unwrap() - .contains_key("leaderboardEnabled") - ); - } - - #[test] - fn image_generation_request_uses_dedicated_asset_slot_and_result_prompt() { - let request = BarkBattleImageAssetGenerateRequest { - slot: BarkBattleAssetSlot::OpponentCharacter, - draft_id: Some("bark-battle-draft-1".to_string()), - billing_purpose: None, - config: BarkBattleConfigEditorPayload { - title: "汪汪冠军杯".to_string(), - description: Some(String::new()), - theme_description: "霓虹公园擂台".to_string(), - player_image_description: "红围巾柴犬".to_string(), - opponent_image_description: "蓝头带哈士奇".to_string(), - onomatopoeia: Some(vec!["轰汪!".to_string(), "炸场!".to_string()]), - player_character_image_src: None, - opponent_character_image_src: None, - ui_background_image_src: None, - difficulty_preset: BarkBattleDifficultyPreset::Normal, - }, - }; - let payload = serde_json::to_value(request).expect("request should serialize"); - - assert_eq!(payload["slot"], json!("opponent-character")); - assert_eq!( - payload["config"]["opponentImageDescription"], - json!("蓝头带哈士奇") - ); - - let response = BarkBattleGeneratedImageAsset { - image_src: "/generated-bark-battle-assets/draft/opponent/image.webp".to_string(), - asset_id: "asset-1".to_string(), - source_type: Some("generated".to_string()), - model: "gpt-image-2".to_string(), - size: "1024*1024".to_string(), - task_id: "task-1".to_string(), - prompt: "后端拼装后的对手形象 prompt".to_string(), - actual_prompt: None, - }; - let payload = serde_json::to_value(response).expect("response should serialize"); - - assert_eq!( - payload["imageSrc"], - json!("/generated-bark-battle-assets/draft/opponent/image.webp") - ); - assert_eq!(payload["prompt"], json!("后端拼装后的对手形象 prompt")); - } - - #[test] - fn replacement_sources_serialize_as_camel_case_config_fields() { - let config = BarkBattleConfigEditorPayload { - title: "周末狗狗杯".to_string(), - description: Some("轻配置草稿".to_string()), - theme_description: "霓虹公园".to_string(), - player_image_description: "柴犬主角".to_string(), - opponent_image_description: "哈士奇对手".to_string(), - onomatopoeia: Some(vec!["轰汪!".to_string(), "冲啊!".to_string()]), - player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), - opponent_character_image_src: Some("https://example.test/opponent.png".to_string()), - ui_background_image_src: Some("/generated-bark-battle/ui.png".to_string()), - difficulty_preset: BarkBattleDifficultyPreset::Hard, - }; - - let payload = serde_json::to_value(config).expect("config should serialize"); - - assert_eq!( - payload["playerCharacterImageSrc"], - json!("/generated-bark-battle/player.png") - ); - assert_eq!( - payload["opponentCharacterImageSrc"], - json!("https://example.test/opponent.png") - ); - assert_eq!( - payload["uiBackgroundImageSrc"], - json!("/generated-bark-battle/ui.png") - ); - assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); - } - - #[test] - fn finish_response_serializes_player_win_and_accepted() { - let response = BarkBattleRunFinishResponse { - status: BarkBattleFinishStatus::Accepted, - run_id: "run-1".to_string(), - work_id: "work-1".to_string(), - config_version: 3, - ruleset_version: "bark-battle-ruleset-v1".to_string(), - difficulty_preset: BarkBattleDifficultyPreset::Normal, - server_result: BarkBattleServerResult::PlayerWin, - score_summary: BarkBattleScoreSummary { - duration_ms: 30_000, - trigger_count: 12, - max_volume: 0.95, - average_volume: 0.62, - final_energy: 88.5, - combo_max: 7, - }, - leaderboard_score: Some(98_765), - anti_cheat_flags: Vec::new(), - updated_at: "2026-05-13T11:00:00Z".to_string(), - }; - - let payload = serde_json::to_value(response).expect("finish response should serialize"); - - assert_eq!(payload["runId"], json!("run-1")); - assert_eq!(payload["status"], json!("accepted")); - assert_eq!(payload["serverResult"], json!("player_win")); - assert_eq!(payload["leaderboardScore"], json!(98_765)); - assert_eq!(payload["scoreSummary"]["finalEnergy"], json!(88.5)); - } - - #[test] - fn work_stats_fields_are_constructible() { - let stats = BarkBattleWorkStats { - work_id: "work-1".to_string(), - config_version: Some(3), - ruleset_version: "bark-battle-ruleset-v1".to_string(), - difficulty_preset: BarkBattleDifficultyPreset::Normal, - play_start_count: 10, - finish_count: 9, - win_count: 5, - draw_count: 2, - loss_count: 2, - flagged_count: 1, - leaderboard_entry_count: 4, - best_leaderboard_score: Some(98_765), - best_final_energy: Some(97.5), - average_final_energy: Some(73.25), - updated_at: "2026-05-13T11:00:00Z".to_string(), - }; - - assert_eq!(stats.work_id, "work-1"); - assert_eq!(stats.play_start_count, 10); - assert_eq!(stats.finish_count, 9); - assert_eq!(stats.win_count, 5); - assert_eq!(stats.draw_count, 2); - assert_eq!(stats.loss_count, 2); - assert_eq!(stats.flagged_count, 1); - assert_eq!(stats.leaderboard_entry_count, 4); - assert_eq!(stats.best_leaderboard_score, Some(98_765)); - assert_eq!(stats.best_final_energy, Some(97.5)); - assert_eq!(stats.average_final_energy, Some(73.25)); - assert_eq!(stats.updated_at, "2026-05-13T11:00:00Z"); - } -} diff --git a/server-rs/crates/shared-contracts/src/big_fish.rs b/server-rs/crates/shared-contracts/src/big_fish.rs deleted file mode 100644 index ea8f2a43b..000000000 --- a/server-rs/crates/shared-contracts/src/big_fish.rs +++ /dev/null @@ -1,261 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreateBigFishSessionRequest { - #[serde(default)] - pub seed_text: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SendBigFishMessageRequest { - pub client_message_id: String, - pub text: String, - #[serde(default)] - pub quick_fill_requested: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ExecuteBigFishActionRequest { - pub action: String, - #[serde(default)] - pub level: Option, - #[serde(default)] - pub motion_key: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RecordBigFishPlayRequest { - #[serde(default)] - pub elapsed_ms: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SubmitBigFishInputRequest { - pub x: f32, - pub y: f32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishAnchorItemResponse { - pub key: String, - pub label: String, - pub value: String, - pub status: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishAnchorPackResponse { - pub gameplay_promise: BigFishAnchorItemResponse, - pub ecology_visual_theme: BigFishAnchorItemResponse, - pub growth_ladder: BigFishAnchorItemResponse, - pub risk_tempo: BigFishAnchorItemResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishLevelBlueprintResponse { - pub level: u32, - pub name: String, - pub one_line_fantasy: String, - pub text_description: String, - pub silhouette_direction: String, - pub size_ratio: f32, - pub visual_description: String, - pub visual_prompt_seed: String, - pub idle_motion_description: String, - pub move_motion_description: String, - pub motion_prompt_seed: String, - pub merge_source_level: Option, - pub prey_window: Vec, - pub threat_window: Vec, - pub is_final_level: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishBackgroundBlueprintResponse { - pub theme: String, - pub color_mood: String, - pub foreground_hints: String, - pub midground_composition: String, - pub background_depth: String, - pub safe_play_area_hint: String, - pub spawn_edge_hint: String, - pub background_prompt_seed: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishRuntimeParamsResponse { - pub level_count: u32, - pub merge_count_per_upgrade: u32, - pub spawn_target_count: u32, - pub leader_move_speed: f32, - pub follower_catch_up_speed: f32, - pub offscreen_cull_seconds: f32, - pub prey_spawn_delta_levels: Vec, - pub threat_spawn_delta_levels: Vec, - pub win_level: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishGameDraftResponse { - pub title: String, - pub subtitle: String, - pub core_fun: String, - pub ecology_theme: String, - pub levels: Vec, - pub background: BigFishBackgroundBlueprintResponse, - pub runtime_params: BigFishRuntimeParamsResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishAgentMessageResponse { - pub id: String, - pub role: String, - pub kind: String, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishAssetSlotResponse { - pub slot_id: String, - pub asset_kind: String, - pub level: Option, - pub motion_key: Option, - pub status: String, - pub asset_url: Option, - pub prompt_snapshot: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishAssetCoverageResponse { - pub level_main_image_ready_count: u32, - pub level_motion_ready_count: u32, - pub background_ready: bool, - pub required_level_count: u32, - pub publish_ready: bool, - pub blockers: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishSessionSnapshotResponse { - pub session_id: String, - pub current_turn: u32, - pub progress_percent: u32, - pub stage: String, - pub anchor_pack: BigFishAnchorPackResponse, - pub draft: Option, - pub asset_slots: Vec, - pub asset_coverage: BigFishAssetCoverageResponse, - pub messages: Vec, - pub last_assistant_reply: Option, - pub publish_ready: bool, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishSessionResponse { - pub session: BigFishSessionSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishActionResponse { - pub session: BigFishSessionSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishVector2Response { - pub x: f32, - pub y: f32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishRuntimeEntityResponse { - pub entity_id: String, - pub level: u32, - pub position: BigFishVector2Response, - pub radius: f32, - pub offscreen_seconds: f32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishRuntimeSnapshotResponse { - pub run_id: String, - pub session_id: String, - pub status: String, - pub tick: u64, - pub player_level: u32, - pub win_level: u32, - pub leader_entity_id: Option, - pub owned_entities: Vec, - pub wild_entities: Vec, - pub camera_center: BigFishVector2Response, - pub last_input: BigFishVector2Response, - pub event_log: Vec, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishRunResponse { - pub run: BigFishRuntimeSnapshotResponse, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn big_fish_session_request_uses_camel_case() { - let payload = serde_json::to_value(CreateBigFishSessionRequest { - seed_text: Some("深海机械鱼".to_string()), - }) - .expect("payload should serialize"); - - assert_eq!(payload, json!({ "seedText": "深海机械鱼" })); - } - - #[test] - fn big_fish_action_request_uses_camel_case() { - let payload = serde_json::to_value(ExecuteBigFishActionRequest { - action: "big_fish_generate_level_motion".to_string(), - level: Some(3), - motion_key: Some("move_swim".to_string()), - }) - .expect("payload should serialize"); - - assert_eq!(payload["motionKey"], json!("move_swim")); - assert_eq!(payload["level"], json!(3)); - } - - #[test] - fn record_big_fish_play_request_uses_camel_case() { - let payload = serde_json::to_value(RecordBigFishPlayRequest { - elapsed_ms: Some(12_345), - }) - .expect("payload should serialize"); - - assert_eq!(payload, json!({ "elapsedMs": 12_345 })); - } -} diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs deleted file mode 100644 index 255c87ed7..000000000 --- a/server-rs/crates/shared-contracts/src/big_fish_works.rs +++ /dev/null @@ -1,38 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishWorkSummaryResponse { - pub work_id: String, - pub source_session_id: String, - pub owner_user_id: String, - pub author_display_name: String, - pub title: String, - pub subtitle: String, - pub summary: String, - #[serde(default)] - pub cover_image_src: Option, - pub status: String, - pub updated_at: String, - #[serde(default)] - pub published_at: Option, - pub publish_ready: bool, - pub level_count: u32, - pub level_main_image_ready_count: u32, - pub level_motion_ready_count: u32, - pub background_ready: bool, - #[serde(default)] - pub play_count: u32, - #[serde(default)] - pub remix_count: u32, - #[serde(default)] - pub like_count: u32, - #[serde(default)] - pub recent_play_count_7d: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BigFishWorksResponse { - pub items: Vec, -} diff --git a/server-rs/crates/shared-contracts/src/creation_agent_document_input.rs b/server-rs/crates/shared-contracts/src/creation_agent_document_input.rs deleted file mode 100644 index 34dba0902..000000000 --- a/server-rs/crates/shared-contracts/src/creation_agent_document_input.rs +++ /dev/null @@ -1,28 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ParseCreationAgentDocumentInputRequest { - pub file_name: String, - #[serde(default)] - pub content_type: Option, - pub content_base64: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CreationAgentDocumentInputPayload { - pub file_name: String, - #[serde(default)] - pub content_type: Option, - pub size_bytes: usize, - pub text: String, - #[serde(default)] - pub source_asset_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ParseCreationAgentDocumentInputResponse { - pub document: CreationAgentDocumentInputPayload, -} diff --git a/server-rs/crates/shared-contracts/src/creation_entry_config.rs b/server-rs/crates/shared-contracts/src/creation_entry_config.rs deleted file mode 100644 index b3d0e91d9..000000000 --- a/server-rs/crates/shared-contracts/src/creation_entry_config.rs +++ /dev/null @@ -1,585 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; - -/// 前台创作入口配置响应。 -/// -/// `event_banner` 保留单条旧契约兼容;新创作入口公告位应优先读取 -/// `event_banners`,由后台表单配置多条公告并支持轮播。 -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CreationEntryConfigResponse { - pub start_card: CreationEntryStartCardResponse, - pub type_modal: CreationEntryTypeModalResponse, - pub event_banner: CreationEntryEventBannerResponse, - pub event_banners: Vec, - pub public_work_interactions: Vec, - pub creation_types: Vec, -} - -/// 创作入口起始卡片文案契约,保留给旧入口卡片兼容使用。 -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CreationEntryStartCardResponse { - pub title: String, - pub description: String, - pub idle_badge: String, - pub busy_badge: String, -} - -/// 创作类型选择弹层的基础文案契约。 -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CreationEntryTypeModalResponse { - pub title: String, - pub description: String, -} - -/// 创作入口单条公告。 -/// -/// `html_code` 是后台公告代码的主格式,只允许以前端沙箱 iframe 展示; -/// 结构化字段仅保留旧数据兼容,不能作为可执行 JSX 或非受控 DOM 注入。 -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[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, - #[serde(default = "default_creation_entry_event_banner_render_mode")] - pub render_mode: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub html_code: Option, -} - -/// 默认渲染模式使用受控结构化 UI,用于旧数据兼容。 -pub fn default_creation_entry_event_banner_render_mode() -> String { - "structured".to_string() -} - -/// 单类公开作品互动能力配置。 -/// -/// 后台可以关闭已接入的点赞 / 改造能力;未接入后端动作的玩法即使误开, -/// 前端仍会按实际能力矩阵返回不可用提示。 -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PublicWorkInteractionConfigResponse { - pub source_type: String, - pub like_enabled: bool, - pub remix_enabled: bool, - pub like_disabled_message: String, - pub remix_disabled_message: String, -} - -/// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。 -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CreationEntryTypeResponse { - pub id: String, - pub title: String, - pub subtitle: String, - pub badge: String, - pub image_src: String, - 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, - #[serde(skip_serializing_if = "Option::is_none")] - pub unified_creation_spec: Option, -} - -/// 统一创作工作台契约,把玩法入口连接到工作台、生成页和结果页阶段。 -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UnifiedCreationSpecResponse { - pub play_id: String, - pub title: String, - #[serde( - default = "default_unified_creation_mud_point_cost", - alias = "mudPointCostText", - deserialize_with = "deserialize_unified_creation_mud_point_cost" - )] - pub mud_point_cost: u32, - pub workspace_stage: String, - pub generation_stage: String, - pub result_stage: String, - pub fields: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UnifiedCreationFieldResponse { - pub id: String, - pub kind: String, - pub label: String, - pub required: bool, -} - -pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"]; -pub const DEFAULT_UNIFIED_CREATION_MUD_POINT_COST: u32 = 10; - -pub fn default_unified_creation_mud_point_cost() -> u32 { - DEFAULT_UNIFIED_CREATION_MUD_POINT_COST -} - -pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option { - let (workspace_stage, generation_stage, result_stage, fields) = match play_id { - "rpg" => ( - "agent-workspace", - "custom-world-generating", - "custom-world-result", - vec![unified_creation_field("message", "text", "创作想法", true)], - ), - "big-fish" => ( - "big-fish-agent-workspace", - "big-fish-generating", - "big-fish-result", - vec![unified_creation_field("message", "text", "玩法想法", true)], - ), - "puzzle" => ( - "puzzle-agent-workspace", - "puzzle-generating", - "puzzle-result", - vec![ - unified_creation_field("pictureDescription", "text", "画面描述", true), - unified_creation_field("referenceImage", "image", "拼图画面", false), - unified_creation_field("promptReferenceImages", "image", "参考图", false), - ], - ), - "match3d" => ( - "match3d-agent-workspace", - "match3d-generating", - "match3d-result", - vec![ - unified_creation_field("themeText", "text", "题材", true), - unified_creation_field("difficulty", "select", "难度", true), - ], - ), - "jump-hop" => ( - "jump-hop-workspace", - "jump-hop-generating", - "jump-hop-result", - vec![unified_creation_field("themeText", "text", "主题", true)], - ), - "wooden-fish" => ( - "wooden-fish-workspace", - "wooden-fish-generating", - "wooden-fish-result", - vec![ - unified_creation_field("hitObjectPrompt", "text", "敲什么", false), - unified_creation_field("hitObjectReferenceImage", "image", "参考图", false), - unified_creation_field("hitSoundAsset", "audio", "敲击音效", false), - unified_creation_field("floatingWords", "text", "功德有什么", true), - ], - ), - "square-hole" => ( - "square-hole-agent-workspace", - "square-hole-generating", - "square-hole-result", - vec![unified_creation_field("message", "text", "玩法想法", true)], - ), - "bark-battle" => ( - "bark-battle-workspace", - "bark-battle-generating", - "bark-battle-result", - vec![ - unified_creation_field("title", "text", "作品标题", true), - unified_creation_field("themeDescription", "text", "主题/场景描述", true), - unified_creation_field("playerImageDescription", "text", "玩家形象描述", true), - unified_creation_field("opponentImageDescription", "text", "对手形象描述", true), - unified_creation_field("onomatopoeia", "text", "拟声词", false), - unified_creation_field("difficultyPreset", "select", "难度", true), - ], - ), - "visual-novel" => ( - "visual-novel-agent-workspace", - "visual-novel-generating", - "visual-novel-result", - vec![ - unified_creation_field("ideaText", "text", "一句话创作", true), - unified_creation_field("visualStyleId", "select", "视觉画风", true), - ], - ), - "baby-object-match" => ( - "baby-object-match-workspace", - "baby-object-match-generating", - "baby-object-match-result", - vec![ - unified_creation_field("itemAName", "text", "物品 A", true), - unified_creation_field("itemBName", "text", "物品 B", true), - ], - ), - "creative-agent" => ( - "creative-agent-workspace", - "puzzle-generating", - "puzzle-result", - vec![ - unified_creation_field("message", "text", "创作想法", true), - unified_creation_field("referenceImage", "image", "参考图", false), - ], - ), - _ => return None, - }; - - Some(UnifiedCreationSpecResponse { - play_id: play_id.to_string(), - title: default_unified_creation_title(play_id)?.to_string(), - mud_point_cost: default_unified_creation_mud_point_cost(), - workspace_stage: workspace_stage.to_string(), - generation_stage: generation_stage.to_string(), - result_stage: result_stage.to_string(), - fields, - }) -} - -pub fn default_unified_creation_title(play_id: &str) -> Option<&'static str> { - match play_id { - "rpg" => Some("文字冒险"), - "big-fish" => Some("摸鱼"), - "puzzle" => Some("拼图"), - "match3d" => Some("抓大鹅"), - "jump-hop" => Some("跳一跳"), - "wooden-fish" => Some("敲木鱼"), - "square-hole" => Some("方洞"), - "bark-battle" => Some("汪汪声浪"), - "visual-novel" => Some("视觉小说"), - "baby-object-match" => Some("宝贝识物"), - "creative-agent" => Some("智能体创作"), - _ => None, - } -} - -pub fn validate_unified_creation_spec_response( - spec: &UnifiedCreationSpecResponse, -) -> Result<(), String> { - if spec.play_id.trim().is_empty() { - return Err("统一创作契约 playId 不能为空".to_string()); - } - if spec.title.trim().is_empty() { - return Err("统一创作契约标题不能为空".to_string()); - } - if spec.mud_point_cost == 0 { - return Err("统一创作契约泥点消耗数量必须大于 0".to_string()); - } - - let workspace_stage = spec.workspace_stage.trim(); - let generation_stage = spec.generation_stage.trim(); - let result_stage = spec.result_stage.trim(); - if workspace_stage.is_empty() || generation_stage.is_empty() || result_stage.is_empty() { - return Err("统一创作契约阶段不能为空".to_string()); - } - if workspace_stage == generation_stage - || workspace_stage == result_stage - || generation_stage == result_stage - { - return Err("统一创作契约阶段不能重复".to_string()); - } - if spec.fields.is_empty() { - return Err("统一创作契约 fields 不能为空".to_string()); - } - - let mut field_ids = BTreeSet::new(); - for field in &spec.fields { - let field_id = field.id.trim(); - if field_id.is_empty() { - return Err("统一创作契约字段 id 不能为空".to_string()); - } - if !field_ids.insert(field_id.to_string()) { - return Err(format!("统一创作契约字段 id 重复:{field_id}")); - } - if field.label.trim().is_empty() { - return Err(format!("统一创作契约字段 {field_id} 标签不能为空")); - } - if !UNIFIED_CREATION_FIELD_KINDS.contains(&field.kind.trim()) { - return Err(format!( - "统一创作契约字段 {field_id} kind 非法:{}", - field.kind - )); - } - } - - Ok(()) -} - -pub fn validate_unified_creation_spec_for_play( - play_id: &str, - spec: &UnifiedCreationSpecResponse, -) -> Result<(), String> { - if spec.play_id.trim() != play_id.trim() { - return Err(format!( - "统一创作契约 playId 必须与入口 ID 一致:{}", - play_id.trim() - )); - } - - validate_unified_creation_spec_response(spec) -} - -pub fn encode_unified_creation_spec_response( - spec: &UnifiedCreationSpecResponse, -) -> Result { - validate_unified_creation_spec_response(spec)?; - serde_json::to_string(spec).map_err(|error| format!("统一创作契约序列化失败:{error}")) -} - -pub fn decode_unified_creation_spec_response( - value: &str, -) -> Result { - let spec = serde_json::from_str::(value) - .map_err(|error| format!("统一创作契约 JSON 非法:{error}"))?; - validate_unified_creation_spec_response(&spec)?; - Ok(spec) -} - -fn deserialize_unified_creation_mud_point_cost<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let value = serde_json::Value::deserialize(deserializer)?; - match value { - serde_json::Value::Number(number) => number - .as_u64() - .and_then(|raw| u32::try_from(raw).ok()) - .ok_or_else(|| serde::de::Error::custom("泥点消耗数量必须是非负整数")), - serde_json::Value::String(text) => parse_unified_creation_mud_point_cost_text(&text) - .ok_or_else(|| serde::de::Error::custom("泥点消耗数量必须是数字")), - _ => Err(serde::de::Error::custom("泥点消耗数量必须是数字")), - } -} - -fn parse_unified_creation_mud_point_cost_text(value: &str) -> Option { - let digits: String = value - .chars() - .skip_while(|character| !character.is_ascii_digit()) - .take_while(|character| character.is_ascii_digit()) - .collect(); - digits.parse::().ok() -} - -pub fn resolve_unified_creation_spec_response( - play_id: &str, - value: Option<&str>, -) -> Option { - match value { - Some(raw) => decode_unified_creation_spec_response(raw).ok(), - None => build_phase1_unified_creation_spec(play_id), - } -} - -fn unified_creation_field( - id: &str, - kind: &str, - label: &str, - required: bool, -) -> UnifiedCreationFieldResponse { - UnifiedCreationFieldResponse { - id: id.to_string(), - kind: kind.to_string(), - label: label.to_string(), - required, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn phase1_unified_creation_specs_cover_existing_templates() { - let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec"); - assert_eq!(puzzle.title, "拼图"); - assert_eq!(puzzle.mud_point_cost, 10); - assert_eq!(puzzle.fields[0].id, "pictureDescription"); - assert_eq!(puzzle.fields[1].kind, "image"); - - let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec"); - assert_eq!(match3d.title, "抓大鹅"); - assert_eq!( - match3d - .fields - .iter() - .filter(|field| field.kind == "select") - .count(), - 1 - ); - - let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec"); - assert_eq!(jump_hop.title, "跳一跳"); - assert_eq!(jump_hop.fields.len(), 1); - assert_eq!(jump_hop.fields[0].id, "themeText"); - - let wooden_fish = - build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec"); - assert!(wooden_fish.fields.iter().any(|field| field.kind == "audio")); - - let visual_novel = - build_phase1_unified_creation_spec("visual-novel").expect("visual-novel spec"); - assert_eq!(visual_novel.workspace_stage, "visual-novel-agent-workspace"); - - let bark_battle = - build_phase1_unified_creation_spec("bark-battle").expect("bark-battle spec"); - assert_eq!(bark_battle.generation_stage, "bark-battle-generating"); - - let baby_object_match = build_phase1_unified_creation_spec("baby-object-match") - .expect("baby-object-match spec"); - assert_eq!( - baby_object_match - .fields - .iter() - .filter(|field| field.kind == "text") - .count(), - 2 - ); - } - - #[test] - fn unified_creation_spec_title_uses_contract_content() { - let raw = r#"{ - "playId": "puzzle", - "title": "想做个什么玩法?", - "mudPointCost": 12, - "workspaceStage": "puzzle-agent-workspace", - "generationStage": "puzzle-generating", - "resultStage": "puzzle-result", - "fields": [ - { - "id": "pictureDescription", - "kind": "text", - "label": "画面描述", - "required": true - } - ] - }"#; - - let spec = - resolve_unified_creation_spec_response("puzzle", Some(raw)).expect("puzzle spec"); - - assert_eq!(spec.title, "想做个什么玩法?"); - assert_eq!(spec.mud_point_cost, 12); - } - - #[test] - fn unified_creation_spec_reads_legacy_mud_point_cost_text() { - let raw = r#"{ - "playId": "puzzle", - "title": "想做个什么玩法?", - "mudPointCostText": "12泥点数", - "workspaceStage": "puzzle-agent-workspace", - "generationStage": "puzzle-generating", - "resultStage": "puzzle-result", - "fields": [ - { - "id": "pictureDescription", - "kind": "text", - "label": "画面描述", - "required": true - } - ] - }"#; - - let spec = - resolve_unified_creation_spec_response("puzzle", Some(raw)).expect("puzzle spec"); - - assert_eq!(spec.mud_point_cost, 12); - } - - #[test] - fn unified_creation_spec_uses_default_mud_point_cost_for_legacy_json() { - let raw = r#"{ - "playId": "puzzle", - "title": "拼图", - "workspaceStage": "puzzle-agent-workspace", - "generationStage": "puzzle-generating", - "resultStage": "puzzle-result", - "fields": [ - { - "id": "pictureDescription", - "kind": "text", - "label": "画面描述", - "required": true - } - ] - }"#; - - let spec = - resolve_unified_creation_spec_response("puzzle", Some(raw)).expect("puzzle spec"); - - assert_eq!(spec.mud_point_cost, 10); - } - - #[test] - fn creation_entry_event_banner_defaults_to_structured_render_mode() { - let banner = serde_json::from_str::( - r#"{ - "title": "旧版横幅", - "description": "兼容旧字段", - "coverImageSrc": "/creation-type-references/puzzle.webp", - "prizePoolMudPoints": 1000, - "startsAtText": "2026-06-01", - "endsAtText": "2026-06-30" - }"#, - ) - .expect("legacy banner json should decode"); - - assert_eq!(banner.render_mode, "structured"); - assert!(banner.html_code.is_none()); - } - - #[test] - fn creation_entry_config_serializes_event_banners_contract() { - let response = CreationEntryConfigResponse { - start_card: CreationEntryStartCardResponse { - title: "新建作品".to_string(), - description: "选择模板".to_string(), - idle_badge: "模板".to_string(), - busy_badge: "开启中".to_string(), - }, - type_modal: CreationEntryTypeModalResponse { - title: "选择创作类型".to_string(), - description: "先选玩法".to_string(), - }, - event_banner: CreationEntryEventBannerResponse { - title: "第一条".to_string(), - description: "兼容单条".to_string(), - cover_image_src: "/creation-type-references/puzzle.webp".to_string(), - prize_pool_mud_points: 1000, - starts_at_text: "2026-06-01".to_string(), - ends_at_text: "2026-06-30".to_string(), - render_mode: "structured".to_string(), - html_code: None, - }, - event_banners: vec![CreationEntryEventBannerResponse { - title: "HTML 条".to_string(), - description: "沙箱".to_string(), - cover_image_src: "/creation-type-references/match3d.webp".to_string(), - prize_pool_mud_points: 800, - starts_at_text: "2026-07-01".to_string(), - ends_at_text: "2026-07-31".to_string(), - render_mode: "html".to_string(), - html_code: Some("
ok
".to_string()), - }], - public_work_interactions: vec![PublicWorkInteractionConfigResponse { - source_type: "puzzle".to_string(), - like_enabled: true, - remix_enabled: true, - like_disabled_message: "拼图点赞暂不可用。".to_string(), - remix_disabled_message: "拼图作品改造暂不可用。".to_string(), - }], - creation_types: Vec::new(), - }; - let value = serde_json::to_value(response).expect("response should serialize"); - - assert_eq!(value["eventBanners"][0]["renderMode"], "html"); - assert_eq!( - value["eventBanners"][0]["htmlCode"], - "
ok
" - ); - assert!(value.get("event_banner").is_none()); - assert!(value.get("eventBanner").is_some()); - assert_eq!(value["publicWorkInteractions"][0]["sourceType"], "puzzle"); - } -} diff --git a/server-rs/crates/shared-contracts/src/creative_agent.rs b/server-rs/crates/shared-contracts/src/creative_agent.rs deleted file mode 100644 index 87976d366..000000000 --- a/server-rs/crates/shared-contracts/src/creative_agent.rs +++ /dev/null @@ -1,562 +0,0 @@ -use crate::puzzle_creative_template::{ - PuzzleCreativeTemplateProtocol, PuzzleCreativeTemplateSelection, PuzzleDraftFieldPatch, - PuzzleImageGenerationPlan, PuzzleTemplateCostRange, -}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum CreativeAgentStage { - Idle, - Perceiving, - Thinking, - Remembering, - SelectingPuzzleTemplate, - WaitingTemplateConfirmation, - PlanningPuzzleLevels, - Acting, - Reflecting, - Collaborating, - TargetReady, - WaitingUser, - Failed, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum CreativeAgentEntryContext { - CreationHome, - PuzzleWorkspace, - GalleryRemix, - DraftRestore, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum CreativeAgentMessageRole { - User, - Assistant, - System, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum CreativeAgentMessageKind { - Chat, - Stage, - ActionResult, - Warning, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum CreativeAgentInputPartType { - InputText, - InputImage, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentInputPart { - #[serde(rename = "type")] - pub part_type: CreativeAgentInputPartType, - #[serde(default)] - pub text: Option, - #[serde(default)] - pub image_url: Option, - #[serde(default)] - pub asset_id: Option, - #[serde(default)] - pub thumbnail_url: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeImageInput { - pub asset_id: String, - pub read_url: String, - #[serde(default)] - pub thumbnail_url: Option, - #[serde(default)] - pub width: Option, - #[serde(default)] - pub height: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeImageSummary { - #[serde(default)] - pub asset_id: Option, - #[serde(default)] - pub read_url: Option, - #[serde(default)] - pub thumbnail_url: Option, - #[serde(default)] - pub width: Option, - #[serde(default)] - pub height: Option, - #[serde(default)] - pub summary: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum CreativeUnsupportedPlayType { - Rpg, - #[serde(rename = "match3d")] - Match3d, - BigFish, - SquareHole, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum CreativeCapabilityStatus { - Unsupported, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeUnsupportedCapability { - pub play_type: CreativeUnsupportedPlayType, - pub title: String, - pub status: CreativeCapabilityStatus, - pub reason: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeInputSummary { - #[serde(default)] - pub text: Option, - pub entry_context: CreativeAgentEntryContext, - pub images: Vec, - #[serde(default)] - pub material_summary: Option, - pub unsupported_capabilities: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentMessage { - pub id: String, - pub role: CreativeAgentMessageRole, - pub kind: CreativeAgentMessageKind, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum CreativeTargetPlayType { - Puzzle, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -pub enum CreativeTargetStage { - PuzzleAgentWorkspace, - PuzzleResult, - PuzzleRuntime, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeTargetSessionBinding { - pub play_type: CreativeTargetPlayType, - pub target_session_id: String, - pub target_stage: CreativeTargetStage, - #[serde(default)] - pub result_profile_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentSessionSnapshot { - pub session_id: String, - pub stage: CreativeAgentStage, - pub input_summary: CreativeInputSummary, - pub messages: Vec, - #[serde(default)] - pub puzzle_template_catalog: Vec, - #[serde(default)] - pub puzzle_template_selection: Option, - #[serde(default)] - pub puzzle_image_generation_plan: Option, - #[serde(default)] - pub target_binding: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreateCreativeAgentSessionRequest { - #[serde(default)] - pub text: Option, - #[serde(default)] - pub images: Vec, - #[serde(default)] - pub entry_context: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentSessionResponse { - pub session: CreativeAgentSessionSnapshot, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StreamCreativeAgentMessageRequest { - pub client_message_id: String, - pub content: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ConfirmCreativePuzzleTemplateRequest { - pub selection: PuzzleCreativeTemplateSelection, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeDraftEditStreamRequest { - pub client_message_id: String, - pub instruction: String, - pub target_puzzle_session_id: String, - pub current_draft: serde_json::Value, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeDraftEditResult { - pub edit_instructions: Vec, - pub session: CreativeAgentSessionSnapshot, - pub puzzle_session: serde_json::Value, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum CreativeAgentSseEventType { - Stage, - AgentMessageDelta, - ThoughtSummaryDelta, - PuzzleTemplateCatalog, - PuzzleTemplateSelection, - PuzzleCostRange, - PuzzleLevelPlan, - ToolStarted, - ToolCompleted, - Reflection, - TargetSession, - Session, - Error, - Done, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentSseEnvelope { - pub event: CreativeAgentSseEventType, - pub data: serde_json::Value, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentStageEvent { - pub session_id: String, - pub stage: CreativeAgentStage, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentMessageDeltaEvent { - pub session_id: String, - pub message_id: String, - pub role: CreativeAgentMessageRole, - pub kind: CreativeAgentMessageKind, - pub text_delta: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentThoughtSummaryDeltaEvent { - pub session_id: String, - pub thought_id: String, - pub text_delta: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentTemplateCatalogEvent { - pub session_id: String, - pub templates: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentTemplateSelectionEvent { - pub session_id: String, - pub selection: PuzzleCreativeTemplateSelection, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentCostRangeEvent { - pub session_id: String, - pub cost_range: PuzzleTemplateCostRange, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentLevelPlanEvent { - pub session_id: String, - pub plan: PuzzleImageGenerationPlan, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentToolEvent { - pub session_id: String, - pub tool_call_id: String, - pub tool_name: String, - #[serde(default)] - pub summary: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentReflectionEvent { - pub session_id: String, - pub pass: bool, - pub summary: String, - pub warnings: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentTargetSessionEvent { - pub session_id: String, - pub binding: CreativeTargetSessionBinding, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentErrorEvent { - #[serde(default)] - pub session_id: Option, - pub code: String, - pub message: String, - pub recoverable: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativeAgentDoneEvent { - pub session_id: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::puzzle_creative_template::{ - PuzzleCreativeTemplateProtocol, PuzzleDraftEditableFieldPath, PuzzleLevelGenerationMode, - PuzzleSupportedLevelMode, PuzzleTemplateImageGenerationPolicy, PuzzleTemplatePricingUnit, - }; - use serde_json::json; - - fn cost_range() -> PuzzleTemplateCostRange { - PuzzleTemplateCostRange { - min_points: 2, - max_points: 12, - pricing_unit: PuzzleTemplatePricingUnit::Point, - reason: "按关卡数和每关图片生成次数估算".to_string(), - } - } - - fn template_selection() -> PuzzleCreativeTemplateSelection { - PuzzleCreativeTemplateSelection { - template_id: "puzzle.default-creative".to_string(), - title: "创意拼图".to_string(), - reason: "素材适合拆成可试玩的拼图关卡".to_string(), - cost_range: cost_range(), - supported_level_mode: PuzzleSupportedLevelMode::SingleOrMulti, - selected_level_mode: PuzzleLevelGenerationMode::SingleLevel, - planned_level_count: 1, - requires_user_confirmation: true, - } - } - - fn template_protocol() -> PuzzleCreativeTemplateProtocol { - PuzzleCreativeTemplateProtocol { - template_id: "puzzle.default-creative".to_string(), - title: "创意拼图".to_string(), - summary: "把图文灵感做成拼图".to_string(), - preview_image_src: None, - supported_level_mode: PuzzleSupportedLevelMode::SingleOrMulti, - min_level_count: 1, - max_level_count: 6, - default_level_count: 1, - cost_range: cost_range(), - required_draft_fields: vec![PuzzleDraftEditableFieldPath::WorkTitle], - image_policy: PuzzleTemplateImageGenerationPolicy { - allow_uploaded_image_directly: true, - allow_generated_images: true, - allow_per_level_reference_image: true, - default_candidate_count_per_level: 1, - }, - } - } - - #[test] - fn creative_agent_session_snapshot_uses_camel_case() { - let snapshot = CreativeAgentSessionSnapshot { - session_id: "creative-session-1".to_string(), - stage: CreativeAgentStage::WaitingTemplateConfirmation, - input_summary: CreativeInputSummary { - text: Some("把这张旅行照做成拼图".to_string()), - entry_context: CreativeAgentEntryContext::CreationHome, - images: vec![CreativeImageSummary { - asset_id: Some("asset-1".to_string()), - read_url: Some("https://example.test/image.png".to_string()), - thumbnail_url: None, - width: Some(1024), - height: Some(768), - summary: Some("一张旅行照片".to_string()), - }], - material_summary: Some("旅行纪念素材".to_string()), - unsupported_capabilities: vec![CreativeUnsupportedCapability { - play_type: CreativeUnsupportedPlayType::BigFish, - title: "大鱼吃小鱼".to_string(), - status: CreativeCapabilityStatus::Unsupported, - reason: "Phase 1 只开放拼图模板".to_string(), - }], - }, - messages: vec![CreativeAgentMessage { - id: "message-1".to_string(), - role: CreativeAgentMessageRole::Assistant, - kind: CreativeAgentMessageKind::Chat, - text: "我会先选择拼图模板。".to_string(), - created_at: "2026-05-05T00:00:00Z".to_string(), - }], - puzzle_template_catalog: vec![template_protocol()], - puzzle_template_selection: Some(template_selection()), - puzzle_image_generation_plan: None, - target_binding: Some(CreativeTargetSessionBinding { - play_type: CreativeTargetPlayType::Puzzle, - target_session_id: "puzzle-session-1".to_string(), - target_stage: CreativeTargetStage::PuzzleResult, - result_profile_id: None, - }), - updated_at: "2026-05-05T00:00:01Z".to_string(), - }; - - let payload = serde_json::to_value(&snapshot).expect("snapshot should serialize"); - assert_eq!(payload["sessionId"], json!("creative-session-1")); - assert_eq!(payload["stage"], json!("waiting_template_confirmation")); - assert_eq!( - payload["inputSummary"]["entryContext"], - json!("creation_home") - ); - assert_eq!( - payload["inputSummary"]["unsupportedCapabilities"][0]["playType"], - json!("big_fish") - ); - assert_eq!( - payload["puzzleTemplateCatalog"][0]["templateId"], - json!("puzzle.default-creative") - ); - assert_eq!( - payload["puzzleTemplateSelection"]["selectedLevelMode"], - json!("single_level") - ); - assert_eq!( - payload["targetBinding"]["targetStage"], - json!("puzzle-result") - ); - - let decoded: CreativeAgentSessionSnapshot = - serde_json::from_value(payload).expect("snapshot should deserialize"); - assert_eq!(decoded, snapshot); - } - - #[test] - fn creative_agent_sse_events_serialize_event_names() { - let event = CreativeAgentSseEnvelope { - event: CreativeAgentSseEventType::PuzzleTemplateSelection, - data: serde_json::to_value(CreativeAgentTemplateSelectionEvent { - session_id: "creative-session-1".to_string(), - selection: template_selection(), - }) - .expect("event data should serialize"), - }; - - let payload = serde_json::to_value(event).expect("event should serialize"); - assert_eq!(payload["event"], json!("puzzle_template_selection")); - assert_eq!( - payload["data"]["selection"]["costRange"]["pricingUnit"], - json!("point") - ); - - let thought_event = CreativeAgentSseEnvelope { - event: CreativeAgentSseEventType::ThoughtSummaryDelta, - data: serde_json::to_value(CreativeAgentThoughtSummaryDeltaEvent { - session_id: "creative-session-1".to_string(), - thought_id: "thought-1".to_string(), - text_delta: "正在理解素材".to_string(), - }) - .expect("event data should serialize"), - }; - let thought_payload = serde_json::to_value(thought_event).expect("event should serialize"); - assert_eq!(thought_payload["event"], json!("thought_summary_delta")); - assert_eq!(thought_payload["data"]["thoughtId"], json!("thought-1")); - - let catalog_event = CreativeAgentSseEnvelope { - event: CreativeAgentSseEventType::PuzzleTemplateCatalog, - data: serde_json::to_value(CreativeAgentTemplateCatalogEvent { - session_id: "creative-session-1".to_string(), - templates: vec![template_protocol()], - }) - .expect("event data should serialize"), - }; - let catalog_payload = serde_json::to_value(catalog_event).expect("event should serialize"); - assert_eq!(catalog_payload["event"], json!("puzzle_template_catalog")); - assert_eq!( - catalog_payload["data"]["templates"][0]["templateId"], - json!("puzzle.default-creative") - ); - } - - #[test] - fn creative_agent_multimodal_parts_keep_image_url_camel_case() { - let request = StreamCreativeAgentMessageRequest { - client_message_id: "client-message-1".to_string(), - content: vec![ - CreativeAgentInputPart { - part_type: CreativeAgentInputPartType::InputText, - text: Some("做一张拼图".to_string()), - image_url: None, - asset_id: None, - thumbnail_url: None, - }, - CreativeAgentInputPart { - part_type: CreativeAgentInputPartType::InputImage, - text: None, - image_url: Some("https://example.test/image.png".to_string()), - asset_id: Some("asset-1".to_string()), - thumbnail_url: None, - }, - ], - }; - - let payload = serde_json::to_value(request).expect("request should serialize"); - assert_eq!(payload["clientMessageId"], json!("client-message-1")); - assert_eq!(payload["content"][0]["type"], json!("input_text")); - assert_eq!(payload["content"][1]["type"], json!("input_image")); - assert_eq!( - payload["content"][1]["imageUrl"], - json!("https://example.test/image.png") - ); - } -} diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs deleted file mode 100644 index d750be624..000000000 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ /dev/null @@ -1,529 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::external_generation::ExternalGenerationJobStatusRecord; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum JumpHopDifficulty { - Easy, - Standard, - Advanced, - Challenge, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum JumpHopStylePreset { - MinimalBlocks, - PaperToy, - NeonGlass, - ForestStone, - FutureMetal, - Custom, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum JumpHopGenerationStatus { - Draft, - Generating, - Ready, - Failed, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum JumpHopTileType { - Start, - Normal, - Target, - Finish, - Bonus, - Accent, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum JumpHopActionType { - CompileDraft, - RegenerateTiles, - UpdateWorkMeta, - UpdateDifficulty, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum JumpHopRunStatus { - Playing, - Failed, - Cleared, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum JumpHopJumpResult { - Miss, - Hit, - Perfect, - Finish, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopWorkspaceCreateRequest { - pub template_id: String, - pub theme_text: String, - #[serde(default)] - pub work_title: String, - #[serde(default)] - pub work_description: String, - #[serde(default)] - pub theme_tags: Vec, - #[serde(default = "default_jump_hop_difficulty")] - pub difficulty: JumpHopDifficulty, - #[serde(default = "default_jump_hop_style_preset")] - pub style_preset: JumpHopStylePreset, - #[serde(default)] - pub character_prompt: String, - #[serde(default)] - pub tile_prompt: String, - #[serde(default)] - pub end_mood_prompt: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopActionRequest { - pub action_type: JumpHopActionType, - #[serde(default)] - pub profile_id: Option, - #[serde(default)] - pub theme_text: Option, - #[serde(default)] - pub work_title: Option, - #[serde(default)] - pub work_description: Option, - #[serde(default)] - pub theme_tags: Option>, - #[serde(default)] - pub difficulty: Option, - #[serde(default)] - pub style_preset: Option, - #[serde(default)] - pub character_prompt: Option, - #[serde(default)] - 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, - #[serde(default)] - pub back_button_asset: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopCharacterAsset { - pub asset_id: String, - pub image_src: String, - pub image_object_key: String, - pub asset_object_id: String, - pub generation_provider: String, - pub prompt: String, - pub width: u32, - pub height: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopDefaultCharacter { - pub character_id: String, - pub display_name: String, - pub model_kind: String, - pub body_color: String, - pub accent_color: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopTileAsset { - pub tile_type: JumpHopTileType, - #[serde(default)] - pub tile_id: Option, - pub image_src: String, - pub image_object_key: String, - pub asset_object_id: String, - pub source_atlas_cell: String, - #[serde(default)] - pub atlas_row: Option, - #[serde(default)] - pub atlas_col: Option, - pub visual_width: u32, - pub visual_height: u32, - pub top_surface_radius: f32, - pub landing_radius: f32, - #[serde(default)] - pub face_assets: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum JumpHopTileFaceKey { - Top, - Front, - Right, - Back, - Left, - Bottom, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopTileFaceAsset { - pub face: JumpHopTileFaceKey, - pub asset_id: String, - pub image_src: String, - pub image_object_key: String, - pub asset_object_id: String, - pub generation_provider: String, - pub prompt: String, - pub width: u32, - pub height: u32, - pub source_atlas_cell: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopTileFaceAssets { - pub top: JumpHopTileFaceAsset, - pub front: JumpHopTileFaceAsset, - pub right: JumpHopTileFaceAsset, - pub back: JumpHopTileFaceAsset, - pub left: JumpHopTileFaceAsset, - pub bottom: JumpHopTileFaceAsset, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopScoring { - pub charge_to_distance_ratio: f32, - pub max_charge_ms: u32, - pub hit_bonus: u32, - pub perfect_bonus: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopPlatform { - pub platform_id: String, - pub tile_type: JumpHopTileType, - pub x: f32, - pub y: f32, - pub width: f32, - pub height: f32, - pub landing_radius: f32, - pub perfect_radius: f32, - pub score_value: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopPath { - pub seed: String, - pub difficulty: JumpHopDifficulty, - pub platforms: Vec, - pub finish_index: u32, - pub camera_preset: String, - pub scoring: JumpHopScoring, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopLastJump { - pub charge_ms: u32, - pub jump_distance: f32, - pub target_platform_index: u32, - pub landed_x: f32, - pub landed_y: f32, - pub result: JumpHopJumpResult, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopDraftResponse { - pub template_id: String, - pub template_name: String, - #[serde(default)] - pub profile_id: Option, - pub theme_text: String, - pub work_title: String, - pub work_description: String, - pub theme_tags: Vec, - pub difficulty: JumpHopDifficulty, - pub style_preset: JumpHopStylePreset, - #[serde(default)] - pub default_character: Option, - pub character_prompt: String, - pub tile_prompt: String, - #[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: Vec, - #[serde(default)] - pub path: Option, - #[serde(default)] - pub cover_composite: Option, - #[serde(default)] - pub back_button_asset: Option, - pub generation_status: JumpHopGenerationStatus, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopSessionSnapshotResponse { - pub session_id: String, - pub owner_user_id: String, - pub status: JumpHopGenerationStatus, - #[serde(default)] - pub draft: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopSessionResponse { - pub session: JumpHopSessionSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopActionResponse { - pub action_type: JumpHopActionType, - pub session: JumpHopSessionSnapshotResponse, - #[serde(default)] - pub work: Option, - #[serde(default)] - pub queue_state: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopWorkSummaryResponse { - pub runtime_kind: String, - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - #[serde(default)] - pub source_session_id: Option, - pub theme_text: String, - pub work_title: String, - pub work_description: String, - pub theme_tags: Vec, - pub difficulty: JumpHopDifficulty, - pub style_preset: JumpHopStylePreset, - #[serde(default)] - pub cover_image_src: Option, - pub publication_status: String, - pub play_count: u32, - pub updated_at: String, - #[serde(default)] - pub published_at: Option, - pub publish_ready: bool, - pub generation_status: JumpHopGenerationStatus, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopWorkProfileResponse { - #[serde(flatten)] - pub summary: JumpHopWorkSummaryResponse, - pub draft: JumpHopDraftResponse, - pub path: JumpHopPath, - #[serde(default)] - pub default_character: Option, - pub character_asset: JumpHopCharacterAsset, - pub tile_atlas_asset: JumpHopCharacterAsset, - pub tile_assets: Vec, - #[serde(default)] - pub back_button_asset: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopWorksResponse { - pub items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopWorkDetailResponse { - pub item: JumpHopWorkProfileResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopWorkMutationResponse { - pub item: JumpHopWorkProfileResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopGalleryCardResponse { - pub public_work_code: String, - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub author_display_name: String, - pub theme_text: String, - pub work_title: String, - pub work_description: String, - #[serde(default)] - pub cover_image_src: Option, - pub theme_tags: Vec, - pub difficulty: JumpHopDifficulty, - pub style_preset: JumpHopStylePreset, - pub publication_status: String, - pub play_count: u32, - pub updated_at: String, - #[serde(default)] - pub published_at: Option, - pub generation_status: JumpHopGenerationStatus, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopGalleryResponse { - pub items: Vec, - pub has_more: bool, - #[serde(default)] - pub next_cursor: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopGalleryDetailResponse { - pub item: JumpHopWorkProfileResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopRuntimeRunSnapshotResponse { - pub run_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub status: JumpHopRunStatus, - pub current_platform_index: u32, - pub successful_jump_count: u32, - pub duration_ms: u64, - pub score: u32, - pub combo: u32, - pub path: JumpHopPath, - #[serde(default)] - pub last_jump: Option, - pub started_at_ms: u64, - #[serde(default)] - pub finished_at_ms: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopRunResponse { - pub run: JumpHopRuntimeRunSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopStartRunRequest { - pub profile_id: String, - #[serde(default)] - pub runtime_mode: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopJumpRequest { - pub drag_distance: f32, - #[serde(default)] - pub drag_vector_x: Option, - #[serde(default)] - pub drag_vector_y: Option, - pub client_event_id: String, -} - -fn default_jump_hop_difficulty() -> JumpHopDifficulty { - JumpHopDifficulty::Standard -} - -fn default_jump_hop_style_preset() -> JumpHopStylePreset { - JumpHopStylePreset::MinimalBlocks -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopRestartRunRequest { - pub client_action_id: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopJumpResponse { - pub run: JumpHopRuntimeRunSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopLeaderboardEntry { - pub rank: u32, - pub player_id: String, - pub display_name: String, - pub successful_jump_count: u32, - pub duration_ms: u64, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JumpHopLeaderboardResponse { - pub profile_id: String, - pub items: Vec, - #[serde(default)] - pub viewer_best: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn jump_hop_workspace_request_uses_camel_case() { - let payload = serde_json::to_value(JumpHopWorkspaceCreateRequest { - template_id: "jump-hop".to_string(), - theme_text: "跳一跳".to_string(), - work_title: "跳一跳".to_string(), - work_description: "俯视角跳跃闯关".to_string(), - theme_tags: vec!["休闲".to_string()], - difficulty: JumpHopDifficulty::Easy, - style_preset: JumpHopStylePreset::MinimalBlocks, - character_prompt: "角色".to_string(), - tile_prompt: "地块".to_string(), - end_mood_prompt: None, - }) - .expect("payload should serialize"); - - assert_eq!(payload["templateId"], json!("jump-hop")); - assert_eq!(payload["difficulty"], json!("easy")); - assert_eq!(payload["stylePreset"], json!("minimal-blocks")); - } -} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 377e9d75f..07fbbdf11 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -6,20 +6,8 @@ pub mod api; #[cfg(feature = "oss-contracts")] pub mod assets; pub mod auth; -#[cfg(any())] -pub mod bark_battle; -#[cfg(any())] -pub mod big_fish; -#[cfg(any())] -pub mod big_fish_works; pub mod client_downloads; -#[cfg(any())] -pub mod creation_agent_document_input; pub mod creation_audio; -#[cfg(any())] -pub mod creation_entry_config; -#[cfg(any())] -pub mod creative_agent; pub mod editor_agent; pub mod editor_generation; pub mod error_reports; @@ -35,32 +23,4 @@ pub mod game_creation_app; pub mod game_distribution; pub mod hyper3d; pub mod llm; -#[cfg(any())] -pub mod public_work; -#[cfg(any())] -pub mod puzzle_agent; -#[cfg(any())] -pub mod puzzle_clear; -#[cfg(any())] -pub mod puzzle_creative_template; -#[cfg(any())] -pub mod puzzle_gallery; -#[cfg(any())] -pub mod puzzle_runtime; -#[cfg(any())] -pub mod puzzle_works; pub mod runtime; -#[cfg(any())] -pub mod runtime_story; -#[cfg(any())] -pub mod square_hole_agent; -#[cfg(any())] -pub mod square_hole_runtime; -#[cfg(any())] -pub mod square_hole_works; -#[cfg(any())] -pub mod story; -#[cfg(any())] -pub mod visual_novel; -#[cfg(any())] -pub mod wooden_fish; diff --git a/server-rs/crates/shared-contracts/src/match3d_agent.rs b/server-rs/crates/shared-contracts/src/match3d_agent.rs deleted file mode 100644 index 46365eecf..000000000 --- a/server-rs/crates/shared-contracts/src/match3d_agent.rs +++ /dev/null @@ -1,305 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::creation_audio::CreationAudioAsset; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreateMatch3DAgentSessionRequest { - #[serde(default)] - pub seed_text: Option, - #[serde(default)] - pub theme_text: Option, - #[serde(default)] - pub reference_image_src: Option, - #[serde(default)] - pub clear_count: Option, - #[serde(default)] - pub difficulty: Option, - #[serde(default)] - pub asset_style_id: Option, - #[serde(default)] - pub asset_style_label: Option, - #[serde(default)] - pub asset_style_prompt: Option, - #[serde(default)] - pub generate_click_sound: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SendMatch3DAgentMessageRequest { - pub client_message_id: String, - pub text: String, - #[serde(default)] - pub quick_fill_requested: Option, - #[serde(default)] - pub reference_image_src: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ExecuteMatch3DAgentActionRequest { - pub action: String, - #[serde(default)] - pub game_name: Option, - #[serde(default)] - pub summary: Option, - #[serde(default)] - pub tags: Option>, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub clear_count: Option, - #[serde(default)] - pub difficulty: Option, - #[serde(default)] - pub generate_click_sound: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DCreatorConfigResponse { - pub theme_text: String, - #[serde(default)] - pub reference_image_src: Option, - pub clear_count: u32, - pub difficulty: u32, - #[serde(default)] - pub asset_style_id: Option, - #[serde(default)] - pub asset_style_label: Option, - #[serde(default)] - pub asset_style_prompt: Option, - #[serde(default)] - pub generate_click_sound: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DResultDraftResponse { - pub profile_id: String, - pub game_name: String, - pub theme_text: String, - #[serde(default)] - pub summary_text: Option, - pub summary: String, - pub tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub reference_image_src: Option, - pub clear_count: u32, - pub difficulty: u32, - pub total_item_count: u32, - pub publish_ready: bool, - pub blockers: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_image_object_key: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub generated_background_asset: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub generated_item_assets: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DGeneratedBackgroundAssetResponse { - pub prompt: String, - #[serde(default)] - pub level_scene_prompt: Option, - #[serde(default)] - pub level_scene_image_src: Option, - #[serde(default)] - pub level_scene_image_object_key: Option, - #[serde(default)] - pub image_src: Option, - #[serde(default)] - pub image_object_key: Option, - #[serde(default)] - pub ui_spritesheet_prompt: Option, - #[serde(default)] - pub ui_spritesheet_image_src: Option, - #[serde(default)] - pub ui_spritesheet_image_object_key: Option, - #[serde(default)] - pub item_spritesheet_prompt: Option, - #[serde(default)] - pub item_spritesheet_image_src: Option, - #[serde(default)] - pub item_spritesheet_image_object_key: Option, - #[serde(default)] - pub container_prompt: Option, - #[serde(default)] - pub container_image_src: Option, - #[serde(default)] - pub container_image_object_key: Option, - pub status: String, - #[serde(default)] - pub error: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DGeneratedItemImageViewResponse { - pub view_id: String, - pub view_index: u32, - #[serde(default)] - pub image_src: Option, - #[serde(default)] - pub image_object_key: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DGeneratedItemAssetResponse { - pub item_id: String, - pub item_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub item_size: Option, - #[serde(default)] - pub image_src: Option, - #[serde(default)] - pub image_object_key: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub image_views: Vec, - #[serde(default)] - pub model_src: Option, - #[serde(default)] - pub model_object_key: Option, - #[serde(default)] - pub model_file_name: Option, - #[serde(default)] - pub task_uuid: Option, - #[serde(default)] - pub subscription_key: Option, - #[serde(default)] - pub sound_prompt: Option, - #[serde(default)] - pub background_music_title: Option, - #[serde(default)] - pub background_music_style: Option, - #[serde(default)] - pub background_music_prompt: Option, - #[serde(default)] - pub background_music: Option, - #[serde(default)] - pub click_sound: Option, - #[serde(default)] - pub background_asset: Option, - pub status: String, - #[serde(default)] - pub error: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DAnchorItemResponse { - pub key: String, - pub label: String, - pub value: String, - pub status: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DAnchorPackResponse { - pub theme: Match3DAnchorItemResponse, - pub clear_count: Match3DAnchorItemResponse, - pub difficulty: Match3DAnchorItemResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DAgentMessageResponse { - pub id: String, - pub role: String, - pub kind: String, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DAgentSessionSnapshotResponse { - pub session_id: String, - pub current_turn: u32, - pub progress_percent: u32, - pub stage: String, - pub anchor_pack: Match3DAnchorPackResponse, - #[serde(default)] - pub config: Option, - #[serde(default)] - pub draft: Option, - pub messages: Vec, - #[serde(default)] - pub last_assistant_reply: Option, - #[serde(default)] - pub published_profile_id: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DAgentSessionResponse { - pub session: Match3DAgentSessionSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DAgentActionResponse { - pub session: Match3DAgentSessionSnapshotResponse, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn create_match3d_session_request_uses_camel_case() { - let payload = serde_json::to_value(CreateMatch3DAgentSessionRequest { - seed_text: Some("水果消除".to_string()), - theme_text: Some("水果".to_string()), - reference_image_src: Some("data:image/png;base64,abc".to_string()), - clear_count: Some(4), - difficulty: Some(3), - asset_style_id: Some("flat-icon".to_string()), - asset_style_label: Some("扁平图标".to_string()), - asset_style_prompt: Some("干净扁平 2D 游戏道具图标风格".to_string()), - generate_click_sound: Some(true), - }) - .expect("payload should serialize"); - - assert_eq!(payload["seedText"], json!("水果消除")); - assert_eq!(payload["themeText"], json!("水果")); - assert_eq!( - payload["referenceImageSrc"], - json!("data:image/png;base64,abc") - ); - assert_eq!(payload["clearCount"], json!(4)); - assert_eq!(payload["assetStyleId"], json!("flat-icon")); - assert_eq!(payload["generateClickSound"], json!(true)); - } - - #[test] - fn execute_match3d_action_request_serializes_click_sound_toggle() { - let payload = serde_json::to_value(ExecuteMatch3DAgentActionRequest { - action: "match3d_compile_draft".to_string(), - game_name: None, - summary: None, - tags: None, - cover_image_src: None, - clear_count: None, - difficulty: None, - generate_click_sound: Some(true), - }) - .expect("payload should serialize"); - - assert_eq!(payload["action"], json!("match3d_compile_draft")); - assert_eq!(payload["generateClickSound"], json!(true)); - } -} diff --git a/server-rs/crates/shared-contracts/src/match3d_runtime.rs b/server-rs/crates/shared-contracts/src/match3d_runtime.rs deleted file mode 100644 index 0c459afd2..000000000 --- a/server-rs/crates/shared-contracts/src/match3d_runtime.rs +++ /dev/null @@ -1,127 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StartMatch3DRunRequest { - pub profile_id: String, - #[serde(default)] - pub item_type_count_override: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ClickMatch3DItemRequest { - #[serde(default)] - pub run_id: Option, - pub item_instance_id: String, - pub client_snapshot_version: u64, - pub client_event_id: String, - pub clicked_at_ms: u64, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct StopMatch3DRunRequest { - pub client_action_id: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DItemSnapshotResponse { - pub item_instance_id: String, - pub item_type_id: String, - pub visual_key: String, - pub x: f32, - pub y: f32, - pub radius: f32, - pub layer: u32, - pub state: String, - pub clickable: bool, - #[serde(default)] - pub tray_slot_index: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DTraySlotResponse { - pub slot_index: u32, - #[serde(default)] - pub item_instance_id: Option, - #[serde(default)] - pub item_type_id: Option, - #[serde(default)] - pub visual_key: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DRunSnapshotResponse { - pub run_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub status: String, - /// 对外 HTTP 快照版本。领域层内部字段名为 board_version,facade 需要在这里完成映射。 - pub snapshot_version: u64, - pub started_at_ms: u64, - pub duration_limit_ms: u64, - #[serde(default)] - pub server_now_ms: Option, - pub remaining_ms: u64, - pub clear_count: u32, - pub total_item_count: u32, - pub cleared_item_count: u32, - pub items: Vec, - pub tray_slots: Vec, - #[serde(default)] - pub failure_reason: Option, - #[serde(default)] - pub last_confirmed_action_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DClickConfirmationResponse { - pub accepted: bool, - #[serde(default)] - pub reject_reason: Option, - #[serde(default)] - pub entered_slot_index: Option, - pub cleared_item_instance_ids: Vec, - pub run: Match3DRunSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DRunResponse { - pub run: Match3DRunSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DClickResponse { - pub confirmation: Match3DClickConfirmationResponse, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn click_match3d_item_request_uses_camel_case() { - let payload = serde_json::to_value(ClickMatch3DItemRequest { - run_id: Some("run-1".to_string()), - item_instance_id: "item-1".to_string(), - client_snapshot_version: 7, - client_event_id: "event-1".to_string(), - clicked_at_ms: 12_345, - }) - .expect("payload should serialize"); - - assert_eq!(payload["runId"], json!("run-1")); - assert_eq!(payload["itemInstanceId"], json!("item-1")); - assert_eq!(payload["clientSnapshotVersion"], json!(7)); - assert_eq!(payload["clientEventId"], json!("event-1")); - assert_eq!(payload["clickedAtMs"], json!(12_345)); - } -} diff --git a/server-rs/crates/shared-contracts/src/match3d_works.rs b/server-rs/crates/shared-contracts/src/match3d_works.rs deleted file mode 100644 index 1536d45f4..000000000 --- a/server-rs/crates/shared-contracts/src/match3d_works.rs +++ /dev/null @@ -1,337 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::creation_audio::CreationAudioAsset; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PutMatch3DWorkRequest { - pub game_name: String, - #[serde(default)] - pub theme_text: Option, - pub summary: String, - pub tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub reference_image_src: Option, - pub clear_count: u32, - pub difficulty: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GenerateMatch3DWorkTagsRequest { - pub game_name: String, - pub theme_text: String, - #[serde(default)] - pub summary: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GenerateMatch3DWorkTagsResponse { - pub tags: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PutMatch3DAudioAssetsRequest { - pub generated_item_assets: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PersistMatch3DGeneratedModelRequest { - pub item_id: String, - pub item_name: String, - pub source_url: String, - #[serde(default)] - pub file_name: Option, - #[serde(default)] - pub task_uuid: Option, - #[serde(default)] - pub subscription_key: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PersistMatch3DGeneratedModelResponse { - pub asset: Match3DGeneratedItemAssetResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GenerateMatch3DCoverImageRequest { - pub prompt: String, - #[serde(default)] - pub uploaded_image_src: Option, - #[serde(default)] - pub reference_image_src: Option, - #[serde(default)] - pub reference_image_srcs: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GenerateMatch3DCoverImageResponse { - pub item: Match3DWorkProfileResponse, - pub cover_image_src: String, - pub cover_image_object_key: String, - pub prompt: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GenerateMatch3DBackgroundImageRequest { - pub prompt: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GenerateMatch3DBackgroundImageResponse { - pub item: Match3DWorkProfileResponse, - pub background_image_src: String, - pub background_image_object_key: String, - pub generated_background_asset: Match3DGeneratedBackgroundAssetResponse, - pub prompt: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GenerateMatch3DContainerImageRequest { - pub prompt: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GenerateMatch3DContainerImageResponse { - pub item: Match3DWorkProfileResponse, - pub container_image_src: String, - pub container_image_object_key: String, - pub generated_background_asset: Match3DGeneratedBackgroundAssetResponse, - pub prompt: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GenerateMatch3DItemAssetsRequest { - pub item_names: Vec, - #[serde(default)] - pub mode: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GenerateMatch3DItemAssetsResponse { - pub item: Match3DWorkProfileResponse, - pub generated_item_assets: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DWorkSummaryResponse { - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - #[serde(default)] - pub source_session_id: Option, - pub game_name: String, - pub theme_text: String, - pub summary: String, - pub tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub reference_image_src: Option, - pub clear_count: u32, - pub difficulty: u32, - pub publication_status: String, - pub play_count: u32, - pub updated_at: String, - #[serde(default)] - pub published_at: Option, - pub publish_ready: bool, - #[serde(default)] - pub generation_status: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_image_object_key: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub generated_background_asset: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub generated_item_assets: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DGeneratedBackgroundAssetResponse { - pub prompt: String, - #[serde(default)] - pub level_scene_prompt: Option, - #[serde(default)] - pub level_scene_image_src: Option, - #[serde(default)] - pub level_scene_image_object_key: Option, - #[serde(default)] - pub image_src: Option, - #[serde(default)] - pub image_object_key: Option, - #[serde(default)] - pub ui_spritesheet_prompt: Option, - #[serde(default)] - pub ui_spritesheet_image_src: Option, - #[serde(default)] - pub ui_spritesheet_image_object_key: Option, - #[serde(default)] - pub item_spritesheet_prompt: Option, - #[serde(default)] - pub item_spritesheet_image_src: Option, - #[serde(default)] - pub item_spritesheet_image_object_key: Option, - #[serde(default)] - pub container_prompt: Option, - #[serde(default)] - pub container_image_src: Option, - #[serde(default)] - pub container_image_object_key: Option, - pub status: String, - #[serde(default)] - pub error: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DGeneratedItemImageViewResponse { - pub view_id: String, - pub view_index: u32, - #[serde(default)] - pub image_src: Option, - #[serde(default)] - pub image_object_key: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DGeneratedItemAssetResponse { - pub item_id: String, - pub item_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub item_size: Option, - #[serde(default)] - pub image_src: Option, - #[serde(default)] - pub image_object_key: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub image_views: Vec, - #[serde(default)] - pub model_src: Option, - #[serde(default)] - pub model_object_key: Option, - #[serde(default)] - pub model_file_name: Option, - #[serde(default)] - pub task_uuid: Option, - #[serde(default)] - pub subscription_key: Option, - #[serde(default)] - pub sound_prompt: Option, - #[serde(default)] - pub background_music_title: Option, - #[serde(default)] - pub background_music_style: Option, - #[serde(default)] - pub background_music_prompt: Option, - #[serde(default)] - pub background_music: Option, - #[serde(default)] - pub click_sound: Option, - #[serde(default)] - pub background_asset: Option, - pub status: String, - #[serde(default)] - pub error: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DWorkProfileResponse { - #[serde(flatten)] - pub summary: Match3DWorkSummaryResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DWorksResponse { - pub items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DWorkDetailResponse { - pub item: Match3DWorkProfileResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Match3DWorkMutationResponse { - pub item: Match3DWorkProfileResponse, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn match3d_work_request_uses_camel_case() { - let payload = serde_json::to_value(PutMatch3DWorkRequest { - game_name: "水果抓大鹅".to_string(), - theme_text: Some("水果".to_string()), - summary: "水果主题".to_string(), - tags: vec!["水果".to_string()], - cover_image_src: None, - reference_image_src: None, - clear_count: 4, - difficulty: 5, - }) - .expect("payload should serialize"); - - assert_eq!(payload["gameName"], json!("水果抓大鹅")); - assert_eq!(payload["clearCount"], json!(4)); - } - - #[test] - fn match3d_work_summary_uses_camel_case_generation_status() { - let payload = serde_json::to_value(Match3DWorkSummaryResponse { - work_id: "work-1".to_string(), - profile_id: "profile-1".to_string(), - owner_user_id: "user-1".to_string(), - source_session_id: Some("session-1".to_string()), - game_name: "水果抓大鹅".to_string(), - theme_text: "水果".to_string(), - summary: "水果主题".to_string(), - tags: vec!["水果".to_string()], - cover_image_src: None, - reference_image_src: None, - clear_count: 4, - difficulty: 5, - publication_status: "draft".to_string(), - play_count: 0, - updated_at: "2026-05-01T00:00:00Z".to_string(), - published_at: None, - publish_ready: false, - generation_status: Some("generating".to_string()), - background_prompt: None, - background_image_src: None, - background_image_object_key: None, - generated_background_asset: None, - generated_item_assets: Vec::new(), - }) - .expect("payload should serialize"); - - assert_eq!(payload["generationStatus"], json!("generating")); - } -} diff --git a/server-rs/crates/shared-contracts/src/public_work.rs b/server-rs/crates/shared-contracts/src/public_work.rs deleted file mode 100644 index 5a0856997..000000000 --- a/server-rs/crates/shared-contracts/src/public_work.rs +++ /dev/null @@ -1,63 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// 公开作品列表统一契约。 -/// -/// 该契约面向 BFF 和后续可选前端直连订阅,字段只包含平台公开卡片需要的摘要信息。 -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PublicWorkGalleryEntryResponse { - pub source_type: String, - pub work_id: String, - pub profile_id: String, - #[serde(default)] - pub source_session_id: Option, - pub public_work_code: String, - pub owner_user_id: String, - pub author_display_name: String, - pub world_name: String, - pub subtitle: String, - pub summary_text: String, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub cover_asset_id: Option, - pub theme_tags: Vec, - pub play_count: u32, - pub remix_count: u32, - pub like_count: u32, - #[serde(default)] - pub recent_play_count_7d: u32, - #[serde(default)] - pub published_at: Option, - pub updated_at: String, - pub sort_time_micros: i64, -} - -/// 公开作品详情统一摘要契约。 -/// -/// `detail_payload_json` 只承载详情页展示扩展字段,不承载正式 runtime 配置。 -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PublicWorkDetailEntryResponse { - #[serde(flatten)] - pub entry: PublicWorkGalleryEntryResponse, - pub detail_payload_json: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PublicWorkGalleryResponse { - pub items: Vec, - #[serde(default)] - pub has_more: bool, - #[serde(default)] - pub next_cursor: Option, - #[serde(default)] - pub total_count: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PublicWorkDetailResponse { - pub item: PublicWorkDetailEntryResponse, -} diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs deleted file mode 100644 index 6d585f5b6..000000000 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ /dev/null @@ -1,294 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::creation_audio::CreationAudioAsset; -use crate::external_generation::ExternalGenerationJobStatusRecord; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreatePuzzleAgentSessionRequest { - #[serde(default)] - pub seed_text: Option, - #[serde(default)] - pub work_title: Option, - #[serde(default)] - pub work_description: Option, - #[serde(default)] - pub picture_description: Option, - #[serde(default)] - pub reference_image_src: Option, - #[serde(default)] - pub reference_image_srcs: Vec, - #[serde(default)] - pub reference_image_asset_object_id: Option, - #[serde(default)] - pub reference_image_asset_object_ids: Vec, - #[serde(default)] - pub image_model: Option, - #[serde(default)] - pub ai_redraw: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SendPuzzleAgentMessageRequest { - pub client_message_id: String, - pub text: String, - #[serde(default)] - pub quick_fill_requested: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ExecutePuzzleAgentActionRequest { - pub action: String, - #[serde(default)] - pub prompt_text: Option, - #[serde(default)] - pub reference_image_src: Option, - #[serde(default)] - pub reference_image_srcs: Vec, - #[serde(default)] - pub reference_image_asset_object_id: Option, - #[serde(default)] - pub reference_image_asset_object_ids: Vec, - #[serde(default)] - pub image_model: Option, - #[serde(default)] - pub ai_redraw: Option, - #[serde(default)] - pub candidate_count: Option, - #[serde(default)] - pub should_auto_name_level: Option, - #[serde(default)] - pub candidate_id: Option, - #[serde(default)] - pub level_id: Option, - #[serde(default)] - pub work_title: Option, - #[serde(default)] - pub work_description: Option, - #[serde(default)] - pub picture_description: Option, - #[serde(default)] - pub level_name: Option, - #[serde(default)] - pub summary: Option, - #[serde(default)] - pub theme_tags: Option>, - #[serde(default)] - pub levels_json: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleFormDraftResponse { - #[serde(default)] - pub work_title: Option, - #[serde(default)] - pub work_description: Option, - #[serde(default)] - pub picture_description: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleAnchorItemResponse { - pub key: String, - pub label: String, - pub value: String, - pub status: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleAnchorPackResponse { - pub theme_promise: PuzzleAnchorItemResponse, - pub visual_subject: PuzzleAnchorItemResponse, - pub visual_mood: PuzzleAnchorItemResponse, - pub composition_hooks: PuzzleAnchorItemResponse, - pub tags_and_forbidden: PuzzleAnchorItemResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleGeneratedImageCandidateResponse { - pub candidate_id: String, - pub image_src: String, - pub asset_id: String, - pub prompt: String, - #[serde(default)] - pub actual_prompt: Option, - pub source_type: String, - pub selected: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleCreatorIntentResponse { - pub source_mode: String, - pub raw_messages_summary: String, - pub theme_promise: String, - pub visual_subject: String, - pub visual_mood: Vec, - pub composition_hooks: Vec, - pub theme_tags: Vec, - pub forbidden_directives: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleResultDraftResponse { - pub work_title: String, - pub work_description: String, - pub level_name: String, - pub summary: String, - pub theme_tags: Vec, - pub forbidden_directives: Vec, - #[serde(default)] - pub creator_intent: Option, - pub anchor_pack: PuzzleAnchorPackResponse, - pub candidates: Vec, - #[serde(default)] - pub selected_candidate_id: Option, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub cover_asset_id: Option, - pub generation_status: String, - pub levels: Vec, - #[serde(default)] - pub form_draft: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleDraftLevelResponse { - pub level_id: String, - pub level_name: String, - pub picture_description: String, - #[serde(default)] - pub picture_reference: Option, - #[serde(default)] - pub ui_background_prompt: Option, - #[serde(default)] - pub ui_background_image_src: Option, - #[serde(default)] - pub ui_background_image_object_key: Option, - #[serde(default)] - pub level_scene_image_src: Option, - #[serde(default)] - pub level_scene_image_object_key: Option, - #[serde(default)] - pub ui_spritesheet_image_src: Option, - #[serde(default)] - pub ui_spritesheet_image_object_key: Option, - #[serde(default)] - pub level_background_image_src: Option, - #[serde(default)] - pub level_background_image_object_key: Option, - #[serde(default)] - pub background_music: Option, - pub candidates: Vec, - #[serde(default)] - pub selected_candidate_id: Option, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub cover_asset_id: Option, - pub generation_status: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleAgentMessageResponse { - pub id: String, - pub role: String, - pub kind: String, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleAgentSuggestedActionResponse { - pub id: String, - pub action_type: String, - pub label: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleResultPreviewBlockerResponse { - pub id: String, - pub code: String, - pub message: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleResultPreviewFindingResponse { - pub id: String, - pub severity: String, - pub code: String, - pub message: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleResultPreviewEnvelopeResponse { - pub draft: PuzzleResultDraftResponse, - pub blockers: Vec, - pub quality_findings: Vec, - pub publish_ready: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleAgentSessionSnapshotResponse { - pub session_id: String, - pub seed_text: String, - pub current_turn: u32, - pub progress_percent: u32, - pub stage: String, - pub anchor_pack: PuzzleAnchorPackResponse, - #[serde(default)] - pub draft: Option, - pub messages: Vec, - #[serde(default)] - pub last_assistant_reply: Option, - #[serde(default)] - pub published_profile_id: Option, - pub suggested_actions: Vec, - #[serde(default)] - pub result_preview: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleAgentSessionResponse { - pub session: PuzzleAgentSessionSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleAgentOperationResponse { - pub operation_id: String, - pub operation_type: String, - pub status: String, - pub phase_label: String, - pub phase_detail: String, - pub progress: u32, - #[serde(default)] - pub error: Option, - #[serde(default)] - pub queue_state: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleAgentActionResponse { - pub operation: PuzzleAgentOperationResponse, - /// 操作完成后的最新会话快照,供前端直接更新界面,避免重复拉取完整 session。 - pub session: PuzzleAgentSessionSnapshotResponse, -} diff --git a/server-rs/crates/shared-contracts/src/puzzle_clear.rs b/server-rs/crates/shared-contracts/src/puzzle_clear.rs deleted file mode 100644 index 615fc5aed..000000000 --- a/server-rs/crates/shared-contracts/src/puzzle_clear.rs +++ /dev/null @@ -1,317 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::external_generation::ExternalGenerationJobStatusRecord; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PuzzleClearGenerationStatus { - Draft, - Generating, - Ready, - Failed, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PuzzleClearActionType { - CompileDraft, - RegenerateAtlas, - UpdateWorkMeta, - UpdateBoardBackground, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PuzzleClearRunStatus { - Playing, - LevelFailed, - LevelCleared, - Finished, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearImageAsset { - pub asset_id: String, - pub image_src: String, - pub image_object_key: String, - pub asset_object_id: String, - pub generation_provider: String, - pub prompt: String, - pub width: u32, - pub height: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearPatternGroup { - pub group_id: String, - pub shape: String, - pub width: u32, - pub height: u32, - pub atlas_x: u32, - pub atlas_y: u32, - pub atlas_width: u32, - pub atlas_height: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearCardAsset { - pub card_id: String, - pub group_id: String, - pub shape: String, - pub orientation: String, - pub part_x: u32, - pub part_y: u32, - pub image_src: String, - pub image_object_key: String, - pub asset_object_id: String, - pub source_atlas_cell: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearWorkspaceCreateRequest { - pub template_id: String, - pub work_title: String, - pub work_description: String, - pub theme_prompt: String, - #[serde(default)] - pub board_background_prompt: String, - pub generate_board_background: bool, - pub board_background_asset: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearActionRequest { - pub action_type: PuzzleClearActionType, - pub profile_id: Option, - pub work_title: Option, - pub work_description: Option, - pub theme_prompt: Option, - #[serde(default)] - pub board_background_prompt: Option, - pub generate_board_background: Option, - pub board_background_asset: Option, - pub atlas_asset: Option, - pub pattern_groups: Option>, - pub card_assets: Option>, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearDraftResponse { - pub template_id: String, - pub template_name: String, - pub profile_id: Option, - pub work_title: String, - pub work_description: String, - pub theme_prompt: String, - #[serde(default)] - pub board_background_prompt: String, - pub generate_board_background: bool, - pub board_background_asset: Option, - pub card_back_image_src: Option, - pub atlas_asset: Option, - pub pattern_groups: Vec, - pub card_assets: Vec, - pub generation_status: PuzzleClearGenerationStatus, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearSessionSnapshotResponse { - pub session_id: String, - pub owner_user_id: String, - pub status: PuzzleClearGenerationStatus, - pub draft: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearSessionResponse { - pub session: PuzzleClearSessionSnapshotResponse, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearActionResponse { - pub action_type: PuzzleClearActionType, - pub session: PuzzleClearSessionSnapshotResponse, - pub work: Option, - #[serde(default)] - pub queue_state: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearWorkSummaryResponse { - pub runtime_kind: String, - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub source_session_id: Option, - pub work_title: String, - pub work_description: String, - pub theme_prompt: String, - pub cover_image_src: Option, - pub publication_status: String, - pub play_count: u32, - pub updated_at: String, - pub published_at: Option, - pub publish_ready: bool, - pub generation_status: PuzzleClearGenerationStatus, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearWorkProfileResponse { - pub summary: PuzzleClearWorkSummaryResponse, - pub draft: PuzzleClearDraftResponse, - pub board_background_asset: Option, - pub atlas_asset: PuzzleClearImageAsset, - pub pattern_groups: Vec, - pub card_assets: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearWorksResponse { - pub items: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearWorkDetailResponse { - pub item: PuzzleClearWorkProfileResponse, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearWorkMutationResponse { - pub item: PuzzleClearWorkProfileResponse, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearBoardCell { - pub row: u32, - pub col: u32, - pub card: Option, - pub locked_group_id: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearBoardSnapshot { - pub rows: u32, - pub cols: u32, - pub cells: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearRuntimeSnapshotResponse { - pub run_id: String, - pub profile_id: String, - pub owner_user_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub runtime_mode: Option, - pub status: PuzzleClearRunStatus, - pub level_index: u32, - pub clears_done: u32, - pub target_clears: u32, - pub level_duration_seconds: u32, - pub level_started_at_ms: u64, - pub board: PuzzleClearBoardSnapshot, - pub ready_columns: Vec>, - pub started_at_ms: u64, - pub finished_at_ms: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearRunResponse { - pub run: PuzzleClearRuntimeSnapshotResponse, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearStartRunRequest { - pub profile_id: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearSwapRequest { - pub from_row: u32, - pub from_col: u32, - pub to_row: u32, - pub to_col: u32, - pub client_action_id: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearRetryLevelRequest { - pub client_action_id: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearNextLevelRequest { - pub client_action_id: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleClearTimeUpRequest { - pub client_action_id: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn workspace_create_request_uses_camel_case() { - let payload = PuzzleClearWorkspaceCreateRequest { - template_id: "puzzle-clear".to_string(), - work_title: "花园拼消消".to_string(), - work_description: "轻松消除".to_string(), - theme_prompt: "春日花园".to_string(), - board_background_prompt: "樱花庭院".to_string(), - generate_board_background: true, - board_background_asset: None, - }; - - let value = serde_json::to_value(payload).expect("request should serialize"); - - assert_eq!(value["templateId"], json!("puzzle-clear")); - assert_eq!(value["themePrompt"], json!("春日花园")); - assert_eq!(value["boardBackgroundPrompt"], json!("樱花庭院")); - assert_eq!(value["generateBoardBackground"], json!(true)); - } - - #[test] - fn runtime_swap_request_uses_camel_case() { - let payload = PuzzleClearSwapRequest { - from_row: 1, - from_col: 2, - to_row: 1, - to_col: 3, - client_action_id: "swap-1".to_string(), - }; - - let value = serde_json::to_value(payload).expect("request should serialize"); - - assert_eq!(value["fromRow"], json!(1)); - assert_eq!(value["toCol"], json!(3)); - assert_eq!(value["clientActionId"], json!("swap-1")); - } -} diff --git a/server-rs/crates/shared-contracts/src/puzzle_creative_template.rs b/server-rs/crates/shared-contracts/src/puzzle_creative_template.rs deleted file mode 100644 index eebccec1e..000000000 --- a/server-rs/crates/shared-contracts/src/puzzle_creative_template.rs +++ /dev/null @@ -1,230 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum PuzzleTemplatePricingUnit { - Point, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum PuzzleSupportedLevelMode { - Single, - Multi, - SingleOrMulti, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum PuzzleLevelGenerationMode { - SingleLevel, - MultiLevel, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleTemplateCostRange { - pub min_points: u32, - pub max_points: u32, - pub pricing_unit: PuzzleTemplatePricingUnit, - pub reason: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub enum PuzzleDraftEditableFieldPath { - #[serde(rename = "workTitle")] - WorkTitle, - #[serde(rename = "workDescription")] - WorkDescription, - #[serde(rename = "workTags")] - WorkTags, - #[serde(rename = "levels[].levelName")] - LevelName, - #[serde(rename = "levels[].pictureDescription")] - LevelPictureDescription, - #[serde(rename = "levels[].pictureReference")] - LevelPictureReference, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleTemplateImageGenerationPolicy { - pub allow_uploaded_image_directly: bool, - pub allow_generated_images: bool, - pub allow_per_level_reference_image: bool, - pub default_candidate_count_per_level: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleCreativeTemplateProtocol { - pub template_id: String, - pub title: String, - pub summary: String, - #[serde(default)] - pub preview_image_src: Option, - pub supported_level_mode: PuzzleSupportedLevelMode, - pub min_level_count: u32, - pub max_level_count: u32, - pub default_level_count: u32, - pub cost_range: PuzzleTemplateCostRange, - pub required_draft_fields: Vec, - pub image_policy: PuzzleTemplateImageGenerationPolicy, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleCreativeTemplateSelection { - pub template_id: String, - pub title: String, - pub reason: String, - pub cost_range: PuzzleTemplateCostRange, - pub supported_level_mode: PuzzleSupportedLevelMode, - pub selected_level_mode: PuzzleLevelGenerationMode, - pub planned_level_count: u32, - pub requires_user_confirmation: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativePuzzleLevelDraftInput { - pub level_name: String, - pub picture_description: String, - /// 任务 A 冻结:Phase 1 采用正式字段方案,后续拼图草稿落地需补正式 pictureReference 字段。 - #[serde(default)] - pub picture_reference: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreativePuzzleDraftToolInput { - pub template_id: String, - pub template_cost_range: PuzzleTemplateCostRange, - pub work_title: String, - pub work_description: String, - pub work_tags: Vec, - pub levels: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleImageGenerationPlanLevel { - pub level_id: String, - pub level_name: String, - pub picture_description: String, - pub image_prompt: String, - #[serde(default)] - pub picture_reference: Option, - pub candidate_count: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleImageGenerationPlan { - pub mode: PuzzleLevelGenerationMode, - pub template_id: String, - pub estimated_cost_range: PuzzleTemplateCostRange, - pub levels: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum PuzzleDraftFieldPatchOperation { - Set, - Append, - Replace, - Remove, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleDraftFieldPatch { - pub field_path: PuzzleDraftEditableFieldPath, - pub operation: PuzzleDraftFieldPatchOperation, - #[serde(default)] - pub level_id: Option, - pub value: serde_json::Value, - pub rationale: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn cost_range() -> PuzzleTemplateCostRange { - PuzzleTemplateCostRange { - min_points: 2, - max_points: 12, - pricing_unit: PuzzleTemplatePricingUnit::Point, - reason: "按关卡数和每关图片生成次数估算".to_string(), - } - } - - #[test] - fn creative_agent_puzzle_template_protocol_uses_camel_case() { - let payload = serde_json::to_value(PuzzleCreativeTemplateProtocol { - template_id: "puzzle.default-creative".to_string(), - title: "创意拼图".to_string(), - summary: "把图文灵感做成拼图".to_string(), - preview_image_src: None, - supported_level_mode: PuzzleSupportedLevelMode::SingleOrMulti, - min_level_count: 1, - max_level_count: 6, - default_level_count: 1, - cost_range: cost_range(), - required_draft_fields: vec![ - PuzzleDraftEditableFieldPath::WorkTitle, - PuzzleDraftEditableFieldPath::LevelPictureReference, - ], - image_policy: PuzzleTemplateImageGenerationPolicy { - allow_uploaded_image_directly: true, - allow_generated_images: true, - allow_per_level_reference_image: true, - default_candidate_count_per_level: 1, - }, - }) - .expect("template should serialize"); - - assert_eq!(payload["templateId"], json!("puzzle.default-creative")); - assert_eq!(payload["previewImageSrc"], json!(null)); - assert_eq!(payload["supportedLevelMode"], json!("single_or_multi")); - assert_eq!(payload["costRange"]["pricingUnit"], json!("point")); - assert_eq!( - payload["requiredDraftFields"], - json!(["workTitle", "levels[].pictureReference"]) - ); - assert_eq!( - payload["imagePolicy"]["allowPerLevelReferenceImage"], - json!(true) - ); - } - - #[test] - fn creative_agent_puzzle_image_plan_roundtrips() { - let plan = PuzzleImageGenerationPlan { - mode: PuzzleLevelGenerationMode::MultiLevel, - template_id: "puzzle.default-creative".to_string(), - estimated_cost_range: cost_range(), - levels: vec![PuzzleImageGenerationPlanLevel { - level_id: "level-1".to_string(), - level_name: "第一关".to_string(), - picture_description: "温暖的家庭照片".to_string(), - image_prompt: "pixel puzzle, warm family photo".to_string(), - picture_reference: Some("asset-ref-1".to_string()), - candidate_count: 1, - }], - }; - - let payload = serde_json::to_value(&plan).expect("plan should serialize"); - assert_eq!(payload["mode"], json!("multi_level")); - assert_eq!( - payload["levels"][0]["pictureReference"], - json!("asset-ref-1") - ); - - let decoded: PuzzleImageGenerationPlan = - serde_json::from_value(payload).expect("plan should deserialize"); - assert_eq!(decoded, plan); - } -} diff --git a/server-rs/crates/shared-contracts/src/puzzle_gallery.rs b/server-rs/crates/shared-contracts/src/puzzle_gallery.rs deleted file mode 100644 index ecf891491..000000000 --- a/server-rs/crates/shared-contracts/src/puzzle_gallery.rs +++ /dev/null @@ -1,30 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::puzzle_works::{PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleGalleryResponse { - pub items: Vec, - #[serde(default)] - pub preview_refs: Vec, - #[serde(default)] - pub has_more: bool, - #[serde(default)] - pub next_cursor: Option, - #[serde(default)] - pub total_count: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleGalleryWorkRefResponse { - pub work_id: String, - pub profile_id: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleGalleryDetailResponse { - pub item: PuzzleWorkProfileResponse, -} diff --git a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs deleted file mode 100644 index 8c5796e09..000000000 --- a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs +++ /dev/null @@ -1,204 +0,0 @@ -use crate::creation_audio::CreationAudioAsset; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StartPuzzleRunRequest { - pub profile_id: String, - #[serde(default)] - pub level_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SwapPuzzlePiecesRequest { - pub first_piece_id: String, - pub second_piece_id: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct DragPuzzlePieceRequest { - pub piece_id: String, - pub target_row: u32, - pub target_col: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct AdvancePuzzleNextLevelRequest { - #[serde(default)] - pub target_profile_id: Option, - #[serde(default)] - pub prefer_similar_work: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct UsePuzzleRuntimePropRequest { - pub prop_kind: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct UpdatePuzzleRuntimePauseRequest { - pub paused: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SubmitPuzzleLeaderboardRequest { - pub profile_id: String, - pub grid_size: u32, - pub elapsed_ms: u64, - pub nickname: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleCellPositionResponse { - pub row: u32, - pub col: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzlePieceStateResponse { - pub piece_id: String, - pub correct_row: u32, - pub correct_col: u32, - pub current_row: u32, - pub current_col: u32, - #[serde(default)] - pub merged_group_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleMergedGroupStateResponse { - pub group_id: String, - pub piece_ids: Vec, - pub occupied_cells: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleLeaderboardEntryResponse { - pub rank: u32, - pub nickname: String, - pub elapsed_ms: u64, - #[serde(default)] - pub visible_tags: Vec, - #[serde(default)] - pub is_current_player: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleBoardSnapshotResponse { - pub rows: u32, - pub cols: u32, - pub pieces: Vec, - pub merged_groups: Vec, - #[serde(default)] - pub selected_piece_id: Option, - pub all_tiles_resolved: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleRuntimeLevelSnapshotResponse { - pub run_id: String, - pub level_index: u32, - #[serde(default)] - pub level_id: Option, - pub grid_size: u32, - pub profile_id: String, - pub level_name: String, - pub author_display_name: String, - pub theme_tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub ui_background_image_src: Option, - #[serde(default)] - pub ui_background_image_object_key: Option, - #[serde(default)] - pub level_background_image_src: Option, - #[serde(default)] - pub level_background_image_object_key: Option, - #[serde(default)] - pub ui_spritesheet_image_src: Option, - #[serde(default)] - pub ui_spritesheet_image_object_key: Option, - #[serde(default)] - pub background_music: Option, - pub board: PuzzleBoardSnapshotResponse, - pub status: String, - #[serde(default)] - pub started_at_ms: u64, - #[serde(default)] - pub cleared_at_ms: Option, - #[serde(default)] - pub elapsed_ms: Option, - #[serde(default)] - pub time_limit_ms: u64, - #[serde(default)] - pub remaining_ms: u64, - #[serde(default)] - pub paused_accumulated_ms: u64, - #[serde(default)] - pub pause_started_at_ms: Option, - #[serde(default)] - pub freeze_accumulated_ms: u64, - #[serde(default)] - pub freeze_started_at_ms: Option, - #[serde(default)] - pub freeze_until_ms: Option, - #[serde(default)] - pub leaderboard_entries: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleRecommendedNextWorkResponse { - pub profile_id: String, - pub level_name: String, - pub author_display_name: String, - pub theme_tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - pub similarity_score: f32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleRunSnapshotResponse { - pub run_id: String, - pub entry_profile_id: String, - pub cleared_level_count: u32, - pub current_level_index: u32, - pub current_grid_size: u32, - pub played_profile_ids: Vec, - pub previous_level_tags: Vec, - #[serde(default)] - pub current_level: Option, - #[serde(default)] - pub recommended_next_profile_id: Option, - #[serde(default)] - pub next_level_mode: String, - #[serde(default)] - pub next_level_profile_id: Option, - #[serde(default)] - pub next_level_id: Option, - #[serde(default)] - pub recommended_next_works: Vec, - #[serde(default)] - pub leaderboard_entries: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleRunResponse { - pub run: PuzzleRunSnapshotResponse, -} diff --git a/server-rs/crates/shared-contracts/src/puzzle_works.rs b/server-rs/crates/shared-contracts/src/puzzle_works.rs deleted file mode 100644 index 8eb2afe0f..000000000 --- a/server-rs/crates/shared-contracts/src/puzzle_works.rs +++ /dev/null @@ -1,153 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::puzzle_agent::{PuzzleAnchorPackResponse, PuzzleDraftLevelResponse}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PutPuzzleWorkRequest { - pub work_title: String, - pub work_description: String, - pub level_name: String, - pub summary: String, - pub theme_tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub cover_asset_id: Option, - #[serde(default)] - pub levels: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleWorkSummaryResponse { - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - #[serde(default)] - pub source_session_id: Option, - pub author_display_name: String, - pub work_title: String, - pub work_description: String, - pub level_name: String, - pub summary: String, - pub theme_tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub cover_asset_id: Option, - pub publication_status: String, - pub updated_at: String, - #[serde(default)] - pub published_at: Option, - pub play_count: u32, - #[serde(default)] - pub remix_count: u32, - #[serde(default)] - pub like_count: u32, - #[serde(default)] - pub recent_play_count_7d: u32, - #[serde(default)] - pub point_incentive_total_half_points: u64, - #[serde(default)] - pub point_incentive_claimed_points: u64, - #[serde(default)] - pub point_incentive_total_points: f64, - #[serde(default)] - pub point_incentive_claimable_points: u64, - pub publish_ready: bool, - #[serde(default)] - pub generation_status: Option, - #[serde(default)] - pub levels: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn puzzle_work_summary_response_uses_point_incentive_fields() { - let payload = serde_json::to_value(PuzzleWorkSummaryResponse { - work_id: "work-1".to_string(), - profile_id: "profile-1".to_string(), - owner_user_id: "user-1".to_string(), - source_session_id: None, - author_display_name: "作者".to_string(), - work_title: "作品".to_string(), - work_description: "描述".to_string(), - level_name: "第一关".to_string(), - summary: "画面".to_string(), - theme_tags: vec!["拼图".to_string(), "夜色".to_string(), "灯光".to_string()], - cover_image_src: None, - cover_asset_id: None, - publication_status: "published".to_string(), - updated_at: "2026-05-01T00:00:00Z".to_string(), - published_at: Some("2026-05-01T00:00:00Z".to_string()), - play_count: 1, - remix_count: 0, - like_count: 0, - recent_play_count_7d: 1, - point_incentive_total_half_points: 3, - point_incentive_claimed_points: 1, - point_incentive_total_points: 1.5, - point_incentive_claimable_points: 0, - publish_ready: true, - generation_status: Some("ready".to_string()), - levels: Vec::new(), - }) - .expect("payload should serialize"); - - assert_eq!(payload["pointIncentiveTotalHalfPoints"], 3); - assert_eq!(payload["pointIncentiveClaimedPoints"], 1); - assert_eq!(payload["pointIncentiveTotalPoints"], 1.5); - assert_eq!(payload["pointIncentiveClaimablePoints"], 0); - assert_eq!(payload["generationStatus"], "ready"); - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleWorkProfileResponse { - #[serde(flatten)] - pub summary: PuzzleWorkSummaryResponse, - pub anchor_pack: PuzzleAnchorPackResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleWorksResponse { - pub items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleWorkDetailResponse { - pub item: PuzzleWorkProfileResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleWorkMutationResponse { - pub item: PuzzleWorkProfileResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleOnboardingGenerateRequest { - pub prompt_text: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleOnboardingGenerateResponse { - pub item: PuzzleWorkSummaryResponse, - pub level: PuzzleDraftLevelResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PuzzleOnboardingSaveRequest { - pub prompt_text: String, - pub item: PuzzleWorkSummaryResponse, -} diff --git a/server-rs/crates/shared-contracts/src/runtime_story.rs b/server-rs/crates/shared-contracts/src/runtime_story.rs deleted file mode 100644 index 7f0365bd3..000000000 --- a/server-rs/crates/shared-contracts/src/runtime_story.rs +++ /dev/null @@ -1,651 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryChoiceAction { - #[serde(rename = "type")] - pub action_type: String, - pub function_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub payload: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryActionRequest { - pub session_id: String, - #[serde(default)] - pub client_version: Option, - pub action: RuntimeStoryChoiceAction, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryAiRequestOptions { - #[serde(default)] - pub available_options: Vec, - #[serde(default)] - pub option_catalog: Vec, -} - -impl Default for RuntimeStoryAiRequestOptions { - fn default() -> Self { - Self { - available_options: Vec::new(), - option_catalog: Vec::new(), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryAiRequest { - #[serde(default)] - pub session_id: Option, - #[serde(default)] - pub client_version: Option, - #[serde(default)] - pub world_type: String, - #[serde(default)] - pub character: Value, - #[serde(default)] - pub monsters: Vec, - #[serde(default)] - pub history: Vec, - #[serde(default)] - pub choice: String, - #[serde(default)] - pub context: Value, - #[serde(default)] - pub request_options: RuntimeStoryAiRequestOptions, - #[serde(default)] - pub last_function_id: Option, - #[serde(default)] - pub observe_signs_requested: bool, - #[serde(default)] - pub recent_action_result: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryAiResponse { - pub story_text: String, - pub options: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub encounter: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryOptionView { - pub function_id: String, - pub action_text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub detail_text: Option, - pub scope: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub interaction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub payload: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum RuntimeStoryOptionInteraction { - #[serde(rename_all = "camelCase")] - Npc { - npc_id: String, - action: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - quest_id: Option, - }, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryPlayerViewModel { - pub hp: i32, - pub max_hp: i32, - pub mana: i32, - pub max_mana: i32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryCompanionViewModel { - pub npc_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub character_id: Option, - pub joined_at_affinity: i32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryEncounterViewModel { - pub id: String, - pub kind: String, - pub npc_name: String, - pub hostile: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub affinity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub recruited: Option, - pub interaction_active: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub battle_mode: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryStatusViewModel { - pub in_battle: bool, - pub npc_interaction_active: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_npc_battle_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_npc_battle_outcome: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryInventoryActionView { - pub function_id: String, - pub action_text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub payload: Option, - pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryInventoryItemActionsView { - #[serde(rename = "use")] - pub use_item: RuntimeStoryInventoryActionView, - pub equip: RuntimeStoryInventoryActionView, - pub dismantle: RuntimeStoryInventoryActionView, - pub reforge: RuntimeStoryInventoryActionView, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryInventoryItemView { - pub item: Value, - pub actions: RuntimeStoryInventoryItemActionsView, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryEquipmentSlotView { - pub slot_id: String, - pub label: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub item: Option, - pub unequip: RuntimeStoryInventoryActionView, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryForgeRequirementView { - pub id: String, - pub label: String, - pub quantity: i32, - pub owned: i32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryForgeRecipeView { - pub id: String, - pub name: String, - pub kind: String, - pub description: String, - pub result_label: String, - pub currency_cost: i32, - pub currency_text: String, - pub requirements: Vec, - pub can_craft: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disabled_reason: Option, - pub action: RuntimeStoryInventoryActionView, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryInventoryViewModel { - pub player_currency: i32, - pub currency_text: String, - pub in_battle: bool, - pub backpack_items: Vec, - pub equipment_slots: Vec, - pub forge_recipes: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeNpcTradeItemView { - pub item_id: String, - pub item: Value, - pub mode: String, - pub unit_price: i32, - pub max_quantity: i32, - pub can_submit: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeNpcGiftItemView { - pub item_id: String, - pub item: Value, - pub affinity_gain: i32, - pub can_submit: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeNpcTradeView { - pub buy_items: Vec, - pub sell_items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeNpcGiftView { - pub items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeNpcInteractionView { - pub npc_id: String, - pub npc_name: String, - pub player_currency: i32, - pub currency_name: String, - pub trade: RuntimeNpcTradeView, - pub gift: RuntimeNpcGiftView, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeBattlePresentation { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub damage_dealt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub damage_taken: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub outcome: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryViewModel { - pub player: RuntimeStoryPlayerViewModel, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub encounter: Option, - pub companions: Vec, - pub inventory: RuntimeStoryInventoryViewModel, - pub available_options: Vec, - pub status: RuntimeStoryStatusViewModel, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub npc_interaction: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeStoryPresentation { - pub action_text: String, - pub result_text: String, - pub story_text: String, - pub options: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub toast: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub battle: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum RuntimeStoryPatch { - #[serde(rename_all = "camelCase")] - StoryHistoryAppend { - action_text: String, - result_text: String, - }, - #[serde(rename_all = "camelCase")] - NpcAffinityChanged { - npc_id: String, - previous_affinity: i32, - next_affinity: i32, - }, - #[serde(rename_all = "camelCase")] - BattleResolved { - function_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - target_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - damage_dealt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - damage_taken: Option, - outcome: String, - }, - #[serde(rename_all = "camelCase")] - StatusChanged { - in_battle: bool, - npc_interaction_active: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - current_npc_battle_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - current_npc_battle_outcome: Option, - }, - #[serde(rename_all = "camelCase")] - EncounterChanged { - #[serde(default, skip_serializing_if = "Option::is_none")] - encounter_id: Option, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn runtime_story_action_request_uses_camel_case_fields() { - let payload = serde_json::to_value(RuntimeStoryActionRequest { - session_id: "runtime-main".to_string(), - client_version: Some(8), - action: RuntimeStoryChoiceAction { - action_type: "story_choice".to_string(), - function_id: "npc_chat".to_string(), - target_id: Some("npc_camp_firekeeper".to_string()), - payload: Some(json!({ "optionText": "继续交谈" })), - }, - }) - .expect("payload should serialize"); - - assert_eq!(payload["sessionId"], json!("runtime-main")); - assert_eq!(payload["clientVersion"], json!(8)); - assert_eq!(payload["action"]["type"], json!("story_choice")); - assert_eq!(payload["action"]["functionId"], json!("npc_chat")); - assert_eq!(payload["action"]["targetId"], json!("npc_camp_firekeeper")); - } - - #[test] - fn runtime_story_ai_request_defaults_optional_arrays() { - let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({ - "worldType": "martial", - "character": { "name": "林迟" }, - "context": { "scene": "camp" } - })) - .expect("payload should deserialize"); - - assert_eq!(payload.world_type, "martial"); - assert!(payload.monsters.is_empty()); - assert!(payload.history.is_empty()); - assert!(payload.request_options.available_options.is_empty()); - } - - #[test] - fn runtime_story_ai_request_accepts_session_only_payload() { - let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({ - "sessionId": "runtime-main", - "clientVersion": 3, - "choice": "继续向前", - "lastFunctionId": "idle_explore_forward", - "requestOptions": { - "optionCatalog": [{ - "functionId": "idle_observe_signs", - "actionText": "观察周围迹象" - }] - } - })) - .expect("payload should deserialize"); - - assert_eq!(payload.session_id.as_deref(), Some("runtime-main")); - assert_eq!(payload.client_version, Some(3)); - assert_eq!(payload.world_type, ""); - assert_eq!(payload.context, Value::Null); - assert_eq!( - payload.last_function_id.as_deref(), - Some("idle_explore_forward") - ); - assert_eq!(payload.request_options.option_catalog.len(), 1); - } - - #[test] - fn runtime_story_presentation_uses_camel_case_fields() { - let view_model = RuntimeStoryViewModel { - player: RuntimeStoryPlayerViewModel { - hp: 32, - max_hp: 40, - mana: 18, - max_mana: 20, - }, - encounter: Some(RuntimeStoryEncounterViewModel { - id: "npc_camp_firekeeper".to_string(), - kind: "npc".to_string(), - npc_name: "守火人".to_string(), - hostile: false, - affinity: Some(12), - recruited: Some(false), - interaction_active: true, - battle_mode: None, - }), - companions: vec![RuntimeStoryCompanionViewModel { - npc_id: "npc_companion_001".to_string(), - character_id: Some("char_companion_001".to_string()), - joined_at_affinity: 64, - }], - inventory: RuntimeStoryInventoryViewModel { - player_currency: 80, - currency_text: "80 铜钱".to_string(), - in_battle: false, - backpack_items: vec![RuntimeStoryInventoryItemView { - item: json!({ - "id": "potion-1", - "name": "疗伤药", - "category": "消耗品", - "quantity": 2, - "rarity": "common", - "tags": ["healing"] - }), - actions: RuntimeStoryInventoryItemActionsView { - use_item: RuntimeStoryInventoryActionView { - function_id: "inventory_use".to_string(), - action_text: "使用疗伤药".to_string(), - payload: Some(json!({ "itemId": "potion-1" })), - enabled: true, - reason: None, - }, - equip: RuntimeStoryInventoryActionView { - function_id: "equipment_equip".to_string(), - action_text: "装备疗伤药".to_string(), - payload: Some(json!({ "itemId": "potion-1" })), - enabled: false, - reason: Some("该物品不能装备。".to_string()), - }, - dismantle: RuntimeStoryInventoryActionView { - function_id: "forge_dismantle".to_string(), - action_text: "拆解疗伤药".to_string(), - payload: Some(json!({ "itemId": "potion-1" })), - enabled: false, - reason: Some("该物品不能拆解。".to_string()), - }, - reforge: RuntimeStoryInventoryActionView { - function_id: "forge_reforge".to_string(), - action_text: "重铸疗伤药".to_string(), - payload: Some(json!({ "itemId": "potion-1" })), - enabled: false, - reason: Some("该物品不能重铸。".to_string()), - }, - }, - }], - equipment_slots: vec![RuntimeStoryEquipmentSlotView { - slot_id: "weapon".to_string(), - label: "武器".to_string(), - item: None, - unequip: RuntimeStoryInventoryActionView { - function_id: "equipment_unequip".to_string(), - action_text: "卸下武器".to_string(), - payload: Some(json!({ "slotId": "weapon" })), - enabled: false, - reason: Some("武器位当前没有装备。".to_string()), - }, - }], - forge_recipes: vec![RuntimeStoryForgeRecipeView { - id: "synthesis-refined-ingot".to_string(), - name: "压炼锭材".to_string(), - kind: "synthesis".to_string(), - description: "把零散残片和基础材料压成稳定可用的金属锭材。".to_string(), - result_label: "精炼锭材".to_string(), - currency_cost: 18, - currency_text: "18 铜钱".to_string(), - requirements: vec![RuntimeStoryForgeRequirementView { - id: "material:any".to_string(), - label: "任意材料".to_string(), - quantity: 3, - owned: 0, - }], - can_craft: false, - disabled_reason: Some("材料不足。".to_string()), - action: RuntimeStoryInventoryActionView { - function_id: "forge_craft".to_string(), - action_text: "制作精炼锭材".to_string(), - payload: Some(json!({ "recipeId": "synthesis-refined-ingot" })), - enabled: false, - reason: Some("材料不足。".to_string()), - }, - }], - }, - available_options: vec![RuntimeStoryOptionView { - function_id: "npc_chat".to_string(), - action_text: "继续交谈".to_string(), - detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), - scope: "npc".to_string(), - interaction: Some(RuntimeStoryOptionInteraction::Npc { - npc_id: "npc_camp_firekeeper".to_string(), - action: "chat".to_string(), - quest_id: None, - }), - payload: Some(json!({ "note": "server-runtime-test" })), - disabled: None, - reason: None, - }], - status: RuntimeStoryStatusViewModel { - in_battle: false, - npc_interaction_active: true, - current_npc_battle_mode: None, - current_npc_battle_outcome: None, - }, - npc_interaction: Some(RuntimeNpcInteractionView { - npc_id: "npc_camp_firekeeper".to_string(), - npc_name: "守火人".to_string(), - player_currency: 80, - currency_name: "铜钱".to_string(), - trade: RuntimeNpcTradeView { - buy_items: vec![RuntimeNpcTradeItemView { - item_id: "npc-potion".to_string(), - item: json!({ - "id": "npc-potion", - "name": "疗伤药", - "category": "消耗品", - "quantity": 2, - "rarity": "common", - "tags": ["healing"] - }), - mode: "buy".to_string(), - unit_price: 20, - max_quantity: 2, - can_submit: true, - reason: None, - }], - sell_items: Vec::new(), - }, - gift: RuntimeNpcGiftView { - items: vec![RuntimeNpcGiftItemView { - item_id: "potion-1".to_string(), - item: json!({ - "id": "potion-1", - "name": "疗伤药", - "category": "消耗品", - "quantity": 2, - "rarity": "common", - "tags": ["healing"] - }), - affinity_gain: 10, - can_submit: true, - reason: None, - }], - }, - }), - }; - let presentation = RuntimeStoryPresentation { - action_text: "".to_string(), - result_text: "".to_string(), - story_text: "守火人抬眼看了你一瞬,示意你把想问的话继续说完。".to_string(), - options: vec![RuntimeStoryOptionView { - function_id: "npc_chat".to_string(), - action_text: "继续交谈".to_string(), - detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), - scope: "npc".to_string(), - interaction: Some(RuntimeStoryOptionInteraction::Npc { - npc_id: "npc_camp_firekeeper".to_string(), - action: "chat".to_string(), - quest_id: None, - }), - payload: Some(json!({ "note": "server-runtime-test" })), - disabled: None, - reason: None, - }], - toast: None, - battle: None, - }; - let patches = vec![RuntimeStoryPatch::StatusChanged { - in_battle: false, - npc_interaction_active: true, - current_npc_battle_mode: None, - current_npc_battle_outcome: None, - }]; - let payload = serde_json::to_value(json!({ - "viewModel": view_model, - "presentation": presentation, - "patches": patches - })) - .expect("payload should serialize"); - - assert_eq!(payload["viewModel"]["player"]["maxHp"], json!(40)); - assert_eq!( - payload["viewModel"]["availableOptions"][0]["interaction"]["npcId"], - json!("npc_camp_firekeeper") - ); - assert_eq!( - payload["viewModel"]["inventory"]["backpackItems"][0]["actions"]["use"]["functionId"], - json!("inventory_use") - ); - assert_eq!( - payload["viewModel"]["inventory"]["forgeRecipes"][0]["canCraft"], - json!(false) - ); - assert_eq!( - payload["presentation"]["storyText"], - json!("守火人抬眼看了你一瞬,示意你把想问的话继续说完。") - ); - assert_eq!(payload["patches"][0]["type"], json!("status_changed")); - } -} diff --git a/server-rs/crates/shared-contracts/src/square_hole_agent.rs b/server-rs/crates/shared-contracts/src/square_hole_agent.rs deleted file mode 100644 index 8c1dc8f7c..000000000 --- a/server-rs/crates/shared-contracts/src/square_hole_agent.rs +++ /dev/null @@ -1,171 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreateSquareHoleSessionRequest { - #[serde(default)] - pub seed_text: Option, - #[serde(default)] - pub theme_text: Option, - #[serde(default)] - pub twist_rule: Option, - #[serde(default)] - pub shape_count: Option, - #[serde(default)] - pub difficulty: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SendSquareHoleMessageRequest { - pub client_message_id: String, - pub text: String, - #[serde(default)] - pub quick_fill_requested: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ExecuteSquareHoleActionRequest { - pub action: String, - #[serde(default)] - pub game_name: Option, - #[serde(default)] - pub summary: Option, - #[serde(default)] - pub tags: Option>, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub regenerate_visual_assets: Option, - #[serde(default)] - pub visual_asset_slot: Option, - #[serde(default)] - pub visual_asset_option_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleShapeOptionResponse { - pub option_id: String, - pub shape_kind: String, - pub label: String, - pub target_hole_id: String, - pub image_prompt: String, - #[serde(default)] - pub image_src: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleHoleOptionResponse { - pub hole_id: String, - pub hole_kind: String, - pub label: String, - pub image_prompt: String, - #[serde(default)] - pub image_src: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleAnchorItemResponse { - pub key: String, - pub label: String, - pub value: String, - pub status: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleAnchorPackResponse { - pub theme: SquareHoleAnchorItemResponse, - pub twist_rule: SquareHoleAnchorItemResponse, - pub shape_count: SquareHoleAnchorItemResponse, - pub difficulty: SquareHoleAnchorItemResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleCreatorConfigResponse { - pub theme_text: String, - pub twist_rule: String, - pub shape_count: u32, - pub difficulty: u32, - #[serde(default)] - pub shape_options: Vec, - #[serde(default)] - pub hole_options: Vec, - #[serde(default)] - pub background_prompt: String, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub background_image_src: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleResultDraftResponse { - pub profile_id: String, - pub game_name: String, - pub theme_text: String, - pub twist_rule: String, - pub summary: String, - pub tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub background_prompt: String, - #[serde(default)] - pub background_image_src: Option, - #[serde(default)] - pub shape_options: Vec, - #[serde(default)] - pub hole_options: Vec, - pub shape_count: u32, - pub difficulty: u32, - pub publish_ready: bool, - pub blockers: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleAgentMessageResponse { - pub id: String, - pub role: String, - pub kind: String, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleSessionSnapshotResponse { - pub session_id: String, - pub current_turn: u32, - pub progress_percent: u32, - pub stage: String, - pub anchor_pack: SquareHoleAnchorPackResponse, - pub config: SquareHoleCreatorConfigResponse, - #[serde(default)] - pub draft: Option, - pub messages: Vec, - #[serde(default)] - pub last_assistant_reply: Option, - #[serde(default)] - pub published_profile_id: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleSessionResponse { - pub session: SquareHoleSessionSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleActionResponse { - pub session: SquareHoleSessionSnapshotResponse, -} diff --git a/server-rs/crates/shared-contracts/src/square_hole_runtime.rs b/server-rs/crates/shared-contracts/src/square_hole_runtime.rs deleted file mode 100644 index c9c42acf8..000000000 --- a/server-rs/crates/shared-contracts/src/square_hole_runtime.rs +++ /dev/null @@ -1,96 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StartSquareHoleRunRequest { - pub profile_id: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct DropSquareHoleShapeRequest { - #[serde(default)] - pub run_id: Option, - pub hole_id: String, - pub client_snapshot_version: u64, - pub client_event_id: String, - pub dropped_at_ms: u64, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StopSquareHoleRunRequest { - pub client_action_id: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleShapeSnapshotResponse { - pub shape_id: String, - pub shape_kind: String, - pub label: String, - pub target_hole_id: String, - pub color: String, - #[serde(default)] - pub image_src: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleHoleSnapshotResponse { - pub hole_id: String, - pub hole_kind: String, - pub label: String, - pub x: f32, - pub y: f32, - #[serde(default)] - pub image_src: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleDropFeedbackResponse { - pub accepted: bool, - #[serde(default)] - pub reject_reason: Option, - pub message: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleRunSnapshotResponse { - pub run_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub status: String, - pub snapshot_version: u64, - pub started_at_ms: u64, - pub duration_limit_ms: u64, - pub remaining_ms: u64, - pub total_shape_count: u32, - pub completed_shape_count: u32, - pub combo: u32, - pub best_combo: u32, - pub score: u32, - pub rule_label: String, - #[serde(default)] - pub background_image_src: Option, - #[serde(default)] - pub current_shape: Option, - pub holes: Vec, - #[serde(default)] - pub last_feedback: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleRunResponse { - pub run: SquareHoleRunSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleDropResponse { - pub feedback: SquareHoleDropFeedbackResponse, - pub run: SquareHoleRunSnapshotResponse, -} diff --git a/server-rs/crates/shared-contracts/src/square_hole_works.rs b/server-rs/crates/shared-contracts/src/square_hole_works.rs deleted file mode 100644 index 7e5dd5020..000000000 --- a/server-rs/crates/shared-contracts/src/square_hole_works.rs +++ /dev/null @@ -1,113 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleShapeOptionResponse { - pub option_id: String, - pub shape_kind: String, - pub label: String, - pub target_hole_id: String, - pub image_prompt: String, - #[serde(default)] - pub image_src: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleHoleOptionResponse { - pub hole_id: String, - pub hole_kind: String, - pub label: String, - pub image_prompt: String, - #[serde(default)] - pub image_src: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PutSquareHoleWorkRequest { - pub game_name: String, - #[serde(default)] - pub theme_text: Option, - pub twist_rule: String, - pub summary: String, - pub tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub background_prompt: Option, - #[serde(default)] - pub background_image_src: Option, - #[serde(default)] - pub shape_options: Option>, - #[serde(default)] - pub hole_options: Option>, - pub shape_count: u32, - pub difficulty: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RegenerateSquareHoleWorkImageRequest { - pub visual_asset_slot: String, - #[serde(default)] - pub visual_asset_option_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleWorkSummaryResponse { - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - #[serde(default)] - pub source_session_id: Option, - pub game_name: String, - pub theme_text: String, - pub twist_rule: String, - pub summary: String, - pub tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - #[serde(default)] - pub background_prompt: String, - #[serde(default)] - pub background_image_src: Option, - #[serde(default)] - pub shape_options: Vec, - #[serde(default)] - pub hole_options: Vec, - pub shape_count: u32, - pub difficulty: u32, - pub publication_status: String, - pub play_count: u32, - pub updated_at: String, - #[serde(default)] - pub published_at: Option, - pub publish_ready: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleWorkProfileResponse { - #[serde(flatten)] - pub summary: SquareHoleWorkSummaryResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleWorksResponse { - pub items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleWorkDetailResponse { - pub item: SquareHoleWorkProfileResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SquareHoleWorkMutationResponse { - pub item: SquareHoleWorkProfileResponse, -} diff --git a/server-rs/crates/shared-contracts/src/story.rs b/server-rs/crates/shared-contracts/src/story.rs deleted file mode 100644 index e8bbde543..000000000 --- a/server-rs/crates/shared-contracts/src/story.rs +++ /dev/null @@ -1,705 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct BeginStorySessionRequest { - pub runtime_session_id: String, - pub world_profile_id: String, - pub initial_prompt: String, - #[serde(default)] - pub opening_summary: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct BeginStoryRuntimeSessionRequest { - pub world_type: String, - #[serde(default)] - pub custom_world_profile: Option, - pub character: Value, - #[serde(default)] - pub runtime_mode: Option, - #[serde(default)] - pub disable_persistence: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ContinueStoryRequest { - pub story_session_id: String, - pub narrative_text: String, - #[serde(default)] - pub choice_function_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ResolveStoryRuntimeActionRequest { - pub story_session_id: String, - #[serde(default)] - pub client_version: Option, - pub function_id: String, - pub action_text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub payload: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct StorySessionPayload { - pub story_session_id: String, - pub runtime_session_id: String, - pub actor_user_id: String, - pub world_profile_id: String, - pub initial_prompt: String, - #[serde(default)] - pub opening_summary: Option, - pub latest_narrative_text: String, - #[serde(default)] - pub latest_choice_function_id: Option, - pub status: String, - pub version: u32, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct StoryEventPayload { - pub event_id: String, - pub story_session_id: String, - pub event_kind: String, - pub narrative_text: String, - #[serde(default)] - pub choice_function_id: Option, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct StorySessionMutationResponse { - pub story_session: StorySessionPayload, - pub story_event: StoryEventPayload, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct StorySessionStateResponse { - pub story_session: StorySessionPayload, - pub story_events: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryRuntimeSnapshotPayload { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub saved_at: Option, - pub bottom_tab: String, - pub game_state: Value, - #[serde(default)] - pub current_story: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryRuntimeProjectionRequest { - pub story_session_id: String, - #[serde(default)] - pub client_version: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryRuntimeActorProjection { - pub hp: i32, - pub max_hp: i32, - pub mana: i32, - pub max_mana: i32, - pub currency: i32, - pub currency_text: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryRuntimeInventoryProjection { - pub backpack_items: Vec, - pub equipment_slots: Vec, - pub forge_recipes: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryRuntimeOptionProjection { - pub function_id: String, - pub action_text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub detail_text: Option, - pub scope: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub payload: Option, - pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryRuntimeStatusProjection { - pub in_battle: bool, - pub npc_interaction_active: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_encounter_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_npc_battle_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_npc_battle_outcome: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryRuntimeProjectionResponse { - pub story_session: StorySessionPayload, - pub story_events: Vec, - pub server_version: u32, - pub game_state: Value, - pub actor: StoryRuntimeActorProjection, - pub inventory: StoryRuntimeInventoryProjection, - pub options: Vec, - pub status: StoryRuntimeStatusProjection, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_narrative_text: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub action_result_text: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub toast: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryRuntimeMutationResponse { - pub projection: StoryRuntimeProjectionResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct StoryBattleRewardItemRequest { - pub item_id: String, - pub category: String, - pub item_name: String, - #[serde(default)] - pub description: Option, - pub quantity: u32, - pub rarity: String, - #[serde(default)] - pub tags: Vec, - pub stackable: bool, - #[serde(default)] - pub stack_key: String, - #[serde(default)] - pub equipment_slot_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CreateStoryBattleRequest { - pub story_session_id: String, - pub runtime_session_id: String, - #[serde(default)] - pub chapter_id: Option, - pub target_npc_id: String, - pub target_name: String, - pub battle_mode: String, - pub player_hp: i32, - pub player_max_hp: i32, - pub player_mana: i32, - pub player_max_mana: i32, - pub target_hp: i32, - pub target_max_hp: i32, - #[serde(default)] - pub experience_reward: u32, - #[serde(default)] - pub reward_items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CreateStoryNpcBattleRequest { - pub story_session_id: String, - pub runtime_session_id: String, - pub npc_id: String, - pub npc_name: String, - pub interaction_function_id: String, - #[serde(default)] - pub release_npc_id: Option, - #[serde(default)] - pub battle_state_id: Option, - pub player_hp: i32, - pub player_max_hp: i32, - pub player_mana: i32, - pub player_max_mana: i32, - pub target_hp: i32, - pub target_max_hp: i32, - #[serde(default)] - pub experience_reward: u32, - #[serde(default)] - pub reward_items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ResolveStoryBattleRequest { - pub battle_state_id: String, - pub function_id: String, - pub action_text: String, - pub base_damage: i32, - pub mana_cost: i32, - pub heal: i32, - pub mana_restore: i32, - pub counter_multiplier_basis_points: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryBattleRewardItemPayload { - pub item_id: String, - pub category: String, - pub item_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - pub quantity: u32, - pub rarity: String, - pub tags: Vec, - pub stackable: bool, - pub stack_key: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub equipment_slot_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryBattleStatePayload { - pub battle_state_id: String, - pub story_session_id: String, - pub runtime_session_id: String, - pub actor_user_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub chapter_id: Option, - pub target_npc_id: String, - pub target_name: String, - pub battle_mode: String, - pub status: String, - pub player_hp: i32, - pub player_max_hp: i32, - pub player_mana: i32, - pub player_max_mana: i32, - pub target_hp: i32, - pub target_max_hp: i32, - pub experience_reward: u32, - pub reward_items: Vec, - pub turn_index: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_action_function_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_action_text: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_result_text: Option, - pub last_damage_dealt: i32, - pub last_damage_taken: i32, - pub last_outcome: String, - pub version: u32, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryBattleStateResponse { - pub battle_state: StoryBattleStatePayload, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryCombatActionPayload { - pub damage_dealt: i32, - pub damage_taken: i32, - pub outcome: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ResolveStoryBattleResponse { - pub battle_state: StoryBattleStatePayload, - pub combat: StoryCombatActionPayload, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryNpcStanceProfilePayload { - pub trust: u8, - pub warmth: u8, - pub ideological_fit: u8, - pub fear_or_guard: u8, - pub loyalty: u8, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_conflict_tag: Option, - pub recent_approvals: Vec, - pub recent_disapprovals: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryNpcStatePayload { - pub npc_state_id: String, - pub runtime_session_id: String, - pub npc_id: String, - pub npc_name: String, - pub affinity: i32, - pub relation_stance: String, - pub help_used: bool, - pub chatted_count: u32, - pub gifts_given: u32, - pub recruited: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trade_stock_signature: Option, - pub revealed_facts: Vec, - pub known_attribute_rumors: Vec, - pub first_meaningful_contact_resolved: bool, - pub seen_backstory_chapter_ids: Vec, - pub stance_profile: StoryNpcStanceProfilePayload, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoryNpcInteractionPayload { - pub npc_state: StoryNpcStatePayload, - pub interaction_status: String, - pub action_text: String, - pub result_text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub story_text: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub battle_mode: Option, - pub encounter_closed: bool, - pub affinity_changed: bool, - pub previous_affinity: i32, - pub next_affinity: i32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreateStoryNpcBattleResponse { - pub npc_interaction: StoryNpcInteractionPayload, - pub battle_state: StoryBattleStatePayload, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn continue_story_request_uses_camel_case_fields() { - let payload = serde_json::to_value(ContinueStoryRequest { - story_session_id: "storysess_1".to_string(), - narrative_text: "继续前进".to_string(), - choice_function_id: Some("npc_chat".to_string()), - }) - .expect("payload should serialize"); - - assert_eq!( - payload, - json!({ - "storySessionId": "storysess_1", - "narrativeText": "继续前进", - "choiceFunctionId": "npc_chat" - }) - ); - } - - #[test] - fn story_session_mutation_response_uses_camel_case_fields() { - let payload = serde_json::to_value(StorySessionMutationResponse { - story_session: StorySessionPayload { - story_session_id: "storysess_1".to_string(), - runtime_session_id: "runtime_1".to_string(), - actor_user_id: "user_1".to_string(), - world_profile_id: "profile_1".to_string(), - initial_prompt: "进入营地".to_string(), - opening_summary: Some("营地开场".to_string()), - latest_narrative_text: "篝火正在燃烧。".to_string(), - latest_choice_function_id: Some("talk".to_string()), - status: "active".to_string(), - version: 1, - created_at: "1.000000Z".to_string(), - updated_at: "1.000000Z".to_string(), - }, - story_event: StoryEventPayload { - event_id: "storyevt_1".to_string(), - story_session_id: "storysess_1".to_string(), - event_kind: "session_started".to_string(), - narrative_text: "篝火正在燃烧。".to_string(), - choice_function_id: Some("talk".to_string()), - created_at: "1.000000Z".to_string(), - }, - }) - .expect("payload should serialize"); - - assert_eq!( - payload["storySession"]["storySessionId"], - json!("storysess_1") - ); - assert_eq!(payload["storyEvent"]["eventKind"], json!("session_started")); - assert_eq!(payload["storyEvent"]["choiceFunctionId"], json!("talk")); - } - - #[test] - fn story_session_state_response_uses_camel_case_fields() { - let payload = serde_json::to_value(StorySessionStateResponse { - story_session: StorySessionPayload { - story_session_id: "storysess_1".to_string(), - runtime_session_id: "runtime_1".to_string(), - actor_user_id: "user_1".to_string(), - world_profile_id: "profile_1".to_string(), - initial_prompt: "进入营地".to_string(), - opening_summary: Some("营地开场".to_string()), - latest_narrative_text: "你看见篝火边有人招手。".to_string(), - latest_choice_function_id: Some("talk_to_npc".to_string()), - status: "active".to_string(), - version: 2, - created_at: "1.000000Z".to_string(), - updated_at: "2.000000Z".to_string(), - }, - story_events: vec![StoryEventPayload { - event_id: "storyevt_2".to_string(), - story_session_id: "storysess_1".to_string(), - event_kind: "story_continued".to_string(), - narrative_text: "你看见篝火边有人招手。".to_string(), - choice_function_id: Some("talk_to_npc".to_string()), - created_at: "2.000000Z".to_string(), - }], - }) - .expect("payload should serialize"); - - assert_eq!( - payload["storySession"]["latestChoiceFunctionId"], - json!("talk_to_npc") - ); - assert_eq!( - payload["storyEvents"][0]["eventKind"], - json!("story_continued") - ); - } - - #[test] - fn story_runtime_projection_response_uses_new_story_runtime_contract() { - let payload = serde_json::to_value(StoryRuntimeProjectionResponse { - story_session: StorySessionPayload { - story_session_id: "storysess_1".to_string(), - runtime_session_id: "runtime_1".to_string(), - actor_user_id: "user_1".to_string(), - world_profile_id: "profile_1".to_string(), - initial_prompt: "进入营地".to_string(), - opening_summary: Some("营地开场".to_string()), - latest_narrative_text: "你看见篝火边有人招手。".to_string(), - latest_choice_function_id: Some("talk_to_npc".to_string()), - status: "active".to_string(), - version: 2, - created_at: "1.000000Z".to_string(), - updated_at: "2.000000Z".to_string(), - }, - story_events: vec![StoryEventPayload { - event_id: "storyevt_2".to_string(), - story_session_id: "storysess_1".to_string(), - event_kind: "story_continued".to_string(), - narrative_text: "你看见篝火边有人招手。".to_string(), - choice_function_id: Some("talk_to_npc".to_string()), - created_at: "2.000000Z".to_string(), - }], - server_version: 2, - game_state: json!({ - "runtimeSessionId": "runtime_1", - "storySessionId": "storysess_1", - "currentScene": "Story" - }), - actor: StoryRuntimeActorProjection { - hp: 32, - max_hp: 40, - mana: 18, - max_mana: 20, - currency: 80, - currency_text: "80 铜钱".to_string(), - }, - inventory: StoryRuntimeInventoryProjection { - backpack_items: vec![json!({ "id": "potion-1", "name": "疗伤药" })], - equipment_slots: vec![json!({ "slotId": "weapon", "label": "武器" })], - forge_recipes: Vec::new(), - }, - options: vec![StoryRuntimeOptionProjection { - function_id: "npc_chat".to_string(), - action_text: "继续交谈".to_string(), - detail_text: Some("围绕当前话题继续推进关系判断。".to_string()), - scope: "npc".to_string(), - payload: Some(json!({ "npcId": "npc_camp_firekeeper" })), - enabled: true, - reason: None, - }], - status: StoryRuntimeStatusProjection { - in_battle: false, - npc_interaction_active: true, - current_encounter_id: Some("npc_camp_firekeeper".to_string()), - current_npc_battle_mode: None, - current_npc_battle_outcome: None, - }, - current_narrative_text: Some("守火人示意你继续说。".to_string()), - action_result_text: None, - toast: None, - }) - .expect("payload should serialize"); - - assert_eq!( - payload["storySession"]["storySessionId"], - json!("storysess_1") - ); - assert_eq!(payload["serverVersion"], json!(2)); - assert_eq!(payload["gameState"]["storySessionId"], json!("storysess_1")); - assert_eq!(payload["actor"]["maxHp"], json!(40)); - assert_eq!( - payload["inventory"]["backpackItems"][0]["name"], - json!("疗伤药") - ); - assert_eq!(payload["options"][0]["functionId"], json!("npc_chat")); - assert!(payload.get("snapshot").is_none()); - assert!(payload.get("viewModel").is_none()); - assert!(payload.get("presentation").is_none()); - } - - #[test] - fn story_runtime_mutation_response_wraps_projection_only() { - let payload = serde_json::to_value(StoryRuntimeMutationResponse { - projection: StoryRuntimeProjectionResponse { - story_session: StorySessionPayload { - story_session_id: "storysess_1".to_string(), - runtime_session_id: "runtime_1".to_string(), - actor_user_id: "user_1".to_string(), - world_profile_id: "profile_1".to_string(), - initial_prompt: "进入营地".to_string(), - opening_summary: Some("营地开场".to_string()), - latest_narrative_text: "营火还亮着。".to_string(), - latest_choice_function_id: None, - status: "active".to_string(), - version: 1, - created_at: "1.000000Z".to_string(), - updated_at: "1.000000Z".to_string(), - }, - story_events: Vec::new(), - server_version: 1, - game_state: json!({ - "runtimeSessionId": "runtime_1", - "storySessionId": "storysess_1", - "currentScene": "Story" - }), - actor: StoryRuntimeActorProjection { - hp: 40, - max_hp: 40, - mana: 20, - max_mana: 20, - currency: 0, - currency_text: "0 铜钱".to_string(), - }, - inventory: StoryRuntimeInventoryProjection { - backpack_items: Vec::new(), - equipment_slots: Vec::new(), - forge_recipes: Vec::new(), - }, - options: Vec::new(), - status: StoryRuntimeStatusProjection { - in_battle: false, - npc_interaction_active: false, - current_encounter_id: None, - current_npc_battle_mode: None, - current_npc_battle_outcome: None, - }, - current_narrative_text: Some("营火还亮着。".to_string()), - action_result_text: None, - toast: None, - }, - }) - .expect("payload should serialize"); - - assert_eq!( - payload["projection"]["storySession"]["storySessionId"], - json!("storysess_1") - ); - assert_eq!(payload["projection"]["serverVersion"], json!(1)); - assert!(payload.get("snapshot").is_none()); - assert!(payload.get("viewModel").is_none()); - assert!(payload.get("presentation").is_none()); - } - - #[test] - fn story_battle_responses_use_story_contract_shape() { - let battle_state = StoryBattleStatePayload { - battle_state_id: "battle_1".to_string(), - story_session_id: "storysess_1".to_string(), - runtime_session_id: "runtime_1".to_string(), - actor_user_id: "user_1".to_string(), - chapter_id: None, - target_npc_id: "npc_wolf".to_string(), - target_name: "黑爪狼".to_string(), - battle_mode: "fight".to_string(), - status: "active".to_string(), - player_hp: 28, - player_max_hp: 40, - player_mana: 12, - player_max_mana: 20, - target_hp: 18, - target_max_hp: 30, - experience_reward: 12, - reward_items: vec![StoryBattleRewardItemPayload { - item_id: "wolf-fang".to_string(), - category: "material".to_string(), - item_name: "狼牙".to_string(), - description: None, - quantity: 1, - rarity: "common".to_string(), - tags: vec!["beast".to_string()], - stackable: true, - stack_key: "wolf-fang".to_string(), - equipment_slot_id: None, - }], - turn_index: 1, - last_action_function_id: Some("battle_attack_basic".to_string()), - last_action_text: Some("普通攻击".to_string()), - last_result_text: Some("你击中了黑爪狼。".to_string()), - last_damage_dealt: 10, - last_damage_taken: 3, - last_outcome: "ongoing".to_string(), - version: 2, - created_at: "1.000000Z".to_string(), - updated_at: "2.000000Z".to_string(), - }; - - let payload = serde_json::to_value(ResolveStoryBattleResponse { - battle_state, - combat: StoryCombatActionPayload { - damage_dealt: 10, - damage_taken: 3, - outcome: "ongoing".to_string(), - }, - }) - .expect("payload should serialize"); - - assert_eq!(payload["battleState"]["battleStateId"], json!("battle_1")); - assert_eq!( - payload["battleState"]["rewardItems"][0]["itemName"], - json!("狼牙") - ); - assert_eq!(payload["combat"]["damageDealt"], json!(10)); - assert!(payload["battleState"].get("chapterId").is_none()); - } -} diff --git a/server-rs/crates/shared-contracts/src/visual_novel.rs b/server-rs/crates/shared-contracts/src/visual_novel.rs deleted file mode 100644 index f373495a4..000000000 --- a/server-rs/crates/shared-contracts/src/visual_novel.rs +++ /dev/null @@ -1,777 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VisualNovelSourceMode { - Idea, - Document, - Blank, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VisualNovelCharacterRole { - Protagonist, - Main, - Supporting, - Antagonist, - Background, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VisualNovelAssetSource { - PlatformAsset, - Generated, - External, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VisualNovelAudioGenerationKind { - BackgroundMusic, - SoundEffect, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VisualNovelSceneAvailability { - Opening, - Always, - PhaseLocked, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VisualNovelAttributePanelMode { - Off, - PlatformWhitelist, - TemplateConfig, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VisualNovelValidationSeverity { - Error, - Warning, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VisualNovelAgentStatus { - Collecting, - Drafting, - Ready, - Failed, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VisualNovelAgentMessageRole { - User, - Assistant, - System, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VisualNovelAgentMessageKind { - Chat, - Summary, - ActionResult, - Warning, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VisualNovelAgentActionKind { - GenerateDraft, - PatchWorld, - PatchCharacter, - PatchScene, - PatchStoryPhase, - GenerateSceneImage, - GenerateCharacterImage, - CompileWorkProfile, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VisualNovelAgentPhase { - Perception, - Reasoning, - Drafting, - Reflection, - Finalizing, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VisualNovelRunMode { - Test, - Play, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VisualNovelRunStatus { - Active, - Completed, - Failed, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VisualNovelRuntimeActionKind { - Choice, - FreeText, - Continue, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VisualNovelTransitionKind { - Fade, - Cut, - Flash, - None, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VisualNovelHistorySource { - Player, - Assistant, - System, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(untagged)] -pub enum VisualNovelFlagValue { - String(String), - Number(f64), - Bool(bool), -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelDraftPatch { - pub path: String, - pub op: String, - #[serde(default)] - pub value: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelValidationIssue { - pub issue_id: String, - pub code: String, - pub severity: VisualNovelValidationSeverity, - pub path: String, - pub message: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelChoiceDraft { - pub choice_id: String, - pub text: String, - #[serde(default)] - pub action_hint: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelCharacterImageAsset { - pub asset_id: String, - pub image_src: String, - #[serde(default)] - pub expression: Option, - pub source: VisualNovelAssetSource, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelWorldDraft { - pub title: String, - pub summary: String, - pub background: String, - pub premise: String, - pub literary_style: String, - pub player_role: String, - pub default_tone: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelCharacterDraft { - pub character_id: String, - pub name: String, - #[serde(default)] - pub gender: Option, - pub role: VisualNovelCharacterRole, - pub appearance: String, - pub personality: String, - pub tone: String, - pub background: String, - pub relationship_to_player: String, - pub image_assets: Vec, - #[serde(default)] - pub default_expression: Option, - pub is_player_visible: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelSceneDraft { - pub scene_id: String, - pub name: String, - pub description: String, - #[serde(default)] - pub background_image_src: Option, - #[serde(default)] - pub music_src: Option, - #[serde(default)] - pub ambient_sound_src: Option, - pub availability: VisualNovelSceneAvailability, - pub phase_ids: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelStoryPhaseDraft { - pub phase_id: String, - pub title: String, - pub goal: String, - pub summary: String, - pub entry_condition: String, - pub exit_condition: String, - pub scene_ids: Vec, - pub character_ids: Vec, - pub suggested_choices: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelOpeningDraft { - #[serde(default)] - pub scene_id: Option, - pub narration: String, - #[serde(default)] - pub speaker_character_id: Option, - #[serde(default)] - pub first_dialogue: Option, - pub initial_choices: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelRuntimeConfigDraft { - pub text_mode_enabled: bool, - pub default_text_mode: bool, - pub max_history_entries: u32, - pub max_assistant_step_count_per_turn: u32, - pub allow_free_text_action: bool, - pub allow_history_regeneration: bool, - pub attribute_panel_mode: VisualNovelAttributePanelMode, - pub save_archive_enabled: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelResultDraft { - #[serde(default)] - pub profile_id: Option, - pub work_title: String, - pub work_description: String, - pub work_tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - pub source_mode: VisualNovelSourceMode, - pub source_asset_ids: Vec, - pub world: VisualNovelWorldDraft, - pub characters: Vec, - pub scenes: Vec, - pub story_phases: Vec, - pub opening: VisualNovelOpeningDraft, - pub runtime_config: VisualNovelRuntimeConfigDraft, - pub publish_ready: bool, - pub validation_issues: Vec, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelAgentMessage { - pub id: String, - pub role: VisualNovelAgentMessageRole, - pub kind: VisualNovelAgentMessageKind, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelAgentPendingAction { - pub action_id: String, - pub kind: VisualNovelAgentActionKind, - pub label: String, - #[serde(default)] - pub target_id: Option, - #[serde(default)] - pub payload: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelAgentSessionSnapshot { - pub session_id: String, - pub owner_user_id: String, - pub source_mode: VisualNovelSourceMode, - pub status: VisualNovelAgentStatus, - pub messages: Vec, - #[serde(default)] - pub draft: Option, - #[serde(default)] - pub pending_action: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CreateVisualNovelSessionRequest { - pub source_mode: VisualNovelSourceMode, - #[serde(default)] - pub seed_text: Option, - #[serde(default)] - pub source_asset_ids: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelSessionResponse { - pub session: VisualNovelAgentSessionSnapshot, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelWorkSummary { - pub runtime_kind: String, - pub profile_id: String, - pub owner_user_id: String, - pub title: String, - pub description: String, - #[serde(default)] - pub cover_image_src: Option, - pub tags: Vec, - pub publish_status: String, - pub publish_ready: bool, - pub play_count: u32, - pub updated_at: String, - #[serde(default)] - pub published_at: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelWorkDetail { - pub work_id: String, - pub summary: VisualNovelWorkSummary, - #[serde(default)] - pub source_session_id: Option, - pub author_display_name: String, - pub source_asset_ids: Vec, - pub draft: VisualNovelResultDraft, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelWorksResponse { - pub works: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelWorkResponse { - pub work: VisualNovelWorkDetail, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct UpdateVisualNovelWorkRequest { - pub draft: VisualNovelResultDraft, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelCompileResponse { - pub session: VisualNovelAgentSessionSnapshot, - pub work: VisualNovelWorkDetail, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CreateVisualNovelBackgroundMusicRequest { - pub prompt: String, - pub title: String, - #[serde(default)] - pub tags: Option, - #[serde(default)] - pub model: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CreateVisualNovelSoundEffectRequest { - pub prompt: String, - #[serde(default)] - pub duration: Option, - #[serde(default)] - pub seed: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelAudioGenerationTaskResponse { - pub kind: VisualNovelAudioGenerationKind, - pub task_id: String, - pub provider: String, - pub status: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PublishVisualNovelGeneratedAudioAssetRequest { - pub scene_id: String, - #[serde(default)] - pub profile_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelGeneratedAudioAssetResponse { - pub kind: VisualNovelAudioGenerationKind, - pub task_id: String, - pub provider: String, - pub status: String, - #[serde(default)] - pub asset_object_id: Option, - #[serde(default)] - pub asset_kind: Option, - #[serde(default)] - pub audio_src: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SendVisualNovelMessageRequest { - pub client_message_id: String, - pub text: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ExecuteVisualNovelAgentActionRequest { - #[serde(default)] - pub action_id: Option, - pub kind: VisualNovelAgentActionKind, - #[serde(default)] - pub target_id: Option, - #[serde(default)] - pub payload: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde( - tag = "type", - rename_all = "snake_case", - rename_all_fields = "camelCase" -)] -pub enum VisualNovelAgentStreamEvent { - Start { - session_id: String, - }, - Phase { - phase: VisualNovelAgentPhase, - }, - TextDelta { - text: String, - }, - DraftPatch { - patch: VisualNovelDraftPatch, - }, - ActionRequired { - action: VisualNovelAgentPendingAction, - }, - Complete { - session: VisualNovelAgentSessionSnapshot, - }, - Error { - message: String, - retryable: bool, - }, - Done {}, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde( - tag = "type", - rename_all = "snake_case", - rename_all_fields = "camelCase" -)] -pub enum VisualNovelRuntimeStep { - SceneChange { - scene_id: String, - #[serde(default)] - background_image_src: Option, - #[serde(default)] - music_src: Option, - }, - Narration { - text: String, - }, - Dialogue { - character_id: String, - character_name: String, - #[serde(default)] - expression: Option, - text: String, - }, - Transition { - transition_kind: VisualNovelTransitionKind, - #[serde(default)] - text: Option, - }, - Choice { - choices: Vec, - }, - Flag { - key: String, - value: VisualNovelFlagValue, - }, - Metric { - key: String, - delta: f64, - }, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelHistoryEntry { - pub entry_id: String, - pub run_id: String, - pub turn_index: u32, - pub source: VisualNovelHistorySource, - #[serde(default)] - pub action_text: Option, - pub steps: Vec, - #[serde(default)] - pub snapshot_before_hash: Option, - #[serde(default)] - pub snapshot_after_hash: Option, - pub created_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelRunSnapshot { - pub run_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub mode: VisualNovelRunMode, - pub status: VisualNovelRunStatus, - #[serde(default)] - pub current_scene_id: Option, - #[serde(default)] - pub current_phase_id: Option, - pub visible_character_ids: Vec, - pub flags: BTreeMap, - pub metrics: BTreeMap, - pub history: Vec, - pub available_choices: Vec, - pub text_mode_enabled: bool, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelRuntimeActionRequest { - pub action_kind: VisualNovelRuntimeActionKind, - #[serde(default)] - pub choice_id: Option, - #[serde(default)] - pub text: Option, - pub client_event_id: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelStartRunRequest { - pub profile_id: String, - pub mode: VisualNovelRunMode, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelRunResponse { - pub run: VisualNovelRunSnapshot, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelHistoryResponse { - pub history: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelRegenerateRequest { - pub history_entry_id: String, - pub client_event_id: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct VisualNovelSaveArchiveState { - pub runtime_kind: String, - pub profile_id: String, - pub run_id: String, - #[serde(default)] - pub current_scene_id: Option, - #[serde(default)] - pub current_phase_id: Option, - pub history_cursor: u32, - #[serde(default)] - pub snapshot_hash: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde( - tag = "type", - rename_all = "snake_case", - rename_all_fields = "camelCase" -)] -pub enum VisualNovelRuntimeStreamEvent { - Start { run_id: String }, - RawText { text: String }, - Step { step: VisualNovelRuntimeStep }, - Snapshot { run: VisualNovelRunSnapshot }, - Complete { run: VisualNovelRunSnapshot }, - Error { message: String, retryable: bool }, - Done {}, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn result_draft_and_runtime_step_use_contract_case() { - let draft = VisualNovelResultDraft { - profile_id: Some("vn-profile-1".to_string()), - work_title: "雨夜书店".to_string(), - work_description: "一段视觉小说测试底稿".to_string(), - work_tags: vec!["悬疑".to_string()], - cover_image_src: None, - source_mode: VisualNovelSourceMode::Idea, - source_asset_ids: Vec::new(), - world: VisualNovelWorldDraft { - title: "雨夜书店".to_string(), - summary: "主角在雨夜进入一间只在午夜出现的书店。".to_string(), - background: "城市边缘的旧街区。".to_string(), - premise: "找回遗失的名字。".to_string(), - literary_style: "细腻、轻悬疑".to_string(), - player_role: "误入书店的读者".to_string(), - default_tone: "克制而温柔".to_string(), - }, - characters: Vec::new(), - scenes: Vec::new(), - story_phases: Vec::new(), - opening: VisualNovelOpeningDraft { - scene_id: None, - narration: "雨声落下。".to_string(), - speaker_character_id: None, - first_dialogue: None, - initial_choices: Vec::new(), - }, - runtime_config: VisualNovelRuntimeConfigDraft { - text_mode_enabled: true, - default_text_mode: false, - max_history_entries: 80, - max_assistant_step_count_per_turn: 8, - allow_free_text_action: true, - allow_history_regeneration: true, - attribute_panel_mode: VisualNovelAttributePanelMode::Off, - save_archive_enabled: true, - }, - publish_ready: false, - validation_issues: Vec::new(), - updated_at: "2026-05-05T00:00:00Z".to_string(), - }; - - let payload = serde_json::to_value(draft).expect("draft should serialize"); - assert_eq!(payload["profileId"], json!("vn-profile-1")); - assert_eq!(payload["sourceMode"], json!("idea")); - assert_eq!(payload["runtimeConfig"]["attributePanelMode"], json!("off")); - - let step = VisualNovelRuntimeStep::SceneChange { - scene_id: "scene-1".to_string(), - background_image_src: None, - music_src: None, - }; - let step_payload = serde_json::to_value(step).expect("step should serialize"); - assert_eq!(step_payload["type"], json!("scene_change")); - assert_eq!(step_payload["sceneId"], json!("scene-1")); - } - - #[test] - fn audio_generation_contracts_use_camel_case_fields() { - let request = CreateVisualNovelSoundEffectRequest { - prompt: "雨声".to_string(), - duration: Some(5), - seed: Some(12), - }; - let payload = serde_json::to_value(request).expect("request should serialize"); - assert_eq!(payload["duration"], json!(5)); - assert_eq!(payload["seed"], json!(12)); - - let response = VisualNovelGeneratedAudioAssetResponse { - kind: VisualNovelAudioGenerationKind::SoundEffect, - task_id: "task-1".to_string(), - provider: "vector-engine-vidu".to_string(), - status: "completed".to_string(), - asset_object_id: Some("assetobj_1".to_string()), - asset_kind: Some("visual_novel_ambient_sound".to_string()), - audio_src: Some("/generated-custom-world-scenes/a.wav".to_string()), - }; - let payload = serde_json::to_value(response).expect("response should serialize"); - assert_eq!(payload["kind"], json!("sound_effect")); - assert_eq!(payload["taskId"], json!("task-1")); - assert_eq!(payload["assetObjectId"], json!("assetobj_1")); - assert_eq!( - payload["audioSrc"], - json!("/generated-custom-world-scenes/a.wav") - ); - } - - #[test] - fn runtime_stream_event_uses_tagged_envelope() { - let event = VisualNovelRuntimeStreamEvent::Step { - step: VisualNovelRuntimeStep::Narration { - text: "门铃响了。".to_string(), - }, - }; - - let payload = serde_json::to_value(event).expect("event should serialize"); - assert_eq!(payload["type"], json!("step")); - assert_eq!(payload["step"]["type"], json!("narration")); - assert_eq!(payload["step"]["text"], json!("门铃响了。")); - } -} diff --git a/server-rs/crates/shared-contracts/src/wooden_fish.rs b/server-rs/crates/shared-contracts/src/wooden_fish.rs deleted file mode 100644 index 342d89ca8..000000000 --- a/server-rs/crates/shared-contracts/src/wooden_fish.rs +++ /dev/null @@ -1,552 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::external_generation::ExternalGenerationJobStatusRecord; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum WoodenFishGenerationStatus { - Draft, - Generating, - Ready, - Failed, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum WoodenFishActionType { - CompileDraft, - RegenerateHitObject, - GenerateHitSound, - ReplaceHitSound, - UpdateWorkMeta, - UpdateFloatingWords, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum WoodenFishRunStatus { - Playing, - Finished, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishImageAsset { - pub asset_id: String, - pub image_src: String, - pub image_object_key: String, - pub asset_object_id: String, - pub generation_provider: String, - pub prompt: String, - pub width: u32, - pub height: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishAudioAsset { - pub asset_id: String, - pub audio_src: String, - pub audio_object_key: String, - pub asset_object_id: String, - pub source: String, - #[serde(default)] - pub prompt: Option, - #[serde(default)] - pub duration_ms: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishWorkspaceCreateRequest { - pub template_id: String, - pub work_title: String, - pub work_description: String, - pub theme_tags: Vec, - pub hit_object_prompt: String, - #[serde(default)] - pub hit_object_reference_image_src: Option, - #[serde(default)] - pub hit_sound_prompt: Option, - #[serde(default)] - pub hit_sound_asset: Option, - pub floating_words: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishActionRequest { - pub action_type: WoodenFishActionType, - #[serde(default, skip_deserializing)] - pub profile_id: Option, - #[serde(default)] - pub work_title: Option, - #[serde(default)] - pub work_description: Option, - #[serde(default)] - pub theme_tags: Option>, - #[serde(default)] - pub hit_object_prompt: Option, - #[serde(default)] - pub hit_object_reference_image_src: Option, - #[serde(default, skip_deserializing)] - pub hit_object_asset: Option, - #[serde(default)] - #[serde(skip_deserializing)] - pub background_asset: Option, - #[serde(default)] - #[serde(skip_deserializing)] - pub back_button_asset: Option, - #[serde(default)] - pub hit_sound_prompt: Option, - #[serde(default)] - pub hit_sound_asset: Option, - #[serde(default)] - pub floating_words: Option>, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishWordCounter { - pub text: String, - pub count: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishDraftResponse { - pub template_id: String, - pub template_name: String, - #[serde(default)] - pub profile_id: Option, - pub work_title: String, - pub work_description: String, - pub theme_tags: Vec, - pub hit_object_prompt: String, - #[serde(default)] - pub hit_object_reference_image_src: Option, - #[serde(default)] - pub hit_sound_prompt: Option, - pub floating_words: Vec, - #[serde(default)] - pub hit_object_asset: Option, - #[serde(default)] - pub background_asset: Option, - #[serde(default)] - pub back_button_asset: Option, - #[serde(default)] - pub hit_sound_asset: Option, - #[serde(default)] - pub cover_image_src: Option, - pub generation_status: WoodenFishGenerationStatus, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishSessionSnapshotResponse { - pub session_id: String, - pub owner_user_id: String, - pub status: WoodenFishGenerationStatus, - #[serde(default)] - pub draft: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishSessionResponse { - pub session: WoodenFishSessionSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishActionResponse { - pub action_type: WoodenFishActionType, - pub session: WoodenFishSessionSnapshotResponse, - #[serde(default)] - pub work: Option, - #[serde(default)] - pub queue_state: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishWorkSummaryResponse { - pub runtime_kind: String, - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - #[serde(default)] - pub source_session_id: Option, - pub work_title: String, - pub work_description: String, - pub theme_tags: Vec, - #[serde(default)] - pub cover_image_src: Option, - pub publication_status: String, - pub play_count: u32, - pub updated_at: String, - #[serde(default)] - pub published_at: Option, - pub publish_ready: bool, - pub generation_status: WoodenFishGenerationStatus, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishWorkProfileResponse { - pub summary: WoodenFishWorkSummaryResponse, - pub draft: WoodenFishDraftResponse, - pub hit_object_asset: WoodenFishImageAsset, - #[serde(default)] - pub background_asset: Option, - #[serde(default)] - pub back_button_asset: Option, - pub hit_sound_asset: WoodenFishAudioAsset, - pub floating_words: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishWorksResponse { - pub items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishWorkDetailResponse { - pub item: WoodenFishWorkProfileResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishWorkMutationResponse { - pub item: WoodenFishWorkProfileResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishGalleryCardResponse { - pub public_work_code: String, - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub author_display_name: String, - pub work_title: String, - pub work_description: String, - #[serde(default)] - pub cover_image_src: Option, - pub theme_tags: Vec, - pub publication_status: String, - pub play_count: u32, - pub updated_at: String, - #[serde(default)] - pub published_at: Option, - pub generation_status: WoodenFishGenerationStatus, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishGalleryResponse { - pub items: Vec, - pub has_more: bool, - #[serde(default)] - pub next_cursor: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishGalleryDetailResponse { - pub item: WoodenFishWorkProfileResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishRuntimeRunSnapshotResponse { - pub run_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub status: WoodenFishRunStatus, - pub total_tap_count: u32, - pub word_counters: Vec, - pub started_at_ms: u64, - pub updated_at_ms: u64, - #[serde(default)] - pub finished_at_ms: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishRunResponse { - pub run: WoodenFishRuntimeRunSnapshotResponse, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishStartRunRequest { - pub profile_id: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishCheckpointRunRequest { - pub total_tap_count: u32, - pub word_counters: Vec, - pub client_event_id: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WoodenFishFinishRunRequest { - pub total_tap_count: u32, - pub word_counters: Vec, - pub client_event_id: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn wooden_fish_workspace_request_uses_camel_case_and_default_words() { - let payload = serde_json::to_value(WoodenFishWorkspaceCreateRequest { - template_id: "wooden-fish".to_string(), - work_title: "今日敲木鱼".to_string(), - work_description: "轻松敲击".to_string(), - theme_tags: vec!["休闲".to_string()], - hit_object_prompt: "卡通木鱼".to_string(), - hit_object_reference_image_src: None, - hit_sound_prompt: Some("清脆木鱼声".to_string()), - hit_sound_asset: None, - floating_words: vec![ - "幸运".to_string(), - "健康".to_string(), - "财富".to_string(), - "姻缘".to_string(), - "幸福".to_string(), - "事业".to_string(), - "成功".to_string(), - "功德".to_string(), - ], - }) - .expect("payload should serialize"); - - assert_eq!(payload["templateId"], json!("wooden-fish")); - assert_eq!(payload["hitObjectPrompt"], json!("卡通木鱼")); - assert_eq!(payload["hitSoundPrompt"], json!("清脆木鱼声")); - assert_eq!(payload["floatingWords"][7], json!("功德")); - } - - #[test] - fn wooden_fish_runtime_snapshot_counts_words_inside_one_run() { - let payload = serde_json::to_value(WoodenFishRuntimeRunSnapshotResponse { - run_id: "wooden-fish-run-1".to_string(), - profile_id: "wooden-fish-profile-1".to_string(), - owner_user_id: "user-1".to_string(), - status: WoodenFishRunStatus::Playing, - total_tap_count: 3, - word_counters: vec![ - WoodenFishWordCounter { - text: "幸运".to_string(), - count: 2, - }, - WoodenFishWordCounter { - text: "功德".to_string(), - count: 1, - }, - ], - started_at_ms: 100, - updated_at_ms: 130, - finished_at_ms: None, - }) - .expect("payload should serialize"); - - assert_eq!(payload["status"], json!("playing")); - assert_eq!(payload["totalTapCount"], json!(3)); - assert_eq!(payload["wordCounters"][0]["text"], json!("幸运")); - } - - #[test] - fn wooden_fish_action_request_serializes_audio_and_image_fields() { - let payload = serde_json::to_value(WoodenFishActionRequest { - action_type: WoodenFishActionType::ReplaceHitSound, - profile_id: Some("wooden-fish-profile-1".to_string()), - work_title: None, - work_description: None, - theme_tags: None, - hit_object_prompt: Some("卡通铜钹".to_string()), - hit_object_reference_image_src: Some("/uploads/reference.png".to_string()), - hit_object_asset: Some(WoodenFishImageAsset { - asset_id: "image-1".to_string(), - image_src: "/generated-wooden-fish-assets/profile/hit-object/image.png".to_string(), - image_object_key: "generated-wooden-fish-assets/profile/hit-object/image.png" - .to_string(), - asset_object_id: "image-object-1".to_string(), - generation_provider: "image2".to_string(), - prompt: "卡通铜钹".to_string(), - width: 1024, - height: 1024, - }), - background_asset: Some(WoodenFishImageAsset { - asset_id: "background-1".to_string(), - image_src: "/generated-wooden-fish-assets/profile/background/image.png".to_string(), - image_object_key: "generated-wooden-fish-assets/profile/background/image.png" - .to_string(), - asset_object_id: "background-object-1".to_string(), - generation_provider: "image2".to_string(), - prompt: "赛博莲花背景".to_string(), - width: 1024, - height: 1536, - }), - back_button_asset: Some(WoodenFishImageAsset { - asset_id: "back-button-1".to_string(), - image_src: "/generated-wooden-fish-assets/profile/back-button/image.png" - .to_string(), - image_object_key: "generated-wooden-fish-assets/profile/back-button/image.png" - .to_string(), - asset_object_id: "back-button-object-1".to_string(), - generation_provider: "image2".to_string(), - prompt: "赛博莲花返回按钮".to_string(), - width: 1024, - height: 1024, - }), - hit_sound_prompt: Some("短促木鱼声".to_string()), - hit_sound_asset: Some(WoodenFishAudioAsset { - asset_id: "sound-1".to_string(), - audio_src: "/generated/wooden-fish.mp3".to_string(), - audio_object_key: "generated/wooden-fish.mp3".to_string(), - asset_object_id: "asset-object-1".to_string(), - source: "upload".to_string(), - prompt: None, - duration_ms: Some(800), - }), - floating_words: Some(vec!["功德".to_string()]), - }) - .expect("payload should serialize"); - - assert_eq!(payload["actionType"], json!("replace-hit-sound")); - assert_eq!(payload["profileId"], json!("wooden-fish-profile-1")); - assert_eq!(payload["hitObjectPrompt"], json!("卡通铜钹")); - assert_eq!( - payload["hitObjectAsset"]["imageObjectKey"], - json!("generated-wooden-fish-assets/profile/hit-object/image.png") - ); - assert_eq!(payload["backgroundAsset"]["height"], json!(1536)); - assert_eq!(payload["backButtonAsset"]["width"], json!(1024)); - assert_eq!(payload["hitSoundAsset"]["source"], json!("upload")); - assert_eq!(payload["hitSoundAsset"]["durationMs"], json!(800)); - } - - #[test] - fn wooden_fish_action_request_ignores_client_hit_object_asset() { - let payload = serde_json::from_value::(json!({ - "actionType": "compile-draft", - "hitObjectPrompt": "卡通铜钹", - "hitObjectAsset": { - "assetId": "client-image", - "imageSrc": "/generated-wooden-fish-assets/client/image.png", - "imageObjectKey": "generated-wooden-fish-assets/client/image.png", - "assetObjectId": "client-asset-object", - "generationProvider": "client", - "prompt": "跳过生成", - "width": 1024, - "height": 1024 - } - })) - .expect("payload should deserialize"); - - assert_eq!(payload.action_type, WoodenFishActionType::CompileDraft); - assert_eq!(payload.hit_object_prompt.as_deref(), Some("卡通铜钹")); - assert_eq!(payload.hit_object_asset, None); - } - - #[test] - fn wooden_fish_work_profile_keeps_summary_and_runtime_assets() { - let image = WoodenFishImageAsset { - asset_id: "image-1".to_string(), - image_src: "/generated/wooden-fish.png".to_string(), - image_object_key: "generated/wooden-fish.png".to_string(), - asset_object_id: "image-object-1".to_string(), - generation_provider: "image2".to_string(), - prompt: "卡通木鱼".to_string(), - width: 1024, - height: 1024, - }; - let audio = WoodenFishAudioAsset { - asset_id: "sound-1".to_string(), - audio_src: "/generated/wooden-fish.mp3".to_string(), - audio_object_key: "generated/wooden-fish.mp3".to_string(), - asset_object_id: "sound-object-1".to_string(), - source: "generated".to_string(), - prompt: Some("清脆木鱼".to_string()), - duration_ms: Some(600), - }; - let back_button = WoodenFishImageAsset { - asset_id: "back-button-1".to_string(), - image_src: "/generated/wooden-fish-back-button.png".to_string(), - image_object_key: "generated/wooden-fish-back-button.png".to_string(), - asset_object_id: "back-button-object-1".to_string(), - generation_provider: "image2".to_string(), - prompt: "主题返回按钮".to_string(), - width: 1024, - height: 1024, - }; - let profile = WoodenFishWorkProfileResponse { - summary: WoodenFishWorkSummaryResponse { - runtime_kind: "wooden-fish".to_string(), - work_id: "wooden-fish-profile-1".to_string(), - profile_id: "wooden-fish-profile-1".to_string(), - owner_user_id: "user-1".to_string(), - source_session_id: Some("wooden-fish-session-1".to_string()), - work_title: "敲木鱼".to_string(), - work_description: String::new(), - theme_tags: vec!["休闲".to_string()], - cover_image_src: Some(image.image_src.clone()), - publication_status: "draft".to_string(), - play_count: 0, - updated_at: "2026-05-20T00:00:00Z".to_string(), - published_at: None, - publish_ready: true, - generation_status: WoodenFishGenerationStatus::Ready, - }, - draft: WoodenFishDraftResponse { - template_id: "wooden-fish".to_string(), - template_name: "敲木鱼".to_string(), - profile_id: Some("wooden-fish-profile-1".to_string()), - work_title: "敲木鱼".to_string(), - work_description: String::new(), - theme_tags: vec!["休闲".to_string()], - hit_object_prompt: "卡通木鱼".to_string(), - hit_object_reference_image_src: None, - hit_sound_prompt: Some("清脆木鱼".to_string()), - floating_words: vec!["功德".to_string()], - hit_object_asset: Some(image.clone()), - background_asset: None, - back_button_asset: Some(back_button.clone()), - hit_sound_asset: Some(audio.clone()), - cover_image_src: Some(image.image_src.clone()), - generation_status: WoodenFishGenerationStatus::Ready, - }, - hit_object_asset: image, - background_asset: None, - back_button_asset: Some(back_button), - hit_sound_asset: audio, - floating_words: vec!["功德".to_string()], - }; - - let payload = serde_json::to_value(profile).expect("profile should serialize"); - - assert_eq!(payload["summary"]["runtimeKind"], json!("wooden-fish")); - assert_eq!( - payload["hitObjectAsset"]["generationProvider"], - json!("image2") - ); - assert_eq!(payload["hitSoundAsset"]["source"], json!("generated")); - assert_eq!( - payload["backButtonAsset"]["imageSrc"], - json!("/generated/wooden-fish-back-button.png") - ); - } -} diff --git a/server-rs/crates/spacetime-module/README.md b/server-rs/crates/spacetime-module/README.md index 0b8b9230c..b2d952aba 100644 --- a/server-rs/crates/spacetime-module/README.md +++ b/server-rs/crates/spacetime-module/README.md @@ -22,7 +22,7 @@ 2. 保持 table / reducer / view / procedure、迁移白名单和生成 bindings 同步 3. 接入身份 claims 透传 4. 在当前 scaffold 基础上接入 publish / dev 循环 -5. 新增业务域时按 `src/active.rs` 的现役声明落位,禁止恢复已退役的 gameplay / custom_world / 逐玩法目录 +5. 新增业务域时按 `src/active.rs` 的现役声明落位,不得恢复不在现役声明中的业务域目录 当前已落地: @@ -41,11 +41,11 @@ `src/runtime.rs` 只承担聚合职责,现役入口如下: -1. 表结构壳:`src/runtime/legacy_schema/{settings,snapshots,browse_history}.rs`、`src/legacy_schema/creation_entry_config.rs` +1. 现役表定义:`src/runtime/legacy_schema/{settings,snapshots,browse_history}.rs`、`src/legacy_schema/creation_entry_config.rs` 2. 现役 procedure / helper:`src/runtime/active/{settings,profile}.rs` 3. 独立配置与投影:`src/runtime/feature_gate_config.rs`、`src/runtime/analytics_date_dimension.rs` -后续新增 runtime 相关 table / reducer / procedure / helper 时,必须直接落到对应二级文件,禁止回写到 `src/active.rs`;不得把同名旧根文件重新加入 `runtime.rs`。 +后续新增 runtime 相关 table / reducer / procedure / helper 时,必须直接落到对应二级文件,禁止回写到 `src/active.rs`;不得把未登记的根文件重新加入 `runtime.rs`。 ### 2.0.2 `ai` 域拆分进度 @@ -127,7 +127,7 @@ 15. `src/runtime/feature_gate_config.rs` 16. `src/runtime/analytics_date_dimension.rs` -后续如果新增 SpacetimeDB 表、reducer、procedure 或同域 helper,必须先判断属于哪个一级模块与二级落位点,再写入对应文件;禁止直接追加到 `src/active.rs`,也禁止恢复已删除的旧创作模板域目录。 +后续如果新增 SpacetimeDB 表、reducer、procedure 或同域 helper,必须先判断属于哪个一级模块与二级落位点,再写入对应文件;禁止直接追加到 `src/active.rs`,也不得恢复不在现役声明中的业务域目录。 ## 当前文档入口 @@ -144,4 +144,4 @@ 1. `spacetime-module` 只聚合状态模型,不直接承接 HTTP、Cookie、Header、OSS、短信、微信、LLM 等外部副作用。 2. 每个业务模块优先在自己的 `crates/module-*` 中定义状态与规则,再由主工程聚合。 3. 主工程不重新吞并各模块实现细节,避免回到单大包结构。 -4. 新增跨域状态必须先在对应 `module-*` 定义领域规则,再由 `spacetime-module` 聚合;不得从历史源码恢复已退役模块。 +4. 新增跨域状态必须先在对应 `module-*` 定义领域规则,再由 `spacetime-module` 聚合;不得从未挂载源码复制未登记模块。 diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index b674363ad..e413af575 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -233,7 +233,7 @@ macro_rules! migration_tables { editor_generation_runtime_identity_rotation, editor_generation_operation, editor_idempotent_create_receipt, - // 旧创作模板历史表已从 schema 与迁移白名单中删除;新增可迁移表必须在此显式登记。 + // 新增可迁移表必须在此显式登记。 } }; } diff --git a/vitest.config.ts b/vitest.config.ts index 89e218467..76a7753c4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -167,6 +167,5 @@ export default defineConfig({ 'packages/image-canvas-react/src/**/*.test.tsx', 'packages/agc-plugin-sdk/src/**/*.test.ts', ], - exclude: ['scripts/loadtest/**'], }, });