From 565d6ad7e1a3463bb4ec112ec212b4bba682b14c 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:12:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(game-distribution):=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E9=9D=A2=20clean=20cut=20=E5=88=B0=20{game=5Fid}/vers?= =?UTF-8?q?ions/{version=5Fnumber}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除统一 POST /versions 与旧两步 POST /games/{game_id}/versions,改由 POST /games(新作品 + 首版,首版号固定 1)与 POST /games/{game_id}/versions/{version_number}(追加 / 重放)承担 - NewGameVersionRequest 去掉 gameId / versionNumber:身份只在路径,body 不重复路径参数 - 删除 GameDistributionCreateGameRequest / GameDistributionCreateVersionRequest 及 From<..> for GameMetadata,并同步 DTO parity 注册表 - 作者版本族路由(package / project-bundle / submit / cancel / 版本详情)迁到 {game_id}/versions/{version_number},按 (game_id, version_number) 取最新一条并校验归属 - 删除只服务旧两步的 validate_game_creation_metadata / validate_legacy_version_declaration / normalize_local_project_id - web 客户端 publishGameVersion / getGameVersion / cancelGameVersion / submitGameVersion / uploadGamePackage 改按新路径签名,调用方与测试同步 - 同步 packages/shared 手写契约与 api-server 路由挂载测试 --- .../shared/src/contracts/gameDistribution.ts | 66 -- .../src/contracts/gameDistributionPublish.ts | 20 +- .../check-game-distribution-dto-parity.mjs | 7 - server-rs/crates/api-server/src/app.rs | 3 +- .../src/modules/game_distribution.rs | 620 ++++++------------ .../src/modules/game_distribution_publish.rs | 144 ++-- .../shared-contracts/src/game_distribution.rs | 150 ----- .../src/game_distribution_publish.rs | 103 +-- .../game-distribution/GameDetailPage.tsx | 12 +- .../GameDistributionPages.test.tsx | 4 +- .../GamePublishPage.test.tsx | 59 +- .../game-distribution/GamePublishPage.tsx | 16 +- .../GameWorkMetadataEditor.tsx | 8 +- .../game-distribution/MyGamesPage.test.tsx | 12 +- .../game-distribution/MyGamesPage.tsx | 29 +- .../gamePublishSubmission.test.ts | 54 +- .../gamePublishSubmission.ts | 34 +- src/services/gameDistributionClient.test.ts | 75 ++- src/services/gameDistributionClient.ts | 94 ++- 19 files changed, 612 insertions(+), 898 deletions(-) diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 839570101..ff5336e5a 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -660,72 +660,6 @@ export type GameDistributionAdminThemeMemberListResponse = { nextCursor: string | null; }; -export type GameDistributionCreateGameRequest = { - /** 发布方本地项目标识;同一作者重复发布会复用既有 gameId。 */ - localProjectId?: string | null; - title: string; - summary: string; - description?: string; - category: GameDistributionCategory; - tags?: string[]; - /** 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。 */ - coverObjectKey?: string | null; - /** 与最终截图顺序一一对应:`string` 沿用线上 objectKey,`null` 取按序的 `screenshot` 二进制 part。 */ - screenshots?: (string | null)[]; - deviceSupport: GameDistributionDeviceSupport; - inputModes: GameDistributionInputMode[]; - orientation: GameDistributionOrientation; - /** - * 上架时选择的共创授权档位:`forbidden` / `nonCommercial` / `full`。 - * - * 缺省按「禁止共创」解释(与库表默认、与旧客户端行为一致);非法取值整请求 400,不会静默 - * 落成 `forbidden`。上架之后**母版**只能通过 `PUT …/fork-authorization` 单向提升。 - * - * **只对母版(0 代作品)生效**:带 `fork` 声明时,服务端在新作品创建时**继承父作品当时的 - * 档位**,这里传的值被忽略且不报错(旧客户端惯常带默认值);服务端不接受「收窄」。继承来的 - * 档位是**终态**——衍生作品再调 `PUT …/fork-authorization` 一律被拒(409 - * `FORK_AUTHORIZATION_INHERITED`);父作品为 `forbidden` 时建立不了血缘,因此衍生作品 - * 不会继承到 `forbidden`。 - * 字段形状刻意保持不变——改成 `Option` 之类的形状会让创建请求的幂等摘要漂移。 - */ - forkAuthorization?: GameDistributionForkAuthorization; - /** 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 */ - fork?: GameDistributionForkMetadata | null; -}; - -export type GameDistributionCreateVersionRequest = { - localProjectId?: string | null; - /** - * 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。 - * - * 传入时只要求 `>= 1`:同一 `gameId` 的同一个版本号可以反复提交,每次提交生成新的 - * `versionId`,允许重复标签与回退到更小的版本号,不与已有最大值比较。 - * 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。 - */ - versionNumber?: number | null; - /** - * 作者提交的买断价(整数泥点,`0` 表示免费)。 - * - * 价格随版本冻结,审核通过时与资料一起生效到游戏行;缺省按 `0`(免费)处理。 - * 取值范围由服务端约束:`0..=1_000_000`,付费必须是正整数。 - */ - priceMudPoints?: number; - packageSha256: string; - packageBytes: number; - packageFileCount: number; - packageEntryPath: 'index.html'; - gameMetadata: GameDistributionCreateGameRequest; - /** - * 「本次核心改动说明」:**衍生作品**(该作品有改编来源)发布新版本时必填, - * trim 后按**字符**计 20–500 个字符;0 代母版忽略该字段(不校验、不落库)。 - * - * 缺失 → 400 `FORK_CHANGE_SUMMARY_REQUIRED`;长度越界 → 400 `FORK_CHANGE_SUMMARY_INVALID`。 - * 它参与请求摘要:同一个 `Idempotency-Key` 换了说明会被按「同键不同请求」拒绝(409), - * 省略该键的旧客户端请求与升级前逐字节一致(缺省不序列化 `null`)。 - */ - changeSummary?: string | null; -}; - /** * 作者编辑游戏级展示资料。 * diff --git a/packages/shared/src/contracts/gameDistributionPublish.ts b/packages/shared/src/contracts/gameDistributionPublish.ts index 925c8e744..09bf36b6e 100644 --- a/packages/shared/src/contracts/gameDistributionPublish.ts +++ b/packages/shared/src/contracts/gameDistributionPublish.ts @@ -5,21 +5,18 @@ import type { } from './gameDistribution'; /** - * 统一发布接口(`POST /api/game-distribution/versions`)的 metadata 形状。 + * 统一发布接口的 metadata 形状(`POST /games` 与 + * `POST /games/{gameId}/versions/{versionNumber}`)。 * - * 有 `gameId` 走更新;无 `gameId` 时 `projectKey` 作为首次发布身份锚。 - * `versionNumber` 必填并参与自然幂等键 `(gameId, projectKey, versionNumber)`: - * 同键同摘要重放、不同摘要 409。封面与截图走 multipart 的 `cover` / `screenshot` part。 + * **身份只从路径来**:`gameId` 与 `versionNumber` 不出现在请求体里——新作品走 `POST /games`、 + * 首版号固定 `1`;追加版本走路径 `{gameId}/versions/{versionNumber}`。`projectKey` 只在 + * `POST /games`(无路径槽)作为首次发布身份锚。封面与截图走 multipart 的 `cover` / `screenshot` part。 * - * 身份字段只在顶层出现;`gameMetadata` 由 Rust `GameMetadata` 经 ts-rs 生成,不再重复 `projectKey`。 + * `gameMetadata` 由 Rust `GameMetadata` 经 ts-rs 生成,不是手写镜像。 */ export type NewGameVersionRequest = { - /** 更新既有作品时给出;首次发布会缺省并把 `projectKey` 作为身份锚。 */ - gameId?: string | null; - /** 首次发布的本地项目标识;同一作者重复发布会确定性派生出同一 gameId。 */ + /** 首次发布的本地项目标识;同一作者重复发布会确定性派生出同一 gameId。只在 `POST /games` 必填。 */ projectKey?: string | null; - /** 用户可见的正整数版本标签,必填且由客户端在一次发布意图内冻结。 */ - versionNumber: number; /** * 作者提交的买断价(整数泥点,`0` 表示免费)。 * @@ -35,8 +32,7 @@ export type NewGameVersionRequest = { /** * 「本次核心改动说明」:衍生作品发布新版本时由服务端事务校验;母版忽略。 * - * 与旧两步请求同口径:api-server 原样透传、事务统一判定;未携带该字段的旧客户端请求 - * 序列化结果保持逐字节不变(缺省不序列化 `null`)。 + * 未携带该字段的请求序列化结果保持逐字节一致(缺省不序列化 `null`)。 */ changeSummary?: string | null; }; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 17f941cf0..67791e0ff 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -109,13 +109,6 @@ const PAIRS = [ 'GameDistributionUpsertThemeMemberRequest', ], ['GameDistributionPurchaseRequest', 'GameDistributionPurchaseRequest'], - // 旧两步发布路径的请求 DTO:两条发布路径并存期间仍是公开契约的一部分, - // 因此与统一路径的 `NewGameVersionRequest` 一样登记、逐字段对齐。 - ['GameDistributionCreateGameRequest', 'GameDistributionCreateGameRequest'], - [ - 'GameDistributionCreateVersionRequest', - 'GameDistributionCreateVersionRequest', - ], ['GameDistributionPurchase', 'GameDistributionPurchase'], ['GameDistributionPurchaseResponse', 'GameDistributionPurchaseResponse'], [ diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 6bca66065..d4b88de14 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1684,7 +1684,6 @@ mod tests { let token = sign_test_user_token(&state, &user, "sess_game_publish_switch"); let payload = serde_json::json!({ "projectKey": "publish-switch-test", - "versionNumber": 1, "packageSha256": "a".repeat(64), "packageBytes": 1, "packageFileCount": 1, @@ -1711,7 +1710,7 @@ mod tests { ); Request::builder() .method("POST") - .uri("/api/game-distribution/versions") + .uri("/api/game-distribution/games") .header("authorization", format!("Bearer {token}")) .header( "content-type", 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 f2a2374bf..97cc68fd1 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -51,8 +51,7 @@ use shared_contracts::game_distribution::{ GAME_DISTRIBUTION_THEME_INVALID_CURSOR, GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND, GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT, GAME_DISTRIBUTION_THEME_NOT_FOUND, GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT, GameDistributionAuthor, - GameDistributionCollectionState, GameDistributionCreateGameRequest, - GameDistributionCreateThemeRequest, GameDistributionCreateVersionRequest, + GameDistributionCollectionState, GameDistributionCreateThemeRequest, GameDistributionForkAuthorization, GameDistributionForksResponse, GameDistributionInputMode, GameDistributionMyReviewResponse, GameDistributionNetworkNode, GameDistributionNetworkResponse, GameDistributionNetworkStatsChild, GameDistributionNetworkStatsGeneration, @@ -112,6 +111,7 @@ use super::game_distribution_media::{ MAX_GAME_SCREENSHOTS, MAX_SINGLE_MEDIA_BYTES, MediaKind, PublishMultipart, UploadedImage, delete_media_objects, parse_publish_multipart, upload_media_image, }; +use super::game_distribution_publish::VersionInGamePath; pub(crate) const MAX_PACKAGE_REQUEST_BODY_BYTES: usize = MAX_PACKAGE_BYTES as usize + 1024; /// 发布资料 multipart 放行量:metadata + 1 张封面 + 最多 `MAX_GAME_SCREENSHOTS` 张截图,每张上限 @@ -632,77 +632,74 @@ pub fn router(state: AppState) -> Router { )) .route_layer(middleware::from_fn(add_no_store_response_headers)); let protected = Router::new() - .route( - "/api/game-distribution/versions", - post(super::game_distribution_publish::publish_version) - .layer(DefaultBodyLimit::max(MAX_PUBLISH_MULTIPART_BYTES)), - ) - // 旧两步发布路径(创建作品 / 追加版本)与统一发布路径并存:旧路径服务共创血缘写入 - // 与既有调用方,统一路径是新增入口;两条路径最终都落到同一套 SpacetimeDB 事务。 + // 统一发布:`POST /games` 一次完成「新作品 + 首版(号固定 1)」,`projectKey` 是身份锚。 .route( "/api/game-distribution/games", - post(create_game).layer(DefaultBodyLimit::max(MAX_PUBLISH_MULTIPART_BYTES)), + post(super::game_distribution_publish::publish_new_game) + .layer(DefaultBodyLimit::max(MAX_PUBLISH_MULTIPART_BYTES)), + ) + // 追加 / 自然重放版本:身份只在路径里,body 不重复 `gameId` / `versionNumber`。 + .route( + "/api/game-distribution/games/{game_id}/versions/{version_number}", + post(super::game_distribution_publish::publish_existing_version) + .layer(DefaultBodyLimit::max(MAX_PUBLISH_MULTIPART_BYTES)), ) .route( - "/api/game-distribution/games/{game_id}/versions", - post(create_version).layer(DefaultBodyLimit::max(MAX_PUBLISH_MULTIPART_BYTES)), - ) - .route( - "/api/game-distribution/versions/{version_id}/package", + "/api/game-distribution/games/{game_id}/versions/{version_number}/package", put(upload_package).layer(DefaultBodyLimit::max(MAX_PACKAGE_REQUEST_BODY_BYTES)), ) .route( - "/api/game-distribution/versions/{version_id}/package/upload-state", + "/api/game-distribution/games/{game_id}/versions/{version_number}/package/upload-state", get(package_upload_state), ) .route( - "/api/game-distribution/versions/{version_id}/package/chunk", + "/api/game-distribution/games/{game_id}/versions/{version_number}/package/chunk", put(upload_package_chunk) .layer(DefaultBodyLimit::max(MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES)), ) .route( - "/api/game-distribution/versions/{version_id}/package/complete", + "/api/game-distribution/games/{game_id}/versions/{version_number}/package/complete", post(complete_package_upload), ) .route( - "/api/game-distribution/versions/{version_id}/package/reset", + "/api/game-distribution/games/{game_id}/versions/{version_number}/package/reset", post(reset_package_upload), ) // 工程源包上行族(M2b):与发行包族逐条对齐,只是资产换成作者的工程源包。 // 载体类型一律 `application/octet-stream`(见技术方案 §3.4),分片边界与偏移头 // 直接复用发行包那一套——两者上限同值,客户端只能有一套偏移语义。 .route( - "/api/game-distribution/versions/{version_id}/project-bundle", + "/api/game-distribution/games/{game_id}/versions/{version_number}/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", + "/api/game-distribution/games/{game_id}/versions/{version_number}/project-bundle/upload-state", get(project_bundle_upload_state), ) .route( - "/api/game-distribution/versions/{version_id}/project-bundle/chunk", + "/api/game-distribution/games/{game_id}/versions/{version_number}/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", + "/api/game-distribution/games/{game_id}/versions/{version_number}/project-bundle/complete", post(complete_project_bundle_upload), ) .route( - "/api/game-distribution/versions/{version_id}/project-bundle/reset", + "/api/game-distribution/games/{game_id}/versions/{version_number}/project-bundle/reset", post(reset_project_bundle_upload), ) .route( - "/api/game-distribution/versions/{version_id}/submit", + "/api/game-distribution/games/{game_id}/versions/{version_number}/submit", post(submit_version), ) .route( - "/api/game-distribution/versions/{version_id}", + "/api/game-distribution/games/{game_id}/versions/{version_number}", get(get_owner_version), ) .route( - "/api/game-distribution/versions/{version_id}/cancel", + "/api/game-distribution/games/{game_id}/versions/{version_number}/cancel", post(cancel_version), ) .route("/api/game-distribution/my-games", get(list_my_games)) @@ -3262,199 +3259,6 @@ async fn delete_owner_game( )) } -async fn create_game( - State(state): State, - Extension(ctx): Extension, - Extension(auth): Extension, - headers: HeaderMap, - multipart: Multipart, -) -> Result, AppError> { - // 未知共创档位(例如 `"allowed"`)在进入业务前就被 serde 拦下:必须映射成平台信封的 400, - // 而不是回框架的默认拒绝文本(前端错误处理依赖信封)。框架文本只用来选文案,绝不回传:至少 - // 不会把「请求体里哪一段长什么样」透给客户端。 - ensure_publish_enabled(&state, Some(auth.claims().user_id())).await?; - let idempotency_key = idempotency_key(&headers)?; - let PublishMultipart { - metadata, - cover, - screenshots, - } = parse_publish_multipart(multipart).await?; - let payload: GameDistributionCreateGameRequest = - serde_json::from_value(metadata).map_err(|error| { - // serde 未知枚举值的文案会列出该枚举的全部合法取值,`nonCommercial` 是共创档位独有的 - // 取值,据此把「档位不合法」与其它字段不合法分成两条文案。 - if error.to_string().contains("nonCommercial") { - bad_request("共创授权档位不合法,只接受 forbidden / nonCommercial / full") - } else { - bad_request("创建作品请求字段不合法") - } - })?; - validate_game_creation_metadata(&payload)?; - let now = now_micros(); - let game_id = format!("game_{}", Uuid::new_v4().simple()); - let owner_user_id = auth.claims().user_id().to_string(); - // 幂等摘要只覆盖请求内容(元数据 + 图片字节),不含服务端生成的 objectKey, - // 这样重放同一个 Idempotency-Key 时摘要稳定、命中既有结果;血缘声明也在 metadata 里, - // 因此同样被摘要覆盖。 - let request_digest = publish_request_digest(&payload, cover.as_ref(), screenshots.as_slice())?; - // 先在接触对象存储前完成所有会让用户输入失败的转换:normalize_local_project_id 与血缘声明 - // 的读取都要在这里求值——留到 create_game_distribution_game 的实参里,`?` 会在上传之后才返回, - // 传完的封面/截图就成了孤儿。 - let local_project_id = normalize_local_project_id(payload.local_project_id.as_deref())?; - let forked_from_game_id = payload - .fork - .as_ref() - .map(|fork| fork.parent_game_id.clone()); - let forked_from_version_id = payload - .fork - .as_ref() - .map(|fork| fork.parent_version_id.clone()); - let (cover_object_key, resolved_screenshots, uploaded_media) = resolve_publish_media( - &state, - game_id.as_str(), - payload.cover_object_key.clone(), - &payload.screenshots, - cover, - screenshots, - None, - ) - .await?; - let game = cleanup_uncommitted_publish_media( - &state, - &uploaded_media, - state - .spacetime_client() - .create_game_distribution_game( - spacetime_client::GameDistributionCreateGameRecordInput { - game_id, - owner_user_id, - title: payload.title, - summary: payload.summary, - description: payload.description, - category: payload.category, - tags_json: serde_json::to_string(&payload.tags) - .map_err(|error| internal(error.to_string()))?, - cover_object_key: Some(cover_object_key), - screenshots_json: Some( - serde_json::to_string(&resolved_screenshots) - .map_err(|error| internal(error.to_string()))?, - ), - author_name: None, - author_avatar_url: None, - device_support_desktop: payload.device_support.desktop, - device_support_mobile: payload.device_support.mobile, - device_support_touch: payload.device_support.touch, - input_modes_json: serde_json::to_string(&payload.input_modes) - .map_err(|error| internal(error.to_string()))?, - orientation: orientation_wire_value(payload.orientation)?, - // 只对**母版**生效:带 `fork` 声明时,新作品的档位由事务从父作品行继承(创建时 - // 快照),这里原样透传的客户端取值会被忽略且不报错——旧客户端在衍生作品上惯常 - // 带默认档位。 - fork_authorization: fork_authorization_value(payload.fork_authorization), - idempotency_key, - request_digest, - now_micros: now, - local_project_id, - forked_from_game_id, - forked_from_version_id, - }, - ) - .await, - ) - .await?; - Ok(json_success_body(Some(&ctx), game_payload(&game.0))) -} - -async fn create_version( - State(state): State, - Extension(ctx): Extension, - Extension(auth): Extension, - headers: HeaderMap, - Path(game_id): Path, - multipart: Multipart, -) -> Result, AppError> { - ensure_publish_enabled(&state, Some(auth.claims().user_id())).await?; - let idempotency_key = idempotency_key(&headers)?; - let owner_user_id = auth.claims().user_id().to_string(); - let PublishMultipart { - metadata, - cover, - screenshots, - } = parse_publish_multipart(multipart).await?; - let payload: GameDistributionCreateVersionRequest = - serde_json::from_value(metadata).map_err(|_| bad_request("metadata 字段不合法"))?; - validate_legacy_version_declaration(&payload)?; - validate_game_creation_metadata(&payload.game_metadata)?; - let current = state - .spacetime_client() - .get_game_distribution_game(GameDistributionGetGameRecordInput { - game_id: game_id.clone(), - owner_user_id: Some(owner_user_id.clone()), - }) - .await - .map_err(map_spacetime_error)? - .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; - let now = now_micros(); - let version_id = format!("gamever_{}", Uuid::new_v4().simple()); - let request_digest = publish_request_digest( - &(game_id.as_str(), &payload), - cover.as_ref(), - screenshots.as_slice(), - )?; - // 与 create_game 同理:用户输入相关的失败必须在 resolve_publish_media 上传之前发生, - // 否则 `?` 会在 cleanup_uncommitted_publish_media 之前返回,留下孤儿媒体对象。 - let local_project_id = normalize_local_project_id(payload.local_project_id.as_deref())?; - let (cover_object_key, resolved_screenshots, uploaded_media) = resolve_publish_media( - &state, - game_id.as_str(), - payload.game_metadata.cover_object_key.clone(), - &payload.game_metadata.screenshots, - cover, - screenshots, - Some(¤t), - ) - .await?; - let version_metadata = GameMetadata::from(&payload.game_metadata); - let metadata_json = resolve_version_metadata_json( - &version_metadata, - cover_object_key.as_str(), - resolved_screenshots.as_slice(), - payload.price_mud_points, - ) - .await?; - let version = cleanup_uncommitted_publish_media( - &state, - &uploaded_media, - state - .spacetime_client() - .create_game_distribution_version( - spacetime_client::GameDistributionCreateVersionRecordInput { - game_id, - owner_user_id, - version_id, - version_number: payload.version_number, - metadata_json, - package_sha256: payload.package_sha256, - package_bytes: payload.package_bytes, - package_file_count: payload.package_file_count, - package_entry_path: payload.package_entry_path, - local_project_id, - idempotency_key, - request_digest, - now_micros: now, - // 原样透传(未 trim、未校验):规则只在写入事务里判定一次,HTTP 层不做第二套判据。 - change_summary: payload.change_summary.clone(), - }, - ) - .await, - ) - .await?; - Ok(json_success_body( - Some(&ctx), - private_version_payload(&version.0), - )) -} - /// 买断制购买:扣泥点与写购买记录在同一事务内完成,`Idempotency-Key` 保证双击与重试只扣一次。 /// /// 只接受普通用户 bearer:管理员令牌与用户令牌共用同一 JWT 签名密钥, @@ -3531,19 +3335,20 @@ async fn upload_package( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): Path, body: Bytes, ) -> Result, AppError> { require_zip_content_type(&headers)?; 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 = state - .spacetime_client() - .get_owner_game_distribution_version(owner_user_id.clone(), version_id.clone()) - .await - .map_err(map_spacetime_error)? - .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + let expected = + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = expected.version_id.clone(); let manifest = match validate_release_zip(&body) { Ok(manifest) => manifest, Err(error) => { @@ -3738,11 +3543,17 @@ async fn package_upload_state( State(state): State, Extension(ctx): Extension, Extension(auth): Extension, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): 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?; + let version = + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = version.version_id.clone(); let oss = game_distribution_oss_client(&state)?; let object_key = game_distribution_package_object_key(&version.game_id, &version_id); let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; @@ -3767,7 +3578,10 @@ async fn upload_package_chunk( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): Path, body: Bytes, ) -> Result, AppError> { require_octet_stream_content_type(&headers, "发行包分片必须使用 application/octet-stream")?; @@ -3784,7 +3598,10 @@ async fn upload_package_chunk( .with_code("PACKAGE_CHUNK_TOO_LARGE") .with_message("发行包分片超过服务端下发的大小")); } - let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + let version = + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = version.version_id.clone(); let chunk_bytes = u64::try_from(body.len()).unwrap_or(u64::MAX); let end = offset .checked_add(chunk_bytes) @@ -3892,13 +3709,18 @@ async fn complete_package_upload( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): 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?; + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = version.version_id.clone(); let oss = game_distribution_oss_client(&state)?; let object_key = game_distribution_package_object_key(&version.game_id, &version_id); let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; @@ -3999,12 +3821,18 @@ async fn reset_package_upload( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): 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?; + let version = + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = version.version_id.clone(); if !matches!(version.status.as_str(), "awaiting_upload" | "upload_failed") { return Err(AppError::from_status(StatusCode::CONFLICT) .with_code("PACKAGE_UPLOAD_RESET_NOT_ALLOWED") @@ -4078,7 +3906,10 @@ async fn upload_project_bundle( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): Path, body: Bytes, ) -> Result, AppError> { require_octet_stream_content_type(&headers, "工程源包必须使用 application/octet-stream")?; @@ -4086,7 +3917,9 @@ async fn upload_project_bundle( 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?; + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = expected.version_id.clone(); ensure_project_bundle_uploadable(&expected)?; let manifest = match validate_project_bundle_zip(&body) { Ok(manifest) => manifest, @@ -4237,11 +4070,17 @@ async fn project_bundle_upload_state( State(state): State, Extension(ctx): Extension, Extension(auth): Extension, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): 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?; + let version = + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = version.version_id.clone(); 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); @@ -4266,7 +4105,10 @@ async fn upload_project_bundle_chunk( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): Path, body: Bytes, ) -> Result, AppError> { require_octet_stream_content_type(&headers, "工程源包分片必须使用 application/octet-stream")?; @@ -4283,7 +4125,10 @@ async fn upload_project_bundle_chunk( .with_code("PROJECT_BUNDLE_CHUNK_TOO_LARGE") .with_message("工程源包分片超过服务端下发的大小")); } - let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + let version = + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = version.version_id.clone(); ensure_project_bundle_uploadable(&version)?; let chunk_bytes = u64::try_from(body.len()).unwrap_or(u64::MAX); let end = offset @@ -4392,13 +4237,18 @@ async fn complete_project_bundle_upload( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): 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?; + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = version.version_id.clone(); 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); @@ -4502,12 +4352,18 @@ async fn reset_project_bundle_upload( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): 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?; + let version = + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = version.version_id.clone(); 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); @@ -4607,7 +4463,10 @@ async fn submit_version( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): Path, Json(payload): Json, ) -> Result<(StatusCode, Json), AppError> { let owner_user_id = auth.claims().user_id().to_string(); @@ -4616,7 +4475,9 @@ async fn submit_version( // 与其它作者入口同口径:版本不存在或不属于当前主体都按 404 处理, // 不能用 403 区分“别人的版本”,否则送审入口会泄露版本是否存在。 let version = - load_owner_version_or_404(&state, owner_user_id.clone(), version_id.clone()).await?; + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = version.version_id.clone(); let game = state .spacetime_client() .get_game_distribution_game(GameDistributionGetGameRecordInput { @@ -4676,10 +4537,15 @@ async fn get_owner_version( State(state): State, Extension(ctx): Extension, Extension(auth): Extension, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): Path, ) -> Result, AppError> { let owner_user_id = auth.claims().user_id().to_string(); - let version = load_owner_version_or_404(&state, owner_user_id.clone(), version_id).await?; + let version = + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; let game = state .spacetime_client() .get_game_distribution_game(GameDistributionGetGameRecordInput { @@ -4704,14 +4570,19 @@ async fn cancel_version( Extension(ctx): Extension, Extension(auth): Extension, headers: HeaderMap, - Path(version_id): Path, + Path(VersionInGamePath { + game_id, + version_number, + }): Path, Json(payload): Json, ) -> 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?; + load_owner_version_by_number_or_404(&state, &owner_user_id, &game_id, version_number) + .await?; + let version_id = version.version_id.clone(); if version.publication_revision != payload.expected_publication_revision { return Err( AppError::from_status(StatusCode::CONFLICT).with_details(json!({ @@ -5777,28 +5648,6 @@ pub(crate) fn normalize_project_key(value: Option<&str>) -> Result) -> Result, AppError> { - let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - if value.chars().count() > 128 { - return Err(bad_request("localProjectId 不能超过 128 个字符")); - } - if value.chars().any(|character| character.is_control()) - || value.contains('/') - || value.contains('\\') - || value == "." - || value == ".." - { - return Err(bad_request( - "localProjectId 只能是短标识,不能包含路径分隔符", - )); - } - Ok(Some(value.to_string())) -} - pub(crate) fn orientation_wire_value( orientation: GameDistributionOrientation, ) -> Result { @@ -5851,9 +5700,11 @@ pub(crate) fn validate_game_metadata(payload: &GameMetadata) -> Result<(), AppEr pub(crate) fn validate_version_declaration( payload: &NewGameVersionRequest, + version_number: u64, ) -> Result<(), AppError> { - // 版本号必填且 >= 1:`0` 走普通 400,不做最大值比较(自然幂等键由版本号参与)。 - resolve_version_number(0, Some(payload.version_number)) + // 版本号来自路径(或 `POST /games` 固定首版 1),必须 >= 1:`0` 走普通 400, + // 不做最大值比较(自然幂等键由版本号参与)。 + resolve_version_number(0, Some(version_number)) .map_err(|error| bad_request(error.to_string()))?; // 价格随版本冻结,非法价格必须在建版本前拒绝,而不是等到审核通过才失败。 normalize_game_price_mud_points(payload.price_mud_points) @@ -5880,47 +5731,6 @@ pub(crate) fn validate_version_declaration( validate_game_metadata(&payload.game_metadata) } -/// 旧两步发布路径的创建资料校验适配:统一发布重构后资料校验只认 `GameMetadata`, -/// 旧请求体字段集与之一致(仅多外层身份 `local_project_id`),因此克隆成等价资料后复用同一套判据。 -fn validate_game_creation_metadata( - payload: &GameDistributionCreateGameRequest, -) -> Result<(), AppError> { - validate_game_metadata(&GameMetadata::from(payload)) -} - -/// 旧两步发布路径的版本声明校验:字段口径与统一请求一致,只是 `versionNumber` 可缺省 -/// (旧客户端兼容,缺省时由写入事务按已有最大值 +1),派生作品的 `changeSummary` 由事务校验。 -fn validate_legacy_version_declaration( - payload: &GameDistributionCreateVersionRequest, -) -> Result<(), AppError> { - if let Some(version_number) = payload.version_number { - resolve_version_number(0, Some(version_number)) - .map_err(|error| bad_request(error.to_string()))?; - } - normalize_game_price_mud_points(payload.price_mud_points) - .map_err(|error| bad_request(error.to_string()))?; - if payload.package_entry_path != "index.html" { - return Err(bad_request("发行包入口必须是 index.html")); - } - if payload.package_bytes == 0 || payload.package_bytes > MAX_PACKAGE_BYTES { - return Err( - AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_message("发行包大小超出限制") - ); - } - if payload.package_file_count == 0 { - return Err(bad_request("发行包至少需要包含一个文件")); - } - if payload.package_sha256.len() != 64 - || !payload - .package_sha256 - .chars() - .all(|value| value.is_ascii_hexdigit()) - { - return Err(bad_request("发行包 SHA-256 格式不合法")); - } - validate_game_creation_metadata(&payload.game_metadata) -} - fn require_zip_content_type(headers: &HeaderMap) -> Result<(), AppError> { let content_type = headers .get(header::CONTENT_TYPE) @@ -6417,21 +6227,24 @@ pub(crate) async fn resolve_version_metadata_json( } /// 读取当前主体名下的版本;未知版本和别人的版本都按不可见处理(404)。 -async fn load_owner_version_or_404( +/// 按对外身份 `(game_id, version_number)` 装载作者本人的版本行。 +/// +/// 同一作者可以对同一版本号重发,历史上会存在多条同号版本行;客户端方法按 `created_at` +/// 取最新一条,与「同号重发即重放」的对外语义一致。所有权仍在 `owner_user_id` 上校验, +/// 别人的版本与不存在的版本统一 404。 +async fn load_owner_version_by_number_or_404( state: &AppState, - owner_user_id: String, - version_id: String, + owner_user_id: &str, + game_id: &str, + version_number: u64, ) -> Result { match state .spacetime_client() - .get_owner_game_distribution_version(owner_user_id, version_id) + .get_game_distribution_version_by_number(game_id.to_string(), version_number) .await { - Ok(Some(version)) => Ok(version), - Ok(None) => Err(AppError::from_status(StatusCode::NOT_FOUND)), - Err(SpacetimeClientError::Procedure(message)) if message.contains("owner 不匹配") => { - Err(AppError::from_status(StatusCode::NOT_FOUND)) - } + Ok(Some(version)) if version.owner_user_id == owner_user_id => Ok(version), + Ok(Some(_)) | Ok(None) => Err(AppError::from_status(StatusCode::NOT_FOUND)), Err(error) => Err(map_spacetime_error(error)), } } @@ -7815,13 +7628,13 @@ mod tests { .expect("路由响应"); assert_eq!(catalog.status(), StatusCode::BAD_GATEWAY); - // 统一发布写入必须要求登录态,未带 Bearer 时在进入业务前就被拒绝。 + // 统一发布写入(新作品 + 首版)必须要求登录态,未带 Bearer 时在进入业务前就被拒绝。 let unauthenticated_create = app .clone() .oneshot( Request::builder() .method("POST") - .uri("/api/game-distribution/versions") + .uri("/api/game-distribution/games") .header("content-type", "application/json") .body(Body::from("{}")) .expect("请求"), @@ -8414,23 +8227,23 @@ mod tests { for (method, uri) in [ ( Method::PUT, - "/api/game-distribution/versions/version_1/project-bundle", + "/api/game-distribution/games/game_1/versions/7/project-bundle", ), ( Method::GET, - "/api/game-distribution/versions/version_1/project-bundle/upload-state", + "/api/game-distribution/games/game_1/versions/7/project-bundle/upload-state", ), ( Method::PUT, - "/api/game-distribution/versions/version_1/project-bundle/chunk", + "/api/game-distribution/games/game_1/versions/7/project-bundle/chunk", ), ( Method::POST, - "/api/game-distribution/versions/version_1/project-bundle/complete", + "/api/game-distribution/games/game_1/versions/7/project-bundle/complete", ), ( Method::POST, - "/api/game-distribution/versions/version_1/project-bundle/reset", + "/api/game-distribution/games/game_1/versions/7/project-bundle/reset", ), ( Method::GET, @@ -8645,74 +8458,14 @@ mod tests { ); } - /// 上架时的共创授权档位:缺省按「禁止共创」解释,显式传值原样保留,未知取值失败关闭。 - /// - /// 一并钉住幂等摘要口径:创建请求的摘要是对整个请求体取的,所以 DTO 必须写成非 `Option` - /// + `#[serde(default)]`——这样「省略」与「显式传 forbidden」序列化结果相同、摘要相同, - /// 同一个 Idempotency-Key 才会被识别成重放而不是「同 key 不同请求」。 - #[test] - fn create_game_request_defaults_fork_authorization_without_changing_digest() { - let base = json!({ - "title": "星轨防线", - "summary": "守住最后一条航线。", - "category": "益智", - "deviceSupport": { "desktop": true, "mobile": false, "touch": false }, - "inputModes": ["keyboard"], - "orientation": "responsive", - }); - - let omitted: GameDistributionCreateGameRequest = - serde_json::from_value(base.clone()).expect("省略档位应可解析"); - assert_eq!( - omitted.fork_authorization, - GameDistributionForkAuthorization::Forbidden, - "缺省必须是禁止共创(与表列默认一致)" - ); - - let mut explicit_value = base.clone(); - explicit_value["forkAuthorization"] = json!("forbidden"); - let explicit: GameDistributionCreateGameRequest = - serde_json::from_value(explicit_value).expect("显式 forbidden 应可解析"); - assert_eq!( - explicit.fork_authorization, - GameDistributionForkAuthorization::Forbidden - ); - assert_eq!( - compute_request_digest(&serde_json::to_vec(&omitted).expect("序列化")), - compute_request_digest(&serde_json::to_vec(&explicit).expect("序列化")), - "省略与显式传默认档位必须得到同一条幂等摘要" - ); - - for (value, expected) in [ - ( - "nonCommercial", - GameDistributionForkAuthorization::NonCommercial, - ), - ("full", GameDistributionForkAuthorization::Full), - ] { - let mut payload = base.clone(); - payload["forkAuthorization"] = json!(value); - let parsed: GameDistributionCreateGameRequest = - serde_json::from_value(payload).expect("合法档位应可解析"); - assert_eq!(parsed.fork_authorization, expected, "{value}"); - } - - let mut unknown = base; - unknown["forkAuthorization"] = json!("allowed"); - assert!( - serde_json::from_value::(unknown).is_err(), - "未知档位必须失败关闭,不能静默落成 forbidden" - ); - } - - /// 创建作品遇到未知共创档位必须回**平台信封的 400**(JSON 负载走 multipart 的 `metadata` - /// 文本 part),而不是让 serde 的拒绝变成框架默认的非信封文本。 + /// 发布请求的 metadata 解析失败必须回**平台信封的 400**(JSON 负载走 multipart 的 + /// `metadata` 文本 part),而不是让 serde 的拒绝变成框架默认的非信封文本。 /// /// 注意顺序:发布开关(`game-distribution:publish`)在 handler 里先于 metadata 解析执行, /// 所以这里的 400 分支要求测试态显式放行该作者;开关未配置时整条写路径统一 503(见下), /// 连 metadata 的解析都没走到——这仍然是「没有创建任何作品」的进程内证据。 #[tokio::test] - async fn create_game_rejects_unknown_fork_authorization_with_envelope_bad_request() { + async fn publish_new_game_rejects_invalid_metadata_with_envelope_bad_request() { use axum::http::Request; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, @@ -8738,7 +8491,10 @@ mod tests { .unwrap(); let build_app = |state: &AppState, claims: AuthenticatedAccessToken| { Router::new() - .route("/api/game-distribution/games", post(create_game)) + .route( + "/api/game-distribution/games", + post(crate::modules::game_distribution_publish::publish_new_game), + ) .layer(Extension(claims)) .layer(middleware::from_fn( crate::request_context::attach_request_context, @@ -8747,21 +8503,35 @@ mod tests { }; let valid_body = json!({ - "title": "星轨防线", - "summary": "守住最后一条航线。", - "category": "益智", - "deviceSupport": { "desktop": true, "mobile": false, "touch": false }, - "inputModes": ["keyboard"], - "orientation": "responsive", + "projectKey": "proj-1", + "packageSha256": "a".repeat(64), + "packageBytes": 1024, + "packageFileCount": 1, + "packageEntryPath": "index.html", + "gameMetadata": { + "title": "星轨防线", + "summary": "守住最后一条航线。", + "category": "益智", + "deviceSupport": { "desktop": true, "mobile": false, "touch": false }, + "inputModes": ["keyboard"], + "orientation": "responsive", + }, }); let unknown_fork_authorization = json!({ - "title": "星轨防线", - "summary": "守住最后一条航线。", - "category": "益智", - "deviceSupport": { "desktop": true, "mobile": false, "touch": false }, - "inputModes": ["keyboard"], - "orientation": "responsive", - "forkAuthorization": "allowed", + "projectKey": "proj-1", + "packageSha256": "a".repeat(64), + "packageBytes": 1024, + "packageFileCount": 1, + "packageEntryPath": "index.html", + "gameMetadata": { + "title": "星轨防线", + "summary": "守住最后一条航线。", + "category": "益智", + "deviceSupport": { "desktop": true, "mobile": false, "touch": false }, + "inputModes": ["keyboard"], + "orientation": "responsive", + "forkAuthorization": "allowed", + }, }); // 发布写路径统一收 `multipart/form-data`:`metadata` 文本 part 承载 JSON 负载,图片另走 @@ -8808,10 +8578,11 @@ mod tests { }]); let app = build_app(&state, AuthenticatedAccessToken::new(claims)); + // 统一发布请求把资料收进 `gameMetadata`:未知 `forkAuthorization` 与缺字段都在整份 + // metadata 反序列化时失败,统一映射成信封 400;不再有「档位专用」文案分支。 for (payload, expected_message) in [ - (unknown_fork_authorization, "共创授权档位不合法"), - // 缺字段而非档位问题的请求走另一条文案分支,避免把「所有解析失败」都说成档位问题。 - (json!({ "title": "星轨防线" }), "创建作品请求字段不合法"), + (unknown_fork_authorization, "metadata 字段不合法"), + (json!({ "title": "星轨防线" }), "metadata 字段不合法"), ] { let response = app .clone() @@ -8943,7 +8714,7 @@ mod tests { .clone() .oneshot( Request::builder() - .uri("/api/game-distribution/versions/version_1") + .uri("/api/game-distribution/games/game_1/versions/7") .body(Body::empty()) .expect("请求"), ) @@ -8956,7 +8727,7 @@ mod tests { .oneshot( Request::builder() .method("POST") - .uri("/api/game-distribution/versions/version_1/cancel") + .uri("/api/game-distribution/games/game_1/versions/7/cancel") .header("content-type", "application/json") .body(Body::from("{}")) .expect("请求"), @@ -11565,9 +11336,7 @@ mod tests { fn priced_version_request(price_mud_points: u64) -> NewGameVersionRequest { NewGameVersionRequest { - game_id: None, project_key: None, - version_number: 1, price_mud_points, package_sha256: "d".repeat(64), package_bytes: 1024, @@ -11601,11 +11370,11 @@ mod tests { #[test] fn version_declaration_validates_game_price() { for price in [0, 1, MAX_GAME_PRICE_MUD_POINTS] { - validate_version_declaration(&priced_version_request(price)) + validate_version_declaration(&priced_version_request(price), 1) .unwrap_or_else(|error| panic!("价格 {price} 应通过校验:{}", error.message())); } let error = - validate_version_declaration(&priced_version_request(MAX_GAME_PRICE_MUD_POINTS + 1)) + validate_version_declaration(&priced_version_request(MAX_GAME_PRICE_MUD_POINTS + 1), 1) .expect_err("超过上限的价格必须被拒"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!( @@ -11615,12 +11384,12 @@ mod tests { ); } - /// 版本号必填且 >= 1:`0` 在触存储前 400,且校验不依赖任何既有版本的最大值。 + /// 版本号来自路径(`POST /games` 固定 1),必须 >= 1:`0` 在触存储前 400, + /// 且校验不依赖任何既有版本的最大值。 #[test] - fn version_declaration_rejects_zero_version_number() { - let mut payload = priced_version_request(0); - payload.version_number = 0; - let error = validate_version_declaration(&payload).expect_err("版本号 0 必须被拒"); + fn version_declaration_rejects_zero_path_version_number() { + let error = validate_version_declaration(&priced_version_request(0), 0) + .expect_err("版本号 0 必须被拒"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!( error.message().contains("版本号"), @@ -11628,9 +11397,8 @@ mod tests { error.message() ); - let mut free_large_number = priced_version_request(0); - free_large_number.version_number = u64::MAX; - validate_version_declaration(&free_large_number).expect("极大版本号不做最大值比较"); + validate_version_declaration(&priced_version_request(0), u64::MAX) + .expect("极大版本号不做最大值比较"); } /// 付费作品的入口只在已购买 / 作者本人 / 管理员三种身份下下发;免费作品行为不变。 diff --git a/server-rs/crates/api-server/src/modules/game_distribution_publish.rs b/server-rs/crates/api-server/src/modules/game_distribution_publish.rs index ae3d81073..7647a3c4f 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution_publish.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution_publish.rs @@ -1,15 +1,20 @@ -//! 游戏分发统一发布写路径:`POST /api/game-distribution/versions`。 +//! 游戏分发统一发布写路径:`POST /games` 与 +//! `POST /games/{game_id}/versions/{version_number}`。 //! -//! 首次发布与更新合并成一次调用:媒体只解析一次,`idempotency_key` 由 -//! `(gameId, projectKey, versionNumber)` 三元组自然派生,不再读 `Idempotency-Key` 头。 -//! 作品行 get-or-create、写版本与自然幂等收据都在 SpacetimeDB 的单个 procedure 事务内完成。 +//! 两条路由只做参数提取与鉴权,事务编排收敛在一个深模块 [`publish_version_core`]。 +//! **身份只从路径来**:新作品走 `POST /games`、首版号固定 `1`;追加版本用路径 +//! `{game_id}` + `{version_number}`;`projectKey` 只在 `POST /games` 作为首次发布身份锚。 +//! 媒体只解析一次,`idempotency_key` 由 `(gameId, projectKey, versionNumber)` 三元组自然派生, +//! 不读 `Idempotency-Key` 头。作品行 get-or-create、写版本与自然幂等收据都在 SpacetimeDB 的 +//! 单个 procedure 事务内完成。 use axum::{ Json, - extract::{Extension, Multipart, State}, + extract::{Extension, Multipart, Path, State}, http::StatusCode, }; use module_game_distribution::{derive_game_distribution_game_id, game_distribution_version_key}; +use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::game_distribution_publish::NewGameVersionRequest; use spacetime_client::{ @@ -29,6 +34,55 @@ use crate::{ request_context::RequestContext, state::AppState, }; +/// 对外版本路径 `…/games/{game_id}/versions/{version_number}`——路径即身份,body 不重复。 +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct VersionInGamePath { + pub(crate) game_id: String, + pub(crate) version_number: u64, +} + +/// 发布目标:把「身份从哪来」显式收敛成两个分支,事务本体只认 `(game_id, version_number)`。 +enum PublishTarget { + /// `POST /games`:新作品 + 首版,首版号固定 `1`。 + NewGame, + /// `POST /games/{game_id}/versions/{version_number}`:既有作品追加 / 自然重放。 + ExistingVersion { + game_id: String, + version_number: u64, + }, +} + +/// `POST /api/game-distribution/games`:一次 multipart 完成「作品行 bootstrap + 首版」。 +pub(crate) async fn publish_new_game( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + multipart: Multipart, +) -> Result, AppError> { + publish_version_core(state, ctx, auth, PublishTarget::NewGame, multipart).await +} + +/// `POST /api/game-distribution/games/{game_id}/versions/{version_number}`:给既有作品写版本。 +pub(crate) async fn publish_existing_version( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Path(path): Path, + multipart: Multipart, +) -> Result, AppError> { + publish_version_core( + state, + ctx, + auth, + PublishTarget::ExistingVersion { + game_id: path.game_id, + version_number: path.version_number, + }, + multipart, + ) + .await +} + /// 自然幂等键里的 `project_key` 锚:更新优先取作品行已存的值(权威),首次发布回退到请求值。 /// /// 两者都缺失时返回 `None`,由 [`game_distribution_version_key`] 留空该段。 @@ -41,16 +95,17 @@ fn resolve_publish_project_key( .or_else(|| request_project_key.map(str::to_string)) } -/// 统一发布:一次 multipart 请求完成「作品行 bootstrap/复用 + 写版本 + 写幂等收据」。 +/// 发布深模块:路由只决定目标,这里统一做校验、媒体解析、幂等键与事务收口。 /// /// `metadata` part 反序列化成 [`NewGameVersionRequest`];`cover` / `screenshot` /// 二进制 part 只在这里解析一次,解析出的 objectKey 同时用于作品行 bootstrap 与版本冻结资料。 -/// 有 `gameId` 时先按 owner 读一次既有作品,保持 404 语义并让沿用媒体校验有当前行可比; -/// 无 `gameId` 时按 `(owner, projectKey)` 确定性派生 gameId,保证事务前媒体前缀稳定。 -pub(crate) async fn publish_version( - State(state): State, - Extension(ctx): Extension, - Extension(auth): Extension, +/// `ExistingVersion` 先按 owner 读一次既有作品,保持 404 语义并让沿用媒体校验有当前行可比; +/// 指向已软删作品时,只有 `gameId` 恰好是本 `projectKey` 的确定性身份才按首次发布继续。 +async fn publish_version_core( + state: AppState, + ctx: RequestContext, + auth: AuthenticatedAccessToken, + target: PublishTarget, multipart: Multipart, ) -> Result, AppError> { ensure_publish_enabled(&state, Some(auth.claims().user_id())).await?; @@ -62,19 +117,30 @@ pub(crate) async fn publish_version( } = parse_publish_multipart(multipart).await?; let payload: NewGameVersionRequest = serde_json::from_value(metadata).map_err(|_| bad_request("metadata 字段不合法"))?; - // 版本号必填(>= 1)、价格、包摘要与资料一起在接触对象存储前校验, - // 避免先上传封面/截图再由 `?` 提前返回留下孤儿媒体。 - validate_version_declaration(&payload)?; - let project_key = normalize_project_key(payload.project_key.as_deref())?; - let explicit_game_id = payload - .game_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let (game_id, current) = match explicit_game_id { - Some(game_id) => { + let requested_version_number = match &target { + PublishTarget::NewGame => 1, + PublishTarget::ExistingVersion { version_number, .. } => *version_number, + }; + // 版本号(>= 1)、价格、包摘要与资料一起在接触对象存储前校验, + // 避免先上传封面/截图再由 `?` 提前返回留下孤儿媒体。 + validate_version_declaration(&payload, requested_version_number)?; + + let (game_id, version_number, current) = match target { + PublishTarget::NewGame => { + let anchor = project_key + .as_deref() + .ok_or_else(|| bad_request("首次发布必须提供非空 projectKey"))?; + ( + derive_game_distribution_game_id(owner_user_id.as_str(), anchor), + requested_version_number, + None, + ) + } + PublishTarget::ExistingVersion { + game_id, + version_number, + } => { let existing = state .spacetime_client() .get_game_distribution_game(GameDistributionGetGameRecordInput { @@ -84,7 +150,7 @@ pub(crate) async fn publish_version( .await .map_err(map_spacetime_error)?; match existing { - Some(current) => (game_id, Some(current)), + Some(current) => (game_id, version_number, Some(current)), // 指向不存在或已软删的作品:只有在该 gameId 恰好是本 projectKey 的确定性身份时, // 才按首次发布继续(模块侧就地把软删行覆盖为全新作品);否则维持 404,避免误建。 None => { @@ -95,24 +161,16 @@ pub(crate) async fn publish_version( if derived != game_id { return Err(AppError::from_status(StatusCode::NOT_FOUND)); } - (derived, None) + (derived, version_number, None) } } } - None => { - let anchor = project_key - .as_deref() - .ok_or_else(|| bad_request("首次发布必须提供非空 projectKey 或游戏 ID"))?; - ( - derive_game_distribution_game_id(owner_user_id.as_str(), anchor), - None, - ) - } }; let now = now_micros(); let version_id = format!("gamever_{}", Uuid::new_v4().simple()); - // 摘要覆盖完整 metadata(含 gameId/projectKey/versionNumber)+ 新图原始字节: + // 摘要覆盖完整 metadata(内容 + projectKey)+ 新图原始字节:身份在路径里, + // 自然幂等键已含版本号,因此摘要不需要重复 `gameId` / `versionNumber`。 // 不含服务端生成的 objectKey,同键重试摘要稳定、命中既有结果。 let request_digest = publish_request_digest(&payload, cover.as_ref(), screenshots.as_slice())?; let (cover_object_key, resolved_screenshots, uploaded_media) = resolve_publish_media( @@ -143,14 +201,14 @@ pub(crate) async fn publish_version( let idempotency_key = game_distribution_version_key( game_id.as_str(), canonical_project_key.as_deref(), - payload.version_number, + version_number, ); let input = GameDistributionPublishVersionRecordInput { game_id: game_id.clone(), owner_user_id, project_key: project_key.clone(), version_id, - version_number: payload.version_number, + version_number, metadata_json, package_sha256: payload.package_sha256, package_bytes: payload.package_bytes, @@ -224,25 +282,25 @@ mod tests { assert_eq!(resolve_publish_project_key(None, None), None); } - /// 同一次发布无论请求带 `projectKey` 还是 `gameId`,都必须收敛到同一个自然幂等键。 + /// 同一次发布无论锚取请求值还是作品行值,都必须收敛到同一个自然幂等键。 #[test] - fn publish_version_key_is_stable_across_request_identity_shapes() { + fn publish_version_key_is_stable_across_project_key_sources() { let project_key = "proj-1"; let game_id = derive_game_distribution_game_id("owner-1", project_key); - // 首次发布:派生 gameId + 请求 projectKey。 + // 首次发布:派生 gameId + 请求 projectKey,首版号 1。 let create_shape = game_distribution_version_key( game_id.as_str(), resolve_publish_project_key(Some(project_key), None).as_deref(), 1, ); - // 更新:只带 gameId,作品行内存了同一个 projectKey。 + // 追加版本:路径身份,作品行内存了同一个 projectKey。 let update_shape = game_distribution_version_key( game_id.as_str(), resolve_publish_project_key(None, Some(project_key)).as_deref(), 1, ); - // 更新且请求也带了 projectKey(AGC 现状):仍取作品行内的权威值。 + // 追加版本且请求也带了 projectKey(AGC 现状):仍取作品行内的权威值。 let update_with_both = game_distribution_version_key( game_id.as_str(), resolve_publish_project_key(Some("other"), Some(project_key)).as_deref(), diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 991dab73e..2dbd34f99 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -722,105 +722,6 @@ pub struct GameMetadata { pub fork: Option, } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GameDistributionCreateGameRequest { - #[serde(default)] - pub local_project_id: Option, - pub title: String, - pub summary: String, - #[serde(default)] - pub description: Option, - pub category: String, - #[serde(default)] - pub tags: Vec, - /// 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。 - #[serde(default)] - pub cover_object_key: Option, - /// 与最终截图顺序一一对应:`Some(objectKey)` 沿用线上,`None` 取按顺序出现的 `screenshot` 二进制 part。 - #[serde(default)] - pub screenshots: Vec>, - pub device_support: GameDistributionDeviceSupport, - pub input_modes: Vec, - pub orientation: GameDistributionOrientation, - /// 上架时选择的共创授权档位;缺省按「禁止共创」解释(与表列默认一致,旧客户端不受影响)。 - /// - /// **只对母版(0 代作品)生效**:带 `fork` 声明时,新作品的档位在创建时**继承父作品当时的 - /// 档位**,本字段一律被忽略且不报错(旧客户端会惯常带默认值),服务端也不接受「收窄」。 - /// 继承来的档位是**终态**:衍生作品之后再调 `PUT …/fork-authorization` 一律被拒(409 - /// `FORK_AUTHORIZATION_INHERITED`),母版才走「只升不降」。父作品为 `forbidden` 时根本建立 - /// 不了血缘,所以衍生作品不会继承到 `forbidden`。 - /// 字段形状刻意保持不变(非 `Option` + `#[serde(default)]`):改了会让幂等摘要漂移。 - /// - /// 写成非 `Option` + `#[serde(default)]` 而不是 `Option<..>`:省略与显式传 `forbidden` - /// 得到同一个值,因此 `create_game` 的幂等摘要(对整个请求体取摘要)也相同——省略与显式 - /// 传默认不会变成两个不同的请求;未知取值由反序列化直接拒绝(api-server 映射 400), - /// 不会静默落成 `forbidden`。 - #[serde(default)] - pub fork_authorization: GameDistributionForkAuthorization, - /// 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 - #[serde(default)] - pub fork: Option, -} - -/// 旧两步发布路径的创建请求与新统一资料形状的桥接:字段集完全一致,只多一个属于外层身份的 -/// `local_project_id`(由 api-server 单独校验),因此可以按值克隆出等价的 `GameMetadata` -/// 复用同一套资料校验阈值,避免两套判据漂移。 -impl From<&GameDistributionCreateGameRequest> for GameMetadata { - fn from(payload: &GameDistributionCreateGameRequest) -> Self { - Self { - title: payload.title.clone(), - summary: payload.summary.clone(), - description: payload.description.clone(), - category: payload.category.clone(), - tags: payload.tags.clone(), - cover_object_key: payload.cover_object_key.clone(), - screenshots: payload.screenshots.clone(), - device_support: payload.device_support.clone(), - input_modes: payload.input_modes.clone(), - orientation: payload.orientation.clone(), - fork_authorization: payload.fork_authorization.clone(), - fork: payload.fork.clone(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GameDistributionCreateVersionRequest { - #[serde(default)] - pub local_project_id: Option, - /// 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。 - /// - /// 传入时只要求 `>= 1`:同一 gameId 的同一个版本号可以反复提交,每次提交生成新的 - /// versionId,允许重复标签与回退到更小的版本号,不与已有最大值比较。缺省时保留旧客户端 - /// 兼容行为:服务端按该游戏已有最大版本号 +1(此时溢出才会返回 409)。 - /// 缺省时序列化省略该字段,保证旧客户端请求的幂等摘要与升级前一致。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version_number: Option, - /// 作者提交的买断制价格(泥点);`0` 表示免费,缺省按免费处理。 - /// 价格随版本冻结,审核通过时与资料一起生效到游戏行。 - #[serde(default)] - pub price_mud_points: u64, - pub package_sha256: String, - pub package_bytes: u64, - pub package_file_count: u32, - pub package_entry_path: String, - pub game_metadata: GameDistributionCreateGameRequest, - /// 「本次核心改动说明」:衍生作品发布新版本时**必填**(按字符计、trim 后 20–500 个字符), - /// 0 代母版**忽略**该字段(不校验、不落库)。 - /// - /// 派生作品判定取**血缘行**(该作品有父即衍生),不接受客户端自称;缺失 / 超长一律失败关闭, - /// 错误码 `FORK_CHANGE_SUMMARY_REQUIRED` / `FORK_CHANGE_SUMMARY_INVALID`(均 400)。 - /// - /// 写成 `#[serde(default, skip_serializing_if = "Option::is_none")]`:与 `version_number` 同一 - /// 纪律——api-server 的幂等摘要对整个请求体取摘要,**未携带该字段的旧客户端请求必须逐字节 - /// 与升级前一致**,否则升级后同 `Idempotency-Key` 的重放会被误判成「同键不同请求」而 409。 - /// 反过来,真的换了说明又复用同一个键,就该按冲突拒绝(说明是这次请求的一部分)。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub change_summary: Option, -} - /// 作者编辑游戏级展示资料的请求体。 /// /// 只覆盖游戏行上的展示字段,随版本冻结的包摘要与资料快照不受影响; @@ -967,57 +868,6 @@ mod tests { assert!(serialized.get("projectKey").is_none()); } - #[test] - fn create_version_request_change_summary_is_optional_and_never_serialized_as_null() { - let request = |extra: serde_json::Value| { - let mut payload = serde_json::json!({ - "packageSha256": "a".repeat(64), - "packageBytes": 10, - "packageFileCount": 1, - "packageEntryPath": "index.html", - "gameMetadata": { - "title": "游戏", - "summary": "简介", - "category": "益智", - "deviceSupport": { "desktop": true, "mobile": false, "touch": false }, - "inputModes": ["keyboard"], - "orientation": "responsive", - }, - }); - if let serde_json::Value::Object(fields) = &mut payload { - for (key, value) in extra.as_object().expect("extra 必须是对象") { - fields.insert(key.clone(), value.clone()); - } - } - payload - }; - - // 旧客户端不带该字段:解析成 `None`,且序列化时**省略该键**——api-server 对请求体取幂等 - // 摘要,多出一个 `"changeSummary":null` 会让升级后的重放被误判成不同请求(409)。 - let legacy: GameDistributionCreateVersionRequest = - serde_json::from_value(request(serde_json::json!({}))).expect("旧请求应可解析"); - assert_eq!(legacy.change_summary, None); - let serialized = serde_json::to_value(&legacy).expect("应可序列化"); - assert!( - serialized.get("changeSummary").is_none(), - "缺省字段必须整个省略而不是发 null:{serialized}" - ); - - let with_summary: GameDistributionCreateVersionRequest = serde_json::from_value(request( - serde_json::json!({ "changeSummary": "把跳台改成三段,并重画全部背景" }), - )) - .expect("带说明的请求应可解析"); - assert_eq!( - with_summary.change_summary.as_deref(), - Some("把跳台改成三段,并重画全部背景") - ); - let serialized = serde_json::to_value(&with_summary).expect("应可序列化"); - assert_eq!( - serialized["changeSummary"], - serde_json::json!("把跳台改成三段,并重画全部背景") - ); - } - #[test] fn version_summary_change_summary_tolerates_missing_and_null() { // 公开版本负载在母版上发 `null`;旧响应则可能整个缺键——两种都必须能解析。 diff --git a/server-rs/crates/shared-contracts/src/game_distribution_publish.rs b/server-rs/crates/shared-contracts/src/game_distribution_publish.rs index 44d9f09d2..060cef5a2 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution_publish.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution_publish.rs @@ -1,9 +1,11 @@ -//! 游戏分发统一发布接口(`POST /api/game-distribution/versions`)的跨端 DTO。 +//! 游戏分发统一发布接口的跨端 DTO(`POST /games` 与 +//! `POST /games/{game_id}/versions/{version_number}`)。 //! -//! 发布写路径只有这一份 metadata 形状:有 `gameId` 走更新、无 `gameId` 时 `projectKey` -//! 作为首次发布身份锚;`versionNumber` 必填并参与自然幂等键。作品行 bootstrap 与版本冻结 -//! 资料统一用 `GameMetadata`,媒体二进制由 multipart 的 `cover` / -//! `screenshot` part 承载,不进入本结构体。 +//! 发布写路径只有这一份 metadata 形状。**身份只从路径来**:`gameId` 与 `versionNumber` +//! 不出现在本结构体里——新作品走 `POST /games`、首版号固定 `1`,追加版本走 +//! `POST /games/{game_id}/versions/{version_number}`;`projectKey` 只在 `POST /games` +//! (无路径槽)作为首次发布身份锚。作品行 bootstrap 与版本冻结资料统一用 `GameMetadata`, +//! 媒体二进制由 multipart 的 `cover` / `screenshot` part 承载,不进入本结构体。 use serde::{Deserialize, Serialize}; @@ -11,21 +13,15 @@ use crate::game_distribution::{ GameDistributionGameSummary, GameDistributionPrivateVersion, GameMetadata, }; -/// 统一发布请求的 metadata JSON 形状。 +/// 统一发布请求的 metadata JSON 形状(不含路径身份字段)。 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct NewGameVersionRequest { - /// 更新既有作品时给出;首次发布会缺省并把 `projectKey` 作为身份锚。 - #[serde(default)] - pub game_id: Option, /// 首次发布的本地项目标识;同一作者重复发布会确定性派生出同一 gameId。 + /// + /// 只在 `POST /games` 必填(无路径槽);追加版本走路径 `{game_id}`,这里可缺省。 #[serde(default)] pub project_key: Option, - /// 用户可见的正整数版本标签,必填且由客户端在一次发布意图内冻结。 - /// - /// 版本身份由 `(owner, projectKey | gameId, versionNumber)` 决定:同键同摘要重放、 - /// 不同摘要 409;服务端不自增,也不与已有最大值比较。 - pub version_number: u64, /// 作者提交的买断制价格(泥点);`0` 表示免费,缺省按免费处理。 /// 价格随版本冻结,审核通过时与资料一起生效到游戏行。 #[serde(default)] @@ -69,49 +65,62 @@ mod tests { } #[test] - fn publish_version_request_accepts_first_publish_and_update_shapes() { - let first: NewGameVersionRequest = serde_json::from_value(serde_json::json!({ + fn publish_version_request_body_has_no_path_identity() { + let body: NewGameVersionRequest = serde_json::from_value(serde_json::json!({ "projectKey": "proj-1", - "versionNumber": 1, "packageSha256": "a".repeat(64), "packageBytes": 10, "packageFileCount": 1, "packageEntryPath": "index.html", "gameMetadata": game_metadata(), })) - .expect("首次发布请求应可解析"); - assert_eq!(first.game_id, None); - assert_eq!(first.project_key.as_deref(), Some("proj-1")); - assert_eq!(first.version_number, 1); + .expect("发布请求应可解析"); + assert_eq!(body.project_key.as_deref(), Some("proj-1")); - let update: NewGameVersionRequest = serde_json::from_value(serde_json::json!({ - "gameId": "game-1", - "versionNumber": 3, - "packageSha256": "b".repeat(64), - "packageBytes": 20, - "packageFileCount": 2, - "packageEntryPath": "index.html", - "gameMetadata": game_metadata(), - })) - .expect("更新请求应可解析"); - assert_eq!(update.game_id.as_deref(), Some("game-1")); - assert_eq!(update.project_key, None); - - let serialized = serde_json::to_value(&update).expect("请求应可序列化"); - assert_eq!(serialized["versionNumber"], 3); - assert_eq!(serialized["gameId"], "game-1"); + // 身份(gameId / versionNumber)只在路径里;序列化结果不得再出现这两个键。 + let serialized = serde_json::to_value(&body).expect("请求应可序列化"); + assert!(serialized.get("gameId").is_none()); + assert!(serialized.get("versionNumber").is_none()); + assert_eq!(serialized["projectKey"], "proj-1"); } + /// `changeSummary` 缺省时必须整个省略,不能发 `null`:幂等摘要对整个请求体取摘要, + /// 多一个 `"changeSummary":null` 会把同键重放误判成「同键不同请求」。 #[test] - fn publish_version_request_requires_version_number() { - let missing = serde_json::from_value::(serde_json::json!({ - "projectKey": "proj-1", - "packageSha256": "a".repeat(64), - "packageBytes": 10, - "packageFileCount": 1, - "packageEntryPath": "index.html", - "gameMetadata": game_metadata(), - })); - assert!(missing.is_err(), "缺版本号必须解析失败"); + fn change_summary_is_optional_and_never_serialized_as_null() { + let request = |extra: serde_json::Value| { + let mut payload = serde_json::json!({ + "projectKey": "proj-1", + "packageSha256": "a".repeat(64), + "packageBytes": 10, + "packageFileCount": 1, + "packageEntryPath": "index.html", + "gameMetadata": game_metadata(), + }); + if let serde_json::Value::Object(fields) = &mut payload { + for (key, value) in extra.as_object().expect("extra 必须是对象") { + fields.insert(key.clone(), value.clone()); + } + } + payload + }; + + let omitted: NewGameVersionRequest = + serde_json::from_value(request(serde_json::json!({}))).expect("缺省说明应可解析"); + assert_eq!(omitted.change_summary, None); + let serialized = serde_json::to_value(&omitted).expect("应可序列化"); + assert!( + serialized.get("changeSummary").is_none(), + "缺省字段必须整个省略而不是发 null:{serialized}" + ); + + let with_summary: NewGameVersionRequest = serde_json::from_value(request( + serde_json::json!({ "changeSummary": "把跳台改成三段,并重画全部背景" }), + )) + .expect("带说明的请求应可解析"); + assert_eq!( + with_summary.change_summary.as_deref(), + Some("把跳台改成三段,并重画全部背景") + ); } } diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index ea02df4f1..a174963c4 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -329,15 +329,19 @@ export function GameDetailPage({ return; } setOwnerGame(entry); - const pendingVersionIds = entry.versions + const pendingVersions = entry.versions .filter( (version) => !version.recoveryAction && VERSION_STATUS_NEEDS_AUTHOR_ACTION[version.status], ) - .map((version) => version.versionId); - if (pendingVersionIds.length === 0) return; - return loadVersionRecoveryActions(pendingVersionIds).then((actions) => { + .map((version) => ({ + versionId: version.versionId, + gameId: entry.id, + versionNumber: version.versionNumber, + })); + if (pendingVersions.length === 0) return; + return loadVersionRecoveryActions(pendingVersions).then((actions) => { if (requestSerial !== requestSerialRef.current) return; setRecoveryActions(actions); }); diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index f8693cf40..59bdf9d51 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -1270,7 +1270,9 @@ describe('GameDetailPage 作者视角', () => { ); expect(await screen.findByText('下一步:等待审核结果')).toBeTruthy(); - expect(loadVersionRecoveryActionsMock).toHaveBeenCalledWith(['version-2']); + expect(loadVersionRecoveryActionsMock).toHaveBeenCalledWith([ + { versionId: 'version-2', gameId: 'game-1', versionNumber: 2 }, + ]); expect(within(screen.getByRole('list')).getByText('v1')).toBeTruthy(); // 已公开作品照旧请求评价与评分,门禁只作用于非公开作品。 await waitFor(() => diff --git a/src/components/game-distribution/GamePublishPage.test.tsx b/src/components/game-distribution/GamePublishPage.test.tsx index 22fe41ab9..6dfcd73fe 100644 --- a/src/components/game-distribution/GamePublishPage.test.tsx +++ b/src/components/game-distribution/GamePublishPage.test.tsx @@ -356,7 +356,7 @@ test('买断制发布把价格随版本请求提交,免费模式提交 0', asy fireEvent.click(screen.getByRole('button', { name: /提交审核/u })); await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1)); - const payload = vi.mocked(publishGameVersion).mock.calls[0]?.[0]; + const payload = vi.mocked(publishGameVersion).mock.calls[0]?.[1]; expect(payload).toEqual(expect.objectContaining({ priceMudPoints: 120 })); expect(payload).toEqual( expect.objectContaining({ @@ -392,7 +392,7 @@ test('免费模式提交 0 泥点并保持免费链路', async () => { fireEvent.click(screen.getByRole('button', { name: /提交审核/u })); await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1)); - expect(vi.mocked(publishGameVersion).mock.calls[0]?.[0]).toEqual( + expect(vi.mocked(publishGameVersion).mock.calls[0]?.[1]).toEqual( expect.objectContaining({ priceMudPoints: 0 }), ); expect(screen.queryByLabelText(/买断价/u)).toBeNull(); @@ -495,11 +495,12 @@ test('按统一发布、上传、送审顺序提交并展示审核中', async () fireEvent.click(screen.getByRole('button', { name: /提交审核/u })); await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1)); - const payload = vi.mocked(publishGameVersion).mock.calls[0]?.[0]; - // 首次发布用本地 projectKey 锚定,版本号冻结为 1。 - expect(payload?.gameId).toBeUndefined(); + const [target, payload] = vi.mocked(publishGameVersion).mock.calls[0] ?? []; + // 首次发布走 `POST /games`:body 只带本地 `projectKey` 身份锚,首版号由服务端固定为 1。 + expect(target).toEqual({ mode: 'create' }); expect(payload?.projectKey).toMatch(/^web-project-/u); - expect(payload?.versionNumber).toBe(1); + expect(payload).not.toHaveProperty('gameId'); + expect(payload).not.toHaveProperty('versionNumber'); expect(payload?.gameMetadata).toEqual( expect.objectContaining({ title: '测试游戏', @@ -514,18 +515,21 @@ test('按统一发布、上传、送审顺序提交并展示审核中', async () expect(payload?.packageEntryPath).toBe('index.html'); expect(payload?.packageFileCount).toBe(2); expect(payload?.packageSha256).toMatch(/^[0-9a-f]{64}$/u); - const media = vi.mocked(publishGameVersion).mock.calls[0]?.[1]; + const media = vi.mocked(publishGameVersion).mock.calls[0]?.[2]; expect(media?.cover?.name).toBe('cover.png'); expect(media?.screenshots?.map((file) => file?.name)).toEqual([ 'shot-1.png', 'shot-2.png', ]); - expect(vi.mocked(uploadGamePackage).mock.calls[0]?.[0]).toBe('gamever-1'); - expect(vi.mocked(uploadGamePackage).mock.calls[0]?.[2]).toBe( + expect(vi.mocked(uploadGamePackage).mock.calls[0]?.[0]).toBe('game-1'); + expect(vi.mocked(uploadGamePackage).mock.calls[0]?.[1]).toBe(1); + expect(vi.mocked(uploadGamePackage).mock.calls[0]?.[3]).toBe( 'gamever-1:upload', ); - expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(0); - expect(vi.mocked(submitGameVersion).mock.calls[0]?.[2]).toBe( + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[0]).toBe('game-1'); + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(1); + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[2]).toBe(0); + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[3]).toBe( 'gamever-1:submit', ); @@ -588,10 +592,11 @@ test('同一账号回到发布页可以沿用原版本继续送审', async () => expect(publishGameVersion).not.toHaveBeenCalled(); expect(uploadGamePackage).not.toHaveBeenCalled(); await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1)); - expect(vi.mocked(submitGameVersion).mock.calls[0]?.[0]).toBe('gamever-1'); - expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(2); + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[0]).toBe('game-1'); + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(1); + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[2]).toBe(2); // 恢复送审与提交模块共用同一套 versionId 派生键,刷新页面后重试也能被服务端去重。 - expect(vi.mocked(submitGameVersion).mock.calls[0]?.[2]).toBe( + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[3]).toBe( 'gamever-1:submit', ); expect( @@ -708,11 +713,16 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号', fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' })); await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1)); - // 更新模式必须带 gameId,用冻结的 max+1 版本号,不再新建游戏。 - const payload = vi.mocked(publishGameVersion).mock.calls[0]?.[0]; - expect(payload?.gameId).toBe('game-1'); + // 更新模式把 gameId/versionNumber 放进路径,body 不重复,不再新建游戏。 + const [target, payload] = vi.mocked(publishGameVersion).mock.calls[0] ?? []; + expect(target).toEqual({ + mode: 'update', + gameId: 'game-1', + versionNumber: 3, + }); expect(payload?.projectKey).toBeUndefined(); - expect(payload?.versionNumber).toBe(3); + expect(payload).not.toHaveProperty('gameId'); + expect(payload).not.toHaveProperty('versionNumber'); expect(payload).toEqual( expect.objectContaining({ packageEntryPath: 'index.html', @@ -728,11 +738,12 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号', }), }), ); - const updateMedia = vi.mocked(publishGameVersion).mock.calls[0]?.[1]; + const updateMedia = vi.mocked(publishGameVersion).mock.calls[0]?.[2]; expect(updateMedia?.cover).toBeNull(); expect(updateMedia?.screenshots).toEqual([null]); - expect(vi.mocked(submitGameVersion).mock.calls[0]?.[0]).toBe('gamever-3'); - expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(4); + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[0]).toBe('game-1'); + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(3); + expect(vi.mocked(submitGameVersion).mock.calls[0]?.[2]).toBe(4); expect( await screen.findByText('已提交审核,审核通过后会自动出现在游戏广场。'), ).toBeTruthy(); @@ -801,7 +812,7 @@ test('线上封面没有冻结快照时沿用游戏行封面对象键', async () fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' })); await waitFor(() => expect(publishGameVersion).toHaveBeenCalledTimes(1)); - expect(vi.mocked(publishGameVersion).mock.calls[0]?.[0]).toEqual( + expect(vi.mocked(publishGameVersion).mock.calls[0]?.[1]).toEqual( expect.objectContaining({ gameMetadata: expect.objectContaining({ coverObjectKey: 'game-distribution/cover/legacy.png', @@ -896,7 +907,7 @@ test('衍生作品发布新版本必须填「本次核心改动说明」', async fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' })); await waitFor(() => expect(publishGameVersion).toHaveBeenCalledTimes(1)); - expect(vi.mocked(publishGameVersion).mock.calls[0]?.[0]).toEqual( + expect(vi.mocked(publishGameVersion).mock.calls[0]?.[1]).toEqual( expect.objectContaining({ changeSummary: summaryText }), ); }); @@ -973,7 +984,7 @@ test('母版发布新版本不渲染改动说明字段,请求体也不带它', fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' })); await waitFor(() => expect(publishGameVersion).toHaveBeenCalledTimes(1)); - expect(vi.mocked(publishGameVersion).mock.calls[0]?.[0]).not.toHaveProperty( + expect(vi.mocked(publishGameVersion).mock.calls[0]?.[1]).not.toHaveProperty( 'changeSummary', ); }); diff --git a/src/components/game-distribution/GamePublishPage.tsx b/src/components/game-distribution/GamePublishPage.tsx index 91cfff2c9..db529910e 100644 --- a/src/components/game-distribution/GamePublishPage.tsx +++ b/src/components/game-distribution/GamePublishPage.tsx @@ -200,11 +200,11 @@ export function GamePublishPage({ if (!game) { throw new Error('找不到这个游戏,或它不属于当前账号'); } - const latestVersionId = game.latestVersion?.versionId; - if (!latestVersionId) { + const latestVersionNumber = game.latestVersion?.versionNumber; + if (!latestVersionNumber) { throw new Error('这个游戏还没有可续发的版本,请直接发布新游戏'); } - const detail = await getGameVersion(latestVersionId); + const detail = await getGameVersion(game.id, latestVersionNumber); if (cancelled) return; setTitle(detail.game.title); setSummary(detail.game.summary); @@ -297,7 +297,7 @@ export function GamePublishPage({ } // 响应丢失在发布阶段时草稿还没有 versionId:无法回读状态,但本地自然键已经冻结, // 再次提交会命中同一 gameId/versionNumber,不会新建第二份。 - if (!stored.versionId) { + if (!stored.versionId || !stored.gameId) { setDraft(stored); setDraftDetail(null); setIsResumingDraft(false); @@ -305,7 +305,7 @@ export function GamePublishPage({ } let cancelled = false; setDraft(stored); - getGameVersion(stored.versionId) + getGameVersion(stored.gameId, stored.versionNumber) .then((detail) => { if (cancelled) return; setDraftDetail(detail); @@ -410,7 +410,8 @@ export function GamePublishPage({ if (draftDetail.version.recoveryAction !== 'submit') { const prepared = await prepareGamePackage(packageFile as File); await uploadGamePackage( - resumeVersionId, + draftDetail.game.id, + draftDetail.version.versionNumber, new Blob([prepared.bytes as unknown as BlobPart], { type: 'application/zip', }), @@ -418,7 +419,8 @@ export function GamePublishPage({ ); } await submitGameVersion( - resumeVersionId, + draftDetail.game.id, + draftDetail.version.versionNumber, draftDetail.game.publicationRevision, `${resumeVersionId}:submit`, ); diff --git a/src/components/game-distribution/GameWorkMetadataEditor.tsx b/src/components/game-distribution/GameWorkMetadataEditor.tsx index cdc8f053a..cbc5da4c8 100644 --- a/src/components/game-distribution/GameWorkMetadataEditor.tsx +++ b/src/components/game-distribution/GameWorkMetadataEditor.tsx @@ -119,16 +119,16 @@ export function GameWorkMetadataEditor({ * 网络重试写出两份审计记录。 */ const saveKeyRef = useRef(null); - const latestVersionId = game.latestVersion?.versionId ?? ''; + const latestVersionNumber = game.latestVersion?.versionNumber ?? 0; useEffect(() => { - if (!latestVersionId) { + if (!latestVersionNumber) { setIsLoadingMedia(false); return; } let cancelled = false; setIsLoadingMedia(true); - getGameVersion(latestVersionId) + getGameVersion(game.id, latestVersionNumber) .then((detail) => { if (cancelled) return; return applyVersionMediaDefaults(detail, { @@ -151,7 +151,7 @@ export function GameWorkMetadataEditor({ return () => { cancelled = true; }; - }, [latestVersionId]); + }, [game.id, latestVersionNumber]); /** 封面是服务端必填项:本地新图或沿用线上图至少要有一个。 */ function resolveMediaSubmitError() { diff --git a/src/components/game-distribution/MyGamesPage.test.tsx b/src/components/game-distribution/MyGamesPage.test.tsx index 611d416d9..88816c547 100644 --- a/src/components/game-distribution/MyGamesPage.test.tsx +++ b/src/components/game-distribution/MyGamesPage.test.tsx @@ -291,9 +291,10 @@ test('审核中的版本二次确认后撤回并带上 publicationRevision', asy fireEvent.click(screen.getByRole('button', { name: '确认撤回' })); await waitFor(() => expect(cancelGameVersion).toHaveBeenCalledTimes(1)); - const [versionId, revision, idempotencyKey] = + const [gameId, versionNumber, revision, idempotencyKey] = vi.mocked(cancelGameVersion).mock.calls[0] ?? []; - expect(versionId).toBe('version-9'); + expect(gameId).toBe('game-1'); + expect(versionNumber).toBe(2); expect(revision).toBe(5); expect(String(idempotencyKey)).toContain('version-9'); await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2)); @@ -472,9 +473,10 @@ test('服务端给出 submit 动作时行内直接提交审核', async () => { fireEvent.click(screen.getByRole('button', { name: '提交审核' })); await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1)); - const [versionId, revision, idempotencyKey] = + const [gameId, versionNumber, revision, idempotencyKey] = vi.mocked(submitGameVersion).mock.calls[0] ?? []; - expect(versionId).toBe('version-8'); + expect(gameId).toBe('game-submit'); + expect(versionNumber).toBe(3); expect(revision).toBe(8); expect(String(idempotencyKey)).toContain('version-8'); }); @@ -569,7 +571,7 @@ test('编辑资料走 PATCH owner 路由并带上当前修订号与封面素材' fireEvent.click(await screen.findByRole('button', { name: '编辑资料' })); const dialog = await screen.findByRole('dialog', { name: '编辑作品资料' }); await waitFor(() => - expect(getGameVersion).toHaveBeenCalledWith('version-edit'), + expect(getGameVersion).toHaveBeenCalledWith('game-edit', 1), ); // 封面素材按冻结资料回填完成后保存按钮才可用。 await waitFor(() => diff --git a/src/components/game-distribution/MyGamesPage.tsx b/src/components/game-distribution/MyGamesPage.tsx index b89c42550..2822ed94a 100644 --- a/src/components/game-distribution/MyGamesPage.tsx +++ b/src/components/game-distribution/MyGamesPage.tsx @@ -267,16 +267,21 @@ export function MyGamesPage({ setDerivedErrorByGameId({}); // 列表投影不带 recoveryAction,只为还需要作者动作的最新版本补一次版本回读; // 单个回读失败只丢失该版本的下一步提示,不影响列表。 - const pendingVersionIds = entries - .map((game) => game.latestVersion) - .filter((version): version is GameDistributionVersionStatusEntry => { - if (!version || version.recoveryAction) return false; - return VERSION_STATUS_NEEDS_AUTHOR_ACTION[version.status]; - }) - .map((version) => version.versionId); + const pendingVersions = entries.flatMap((game) => { + const version = game.latestVersion; + if (!version || version.recoveryAction) return []; + if (!VERSION_STATUS_NEEDS_AUTHOR_ACTION[version.status]) return []; + return [ + { + versionId: version.versionId, + gameId: game.id, + versionNumber: version.versionNumber, + }, + ]; + }); setRecoveryActions( - pendingVersionIds.length > 0 - ? await loadVersionRecoveryActions(pendingVersionIds) + pendingVersions.length > 0 + ? await loadVersionRecoveryActions(pendingVersions) : {}, ); } catch (loadError: unknown) { @@ -446,7 +451,8 @@ export function MyGamesPage({ setNotice(''); try { await cancelGameVersion( - version.versionId, + game.id, + version.versionNumber, version.publicationRevision, createCancelKey(version.versionId), ); @@ -469,7 +475,8 @@ export function MyGamesPage({ setNotice(''); try { await submitGameVersion( - version.versionId, + game.id, + version.versionNumber, game.publicationRevision, createSubmitKey(version.versionId), ); diff --git a/src/components/game-distribution/gamePublishSubmission.test.ts b/src/components/game-distribution/gamePublishSubmission.test.ts index 2550b5f33..c937f47d4 100644 --- a/src/components/game-distribution/gamePublishSubmission.test.ts +++ b/src/components/game-distribution/gamePublishSubmission.test.ts @@ -106,19 +106,25 @@ describe('gamePublishSubmission', () => { versionNumber: 1, }); expect(client.publishGameVersion).toHaveBeenCalledTimes(1); - const payload = client.publishGameVersion.mock.calls[0]?.[0]; + const [target, payload] = client.publishGameVersion.mock.calls[0] ?? []; + expect(target).toEqual({ mode: 'create' }); expect(payload).toMatchObject({ projectKey: 'web-project-fixed', - versionNumber: 1, + priceMudPoints: 0, + packageSha256: BASE_INPUT.packageSha256, }); + // 身份只在路径里:create 走 `/games`,body 不带 gameId / versionNumber。 expect(payload).not.toHaveProperty('gameId'); + expect(payload).not.toHaveProperty('versionNumber'); expect(client.uploadGamePackage).toHaveBeenCalledWith( - 'version-1', + 'game-1', + 1, BASE_INPUT.packageBody, 'version-1:upload', ); expect(client.submitGameVersion).toHaveBeenCalledWith( - 'version-1', + 'game-1', + 1, 7, 'version-1:submit', ); @@ -163,9 +169,10 @@ describe('gamePublishSubmission', () => { expect(createProjectKey).toHaveBeenCalledTimes(1); expect(publishGameVersion.mock.calls).toHaveLength(2); for (const call of publishGameVersion.mock.calls) { - expect(call[0]).toMatchObject({ + expect(call[0]).toEqual({ mode: 'create' }); + expect(call[1]).toMatchObject({ projectKey: 'web-project-fixed', - versionNumber: 1, + priceMudPoints: 0, }); } }); @@ -195,11 +202,18 @@ describe('gamePublishSubmission', () => { versionNumber: 3, }); - const payload = client.publishGameVersion.mock.calls[0]?.[0]; - expect(payload).toMatchObject({ gameId: 'game-1', versionNumber: 3 }); + const [target, payload] = client.publishGameVersion.mock.calls[0] ?? []; + expect(target).toEqual({ + mode: 'update', + gameId: 'game-1', + versionNumber: 3, + }); expect(payload).not.toHaveProperty('projectKey'); + expect(payload).not.toHaveProperty('gameId'); + expect(payload).not.toHaveProperty('versionNumber'); expect(client.submitGameVersion).toHaveBeenCalledWith( - 'version-3', + 'game-1', + 3, 9, 'version-3:submit', ); @@ -237,10 +251,13 @@ describe('gamePublishSubmission', () => { versionNumber: 4, }); - expect(client.publishGameVersion.mock.calls[0]?.[0]).toMatchObject({ + const [target, payload] = client.publishGameVersion.mock.calls[0] ?? []; + expect(target).toEqual({ + mode: 'update', gameId: 'game-1', versionNumber: 3, }); + expect(payload).not.toHaveProperty('projectKey'); expect(createProjectKey).not.toHaveBeenCalled(); }); @@ -266,10 +283,10 @@ describe('gamePublishSubmission', () => { await submit({ ...BASE_INPUT, ownerUserId: 'user-1' }); - expect(client.publishGameVersion.mock.calls[0]?.[0]).toMatchObject({ - projectKey: 'web-project-new', - versionNumber: 1, - }); + const [target, payload] = client.publishGameVersion.mock.calls[0] ?? []; + expect(target).toEqual({ mode: 'create' }); + expect(payload).toMatchObject({ projectKey: 'web-project-new' }); + expect(payload).not.toHaveProperty('versionNumber'); }); it('更新目标换成另一款游戏时丢弃旧草稿的 versionId', async () => { @@ -283,7 +300,9 @@ describe('gamePublishSubmission', () => { updatedAt: '2026-10-06T00:00:00.000Z', }); const client = createClient({ - publishGameVersion: vi.fn(async () => publishResponse({ gameId: 'game-2' })), + publishGameVersion: vi.fn(async () => + publishResponse({ gameId: 'game-2' }), + ), }); const submit = createGamePublishSubmission({ ...client, @@ -302,10 +321,13 @@ describe('gamePublishSubmission', () => { const firstWrite = store.writePublishDraft.mock.calls[0]?.[0]; expect(firstWrite).toMatchObject({ gameId: 'game-2' }); expect(firstWrite).not.toHaveProperty('versionId'); - expect(client.publishGameVersion.mock.calls[0]?.[0]).toMatchObject({ + const [target, payload] = client.publishGameVersion.mock.calls[0] ?? []; + expect(target).toEqual({ + mode: 'update', gameId: 'game-2', versionNumber: 4, }); + expect(payload).not.toHaveProperty('projectKey'); }); it('上传失败返回 typed error 并保留草稿与版本标识,不继续送审', async () => { diff --git a/src/components/game-distribution/gamePublishSubmission.ts b/src/components/game-distribution/gamePublishSubmission.ts index 3be7b7493..689b1dbcf 100644 --- a/src/components/game-distribution/gamePublishSubmission.ts +++ b/src/components/game-distribution/gamePublishSubmission.ts @@ -6,7 +6,10 @@ import type { GameDistributionPublishVersionResponse, NewGameVersionRequest, } from '../../../packages/shared/src/contracts/gameDistributionPublish'; -import type { GameDistributionPublishMediaParts } from '../../services/gameDistributionClient'; +import type { + GameDistributionPublishMediaParts, + GamePublishVersionPath, +} from '../../services/gameDistributionClient'; import { createGamePublishProjectKey, type GamePublishDraft, @@ -53,16 +56,19 @@ export type GamePublishSubmissionInput = { /** 网络与草稿存储依赖;测试注入假实现即可在接口层覆盖整条编排。 */ export type GamePublishSubmissionClient = { publishGameVersion: ( + target: GamePublishVersionPath, payload: NewGameVersionRequest, media?: GameDistributionPublishMediaParts, ) => Promise; uploadGamePackage: ( - versionId: string, + gameId: string, + versionNumber: number, packageBody: Blob | ArrayBuffer, idempotencyKey: string, ) => Promise; submitGameVersion: ( - versionId: string, + gameId: string, + versionNumber: number, expectedPublicationRevision: number, idempotencyKey: string, ) => Promise; @@ -163,16 +169,15 @@ function resolveDraftIdentity( }; } -/** 组发布请求:首次发布送 projectKey,更新送 gameId;两者都不带 Idempotency-Key。 */ +/** 组发布请求:身份只在路径里;只有首次发布才带 `projectKey` 身份锚。 */ function buildPublishVersionRequest( input: GamePublishSubmissionInput, identity: { projectKey: string; versionNumber: number }, ): NewGameVersionRequest { return { - ...(input.target.mode === 'update' - ? { gameId: input.target.gameId.trim() } - : { projectKey: identity.projectKey }), - versionNumber: identity.versionNumber, + ...(input.target.mode === 'create' + ? { projectKey: identity.projectKey } + : {}), priceMudPoints: input.priceMudPoints, packageSha256: input.packageSha256, packageBytes: input.packageBytes, @@ -237,6 +242,13 @@ export function createGamePublishSubmission( let response: GameDistributionPublishVersionResponse; try { response = await dependencies.publishGameVersion( + input.target.mode === 'update' + ? { + mode: 'update', + gameId: input.target.gameId.trim(), + versionNumber: identity.versionNumber, + } + : { mode: 'create' }, buildPublishVersionRequest(input, identity), input.media, ); @@ -260,7 +272,8 @@ export function createGamePublishSubmission( try { await dependencies.uploadGamePackage( - version.versionId, + game.id, + version.versionNumber, input.packageBody, `${version.versionId}:upload`, ); @@ -278,7 +291,8 @@ export function createGamePublishSubmission( try { await dependencies.submitGameVersion( - version.versionId, + game.id, + version.versionNumber, game.publicationRevision, `${version.versionId}:submit`, ); diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index 8038fc816..4c82530f4 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -302,7 +302,7 @@ describe('gameDistributionClient', () => { expect(clientIds).toEqual([clientIds[0], clientIds[0]]); }); - it('统一发布请求打 /versions 且不携带幂等键,保留后端失败状态', async () => { + it('统一发布请求 create 走 /games 且不携带幂等键,保留后端失败状态', async () => { setStoredAccessToken('test-access-token', { emit: false }); const fetchMock = vi.fn().mockImplementation( () => @@ -313,25 +313,27 @@ describe('gameDistributionClient', () => { ); vi.stubGlobal('fetch', fetchMock); await expect( - publishGameVersion({ - projectKey: 'web-project-1', - versionNumber: 1, - packageSha256: 'x', - packageBytes: 1, - packageFileCount: 1, - packageEntryPath: 'index.html', - gameMetadata: { - title: '测试游戏', - summary: '测试', - category: '益智', - deviceSupport: { desktop: true, mobile: false, touch: false }, - inputModes: ['keyboard'], - orientation: 'responsive', + publishGameVersion( + { mode: 'create' }, + { + projectKey: 'web-project-1', + packageSha256: 'x', + packageBytes: 1, + packageFileCount: 1, + packageEntryPath: 'index.html', + gameMetadata: { + title: '测试游戏', + summary: '测试', + category: '益智', + deviceSupport: { desktop: true, mobile: false, touch: false }, + inputModes: ['keyboard'], + orientation: 'responsive', + }, }, - }), + ), ).rejects.toMatchObject({ status: 422 }); const [url, init] = fetchMock.mock.calls[0] ?? []; - expect(url).toBe('/api/game-distribution/versions'); + expect(url).toBe('/api/game-distribution/games'); expect(init.method).toBe('POST'); // 自然幂等键由 projectKey/gameId + versionNumber 决定,统一发布路由不读 Idempotency-Key。 expect(init.headers['Idempotency-Key']).toBeUndefined(); @@ -350,9 +352,9 @@ describe('gameDistributionClient', () => { ); vi.stubGlobal('fetch', fetchMock); setStoredAccessToken('test-access-token', { emit: false }); - await uploadGamePackage('version-1', new ArrayBuffer(2), 'idem-upload'); + await uploadGamePackage('game-1', 2, new ArrayBuffer(2), 'idem-upload'); expect(fetchMock).toHaveBeenCalledWith( - '/api/game-distribution/versions/version-1/package', + '/api/game-distribution/games/game-1/versions/2/package', expect.objectContaining({ method: 'PUT', body: expect.any(ArrayBuffer), @@ -519,12 +521,14 @@ describe('gameDistributionClient 作者接口', () => { ); vi.stubGlobal('fetch', fetchMock); - const detail = await getGameVersion('version-1'); + const detail = await getGameVersion('game-1', 2); expect(detail.version.recoveryAction).toBe('reupload'); const readCall = fetchMock.mock.calls.find(([url]) => - String(url).includes('/api/game-distribution/versions/version-1'), + String(url).includes('/api/game-distribution/games/game-1/versions/2'), + ); + expect(readCall?.[0]).toBe( + '/api/game-distribution/games/game-1/versions/2', ); - expect(readCall?.[0]).toBe('/api/game-distribution/versions/version-1'); expect(readCall?.[1]?.method).toBe('GET'); }); @@ -540,10 +544,10 @@ describe('gameDistributionClient 作者接口', () => { ); vi.stubGlobal('fetch', fetchMock); - await cancelGameVersion('version-1', 3, 'idem-1', '看错了'); + await cancelGameVersion('game-1', 2, 3, 'idem-1', '看错了'); const cancelCalls = () => fetchMock.mock.calls.filter(([url]) => - String(url).includes('/versions/version-1/cancel'), + String(url).includes('/games/game-1/versions/2/cancel'), ); expect(cancelCalls()).toHaveLength(1); const cancelCall = cancelCalls()[0]; @@ -556,10 +560,13 @@ describe('gameDistributionClient 作者接口', () => { reason: '看错了', }); - await expect(cancelGameVersion('', 1, 'idem-2')).rejects.toThrow( - '撤回版本缺少版本编号', + await expect(cancelGameVersion('', 2, 1, 'idem-2')).rejects.toThrow( + '缺少作品编号', ); - await expect(cancelGameVersion('version-1', 1, ' ')).rejects.toThrow( + await expect(cancelGameVersion('game-1', 0, 1, 'idem-2')).rejects.toThrow( + '缺少合法版本号', + ); + await expect(cancelGameVersion('game-1', 2, 1, ' ')).rejects.toThrow( '撤回版本缺少幂等键', ); // 本地校验失败不得发出任何撤回请求。 @@ -800,9 +807,9 @@ describe('gameDistributionClient 发布媒体 multipart', () => { }); await publishGameVersion( + { mode: 'create' }, { projectKey: 'web-project-1', - versionNumber: 1, packageSha256: 'a'.repeat(64), packageBytes: 10, packageFileCount: 2, @@ -818,7 +825,7 @@ describe('gameDistributionClient 发布媒体 multipart', () => { ); const [url, init] = fetchMock.mock.calls[0] ?? []; - expect(url).toBe('/api/game-distribution/versions'); + expect(url).toBe('/api/game-distribution/games'); expect(init.method).toBe('POST'); expect(init.headers['Idempotency-Key']).toBeUndefined(); expect(init.body).toBeInstanceOf(FormData); @@ -826,7 +833,6 @@ describe('gameDistributionClient 发布媒体 multipart', () => { expect(JSON.parse(String(formData.get('metadata')))).toEqual( expect.objectContaining({ projectKey: 'web-project-1', - versionNumber: 1, gameMetadata: expect.objectContaining({ title: '星轨防线', coverObjectKey: null, @@ -840,7 +846,7 @@ describe('gameDistributionClient 发布媒体 multipart', () => { expect(init.headers['Content-Type']).toBeUndefined(); }); - it('更新发布带 gameId 且媒体 part 独立提交', async () => { + it('更新发布路径带 gameId/versionNumber 且媒体 part 独立提交', async () => { setStoredAccessToken('author-token', { emit: false }); const fetchMock = vi.fn().mockResolvedValue( new Response( @@ -856,9 +862,8 @@ describe('gameDistributionClient 发布媒体 multipart', () => { const cover = new File(['cover-bytes'], 'cover.png', { type: 'image/png' }); await publishGameVersion( + { mode: 'update', gameId: 'game-1', versionNumber: 2 }, { - gameId: 'game-1', - versionNumber: 2, priceMudPoints: 120, packageSha256: 'a'.repeat(64), packageBytes: 10, @@ -874,13 +879,11 @@ describe('gameDistributionClient 发布媒体 multipart', () => { ); const [url, init] = fetchMock.mock.calls[0] ?? []; - expect(url).toBe('/api/game-distribution/versions'); + expect(url).toBe('/api/game-distribution/games/game-1/versions/2'); expect(init.headers['Idempotency-Key']).toBeUndefined(); const formData = init.body as FormData; expect(JSON.parse(String(formData.get('metadata')))).toEqual( expect.objectContaining({ - gameId: 'game-1', - versionNumber: 2, priceMudPoints: 120, gameMetadata: expect.objectContaining({ title: '星轨防线' }), }), diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index afa928326..e6a3c88a1 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -383,19 +383,44 @@ export async function deleteMyGame( * 回读失败只丢失该版本的下一步提示,不影响列表与其它作品。 */ export async function loadVersionRecoveryActions( - versionIds: readonly string[], + versions: readonly { + versionId: string; + gameId: string; + versionNumber: number; + }[], ): Promise> { - const uniqueVersionIds = [ - ...new Set(versionIds.map((versionId) => versionId.trim()).filter(Boolean)), - ]; + const unique = new Map< + string, + { versionId: string; gameId: string; versionNumber: number } + >(); + for (const version of versions) { + const versionId = version.versionId.trim(); + const gameId = version.gameId.trim(); + if ( + !versionId || + !gameId || + !Number.isSafeInteger(version.versionNumber) || + version.versionNumber < 1 + ) { + continue; + } + unique.set(versionId, { + versionId, + gameId, + versionNumber: version.versionNumber, + }); + } + const entries = [...unique.values()]; const details = await Promise.allSettled( - uniqueVersionIds.map((versionId) => getGameVersion(versionId)), + entries.map((version) => + getGameVersion(version.gameId, version.versionNumber), + ), ); const actions: Record = {}; - uniqueVersionIds.forEach((versionId, index) => { + entries.forEach((version, index) => { const detail = details[index]; if (detail?.status === 'fulfilled') { - actions[versionId] = detail.value.version.recoveryAction; + actions[version.versionId] = detail.value.version.recoveryAction; } }); return actions; @@ -857,21 +882,40 @@ export function saveMyGameReview( ); } +/** 发布目标:身份只从路径来——新作品 `POST /games`(首版号固定 `1`),追加版本走 `{gameId}/versions/{versionNumber}`。 */ +export type GamePublishVersionPath = + | { mode: 'create' } + | { mode: 'update'; gameId: string; versionNumber: number }; + +/** 对外版本路径:路径即身份,body 不重复 `gameId` / `versionNumber`。 */ +function versionInGamePath(gameId: string, versionNumber: number): string { + const normalizedGameId = gameId.trim(); + if (!normalizedGameId) throw new Error('缺少作品编号'); + if (!Number.isSafeInteger(versionNumber) || versionNumber < 1) { + throw new Error('缺少合法版本号'); + } + return `/games/${encodeURIComponent(normalizedGameId)}/versions/${versionNumber}`; +} + /** - * 统一发布入口:首次发布与更新都走 `POST /versions`。 + * 统一发布入口:首次发布与更新共用同一份 metadata(`NewGameVersionRequest`),只有路径不同。 * - * `metadata` 携带 `gameId`(更新)或 `projectKey`(首次发布)、必填的 `versionNumber` 与包摘要; * `gameMetadata` 首次发布用于 bootstrap 游戏行,更新时冻结进版本资料快照。封面/截图的新图字节 * 作为 `cover` / `screenshot` part 随同一次 multipart 请求提交,服务端只解析一次媒体。 * * 幂等由自然键 `(owner, projectKey | gameId, versionNumber)` 决定,本路由不读 `Idempotency-Key`。 */ export async function publishGameVersion( + target: GamePublishVersionPath, payload: NewGameVersionRequest, media?: GameDistributionPublishMediaParts, ): Promise { + const path = + target.mode === 'create' + ? '/games' + : versionInGamePath(target.gameId, target.versionNumber); return requestJson( - '/versions', + path, { method: 'POST', body: buildGameDistributionFormData(payload, media), @@ -880,12 +924,10 @@ export async function publishGameVersion( ); } -/** 回读单个版本状态、驳回理由与服务端给出的恢复动作。 */ -export async function getGameVersion(versionId: string) { - const normalizedVersionId = versionId.trim(); - if (!normalizedVersionId) throw new Error('缺少版本编号'); +/** 回读单个版本状态、驳回理由与服务端给出的恢复动作;版本由 `(gameId, versionNumber)` 定位。 */ +export async function getGameVersion(gameId: string, versionNumber: number) { return requestJson( - `/versions/${encodeURIComponent(normalizedVersionId)}`, + versionInGamePath(gameId, versionNumber), { method: 'GET' }, '读取版本状态失败', ); @@ -893,18 +935,17 @@ export async function getGameVersion(versionId: string) { /** 撤回尚未公开的版本;幂等键由调用方生成,重复提交返回同一结果。 */ export async function cancelGameVersion( - versionId: string, + gameId: string, + versionNumber: number, expectedPublicationRevision: number, idempotencyKey: string, reason?: string, ) { - const normalizedVersionId = versionId.trim(); const normalizedKey = idempotencyKey.trim(); - if (!normalizedVersionId) throw new Error('撤回版本缺少版本编号'); if (!normalizedKey) throw new Error('撤回版本缺少幂等键'); const normalizedReason = reason?.trim(); return requestJson( - `/versions/${encodeURIComponent(normalizedVersionId)}/cancel`, + `${versionInGamePath(gameId, versionNumber)}/cancel`, { method: 'POST', headers: { 'Idempotency-Key': normalizedKey }, @@ -918,16 +959,15 @@ export async function cancelGameVersion( } export async function submitGameVersion( - versionId: string, + gameId: string, + versionNumber: number, expectedPublicationRevision: number, idempotencyKey: string, ) { - const normalizedVersionId = versionId.trim(); const normalizedKey = idempotencyKey.trim(); - if (!normalizedVersionId) throw new Error('提交审核缺少版本 ID'); if (!normalizedKey) throw new Error('提交审核缺少幂等键'); return requestJson<{ version?: { status?: string } }>( - `/versions/${encodeURIComponent(normalizedVersionId)}/submit`, + `${versionInGamePath(gameId, versionNumber)}/submit`, { method: 'POST', headers: { 'Idempotency-Key': normalizedKey }, @@ -938,17 +978,17 @@ export async function submitGameVersion( } export async function uploadGamePackage( - versionId: string, + gameId: string, + versionNumber: number, packageBody: Blob | ArrayBuffer, idempotencyKey: string, ) { - const normalizedVersionId = versionId.trim(); const normalizedIdempotencyKey = idempotencyKey.trim(); - if (!normalizedVersionId || !normalizedIdempotencyKey) { + if (!normalizedIdempotencyKey) { throw new Error('游戏发行包上传缺少必要标识'); } return requestJson<{ versionId: string; status: 'uploaded' }>( - `/versions/${encodeURIComponent(normalizedVersionId)}/package`, + `${versionInGamePath(gameId, versionNumber)}/package`, { method: 'PUT', headers: {