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 留白与已知限制(显式列出,不静默省略)