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:
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user