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) + ); + } }