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
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:
@@ -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 迁移、契约与门禁
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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` 之外
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
"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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -319,6 +319,28 @@ pub fn page_theme_members<T>(
|
||||
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),
|
||||
"回传多于可见只可能是两组数字不同源:没有少发东西,不得报截断"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GameDistributionGameSummary>,
|
||||
pub roots_truncated: bool,
|
||||
}
|
||||
|
||||
/// 后台创建主题(`POST /admin/api/game-distribution/themes`)的请求体。
|
||||
|
||||
@@ -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<GameDistributionPublicGameRecord>,
|
||||
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]
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ pub struct GameDistributionThemeDetailResult {
|
||||
pub badge: String,
|
||||
pub member_count: u64,
|
||||
pub roots: Vec<GameDistributionPublicGameSnapshot>,
|
||||
pub roots_truncated: bool,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GameDistributionPublicGameSnapshot>,
|
||||
pub roots_truncated: bool,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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<GameDistributionPublicGameSnapshot>,
|
||||
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::<Vec<_>>();
|
||||
// 信号由「真实可见数 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 的词。
|
||||
|
||||
Reference in New Issue
Block a user