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:
@@ -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'],
|
||||
[
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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: '星轨防线' }),
|
||||
}),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user