feat(游戏共创): 上架时即可指定共创授权档位(创建作品请求增量)

- TS 契约 `GameDistributionCreateGameRequest` 新增可选 `forkAuthorization?: GameDistributionForkAuthorization`,注释写明缺省禁止共创、非法值整请求 400、上架后只能单向提升
- Rust 契约同结构体新增 `#[serde(default)] pub fork_authorization: GameDistributionForkAuthorization`(非 `Option`):省略与显式传 `forbidden` 得到同一个值,**幂等摘要因此相同**——创建请求的摘要取自整个请求体,若写成 `Option` + `skip_serializing_if`,两种写法会变成「同 key 不同请求」而互相冲突;未知取值由反序列化直接拒绝
- api-server `create_game`:载荷改为 `Result<Json<..>, JsonRejection>`,拒绝统一映射成平台信封 400(与 `set_fork_authorization` 同修法),并按 rejection 文本区分文案(含 `forkAuthorization` → 「共创授权档位不合法,只接受 forbidden / nonCommercial / full」,其余 → 「创建作品请求字段不合法」;框架文本只用于选文案,不回传客户端);创建记录入参新增 `fork_authorization: fork_authorization_value(payload.fork_authorization)`
- spacetime-client:`GameDistributionCreateGameRecordInput` 新增 `fork_authorization: String` 并透传给 procedure;重新生成的 `GameDistributionCreateGameInput` 同步带上该字段
- spacetime-module:`GameDistributionCreateGameInput` 新增 `fork_authorization: String`;创建事务里用 `ForkAuthorization::parse` 解析,未知取值失败关闭(`FORK_AUTHORIZATION_UNKNOWN`,**不静默落成 forbidden**),并把原来的硬编码 `FORK_AUTHORIZATION_FORBIDDEN` 换成解析结果
- 资料编辑复用创建校验的转换函数与既有测试夹具按新字段补默认值;AGC 侧测试字面量同步(AGC 发布面板的 UI 接线留到后续,缺省值等价于不传)
- 测试:`create_game_request_defaults_fork_authorization_without_changing_digest`(缺省 → forbidden;显式 forbidden 与原缺省**摘要相同**;`nonCommercial` / `full` 原样保留;`"allowed"` 反序列化失败)与 `create_game_rejects_unknown_fork_authorization_with_envelope_bad_request`(未知档位 → 400 + 平台信封且无框架纯文本;非档位字段问题走通用文案;合法载荷不会被误拒——测试态发布灰度未配置,得到 503 而非 400,即证明载荷路径已通过且未触达创建)
- 文档:技术方案 §2.4(发布入口上架时即选档位)、§3.4(`POST /games` 请求增量补 `forkAuthorization`)、§3.9(补充说明上架指定档位已支持、事后提升仍保留)
This commit is contained in:
2026-10-05 01:59:41 +08:00
parent 775dea02ea
commit eeb345f2ad
7 changed files with 223 additions and 4 deletions
@@ -1386,6 +1386,9 @@ 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,
fork: None,
}
}
@@ -1575,8 +1578,18 @@ async fn create_game(
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Json(payload): Json<GameDistributionCreateGameRequest>,
payload: Result<Json<GameDistributionCreateGameRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
// 未知共创档位(例如 `"allowed"`)在进入业务前就被 serde 拦下:必须映射成平台信封的 400,
// 而不是让 axum 默认的 `JsonRejection` 回 422 纯文本(前端错误处理依赖信封)。框架文本只用来
// 选文案,绝不回传:至少不会把「请求体里哪一段长什么样」透给客户端。
let Json(payload) = payload.map_err(|rejection| {
if rejection.body_text().contains("forkAuthorization") {
bad_request("共创授权档位不合法,只接受 forbidden / nonCommercial / full")
} else {
bad_request("创建作品请求字段不合法")
}
})?;
ensure_publish_enabled(&state, Some(auth.claims().user_id())).await?;
let idempotency_key = idempotency_key(&headers)?;
validate_game_metadata(&payload)?;
@@ -1608,6 +1621,7 @@ async fn create_game(
input_modes_json: serde_json::to_string(&payload.input_modes)
.map_err(|error| internal(error.to_string()))?,
orientation: orientation_wire_value(payload.orientation)?,
fork_authorization: fork_authorization_value(payload.fork_authorization),
idempotency_key,
request_digest,
now_micros: now,
@@ -4455,6 +4469,7 @@ mod tests {
},
input_modes: vec![GameDistributionInputMode::Keyboard],
orientation: GameDistributionOrientation::Landscape,
fork_authorization: GameDistributionForkAuthorization::Forbidden,
fork: None,
}
}
@@ -5394,6 +5409,181 @@ mod tests {
assert_eq!(body.len() as u64, target.package_bytes);
}
/// 上架时的共创授权档位:缺省按「禁止共创」解释,显式传值原样保留,未知取值失败关闭。
///
/// 一并钉住幂等摘要口径:创建请求的摘要是对整个请求体取的,所以 DTO 必须写成非 `Option`
/// + `#[serde(default)]`——这样「省略」与「显式传 forbidden」序列化结果相同、摘要相同,
/// 同一个 Idempotency-Key 才会被识别成重放而不是「同 key 不同请求」。
#[test]
fn create_game_request_defaults_fork_authorization_without_changing_digest() {
let base = json!({
"title": "星轨防线",
"summary": "守住最后一条航线。",
"category": "益智",
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
});
let omitted: GameDistributionCreateGameRequest =
serde_json::from_value(base.clone()).expect("省略档位应可解析");
assert_eq!(
omitted.fork_authorization,
GameDistributionForkAuthorization::Forbidden,
"缺省必须是禁止共创(与表列默认一致)"
);
let mut explicit_value = base.clone();
explicit_value["forkAuthorization"] = json!("forbidden");
let explicit: GameDistributionCreateGameRequest =
serde_json::from_value(explicit_value).expect("显式 forbidden 应可解析");
assert_eq!(
explicit.fork_authorization,
GameDistributionForkAuthorization::Forbidden
);
assert_eq!(
compute_request_digest(&serde_json::to_vec(&omitted).expect("序列化")),
compute_request_digest(&serde_json::to_vec(&explicit).expect("序列化")),
"省略与显式传默认档位必须得到同一条幂等摘要"
);
for (value, expected) in [
(
"nonCommercial",
GameDistributionForkAuthorization::NonCommercial,
),
("full", GameDistributionForkAuthorization::Full),
] {
let mut payload = base.clone();
payload["forkAuthorization"] = json!(value);
let parsed: GameDistributionCreateGameRequest =
serde_json::from_value(payload).expect("合法档位应可解析");
assert_eq!(parsed.fork_authorization, expected, "{value}");
}
let mut unknown = base;
unknown["forkAuthorization"] = json!("allowed");
assert!(
serde_json::from_value::<GameDistributionCreateGameRequest>(unknown).is_err(),
"未知档位必须失败关闭,不能静默落成 forbidden"
);
}
/// 创建作品遇到未知共创档位必须回**平台信封的 400**,且不进入创建路径。
///
/// 「400 而不是 503/502」本身就是「没有创建任何作品」的进程内证据:载荷在业务之前就被拒,
/// 连发布灰度(测试态未配置,合法载荷得到 503)都没走到,因此不可能触达数据库与对象存储。
#[tokio::test]
async fn create_game_rejects_unknown_fork_authorization_with_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: "game-author".into(),
session_id: "create-game-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", post(create_game))
.layer(Extension(AuthenticatedAccessToken::new(claims)))
.layer(middleware::from_fn(
crate::request_context::attach_request_context,
))
.with_state(state);
let valid_body = json!({
"title": "星轨防线",
"summary": "守住最后一条航线。",
"category": "益智",
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
});
let unknown_fork_authorization = json!({
"title": "星轨防线",
"summary": "守住最后一条航线。",
"category": "益智",
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
"forkAuthorization": "allowed",
});
for (payload, expected_message) in [
(unknown_fork_authorization, "共创授权档位不合法"),
// 缺字段而非档位问题的请求走另一条文案分支,避免把「所有解析失败」都说成档位问题。
(json!({ "title": "星轨防线" }), "创建作品请求字段不合法"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/game-distribution/games")
.header("content-type", "application/json")
.header("idempotency-key", "create-game-key-1")
.header(API_RESPONSE_ENVELOPE_HEADER, "v1")
.body(Body::from(payload.to_string()))
.expect("请求"),
)
.await
.expect("路由响应");
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{payload}");
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"),
"不得返回框架的纯文本拒绝:{text}"
);
let envelope: Value = serde_json::from_str(&text).expect("必须是平台信封 JSON");
assert_eq!(envelope["ok"], Value::Bool(false), "{text}");
assert_eq!(
envelope["error"]["code"],
Value::String("BAD_REQUEST".into()),
"{text}"
);
assert!(
envelope["error"]["message"]
.as_str()
.is_some_and(|message| message.contains(expected_message)),
"期望 message 含「{expected_message}」:{text}"
);
}
// 合法载荷不会被误拒:这里应当走到发布灰度(测试态未配置 → 503),而不是 400。
let valid = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/game-distribution/games")
.header("content-type", "application/json")
.header("idempotency-key", "create-game-key-2")
.body(Body::from(valid_body.to_string()))
.expect("请求"),
)
.await
.expect("路由响应");
assert_eq!(valid.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn recovery_action_covers_every_version_status() {
for (status, expected) in [