From d061b2e373e9854687932c129de801c3e942c2e5 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 15:42:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20M?= =?UTF-8?q?2b=20=E4=B8=8A=E8=A1=8C=E8=B7=AF=E7=94=B1=E6=97=8F=20+=20?= =?UTF-8?q?=E4=B8=8B=E8=A1=8C=E4=BC=98=E5=85=88=E4=B8=8E=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E7=BB=B4=E5=BA=A6=EF=BC=88=E5=9D=97=20C+D?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 上行(作者、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 --- .../src/modules/game_distribution.rs | 1076 ++++++++++++++++- .../crates/spacetime-client/src/active.rs | 19 +- .../src/active/mapper/game_distribution.rs | 11 + 3 files changed, 1033 insertions(+), 73 deletions(-) diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index fcda94723..6679a210c 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -17,9 +17,10 @@ use axum::{ routing::{get, post, put}, }; use module_game_distribution::{ - MAX_PACKAGE_BYTES, ReleaseAssetError, ReleasePackageError, ReleasePackageManifest, - compute_request_digest, extract_release_asset, normalize_review_comment, - normalize_review_moderation_reason, release_asset_content_type, validate_release_zip, + MAX_PACKAGE_BYTES, MAX_PROJECT_BUNDLE_BYTES, ProjectBundleError, ProjectBundleManifest, + ReleaseAssetError, ReleasePackageError, ReleasePackageManifest, compute_request_digest, + extract_release_asset, normalize_review_comment, normalize_review_moderation_reason, + release_asset_content_type, validate_project_bundle_zip, validate_release_zip, validate_review_list_status, }; use platform_auth::read_refresh_session_token; @@ -87,6 +88,10 @@ pub(crate) const MAX_PACKAGE_REQUEST_BODY_BYTES: usize = MAX_PACKAGE_BYTES as us pub(crate) const PACKAGE_UPLOAD_CHUNK_BYTES: usize = 8 * 1024 * 1024; /// 分片路由的请求体放行量:分片大小 + 1 KiB 头部余量。 pub(crate) const MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES: usize = PACKAGE_UPLOAD_CHUNK_BYTES + 1024; +/// 工程源包整包 PUT 的请求体放行量:上限与发行包同值(两者共用同一条上传链路,反代与 +/// Pingora 的放行量就是按 200 MiB 校准的),只多留 1 KiB 头部余量。 +pub(crate) const MAX_PROJECT_BUNDLE_REQUEST_BODY_BYTES: usize = + MAX_PROJECT_BUNDLE_BYTES as usize + 1024; /// 分片偏移由客户端显式声明,服务端以对象当前长度为唯一权威。 const PACKAGE_UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; const MAX_LIST_LIMIT: u32 = 48; @@ -301,8 +306,9 @@ pub fn router(state: AppState) -> Router { get(list_user_reviews), ) .route_layer(middleware::from_fn(add_no_store_response_headers)); - // Fork 取件通道(M2a):要求 Bearer 登录,但**不叠加发布灰度**——灰度只针对「发布」, - // 任何登录用户都应该能改编已授权的作品。两个 handler 共用同一条校验,规则只写一遍。 + // Fork 取件通道(M2a/M2b):要求 Bearer 登录,但**不叠加发布灰度**——灰度只针对「发布」, + // 任何登录用户都应该能改编已授权的作品。三个 handler 共用同一条校验,规则只写一遍; + // `/project` 失败关闭:只有选定资产确为工程源包时才服务,绝不悄悄回落成品包。 let fork_sources = Router::new() .route( "/api/game-distribution/games/{game_id}/fork-source", @@ -312,6 +318,10 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/games/{game_id}/fork-source/package", get(get_fork_source_package), ) + .route( + "/api/game-distribution/games/{game_id}/fork-source/project", + get(get_fork_source_project), + ) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, @@ -348,6 +358,31 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/versions/{version_id}/package/reset", post(reset_package_upload), ) + // 工程源包上行族(M2b):与发行包族逐条对齐,只是资产换成作者的工程源包。 + // 载体类型一律 `application/octet-stream`(见技术方案 §3.4),分片边界与偏移头 + // 直接复用发行包那一套——两者上限同值,客户端只能有一套偏移语义。 + .route( + "/api/game-distribution/versions/{version_id}/project-bundle", + put(upload_project_bundle) + .layer(DefaultBodyLimit::max(MAX_PROJECT_BUNDLE_REQUEST_BODY_BYTES)), + ) + .route( + "/api/game-distribution/versions/{version_id}/project-bundle/upload-state", + get(project_bundle_upload_state), + ) + .route( + "/api/game-distribution/versions/{version_id}/project-bundle/chunk", + put(upload_project_bundle_chunk) + .layer(DefaultBodyLimit::max(MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES)), + ) + .route( + "/api/game-distribution/versions/{version_id}/project-bundle/complete", + post(complete_project_bundle_upload), + ) + .route( + "/api/game-distribution/versions/{version_id}/project-bundle/reset", + post(reset_project_bundle_upload), + ) .route( "/api/game-distribution/versions/{version_id}/submit", post(submit_version), @@ -960,15 +995,18 @@ fn inject_release_storage_bootstrap(content: Vec, content_type: &str) -> Vec result } -/// 读取(并按对象键缓存)已确认的私有发行包。 -async fn release_package_bytes( +/// 读取(并按**对象键**缓存)已确认的私有资产。 +/// +/// 缓存键就是对象键,因此资产种类天然分开:同一 (作品, 版本) 的成品包与工程源包落在不同 +/// 对象键上,各自取回自己那份字节,不会串味。上限由调用方给(发行包 200 MiB、工程源包 +/// 同为 200 MiB),`max_bytes` 是 OSS 读路径的硬闸门。 +async fn release_asset_bytes( state: &AppState, - game_id: &str, - version_id: &str, + object_key: &str, + max_bytes: u64, ) -> Result { - let object_key = format!("{GAME_DISTRIBUTION_OBJECT_PREFIX}{game_id}/{version_id}.zip"); let cache = &*RELEASE_PACKAGE_CACHE; - if let Some(cached) = cache.lock().ok().and_then(|guard| guard.get(&object_key)) { + if let Some(cached) = cache.lock().ok().and_then(|guard| guard.get(object_key)) { return Ok(cached); } let oss = state.project_snapshot_oss_client().ok_or_else(|| { @@ -978,8 +1016,8 @@ async fn release_package_bytes( .get_object( state.editor_oss_http_client(), OssGetObjectRequest { - object_key: object_key.clone(), - max_bytes: MAX_PACKAGE_BYTES as usize, + object_key: object_key.to_string(), + max_bytes: max_bytes as usize, }, ) .await @@ -993,11 +1031,21 @@ async fn release_package_bytes( // `Bytes::from(Vec)` 直接接管所有权,不再复制整包;之后每次命中缓存只加引用计数。 let bytes = Bytes::from(bytes); if let Ok(mut guard) = cache.lock() { - guard.insert(object_key, bytes.clone()); + guard.insert(object_key.to_string(), bytes.clone()); } Ok(bytes) } +/// 读取(并按对象键缓存)已确认的私有发行包;键与上限与既有行为逐字节一致。 +async fn release_package_bytes( + state: &AppState, + game_id: &str, + version_id: &str, +) -> Result { + let object_key = game_distribution_package_object_key(game_id, version_id); + release_asset_bytes(state, &object_key, MAX_PACKAGE_BYTES).await +} + fn release_asset_response_with_cache( content: Vec, content_type: &'static str, @@ -1931,12 +1979,12 @@ async fn upload_package_chunk( Path(version_id): Path, body: Bytes, ) -> Result, AppError> { - require_octet_stream_content_type(&headers)?; + require_octet_stream_content_type(&headers, "发行包分片必须使用 application/octet-stream")?; let owner_user_id = auth.claims().user_id().to_string(); ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; // 分片级重放由偏移语义保证,这里仍要求幂等键,保持与其它写入口一致的调用约定。 let _idempotency_key = idempotency_key(&headers)?; - let offset = package_upload_offset(&headers)?; + let offset = package_upload_offset(&headers, "发行包")?; if body.is_empty() { return Err(bad_request("发行包分片内容不能为空")); } @@ -2195,6 +2243,505 @@ async fn reset_package_upload( )) } +/// 工程源包上传的阶段门:5 条路由共用这一条规则,禁止在 handler 里各写一遍。 +/// +/// 两道判定缺一不可: +/// - **先判「已确认过工程包」**(`project_bundle_bytes > 0`)→ 409,文案必须含「已存在」, +/// 与 `map_spacetime_error` 的「已存在」子串映射同口径。顺序不能颠倒:确认工程包**不驱动** +/// 版本状态机,已确认的版本仍可能停在 `awaiting_upload`,只判状态会把它当成可写,放任第二次 +/// 上传覆盖已确认的摘要与字节。 +/// - **再判阶段**:只有 `awaiting_upload` / `upload_failed` 能写(与发行包确认同一道门); +/// 已提交、验证中、待审核、已拒绝、已公开、已撤回、已取消一律 409——版本不可变。 +fn ensure_project_bundle_uploadable( + version: &GameDistributionVersionRecord, +) -> Result<(), AppError> { + if version.project_bundle_bytes > 0 { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PROJECT_BUNDLE_ALREADY_EXISTS") + .with_message("同一版本已存在工程源包,换内容必须新建版本")); + } + if !matches!(version.status.as_str(), "awaiting_upload" | "upload_failed") { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED") + .with_message(format!( + "版本状态 {} 不允许上传工程源包,未公开前才能写一次", + version.status + ))); + } + Ok(()) +} + +/// 工程源包校验失败的映射:与发行包同形(422 + 稳定错误码 + 具体原因),只是错误码换成工程包。 +fn map_project_bundle_error(error: ProjectBundleError) -> AppError { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY) + .with_code("PROJECT_BUNDLE_VALIDATION_FAILED") + .with_details(json!({ "provider": "game-distribution", "reason": format!("{error:?}") })) +} + +/// 工程源包整包上传(一次 PUT):语义逐条镜像发行包整包上传,只是资产与对象键换成工程源包。 +/// +/// 载体类型是 `application/octet-stream`(技术方案 §3.4):作者侧打包器已经产出 zip 字节, +/// 不需要客户端再声明 `application/zip`;**服务端仍独立跑工程包门禁**,不信任客户端。 +async fn upload_project_bundle( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, + body: Bytes, +) -> Result, AppError> { + require_octet_stream_content_type(&headers, "工程源包必须使用 application/octet-stream")?; + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let idempotency_key = idempotency_key(&headers)?; + let expected = + load_owner_version_or_404(&state, owner_user_id.clone(), version_id.clone()).await?; + ensure_project_bundle_uploadable(&expected)?; + let manifest = match validate_project_bundle_zip(&body) { + Ok(manifest) => manifest, + Err(error) => { + let reason = format!("{error:?}"); + warn!( + request_id = ctx.request_id(), + operation = "project_bundle_rejected", + game_id = %expected.game_id, + version_id = %version_id, + code = "PROJECT_BUNDLE_VALIDATION_FAILED", + reason = %reason, + uploaded_bytes = body.len(), + elapsed_ms = ctx.elapsed(), + "工程源包校验失败" + ); + let mapped = map_project_bundle_error(error); + record_upload_failure( + &state, + &owner_user_id, + &version_id, + &idempotency_key, + "PROJECT_BUNDLE_VALIDATION_FAILED", + reason, + ) + .await; + return Err(mapped); + } + }; + let bundle_object_key = + game_distribution_project_bundle_object_key(&expected.game_id, &version_id); + let oss = game_distribution_oss_client(&state)?; + let existing = oss + .head_internal_object(state.editor_oss_http_client(), &bundle_object_key) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let skipped = match existing { + Some(existing) if existing.content_length == manifest.bundle_bytes => true, + Some(_) => { + let error = AppError::from_status(StatusCode::CONFLICT) + .with_code("PROJECT_BUNDLE_OBJECT_MISMATCH") + .with_message("工程源包对象已存在但体积不一致"); + record_upload_failure( + &state, + &owner_user_id, + &version_id, + &idempotency_key, + "PROJECT_BUNDLE_OBJECT_MISMATCH", + "工程源包对象已存在但体积不一致".to_string(), + ) + .await; + return Err(error); + } + None => false, + }; + if !skipped { + // 重试口径与发行包一致:只重试 platform-oss 认定的可重试分类(传输/超时/408/429/5xx)。 + oss.put_internal_object_with_retry( + state.editor_oss_http_client(), + OssInternalPutObjectRequest { + object_key: bundle_object_key.clone(), + content_type: Some("application/zip".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::new(), + body: body.to_vec(), + }, + GAME_DISTRIBUTION_OSS_PUT_MAX_ATTEMPTS, + &GAME_DISTRIBUTION_OSS_PUT_RETRY_DELAYS_MS, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + } + confirm_validated_project_bundle( + &state, + &ctx, + &owner_user_id, + &version_id, + &expected.game_id, + &manifest, + bundle_object_key, + &idempotency_key, + skipped, + ) + .await +} + +/// 工程源包校验通过后的共同收口:整包 PUT 与分片 complete 共用这条路径。 +/// +/// 幂等摘要的组织方式与发行包 complete 同形(`(version_id, sha256)` 序列化后取摘要), +/// 只是字段换成工程源包;同 key 重放由模块事务按摘要识别并返回 `replayed = true`。 +#[allow(clippy::too_many_arguments)] +async fn confirm_validated_project_bundle( + state: &AppState, + ctx: &RequestContext, + owner_user_id: &str, + version_id: &str, + game_id: &str, + manifest: &ProjectBundleManifest, + bundle_object_key: String, + idempotency_key: &str, + oss_put_skipped: bool, +) -> Result, AppError> { + let request_digest = compute_request_digest( + &serde_json::to_vec(&(version_id, manifest.bundle_sha256.as_str())) + .map_err(|error| internal(error.to_string()))?, + ); + let log_bundle_bytes = manifest.bundle_bytes; + let log_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX); + let log_sha_prefix = manifest.bundle_sha256.chars().take(12).collect::(); + let confirmed = state + .spacetime_client() + .confirm_game_distribution_project_bundle( + spacetime_client::GameDistributionConfirmProjectBundleRecordInput { + version_id: version_id.to_string(), + owner_user_id: owner_user_id.to_string(), + project_bundle_object_key: bundle_object_key, + project_bundle_bytes: manifest.bundle_bytes, + project_bundle_sha256: manifest.bundle_sha256.clone(), + idempotency_key: idempotency_key.to_string(), + request_digest, + updated_at_micros: now_micros(), + }, + ) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "project_bundle_confirmed", + game_id = %game_id, + version_id = %confirmed.0.version_id, + project_bundle_bytes = log_bundle_bytes, + file_count = log_file_count, + sha256_prefix = %log_sha_prefix, + replayed = confirmed.1, + oss_put_skipped, + elapsed_ms = ctx.elapsed(), + "工程源包已确认" + ); + Ok(json_success_body( + Some(ctx), + json!({ "versionId": confirmed.0.version_id, "status": confirmed.0.status }), + )) +} + +/// 工程源包分片续传的状态查询:已收字节同样取自 OSS 对象事实,因此进程重启、换机器或换网络 +/// 后都能从权威偏移继续。工程源包没有「创建版本时预登记的大小」,因此响应里没有 declared* 键。 +async fn project_bundle_upload_state( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + ensure_project_bundle_uploadable(&version)?; + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_project_bundle_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + Ok(json_success_body( + Some(&ctx), + json!({ + "versionId": version_id, + "status": version.status, + "chunkBytes": PACKAGE_UPLOAD_CHUNK_BYTES, + "receivedBytes": received_bytes, + }), + )) +} + +/// 工程源包分片写入:偏移语义、分片边界与并发处理逐条对齐发行包分片。 +/// +/// 工程源包没有预登记大小,因此「不得超过」的闸门是合同上限 `MAX_PROJECT_BUNDLE_BYTES` +/// (与发行包上限同值);真实体积由 complete 时的整包校验确定。 +async fn upload_project_bundle_chunk( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, + body: Bytes, +) -> Result, AppError> { + require_octet_stream_content_type(&headers, "工程源包分片必须使用 application/octet-stream")?; + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + // 分片级重放由偏移语义保证,这里仍要求幂等键,保持与其它写入口一致的调用约定。 + let _idempotency_key = idempotency_key(&headers)?; + let offset = package_upload_offset(&headers, "工程源包")?; + if body.is_empty() { + return Err(bad_request("工程源包分片内容不能为空")); + } + if body.len() > PACKAGE_UPLOAD_CHUNK_BYTES { + return Err(AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE) + .with_code("PROJECT_BUNDLE_CHUNK_TOO_LARGE") + .with_message("工程源包分片超过服务端下发的大小")); + } + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + ensure_project_bundle_uploadable(&version)?; + let chunk_bytes = u64::try_from(body.len()).unwrap_or(u64::MAX); + let end = offset + .checked_add(chunk_bytes) + .ok_or_else(|| bad_request("工程源包分片偏移溢出"))?; + if end > MAX_PROJECT_BUNDLE_BYTES { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PROJECT_BUNDLE_UPLOAD_EXCEEDS_LIMIT") + .with_details(json!({ + "provider": "game-distribution", + "maxProjectBundleBytes": MAX_PROJECT_BUNDLE_BYTES, + "receivedBytes": offset, + "message": "分片写入会超过工程源包体积上限", + }))); + } + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_project_bundle_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + if offset != received_bytes { + warn!( + request_id = ctx.request_id(), + operation = "project_bundle_chunk_offset_mismatch", + game_id = %version.game_id, + version_id = %version_id, + declared_offset = offset, + received_bytes, + "工程源包分片偏移与服务端已收字节不一致" + ); + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PROJECT_BUNDLE_UPLOAD_OFFSET_MISMATCH") + .with_message("分片偏移与服务端已收字节不一致,请按权威偏移续传") + .with_details(json!({ + "provider": "game-distribution", + "receivedBytes": received_bytes, + }))); + } + let append_result = oss + .append_internal_object_with_retry( + state.editor_oss_http_client(), + OssAppendInternalObjectRequest { + object_key: object_key.clone(), + content_type: Some("application/zip".to_string()), + access: OssObjectAccess::Private, + position: offset, + body: body.to_vec(), + }, + GAME_DISTRIBUTION_OSS_PUT_MAX_ATTEMPTS, + &GAME_DISTRIBUTION_OSS_PUT_RETRY_DELAYS_MS, + ) + .await; + let appended = match append_result { + Ok(appended) => appended, + Err(error) => { + // 同偏移的并发写入可能先赢了一片:只要权威长度已经前进,就按偏移冲突返回, + // 让客户端按权威偏移续传,而不是把一个可恢复的并发结果报成上游故障。 + if let Ok(authoritative) = staged_package_bytes(&state, oss, &object_key).await + && authoritative > offset + { + warn!( + request_id = ctx.request_id(), + operation = "project_bundle_chunk_offset_lost_race", + game_id = %version.game_id, + version_id = %version_id, + declared_offset = offset, + received_bytes = authoritative, + "并发写入已推进已收字节,按偏移冲突返回权威位置" + ); + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PROJECT_BUNDLE_UPLOAD_OFFSET_MISMATCH") + .with_message("分片偏移与服务端已收字节不一致,请按权威偏移续传") + .with_details(json!({ + "provider": "game-distribution", + "receivedBytes": authoritative, + }))); + } + return Err(map_oss_error(error, "aliyun-oss")); + } + }; + info!( + request_id = ctx.request_id(), + operation = "project_bundle_chunk_stored", + game_id = %version.game_id, + version_id = %version_id, + offset, + chunk_bytes = appended.appended_bytes, + received_bytes = appended.next_position, + elapsed_ms = ctx.elapsed(), + "工程源包分片已写入" + ); + Ok(json_success_body( + Some(&ctx), + json!({ + "versionId": version_id, + "chunkBytes": PACKAGE_UPLOAD_CHUNK_BYTES, + "receivedBytes": appended.next_position, + }), + )) +} + +/// 工程源包分片续传的完成动作:全部字节到齐后才回读整包、独立校验并确认。 +/// +/// 校验失败时照抄发行包 complete 的处理:删除半包对象、记一次上传失败、返回既有错误形状 +/// (422 + `PROJECT_BUNDLE_VALIDATION_FAILED`),避免半包留在对象键上拖住后续重传。 +async fn complete_project_bundle_upload( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let idempotency_key = idempotency_key(&headers)?; + let version = + load_owner_version_or_404(&state, owner_user_id.clone(), version_id.clone()).await?; + ensure_project_bundle_uploadable(&version)?; + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_project_bundle_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + if received_bytes == 0 { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PROJECT_BUNDLE_UPLOAD_NOT_STARTED") + .with_details(json!({ + "provider": "game-distribution", + "receivedBytes": 0, + "message": "该版本还没有任何已收工程源包分片", + }))); + } + let body = oss + .get_object( + state.editor_oss_http_client(), + OssGetObjectRequest { + object_key: object_key.clone(), + max_bytes: MAX_PROJECT_BUNDLE_BYTES as usize, + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + // 工程源包没有预登记大小,「收齐」只能由「HEAD 的权威长度 == 读回的字节数」证明; + // 两者不一致说明读回期间对象被并发改写,按未收齐拒绝,让客户端重新对齐偏移。 + if u64::try_from(body.len()).unwrap_or(u64::MAX) != received_bytes { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PROJECT_BUNDLE_UPLOAD_INCOMPLETE") + .with_message("工程源包分片尚未收齐") + .with_details(json!({ + "provider": "game-distribution", + "receivedBytes": received_bytes, + "readBytes": body.len(), + }))); + } + let manifest = match validate_project_bundle_zip(&body) { + Ok(manifest) => manifest, + Err(error) => { + let reason = format!("{error:?}"); + warn!( + request_id = ctx.request_id(), + operation = "project_bundle_rejected", + game_id = %version.game_id, + version_id = %version_id, + code = "PROJECT_BUNDLE_VALIDATION_FAILED", + reason = %reason, + uploaded_bytes = body.len(), + elapsed_ms = ctx.elapsed(), + "工程源包校验失败" + ); + let mapped = map_project_bundle_error(error); + if let Err(delete_error) = oss + .delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + { + warn!( + request_id = ctx.request_id(), + operation = "project_bundle_staging_delete_failed", + version_id = %version_id, + error = %delete_error, + "校验失败的半包对象删除失败,需要人工确认对象键状态" + ); + } + record_upload_failure( + &state, + &owner_user_id, + &version_id, + &idempotency_key, + "PROJECT_BUNDLE_VALIDATION_FAILED", + reason, + ) + .await; + return Err(mapped); + } + }; + confirm_validated_project_bundle( + &state, + &ctx, + &owner_user_id, + &version_id, + &version.game_id, + &manifest, + object_key, + &idempotency_key, + true, + ) + .await +} + +/// 显式重置工程源包分片会话:删除暂存对象并把已收字节归零。 +/// +/// 与发行包 reset 同一道门(共用 `ensure_project_bundle_uploadable`):只有尚未确认过工程源包 +/// 且仍处于上传档位的版本能重置;已确认的版本换内容必须新建版本。 +async fn reset_project_bundle_upload( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let _idempotency_key = idempotency_key(&headers)?; + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + ensure_project_bundle_uploadable(&version)?; + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_project_bundle_object_key(&version.game_id, &version_id); + oss.delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + info!( + request_id = ctx.request_id(), + operation = "project_bundle_upload_reset", + game_id = %version.game_id, + version_id = %version_id, + elapsed_ms = ctx.elapsed(), + "工程源包分片会话已重置" + ); + Ok(json_success_body( + Some(&ctx), + json!({ "versionId": version_id, "receivedBytes": 0 }), + )) +} + fn game_distribution_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { state.project_snapshot_oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("游戏发行包 OSS 未配置") @@ -2205,6 +2752,16 @@ fn game_distribution_package_object_key(game_id: &str, version_id: &str) -> Stri format!("{GAME_DISTRIBUTION_OBJECT_PREFIX}{game_id}/{version_id}.zip") } +/// 工程源包对象键。 +/// +/// **必须**与发行包键(`…/{version_id}.zip`)不同:同一 (作品, 版本) 的两份资产(成品包与 +/// 工程源包)会先后上传,共用键会让后传的那份覆盖前一份,已确认的摘要与字节随即变成谎话, +/// 发行网关与取件通道也会读到另一份资产。这里靠 `.project.zip` 后缀区分,两个键都在同一 +/// 前缀族下,便于生命周期策略统一。 +fn game_distribution_project_bundle_object_key(game_id: &str, version_id: &str) -> String { + format!("{GAME_DISTRIBUTION_OBJECT_PREFIX}{game_id}/{version_id}.project.zip") +} + /// 已收字节的权威来源:对象存在时的长度;确定不存在时是 0,其它失败按上游错误上报。 async fn staged_package_bytes( state: &AppState, @@ -2218,7 +2775,12 @@ async fn staged_package_bytes( Ok(head.map(|object| object.content_length).unwrap_or(0)) } -fn require_octet_stream_content_type(headers: &HeaderMap) -> Result<(), AppError> { +/// `application/octet-stream` 是发行包分片与工程源包(整包与分片)共同的载体类型; +/// 错误文案由调用方给,避免把「发行包分片」这句话安在工程源包上。 +fn require_octet_stream_content_type( + headers: &HeaderMap, + message: &'static str, +) -> Result<(), AppError> { let content_type = headers .get(header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) @@ -2231,20 +2793,22 @@ fn require_octet_stream_content_type(headers: &HeaderMap) -> Result<(), AppError .to_ascii_lowercase() }); if content_type.as_deref() != Some("application/octet-stream") { - return Err(bad_request("发行包分片必须使用 application/octet-stream")); + return Err(bad_request(message)); } Ok(()) } -fn package_upload_offset(headers: &HeaderMap) -> Result { +/// 分片偏移头:发行包分片与工程源包分片共用同一个头名与解析口径(两者上限同值,客户端 +/// 只能有一套偏移语义);`asset` 只用于错误文案,避免把「发行包」安在工程源包上。 +fn package_upload_offset(headers: &HeaderMap, asset: &'static str) -> Result { let raw = headers .get(PACKAGE_UPLOAD_OFFSET_HEADER) .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or_else(|| bad_request("缺少发行包分片偏移"))?; + .ok_or_else(|| bad_request(format!("缺少{asset}分片偏移")))?; raw.parse::() - .map_err(|_| bad_request("发行包分片偏移必须是非负整数")) + .map_err(|_| bad_request(format!("{asset}分片偏移必须是非负整数"))) } async fn submit_version( @@ -2517,12 +3081,14 @@ async fn set_fork_authorization( )) } -/// 取件校验通过后的目标:内容下发只需要版本身份与摘要。 -#[derive(Debug)] +/// 取件校验通过后的目标:内容下发只需要**选定资产**的版本身份与摘要。 +#[derive(Debug, PartialEq, Eq)] struct ForkSourceTarget { version_id: String, - package_sha256: String, - package_bytes: u64, + /// 选定资产:有工程源包时 `Project`(优先),否则回落 `Package`。 + source: GameDistributionForkSourceKind, + sha256: String, + bytes: u64, } /// 取件校验的纯映射:把「读到了什么」折成 HTTP 语义。 @@ -2531,6 +3097,11 @@ struct ForkSourceTarget { /// 来源(已软删除 / 未公开 / 没有当前公开版本)→ 409;授权为禁止或**未知档位** → 403 /// (未知按「禁止共创」解释,与 `resolve_game_distribution_fork_declaration_tx` 同口径)。 /// 抽成纯函数是为了让这条映射可被单测钉住,而不是散落在两个 handler 里各写一遍。 +/// +/// 选定资产(M2b):`project_bundle_bytes > 0 && project_bundle_sha256.is_some()` 才算「有工程 +/// 源包」,此时优先 `Project`;否则回落 `Package`。**失败关闭**:工程包的字节数与摘要必须成对 +/// ——「字节数 > 0 但摘要为空」这种半写行按「没有工程包」处理并回落成品包,而不是把取件指向一个 +/// 摘不出来、客户端也无法校验的资产;没有任何可用资产时 409,不发半截信息。 fn fork_source_target( record: GameDistributionForkSourceRecord, ) -> Result { @@ -2548,20 +3119,33 @@ fn fork_source_target( if !authorization.allows_fork() { return Err(AppError::from_status(StatusCode::FORBIDDEN).with_code("FORK_NOT_AUTHORIZED")); } - // 失败关闭:`available` 为真却没有版本元数据时不发半截信息,按不可用处理。 - let (Some(version_id), Some(package_sha256), Some(package_bytes)) = ( - record.version_id, - record.package_sha256, - record.package_bytes, - ) else { + let Some(version_id) = record.version_id else { + return Err( + AppError::from_status(StatusCode::CONFLICT).with_code("FORK_SOURCE_NOT_AVAILABLE") + ); + }; + // 有工程源包(字节数与摘要成对)→ 优先取源码包;半写行与缺失都按「没有工程包」处理。 + if let (Some(sha256), bytes) = (record.project_bundle_sha256, record.project_bundle_bytes) + && bytes > 0 + { + return Ok(ForkSourceTarget { + version_id, + source: GameDistributionForkSourceKind::Project, + sha256, + bytes, + }); + } + // 回落成品包:同样要求字节数与摘要成对,否则失败关闭。 + let (Some(sha256), Some(bytes)) = (record.package_sha256, record.package_bytes) else { return Err( AppError::from_status(StatusCode::CONFLICT).with_code("FORK_SOURCE_NOT_AVAILABLE") ); }; Ok(ForkSourceTarget { version_id, - package_sha256, - package_bytes, + source: GameDistributionForkSourceKind::Package, + sha256, + bytes, }) } @@ -2586,17 +3170,24 @@ fn is_path_safe_game_id(game_id: &str) -> bool { }) } -/// 取件下载路径:同源相对路径,**绝不下发 OSS 对象键**。 -fn build_fork_source_download_path(game_id: &str) -> Result { +/// 取件下载路径:同源相对路径,**绝不下发 OSS 对象键**;路径按选定资产指向对应资产。 +fn build_fork_source_download_path( + game_id: &str, + source: GameDistributionForkSourceKind, +) -> Result { if !is_path_safe_game_id(game_id) { return Err(internal("游戏标识不适用于取件路径")); } + let asset = match source { + GameDistributionForkSourceKind::Project => "project", + GameDistributionForkSourceKind::Package => "package", + }; Ok(format!( - "/api/game-distribution/games/{game_id}/fork-source/package" + "/api/game-distribution/games/{game_id}/fork-source/{asset}" )) } -/// 取件元数据响应:只含版本身份与摘要,对象键留在服务端。 +/// 取件元数据响应:只含版本身份与**选定资产**的摘要,对象键留在服务端。 fn fork_source_payload( game_id: &str, target: &ForkSourceTarget, @@ -2605,11 +3196,10 @@ fn fork_source_payload( fork_source: GameDistributionForkSource { game_id: game_id.to_string(), version_id: target.version_id.clone(), - // 当前只有已构建成品包;M2b 引入工程源包后这里改成「有工程包则 Project 优先」。 - source: GameDistributionForkSourceKind::Package, - sha256: target.package_sha256.clone(), - bytes: target.package_bytes, - download_path: build_fork_source_download_path(game_id)?, + source: target.source, + sha256: target.sha256.clone(), + bytes: target.bytes, + download_path: build_fork_source_download_path(game_id, target.source)?, }, }) } @@ -2628,7 +3218,8 @@ async fn get_fork_source( operation = "game_fork_source_metadata", game_id = %game_id, version_id = %target.version_id, - bytes = target.package_bytes, + source = ?target.source, + bytes = target.bytes, elapsed_ms = ctx.elapsed(), "下发 Fork 取件元数据" ); @@ -2657,10 +3248,36 @@ async fn get_fork_source_package( )) } -/// 取件包的响应头:ZIP + 长度 + 附件文件名 + no-store。 -fn fork_source_package_response(game_id: &str, version_id: &str, package: Bytes) -> Response { - let content_length = package.len(); - let mut response = Response::new(Body::from(package)); +/// Fork 取件本体(源码级):直接回该版本工程源包 ZIP 的字节。 +/// +/// 与 `/fork-source/package` 走同一套 `resolve_fork_source` 校验,差别只有一处且是**失败关闭**: +/// 选定资产不是 `Project` 时返回 409 `FORK_SOURCE_NOT_AVAILABLE`,绝不悄悄回落成品包——客户端 +/// 按 `source` 决定建项形态,在这里回一份成品包会让客户端按源码解压并直接失败。 +/// 读路径复用发行包的整包读入 + 进程内缓存,但对象键换成工程源包,缓存键因此天然带资产维度。 +/// 与成品包一样**不做**引用归一化与 bootstrap 注入:下发的是原始工程包,客户端要按摘要校验。 +async fn get_fork_source_project( + State(state): State, + Path(game_id): Path, +) -> Result { + let target = resolve_fork_source(&state, &game_id).await?; + if target.source != GameDistributionForkSourceKind::Project { + return Err( + AppError::from_status(StatusCode::CONFLICT).with_code("FORK_SOURCE_NOT_AVAILABLE") + ); + } + let object_key = game_distribution_project_bundle_object_key(&game_id, &target.version_id); + let bundle = release_asset_bytes(&state, &object_key, MAX_PROJECT_BUNDLE_BYTES).await?; + Ok(fork_source_project_response( + &game_id, + &target.version_id, + bundle, + )) +} + +/// 取件包的响应头:ZIP + 长度 + 附件文件名 + no-store。两种资产只有文件名后缀不同。 +fn fork_source_bundle_response(file_name: String, bundle: Bytes) -> Response { + let content_length = bundle.len(); + let mut response = Response::new(Body::from(bundle)); let headers = response.headers_mut(); headers.insert( header::CONTENT_TYPE, @@ -2674,15 +3291,21 @@ fn fork_source_package_response(game_id: &str, version_id: &str, package: Bytes) // 文件名只由路径段安全的两个 ID 拼成,不含用户输入的自由文本。 headers.insert( header::CONTENT_DISPOSITION, - HeaderValue::from_str(&format!( - "attachment; filename=\"{game_id}-{version_id}.zip\"" - )) - .unwrap_or_else(|_| HeaderValue::from_static("attachment")), + HeaderValue::from_str(&format!("attachment; filename=\"{file_name}\"")) + .unwrap_or_else(|_| HeaderValue::from_static("attachment")), ); headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); response } +fn fork_source_package_response(game_id: &str, version_id: &str, package: Bytes) -> Response { + fork_source_bundle_response(format!("{game_id}-{version_id}.zip"), package) +} + +fn fork_source_project_response(game_id: &str, version_id: &str, bundle: Bytes) -> Response { + fork_source_bundle_response(format!("{game_id}-{version_id}-project.zip"), bundle) +} + async fn admin_list_reviews( State(state): State, Extension(ctx): Extension, @@ -4498,6 +5121,33 @@ mod tests { assert!(MAX_PACKAGE_REQUEST_BODY_BYTES > MAX_PACKAGE_BYTES as usize); } + #[test] + fn project_bundle_limits_mirror_the_package_pipeline() { + // 工程源包与发行包共用同一条上传链路(反代 / Pingora 的放行量按 200 MiB 校准), + // 因此三个上限必须逐项相等;请求体放行量同样要盖过包体上限,否则合法工程包会在 + // `DefaultBodyLimit` 处被 413,`validate_project_bundle_zip` 根本没机会执行。 + assert_eq!(MAX_PROJECT_BUNDLE_BYTES, MAX_PACKAGE_BYTES); + assert_eq!( + MAX_PROJECT_BUNDLE_BYTES, + shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES, + ); + assert!(MAX_PROJECT_BUNDLE_REQUEST_BODY_BYTES > MAX_PROJECT_BUNDLE_BYTES as usize); + assert!(PACKAGE_UPLOAD_CHUNK_BYTES < MAX_PROJECT_BUNDLE_BYTES as usize); + assert!(MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES > PACKAGE_UPLOAD_CHUNK_BYTES); + } + + #[test] + fn project_bundle_validation_errors_are_unprocessable() { + // 与发行包同形:422 + 稳定错误码 + 具体原因,客户端按 code 决策、按 reason 定位。 + let error = map_project_bundle_error(ProjectBundleError::EmptyBundle); + assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(error.code(), "PROJECT_BUNDLE_VALIDATION_FAILED"); + assert_eq!( + error.details().and_then(|details| details.get("reason")), + Some(&Value::String("EmptyBundle".to_string())) + ); + } + #[test] fn package_chunk_size_stays_inside_declared_limits() { // 分片必须能整除式地覆盖 200 MiB 档发行包(最多 25 片),且分片放行量要留出头部余量。 @@ -4513,40 +5163,66 @@ mod tests { #[test] fn package_upload_offset_requires_non_negative_integer() { let mut headers = HeaderMap::new(); - assert!(package_upload_offset(&headers).is_err()); + assert!(package_upload_offset(&headers, "发行包").is_err()); headers.insert(PACKAGE_UPLOAD_OFFSET_HEADER, HeaderValue::from_static(" ")); - assert!(package_upload_offset(&headers).is_err()); + assert!(package_upload_offset(&headers, "发行包").is_err()); headers.insert(PACKAGE_UPLOAD_OFFSET_HEADER, HeaderValue::from_static("-1")); - assert!(package_upload_offset(&headers).is_err()); + assert!(package_upload_offset(&headers, "发行包").is_err()); headers.insert( PACKAGE_UPLOAD_OFFSET_HEADER, HeaderValue::from_static("8388608"), ); assert_eq!( - package_upload_offset(&headers).expect("合法偏移"), + package_upload_offset(&headers, "发行包").expect("合法偏移"), PACKAGE_UPLOAD_CHUNK_BYTES as u64 ); + + // 工程源包分片共用同一个头名与解析口径,只是错误文案换成工程源包。 + let mut project_headers = HeaderMap::new(); + assert_eq!( + package_upload_offset(&project_headers, "工程源包") + .expect_err("缺少偏移头必须报错") + .message(), + "缺少工程源包分片偏移" + ); + project_headers.insert(PACKAGE_UPLOAD_OFFSET_HEADER, HeaderValue::from_static("-1")); + assert!(package_upload_offset(&project_headers, "工程源包").is_err()); } #[test] fn package_chunk_content_type_must_be_octet_stream() { + let message = "发行包分片必须使用 application/octet-stream"; let mut headers = HeaderMap::new(); - assert!(require_octet_stream_content_type(&headers).is_err()); + assert!(require_octet_stream_content_type(&headers, message).is_err()); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/zip"), ); - assert!(require_octet_stream_content_type(&headers).is_err()); + assert!(require_octet_stream_content_type(&headers, message).is_err()); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"), ); - assert!(require_octet_stream_content_type(&headers).is_ok()); + assert!(require_octet_stream_content_type(&headers, message).is_ok()); + + // 工程源包整包上传同样只认 octet-stream;错误文案取自调用方。 + let project_message = "工程源包必须使用 application/octet-stream"; + let mut project_headers = HeaderMap::new(); + project_headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/zip"), + ); + assert_eq!( + require_octet_stream_content_type(&project_headers, project_message) + .expect_err("工程源包不接受 application/zip") + .message(), + project_message + ); } #[test] @@ -5285,6 +5961,21 @@ mod tests { version_id: version.map(|(version_id, _, _)| version_id.to_string()), package_sha256: version.map(|(_, sha256, _)| sha256.to_string()), package_bytes: version.map(|(_, _, bytes)| bytes), + project_bundle_sha256: None, + project_bundle_bytes: 0, + } + } + + /// 在成品包记录上叠加工程源包两列:字节数与摘要分开给,才能构造「半写行」这种失败关闭用例。 + fn fork_source_record_with_project( + base: GameDistributionForkSourceRecord, + project_bundle_sha256: Option<&str>, + project_bundle_bytes: u64, + ) -> GameDistributionForkSourceRecord { + GameDistributionForkSourceRecord { + project_bundle_sha256: project_bundle_sha256.map(str::to_string), + project_bundle_bytes, + ..base } } @@ -5339,8 +6030,9 @@ mod tests { )) .expect("允许共创且已公开应通过"); assert_eq!(target.version_id, "version_1"); - assert_eq!(target.package_sha256, "a".repeat(64)); - assert_eq!(target.package_bytes, 2048); + assert_eq!(target.source, GameDistributionForkSourceKind::Package); + assert_eq!(target.sha256, "a".repeat(64)); + assert_eq!(target.bytes, 2048); // 失败关闭:可用却没有版本元数据时按不可用处理,不发半截信息。 let incomplete = fork_source_target(fork_source_record(true, true, "full", None)) @@ -5380,8 +6072,13 @@ mod tests { assert!(!body.contains(".zip"), "{body}"); // 路径段不安全的 gameId 宁可失败,也不拼进下载路径。 - assert!(build_fork_source_download_path("../escape").is_err()); - assert!(build_fork_source_download_path("").is_err()); + assert!( + build_fork_source_download_path("../escape", GameDistributionForkSourceKind::Package) + .is_err() + ); + assert!( + build_fork_source_download_path("", GameDistributionForkSourceKind::Package).is_err() + ); } /// 取件包响应头:ZIP + 长度 + 附件文件名 + no-store,长度与内容一致。 @@ -5414,12 +6111,263 @@ mod tests { // Content-Length 与响应体实际字节一致;同一条 fixture 里它也等于版本行的 package_bytes。 assert_eq!( response.headers()[header::CONTENT_LENGTH], - HeaderValue::from_str(&target.package_bytes.to_string()).expect("长度头") + HeaderValue::from_str(&target.bytes.to_string()).expect("长度头") ); let body = axum::body::to_bytes(response.into_body(), 32_768) .await .expect("读取响应体"); - assert_eq!(body.len() as u64, target.package_bytes); + assert_eq!(body.len() as u64, target.bytes); + } + + /// 工程源包上行族 5 条 + 源码级取件 1 条:都必须在进入业务前要求登录态。 + /// + /// 这条测试只证「没带 Bearer 一律 401」,不碰 OSS / SpacetimeDB;成功路径留给 dev 栈端到端。 + #[tokio::test] + async fn project_bundle_routes_require_bearer_before_any_work() { + use axum::{ + body::Body, + http::{Method, Request}, + }; + use tower::ServiceExt; + + let app = crate::app::build_router( + crate::state::AppState::new(crate::config::AppConfig::default()) + .expect("测试状态应可构建"), + ); + for (method, uri) in [ + ( + Method::PUT, + "/api/game-distribution/versions/version_1/project-bundle", + ), + ( + Method::GET, + "/api/game-distribution/versions/version_1/project-bundle/upload-state", + ), + ( + Method::PUT, + "/api/game-distribution/versions/version_1/project-bundle/chunk", + ), + ( + Method::POST, + "/api/game-distribution/versions/version_1/project-bundle/complete", + ), + ( + Method::POST, + "/api/game-distribution/versions/version_1/project-bundle/reset", + ), + ( + Method::GET, + "/api/game-distribution/games/game_1/fork-source/project", + ), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method.clone()) + .uri(uri) + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{method} {uri}" + ); + } + } + + fn version_record_for_stage_gate( + status: &str, + project_bundle_bytes: u64, + ) -> GameDistributionVersionRecord { + GameDistributionVersionRecord { + version_id: "version_1".to_string(), + game_id: "game_1".to_string(), + owner_user_id: "user_1".to_string(), + version_number: 1, + package_sha256: "a".repeat(64), + package_bytes: 1024, + package_file_count: 1, + package_entry_path: "index.html".to_string(), + status: status.to_string(), + review_reason: None, + entry_url: None, + publication_revision: 1, + created_at: "2026-10-05T00:00:00Z".to_string(), + updated_at: "2026-10-05T00:00:00Z".to_string(), + metadata_json: None, + project_bundle_object_key: None, + project_bundle_bytes, + project_bundle_sha256: None, + } + } + + /// 阶段门:只有「未确认过工程包」且处于两个上传档位的版本能写;其余一律 409。 + /// + /// 「已确认过」必须先判:确认工程包不驱动状态机,已确认的版本仍可能停在 `awaiting_upload`, + /// 只判状态会把它当成可写,放任二次上传覆盖已确认的摘要与字节。 + #[test] + fn project_bundle_stage_gate_only_allows_unconfirmed_upload_states() { + for status in ["awaiting_upload", "upload_failed"] { + ensure_project_bundle_uploadable(&version_record_for_stage_gate(status, 0)) + .unwrap_or_else(|error| panic!("{status} 应放行:{error:?}")); + } + for status in [ + "uploaded", + "validating", + "pending_review", + "rejected", + "published", + "revoked", + "cancelled", + ] { + let error = ensure_project_bundle_uploadable(&version_record_for_stage_gate(status, 0)) + .expect_err("非上传档位必须 409"); + assert_eq!(error.status_code(), StatusCode::CONFLICT, "{status}"); + assert_eq!( + error.code(), + "PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED", + "{status}" + ); + } + // 已确认过工程包:即使状态仍是 `awaiting_upload` 也必须 409,且文案含「已存在」, + // 与 `map_spacetime_error` 的 409 子串映射同口径。 + let error = ensure_project_bundle_uploadable(&version_record_for_stage_gate( + "awaiting_upload", + 2048, + )) + .expect_err("已确认工程包必须 409"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + assert_eq!(error.code(), "PROJECT_BUNDLE_ALREADY_EXISTS"); + assert!(error.message().contains("已存在"), "{:?}", error.message()); + } + + /// 选定资产:有工程包(字节数与摘要成对)走 `project` 且下载路径指向 `/project`;没有则回落 + /// `package`;「字节数 > 0 但摘要为空」的半写行按没有工程包处理,绝不把取件指向取不到的资产。 + #[test] + fn fork_source_target_prefers_project_bundle_and_falls_back_to_package() { + let package_sha = "a".repeat(64); + let package_version = Some(("version_1", package_sha.as_str(), 2048)); + + // ① 有工程包 → Project,摘要 / 字节数取工程包那份,下载路径走 /project。 + let project_sha = "b".repeat(64); + let with_project = fork_source_target(fork_source_record_with_project( + fork_source_record(true, true, "full", package_version), + Some(project_sha.as_str()), + 4096, + )) + .expect("有工程包应通过"); + assert_eq!(with_project.source, GameDistributionForkSourceKind::Project); + assert_eq!(with_project.sha256, project_sha); + assert_eq!(with_project.bytes, 4096); + let project_payload = fork_source_payload("game_1", &with_project).expect("payload"); + assert_eq!( + project_payload.fork_source.source, + GameDistributionForkSourceKind::Project + ); + assert_eq!(project_payload.fork_source.sha256, project_sha); + assert_eq!(project_payload.fork_source.bytes, 4096); + assert_eq!( + project_payload.fork_source.download_path, + "/api/game-distribution/games/game_1/fork-source/project" + ); + + // ② 无工程包 → Package,下载路径走 /package。 + let without_project = + fork_source_target(fork_source_record(true, true, "full", package_version)) + .expect("无工程包应回落到成品包"); + assert_eq!( + without_project.source, + GameDistributionForkSourceKind::Package + ); + assert_eq!(without_project.sha256, "a".repeat(64)); + assert_eq!(without_project.bytes, 2048); + assert_eq!( + fork_source_payload("game_1", &without_project) + .expect("payload") + .fork_source + .download_path, + "/api/game-distribution/games/game_1/fork-source/package" + ); + + // ③ 半写行:字节数 > 0 但摘要为空 → 按没有工程包处理,回落 Package。 + let half_written = fork_source_target(fork_source_record_with_project( + fork_source_record(true, true, "full", package_version), + None, + 4096, + )) + .expect("半写行必须回落成品包"); + assert_eq!(half_written.source, GameDistributionForkSourceKind::Package); + assert_eq!(half_written.sha256, "a".repeat(64)); + assert_eq!(half_written.bytes, 2048); + + // 反向的半写行:摘要有了但字节数为 0,同样不算「有工程包」。 + let empty_bytes = fork_source_target(fork_source_record_with_project( + fork_source_record(true, true, "full", package_version), + Some(project_sha.as_str()), + 0, + )) + .expect("零字节工程包必须回落成品包"); + assert_eq!(empty_bytes.source, GameDistributionForkSourceKind::Package); + + // 只有工程包、没有成品包元数据时也走 Project(工程包本身是合法资产)。 + let project_only = fork_source_target(fork_source_record_with_project( + fork_source_record( + true, + true, + "full", + Some(("version_1", "a".repeat(64).as_str(), 0)), + ), + Some(project_sha.as_str()), + 4096, + )) + .expect("只有工程包也应可服务"); + assert_eq!(project_only.source, GameDistributionForkSourceKind::Project); + + // 两份资产都缺失 → 失败关闭 409,不发半截信息。 + let neither = fork_source_target(fork_source_record_with_project( + fork_source_record(true, true, "full", None), + None, + 0, + )) + .expect_err("没有任何可用资产必须失败关闭"); + assert_eq!(neither.status_code(), StatusCode::CONFLICT); + assert_eq!(neither.code(), "FORK_SOURCE_NOT_AVAILABLE"); + } + + /// 两份资产必须落在不同对象键上,缓存按对象键分桶因此不会串味。 + /// + /// 同一 (作品, 版本) 会先后上传成品包与工程源包:共用键会让后传的覆盖前一份,已确认的摘要 + /// 与字节随即变成谎话;发行网关与取件通道也会读到另一份资产。 + #[test] + fn project_bundle_key_and_cache_keep_assets_apart() { + let package_key = game_distribution_package_object_key("game_1", "version_1"); + let project_key = game_distribution_project_bundle_object_key("game_1", "version_1"); + // 发行包键的字符串必须逐字节不变(发行网关与既有缓存都按它取值)。 + assert_eq!( + package_key, + format!("{GAME_DISTRIBUTION_OBJECT_PREFIX}game_1/version_1.zip") + ); + assert_eq!( + project_key, + format!("{GAME_DISTRIBUTION_OBJECT_PREFIX}game_1/version_1.project.zip") + ); + assert_ne!(package_key, project_key); + + let mut cache = ReleasePackageCache::default(); + cache.insert(package_key.clone(), Bytes::from(vec![1_u8; 8])); + cache.insert(project_key.clone(), Bytes::from(vec![2_u8; 16])); + assert_eq!( + cache.get(&package_key).map(|bytes| bytes.to_vec()), + Some(vec![1_u8; 8]) + ); + assert_eq!( + cache.get(&project_key).map(|bytes| bytes.to_vec()), + Some(vec![2_u8; 16]) + ); } /// 上架时的共创授权档位:缺省按「禁止共创」解释,显式传值原样保留,未知取值失败关闭。 diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 0e3a1fde3..451ddbc6c 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -24,15 +24,16 @@ pub mod game_distribution; pub use game_distribution::{ GameDistributionAdminGameListRecordInput, GameDistributionAdminUserReviewListRecordInput, GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput, - GameDistributionConfirmPackageRecordInput, GameDistributionCreateGameRecordInput, - GameDistributionCreateVersionRecordInput, GameDistributionDeleteGameRecordInput, - GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput, - GameDistributionOwnerGameListRecordInput, GameDistributionPlayCountIncrementRecordInput, - GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput, - GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, - GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, - GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, - GameDistributionUnpublishRecordInput, GameDistributionUpdateMetadataRecordInput, + GameDistributionConfirmPackageRecordInput, GameDistributionConfirmProjectBundleRecordInput, + GameDistributionCreateGameRecordInput, GameDistributionCreateVersionRecordInput, + GameDistributionDeleteGameRecordInput, GameDistributionFailUploadRecordInput, + GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput, + GameDistributionPlayCountIncrementRecordInput, GameDistributionPublicGameListRecordInput, + GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, + GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationRecordInput, + GameDistributionSetForkAuthorizationRecordInput, GameDistributionSubmitReviewRecordInput, + GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, + GameDistributionUpdateMetadataRecordInput, }; #[path = "active/external_generation.rs"] pub mod external_generation; diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index bd08c5da3..0f5773819 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -323,6 +323,10 @@ pub struct GameDistributionDerivedGamesRecord { /// `found == false` 表示游戏行不存在(api-server → 404);`available == false` 表示行存在但 /// 不可作为改编来源(已软删除 / 未公开 / 没有当前公开版本 → 409)。**不含对象键**: /// 只带版本身份与摘要,下载路径由 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)] pub struct GameDistributionForkSourceRecord { pub found: bool, @@ -331,6 +335,10 @@ pub struct GameDistributionForkSourceRecord { pub version_id: Option, pub package_sha256: Option, pub package_bytes: Option, + /// 工程源包整包 SHA-256;未上传为 `None`。 + pub project_bundle_sha256: Option, + /// 工程源包字节数;`0` 表示未上传。 + pub project_bundle_bytes: u64, } fn map_game( @@ -519,6 +527,9 @@ pub(crate) fn map_game_distribution_fork_source_result( // SpacetimeDB 生成绑定把 `package_sha256` 折成 `package_sha_256`(与版本快照同约定)。 package_sha256: result.package_sha_256, 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, }) }