From 95b627459a348b9d70b2fa789d2132948c8263e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 17:08:18 +0800 Subject: [PATCH] =?UTF-8?q?docs(=E6=B8=B8=E6=88=8F=E5=88=86=E5=8F=91):=20?= =?UTF-8?q?=E5=9B=9E=E5=86=99=E7=BB=9F=E4=B8=80=E5=8F=91=E5=B8=83=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=E7=89=88=E6=9C=AC=E5=8F=B7=E8=87=AA=E7=84=B6?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E4=B8=BB=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 玩法链路新增「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」,取代旧的 POST /games 与 POST /games/{gameId}/versions 两条路由 - 玩法链路更新 versionNumber 必填且客户端冻结、同号同版本、幂等双口径、媒体只解析一次与 API 路由表 - 后端数据契约补充统一发布接口、自然幂等键、确定性 gameId 与单事务 procedure - AGC 实施计划更新发布媒体为单次 POST /versions 并冻结版本号 - decision-log 记录统一发布接口与版本号自然幂等决策 - pitfalls 记录首次发布封面/截图重复上传的现象、根因与现行口径 --- .../shared-memory/decision-log.md | 8 +++++ docs/project-memory/shared-memory/pitfalls.md | 7 ++++ ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 ++- ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 36 ++++++++++++++----- 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 097d41d03..629e47997 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9928,3 +9928,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - skill pack 例外:`agc-skill-pack.v1` 只登记 `vite-export-taonier`;未跟踪的 `vite-export-taptaph5` 是另一条 WIP,不登记,`npm run agc:skill-pack:check` 对其未声明文件报红为已知接受项。 - 权威行为已回写 AGC 实施计划「2026-10-06」章节、玩法链路「游戏分发发布媒体直传合同」与后端数据契约;实施计划《【实施计划】陶泥儿导出文件草稿化与发布媒体直传-2026-10-06.md》与对应里程碑规范**保留在 `docs/project-memory/plans/`**,不再按该计划 §5.4 在收尾时删除。 - owner 作用域媒体读(同日补充):公开读只覆盖已发布作品,作者预览未发布 / 待审 / 被驳回作品的封面与截图会退化成占位图。新增 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(及 `.../media/read-bytes`,需 bearer),判定 = `gameId` 属于当前登录主体且 objectKey 命中该作品当前行的 `cover_object_key` / `screenshots_json`;复用同一签名器与 `get_game_distribution_game(owner_user_id)`,不新增 procedure、不给素材库 ACL 开特例,公开读判定不变。AGC `resolve_preview_url`、平台 web `useGameDistributionMediaReadUrl`(带 `gameId`)、`MyGamesPage` 封面与发布/编辑回填全部切到 owner 路由。 + +## 2026-10-07:游戏分发统一发布接口与版本号自然幂等 + +- 单一路由:`POST /api/game-distribution/versions` 取代 `POST /games` 与 `POST /games/{gameId}/versions`;`metadata` 用 `gameId?` / `localProjectId?` 区分更新与首次发布,无 `gameId` 时 `localProjectId` 必填并作为身份锚。首次发布的作品行由本次 `gameMetadata` + 解析后的媒体 objectKey bootstrap,审核通过后再由版本冻结资料覆盖。 +- 版本号:`versionNumber` 改为必填且由客户端在一次发布意图内冻结;删掉服务端 `None => max_existing + 1` 自增分支,删掉「同号创建新 versionId」与「同号 pending 被取消替换」。自然幂等键 = `(ownerUserId, localProjectId | gameId, versionNumber)`,同键同 `request_digest` 重放、不同摘要 409;该路由不要求 `Idempotency-Key`。购买、送审、审核、撤回、下架、软删等其他写动作继续用 `Idempotency-Key`。 +- 作品 ID:无 `gameId` 时由 `(ownerUserId, localProjectId)` 确定性派生,媒体 objectKey 前缀在事务前稳定。作品 get-or-create、建版本、写 `create_version` 收据在单个 SpacetimeDB procedure 的一次 `try_with_tx` 内提交;同一次发布只解析一次封面/截图字节,消除首次发布「建作品传一遍、建首版再传一遍」的重复上传。 +- 客户端:AGC `publish_local_project_game` 合并为一次调用,发布开始时冻结版本号;网页新建发布生成并持久化 `localProjectId`、`versionNumber` 固定为 `1`,更新既有作品先读作者中心 max 再 +1 并冻结。 +- 权威行为见玩法链路「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」、后端数据契约同日条目与 AGC 实施计划;模块不在线,硬切无迁移。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d52650295..6ef189c76 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6578,3 +6578,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现行口径**:恢复顺序固定为「进程内会话表 → 本项目目录下 mtime 最新的会话轨迹」。目录名按 Claude Code 自己的派生规则算(cwd 里非字母数字一律换成 `-`,实例 `C:\Users\...\projects\gameagent-0514673b` → `C--Users-...-projects-gameagent-0514673b`),并且只认本项目目录——SDK 的 `resume` 也只在那里查这份轨迹,从别的目录捞来的 id 会被判成「No conversation found」,比不恢复更糟。日志 `agent.direct_codex.claude_resume source=memory|disk|none` 记录本回合的恢复来源,用户再报「看不到历史对话」时可以一眼分辨是内存命中、磁盘回放,还是本项目确实没有历史会话。 - **验证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_` 34 passed(新增 `claude_code_session_lookup_uses_the_project_directory_and_newest_trace`、`claude_direct_resume_falls_back_to_disk_and_prefers_the_process_map`)。真实环境用随包 `claude.exe`(2.1.285)复验:把项目隔离 home 的 `projects/` 拷进临时 `CLAUDE_CONFIG_DIR`、cwd 设为项目根,`--resume 00000000-…` 立刻回 `No conversation found with session ID`;`--resume f79dae28-…`(磁盘上最新那份)不报找不到,直接进入模型请求,落在本地抓包桩上的请求体里带着完整历史(`messages` 13 条,首条就是 14:23 的「做个废土风的扫雷…」原文)。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`。 + +## 2026-10-07 首次发布把封面/截图上传了两遍:create_game 与 create_version 各直传一次 + +- **现象**:首次发布新游戏时,封面/截图的同一批字节先在建作品(`POST /games`)时上传一遍,紧接着建首版(`POST /games/{gameId}/versions`)又上传一遍,服务端两条写路径各自 `upload_media_image` 生成新的随机 objectKey,OSS 与带宽都白花一份。 +- **根因**:游戏行(`cover_object_key` / `screenshots_json`)与版本冻结资料都存媒体,`create_game` 与 `create_version` 是两次独立 multipart 写请求;AGC `publish_local_project_game` 与网页 `GamePublishPage` 都把同一份 `media` 传了两次。 +- **现行口径**:合并为 `POST /api/game-distribution/versions`,无 `gameId` 时按 `(owner, localProjectId)` get-or-create 作品并确定性派生 `gameId`,一次解析的同一批 objectKey 同时用于作品行 bootstrap 与版本冻结资料。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs`、`src/components/game-distribution/GamePublishPage.tsx`、`server-rs/crates/api-server/src/modules/game_distribution.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5326f0035..33c83f748 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -9,7 +9,7 @@ - 适配与错误:未适配弹「让陶泥儿帮我调通」;adapt/fill/repair 三类指令与小红书一致,失败现场原样转发 code agent;「帮我填」只写元数据,不改打包适配。 - 线上值:草稿是唯一真相;读命令额外返回只读线上值(live game 行),不自动 seed/回填;面板把线上值**逐字段内联**挂在对应输入框下方(比当前值小一行),首次发布线上为空时不铺空对照;读取自动进行、失败按固定间隔静默自动重试,界面不提供手动「重新读取」入口;文案字段与图片均可「沿用线上值」,图片路径为空表示沿用(发布时带线上 objectKey)。 - 退役收口:`exports/` 目录、历史试玩包链、`prepare/upload/list` 命令、生成任务产物声明、prompts、run-trace 白名单与排除列表都不再存在;AI 资料建议与封面生成、`useGameDistributionPublishForm`/`GameDistributionPublishFormView`/`GamePublishBlockedDialog` 一并删除,保留发布进度对话框。 -- 发布媒体:`POST /api/game-distribution/games`、`POST .../versions`、`PATCH .../my-games/{id}` 收 `multipart/form-data`(元数据文本 + 封面/截图原始二进制 + 沿用 objectKey);整包仍走 raw/chunked。AGC 发布命令所需的本地图路径由发布面板(UI 表单)随命令传入,**不再回读 `.export/taonier.json` 草稿注册表**:文字字段与本地媒体路径都取 `packageAll()` 落定的同一份权威表单,面板看到的值就是提交的值。图片落项目快照桶 `agc/project-snapshots/v1/game-distribution/media/...`,不建 `asset_object`;公开读走 `GET /api/game-distribution/media/read-url`(及 `read-bytes`),判定 = 已发布且 active 的 game 且 objectKey 命中封面/截图,不经素材库 ACL。作者预览自己作品的线上封面/截图走 owner 作用域 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(需 bearer,判定 = 作品归属 + objectKey 落在该作品媒体命名空间),未发布 / 待审 / 被驳回也能换签;AGC `resolve_preview_url` 已切到该路由,不再走素材库 `/api/assets/read-url`。硬切,无旧 JSON/assetId 兼容。 +- 发布媒体:`POST /api/game-distribution/versions`(2026-10-07 起取代 `POST /games` 与 `POST .../versions`)、`PATCH .../my-games/{id}` 收 `multipart/form-data`(元数据文本 + 封面/截图原始二进制 + 沿用 objectKey);整包仍走 raw/chunked。AGC `publish_local_project_game` 的首次发布与更新合并为一次 `POST /versions` 调用(作品按 `localProjectId` get-or-create,媒体只解析一次),版本号在发布开始时冻结并在该次意图内复用;该路由幂等键 = `(owner, localProjectId | gameId, versionNumber)`,不要求 `Idempotency-Key`。AGC 发布命令所需的本地图路径由发布面板(UI 表单)随命令传入,**不再回读 `.export/taonier.json` 草稿注册表**:文字字段与本地媒体路径都取 `packageAll()` 落定的同一份权威表单,面板看到的值就是提交的值。图片落项目快照桶 `agc/project-snapshots/v1/game-distribution/media/...`,不建 `asset_object`;公开读走 `GET /api/game-distribution/media/read-url`(及 `read-bytes`),判定 = 已发布且 active 的 game 且 objectKey 命中封面/截图,不经素材库 ACL。作者预览自己作品的线上封面/截图走 owner 作用域 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(需 bearer,判定 = 作品归属 + objectKey 落在该作品媒体命名空间),未发布 / 待审 / 被驳回也能换签;AGC `resolve_preview_url` 已切到该路由,不再走素材库 `/api/assets/read-url`。硬切,无旧 JSON/assetId 兼容。 - 验收:草稿/打包/失败转发/沿用线上值与发布直传的定向测试;真实 vite 工程首轮适配与一次真实发布 smoke。 ## 2026-10-05 导出产物面板与小红书小工具导出 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 674a59b86..442098121 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -506,7 +506,9 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 购买与播放鉴权 HTTP(2026-10-05):`POST /api/game-distribution/games/{gameId}/purchase`(`require_bearer_auth` + 必填 `Idempotency-Key`,请求体 `{ expectedPriceMudPoints }`)经 facade 调 `purchase_game_distribution_game_and_return`,返回 `{ purchase, walletBalance, replayed }`;余额不足 400 `INSUFFICIENT_MUD_POINTS`、价格已变化 409、免费游戏 400、作者本人自购 400 `GAME_PURCHASE_OWNER_EXEMPT`(作者免购买,绝不扣费)、管理员令牌 403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED`(购买只接受普通用户 bearer)、游戏不可见 404、缺幂等键 400、未登录 401。`POST /api/game-distribution/games/{gameId}/play-session` 对免费作品直接回既有公开入口 `/games/{gameId}/`;付费作品同时接受管理员令牌(按现有 admin 鉴权)与用户令牌,已购买 / 作者本人 / 管理员才签发绑定 `gameId + userId`、2 小时有效期的进程内会话,令牌为内存态,进程重启即失效。网关 `GET /api/game-distribution/play-sessions/{token}[/{assetPath}]` 不挂登录中间件、凭令牌读取当前公开版本包,能解析出平台刷新会话 Cookie 时 403,令牌过期 / 不存在、游戏下架 / 封禁或没有有效公开版本一律 404,全部 `no-store`。公开详情 `GET /api/game-distribution/games/{gameId}` 可选鉴权读取查看者:`purchased` 只反映真实购买记录,付费作品对未购买且非作者 / 非管理员把 `currentVersion.entryUrl` 置 `null`(资料与价格仍可见);`GET /api/game-distribution/releases/{gameId}[/{assetPath}]` 在当前公开版本 `price_mud_points > 0` 时同样 404,付费作品只能经播放会话路径播放。 - 游玩计数写入:`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`,完整行为见玩法链路的「游玩计数(已实现)」。 -- 发布媒体直传(2026-10-06):游戏行只存 `cover_object_key` 与 objectKey 数组 `screenshots_json`;版本冻结资料 `GameDistributionFrozenMetadata` 用 `coverObjectKey`(`String`)与 `screenshots`(`Vec`,objectKey),`GameDistributionFrozenScreenshot` 不存在。请求 DTO(create game / create version / update metadata)用 `coverObjectKey` 与 `screenshots: (string|null)[]`:`string` 槽位沿用该作品当前媒体的 objectKey,`null` 槽位按序消费可重复的 `screenshot` 二进制 part,可选 `cover` part 出现时覆盖 `metadata.coverObjectKey`。公开读走 `GET /api/game-distribution/media/read-url`(及 `read-bytes`),判定为「已发布且 active 的 game + objectKey 命中 `cover_object_key` / `screenshots_json`」,不经素材库 ACL;owner 作用域读路由 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(`read-bytes` 同理,需 bearer)按「作品归属 + objectKey 落在该作品媒体命名空间」放行(不要求命中游戏行当前媒体,待审 / 被驳回 / 历史版本以及首版 `create game` 与 `create version` 各自上传得到的 key 都始终对作者可见),供作者预览未发布 / 被驳回作品的封面与截图(软删作品该路径 404);公开读授权 procedure 只按 objectKey 查询(`get_game_distribution_media_read_access_and_return`),owner 读直接复用 `get_game_distribution_game`(带 `owner_user_id`),不新增 procedure。`request_digest` 覆盖规范化元数据 + 图片字节 hash + 沿用 objectKey,覆盖 create / version / update 三条写路径。硬切、无历史数据;schema 变更仍须同步 `migration.rs`、表目录与生成绑定并运行 `npm run check:spacetime-schema`。 +- 发布媒体直传(2026-10-06):游戏行只存 `cover_object_key` 与 objectKey 数组 `screenshots_json`;版本冻结资料 `GameDistributionFrozenMetadata` 用 `coverObjectKey`(`String`)与 `screenshots`(`Vec`,objectKey),`GameDistributionFrozenScreenshot` 不存在。请求 DTO(create game / create version / update metadata)用 `coverObjectKey` 与 `screenshots: (string|null)[]`:`string` 槽位沿用该作品当前媒体的 objectKey,`null` 槽位按序消费可重复的 `screenshot` 二进制 part,可选 `cover` part 出现时覆盖 `metadata.coverObjectKey`。公开读走 `GET /api/game-distribution/media/read-url`(及 `read-bytes`),判定为「已发布且 active 的 game + objectKey 命中 `cover_object_key` / `screenshots_json`」,不经素材库 ACL;owner 作用域读路由 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(`read-bytes` 同理,需 bearer)按「作品归属 + objectKey 落在该作品媒体命名空间」放行(不要求命中游戏行当前媒体,待审 / 被驳回 / 历史版本以及首版发布上传得到的 key 都始终对作者可见),供作者预览未发布 / 被驳回作品的封面与截图(软删作品该路径 404);公开读授权 procedure 只按 objectKey 查询(`get_game_distribution_media_read_access_and_return`),owner 读直接复用 `get_game_distribution_game`(带 `owner_user_id`),不新增 procedure。`request_digest` 覆盖规范化元数据 + 图片字节 hash + 沿用 objectKey,覆盖 create / version / update 三条写路径。硬切、无历史数据;schema 变更仍须同步 `migration.rs`、表目录与生成绑定并运行 `npm run check:spacetime-schema`。 + +- 统一发布接口与版本号自然幂等(2026-10-07):`POST /api/game-distribution/versions` 取代 `POST /games` 与 `POST /games/{gameId}/versions`;`metadata` 增加必填 `versionNumber`,删掉 `resolve_game_distribution_version_number` 的 `None => max_existing + 1` 自增分支。无 `gameId` 时以 `localProjectId` 为身份锚,`gameId` 由 `(ownerUserId, localProjectId)` 确定性派生;新 procedure 在一次 `try_with_tx` 内 get-or-create 游戏行、写入版本行并落一张 `create_version` 收据(`idempotency_key = "{anchor}:v{versionNumber}"`,同键同摘要重放、不同摘要 409,不要求 `Idempotency-Key`)。删掉「同号 pending 被新提交取消替换」逻辑;`request_digest` 口径不变;媒体只解析一次,同一批 objectKey 同时用于游戏行 bootstrap 与版本冻结资料。消费方:AGC `publish_local_project_game` 合并为一次调用;网页 `GamePublishPage` 新建固定 `versionNumber=1` 并持久化生成的 `localProjectId`,更新读作者中心 max+1 后冻结。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。 ### `game_distribution_review` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index cddd795cc..c20bfdcd8 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -60,13 +60,34 @@ | Date | 2026-10-06 | | 适用边界 | game-distribution 发布资料媒体(封面/截图)的上传与公开读取;发行包上传链路不变 | -- 写:`POST /api/game-distribution/games`、`POST /api/game-distribution/games/{gameId}/versions`、`PATCH /api/game-distribution/my-games/{gameId}` 接受 `multipart/form-data`:`metadata` 文本 part(JSON,含 `coverObjectKey` 与 `screenshots: (string|null)[]`);`cover` 二进制 part(可选,出现时覆盖 `coverObjectKey`);`screenshot` 二进制 parts(≤6 张,按 `null` 槽位顺序消费)。`string` 槽位表示沿用线上 objectKey。硬切,不接受素材 ID 形式的媒体引用。 +- 写:`POST /api/game-distribution/versions`(2026-10-07 起取代 `POST /games` 与 `POST /games/{gameId}/versions`)、`PATCH /api/game-distribution/my-games/{gameId}` 接受 `multipart/form-data`:`metadata` 文本 part(JSON,含 `coverObjectKey` 与 `screenshots: (string|null)[]`);`cover` 二进制 part(可选,出现时覆盖 `coverObjectKey`);`screenshot` 二进制 parts(≤6 张,按 `null` 槽位顺序消费)。`string` 槽位表示沿用线上 objectKey。硬切,不接受素材 ID 形式的媒体引用。 - 存储:新图由服务端写入项目快照桶 `agc/project-snapshots/v1/game-distribution/media//{cover|screenshot}-.`,**不建 `asset_object`**;冻结资料与游戏行只存 `coverObjectKey` / 截图 objectKey。 - 读:`GET /api/game-distribution/media/read-url`(匿名)按 objectKey 返回签名读地址;判定 = 已发布且 active 的 game 且 objectKey 命中其冻结媒体;需要同源字节时用 `.../media/read-bytes`。作者预览自己作品走 owner 作用域 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(需 bearer,`read-bytes` 同理):判定 = 该 `gameId` 属于当前登录主体且 objectKey 落在该作品媒体命名空间 `agc/project-snapshots/v1/game-distribution/media//`,不再要求命中游戏行当前媒体;未发布 / 待审 / 被驳回、以及审核通过前尚未生效到游戏行的版本媒体都能换签(软删作品返回 404),与公开读共用签名器。 - 幂等:`request_digest` 覆盖规范化元数据 + 新图字节 hash + 沿用 objectKey,保持重放语义。 - 归属:仅 owner 可写;沿用 objectKey 必须属于该游戏当前媒体;附图校验 `image/*`、张数与体积上限。 - 与素材库解耦:公开读判定只按游戏分发域内规则(已发布且 active 的 game + objectKey 命中冻结媒体),不经素材库 ACL;素材桶 `/api/assets/read-url` 的通用签名与字节中转抽成公共 helper 供新路由复用,授权仍域内分离。 +## 游戏分发统一发布接口与版本号自然幂等合同(2026-10-07) + +| 字段 | 值 | +| --- | --- | +| Version | 0.1 | +| Status | current(已定稿;实现与证据按实施计划回写) | +| Date | 2026-10-07 | +| 适用边界 | game-distribution 作者发布写路径(建作品 + 建版本合一)与版本号幂等语义;媒体上传/读取沿用「游戏分发发布媒体直传合同(2026-10-06)」;发行包上传、审核、公开读取不变 | +| 取代 | 本文件「AGC 游戏分发与在线游玩合同」中「平台侧 versionNumber 允许回退/重复提交、服务端不传时自增」「同一 gameId 同一 versionNumber 再次提交创建新 versionId」两条,以及 `POST /games`、`POST /games/{gameId}/versions` 两条路由 | + +- **单一路由**:`POST /api/game-distribution/versions` 取代 `POST /games` 与 `POST /games/{gameId}/versions`。带 `gameId` 表示给既有作品发新版本;不带时用 `localProjectId` 解析/创建作品身份。`metadata` 文本 part 形状:`{ gameId?, localProjectId?, versionNumber, priceMudPoints?, packageSha256, packageBytes, packageFileCount, packageEntryPath, gameMetadata }`,另有可选 `cover` 与可重复 `screenshot` part(媒体语义同 2026-10-06 合同)。 +- **身份锚**:`gameId` 存在时以其为锚;否则 `localProjectId` 必填(trim 非空)并作为锚。首次发布的作品行由本次 `gameMetadata` + 解析后的媒体 objectKey bootstrap(作者列表与线上值立即可见),审核通过后再由版本冻结资料覆盖。 +- **作品 ID 派生**:无 `gameId` 时,`gameId` 由 `(ownerUserId, localProjectId)` 确定性派生(`game_` + 稳定摘要),不再随机生成。这样媒体 objectKey 前缀在事务前就稳定,重试/并发不会落到不同命名空间。 +- **版本号必填**:`versionNumber` 是客户端提交的正整数(`>= 1`),不再有「服务端不传取 max+1」的兼容分支;服务端不比较最大值,也不因版本号小于已有最大值而拒绝。 +- **自然幂等键**:`(ownerUserId, anchor, versionNumber)` 就是版本身份与幂等键,服务端据此记账(沿用 `action = create_version`,`idempotency_key = "{anchor}:v{versionNumber}"`),不要求也不读取 `Idempotency-Key`。同键同 `request_digest` 返回既有 `{ game, version }`(`replayed: true`);同键不同摘要返回 `409 IDEMPOTENCY_CONFLICT`。 +- **客户端冻结**:一次发布意图(含重试、续传、响应丢失后重发)必须复用同一 `versionNumber`。AGC 取 `localProjectId = manifest.projectId`,版本号取面板发布开始时读到的平台 max+1 并在该次意图内固定;网页新建发布时生成并持久化一个 `localProjectId`、`versionNumber` 固定为 `1`,更新既有作品时先读作者中心 max 再 +1 并在该次意图内固定。主动发起新的一次发布才重新取号。 +- **原子性**:作品 get-or-create、版本行写入与幂等收据在单个 SpacetimeDB procedure 的一次 `try_with_tx` 内提交;移除「同号 pending 被新提交取消替换」与「同号创建新 versionId」。 +- **媒体解析一次**:合并后同一次发布只解析(上传)一次封面/截图字节,同一批 objectKey 同时用于作品行 bootstrap 与版本冻结资料;不再出现建作品与建首版各上传一次。 +- **响应**:`{ game, version, replayed }`;`game` 为作者侧游戏投影(含 `publicationRevision`),`version` 为私有版本投影。创建后发行包仍走既有 `PUT /versions/{versionId}/package` 链路。 +- **不变**:`PATCH /my-games/{gameId}`、`PUT /versions/{versionId}/package`、`POST /versions/{versionId}/submit|cancel`、审核、`publicationRevision` CAS 与公开读全部不变;`Idempotency-Key` 继续用于购买、送审、审核、撤回、下架、软删等其他写动作。 + ## AGC 游戏分发与在线游玩合同 | 字段 | 值 | @@ -113,8 +134,8 @@ - `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 递增,但不限制用户版本号。 +- 平台侧 `versionNumber` 是客户端提交的正整数版本标签(`>= 1`),**必填且在一次发布意图内冻结**;服务端不再自增、也不比较最大值。AGC 提交发布开始时读到的平台作者视图 max+1 并在该次意图内固定;网页新建发布提交 `1`,更新既有作品提交作者中心 max+1 后固定。该值与 AGC 内部版本标签(上条)互不推导,语义与自然幂等键见「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。AGC 项目清单里的历史字段 `projectVersion` 属遗留位:仅为兼容旧清单可解析而保留,任何读写路径都不得再把它当作发行版本来源,AGC 也不再提供修改它的入口。 +- 同一作品(`owner` + `localProjectId` 或 `gameId`)与同一 `versionNumber` 只对应一个版本实例:同键同摘要返回既有 `versionId`(重放),同键不同摘要返回 `409 IDEMPOTENCY_CONFLICT`;不再有「同号创建新 `versionId`」或「同号 pending 被取消替换」。旧的 `published`/`activeVersionId` 继续服务,新提交审核通过后才切换 `activeVersionId`。`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`,不自动公开。审核记录保存审核者、目标 `versionId`、结论、理由和时间。后台只授权现有管理员身份,不让普通作者调用审核动作。 @@ -125,11 +146,11 @@ ### 幂等、并发与恢复 -- 所有创建、提交、审核、撤销和下架动作携带 `Idempotency-Key`。服务端以认证主体、动作和 key 保存请求摘要与结果;同 key 同请求返回原结果,同 key 不同请求返回 `409 IDEMPOTENCY_CONFLICT`。至少保留 30 天;客户端超出恢复窗口先回读记录,不能把未知结果自动当作失败重发。 +- 幂等分两种口径:创建/发布版本用自然键 `(owner, localProjectId | gameId, versionNumber)`,不要求 `Idempotency-Key`;提交、审核、撤销、下架、购买、软删等其他写动作仍携带 `Idempotency-Key`,服务端以认证主体、动作和 key 保存请求摘要与结果,同 key 同请求返回原结果,同 key 不同请求返回 `409 IDEMPOTENCY_CONFLICT`,至少保留 30 天;客户端超出恢复窗口先回读记录,不能把未知结果自动当作失败重发。 - 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。首版整包重传,不宣称支持分片断点续传。 -- 重复提交同一次 AGC 操作不得创建第二个游戏或版本;原生端持久保存操作 ID、目标游戏/版本和 key,网页保存恢复标识并以服务端回读为准。相同 ZIP 用于不同资料修订时允许新版本,不能仅按包摘要吞掉新的发布意图。 +- 重复提交同一次 AGC 操作不得创建第二个游戏或版本;原生端在一次发布意图内持久保存操作 ID、目标游戏/版本号与 key,网页保存恢复标识并以服务端回读为准。相同 ZIP 用于不同资料修订时,如果版本号也相同则命中同一版本(不新开);要开新版本必须显式换 `versionNumber`,不能仅按包摘要吞掉或新建发布意图。 - 公开版本切换、作者下架和管理员审核必须带 `expectedPublicationRevision`,在持久化事务中比较并推进。并发变化返回 `409 PUBLICATION_CONFLICT`;旧送审版本不能在用户已发布更新或下架之后静默覆盖状态。审核员重新查看现状后才能提交新的明确动作。 -- 网络中断或响应丢失后先查询原操作/版本;服务端恢复 `validating` 的在途任务并按版本身份幂等续作,不另建版本。登录失效保留私有草稿和恢复标识,重新登录同账号后继续;换账号不能读取或接管原账号操作。 +- 网络中断或响应丢失后,先用同一 `localProjectId`/`gameId` + `versionNumber` 重发(服务端按自然键重放)或先查询原版本;服务端恢复 `validating` 的在途任务并按版本身份幂等续作,不另建版本。登录失效保留私有草稿和恢复标识,重新登录同账号后继续;换账号不能读取或接管原账号操作。 ### HTTP 与持久化边界(拟定) @@ -148,8 +169,7 @@ | `GET /my-games/{gameId}` | 登录作者 | **已实现**:作者读自己名下单个游戏的详情,条目与 `GET /my-games` 同形(含全部版本私有状态、驳回理由与已公开版本的 `entryUrl`)。作者要能打开「审核中 / 被驳回 / 已下架 / 已撤回」的作品,公开详情只服务已公开投影,所以作者视角必须走这条 owner 作用域路由;游戏不存在或不属于当前主体都返回 404 | | `PATCH /my-games/{gameId}` | 登录作者 | **已实现**:作者编辑自己名下游戏的展示资料(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向),立即生效并落 `tracking_event` 审计;要求 `Idempotency-Key` 与 `expectedPublicationRevision` CAS,随版本冻结的包摘要与资料快照不受影响,缺封面或沿用 objectKey 不命中该作品当前媒体仍按创建口径拒绝 | | `DELETE /my-games/{gameId}?expectedPublicationRevision=` | 登录作者 | **已实现**:作者软删除自己的作品。只写 `deleted_at` 并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料保留;作者列表/公开目录/公开详情/发行网关/审核队列与后台默认视图都不再返回,后台可用 `status=deleted` 查看。要求 `Idempotency-Key`(同 key 同请求返回原结果),不受发布灰度开关约束 | -| `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` | -| `POST /games/{gameId}/versions` | owner | **已实现**:创建不可变待上传版本,冻结包摘要/字节数/文件数与资料 | +| `POST /versions` | 登录作者 | **本次改造**:统一发布入口。`metadata` JSON 带 `gameId?` / `localProjectId?` / `versionNumber`(必填)/ 包声明 / `gameMetadata`,媒体走 `cover`/`screenshot` part;无 `gameId` 时按 `(owner, localProjectId)` get-or-create 作品并确定性派生 gameId,`gameMetadata` + 媒体 bootstrap 作品行。幂等键 = `(owner, localProjectId \| gameId, versionNumber)`,不要求 `Idempotency-Key`;返回 `{ game, version, replayed }`。详见「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」 | | `PUT /versions/{versionId}/package` | owner | **已实现**:接收真实 ZIP、重算摘要与文件清单并写入私有对象;不执行游戏代码 | | `GET /versions/{versionId}` | owner/管理员 | **已实现**:回读状态、错误代码、审核结论与服务端 `recoveryAction`;管理员走 `/admin/api/game-distribution/versions/{versionId}`;未知版本与别人的版本都按不可见返回 404 | | `POST /versions/{versionId}/submit` | owner | **已实现**:只有已确认完整包可提交,返回 202 并进入 `pending_review` |