feat(游戏共创): 后台读取主题成员名单(含草稿/归档主题与不可见成员 + 可见性列)
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

缺口:后台原先只能借**公开投影** `GET /api/game-distribution/themes/{themeId}` 列成员,而公开投影只服务
`published` 主题 ⇒ **草稿 / 已归档主题在后台看不到成员名单**,运营没法在发布前核对。

新增 `GET /admin/api/game-distribution/themes/{theme_id}/members?limit=&cursor=`:

- **鉴权照既有两层**:路由挂既有 admin 支的 `route_layer(require_admin_auth)`,handler 取
  `Extension<AuthenticatedAdmin>`(未登录/失效 → 401 `UNAUTHORIZED`、非 admin → 403,同组同码同形);
  模块侧 procedure 先跑 `require_editor_generation_runtime_service_identity`(照 `033e3aa79` 五条写接口)。
- **返回全部成员行,不套公开可见性过滤**,且**不要求主题已发布**(`draft` / `archived` 照常可读;
  只有主题真的不存在才 404 `THEME_NOT_FOUND`)。响应:
  `{ themeId, totalMembers, members: [{ rootGameId, title, sortOrder, createdAt, visible, visibility }], nextCursor }`。
- **两个可见性字段刻意独立**:
  · `visible` = 「公开侧此刻会不会出现」,**复用** `game_distribution_theme_member_visible`(未删 + 已公开
    + 有当前公开版本),不另写第二套判定;
  · `visibility` = 更细状态(新纯函数):游戏行不存在 → `missing`(优先于 `deleted`),软删除行 → `deleted`,
    否则原样透传游戏行 `visibility`(`published` / `unpublished` / `suspended`)。
    因此 `visibility == "published"` 但无当前公开版本时 `visible = false` ——运营能看出「公开了但没公开版本」;
    三处钉住这个组合(纯函数单测、api-server payload 单测、事务结构断言)。
- **排序与分页照既有口径**:复用 `sort_theme_members`(`sort_order` 升序 + 成员 id 兜底)+ 新增
  `page_admin_theme_members`;`limit` 缺省 20 / 上限 50 / `0` 取默认 / 超界截断(同一归一化);
  游标 `"{sortOrder}:{memberId}"`,解析**只委托** `parse_game_distribution_theme_cursor` ⇒ 非法游标仍是
  上一轮刚修好的**可达** `THEME_INVALID_CURSOR`(不另造一个不可达码);`totalMembers` 是成员**行**总数,
  切页前算,不受分页影响。
- **契约与登记**:`packages/shared/src/contracts/gameDistribution.ts` 新增
  `GameDistributionAdminThemeMemberRow` / `…ListResponse` / `…MemberVisibility`;parity 登记
  `TS_ONLY_TYPES` +3、`RESPONSE_BUILDERS` +2(列表响应与单条成员各一条,条目形状有独立证据);api-server
  用命名构建器(不内联 `json!`)。
- **测试**:module-game-distribution +5 条纯函数(`missing` 优先于 `deleted`、三类不可见各一条并断言
  `visible`/`visibility` 组合、`published` 无公开版本 ⇒ `visible=false`、游标委托共享实现、翻页不重不漏、
  末页 null、复用共享比较器与同一归一化);spacetime-module +1 并扩 2(结构断言:走主题索引、不套公开可见性
  过滤、不复用「只取可见成员」的助手、不自写排序、不删行、`totalMembers` 在切页前取);spacetime-client +1
  mapper(行总数与游标透传、两个可见性字段不互相推导、空名单是正常结果);api-server 例(401/403、草稿主题
  可读、含不可见成员仍返回、`totalMembers` 不受分页影响、limit 缺省与截断、非法游标 → 400 `THEME_INVALID_CURSOR`、
  主题不存在 → 404)。
- **文档**:技术方案 `§3.10.8`(新行 + `visible`/`visibility` 语义 + 「草稿也能读」的理由)与 `§3.4` 后台接口表;
  里程碑(后台路由清单、实施清单、验收与测试清单,原「只能借公开投影」的留白项标记为已落地)。
- **生成绑定**:新增 4 个 `admin_theme_member_list*` / `list_admin_…_procedure` 生成文件 + `module_bindings.rs`
  入口;wire format 有新增(新 procedure/结果类型)⇒ 部署需重新 publish 模块。

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

不在本提交内(属另一个 session 的文件,工作区里由我的子代理顺带做了**纯新增性**改动,未提交):
`apps/admin-web/src/api/adminGameThemeTypes.ts` 增加了三个指向共享契约新类型的别名 + 一段注释 —— 与该 session
「登记好之后改回引用共享契约」的计划一致,交由其 review / 提交(或直接删掉其本地重复定义)。
This commit is contained in:
2026-10-06 04:28:39 +08:00
parent ce614d59c1
commit 0d0166c2d9
17 changed files with 1289 additions and 21 deletions
@@ -74,13 +74,16 @@ 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_member_visibility(game_row_present, is_deleted, game_visibility) -> String` | 后台成员行的**细粒度**状态(只给后台) | `missing`(游戏行不存在)**优先于** `deleted`(软删除),否则原样透传游戏行的 `visibility`。与 `game_distribution_theme_member_visible` **刻意独立**:前者答「为什么」,后者答「此刻公开侧会不会出现」 |
| `encode_game_distribution_theme_member_cursor(sort_order, member_id)` / `parse_game_distribution_theme_member_cursor(value)` | 后台成员名单游标编解码 | `"{sort_order}:{member_id}"`;解析**只委托** `parse_game_distribution_theme_cursor`(**共用同一个可达的 `THEME_INVALID_CURSOR`**,不复制第二份解析) |
| `page_admin_theme_members(items, cursor, limit) -> (page, next_cursor)` | 后台成员名单切页 | 复用**同一份** `sort_theme_members`(全序,翻页不重不漏);`limit` 归一化复用 `game_distribution_theme_page_limit`;调用方**不**做可见性过滤(后台看全部行) |
| `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 切页」的错位。
### C. 事务与 procedure(`server-rs/crates/spacetime-module/src/game_distribution.rs`)
**已落地(读:`742723a58`;写:`033e3aa79`)。**
**已落地(读:`742723a58`;写:`033e3aa79`;后台成员名单读:本条)。**
命名沿用既有:**写** = `*_and_return`,**读** = `list_*` / `get_*`。
@@ -91,6 +94,7 @@ A–F 已落地(服务端);G 前端与后台 UI 未做。
| `upsert_game_distribution_theme_member_and_return` | 按确定性主键写成员(不存在则插入,存在则更新 `sort_order`);**先判主题存在**,再判作品存在,再按血缘点查判「是否根」(非根 → `THEME_MEMBER_NOT_ROOT`);重复调用不产生第二行,`created_at` 首次写入后不再变 |
| `remove_game_distribution_theme_member_and_return` | 按确定性主键删除;不存在也算成功(无「重放 vs 新意图」差异,不需要幂等键);主题不存在 → `THEME_NOT_FOUND` |
| `list_game_distribution_admin_themes` | 后台列表:支持 `status` 过滤(`all` / `draft` / `published` / `archived`),`limit` 缺省与上限与后台作品列表同口径(200),按 `created_at` 倒序 + `theme_id` 升序 |
| `list_admin_game_distribution_theme_members` | 后台成员**名单**:**不套**公开可见性过滤、**不要求主题已发布**(`draft` / `archived` 照常可读),返回全部成员行(每行 `visible` / `visibility`)+ **行总数**(不受分页影响)+ 下一页游标;排序复用 `sort_theme_members`、切页走 `page_admin_theme_members`;主题不存在 → `THEME_NOT_FOUND` |
| `list_game_distribution_public_themes` | 公开列表:只取 `published`,**先过滤再排序切页**,返回 `(themes, next_cursor)`;每条的 `member_count` = 该主题当前可见成员数(同一可见性判定) |
| `get_game_distribution_theme_detail` | 公开详情:主题不存在 / 非 `published` → `found = false`(api-server 映射 404);命中时返回主题行 + 可见成员根(按 `sort_order` + `member_id` 排序),成员卡片信息按 `public_game_payload` 所需事实取(游戏行 + 当前公开版本 + 评分摘要) |
| `list_game_distribution_theme_refs_for_root` | 按 `root_game_id` 走 `by_game_distribution_theme_member_root_game_id`,联主题行,只保留 `published`,按主题公开列表同一比较器排序;供作品详情 `themes` 增量使用 |
@@ -100,7 +104,7 @@ A–F 已落地(服务端);G 前端与后台 UI 未做。
### D. api-server 路由(`server-rs/crates/api-server/src/modules/game_distribution.rs`)
**已落地(公开族:`742723a58`;后台族:`033e3aa79`)。**
**已落地(公开族:`742723a58`;后台族:`033e3aa79`;后台成员名单读:本条)。**
公开族(挂 `public_games` 那一支,带 `add_no_store_response_headers`):
@@ -119,6 +123,7 @@ A–F 已落地(服务端);G 前端与后台 UI 未做。
| `GET /admin/api/game-distribution/themes?limit=&status=` | 后台列表(含 `draft` / `archived`) | 只读 |
| `PUT /admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}` | 增 / 改成员(body 可带 `sortOrder`) | 确定性主键保证幂等 |
| `DELETE /admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}` | 移除成员(不存在也算成功) | 不需要幂等键 |
| `GET /admin/api/game-distribution/themes/{theme_id}/members?limit=&cursor=` | 读取成员**名单**:**全部成员行**(含当前不可见的)+ 每行 `visible` / `visibility`;**含 `draft` / `archived` 主题**(原缺口:借公开投影时草稿 / 归档主题列不出成员) | 只读(`limit` 缺省 20 / 上限 50;游标 `"{sortOrder}:{memberId}"`,末页 `null`) |
### E. 错误码映射(api-server 集中映射,沿用 `FORK_*` 那套「前缀字符串 → 状态码」写法)
@@ -136,11 +141,11 @@ A–F 已落地(服务端);G 前端与后台 UI 未做。
### F. 契约与 DTO 同步
**已落地(类型与数据契约表:`2fa201e0d`;5 条响应构建器登记:`742723a58`)。** DTO parity 现为 **58 组类型 / 10 个手拼响应构建器**。
**已落地(类型与数据契约表:`2fa201e0d`;公开构建器登记:`742723a58`;后台 payload 登记:`4a3339782`;成员名单读接口的两个构建器:本条)。** DTO parity 现为 **58 组类型 / 17 个手拼响应构建器 / 15 个手拼响应类型**。
- Rust DTO:`server-rs/crates/shared-contracts/src/game_distribution.rs`(主题列表 / 详情 / 后台写请求的私有类型;**不下发**任何对象键或内部计数)。
- TS DTO:`packages/shared/src/contracts/gameDistribution.ts`。
- `scripts/check-game-distribution-dto-parity.mjs`:登记新响应构建器(`public_themes_payload` / `public_theme_detail_payload` / 详情 `themes` 增量构建器),证明这些路径确实会发出新键。
- TS DTO:`packages/shared/src/contracts/gameDistribution.ts`——后台成员名单新增 `GameDistributionAdminThemeMemberRow` / `GameDistributionAdminThemeMemberListResponse` / `GameDistributionAdminThemeMemberVisibility`(服务端同样是逐字段手拼 JSON,因此走 `TS_ONLY_TYPES` 登记)。
- `scripts/check-game-distribution-dto-parity.mjs`:登记新响应构建器(`public_themes_payload` / `public_theme_detail_payload` / 详情 `themes` 增量构建器 / 后台主题一族,以及本条的两个:`admin_theme_members_payload`(列表)与 `admin_theme_member_row_payload`(单条行)),证明这些路径确实会发出新键——列表与单条各登记一条,让「条目到底发哪几个键」有独立证据。
- `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`:**已随表落地补齐**(`2fa201e0d`,两张表小节;`check:spacetime-schema` 现为 **96** tables)。
### G. 前端(公开侧;后台 UI 不在本轮)
@@ -165,10 +170,10 @@ A–F 已落地(服务端);G 前端与后台 UI 未做。
| 层 | 用例 |
| --- | --- |
| 纯函数(`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 含新构建器(`public_theme_detail_payload` 的 `mustEmit` 含 `rootsTruncated`);TS 类型与 Rust DTO 逐键一致 |
| 纯函数(`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` 升序);**后台成员短名单**:细粒度状态三态(`missing` 优先于 `deleted`、否则原样透传)+ 与 `visible` 的组合(`visibility=published` 且无公开版本 ⇒ `visible=false`)、成员游标委托主题游标解析(共用可达稳定码、只切第一个冒号)、后台成员全序翻页不重不漏 / 末页游标 `None` / `limit=0` 取默认 / 超界截断、以及「切页复用 `sort_theme_members` + 同一归一化函数」的源码断言 |
| 事务(`spacetime-module`) | 同主题同根重复 upsert 只有一行且 `created_at` 不变;跨主题同根两行并存;非根入成员被拒;成员作品下架 / 软删除后**行还在**、公开投影不含它、重新公开后自动回来;归档主题不出现在公开列表;`theme_id` 服务端生成且形如 `theme-*`;`created_by_user_id` 取自 admin 会话主体;`updated_at` 每次更新刷新;**后台成员名单**:不套公开可见性过滤 / 不要求主题已发布 / 复用共享排序切页纯函数 / 不 `.delete(`,且 `totalMembers` 在切页**之前**取(结构断言);行快照复用 `game_distribution_theme_member_visible` 并把 `visibility` 交给模块侧纯函数,作品行缺失不跳过 |
| api-server 路由 | 公开列表 200 / 匿名可读 / 非法游标 400 / 末页 `nextCursor === null`;公开详情 404(不存在、`draft`、`archived`)与 200 空 `roots`;作品详情 `themes` 增量(只含公开主题;**第 N 代作品按其根查到所属主题**);两条公开路径都带 `Cache-Control: no-store`;后台**六条**路由未带 admin 会话一律 401、非 admin 令牌 403;后台写:空 `name` / 非法 `status` 400、未知 `theme_id` 404、作品不存在 404、非根 409、同键重放 `replayed: true`、同键不同请求 409、`DELETE` 重复调用 200;后台成员名单:非法游标 400 `THEME_INVALID_CURSOR`、主题不存在 404 `THEME_NOT_FOUND`、响应键集合(列表 4 键 / 单条行 6 键)与 `title` / `nextCursor` 的 `null` 语义 |
| 契约 | DTO parity 含新构建器(`public_theme_detail_payload` 的 `mustEmit` 含 `rootsTruncated`;后台 `admin_theme_members_payload` / `admin_theme_member_row_payload` 分别钉住列表与单条行);TS 类型与 Rust DTO 逐键一致 |
| 前端 | 主题列表(渲染 / 空态 / 失败态 / 加载更多);主题页多棵树(mock 两个根各自 `lineage`)与「已发布但空成员」空态;详情页 `themes` 链到主题页、无主题时不渲染;窄屏布局不撑破 |
### I. 门禁命令与证据
@@ -223,9 +228,15 @@ E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node scripts/check-game-d
- [x] 后台写接口错误码可区分:空名 / 非法状态 400、未知主题 404、作品不存在 404、非根 409、同键不同请求 409;同键重放如实回报幂等重放(错误码映射单测 + 领域错误类型单测;**经真实库存的 404 / 409 等 dev 栈 e2e**)。
- [x] 后台 `DELETE` 成员重复调用结果相同且成功(幂等),成员不存在不是错误(事务结构断言:响应不含「之前存不存在」)。
- [x] 后台列表含 `draft` / `archived` 主题;非法 `status` 过滤值返回 400。
- [x] 后台能读到成员**名单**(`GET …/themes/{theme_id}/members`):**草稿 / 归档主题也能读**(事务不套 `game_distribution_theme_public_visible`,结构断言),且**含当前不可见的成员**(未公开 / 软删除 / 无公开版本三类都原样返回,不套公开可见性过滤)。主题不存在才是 404 `THEME_NOT_FOUND`。
- [x] 名单每行 `visible` 与 `visibility` 的组合正确且**不许互相推导**:`visibility` 为 `deleted` / `missing` / 原样透传游戏行可见性,`visible` 复用 `game_distribution_theme_member_visible`;`visibility == "published"` 且**无**当前公开版本时 `visible == false`(刻意的组合,纯函数与事务结构两处钉住)。
- [x] `totalMembers` 是**成员行总数**(含不可见、**不受分页影响**,事务在切页之前取);排序稳定(`sort_order` 升序 + 成员主键升序兜底,**复用公开侧同一比较器**);翻页不重不漏、**末页 `nextCursor: null`**;`limit` 缺省 20 / 上限 50 / `0` 取默认 / 超界截断;**非法游标 → 400 `THEME_INVALID_CURSOR`**(可达的稳定码,用模块真实产出的文案钉住)。
- [x] 后台成员名单未带 admin 会话 → 401(同码同形),**非 admin 令牌 → 403**(复用既有 `require_admin_auth`,不自造);响应键集合由 DTO parity 两个构建器分别钉住(列表 4 键 / 单条行 6 键),`title` 与 `nextCursor` 的 `null` **必须发出键**。
- [ ] 公开前端:共创 Tab 能列出主题并进入主题页;主题页按成员根渲染**多棵树**(复用 `/games/{id}/lineage`);已发布空主题显示空态;详情页 `themes` 能跳到主题页。**等前端**
- [ ] 新增路由在桌面与窄屏可用,且 `check:nginx-spa-routes` / `check:pingora-route-parity` 通过。**等前端**
- [x] 契约同步:DTO parity 通过(58 组 / 10 构建器);数据契约表文档随表落地补齐(`2fa201e0d`)。
- [x] 契约同步:DTO parity 通过(58 组 / 17 构建器 / 15 手拼类型);数据契约表文档随表落地补齐(`2fa201e0d`)。
**已落地(本条补)**:后台成员**名单读**接口——原缺口「后台要成员名单只能借道**公开投影**(`GET /api/game-distribution/themes/{theme_id}`),而公开投影只服务 `published` 主题,于是**草稿 / 归档主题在后台看不到成员**」已随 `GET /admin/api/game-distribution/themes/{theme_id}/members?limit=&cursor=` 补掉:不套公开可见性过滤、含当前不可见成员、每行带 `visible` / `visibility` 两个独立口径,`draft` / `archived` 照常可读。技术方案侧同步写在 §3.4 后台表与 §3.10.8。
**勾选口径**:勾选项由已落地的纯函数单测 / 事务结构断言 / api-server 定向测试 / DTO parity / schema 门禁证实;**未跑真实 dev 栈的端到端整链**(`scripts/check-game-distribution-theme-e2e.mjs` 未创建),所以凡依赖真实库存的运行时分支(未知主题 404、作品不存在 404、同键换请求 409、下架→重公开自动回归等)目前只到单元 / 结构 / 映射层。第 17 / 18 条(前端)**等前端**。
@@ -246,3 +257,5 @@ E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node scripts/check-game-d
## 已知留白
主题封面图、slug / URL 别名、埋点与统计、主题内「跳到某一代节点」高亮、批量树端点、主题级联归档时的成员清理、后台管理 UI。理由与将来接法见技术方案 §3.10.9 与本文件「不在范围内」。
(原缺口「后台要成员名单只能借公开投影 ⇒ 草稿 / 归档主题看不到成员」**已落地**为后台成员名单读接口,见上文 §C / §D / §F 与「验收标准」;后台**管理 UI** 仍是留白。)
@@ -352,6 +352,7 @@ pub(crate) project_bundle_sha256: Option<String>,
| `GET /admin/api/game-distribution/themes?limit=&status=`(新,2026-10-06) | 后台列表,**含 `draft` / `archived`**。`limit` 缺省与上限同为 **200**(超界截断),**无游标**(主题是运营维护的小集合)。`status` 白名单 `all` / `draft` / `published` / `archived`(缺省 = `all`),非法过滤值 → 400 `THEME_BAD_REQUEST`(失败关闭,不退化成全量)。响应 `{ themes: [{ themeId, name, summary, badge, sortOrder, status, memberCount, createdAt, updatedAt }] }`;此处 `memberCount` 是**成员行总数**(含当前对外不可见的成员),与公开侧同名键的「可见成员数」口径不同 |
| `PUT /admin/api/game-distribution/themes/{themeId}/members/{rootGameId}`(新,2026-10-06) | 增 / 改成员(幂等 upsert,body 可带 `sortOrder`,缺省 0)。**不要求 `Idempotency-Key`**:成员身份完全由路径给出,确定性主键 `"{themeId}:{rootGameId}"` 天然幂等,重复调用只更新 `sortOrder`(`createdAt` 不变)。只允许**根作品**:非根(有血缘行)→ **409 `THEME_MEMBER_NOT_ROOT`**;作品不存在 → 404 `THEME_MEMBER_GAME_NOT_FOUND`;主题不存在 → 404 `THEME_NOT_FOUND`。**不做「作品必须已公开」的前置校验**(可先挂草稿根,作品公开后自动进入公开投影)。响应 `{ themeId, rootGameId, sortOrder, createdAt }` |
| `DELETE /admin/api/game-distribution/themes/{themeId}/members/{rootGameId}`(新,2026-10-06) | 移除成员。**不要求 `Idempotency-Key`**:按确定性主键删除,**成员不存在也算成功(200)**;响应刻意不含「之前存不存在」,重复调用逐字节相同。主题不存在仍是 404 `THEME_NOT_FOUND`(那是路径里的主题 ID 错了)。响应 `{ themeId, rootGameId }` |
| `GET /admin/api/game-distribution/themes/{themeId}/members?limit=&cursor=`(新,2026-10-06) | 后台读取主题成员**名单**(运营核对)。鉴权同上(`require_admin_auth` + `Extension<AuthenticatedAdmin>`;未登录 / 失效会话 → **401 `UNAUTHORIZED`**,非 admin role → **403**)。**返回全部成员行、不套公开可见性过滤**,且 **`draft` / `archived` 主题照常可读**——公开投影只服务已发布主题,后台借道它时草稿 / 归档主题根本列不出成员,运营没法在发布前核对(本接口就是补这个缺口)。响应 `{ themeId, totalMembers, members: [{ rootGameId, title, sortOrder, createdAt, visible, visibility }], nextCursor }`:`totalMembers` 是**成员行总数**(含当前不可见的行、**不受分页影响**);`title` 取游戏行标题,游戏行不存在时为 `null`(键仍发出);`visible` = 此刻**公开投影**会不会包含它(未删 + 已公开 + 有当前公开版本,**复用** `game_distribution_theme_member_visible`,不另写一套判定),`visibility` = 更细的状态(软删除 → `deleted`,游戏行不存在 → `missing`,否则原样透传游戏行的 `published` / `unpublished` / `suspended`)——两者**刻意独立**:`visibility == "published"` 且**无**当前公开版本时 `visible == false`,运营据此看出「作品已公开、但没有公开版本」这种异常。分页与其它列表完全一致:`limit` 缺省 **20** / 上限 **50** / `0` 取默认 / 超界**截断**;游标形如 `"{sortOrder}:{memberId}"`(`memberId` 本身是 `"{themeId}:{rootGameId}"`,解析只切第一个冒号,与 `"{i64}:{rest}"` 惯例同构);**非法游标 → 400 `THEME_INVALID_CURSOR`**(可达的稳定码,不吞成 200 空页);`nextCursor` 为真实值、**末页为 `null`**。排序 `sortOrder` 升序 + 成员主键升序兜底(**与公开侧同一份比较器** `sort_theme_members`,翻页不重不漏)。主题不存在 → **404 `THEME_NOT_FOUND`**;`draft` / `archived` **不是** 404(后台要看得见) |
#### 契约同步(强制)
@@ -651,15 +652,17 @@ pub struct GameDistributionThemeMember {
| `GET /admin/api/game-distribution/themes?limit=&status=` | 后台列表,**含 `draft` / `archived`**;`status` 缺省 = 全量 | 只读 | 非法 `status` 过滤值 → 400(白名单 `all` / `draft` / `published` / `archived`,与后台作品列表同一处理方式) |
| `PUT /admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}` | 增 / 改成员(幂等 upsert,body 可带 `sortOrder`) | 幂等由**确定性主键**保证(同主题同根只有一行),重复调用不产生第二行、结果相同 | 主题不存在 → 404 `THEME_NOT_FOUND`;作品不存在 → 404 `THEME_MEMBER_GAME_NOT_FOUND`;作品**非根**(有血缘行)→ 409 `THEME_MEMBER_NOT_ROOT` |
| `DELETE /admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}` | 移除成员 | **不要求** `Idempotency-Key`:按确定性主键删除,重复调用结果相同(不存在也算成功),没有「重放 vs 新意图」需要区分(与 `DELETE …/collection` 同一取舍) | 主题不存在 → 404;成员不存在 → 200(幂等成功) |
| `GET /admin/api/game-distribution/themes/{theme_id}/members?limit=&cursor=` | 读取主题成员**名单**:**全部成员行**(含当前不可见的)+ 每行 `visible` / `visibility`;**`draft` / `archived` 主题照常可读** | 只读;分页 `limit` 缺省 20 / 上限 50 / `0` 取默认 / 超界截断,游标 `"{sortOrder}:{memberId}"`,末页 `nextCursor: null` | 非法游标 → 400 `THEME_INVALID_CURSOR`;主题不存在 → 404 `THEME_NOT_FOUND`;`draft` / `archived` **不是** 404 |
说明与理由:
- **成员增删「都幂等」**:`PUT` 靠确定性主键、`DELETE` 靠「不存在也算成功」,两条都不需要「先查后写」的事务假设。
- **不做「成员必须是公开作品」的前置校验**:运营完全可以先把主题和成员备好(作品还是草稿),等作品公开后成员**自动**出现在公开投影里;这与 §3.10.5「投影跳过但不删行」是同一条规则的正面用法。代价:运营不能靠 `PUT` 的返回判断成员当前是否对外可见——后台列表需要按可见性口径显示「当前不可见」标记(**本轮后台 UI 不做**)。
- **`sort_order` 的定位**:本轮的**公开主题列表排序由游标惯例决定(`created_at` 倒序 + `theme_id` 升序兜底)**,`sort_order` 用于**主题内成员排序**与后台列表展示顺序。理由:游标格式的主键是 micros,若公开列表改按 `sort_order` 排,就需要 `(sort_order, theme_id)` 双键游标,与「沿用刚落地的那套游标惯例」冲突。若产品要求共创 Tab 按运营序展示,需要显式改游标格式(见 §7 与本轮里程碑的待确认项)。**留白**:主题级排序的运营拖拽 UI。
- **成员名单**读**接口(2026-10-06 补)**:后台要成员名单,原先只能借道**公开投影** `GET /api/game-distribution/themes/{theme_id}`,而公开投影只服务 `status == published` 的主题——**草稿 / 归档主题的成员在后台根本列不出来**,运营没法在发布前核对名单。因此补 `GET …/themes/{theme_id}/members?limit=&cursor=`:**不套公开可见性过滤**(草稿主题也读得到)、返回**全部成员行**,每行带两个**刻意独立**的字段——`visible` = 此刻公开投影会不会包含它(未删 + 已公开 + 有当前公开版本,**复用** `game_distribution_theme_member_visible`,不另写一套判定),`visibility` = 更细的状态(软删除 → `deleted`,游戏行不存在 → `missing`,否则原样透传游戏行的 `published` / `unpublished` / `suspended`)。两者不可互相推导:`visibility == "published"` 而**没有**当前公开版本时 `visible == false`,这个组合是刻意的——运营要能一眼看出「作品已公开、但没有公开版本」。排序复用公开侧**同一份**比较器(`sort_theme_members`:`sort_order` 升序 + 成员主键升序兜底),只是多套一层游标与 `limit`;游标 `"{sortOrder}:{memberId}"` 沿用 `"{i64}:{rest}"` 惯例、解析**只委托**主题列表那套解析(因此非法游标仍是**可达的** 400 `THEME_INVALID_CURSOR`)。`title` 只为后台渲染而发(后台本就允许看草稿作品),游戏行不存在时为 `null` 但**键仍发出**。`draft` / `archived` **不是** 404(主题不存在才是)。
- **错误码命名**:沿用 `FORK_*` 那套「`模块前缀_原因` 字符串 → 状态码」的映射写法(`theme_*` 纯函数产出,api-server 集中映射),不落到 axum 默认的 422 纯文本。
**已实现(`033e3aa79`)**:五条后台路由 + 权限校验 + 幂等 + 错误码映射 + 后台列表(含 `draft` / `archived`)。**留白**:后台管理页面(UI 不在本轮,本条只落接口与事务)。
**已实现(`033e3aa79`)**:五条后台路由 + 权限校验 + 幂等 + 错误码映射 + 后台列表(含 `draft` / `archived`)。**已实现(本条)**:成员名单读接口(第 6 条后台路由,含 `visible` / `visibility` 两个口径与游标分页),把「后台要成员名单只能借公开投影、草稿 / 归档主题看不到成员」这个缺口补掉。**留白**:后台管理页面(UI 不在本轮,本条只落接口与事务)。
#### 3.10.9 留白与已知限制(显式列出,不静默省略)
@@ -492,6 +492,64 @@ export type GameDistributionAdminThemeMemberRemovalResponse = {
rootGameId: string;
};
/**
* 后台成员行的细粒度状态(**只给后台**,公开投影不发这个键)。
*
* - `published` / `unpublished` / `suspended`:成员行还在、游戏行也在,原样透传游戏行的可见性;
* - `deleted`:游戏行被软删除;
* - `missing`:游戏行**根本不存在**(历史脏行)。
*
* 与同一行的 `visible` **是两个独立字段、不许互相推导**:`visible` 回答「此刻公开投影会不会
* 出现它」,`visibility` 回答「为什么」。因此 `visibility === 'published'` 但没有当前公开版本时
* `visible === false`——这是刻意的,运营据此看出「作品已公开、但没有公开版本」这种异常。
*/
export type GameDistributionAdminThemeMemberVisibility =
| 'published'
| 'unpublished'
| 'suspended'
| 'deleted'
| 'missing';
/**
* 后台主题成员行:`GET /admin/api/game-distribution/themes/{themeId}/members` 的 `members[]`。
*
* **后台看得到全部成员行**(含当前对外不可见的),这是本接口存在的理由:草稿 / 归档主题在公开
* 投影里根本不存在,运营没法在发布前核对名单。
*
* `title` 取游戏行标题,游戏行不存在时为 `null`(该行仍要出现在名单里,否则与 `totalMembers` 对不上)。
* 只为后台渲染而发,后台本就允许看草稿作品。
*/
export type GameDistributionAdminThemeMemberRow = {
rootGameId: string;
title: string | null;
sortOrder: number;
/** 首次挂载时间(重复 PUT 只改 `sortOrder`,不刷新它)。 */
createdAt: string;
/** 此刻**公开投影**是否包含它(未删除 + 已公开 + 有当前公开版本)。 */
visible: boolean;
visibility: GameDistributionAdminThemeMemberVisibility;
};
/**
* 后台主题成员列表响应。
*
* `totalMembers` 是该主题的**成员行总数**(含当前不可见的成员),**不受分页影响**;它与
* `members.length` 不是一回事(后者只是这一页)。
*
* 分页与其它列表同一口径:`limit` 缺省 **20** / 上限 **50** / `0` 取默认 / 超界截断;游标形如
* `"{sortOrder}:{memberId}"`(`memberId` 本身是 `"{themeId}:{rootGameId}"`,解析只切第一个冒号),
* 排序为 `sortOrder` 升序 + 成员主键升序兜底(全序,翻页不重不漏);**非法游标 → 400
* `THEME_INVALID_CURSOR`**;末页 `nextCursor` 为 `null`。
*
* 主题不存在 → 404 `THEME_NOT_FOUND`;`draft` / `archived` **不是** 404(后台要看得见)。
*/
export type GameDistributionAdminThemeMemberListResponse = {
themeId: string;
totalMembers: number;
members: GameDistributionAdminThemeMemberRow[];
nextCursor: string | null;
};
export type GameDistributionCreateGameRequest = {
/** 发布方本地项目标识;同一作者重复发布会复用既有 gameId。 */
localProjectId?: string | null;
@@ -132,6 +132,11 @@ const TS_ONLY_TYPES = [
'GameDistributionAdminThemeMutationResponse',
'GameDistributionAdminThemeMemberResponse',
'GameDistributionAdminThemeMemberRemovalResponse',
// 后台主题成员**读**接口:列表响应与单条行也都是手拼 JSON(服务端没有对应的 Rust 结构体),
// 与上面四个同类登记;`visibility` 联合类型是服务端按游戏行现算的字符串,同样只有 TS 侧定义。
'GameDistributionAdminThemeMemberRow',
'GameDistributionAdminThemeMemberListResponse',
'GameDistributionAdminThemeMemberVisibility',
];
// 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。
@@ -259,6 +264,29 @@ const RESPONSE_BUILDERS = [
ts: 'GameDistributionAdminThemeMemberRemovalResponse',
mustEmit: ['themeId', 'rootGameId'],
},
{
// 后台成员**读**接口的列表响应:`totalMembers` 是**成员行总数**(含当前不可见的行、不受分页
// 影响),`nextCursor` 必须发出(末页为 `null`,不是省略)——缺了它客户端会把末页当成
// 「还有下一页」并死循环翻页。
fn: 'admin_theme_members_payload',
ts: 'GameDistributionAdminThemeMemberListResponse',
mustEmit: ['themeId', 'totalMembers', 'members', 'nextCursor'],
},
{
// 后台成员行的单条形状:`visibility` 与 `visible` 是两个独立字段(前者回答「为什么」、后者
// 回答「此刻公开侧会不会出现」),`title` 在游戏行不存在时为 `null` 但**键必须发出**。
// 它是数组元素,顶层键解析抓不到它,因此单独登记。
fn: 'admin_theme_member_row_payload',
ts: 'GameDistributionAdminThemeMemberRow',
mustEmit: [
'rootGameId',
'title',
'sortOrder',
'createdAt',
'visible',
'visibility',
],
},
];
function camelCase(value) {
@@ -63,7 +63,8 @@ use shared_contracts::game_distribution::{
};
use spacetime_client::{
GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord,
GameDistributionAdminThemeListRecordInput, GameDistributionAdminThemeMemberRecord,
GameDistributionAdminThemeListRecordInput, GameDistributionAdminThemeMemberListRecordInput,
GameDistributionAdminThemeMemberRecord, GameDistributionAdminThemeMemberRowRecord,
GameDistributionAdminThemeMutationRecord, GameDistributionAdminThemeRecord,
GameDistributionAdminUserReviewListRecordInput, GameDistributionAdminUserReviewRecord,
GameDistributionAdminVersionRecord, GameDistributionApproveRecordInput,
@@ -310,6 +311,17 @@ struct AdminThemeListQuery {
status: Option<String>,
}
/// 后台主题**成员名单**的查询串:`limit` + `cursor`。
///
/// 与其它游标列表同一套数值与处理方式(缺省 20 / 上限 50 / `0` 取默认 / 超界截断 / 非法游标 400)——
/// 后台名单同样是一页页翻的表格,没有理由用第二套分页口径。游标解析在事务里(模块侧纯函数),
/// 这里只透传字符串:handler 自己解析会让「HTTP 挡住的」与「落库挡住的」长出两套判据。
#[derive(Debug, Deserialize)]
struct AdminThemeMemberListQuery {
limit: Option<u32>,
cursor: Option<String>,
}
/// 作者软删除游戏:CAS 修订号走查询串,删除本身没有请求体。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -527,6 +539,13 @@ pub fn router(state: AppState) -> Router<AppState> {
"/admin/api/game-distribution/themes/{theme_id}",
put(admin_update_theme),
)
// 成员**读**接口:与成员写接口同一前缀、同一条 `require_admin_auth`。
// 它返回**全部成员行**(含当前对外不可见的)且 `draft` / `archived` 主题照常可读——
// 后台运营要在发布前核对名单,而公开投影只服务已发布主题(原缺口正是这里)。
.route(
"/admin/api/game-distribution/themes/{theme_id}/members",
get(admin_list_theme_members),
)
.route(
"/admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}",
put(admin_upsert_theme_member).delete(admin_remove_theme_member),
@@ -2006,6 +2025,60 @@ async fn admin_list_themes(
Ok(json_success_body(Some(&ctx), admin_themes_payload(&themes)))
}
/// 后台读取主题成员名单:
/// `GET /admin/api/game-distribution/themes/{theme_id}/members?limit=&cursor=`。
///
/// **全部成员行**(含当前对外不可见的)+ **`draft` / `archived` 主题照常可读**——这正是本接口存在的
/// 理由:公开投影只服务已发布主题,后台借道它时草稿 / 归档主题根本列不出成员,运营没法在发布前
/// 核对。每行带 `visible`(此刻公开投影会不会包含它)与 `visibility`(更细的状态),两者刻意独立。
///
/// 分页与其它列表同一口径(`limit` 缺省 20 / 上限 50 / `0` 取默认 / 超界截断;游标
/// `"{sortOrder}:{memberId}"`),归一化复用 `themes_page_limit`(同一函数 ⇒ 日志里的 `limit` 就是
/// 事务真正生效的页大小);非法游标由事务回稳定码错误 → 400 `THEME_INVALID_CURSOR`;主题不存在 →
/// 404 `THEME_NOT_FOUND`。
async fn admin_list_theme_members(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Path(theme_id): Path<String>,
Query(query): Query<AdminThemeMemberListQuery>,
) -> Result<Json<Value>, AppError> {
let limit = themes_page_limit(query.limit);
let cursor = normalize_optional(query.cursor);
let record = state
.spacetime_client()
.list_admin_game_distribution_theme_members(
GameDistributionAdminThemeMemberListRecordInput {
theme_id: theme_id.clone(),
limit,
cursor: cursor.clone(),
},
)
.await
.map_err(map_spacetime_error)?;
info!(
request_id = ctx.request_id(),
operation = "admin_game_theme_members_listed",
theme_id = %theme_id,
members = record.members.len(),
total_members = record.total_members,
limit,
has_cursor = cursor.is_some(),
has_more = record.next_cursor.is_some(),
elapsed_ms = ctx.elapsed(),
"后台读取共创主题成员名单"
);
Ok(json_success_body(
Some(&ctx),
admin_theme_members_payload(
&theme_id,
record.total_members,
&record.members,
record.next_cursor,
),
))
}
/// DTO 枚举 → 线上字符串:只此一处转换(`serde` 的 `camelCase` 与模块侧白名单同值,有单测钉住)。
fn theme_status_value(status: GameDistributionThemeStatus) -> String {
match status {
@@ -2073,6 +2146,50 @@ fn admin_theme_member_removal_payload(theme_id: &str, root_game_id: &str) -> Val
json!({ "themeId": theme_id, "rootGameId": root_game_id })
}
/// 后台成员名单里的一行:
/// `{ rootGameId, title, sortOrder, createdAt, visible, visibility }`。
///
/// `title` 取游戏行标题,游戏行不存在时发 `null`——**键必须发出**(不是省略):省略会让
/// 「这个成员的作品查不到了」与「服务端忘了发这个键」长得一样。
///
/// `visible` 与 `visibility` 是两个独立口径,都必须原样发出:前者是「此刻公开投影会不会包含它」,
/// 后者是「为什么」(`published` / `unpublished` / `suspended` / `deleted` / `missing`)。因此
/// `visibility == "published"` 而没有当前公开版本时 `visible == false` 是**刻意**的。
fn admin_theme_member_row_payload(member: &GameDistributionAdminThemeMemberRowRecord) -> Value {
json!({
"rootGameId": member.root_game_id,
"title": member.title,
"sortOrder": member.sort_order,
"createdAt": member.created_at,
"visible": member.visible,
"visibility": member.visibility,
})
}
/// 后台成员名单响应:`{ themeId, totalMembers, members, nextCursor }`。
///
/// `totalMembers` 是**成员行总数**(含当前对外不可见的行、**不受分页影响**):它与 `members.length`
/// 刻意不是一回事(后者只是这一页),运营据此知道「翻页有没有翻完」。
///
/// `nextCursor` **必须发出**(末页为 `null`,不是省略):缺了它客户端无法区分「末页」与「服务端
/// 这次没给游标」,会把最后一页当成还有下一页而反复翻同一个请求。
fn admin_theme_members_payload(
theme_id: &str,
total_members: u64,
members: &[GameDistributionAdminThemeMemberRowRecord],
next_cursor: Option<String>,
) -> Value {
json!({
"themeId": theme_id,
"totalMembers": total_members,
"members": members
.iter()
.map(admin_theme_member_row_payload)
.collect::<Vec<_>>(),
"nextCursor": next_cursor,
})
}
/// 读接口的「不可读即 404」:族谱与衍生列表的锚点必须公开可读,否则按「不存在」处理。
///
/// 抽成函数是为了让这条映射可被单测钉住(不可读 → 404),而不是散落在两个 handler 里。
@@ -9207,6 +9324,7 @@ mod tests {
("GET", "/admin/api/game-distribution/themes"),
("POST", "/admin/api/game-distribution/themes"),
("PUT", "/admin/api/game-distribution/themes/theme_1"),
("GET", "/admin/api/game-distribution/themes/theme_1/members"),
(
"PUT",
"/admin/api/game-distribution/themes/theme_1/members/game_1",
@@ -9371,4 +9489,168 @@ mod tests {
Value::String("BAD_REQUEST".into())
);
}
/// 后台成员名单的两个领域错误必须落**契约里的状态码与稳定码**。
///
/// 用**模块真实产出**的文案(成员游标那层薄封装委托的解析器、事务用的领域错误),而不是合成
/// 字符串:上一轮的真缺陷正是「契约码在 HTTP 面不可达」,合成文案证明不了可达性。
/// 文案不得含 `FORK_`:`FORK_` 分支排在 `THEME_` **之前**,会被它抢走。
#[test]
fn admin_theme_member_list_maps_its_two_domain_errors() {
let cursor_message =
module_game_distribution::parse_game_distribution_theme_member_cursor("不是游标")
.expect_err("非法游标必须报错");
assert!(cursor_message.contains("格式无效"), "{cursor_message}");
assert!(!cursor_message.contains("FORK_"), "{cursor_message}");
let cursor_error =
map_spacetime_error(SpacetimeClientError::Procedure(cursor_message.clone()));
assert_eq!(
cursor_error.status_code(),
StatusCode::BAD_REQUEST,
"{cursor_message}"
);
assert_eq!(
cursor_error.code(),
GAME_DISTRIBUTION_THEME_INVALID_CURSOR,
"非法游标必须映射出契约里的稳定码,否则客户端只能匹配中文文案:{cursor_message}"
);
let not_found = map_spacetime_error(SpacetimeClientError::Procedure(
module_game_distribution::GameDistributionError::ThemeNotFound.to_string(),
));
assert_eq!(
not_found.status_code(),
StatusCode::NOT_FOUND,
"主题不存在必须是 404(draft / archived 不是:后台要看得见)"
);
assert_eq!(not_found.code(), GAME_DISTRIBUTION_THEME_NOT_FOUND);
}
/// 后台成员名单的两条响应形状:列表四个键、单条六个键,且 `title` / `nextCursor` 的 `null`
/// **必须发出去**(省略会让「查不到作品」与「服务端忘了发键」长得一样)。
#[test]
fn admin_theme_member_payloads_match_the_documented_shape() {
let row = GameDistributionAdminThemeMemberRowRecord {
root_game_id: "game_root".to_string(),
title: None,
sort_order: 3,
created_at: "2026-10-06T00:00:00Z".to_string(),
// 「已公开但没有当前公开版本」:两个字段刻意独立,值必须原样发出。
visible: false,
visibility: "published".to_string(),
};
let payload = admin_theme_member_row_payload(&row);
let mut keys = payload
.as_object()
.expect("成员行必须是对象")
.keys()
.cloned()
.collect::<Vec<_>>();
keys.sort();
assert_eq!(
keys,
vec![
"createdAt",
"rootGameId",
"sortOrder",
"title",
"visibility",
"visible",
]
);
assert_eq!(
payload["title"],
Value::Null,
"游戏行不存在时 title 发 null,不是省略键"
);
assert_eq!(payload["visible"], false);
assert_eq!(payload["visibility"], "published");
assert_eq!(payload["sortOrder"], 3);
let list = admin_theme_members_payload("theme_1", 12, std::slice::from_ref(&row), None);
let mut list_keys = list
.as_object()
.expect("名单必须是对象")
.keys()
.cloned()
.collect::<Vec<_>>();
list_keys.sort();
assert_eq!(
list_keys,
vec!["members", "nextCursor", "themeId", "totalMembers"]
);
assert_eq!(
list["totalMembers"], 12,
"totalMembers 是成员行总数,与这一页回了几个无关"
);
assert_eq!(list["themeId"], "theme_1");
assert_eq!(list["members"][0], payload);
assert_eq!(
list["nextCursor"],
Value::Null,
"末页游标必须发 null(不是省略),否则客户端会反复翻同一页"
);
// 末页之外:游标原样发出,客户端据此继续翻页。
let more = admin_theme_members_payload(
"theme_1",
12,
std::slice::from_ref(&row),
Some("3:theme_1:game_root".to_string()),
);
assert_eq!(more["nextCursor"], "3:theme_1:game_root");
}
/// 非 admin 令牌 → **403**(不是 401):鉴权复用既有 `require_admin_auth`,`roles` 里没有
/// `admin` 一律 403——本接口不自造第二套权限判定。
#[tokio::test]
async fn admin_theme_member_route_rejects_non_admin_roles_with_forbidden() {
use axum::http::Request;
use tower::ServiceExt;
let state = AppState::new(crate::config::AppConfig {
admin_username: Some("theme-owner".into()),
admin_password: Some("theme-test-password".into()),
..Default::default()
})
.expect("state should build");
let token = {
let runtime = state.admin_runtime().expect("后台管理已启用");
let claims = runtime
.build_account_claims(
"theme-member-account".into(),
"theme-member".into(),
"普通成员".into(),
vec!["member".into()],
1,
time::OffsetDateTime::now_utc(),
)
.expect("claims should build");
runtime.sign_token(&claims).expect("token should sign")
};
let app = crate::app::build_router(state);
for uri in [
"/admin/api/game-distribution/themes/theme_1/members",
"/admin/api/game-distribution/themes",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.header("authorization", format!("Bearer {token}"))
.header(shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER, "1")
.body(Body::empty())
.expect("请求"),
)
.await
.expect("路由响应");
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{uri}");
let body = axum::body::to_bytes(response.into_body(), 32_768)
.await
.unwrap();
let body: Value = serde_json::from_slice(&body).expect("必须是平台信封 JSON");
assert_eq!(body["ok"], false, "{uri}");
assert_eq!(body["error"]["code"], "FORBIDDEN", "{uri}");
}
}
}
@@ -66,16 +66,20 @@ pub use release::{
};
pub use theme::{
GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL, GAME_DISTRIBUTION_THEME_BADGE_MAX_CHARS,
GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS, GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT,
GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX, GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED,
GAME_DISTRIBUTION_THEME_STATUS_DRAFT, GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED,
GAME_DISTRIBUTION_THEME_STATUSES, GAME_DISTRIBUTION_THEME_SUMMARY_MAX_CHARS,
GameDistributionThemeMemberPageItem, GameDistributionThemePageItem,
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_MEMBER_VISIBILITY_DELETED,
GAME_DISTRIBUTION_THEME_MEMBER_VISIBILITY_MISSING, GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS,
GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX,
GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED, GAME_DISTRIBUTION_THEME_STATUS_DRAFT,
GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED, GAME_DISTRIBUTION_THEME_STATUSES,
GAME_DISTRIBUTION_THEME_SUMMARY_MAX_CHARS, GameDistributionThemeMemberPageItem,
GameDistributionThemePageItem, encode_game_distribution_theme_cursor,
encode_game_distribution_theme_member_cursor,
game_distribution_theme_admin_status_filter_valid, game_distribution_theme_member_id,
game_distribution_theme_member_visibility, game_distribution_theme_member_visible,
game_distribution_theme_page_limit, game_distribution_theme_public_visible,
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,
page_admin_theme_members, page_public_themes, page_theme_members,
parse_game_distribution_theme_cursor, parse_game_distribution_theme_member_cursor,
sort_public_themes, sort_theme_members,
};
@@ -341,6 +341,106 @@ pub fn game_distribution_theme_roots_truncated(
returned_root_count < visible_member_count
}
/// 后台成员行细粒度状态:成员行还在、但它指向的作品行**已被软删除**。
pub const GAME_DISTRIBUTION_THEME_MEMBER_VISIBILITY_DELETED: &str = "deleted";
/// 后台成员行细粒度状态:成员行还在、但它指向的作品行**根本不存在**(历史脏行 / 被物理清理)。
pub const GAME_DISTRIBUTION_THEME_MEMBER_VISIBILITY_MISSING: &str = "missing";
/// 后台成员行的细粒度可见性状态(**只给后台**,公开投影不发这个键)。
///
/// 与 [`game_distribution_theme_member_visible`] 的分工是刻意的、也必须写死:
/// - [`game_distribution_theme_member_visible`] 回答「**此刻公开侧会不会出现它**」——未软删除 +
/// `visibility == published` + 有当前公开版本。后台页面据此显示「当前不可见」标记。
/// - 本函数回答「**为什么**不可见」,把那个布尔拆成可读的状态:软删除的行 → `deleted`,作品行
/// 不存在的历史行 → `missing`,否则原样透传作品行的 `visibility`(`unpublished` / `published`
/// / `suspended`)。
///
/// 两个口径**不是**同一件事,也不许互相推导:`visibility == "published"` 但作品没有当前公开版本时
/// `visible == false`,这是**刻意**的(运营要能一眼看出「作品已公开、但没有公开版本」这种异常,
/// 而不是只看到一个说不清原因的「不可见」)。
///
/// 判定顺序:**作品行不存在优先于软删除**——没有行就无从谈起 `deleted_at`,把那种历史行报成
/// `deleted` 会误导运营去「恢复一个不存在的作品」。
pub fn game_distribution_theme_member_visibility(
game_row_present: bool,
is_deleted: bool,
game_visibility: &str,
) -> String {
if !game_row_present {
return GAME_DISTRIBUTION_THEME_MEMBER_VISIBILITY_MISSING.to_string();
}
if is_deleted {
return GAME_DISTRIBUTION_THEME_MEMBER_VISIBILITY_DELETED.to_string();
}
game_visibility.to_string()
}
/// 后台成员列表游标编码:`{sort_order}:{member_id}`(`member_id` = `{theme_id}:{root_game_id}`)。
///
/// 与主题列表 / 收藏列表同一套 `"{i64}:{rest}"` 惯例:第一段是**排序键**(这里为运营权重
/// `sort_order`),第二段是这个序列里的唯一兜底键(成员主键,本身自带冒号)。解析仍只切**第一个**
/// 冒号,因此 `member_id` 里的冒号不会把格式搞坏(与收藏列表同一条纪律)。
///
/// 与主题列表游标第一段是 `created_at_micros` 不同:这条列表按运营权重排序,游标锚点因此是权重
/// 而不是时间——两者都只是「i64 + 唯一键」,格式上同构。
pub fn encode_game_distribution_theme_member_cursor(sort_order: i64, member_id: &str) -> String {
format!("{sort_order}:{member_id}")
}
/// 后台成员列表游标解析;**只委托** [`parse_game_distribution_theme_cursor`],不重写解析。
///
/// 两个游标都是 `"{i64}:{rest}"`,唯一键的差异(`theme_id` vs `member_id`)不改变解析规则,
/// 因此这里刻意不去复制一份「切第一个冒号 + 校验 i64 + 非空」的实现:复制出来的第二份迟早会与
/// 第一份漂移(那正是本项目出过一次的 `THEME_INVALID_CURSOR` 不可达缺陷)。委托意味着两侧共用
/// 同一个失败文案,而该文案以稳定码 `THEME_INVALID_CURSOR` 开头、且不含会被 api-server 抢先命中的
/// `FORK_` 子串——非法游标因此在 HTTP 面上仍然是**可达的 400 + 稳定码**。
pub fn parse_game_distribution_theme_member_cursor(value: &str) -> Result<(i64, String), String> {
parse_game_distribution_theme_cursor(value)
}
/// 后台成员列表切页:排序 → 跳过游标之前的位置 → 取 `limit` 条,并返回下一页游标
/// (没有下一页时为 `None`)。
///
/// 与公开侧**共用同一份比较器** [`sort_theme_members`](`sort_order` 升序 + `member_id` 升序兜底,
/// 全序),只是在它之上多了游标与切页——后台与公开对「成员之间谁在前」不该有两套语义。
///
/// 与公开投影的关键差异:调用方**不**做可见性过滤(后台要看全部成员行,含草稿 / 归档主题、
/// 含当前不可见的成员)。因此这里的顺序纪律与 `/my-collections` 相反半边:不是「先过滤再切页」,
/// 而是「**行集合本身就是全集**,直接排序切页」——后台没有「被滤掉的行占掉名额」的问题,因为
/// 一行都不会被滤掉。`limit` 归一化复用 [`game_distribution_theme_page_limit`](缺省 20 / 上限 50 /
/// `0` 取默认 / 超界截断),与其它列表同一套数值。
pub fn page_admin_theme_members<T>(
items: Vec<GameDistributionThemeMemberPageItem<T>>,
cursor: Option<(i64, String)>,
limit: u32,
) -> (Vec<T>, Option<String>) {
let limit = game_distribution_theme_page_limit(limit);
let mut items = sort_theme_members(items);
if let Some((cursor_sort_order, cursor_member_id)) = cursor.as_ref() {
// 严格「在游标之后」:权重更大,或权重相同且 member_id 更大(升序方向)。
items.retain(|item| {
item.sort_order > *cursor_sort_order
|| (item.sort_order == *cursor_sort_order
&& item.member_id.as_str() > cursor_member_id.as_str())
});
}
// 多取一条判断是否还有下一页;游标只指向这一页真正回传的最后一条。
let has_more = items.len() > limit;
items.truncate(limit);
let next_cursor = if has_more {
items.last().map(|item| {
encode_game_distribution_theme_member_cursor(item.sort_order, item.member_id.as_str())
})
} else {
None
};
(
items.into_iter().map(|item| item.payload).collect(),
next_cursor,
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -843,4 +943,183 @@ mod tests {
"回传多于可见只可能是两组数字不同源:没有少发东西,不得报截断"
);
}
/// 后台成员行的细粒度状态:`missing` 优先于 `deleted`,其余原样透传作品的 `visibility`;
/// 并与 `visible` 的组合口径一并钉住——「已公开但没有当前公开版本」必须 `visible = false`
/// 而 `visibility = "published"`(刻意的两个独立字段,不许互相推导)。
#[test]
fn admin_member_visibility_classifies_missing_deleted_and_passthrough() {
assert_eq!(
game_distribution_theme_member_visibility(false, false, "published"),
GAME_DISTRIBUTION_THEME_MEMBER_VISIBILITY_MISSING,
"作品行不存在 → missing"
);
assert_eq!(
game_distribution_theme_member_visibility(false, true, "published"),
GAME_DISTRIBUTION_THEME_MEMBER_VISIBILITY_MISSING,
"没有行就无从谈起 deleted_at:missing 必须优先于 deleted"
);
assert_eq!(
game_distribution_theme_member_visibility(true, true, "published"),
GAME_DISTRIBUTION_THEME_MEMBER_VISIBILITY_DELETED
);
for visibility in ["unpublished", "published", "suspended"] {
assert_eq!(
game_distribution_theme_member_visibility(true, false, visibility),
visibility,
"未删且行存在时原样透传作品的 visibility"
);
}
// 三类「当前不可见」各一条(未公开 / 已下架 / 已公开但无当前公开版本),逐一断言
// `visible == false`,同时 `visibility` 仍是原样透传的那个值:两个字段不许互相推导。
for (visibility, is_published, has_public_version) in [
("unpublished", false, true),
("suspended", false, true),
("published", true, false),
] {
assert_eq!(
game_distribution_theme_member_visibility(true, false, visibility),
visibility
);
assert!(
!game_distribution_theme_member_visible(false, is_published, has_public_version),
"{visibility}(published={is_published}, has_version={has_public_version})此刻必须不可见"
);
}
assert!(
!game_distribution_theme_member_visible(false, false, false),
"missing 行(没有作品行)不可能可见"
);
assert!(
!game_distribution_theme_member_visible(true, true, true),
"软删除行不可能可见"
);
}
/// 后台成员游标:与主题列表**共用同一份解析实现**(因此共用同一个可达的稳定码),
/// 只切第一个冒号(`member_id` 自带冒号),非法输入仍一律 `Err`。
#[test]
fn admin_member_cursor_delegates_to_the_theme_cursor_parser() {
let cursor = encode_game_distribution_theme_member_cursor(3, "theme_a:game_b");
assert_eq!(cursor, "3:theme_a:game_b");
assert_eq!(
parse_game_distribution_theme_member_cursor(&cursor).unwrap(),
(3, "theme_a:game_b".to_string())
);
// 解析只切第一个冒号:第二段仍是完整的 `{theme_id}:{root_game_id}`。
assert_eq!(
parse_game_distribution_theme_member_cursor("0:theme_a:game_b").unwrap(),
(0, "theme_a:game_b".to_string())
);
assert_eq!(
parse_game_distribution_theme_member_cursor("-7:theme_a:game_b").unwrap(),
(-7, "theme_a:game_b".to_string())
);
let error =
parse_game_distribution_theme_member_cursor("不是游标").expect_err("非法游标必须报错");
assert!(
error.starts_with(crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE),
"委托解析器意味着两处共用同一个可达的稳定码:{error}"
);
for malformed in ["", "3", "abc:x", "3:", " :x"] {
assert!(
parse_game_distribution_theme_member_cursor(malformed).is_err(),
"非法游标必须报错:{malformed:?}"
);
}
}
/// 后台成员分页:复用同一份全序比较器,翻页不重不漏(同权重靠 `member_id` 兜底),
/// 末页 `None`,`limit` 归一化与超界截断与其它列表同口径。
#[test]
fn admin_member_page_walks_every_row_without_gaps() {
let rows = vec![
member("theme_a:game_c", 2),
member("theme_a:game_a", 1),
member("theme_a:game_b", 1),
member("theme_a:game_d", 9),
member("theme_a:game_e", 0),
];
let mut seen: Vec<String> = Vec::new();
let mut cursor = None;
let mut pages = 0;
loop {
let (page, next) = page_admin_theme_members(rows.clone(), cursor, 2);
assert!(!page.is_empty(), "非末页不得为空");
if next.is_some() {
assert_eq!(page.len(), 2, "还有下一页时必须填满 limit 条");
}
seen.extend(page);
pages += 1;
match next {
Some(value) => {
cursor = Some(parse_game_distribution_theme_member_cursor(&value).unwrap())
}
None => break,
}
}
assert_eq!(pages, 3);
assert_eq!(
seen,
vec![
"theme_a:game_e",
"theme_a:game_a",
"theme_a:game_b",
"theme_a:game_c",
"theme_a:game_d",
],
"权重升序 + member_id 升序兜底:全序翻页不重不漏"
);
let (all, next) = page_admin_theme_members(rows.clone(), None, 0);
assert_eq!(all.len(), 5, "limit=0 取默认 20:装着五行");
assert_eq!(next, None, "末页必须没有游标");
let (capped, next) = page_admin_theme_members(rows, None, u32::MAX);
assert_eq!(capped.len(), 5, "超界截断到上限 50,仍装得下");
assert_eq!(next, None);
}
/// 游标锚在「这一页真正回传的最后一条」上:下一页从它**之后**开始(不重复它)。
#[test]
fn admin_member_page_cursor_points_at_the_last_returned_row() {
let rows = vec![
member("theme_a:game_a", 1),
member("theme_a:game_b", 3),
member("theme_a:game_c", 5),
];
let (first, next) = page_admin_theme_members(rows.clone(), None, 1);
assert_eq!(first, vec!["theme_a:game_a"]);
let cursor = next.expect("还有两行");
assert_eq!(cursor, "1:theme_a:game_a");
let (second, next) = page_admin_theme_members(
rows,
Some(parse_game_distribution_theme_member_cursor(&cursor).unwrap()),
1,
);
assert_eq!(second, vec!["theme_a:game_b"]);
assert_eq!(next.as_deref(), Some("3:theme_a:game_b"));
}
/// 后台成员切页必须复用**同一份**成员比较器(`sort_theme_members`)与页大小归一化函数,
/// 不在切页里另长一套排序 / 另一套 `limit` 语义。
#[test]
fn admin_member_page_reuses_the_shared_member_comparator_and_limit() {
let source = include_str!("theme.rs");
let start = source
.find("pub fn page_admin_theme_members<T>(")
.expect("后台成员切页必须存在");
let tail = &source[start..];
let body = &tail[..tail.find("\n}\n").expect("函数必须有结尾")];
assert!(
body.contains("sort_theme_members(items)"),
"排序必须复用公开侧同一份比较器(sort_order 升序 + member_id 升序兜底)"
);
assert!(!body.contains("sort_by"), "不得在切页里自写第二套比较器");
assert!(
body.contains("game_distribution_theme_page_limit(limit)"),
"页大小归一化必须复用同一函数(缺省 20 / 上限 50 / 0 取默认 / 超界截断)"
);
}
}
@@ -24,6 +24,7 @@ pub mod external_api_key;
pub mod game_distribution;
pub use game_distribution::{
GameDistributionAdminGameListRecordInput, GameDistributionAdminThemeListRecordInput,
GameDistributionAdminThemeMemberListRecordInput,
GameDistributionAdminUserReviewListRecordInput, GameDistributionApproveRecordInput,
GameDistributionCancelVersionRecordInput, GameDistributionCollectGameRecordInput,
GameDistributionConfirmPackageRecordInput, GameDistributionConfirmProjectBundleRecordInput,
@@ -96,7 +96,8 @@ pub use self::external_generation::{
ExternalGenerationQueueStatsRecord,
};
pub use self::game_distribution::{
GameDistributionAdminGameRecord, GameDistributionAdminThemeMemberRecord,
GameDistributionAdminGameRecord, GameDistributionAdminThemeMemberListRecord,
GameDistributionAdminThemeMemberRecord, GameDistributionAdminThemeMemberRowRecord,
GameDistributionAdminThemeMutationRecord, GameDistributionAdminThemeRecord,
GameDistributionAdminUserReviewDetailRecord, GameDistributionAdminUserReviewListRecord,
GameDistributionAdminUserReviewRecord, GameDistributionAdminVersionRecord,
@@ -176,6 +177,7 @@ pub(crate) use self::external_generation::{
pub(crate) use self::game_distribution::{
ensure_admin_user_review_result, ensure_user_review_result, map_admin_user_review,
map_game_distribution_admin_game_list_result, map_game_distribution_admin_theme_list_result,
map_game_distribution_admin_theme_member_list_result,
map_game_distribution_admin_theme_member_remove_result,
map_game_distribution_admin_theme_member_upsert_result,
map_game_distribution_admin_theme_mutation_result,
@@ -424,6 +424,29 @@ pub struct GameDistributionAdminThemeMemberRecord {
pub created_at: String,
}
/// 后台主题成员**读**行记录(含当前对外不可见的行):运营核对名单用。
///
/// `title` 取游戏行标题,游戏行不存在时为 `None`(api-server 发 `null`)。`visible` 与 `visibility`
/// 是两个独立口径:前者是「此刻公开投影会不会出现它」,后者是「为什么」——`visibility` 为
/// `published` 而没有当前公开版本时 `visible = false` 是刻意的。
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionAdminThemeMemberRowRecord {
pub root_game_id: String,
pub title: Option<String>,
pub sort_order: i64,
pub created_at: String,
pub visible: bool,
pub visibility: String,
}
/// 后台主题成员列表记录:**行总数**(不受分页影响)+ 这一页 + 下一页游标(末页 `None`)。
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionAdminThemeMemberListRecord {
pub total_members: u64,
pub members: Vec<GameDistributionAdminThemeMemberRowRecord>,
pub next_cursor: Option<String>,
}
fn map_game(
value: crate::module_bindings::GameDistributionGameSnapshot,
) -> GameDistributionGameRecord {
@@ -803,6 +826,35 @@ pub(crate) fn map_game_distribution_admin_theme_member_remove_result(
Ok(())
}
/// 后台主题成员**列表**:`ok == false` 走既有错误映射(主题不存在 → 404 `THEME_NOT_FOUND`、
/// 非法游标 → 400 `THEME_INVALID_CURSOR`)。
///
/// 成功时空列表是**正常结果**(主题存在但一个成员都没挂),因此这里不把空 `members` 当契约违背;
/// 行总数与下一页游标原样透传(api-server 据此发 `totalMembers` 与 `nextCursor`,末页 `null`)。
pub(crate) fn map_game_distribution_admin_theme_member_list_result(
result: crate::module_bindings::GameDistributionAdminThemeMemberListResult,
) -> Result<GameDistributionAdminThemeMemberListRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(GameDistributionAdminThemeMemberListRecord {
total_members: result.total_members,
members: result
.members
.into_iter()
.map(|row| GameDistributionAdminThemeMemberRowRecord {
root_game_id: row.root_game_id,
title: row.title,
sort_order: row.sort_order,
created_at: shared_kernel::format_timestamp_micros(row.created_at_micros),
visible: row.visible,
visibility: row.visibility,
})
.collect(),
next_cursor: result.next_cursor,
})
}
/// 后台主题列表:含 `draft` / `archived`(可见性过滤只发生在公开路径上)。
pub(crate) fn map_game_distribution_admin_theme_list_result(
result: crate::module_bindings::GameDistributionAdminThemeListResult,
@@ -1195,4 +1247,82 @@ mod admin_theme_result_tests {
.expect_err("非法过滤值必须是错误(api-server 才能映射 400)");
assert!(error.to_string().contains("THEME_BAD_REQUEST"));
}
/// 后台成员列表:行总数与末页游标原样透传、`visible` / `visibility` 不许互相推导、
/// **空成员是正常结果**(主题存在但一个都没挂),失败折成可映射的 `Procedure` 错误。
#[test]
fn admin_theme_member_list_keeps_totals_and_visibility_columns_apart() {
let result = map_game_distribution_admin_theme_member_list_result(
crate::module_bindings::GameDistributionAdminThemeMemberListResult {
ok: true,
theme_id: "theme_a".to_string(),
total_members: 2,
members: vec![
crate::module_bindings::GameDistributionAdminThemeMemberRowSnapshot {
root_game_id: "game_root".to_string(),
title: Some("作品标题".to_string()),
sort_order: 3,
created_at_micros: CREATED_MICROS,
visible: false,
// 「已公开但没有当前公开版本」:visible=false 而 visibility=published,刻意。
visibility: "published".to_string(),
},
crate::module_bindings::GameDistributionAdminThemeMemberRowSnapshot {
root_game_id: "game_gone".to_string(),
title: None,
sort_order: 9,
created_at_micros: UPDATED_MICROS,
visible: false,
visibility: "missing".to_string(),
},
],
next_cursor: Some("3:theme_a:game_root".to_string()),
error_message: None,
},
)
.expect("成功读结果");
assert_eq!(result.total_members, 2, "行总数原样透传(不受分页影响)");
assert_eq!(result.members.len(), 2);
assert_eq!(result.members[0].created_at, "1700000000.123456Z");
assert!(!result.members[0].visible);
assert_eq!(
result.members[0].visibility, "published",
"visible 与 visibility 是两个独立字段,映射层不许互相推导"
);
assert_eq!(result.members[1].title, None, "作品行缺失时 title 是 null");
assert_eq!(result.members[1].visibility, "missing");
assert_eq!(
result.next_cursor.as_deref(),
Some("3:theme_a:game_root"),
"下一页游标原样透传(api-server 据此发 nextCursor)"
);
// 主题存在但一个成员都没挂:空数组 + total 0 是正常结果,不是契约违背。
let empty = map_game_distribution_admin_theme_member_list_result(
crate::module_bindings::GameDistributionAdminThemeMemberListResult {
ok: true,
theme_id: "theme_b".to_string(),
total_members: 0,
members: Vec::new(),
next_cursor: None,
error_message: None,
},
)
.expect("空名单也成功");
assert!(empty.members.is_empty());
assert_eq!(empty.next_cursor, None, "末页游标是 None");
let error = map_game_distribution_admin_theme_member_list_result(
crate::module_bindings::GameDistributionAdminThemeMemberListResult {
ok: false,
theme_id: String::new(),
total_members: 0,
members: Vec::new(),
next_cursor: None,
error_message: Some("THEME_NOT_FOUND: 主题不存在".to_string()),
},
)
.expect_err("主题不存在必须是错误(api-server 才能映射 404)");
assert_eq!(error.to_string(), "THEME_NOT_FOUND: 主题不存在");
}
}
@@ -347,6 +347,18 @@ pub struct GameDistributionAdminThemeListRecordInput {
pub limit: u32,
}
/// 后台主题成员列表:`theme_id` 必填;`limit` 与其它列表同口径(缺省 20 / 上限 50 / `0` 取默认 /
/// 超界截断);`cursor` 形如 `"{sortOrder}:{memberId}"`。
///
/// **不套公开可见性过滤、也不要求主题已发布**:返回该主题的全部成员行(含当前不可见的),
/// `draft` / `archived` 主题照常可读——后台运营要在发布前核对名单。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameDistributionAdminThemeMemberListRecordInput {
pub theme_id: String,
pub limit: u32,
pub cursor: Option<String>,
}
impl SpacetimeClient {
pub async fn list_admin_game_distribution_user_reviews(
&self,
@@ -1132,6 +1144,42 @@ impl SpacetimeClient {
.await
}
/// 后台主题成员列表:返回该主题的**全部成员行**(含当前对外不可见的行),带每行可见性、行总数
/// 与下一页游标。
///
/// 与公开投影的差别正是本接口存在的理由:**不套公开可见性过滤**、**也不要求主题已发布**——
/// 草稿 / 归档主题的成员名单在公开投影里根本读不到,运营却必须在发布前核对。
///
/// 主题不存在由事务回带码错误(api-server 映射 404 `THEME_NOT_FOUND`);非法游标同样由事务回
/// 稳定码错误(400 `THEME_INVALID_CURSOR`),这里不吞成「200 + 空页」。
pub async fn list_admin_game_distribution_theme_members(
&self,
input: GameDistributionAdminThemeMemberListRecordInput,
) -> Result<GameDistributionAdminThemeMemberListRecord, SpacetimeClientError> {
let input = crate::module_bindings::GameDistributionAdminThemeMemberListInput {
theme_id: input.theme_id,
limit: input.limit,
cursor: input.cursor,
};
self.call_after_connect(
"list_admin_game_distribution_theme_members",
move |connection, sender| {
connection
.procedures()
.list_admin_game_distribution_theme_members_and_return_then(
input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_game_distribution_admin_theme_member_list_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
/// 批量累加已公开游戏的游玩次数;非公开游戏由事务静默跳过。
pub async fn increment_game_distribution_game_play_counts(
&self,
@@ -426,7 +426,10 @@ pub mod game_distribution_admin_game_list_result_type;
pub mod game_distribution_admin_game_snapshot_type;
pub mod game_distribution_admin_theme_list_input_type;
pub mod game_distribution_admin_theme_list_result_type;
pub mod game_distribution_admin_theme_member_list_input_type;
pub mod game_distribution_admin_theme_member_list_result_type;
pub mod game_distribution_admin_theme_member_mutation_result_type;
pub mod game_distribution_admin_theme_member_row_snapshot_type;
pub mod game_distribution_admin_theme_member_snapshot_type;
pub mod game_distribution_admin_theme_mutation_result_type;
pub mod game_distribution_admin_theme_snapshot_type;
@@ -573,6 +576,7 @@ pub mod initialize_editor_generation_pricing_config_if_missing_and_return_proced
pub mod is_game_distribution_collected_and_return_procedure;
pub mod list_admin_accounts_and_return_procedure;
pub mod list_admin_game_distribution_games_and_return_procedure;
pub mod list_admin_game_distribution_theme_members_and_return_procedure;
pub mod list_admin_game_distribution_themes_and_return_procedure;
pub mod list_admin_game_distribution_user_reviews_and_return_procedure;
pub mod list_agc_tracking_events_procedure;
@@ -1366,7 +1370,10 @@ pub use game_distribution_admin_game_list_result_type::GameDistributionAdminGame
pub use game_distribution_admin_game_snapshot_type::GameDistributionAdminGameSnapshot;
pub use game_distribution_admin_theme_list_input_type::GameDistributionAdminThemeListInput;
pub use game_distribution_admin_theme_list_result_type::GameDistributionAdminThemeListResult;
pub use game_distribution_admin_theme_member_list_input_type::GameDistributionAdminThemeMemberListInput;
pub use game_distribution_admin_theme_member_list_result_type::GameDistributionAdminThemeMemberListResult;
pub use game_distribution_admin_theme_member_mutation_result_type::GameDistributionAdminThemeMemberMutationResult;
pub use game_distribution_admin_theme_member_row_snapshot_type::GameDistributionAdminThemeMemberRowSnapshot;
pub use game_distribution_admin_theme_member_snapshot_type::GameDistributionAdminThemeMemberSnapshot;
pub use game_distribution_admin_theme_mutation_result_type::GameDistributionAdminThemeMutationResult;
pub use game_distribution_admin_theme_snapshot_type::GameDistributionAdminThemeSnapshot;
@@ -1513,6 +1520,7 @@ pub use initialize_editor_generation_pricing_config_if_missing_and_return_proced
pub use is_game_distribution_collected_and_return_procedure::is_game_distribution_collected_and_return;
pub use list_admin_accounts_and_return_procedure::list_admin_accounts_and_return;
pub use list_admin_game_distribution_games_and_return_procedure::list_admin_game_distribution_games_and_return;
pub use list_admin_game_distribution_theme_members_and_return_procedure::list_admin_game_distribution_theme_members_and_return;
pub use list_admin_game_distribution_themes_and_return_procedure::list_admin_game_distribution_themes_and_return;
pub use list_admin_game_distribution_user_reviews_and_return_procedure::list_admin_game_distribution_user_reviews_and_return;
pub use list_agc_tracking_events_procedure::list_agc_tracking_events;
@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionAdminThemeMemberListInput {
pub theme_id: String,
pub limit: u32,
pub cursor: Option<String>,
}
impl __sdk::InModule for GameDistributionAdminThemeMemberListInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_admin_theme_member_row_snapshot_type::GameDistributionAdminThemeMemberRowSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionAdminThemeMemberListResult {
pub ok: bool,
pub theme_id: String,
pub total_members: u64,
pub members: Vec<GameDistributionAdminThemeMemberRowSnapshot>,
pub next_cursor: Option<String>,
pub error_message: Option<String>,
}
impl __sdk::InModule for GameDistributionAdminThemeMemberListResult {
type Module = super::RemoteModule;
}
@@ -0,0 +1,20 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionAdminThemeMemberRowSnapshot {
pub root_game_id: String,
pub title: Option<String>,
pub sort_order: i64,
pub created_at_micros: i64,
pub visible: bool,
pub visibility: String,
}
impl __sdk::InModule for GameDistributionAdminThemeMemberRowSnapshot {
type Module = super::RemoteModule;
}
@@ -0,0 +1,62 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_admin_theme_member_list_input_type::GameDistributionAdminThemeMemberListInput;
use super::game_distribution_admin_theme_member_list_result_type::GameDistributionAdminThemeMemberListResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ListAdminGameDistributionThemeMembersAndReturnArgs {
pub input: GameDistributionAdminThemeMemberListInput,
}
impl __sdk::InModule for ListAdminGameDistributionThemeMembersAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `list_admin_game_distribution_theme_members_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait list_admin_game_distribution_theme_members_and_return {
fn list_admin_game_distribution_theme_members_and_return(
&self,
input: GameDistributionAdminThemeMemberListInput,
) {
self.list_admin_game_distribution_theme_members_and_return_then(input, |_, _| {});
}
fn list_admin_game_distribution_theme_members_and_return_then(
&self,
input: GameDistributionAdminThemeMemberListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionAdminThemeMemberListResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl list_admin_game_distribution_theme_members_and_return for super::RemoteProcedures {
fn list_admin_game_distribution_theme_members_and_return_then(
&self,
input: GameDistributionAdminThemeMemberListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionAdminThemeMemberListResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, GameDistributionAdminThemeMemberListResult>(
"list_admin_game_distribution_theme_members_and_return",
ListAdminGameDistributionThemeMembersAndReturnArgs { input },
__callback,
);
}
}
@@ -2017,6 +2017,69 @@ impl GameDistributionAdminThemeListResult {
}
}
/// 后台主题成员列表输入(`GET /admin/api/game-distribution/themes/{theme_id}/members`)。
///
/// 分页口径与其它列表完全一致:`limit` 为 0 取默认 20、超界截断到 50(`module_game_distribution` 的
/// `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_*` 是唯一口径),`cursor` 为
/// `"{sort_order}:{member_id}"`(第一段是**排序键**权重,第二段是成员主键,本身自带冒号)。
///
/// 与公开列表的差别**只有可见性与主题状态**:这条路径返回**全部成员行**(含当前不可见的成员),
/// 且 `draft` / `archived` 主题也照常返回(后台运营要在发布前核对名单)。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionAdminThemeMemberListInput {
pub theme_id: String,
pub limit: u32,
pub cursor: Option<String>,
}
/// 后台主题成员行(含**当前对外不可见**的行):运营核对名单用。
///
/// `title` 取游戏行标题,游戏行不存在时为 `None`(api-server 发 `null`)——只为后台渲染,后台本就被
/// 允许看草稿作品。两个可见性字段**刻意分开**,不许互相推导:
/// - `visible` = 此刻**公开投影**会不会包含它(未软删除 + `visibility == published` + 有当前公开
/// 版本),复用 [`module_game_distribution::game_distribution_theme_member_visible`];
/// - `visibility` = 更细的状态:软删除行 → `deleted`,游戏行不存在 → `missing`,否则取游戏行的
/// `visibility`(`unpublished` / `published` / `suspended`)。
///
/// 因此 `visibility == "published"` 而**没有**当前公开版本时 `visible == false`——这是**刻意**的:
/// 运营要能一眼看出「作品已公开、但没有公开版本」这种异常。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionAdminThemeMemberRowSnapshot {
pub root_game_id: String,
pub title: Option<String>,
pub sort_order: i64,
pub created_at_micros: i64,
pub visible: bool,
pub visibility: String,
}
/// 后台主题成员列表结果:**成员行总数**(不受分页影响)+ 这一页的行 + 下一页游标。
///
/// `total_members` 与 `members.len()` 刻意不是一回事:前者回答「这个主题挂了几行」(含当前不可见
/// 的行),后者只是这一页;`next_cursor` 为 `None` 表示这就是最后一页(api-server 发 `null`)。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionAdminThemeMemberListResult {
pub ok: bool,
pub theme_id: String,
pub total_members: u64,
pub members: Vec<GameDistributionAdminThemeMemberRowSnapshot>,
pub next_cursor: Option<String>,
pub error_message: Option<String>,
}
impl GameDistributionAdminThemeMemberListResult {
fn failed(error: String) -> Self {
Self {
ok: false,
theme_id: String::new(),
total_members: 0,
members: Vec::new(),
next_cursor: None,
error_message: Some(error),
}
}
}
impl GameDistributionLineageResult {
/// 目标作品不存在或已软删除:`ok` 仍为 true,由 api-server 映射成 404。
fn not_found() -> Self {
@@ -2970,6 +3033,39 @@ pub fn list_admin_game_distribution_themes_and_return(
}
}
/// 后台主题成员列表(`GET /admin/api/game-distribution/themes/{theme_id}/members`)。
///
/// 需要服务身份(后台只读同样是后台数据),与同族五条后台写接口**同一层**的第二道鉴权:HTTP 层的
/// admin 会话由 api-server 的 `require_admin_auth` 负责,这里只保证「只有 api-server 能调」。
///
/// 与公开投影的关键差异:**不套公开可见性过滤**、**也不要求主题已发布**——返回该主题的**全部成员行**
/// (含当前不可见的行),`draft` / `archived` 主题照常可读;每行带当前可见性与更细的
/// `visibility` 状态,让运营在发布前就能核对名单(这正是原「后台要成员名单只能借公开投影、
/// 于是草稿 / 归档主题看不到成员」那个缺口的补法)。
#[spacetimedb::procedure]
pub fn list_admin_game_distribution_theme_members_and_return(
ctx: &mut ProcedureContext,
input: GameDistributionAdminThemeMemberListInput,
) -> GameDistributionAdminThemeMemberListResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_editor_generation_runtime_service_identity(tx, caller)?;
list_admin_game_distribution_theme_members_tx(tx, input.clone())
}) {
Ok((theme_id, total_members, members, next_cursor)) => {
GameDistributionAdminThemeMemberListResult {
ok: true,
theme_id,
total_members,
members,
next_cursor,
error_message: None,
}
}
Err(error) => GameDistributionAdminThemeMemberListResult::failed(error),
}
}
#[spacetimedb::procedure]
pub fn get_game_distribution_version_and_return(
ctx: &mut ProcedureContext,
@@ -5757,6 +5853,54 @@ fn game_distribution_admin_theme_snapshot(
}
}
/// 主题成员行 → 后台行快照(含当前对外不可见的行)。
///
/// `visible` 复用公开投影那条**同一份**判定([`module_game_distribution::game_distribution_theme_member_visible`]:
/// 未删 + 已公开 + 有当前公开版本),绝不在这里重写第二个同义判定——否则「公开侧看得见 / 后台说
/// 看不见」这类漂移会立刻出现。`visibility` 只回答「**为什么**」,由模块侧纯函数给出(软删除 /
/// 作品行不存在 / 原样透传作品行的 `visibility`);两者是独立字段,`visibility == "published"`
/// 且无当前公开版本时 `visible == false` 是刻意的。
///
/// 游戏行不存在时 `title = None`(api-server 发 `null`):那是历史脏行,行本身仍要出现在名单里,
/// 让运营看到「这里挂着一个已经查不到的作品」——静默跳过反而会让名单与 `totalMembers` 对不上。
fn game_distribution_admin_theme_member_row_snapshot(
ctx: &ReducerContext,
member: &GameDistributionThemeMember,
) -> GameDistributionAdminThemeMemberRowSnapshot {
let game = ctx
.db
.game_distribution_game()
.game_id()
.find(&member.root_game_id);
let is_deleted = game.as_ref().is_some_and(|game| game.deleted_at.is_some());
let is_published = game
.as_ref()
.is_some_and(|game| game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED);
let has_public_version = game
.as_ref()
.is_some_and(|game| public_game_distribution_version(ctx, game).is_some());
let visible = module_game_distribution::game_distribution_theme_member_visible(
is_deleted,
is_published,
has_public_version,
);
let visibility = module_game_distribution::game_distribution_theme_member_visibility(
game.is_some(),
is_deleted,
game.as_ref()
.map(|game| game.visibility.as_str())
.unwrap_or_default(),
);
GameDistributionAdminThemeMemberRowSnapshot {
root_game_id: member.root_game_id.clone(),
title: game.map(|game| game.title),
sort_order: member.sort_order,
created_at_micros: member.created_at.to_micros_since_unix_epoch(),
visible,
visibility,
}
}
/// 后台创建主题。
///
/// 幂等:命中同键收据且摘要一致时回**首次创建的那一行**(`replayed = true`),而不是拿本次请求
@@ -6096,6 +6240,71 @@ fn list_admin_game_distribution_themes_tx(
.collect())
}
/// 后台主题成员列表:返回该主题的**全部成员行**(含当前对外不可见的行),游标分页。
///
/// 三条与公开投影**刻意不同**的口径:
/// 1. **不套公开可见性过滤**:成员行的全集就是切页序列,一行都不会在切页前被滤掉(后台运营要在发布
/// 前核对名单,包括当前不可见的成员)。这与 `/my-collections` 的「先过滤再切页」不同,因此这里
/// 也不需要那条纪律——没有行会被滤掉,就不存在「被滤掉的行占掉名额」的问题。
/// 2. **主题 `draft` / `archived` 照常可读**:只判主题**存在**(不存在 → `THEME_NOT_FOUND` → 404),
/// 不套 `game_distribution_theme_public_visible`。后台看得到草稿主题的成员,这是本接口存在的理由。
/// 3. **排序复用公开侧同一份比较器**(`sort_theme_members`:`sort_order` 升序 + `member_id` 升序
/// 兜底,全序),只是额外套上游标与 `limit`(`page_admin_theme_members`)。
///
/// `total_members` 是**成员行总数**(`game_distribution_theme_member_row_count`,不受分页影响):
/// 运营要知道「这个主题一共挂了几行」,而不是「这一页回了几行」。
///
/// 返回 `(theme_id, total_members, 这一页的行, 下一页游标)`。
fn list_admin_game_distribution_theme_members_tx(
ctx: &ReducerContext,
input: GameDistributionAdminThemeMemberListInput,
) -> Result<
(
String,
u64,
Vec<GameDistributionAdminThemeMemberRowSnapshot>,
Option<String>,
),
String,
> {
let theme_id = required_game_distribution_text(input.theme_id, "theme_id")?;
if ctx
.db
.game_distribution_theme()
.theme_id()
.find(&theme_id)
.is_none()
{
return Err(module_game_distribution::GameDistributionError::ThemeNotFound.to_string());
}
let cursor = input
.cursor
.and_then(normalize_game_distribution_optional)
.map(|value| {
// 薄封装(只委托主题列表那套解析):非法游标因此仍是可达的 `THEME_INVALID_CURSOR`。
module_game_distribution::parse_game_distribution_theme_member_cursor(value.as_str())
})
.transpose()?;
// 计数在切页**之前**取:`total_members` 是行总数,与这一页回了几行无关。
let total_members = game_distribution_theme_member_row_count(ctx, theme_id.as_str());
let rows = ctx
.db
.game_distribution_theme_member()
.by_game_distribution_theme_member_theme_id()
.filter(theme_id.as_str())
.map(
|member| module_game_distribution::GameDistributionThemeMemberPageItem {
member_id: member.member_id.clone(),
sort_order: member.sort_order,
payload: game_distribution_admin_theme_member_row_snapshot(ctx, &member),
},
)
.collect::<Vec<_>>();
let (members, next_cursor) =
module_game_distribution::page_admin_theme_members(rows, cursor, input.limit);
Ok((theme_id, total_members, members, next_cursor))
}
///
/// 这里只负责取行(根游戏行、按根索引命中的血缘行、以及每行引用的游戏行),
/// 「家族归属校验、根自身入集合、游戏行缺失跳过、重复行去重」全部交给
@@ -7309,6 +7518,7 @@ mod tests {
"pub fn upsert_game_distribution_theme_member_and_return(",
"pub fn remove_game_distribution_theme_member_and_return(",
"pub fn list_admin_game_distribution_themes_and_return(",
"pub fn list_admin_game_distribution_theme_members_and_return(",
] {
let body = function_body(source, marker);
assert!(
@@ -7344,6 +7554,10 @@ mod tests {
"impl GameDistributionAdminThemeListResult {",
"themes: Vec::new()",
);
failed_result(
"impl GameDistributionAdminThemeMemberListResult {",
"members: Vec::new()",
);
// 创建 / 更新的成功路径必须把 `replayed` 透传给 handler(否则重放会被说成新建)。
let create_procedure =
function_body(source, "pub fn create_game_distribution_theme_and_return(");
@@ -7391,4 +7605,81 @@ mod tests {
let create = function_body(source, "fn create_game_distribution_theme_tx(");
assert!(create.contains("ensure_game_distribution_theme_receipt_digest"));
}
/// 后台成员列表:**不套**可见性过滤、**不要求主题已发布**、复用公开侧的排序切页纯函数、不删行,
/// 且 `totalMembers` 取的是**行总数**(切页之前算,与这一页回了几行无关)。
#[test]
fn admin_theme_member_list_keeps_invisible_rows_and_drafts() {
let source = include_str!("game_distribution.rs");
let body = function_body(source, "fn list_admin_game_distribution_theme_members_tx(");
for required in [
// 按主题索引取全部成员行(不扫描全表、也不走「只取可见成员」的公开投影助手)。
"by_game_distribution_theme_member_theme_id()",
// 排序 + 切页走共享纯函数(内部即 `sort_theme_members`:sort_order 升序 + member_id 兜底)。
"module_game_distribution::page_admin_theme_members",
// 游标解析走共享纯函数(非法游标 → 可达的 `THEME_INVALID_CURSOR`)。
"module_game_distribution::parse_game_distribution_theme_member_cursor",
"GameDistributionError::ThemeNotFound",
"game_distribution_theme_member_row_count",
] {
assert!(body.contains(required), "后台成员列表事务缺少:{required}");
}
// 后台要看全部行:不得套公开可见性过滤,也不得借道「只取可见成员」的助手。
assert!(
!body.contains("game_distribution_theme_public_visible"),
"后台成员列表不得因主题 draft / archived 而拒绝:那是本接口存在的理由"
);
assert!(
!body.contains("game_distribution_theme_visible_members"),
"后台成员列表必须含当前不可见的行,不能复用「只取可见成员」的公开投影助手"
);
assert!(
!body.contains("game_distribution_theme_member_visible"),
"可见性判定只属于逐行投影(行快照),不属于「要不要返回这一行」"
);
// 排序只有一份实现:事务里不得自己 `sort_by`,必须走共享纯函数。
assert!(
!body.contains("sort_by"),
"事务里不得自写比较器:排序必须复用 `sort_theme_members`(经 `page_admin_theme_members`)"
);
assert!(
!body.contains(".delete("),
"后台成员列表是纯读:不得以任何理由删除成员行"
);
// 计数必须在切页之前取,才可能是「行总数」而不是「这一页的行数」。
let count_at = body
.find("game_distribution_theme_member_row_count")
.expect("必须取行总数");
let page_at = body
.find("module_game_distribution::page_admin_theme_members")
.expect("必须切页");
assert!(
count_at < page_at,
"totalMembers 必须在切页之前取:反了就退化成「这一页回了几行」"
);
// 行快照:`visible` 复用公开侧同一份判定,`visibility` 走模块侧纯函数拆分「为什么」;
// 游戏行缺失时仍然产出**一行**(title = None),不得静默跳过(否则名单与计数对不上)。
let row = function_body(
source,
"fn game_distribution_admin_theme_member_row_snapshot(",
);
assert!(
row.contains("module_game_distribution::game_distribution_theme_member_visible"),
"visible 必须复用公开投影的同一份判定,不许在这里重写一套"
);
assert!(
row.contains("module_game_distribution::game_distribution_theme_member_visibility"),
"visibility 必须走模块侧纯函数(missing 优先于 deleted)"
);
assert!(
row.contains("public_game_distribution_version(ctx, game)"),
"「有当前公开版本」必须与公开侧用同一个查法"
);
assert!(
!row.contains("return None"),
"作品行缺失的行仍要回传(title = null),否则名单与 totalMembers 对不上"
);
assert!(!row.contains(".delete("), "行快照是纯读");
}
}