diff --git a/docs/README.md b/docs/README.md index 199e4a4d9..a860893a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,7 @@ - [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):平台壳、图片画布、游戏分发与在线游玩合同;网站游戏评分与评价已实现并通过本地验证,待用户验收,未部署。 - [网站游戏评分与评价里程碑](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md):唯一评价、编辑预填、4000 字符、公共分页与平均分/人数的验收边界与本地证据。 - [创作者主页与关注粉丝合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同):前后端已实现并通过工程验证,用户已确认提交交付,未部署。第四项默认进入自己主页,他人的关注/粉丝列表统一只读并支持主页跳转。 -- [创作者主页与关注粉丝工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md):分层落点、关系表/DTO、授权、关系列表分页、组件状态、深链与验证边界;游戏列表沿用最多 48 项限制,不做额外分页改造。 +- [创作者主页与关注粉丝工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md):分层落点、关系表/DTO、授权、关系列表分页、组件状态、深链与验证边界;公开游戏目录原为最多 48 项且不分页,**2026-10-07 已补真游标分页**(缺省 48 / 上限 100 / 非法游标 400 `CATALOG_INVALID_CURSOR` / 末页 `nextCursor` 为 `null`),作者过滤沿用原排序但不再被 48 项截死。 - [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。 - [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。 - [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。 diff --git a/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md b/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md index a10d7b7e9..bef890bf4 100644 --- a/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md +++ b/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md @@ -26,7 +26,7 @@ 以上新增文件是拟定落点,实施时可按相邻模块组织拆分,但不得建立第二套身份、作品或数据访问系统。不涉及 AGC 桌面客户端、外部 OpenAPI、后台管理页或游戏运行态。 -源码已确认:公开游戏列表和“我的游戏”一次最多取 48 项,公开列表 `nextCursor` 固定为 null,前端 `listGames` 只返回数组;游戏广场的设备过滤发生在这份数组上。用户明确本次不额外改造这些现状。创作者主页只增加服务端作者过滤,沿用最多 48 项和原排序,不增加游戏分页、加载更多或新的筛选控件。 +源码已确认(2026-10-05 基线):公开游戏列表和“我的游戏”一次最多取 48 项,公开列表 `nextCursor` 固定为 null,前端 `listGames` 只返回数组;游戏广场的设备过滤发生在这份数组上。**2026-10-07 变更**:公开游戏目录 `GET /api/game-distribution/games` 已补**真游标分页**(`limit` 缺省 48——即为保持这条既有首屏语义、上限 100、超界截断;`cursor` = `"{createdAtMicros}:{gameId}"`;非法游标 400 `CATALOG_INVALID_CURSOR`;末页 `nextCursor` 为 `null`)。**前端尚未消费该游标**(`src/services/gameDistributionClient.ts` 的 `listGames` 仍只返回数组,属前端跟进的留白)。`/my-games`(“我的游戏”)仍是一次最多 48 项、不分页。创作者主页的作者过滤不变(服务端精确 owner 过滤 + 同一排序),只是过滤后的结果不再被 48 项截死,可继续翻页。 ## 关系模型和授权 @@ -98,7 +98,7 @@ type CreatorRelationshipResponse = { 先排除不存在的对端,再计算 total 和分页;读取 limit+1 判断是否还有下一页。同时间多行不漏读,游标主人或类型不匹配返回 400。并发增删不保证多页构成冻结快照,按 userId 去重,刷新从首批开始;不引入跨请求数据库快照或游标持久化表。 -作者游戏只扩展现有 `GET /api/game-distribution/games` 的 `authorId`:未传保持原行为,显式空值返回 400;先按稳定 owner ID 和既有公开条件过滤,再排序、截取最多 48 项。现有条件包含 published、未删除及有效公开版本。前端 `GameListQuery` 增加可选 authorId,仍返回现有游戏数组;不修改游戏表结构或引入新的游戏列表系统。 +作者游戏只扩展现有 `GET /api/game-distribution/games` 的 `authorId`:未传保持原行为,显式空值返回 400;先按稳定 owner ID 和既有公开条件过滤,再排序切页(2026-10-07 起该端点自带游标分页,缺省 48 / 上限 100 / 末页 `nextCursor` 为 `null`),不再截死 48 项。现有条件包含 published、未删除及有效公开版本。前端 `GameListQuery` 增加可选 authorId;TS 客户端 `listGames` 目前仍只取数组(未消费 `nextCursor`,留给前端跟进);不修改游戏表结构或引入新的游戏列表系统。 ## 前端状态与组件责任 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 4ea954452..bd4cb72dc 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -121,7 +121,7 @@ | 方法与路径 | 身份 | 行为 | | --- | --- | --- | -| `GET /games` | 游客 | **已实现**:关键词与分类筛选,最多 48 项;仅公开可玩版本 | +| `GET /games?search=&category=&authorId=&limit=&cursor=` | 游客 | **已实现**:关键词、分类与作者筛选,仅公开可玩版本(`published`、未软删除、有当前公开版本)。**真游标分页**:`limit` 缺省 **48**(保持既有首屏语义)、上限 **100**,超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{gameId}"`(与 `/my-collections`、主题列表同一套 `"{micros}:{id}"` 惯例,解析只切第一个冒号);**游标格式非法 → 400 `CATALOG_INVALID_CURSOR`**(不是 200 的空页)。排序 `createdAt` 倒序 + `gameId` 升序兜底(全序,翻页不重不漏),顺序定义为「先按可见性过滤、再排序切页」;响应 `{ games, nextCursor }`,`nextCursor` 是真实游标、**末页为 `null`** | | `GET /games/{gameId}` | 游客/登录 | **已实现**:当前公开资料、`priceMudPoints` 与查看者 `purchased`(匿名、坏令牌与读取失败按未购买,内容访问失败关闭);付费作品对未购买且非作者/管理员的查看者把 `currentVersion.entryUrl` 置为 `null`,资料与价格仍可见;不可见时 404 | | `POST /games/{gameId}/plays` | 游客/登录 | **已实现**:上报一次「开始游戏」;可选 Bearer,非公开 404、超限 429,成功返回 `{recorded}`;只进 api-server 内存缓冲,失败不影响游玩 | | `POST /games/{gameId}/purchase` | 登录用户 | **已实现**:买断制购买,必填 `Idempotency-Key`,体 `{ expectedPriceMudPoints }`;扣费与购买记录同事务,重复购买幂等返回既有记录不再扣费;余额不足 400 `INSUFFICIENT_MUD_POINTS`、作者自购 400 `GAME_PURCHASE_OWNER_EXEMPT`、后台管理员令牌 403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED`、价格已被新版本改变 409;免费游戏按 400 拒绝 | @@ -694,7 +694,7 @@ - 他人主页按钮为“关注”或“已关注”,点击“已关注”执行取消关注;自己的主页隐藏该按钮,不允许关注自己。按钮的可访问名称明确表达“取消关注”。 - 游戏详情的作者头像/名称可点击进入主页,包括本人;作者旁增加同一套关注按钮。作者链接和关注按钮是独立点击目标,不因关注同时触发跳转;自己的游戏隐藏关注按钮。 - 主页游戏卡复用现有公开卡片和详情路由,只列该用户仍然公开、未删除且有有效公开版本的游戏。自己的主页也遵循公开口径;未公开、审核中、下架游戏继续在 `/games/mine` 管理。 -- 游戏列表沿用现有读取限制:服务端先按作者和公开可见性筛选,再按创建时间倒序、游戏 ID 升序排序,最多返回 48 项;`nextCursor` 仍为空,不增加游戏分页或加载更多。不能下载广场前 48 项后在前端过滤作者。游戏广场和“我的游戏”保持原状;用户已明确本次不做额外改造。 +- 游戏列表沿用现有读取口径:服务端先按作者和公开可见性筛选,再按创建时间倒序、游戏 ID 升序排序(**先过滤、再排序切页**)。`GET /api/game-distribution/games` **已补真游标分页**(`limit` 缺省 48 / 上限 100 / 超界截断;`cursor` = `"{createdAtMicros}:{gameId}"`;末页 `nextCursor` 为 `null`),因此按作者过滤后不再受 48 项限制,客户端可继续翻页。不能下载广场前 48 项后在前端过滤作者——作者过滤必须在服务端、与切页同一次事务里完成。“我的游戏”保持原状。 ### 关系方向与列表操作 @@ -743,7 +743,7 @@ | `DELETE /api/creators/me/followers/{followerId}` | 当前登录用户移除自己的粉丝,幂等 | | 扩展 `GET /api/game-distribution/games?authorId=` | 服务端精确作者过滤,复用公开游戏可见性与卡片契约 | -以上接口已在本地隔离环境实现并验证,未部署。关注/粉丝列表默认 20、上限 50,按关系建立时间与稳定身份倒序,返回 `items/nextCursor/total`;游标绑定主人和列表类型,非法/错域游标返回 400。增删期间重新加载可能改变页内成员,前端按用户 ID 去重,重新关注产生新的建立时间。此分页规则仅用于新关系列表;游戏列表继续最多读取 48 项,不实现分页。 +以上接口已在本地隔离环境实现并验证,未部署。关注/粉丝列表默认 20、上限 50,按关系建立时间与稳定身份倒序,返回 `items/nextCursor/total`;游标绑定主人和列表类型,非法/错域游标返回 400。增删期间重新加载可能改变页内成员,前端按用户 ID 去重,重新关注产生新的建立时间。此分页规则用于关系列表;游戏列表原为「最多读取 48 项、不实现分页」,现已由公开目录自身的真游标分页替换(缺省 48 / 上限 100 / 超界截断 / 末页 `nextCursor` 为 `null`,见上文 HTTP 边界表的 `GET /games` 行),因此作者过滤后不再受 48 项限制。`/my-games`(作者自己的作品管理列表)单次仍最多 48 项、不分页。 关系写入成功返回 200 与当前访问者关系;重复删除不存在的关系仍成功。不合法输入或关注自己返回 400,未认证 401,越权 403,目标不存在或不可公开 404。本人读取自己的关系列表时以有效认证批量附带各行关系;游客和他人的关注、粉丝列表该字段为 null,后者即使已登录也不计算逐行关系。带关系的响应禁用共享缓存,认证失效时清除访问者关系并按游客重新读取。 diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index ee7668a5e..585ea0e74 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -397,6 +397,15 @@ export type GameDistributionGame = { createdAt: string; }; +/** + * 公开游戏目录(`GET /api/game-distribution/games`)的响应体。 + * + * 只含公开可玩的作品(`published` + 未软删除 + 有当前公开版本)。**真游标分页**:`limit` 缺省 + * **48**(保持既有首屏语义)、上限 **100**,超界截断而非报错;`cursor` 形如 + * `"{createdAtMicros}:{gameId}"`(解析只切第一个冒号);游标格式非法时服务端返回 400 + * `CATALOG_INVALID_CURSOR`(不是 200 的空页)。排序为创建时间倒序 + `gameId` 升序兜底 + * (全序,翻页不重不漏);`nextCursor` 是真实游标,**最后一页为 `null`**。 + */ export type GameDistributionListResponse = { games: GameDistributionGame[]; nextCursor?: string | null; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 564a3d189..a64c554a2 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -208,6 +208,14 @@ const RESPONSE_BUILDERS = [ ts: 'GameDistributionMyCollectionsResponse', mustEmit: ['games', 'nextCursor'], }, + { + // 公开游戏目录(游戏广场):`games` / `nextCursor` 两个键都必须发出(末页 `nextCursor: null`)。 + // 修正前这条路径是内联 `json!` 且把 `nextCursor` 写死 `null`——不在本清单里,所以「发不发 + // 游标」没有任何证据;现在它是命名构建器,键与 `GameDistributionListResponse` 逐键比对。 + fn: 'public_games_payload', + ts: 'GameDistributionListResponse', + mustEmit: ['games', 'nextCursor'], + }, { // 公开共创主题列表:`themes` / `nextCursor` 两个键都必须发出(末页 `nextCursor: null`)。 // 单条的形状由 `public_theme_summary_payload` 拼,这里只证明顶层键与 TS 契约一致。 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 8b8041502..cf6ebbb52 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -20,15 +20,16 @@ use axum::{ }; use flate2::{Compression as GzipCompression, write::GzEncoder}; use module_game_distribution::{ + GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_MAX, GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX, GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX, MAX_GAME_PRICE_MUD_POINTS, MAX_PACKAGE_BYTES, MAX_PROJECT_BUNDLE_BYTES, ProjectBundleError, ProjectBundleManifest, ReleaseAssetError, ReleasePackageError, ReleasePackageManifest, - compute_request_digest, extract_release_asset, game_distribution_collection_page_limit, - game_distribution_theme_admin_status_filter_valid, game_distribution_theme_page_limit, - normalize_game_price_mud_points, normalize_review_comment, normalize_review_moderation_reason, - release_asset_content_type, validate_project_bundle_zip, validate_release_zip, - validate_review_list_status, + compute_request_digest, extract_release_asset, game_distribution_catalog_page_limit, + game_distribution_collection_page_limit, game_distribution_theme_admin_status_filter_valid, + game_distribution_theme_page_limit, normalize_game_price_mud_points, 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}; @@ -46,11 +47,12 @@ use shared_contracts::admin::{ AdminGameReviewsResponse, }; use shared_contracts::game_distribution::{ - GAME_DISTRIBUTION_CATEGORIES, GAME_DISTRIBUTION_THEME_BAD_REQUEST, - GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT, GAME_DISTRIBUTION_THEME_INVALID_CURSOR, - GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND, GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT, - GAME_DISTRIBUTION_THEME_NOT_FOUND, GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT, - GameDistributionAuthor, GameDistributionCollectionState, GameDistributionContributionChild, + GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR, GAME_DISTRIBUTION_CATEGORIES, + GAME_DISTRIBUTION_THEME_BAD_REQUEST, GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT, + GAME_DISTRIBUTION_THEME_INVALID_CURSOR, GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND, + GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT, GAME_DISTRIBUTION_THEME_NOT_FOUND, + GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT, GameDistributionAuthor, + GameDistributionCollectionState, GameDistributionContributionChild, GameDistributionContributionGeneration, GameDistributionContributionResponse, GameDistributionContributionTotals, GameDistributionCreateGameRequest, GameDistributionCreateThemeRequest, GameDistributionCreateVersionRequest, @@ -208,6 +210,11 @@ impl ReleasePackageCache { } } +/// 公开游戏目录的查询串:关键词 / 分类 / 作者过滤 + `limit` + `cursor`。 +/// +/// 分页口径独立于 `/my-collections`(20 / 50)与主题列表(20 / 50):目录是广场首屏,缺省保持 +/// 既有的 **48**、上限 **100**(都是模块侧常量,这里不抄第二份数字)。`cursor` 只透传字符串—— +/// 解析在事务里(模块侧纯函数),handler 自己解析会让「HTTP 挡住的」与「落库挡住的」长出两套判据。 #[derive(Debug, Deserialize)] struct GameListQuery { #[serde(alias = "keyword")] @@ -215,6 +222,8 @@ struct GameListQuery { category: Option, #[serde(rename = "authorId")] author_id: Option, + limit: Option, + cursor: Option, } #[derive(Debug, Deserialize)] @@ -1463,6 +1472,14 @@ fn release_asset_not_modified_response(etag: &str, cache_control: &'static str) response } +/// 公开游戏目录:`GET /api/game-distribution/games?search=&category=&authorId=&limit=&cursor=`。 +/// +/// 匿名可读;只含公开可玩的作品。**真游标分页**:`limit` 缺省 48(保持既有首屏语义)、上限 100、 +/// 超界**截断**(客户端拿到一个完整页,而不是一个需要重试的错误);游标格式非法由模块侧报错, +/// 这里透传成 400 + 稳定码 `CATALOG_INVALID_CURSOR`(不吞掉、也不自己造一种 200 的空页)。 +/// +/// 修正前这里写死 `limit = 48` 且把 `nextCursor` 恒置 `null`:库里第 49 条起的作品(含最老的 +/// 母版与主干)在 HTTP 面永久不可见。`nextCursor` 现在由事务给出的真实游标决定,末页为 `null`。 async fn list_games( State(state): State, Extension(ctx): Extension, @@ -1476,24 +1493,60 @@ async fn list_games( .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) })?; - let games = state + // 与模块侧同一套归一化(同一函数),因此日志里的 `limit` 就是事务真正生效的页大小。 + let limit = public_games_page_limit(query.limit); + let cursor = normalize_optional(query.cursor); + let (games, next_cursor) = state .spacetime_client() .list_game_distribution_games(GameDistributionPublicGameListRecordInput { search: normalize_optional(query.search), category: normalize_optional(query.category), - limit: MAX_LIST_LIMIT, + limit, author_id, + cursor: cursor.clone(), }) .await .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_catalog_listed", + games = games.len(), + limit, + max_limit = GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_MAX, + has_cursor = cursor.is_some(), + has_more = next_cursor.is_some(), + elapsed_ms = ctx.elapsed(), + "读取公开游戏目录" + ); + Ok(json_success_body( + Some(&ctx), + public_games_payload(games, next_cursor), + )) +} + +/// 公开游戏目录响应:`{ games: [...], nextCursor }`。 +/// +/// `nextCursor` 是**真实**游标:还有下一页时给出,最后一页为 `null`,客户端据此决定是否继续拉。 +/// 同步纯函数:既是 handler 的组装点,也是 DTO parity 脚本登记的响应构建器。 +fn public_games_payload( + games: Vec, + next_cursor: Option, +) -> Value { let games = games .into_iter() .map(|game| public_game_payload(game, &GameViewerContext::default())) .collect::>(); - Ok(json_success_body( - Some(&ctx), - json!({ "games": games, "nextCursor": Value::Null }), - )) + json!({ "games": games, "nextCursor": next_cursor }) +} + +/// 查询串的 `limit` → 生效页大小:缺省取默认 48(既有首屏语义),超界截断到上限 100(`0` 也取默认)。 +/// +/// 归一化委托 `module_game_distribution::game_distribution_catalog_page_limit`,与事务里真正切页 +/// 用的是**同一个函数**,避免「日志写 100、实际发了 48」这类漂移——上一版的硬上限缺陷正长在这里。 +fn public_games_page_limit(limit: Option) -> u32 { + game_distribution_catalog_page_limit( + limit.unwrap_or(GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_DEFAULT), + ) as u32 } /// 解析公开投影的查看者:可选鉴权读取当前用户,决定 `purchased` 与付费入口可见性。 @@ -6147,6 +6200,31 @@ fn map_spacetime_error(error: SpacetimeClientError) -> AppError { .with_code(code) .with_details(json!({ "provider": "game-distribution", "message": message })) } + // 公开游戏目录(`CATALOG_*`)错误码:与上两段同构,码由**前缀**决定而不是由文案里的中文 + // 子串决定,因此必须排在下面的「不匹配 / 不存在 / 状态」等 `contains` 分支**之前**—— + // 排在后面时,任何一条恰好含那些子串的 `CATALOG_` 文案都会被它们抢走(落 404 / 409)。 + // 同理,`FORK_` / `THEME_` 两个更早的 `contains` 分支要求目录文案**不含**这两个前缀, + // 这一点由模块侧单测与本文件的可达性枚举测试同时钉住。 + // + // 未登记的 `CATALOG_` 码按「请求非法」(400)保守处理,不会落到 axum 默认的 422 纯文本。 + SpacetimeClientError::Procedure(message) if message.contains("CATALOG_") => { + let code = message + .split(':') + .next() + .map(str::trim) + .filter(|code| code.starts_with("CATALOG_")) + .unwrap_or("CATALOG_ERROR"); + let (status, code) = match code { + GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR => ( + StatusCode::BAD_REQUEST, + GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR, + ), + _ => (StatusCode::BAD_REQUEST, "CATALOG_ERROR"), + }; + AppError::from_status(status) + .with_code(code) + .with_details(json!({ "provider": "game-distribution", "message": message })) + } SpacetimeClientError::Procedure(message) if message.contains("owner 不匹配") => { AppError::from_status(StatusCode::FORBIDDEN) .with_details(json!({ "provider": "game-distribution", "message": message })) @@ -9580,6 +9658,197 @@ mod tests { ); } + /// 公开目录 `limit` 口径:缺省 **48**(保持既有首屏语义)、上限 **100**、`0` 取默认、超界截断。 + /// + /// 归一化就是模块里的那一个函数,因此 handler 记的 `limit` 与事务真正用的页大小不可能对不上。 + /// 48 从此只是**默认值**:上一版的硬上限缺陷正是 handler 写死 `limit = 48` + 响应写死 + /// `nextCursor: null`,库里第 49 条起的作品因此永久不可见。 + #[test] + fn public_games_limit_defaults_and_truncates() { + assert_eq!(public_games_page_limit(None), 48); + assert_eq!(public_games_page_limit(Some(0)), 48); + assert_eq!(public_games_page_limit(Some(5)), 5); + assert_eq!(public_games_page_limit(Some(48)), 48); + assert_eq!(public_games_page_limit(Some(100)), 100); + assert_eq!( + public_games_page_limit(Some(101)), + 100, + "超界截断而不是报错" + ); + assert_eq!(public_games_page_limit(Some(u32::MAX)), 100); + assert_eq!(GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_DEFAULT, 48); + assert_eq!(GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_MAX, 100); + } + + /// 公开目录响应形状:`games` 与 `nextCursor` 两个键一定发出;游标是真实值,最后一页为 `null`。 + #[test] + fn public_games_payload_carries_real_next_cursor() { + let with_more = public_games_payload(Vec::new(), Some("100:game_a".to_string())); + assert_eq!( + with_more, + json!({ "games": [], "nextCursor": "100:game_a" }) + ); + let last_page = public_games_payload(Vec::new(), None); + assert_eq!(last_page, json!({ "games": [], "nextCursor": Value::Null })); + // 有内容时 `games` 走公开目录同一份投影,而不是另造一种条目形状。 + let page = public_games_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 **且带稳定码 `CATALOG_INVALID_CURSOR`**。 + /// + /// 模块产出的文案必须以契约定长码开头,否则 `CATALOG_` 分支不命中,会落兜底 400 + 通用 + /// `BAD_REQUEST`——客户端只能去匹配中文。文案也不得含更早命中的 `FORK_` / `THEME_` 前缀。 + #[test] + fn catalog_list_invalid_cursor_maps_to_bad_request_with_stable_code() { + let message = module_game_distribution::parse_game_distribution_catalog_cursor("不是游标") + .expect_err("非法游标必须报错"); + assert!(message.contains("格式无效"), "{message}"); + for forbidden in ["FORK_", "THEME_"] { + assert!( + !message.contains(forbidden), + "游标错误文案不得含「{forbidden}」(那两个 contains 分支排在 CATALOG_ 之前,会被抢走):{message}" + ); + } + let error = map_spacetime_error(SpacetimeClientError::Procedure(message.clone())); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!( + error.code(), + GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR, + "非法游标必须映射出契约里的稳定码,否则客户端只能匹配中文文案:{message}" + ); + } + + /// **可达性**:模块真实产出的每个目录码都必须映射出契约里的 (状态码, 稳定码)。 + /// + /// 与主题侧那条枚举测试同一个理由:`format!("{code}: 原因")` 这类**合成**文案只能证明「映射 + /// 表里有这一行」,证明不了「模块真的会产出这个前缀」——上一轮 `THEME_INVALID_CURSOR` 就是 + /// 这么变成死代码的。这里用模块真实产出的文案,并逐码比对模块常量与 `shared-contracts` 常量 + /// 是同一组字符串(两侧改字都会红);将来新增 `CATALOG_*` 码必须一并进这张表。 + #[test] + fn catalog_error_codes_are_reachable_from_module_messages() { + let cases = [( + module_game_distribution::parse_game_distribution_catalog_cursor("不是游标") + .expect_err("非法游标必须报错"), + module_game_distribution::GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR_CODE, + GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR, + StatusCode::BAD_REQUEST, + )]; + for (message, module_code, contract_code, expected_status) in &cases { + assert_eq!( + module_code, contract_code, + "模块常量与 shared-contracts 常量必须是同一组字符串:{message}" + ); + assert!( + message.starts_with(contract_code), + "模块产出的文案必须以契约码开头(否则 HTTP 面不可达):{message}" + ); + let error = map_spacetime_error(SpacetimeClientError::Procedure(message.clone())); + assert_eq!(error.status_code(), *expected_status, "{message}"); + assert_eq!( + error.code(), + *contract_code, + "稳定码必须原样透传到 HTTP 面:{message}" + ); + } + // 覆盖完整性:本模块一共只有这一个目录码;将来新增的码必须一并进这张表。 + for code in [module_game_distribution::GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR_CODE] { + assert!( + cases.iter().any(|(_, covered, _, _)| *covered == code), + "目录码 {code} 未被这条枚举测试覆盖" + ); + } + } + + /// **缺陷回归**:51 条公开作品按 handler 的真实链路翻页必须能翻到**最老那条**,且未公开 / + /// 已软删除 / 没有当前公开版本的作品不出现在任何一页。 + /// + /// api-server 的单测不起真库,因此这里用 handler 自己的 `public_games_page_limit` 复现「先过滤、 + /// 再排序切页」的顺序(与事务里的顺序一致),并逐页过一遍 `public_games_payload`:这样一条测试 + /// 同时钉住页大小归一化、游标格式、每页的响应形状与「不可见作品不占名额」。 + #[test] + fn public_games_paging_walks_to_the_oldest_and_hides_invisible_rows() { + // 可见作品 51 条(`game_00` 最新、`game_50` 最老),另有三种不可见行混在同一批输入里。 + let mut rows = (0..51) + .map(|index| { + ( + format!("game_{index:02}"), + 10_000 - index as i64, + true, // published + 未软删 + 有当前公开版本 + ) + }) + .collect::>(); + rows.push(("game_draft".to_string(), 9_999, false)); // 未公开 + rows.push(("game_deleted".to_string(), 9_998, false)); // 已软删除 + rows.push(("game_no_version".to_string(), 9_997, false)); // 没有当前公开版本 + let visible = rows + .into_iter() + .filter(|(_, _, visible)| *visible) + .map(|(game_id, created_at_micros, _)| { + module_game_distribution::GameDistributionCatalogPageItem { + payload: game_id.clone(), + game_id, + created_at_micros, + } + }) + .collect::>(); + + let mut seen: Vec = Vec::new(); + let mut cursor = None; + let mut pages = 0; + loop { + let (page, next_cursor) = + module_game_distribution::page_public_game_distribution_catalog( + visible.clone(), + cursor, + public_games_page_limit(None), + ); + let body = public_games_payload(Vec::new(), next_cursor.clone()); + assert!( + body.get("games").is_some() && body.get("nextCursor").is_some(), + "每页都必须带 games 与 nextCursor:{body}" + ); + if next_cursor.is_none() { + assert_eq!( + body["nextCursor"], + Value::Null, + "末页 nextCursor 必须是 null" + ); + } + seen.extend(page); + pages += 1; + match next_cursor { + Some(value) => { + cursor = Some( + module_game_distribution::parse_game_distribution_catalog_cursor(&value) + .expect("服务端产出的游标必须能被自己解析"), + ) + } + None => break, + } + } + assert_eq!(pages, 2, "51 条按默认 48 分页应为 2 页"); + let expected = (0..51) + .map(|index| format!("game_{index:02}")) + .collect::>(); + assert_eq!( + seen, expected, + "翻页顺序必须严格是「创建时间倒序」且不重不漏" + ); + assert_eq!( + seen.last().map(String::as_str), + Some("game_50"), + "最老的那条必须能翻到——这正是本次修复的目标" + ); + for invisible in ["game_draft", "game_deleted", "game_no_version"] { + assert!( + !seen.iter().any(|game_id| game_id == invisible), + "不可见作品不得出现在任何一页:{invisible}" + ); + } + } + fn public_game_record_fixture() -> GameDistributionPublicGameRecord { GameDistributionPublicGameRecord { game: GameDistributionGameRecord { diff --git a/server-rs/crates/module-game-distribution/src/catalog.rs b/server-rs/crates/module-game-distribution/src/catalog.rs new file mode 100644 index 000000000..c077b83bc --- /dev/null +++ b/server-rs/crates/module-game-distribution/src/catalog.rs @@ -0,0 +1,411 @@ +//! 公开游戏目录(游戏广场 `/api/game-distribution/games`)的纯函数规则。 +//! +//! 表与事务在 `spacetime-module::game_distribution`;这里只放能被不起 SpacetimeDB 的单测钉住的 +//! 规则:公开目录列表的页大小口径、游标编解码与「稳定全序排序 + 切页」。 +//! +//! 为什么需要它:公开目录原先在事务里写死 `limit = 48` 且没有游标——库里第 49 条之后的作品 +//! (含最老的母版与主干)在 HTTP 面**永久不可见**,客户端只能显示「已检查最新 48 个作品」。 +//! 数据一直在、详情与 `/lineage` 都读得到,缺的只是「翻到下一页」的通道。这里补上游标分页, +//! 并沿用仓库既有的 `"{i64}:{id}"` 游标惯例(与主题列表 / 收藏列表同一套),不另立第二套语义。 + +/// 公开目录一页的默认条数。 +/// +/// 取 48 是**保持既有首屏语义**:现网 AGC 共创页首屏就是 48 条(客户端按这个数显示「已检查 +/// 最新 N 个作品」),改小会让首屏少一截、改大则与既有观测口径对不上。它只是**缺省值**, +/// 不再是硬上限——正是「写死 48 且无游标」让第 49 条起的作品永远不可见。 +pub const GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_DEFAULT: u32 = 48; + +/// 公开目录单次响应回传的条数上限。 +/// +/// 与 `/my-collections`(50)/ 主题列表(50)不同值:公开目录的条目是完整公开投影(含当前版本 +/// 与评分摘要),而广场是首屏页面、一次要铺满网格,因此给到 100 仍然只是「单响应体量」的上限。 +/// 超界一律**截断**而不是报错——客户端拿到的是一个完整页,而不是一个需要重试的错误。 +pub const GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_MAX: u32 = 100; + +/// 公开目录游标格式非法的稳定码。 +/// +/// 文案必须以码开头(`"{CODE}: {中文说明}"`):api-server 的 `CATALOG_` 映射分支要求消息含 +/// `CATALOG_` 且按冒号前的码查状态码表,少了前缀就静默退化成兜底的 400 + 通用 `BAD_REQUEST`, +/// 客户端只能去匹配中文——这正是本项目在主题列表上出过一次的真缺陷(游标错误不是领域变体, +/// 只是裸字符串,`Display` 的前缀测试覆盖不到它,所以 api-server 另有一条测试专门枚举全部 +/// `CATALOG_` 码,逐条从**模块真实产出的文案**映射)。 +/// +/// 与 `shared-contracts` 里的 `GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR` 是同一组字符串的两个 +/// 副本(本 crate 不依赖 `shared-contracts`),两侧由 api-server 的可达性枚举测试逐码比对。 +pub const GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR_CODE: &str = "CATALOG_INVALID_CURSOR"; + +/// 请求里的 `limit` → 实际页大小:`0` 取默认 48,超界截断到上限 100。 +/// +/// 与主题列表 / 收藏列表同一套归一化口径(同一段 `if == 0 ... min(...)` 的形状),抽成独立函数 +/// 是为了让 api-server 记日志的「生效条数」与模块真正使用的页大小**同源**:两处各自写一遍迟早 +/// 会漂移,日志就会写 100 而实际只发 48。 +/// +/// `0` 取默认而不是「返回 0 条」是既有约定:`limit=0` 只可能是客户端「没填」的表达,回一个空页 +/// 会把「缺省」渲染成「没有作品」。 +pub fn game_distribution_catalog_page_limit(limit: u32) -> usize { + let resolved = if limit == 0 { + GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_DEFAULT + } else { + limit.min(GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_MAX) + }; + resolved as usize +} + +/// 公开目录分页序列里的一条:游标 / 排序所需的两个键 + 调用方自己的负载。 +/// +/// 泛型 `payload` 让「排序键」与「这一页要回传的东西」绑在同一个值上,纯函数切页时整条值一起 +/// 移动,因此不可能出现「按 A 排序、按 B 切页」的错位;模块侧把负载填成游戏行,测试里填成任意 +/// 轻量值。 +#[derive(Clone, Debug, PartialEq)] +pub struct GameDistributionCatalogPageItem { + /// 作品主键 `game-*`;作品表内唯一,因此可作全序兜底键。 + pub game_id: String, + /// 作品创建时间(Unix 微秒),主排序键。 + pub created_at_micros: i64, + pub payload: T, +} + +/// 公开目录游标编码:`{created_at_micros}:{game_id}`。 +/// +/// 与主题列表 / 收藏列表 / 后台列表同一套 `"{micros}:{id}"` 惯例。`game_id` 由本领域签发 +/// (`game-*`)、**不含冒号**,因此这里比收藏列表(`collection_id` 自带冒号)更简单;解析仍只切 +/// **第一个**冒号,格式对将来更长的 ID 也不会退化。 +pub fn encode_game_distribution_catalog_cursor(created_at_micros: i64, game_id: &str) -> String { + format!("{created_at_micros}:{game_id}") +} + +/// 公开目录游标解析;格式非法时返回带**稳定码**的 `Err`:`CATALOG_INVALID_CURSOR: …`。 +/// +/// 规则与主题列表游标解析逐条同构(切第一个冒号、`i64` 必须可解析、`game_id` 必须非空),因此 +/// 客户端的非法游标一律在事务里失败关闭、由 api-server 映射 **400 + 稳定码**,不会退化成 +/// 「200 + 空页」(后者会让客户端以为目录真的到底了)。 +/// +/// 文案刻意避开会被 api-server **更早的**分支抢走的子串:`FORK_` 与 `THEME_` 两个 `contains` +/// 分支排在 `CATALOG_` 之前,文案里只要出现它们就会被抢先映射(甚至映射成 409)。这条约束由 +/// 单测与 api-server 的可达性枚举测试同时钉住。 +pub fn parse_game_distribution_catalog_cursor(value: &str) -> Result<(i64, String), String> { + let invalid = || { + format!( + "{}: 目录游标格式无效", + GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR_CODE + ) + }; + let (micros, game_id) = value.split_once(':').ok_or_else(invalid)?; + let micros = micros.parse::().map_err(|_| invalid())?; + if game_id.is_empty() { + return Err(invalid()); + } + Ok((micros, game_id.to_string())) +} + +/// 公开目录的稳定排序:`created_at` **倒序** + `game_id` **升序**兜底(与原先事务里的比较器 +/// **逐字同序**,因此这一改动不会让既有首页顺序发生任何变化)。 +/// +/// 为什么这个比较器必须是**全序**:`created_at` 相同的两条必须还能分出先后,否则同一页边界上的 +/// 项会在翻页时重复或漏掉——一批用同一次 `seed` 灌进库的演示作品完全可能共享创建时间。 +/// `game_id` 是主键、唯一,因此 `(created_at_micros, game_id)` 唯一决定顺序。 +pub fn sort_public_game_distribution_catalog( + mut items: Vec>, +) -> Vec> { + items.sort_by(|left, right| { + right + .created_at_micros + .cmp(&left.created_at_micros) + .then_with(|| left.game_id.cmp(&right.game_id)) + }); + items +} + +/// 公开目录分页:按上面的比较器排序、跳过游标之前的位置、取 `limit` 条,并返回下一页游标 +/// (没有下一页时为 `None`)。 +/// +/// 调用方必须**先**按可见性过滤(作者 / `published` / 未软删 / 有当前公开版本 / 分类 / 关键词)、 +/// **再**把结果交给本函数:游标位置定义在已过滤的序列上。反过来先把未过滤序列切页、再逐页过滤, +/// 被滤掉的行会凭空占掉名额,下一页的游标又指回过滤前的序列——每翻一页都漏掉自己的若干条 +/// (与 `/my-collections`、公开主题列表同一条纪律)。 +/// +/// `has_more` 用「多取一条」判定而不是 `len == limit`:`len == limit` 时无法区分「正好一页」与 +/// 「还有下一页」,前一种情况下发游标会让客户端多翻一次死页。`next_cursor` 只编码**这一页真正 +/// 回传的最后一条**,因此下一页从它之后开始,边界不重不漏。 +pub fn page_public_game_distribution_catalog( + items: Vec>, + cursor: Option<(i64, String)>, + limit: u32, +) -> (Vec, Option) { + let limit = game_distribution_catalog_page_limit(limit); + let mut items = sort_public_game_distribution_catalog(items); + if let Some((cursor_micros, cursor_id)) = cursor.as_ref() { + // 严格「在游标之后」:创建时间更早,或时间相同且 game_id 更大(升序方向)。 + items.retain(|item| { + item.created_at_micros < *cursor_micros + || (item.created_at_micros == *cursor_micros + && item.game_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_catalog_cursor(item.created_at_micros, item.game_id.as_str()) + }) + } else { + None + }; + ( + items.into_iter().map(|item| item.payload).collect(), + next_cursor, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `limit` 语义:`0` 取默认 48(不是「返回 0 条」),超界截断到 100(不是报错)。 + /// + /// 48 是**默认值而非硬上限**——这正是本次修复的核心:写死 48 会让第 49 条起的作品永久不可见。 + #[test] + fn page_limit_defaults_zero_and_truncates_overflow() { + assert_eq!(game_distribution_catalog_page_limit(0), 48); + assert_eq!(game_distribution_catalog_page_limit(1), 1); + assert_eq!(game_distribution_catalog_page_limit(48), 48); + assert_eq!(game_distribution_catalog_page_limit(100), 100); + assert_eq!( + game_distribution_catalog_page_limit(101), + 100, + "超界截断而不是报错" + ); + assert_eq!(game_distribution_catalog_page_limit(u32::MAX), 100); + assert_eq!(GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_DEFAULT, 48); + assert_eq!(GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_MAX, 100); + } + + /// 游标编解码:往返双射、解析只切第一个冒号、负数微秒可逆,各种非法输入一律 `Err`。 + #[test] + fn cursor_round_trips_and_rejects_malformed_values() { + let cursor = encode_game_distribution_catalog_cursor(1_700_000_000_000_000, "game_a"); + assert_eq!(cursor, "1700000000000000:game_a"); + assert_eq!( + parse_game_distribution_catalog_cursor(&cursor).unwrap(), + (1_700_000_000_000_000, "game_a".to_string()) + ); + // 负数微秒(仅编码假设,仍必须可逆)。 + assert_eq!( + parse_game_distribution_catalog_cursor("-5:game_a").unwrap(), + (-5, "game_a".to_string()) + ); + // 解析只切第一个冒号:micros 之后的整段(含更多冒号)都是 game_id。 + assert_eq!( + parse_game_distribution_catalog_cursor("9:game:a").unwrap(), + (9, "game:a".to_string()) + ); + + for malformed in ["", "not-a-cursor", "100", "abc:game_a", "100:", " :game_a"] { + assert!( + parse_game_distribution_catalog_cursor(malformed).is_err(), + "非法游标必须报错:{malformed:?}" + ); + } + } + + /// 非法游标文案必须以**稳定码**开头,且不含会被 api-server 更早分支抢走的子串。 + /// + /// `map_spacetime_error` 里 `FORK_` 与 `THEME_` 两个 `contains` 分支都排在 `CATALOG_` 之前, + /// 文案里只要出现其中之一就会被抢先映射(`FORK_` 甚至落 409)。 + #[test] + fn invalid_cursor_message_stays_in_the_bad_request_bucket() { + let message = + parse_game_distribution_catalog_cursor("不是游标").expect_err("非法游标必须报错"); + assert!( + message.starts_with(GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR_CODE), + "游标错误必须以稳定码开头,否则 HTTP 面只能回通用 BAD_REQUEST:{message}" + ); + // 前缀后面必须真的跟冒号:api-server 用 `split(':').next()` 取码,缺了冒号就会把整句 + // 文案当成码,落到未登记分支变成通用 `CATALOG_ERROR` 之外的兜底。 + assert!( + message.starts_with(&format!("{GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR_CODE}:")), + "稳定码后面必须跟冒号:{message}" + ); + assert!(message.contains("格式无效"), "{message}"); + for forbidden in [ + "FORK_", + "THEME_", + "不存在", + "已被删除", + "状态", + "不匹配", + "幂等", + "已存在", + ] { + assert!( + !message.contains(forbidden), + "游标错误文案不得含「{forbidden}」:{message}" + ); + } + } + + fn item(game_id: &str, created_at_micros: i64) -> GameDistributionCatalogPageItem { + GameDistributionCatalogPageItem { + game_id: game_id.to_string(), + created_at_micros, + payload: game_id.to_string(), + } + } + + /// 不足一页:全部回传,且**没有**下一页游标(不能给一个指向空页的游标,否则客户端会多翻一次死页)。 + #[test] + fn page_returns_everything_without_cursor_when_under_limit() { + let (page, next) = page_public_game_distribution_catalog( + vec![item("game_a", 300), item("game_b", 200)], + None, + 20, + ); + assert_eq!(page, vec!["game_a", "game_b"]); + assert_eq!(next, None); + } + + /// 恰好一页:`has_more` 依赖「多取一条」,因此 `len == limit` 时不能再给游标。 + #[test] + fn page_at_exact_limit_has_no_cursor() { + let (page, next) = page_public_game_distribution_catalog( + vec![item("game_a", 300), item("game_b", 200)], + None, + 2, + ); + assert_eq!(page.len(), 2); + assert_eq!(next, None); + } + + /// **本次缺陷的回归网**:51 条公开作品用默认页大小(48)翻页,必须能翻到**最老那条**。 + /// + /// 修正前 `limit` 写死 48 且没有游标:第一页给出最新 48 条,库里的第 49–51 条(含最老的母版 + /// 与主干)在任何一页都拿不到。这里逐页翻到底,断言总条数 = 51、顺序严格是「创建时间倒序」、 + /// 且最老的那条确实出现过。 + #[test] + fn paging_walks_the_51_game_catalog_down_to_the_oldest() { + // `game_00` 最新、`game_50` 最老(创建时间等差递减)。 + let entries = (0..51) + .map(|index| item(&format!("game_{index:02}"), 10_000 - index as i64)) + .collect::>(); + let mut seen: Vec = Vec::new(); + let mut cursor = None; + let mut pages = 0; + loop { + let (page, next) = page_public_game_distribution_catalog( + entries.clone(), + cursor, + GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_DEFAULT, + ); + assert!(!page.is_empty(), "非末页不得为空"); + seen.extend(page); + pages += 1; + match next { + Some(value) => { + cursor = Some(parse_game_distribution_catalog_cursor(&value).unwrap()) + } + None => break, + } + } + assert_eq!(pages, 2, "51 条按默认 48 分页应为 2 页"); + assert_eq!(seen.len(), 51); + let expected = (0..51) + .map(|index| format!("game_{index:02}")) + .collect::>(); + assert_eq!( + seen, expected, + "翻页顺序必须严格是「创建时间倒序」且不重不漏" + ); + assert_eq!( + seen.last().map(String::as_str), + Some("game_50"), + "最老的那条必须能翻到——这正是本次修复的目标" + ); + } + + /// 同一 `created_at` 多条:`game_id` 升序兜底保证全序,翻页边界不重不漏。 + /// + /// 这是唯一能钉住「排序是全序」的场景:若比较器在 `created_at` 相同时不稳定,三条同刻作品里 + /// 的某一条就会被下一页重复返回或被整段跳过。 + #[test] + fn page_tiebreaks_equal_created_at_by_game_id_ascending() { + let entries = vec![ + item("game_c", 100), + item("game_a", 100), + item("game_d", 99), + item("game_b", 100), + ]; + let (first, next) = page_public_game_distribution_catalog(entries.clone(), None, 2); + assert_eq!(first, vec!["game_a", "game_b"]); + let cursor = next.expect("还有同刻与更早的项"); + assert_eq!(cursor, "100:game_b"); + let (second, last) = page_public_game_distribution_catalog( + entries, + Some(parse_game_distribution_catalog_cursor(&cursor).unwrap()), + 2, + ); + assert_eq!(second, vec!["game_c", "game_d"]); + assert_eq!(last, None, "第二页就是最后一页"); + } + + /// **先过滤、再排序切页**:被滤掉的行不得凭空占掉名额,也不得出现在任何一页里。 + /// + /// 用「可见性过滤在调用方、发生在切页之前」的结构模拟模块侧的真实顺序:把未公开 / 已软删 / + /// 无公开版本的行混在同一批输入里,先 `retain`,再交给分页函数;若顺序反过来(先切页再过滤), + /// 第一页就会因为不可见行占位而少回传,翻页也会漏项。 + #[test] + fn visibility_filter_must_happen_before_paging() { + // game_b / game_d 是「不可见」的作品(未公开 / 已软删除),它们不该占页名额。 + let raw = vec![ + item("game_a", 500), + item("game_b", 400), + item("game_c", 300), + item("game_d", 200), + item("game_e", 100), + ]; + let visible = raw + .into_iter() + .filter(|entry| entry.game_id != "game_b" && entry.game_id != "game_d") + .collect::>(); + + let mut seen: Vec = Vec::new(); + let mut cursor = None; + loop { + let (page, next) = page_public_game_distribution_catalog(visible.clone(), cursor, 1); + if next.is_some() { + assert_eq!(page.len(), 1, "过滤后仍有余量时必须填满 limit 条"); + } + seen.extend(page); + match next { + Some(value) => { + cursor = Some(parse_game_distribution_catalog_cursor(&value).unwrap()) + } + None => break, + } + } + assert_eq!(seen, vec!["game_a", "game_c", "game_e"]); + assert!( + !seen.iter().any(|id| id == "game_b" || id == "game_d"), + "不可见作品不得出现在任何一页" + ); + } + + /// 游标只指向这一页**真正回传的最后一条**:下一页从它之后开始,边界既不重复也不跳过。 + #[test] + fn cursor_points_at_the_last_returned_item() { + let entries = vec![ + item("game_a", 500), + item("game_b", 400), + item("game_c", 300), + ]; + let (page, next) = page_public_game_distribution_catalog(entries.clone(), None, 2); + assert_eq!(page, vec!["game_a", "game_b"]); + assert_eq!(next.as_deref(), Some("400:game_b")); + let (rest, last) = page_public_game_distribution_catalog( + entries, + Some(parse_game_distribution_catalog_cursor(&next.unwrap()).unwrap()), + 2, + ); + assert_eq!(rest, vec!["game_c"]); + assert_eq!(last, None); + } +} diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index 7e2ace977..b269c028c 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -1,4 +1,5 @@ mod application; +mod catalog; mod collection; mod commands; mod contribution; @@ -23,6 +24,13 @@ pub use application::{ GameDistributionService, GameOperationResult, InMemoryGameDistributionStore, PublicationOperationResult, PurchaseOperationResult, }; +pub use catalog::{ + GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR_CODE, GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_DEFAULT, + GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_MAX, GameDistributionCatalogPageItem, + encode_game_distribution_catalog_cursor, game_distribution_catalog_page_limit, + page_public_game_distribution_catalog, parse_game_distribution_catalog_cursor, + sort_public_game_distribution_catalog, +}; pub use collection::{ GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX, GameDistributionCollectionPageItem, encode_game_distribution_collection_cursor, diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 8cf6d67d2..413cb5cc9 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -51,6 +51,14 @@ pub const GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT: &str = "THEME_MEMBER_NOT_ROOT /// 目标作品不存在。 pub const GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND: &str = "THEME_MEMBER_GAME_NOT_FOUND"; +/// 公开游戏目录(游戏广场)游标格式非法的稳定码。 +/// +/// 与 `THEME_INVALID_CURSOR` 同族但**不同前缀**:目录是作品列表、主题是运营归组,两组码各归各的 +/// 映射分支,混用会让「非法目录游标」被当成主题错误去查主题表。api-server 的 `CATALOG_` 分支 +/// 排在「不存在 / 状态」等子串分支之前,因此模块产出的文案也**不得**含 `FORK_` / `THEME_` +/// (那两个 `contains` 分支更早命中),这一点由两侧的单测同时钉住。 +pub const GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR: &str = "CATALOG_INVALID_CURSOR"; + /// 发布页免费生成简介与分类的输入。 /// /// 只传经过裁剪的项目摘要,不传本地绝对路径、聊天记录、凭据或完整 manifest。 @@ -483,6 +491,14 @@ pub struct GameDistributionCollectionState { pub replayed: Option, } +/// 公开游戏目录(`GET /api/game-distribution/games`)的响应体。 +/// +/// 只含公开可玩的作品(`published` + 未软删除 + 有当前公开版本),逐条是公开投影。**真游标 +/// 分页**:`limit` 缺省 **48**(保持既有首屏语义)、上限 **100**,超界**截断**而非报错;`cursor` +/// 形如 `"{created_at_micros}:{game_id}"`(与主题 / 收藏列表同一套 `"{micros}:{id}"` 惯例,解析 +/// 只切第一个冒号);**格式非法 → 400 `CATALOG_INVALID_CURSOR`**(不吞成 200 空页)。排序 +/// `created_at` 倒序 + `game_id` 升序兜底(全序,翻页不重不漏),`next_cursor` 为真实值、 +/// **末页为 `null`**。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameDistributionListResponse { 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 2ec95007d..e0a895378 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 @@ -1044,13 +1044,19 @@ pub(crate) fn map_game_distribution_owner_game_list_result( .collect()) } +/// 公开游戏目录:`ok == false` 是服务端失败(游标非法等,走既有错误映射 → 400)。 +/// +/// 同时透传真实的下页游标(`None` = 最后一页),让 api-server 能原样发出 `nextCursor`。 pub(crate) fn map_game_distribution_public_game_list_result( result: crate::module_bindings::GameDistributionGameListProcedureResult, -) -> 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, + )) } pub(crate) fn map_game_distribution_version_result( @@ -1239,6 +1245,56 @@ mod theme_result_tests { } } +/// 公开游戏目录结果的映射:失败必须折成 `Procedure` 错误(api-server 才能按码映射 400), +/// 成功必须透传**真实游标**(`None` = 最后一页)。 +/// +/// 修正前这条映射只回 `Vec`:游标在 facade 这一层就被丢掉, +/// api-server 只能把 `nextCursor` 写死成 `null`,于是客户端永远翻不到第二页。 +#[cfg(test)] +mod catalog_result_tests { + use super::*; + + #[test] + fn catalog_list_keeps_the_real_cursor_and_folds_failures_into_procedure_errors() { + let (games, next_cursor) = map_game_distribution_public_game_list_result( + crate::module_bindings::GameDistributionGameListProcedureResult { + ok: true, + games: Vec::new(), + next_cursor: Some("1700000000000000:game_a".to_string()), + error_message: None, + }, + ) + .expect("列表结果"); + assert!(games.is_empty()); + assert_eq!(next_cursor.as_deref(), Some("1700000000000000:game_a")); + + let (_, last_page) = map_game_distribution_public_game_list_result( + crate::module_bindings::GameDistributionGameListProcedureResult { + ok: true, + games: Vec::new(), + next_cursor: None, + error_message: None, + }, + ) + .expect("末页结果"); + assert_eq!(last_page, None, "末页没有下一页游标"); + + let error = map_game_distribution_public_game_list_result( + crate::module_bindings::GameDistributionGameListProcedureResult { + ok: false, + games: Vec::new(), + next_cursor: None, + error_message: Some("CATALOG_INVALID_CURSOR: 目录游标格式无效".to_string()), + }, + ) + .expect_err("非法游标必须是错误(api-server 才能映射 400 + 稳定码)"); + assert_eq!( + error.to_string(), + "CATALOG_INVALID_CURSOR: 目录游标格式无效" + ); + } +} + /// 后台主题写接口结果的映射。 /// /// 三条纪律:① `ok = false` 必须折成 `Procedure` 错误(api-server 才能按码映射 400 / 404 / 409); diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 23368f86c..79c5d4a4f 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -33,6 +33,11 @@ pub struct GameDistributionPublicGameListRecordInput { pub category: Option, pub limit: u32, pub author_id: Option, + /// 上一页返回的目录游标(`"{created_at_micros}:{game_id}"`);`None` = 第一页。 + /// + /// 解析在模块侧事务里,非法格式由服务端回稳定码 `CATALOG_INVALID_CURSOR`(api-server 映射 + /// 400);facade 不预校验,避免「客户端挡住的」与「服务端挡住的」变成两套判据。 + pub cursor: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -657,15 +662,20 @@ impl SpacetimeClient { .await } + /// 公开游戏目录(游标分页);只含公开可玩的作品。 + /// + /// 返回 `(games, next_cursor)`:`next_cursor` 为 `None` 表示这一页就是最后一页。 + /// 游标格式非法由模块侧报错,api-server 透传成 400(不吞掉、也不自己造一种 200 的空页)。 pub async fn list_game_distribution_games( &self, input: GameDistributionPublicGameListRecordInput, - ) -> Result, SpacetimeClientError> { + ) -> Result<(Vec, Option), SpacetimeClientError> { let input = crate::module_bindings::GameDistributionPublicGameListInput { search: input.search, category: input.category, limit: input.limit, author_id: input.author_id, + cursor: input.cursor, }; self.call_after_connect("list_game_distribution_games", move |connection, sender| { connection diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_list_procedure_result_type.rs index fb6d59a54..953cc14ff 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_list_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_list_procedure_result_type.rs @@ -11,6 +11,7 @@ use super::game_distribution_public_game_snapshot_type::GameDistributionPublicGa pub struct GameDistributionGameListProcedureResult { pub ok: bool, pub games: Vec, + pub next_cursor: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs index c7b8add29..007452359 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs @@ -11,6 +11,7 @@ pub struct GameDistributionPublicGameListInput { pub category: Option, pub limit: u32, pub author_id: Option, + pub cursor: Option, } impl __sdk::InModule for GameDistributionPublicGameListInput { diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 710550512..ccc5319ff 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1395,6 +1395,11 @@ pub struct GameDistributionPublicGameListInput { pub category: Option, pub limit: u32, pub author_id: Option, + /// 上一页返回的目录游标(`"{created_at_micros}:{game_id}"`);`None` = 第一页。 + /// + /// 解析在事务里(模块侧纯函数),非法格式回稳定码 `CATALOG_INVALID_CURSOR` → 400, + /// 而不是静默当成第一页(静默会把「游标坏了」渲染成「又回到最新一页」)。 + pub cursor: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -2333,13 +2338,32 @@ pub struct GameDistributionGameProcedureResult { pub error_message: Option, } +/// 公开目录列表结果;只含公开可玩作品。 +/// +/// `next_cursor` 是真实的下一页游标:`None` 表示这一页就是最后一页(handler 原样透传成 +/// `nextCursor`,末页发 `null`)。修正前这里没有游标字段、事务里写死 48 条,库里第 49 条起的 +/// 作品(含最老的母版与主干)在 HTTP 面永久不可见;游标格式非法时 `ok = false`,映射 400。 #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct GameDistributionGameListProcedureResult { pub ok: bool, pub games: Vec, + pub next_cursor: Option, pub error_message: Option, } +impl GameDistributionGameListProcedureResult { + /// 失败路径显式回 `next_cursor: None`:与成功路径共用一个不带残值的形状,客户端不会在 + /// `ok = false` 的响应里读到一个指向不存在页面的游标。 + fn failed(error: String) -> Self { + Self { + ok: false, + games: Vec::new(), + next_cursor: None, + error_message: Some(error), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionVersionProcedureResult { pub ok: bool, @@ -2820,21 +2844,25 @@ pub fn list_owner_game_distribution_games_and_return( } } -/// Facade 约定的公开游戏列表名称;公开筛选和排序均在事务内执行。 +/// Facade 约定的公开游戏列表名称;公开筛选、排序与游标切页均在事务内执行。 +/// +/// 该 facade 结果类型本来就带 `next_cursor`(收藏 / 主题列表共用同一形状),因此这条路径与 +/// `list_public_game_distribution_games_and_return` 共享同一份切页结果,两条 procedure 不会对 +/// 「下一页是谁」给出不同答案。 #[spacetimedb::procedure] pub fn list_game_distribution_games_and_return( ctx: &mut ProcedureContext, input: GameDistributionPublicGameListInput, ) -> GameDistributionProcedureResult { match ctx.try_with_tx(|tx| list_public_game_distribution_games_tx(tx, input.clone())) { - Ok(games) => GameDistributionProcedureResult { + Ok((games, next_cursor)) => GameDistributionProcedureResult { ok: true, replayed: false, game: None, games: games.into_iter().map(|item| item.game).collect(), version: None, versions: Vec::new(), - next_cursor: None, + next_cursor, error_message: None, }, Err(error) => game_distribution_result_error(error), @@ -2866,23 +2894,23 @@ pub fn get_game_distribution_game_and_return( } } -/// 返回当前公开且已发布版本的游戏目录;审核待处理或已暂停游戏不会出现在结果中。 +/// 返回当前公开且已发布版本的游戏目录(游标分页);审核待处理或已暂停游戏不会出现在结果中。 +/// +/// `next_cursor` 为 `None` 表示这一页就是最后一页;游标格式非法由事务回稳定码,`ok = false` +/// 经既有映射落 400(不吞成 200 空页,否则客户端会以为目录到底了)。 #[spacetimedb::procedure] pub fn list_public_game_distribution_games_and_return( ctx: &mut ProcedureContext, input: GameDistributionPublicGameListInput, ) -> GameDistributionGameListProcedureResult { match ctx.try_with_tx(|tx| list_public_game_distribution_games_tx(tx, input.clone())) { - Ok(games) => GameDistributionGameListProcedureResult { + Ok((games, next_cursor)) => GameDistributionGameListProcedureResult { ok: true, games, + next_cursor, error_message: None, }, - Err(error) => GameDistributionGameListProcedureResult { - ok: false, - games: Vec::new(), - error_message: Some(error), - }, + Err(error) => GameDistributionGameListProcedureResult::failed(error), } } @@ -5526,28 +5554,39 @@ fn list_owner_game_distribution_games_tx( .collect()) } +/// 公开游戏目录(游标分页)。 +/// +/// 顺序纪律是「**先过滤、再排序切页**」:作者 / 可见性 / 软删除 / 当前公开版本 / 分类 / 关键词 +/// 全部在切页之前完成,游标位置因此定义在已过滤序列上。反过来先把未过滤序列切页、再逐页过滤, +/// 被滤掉的行会凭空占掉名额,下一页的游标又指回过滤前的序列,于是每翻一页都漏掉自己的若干条 +/// (与 `/my-collections`、公开主题列表同一条纪律)。排序 / 切页走 +/// `page_public_game_distribution_catalog`(`created_at` 倒序 + `game_id` 升序兜底的全序), +/// 翻页不重不漏;页大小由同一个纯函数归一(缺省 48 / 上限 100 / 超界截断),事务与 handler 的 +/// 日志口径因此同源。 fn list_public_game_distribution_games_tx( ctx: &ReducerContext, input: GameDistributionPublicGameListInput, -) -> Result, String> { +) -> Result<(Vec, Option), String> { let author_id = input .author_id .as_deref() .map(module_auth::creator::normalize_user_id) .transpose() .map_err(|error| error.to_string())?; - let limit = if input.limit == 0 { - GAME_DISTRIBUTION_MAX_LIST_LIMIT - } else { - input.limit.min(GAME_DISTRIBUTION_MAX_LIST_LIMIT) - } as usize; + let cursor = input + .cursor + .and_then(normalize_game_distribution_optional) + .map(|value| { + module_game_distribution::parse_game_distribution_catalog_cursor(value.as_str()) + }) + .transpose()?; let search = input .search .and_then(|value| normalize_game_distribution_optional(value)); let category = input .category .and_then(|value| normalize_game_distribution_optional(value)); - let mut games = ctx + let visible = ctx .db .game_distribution_game() .iter() @@ -5572,18 +5611,25 @@ fn list_public_game_distribution_games_tx( }) }) .filter(|game| public_game_distribution_version(ctx, game).is_some()) + .map( + |game| module_game_distribution::GameDistributionCatalogPageItem { + game_id: game.game_id.clone(), + created_at_micros: game.created_at.to_micros_since_unix_epoch(), + payload: game, + }, + ) .collect::>(); - games.sort_by(|left, right| { - right - .created_at - .cmp(&left.created_at) - .then_with(|| left.game_id.cmp(&right.game_id)) - }); - games.truncate(limit); - Ok(games - .into_iter() - .filter_map(|game| public_game_distribution_snapshot(ctx, &game)) - .collect()) + let (page, next_cursor) = module_game_distribution::page_public_game_distribution_catalog( + visible, + cursor, + input.limit, + ); + Ok(( + page.into_iter() + .filter_map(|game| public_game_distribution_snapshot(ctx, &game)) + .collect(), + next_cursor, + )) } fn get_public_game_distribution_game_tx( @@ -8302,6 +8348,71 @@ mod tests { ); } + /// 公开游戏目录:**先过滤再排序切页**、投影不删行、结果带回真实 `next_cursor`。 + /// + /// 这是「写死 48 + 无游标 ⇒ 库里第 49 条起的作品(含最老的母版与主干)永久不可见」那个真缺陷 + /// 的结构回归网:谁把切页换回 `truncate(limit)`、把页大小换回 `GAME_DISTRIBUTION_MAX_LIST_LIMIT`, + /// 或把 `next_cursor` 从结果类型里丢掉,这条断言都会红。 + #[test] + fn public_game_catalog_filters_before_paging_and_returns_a_real_cursor() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn list_public_game_distribution_games_tx("); + assert!( + body.contains("module_game_distribution::parse_game_distribution_catalog_cursor"), + "游标解析必须走共享纯函数(非法游标 → 400 稳定码 CATALOG_INVALID_CURSOR)" + ); + assert!( + body.contains("module_game_distribution::page_public_game_distribution_catalog"), + "排序切页必须走共享纯函数(created_at 倒序 + game_id 升序兜底)" + ); + assert!( + body.contains("module_game_distribution::GameDistributionCatalogPageItem"), + "排序键与负载必须绑定成一条值(避免按 A 排序、按 B 切页)" + ); + let filter_at = body + .find("GAME_DISTRIBUTION_VISIBILITY_PUBLISHED") + .expect("可见性过滤必须在事务内"); + let page_at = body + .find("module_game_distribution::page_public_game_distribution_catalog(") + .expect("切页必须在事务内"); + assert!( + filter_at < page_at, + "必须先按可见性过滤、再排序切页:反过来会每翻一页漏掉自己的若干条作品" + ); + assert!( + !body.contains(".delete("), + "投影阶段不得删除作品行:下架 / 未公开只是不进入公开投影" + ); + assert!( + !body.contains("GAME_DISTRIBUTION_MAX_LIST_LIMIT"), + "目录页大小必须走自己的归一函数(缺省 48 / 上限 100);用 48 那个常量会让 48 又变回硬上限" + ); + assert!( + !body.contains(".truncate("), + "截断必须由纯函数完成(多取一条判 has_more),事务里自己 truncate 会丢掉下一页游标" + ); + // 结果类型必须给出真实游标字段:成功路径透传,失败路径显式回 None。 + let procedure = function_body( + source, + "pub fn list_public_game_distribution_games_and_return(", + ); + assert!(procedure.contains("Ok((games, next_cursor))")); + assert!(procedure.contains("next_cursor,")); + assert!(procedure.contains("GameDistributionGameListProcedureResult::failed(error)")); + let failed = function_body(source, "impl GameDistributionGameListProcedureResult {"); + assert!( + failed.contains("next_cursor: None"), + "失败路径必须显式回 None,不能在 ok = false 的响应里带一个残值游标" + ); + // facade 那条 procedure 与公开列表共享同一份切页结果,不会各答一个「下一页」。 + let facade = function_body(source, "pub fn list_game_distribution_games_and_return("); + assert!(facade.contains("Ok((games, next_cursor))")); + assert!( + facade.contains("next_cursor,"), + "facade 结果类型本来就带 next_cursor,必须一并透传" + ); + } + /// 主题详情:不存在 / 未发布用同一句 404 文案;已发布空成员是正常结果;roots 复用公开投影。 #[test] fn public_theme_detail_maps_missing_to_not_found_and_empty_members_to_success() {