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:
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user