merge latest master

This commit is contained in:
2026-10-04 12:24:47 +08:00
159 changed files with 13451 additions and 1653 deletions
+1
View File
@@ -78,6 +78,7 @@
- [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md)
- [AGC 栏目画布底部工具栏入口矩阵](./technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md):栏目画布底部工具栏的入口矩阵、可用条件与验收口径。
- [AGC 资源工作台三处交互收口改动前对照图](./technical/assets/agc-resource-workbench-ui-before-20260914/README.md):任务侧栏两个关闭入口、左侧贴边折叠把手、顶部播放按钮居中悬浮三张改动前截图与问题说明。
- [AGC 顶栏布局收口改动后对照图](./technical/assets/agc-toolbar-layout-after-20261004/README.md):顶栏一行到底、版本入口钉右并三档降级、「播放」并入「运行」页签、运行画面刷新入口四张改动后截图与口径。
- [AGC 资源派生与非破坏性编辑合同](./technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md):AGC 全类型现有资源非破坏性编辑的权威合同,约束资源派生、替换与写回边界。
- [AGC 聊天素材引用](./【功能说明】AGC聊天素材引用-2026-09-08.md):聊天输入框 @ 引用项目素材的入口、引用模型与「当前版本素材」口径。
- [AGC 聊天 AI 润色与发送前提醒](./【功能说明】AGC聊天AI润色与发送前提醒-2026-09-10.md):提示词润色与发送前提醒的交互、失败与取消口径。
@@ -131,7 +131,7 @@
- 资源总览的“资源依赖 / 资源类型”视图切换使用连通的分段按钮组,相邻选项共享边界并保持唯一选中语义。每个分段都必须有清晰的键盘焦点指示,焦点环不得被分段容器的圆角或 `overflow` 裁切。
- 右侧 Supervisor 对话中,用户消息使用右对齐、最大宽度受限的主题暖色气泡,assistant 消息保持左对齐;消息换行不得产生水平溢出,执行过程卡继续占满消息区可用宽度。消息列表必须约束在右侧对话列内并独立滚动,不得覆盖中央资源或运行视图;提交按钮必须保留随状态变化的可访问名称。气泡正文在 light / dark 平台主题下均须满足 WCAG AA 普通文本 `4.5:1` 对比度。
- 客户端正式产品仍只按最小 `1280×720` 横屏合同交付,并保留 `1280×800` 默认窗口与既有基线验收;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。
- 工作台顶部播放按钮紧贴「资源管理 / 运行」模式切换之后、整组左对齐;不再在桌面视窗中水平居中悬浮;两个页签下都常驻(运行视图空态与预览失败态要靠它重跑)。资源管理视窗触发播放时直接切换到运行视窗并启动本地预览,不再弹出 `game.run_local` 二次确认。
- 工作台顶部工具条固定**一行到底**,**两枚体量最大的分组控件分居这一行两端**:左端「资源管理 / 运行」模式分段,右端「依赖 / 类型」排序分段;中间依次是动作按钮与版本入口,**版本入口紧邻排序分段左侧**(不放在最右)、固定显示 `版本 N`,空间够时补上括号里的「创建原因 + 时间」。「运行」页签本身就是播放入口(页签前带 ▶ 图标;切过去即启动本地预览,不再单开一枚「播放」按钮,也不再弹出 `game.run_local` 二次确认;运行视图空态与预览失败态靠再点一次该页签重跑,不可运行时页签不置灰、点了不切视图并出提示)。**站在运行视图上却没有画面时自动补发一次载入**(典型场景:从别的项目切过来,工作台不重挂,运行视图原样留下而画面已经没了),同一个项目只自动补一次,画面已在就不重复启动。空间不足按固定顺序降级:先只留 `版本 N`,再把「打开项目目录 / 资源面板 / 整理画布」收进「更多」下拉——任何宽度都不把控件甩到第二行,右端始终由「依赖 / 类型」收住。运行画面右下角提供「刷新运行画面」:游戏预览不是实时刷新,改完代码需要重载页面。
- 创建模式素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择。图片精修继承源名称和用途,不显示创建模式保存设置;候选图片只从选中图片的“设为最终图”提交。精修顶栏只保留返回、导入、定位当前最终图、撤销和重做,删除进入图片上下文工具栏,通用 AI 生成只保留给创建模式。图片输出统一使用 PNG;创建模式工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。
- 首页创作输入区与“最近项目”之间不展示共享项目状态文本,“最近项目”标题下也不追加解释性副标题;默认、成功、进行中或失败状态均不得在该位置形成文字行,项目管理页继续保留自己的状态反馈。
@@ -0,0 +1,87 @@
# 【实施计划】AGC 发布版本以工程内部版本为准
| 字段 | 值 |
| --- | --- |
| Milestone | `docs/project-memory/plans/【里程碑】AGC发布版本以工程内部版本为准-2026-10-03.md` |
| Status | proposed |
| Owner | Genarrative Agent |
| 关联 | PR #565(`feat/game-works-management`);取代 `【里程碑/实施计划】AGC已发布游戏版本更新-2026-10-02` 中「用户可编辑发行版本标签」的口径 |
## 修改边界(不动的东西)
- 不改 `server-rs/crates/{spacetime-module,module-game-distribution,api-server,spacetime-client}`:服务端 `versionNumber` 语义与 `publicationRevision` CAS 保持不变。
- 不改 `apps/admin-web/**`、不改 `src-tauri/src/project/export.rs`(两者正被其它 worker 修改)。
- 不删除本地字段 `projectVersion`(Rust DTO 带 `deny_unknown_fields`:`server-rs/crates/shared-contracts/src/game_creation_app.rs:1121`)。
## 修改顺序
1. 文档:主规范改写 → 本里程碑 → 本实现计划 → `decision-log` 条目 → 两份 2026-10-02 计划文档的取代标注。
2. 契约:TS 解析器改为内部版本派生。
3. Rust:拆掉两条 `project_version` 写链路 + 删除手改命令。
4. 渲染层:面板改只读派生、删失焦写回与 `onManifestUpdated`。
5. service:删保存函数、改默认值与注释。
6. 测试改写 + 门禁全跑。
## 逐条改动(文件 → 位置 → 改什么 → 验收)
| # | 文件 | 位置 | 改什么 | 验收 |
| --- | --- | --- | --- | --- |
| 1 | `packages/shared/src/contracts/gameCreationApp.ts` | `:1001-1013` | `resolveGameCreationAppProjectVersion` 去掉 `binding` 形参,返回 `versions.length > 0 ? versions.length : GAME_CREATION_APP_FIRST_PROJECT_VERSION`;注释改为「唯一来源 = 工程内部版本」 | `npm run agc:typecheck`;面板/服务用例 |
| 2 | 同上 | `:1034-1037` | `projectVersion?: number \| null` 保留,注释改「遗留位:旧清单兼容读,不再读写」 | 静态检查 |
| 3 | `server-rs/crates/shared-contracts/src/game_creation_app.rs` | `:1107-1118`、`:1090-1096` | 删 `resolve_game_creation_app_project_version` 与 `GAME_CREATION_APP_FIRST_PROJECT_VERSION` / `is_game_creation_app_project_version`;对应单测改为「字段被忽略」 | `cargo test -p shared-contracts` |
| 4 | 同上 | `:1146-1150` | `project_version` 字段保留,注释改「遗留:仅为旧清单可解析,不再读写」 | 同上 |
| 5 | `apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs` | `:24-27` | 去掉解析器 import | `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml game_distribution_publish` |
| 6 | 同上 | `:536-541`(`write_publication_binding`) | 删除 `manifest.project_version = Some(resolve(...))`,改为置 `None`;保留 `manifest.publication = Some(binding.clone())` | 同上 + `jq` 自检 |
| 7 | 同上 | `:1531-1535`(发布成功回写) | 删除 `manifest.project_version = Some(version.version_number)`,改为置 `None`;保留 publication 回写 | 同上 |
| 8 | 同上 | `:944-965` | 删除 `update_game_distribution_project_version` 命令 | 编译 + `rg` 零命中 |
| 9 | `apps/ai-game-creator-shell/src-tauri/src/desktop.rs` | `:614` | 从 invoke handler 清单删除该命令 | 编译 |
| 10 | `apps/ai-game-creator-shell/scripts/check-config.mjs` | `:138` | 从命令注册表删除 `'update_game_distribution_project_version'` | `npm run agc:typecheck` |
| 11 | `apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts` | `:148-167` | 删除 `saveGameDistributionProjectVersion` | typecheck + 用例 |
| 12 | 同上 | `:329-337`、`:362-363` | 注释改为「唯一来源 = 工程内部版本」;默认值 `args.versionNumber ?? resolveGameCreationAppProjectVersion(args.manifest)` | 同上 |
| 13 | `apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx` | `:129-136`、`:283-288`、`:331-332`、`:397-403`、`:683-708`、`:731-734`、`:804` | 删 `parseProjectVersion`、`projectVersion`/`savedProjectVersion` state、`projectVersionSaveRequestRef`、`handleProjectVersionCommit` 与三处 setState;新增派生值 `useMemo(() => resolveGameCreationAppProjectVersion(manifest), [manifest.versions])`;提交改为 `versionNumber: projectVersion` | 面板用例 |
| 14 | 同上 | `:947-1000` | 「项目版本」输入框改 `readOnly`(保留 `aria-label="项目版本"`),删 `onChange`/`onBlur`/`disabled`;提示语改为「来自 AGC 内部版本(版本 N)…」 | 面板用例 |
| 15 | 同上 | `:242-243`、`:700` | 删除 `onManifestUpdated` prop 与唯一调用点 | typecheck |
| 16 | `apps/ai-game-creator-shell/src/App.tsx` | `:1816`、`:1951` | 删除两处 `onManifestUpdated={setManifest}` | typecheck |
| 17 | `apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx` | `:735-761`、`:778-823`、`:825-868` | 前两条改为「标签 = 内部版本数」;后两条(失焦写回 / 非法回退)整块删除;fixture `MANIFEST` 补非空 `versions` | vitest |
| 18 | `apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts` | `:319-420` | 删 `saveGameDistributionProjectVersion` 用例块;默认值断言改为「无 `versions` → 1;N 条 → N」 | vitest |
| 19 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` | `:97`、`:99`、`:106` + 新增两条 | 见主规范新条款 | `npm run check:doc-index` |
| 20 | `docs/project-memory/plans/【实施计划】AGC已发布游戏版本更新-2026-10-02.md` | `:11`、`:23` | 行内追加「被 2026-10-03 里程碑取代」标注 | 阅读 |
| 21 | `docs/project-memory/plans/【里程碑】AGC已发布游戏版本更新-2026-10-02.md` | `:27-30` | 同上,逐条标注被取代/收窄 | 阅读 |
| 22 | `docs/project-memory/shared-memory/decision-log.md` | 最新条目区 | 追加本决策条目 | `npm run check:doc-index` |
## 验证命令
```bash
npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts \
apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx \
apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts
npm run agc:typecheck
cd server-rs && cargo test -p shared-contracts
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml game_distribution_publish
npm run check:doc-index
npm run check:encoding
git diff --check
# 静态断言:命令与字段来源都已拆干净
rg -n "update_game_distribution_project_version" . # 期望 0 命中
rg -n "projectVersion" apps/ai-game-creator-shell/src packages/shared/src # 期望仅剩遗留声明/注释
```
## 数据迁移
- 无迁移脚本。读路径立即忽略 `projectVersion`;改动点 6/7 会在下一次「平台回读绑定 / 发布成功」时把它置空,`skip_serializing_if` 使该键随下次落盘消失。
- 平台侧历史版本行不做任何改写(不可变留痕)。
## 风险与回滚点
- 回滚点:只动 AGC 客户端 + 本地契约 + 文档;回滚 = 还原提交,服务端数据无副作用。
- 旧客户端降级:清单里 `projectVersion` 被置空后,旧版 AGC 会回落到绑定 `latestVersionNumber`(旧行为重现),仅影响本机,需写 release note。
- 派生标签会因「截尾删除版本」而回退(删素材连带删版本),平台接受该回退,但会产生「同一标签先后指向不同包」——这属平台现行契约允许的形态(用户手动重复提交同版本同样会产生)。
## 不做(本里程碑之外)
1. 「线上最近提交 vN」加「(平台)」字样(纯文案)。
2. 平台历史版本号对齐(**明确不做**:不可变历史行无改写入口,会破坏审核留痕)。
3. 后台/作者侧的「同标签多实例」可辨识列(落在 `apps/admin-web/**`,正被其它 worker 修改)。
4. 「截尾删除致标签回退」的产品兜底(引入单调计数器 = 新字段 = SpacetimeDB schema 变更,须单独评审)。
5. `edit-<operationId>` 形状的资源编辑版本是否计入内部版本序数(当前计入,与资源卡口径一致)。
6. 旧客户端降级提示写 release note。
@@ -0,0 +1,40 @@
# 【实施计划】AGC 已发布游戏版本更新
| 字段 | 值 |
| --- | --- |
| Milestone | `docs/project-memory/plans/【里程碑】AGC已发布游戏版本更新-2026-10-02.md` |
| Status | proposed |
| Owner | Genarrative Agent |
## 修改边界
- `packages/shared/src/contracts/gameCreationApp.ts`:本地唯一 `projectVersion` 和 `publication` 绑定,不混入内部编辑迭代 `versions[]`。**(2026-10-03 取代:发布标签改为由工程内部 `versions[]` 派生、只读;`projectVersion` 降级为不再读写的遗留兼容位,见 `【里程碑】AGC发布版本以工程内部版本为准-2026-10-03.md`。)**
- `packages/shared/src/contracts/gameDistribution.ts` 与 Rust shared-contracts:创建版本请求支持正整数 `versionNumber`,允许重复标签提交。
- `apps/ai-game-creator-shell`:绑定回读、首次发布/更新 UI、项目版本输入、原 gameId 新提交、结果回写。
- `api-server`、`spacetime-client`、`spacetime-module`:创建版本事务只校验正整数;同 gameId/同 versionNumber 生成新 versionId,publicationRevision 继续 CAS。
- 现有创建版本 procedure 输入/生成绑定需要同步,但不修改持久表字段。
## 实现顺序
1. 契约先补唯一 `projectVersion` 和本地 publication,明确 versionId/版本标签/内部 projectRevision 的边界。
2. 创建版本事务不再要求大于 max;未传时保留旧客户端自动递增,传入时校验正整数,并按同版本最新有效提交处理 pending 替代。
3. AGC 发布绑定保存 gameId、最新 versionId、状态和 revision;按账号和 API origin 隔离绑定。
4. AGC 打开项目回读已有作品;旧项目通过 localProjectId 恢复关联。
5. 发布面板编辑 projectVersion,区分首次发布/更新,允许重复提交和回退,不再存在独立 targetVersion。**(2026-10-03 取代:面板不再编辑版本标签,标签由工程内部 `versions[]` 派生且只读;仍区分首次发布/更新。)**
6. Rust 发布 facade 在更新模式跳过 create game,使用原 gameId 创建新 version;幂等账本把目标 projectVersion、资料和提交意图纳入发布意图。
7. 验证重复提交同版本、回退版本、响应丢失、切换账号、publicationRevision 冲突和旧公开版本继续可玩。
## 数据库影响
- 不新增表。
- 不修改 `version_number` 字段类型或重排字段。
- 修改创建版本 procedure 输入 DTO 与生成绑定;按当前 SpacetimeDB 门禁验证。
- 老客户端不传版本号仍自动递增,已有版本和历史数据不需要回填。
## 验证
- 领域/数据库创建版本测试:默认递增、指定更高版本、重复/较小/零/负数/非整数/越界输入、并发相同目标号。
- AGC 发布面板测试:首发、更新、默认目标版本、用户改号和恢复失败。
- AGC Rust 发布测试:跳过 create game、原 gameId、新 version、结果持久化、幂等意图区分。
- `npm run typecheck`、定向 Rust/Vitest、schema/生成绑定检查、编码与 diff。
- 真机发布:首次发布 → 重开项目 → 修改游戏 → 指定更高版本 → 更新送审 → 审核后原公开链接运行新包。
@@ -0,0 +1,52 @@
# 【实施计划】后台游戏审核详情与待审版本试玩
| 字段 | 值 |
| --- | --- |
| Milestone | `docs/project-memory/plans/【里程碑】后台游戏审核详情与待审版本试玩-2026-10-02.md` |
| Status | in-progress |
| Owner | Genarrative Agent |
## 修改边界
### 允许修改
- `server-rs/crates/api-server`:管理员版本详情/预览会话/待审版本资源读取,以及现有发行资源公共逻辑的安全复用。
- `server-rs/crates/shared-contracts`、`apps/admin-web/src/api`:审核详情、预览会话和资料字段 DTO。
- `apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx` 及其测试:详情工作台、试玩入口、审核动作刷新。
- 现有 admin 权限映射、发行 sandbox/CSP/storage 兼容逻辑的必要扩展。
- 当前玩法主规范、里程碑证据和实施计划。
### 明确不修改
- `spacetime-module` 持久表结构、`migration.rs`、SpacetimeDB schema 和现有生成绑定,除非调研发现当前字段不足并重新获得迁移确认。
- `/api/external/v1`、作者发布页面、公开游戏详情和用户评价数据模型。
- 审核员分派、批量审核、精选推荐和后台游戏资料编辑。
## 实现顺序
1. 固化现有详情响应的字段映射与权限边界,先补 admin DTO 和契约测试。
2. 定义版本绑定的短期预览会话与错误语义,复用发行 ZIP 路径白名单、大小限制、HTML 注入、CSP 和响应头。
3. 实现管理员待审版本预览资源读取,确认公开发行路径不能读取未公开版本。
4. 将审核列表升级为详情工作台:资料分组、发布者、版本摘要、封面/截图、试玩状态和审核动作。
5. 补齐正常、拒绝、过期、越权、Cookie、缺文件、资源 404、CAS 冲突和幂等重放测试。
6. 运行真实本地审核 smoke,确认打开的是待审 `versionId`,不是当前公开版本;完成后回写主规范并删除临时计划。
## 验证命令
1. `npm run typecheck`
2. `npm exec -- vitest run apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx apps/admin-web/src/api/adminApiClient.test.ts --root .`
3. `cargo test -p api-server game_distribution --manifest-path server-rs/Cargo.toml`
4. `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`
5. `npm run check:encoding`
6. `npm run check:doc-index`
7. `git diff --check`
8. 本地 `npm run dev` + 管理员登录 + 待审版本详情/试玩 smoke。
## 风险与回滚点
- **预览越权**:Token 必须绑定 `versionId` 和过期时间;错误实现会把私有待审包变成可枚举资源。回滚点是关闭预览会话路由,不影响公开发行网关。
- **版本串播**:预览 URL 不能复用 `game.currentVersion.entryUrl`;详情和资源读取都必须携带并校验待审版本。回滚点是隐藏试玩按钮,保留资料审核。
- **sandbox 放宽**:不得添加 `allow-same-origin`;如 storage 兼容层回归,回滚新增注入逻辑而不放宽 sandbox。
- **资料漂移**:详情必须展示版本冻结快照;若快照缺失,只显示明确缺失状态,不静默用作者当前资料替代。
- **审核并发**:继续使用 `publicationRevision` 和幂等键;CAS 冲突只刷新,不自动重放决定。
- **API 兼容**:新字段可选,旧后台响应不能因缺少冻结资料而崩溃;预览接口失败不能影响公开游戏游玩。
@@ -0,0 +1,81 @@
# 【实施计划】游戏作品管理与客户端发布收口-2026-09-30
关联 Issue:`#470`「做一下创作者作品管理,从工具到平台的一键导出和发布」。
分支:`feat/game-works-management`。
## 目标
在 10.7「陶泥儿游戏平台」上线前,把「创作者作品管理(用户侧 / 后台)+ 客户端打包上传(只保证 Phaser 4)」这条链路补齐到可用:作品能进来、能看得见状态、能管住。
- 发布管道(AGC 构建打包 → 8 MiB 分片上传 → 送审 → 后台审核 → 公开可玩 → 发行网关)已实现,并有真实栈证据(见[实施计划【游戏分发阶段A领域合同】](【实施计划】游戏分发阶段A领域合同-2026-09-19.md))。
- 本轮已补齐:客户端 Phaser 4 + Vite 工程栈门禁、发布阶段/上传百分比、发布根幂等账本;Web 作者私有详情、状态/版本筛选、版本历史、资料编辑、软删除和作者操作;后台作品搜索、作者/状态筛选、游标分页与删除状态展示。
- 生产发布开关仍默认关闭;真实客户端 GUI 发布、生产首个作品和发布包清理策略仍需运行时取证。
## 已落地(本分支)
| 项 | 落点 | 证据 |
| --- | --- | --- |
| 作者读自己名下单个游戏详情 `GET /api/game-distribution/my-games/{gameId}` | `api-server/src/modules/game_distribution.rs`(`get_owner_game`、`load_owner_game_versions`、`owner_game_entry_payload`) | `cargo test -p api-server game_distribution::tests` → 26 passed;`owner_game_entry_payload_carries_private_versions_that_public_payload_omits` 断言作者条目带版本私有状态、公开投影不带 |
| 版本私有状态补 `entryUrl` / `packageFileCount` | 同上 `private_version_payload` | 同上测试断言 |
| 主规范路由表登记新路由并修正 `/my/games` 错名 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 路由表 | — |
设计要点:不新增 SpacetimeDB 表与 procedure。详情路由的 game 走 `get_game_distribution_game`(owner 作用域,精确命中),版本沿用作者自有列表 procedure 的聚合结果。
## 计划与落地状态
### 波 1:闭环可用
- [ ] AGC 真机把「构建 → 打包 → 分片上传 → 送审」跑通一次。
- [x] 发布进度接线:前端监听既有 `game-package-upload-progress`,渲染真实阶段与百分比。
- [x] 幂等键落盘:按账号、origin、本地项目和包摘要持久化根键,重试复用同一 key。
- [ ] 运营开闸 + 生产发布第一个作品,走完审核 → 公开 → 游客可玩。
### 波 2:管理完整
- [x] 作品资料编辑:展示资料在 `game` 级可编辑、立即生效并留审计;包仍随版本冻结。
- [x] 作品软删除:`game_distribution_game` 末尾追加 `deleted_at`,公开投影与列表过滤软删行。
- [x] 网页侧对齐:`/games/mine` 补删除、资料编辑、版本历史、作者查看未公开作品。
- [x] 后台作品列表:游标分页 + 搜索 + 按作者/状态筛选。
### 波 3:运营治理
11. 审核历史展示(复用版本已有的 `reviewed_by_user_id` / `submitted_at` / `reviewed_at` / `published_at`,不新增表)。
12. 批量审核操作。
13. 精选 / 推荐位(与游玩线的推荐排序定边界)。
14. 举报处理(先确认能否复用现役反馈管道)。
## 尚未完成
- AGC 真机 GUI 端到端发布取证。
- 生产环境发布第一个作品并开闸。
- 非 Phaser(Godot / Cocos / Unity)发布的主动拦截:这些工程没有 npm 构建工作区,仍只给泛化错误文案(单 HTML 项目同理)。
- 发布包清理策略的上线验收。
## 不变式
- 新增字段一律追加到 Rust 表结构体末尾并带明确默认值;不改名、不重排、不改类型。改 schema 后同步 `migration.rs`、表目录与生成绑定,并跑 `npm run check:spacetime-schema`。
- 公开投影不得携带作者私有字段(包摘要、驳回理由、文档本地路径)。作者视角走 owner 作用域路由。
- 复用现役管道与组件,不新造上传、审核、发行通道。
## 波 1 · AGC 客户端发布切片(2026-10-01 落地;自动门禁已通过,真实 GUI/生产待验收)
| 项 | 落点 | 行为 |
| --- | --- | --- |
| 发布进度接线 | `src-tauri/src/game_package_upload.rs`、`game_distribution_publish.rs`、`commands.rs`、`src/services/gamePackageUploadProgress.ts`、`src/components/game-distribution/GamePublishProgressDialog.tsx`、`GamePublishPhaseSteps.tsx`、`GameDistributionPublishPanel.tsx`、`App.tsx` | 既有 `game-package-upload-progress` 事件扩展出 `phase`(`prepare` / `upload` / `verify` / `submit`)与 `message`,上传阶段仍带 `receivedBytes` / `totalBytes`;`/complete`(重算摘要 + 展开清单)之前由上传器回调切到「校验」。渲染层在全屏进度弹窗显示「构建」步骤,在发布面板显示「构建 → 准备 → 上传 → 校验 → 送审」步骤条与上传百分比。 |
| 根幂等键落盘 | 新增 `src-tauri/src/game_publish_attempt.rs`(应用数据目录 `game-publish-attempts.json`,原子写 + 进程内锁) | 键按「账号 + origin + 本地项目 + 归一化包摘要」解析:同一份包重发、响应丢失、进程重启与分片续传复用同一根键(服务端因此只认一次尝试);送审成功后清除记录,下一次发布会新增版本。渲染层不再铸键,`publishLocalProjectGame` 只在显式传入时才带 `idempotencyKey`。账本只存账号标识、origin、本地项目标识、包摘要与根键,不含 token 与本地绝对路径。 |
| 发布前工程栈校验 | `src-tauri/src/project/export.rs`(`ensure_publish_project_stack`,导出与发布两条入口都调用) | 存在 npm 构建工作区时要求 Phaser 4 + Vite:缺 `phaser` 依赖、声明的主版本不是 4(含 `package-lock.json` 回退判定)、以及既没有 `vite` 依赖也没有 `vite.config.*` 都给出可操作中文错误(指明缺什么、当前声明、怎么修)。没有 npm 工作区的老单文件项目保持原有发布能力。 |
## 已知限制
- 作者自有游戏列表与新增的作者详情共享同一个单次条数上限(48):单作者作品数超过上限时,详情页拿不到版本记录。放量前需要补一条按 `gameId` 精确列版本的 procedure。
- 客户端不暴露 `localProjectId`:本地项目与线上作品的对应关系由客户端本地账本维护,服务端只在发布时用它复用 `gameId`。
- 发布尝试账本按「本地项目 + 包摘要」判同一尝试:改了工程或重新导出了不同内容的包就是新尝试(新建版本),旧版本的半成品包仍需走网页作者侧的恢复/重置入口。
- 发布阶段条按「本次订阅窗口」收敛(事件的 `versionId` 在命令返回前对渲染层不可知);同一窗口同一时刻只有一次发布在跑。
- 工程栈校验按首版「只支持 Phaser 4」执行:npm 工程(含 Three.js 等三维工程)未声明 `phaser` 会被拦下并提示安装 Phaser 4。若产品决定放行其它 npm 技术栈,需要在这里改成「phaser 声明存在时必须是 4,其余技术栈按白名单放行」。
## 尚未完成
- AGC 真机 GUI 端到端发布取证。
- 生产环境发布第一个作品并开闸。
- 非 Phaser(Godot / Cocos / Unity)发布的主动拦截:这些工程没有 npm 构建工作区,仍只给泛化错误文案(单 HTML 项目同理)。
- 发布包清理策略的上线验收。
@@ -49,6 +49,8 @@ AGC 发布面板完成三项收敛:
## 6. 本次缺陷修复记录
- 2026-09-23:AGC 生成游戏封面请求补充 `generationInputs.source = "ai-game-creator-client"`。队列 worker 依据该来源选择 `GameCreatorResourceEditor` 结果契约;未标记来源时会按 `Standard` 紧凑化并省略 `result`,导致生成完成后无法回传 `assetObjectId`。对应前端定向测试已锁定请求字段与平台素材 ID 回填链路。
- 2026-10-02:生成结果中的 `imageSrc/objectKey` 是私有素材引用,不再直接交给 WebView `<img>`;AGC Rust 发布 facade 经鉴权 `read-url` 有界下载为 `data:` 图片预览,预览失败不阻断已登记素材发布。
## 7. 本轮核对(2026-09-28)
六项交付结果都在合并后的工作树里,并有本轮实测:
@@ -0,0 +1,33 @@
# 【里程碑】AGC 发布版本以工程内部版本为准
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | proposed |
| Date | 2026-10-03 |
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(「身份、状态、审核与更新」章) |
## 目标
让 AGC 发布面板的「项目版本」只有一个权威来源:AGC 工程内部版本记录 `manifest.versions[]`(即资源总览「项目版本」栏目里的版本卡)。标签等于当前存活的内部版本条数(最新卡的「版本 N」),只读、不可手填、不被平台 `version_number` 回填或覆盖,与 `publicationRevision` 无关。
## 边界
- 不改服务端能力:`publicationRevision` 继续严格 CAS;平台 `versionNumber` 继续接受正整数、重复与回退(网页端与历史客户端依赖该兼容口径)。
- 不新增或修改 SpacetimeDB 表、字段与 procedure;`game_distribution_version.version_number` 不动。
- 不重写平台历史版本行;不改公开地址、审核状态机与发行网关。
- 不做版本历史页、版本对比、一键回滚 UI。
- 不删除本地清单字段 `projectVersion`(Rust DTO 带 `deny_unknown_fields`,删除会让存量清单解析失败),它降级为不再读写的兼容位。
## 验收标准
- [ ] 发布面板显示的「项目版本」等于当前 `manifest.versions` 的条数(`versions` 为空时为 1),与平台 `version_number`、`publicationRevision` 均无关。
- [ ] 面板不提供任何修改该值的入口;提交时 `versionNumber` 逐字等于该派生值。
- [ ] 打开已有线上作品(线上最近提交 v6、内部版本 4 条)时,面板显示 v4,且仍并列显示「线上最近提交 v6 · 审核中」。
- [ ] AGC 任何路径都不再写入 `projectVersion`(含发布成功回写、绑定回读回填、面板失焦写回)。
- [ ] 存量清单里已有的 `projectVersion` 值不影响展示与提交。
- [ ] 内部版本被截尾删除(长度 4 → 3)后,面板显示 v3 且能正常提交,不需要任何本地冲突处理。
## 依赖
- 无外部依赖;与「AGC 已发布游戏版本更新(2026-10-02)」是**取代**关系(见该文档内的取代标注)。
@@ -0,0 +1,33 @@
# 【里程碑】AGC 已发布游戏版本更新
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | proposed |
| Date | 2026-10-02 |
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md#AGC 游戏分发与在线游玩合同` |
## 目标
让 AGC 区分首次发布与已有作品更新:已有作品继续使用原 `gameId`;用户维护唯一项目发行版本标签,同一标签可以重复提交或回退,具体提交由不可变 `versionId` 标识。
- 同一 `gameId` 的新版本上传、校验、送审和结果回写。
## 不在范围内
- AGC 完整版本历史页、版本对比和一键回滚 UI;回退版本标签的发布路径属于本里程碑。
- 原地替换已发布 `versionId`;已发布提交保持不可变。
- 新增 SpacetimeDB 持久表或修改 `game_distribution_version.version_number` 类型。
- 改变公开 URL、审核状态机、发行网关或后台审核权限。
## 验收标准
- [ ] 首次发布完成后项目清单保存 `gameId`、`versionId`、版本号、状态和 `publicationRevision`。
- [ ] 重新打开同一项目显示“更新游戏”,不再显示“创建平台游戏”。
- [ ] 项目清单只有一个用户发行版本字段,发布面板编辑它,不存在独立 targetVersion。**(2026-10-03 取代:发布标签改由 AGC 工程内部 `versions[]` 派生且只读,面板不再编辑;见 `【里程碑】AGC发布版本以工程内部版本为准-2026-10-03.md`。)**
- [ ] 同一 `gameId`、同一 `versionNumber` 可以重复提交,生成新的 `versionId`,不覆盖旧公开实例。
- [ ] 项目版本可以低于线上最新版本,服务端允许回退标签,但仍校验为正整数。**(2026-10-03 收窄:平台侧继续成立;AGC 侧不再由用户手填回退,派生标签只在内部版本被截尾删除时变小。)**
- [ ] 同版本旧 pending 提交被取消或被最新提交替代,审核队列不产生重复有效任务。
- [ ] `publicationRevision` 仍用于并发 CAS,与用户版本号规则独立。
- [ ] 更新使用同一 `gameId`,只增加一个新 `versionId`,公开地址保持不变。
- [ ] 旧项目没有发布绑定时能按作者和 `localProjectId` 恢复;恢复失败时明确回到首次发布。
- [ ] 上传响应丢失、重复提交、版本号冲突和切换账号不会创建重复游戏或错误接管作品。
@@ -0,0 +1,51 @@
# 【里程碑】后台游戏审核详情与待审版本试玩
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress |
| Date | 2026-10-02 |
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏审核详情与待审版本试玩合同` |
## 目标
把后台游戏版本审核从“列表操作”补成可复核的审核详情工作台:审核员能确认发布者和冻结游戏资料,能试玩当前待审版本,并能在同一详情上下达审核决定。
## 范围
- 待审版本详情回读。
- 发布者、游戏资料、版本包摘要和冻结资料展示。
- 单个待审版本的短期隔离试玩会话。
- 试玩资源、Cookie、sandbox、CSP 和版本绑定边界。
- 版本级审核动作(通过、拒绝)与现有 CAS/幂等规则对齐;游戏级安全下架入口留在「游戏管理」页。
- admin-web、api-server、shared-contracts 和相关测试的契约同步。
## 不在范围内
- 新增游戏审核、发布者、试玩会话或游戏资料持久化表。
- 审核员分派、SLA、批量审核和精选推荐。
- 后台修改游戏资料、重新构建或重新上传发行包。
- 公开发行接口、作者发布链路和 `/api/external/v1`。
## 依赖与前置条件
- 现有管理员版本详情接口继续返回游戏投影和版本冻结资料。
- 现有 SpacetimeDB `game_distribution_game` 与 `game_distribution_version` 字段满足详情展示。
- 现有发行 ZIP 校验、静态资源白名单和 HTML storage 兼容层可以抽取复用。
- 现有游戏审核权限映射作为第一版预览权限,不新增权限角色。
## 验收标准
- [ ] 审核详情显示发布者、完整游戏资料、版本摘要和审核状态。
- [ ] 详情使用待审版本冻结资料,不被后续 game 行资料修改污染。
- [ ] 审核员可以打开当前 `versionId` 试玩,且 JS/CSS/图片/音频等同包资源可加载。
- [ ] 预览 Token 绑定版本、短期有效、过期和越权读取均失败。
- [ ] 预览不读取平台 Cookie,不授予 `allow-same-origin`,保留发行 sandbox/CSP/storage 约束。
- [ ] 审核页的通过/拒绝继续执行理由、CAS、幂等和刷新规则;行内不出现游戏级安全下架。
- [ ] 无 SpacetimeDB schema、migration、公开契约破坏性变化。
## 证据要求
- 自动化:admin-web 详情/动作测试、api-server 预览会话和权限测试、DTO/类型检查、编码和 diff 检查。
- 运行时:本地管理员打开待审版本,确认资料、封面/截图和待审包试玩;确认旧公开版本不会被误播。
- 边界:过期 Token、错误 versionId、Cookie、越权管理员、缺入口、资源 404、CAS 冲突和幂等重放。
@@ -1,5 +1,18 @@
# 决策记录
## 2026-10-03 AGC 发布版本标签改为由工程内部版本派生,取代「用户可编辑标签」口径
- 背景:用户实机验收指出发布面板「项目版本」显示 v6,而 AGC 工程内部只有 4 条正式版本记录(资源总览「项目版本」栏目 4 张卡,顶栏「智能体修订」下拉同样只有这 4 条)。核实:面板值来自本地清单 `manifest.projectVersion` 这个可编辑标量,它被三条链路反复钉到**平台** `game_distribution_version.version_number` 上——发布成功回写(`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1531-1535`)、打开面板回读绑定回填(`:536-541`)、用户手改(`:944-965`);而 `manifest.versions` 从头到尾不参与该值。`publicationRevision` 只做 CAS,与任何版本号都无推导关系(`module-game-distribution/src/domain.rs:27-40` 的版本号解析只比 `max_existing` 与 `requested`)。
- 决策:AGC 发布面板的「项目版本」改为**由 AGC 工程内部版本记录 `versions[]` 派生**(标签 = 当前存活内部版本条数,即最新版本卡的「版本 N」,空数组取 1),**只读**,且不得被平台 `version_number` 回填或覆盖、不得读取 `publicationRevision`。平台侧 `versionNumber` 语义不变(正整数、允许重复与回退;`None` 仍自动 `max+1` 以兼容网页端与历史客户端)。本地字段 `projectVersion` 降级为遗留兼容位:保留可解析、不再读写(Rust DTO 带 `deny_unknown_fields`,删字段会让存量清单解析失败)。
- 取代(逐条):
1. `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md:97,99`(原「项目清单保存唯一用户发行版本 `projectVersion`」「AGC 发布面板直接编辑项目清单的 `projectVersion`」)→ 已就地改写为「派生只读 + 遗留兼容位」。
2. 同文件原 `:105`(「允许用户修改项目版本标签、重复提交同版本和回退到旧版本标签」)→ 已就地改写为「用户不再编辑版本标签;回退与重复仍由平台侧接受」。
3. `docs/project-memory/plans/【实施计划】AGC已发布游戏版本更新-2026-10-02.md:11,23`(「本地唯一 `projectVersion`…不混入内部编辑迭代 `versions[]`」「发布面板编辑 `projectVersion`」)→ 行内追加取代标注。
4. `docs/project-memory/plans/【里程碑】AGC已发布游戏版本更新-2026-10-02.md:27-30` 的验收项「项目清单只有一个用户发行版本字段,发布面板编辑它」被取代;「项目版本可以低于线上最新版本」在平台侧继续成立,AGC 侧口径改为「派生标签可能因截尾删除而变小」。
- 边界:不改 SpacetimeDB 表/字段/procedure,不改 `publicationRevision` CAS,不改公开地址与审核状态机,不重写平台历史版本行,不做版本历史/回滚 UI。AGC 工程内部版本 `versions` 只能追加或按显式放行删除一个后缀(删素材连带删版本),因此派生标签不保证单调;平台必须继续接受回退标签。
- 影响范围:`packages/shared/src/contracts/gameCreationApp.ts`、`server-rs/crates/shared-contracts/src/game_creation_app.rs`、`apps/ai-game-creator-shell/src-tauri/src/{game_distribution_publish.rs,desktop.rs}`、`apps/ai-game-creator-shell/src/{services/gameDistributionPublish.ts,components/game-distribution/GameDistributionPublishPanel.tsx,App.tsx}`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/tests/**`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/project-memory/plans/`。
- 验证方式:`npx vitest run …gameDistributionPublish*.test.*`、`npm run agc:typecheck`(含 `scripts/check-config.mjs`)、`cargo test -p shared-contracts`、`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml game_distribution_publish`、`npm run check:doc-index`、`npm run check:encoding`、`git diff --check`;真机:内部版本 4 条、线上最近提交 v6 的项目打开面板显示 v4 且只读,提交 `versionNumber: 4`。
- 关联文档:[平台入口与玩法链路](../../【玩法创作】平台入口与玩法链路-2026-05-15.md)、[里程碑](【里程碑】AGC发布版本以工程内部版本为准-2026-10-03.md)。
## 2026-10-04 游戏游玩次数修订:关停不强制 flush、flush 失败丢弃剩余分片、客户端 IP 只信 X-Real-IP
- 变更:ADR `docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md` 修订——原「正常 SIGTERM/滚动重启必须在 `finalize_shutdown` 内 force flush」作废;崩溃、被杀、正常关停都允许丢最后一个未落库窗口,`api-server` 不再注册关停 flush。
@@ -19,6 +32,18 @@
- 权威文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的 `game_distribution_game` 节,以及 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 的「游玩计数(已实现)」节。
- 验证:`cargo check -p api-server` 与 `cargo test -p api-server game_play_counter`(9 passed)通过;前端定向 vitest(点击上报断言 + clientId 稳定性)与 `eslint --max-warnings 0` 通过;`npm run check:server-rs-ddd`、`npm run check:generated-bindings`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。
## 2026-10-04 AGC 工作台顶栏一行到底:三档降级 + 播放并入运行页签 + 运行画面刷新
- 背景:用户现场截图指出四个问题(改动前的形态与口径见 `docs/technical/assets/agc-toolbar-layout-after-20261004/README.md`):① 顶栏放不下时两侧容器各自 `flex-wrap: wrap`,第二行只剩「播放」与版本入口,两行控件分裂;② 版本入口不贴右缘,被前面按钮的文本宽度顶开;③「播放」与「运行」两个入口说的是同一件事;④ 运行画面里的游戏不是 vite dev 的实时刷新,改完代码只能切到资源管理再切回来才能重载页面(飞书讨论里提的「单独的刷新」)。
- 决策(顶栏排版):工具条改**一行到底**(`flex-wrap: nowrap` + `overflow: hidden`),空间不足不再换行,而是按固定顺序降级、档位写在工具条的 `data-layout` 上:`full` → `compact-version`(版本入口只留 `版本 N`)→ `collapsed-actions`(`打开项目目录 / 资源面板 / 整理画布` 收进「更多」下拉)。顺序与 1px 判定余量是纯函数(`workbenchToolbarModel.ts`,单独用例钉顺序);档位由 `useWorkbenchToolbarLayout` 实测写入——`ResizeObserver` 管可用宽度、`MutationObserver` 管**内容**变化(切运行页、出现「恢复草稿」都不改顶栏宽度,只看尺寸会停在旧档位上),每次量宽都把候选档位真的写到 DOM 再读 `scrollWidth`。档位不参与 React 状态:写的是工具条自己的属性,React 不声明就不会覆盖;也没用容器查询(阈值随模式与按钮出现与否变化,写死必然抖)。
- 决策(两个「吞掉溢出」的坑,都是实测踩出来的):`overflow: hidden` 的 flex 子项能缩到 0 或靠省略号吸收溢出,档位判定就永远量不到真实溢出——所以版本入口默认 `min-width: max-content`(只在 `collapsed-actions` 档放开为 `0`,那一档已退无可退,省略号才是兜底)、「依赖 / 类型」分段补 `flex: 0 0 auto`(此前窄宽度下被压成 0 宽)。`max-width: 1000px` / `max-width: 760px` 两处把工具条改成 `flex-direction: column` 的媒体查询删除(那是「第二行」的另一个来源,降级已由档位负责),`≤1000px` 里给动作区的 `justify-content: flex-end` 一并删除(溢出会甩到左边,`scrollWidth` 看不见)。最后一档确实放不下时(视口远小于 1280 合同宽度)才改右对齐:宁可裁左边,也不把钉在最右的版本入口裁没。
- 决策(版本入口与两端分组):工具条这一行**两端留给体量最大的两枚分组控件**——左端「资源管理 / 运行」、右端「依赖 / 类型」(用户口径:「最大的这两个放两边」),中间依次是动作按钮与版本入口;版本入口 `margin-left: auto` 推到动作区右侧,且紧邻排序分段左侧(用户口径:「版本应该在依赖 / 类型左边」),不再是最右那一枚。显示 `版本 N(原因 · 时间)`,其中 `版本 N` 复用资源画布版本卡的编号口径(`manifest.versions` 落盘顺序 + 1,`formatIterationVersionTitle`),括号里那截是独立一层(`formatIterationVersionDetail`)——窄档位收掉的是这一层而不是整枚入口;可访问名、菜单项与排障文案一律保留完整标识。DOM 顺序由 `tests/resourceVersionSwitch.test.tsx`(版本入口在排序分段之前)与 `tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx`(排序分段是动作行最后一个子元素)双向钉住。
- 决策(播放并入运行 + 刷新入口):删掉独立的「播放」按钮,「运行」页签前加 ▶ 图标,点页签=`showRunView()` + `onPlay?.()`(与旧播放按钮逐字等价,含「再点一次=重跑」);不可运行时页签不置灰、点了既不切视图也不发播放请求,只出既有提示。运行画面右下角新增「刷新运行画面」(全屏那一枚左侧):`onPlay` 命中活体预览只切视图、不重启服务,真正重载页面靠换 `iframe` 的元素身份(`LocalGamePreviewFrame` 新增 `reloadNonce`)——运行页在另一个端口上,跨域 iframe 里 `contentWindow.location.reload()` 会被浏览器挡掉。
- 决策(进入项目自动载入):站在运行视图上却没有画面可看时自动补发一次 `onPlay`——典型现场是从别的项目切过来(工作台不重挂,`mode` 是工作台自己的 state,上一条项目的运行视图原样留下而画面已经没了),用户只会看到「客户端运行画面尚未载入」,像坏了一样。只在**没有画面且可运行**时发;`showRunView` 先记账再自己发播放(`autoRunPreviewProjectRef`)所以点页签不会被重复触发;同一个项目只自动补一次,失败不打转,手动重跑仍走页签或画面上的刷新按钮。
- 边界:不改后端、契约与 SpacetimeDB;`runAvailable` / `showRunView` 的门槛语义不变,自动切运行的两条路径(会话内已确认的预览、播放请求)不走 `showRunView`,不会多发播放请求。窄于合同宽度只保证不崩,不做移动端布局。
- 影响范围:`apps/ai-game-creator-shell/src/{styles.css,view/project-development/{index.tsx,workbenchToolbarModel.ts,useWorkbenchToolbarLayout.ts,WorkbenchMoreActionsMenu.tsx},features/resource-canvas/{GameRunVersionPicker.tsx,resourceCanvasVersionBindingModel.ts},features/project-workspace/LocalGamePreviewFrame.tsx}`;用例 `tests/{workbenchToolbarLayout,runPreviewRefresh,runAutoLoadOnEnter}.test.ts(x)`(新增)、`tests/appSurface/project-development.suite.ts`、`tests/{gameRunToolbarActionsStyle,resourceCanvasVersionBindingModel}.test.ts`;文档 `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S17 改写 + 新增 S17a)、`docs/README.md`、新目录 `docs/technical/assets/agc-toolbar-layout-after-20261004/`。
- 验证:`npx vitest run apps/ai-game-creator-shell/tests`(200 passed / 1 skipped 文件,1929 passed / 17 skipped 用例,末次全量);定向 8 个文件 253 passed;`npm run typecheck`(在 `apps/ai-game-creator-shell`,含 `check:tests:types`——只跑 `tsc -p tsconfig.json` 覆盖不到 `tests/`)、eslint `--max-warnings 0`、`prettier --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 全绿。真机几何用一次性 Vite 夹具在真实 Chromium 里逐档实测(视口 1412 / 1240 / 1100 / 1024 / 960 / 860 / 800 / 760 / 700 / 640 / 560 / 480 / 400):始终单行、排序分段贴右缘(`工具栏右缘 - padding - 排序分段右缘 = 0`)、版本入口始终在排序分段左侧、1240/1100 走 `compact-version`、1024/800/700/640/560/480 走 `collapsed-actions`,「更多」下拉三条动作可点且点「资源面板」真的开面板、点「刷新运行画面」`iframe` 换新节点而 `src` 不变;截图见 `docs/technical/assets/agc-toolbar-layout-after-20261004/`。
## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602)
- 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。
@@ -2,6 +2,42 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
## 2026-10-03 AGC 随包 plugins 的 feature 档位必须与消费方一致,且门禁会因 build.rs 未重跑而假通过
- **现象**:Windows 本机 `npm run check:generated-bindings`(`npm run lint` 链内,`scripts/check-repository-ci.sh` 的 Repository checks 也走它)在 `build.rs:167:29` panic:`插件随包资源校验失败:随包插件存在未声明文件:.../src-tauri/resources/plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll(目标 x86_64-pc-windows-msvc 与当前 feature 组合不允许;请先执行随包资源准备步骤)`;树上换成 `agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe` 时报同一类错。反向还有更隐蔽的形态:门禁 2 秒就 exit 0 说「通过」,但 tree 上其实带着编辑器产物。
- **原因**:`apps/ai-game-creator-shell/src-tauri/resources/plugins/` 是 gitignored 但被 dev / 发布 / 门禁多流程共用的目录,它的**档位**(staging 里放了哪些编辑器产物)必须与本次 cargo 调用实际生效的 feature 组合一致。`apps/ai-game-creator-shell/src-tauri/Cargo.toml` 的 `[features] default =` 是空的,而 `scripts/check-generated-bindings.mjs` 对 AGC 用的是裸 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(**不带任何 feature**);`build.rs` 按「当前 TARGET + 已启用 `CARGO_FEATURE_*`」构造 plugins 允许集合,于是为 windows 编辑器 feature 准备的 `origin: prepared` / `libraryStaging` 产物全成了「未声明文件」。
- **关键坑(假通过)**:`resources/**` **不是**构建脚本声明的输入(设计如此,避免每次资源变化都重编),所以缓存命中的 `cargo test` **根本不会重跑 `build.rs`**,校验被整个跳过。实测:树上带着未声明的 unity 产物时,门禁仍以 2.33 秒 exit 0「通过」;`touch apps/ai-game-creator-shell/src-tauri/build.rs` 强制重跑后才暴露。
- **处理(现行口径)**:校验 / 无 feature 的消费方先复位到 featureless:`npm run agc:bundled-resources:prepare -- --features=`。要带编辑器能力的本地 AGC:`npm run agc:bundled-resources:prepare -- --target x86_64-pc-windows-msvc --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute`(`cocos-editor-bridge.dll` 的 native payload 另需 `cocos-editor-injection`)。要求门禁**真校验**过,先 `touch apps/ai-game-creator-shell/src-tauri/build.rs` 再跑门禁。
- **判据/取证**:复位后强制重跑那次输出 `生成绑定校验通过:shared-contracts(1 个文件)` / `生成绑定校验通过:ai-game-creator-shell(104 个文件)` / `生成绑定校验通过:合计 105 个文件与 Rust 声明一致`(exit 0)。featureless 判据:`resources/plugins` 只有各插件 `plugin.json` + `src/`(cocos 另有 `panels/`),不存在 `Agc.Unity.Attach.exe` / `agc_godot_editor.dll` / `cocos-editor-bridge.dll`。
- **边界**:这是同一工作树里多人共享的档位——featureless 是无 feature 构建与并发 cargo 运行的前提,带编辑器产物会让它们失败,反之亦然。`prepare-bundled-resources.mjs` 的原子替换要 rename `resources/plugins`,撞上外部目录句柄会 `EPERM` 并把 staging 留在 `resources/plugins-staging-<pid>-<hex>`(`.gitignore:61` 已声明该模式),确认无并发进程后重试即可。
- **关联**:`apps/ai-game-creator-shell/src-tauri/build.rs`(`validate_staged_plugins` / `validate_prepared_payloads`)、`src-tauri/build_support/package-layout.json`、`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,cargo-features.mjs}`、`scripts/check-generated-bindings.mjs`;另见本文件「随包资源的写入方按产物来源分界」「AGC 随包资源的布局只能改声明文件」「构建期 staging 撞上不装 npm 依赖的 Linux 门禁」三条。
## 2026-10-03 工作树位于 `.worktrees/` 下时前端 Vite 被自己的忽略规则整棵排除:改代码不热更
- **现象**:在 `.worktrees/<name>/` 里的工作树改主站或后台前端源码,浏览器看不到任何更新;重启 Vite 也不生效(本轮有人重启 3 次才发现不是缓存问题)。
- **原因**:根 `vite.config.ts`(`ignoredWatchGlobs`,约 461-468 行)与 `apps/admin-web/vite.config.ts`(约 19-24 行)的忽略列表都含 `'**/.worktrees/**'`。工作树本身就在 `.worktrees/` 目录下,该 glob 会匹配工作树内的**每一个文件**,等于把整个项目排除出 watch——不报错、不提示,只是永远不触发 HMR / 重建。
- **影响面**:主站 web 与 admin-web 都中招(两者共用这条规则);`npm run dev:web` 走 `scripts/vite-cli.mjs`,它只是转发到 Vite 自带 bin、用的是同一份根配置,所以经 CLI 入口启动也一样。`apps/ai-game-creator-shell/vite.config.ts` 只忽略 `'**/src-tauri/target/**'`,**不受影响**。
- **处理方向(本轮未改配置)**:忽略规则必须只排除**其它** worktree 而放行当前 root——例如按真实仓库根计算,形如只忽略 `<repoRoot>/.worktrees/*/` 且显式排除当前 root;或当项目 root 自身已落在 `.worktrees/` 内时不注入该条。不要保留无条件的 `'**/.worktrees/**'`。
- **验证方向**:修改 `src/**` 一句话后,主站与 admin-web 各自应打印 HMR 更新(修改后立即生效),而不是只在重启后才生效;必要时打印生效的 watch 忽略集合确认不再覆盖当前 root。
- **关联**:`vite.config.ts`、`apps/admin-web/vite.config.ts`、`apps/ai-game-creator-shell/vite.config.ts`、`scripts/vite-cli.mjs`。
## 2026-10-03 同一工作树并发拉起多份 dev 栈:`.app/dev-stack.json` 互相覆盖,启动兜底清扫会反杀健康栈
- **现象**:在同一工作树里再开一个 `npm run dev` 之后,AGC 侧报「后端归属校验失败」,或前端代理连到别的端口(「后端端口记错」);更严重的是新会话启动后,`8082` / `8083` 上原本健康的后端被清掉,旧会话随即报连接失败。
- **原因**:`.app/dev-stack.json` 是**全工作树单文件**(`scripts/dev.mjs` 的 `resolveDevStackStatePath()` → `<repoRoot>/.app/dev-stack.json`,`scripts/dev-all.mjs` 与若干 e2e 脚本也读它),每个 `DevRunner` 都整份覆写快照,端口、SpacetimeDB data-dir 与 instance id 只保留最后写入者,于是两份并发栈互相覆盖实例信息。同时 `dev.mjs` 在启动/退出时会按身份兜底清扫 `stopWindowsWorktreeBackendProcesses`(`api-server.exe` 绝对路径 + SpacetimeDB `--data-dir`),这是**按工作树**而不是按会话匹配的:其它会话留下的半死栈一旦重启,就会把当前健康栈一并收走。
- **处理(现行口径)**:同一工作树保持**单栈**;确需并发时用显式端口参数(`--api-port` / `--web-port` / `--admin-web-port` / `--spacetime-port` 等)错开,并接受状态文件只有一个「最后写入者」。清理残留必须按**端口 → PID → 命令行**确认归属,再杀该 PID 的整棵进程树;不要 `taskkill /IM node.exe`(会误伤其它会话与 IDE 的 Node 进程)。
- **排查顺序**:先比对 `.app/dev-stack.json` 的 status / 端口与实际监听(`Get-NetTCPConnection -State Listen -LocalPort ...`)是否一致,再用 `Get-CimInstance Win32_Process` 按本工作树 `server-rs\target\debug\api-server.exe` 路径与 SpacetimeDB `--data-dir` 核对归属;不要因为 `/healthz` 返回 200 就认定后端属于当前会话。
- **关联**:`scripts/dev.mjs`(`resolveDevStackStatePath` / `stopWindowsWorktreeBackendProcesses`)、`scripts/dev-windows-process.mjs`、`scripts/dev-all.mjs`、`scripts/check-game-distribution-ratings-e2e.mjs`;另见本文件「`npm run agc` 的 Ctrl+C 不能只依赖 shell 包装层与端口健康检查」与 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
## 2026-10-03 Windows 上会弹阻塞模态的启动失败用例挂死本机分片 runner
- **现象**:Windows 本机 `npm run ai-game-creator-shell:check:rust:shell -- --shard-index=3/4` 长时间无进展(可到 2400s 超时);单独跑那条用例同样超时——进程还在、CPU 不再增长、也没有子进程,形态很像「测试死锁」或「分片器坏了」。
- **原因**:`apps/ai-game-creator-shell/src-tauri/src/main.rs:2042` 的 `startup_log_slot_fail_without_path_still_reports_instead_of_going_silent` 调 `StartupLogSlot::fail()`(约 1854 行),而 `fail()` 会走 `show_startup_error_dialog()`;Windows 实现(约 1740 行)用的是 `MessageBoxW(..., MB_OK | MB_ICONERROR | MB_SETFOREGROUND)`,是**阻塞模态**,没有人点「确定」就永不返回。`STARTUP_ERROR_DIALOG_SHOWN`(约 1534 行)只在同一个进程内保证「只弹一次」,对测试用例没有任何豁免。分片 runner 用 `--exact <名单> --test-threads=1` 串行执行,一条挂死就整片挂死。
- **影响面**:Linux CI 走非 Windows 分支(约 1778 行)只写 stderr,**不受影响**;这是本机专属现象,不要据此判定 Rust 代码或分片规则有问题。
- **处理(本机绕过)**:改用等价分块跑,而不是整片上阵——同一个测试二进制、同一 `--exact <名单>` 与 `--test-threads=1` argv、同一 TMPDIR 隔离,把这条阻塞用例排除或单独限定。
- **判据/取证**:单独执行 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- --exact startup_log_slot_fail_without_path_still_reports_instead_of_going_silent --test-threads=1` 本机同样挂住;对照非 Windows 分支只产生 stderr 文案。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/main.rs`、`apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`;另见本文件「AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖」条中此前记为「本机跑到约 20 分钟后长时间无进展」的同一现象。
## 2026-10-03 AGC 画布「引用」死按钮:window 事件的消费者挂在一个只在另一条链路赋值的 ref 上
- **现象**(Issue 602):AGC 资源画布选中一张已登记素材,选中工具条点「引用」(图标 `@`、可见文案与 `title` 都是「引用」)没有任何反应——聊天输入框里不出现 `@素材名` 芯片,也没有任何提示。普通项目(`directProjectMode`)必现,立项策划项目(`planningStartMode`)复现不出来;把素材卡拖到对话栏的批量引用同样没反应。
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,24 @@
# AGC 顶栏布局收口:改动后对照图(2026-10-04)
本目录是「优化 AGC 标题栏布局」(`fix/agc-toolbar-layout`)的改动后截图,供 PR 与后续回归对照使用。四张图都取自一次性冒烟夹具(`.codex/skills/agc-workbench-browser-smoke`:真实 Chromium 挂 `ProjectDevelopmentView`,不是 Tauri 客户端),因此没有客户端窗口边框与左侧启动器侧栏。
| 文件 | 画面 | 改动后的形态 |
| ------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `01-toolbar-resources-full-1412.jpg` | 资源管理页(窗口 1412) | 顶栏一行到底:**两端是两枚大分组**——左端「资源管理 / 运行」,右端「依赖 / 类型」;中间依次是动作按钮与版本入口,版本入口紧邻排序分段左侧并显示完整标识 `版本 1(智能体修订 · 2026/10/3 14:21:05)` |
| `02-toolbar-resources-compact-1240.jpg` | 资源管理页(窗口 1240) | 空间不足第一档:版本入口收成 `版本 1`,其余一枚不动、不换行,行尾仍由「依赖 / 类型」收住 |
| `03-toolbar-more-menu-800.jpg` | 资源管理页(窗口 800) | 空间不足第二档:`打开项目目录 / 资源面板 / 整理画布` 收进「更多」下拉,展开即同一批动作;行尾仍由「依赖 / 类型」收住 |
| `04-toolbar-run-tab-and-preview-refresh-1412.jpg` | 运行页(窗口 1412) | 「播放」并入「运行」页签(页签前带 ▶);运行画面右下角多一枚「刷新运行画面」,与「全屏预览」并列 |
改动前的问题(用户现场截图):
1. 顶栏放不下时两侧各自 `flex-wrap: wrap`:第二行只剩「播放」与版本入口,两行控件分裂;
2. 版本入口不贴右缘,被前面按钮的文本宽度顶开;
3. 「播放」与「运行」是两个入口说同一件事;
4. 运行画面里的游戏不是实时刷新,改完代码只能切到资源管理再切回来重载;
5. 两枚体量最大的分组控件(「资源管理 / 运行」「依赖 / 类型」)挤在同一侧,版本入口又压在「依赖 / 类型」右边——现在两枚大分组分居一行两端,版本入口在排序分段左侧。
相关文档口径已同步到:
- [`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../../../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md)(顶部工具条那一行)
- [`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`](../【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md)(S17 播放、S17a 刷新)
- [`docs/project-memory/shared-memory/decision-log.md`](../../../project-memory/shared-memory/decision-log.md)(顶栏降级档位与两端分组决策)
@@ -63,8 +63,9 @@
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| ---------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **S16** 版本切换 | 运行模块**右上角**版本选择器 → 切到另一版本 | 触发钮显示当前版本;切换后资源卡「当前使用」高亮与 **@ 面板「当前版本素材」同步更新**,并重载当前预览 | L165、L190、L324、L530;#309 C7 | `aria-label="当前版本:<label>"`、菜单 `role="listbox" aria-label="切换游戏版本"`、选项 `role="option" aria-selected`;`[data-used-by-current-version="true"]` 集合随切换变化 | **C7 只做记录层 + UI 层**;"历史版本独有资源集合被运行时按版本加载"需新增版本化资源解析机制,**本轮未实现**;版本卡卡面**不渲染**「项目修订 / 父版本」文字 |
| **S17** 播放 | 顶部「播放」 | 直接切运行视图并启动本地预览;**不再弹 `game.run_local` 二次确认**;预览占满中央可用区、无 iframe 滚动条 | L16、L71–72、L122、L493 | `data-resource-view-state` 变为 `undefined`;`await window.__TAURI__.core.invoke('get_local_game_preview_status',{projectPath})` → `status==='running'` 且有 `url`(形如 `http://127.0.0.1:<随机端口>/`) | 播放按钮 `disabled={!runAvailable \|\| !onPlay \|\| uiEditorRoute!==null}`,**不可用时点不动**;不可用提示实现文案为「首个可运行原型尚未完成,运行视图暂不可用」,与 PRD L165 表述不同(见 7.4) |
| **S16** 版本切换 | 顶栏动作区的版本入口(「依赖 / 类型」左侧,运行页与资源页都在)→ 切到另一版本 | 触发钮显示当前版本;切换后资源卡「当前使用」高亮与 **@ 面板「当前版本素材」同步更新**,并重载当前预览 | L165、L190、L324、L530;#309 C7 | `aria-label="当前版本:<label>"`、菜单 `role="listbox" aria-label="切换游戏版本"`、选项 `role="option" aria-selected`;`[data-used-by-current-version="true"]` 集合随切换变化 | **C7 只做记录层 + UI 层**;"历史版本独有资源集合被运行时按版本加载"需新增版本化资源解析机制,**本轮未实现**;版本卡卡面**不渲染**「项目修订 / 父版本」文字 |
| **S17** 播放 | 顶部「运行」页签(页签前带 ▶ 图标) | 切到运行视图并启动本地预览;**不再弹 `game.run_local` 二次确认**;预览占满中央可用区、无 iframe 滚动条;**站在运行视图却没有画面时(例如从别的项目切过来)自动补发一次载入**,同一个项目不重复补发 | L16、L71–72、L122、L493 | `data-resource-view-state` 变为 `undefined`;`await window.__TAURI__.core.invoke('get_local_game_preview_status',{projectPath})` → `status==='running'` 且有 `url`(形如 `http://127.0.0.1:<随机端口>/`) | 播放并进「运行」页签,**没有独立的「播放」按钮**:不可运行时(`runAvailable === false`)页签不置灰,点了既不切视图也不发播放请求,只出提示「首个可运行原型尚未完成,运行视图暂不可用」(与 PRD L165 表述不同,见 7.4) |
| **S17a** 刷新运行画面 | 运行画面右下角「刷新运行画面」(全屏那一枚左边) | 重新载入游戏页面:游戏预览不是 vite dev 的实时刷新,改完代码要看到新一版只有重载页面这一条路(此前只能切到资源管理再切回来) | L134 | `iframe` 换成新 DOM 节点且 `src` 不变(跨域 iframe 里 `contentWindow.location.reload()` 会被浏览器挡掉,实现走 React `key` 换元素身份);点击同时向宿主确认一次预览还活着(`onPlay` 命中活体预览不重启服务)。没有活预览时**不渲染**这枚按钮 | 全屏里照旧可用(两枚按钮都在画面那一格内);只重载页面,不重跑构建 |
| **S18** 停止与退出收尾 | 聊天侧工程操作组点「停止预览」;或关闭客户端 | 预览停止、manifest `preview` 对齐 `stopped`(清 url/port);退出时也统一收尾 | L165 | 停止后 `get_local_game_preview_status` 非 running;`.agent/logs/preview.log` 追加 `stopped`;退出后重开同一项目应落回 `resource-overview` | 停止按钮**不在**运行视图工具栏,在工作台右侧工程操作组;退出失败会打 `preview.gui_exit.stop_failed` |
### E 阶段 · 对话侧
@@ -170,6 +171,7 @@ window.__TAURI__.core.invoke = (cmd, args) => {
| S15 | 弹窗是否出现 `被 N 个游戏版本使用`;不勾选时版本记录仍在(可读但悬空) |
| S16 | `aria-label="当前版本:…"` 是否随切换更新;`[data-used-by-current-version="true"]` 集合是否变化 |
| S17 | `get_local_game_preview_status` 返回的 `status/url/port`;`.agent/logs/preview.log` 的 `running` 行 |
| S17a | `iframe` 是否是**新的** DOM 节点(`document.querySelector('iframe')` 前后比较)而 `src` 不变;没有活预览时 `button[aria-label="刷新运行画面"]` 是否为 `null` |
| S18 | 退出后重开项目是否落回资源总览;Rust 日志中的 `preview.gui_exit.stop_failed` |
| S19 | chip 的 `data-resource-reference-id`;两页签 `role="tab"` 的 `aria-selected`;空态文案(`当前版本还没有绑定素材` / `当前项目还没有已登记素材` / `没有匹配的素材`) |
| S20 | 弹窗标题「发送前提醒」;`localStorage['agc.chat.prompt-polish-reminder.disabled']` 的值;`润色中…` 或 `AI 润色失败,可重试` 状态行 |
@@ -204,6 +206,7 @@ api-server 是否本次重启:□ 是 □ 否
| S15 删除三分支 | ☐ 无引用 ☐ 引用未勾选 ☐ 勾选连带 | |
| S16 版本切换 + 当前使用高亮 + @ 同步 | ☐ 过 ☐ 不过 | |
| S17 播放 → 运行视图 + 本地预览 | ☐ 过 ☐ 不过 | url:******\_\_****** |
| S17a 刷新运行画面(iframe 换节点、src 不变) | ☐ 过 ☐ 不过 | |
| S18 停止预览 / 退出收尾对齐 stopped | ☐ 过 ☐ 不过 | |
| S19 @ 引用两页签 + 独立筛选 + chip 原子性 | ☐ 过 ☐ 不过 | |
| S19a @ 面板标签筛选(多标签 AND,叠加关键字与功能分类) | ☐ 过 ☐ 不过 | |
@@ -264,7 +267,7 @@ api-server 是否本次重启:□ 是 □ 否
| 偏差 | 文档表述 | 代码实际 | 建议 |
| ---------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| 资源卡「打开详情」 | PRD L61–62 要求"打开详情"与"播放 / 暂停"是可分别键盘聚焦的同级按钮 | 不存在该按钮(测试显式断言为 `null`);真正入口是 `选中资源:<栏目> <名>`,播放按钮是 `播放/暂停 <名>`;**只读信息浮层已回归**(2026-09-11 工具条「信息」动作) | 按 #309 C3(取消资源详情面板与全屏编辑路由)判,**C3 取消的仍只是可编辑详情面板与全屏编辑路由**;PRD L61–62 建议同步修订 |
| 运行入口不可用文案 | PRD L165 显示"当前无可运行版本",但仍允许点击 | 源码中无此字符串;实现为「首个可运行原型尚未完成,运行视图暂不可用」;且播放按钮 `disabled`,只有"运行"tab 不 disabled、用 `data-unavailable` 表达 | 建议按"tab 可点 + 播放按钮置灰"判,并把 PRD L165 文案对齐实现 |
| 运行入口不可用文案 | PRD L165 显示"当前无可运行版本",但仍允许点击 | 源码中无此字符串;实现为「首个可运行原型尚未完成,运行视图暂不可用」;「运行」页签不置灰、用 `data-unavailable` 表达,点了不切视图也不发播放请求 | 按"页签可点 + 不可用时什么都不做并出提示"判,并把 PRD L165 文案对齐实现 |
| 画布生成入口 | #309「画布生成入口未接」 | 已接两条:浮层入口只做视频;音频与图片类在栏目画布底部工具栏(见 S11a / PRD §3.10) | 按代码与当前产品口径判,并更新 #309 进度段 |
| 分类手设入口 | 旧版 PRD「用户可在分类与标签面板手动设置 category」 | 已移除;面板只编辑 tags(已随 `6bdc8bbd9` 入库) | 按新口径判 |
| 真机 manifest 分类分布 | — | 落盘 `unclassified 57 / ui-interaction 3 / scene 1` | **读时自愈**会把 kind 可明确分类的资产显示到正确栏目,判定必须看 UI 栏目计数,不能看 manifest 文件,否则必然误报"分类不生效" |
@@ -483,8 +483,10 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
- 用途:游戏分发稳定身份与公开版本指针。保存 owner、标题/简介/分类资料、设备与输入声明、`publication_revision`、当前 `active_version_id`、可见性和游玩计数;标签与输入模式按版本化 JSON 保存,展示资料由 `api-server` 通过 `spacetime-client` 归一后返回。
- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。 发布写入受灰度配置键 `game-distribution:publish` 约束:**灰度默认关闭**,未配置或 `enabled=false` 时写入口(创建游戏/版本、确认包、送审、审核通过激活)返回 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`,`enabled=true` 且白名单/比例/标签命中才放行,读取与安全下架保持可用;同一判据在 `GET /api/runtime/frontend-config` 以 `gameDistributionPublishEnabled` 下发给前端入口,匿名恒为 `false`。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。已软删除的游戏不参与复用:删除后重新发布同一本地项目应得到新的游戏身份。
- 软删除:游戏行末尾追加可空 `deleted_at`(2026-10-01)。非空表示作者已删除该作品:`delete_game_distribution_game_and_return` 只写该时间戳并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料一律不改写。软删行不进入作者列表(`list_owner_game_distribution_games_and_return`)、公开目录(`list_public_game_distribution_games_and_return`)、公开详情(`get_public_game_distribution_game_and_return`)、发行网关素材授权(`game_distribution_asset_has_public_read_grant`)与审核队列;后台默认视图同样排除,只有显式 `status=deleted` 才会读到。作者侧版本回读对软删作品返回空(404),因此上传、确认与送审入口一并关闭。
- 资料编辑:`update_game_distribution_game_metadata_and_return` 覆盖游戏行上的展示字段(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向)并立即生效,要求 `expected_publication_revision` CAS;版本行与冻结资料不变,下一次审核通过仍会用新版本的冻结资料覆盖游戏行。
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published`、`deleted_at` 为空且存在有效 `active_version_id` 的投影。
- 游玩计数写入:`play_count` 只由批量 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `GameDistributionPlayCountIncrementInput { increments: Vec<{ gameId, delta }> }`)累加。`api-server` 在内存里按 `identity + gameId` 做 30 分钟去重、按 `IP + gameId` 做固定窗口限流后,按 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5 秒)批量落库;事务内只对 `published` 且存在有效 `active_version_id` 的游戏 `saturating_add`,非公开静默跳过,且**不更新** `updated_at`。公开 HTTP 入口为 `POST /api/game-distribution/games/{gameId}/plays`,完整行为见玩法链路的「游玩计数(已实现)」。
### `game_distribution_review`
@@ -525,11 +527,12 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
### 后台游戏管理读模型与恢复动作(2026-09-23)
- 页面目标:后台新增「游戏管理」页,展示全量游戏(标题 / 作者名 + 头像 / gameId / 状态 / 版本数 / 游玩数),行内提供安全下架、恢复与版本历史;它是运营面的全量视图,不替代 `#game-distribution` 待审队列。
- 数据来源:新增只读 procedure `list_admin_game_distribution_games_and_return`(输入 `GameDistributionAdminGameListInput { limit }`)。它在同一事务里读 `game_distribution_game`,按 `by_game_distribution_version_game_id` 统计每个游戏的版本数并取最近 20 个版本;作者名与头像按 `user_account.user_id` 读时联 `display_name` / `avatar_url`(行内快照为空时以联表结果为准)。不新增表、不改 schema、不改公开投影。
- 恢复动作:新增 procedure `restore_game_distribution_game_and_return`(输入 `GameDistributionRestoreInput`)。它只允许管理员解除 `suspended`:重新激活该游戏最近一个由管理员暂停撤回(`status = revoked`、`published_at` 非空且 `reviewed_by_user_id` 非空)的版本,恢复 `visibility = published` 并递增 `publication_revision`;作者自行下架的版本不写审核者,因此不会被恢复动作重新公开。没有可恢复版本、`expected_publication_revision` CAS 不符或游戏不在暂停态时失败关闭。幂等收据复用 `game_distribution_idempotency_receipt`(action = `restore`),恢复动作不受发布灰度开关限制,与安全下架同口径。
- 后台 HTTP:`GET /admin/api/game-distribution/games?limit=` 返回 `{ games: [{ gameId, title, author{ id, name, avatarUrl }, status, versionCount, playCount, activeVersionId, publicationRevision, createdAt, updatedAt, versions: [...] }] }`;`POST /admin/api/game-distribution/games/{gameId}/restore` 要求 `Idempotency-Key` 与 `expectedPublicationRevision`,返回 `{ game, replayed }`。两者都走 `require_admin_auth`,Tab 权限为 `game-management` 或 `editor-showcase`,不新增公开契约。
- 前端:`apps/admin-web` 新增 `#game-management` 路由与 `AdminGameManagementPage`,复用现有 `admin-table` 表格与 `useAdminWriteConfirm` 二次确认;版本历史在弹层内展示,长列表保持横向滚动。
- 页面目标:后台新增「游戏管理」页,展示游戏(标题 / 作者名 + 头像 / gameId / 状态 / 版本数 / 游玩数),支持关键词、作者与状态筛选与游标分页,行内提供安全下架、恢复与版本历史;它是运营面的全量视图,不替代 `#game-distribution` 待审队列。
- 数据来源:只读 procedure `list_admin_game_distribution_games_and_return`(输入 `GameDistributionAdminGameListInput { limit, keyword, ownerUserId, status, cursor }`)。它在同一事务里读 `game_distribution_game`,按 `by_game_distribution_version_game_id` 统计每个游戏的版本数并取最近 20 个版本;作者名与头像按 `user_account.user_id` 读时联 `display_name` / `avatar_url`(行内快照为空时以联表结果为准)。不新增表、不改公开投影。
- 筛选与分页(2026-10-01):`keyword` 大小写不敏感匹配标题、gameId 或作者 user ID;`ownerUserId` 精确匹配作者;`status` 取 `published` / `unpublished` / `suspended` / `deleted`,为空时排除已软删除作品(`deleted` 只返回软删行)。排序仍是创建时间倒序、gameId 升序,游标是 `<created_at_micros>:<gameId>`,结果类型新追加 `next_cursor`;多取一条判断是否还有下一页,因此**不会**像旧版那样静默截断到 200 条。
- 恢复动作:procedure `restore_game_distribution_game_and_return`(输入 `GameDistributionRestoreInput`)只允许管理员解除 `suspended`:重新激活该游戏最近一个由管理员暂停撤回(`status = revoked`、`published_at` 非空且 `reviewed_by_user_id` 非空)的版本,恢复 `visibility = published` 并递增 `publication_revision`;作者自行下架的版本不写审核者,因此不会被恢复动作重新公开。没有可恢复版本、`expected_publication_revision` CAS 不符、游戏不在暂停态或已软删除时失败关闭。幂等收据复用 `game_distribution_idempotency_receipt`(action = `restore`),恢复动作不受发布灰度开关限制,与安全下架同口径。
- 后台 HTTP:`GET /admin/api/game-distribution/games?limit=&keyword=&owner=&status=&cursor=` 返回 `{ games: [{ gameId, title, author{ id, name, avatarUrl }, status, versionCount, playCount, activeVersionId, publicationRevision, createdAt, updatedAt, deletedAt, versions: [...] }], nextCursor }`(旧字段保持原样,`deletedAt` 与 `nextCursor` 是新增项);`status` 不在白名单时返回 400。`POST /admin/api/game-distribution/games/{gameId}/restore` 要求 `Idempotency-Key` 与 `expectedPublicationRevision`,返回 `{ game, replayed }`。两者都走 `require_admin_auth`,Tab 权限为 `game-management` 或 `editor-showcase`,不新增公开契约。
- 前端:`apps/admin-web` 的 `#game-management` 路由与 `AdminGameManagementPage` 提供关键词/作者/状态筛选、`AdminPagination` 游标翻页与版本历史弹层;`status=deleted` 行展示删除时间,下架/恢复沿用 `useAdminWriteConfirm` 二次确认。后台 fake API(`scripts/admin-web-fake-api.mjs`)按同一查询参数过滤并返回 `nextCursor`。
- 验收:`cargo test -p api-server game_distribution`、`cargo test -p spacetime-module game_distribution`、`npm run spacetime:generate` 后 `npm run check:spacetime-schema`、admin-web 定向 Vitest + typecheck、`npm run check:encoding`、`git diff --check`。
### `game_distribution_idempotency_receipt`
@@ -56,9 +56,9 @@
| 字段 | 值 |
| --- | --- |
| Version | 0.2 |
| Status | proposed(待评审,未上线) |
| Status | current(实现已落地;真实客户端 GUI 与生产发布仍待验收) |
| Date | 2026-09-18 |
| 适用边界 | 本节是新增业务提案;评审与实现验收前,不改变上文现役入口和移动端合同 |
| 适用边界 | 当前 game-distribution 作品管理、Phaser 4 客户端发布和静态 Web 发行合同;生产发布开关默认关闭 |
### 交付目标与范围
@@ -83,25 +83,29 @@
### 真实发行包与资料合同
1. AGC 发布取当前 npm 工程已成功构建的 `dist/` 内容,重新检查入口和实际字节;ZIP 内部必须把 `dist/index.html` 归一化为根 `index.html`,其余路径相对发行根保持不变。不得上传整个项目、源码快照或仅发送本地路径。网页 ZIP 同样要求根 `index.html`,不猜测并自动剥离多层目录。
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。AGC 发布归一化阶段只把包内已知根路径 `/assets/`、`/game/`、`/ui/` 转成相对引用,不修改项目源码;其它外部绝对地址仍由客户端/服务器拒绝,静态校验不能代替运行时 CSP 阻断。
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install` 和 `npm run build`,将 `dist` 归一化为根 `index.html` ZIP;为兼容 AGC 上传素材的运行 URL,会补入项目根 `assets/**` 中 dist 未包含的文件,同路径以 dist 构建产物为准,不修改项目源码。
6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`,游戏文档又运行在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie 与 storage。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP,并在游戏脚本前注入隔离的运行期 `localStorage` / `sessionStorage` 兼容层,避免游戏直接读取 opaque origin 原生 storage 时抛 `SecurityError`。兼容层只在当前运行实例内存中有效,不读取平台 Cookie、主站 DOM 或账号数据。公开发行与审核预览只拒绝真实平台 refresh Cookie;审核预览 Token 绑定单个版本且短期有效。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
9. 发行入口既不由管理员填写,也不需要部署侧配置:审核通过时 `api-server` 按 gameId 派生**平台同源路径** `/games/{gameId}/` 写入公开投影,dev / release / 预览环境口径完全一致,不再需要发行域名、通配 DNS 或通配证书。gameId 必须是服务端生成的稳定标识(只允许 `[A-Za-z0-9_-]`),派生失败时审核通过直接失败,不回落主站其它路径、内网地址或任意外部地址。客户端读取该字段时按当前 origin 解析成绝对地址再交给 iframe;历史数据里的绝对 URL(非当前源的 https)继续兼容,新写入只用相对路径。路径到发行网关的映射由边缘 nginx 的同源发行入口 location 完成。
### 身份、状态、审核与更新
- `gameId` 是服务端分配的稳定游戏身份;`ownerUserId` 只从当前认证主体派生。AGC 的本地 `projectId` 只能作为作者名下的关联提示,不能证明云端游戏所有权。网页上传和 AGC 发布使用相同游戏、版本与上传记录,不建立两套发行系统。
- 同一作者用相同 `localProjectId` 再次发布时复用既有 `gameId` 并只新增版本;游戏身份、版本号和服务端校验都不依赖客户端传来的路径或 ID 可信度。缺少 `localProjectId` 的旧客户端仍可发布,但会被视为新建游戏。
- 每次发行分配唯一 `versionId` 与游戏内递增 `versionNumber`。版本的游戏归属、包摘要、已确认字节和送审资料冻结后不可变;改包或改送审资料必须创建新版本。版本状态可以流转,内容不能原地覆盖。
- `gameId` 是服务端分配的稳定游戏身份;`ownerUserId` 只从当前认证主体派生。AGC 本地 `projectId` 只用于关联提示,不能证明云端游戏所有权。AGC 项目清单保存平台绑定(`gameId`、最近 `versionId`、`publicationRevision`、状态)与 AGC 工程内部版本记录 `versions[]`;AGC 发布面板展示的「项目版本」不是清单里可编辑的标量,而是由 `versions[]` 派生的只读标签(见下条)。网页上传和 AGC 发布使用相同游戏、版本与上传记录,不建立两套发行系统。
- 同一作者用相同 `localProjectId` 再次发布时复用既有 `gameId`。一次具体上传/资料/审核快照由不可变 `versionId` 标识;同一个用户版本标签可以有多个提交实例,旧公开实例不原地修改。AGC 如果清单缺少发布记录,首次更新前按作者作品回读和 `localProjectId` 做一次性恢复。
- AGC 发布面板的「项目版本」是 AGC 工程内部版本序数,唯一来源是 AGC 项目清单的 `versions[]`(AGC 工程内部版本记录,即资源总览「项目版本」栏目里的版本卡):标签等于当前存活的内部版本条数(即最新内部版本卡的「版本 N」),`versions` 为空时取 1。该标签**只读**,用户不能编辑,且**不由平台侧任何字段推导**——不得读取、回填或覆盖平台 `version_number`,也不得读取 `publicationRevision`。每次智能体修订追加一条内部版本,标签随之推进。
- 平台侧 `versionNumber` 是用户可见的正整数版本标签,**不要求严格递增,允许回退和重复提交**;服务端不传时保留旧客户端自动取最大值加一的兼容行为,传入时只校验 `versionNumber >= 1`。该兼容口径只为网页端与历史客户端保留,AGC 不再用它维护"用户发行版本"字段。AGC 项目清单里的历史字段 `projectVersion` 属遗留位:仅为兼容旧清单可解析而保留,任何读写路径都不得再把它当作发行版本来源,AGC 也不再提供修改它的入口。
- 同一 `gameId` 与同一 `versionNumber` 的再次提交创建新的 `versionId`;旧的 `published`/`activeVersionId` 继续服务,新的提交审核通过后才切换 `activeVersionId`。旧的 pending 提交可被新提交标记为 `cancelled`,审核队列只展示最新有效提交。`publicationRevision` 仍然严格 CAS 递增,但不限制用户版本号。
- 游戏单独保存 `publicationRevision`、`activeVersionId` 和可见性 `unpublished | published | suspended`;正式可见性由服务端持久化事实决定。未通过审核时 `activeVersionId` 为空;`suspended` 是管理员安全下架,作者不能自行解除。
- 版本状态为 `awaiting_upload → uploaded → validating → pending_review → published`。上传确定失败进入 `upload_failed`,验证失败进入 `validation_failed`,人工拒绝进入 `rejected`;尚未公开版本可以撤回为 `cancelled`,已公开版本可撤销为 `revoked`。已成功上传的相同字节可重新校验;内容改变或审核拒绝后的修改必须新建版本。
- 首版建议人工审核。审核员检查游戏资料、真实桌面运行、声明移动适配、内容与外部请求被阻断的行为;自动包校验通过只进入 `pending_review`,不自动公开。审核记录保存审核者、目标版本、结论、理由和时间。后台只授权现有管理员身份,不让普通作者调用审核动作。
- 更新送审时旧 `activeVersionId` 继续服务目录、详情与游玩。审核通过并完成对象可读验证后,一次事务切换当前公开版本和公开资料;新版本上传、校验、审核或对象安装失败均不改变旧版本。
- 发布页先自动填入可用标题和封面,首次需要确认必需资料,此后复用上次资料;用户一次“提交发布”动作串联校验、上传和送审,并显示真实阶段。等待审核不能显示“已发布”;成功后提供查看详情和复制公开链接。
- 版本状态为 `awaiting_upload → uploaded → validating → pending_review → published`。上传确定失败进入 `upload_failed`,验证失败进入 `validation_failed`,人工拒绝进入 `rejected`;尚未公开提交可以撤回为 `cancelled`,已公开实例可撤销为 `revoked`。同一用户版本标签的新内容必须创建新提交实例。
- 首版建议人工审核。审核员检查游戏资料、真实桌面运行、声明移动适配、内容与外部请求被阻断的行为;自动包校验通过只进入 `pending_review`,不自动公开。审核记录保存审核者、目标 `versionId`、结论、理由和时间。后台只授权现有管理员身份,不让普通作者调用审核动作。
- 更新送审时旧 `activeVersionId` 继续服务目录、详情与游玩。审核通过并完成对象可读验证后,一次事务切换当前公开版本和公开资料;新提交上传、校验、审核或对象安装失败均不改变旧版本。
- AGC 发布面板在无绑定时显示“首次发布”,存在绑定时显示“更新游戏”,回填**派生的项目版本标签、线上版本状态和建议值**;不再回填 `projectVersion`。面板同时逐字展示平台侧事实「线上最近提交 vN · 状态」(`N` 取该作品最近更新的版本行,与派生标签可以不同),两者并列展示、互不推导。首版不做完整版本历史/回滚管理;用户不再直接编辑版本标签,回退与重复提交仍由平台侧接受(例如 AGC 工程内部版本被截尾删除后派生标签会变小)。
- 兼容与迁移:AGC 项目清单的 `versions` 只能追加,或按显式放行删除一个**后缀**(删除素材时连带删除引用该素材的版本);因此内部版本序数(派生标签)不保证单调递增,历史发布标签可能高于后续发布标签。平台必须继续接受回退标签,AGC 不得把回退在本地判成冲突。本地遗留字段 `projectVersion` 只做兼容解析,不参与任何派生。
- 非目标:不新增服务端内部版本号字段、不新增或修改 SpacetimeDB 表/字段/procedure、不改 `publicationRevision` 的 CAS 语义、不做版本历史/对比/一键回滚 UI、不重写平台历史版本行的 `version_number`。
### 幂等、并发与恢复
@@ -121,7 +125,10 @@
| `GET /games/{gameId}` | 游客 | **已实现**:当前公开资料与 `currentVersion.entryUrl`;不可见时 404 |
| `POST /games/{gameId}/plays` | 游客/登录 | **已实现**:上报一次「开始游戏」;可选 Bearer,非公开 404、超限 429,成功返回 `{recorded}`;只进 api-server 内存缓冲,失败不影响游玩 |
| `GET /game-distribution/releases/{gameId}[/{assetPath}]` | 游客 | **已实现**:根路径等价于 `index.html`;发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` |
| `GET /my/games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生 |
| `GET /my-games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生,单次最多 48 项 |
| `GET /my-games/{gameId}` | 登录作者 | **已实现**:作者读自己名下单个游戏的详情,条目与 `GET /my-games` 同形(含全部版本私有状态、驳回理由与已公开版本的 `entryUrl`)。作者要能打开「审核中 / 被驳回 / 已下架 / 已撤回」的作品,公开详情只服务已公开投影,所以作者视角必须走这条 owner 作用域路由;游戏不存在或不属于当前主体都返回 404 |
| `PATCH /my-games/{gameId}` | 登录作者 | **已实现**:作者编辑自己名下游戏的展示资料(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向),立即生效并落 `tracking_event` 审计;要求 `Idempotency-Key` 与 `expectedPublicationRevision` CAS,随版本冻结的包摘要与资料快照不受影响,缺封面/截图归属不符仍按创建口径拒绝 |
| `DELETE /my-games/{gameId}?expectedPublicationRevision=` | 登录作者 | **已实现**:作者软删除自己的作品。只写 `deleted_at` 并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料保留;作者列表/公开目录/公开详情/发行网关/审核队列与后台默认视图都不再返回,后台可用 `status=deleted` 查看。要求 `Idempotency-Key`(同 key 同请求返回原结果),不受发布灰度开关约束 |
| `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` |
| `POST /games/{gameId}/versions` | owner | **已实现**:创建不可变待上传版本,冻结包摘要/字节数/文件数与资料 |
| `PUT /versions/{versionId}/package` | owner | **已实现**:接收真实 ZIP、重算摘要与文件清单并写入私有对象;不执行游戏代码 |
@@ -131,7 +138,8 @@
| `POST /games/{gameId}/unpublish` | owner | **已实现**:CAS 关闭公开游戏及其版本入口,不删除审核记录 |
| `GET /admin/api/game-distribution/reviews` | 管理员 | **已实现**:分页获取待审版本;此行是完整后台路径 |
| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准由服务端按 gameId 派生平台同源发行路径 `/games/{gameId}/` 并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 |
| `POST /admin/api/game-distribution/games/{gameId}/suspend` | 管理员 | **已实现**:安全下架整个游戏并撤销发行访问,要求 `expectedPublicationRevision` CAS 与幂等键;后台游戏审核页提供带原因输入与二次确认的入口;此行是完整后台路径 |
| `POST /admin/api/game-distribution/games/{gameId}/suspend` | 管理员 | **已实现**:安全下架整个游戏并撤销发行访问,要求 `expectedPublicationRevision` CAS 与幂等键;入口在后台「游戏管理」页,带原因输入与二次确认(审核页只做版本级通过/拒绝,不承载游戏级下架);此行是完整后台路径 |
| `GET /admin/api/game-distribution/games` | 管理员 | **已实现**:后台作品管理列表,支持 `limit` / `keyword` / `owner` / `status`(`published`、`unpublished`、`suspended`、`deleted`,为空时排除软删行)/ `cursor` 过滤与游标分页,返回 `{ games, nextCursor }`;`status` 不在白名单时 400;此行是完整后台路径 |
除显式 `/admin/api/...` 外,表内路径均相对 `/api/game-distribution`。错误采用现有平台 envelope,覆盖 400 格式错误、401 未登录、403 owner/审核权限错误、404 不可见、409 幂等/状态/并发冲突、413 大小上限、422 包或资料校验失败、429 限流和明确的可重试 5xx;服务端响应不包含存储凭据和本地绝对路径。
@@ -470,3 +478,75 @@
2026-10-01 按用户后续实现授权完成公开快照、共享契约、后端响应和共享评分文本接入。绑定以仓库固定 SpacetimeDB 2.8.3 生成,持久化表未变;只为列表最终返回游戏聚合评价。隔离库没有既有评分 fixture,本次复用既有 helper 临时创建两个账号、两款游戏和两条评价,未新增仓库 E2E 脚本或完整管理矩阵。
本地验证通过,尚待用户验收和生产部署。未跑完整仓库测试、完整评价/后台管理 E2E、真实手机、长标题/极大人数浏览器矩阵、浏览器登录改分和实际发行包游玩;返回刷新使用真实 API 改分验证,登录用户公开列表已在 API smoke 对照。
## 后台游戏审核详情与待审版本试玩合同
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress |
| Date | 2026-10-02 |
| 适用范围 | admin-web 游戏版本审核、作者/游戏资料回读、待审版本隔离试玩 |
| 数据事实源 | `game_distribution_game`、`game_distribution_version` 及现有版本冻结资料 |
### 目标
- 审核员在一个详情工作台内看到发布者、游戏资料、待审版本包信息、审核状态和审核历史。
- 审核员可以实际试玩当前待审 `versionId`,不能误播旧的公开版本或另一个待审版本。
- 试玩保持发行网关的 opaque sandbox、Cookie 隔离、CSP、路径白名单和运行期 storage 兼容边界。
- 通过、拒绝继续使用现有 `publicationRevision`、认证管理员和幂等语义;安全下架是游戏级动作,入口留在后台「游戏管理」页,不出现在「审核一个新版本」的语境里。
### 非目标
- 不新增平行游戏审核表、游戏资料表、发布者表或公开发行通道。
- 不把待审包提前写入公开发行网关路径,不改变未审核版本的可见性。
- 不在审核页提供游戏资料编辑、重新打包、重新上传或替作者发布。
- 不把管理员 Access Token 放进 iframe URL,也不授予 `allow-same-origin`。
### 详情数据与权限
- 审核列表使用现有版本审核列表;点击版本后回读现有管理员版本详情。
- 详情必须展示:发布者 ID/名称/头像、游戏标题、简介、详细介绍、分类、标签、操作方式、支持设备、输入方式、方向、封面、截图、版本号、包大小、文件数、SHA-256、提交时间、审核时间、审核理由和 `publicationRevision`。
- 展示的游戏资料优先使用该版本冻结的 `metadata_json`;不能用审核期间作者后来修改的 game 行资料替代待审快照。
- 发布者和游戏资料仅通过管理员受保护接口读取;公开目录不得因此增加作者私有字段或待审版本字段。
- 封面与截图通过现有后台素材换签接口读取 Object Key,不把私有 Object Key 当作浏览器直链。
### 待审版本试玩
- 管理员先创建绑定单个 `versionId` 的短期预览会话,再得到 `previewUrl` 与过期时间。
- 预览会话不持久化到 SpacetimeDB;Token 仅能读取绑定版本包,过期后不可继续读取。
- 预览资源读取必须拒绝平台 Cookie,继续返回 `nosniff`、跨来源静态资源头、最小权限 CSP,并复用发行 ZIP 路径和大小白名单。
- HTML 入口必须保留 `sandbox="allow-scripts"`,继续注入隔离的运行期 `localStorage/sessionStorage` 兼容层;不得添加 `allow-same-origin`。
- 试玩入口、JS、CSS、图片、音频和其它包内资源都必须来自同一个绑定的待审版本;读取失败显示明确资源/入口错误,不降级到公开版本。
### 审核动作
- 审核页(详情与待审列表)只保留版本级动作:通过、拒绝。安全下架作用于整个游戏(强制下线线上版本),入口在后台「游戏管理」页,不放在审核新版本的语境里。
- 拒绝继续要求理由;拒绝与游戏管理页的安全下架等所有写操作携带 `publicationRevision` 和 `Idempotency-Key`。
- 并发修订返回冲突时只提示刷新并重新读取,不覆盖其他审核员的决定。
- 审核操作成功后刷新详情和待审列表,不能只修改前端本地状态。
### 契约与数据库边界
- 第一版不新增 SpacetimeDB 表、字段、migration 或生成绑定。
- 复用现有 `game_distribution_game`、`game_distribution_version`、`metadata_json`、审核人/时间/理由和 `publicationRevision` 字段。
- 预览会话属于 api-server 短期运行态;如果未来需要审核任务分派或完整试玩审计,另开规范评估是否增加持久化表。
- 管理员预览接口属于后台契约,必须同步 admin DTO、权限映射、API 测试和前端类型;不改变 `/api/external/v1`。
### 验收标准
| 条款 | 验收方式 | 证据 |
| --- | --- | --- |
| 发布者和资料 | 详情回读 + admin-web 页面检查 | admin-web 详情测试通过,显示作者、资料、版本摘要和冻结 JSON |
| 待审快照 | 修改 game 行资料后仍显示待审版本冻结资料 | 详情响应和页面已接入 `frozenMetadata`;真实数据验证待补 |
| 版本隔离试玩 | 用两个版本验证预览 URL 不能串读,待审包资源全部可加载 | 预览会话路由和版本绑定已实现;真实审核试玩待补 |
| 安全边界 | Cookie、过期 Token、越权 versionId、公开路径访问拒绝 | sandbox、Cookie 拒绝和短期 Token 已实现;HTTP 边界测试待补 |
| 审核动作 | 审核页通过/拒绝、理由、CAS 冲突、幂等重放;游戏级安全下架在「游戏管理」页 | 审核页只保留版本级动作(含取消不写请求);游戏管理页保留「下架(原因必填)+ 二次确认」入口 |
| 工程门禁 | admin-web 测试、api-server 定向测试、类型、编码、diff | admin-web typecheck、详情测试、api-server cargo check、Rust fmt、编码和 diff 通过 |
2026-10-02 已完成第一版审核详情工作台与待审版本预览接线:后台详情回读复用现有 game/version 投影,预览 Token 只绑定待审 versionId,预览 iframe 保持 `sandbox="allow-scripts"`。尚未完成真实管理员登录、待审包资源全量加载和 Token 越权 HTTP smoke。
### 未决问题
- 管理员权限继续复用现有游戏审核权限映射,还是单独增加 `game-review-preview` 动作权限;第一里程碑默认复用现有审核权限,不扩大权限模型。
- 预览 Token 的单次 HTML 读取与同包静态资源多次读取需要统一 TTL 和失效规则;第一里程碑使用短 TTL 的版本绑定会话。