refactor(game-distribution): 发布面 clean cut 到 {game_id}/versions/{version_number}

- 删除统一 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 路由挂载测试
This commit is contained in:
2026-10-08 00:12:55 +08:00
parent 9606253114
commit 565d6ad7e1
19 changed files with 612 additions and 898 deletions
@@ -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;
};
/**
* 作者编辑游戏级展示资料。
*
@@ -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;
};
@@ -109,13 +109,6 @@ const PAIRS = [
'GameDistributionUpsertThemeMemberRequest',
],
['GameDistributionPurchaseRequest', 'GameDistributionPurchaseRequest'],
// 旧两步发布路径的请求 DTO:两条发布路径并存期间仍是公开契约的一部分,
// 因此与统一路径的 `NewGameVersionRequest` 一样登记、逐字段对齐。
['GameDistributionCreateGameRequest', 'GameDistributionCreateGameRequest'],
[
'GameDistributionCreateVersionRequest',
'GameDistributionCreateVersionRequest',
],
['GameDistributionPurchase', 'GameDistributionPurchase'],
['GameDistributionPurchaseResponse', 'GameDistributionPurchaseResponse'],
[
+1 -2
View File
@@ -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",
File diff suppressed because it is too large Load Diff
@@ -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<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
multipart: Multipart,
) -> Result<Json<Value>, 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<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
Path(path): Path<VersionInGamePath>,
multipart: Multipart,
) -> Result<Json<Value>, 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<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
/// `ExistingVersion` 先按 owner 读一次既有作品,保持 404 语义并让沿用媒体校验有当前行可比;
/// 指向已软删作品时,只有 `gameId` 恰好是本 `projectKey` 的确定性身份才按首次发布继续。
async fn publish_version_core(
state: AppState,
ctx: RequestContext,
auth: AuthenticatedAccessToken,
target: PublishTarget,
multipart: Multipart,
) -> Result<Json<Value>, 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(),
@@ -722,105 +722,6 @@ pub struct GameMetadata {
pub fork: Option<GameDistributionForkMetadata>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionCreateGameRequest {
#[serde(default)]
pub local_project_id: Option<String>,
pub title: String,
pub summary: String,
#[serde(default)]
pub description: Option<String>,
pub category: String,
#[serde(default)]
pub tags: Vec<String>,
/// 沿用线上封面时给出既有对象键;同时携带 `cover` 二进制 part 时以 part 为准。
#[serde(default)]
pub cover_object_key: Option<String>,
/// 与最终截图顺序一一对应:`Some(objectKey)` 沿用线上,`None` 取按顺序出现的 `screenshot` 二进制 part。
#[serde(default)]
pub screenshots: Vec<Option<String>>,
pub device_support: GameDistributionDeviceSupport,
pub input_modes: Vec<GameDistributionInputMode>,
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<GameDistributionForkMetadata>,
}
/// 旧两步发布路径的创建请求与新统一资料形状的桥接:字段集完全一致,只多一个属于外层身份的
/// `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<String>,
/// 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。
///
/// 传入时只要求 `>= 1`:同一 gameId 的同一个版本号可以反复提交,每次提交生成新的
/// versionId,允许重复标签与回退到更小的版本号,不与已有最大值比较。缺省时保留旧客户端
/// 兼容行为:服务端按该游戏已有最大版本号 +1(此时溢出才会返回 409)。
/// 缺省时序列化省略该字段,保证旧客户端请求的幂等摘要与升级前一致。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_number: Option<u64>,
/// 作者提交的买断制价格(泥点);`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<String>,
}
/// 作者编辑游戏级展示资料的请求体。
///
/// 只覆盖游戏行上的展示字段,随版本冻结的包摘要与资料快照不受影响;
@@ -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`;旧响应则可能整个缺键——两种都必须能解析。
@@ -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<String>,
/// 首次发布的本地项目标识;同一作者重复发布会确定性派生出同一 gameId。
///
/// 只在 `POST /games` 必填(无路径槽);追加版本走路径 `{game_id}`,这里可缺省。
#[serde(default)]
pub project_key: Option<String>,
/// 用户可见的正整数版本标签,必填且由客户端在一次发布意图内冻结。
///
/// 版本身份由 `(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::<NewGameVersionRequest>(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("把跳台改成三段,并重画全部背景")
);
}
}
@@ -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);
});
@@ -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(() =>
@@ -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',
);
});
@@ -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`,
);
@@ -119,16 +119,16 @@ export function GameWorkMetadataEditor({
* 网络重试写出两份审计记录。
*/
const saveKeyRef = useRef<string | null>(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() {
@@ -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(() =>
@@ -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),
);
@@ -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 () => {
@@ -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<GameDistributionPublishVersionResponse>;
uploadGamePackage: (
versionId: string,
gameId: string,
versionNumber: number,
packageBody: Blob | ArrayBuffer,
idempotencyKey: string,
) => Promise<unknown>;
submitGameVersion: (
versionId: string,
gameId: string,
versionNumber: number,
expectedPublicationRevision: number,
idempotencyKey: string,
) => Promise<unknown>;
@@ -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`,
);
+39 -36
View File
@@ -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: '星轨防线' }),
}),
+67 -27
View File
@@ -383,19 +383,44 @@ export async function deleteMyGame(
* 回读失败只丢失该版本的下一步提示,不影响列表与其它作品。
*/
export async function loadVersionRecoveryActions(
versionIds: readonly string[],
versions: readonly {
versionId: string;
gameId: string;
versionNumber: number;
}[],
): Promise<Record<string, GameDistributionRecoveryAction>> {
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<string, GameDistributionRecoveryAction> = {};
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<GameDistributionPublishVersionResponse> {
const path =
target.mode === 'create'
? '/games'
: versionInGamePath(target.gameId, target.versionNumber);
return requestJson<GameDistributionPublishVersionResponse>(
'/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<GameDistributionVersionDetail>(
`/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<GameDistributionCancelVersionResponse>(
`/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: {