feat(游戏共创): 收录(收藏)接口:三条路由 + 公开详情 collected(块 2/3)

- 新增 `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: [<public_game_payload>…], 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
This commit is contained in:
2026-10-06 01:13:37 +08:00
parent 77c30bb7b2
commit cc7d214136
@@ -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<AppState> {
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<AppState> {
.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<AppState>,
Extension(ctx): Extension<RequestContext>,
headers: HeaderMap,
Path(game_id): Path<String>,
) -> Result<Json<Value>, 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<bool>,
) -> 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<String, AppError> {
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<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Path(game_id): Path<String>,
) -> Result<Json<Value>, 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<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
Path(game_id): Path<String>,
) -> Result<Json<Value>, 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<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<GameDistributionPublicGameRecord>) -> Value {
let games = games
.into_iter()
.map(public_game_payload)
.collect::<Vec<_>>();
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)
);
}
}