From 54094b5c156bc60bb410f48d01990554a014675d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 8 Oct 2026 00:29:42 +0800 Subject: [PATCH] =?UTF-8?q?refactor(game-distribution):=20=E5=85=B1?= =?UTF-8?q?=E5=88=9B=E6=8E=88=E6=9D=83=E5=B9=B6=E5=85=A5=E8=B5=84=E6=96=99?= =?UTF-8?q?=20PATCH=EF=BC=8C=E5=85=AC=E5=BC=80=E8=AF=BB=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20forks/network?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 PUT /games/{game_id}/fork-authorization 与 GameDistributionSetForkAuthorizationRequest,档位并入 PATCH /my-games/{game_id}(forkAuthorization 可选,缺省不动档位) - 资料 PATCH 在 metadata 事务成功后串行调用提升事务(派生幂等键 {key}:fork,自有收据可重放),只升不降与衍生终态仍由同一领域纯函数裁决 - web 客户端 updateGameForkAuthorization 改为用当前投影资料组装资料 PATCH,去掉 expectedForkAuthorization 入参 - 公开读路由改名:/lineage→/network、/derived→/forks、/contribution→/network/stats、/fork-source→/source(含 /package /project),同步取件 downloadUrl 与 AGC 取件 URL - 同步 packages/shared 契约、DTO parity 注册表、生成绑定注释与测试 --- .../src/game_distribution_publish.rs | 14 +- .../shared/src/contracts/gameDistribution.ts | 14 +- .../src/contracts/generated/GameMetadata.ts | 3 +- .../check-game-distribution-dto-parity.mjs | 4 - .../src/modules/game_distribution.rs | 267 +++++------------- .../shared-contracts/src/game_distribution.rs | 45 ++- .../game-distribution/MyGamesPage.test.tsx | 9 +- .../game-distribution/MyGamesPage.tsx | 6 +- src/services/gameDistributionClient.test.ts | 67 +++-- src/services/gameDistributionClient.ts | 52 ++-- 10 files changed, 182 insertions(+), 299 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index 7373976c5..c21ef812e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -663,7 +663,7 @@ fn fork_source_download_segments(download_path: &str) -> Result, Str Ok(segments) } -/// 取件元数据(`GET …/fork-source`):受鉴权但不叠加发布灰度。 +/// 取件元数据(`GET …/source`):受鉴权但不叠加发布灰度。 async fn request_fork_source_metadata( client: &reqwest::Client, snapshot: &PlatformSessionSnapshot, @@ -672,7 +672,7 @@ async fn request_fork_source_metadata( let current = current_scoped_session(snapshot)?; let url = endpoint( snapshot, - &["api", "game-distribution", "games", game_id, "fork-source"], + &["api", "game-distribution", "games", game_id, "source"], )?; let response = client .request(Method::GET, &url) @@ -2458,22 +2458,20 @@ mod tests { #[test] fn fork_source_download_path_accepts_only_same_origin_relative_paths() { assert_eq!( - fork_source_download_segments( - "/api/game-distribution/games/game_1/fork-source/package" - ) - .expect("relative path"), + fork_source_download_segments("/api/game-distribution/games/game_1/source/package") + .expect("relative path"), vec![ "api", "game-distribution", "games", "game_1", - "fork-source", + "source", "package" ] ); for unsafe_path in [ "", - "api/game-distribution/games/game_1/fork-source/package", + "api/game-distribution/games/game_1/source/package", "//evil.example.com/package", "https://evil.example.com/package", "/api/../package", diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index ff5336e5a..6ae151484 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -665,6 +665,7 @@ export type GameDistributionAdminThemeMemberListResponse = { * * 只覆盖游戏行上的展示字段;随版本冻结的包摘要与资料快照不受影响。 * `expectedPublicationRevision` 是公开切换 CAS,并发变化返回 409。 + * 共创授权档位并入同一 PATCH:`forkAuthorization` 缺省表示这次不动档位。 */ export type GameDistributionUpdateGameMetadataRequest = { expectedPublicationRevision: number; @@ -680,6 +681,8 @@ export type GameDistributionUpdateGameMetadataRequest = { deviceSupport: GameDistributionDeviceSupport; inputModes: GameDistributionInputMode[]; orientation: GameDistributionOrientation; + /** 作品级共创授权档位;`null` / 缺省表示不动,母版只升不降、衍生作品终态拒绝。 */ + forkAuthorization?: GameDistributionForkAuthorization | null; }; export type GameDistributionPrivateVersion = { @@ -753,17 +756,6 @@ export type GameDistributionCancelVersionResponse = { replayed: boolean; }; -/** - * 修改共创授权;`expectedForkAuthorization` 是 CAS 期望值。 - * - * **只有母版**能提升(只升不降)。衍生作品(存在血缘行)的档位由创建时继承父作品决定、是终态, - * 任何请求都返回 409 `FORK_AUTHORIZATION_INHERITED`——包括传同值的幂等重试。 - */ -export type GameDistributionSetForkAuthorizationRequest = { - expectedForkAuthorization: GameDistributionForkAuthorization; - forkAuthorization: GameDistributionForkAuthorization; -}; - /** 创建游戏时的改编来源声明:父作品 + 建立血缘时锁定的父版本。 */ export type GameDistributionForkMetadata = { parentGameId: string; diff --git a/packages/shared/src/contracts/generated/GameMetadata.ts b/packages/shared/src/contracts/generated/GameMetadata.ts index c22da06ba..bb22113ba 100644 --- a/packages/shared/src/contracts/generated/GameMetadata.ts +++ b/packages/shared/src/contracts/generated/GameMetadata.ts @@ -22,7 +22,8 @@ screenshots: Array, deviceSupport: { desktop: boolean, mobile: bo * * **只对母版(0 代作品)生效**:带 `fork` 声明时,新作品的档位在创建时**继承父作品当时的 * 档位**,本字段一律被忽略且不报错(旧客户端会惯常带默认值),服务端也不接受「收窄」。 - * 继承来的档位是**终态**:衍生作品之后再调 `PUT …/fork-authorization` 一律被拒(409 + * 继承来的档位是**终态**:衍生作品之后再改档位(并入 `PATCH + * /api/game-distribution/my-games/{game_id}` 的资料 PATCH)一律被拒(409 * `FORK_AUTHORIZATION_INHERITED`),母版才走「只升不降」。父作品为 `forbidden` 时根本建立 * 不了血缘,所以衍生作品不会继承到 `forbidden`。 * 字段形状刻意保持不变(非 `Option` + `#[serde(default)]`):改了会让幂等摘要漂移。 diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 67791e0ff..f4f3fd226 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -82,10 +82,6 @@ const PAIRS = [ ['GameDistributionSource', 'GameDistributionSource'], ['GameDistributionSourceResponse', 'GameDistributionSourceResponse'], ['GameDistributionForkMetadata', 'GameDistributionForkMetadata'], - [ - 'GameDistributionSetForkAuthorizationRequest', - 'GameDistributionSetForkAuthorizationRequest', - ], // 收藏(收录):PUT / DELETE 共用同一个权威投影值形状(`replayed` 只有 PUT 会发); // 列表响应用独立类型登记,避免与公开目录的分页口径混成一个契约。 ['GameDistributionCollectionState', 'GameDistributionCollectionState'], 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 97cc68fd1..4b64452ba 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -59,8 +59,7 @@ use shared_contracts::game_distribution::{ GameDistributionOrientation, GameDistributionPlaySessionResponse, GameDistributionPurchase, GameDistributionPurchaseRequest, GameDistributionPurchaseResponse, GameDistributionRatingSummary, GameDistributionReview, GameDistributionReviewsResponse, - GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse, - GameDistributionSetForkAuthorizationRequest, GameDistributionSource, + GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse, GameDistributionSource, GameDistributionSourceKind, GameDistributionSourceResponse, GameDistributionThemeStatus, GameDistributionUpdateGameMetadataRequest, GameDistributionUpdateThemeRequest, GameDistributionUpsertThemeMemberRequest, GameDistributionVisibility, GameMetadata, @@ -584,15 +583,15 @@ pub fn router(state: AppState) -> Router { // `/project` 失败关闭:只有选定资产确为工程源包时才服务,绝不悄悄回落成品包。 let fork_sources = Router::new() .route( - "/api/game-distribution/games/{game_id}/fork-source", + "/api/game-distribution/games/{game_id}/source", get(get_source), ) .route( - "/api/game-distribution/games/{game_id}/fork-source/package", + "/api/game-distribution/games/{game_id}/source/package", get(get_source_package), ) .route( - "/api/game-distribution/games/{game_id}/fork-source/project", + "/api/game-distribution/games/{game_id}/source/project", get(get_source_project), ) .route_layer(middleware::from_fn_with_state( @@ -605,7 +604,7 @@ pub fn router(state: AppState) -> Router { // 整组 `no-store`;归属判定在事务里(非作者 → 403 / 不存在 → 404,见 handler 注释)。 let contribution = Router::new() .route( - "/api/game-distribution/games/{game_id}/contribution", + "/api/game-distribution/games/{game_id}/network/stats", get(get_game_network_stats), ) .route_layer(middleware::from_fn_with_state( @@ -714,10 +713,6 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/games/{game_id}/unpublish", post(unpublish_game), ) - .route( - "/api/game-distribution/games/{game_id}/fork-authorization", - put(set_fork_authorization), - ) .route( "/api/game-distribution/games/{game_id}/purchase", post(purchase_game), @@ -786,11 +781,11 @@ pub fn router(state: AppState) -> Router { .route("/api/game-distribution/games", get(list_games)) .route("/api/game-distribution/games/{game_id}", get(get_game)) .route( - "/api/game-distribution/games/{game_id}/lineage", + "/api/game-distribution/games/{game_id}/network", get(get_game_network), ) .route( - "/api/game-distribution/games/{game_id}/derived", + "/api/game-distribution/games/{game_id}/forks", get(get_game_forks), ) .route( @@ -3033,9 +3028,11 @@ fn game_metadata_update_as_create_request( device_support: payload.device_support.clone(), input_modes: payload.input_modes.clone(), orientation: payload.orientation, - // 这两个字段只服务创建语义:资料编辑既不建立血缘也不改授权档位(授权只能靠 - // `set_fork_authorization` 单向提升),所以复用创建校验时固定取默认值。 - fork_authorization: GameDistributionForkAuthorization::Forbidden, + // 血缘只服务创建语义(资料编辑不建立血缘);授权档位并入资料 PATCH 后按请求值透传, + // 这里复用创建校验时只关心「值是否合法」,档位方向由提升事务单独裁决。 + fork_authorization: payload + .fork_authorization + .unwrap_or(GameDistributionForkAuthorization::Forbidden), fork: None, } } @@ -3108,6 +3105,8 @@ async fn update_owner_game_metadata( // 资料校验与媒体归属解析复用创建游戏同一套:编辑不能绕过"必须有封面/沿用图必须属于本作品"。 let create_metadata = game_metadata_update_as_create_request(&payload); validate_game_metadata(&create_metadata)?; + // 授权档位并入资料 PATCH:档位本身仍由独立提升事务裁决,这里先把请求值留在身边。 + let requested_fork_authorization = payload.fork_authorization; let current = state .spacetime_client() .get_game_distribution_game(GameDistributionGetGameRecordInput { @@ -3151,6 +3150,11 @@ async fn update_owner_game_metadata( ); let log_owner_user_id = owner_user_id.clone(); let log_title = payload.title.clone(); + // 提升事务需要独立的身份 / 期望值 / 幂等键:metadata 事务会把三者吃掉。 + let fork_game_id = game_id.clone(); + let fork_owner_user_id = owner_user_id.clone(); + let fork_expected = current.fork_authorization.clone(); + let fork_idempotency_key = format!("{idempotency_key}:fork"); let game = cleanup_uncommitted_publish_media( &state, &uploaded_media, @@ -3184,21 +3188,49 @@ async fn update_owner_game_metadata( .await, ) .await?; + let (mut game_record, replayed) = game; + // 共创授权并入资料 PATCH:档位走独立的提升事务(只升不降 + 衍生终态 + 自有幂等收据), + // 派生键与主键同寿命,重试命中同一收据;metadata 事务返回的快照不含新档位,这里替换。 + if let Some(target) = requested_fork_authorization { + let target_value = fork_authorization_value(target); + if target_value != fork_expected { + let fork_digest = compute_request_digest( + &serde_json::to_vec(&(fork_game_id.as_str(), &fork_expected, &target_value)) + .map_err(|error| internal(error.to_string()))?, + ); + game_record = state + .spacetime_client() + .set_game_distribution_fork_authorization( + GameDistributionSetForkAuthorizationRecordInput { + game_id: fork_game_id, + owner_user_id: fork_owner_user_id, + fork_authorization: target_value, + expected_fork_authorization: fork_expected, + idempotency_key: fork_idempotency_key, + request_digest: fork_digest, + now_micros: now_micros(), + }, + ) + .await + .map_err(map_spacetime_error)? + .0; + } + } record_tracking_event_after_success(&state, &ctx, audit).await; info!( request_id = ctx.request_id(), operation = "game_metadata_updated", - game_id = %game.0.game_id, + game_id = %game_record.game_id, owner_user_id = %log_owner_user_id, title = %log_title, - publication_revision = game.0.publication_revision, - replayed = game.1, + publication_revision = game_record.publication_revision, + replayed = replayed, elapsed_ms = ctx.elapsed(), "作者更新游戏展示资料" ); Ok(json_success_body( Some(&ctx), - json!({ "game": game_payload(&game.0), "replayed": game.1 }), + json!({ "game": game_payload(&game_record), "replayed": replayed }), )) } @@ -4693,58 +4725,6 @@ async fn unpublish_game( )) } -/// 作者修改作品的共创授权档位。**只有母版**可提升:只升不降,降级与未知档位由领域层拒绝; -/// 衍生作品(存在血缘行)的档位由创建时继承父作品决定,是终态,一律 409 -/// `FORK_AUTHORIZATION_INHERITED`(含传同值的幂等重试)。 -async fn set_fork_authorization( - State(state): State, - Extension(ctx): Extension, - Extension(auth): Extension, - headers: HeaderMap, - Path(game_id): Path, - payload: Result, JsonRejection>, -) -> Result, AppError> { - // 未知档位(例如 `"allowed"`)在进入业务前就被 serde 拦下:这里必须把它映射成平台信封的 - // 400,而不是让 axum 的默认 `JsonRejection` 直接回 422 纯文本——合同要求未知档位是 400, - // 且前端错误处理依赖信封(同文件的评价保存、评价管理两处同写法)。 - // 领域层的 `FORK_AUTHORIZATION_UNKNOWN`(map_spacetime_error 里映射 400)仍然可达: - // procedure 路径读到库里存的未知档位字符串时依旧由它兜底。 - let Json(payload) = payload.map_err(|_| { - AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("共创授权档位不合法,只接受 forbidden / nonCommercial / full") - })?; - 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 request_digest = compute_request_digest( - &serde_json::to_vec(&( - game_id.as_str(), - payload.expected_fork_authorization, - payload.fork_authorization, - )) - .map_err(|error| internal(error.to_string()))?, - ); - let game = state - .spacetime_client() - .set_game_distribution_fork_authorization(GameDistributionSetForkAuthorizationRecordInput { - game_id, - owner_user_id, - fork_authorization: fork_authorization_value(payload.fork_authorization), - expected_fork_authorization: fork_authorization_value( - payload.expected_fork_authorization, - ), - idempotency_key, - request_digest, - now_micros: now_micros(), - }) - .await - .map_err(map_spacetime_error)?; - Ok(json_success_body( - Some(&ctx), - json!({ "game": game_payload(&game.0), "replayed": game.1 }), - )) -} - /// 取件校验通过后的目标:内容下发只需要**选定资产**的版本身份与摘要。 #[derive(Debug, PartialEq, Eq)] struct ForkSourceTarget { @@ -4845,7 +4825,7 @@ fn build_fork_source_download_path( GameDistributionSourceKind::Package => "package", }; Ok(format!( - "/api/game-distribution/games/{game_id}/fork-source/{asset}" + "/api/game-distribution/games/{game_id}/source/{asset}" )) } @@ -7735,129 +7715,14 @@ mod tests { .await .expect("路由响应"); assert_eq!(unauthenticated_delete.status(), StatusCode::UNAUTHORIZED); - - // 共创授权提升属于作者写入:未带 Bearer 必须在进入业务前被拒,且不能是 404/405, - // 否则说明路由没有挂进受保护区、被别的路径掩盖了。 - let unauthenticated_fork = app - .oneshot( - Request::builder() - .method("PUT") - .uri("/api/game-distribution/games/game_1/fork-authorization") - .header("content-type", "application/json") - .body(Body::from( - r#"{"expectedForkAuthorization":"forbidden","forkAuthorization":"nonCommercial"}"#, - )) - .expect("请求"), - ) - .await - .expect("路由响应"); - assert_eq!(unauthenticated_fork.status(), StatusCode::UNAUTHORIZED); } - /// 未知共创档位必须在 HTTP 面回**平台信封的 400**,而不是让 serde 的拒绝直接变成 axum 默认的 - /// 422 纯文本(合同要求 400,且前端错误处理依赖信封)。 - /// - /// 关键点:反序列化失败发生在进入业务之前,所以两处档位字段(目标档位、期望档位)都要覆盖; - /// 这里用真实 handler + 注入的登录身份,断言不会触达数据库(被拒绝的请求在解析后就返回)。 - #[tokio::test] - async fn set_fork_authorization_maps_unknown_authorization_to_envelope_bad_request() { - use axum::http::Request; - use platform_auth::{ - AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, - }; - use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER; - use tower::ServiceExt; - - let state = AppState::new(crate::config::AppConfig::default()).unwrap(); - let claims = AccessTokenClaims::from_input( - AccessTokenClaimsInput { - user_id: "fork-author".into(), - session_id: "fork-session".into(), - provider: AuthProvider::Password, - roles: vec!["user".into()], - token_version: 1, - phone_verified: false, - binding_status: BindingStatus::Active, - display_name: None, - }, - state.auth_jwt_config(), - time::OffsetDateTime::now_utc(), - ) - .unwrap(); - let app = Router::new() - .route( - "/api/game-distribution/games/{game_id}/fork-authorization", - put(set_fork_authorization), - ) - .layer(Extension(AuthenticatedAccessToken::new(claims))) - // 用生产同一套 request context 中间件:错误 envelope 的 `ok` / `meta` 由它挂上的 - // task-local 决定(直接手塞 Extension 只能拿到 legacy 形状,断言不到 envelope)。 - .layer(middleware::from_fn( - crate::request_context::attach_request_context, - )) - .with_state(state); - - for (payload, label) in [ - ( - r#"{"expectedForkAuthorization":"forbidden","forkAuthorization":"allowed"}"#, - "目标档位未知", - ), - ( - r#"{"expectedForkAuthorization":"allowed","forkAuthorization":"full"}"#, - "期望档位未知", - ), - ] { - let response = app - .clone() - .oneshot( - Request::builder() - .method("PUT") - .uri("/api/game-distribution/games/game_1/fork-authorization") - .header("content-type", "application/json") - .header("idempotency-key", "fork-authorization-key-1") - // 客户端要 envelope 时错误体才按 ApiErrorEnvelope 输出。 - .header(API_RESPONSE_ENVELOPE_HEADER, "v1") - .body(Body::from(payload)) - .expect("请求"), - ) - .await - .expect("路由响应"); - assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{label}"); - let body = axum::body::to_bytes(response.into_body(), 32_768) - .await - .unwrap(); - let text = String::from_utf8(body.to_vec()).expect("响应必须是 UTF-8"); - assert!( - !text.contains("Failed to deserialize"), - "{label} 不得返回框架的纯文本拒绝:{text}" - ); - let envelope: Value = serde_json::from_str(&text).expect("必须是平台信封 JSON"); - assert_eq!(envelope["ok"], Value::Bool(false), "{label}"); - assert_eq!(envelope["data"], Value::Null, "{label}"); - assert_eq!( - envelope["error"]["code"], - Value::String("BAD_REQUEST".into()), - "{label}" - ); - assert!( - envelope["error"]["message"] - .as_str() - .is_some_and(|message| message.contains("共创授权档位不合法")), - "{label} 的信封 message 应说明档位不合法:{text}" - ); - assert!( - envelope["meta"]["apiVersion"].is_string(), - "{label} 的信封必须带 meta.apiVersion:{text}" - ); - } - } - - /// 族谱与衍生列表是公开只读路由:必须挂载、匿名可读、不缓存。 + /// 创作网络(族谱)与改编列表是公开只读路由:必须挂载、匿名可读、不缓存。 /// /// 测试态没有可用数据库,所以证明点是「已挂载并走到 SpacetimeDB」(502), /// 而不是 404 / 401 / 405;同时路由层必须带 `no-store`。 #[tokio::test] - async fn lineage_and_derived_routes_are_public_and_no_store() { + async fn network_and_forks_routes_are_public_and_no_store() { use axum::{body::Body, http::Request}; use tower::ServiceExt; @@ -7867,8 +7732,8 @@ mod tests { ); for uri in [ - "/api/game-distribution/games/game_1/lineage", - "/api/game-distribution/games/game_1/derived", + "/api/game-distribution/games/game_1/network", + "/api/game-distribution/games/game_1/forks", ] { let response = app .clone() @@ -8023,8 +7888,8 @@ mod tests { .expect("测试状态应可构建"), ); for uri in [ - "/api/game-distribution/games/game_1/fork-source", - "/api/game-distribution/games/game_1/fork-source/package", + "/api/game-distribution/games/game_1/source", + "/api/game-distribution/games/game_1/source/package", ] { let response = app .clone() @@ -8154,7 +8019,7 @@ mod tests { ); assert_eq!( payload.fork_source.download_path, - "/api/game-distribution/games/game_1/fork-source/package" + "/api/game-distribution/games/game_1/source/package" ); // 不外泄对象键:序列化结果里不得出现对象键前缀或对象键字段名。 @@ -8247,7 +8112,7 @@ mod tests { ), ( Method::GET, - "/api/game-distribution/games/game_1/fork-source/project", + "/api/game-distribution/games/game_1/source/project", ), ] { let response = app @@ -8363,7 +8228,7 @@ mod tests { 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" + "/api/game-distribution/games/game_1/source/project" ); // ② 无工程包 → Package,下载路径走 /package。 @@ -8378,7 +8243,7 @@ mod tests { .expect("payload") .fork_source .download_path, - "/api/game-distribution/games/game_1/fork-source/package" + "/api/game-distribution/games/game_1/source/package" ); // ③ 半写行:字节数 > 0 但摘要为空 → 按没有工程包处理,回落 Package。 @@ -9458,6 +9323,7 @@ mod tests { }, input_modes: vec![GameDistributionInputMode::Touch], orientation: GameDistributionOrientation::Portrait, + fork_authorization: Some(GameDistributionForkAuthorization::NonCommercial), }; let converted = game_metadata_update_as_create_request(&update); assert_eq!(converted.title, "新标题"); @@ -9477,6 +9343,11 @@ mod tests { vec![GameDistributionInputMode::Touch] ); assert_eq!(converted.orientation, GameDistributionOrientation::Portrait); + // 授权档位按请求值透传,档位方向由提升事务单独裁决。 + assert_eq!( + converted.fork_authorization, + GameDistributionForkAuthorization::NonCommercial + ); // 同一套校验:合法资料通过。 validate_game_metadata(&converted).expect("合法资料应通过创建口径的校验"); } @@ -11902,7 +11773,7 @@ mod tests { let response = app .oneshot( Request::builder() - .uri("/api/game-distribution/games/game_1/contribution") + .uri("/api/game-distribution/games/game_1/network/stats") .body(Body::empty()) .unwrap(), ) diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 2dbd34f99..9fc85391b 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -309,17 +309,6 @@ pub struct GameDistributionForkMetadata { pub parent_version_id: String, } -/// 修改作品共创授权:**只有母版**能提升(只升不降),`expected` 用于并发校验。 -/// -/// 衍生作品(存在血缘行)的档位由创建时继承父作品决定、是终态,任何 `PUT` 都返回 409 -/// `FORK_AUTHORIZATION_INHERITED`(含传同值的幂等重试)。 -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GameDistributionSetForkAuthorizationRequest { - pub expected_fork_authorization: GameDistributionForkAuthorization, - pub fork_authorization: GameDistributionForkAuthorization, -} - /// 族谱树上对外可见的单个作品节点。 /// /// 只带作品级公开信息:不含**素材键 / 内部 id**(例如 `coverAssetId`);封面对象键 @@ -695,7 +684,8 @@ pub struct GameMetadata { /// /// **只对母版(0 代作品)生效**:带 `fork` 声明时,新作品的档位在创建时**继承父作品当时的 /// 档位**,本字段一律被忽略且不报错(旧客户端会惯常带默认值),服务端也不接受「收窄」。 - /// 继承来的档位是**终态**:衍生作品之后再调 `PUT …/fork-authorization` 一律被拒(409 + /// 继承来的档位是**终态**:衍生作品之后再改档位(并入 `PATCH + /// /api/game-distribution/my-games/{game_id}` 的资料 PATCH)一律被拒(409 /// `FORK_AUTHORIZATION_INHERITED`),母版才走「只升不降」。父作品为 `forbidden` 时根本建立 /// 不了血缘,所以衍生作品不会继承到 `forbidden`。 /// 字段形状刻意保持不变(非 `Option` + `#[serde(default)]`):改了会让幂等摘要漂移。 @@ -746,6 +736,13 @@ pub struct GameDistributionUpdateGameMetadataRequest { pub device_support: GameDistributionDeviceSupport, pub input_modes: Vec, pub orientation: GameDistributionOrientation, + /// 作品级共创授权档位;`None` 表示这次资料编辑不动档位。 + /// + /// 并入通用资料 PATCH 后不再单独传 `expectedForkAuthorization`:并发控制统一由 + /// `expected_publication_revision` 承担,方向(只升不降)与衍生作品终态仍由同一领域 + /// 纯函数裁决。缺省不序列化该键,保证旧客户端的幂等摘要逐字节不变。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_authorization: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -934,6 +931,30 @@ mod tests { assert_eq!(serialized["coverObjectKey"], "generated/cover.png"); assert_eq!(serialized["screenshots"][0], serde_json::Value::Null); assert_eq!(serialized["screenshots"][1], "generated/shot.png"); + // 不传档位时不发射该键:旧客户端的幂等摘要逐字节不变。 + assert!(serialized.get("forkAuthorization").is_none()); + } + + #[test] + fn update_game_metadata_request_carries_optional_fork_authorization() { + let with_fork: GameDistributionUpdateGameMetadataRequest = + serde_json::from_value(serde_json::json!({ + "expectedPublicationRevision": 3, + "title": "游戏", + "summary": "简介", + "category": "益智", + "deviceSupport": { "desktop": true, "mobile": false, "touch": false }, + "inputModes": ["keyboard"], + "orientation": "responsive", + "forkAuthorization": "nonCommercial", + })) + .expect("带授权档位的资料编辑请求应可解析"); + assert_eq!( + with_fork.fork_authorization, + Some(GameDistributionForkAuthorization::NonCommercial) + ); + let serialized = serde_json::to_value(&with_fork).expect("应可序列化"); + assert_eq!(serialized["forkAuthorization"], "nonCommercial"); } #[test] diff --git a/src/components/game-distribution/MyGamesPage.test.tsx b/src/components/game-distribution/MyGamesPage.test.tsx index 88816c547..6b351adb9 100644 --- a/src/components/game-distribution/MyGamesPage.test.tsx +++ b/src/components/game-distribution/MyGamesPage.test.tsx @@ -664,8 +664,8 @@ test('把共创授权从禁止提升为非商用会携带期望档位并重新 createGame({ forkAuthorization: 'forbidden' }), ]); vi.mocked(updateGameForkAuthorization).mockResolvedValue({ - ok: true, - game: { id: 'game-1', forkAuthorization: 'nonCommercial' }, + game: createGame({ id: 'game-1', forkAuthorization: 'nonCommercial' }), + replayed: false, }); renderPage(createAuthValue()); @@ -684,11 +684,10 @@ test('把共创授权从禁止提升为非商用会携带期望档位并重新 await waitFor(() => expect(updateGameForkAuthorization).toHaveBeenCalledTimes(1), ); - const [gameId, target, expected, idempotencyKey] = + const [forkGame, target, idempotencyKey] = vi.mocked(updateGameForkAuthorization).mock.calls[0] ?? []; - expect(gameId).toBe('game-1'); + expect(forkGame.id).toBe('game-1'); expect(target).toBe('nonCommercial'); - expect(expected).toBe('forbidden'); expect(String(idempotencyKey)).toContain('game-1'); await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2)); expect(screen.getByText(/共创授权已更新为「允许非商用共创」/)).toBeTruthy(); diff --git a/src/components/game-distribution/MyGamesPage.tsx b/src/components/game-distribution/MyGamesPage.tsx index 2822ed94a..cbc659ebf 100644 --- a/src/components/game-distribution/MyGamesPage.tsx +++ b/src/components/game-distribution/MyGamesPage.tsx @@ -494,7 +494,6 @@ export function MyGamesPage({ async function handleUpdateForkAuthorization( game: GameDistributionMyGame, target: GameDistributionForkAuthorization, - expected: GameDistributionForkAuthorization, ) { setSavingForkAuthorizationGameId(game.id); setForkAuthorizationEditorGameId(''); @@ -502,10 +501,10 @@ export function MyGamesPage({ setError(''); setNotice(''); try { + // 授权并入通用资料 PATCH:客户端把当前投影里的资料原样带上,只改档位字段。 await updateGameForkAuthorization( - game.id, + game, target, - expected, createForkAuthorizationKey(game.id), ); setNotice( @@ -1171,7 +1170,6 @@ export function MyGamesPage({ void handleUpdateForkAuthorization( game, forkAuthorizationTarget, - forkAuthorization, ) } > diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index 4c82530f4..b1e2b1d5f 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -201,10 +201,10 @@ describe('gameDistributionClient', () => { await getDerivedGames('game-1'); expect(fetchMock.mock.calls[0]?.[0]).toBe( - '/api/game-distribution/games/game-1/lineage', + '/api/game-distribution/games/game-1/network', ); expect(fetchMock.mock.calls[1]?.[0]).toBe( - '/api/game-distribution/games/game-1/derived', + '/api/game-distribution/games/game-1/forks', ); for (const [, init] of fetchMock.mock.calls) { expect(init).toMatchObject({ method: 'GET', cache: 'no-store' }); @@ -450,7 +450,7 @@ describe('gameDistributionClient 作者接口', () => { expect(unpublishCalls()).toHaveLength(1); }); - it('共创授权提升只发 PUT,携带期望档位与幂等键,空入参本地失败关闭', async () => { + it('共创授权提升并入资料 PATCH,携带目标档位与幂等键,空入参本地失败关闭', async () => { setStoredAccessToken('author-token', { emit: false }); const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( @@ -464,39 +464,50 @@ describe('gameDistributionClient 作者接口', () => { ), ); vi.stubGlobal('fetch', fetchMock); + const forkGame = { + id: 'game-1', + title: '星轨防线', + summary: '守住轨道城', + description: '', + category: '动作' as const, + tags: ['塔防'], + coverColor: '#F3E4D0', + icon: '🎮', + coverObjectKey: 'game-distribution/cover/online.png', + screenshots: ['game-distribution/shot/1.png'], + author: { id: 'author-1', name: '创作者', avatarUrl: null }, + deviceSupport: { desktop: true, mobile: false, touch: false }, + inputModes: ['keyboard' as const, 'mouse' as const], + orientation: 'responsive' as const, + status: 'published' as const, + publicationRevision: 5, + forkAuthorization: 'forbidden' as const, + }; + + await updateGameForkAuthorization(forkGame, 'nonCommercial', 'fork-key-1'); - await updateGameForkAuthorization( - 'game-1', - 'nonCommercial', - 'forbidden', - 'fork-key-1', - ); const forkCalls = () => fetchMock.mock.calls.filter(([url]) => - String(url).includes('/fork-authorization'), + String(url).includes('/my-games/game-1'), ); expect(forkCalls()).toHaveLength(1); - expect(forkCalls()[0]?.[0]).toBe( - '/api/game-distribution/games/game-1/fork-authorization', - ); - expect(forkCalls()[0]?.[1]).toEqual( - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ - expectedForkAuthorization: 'forbidden', - forkAuthorization: 'nonCommercial', - }), - headers: expect.objectContaining({ - 'Idempotency-Key': 'fork-key-1', - }), - }), + expect(forkCalls()[0]?.[0]).toBe('/api/game-distribution/my-games/game-1'); + const init = forkCalls()[0]?.[1] as RequestInit; + expect(init.method).toBe('PATCH'); + expect(init.headers).toEqual( + expect.objectContaining({ 'Idempotency-Key': 'fork-key-1' }), ); + const formData = init.body as FormData; + const metadata = JSON.parse(String(formData.get('metadata'))) as Record< + string, + unknown + >; + expect(metadata.forkAuthorization).toBe('nonCommercial'); + expect(metadata.expectedPublicationRevision).toBe(5); + expect(metadata.coverObjectKey).toBe('game-distribution/cover/online.png'); await expect( - updateGameForkAuthorization(' ', 'full', 'forbidden', 'k'), - ).rejects.toThrow('缺少游戏编号'); - await expect( - updateGameForkAuthorization('game-1', 'full', 'forbidden', ' '), + updateGameForkAuthorization(forkGame, 'full', ' '), ).rejects.toThrow('缺少幂等键'); // 本地校验失败不得发出任何授权请求。 expect(forkCalls()).toHaveLength(1); diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index e6a3c88a1..e8cbd735c 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -15,7 +15,6 @@ import type { GameDistributionReviewsResponse, GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse, - GameDistributionSetForkAuthorizationRequest, GameDistributionUpdateGameMetadataRequest, GameDistributionVersionDetail, GameDistributionVersionStatus as GameDistributionVersionStatusValue, @@ -447,38 +446,35 @@ export async function unpublishGame( } /** - * 提升作品共创授权(只升不降)。`expectedForkAuthorization` 为 CAS 期望值, - * 服务端发现档位冲突或降级时返回 409,错误信息由 `requestJson` 透传。 + * 提升作品共创授权档位(只升不降)。 + * + * 授权已并入通用资料 PATCH:这里用列表/详情投影里的当前资料 + 目标档位组装同一份 + * `GameDistributionUpdateGameMetadataRequest`,并发控制由 `expectedPublicationRevision` 承担 + * (不再单独传 `expectedForkAuthorization`)。服务端发现降级、未知档位或衍生作品终态时返回 + * 409 / 400,错误信息由 `requestJson` 透传。 */ export async function updateGameForkAuthorization( - gameId: string, + game: GameDistributionGame, forkAuthorization: GameDistributionForkAuthorization, - expectedForkAuthorization: GameDistributionForkAuthorization, idempotencyKey: string, ) { - const normalizedGameId = gameId.trim(); - const normalizedKey = idempotencyKey.trim(); - if (!normalizedGameId) throw new Error('缺少游戏编号'); - if (!normalizedKey) throw new Error('设置共创授权缺少幂等键'); - const payload: GameDistributionSetForkAuthorizationRequest = { - expectedForkAuthorization, - forkAuthorization, - }; - return requestJson<{ - ok?: boolean; - replayed?: boolean; - game: { - id: string; - forkAuthorization?: GameDistributionForkAuthorization; - }; - }>( - `/games/${encodeURIComponent(normalizedGameId)}/fork-authorization`, + return updateMyGame( + game.id, { - method: 'PUT', - headers: { 'Idempotency-Key': normalizedKey }, - body: JSON.stringify(payload), + expectedPublicationRevision: game.publicationRevision, + title: game.title, + summary: game.summary, + description: game.description, + category: game.category, + tags: game.tags, + coverObjectKey: game.coverObjectKey ?? null, + screenshots: game.screenshots ?? [], + deviceSupport: game.deviceSupport, + inputModes: game.inputModes ?? [], + orientation: game.orientation ?? 'responsive', + forkAuthorization, }, - '设置共创授权失败', + idempotencyKey, ); } @@ -768,7 +764,7 @@ export async function getGameLineage(gameId: string) { const normalizedId = gameId.trim(); if (!normalizedId) return null; return requestJson( - `/games/${encodeURIComponent(normalizedId)}/lineage`, + `/games/${encodeURIComponent(normalizedId)}/network`, { method: 'GET', cache: 'no-store' }, '读取创作族谱失败', PUBLIC_GAME_REQUEST_OPTIONS, @@ -783,7 +779,7 @@ export async function getDerivedGames(gameId: string) { const normalizedId = gameId.trim(); if (!normalizedId) return null; return requestJson( - `/games/${encodeURIComponent(normalizedId)}/derived`, + `/games/${encodeURIComponent(normalizedId)}/forks`, { method: 'GET', cache: 'no-store' }, '读取衍生作品失败', PUBLIC_GAME_REQUEST_OPTIONS,