diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 8a30fcf6e..677c11663 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -312,9 +312,17 @@ pub(crate) project_bundle_sha256: Option, | --- | --- | | `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, #### 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`)——所以「能试玩」确实不需要构建,但「能发布」需要依赖声明。 diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index fec2488e2..267e9a1ae 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -225,6 +225,33 @@ export type GameDistributionDerivedResponse = { truncated: boolean; }; +/** + * Fork 取件内容的形态。M2a 只有已构建的发行成品包;M2b 引入工程源包后,同一版本同时存在 + * 两者时优先 `project`。 + */ +export type GameDistributionForkSourceKind = 'package' | 'project'; + +/** + * Fork 取件元数据:客户端据此校验取到的内容并决定建项形态。 + * + * 只含版本身份与摘要,**不含 OSS 对象键**——下载走同源受鉴权路径 `downloadPath`, + * 对象键只留在服务端。`sha256` / `bytes` 取自版本行已存的发行包摘要,不重新计算。 + */ +export type GameDistributionForkSource = { + gameId: string; + versionId: string; + source: GameDistributionForkSourceKind; + sha256: string; + bytes: number; + /** 同源相对路径;客户端按当前 origin 解析后带 Bearer 请求,不接受绝对 URL。 */ + downloadPath: string; +}; + +/** Fork 取件元数据接口(`GET …/fork-source`)的响应体。 */ +export type GameDistributionForkSourceResponse = { + forkSource: GameDistributionForkSource; +}; + export type GameDistributionGame = { id: string; title: string; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index ddb761b3b..9145dbfb8 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -63,6 +63,10 @@ const PAIRS = [ ['GameDistributionLineageNode', 'GameDistributionLineageNode'], ['GameDistributionLineageResponse', 'GameDistributionLineageResponse'], ['GameDistributionDerivedResponse', 'GameDistributionDerivedResponse'], + // M2a 取件通道:元数据响应与内容形态同样逐字段对齐(不含对象键是契约的一部分)。 + ['GameDistributionForkSourceKind', 'GameDistributionForkSourceKind'], + ['GameDistributionForkSource', 'GameDistributionForkSource'], + ['GameDistributionForkSourceResponse', 'GameDistributionForkSourceResponse'], ['GameDistributionForkDeclaration', 'GameDistributionForkDeclaration'], [ 'GameDistributionSetForkAuthorizationRequest', 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 e6d0cfcd4..ba86c6bee 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -1,6 +1,6 @@ use std::{ collections::{BTreeMap, HashMap, VecDeque}, - sync::{Arc, Mutex, OnceLock}, + sync::{LazyLock, Mutex}, time::{Instant, SystemTime, UNIX_EPOCH}, }; @@ -40,8 +40,9 @@ use shared_contracts::game_distribution::{ GAME_DISTRIBUTION_CATEGORIES, GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT, GameDistributionAuthor, GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, GameDistributionDerivedResponse, - GameDistributionForkAuthorization, GameDistributionInputMode, - GameDistributionLineageNode, GameDistributionLineageResponse, + GameDistributionForkAuthorization, GameDistributionForkSource, + GameDistributionForkSourceKind, GameDistributionForkSourceResponse, + GameDistributionInputMode, GameDistributionLineageNode, GameDistributionLineageResponse, GameDistributionMyReviewResponse, GameDistributionOrientation, GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest, GameDistributionRatingSummary, GameDistributionReview, GameDistributionReviewsResponse, @@ -54,8 +55,9 @@ use spacetime_client::{ GameDistributionAdminUserReviewListRecordInput, GameDistributionAdminUserReviewRecord, GameDistributionAdminVersionRecord, GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput, GameDistributionDeleteGameRecordInput, - GameDistributionDerivedGamesRecord, GameDistributionGameRecord, - GameDistributionGetGameRecordInput, GameDistributionLineageNodeRecord, + GameDistributionDerivedGamesRecord, GameDistributionForkSourceRecord, + GameDistributionGameRecord, GameDistributionGetGameRecordInput, + GameDistributionLineageNodeRecord, GameDistributionLineageTreeRecord, GameDistributionOwnerGameRecord, GameDistributionPublicGameListRecordInput, GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, GameDistributionRejectRecordInput, @@ -120,21 +122,24 @@ const RELEASE_STORAGE_BOOTSTRAP: &str = concat!( /// /// 单个资源取自整个 ZIP,若每个请求都重新下载整包会拖垮发行网关;缓存只保存已通过 /// 校验的私有包字节,键是对象键,超出条目或字节预算时按插入顺序淘汰。 -static RELEASE_PACKAGE_CACHE: OnceLock> = OnceLock::new(); +/// 值用 `Bytes` 而不是 `Vec`:整包下发(M2a 取件通道)要直接把缓存里的字节交给响应体, +/// `Bytes::clone` 只加引用计数,不会为了一个 200 MiB 的包再复制一份。 +static RELEASE_PACKAGE_CACHE: LazyLock> = + LazyLock::new(|| Mutex::new(ReleasePackageCache::default())); #[derive(Default)] struct ReleasePackageCache { - packages: HashMap>>, + packages: HashMap, order: VecDeque, total_bytes: usize, } impl ReleasePackageCache { - fn get(&self, object_key: &str) -> Option>> { + fn get(&self, object_key: &str) -> Option { self.packages.get(object_key).cloned() } - fn insert(&mut self, object_key: String, bytes: Arc>) { + fn insert(&mut self, object_key: String, bytes: Bytes) { self.insert_with_limits( object_key, bytes, @@ -146,7 +151,7 @@ impl ReleasePackageCache { fn insert_with_limits( &mut self, object_key: String, - bytes: Arc>, + bytes: Bytes, max_entries: usize, max_bytes: usize, ) { @@ -298,6 +303,22 @@ pub fn router(state: AppState) -> Router { get(list_user_reviews), ) .route_layer(middleware::from_fn(add_no_store_response_headers)); + // Fork 取件通道(M2a):要求 Bearer 登录,但**不叠加发布灰度**——灰度只针对「发布」, + // 任何登录用户都应该能改编已授权的作品。两个 handler 共用同一条校验,规则只写一遍。 + let fork_sources = Router::new() + .route( + "/api/game-distribution/games/{game_id}/fork-source", + get(get_fork_source), + ) + .route( + "/api/game-distribution/games/{game_id}/fork-source/package", + get(get_fork_source_package), + ) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .route_layer(middleware::from_fn(add_no_store_response_headers)); let protected = Router::new() .route( "/api/game-distribution/publish-metadata/suggestions", @@ -439,6 +460,7 @@ pub fn router(state: AppState) -> Router { .merge(public_games) .merge(protected) .merge(user_reviews) + .merge(fork_sources) .merge(admin_user_reviews) .merge(admin) } @@ -945,9 +967,9 @@ async fn release_package_bytes( state: &AppState, game_id: &str, version_id: &str, -) -> Result>, AppError> { +) -> Result { let object_key = format!("{GAME_DISTRIBUTION_OBJECT_PREFIX}{game_id}/{version_id}.zip"); - let cache = RELEASE_PACKAGE_CACHE.get_or_init(|| Mutex::new(ReleasePackageCache::default())); + let cache = &*RELEASE_PACKAGE_CACHE; if let Some(cached) = cache.lock().ok().and_then(|guard| guard.get(&object_key)) { return Ok(cached); } @@ -970,7 +992,8 @@ async fn release_package_bytes( map_oss_error(error, "aliyun-oss") } })?; - let bytes = Arc::new(bytes); + // `Bytes::from(Vec)` 直接接管所有权,不再复制整包;之后每次命中缓存只加引用计数。 + let bytes = Bytes::from(bytes); if let Ok(mut guard) = cache.lock() { guard.insert(object_key, bytes.clone()); } @@ -2479,6 +2502,179 @@ async fn set_fork_authorization( )) } +/// 取件校验通过后的目标:内容下发只需要版本身份与摘要。 +#[derive(Debug)] +struct ForkSourceTarget { + version_id: String, + package_sha256: String, + package_bytes: u64, +} + +/// 取件校验的纯映射:把「读到了什么」折成 HTTP 语义。 +/// +/// 顺序即合同顺序,也与 procedure 侧创建血缘时的判定顺序一致:行不存在 → 404;行存在但不可作 +/// 来源(已软删除 / 未公开 / 没有当前公开版本)→ 409;授权为禁止或**未知档位** → 403 +/// (未知按「禁止共创」解释,与 `resolve_game_distribution_fork_declaration_tx` 同口径)。 +/// 抽成纯函数是为了让这条映射可被单测钉住,而不是散落在两个 handler 里各写一遍。 +fn fork_source_target( + record: GameDistributionForkSourceRecord, +) -> Result { + if !record.found { + return Err( + AppError::from_status(StatusCode::NOT_FOUND).with_code("FORK_SOURCE_NOT_FOUND") + ); + } + if !record.available { + return Err( + AppError::from_status(StatusCode::CONFLICT).with_code("FORK_SOURCE_NOT_AVAILABLE") + ); + } + let authorization = + module_game_distribution::ForkAuthorization::parse(record.fork_authorization.as_str()) + .unwrap_or(module_game_distribution::ForkAuthorization::Forbidden); + 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 { + return Err( + AppError::from_status(StatusCode::CONFLICT).with_code("FORK_SOURCE_NOT_AVAILABLE") + ); + }; + Ok(ForkSourceTarget { + version_id, + package_sha256, + package_bytes, + }) +} + +/// 取件通道的公共校验:两个 handler 都走它,规则只写一遍。 +async fn resolve_fork_source( + state: &AppState, + game_id: &str, +) -> Result { + let record = state + .spacetime_client() + .get_game_distribution_fork_source(game_id.to_string()) + .await + .map_err(map_spacetime_error)?; + fork_source_target(record) +} + +/// 游戏标识必须能安全落在 URL 路径段里;发行入口与取件下载路径共用同一条判据。 +fn is_path_safe_game_id(game_id: &str) -> bool { + !game_id.is_empty() + && game_id.chars().all(|character| { + character.is_ascii_alphanumeric() || character == '-' || character == '_' + }) +} + +/// 取件下载路径:同源相对路径,**绝不下发 OSS 对象键**。 +fn build_fork_source_download_path(game_id: &str) -> Result { + if !is_path_safe_game_id(game_id) { + return Err(internal("游戏标识不适用于取件路径")); + } + Ok(format!( + "/api/game-distribution/games/{game_id}/fork-source/package" + )) +} + +/// 取件元数据响应:只含版本身份与摘要,对象键留在服务端。 +fn fork_source_payload( + game_id: &str, + target: &ForkSourceTarget, +) -> Result { + Ok(GameDistributionForkSourceResponse { + 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)?, + }, + }) +} + +/// Fork 取件元数据:告诉客户端「能改编哪一版、摘要多少、去哪儿取」。 +/// +/// 受鉴权(Bearer)但不叠加发布灰度:任何登录用户都应该能改编已授权的作品。 +async fn get_fork_source( + State(state): State, + Extension(ctx): Extension, + Path(game_id): Path, +) -> Result, AppError> { + let target = resolve_fork_source(&state, &game_id).await?; + info!( + request_id = ctx.request_id(), + operation = "game_fork_source_metadata", + game_id = %game_id, + version_id = %target.version_id, + bytes = target.package_bytes, + elapsed_ms = ctx.elapsed(), + "下发 Fork 取件元数据" + ); + Ok(json_success_body( + Some(&ctx), + fork_source_payload(&game_id, &target)?, + )) +} + +/// Fork 取件本体:直接回该版本发行包 ZIP 的字节。 +/// +/// 复用发行网关那条读包路径(`release_package_bytes`:整包读入内存 + 进程内缓存,上限 4 条 / +/// 256 MiB),不新造 OSS 客户端或第二条读包通道;缓存值是 `Bytes`,因此这里把整包交给响应体 +/// 只加一次引用计数,不复制。**不做**发行网关的引用归一化与 bootstrap 注入——那是给在线试玩 +/// 用的,取件下发的是原始构建产物,客户端要按摘要校验后离线解压,任何改写都会让摘要对不上。 +async fn get_fork_source_package( + State(state): State, + Path(game_id): Path, +) -> Result { + let target = resolve_fork_source(&state, &game_id).await?; + let package = release_package_bytes(&state, &game_id, &target.version_id).await?; + Ok(fork_source_package_response( + &game_id, + &target.version_id, + 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)); + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/zip"), + ); + headers.insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&content_length.to_string()) + .unwrap_or_else(|_| HeaderValue::from_static("0")), + ); + // 文件名只由路径段安全的两个 ID 拼成,不含用户输入的自由文本。 + headers.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&format!( + "attachment; filename=\"{game_id}-{version_id}.zip\"" + )) + .unwrap_or_else(|_| HeaderValue::from_static("attachment")), + ); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + ); + response +} + async fn admin_list_reviews( State(state): State, Extension(ctx): Extension, @@ -2783,11 +2979,7 @@ async fn derive_release_entry_url(state: &AppState, version_id: &str) -> Result< /// 发行入口固定走平台同源路径,游戏标识必须能安全落在路径段里。 fn build_release_entry_url(game_id: &str) -> Result { - if game_id.is_empty() - || !game_id.chars().all(|character| { - character.is_ascii_alphanumeric() || character == '-' || character == '_' - }) - { + if !is_path_safe_game_id(game_id) { return Err(internal("游戏标识不适用于发行路径")); } Ok(format!("/games/{game_id}/")) @@ -5033,6 +5225,193 @@ mod tests { ); } + /// 取件通道两个路由都必须在进入业务前要求登录态。 + #[tokio::test] + async fn fork_source_routes_require_bearer_before_any_read() { + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + let app = crate::app::build_router( + crate::state::AppState::new(crate::config::AppConfig::default()) + .expect("测试状态应可构建"), + ); + for uri in [ + "/api/game-distribution/games/game_1/fork-source", + "/api/game-distribution/games/game_1/fork-source/package", + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}"); + } + } + + fn fork_source_record( + found: bool, + available: bool, + fork_authorization: &str, + version: Option<(&str, &str, u64)>, + ) -> GameDistributionForkSourceRecord { + GameDistributionForkSourceRecord { + found, + available, + fork_authorization: fork_authorization.to_string(), + version_id: version.map(|(version_id, _, _)| version_id.to_string()), + package_sha256: version.map(|(_, sha256, _)| sha256.to_string()), + package_bytes: version.map(|(_, _, bytes)| bytes), + } + } + + /// 取件校验的 HTTP 映射:404 / 409 / 403 与通过逐个钉死。两个 handler 共用同一条, + /// 因此这一组断言同时覆盖元数据与内容本体两条路径。 + #[test] + fn fork_source_target_maps_contract_status_codes() { + // 行不存在 → 404;此时其余字段无意义,不得被误判成 409/403。 + let missing = fork_source_target(fork_source_record( + false, + false, + "forbidden", + None, + )) + .expect_err("行不存在必须失败"); + assert_eq!(missing.status_code(), StatusCode::NOT_FOUND); + assert_eq!(missing.code(), "FORK_SOURCE_NOT_FOUND"); + + // 行存在但不可用(已软删除 / 未公开 / 没有当前公开版本)→ 409,且**先于**授权判定: + // 未公开 + 允许共创仍然是 409,不能因为授权开放就暴露「这个 gameId 存在」。 + for label in ["已软删除", "未公开", "没有当前公开版本"] { + let unavailable = fork_source_target(fork_source_record( + true, + false, + "nonCommercial", + None, + )) + .expect_err("不可用作来源必须失败"); + assert_eq!(unavailable.status_code(), StatusCode::CONFLICT, "{label}"); + assert_eq!(unavailable.code(), "FORK_SOURCE_NOT_AVAILABLE", "{label}"); + } + + // 可用但授权为禁止 → 403。 + let forbidden = fork_source_target(fork_source_record( + true, + true, + "forbidden", + Some(("version_1", "sha", 8)), + )) + .expect_err("禁止共创必须失败"); + assert_eq!(forbidden.status_code(), StatusCode::FORBIDDEN); + assert_eq!(forbidden.code(), "FORK_NOT_AUTHORIZED"); + + // 未知档位按「禁止共创」解释,与 procedure 侧创建血缘同口径。 + let unknown = fork_source_target(fork_source_record( + true, + true, + "allowed", + Some(("version_1", "sha", 8)), + )) + .expect_err("未知档位必须失败"); + assert_eq!(unknown.status_code(), StatusCode::FORBIDDEN); + assert_eq!(unknown.code(), "FORK_NOT_AUTHORIZED"); + + // 通过:版本元数据按行原值带出。 + let target = fork_source_target(fork_source_record( + true, + true, + "nonCommercial", + Some(("version_1", "a".repeat(64).as_str(), 2048)), + )) + .expect("允许共创且已公开应通过"); + assert_eq!(target.version_id, "version_1"); + assert_eq!(target.package_sha256, "a".repeat(64)); + assert_eq!(target.package_bytes, 2048); + + // 失败关闭:可用却没有版本元数据时按不可用处理,不发半截信息。 + let incomplete = fork_source_target(fork_source_record(true, true, "full", None)) + .expect_err("缺版本元数据必须失败关闭"); + assert_eq!(incomplete.status_code(), StatusCode::CONFLICT); + assert_eq!(incomplete.code(), "FORK_SOURCE_NOT_AVAILABLE"); + } + + /// 元数据响应:摘要与字节数取自版本行,下载路径是同源相对路径,响应体不含对象键。 + #[test] + fn fork_source_payload_carries_row_digest_without_object_key() { + let target = fork_source_target(fork_source_record( + true, + true, + "nonCommercial", + Some(("version_9", "b".repeat(64).as_str(), 4096)), + )) + .expect("应通过"); + let payload = fork_source_payload("game_1", &target).expect("payload"); + assert_eq!(payload.fork_source.game_id, "game_1"); + assert_eq!(payload.fork_source.version_id, "version_9"); + assert_eq!(payload.fork_source.sha256, "b".repeat(64)); + assert_eq!(payload.fork_source.bytes, 4096); + assert_eq!( + payload.fork_source.source, + GameDistributionForkSourceKind::Package + ); + assert_eq!( + payload.fork_source.download_path, + "/api/game-distribution/games/game_1/fork-source/package" + ); + + // 不外泄对象键:序列化结果里不得出现对象键前缀或对象键字段名。 + let body = serde_json::to_string(&payload).expect("序列化"); + assert!(!body.contains("project-snapshots"), "{body}"); + assert!(!body.contains("objectKey"), "{body}"); + assert!(!body.contains(".zip"), "{body}"); + + // 路径段不安全的 gameId 宁可失败,也不拼进下载路径。 + assert!(build_fork_source_download_path("../escape").is_err()); + assert!(build_fork_source_download_path("").is_err()); + } + + /// 取件包响应头:ZIP + 长度 + 附件文件名 + no-store,长度与内容一致。 + #[tokio::test] + async fn fork_source_package_response_sets_zip_download_headers() { + let target = fork_source_target(fork_source_record( + true, + true, + "full", + Some(("version_3", "c".repeat(64).as_str(), 2048)), + )) + .expect("应通过"); + let response = fork_source_package_response( + "game_1", + &target.version_id, + Bytes::from(vec![7_u8; 2048]), + ); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + HeaderValue::from_static("application/zip") + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + HeaderValue::from_static("no-store") + ); + assert_eq!( + response.headers()[header::CONTENT_DISPOSITION], + HeaderValue::from_static("attachment; filename=\"game_1-version_3.zip\"") + ); + // Content-Length 与响应体实际字节一致;同一条 fixture 里它也等于版本行的 package_bytes。 + assert_eq!( + response.headers()[header::CONTENT_LENGTH], + HeaderValue::from_str(&target.package_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); + } + #[test] fn recovery_action_covers_every_version_status() { for (status, expected) in [ @@ -5304,23 +5683,23 @@ mod tests { fn release_package_cache_evicts_by_entry_and_byte_budget() { let mut cache = ReleasePackageCache::default(); for index in 0..RELEASE_PACKAGE_CACHE_MAX_ENTRIES { - cache.insert(format!("key-{index}"), Arc::new(vec![0_u8; 8])); + cache.insert(format!("key-{index}"), Bytes::from(vec![0_u8; 8])); } assert!(cache.get("key-0").is_some()); - cache.insert("overflow".to_string(), Arc::new(vec![0_u8; 8])); + cache.insert("overflow".to_string(), Bytes::from(vec![0_u8; 8])); assert!(cache.get("key-0").is_none(), "最旧条目应被淘汰"); assert!(cache.get("overflow").is_some()); let mut byte_budget = ReleasePackageCache::default(); - byte_budget.insert_with_limits("big".to_string(), Arc::new(vec![0_u8; 8]), 8, 16); - byte_budget.insert_with_limits("second".to_string(), Arc::new(vec![0_u8; 8]), 8, 16); - byte_budget.insert_with_limits("third".to_string(), Arc::new(vec![0_u8; 8]), 8, 16); + byte_budget.insert_with_limits("big".to_string(), Bytes::from(vec![0_u8; 8]), 8, 16); + byte_budget.insert_with_limits("second".to_string(), Bytes::from(vec![0_u8; 8]), 8, 16); + byte_budget.insert_with_limits("third".to_string(), Bytes::from(vec![0_u8; 8]), 8, 16); assert!(byte_budget.get("big").is_none(), "超出字节预算时应淘汰旧包"); assert_eq!(byte_budget.total_bytes, 16); let mut oversized = ReleasePackageCache::default(); - oversized.insert_with_limits("kept".to_string(), Arc::new(vec![0_u8; 4]), 8, 16); - oversized.insert_with_limits("huge".to_string(), Arc::new(vec![0_u8; 17]), 8, 16); + oversized.insert_with_limits("kept".to_string(), Bytes::from(vec![0_u8; 4]), 8, 16); + oversized.insert_with_limits("huge".to_string(), Bytes::from(vec![0_u8; 17]), 8, 16); assert!(oversized.get("huge").is_none(), "超预算包本身不得进入缓存"); assert!( oversized.get("kept").is_some(), diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 6421f9dec..ff2cf0a71 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -300,6 +300,41 @@ pub struct GameDistributionDerivedResponse { pub truncated: bool, } +/// Fork 取件内容的形态。M2a 只有已构建的发行成品包;M2b 引入工程源包后,同一版本同时存在 +/// 两者时优先 `project`。 +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum GameDistributionForkSourceKind { + /// 已构建的发行成品包(静态可玩产物)。 + Package, + /// 可编辑的工程源包(M2b 引入)。 + Project, +} + +/// Fork 取件元数据:客户端据此校验取到的内容并决定建项形态。 +/// +/// 只含版本身份与摘要,**不含 OSS 对象键**——下载走同源受鉴权路径 `download_path`, +/// 对象键只留在服务端。`sha256` / `bytes` 取自版本行已存的发行包摘要,不重新计算。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionForkSource { + pub game_id: String, + pub version_id: String, + pub source: GameDistributionForkSourceKind, + pub sha256: String, + pub bytes: u64, + /// 同源相对路径(如 `/api/game-distribution/games/game_1/fork-source/package`); + /// 客户端按当前 origin 解析后带 Bearer 请求,不接受绝对 URL。 + pub download_path: String, +} + +/// Fork 取件元数据接口(`GET /api/game-distribution/games/{gameId}/fork-source`)的响应体。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionForkSourceResponse { + pub fork_source: GameDistributionForkSource, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameDistributionListResponse { diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 00c143b74..e23a2ac27 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -97,14 +97,14 @@ pub use self::game_distribution::{ GameDistributionAdminGameRecord, GameDistributionAdminUserReviewDetailRecord, GameDistributionAdminUserReviewListRecord, GameDistributionAdminUserReviewRecord, GameDistributionAdminVersionRecord, GameDistributionDerivedGamesRecord, - GameDistributionGameRecord, GameDistributionLineageNodeRecord, - GameDistributionLineageRecord, GameDistributionLineageTreeRecord, - GameDistributionOwnerGameRecord, GameDistributionPublicGameRecord, - GameDistributionRatingSummaryRecord, GameDistributionReviewGameListRecord, - GameDistributionReviewGameRecord, GameDistributionReviewModerationOperationRecord, - GameDistributionReviewModerationRecord, GameDistributionUserReviewListRecord, - GameDistributionUserReviewRecord, GameDistributionUserReviewSaveRecord, - GameDistributionVersionRecord, + GameDistributionForkSourceRecord, GameDistributionGameRecord, + GameDistributionLineageNodeRecord, GameDistributionLineageRecord, + GameDistributionLineageTreeRecord, GameDistributionOwnerGameRecord, + GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, + GameDistributionReviewGameListRecord, GameDistributionReviewGameRecord, + GameDistributionReviewModerationOperationRecord, GameDistributionReviewModerationRecord, + GameDistributionUserReviewListRecord, GameDistributionUserReviewRecord, + GameDistributionUserReviewSaveRecord, GameDistributionVersionRecord, }; pub use self::llm_router_account::{LlmRouterAccountRecord, LlmRouterAccountUpsertRecordInput}; pub use self::payment::{ @@ -170,10 +170,11 @@ pub(crate) use self::external_generation::{ pub(crate) use self::game_distribution::{ ensure_admin_user_review_result, ensure_user_review_result, map_admin_user_review, map_game_distribution_admin_game_list_result, map_game_distribution_derived_games_result, - map_game_distribution_game_result, map_game_distribution_lineage_result, - map_game_distribution_owner_game_list_result, map_game_distribution_public_game_list_result, - map_game_distribution_public_game_result, map_game_distribution_review_list_result, - map_game_distribution_version_result, map_review_moderation_operation, map_user_review, + map_game_distribution_fork_source_result, map_game_distribution_game_result, + map_game_distribution_lineage_result, map_game_distribution_owner_game_list_result, + map_game_distribution_public_game_list_result, map_game_distribution_public_game_result, + map_game_distribution_review_list_result, map_game_distribution_version_result, + map_review_moderation_operation, map_user_review, }; pub(crate) use self::payment::{ map_payment_api_key_list_result, map_payment_api_key_result, map_payment_app_list_result, 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 936d139b7..0d1cbf3c6 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 @@ -312,6 +312,21 @@ pub struct GameDistributionDerivedGamesRecord { pub truncated: bool, } +/// Fork 取件信息(受鉴权内容下发通道的元数据)。 +/// +/// `found == false` 表示游戏行不存在(api-server → 404);`available == false` 表示行存在但 +/// 不可作为改编来源(已软删除 / 未公开 / 没有当前公开版本 → 409)。**不含对象键**: +/// 只带版本身份与摘要,下载路径由 api-server 拼同源相对路径。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionForkSourceRecord { + pub found: bool, + pub available: bool, + pub fork_authorization: String, + pub version_id: Option, + pub package_sha256: Option, + pub package_bytes: Option, +} + fn map_game( value: crate::module_bindings::GameDistributionGameSnapshot, ) -> GameDistributionGameRecord { @@ -478,6 +493,25 @@ pub(crate) fn map_game_distribution_derived_games_result( })) } +/// Fork 取件信息:`ok == false` 是服务端失败(走既有错误映射);`found` / `available` 原样透传, +/// HTTP 语义(404 / 409 / 403)由 api-server 决定。 +pub(crate) fn map_game_distribution_fork_source_result( + result: crate::module_bindings::GameDistributionForkSourceResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(GameDistributionForkSourceRecord { + found: result.found, + available: result.available, + fork_authorization: result.fork_authorization, + version_id: result.version_id, + // SpacetimeDB 生成绑定把 `package_sha256` 折成 `package_sha_256`(与版本快照同约定)。 + package_sha256: result.package_sha_256, + package_bytes: result.package_bytes, + }) +} + fn map_public_game( value: crate::module_bindings::GameDistributionPublicGameSnapshot, ) -> GameDistributionPublicGameRecord { diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 9dc016a16..97d1abe75 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -673,6 +673,30 @@ impl SpacetimeClient { .await } + /// 读取某作品的 Fork 取件信息(受鉴权内容下发通道的元数据)。 + /// + /// `found` / `available` 原样回传给 api-server,由它映射 404 / 409 / 403;这里不做 HTTP 语义。 + pub async fn get_game_distribution_fork_source( + &self, + game_id: String, + ) -> Result { + let input = crate::module_bindings::GameDistributionForkSourceInput { game_id }; + self.call_after_connect( + "get_game_distribution_fork_source", + move |connection, sender| { + connection + .procedures() + .get_game_distribution_fork_source_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_fork_source_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + /// 批量累加已公开游戏的游玩次数;非公开游戏由事务静默跳过。 pub async fn increment_game_distribution_game_play_counts( &self, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 17f17c092..455c70850 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -424,6 +424,8 @@ pub mod game_distribution_delete_game_input_type; pub mod game_distribution_derived_games_input_type; pub mod game_distribution_derived_games_result_type; pub mod game_distribution_fail_upload_input_type; +pub mod game_distribution_fork_source_input_type; +pub mod game_distribution_fork_source_result_type; pub mod game_distribution_game_list_procedure_result_type; pub mod game_distribution_game_procedure_result_type; pub mod game_distribution_game_snapshot_type; @@ -493,6 +495,7 @@ pub mod get_external_generation_job_result_and_return_procedure; pub mod get_external_generation_job_summary_and_return_procedure; pub mod get_external_generation_queue_stats_and_return_procedure; pub mod get_feature_gate_config_procedure; +pub mod get_game_distribution_fork_source_and_return_procedure; pub mod get_game_distribution_game_and_return_procedure; pub mod get_game_distribution_lineage_and_return_procedure; pub mod get_game_distribution_my_review_and_return_procedure; @@ -1297,6 +1300,8 @@ pub use game_distribution_delete_game_input_type::GameDistributionDeleteGameInpu pub use game_distribution_derived_games_input_type::GameDistributionDerivedGamesInput; pub use game_distribution_derived_games_result_type::GameDistributionDerivedGamesResult; pub use game_distribution_fail_upload_input_type::GameDistributionFailUploadInput; +pub use game_distribution_fork_source_input_type::GameDistributionForkSourceInput; +pub use game_distribution_fork_source_result_type::GameDistributionForkSourceResult; pub use game_distribution_game_list_procedure_result_type::GameDistributionGameListProcedureResult; pub use game_distribution_game_procedure_result_type::GameDistributionGameProcedureResult; pub use game_distribution_game_snapshot_type::GameDistributionGameSnapshot; @@ -1366,6 +1371,7 @@ pub use get_external_generation_job_result_and_return_procedure::get_external_ge pub use get_external_generation_job_summary_and_return_procedure::get_external_generation_job_summary_and_return; pub use get_external_generation_queue_stats_and_return_procedure::get_external_generation_queue_stats_and_return; pub use get_feature_gate_config_procedure::get_feature_gate_config; +pub use get_game_distribution_fork_source_and_return_procedure::get_game_distribution_fork_source_and_return; pub use get_game_distribution_game_and_return_procedure::get_game_distribution_game_and_return; pub use get_game_distribution_lineage_and_return_procedure::get_game_distribution_lineage_and_return; pub use get_game_distribution_my_review_and_return_procedure::get_game_distribution_my_review_and_return; diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index e11488b09..949bd5735 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1188,6 +1188,12 @@ pub struct GameDistributionDerivedGamesInput { pub game_id: String, } +/// 读取某作品的 Fork 取件信息(受鉴权内容下发通道的元数据);只按作品 ID 查询。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionForkSourceInput { + pub game_id: String, +} + /// 单次游玩计数增量;api-server 已按 `game_id` 聚合,同一输入内不重复。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionPlayCountIncrement { @@ -1384,6 +1390,61 @@ pub struct GameDistributionDerivedGamesResult { pub error_message: Option, } +/// Fork 取件的可判定状态。 +/// +/// 单独成类型而不是让 api-server 自己读游戏行:`get_game_distribution_game_for_owner_or_public_tx` +/// 那条读路径把「已软删除」与「未公开」都折叠成 `None`,HTTP 层就没法区分 404 与 409。 +/// 这里把「行是否存在」「能否作为改编来源」「授权档位」三件事分开回传,映射留给 api-server。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionForkSourceSnapshot { + /// 游戏行是否存在(含已软删除的行;`false` 时其余字段无意义)。 + pub found: bool, + /// 是否可作为改编来源:`can_serve_as_fork_source`(未软删除 + 已公开)且存在当前公开版本。 + pub available: bool, + /// 作品级共创授权档位原值;api-server 按「未知按禁止」解释。 + pub fork_authorization: String, + /// 当前公开版本;不可用或没有公开版本时为 None。 + pub version_id: Option, + /// 当前公开版本的发行包摘要;取自版本行,不重新计算。 + pub package_sha256: Option, + pub package_bytes: Option, +} + +/// Fork 取件读取结果。 +/// +/// 扁平列出全部字段而不是用 `Option`:`ok == true` 却没有任何判定结果是不可能状态, +/// 扁平结构让它无法表达。`found == false`(行不存在 → api-server 404)与 +/// `available == false`(行存在但不可作为来源 → 409)由 api-server 分别映射。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionForkSourceResult { + pub ok: bool, + pub found: bool, + pub available: bool, + /// 作品级共创授权档位原值;api-server 按「未知按禁止」解释后映射 403。 + pub fork_authorization: String, + /// 当前公开版本;不可用或没有公开版本时为 None。 + pub version_id: Option, + pub package_sha256: Option, + pub package_bytes: Option, + pub error_message: Option, +} + +impl GameDistributionForkSourceResult { + /// 参数非法等服务端失败:`ok = false`,api-server 走既有错误映射。 + fn failed(error: String) -> Self { + Self { + ok: false, + found: false, + available: false, + fork_authorization: String::new(), + version_id: None, + package_sha256: None, + package_bytes: None, + error_message: Some(error), + } + } +} + impl GameDistributionLineageResult { /// 目标作品不存在或已软删除:`ok` 仍为 true,由 api-server 映射成 404。 fn not_found() -> Self { @@ -2005,6 +2066,35 @@ pub fn list_game_distribution_derived_games_and_return( } } +/// 读取某作品的 Fork 取件信息;受信服务身份只读,api-server 侧另外要求 Bearer 登录。 +/// +/// 行不存在时 `found = false`(api-server → 404);行存在但不可作为来源(已软删除 / 未公开 / +/// 没有当前公开版本)时 `found = true, available = false`(→ 409);授权为禁止或未知由 +/// api-server 按 `fork_authorization` 判成 403。响应只含版本身份与摘要,不含对象键。 +#[spacetimedb::procedure] +pub fn get_game_distribution_fork_source_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionForkSourceInput, +) -> GameDistributionForkSourceResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + get_game_distribution_fork_source_tx(tx, input.clone()) + }) { + Ok(source) => GameDistributionForkSourceResult { + ok: true, + found: source.found, + available: source.available, + fork_authorization: source.fork_authorization, + version_id: source.version_id, + package_sha256: source.package_sha256, + package_bytes: source.package_bytes, + error_message: None, + }, + Err(error) => GameDistributionForkSourceResult::failed(error), + } +} + /// 管理员读取任意版本状态;admin 权限由 api-server 在调用前校验,procedure 只接受受信服务身份。 #[spacetimedb::procedure] pub fn get_game_distribution_version_and_return( @@ -4099,6 +4189,48 @@ fn game_distribution_derived_games_tx( })) } +/// 读取某作品的 Fork 取件信息(受鉴权内容下发通道的元数据)。 +/// +/// 与族谱 / 衍生列表不同,这里**必须**区分「行不存在」与「行存在但不可用」:HTTP 合同要求 +/// 前者 404、后者 409,所以不复用「折叠成 `None`」的写法,而是把三件事分开回传——行是否存在、 +/// 能否作为改编来源(`can_serve_as_fork_source` **且**存在当前公开版本)、授权档位原值。 +/// 授权是否生效、以及「未知档位按禁止解释」留给 api-server,与 procedure 侧创建血缘时同一口径。 +/// 只回传版本身份与摘要,不回传对象键。 +fn get_game_distribution_fork_source_tx( + ctx: &ReducerContext, + input: GameDistributionForkSourceInput, +) -> Result { + let game_id = required_game_distribution_text(input.game_id, "game_id")?; + let Some(game) = ctx.db.game_distribution_game().game_id().find(&game_id) else { + return Ok(GameDistributionForkSourceSnapshot { + found: false, + available: false, + fork_authorization: String::new(), + version_id: None, + package_sha256: None, + package_bytes: None, + }); + }; + // 取件必须有「当前公开版本」,否则没有可下发的内容。 + let version = public_game_distribution_version(ctx, &game); + let available = module_game_distribution::can_serve_as_fork_source( + game.deleted_at.is_some(), + game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, + ) && version.is_some(); + Ok(GameDistributionForkSourceSnapshot { + found: true, + available, + fork_authorization: game.fork_authorization.clone(), + version_id: version + .as_ref() + .map(|version| version.version_id.clone()), + package_sha256: version + .as_ref() + .map(|version| version.package_sha256.clone()), + package_bytes: version.map(|version| version.package_bytes), + }) +} + /// 折叠族谱树的输入行:根作品自身 + 所有以该根为祖先的血缘行。 /// /// 只读行事实,不做任何可见性判断——「哪些节点能出现、身份字段要不要降级」全部由