refactor(game-distribution): 共创授权并入资料 PATCH,公开读路由对齐 forks/network

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