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。
## 已知留白