diff --git a/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md b/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md index 7a0c6fec6..1ba4f48e9 100644 --- a/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md +++ b/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md @@ -41,7 +41,7 @@ - M3(族谱与衍生列表)已落地:主题页的树**直接复用** `/games/{id}/lineage`,本里程碑不重建任何树查询或树整形。 - 公开目录同一份 `public_game_payload` 投影已存在于 api-server(主题详情与成员卡片都复用它)。 - 游标分页惯例已由 `/my-collections`(2026-10-06)落地:默认 20 / 上限 50 / 游标 `"{micros}:{id}"` / 非法游标 400 / 末页 `nextCursor: null`。 -- 设计已由技术方案 §3.10 定稿(含四条留白)。**待产品确认的有两条**:①「主题级排序口径」;② 主题详情 `roots` 的 **50 上限**行为(见文末「待确认项」)。 +- 设计已由技术方案 §3.10 定稿(含四条留白)。**待产品确认的只剩一条**:①「主题级排序口径」;② 主题详情 `roots` 的 **50 上限**行为**已于 2026-10-06 定口径**(`memberCount` 报真实可见成员数 + 新增 `rootsTruncated` 截断信号,见文末「待确认项」)。 ## 实施清单(执行结果) @@ -74,6 +74,7 @@ A–F 已落地(服务端);G 前端与后台 UI 未做。 | `encode_game_distribution_theme_cursor(created_at_micros: i64, theme_id: &str) -> String` / `parse_game_distribution_theme_cursor(value: &str) -> Result<(i64, String), String>` | 游标编解码 | 格式 `"{micros}:{themeId}"`,解析只切**第一个**冒号;解析失败返回 `Err`(api-server 映射 400) | | `sort_public_themes(...)` + `page_public_themes(items, cursor, limit) -> (page, next_cursor)` | 主题公开列表排序与切页 | 排序键 `created_at` **倒序** + `theme_id` **升序**兜底(全序,翻页不重不漏);**先过滤可见性、再排序切页** | | `sort_theme_members(...)` + `page_theme_members(items, limit) -> Vec<...>` | 主题内成员排序 | `sort_order` **升序** + `member_id` **升序**兜底(`sort_order` 允许重复) | +| `game_distribution_theme_roots_truncated(visible_member_count: usize, returned_root_count: usize) -> bool` | 详情 `roots` 是否被响应体积上限截断 | 仅 `returned_root_count < visible_member_count` 为 `true`;相等(不足上限,全发)与「回传多于可见」(两组数字不同源,防御性)都是 `false`。与族谱 `truncated` 同约定 | 复用 `collection.rs` 里 `GameDistributionCollectionPageItem` 那种「排序键与负载绑定」的写法,避免「按 A 排序、按 B 切页」的错位。 @@ -105,8 +106,8 @@ A–F 已落地(服务端);G 前端与后台 UI 未做。 | 方法 / 路径 | handler | 响应 | 错误 | | --- | --- | --- | --- | -| `GET /api/game-distribution/themes?limit=&cursor=` | `list_public_themes` | `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }` | 非法游标 → 400(平台信封) | -| `GET /api/game-distribution/themes/{theme_id}` | `get_public_theme` | `{ themeId, name, summary, badge, memberCount, roots: [...] }` | 不存在 / 未发布 → 404(不返回空壳) | +| `GET /api/game-distribution/themes?limit=&cursor=` | `list_public_themes` | `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }`(`memberCount` = 真实可见成员数,不截断) | 非法游标 → 400(平台信封) | +| `GET /api/game-distribution/themes/{theme_id}` | `get_public_theme` | `{ themeId, name, summary, badge, memberCount, roots: [...], rootsTruncated }`(`memberCount` 不截断;`rootsTruncated` = 这份 `roots` 被 50 上限截断) | 不存在 / 未发布 → 404(不返回空壳) | | `GET /api/game-distribution/games/{game_id}`(**既有,响应增量**) | `get_game` | 追加 `themes: [{ themeId, name, badge }]` | 沿用既有 404 | 后台族(挂 `admin` 那一支的 `route_layer(middleware::from_fn_with_state(state, require_admin_auth))`): @@ -164,10 +165,10 @@ A–F 已落地(服务端);G 前端与后台 UI 未做。 | 层 | 用例 | | --- | --- | -| 纯函数(`module-game-distribution`) | 状态白名单(合法三态 / 未知值);主题公开可见性(`draft` / `archived` false);成员可见性四组合 + 「未删已公开但无当前公开版本」必须 false;`member_id` 确定性(同输入同输出、不同主题不同行);成员只允许根(`has_lineage_row` true → 拒绝);游标编解码往返 + 非法游标(缺冒号 / 非数字 / 空 ID)+ 解析只切第一个冒号;页大小归一化(缺省 20 / 0 取默认 / 超界截断到 50);**过滤后切页不重不漏**(可见性过滤位置在排序之前);排序兜底(`sort_order` 相同按 `member_id` 升序;`created_at` 相同按 `theme_id` 升序) | +| 纯函数(`module-game-distribution`) | 状态白名单(合法三态 / 未知值);主题公开可见性(`draft` / `archived` false);成员可见性四组合 + 「未删已公开但无当前公开版本」必须 false;`member_id` 确定性(同输入同输出、不同主题不同行);成员只允许根(`has_lineage_row` true → 拒绝);游标编解码往返 + 非法游标(缺冒号 / 非数字 / 空 ID)+ 解析只切第一个冒号;页大小归一化(缺省 20 / 0 取默认 / 超界截断到 50);`rootsTruncated` 三态(相等 / 截断 / 防御性「回传多于可见」);**过滤后切页不重不漏**(可见性过滤位置在排序之前);排序兜底(`sort_order` 相同按 `member_id` 升序;`created_at` 相同按 `theme_id` 升序) | | 事务(`spacetime-module`) | 同主题同根重复 upsert 只有一行且 `created_at` 不变;跨主题同根两行并存;非根入成员被拒;成员作品下架 / 软删除后**行还在**、公开投影不含它、重新公开后自动回来;归档主题不出现在公开列表;`theme_id` 服务端生成且形如 `theme-*`;`created_by_user_id` 取自 admin 会话主体;`updated_at` 每次更新刷新 | | api-server 路由 | 公开列表 200 / 匿名可读 / 非法游标 400 / 末页 `nextCursor === null`;公开详情 404(不存在、`draft`、`archived`)与 200 空 `roots`;作品详情 `themes` 增量(只含公开主题;**第 N 代作品按其根查到所属主题**);两条公开路径都带 `Cache-Control: no-store`;后台五条路由未带 admin 会话一律 401;后台写:空 `name` / 非法 `status` 400、未知 `theme_id` 404、作品不存在 404、非根 409、同键重放 `replayed: true`、同键不同请求 409、`DELETE` 重复调用 200 | -| 契约 | DTO parity 含新构建器;TS 类型与 Rust DTO 逐键一致 | +| 契约 | DTO parity 含新构建器(`public_theme_detail_payload` 的 `mustEmit` 含 `rootsTruncated`);TS 类型与 Rust DTO 逐键一致 | | 前端 | 主题列表(渲染 / 空态 / 失败态 / 加载更多);主题页多棵树(mock 两个根各自 `lineage`)与「已发布但空成员」空态;详情页 `themes` 链到主题页、无主题时不渲染;窄屏布局不撑破 | ### I. 门禁命令与证据 @@ -216,7 +217,7 @@ E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node scripts/check-game-d - [x] 公开主题列表分页沿用既有游标惯例:默认 20、上限 50、超界截断;非法游标 400;`nextCursor` 为真实值且末页为 `null`;排序为 `created_at` 倒序 + `theme_id` 升序兜底,翻页不重不漏。 - [x] 主题详情的 `roots` 按 `sort_order` 升序 + `member_id` 升序稳定排序,逐条为既有公开作品投影(不泄露对象键、不泄露未公开作品信息)。 - [x] 作品详情新增 `themes`:**第 N 代作品**(非根)也能看到并跳到其所属公开主题(按根反查),且只含公开主题。 -- [x] 主题列表与详情的 `memberCount` 等于同响应里可见成员数(`roots` 长度),不含草稿 / 已下架成员(**注意 50 上限:> 50 时该等式仍成立,因为两者都被截断,见「待确认项」**)。 +- [x] 主题列表与详情的 `memberCount` 是**真实可见成员数**(不截断,不含草稿 / 已下架成员);详情另有 `rootsTruncated` 表示这份 `roots` 被 50 上限截断(`roots` 少于 `memberCount` 时为 `true`,与族谱 `truncated` 同约定)。**注意:`memberCount == roots.length` 不再是不变式**(可见成员 > 50 时故意不等,见「待确认项」的已定口径)。 - [x] 两条公开主题路径匿名可读且带 `Cache-Control: no-store`。 - [x] 后台五条路由未带 admin 会话一律 401;鉴权复用既有 `require_admin_auth` + `AuthenticatedAdmin`,不自造。 - [x] 后台写接口错误码可区分:空名 / 非法状态 400、未知主题 404、作品不存在 404、非根 409、同键不同请求 409;同键重放如实回报幂等重放(错误码映射单测 + 领域错误类型单测;**经真实库存的 404 / 409 等 dev 栈 e2e**)。 @@ -240,7 +241,7 @@ E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node scripts/check-game-d ## 待确认项(需用户 / 产品拍板) - **主题级排序口径**:本方案按「沿用既有游标惯例」把公开列表排在 `created_at`(倒序)+ `theme_id`(升序兜底)上,`sort_order` 只用于**主题内成员排序**与后台列表。若产品要求共创 Tab 按运营 `sort_order` 展示,需要把游标改成 `(sort_order, theme_id)` 双键并同步前端——**这会改一次接口契约**(已按现状实现,改需另开一轮)。 -- **主题详情 `roots` 的 50 上限行为**:`get_game_distribution_theme_detail_tx` 用 `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX`(= 50)截断可见成员,且 `memberCount == roots.len()`,因此**可见成员 > 50 时静默截断且没有「还有更多」标志**(无 `truncated`、无成员游标)。三个备选:① 去上限;② 给成员加**独立游标**;③ 保持截断但 `memberCount` 报**真实可见数**。**本轮不改契约**(同步写在技术方案 §3.10.6 / §3.10.9 / §7 第 3 条)。 +- **主题详情 `roots` 的 50 上限行为(已定口径,2026-10-06)**:原「三个备选」里采纳 ③:`roots` **保留** 50 上限(`get_game_distribution_theme_detail_tx` 里走 `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX`),**但 `memberCount` 改为报真实可见成员数**(不再取截断后的 `roots.len()`,列表与详情都走 `game_distribution_theme_member_count`),并新增布尔信号 `rootsTruncated`:仅当 `roots` 少于 `memberCount` 时为 `true`(纯函数 `game_distribution_theme_roots_truncated`),与族谱响应的 `truncated` 同约定,客户端可据此提示「仅显示前 50 个」。未采纳 ①(去上限:一次性放大响应体积)与 ②(成员独立游标:契约从「一次拿全」改成翻页、须配套成员游标)。同步写在技术方案 §3.4 / §3.10.6 / §3.10.9。 ## 已知留白 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 410ac1cbc..2e964762e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -533,7 +533,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 只允许**根作品**:根 = 该作品没有血缘行(第 0 代)。判定事实由调用方用血缘点查给出(`game_distribution_lineage().game_id().find(&game_id)` 命中即有行 ⇒ 非根),规则由纯函数 `game_distribution_theme_root_acceptable` 表达;非根写入被拒(`THEME_MEMBER_NOT_ROOT` 409)。理由:主题页呈现的作品树由 `/games/{id}/lineage` **按根**聚合,若允许子作品也入主题,同一棵树会在主题页里出现多次。 - 防重:主键是确定性构造的 `{theme_id}:{root_game_id}`(`module_game_distribution::game_distribution_theme_member_id`),同一 (主题, 根) 在结构上不可能出现第二行——去重由主键约束本身承担,而不是「事务里先查后写」。因此**不需要**额外的 `(theme_id, root_game_id)` 唯一索引。跨主题的同一根会有**多行**(每主题一行),这是预期行为而不是重复数据。 - 索引:`by_game_distribution_theme_member_theme_id`(主题页按主题取成员)、`by_game_distribution_theme_member_root_game_id`(作品详情按根反查所属公开主题,因此第 N 代作品也能看到自己的主题)。 -- 可见性:成员只出现在「作品公开未删且存在当前公开版本」时——判定**委托**收藏的同一条口径 `module_game_distribution::game_distribution_theme_member_visible`(内部即 `game_distribution_collection_visible`),不新写第二个同义判定。作品下架 / 软删除时该成员在**投影里跳过但不删除行**,作品重新公开后自动回到主题页;主题详情的 `roots` 与列表的 `memberCount` 用同一判定,因此两个数永远一致。 +- 可见性:成员只出现在「作品公开未删且存在当前公开版本」时——判定**委托**收藏的同一条口径 `module_game_distribution::game_distribution_theme_member_visible`(内部即 `game_distribution_collection_visible`),不新写第二个同义判定。作品下架 / 软删除时该成员在**投影里跳过但不删除行**,作品重新公开后自动回到主题页;主题详情的 `roots` 与列表 / 详情的 `memberCount` 用**同一判定**(因此成员口径永不漂移),但**两个数在可见成员超过详情响应体积上限 50 时故意不相等**:`memberCount` 报真实可见成员数,`roots` 只回前 50 条,差额由详情响应的 `rootsTruncated` 表达(纯函数 `module_game_distribution::game_distribution_theme_roots_truncated`,与族谱 `truncated` 同约定)。 - 排序:主题内成员按 `sort_order` 升序 + `member_id` 升序兜底(`sort_order` 允许重复,兜底键保证全序、翻页/渲染不重不漏);可见性过滤必须发生在排序切页之前。 - 迁移:随迁移导出/导入(运营业务事实);归档主题与下架作品都不删除成员行。`migration_tables!` 已登记该表。 - 读写路径(均已落地,2026-10-06):公开详情 `get_game_distribution_theme_detail_and_return`(成员投影在 `get_game_distribution_theme_detail_tx` 内按 `by_game_distribution_theme_member_theme_id` 取行)、作品详情 `themes` 增量 `list_game_distribution_theme_refs_for_root_and_return`(`list_game_distribution_theme_refs_for_root_tx`,按 `by_game_distribution_theme_member_root_game_id` 反查);后台写 `upsert_game_distribution_theme_member_and_return` / `remove_game_distribution_theme_member_and_return`(`upsert_game_distribution_theme_member_tx` / `remove_game_distribution_theme_member_tx`)。私有表,只经 api-server 的受信服务身份调用。 diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index de5675d8e..0aef604fc 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -311,8 +311,8 @@ pub(crate) project_bundle_sha256: Option, | `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)。**不追加 `forkSourceAvailable`**:网页端「改造这个作品」入口的显隐只依据 `forkAuthorization`(已公开 + 非禁止即可引导去取件),真实可复刻形态由 `/games/{gameId}/fork-source` 的 `source` 字段回答,不需要在公开详情里提前判断;若 M2b 需要在详情页区分「可源码级改造 / 只能参考」,再在 M2b 里加该字段。**另追加 `collected`(仅登录用户,2026-10-06)**:登录已认证时返回 `true` / `false`(真实投影,值来自 `is_game_distribution_collected_and_return`);**匿名请求不返回该字段**(也不发 `false`——`false` 会把「未登录」说成「没收藏」)。该字段随请求者变化,所以这条路径必须 `no-store`、不得有任何共享缓存。**再追加 `themes: [{ themeId, name, badge }]`(2026-10-06 增量,共创主题)**:该作品所属的**公开**主题(只含 `status == published`);**匿名与登录都发、无主题时恒发空数组**(与「仅登录才发」的 `collected` 不同——没有主题与没登录因此不会被混成同一种缺键)。口径是「先取该作品的**根**、再按根反查公开主题」,所以**第 N 代作品也能看到并跳到主题页**;单条只够渲染跳转入口(简介与成员数去主题页取) | | `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status }`,按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点 | | `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 | -| `GET /api/game-distribution/themes?limit=&cursor=`(新,2026-10-06) | 公开共创主题列表。**匿名可读 + `no-store`**,只含 `status == published` 的主题。响应 `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }`;`memberCount` = 该主题**当前公开可见成员数**(与详情 `roots` 长度同一判定,不是成员行总数)。分页沿用 `/my-collections` 那套游标惯例:`limit` 缺省 **20**、上限 **50**、超界**截断**(客户端拿到一个完整页,而不是需要重试的错误);`cursor` 形如 `"{createdAtMicros}:{themeId}"`(解析只切第一个冒号);**非法游标 → 400 `THEME_INVALID_CURSOR`**(模块侧报错透传,不吞成 200 空页);`nextCursor` 为真实值,**末页为 `null`**。排序 `created_at` 倒序 + `themeId` 升序兜底(全序,翻页不重不漏),顺序定义为「**先按可见性过滤、再排序切页**」 | -| `GET /api/game-distribution/themes/{themeId}`(新,2026-10-06) | 公开主题详情。**匿名可读 + `no-store`**;顶层**扁平** `{ themeId, name, summary, badge, memberCount, roots }`(与 `GET /games/{gameId}` 同形,不引入第二套包装)。`roots` 逐条是公开目录**同一份** `public_game_payload` 投影,按 `sort_order` 升序 + `member_id` 升序稳定排序(不泄露对象键、不泄露未公开作品)。主题**不存在 / `draft` / `archived` 一律 404**(模块侧回同一句「主题不存在」,由既有映射落 404;**不返回空壳**、公开侧不发 `THEME_*` 码);**已发布但可见成员为空 = 200 + 空 `roots`**(空态而不是错误)。**已知限制(待拍板)**:`roots` 受页上限 **50** 约束且 `memberCount == roots.len()`,可见成员 > 50 时**静默截断且没有「还有更多」标志**(三个备选见 §3.10.6 与 §3.10.9) | +| `GET /api/game-distribution/themes?limit=&cursor=`(新,2026-10-06) | 公开共创主题列表。**匿名可读 + `no-store`**,只含 `status == published` 的主题。响应 `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }`;`memberCount` = 该主题**真实可见成员数**(不截断、不套响应体积上限,也不是成员行总数)。它与详情 `roots` 的长度在可见成员超过 50 时**故意不相等**——「展示上限」不该污染「这个主题有多少棵树」,判定口径仍是同一份。分页沿用 `/my-collections` 那套游标惯例:`limit` 缺省 **20**、上限 **50**、超界**截断**(客户端拿到一个完整页,而不是需要重试的错误);`cursor` 形如 `"{createdAtMicros}:{themeId}"`(解析只切第一个冒号);**非法游标 → 400 `THEME_INVALID_CURSOR`**(模块侧报错透传,不吞成 200 空页);`nextCursor` 为真实值,**末页为 `null`**。排序 `created_at` 倒序 + `themeId` 升序兜底(全序,翻页不重不漏),顺序定义为「**先按可见性过滤、再排序切页**」 | +| `GET /api/game-distribution/themes/{themeId}`(新,2026-10-06) | 公开主题详情。**匿名可读 + `no-store`**;顶层**扁平** `{ themeId, name, summary, badge, memberCount, roots, rootsTruncated }`(与 `GET /games/{gameId}` 同形,不引入第二套包装)。`roots` 逐条是公开目录**同一份** `public_game_payload` 投影,按 `sort_order` 升序 + `member_id` 升序稳定排序(不泄露对象键、不泄露未公开作品)。主题**不存在 / `draft` / `archived` 一律 404**(模块侧回同一句「主题不存在」,由既有映射落 404;**不返回空壳**、公开侧不发 `THEME_*` 码);**已发布但可见成员为空 = 200 + 空 `roots`**(空态而不是错误)。**成员数口径已定(2026-10-06)**:`roots` 受响应体积上限 **50** 约束(排序后取前 50 条),`memberCount` 报**真实可见成员数**(不截断),`rootsTruncated` 仅当 `roots` 少于 `memberCount` 时为 `true`——即「下面这份 `roots` 被上限截断」的显式信号,与族谱响应的 `truncated` 同约定(详见 §3.10.6 与 §3.10.9) | #### 作者(Bearer + 发布灰度) @@ -610,14 +610,16 @@ pub struct GameDistributionThemeMember { | 方法 / 路径 | 响应 | 说明 | | --- | --- | --- | | `GET /api/game-distribution/themes?limit=&cursor=` | `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }` | 公开主题列表(只含 `published`)。**分页沿用刚落地的那套游标惯例**:默认 20、上限 50、超界截断;游标 `"{createdAtMicros}:{themeId}"`(`"{micros}:{id}"` 同一套惯例,解析只切第一个冒号);非法游标 **400**;`nextCursor` 为真实值,**末页为 `null`** | -| `GET /api/game-distribution/themes/{theme_id}` | `{ themeId, name, summary, badge, memberCount, roots: [<既有公开作品投影>...] }` | 主题详情 + 成员根清单。`roots` 逐条用公开目录同一份 `public_game_payload` 投影;排序 `sort_order` 升序 + `member_id` 升序兜底(`sort_order` 允许重复,兜底键保证全序、翻页/渲染不重不漏)。顶层扁平形状与 `GET /games/{gameId}` 同形,不引入第二套包装。不存在 / 未发布 → 404(§3.10.5) | +| `GET /api/game-distribution/themes/{theme_id}` | `{ themeId, name, summary, badge, memberCount, roots: [<既有公开作品投影>...], rootsTruncated }` | 主题详情 + 成员根清单。`roots` 逐条用公开目录同一份 `public_game_payload` 投影;排序 `sort_order` 升序 + `member_id` 升序兜底(`sort_order` 允许重复,兜底键保证全序、翻页/渲染不重不漏),并按响应体积上限取前 50 条。`memberCount` 是**真实可见成员数**,`rootsTruncated` 说明这份 `roots` 有没有被上限截断。顶层扁平形状与 `GET /games/{gameId}` 同形,不引入第二套包装。不存在 / 未发布 → 404(§3.10.5) | 补充口径: -- **`memberCount` = 当前公开可见成员数**(与 `roots` 长度一致,同一可见性纯函数)。理由:若回报「全部成员行数」,运营就能通过计数变化探测「存在草稿或被下架成员」,且它会与 `roots` 长度**对不上**,客户端无法解释两个数为什么不一致。代价:计数是逐主题现算的(不新增物化计数字段,与仓库「实时算、不加物化计数」的既有取舍一致)。 +- **`memberCount` = 真实可见成员数**(不截断),回答「这个主题有多少棵作品树」;理由:若回报「全部成员行数」,运营就能通过计数变化探测「存在草稿或被下架成员」。代价:计数是逐主题现算的(不新增物化计数字段,与仓库「实时算、不加物化计数」的既有取舍一致)。 +- **`rootsTruncated` = 「下面这份 `roots` 被响应体积上限截断」的显式信号**(仅当 `roots` 少于同响应的 `memberCount` 时为 `true`)。它与 §3.4 族谱响应的 `truncated` 是**同一个约定**(「这份列表被上限截断」,客户端按同一种方式分支),不发明第二套「还有更多」的表示。为什么不让 `memberCount` 继续等于 `roots.len()`:那个等式把「展示上限」(响应体积)和「事实」(主题有多少棵树)绑成一个数,可见成员 > 50 时客户端拿到一个**偏低**的成员数却毫无察觉;拆成两个字段后,客户端既能显示真实规模,也知道自己看到的是不是全集。 +- 切页与计数的顺序:`roots` 走「先按可见性过滤 → 再按 `sort_order` 排序 → 取前 50 条」,`memberCount` 数的是**过滤后、切页前**的整份可见成员(同一份可见性判定,只有一处实现),因此两个数只可能因「上限」而不同,不会因口径不同而漂移。 - 排序与切页顺序定义为「**先按可见性过滤、再排序切页**」,游标位置落在已过滤序列上——否则每翻一页都会漏掉自己的若干条(与 `/my-collections` 同一条纪律)。 - 全路径 `no-store`,挂在既有 `add_no_store_response_headers` 上;不新增第二套响应头中间件。 -- **已知限制(待拍板):主题详情的 `roots` 沿用页上限 50,且 `memberCount == roots.len()`。** 事务用 `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX`(= 50,与公开列表同一常量)截断可见成员,并把 `member_count` 直接取成截断后的 `roots.len()`——因此**可见成员 > 50 时静默截断且没有「还有更多」标志**(`truncated` / `nextCursor` 都没有,客户端无法知道自己少看了)。三个备选**待拍板**:① 去掉详情 `roots` 的上限(一次全发,风险是超大主题响应体积);② 给详情成员加**独立游标**(`roots` 分页,但契约要从「一次拿全」改成翻页);③ 保持截断但让 `memberCount` 报**真实可见数**(不再等于 `roots.len()`,客户端据此能发现「还有更多」但仍然拿不到)。**本轮不改契约**,先如实记录(同一条限制在 §3.10.9 与里程碑「待确认项」)。 +- **口径已定(2026-10-06,原「三个备选待拍板」的选项 ③)**:详情 `roots` **保留** 50 上限(一次全发的响应体积风险与超长主题的首屏成本都不划算,`roots` 分页则要把「一次拿全」的契约改成翻页,得配成员游标),同时 `memberCount` 报**真实可见成员数**、并新增 `rootsTruncated` 给客户端一个能分支的信号。落地位置:纯函数 `module_game_distribution::game_distribution_theme_roots_truncated`、事务 `get_game_distribution_theme_detail_tx`(`member_count` 不再取 `roots.len()`)、响应键 `rootsTruncated`(已在 `check:game-distribution-dto-parity` 的 `mustEmit` 里钉住)。 **已实现(`742723a58`)**:上述两条公开路由 + 可见性过滤 + 游标分页;DTO parity 登记 `public_themes_payload` / `public_theme_detail_payload` / `public_theme_summary_payload` 三个构建器。 @@ -669,11 +671,11 @@ pub struct GameDistributionThemeMember { | 主题内「**跳到某一代节点**」高亮 | 属 UI 层能力,族谱页已有 `from` 参数(§3.10.2),后端不需要新字段;在没有主题页 UI 之前做它没有消费方 | 主题页 UI 落地时直接复用 `from` 参数 | | **批量树端点** | 本轮接受前端 N 次 `/games/{id}/lineage`(N = 成员数);主题成员量级小,且「不在主题侧重建树查询」的价值高于省这几次请求 | 若主题成员规模上升或出现首屏超时,再提供一个批量端点(服务端内部仍是同一份 `build_lineage_tree`,不新造规则) | -**已知限制(已落地行为,等拍板;不是「留白」)**: +**已知限制(已落地行为;原「待拍板」项已于 2026-10-06 定口径,见下)**: -| 限制 | 现状 | 备选(待拍板) | +| 限制 | 现状 | 处置 | | --- | --- | --- | -| 主题详情 `roots` 的 **50 上限** | `get_game_distribution_theme_detail_tx` 用 `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX`(= 50)截断可见成员,并把 `memberCount` 取成截断后的 `roots.len()`。因此**可见成员 > 50 时静默截断,且既没有 `truncated` 也没有成员 `nextCursor`**——客户端拿不到「还有更多」的任何信号(详见 §3.10.6) | ① 去上限(一次全发,承担响应体积);② 给成员加**独立游标**(详情节改成翻页);③ 保持截断但 `memberCount` 报**真实可见数**(客户端能发现不一致)。**本轮不改契约** | +| 主题详情 `roots` 的 **50 上限** | `get_game_distribution_theme_detail_tx` 用 `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX`(= 50)截断 `roots`。**已定口径**:`memberCount` 改为报**真实可见成员数**(不再取 `roots.len()`),并新增 `rootsTruncated` 仅当 `roots` 少于 `memberCount` 时为 `true`——客户端据此知道「我看到的不是全集」,与族谱响应的 `truncated` 同约定(详见 §3.10.6)。 | ✅ 已定:保留 50 上限(选项 ③:不加成员游标、不一次全发)。落地点见 §3.10.6;`rootsTruncated` 已进 DTO parity 的 `mustEmit` | #### 3.10.10 迁移、契约与门禁 diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index afd6cce28..5c832ea7d 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -346,8 +346,9 @@ export type GameDistributionThemeReference = { /** * 公开主题列表的单条:主题名、简介、角标与**当前公开可见成员数**。 * - * `memberCount` 与详情响应的 `roots` 长度是同一个数(同一份成员可见性判定),不含草稿 / - * 已下架成员;服务端不回报「全部成员行数」,客户端也不该用两个数互相推算。 + * `memberCount` 是**真实可见成员数**(不含草稿 / 已下架成员),服务端不回报「全部成员行数」。 + * 它是真实数:可见成员超过响应体积上限(50)时,它与主题详情 `roots` 的长度**故意不相等**—— + * 「这份 `roots` 被截断」由详情响应上的 `rootsTruncated` 单独表达,客户端不要用这个字段反推。 */ export type GameDistributionThemeSummary = { themeId: string; @@ -375,6 +376,11 @@ export type GameDistributionThemeListResponse = { * 顶层扁平形状与 `GET /games/{gameId}` 同形,不引入第二套包装。`roots` 逐条是公开目录同一份 * 作品投影,排序为 `sortOrder` 升序 + `memberId` 升序兜底。主题不存在或未发布时接口返回 404 * (不返回空壳);已发布但可见成员为空时返回 200 + 空 `roots`(展示层按空态而不是错误渲染)。 + * + * 两个成员数相关的字段口径**独立,不要互相推导**: + * - `memberCount` 是**真实可见成员数**(不截断),回答「这个主题有多少棵作品树」; + * - `rootsTruncated` 才是「**下面这份 `roots` 被响应体积上限(50)截断**」的信号,与族谱响应的 + * `truncated` 同一个约定(列表被上限截断时置 `true`)。`roots.length !== memberCount` 时就得看它。 */ export type GameDistributionThemeDetail = { themeId: string; @@ -383,6 +389,7 @@ export type GameDistributionThemeDetail = { badge: string; memberCount: number; roots: GameDistributionGame[]; + rootsTruncated: boolean; }; /** diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 89f306da8..61207f1b6 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -186,11 +186,21 @@ const RESPONSE_BUILDERS = [ mustEmit: ['themes', 'nextCursor'], }, { - // 公开共创主题详情:顶层扁平(与 `GET /games/{gameId}` 同形),六个键都由这一条路径发出。 + // 公开共创主题详情:顶层扁平(与 `GET /games/{gameId}` 同形),**七个**键都由这一条路径发出。 + // `memberCount`(真实可见成员数,不截断)与 `rootsTruncated`(这份 `roots` 有没有被响应体积 + // 上限截断)是两个独立口径,缺了后者客户端就只能靠「两个数不相等」去猜自己是否少看了。 // `roots` 逐条复用公开目录同一份 `public_game_payload`(不是第二套作品摘要)。 fn: 'public_theme_detail_payload', ts: 'GameDistributionThemeDetail', - mustEmit: ['themeId', 'name', 'summary', 'badge', 'memberCount', 'roots'], + mustEmit: [ + 'themeId', + 'name', + 'summary', + 'badge', + 'memberCount', + 'roots', + 'rootsTruncated', + ], }, { // 主题条目(列表单条与详情头部共用):`public_themes_payload` 里逐条调用它,`roots` 之外 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 36cc013ba..7672b75fe 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -1710,8 +1710,9 @@ fn public_themes_payload( /// 主题条目:`{ themeId, name, summary, badge, memberCount }`(列表与详情头部共用同一形状)。 /// -/// `memberCount` 是**当前公开可见成员数**(与详情 `roots` 长度同一个数):回报「成员行总数」会让 -/// 运营通过计数变化探测到「存在草稿或被下架成员」,且它会与 `roots` 长度对不上。 +/// `memberCount` 是**真实可见成员数**(与详情响应的 `memberCount` 同一个数,不截断):回报 +/// 「成员行总数」会让运营通过计数变化探测到「存在草稿或被下架成员」。它**不是**详情 `roots` 的 +/// 长度——详情 `roots` 受响应体积上限约束,「被截断」由详情响应上的 `rootsTruncated` 表达。 fn public_theme_summary_payload(theme: GameDistributionThemeSummaryRecord) -> Value { json!({ "themeId": theme.theme_id, @@ -1724,6 +1725,11 @@ fn public_theme_summary_payload(theme: GameDistributionThemeSummaryRecord) -> Va /// 公开主题详情响应:顶层扁平(与 `GET /games/{gameId}` 同形,不引入第二套包装), /// `roots` 逐条是公开目录**同一份** `public_game_payload` 投影。 +/// +/// `memberCount`(真实可见成员数,不截断)与 `rootsTruncated`(这份 `roots` 有没有被响应体积上限 +/// 截断)是两个独立键:可见成员 > 50 时 `memberCount` 照实报、`roots` 只回前 50 条并置 +/// `rootsTruncated: true`,与族谱响应的 `truncated` 同一种约定,客户端据此决定要不要提示 +/// 「仅显示前 50 个」。**不要**用 `memberCount === roots.length` 当不变式。 fn public_theme_detail_payload(theme: GameDistributionThemeDetailRecord) -> Value { json!({ "themeId": theme.theme_id, @@ -1736,6 +1742,7 @@ fn public_theme_detail_payload(theme: GameDistributionThemeDetailRecord) -> Valu .into_iter() .map(public_game_payload) .collect::>(), + "rootsTruncated": theme.roots_truncated, }) } @@ -8834,7 +8841,8 @@ mod tests { ); } - /// 主题详情响应:顶层扁平(六个键),`roots` 走公开目录**同一份**投影,不另造条目形状。 + /// 主题详情响应:顶层扁平(**七个**键),`roots` 走公开目录**同一份**投影,不另造条目形状; + /// `rootsTruncated` 与 `memberCount` 是两个独立键(空成员时前者为 `false`,不是缺键)。 #[test] fn public_theme_detail_payload_is_flat_and_reuses_public_game_projection() { let detail = public_theme_detail_payload(GameDistributionThemeDetailRecord { @@ -8844,6 +8852,7 @@ mod tests { badge: "精选".to_string(), member_count: 1, roots: vec![public_game_record_fixture()], + roots_truncated: false, }); let mut keys = detail .as_object() @@ -8859,18 +8868,22 @@ mod tests { "memberCount", "name", "roots", + "rootsTruncated", "summary", "themeId" - ] + ], + "顶层必须是契约里那七个键:`rootsTruncated` 是「这份 roots 被上限截断」的独立信号" ); assert_eq!(detail["themeId"], "theme_a"); assert_eq!(detail["memberCount"], 1); + assert_eq!(detail["rootsTruncated"], Value::Bool(false)); // `roots` 的条目与公开目录逐字节一致:同一条投影链路,不存在第二套作品摘要。 assert_eq!( detail["roots"][0], public_game_payload(public_game_record_fixture()) ); - // 已发布但可见成员为空:200 + 空 `roots`(不是 404、不是缺键)。 + // 已发布但可见成员为空:200 + 空 `roots`(不是 404、不是缺键),且 `rootsTruncated` 必须 + // 显式是 `false`——空清单没什么可截断的,缺键会让客户端只能靠 `undefined` 猜。 let empty = public_theme_detail_payload(GameDistributionThemeDetailRecord { theme_id: "theme_b".to_string(), name: "主题 theme_b".to_string(), @@ -8878,9 +8891,29 @@ mod tests { badge: String::new(), member_count: 0, roots: Vec::new(), + roots_truncated: false, }); assert_eq!(empty["memberCount"], 0); assert_eq!(empty["roots"], Value::Array(Vec::new())); + assert_eq!(empty["rootsTruncated"], Value::Bool(false)); + // 截断路径:`memberCount` 照实报真实可见数(120),`roots` 只有 1 条并置 `true`—— + // 两个键口径独立,任何「用其中一个推另一个」的改动都会在这里红。 + let truncated = public_theme_detail_payload(GameDistributionThemeDetailRecord { + theme_id: "theme_c".to_string(), + name: "主题 theme_c".to_string(), + summary: String::new(), + badge: String::new(), + member_count: 120, + roots: vec![public_game_record_fixture()], + roots_truncated: true, + }); + assert_eq!(truncated["memberCount"], 120); + assert_eq!(truncated["rootsTruncated"], Value::Bool(true)); + assert_eq!( + truncated["roots"].as_array().map(Vec::len), + Some(1), + "截断只影响 roots 的条数,不影响 memberCount" + ); } /// 主题公开路由必须挂载且整组 `no-store`:匿名可读(没有鉴权层),未连库时走到 SpacetimeDB diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index 84bfb1c32..5760c4379 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -74,7 +74,8 @@ pub use theme::{ encode_game_distribution_theme_cursor, game_distribution_theme_admin_status_filter_valid, game_distribution_theme_member_id, game_distribution_theme_member_visible, game_distribution_theme_page_limit, game_distribution_theme_public_visible, - game_distribution_theme_root_acceptable, game_distribution_theme_status_valid, - game_distribution_theme_text_violation, page_public_themes, page_theme_members, - parse_game_distribution_theme_cursor, sort_public_themes, sort_theme_members, + game_distribution_theme_root_acceptable, game_distribution_theme_roots_truncated, + game_distribution_theme_status_valid, game_distribution_theme_text_violation, + page_public_themes, page_theme_members, parse_game_distribution_theme_cursor, + sort_public_themes, sort_theme_members, }; diff --git a/server-rs/crates/module-game-distribution/src/theme.rs b/server-rs/crates/module-game-distribution/src/theme.rs index 1e63e40e2..f11dfae13 100644 --- a/server-rs/crates/module-game-distribution/src/theme.rs +++ b/server-rs/crates/module-game-distribution/src/theme.rs @@ -319,6 +319,28 @@ pub fn page_theme_members( items.into_iter().map(|item| item.payload).collect() } +/// 主题详情的 `roots` 是否**被响应体积上限截断**了。 +/// +/// `visible_member_count` 是真实可见成员数(不截断,原样作为 `memberCount` 回报), +/// `returned_root_count` 是这一份 `roots` 实际回传的条数:只有「回传的**少于**可见的」才是截断, +/// 相等(常见情形:可见成员不足上限,全发)与更多都是 `false`。 +/// +/// 为什么必须把这个信号和 `memberCount` 分开:`page_theme_members` 会把成员截断到 +/// [`GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX`],而 `memberCount` 刻意报**真实可见数**——两个数一旦 +/// 不等,客户端必须能分辨「这个主题就有这么多根」与「服务端只回了前 50 个」。不发明新约定: +/// 族谱接口的 `truncated`(`GameDistributionLineageResponse.truncated`)就是同一个语义 +/// (「下面这份列表被上限截断了」),客户端按同一种方式分支。 +/// +/// 「更多」这一支写死成 `false` 是**防御性口径**:`returned > visible` 只可能来自调用方把两组 +/// 互不同源的数字拼在一起(例如清点在过滤前、取值在过滤后)。那种情况下并没有少发任何东西, +/// 报「被截断」反而会诱导客户端去翻一个不存在的下一页,所以宁可报 `false`。 +pub fn game_distribution_theme_roots_truncated( + visible_member_count: usize, + returned_root_count: usize, +) -> bool { + returned_root_count < visible_member_count +} + #[cfg(test)] mod tests { use super::*; @@ -789,4 +811,36 @@ mod tests { "limit=0 不回传成员" ); } + + /// `rootsTruncated` 三态:全发(含空)/ 被上限截断 / 防御性「回传多于可见」。 + /// + /// 第一组是「相等」,最常见(可见成员不足上限,整份回传);第二组是唯一必须报 `true` 的情形; + /// 第三组钉住防御性口径——两组数字不同源时不得凭空报「还有更多」。 + #[test] + fn roots_truncated_is_true_only_when_the_returned_list_was_cut() { + assert!(!game_distribution_theme_roots_truncated(0, 0), "空主题"); + assert!( + !game_distribution_theme_roots_truncated(3, 3), + "不足上限:全发" + ); + assert!( + !game_distribution_theme_roots_truncated( + GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX as usize, + GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX as usize, + ), + "恰好等于上限:一份都没少,不算截断" + ); + + assert!( + game_distribution_theme_roots_truncated(51, 50), + "可见 51 条只回 50 条:必须给出「还有更多」的信号" + ); + assert!(game_distribution_theme_roots_truncated(120, 50)); + assert!(game_distribution_theme_roots_truncated(1, 0)); + + assert!( + !game_distribution_theme_roots_truncated(50, 51), + "回传多于可见只可能是两组数字不同源:没有少发东西,不得报截断" + ); + } } diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 796461f8c..dccd9ffae 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -449,8 +449,10 @@ pub struct GameDistributionThemeReference { /// 公开主题列表的单条:主题名、简介、角标与**当前公开可见成员数**。 /// -/// `member_count` 与详情响应的 `roots` 长度是同一个数(同一份成员可见性判定):若回报「全部成员 -/// 行数」,运营就能通过计数变化探测存在草稿 / 被下架成员,且它会与 `roots` 长度对不上。 +/// `member_count` 是**真实可见成员数**(同一份成员可见性判定,不含草稿 / 已下架成员), +/// 不是成员行总数:若回报「全部成员行数」,运营就能通过计数变化探测存在草稿 / 被下架成员。 +/// 也正因为它是真实数,它与详情 `roots` 的长度**在可见成员超过响应体积上限时不再相等**—— +/// 「下面这份 roots 被上限截断」是详情响应上独立的 `roots_truncated` 信号,不要用这个字段反推。 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameDistributionThemeSummary { @@ -479,6 +481,12 @@ pub struct GameDistributionThemeListResponse { /// 顶层扁平形状与 `GET /games/{gameId}` 同形,不引入第二套包装。`roots` 逐条是**公开目录同一份** /// 作品投影(`GameDistributionGameSummary`),排序为 `sort_order` 升序 + `member_id` 升序兜底。 /// 主题不存在或未发布时接口返回 404(不返回空壳);已发布但可见成员为空时返回 200 + 空 `roots`。 +/// +/// 两个成员数相关的字段口径**刻意不同,不要互相推导**: +/// - `member_count` 是**真实可见成员数**(不截断),回答「这个主题有多少棵作品树」; +/// - `roots_truncated` 才是「**下面这份 `roots` 被响应体积上限截断**」的信号,回答「你看到的是 +/// 不是全部」。它与族谱响应上的 `truncated` 是同一个约定(列表被上限截断时置 `true`), +/// 客户端按同一种方式分支;`roots.len()` 与 `member_count` 不等时就必须看这个字段。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameDistributionThemeDetail { @@ -488,6 +496,7 @@ pub struct GameDistributionThemeDetail { pub badge: String, pub member_count: u64, pub roots: Vec, + pub roots_truncated: bool, } /// 后台创建主题(`POST /admin/api/game-distribution/themes`)的请求体。 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 c60fd76dc..b5c32c10b 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 @@ -375,6 +375,10 @@ pub struct GameDistributionThemeReferenceRecord { /// /// 主题不存在 / 未发布时模块侧 `ok == false`,这里折成 `Procedure` 错误,由 api-server 的既有 /// 映射落 404(不返回空壳);已发布但可见成员为空是**正常**结果(空 `roots`)。 +/// +/// `member_count` 是**真实可见成员数**(与列表 `GameDistributionThemeSummaryRecord::member_count` +/// 同口径、不截断),`roots_truncated` 才是「这份 `roots` 被响应体积上限截断」的信号——两者是 +/// 独立字段,不要用 `member_count == roots.len()` 反推(可见成员 > 50 时该等式故意不成立)。 #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct GameDistributionThemeDetailRecord { pub theme_id: String, @@ -383,6 +387,7 @@ pub struct GameDistributionThemeDetailRecord { pub badge: String, pub member_count: u64, pub roots: Vec, + pub roots_truncated: bool, } /// 后台主题行记录:公开投影看不到的列(运营排序、状态、时间戳)与**成员行总数**都在这里。 @@ -718,6 +723,7 @@ pub(crate) fn map_game_distribution_theme_detail_result( badge: result.badge, member_count: result.member_count, roots: result.roots.into_iter().map(map_public_game).collect(), + roots_truncated: result.roots_truncated, }) } @@ -966,6 +972,7 @@ mod theme_result_tests { badge: if ok { "精选" } else { "" }.to_string(), member_count: if ok { 1 } else { 0 }, roots: Vec::new(), + roots_truncated: false, error_message: (!ok).then(|| "主题不存在".to_string()), } } @@ -980,6 +987,30 @@ mod theme_result_tests { assert_eq!(detail.theme_id, "theme_a"); assert_eq!(detail.member_count, 1); assert!(detail.roots.is_empty(), "空成员不是错误"); + assert!(!detail.roots_truncated, "未截断必须是 false(不靠缺省值)"); + } + + /// `member_count` 与 `roots_truncated` 是两个独立口径:映射层不得拿 `roots.len()` 去覆盖任何一个。 + #[test] + fn theme_detail_truncation_flag_survives_mapping_with_a_full_member_count() { + let mut result = detail_result(true); + result.member_count = 120; + result.roots_truncated = true; + let detail = + map_game_distribution_theme_detail_result(result).expect("已发布主题是正常结果"); + assert_eq!( + detail.member_count, 120, + "memberCount 是真实可见成员数,不能被回传条数截回去" + ); + assert!( + detail.roots_truncated, + "「这份 roots 被截断」必须原样透传到 api-server(否则客户端只能靠两个数猜)" + ); + assert_ne!( + detail.member_count, + detail.roots.len() as u64, + "截断时两个数故意不相等——正是本字段存在的理由" + ); } #[test] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_detail_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_detail_result_type.rs index fc3035f70..7d013eaaa 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_detail_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_detail_result_type.rs @@ -16,6 +16,7 @@ pub struct GameDistributionThemeDetailResult { pub badge: String, pub member_count: u64, pub roots: Vec, + pub roots_truncated: bool, 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 9a8fdfc0f..830d4629d 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1809,10 +1809,12 @@ impl GameDistributionCollectionStateResult { /// 公开主题的对外投影:列表条目与详情头部**共用同一个形状**。 /// -/// `member_count` 是**当前公开可见成员数**,不是成员行总数:回报行数会让运营通过计数变化探测 -/// 出「存在草稿或被下架的成员」,而且它会与详情 `roots` 的长度对不上,客户端无法解释两个数 -/// 为什么不一致。两处都用同一份可见性判定(`game_distribution_theme_member_visible`)算, -/// 因此永远是同一个数。 +/// `member_count` 是**当前公开可见成员数**(不截断),不是成员行总数:回报行数会让运营通过计数 +/// 变化探测出「存在草稿或被下架的成员」。它**不是**详情 `roots` 的长度——详情受响应体积上限 +/// 约束,可能只回传前 `module_game_distribution::GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX` 条, +/// 「这份 roots 被截断了」由详情响应上独立的 `roots_truncated` 表达(与族谱 `truncated` 同约定)。 +/// 计数口径由 `game_distribution_theme_member_count` 一处定义,列表与详情都走它,因此两个入口 +/// 报的成员数永远一致。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionThemeSnapshot { pub theme_id: String, @@ -1862,6 +1864,12 @@ impl GameDistributionThemeListResult { /// `ok == false` 表示主题不存在或未发布(api-server 映射 404);两种情形**共用同一句文案**, /// 客户端无法区分,这正是目的。已发布但可见成员为空是**正常**结果(`ok = true` + 空 `roots`): /// 主题是运营实体,不是「不可读锚点」,空主题不指向任何作品、不构成泄露。 +/// +/// `member_count`(真实可见成员数,不截断)与 `roots_truncated`(这份 `roots` 是否被截断)是 +/// **两个独立口径**:可见成员超过响应体积上限时 `roots` 只回前 +/// `module_game_distribution::GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX` 条、`member_count` 仍然 +/// 报真实数,客户端据 `roots_truncated` 得知自己拿到的是子集(沿用族谱 `truncated` 的约定), +/// 不再靠 `member_count == roots.len()` 这种已被上限污染的等式推断。 #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct GameDistributionThemeDetailResult { pub ok: bool, @@ -1871,6 +1879,7 @@ pub struct GameDistributionThemeDetailResult { pub badge: String, pub member_count: u64, pub roots: Vec, + pub roots_truncated: bool, pub error_message: Option, } @@ -1885,6 +1894,8 @@ impl GameDistributionThemeDetailResult { badge: String::new(), member_count: 0, roots: Vec::new(), + // 失败路径没有 `roots`,也就谈不上「被截断」;显式给 `false`,不靠缺省值。 + roots_truncated: false, error_message: Some(error), } } @@ -2800,13 +2811,14 @@ pub fn list_game_distribution_themes_and_return( /// 公开共创主题详情(匿名可读):主题头 + 可见成员根清单(逐条公开目录同一份投影)。 /// /// 主题不存在 / 未发布 → `ok = false`,由 api-server 映射 404;已发布但可见成员为空是正常结果。 +/// `member_count` 是真实可见成员数,`roots_truncated` 说明这份 `roots` 有没有被响应体积上限截断。 #[spacetimedb::procedure] pub fn get_game_distribution_theme_detail_and_return( ctx: &mut ProcedureContext, input: GameDistributionThemeDetailInput, ) -> GameDistributionThemeDetailResult { match ctx.try_with_tx(|tx| get_game_distribution_theme_detail_tx(tx, input.clone())) { - Ok((theme, roots)) => GameDistributionThemeDetailResult { + Ok((theme, roots, roots_truncated)) => GameDistributionThemeDetailResult { ok: true, theme_id: theme.theme_id, name: theme.name, @@ -2814,6 +2826,7 @@ pub fn get_game_distribution_theme_detail_and_return( badge: theme.badge, member_count: theme.member_count, roots, + roots_truncated, error_message: None, }, Err(error) => GameDistributionThemeDetailResult::not_found(error), @@ -5487,18 +5500,17 @@ fn game_distribution_theme_visible_members( .collect() } -/// 主题的 `member_count`:当前公开可见成员数,与详情 `roots` 的长度**是同一个数**。 +/// 主题的 `member_count`:当前公开可见成员数,**不受响应体积上限约束**。 /// -/// 两处都走 `game_distribution_theme_visible_members`(同一份可见性判定)+ `page_theme_members` -/// (同一个响应体积上限),所以列表里的数字与详情里的根数不会漂移;也不会因为「存在草稿或 -/// 被下架的成员」而虚高——那正是运营不该被外部探测到的信息。不新增物化计数字段(与仓库 -/// 「实时算」的既有取舍一致)。 +/// 就是 `game_distribution_theme_visible_members` 的长度:口径只有一份(同一份成员可见性判定), +/// 列表与详情都从这里取,两个入口报的数因此永远一致;也不会因为「存在草稿或被下架的成员」而 +/// 虚高——那正是运营不该被外部探测到的信息。不新增物化计数字段(与仓库「实时算」的既有取舍一致)。 +/// +/// 刻意**不**再走 `page_theme_members`:那个函数施加的是**展示上限**(详情一次最多回 50 条 +/// `roots`),把它用在计数上会让「这个主题有多少棵作品树」随响应体积漂移——可见成员 120 的主题 +/// 会被报成 50,且客户端无从分辨。上限只约束 `roots` 的条数,其信号是详情上的 `roots_truncated`。 fn game_distribution_theme_member_count(ctx: &ReducerContext, theme_id: &str) -> u64 { - module_game_distribution::page_theme_members( - game_distribution_theme_visible_members(ctx, theme_id), - module_game_distribution::GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX as usize, - ) - .len() as u64 + game_distribution_theme_visible_members(ctx, theme_id).len() as u64 } /// 主题行 → 对外投影(含当前可见成员数)。 @@ -5521,7 +5533,9 @@ fn game_distribution_theme_snapshot( /// 可见性、再排序切页**:游标位置定义在已过滤序列上。反过来先把未过滤的主题切页、再逐页过滤, /// 被滤掉的行会凭空占掉名额,下一页的游标又指回过滤前的序列,于是每翻一页都漏掉自己的若干条 /// (与 `/my-collections` 同一条纪律)。排序 / 切页走 `page_public_themes`(`created_at` 倒序 + -/// `theme_id` 升序兜底的全序),翻页不重不漏;`member_count` 只为**这一页**的主题现算。 +/// `theme_id` 升序兜底的全序),翻页不重不漏;`member_count` 只为**这一页**的主题现算,且是 +/// **真实可见成员数**(不套响应体积上限——那个上限只约束详情 `roots` 的条数,见 +/// `get_game_distribution_theme_detail_tx`)。 fn list_game_distribution_themes_tx( ctx: &ReducerContext, input: GameDistributionThemeListInput, @@ -5565,6 +5579,13 @@ fn list_game_distribution_themes_tx( /// 它的存在由运营的发布行为对外确认,空树不指向任何作品、不构成泄露;用 404 反而会把「刚建好 /// 还没挂作品」这种正常运营状态误报成故障。成员清单按 `sort_order` 升序 + `member_id` 升序稳定 /// 排序(`sort_order` 允许重复,兜底键保证全序),并受与列表同一个响应体积上限约束。 +/// +/// 返回三元组 `(主题头, roots, roots_truncated)`:**`member_count` 数的是全部可见成员** +/// (`game_distribution_theme_member_count`,与上限无关),**`roots` 才是被上限截断的那一份** +/// (`page_theme_members` + `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX`)。两者不再恒等—— +/// 可见成员超过上限时 `member_count` 报真实数、`roots` 只回前 50 条,差额由 +/// `game_distribution_theme_roots_truncated` 折成一个显式信号(与族谱 `truncated` 同约定), +/// 客户端不必再靠两个数相等来推断「有没有少看」。 fn get_game_distribution_theme_detail_tx( ctx: &ReducerContext, input: GameDistributionThemeDetailInput, @@ -5572,6 +5593,7 @@ fn get_game_distribution_theme_detail_tx( ( GameDistributionThemeSnapshot, Vec, + bool, ), String, > { @@ -5582,6 +5604,9 @@ fn get_game_distribution_theme_detail_tx( if !module_game_distribution::game_distribution_theme_public_visible(theme.status.as_str()) { return Err(GAME_DISTRIBUTION_THEME_NOT_FOUND_MESSAGE.to_string()); } + // 计数与切页读的是**同一份**可见成员清单:`member_count` 取它的全长(不截断),`roots` 取排序 + // 后的前 50 条。顺序不能反:先切页再计数就会把「这个主题有多少棵树」变成「这次回了多少条」。 + let member_count = game_distribution_theme_member_count(ctx, theme.theme_id.as_str()); let roots = module_game_distribution::page_theme_members( game_distribution_theme_visible_members(ctx, theme.theme_id.as_str()), module_game_distribution::GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX as usize, @@ -5589,16 +5614,23 @@ fn get_game_distribution_theme_detail_tx( .iter() .filter_map(|game| public_game_distribution_snapshot(ctx, game)) .collect::>(); + // 信号由「真实可见数 vs 实际回传条数」现算,不写死「超过 50 就 true」:上限一旦调整、 + // 或某个成员的公开投影此刻不可得(`filter_map` 少一条),信号都跟着走,不需要改第二处。 + let roots_truncated = module_game_distribution::game_distribution_theme_roots_truncated( + member_count as usize, + roots.len(), + ); Ok(( GameDistributionThemeSnapshot { theme_id: theme.theme_id.clone(), name: theme.name.clone(), summary: theme.summary.clone(), badge: theme.badge.clone(), - // 与 `roots` 同一个数(同一份可见性判定 + 同一个上限),客户端不会看到两个自相矛盾的计数。 - member_count: roots.len() as u64, + // 真实可见成员数:**不是** `roots.len()`(后者被响应体积上限约束)。 + member_count, }, roots, + roots_truncated, )) } @@ -6898,7 +6930,8 @@ mod tests { !body.contains(".delete("), "投影阶段不得删除主题行:归档 / 未发布只是不进入公开投影" ); - // `memberCount` 只算当前可见成员,且只在**这一页**的主题上现算(不为被切掉的条目白算)。 + // `memberCount` 数的是**全部**可见成员(不套响应体积上限),且只在**这一页**的主题上现算 + // (不为被切掉的条目白算)。 assert!(body.contains("game_distribution_theme_snapshot")); assert!(body.contains("page.iter()"), "成员数必须在切页之后再算"); let counter = function_body(source, "fn game_distribution_theme_member_count("); @@ -6907,8 +6940,14 @@ mod tests { "成员数必须与 roots 共用同一份可见成员清单" ); assert!( - counter.contains("GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX"), - "成员数与 roots 必须共用同一个响应体积上限,否则两个数会对不上" + counter.contains(".len()"), + "成员数就是可见成员清单的长度(一次遍历,不额外物化)" + ); + assert!( + !counter.contains("GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX") + && !counter.contains("page_theme_members"), + "成员数必须数**全部**可见成员:套上响应体积上限(或走 page_theme_members)会让 120 个 \ + 成员的主题报成 50,客户端看不出少了" ); let members = function_body(source, "fn game_distribution_theme_visible_members("); assert!( @@ -6964,9 +7003,23 @@ mod tests { 2, "详情只允许这两种失败:不得为「已发布但可见成员为空」再造一种错误(那是 200 + 空 roots)" ); + // 两个成员数相关的口径必须**分开**:`memberCount` 数全部可见成员(不截断),`roots` 才是被 + // 响应体积上限切过的那一份,差额由 `rootsTruncated` 显式表达。 assert!( - body.contains("member_count: roots.len() as u64"), - "memberCount 必须与同响应的 roots 长度是同一次计算的结果" + body.contains("game_distribution_theme_member_count(ctx, theme.theme_id.as_str())"), + "memberCount 必须走可见成员计数(数的是全部可见成员),不是切完页的那一份" + ); + assert!( + !body.contains("member_count: roots.len() as u64"), + "memberCount 不得再恒等于 roots.len():那正是「可见成员 > 50 时静默截断且无信号」的成因" + ); + assert!( + body.contains("module_game_distribution::game_distribution_theme_roots_truncated("), + "roots 被截断必须由共享纯函数折成显式信号(rootsTruncated),不写死「> 50 就 true」" + ); + assert!( + body.contains("roots_truncated,"), + "rootsTruncated 必须原样进入事务返回值(算了不传 = 客户端仍然看不到)" ); assert!(!body.contains(".delete("), "详情是纯读,不得删除任何行"); // 公开 404 文案必须落在 api-server 的 404 子串分支上,且不得带会把映射抢到 409 的词。