fix(游戏共创): 未知共创档位回平台信封 400,而不是框架 422 纯文本

- api-server `set_fork_authorization`:载荷由裸 `Json(..)` 改为 `Result<Json<GameDistributionSetForkAuthorizationRequest>, JsonRejection>`,拒绝统一映射成 400 + 平台信封(对齐同模块评价保存 / 评价管理两处的既有写法),文案「共创授权档位不合法,只接受 forbidden / nonCommercial / full」
- 保留领域层 `FORK_AUTHORIZATION_UNKNOWN`(map_spacetime_error 里映射 400):procedure 读到库里存的未知档位字符串时仍由它兜底,HTTP 面不再可达不需要删
- 新增 HTTP 级用例 `set_fork_authorization_maps_unknown_authorization_to_envelope_bad_request`:目标档位未知、期望档位未知各断言 400、平台信封(`ok=false` / `data=null` / `error.code=BAD_REQUEST` / message 含「共创授权档位不合法」/ `meta.apiVersion` 存在),并断言响应体不含框架的 `Failed to deserialize` 纯文本;用生产同一套 `attach_request_context` 中间件 + `x-genarrative-response-envelope: v1` 才拿得到信封形状
- 既有的「共创授权路由未带 Bearer 必须 401」用例保持不变(`catalog_and_publish_routes_are_mounted` 通过)
- 技术方案 §5.1:新增「未知共创档位(HTTP 面合同)」证据行
This commit is contained in:
2026-10-05 00:38:17 +08:00
parent 036989d1cc
commit e9003a33bd
2 changed files with 107 additions and 1 deletions
@@ -500,6 +500,7 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的
| **软删除语义收敛 ①来源校验** | `resolve_game_distribution_fork_declaration_tx` 增加「来源 `deleted_at` 非空 → `FORK_SOURCE_NOT_AVAILABLE`(409)」,显式判定而不是只靠 `visibility` 复位 | ✅ 已验证(`cargo test -p api-server game_distribution`;领域错误码沿用 `FORK_*` 前缀) |
| **软删除语义收敛 ②衍生作品数** | `game_distribution_public_fork_count` 只统计 `deleted_at.is_none() && visibility == published` 的子作品;作者删除子作品后父作品计数随之下降 | ✅ 已验证(同上) |
| **软删除语义收敛 ③溯源摘要** | `game_distribution_lineage_snapshot` 在父(或根)作品被软删除时把 `parent_title` / `root_title` 置空、父作者名置空,api-server 原样发 `null`,TS 契约改为 `string \| null`,详情页降级为「原作品已不可用」 | ✅ 已验证(DTO parity + 前端用例 + `fork_lineage_visible_identity` 单测,「父不可用即使传入标题也必须 `(None, None)`」已由 `cargo test -p module-game-distribution` 断言) |
| **未知共创档位(HTTP 面合同)** | `PUT /api/game-distribution/games/{gameId}/fork-authorization` 载荷里目标或期望档位为未知值(如 `"allowed"`)时返回 **400 + 平台信封**(不再落到 axum 默认的 422 纯文本);新用例 `set_fork_authorization_maps_unknown_authorization_to_envelope_bad_request` 断言 `ok=false`、`data=null`、`error.code=BAD_REQUEST`、message 含「共创授权档位不合法」、带 `meta.apiVersion`,且不带框架的 `Failed to deserialize` 文案。领域层 `FORK_AUTHORIZATION_UNKNOWN` 分支保留(procedure 读到库里未知档位时仍可达) | ✅ 已验证(`cargo test -p api-server game_distribution`,45 passed) |
| **详情页结构对齐 #565** | 详情页改用共享 `GameDetailDisplay`,共创卡通过 `infoCards` 渲染;vitest 用例「展示共创授权、代际与溯源信息,且不提供改造入口」与「旧数据缺少授权与血缘字段时只按禁止共创渲染」通过 | ✅ 已验证 |
---
@@ -2325,8 +2325,17 @@ async fn set_fork_authorization(
Extension(auth): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Path(game_id): Path<String>,
Json(payload): Json<GameDistributionSetForkAuthorizationRequest>,
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)?;
@@ -4668,6 +4677,102 @@ mod tests {
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}"
);
}
}
#[test]
fn recovery_action_covers_every_version_status() {
for (status, expected) in [