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
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:
@@ -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;
|
||||
|
||||
+17
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+62
@@ -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("), "行快照是纯读");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user