feat(游戏共创): M2b 上行路由族 + 下行优先与缓存资产维度(块 C+D)

- 上行(作者、Bearer + 发布灰度,与发行包族逐条对齐):`PUT /versions/{id}/project-bundle`(整包一次上传,≤ 200 MiB)、`GET …/project-bundle/upload-state`、`PUT …/project-bundle/chunk`(`x-genarrative-upload-offset`,分片上限同发行包)、`POST …/project-bundle/complete`、`POST …/project-bundle/reset`;载体一律 `application/octet-stream`,分片边界与偏移语义只有一套
- 对象键:新增 `game_distribution_project_bundle_object_key(game_id, version_id)` = `…/{version_id}.project.zip`;发行包 helper **未改**(仍是 `.zip`)→ 同一(作品, 版本)的两份资产互不覆盖、也不会串用读取缓存
- 阶段门:新增**唯一**共享函数 `ensure_project_bundle_uploadable`,5 条上行路由全部调用、无重复实现。判定顺序:① 已确认(`project_bundle_bytes > 0`)→ 409 `PROJECT_BUNDLE_ALREADY_EXISTS`(必须先判:确认工程包不驱动版本状态机,已确认的版本可能仍停在 `awaiting_upload`);② `status` ∉ {`awaiting_upload`, `upload_failed`} → 409 `PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED`(与模块事务同口径)
- `complete`:回读整包 → 与 HEAD 权威长度比对 → `validate_project_bundle_zip`(失败删半包对象 + 记上传失败 + 422 `PROJECT_BUNDLE_VALIDATION_FAILED`)→ `confirm_game_distribution_project_bundle`;幂等键与摘要口径照抄发行包 complete
- 下行优先:`fork_source_target` 选定资产 = 有工程包时 `Project`(优先)否则 `Package`;「字节数 > 0 **且**摘要非空」才算有工程包,半写行回落 `package`(失败关闭)。元数据 `source` / `sha256` / `bytes` / `downloadPath` 随所选资产;新增 `GET /games/{id}/fork-source/project`(同两层中间件、无 Query 提取器;没有工程包时 409 `FORK_SOURCE_NOT_AVAILABLE`,**绝不静默回落成品包**),响应头与成品包同形(文件名 `{gameId}-{versionId}-project.zip`)
- 缓存键加资产维度:`release_package_bytes` 泛化为 `release_asset_bytes(state, object_key, max_bytes)`(缓存键即对象键),发行包侧退化为薄封装——键字符串、上限、`Bytes` 值类型与 4 条 / 256 MiB 预算逐字节不变,既有缓存测试继续通过
- 顺着同一「不复制第二套」原则抽出/参数化的共享件:`fork_source_bundle_response`(两份资产共用响应构造)、`package_upload_offset(headers, asset)`、`require_octet_stream_content_type(headers, asset)`(按资产给错误文案,发行包文案不变)
- 补上块 A 遗漏的两处客户端登记(否则下行字段读不到):`spacetime_client` 对 `GameDistributionConfirmProjectBundleRecordInput` 的 re-export,以及 `GameDistributionForkSourceRecord` 新增 `project_bundle_sha256` / `project_bundle_bytes` 与对应映射
- 测试 6 条:5 条上行 + 1 条下行共 6 条路由未带 Bearer → 401(逐条);`ensure_project_bundle_uploadable` 全状态(含「已确认优先于阶段」)+ 7 档拒绝;`fork_source_target` 6 种资产组合(工程包优先 / 回落成品包 / 两种半写行 / 只有工程包 / 两份都缺 → 409);两个对象键互不相等 + `ReleasePackageCache` 双资产不串味;上限镜像(`MAX_PROJECT_BUNDLE_BYTES == MAX_PACKAGE_BYTES == shared_contracts::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES`、分片放行量 > 分片大小);校验失败 422 映射
- 门禁:`cargo check --all-targets` 0;`cargo test -p api-server game_distribution` **60 passed**(基线 54;既有发行包族用例全绿 → 抽取共享件未改发行包行为);DTO parity 42 组 OK;`check:encoding` 5301 files OK;`git diff --check` 0;`cargo fmt --all -- --check` 0
This commit is contained in:
2026-10-05 15:42:35 +08:00
parent d39257b608
commit 861dc0a676
3 changed files with 1033 additions and 73 deletions
File diff suppressed because it is too large Load Diff
@@ -24,15 +24,16 @@ pub mod game_distribution;
pub use game_distribution::{ pub use game_distribution::{
GameDistributionAdminGameListRecordInput, GameDistributionAdminUserReviewListRecordInput, GameDistributionAdminGameListRecordInput, GameDistributionAdminUserReviewListRecordInput,
GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput, GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput,
GameDistributionConfirmPackageRecordInput, GameDistributionCreateGameRecordInput, GameDistributionConfirmPackageRecordInput, GameDistributionConfirmProjectBundleRecordInput,
GameDistributionCreateVersionRecordInput, GameDistributionDeleteGameRecordInput, GameDistributionCreateGameRecordInput, GameDistributionCreateVersionRecordInput,
GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput, GameDistributionDeleteGameRecordInput, GameDistributionFailUploadRecordInput,
GameDistributionOwnerGameListRecordInput, GameDistributionPlayCountIncrementRecordInput, GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput,
GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput, GameDistributionPlayCountIncrementRecordInput, GameDistributionPublicGameListRecordInput,
GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput,
GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationRecordInput,
GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, GameDistributionSetForkAuthorizationRecordInput, GameDistributionSubmitReviewRecordInput,
GameDistributionUnpublishRecordInput, GameDistributionUpdateMetadataRecordInput, GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput,
GameDistributionUpdateMetadataRecordInput,
}; };
#[path = "active/external_generation.rs"] #[path = "active/external_generation.rs"]
pub mod external_generation; pub mod external_generation;
@@ -323,6 +323,10 @@ pub struct GameDistributionDerivedGamesRecord {
/// `found == false` 表示游戏行不存在(api-server → 404);`available == false` 表示行存在但 /// `found == false` 表示游戏行不存在(api-server → 404);`available == false` 表示行存在但
/// 不可作为改编来源(已软删除 / 未公开 / 没有当前公开版本 → 409)。**不含对象键**: /// 不可作为改编来源(已软删除 / 未公开 / 没有当前公开版本 → 409)。**不含对象键**:
/// 只带版本身份与摘要,下载路径由 api-server 拼同源相对路径。 /// 只带版本身份与摘要,下载路径由 api-server 拼同源相对路径。
///
/// `project_bundle_*` 是 M2b 的可选第二份资产:`project_bundle_bytes > 0 &&
/// project_bundle_sha256.is_some()` 才算「有工程源包」,api-server 据此在 `project` 与
/// `package` 之间选资产;字节数与摘要成对写入,缺一即按没有工程包处理。
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionForkSourceRecord { pub struct GameDistributionForkSourceRecord {
pub found: bool, pub found: bool,
@@ -331,6 +335,10 @@ pub struct GameDistributionForkSourceRecord {
pub version_id: Option<String>, pub version_id: Option<String>,
pub package_sha256: Option<String>, pub package_sha256: Option<String>,
pub package_bytes: Option<u64>, pub package_bytes: Option<u64>,
/// 工程源包整包 SHA-256;未上传为 `None`。
pub project_bundle_sha256: Option<String>,
/// 工程源包字节数;`0` 表示未上传。
pub project_bundle_bytes: u64,
} }
fn map_game( fn map_game(
@@ -519,6 +527,9 @@ pub(crate) fn map_game_distribution_fork_source_result(
// SpacetimeDB 生成绑定把 `package_sha256` 折成 `package_sha_256`(与版本快照同约定)。 // SpacetimeDB 生成绑定把 `package_sha256` 折成 `package_sha_256`(与版本快照同约定)。
package_sha256: result.package_sha_256, package_sha256: result.package_sha_256,
package_bytes: result.package_bytes, package_bytes: result.package_bytes,
// 同约定:`project_bundle_sha256` → `project_bundle_sha_256`。
project_bundle_sha256: result.project_bundle_sha_256,
project_bundle_bytes: result.project_bundle_bytes,
}) })
} }