feat(游戏共创): 主题详情 rootsTruncated 信号(memberCount 报真实可见成员数)
Project CI / AI game creator shell Rust crates (pull_request) Successful in 6m1s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m10s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m58s
Project CI / Backend tests (pull_request) Successful in 9m10s
Project CI / Frontend tests (pull_request) Successful in 2m58s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m32s
Project CI / Native shell tests (pull_request) Successful in 6m37s
Project CI / Repository checks (pull_request) Successful in 4m55s

裁定(与族谱 `truncated` 同约定):`memberCount` = **真实可见成员数**(不受展示上限污染),新增布尔
`rootsTruncated`,**仅当 `roots` 被 50 上限截断时为 true**。此前两个数被同一个 50 上限截断 ⇒ 可见成员
> 50 时静默截断,客户端既看不到全部树、也拿不到任何信号。

- 纯函数:`game_distribution_theme_roots_truncated(visible_member_count, returned_root_count)`
  (相等 / 回传更多 ⇒ false;后者是防御性口径:两组数字不同源时不得凭空报「还有更多」)。
- 契约:`GameDistributionThemeDetail` 加 `roots_truncated`(serde camelCase ⇒ `rootsTruncated`),
  TS 镜像同步;parity `public_theme_detail_payload.mustEmit` 6 键 → **7 键**。
- 事务:`member_count` 走可见成员清单的 `len()`(**不再**经 `page_theme_members` 与上限),`roots` 仍是
  同一清单排序后取前 50,`rootsTruncated` 由纯函数算出(不写死「>50 就 true」——阈值与上限是两个
  独立事实);列表入口共用同一计数函数 ⇒ 同一主题在列表与详情里不会报出两个 `memberCount`。
- api-server:`public_theme_detail_payload` 增加 `rootsTruncated`;形状单测 6 → 7 键,并加
  `memberCount=120 / roots=1 / rootsTruncated=true` 用例,证明两个键口径独立。
- 结构断言同步修正:列表侧原「成员数与 roots 共用同一上限」改为「成员数数的是全部可见成员(断言
  源码里不含切页函数与上限常量)」;详情侧改为「必须调用共享纯函数 + 不得再恒等 `roots.len()` +
  该字段必须进入返回值」。
- 生成绑定:`module_bindings/game_distribution_theme_detail_result_type.rs` 手改后用临时 out-dir 重跑
  `spacetime generate --lang rust` 逐字节校对一致。**wire format 变更 ⇒ 部署需重新 publish 模块**
  (客户端绑定已同步)。
- 文档:技术方案 §3.4 / §3.10.6 / §3.10.9(原「已知限制·三个备选待拍板」→ 已定口径)、里程碑
  (依赖、接口表、验收标准、纯函数表、测试清单、待确认项)、以及数据契约文档里那句「主题详情的
  roots 与列表的 memberCount 永远一致」(本改动已使其为假)。

门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p api-server game_distribution` 86 passed;
`cargo test -p module-game-distribution` **101 passed**;`cargo test -p spacetime-module` 286 passed /
1 ignored;DTO parity 0(58 组 / **15** 构建器 / 12 手拼类型);`check:spacetime-schema` 0(96 tables);
`check:encoding` 0(5420 files);`cargo fmt --all -- --check` 0;`git diff --check` 0。

遗留(不在本仓可改范围,需前端 session 跟进):`src/services/gameDistributionClient.ts` 有自己一份主题
详情本地类型与 normalize,会丢弃 `rootsTruncated`(约 3 行小改),因此网页主题页暂时拿不到截断信号;
admin-web 走共享契约类型,后台侧即时生效。
This commit is contained in:
2026-10-06 04:09:45 +08:00
parent 4a33397822
commit ce614d59c1
12 changed files with 255 additions and 53 deletions
@@ -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<T>` 那种「排序键与负载绑定」的写法,避免「按 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。
## 已知留白
@@ -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 的受信服务身份调用。
@@ -311,8 +311,8 @@ pub(crate) project_bundle_sha256: Option<String>,
| `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 迁移、契约与门禁