From cc7d2141360f3c3f725bc555a47cbd5269d416a2 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 01:13:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E6=94=B6=E5=BD=95=EF=BC=88=E6=94=B6=E8=97=8F=EF=BC=89=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=9A=E4=B8=89=E6=9D=A1=E8=B7=AF=E7=94=B1=20+=20?= =?UTF-8?q?=E5=85=AC=E5=BC=80=E8=AF=A6=E6=83=85=20collected=EF=BC=88?= =?UTF-8?q?=E5=9D=97=202/3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `collections` 路由组(整组 `require_bearer_auth` + `add_no_store_response_headers`,用户态读不会被任何缓存复用): · `PUT /api/game-distribution/games/{game_id}/collection` → `{ collected: true, replayed }`,要求 `Idempotency-Key`(沿用既有 `idempotency_key(&headers)?`);同键重放 `replayed=true`,同键不同请求(摘要绑定 `(userId, gameId)`)→ 409,重复收藏(不同键)只留一行且成功 · `DELETE .../collection` → `{ collected: false }`,**不要求 Idempotency-Key**:按确定性主键删除本就幂等(不存在也算成功),没有「重放 vs 新意图」可区分;也**不要求作品仍公开**(下架后仍可清理) · `GET /api/game-distribution/my-collections` → `{ games: […], nextCursor: null }`,逐条复用既有公开目录的组装方式,只含当前公开可读作品 - 公开详情 `GET /games/{game_id}` 增量:**已认证**返回 `collected: true|false`;**匿名不返回该键**(选这种口径的理由:`false` 会把「未登录」说成「没收藏」,客户端无法区分就会渲染出错误的按钮态);携带无效/过期 token 时按**匿名**处理(沿用 `record_game_play` 的取舍——公开详情匿名可读,不该因此掉 401),因此该响应与纯匿名逐字节相同(单测断言)。实现复用既有 `auth::optional_access_token_from_headers`(`auth.rs:223`),**没有自造 extractor**;值来自新 procedure(真实投影),**未改动既有公开快照契约** - **共享缓存核实(硬性项)**:公开详情所在的 `public_games` 路由组带 `add_no_store_response_headers`;`app::build_router` 只做 merge/背压/错误归一化/request_id/tracing/`attach_request_context`,**没有任何响应体缓存或预渲染**;本模块唯一进程内缓存是 `RELEASE_PACKAGE_CACHE`(键 = OSS 对象键、值 = 发行包字节),与 per-user JSON 无关。结论:**该路径无共享缓存,不需要绕开** - 错误码:作品不存在 → 404;未公开/已软删除/无当前公开版本 → 409(文案「作品状态不允许收藏(未公开或已软删除)」刻意含「状态」而不含「已被删除」,以命中既有映射的 409 分支而非 404 分支) - 测试(本文件内,新增 5 条):三条路由无 Bearer → 401 且 `no-store`;404/409/摘要不一致 409 的映射;摘要绑定 `(user, game)` 的幂等隔离;PUT/DELETE 的载荷形状;公开详情「匿名无 collected 且与目录负载逐字节相等、登录时 Some(true)/Some(false) 各出现且只多这一个键」 - 门禁:`cargo test -p api-server game_distribution` **70 passed**(含新增 5 条);`cargo check --all-targets` 0 --- .../src/modules/game_distribution.rs | 395 +++++++++++++++++- 1 file changed, 382 insertions(+), 13 deletions(-) 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 53d4d9291..899accad8 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -42,7 +42,7 @@ use shared_contracts::admin::{ }; use shared_contracts::game_distribution::{ GAME_DISTRIBUTION_CATEGORIES, GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT, - GameDistributionAuthor, GameDistributionCreateGameRequest, + GameDistributionAuthor, GameDistributionCollectionState, GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, GameDistributionDerivedResponse, GameDistributionForkAuthorization, GameDistributionForkSource, GameDistributionForkSourceKind, GameDistributionForkSourceResponse, GameDistributionInputMode, GameDistributionLineageNode, @@ -57,16 +57,17 @@ use spacetime_client::{ GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord, GameDistributionAdminUserReviewListRecordInput, GameDistributionAdminUserReviewRecord, GameDistributionAdminVersionRecord, GameDistributionApproveRecordInput, - GameDistributionCancelVersionRecordInput, GameDistributionDeleteGameRecordInput, - GameDistributionDerivedGamesRecord, GameDistributionForkSourceRecord, - GameDistributionGameRecord, GameDistributionGetGameRecordInput, - GameDistributionLineageNodeRecord, GameDistributionLineageTreeRecord, - GameDistributionOwnerGameRecord, GameDistributionPublicGameListRecordInput, - GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, - GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, - GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationOperationRecord, - GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, - GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, + GameDistributionCancelVersionRecordInput, GameDistributionCollectGameRecordInput, + GameDistributionDeleteGameRecordInput, GameDistributionDerivedGamesRecord, + GameDistributionForkSourceRecord, GameDistributionGameRecord, + GameDistributionGetGameRecordInput, GameDistributionLineageNodeRecord, + GameDistributionLineageTreeRecord, GameDistributionOwnerGameRecord, + GameDistributionPublicGameListRecordInput, GameDistributionPublicGameRecord, + GameDistributionRatingSummaryRecord, GameDistributionRejectRecordInput, + GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, + GameDistributionReviewModerationOperationRecord, GameDistributionReviewModerationRecordInput, + GameDistributionSetForkAuthorizationRecordInput, GameDistributionSubmitReviewRecordInput, + GameDistributionSuspendRecordInput, GameDistributionUncollectGameRecordInput, GameDistributionUnpublishRecordInput, GameDistributionUpdateMetadataRecordInput, GameDistributionUserReviewRecord, GameDistributionVersionRecord, SpacetimeClientError, }; @@ -332,6 +333,24 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )) .route_layer(middleware::from_fn(add_no_store_response_headers)); + // 收藏(收录):登录用户的真实用户态投影,绝不虚构收藏状态。 + // - PUT 需要 `Idempotency-Key`(重放与「重复收藏」要靠收据区分); + // - DELETE 按确定性主键 `{userId}:{gameId}` 删除,天然幂等,所以**不要求**幂等键; + // - 读路径是 per-user 数据,整组 `no-store`,不给任何共享缓存留缝。 + let collections = Router::new() + .route( + "/api/game-distribution/games/{game_id}/collection", + put(collect_game).delete(uncollect_game), + ) + .route( + "/api/game-distribution/my-collections", + get(list_my_collections), + ) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .route_layer(middleware::from_fn(add_no_store_response_headers)); let protected = Router::new() .route( "/api/game-distribution/publish-metadata/suggestions", @@ -499,6 +518,7 @@ pub fn router(state: AppState) -> Router { .merge(protected) .merge(user_reviews) .merge(fork_sources) + .merge(collections) .merge(admin_user_reviews) .merge(admin) } @@ -1288,15 +1308,188 @@ async fn list_games( async fn get_game( State(state): State, Extension(ctx): Extension, + headers: HeaderMap, Path(game_id): Path, ) -> Result, AppError> { let game = state .spacetime_client() - .get_public_game_distribution_game(game_id) + .get_public_game_distribution_game(game_id.clone()) .await .map_err(map_spacetime_error)? .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; - Ok(json_success_body(Some(&ctx), public_game_payload(game))) + // 可选登录态:登录时才追加 `collected`(真实投影,不是前端本地状态)。 + // + // 无效 / 过期 token 按**匿名**处理(与 `record_game_play` 同一取舍):公开详情是匿名可读的, + // 不能因为客户端带着一个过期 token 就把整页变成 401;客户端刷新 token 后会重新拉取。 + // 这也意味着「带了 token 但被按匿名处理」时返回的响应与纯匿名完全相同——即**不带**该键。 + let collected = match optional_access_token_from_headers( + &state, + format!("/api/game-distribution/games/{game_id}"), + headers, + ctx.request_id().to_string(), + ) + .await + { + Ok(Some(authenticated)) => { + let user_id = authenticated.claims().user_id().to_string(); + Some( + state + .spacetime_client() + .is_game_distribution_collected(game_id.clone(), user_id) + .await + .map_err(map_spacetime_error)?, + ) + } + Ok(None) => None, + Err(error) => { + debug!(error = %error, "公开详情忽略无效 bearer,按匿名返回"); + None + } + }; + Ok(json_success_body( + Some(&ctx), + public_game_detail_payload(game, collected), + )) +} + +/// 公开详情负载:在公开目录那条 `public_game_payload` 之上按可选登录态追加 `collected`。 +/// +/// 抽成函数而不是写在 handler 里,一是让「登录才加键、匿名不加键」能被单测钉住,二是它同时是 +/// DTO parity 脚本登记的响应构建器(证明这条路径确实会发出 `collected`)。 +/// +/// `collected == None`(匿名 / 无效 token 按匿名)时**不加键**,而不是发 `false`:`false` 会把 +/// 「未登录」说成「没收藏」,客户端无法区分,会渲染出错误的收藏按钮态。 +fn public_game_detail_payload( + game: GameDistributionPublicGameRecord, + collected: Option, +) -> Value { + let mut payload = public_game_payload(game); + if let Some(collected) = collected { + if let Value::Object(object) = &mut payload { + object.insert("collected".to_string(), json!(collected)); + } + } + payload +} + +/// 收藏(收录)的请求摘要:只绑定 `(user_id, game_id)`。 +/// +/// 服务端的收据键是 `(user_id, action, idempotency_key)`,而 `Idempotency-Key` 由客户端生成、 +/// 可能在不同作品之间复用。摘要里带上这对组合,才让「同一个 key 撞到不同作品 / 不同用户」被 +/// 判成同键不同请求(409),而不是把别人的收藏结果重放给当前请求者。 +fn collection_request_digest(user_id: &str, game_id: &str) -> Result { + Ok(compute_request_digest( + &serde_json::to_vec(&(user_id, game_id)).map_err(|error| internal(error.to_string()))?, + )) +} + +/// 收藏(收录)某作品:`PUT /games/{gameId}/collection`。 +/// +/// 幂等语义:必须带 `Idempotency-Key`。同键重放返回同一结果并带 `replayed = true`; +/// 同键不同请求(换作品 / 换用户)是 409;重复收藏(不同键)不会产生第二行,仍然成功。 +async fn collect_game( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(game_id): Path, +) -> Result, AppError> { + let user_id = auth.claims().user_id().to_string(); + let idempotency_key = idempotency_key(&headers)?; + let request_digest = collection_request_digest(user_id.as_str(), game_id.as_str())?; + let collection = state + .spacetime_client() + .set_game_distribution_collection(GameDistributionCollectGameRecordInput { + game_id: game_id.clone(), + user_id: user_id.clone(), + idempotency_key, + request_digest, + now_micros: now_micros(), + }) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_collection_set", + game_id = %game_id, + replayed = collection.replayed, + elapsed_ms = ctx.elapsed(), + "收藏游戏作品" + ); + Ok(json_success_body( + Some(&ctx), + GameDistributionCollectionState { + collected: collection.collected, + replayed: Some(collection.replayed), + }, + )) +} + +/// 取消收藏(收录):`DELETE /games/{gameId}/collection`。 +/// +/// **不要求 `Idempotency-Key`**:删除按确定性主键 `{userId}:{gameId}` 执行,重复调用结果完全 +/// 相同(不存在也算成功),没有「重放 vs 新意图」需要区分——幂等键只在请求本身无法表达意图时 +/// 才有意义。也**不要求作品仍公开**:下架后拒绝取消只会给用户留下清理不掉的脏行。 +async fn uncollect_game( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Path(game_id): Path, +) -> Result, AppError> { + let user_id = auth.claims().user_id().to_string(); + let collection = state + .spacetime_client() + .unset_game_distribution_collection(GameDistributionUncollectGameRecordInput { + game_id: game_id.clone(), + user_id, + }) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_collection_unset", + game_id = %game_id, + elapsed_ms = ctx.elapsed(), + "取消收藏游戏作品" + ); + Ok(json_success_body( + Some(&ctx), + GameDistributionCollectionState { + collected: collection.collected, + // 取消没有幂等键,因此不下发 `replayed`(`skip_serializing_if` 会略过该键)。 + replayed: None, + }, + )) +} + +/// 「我的收藏(收录)」:`GET /my-collections`。 +/// +/// 逐条用公开目录同一份 `public_game_payload` 组装,形状与公开目录一致(`games` + `nextCursor`)。 +/// 只返回当前公开可读的作品;已下架 / 软删除的收藏**只是不在响应里**,行不删除——作品重新 +/// 公开后会自动回来。 +async fn list_my_collections( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, +) -> Result, AppError> { + let user_id = auth.claims().user_id().to_string(); + let games = state + .spacetime_client() + .list_game_distribution_collections(user_id) + .await + .map_err(map_spacetime_error)?; + Ok(json_success_body(Some(&ctx), my_collections_payload(games))) +} + +/// 「我的收藏」响应负载:分页字段与公开目录逐字一致(收藏量受用户自身规模约束,游标恒为 null)。 +/// +/// 同步纯函数:既是 handler 的组装点,也是 DTO parity 脚本登记的响应构建器。 +fn my_collections_payload(games: Vec) -> Value { + let games = games + .into_iter() + .map(public_game_payload) + .collect::>(); + json!({ "games": games, "nextCursor": Value::Null }) } /// 读接口的「不可读即 404」:族谱与衍生列表的锚点必须公开可读,否则按「不存在」处理。 @@ -7566,4 +7759,180 @@ mod tests { headers.insert(header::USER_AGENT, " test-agent ".parse().unwrap()); assert_eq!(user_agent_tag(&headers), "test-agent"); } + + /// 收藏三条路由都必须先过登录门禁,并整组 `no-store`(用户态读接口不得被任何共享缓存复用)。 + /// + /// 测试态没有可用数据库,所以证明点是「已挂载且被认证中间件挡下」(401 + no-store), + /// 而不是 404 / 405;成功路径留给 dev 栈端到端。 + #[tokio::test] + async fn collection_routes_require_bearer_and_are_no_store() { + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + let app = + crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap()); + for (method, uri) in [ + ("PUT", "/api/game-distribution/games/game_1/collection"), + ("DELETE", "/api/game-distribution/games/game_1/collection"), + ("GET", "/api/game-distribution/my-collections"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("idempotency-key", "collection-key-1") + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{method} {uri}" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "{method} {uri} 是用户态接口,必须不缓存" + ); + } + } + + /// 收藏的错误码:作品不存在 → 404;状态不允许收藏 → 409;同键不同摘要 → 409。 + /// + /// 这条测试是「文案 ↔ HTTP 语义」的唯一连结处:模块侧只发文案,映射在这一层, + /// 所以这里必须钉住 `状态` 子串不被 `不存在` / `已被删除` / `FORK_` 抢先命中。 + #[test] + fn collection_errors_map_to_not_found_and_conflict() { + let conflict_message = + shared_contracts::game_distribution::GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT; + assert!( + conflict_message.contains("状态"), + "409 依赖 `状态` 子串命中冲突分支" + ); + assert!( + !conflict_message.contains("不存在") + && !conflict_message.contains("已被删除") + && !conflict_message.contains("FORK_"), + "冲突文案不得抢先命中 404 / 共创分支:{conflict_message}" + ); + for (message, expected) in [ + ("作品不存在".to_string(), StatusCode::NOT_FOUND), + (conflict_message.to_string(), StatusCode::CONFLICT), + ( + "幂等键对应的请求摘要不一致".to_string(), + StatusCode::CONFLICT, + ), + ] { + assert_eq!( + map_spacetime_error(SpacetimeClientError::Procedure(message.clone())).status_code(), + expected, + "{message}" + ); + } + } + + /// 幂等摘要必须绑定 `(user_id, game_id)`:不同作品 / 不同用户互不干扰。 + #[test] + fn collection_request_digest_binds_user_and_game() { + let baseline = collection_request_digest("usr_1", "game_a").expect("摘要可算"); + assert_eq!( + baseline, + collection_request_digest("usr_1", "game_a").expect("摘要可算") + ); + assert_ne!( + baseline, + collection_request_digest("usr_1", "game_b").expect("摘要可算") + ); + assert_ne!( + baseline, + collection_request_digest("usr_2", "game_a").expect("摘要可算") + ); + } + + /// PUT / DELETE 的响应形状:`collected` 一定在;`replayed` 只有 PUT 发。 + #[test] + fn collection_state_payload_shape_matches_contract() { + let put = serde_json::to_value(GameDistributionCollectionState { + collected: true, + replayed: Some(false), + }) + .expect("PUT 响应应可序列化"); + assert_eq!(put, json!({ "collected": true, "replayed": false })); + let delete = serde_json::to_value(GameDistributionCollectionState { + collected: false, + replayed: None, + }) + .expect("DELETE 响应应可序列化"); + assert_eq!(delete, json!({ "collected": false })); + } + + fn public_game_record_fixture() -> GameDistributionPublicGameRecord { + GameDistributionPublicGameRecord { + game: GameDistributionGameRecord { + game_id: "game_1".to_string(), + owner_user_id: "user_1".to_string(), + title: "收藏测试作品".to_string(), + summary: "摘要".to_string(), + description: "描述".to_string(), + category: "益智".to_string(), + tags_json: "[]".to_string(), + cover_asset_id: None, + author_name: None, + author_avatar_url: None, + device_support_desktop: true, + device_support_mobile: false, + device_support_touch: false, + input_modes_json: "[]".to_string(), + orientation: "responsive".to_string(), + publication_revision: 1, + active_version_id: Some("version_1".to_string()), + visibility: "published".to_string(), + play_count: 0, + created_at: "2026-09-20T00:00:00Z".to_string(), + updated_at: "2026-09-20T00:00:00Z".to_string(), + local_project_id: None, + cover_object_key: None, + screenshots_json: None, + fork_authorization: "forbidden".to_string(), + }, + current_version: None, + rating_summary: GameDistributionRatingSummaryRecord { + average_score: None, + rating_count: 0, + }, + fork_count: 0, + lineage: None, + } + } + + /// 公开详情的 `collected` 可见性:登录才加键;匿名**不加键**(不是 `false`)。 + /// + /// `false` 会把「未登录」说成「没收藏」,客户端无法区分,会渲染出错误的按钮态。 + #[test] + fn public_game_detail_payload_only_adds_collected_for_signed_in_viewer() { + let anonymous = public_game_detail_payload(public_game_record_fixture(), None); + assert!( + anonymous.get("collected").is_none(), + "匿名请求不得带 collected:{anonymous}" + ); + // 匿名负载与公开目录负载逐字节一致(这条路径不引入任何 per-user 字段)。 + assert_eq!(anonymous, public_game_payload(public_game_record_fixture())); + + let collected = public_game_detail_payload(public_game_record_fixture(), Some(true)); + assert_eq!(collected["collected"], Value::Bool(true)); + let not_collected = public_game_detail_payload(public_game_record_fixture(), Some(false)); + assert_eq!(not_collected["collected"], Value::Bool(false)); + // 收藏态只加这一个键,不会顺手把别的私有字段带出去。 + assert_eq!( + not_collected.as_object().map(|object| object.len()), + anonymous.as_object().map(|object| object.len() + 1) + ); + } }