From e9003a33bd90137a329f27fc1b3129014e2590c1 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 00:38:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E6=9C=AA=E7=9F=A5=E5=85=B1=E5=88=9B=E6=A1=A3=E4=BD=8D=E5=9B=9E?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=BF=A1=E5=B0=81=20400=EF=BC=8C=E8=80=8C?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E6=A1=86=E6=9E=B6=20422=20=E7=BA=AF=E6=96=87?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-server `set_fork_authorization`:载荷由裸 `Json(..)` 改为 `Result, 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 面合同)」证据行 --- ...�技术方案】游戏共创与作品Fork-2026-10-03.md | 1 + .../src/modules/game_distribution.rs | 107 +++++++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index ced248b50..8ac98d729 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -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 用例「展示共创授权、代际与溯源信息,且不提供改造入口」与「旧数据缺少授权与血缘字段时只按禁止共创渲染」通过 | ✅ 已验证 | --- diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index ceb3d3acc..e287b4b78 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -2325,8 +2325,17 @@ async fn set_fork_authorization( Extension(auth): Extension, headers: HeaderMap, Path(game_id): Path, - Json(payload): Json, + payload: Result, JsonRejection>, ) -> Result, 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 [