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