diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index e4b1f173a..000f652df 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -338,7 +338,7 @@ pub(crate) project_bundle_sha256: Option, | --- | --- | | `PUT /games/{gameId}/collection` | 收藏。要求 `Idempotency-Key`;请求摘要绑定 `(userId, gameId)`。**幂等**:同键重放返回 `replayed: true`(本次没有新事实),同键不同请求(换作品 / 换用户)→ **409**。**防重靠结构**:主键是确定性构造的 `{userId}:{gameId}`,同一 (用户, 作品) 不可能出现第二行(重复收藏只留一行、仍算成功),不靠「事务里先查后写」,也不额外建唯一索引。错误码:作品不存在 → **404**;未公开 / 已软删除 / 没有当前公开版本 → **409**(失败关闭,文案 `作品状态不允许收藏(未公开或已软删除)`,不含「不存在」/「已被删除」,不用错误码泄露未公开作品的存在性)。响应 `{ collected: true, replayed: boolean }` | | `DELETE /games/{gameId}/collection` | 取消收藏。**不要求 `Idempotency-Key`**:按确定性主键删除,重复调用结果完全相同(不存在也算成功),没有「重放 vs 新意图」需要区分。**也不要求作品仍公开 / 未被删除**:下架后拒绝取消只会给用户留下清理不掉的脏行。响应 `{ collected: false }`(**不带** `replayed`) | -| `GET /my-collections` | 「我的收藏(收录)」列表:逐条用公开目录同一份 `public_game_payload` 投影,形状与公开目录一致 `{ games, nextCursor }`(`nextCursor` 恒为 `null`)。**只含当前公开可读的作品**;未公开 / 已软删除的行在投影时被跳过但**不删除**,作品重新公开后自动回到列表 | +| `GET /my-collections?limit=&cursor=`(2026-10-06 补真分页) | 「我的收藏(收录)」列表:逐条用公开目录同一份 `public_game_payload` 投影,形状与公开目录一致 `{ games, nextCursor }`。**真游标分页**:`limit` 缺省 **20**(网格一屏)、上限 **50**(约束单响应体积;与后台列表的 200 口径**不必相等**),超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{collectionId}"`(与后台列表同一套 `"{micros}:{id}"` 惯例,`collectionId` = `{userId}:{gameId}` 自带冒号,解析只切第一个冒号);**格式非法 → 400**;`nextCursor` 为真实值,**最后一页为 `null`**。排序:**收藏时间倒序,同值用 `collectionId` 升序兜底**(`collectionId` 唯一 ⇒ 全序,翻页不重不漏)。分页顺序定义为「**先按可见性过滤、再排序切页**」——游标位置落在已过滤序列上,否则每翻一页都会漏掉自己的若干条收藏。**只含当前公开可读的作品**;未公开 / 已软删除的行在投影时被跳过但**不删除**,作品重新公开后自动回到列表 | #### 后台(admin) diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index a6e4452f7..d958205c4 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -305,6 +305,19 @@ export type GameDistributionListResponse = { nextCursor?: string | null; }; +/** + * 「我的收藏(收录)」列表接口(`GET /api/game-distribution/my-collections`)的响应体。 + * + * 形状与公开目录 `GameDistributionListResponse` 一致(同一份公开投影),但分页语义不同: + * 默认一页 20 条、上限 50 条,排序按「收藏时间倒序 + collectionId 升序兜底」。 + * `nextCursor` 是真实游标(`"{createdAtMicros}:{collectionId}"`),最后一页为 `null`; + * 游标格式非法时服务端返回 400(不是 200 的空页)。 + */ +export type GameDistributionMyCollectionsResponse = { + games: GameDistributionGame[]; + nextCursor?: string | null; +}; + export type GameDistributionCreateGameRequest = { /** 发布方本地项目标识;同一作者重复发布会复用既有 gameId。 */ localProjectId?: string | null; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 28a0194b4..8cd188e57 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -80,8 +80,13 @@ const PAIRS = [ 'GameDistributionSetForkAuthorizationRequest', 'GameDistributionSetForkAuthorizationRequest', ], - // 收藏(收录):PUT / DELETE 共用同一个权威投影值形状(`replayed` 只有 PUT 会发)。 + // 收藏(收录):PUT / DELETE 共用同一个权威投影值形状(`replayed` 只有 PUT 会发); + // 列表响应用独立类型登记,避免与公开目录的分页口径混成一个契约。 ['GameDistributionCollectionState', 'GameDistributionCollectionState'], + [ + 'GameDistributionMyCollectionsResponse', + 'GameDistributionMyCollectionsResponse', + ], ['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'], ['AdminGameReviewGame', 'AdminGameReviewGame'], ['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'], @@ -142,9 +147,10 @@ const RESPONSE_BUILDERS = [ { fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' }, { fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' }, { - // 「我的收藏」复用公开目录的分页字段与条目形状;游标恒为 null(收藏量受用户自身规模约束)。 + // 「我的收藏」复用公开目录的条目形状,但有自己的分页契约(默认 20 / 上限 50 / 真实游标): + // `nextCursor` 是这条路径**必须**发出的键(最后一页发 `null`,不是省略)。 fn: 'my_collections_payload', - ts: 'GameDistributionListResponse', + ts: 'GameDistributionMyCollectionsResponse', mustEmit: ['games', 'nextCursor'], }, ]; 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 899accad8..f70c97a22 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -19,11 +19,12 @@ use axum::{ }; use flate2::{Compression as GzipCompression, write::GzEncoder}; use module_game_distribution::{ + GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX, MAX_PACKAGE_BYTES, MAX_PROJECT_BUNDLE_BYTES, ProjectBundleError, ProjectBundleManifest, ReleaseAssetError, ReleasePackageError, ReleasePackageManifest, compute_request_digest, - extract_release_asset, normalize_review_comment, normalize_review_moderation_reason, - release_asset_content_type, validate_project_bundle_zip, validate_release_zip, - validate_review_list_status, + extract_release_asset, game_distribution_collection_page_limit, normalize_review_comment, + normalize_review_moderation_reason, release_asset_content_type, validate_project_bundle_zip, + validate_release_zip, validate_review_list_status, }; use platform_auth::read_refresh_session_token; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest}; @@ -261,6 +262,17 @@ struct AdminGameListQuery { cursor: Option, } +/// 「我的收藏(收录)」列表的查询串:`limit` + `cursor`。 +/// +/// 分页口径与后台列表**同构但数值不同**:默认 20(网格一屏)、上限 50(约束单响应体积)。 +/// 两处口径不必相等——后台是运营表格视图,需要一次扫读更多行;用户态网格按屏取数。 +/// 数值本身只在 `module_game_distribution` 里定义一次,这里引用常量而不是再抄一遍。 +#[derive(Debug, Deserialize)] +struct MyCollectionsQuery { + limit: Option, + cursor: Option, +} + /// 作者软删除游戏:CAS 修订号走查询串,删除本身没有请求体。 #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1462,34 +1474,70 @@ async fn uncollect_game( )) } -/// 「我的收藏(收录)」:`GET /my-collections`。 +/// 「我的收藏(收录)」:`GET /my-collections?limit=&cursor=`。 /// /// 逐条用公开目录同一份 `public_game_payload` 组装,形状与公开目录一致(`games` + `nextCursor`)。 /// 只返回当前公开可读的作品;已下架 / 软删除的收藏**只是不在响应里**,行不删除——作品重新 /// 公开后会自动回来。 +/// +/// 分页:默认 20、上限 50,超界**截断**(与后台列表一致:客户端拿到一个完整页,而不是重试错误); +/// 游标格式非法由模块侧报错并在这里透传成 400(不吞掉、也不自己造一种 200 的空页)。 async fn list_my_collections( State(state): State, Extension(ctx): Extension, Extension(auth): Extension, + Query(query): Query, ) -> Result, AppError> { let user_id = auth.claims().user_id().to_string(); - let games = state + // 与模块侧同一套归一化(同一函数),因此日志里的 `limit` 就是真正生效的页大小。 + let limit = my_collections_page_limit(query.limit); + let cursor = normalize_optional(query.cursor); + let (games, next_cursor) = state .spacetime_client() - .list_game_distribution_collections(user_id) + .list_game_distribution_collections(user_id, limit, cursor.clone()) .await .map_err(map_spacetime_error)?; - Ok(json_success_body(Some(&ctx), my_collections_payload(games))) + info!( + request_id = ctx.request_id(), + operation = "game_collections_listed", + games = games.len(), + limit, + max_limit = GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX, + has_cursor = cursor.is_some(), + has_more = next_cursor.is_some(), + elapsed_ms = ctx.elapsed(), + "读取我的收藏列表" + ); + Ok(json_success_body( + Some(&ctx), + my_collections_payload(games, next_cursor), + )) } -/// 「我的收藏」响应负载:分页字段与公开目录逐字一致(收藏量受用户自身规模约束,游标恒为 null)。 +/// 「我的收藏」响应负载:分页字段与公开目录逐字一致(`games` + `nextCursor`)。 +/// +/// `nextCursor` 是**真实**游标:还有下一页时给出,最后一页为 `null`,客户端据此决定是否继续拉。 /// /// 同步纯函数:既是 handler 的组装点,也是 DTO parity 脚本登记的响应构建器。 -fn my_collections_payload(games: Vec) -> Value { +fn my_collections_payload( + games: Vec, + next_cursor: Option, +) -> Value { let games = games .into_iter() .map(public_game_payload) .collect::>(); - json!({ "games": games, "nextCursor": Value::Null }) + json!({ "games": games, "nextCursor": next_cursor }) +} + +/// 查询串的 `limit` → 生效页大小:缺省取默认 20,超界截断到上限 50(`0` 也取默认)。 +/// +/// 归一化本身委托给 `module_game_distribution::game_distribution_collection_page_limit`, +/// 与事务里真正切页用的是**同一个函数**,避免「日志写 50、实际发了 20」这类漂移。 +fn my_collections_page_limit(limit: Option) -> u32 { + game_distribution_collection_page_limit( + limit.unwrap_or(GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT), + ) as u32 } /// 读接口的「不可读即 404」:族谱与衍生列表的锚点必须公开可读,否则按「不存在」处理。 @@ -7873,6 +7921,71 @@ mod tests { assert_eq!(delete, json!({ "collected": false })); } + /// `limit` 口径:缺省 20、超界截断到 50、`0` 取默认;都不是报错。 + /// + /// 这条测试同时钉住「api-server 与模块侧同源」:归一化函数就是模块里的那一个, + /// 因此 handler 记的 `limit` 与事务真正用的页大小不可能对不上。 + #[test] + fn my_collections_limit_defaults_and_truncates() { + assert_eq!(my_collections_page_limit(None), 20); + assert_eq!(my_collections_page_limit(Some(0)), 20); + assert_eq!(my_collections_page_limit(Some(5)), 5); + assert_eq!(my_collections_page_limit(Some(50)), 50); + assert_eq!( + my_collections_page_limit(Some(51)), + 50, + "超界截断而不是报错" + ); + assert_eq!(my_collections_page_limit(Some(u32::MAX)), 50); + // 与后台列表口径**不必相等**,但两个数都必须是「正数且有界」。 + assert!(my_collections_page_limit(None) > 0); + assert!(GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX <= 200); + } + + /// 响应形状:`games` 与 `nextCursor` 两个键一定发出;游标是真实值,最后一页为 `null`。 + #[test] + fn my_collections_payload_carries_real_next_cursor() { + let with_more = my_collections_payload(Vec::new(), Some("100:usr_1:game_a".to_string())); + assert_eq!( + with_more, + json!({ "games": [], "nextCursor": "100:usr_1:game_a" }) + ); + let last_page = my_collections_payload(Vec::new(), None); + assert_eq!(last_page, json!({ "games": [], "nextCursor": Value::Null })); + // 有内容时 `games` 走公开目录同一份投影,而不是另造一种条目形状。 + let page = my_collections_payload(vec![public_game_record_fixture()], None); + assert_eq!(page["games"].as_array().map(Vec::len), Some(1)); + assert_eq!(page["games"][0]["id"], "game_1"); + } + + /// 非法游标必须落 400:模块侧文案经 `map_spacetime_error` 兜底分支,不得被 404 / 409 子串抢先命中。 + #[test] + fn my_collections_invalid_cursor_maps_to_bad_request() { + let message = + module_game_distribution::parse_game_distribution_collection_cursor("不是游标") + .expect_err("非法游标必须报错"); + assert!(message.contains("格式无效"), "{message}"); + for forbidden in [ + "不存在", + "已被删除", + "状态", + "不匹配", + "幂等", + "已存在", + "FORK_", + ] { + assert!( + !message.contains(forbidden), + "游标错误文案不得含「{forbidden}」:{message}" + ); + } + assert_eq!( + map_spacetime_error(SpacetimeClientError::Procedure(message.clone())).status_code(), + StatusCode::BAD_REQUEST, + "{message}" + ); + } + fn public_game_record_fixture() -> GameDistributionPublicGameRecord { GameDistributionPublicGameRecord { game: GameDistributionGameRecord { diff --git a/server-rs/crates/module-game-distribution/src/collection.rs b/server-rs/crates/module-game-distribution/src/collection.rs index e52b46367..e4c702395 100644 --- a/server-rs/crates/module-game-distribution/src/collection.rs +++ b/server-rs/crates/module-game-distribution/src/collection.rs @@ -1,7 +1,22 @@ //! 收藏(收录)的纯函数规则。 //! //! 表与事务在 `spacetime-module::game_distribution`;这里只放能被不起 SpacetimeDB 的单测钉住的 -//! 两条规则:确定性主键的构造,以及「收藏行是否出现在列表投影里」的可见性判定。 +//! 规则:确定性主键的构造、「收藏行是否出现在列表投影里」的可见性判定,以及 +//! 「我的收藏」列表的游标编解码与排序切页。 + +/// 「我的收藏」一页的默认条数。 +/// +/// 取值 20 的理由:前端「我的收藏」是网格布局,一屏放得下 20 张卡片,首屏少一次往返。 +/// 与后台列表默认口径(`GAME_DISTRIBUTION_ADMIN_GAME_LIST_LIMIT` = 200)**不必相等**: +/// 后台是运营用的表格视图、单行信息量小且需要批量扫读,用户态网格则按屏取数。 +pub const GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT: u32 = 20; + +/// 「我的收藏」单次响应回传的条数上限。 +/// +/// 取值 50 的理由:约束单个响应的体积(每条约等于一份公开游戏投影,含当前版本与评分摘要), +/// 同时给「一屏 20 条」留出一次翻页拉满 2 页的余量。超界一律**截断**而不是报错——与后台列表 +/// 同一处理方式(客户端拿到的是一个完整页,而不是一个需要重试的错误)。 +pub const GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX: u32 = 50; /// 收藏主键:`{user_id}:{game_id}`。 /// @@ -34,6 +49,112 @@ pub fn game_distribution_collection_visible( !is_deleted && is_published && has_public_version } +/// 分页序列里的一条收藏:游标/排序所需的两个键 + 调用方自己的负载。 +/// +/// 泛型 `payload` 让「排序键」与「这一页要回传的东西」绑在同一个值上,纯函数切页时整条值一起移动, +/// 因此不可能出现「按 A 排序、按 B 切页」的错位;模块侧把负载填成游戏行,测试里填成任意轻量值。 +#[derive(Clone, Debug, PartialEq)] +pub struct GameDistributionCollectionPageItem { + /// 收藏主键 `{user_id}:{game_id}`;同一用户范围内唯一,因此可作全序兜底键。 + pub collection_id: String, + /// 收藏时间(Unix 微秒),主排序键。 + pub created_at_micros: i64, + pub payload: T, +} + +/// 收藏列表游标编码:`{created_at_micros}:{collection_id}`。 +/// +/// 与后台列表(`parse_admin_game_distribution_cursor`)同一套 `"{micros}:{id}"` 惯例。 +/// 注意 `collection_id` 本身由 `game_distribution_collection_id` 拼成、**含冒号** +/// (`{user_id}:{game_id}`):解析只切**第一个**冒号,`micros` 之后的整段(含更多冒号)都是 +/// collection_id,因此编解码仍然是双射(`encode` → `parse` 原样返回)。 +pub fn encode_game_distribution_collection_cursor( + created_at_micros: i64, + collection_id: &str, +) -> String { + format!("{created_at_micros}:{collection_id}") +} + +/// 收藏列表游标解析;格式非法时返回 `Err`(api-server 会把它透传成 400)。 +/// +/// 严格的错误文案是刻意的:它不得含「不存在」「已被删除」「状态」「不匹配」「幂等」等子串, +/// 否则会被 api-server 的既有 `map_spacetime_error` 分支抢先映射成 404 / 409;「格式无效」 +/// 只会落到兜底的 400,这正是「客户端传了坏游标」的语义。 +pub fn parse_game_distribution_collection_cursor(value: &str) -> Result<(i64, String), String> { + let (micros, collection_id) = value + .split_once(':') + .ok_or_else(|| "收藏列表游标格式无效".to_string())?; + let micros = micros + .parse::() + .map_err(|_| "收藏列表游标格式无效".to_string())?; + if collection_id.is_empty() { + return Err("收藏列表游标格式无效".to_string()); + } + Ok((micros, collection_id.to_string())) +} + +/// 请求里的 `limit` → 实际页大小:`0` 取默认,超界截断到上限。 +/// +/// 抽成独立函数是为了让 api-server 记日志的「生效条数」与模块真正使用的页大小**同源**: +/// 两处各自写一遍 `if == 0 ... min(...)` 迟早会漂移。 +pub fn game_distribution_collection_page_limit(limit: u32) -> usize { + let resolved = if limit == 0 { + GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT + } else { + limit.min(GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX) + }; + resolved as usize +} + +/// 按「收藏时间倒序 + `collection_id` 升序兜底」排序、跳过游标之前的位置、取 `limit` 条, +/// 并返回下一页游标(没有下一页时为 `None`)。 +/// +/// 为什么这个比较器是**全序**:`created_at` 相同的两条必须还能分出先后,否则同一页边界上 +/// 的项会在翻页时重复或漏掉。`collection_id` 是主键、在同一用户范围内唯一,因此 +/// `(created_at_micros, collection_id)` 唯一决定顺序 —— 相同 `created_at` 不会被吞掉。 +/// +/// 调用方必须**先**按可见性过滤、**再**把结果交给本函数:游标位置定义在已过滤的序列上。 +/// 反过来先把未过滤序列切页、再逐页过滤,会让被滤掉的行凭空占掉名额,并使下一页的游标指回 +/// 过滤前的序列——每翻一页都漏掉自己的若干条收藏。 +pub fn game_distribution_collection_page( + mut items: Vec>, + cursor: Option<(i64, String)>, + limit: u32, +) -> (Vec, Option) { + let limit = game_distribution_collection_page_limit(limit); + items.sort_by(|left, right| { + right + .created_at_micros + .cmp(&left.created_at_micros) + .then_with(|| left.collection_id.cmp(&right.collection_id)) + }); + if let Some((cursor_micros, cursor_id)) = cursor.as_ref() { + // 严格「在游标之后」:时间更早,或时间相同且 collection_id 更大(升序方向)。 + items.retain(|item| { + item.created_at_micros < *cursor_micros + || (item.created_at_micros == *cursor_micros + && item.collection_id.as_str() > cursor_id.as_str()) + }); + } + // 多取一条判断是否还有下一页;游标只指向这一页真正回传的最后一条。 + let has_more = items.len() > limit; + items.truncate(limit); + let next_cursor = if has_more { + items.last().map(|item| { + encode_game_distribution_collection_cursor( + item.created_at_micros, + item.collection_id.as_str(), + ) + }) + } else { + None + }; + ( + items.into_iter().map(|item| item.payload).collect(), + next_cursor, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -79,4 +200,174 @@ mod tests { // 重新公开:同一行再次可见。 assert!(game_distribution_collection_visible(false, true, true)); } + + fn item( + collection_id: &str, + created_at_micros: i64, + ) -> GameDistributionCollectionPageItem { + GameDistributionCollectionPageItem { + collection_id: collection_id.to_string(), + created_at_micros, + payload: collection_id.to_string(), + } + } + + /// 不足一页:全部回传,且**没有**下一页游标(不能给一个指向空页的游标,否则客户端会多翻一次死页)。 + #[test] + fn page_returns_everything_without_cursor_when_under_limit() { + let (page, next) = game_distribution_collection_page( + vec![item("usr_1:game_a", 300), item("usr_1:game_b", 200)], + None, + 20, + ); + assert_eq!(page, vec!["usr_1:game_a", "usr_1:game_b"]); + assert_eq!(next, None); + } + + /// 恰好一页:`has_more` 依赖「多取一条」,因此 `len == limit` 时不能再给游标。 + #[test] + fn page_at_exact_limit_has_no_cursor() { + let (page, next) = game_distribution_collection_page( + vec![item("usr_1:game_a", 300), item("usr_1:game_b", 200)], + None, + 2, + ); + assert_eq!(page.len(), 2); + assert_eq!(next, None); + } + + /// 连续翻页直到消耗完:每页**不重复**上一页的项,也**不跳过**任何项;最后一页游标为 `None`。 + #[test] + fn paging_walks_the_whole_sequence_without_gaps_or_duplicates() { + let entries = vec![ + item("usr_1:game_a", 500), + item("usr_1:game_b", 400), + item("usr_1:game_c", 300), + item("usr_1:game_d", 200), + item("usr_1:game_e", 100), + ]; + let mut seen: Vec = Vec::new(); + let mut cursor = None; + let mut pages = 0; + loop { + let (page, next) = game_distribution_collection_page(entries.clone(), cursor, 2); + assert!(!page.is_empty(), "非末页不得为空"); + if next.is_some() { + assert_eq!(page.len(), 2, "还有下一页时必须填满 limit 条"); + } + seen.extend(page); + pages += 1; + match next { + Some(value) => { + cursor = Some(parse_game_distribution_collection_cursor(&value).unwrap()) + } + None => break, + } + } + assert_eq!(pages, 3); + assert_eq!( + seen, + vec![ + "usr_1:game_a", + "usr_1:game_b", + "usr_1:game_c", + "usr_1:game_d", + "usr_1:game_e", + ], + "翻页顺序必须严格是「收藏时间倒序」且不重不漏" + ); + } + + /// 同一 `created_at` 多条:`collection_id` 升序兜底保证全序,翻页边界不重不漏。 + /// + /// 这是唯一能钉住「排序是全序」的场景:若比较器在 `created_at` 相同时不稳定,三条同刻收藏 + /// 里的某一条就会被下一页重复返回或被整段跳过。 + #[test] + fn page_tiebreaks_equal_created_at_by_collection_id_ascending() { + let entries = vec![ + item("usr_1:game_c", 100), + item("usr_1:game_a", 100), + item("usr_1:game_d", 99), + item("usr_1:game_b", 100), + ]; + let (first, next) = game_distribution_collection_page(entries.clone(), None, 2); + assert_eq!(first, vec!["usr_1:game_a", "usr_1:game_b"]); + let cursor = next.expect("还有同刻与更早的项"); + assert_eq!(cursor, "100:usr_1:game_b"); + let (second, last) = game_distribution_collection_page( + entries, + Some(parse_game_distribution_collection_cursor(&cursor).unwrap()), + 2, + ); + assert_eq!(second, vec!["usr_1:game_c", "usr_1:game_d"]); + assert_eq!(last, None, "第二页就是最后一页"); + } + + /// `limit` 语义:`0` 取默认 20(不是「返回 0 条」),超界截断到 50(不是报错)。 + #[test] + fn page_limit_defaults_zero_and_truncates_overflow() { + assert_eq!(game_distribution_collection_page_limit(0), 20); + assert_eq!(game_distribution_collection_page_limit(1), 1); + assert_eq!(game_distribution_collection_page_limit(50), 50); + assert_eq!( + game_distribution_collection_page_limit(51), + 50, + "超界截断而不是报错" + ); + assert_eq!(game_distribution_collection_page_limit(u32::MAX), 50); + + let oversized = (0..25) + .map(|index| item(&format!("usr_1:game_{index:02}"), 1_000 - index)) + .collect::>(); + let (page, next) = game_distribution_collection_page(oversized, None, 0); + assert_eq!(page.len(), 20, "limit=0 必须取默认 20 条"); + assert!(next.is_some(), "25 条 > 20 条,必须有下一页游标"); + } + + /// 游标编解码:`collection_id` 自带冒号也只切第一个,因此是双射;格式非法一律 `Err`。 + #[test] + fn cursor_round_trips_and_rejects_malformed_values() { + let cursor = + encode_game_distribution_collection_cursor(1_700_000_000_000_000, "usr_1:game_a"); + assert_eq!(cursor, "1700000000000000:usr_1:game_a"); + assert_eq!( + parse_game_distribution_collection_cursor(&cursor).unwrap(), + (1_700_000_000_000_000, "usr_1:game_a".to_string()) + ); + // 负数微秒(仅编码假设,仍必须可逆)。 + assert_eq!( + parse_game_distribution_collection_cursor("-5:usr_1:game_a").unwrap(), + (-5, "usr_1:game_a".to_string()) + ); + + for malformed in [ + "", + "abc", + "1700000000000000", + "1700000000000000:", + ":usr_1:game_a", + "not-a-number:usr_1:game_a", + ] { + assert!( + parse_game_distribution_collection_cursor(malformed).is_err(), + "非法游标必须报错:{malformed}" + ); + } + // 文案不得命中 api-server 的 404 / 409 子串分支,只能落到兜底 400。 + let message = parse_game_distribution_collection_cursor("bad").unwrap_err(); + for forbidden in [ + "不存在", + "已被删除", + "状态", + "不匹配", + "幂等", + "已存在", + "FORK_", + ] { + assert!( + !message.contains(forbidden), + "游标错误文案不得含「{forbidden}」:{message}" + ); + } + } } diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index dc9dde519..ac24b98c7 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -21,7 +21,13 @@ pub use application::{ GameDistributionService, GameOperationResult, InMemoryGameDistributionStore, PublicationOperationResult, }; -pub use collection::{game_distribution_collection_id, game_distribution_collection_visible}; +pub use collection::{ + GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX, + GameDistributionCollectionPageItem, encode_game_distribution_collection_cursor, + game_distribution_collection_id, game_distribution_collection_page, + game_distribution_collection_page_limit, game_distribution_collection_visible, + parse_game_distribution_collection_cursor, +}; pub use commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision}; pub use domain::{ FORK_AUTHORIZATION_FORBIDDEN, FORK_AUTHORIZATION_FULL, FORK_AUTHORIZATION_NON_COMMERCIAL, diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 2beb4cc6e..9a606b80f 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -379,6 +379,20 @@ pub struct GameDistributionListResponse { pub next_cursor: Option, } +/// 「我的收藏(收录)」列表接口(`GET /api/game-distribution/my-collections`)的响应体。 +/// +/// 形状与公开目录 `GameDistributionListResponse` 一致(同一份公开投影),但**分页语义不同**: +/// 默认一页 20 条、上限 50 条(公开目录另有自己的口径),排序按「收藏时间倒序 + collection_id +/// 升序兜底」。`next_cursor` 是真实游标(`"{created_at_micros}:{collection_id}"`), +/// 最后一页为 `null`;游标格式非法时服务端返回 400。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionMyCollectionsResponse { + pub games: Vec, + #[serde(default)] + pub next_cursor: Option, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameDistributionCreateGameRequest { diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index 1232374d0..35fd4356e 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -567,13 +567,18 @@ pub(crate) fn map_game_distribution_collection_state_result( } /// 「我的收藏(收录)」列表:逐条用公开目录同一份公开投影映射,形状因此与公开目录一致。 +/// +/// 同时透传真实的下页游标(`None` = 最后一页),让 api-server 能原样发出 `nextCursor`。 pub(crate) fn map_game_distribution_collection_list_result( result: crate::module_bindings::GameDistributionCollectionListResult, -) -> Result, SpacetimeClientError> { +) -> Result<(Vec, Option), SpacetimeClientError> { if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } - Ok(result.games.into_iter().map(map_public_game).collect()) + Ok(( + result.games.into_iter().map(map_public_game).collect(), + result.next_cursor, + )) } fn map_public_game( diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 686b0443f..a0ff52ff3 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -787,12 +787,20 @@ impl SpacetimeClient { .await } - /// 「我的收藏(收录)」列表;只返回当前公开可读的作品,形状与公开目录一致。 + /// 「我的收藏(收录)」列表(分页);只返回当前公开可读的作品,形状与公开目录一致。 + /// + /// 返回 `(games, next_cursor)`:`next_cursor` 为 `None` 表示这一页就是最后一页。 pub async fn list_game_distribution_collections( &self, user_id: String, - ) -> Result, SpacetimeClientError> { - let input = crate::module_bindings::GameDistributionCollectionListInput { user_id }; + limit: u32, + cursor: Option, + ) -> Result<(Vec, Option), SpacetimeClientError> { + let input = crate::module_bindings::GameDistributionCollectionListInput { + user_id, + limit, + cursor, + }; self.call_after_connect( "list_game_distribution_collections", move |connection, sender| { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_input_type.rs index 015967173..629172423 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_input_type.rs @@ -8,6 +8,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[sats(crate = __lib)] pub struct GameDistributionCollectionListInput { pub user_id: String, + pub limit: u32, + pub cursor: Option, } impl __sdk::InModule for GameDistributionCollectionListInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_result_type.rs index 84f231a1e..4411b43fe 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_collection_list_result_type.rs @@ -11,6 +11,7 @@ use super::game_distribution_public_game_snapshot_type::GameDistributionPublicGa pub struct GameDistributionCollectionListResult { pub ok: bool, pub games: Vec, + pub next_cursor: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 0708865e4..9801d1728 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1282,9 +1282,14 @@ pub struct GameDistributionUncollectGameInput { } /// 读取某用户的收藏(收录)列表。 +/// +/// 分页参数与后台列表同构:`limit` 为 0 时取默认、超界截断到上限(`module_game_distribution` +/// 的 `GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_*` 是唯一口径),`cursor` 为 `"{micros}:{collection_id}"`。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionCollectionListInput { pub user_id: String, + pub limit: u32, + pub cursor: Option, } /// 读取「某用户是否已收藏某作品」;公开详情用它算 `collected`。 @@ -1587,10 +1592,14 @@ impl GameDistributionCollectionMutationResult { } /// 「我的收藏(收录)」列表结果;逐条用**公开游戏快照**投影,因此形状与公开目录逐字一致。 +/// +/// `next_cursor` 是真实的下一页游标:`None` 表示这一页就是最后一页。handler 原样透传成 +/// `nextCursor`(最后一页发 `null`),客户端据此决定还要不要继续拉。 #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct GameDistributionCollectionListResult { pub ok: bool, pub games: Vec, + pub next_cursor: Option, pub error_message: Option, } @@ -2342,14 +2351,16 @@ pub fn list_game_distribution_collections_and_return( require_editor_generation_runtime_service_identity(tx, caller)?; list_game_distribution_collections_tx(tx, input.clone()) }) { - Ok(games) => GameDistributionCollectionListResult { + Ok((games, next_cursor)) => GameDistributionCollectionListResult { ok: true, games, + next_cursor, error_message: None, }, Err(error) => GameDistributionCollectionListResult { ok: false, games: Vec::new(), + next_cursor: None, error_message: Some(error), }, } @@ -4800,34 +4811,35 @@ fn unset_game_distribution_collection_tx( Ok(false) } -/// 「我的收藏(收录)」列表。 +/// 「我的收藏(收录)」列表(分页)。 /// -/// 按 `user_id` 索引取该用户的全部收藏行,逐条用**既有** `public_game_distribution_snapshot` -/// 投影(与公开目录 / 公开详情同一份),因此形状与公开目录逐字一致。 +/// 按 `user_id` 索引取该用户的收藏行,逐条用**既有** `public_game_distribution_snapshot` 投影 +/// (与公开目录 / 公开详情同一份),因此形状与公开目录逐字一致。 /// /// 只返回当前公开可读的作品:未公开 / 已软删除 / 没有当前公开版本的行在**投影时**被跳过, /// 但**不删除行**(`module_game_distribution::game_distribution_collection_visible` 就是这条 /// 判定的纯函数形式)。作品重新公开后,同一行会自动回到列表里。 +/// +/// 顺序**必须先过滤、再排序切页**:可见性是一条**过滤**规则,游标位置定义在已过滤的序列上。 +/// 若先把未过滤的行切页、再逐页过滤,被滤掉的行会凭空占掉这一页的名额(用户看到「不足一页」), +/// 而下一页的游标又指回过滤前的序列,于是每翻一页都会漏掉自己的若干条收藏——页越大漏得越多。 fn list_game_distribution_collections_tx( ctx: &ReducerContext, input: GameDistributionCollectionListInput, -) -> Result, String> { +) -> Result<(Vec, Option), String> { let user_id = required_game_distribution_text(input.user_id, "user_id")?; - let mut collections = ctx + let cursor = input + .cursor + .and_then(normalize_game_distribution_optional) + .map(|value| { + module_game_distribution::parse_game_distribution_collection_cursor(value.as_str()) + }) + .transpose()?; + let visible = ctx .db .game_distribution_collection() .by_game_distribution_collection_user_id() .filter(&user_id) - .collect::>(); - // 最近收藏在前;同一时刻按 game_id 升序,保证顺序稳定(不依赖表的物理顺序)。 - collections.sort_by(|left, right| { - right - .created_at - .cmp(&left.created_at) - .then_with(|| left.game_id.cmp(&right.game_id)) - }); - Ok(collections - .into_iter() .filter_map(|collection| { let game = ctx .db @@ -4843,9 +4855,23 @@ fn list_game_distribution_collections_tx( if !visible { return None; } - public_game_distribution_snapshot(ctx, &game) + Some( + module_game_distribution::GameDistributionCollectionPageItem { + collection_id: collection.collection_id.clone(), + created_at_micros: collection.created_at.to_micros_since_unix_epoch(), + payload: game, + }, + ) }) - .collect()) + .collect::>(); + let (page, next_cursor) = + module_game_distribution::game_distribution_collection_page(visible, cursor, input.limit); + Ok(( + page.iter() + .filter_map(|game| public_game_distribution_snapshot(ctx, game)) + .collect(), + next_cursor, + )) } /// @@ -5546,7 +5572,8 @@ mod tests { ); } - /// 列表事务:按 `user_id` 索引取行,只投影当前公开可读的作品,且不删除任何行。 + /// 列表事务:按 `user_id` 索引取行,只投影当前公开可读的作品,不删除任何行, + /// 并且**先过滤再切页**(游标位置必须落在已过滤的序列上)。 #[test] fn collection_list_projects_only_currently_public_games() { let source = include_str!("game_distribution.rs"); @@ -5558,6 +5585,40 @@ mod tests { !body.contains(".delete("), "投影阶段不得删除收藏行:作品重新公开后同一行必须自动回来" ); + // 分页必须走共享纯函数(游标解析 + 排序切页),而不是在事务里另写一套 sort/truncate。 + assert!( + body.contains("module_game_distribution::parse_game_distribution_collection_cursor") + ); + assert!(body.contains("module_game_distribution::game_distribution_collection_page")); + let visible_at = body + .find("game_distribution_collection_visible") + .expect("可见性判定必须在事务内"); + let page_at = body + .find("game_distribution_collection_page(") + .expect("切页必须在事务内"); + assert!( + visible_at < page_at, + "必须先按可见性过滤、再排序切页:反过来会每翻一页漏掉自己的若干条收藏" + ); + } + + /// 列表结果必须带真实的 `next_cursor`:过程成功路径回传它,失败路径回 `None`。 + #[test] + fn collection_list_result_carries_next_cursor() { + let source = include_str!("game_distribution.rs"); + let body = function_body( + source, + "pub fn list_game_distribution_collections_and_return(", + ); + assert!(body.contains("Ok((games, next_cursor))")); + assert!( + body.contains("next_cursor,"), + "成功路径必须把事务返回的真实游标放进结果" + ); + assert!( + body.contains("next_cursor: None,"), + "失败路径必须显式给出 next_cursor(不能靠缺省值)" + ); } /// 收藏前置失败关闭:作品不存在与「状态不允许收藏」必须用不同文案,后者命中 api-server 的