feat(游戏共创): M2a 受鉴权内容下发通道(fork-source 元数据 + 整包取件)
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 4m58s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m4s
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

- 契约:新增 `GameDistributionForkSourceKind` / `GameDistributionForkSource` / `GameDistributionForkSourceResponse`(shared-contracts + TS 镜像 + parity 登记),字段 `gameId / versionId / source / sha256 / bytes / downloadPath`,**不含对象键**
- 读取:spacetime-module 新增 `GameDistributionForkSourceInput`、扁平结果类型与 procedure `get_game_distribution_fork_source_and_return`;tx 把「行是否存在 / 能否作为来源(未删+已公开+有当前公开版本)/ 授权档位原值」分开回传——既有 owner-or-public 读会把「已软删除」与「未公开」都折成 None,HTTP 层就没法区分 404 与 409;`sha256` / `bytes` 取自版本行,不重算
- 客户端:spacetime-client 新增 record + mapper + `get_game_distribution_fork_source`(生成绑定把 `package_sha256` 折成 `package_sha_256`,与版本快照同约定)
- 路由:新增 `fork_sources` 子路由(`require_bearer_auth` + `add_no_store_response_headers`,**不叠加发布灰度**),两条:`GET …/fork-source`(元数据)与 `GET …/fork-source/package`(ZIP 字节)
- 校验:抽出纯函数 `fork_source_target` 供两个 handler 共用——404 `FORK_SOURCE_NOT_FOUND` / 409 `FORK_SOURCE_NOT_AVAILABLE` / 403 `FORK_NOT_AUTHORIZED`(未知档位按禁止解释);`available` 为真却缺版本元数据时失败关闭为 409
- 取件本体:复用发行网关的 `release_package_bytes`(整包进内存 + 进程内缓存,4 条 / 256 MiB),不新造 OSS 客户端;缓存值由 `Arc<Vec<u8>>` 改为 `Bytes`,整包交给响应体只加引用计数、不复制;响应**不做**引用归一化与 `RELEASE_STORAGE_BOOTSTRAP` 注入(下发原始构建产物,客户端按摘要校验后离线解压);`RELEASE_PACKAGE_CACHE` 顺手改用 `LazyLock`(初始值编译期已知)
- 路径:`downloadPath` 为同源相对路径,复用新抽出的 `is_path_safe_game_id`(与发行入口同一判据),绝不下发对象键
- 测试:新增 4 条——两条路由未带 Bearer → 401;`fork_source_target` 的 404 / 409 / 403 / 未知档位 / 缺元数据失败关闭逐项断言(409 先于 403);元数据 payload 的 sha256/bytes 取自行、downloadPath 形状、序列化后不含对象键;取件包头与长度(`application/zip`、`Content-Length` = 行 `package_bytes`、`Content-Disposition`、`no-store`)
- 文档:技术方案 §3.4 把两个接口从「规划」改成与实现一致的形状,并单独拆出「登录用户(Bearer,不叠加发布灰度)」小节;§3.5.1 的「下载通道(当前不存在)」改为 M2a 已实现,写明它只服务当前公开版本、不做归一化、大包续传仍待做
This commit is contained in:
2026-10-05 01:29:18 +08:00
parent 4207d24f00
commit 3d60caa14a
10 changed files with 689 additions and 39 deletions
@@ -312,9 +312,17 @@ pub(crate) project_bundle_sha256: Option<String>,
| --- | --- |
| `PUT /games/{gameId}/fork-authorization`(新) | body `{ expectedForkAuthorization, forkAuthorization }` + `Idempotency-Key`;只允许提升;返回最新 `forkAuthorization` 与 `replayed` |
| `PUT /versions/{versionId}/project-bundle`(新) | `application/octet-stream`,整包或复用现役分片族(`upload-state` / `chunk` / `complete`);服务端校验 zip 门禁后写 OSS 并确认。前置:调用者是该版本作者,且该版本尚**没有**工程包;**发布阶段**上传只要求版本归属,**补齐**场景额外要求该版本是作品当前公开版本且 `fork_authorization != forbidden` |
| `GET /games/{gameId}/fork-source`(新) | 校验授权与来源可用性,返回 `{ gameId, versionId, bundleSha256, bundleBytes, downloadPath }`;`downloadPath` 为受鉴权网关路径,不是可匿名访问的对象键 |
| `POST /games`(**既有,请求增量**) | 追加可选 `forkedFromGameId` / `forkedFromVersionId` |
#### 登录用户(Bearer,**不叠加**发布灰度)
灰度只针对「发布」,改编不应当被发布开关挡住:任何已登录用户都能改编已授权公开的作品。两个接口共用同一条校验(同一个函数,规则不分叉),错误码沿用 `FORK_*`:
| 方法 / 路径 | 说明 |
| --- | --- |
| `GET /games/{gameId}/fork-source`(**M2a 已实现**) | 受鉴权元数据。404 `FORK_SOURCE_NOT_FOUND`(作品不存在)/ 409 `FORK_SOURCE_NOT_AVAILABLE`(已软删除、未公开、或没有当前公开版本)/ 403 `FORK_NOT_AUTHORIZED`(授权为 `forbidden` 或未知档位,未知按禁止解释);通过时返回 `{ forkSource: { gameId, versionId, source, sha256, bytes, downloadPath } }`。`sha256` / `bytes` 取自版本行已存的发行包摘要(不重算),`source` 当前恒为 `package`(M2b 有工程源包时优先 `project`),`downloadPath` 是同源相对路径,**绝不下发 OSS 对象键** |
| `GET /games/{gameId}/fork-source/package`(**M2a 已实现**) | 取件本体:直接回该版本发行包 ZIP 的字节,头为 `Content-Type: application/zip`、`Content-Length`、`Content-Disposition: attachment; filename="{gameId}-{versionId}.zip"`、`Cache-Control: no-store`。数据复用现役发行网关的读包路径(整包进内存 + 进程内缓存,上限 4 条 / 256 MiB),**不做**网关那套引用归一化与 `RELEASE_STORAGE_BOOTSTRAP` 注入——下发的是原始构建产物,客户端要按摘要校验后离线解压,任何改写都会让摘要对不上 |
#### 后台(admin)
| 方法 / 路径 | 说明 |
@@ -335,7 +343,7 @@ pub(crate) project_bundle_sha256: Option<String>,
#### 3.5.1 成品包路径(零新增上传资产,但只到「可玩参考」)
- **内容来源**:父作品当前公开版本的 `package_object_key`(已存在,无需作者做任何额外动作)。服务端按现有发行网关同一套对象读取与校验复用该 ZIP,不新造存储。包内容是纯运行产物:`collect_project_export_package_files`(`src-tauri/src/project/export.rs:770-804`)只收 `dist` 树(条目统一改名成 `game/...`)+ 根 `assets/` 兜底 + `exports/README.md`,**根 `package.json` 与源码不进 ZIP**。
- **下载通道(当前不存在,M2a 必须新增)**:平台今天**没有**「下载某个版本发行包」的接口——作者侧路由只有 create/upload/chunk/complete/reset/submit/get_owner_version/cancel,`private_version_payload`(`api-server/src/modules/game_distribution.rs:3029-3041`)也不返回对象键或下载地址;对外只有公开发行网关逐文件读取 `/api/game-distribution/releases/{game_id}/{*asset_path}`(`:395-421`),且**只服务当前已公开版本**(`:800-863`,非公开/下架 404),扩展名限制在白名单内(`module-game-distribution/src/release.rs:36-64`:html/js/mjs/css/json/wasm/svg/png/jpg/webp/gif/avif/ico/mp3/ogg/wav/m4a/mp4/webm/woff/woff2/ttf/otf/txt/xml)。因此 §3.4 规划的受鉴权入口 `GET /games/{gameId}/fork-source`(返回网关路径而非裸对象键)是**待实现项**,不是既有能力;同时要注意服务端读包是整包进内存 + 进程内缓存(`api-server/src/modules/game_distribution.rs:926-958`,缓存上限 4 条 / 256 MiB,`:101-104`),客户端侧 `fetch_limited_bytes`(`apps/ai-game-creator-shell/src-tauri/src/template_library.rs:564-591`)同样整包进内存、上限 512 MiB、无 Range 无续传;分片能力只有**上传侧**(`game_package_upload.rs:96-105`、`runtime.rs:353-363`),下载侧完全没有对应能力。
- **下载通道(M2a 已实现)**:`GET /games/{gameId}/fork-source`(元数据)+ `GET /games/{gameId}/fork-source/package`(ZIP 字节)两条受鉴权路由,**要求 Bearer、不叠加发布灰度**(灰度只针对发布),校验顺序与错误码见 §3.4「登录用户」小节。服务的是**当前已公开版本**的发行包(与公开发行网关同一份对象),元数据里的 `sha256` / `bytes` 直接取该版本行,`downloadPath` 是同源相对路径、不下发对象键;本体复用发行网关的读包路径(整包进内存 + 进程内缓存,上限 4 条 / 256 MiB,`:926-958`、`:101-104`),但**不做**引用归一化与 bootstrap 注入——取件下发的是原始构建产物,客户端按摘要校验后离线解压,任何改写都会让摘要对不上。客户端侧仍是整包读取(`fetch_limited_bytes`,上限 512 MiB、无 Range 无续传;分片能力只有上传侧),大包的续传/分片下载是后续议题。**未实现**的是按版本直取任意历史版本:取件只服务当前公开版本,与发行网关同口径。
- **AGC 建项:能试玩,不能发布。**
- 试玩成立:`validate_project_game_entry`(`src-tauri/src/project/verification.rs:49-66`)只要求入口是 HTML 文档、引用可解析(`:68-100`),与 `package.json`、构建脚本无关;把包内文件铺进可玩入口目录即可试玩。
- 发布必然被拦:导出/发布的唯一入口 `export_local_project_package`(`src-tauri/src/commands/desktop.rs:1148-1156`)→ `export_local_project_package_for_publish_at`(`export.rs:259-306`),其中「已有可玩入口」的静态入口分支(`export.rs:262-265`)**仍然无条件调用 `ensure_publish_project_stack(root)`**(实检在 `export.rs:413-459`,调用点在 `export.rs:463-468`):`package.json` 必须声明 phaser 主版本 4(`:440-453`)且声明 vite 依赖或存在 `vite.config.*`(`:398-410`、`:454-458`)。注释直接写明「无 `package.json` 的单文件 HTML 项目不属于首版 Phaser 4 发布合同」(`:407-408`),并有反例测试(`:1239-1243`)。该分支**不要求 build 脚本**——`resolve_publish_build_plan` 只在「无入口」分支调用(`export.rs:266-273`)——所以「能试玩」确实不需要构建,但「能发布」需要依赖声明。