From 0d0166c2d9bdf10735a3ef12b8ac38b6123ff513 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 04:28:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=AF=BB=E5=8F=96=E4=B8=BB=E9=A2=98=E6=88=90?= =?UTF-8?q?=E5=91=98=E5=90=8D=E5=8D=95=EF=BC=88=E5=90=AB=E8=8D=89=E7=A8=BF?= =?UTF-8?q?/=E5=BD=92=E6=A1=A3=E4=B8=BB=E9=A2=98=E4=B8=8E=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E6=88=90=E5=91=98=20+=20=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7=E5=88=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺口:后台原先只能借**公开投影** `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`(未登录/失效 → 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 / 提交(或直接删掉其本地重复定义)。 --- .../【里程碑】共创主题与作品树-2026-10-06.md | 33 +- ...�技术方案】游戏共创与作品Fork-2026-10-03.md | 5 +- .../shared/src/contracts/gameDistribution.ts | 58 ++++ .../check-game-distribution-dto-parity.mjs | 28 ++ .../src/modules/game_distribution.rs | 284 ++++++++++++++++- .../module-game-distribution/src/lib.rs | 20 +- .../module-game-distribution/src/theme.rs | 279 +++++++++++++++++ .../crates/spacetime-client/src/active.rs | 1 + .../spacetime-client/src/active/mapper.rs | 4 +- .../src/active/mapper/game_distribution.rs | 130 ++++++++ .../spacetime-client/src/game_distribution.rs | 48 +++ .../spacetime-client/src/module_bindings.rs | 8 + ...tion_admin_theme_member_list_input_type.rs | 17 + ...ion_admin_theme_member_list_result_type.rs | 22 ++ ...on_admin_theme_member_row_snapshot_type.rs | 20 ++ ...tion_theme_members_and_return_procedure.rs | 62 ++++ .../spacetime-module/src/game_distribution.rs | 291 ++++++++++++++++++ 17 files changed, 1289 insertions(+), 21 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_list_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_row_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_theme_members_and_return_procedure.rs diff --git a/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md b/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md index 1ba4f48e9..b9fd75377 100644 --- a/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md +++ b/docs/project-memory/plans/【里程碑】共创主题与作品树-2026-10-06.md @@ -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` 那种「排序键与负载绑定」的写法,避免「按 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** 仍是留白。) diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 0aef604fc..223d9ba0d 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -352,6 +352,7 @@ pub(crate) project_bundle_sha256: Option, | `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`;未登录 / 失效会话 → **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 留白与已知限制(显式列出,不静默省略) diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 5c832ea7d..c8cc2f0c0 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -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; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 61207f1b6..6aa88eab6 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -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) { diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 7672b75fe..581b3f72b 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -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, } +/// 后台主题**成员名单**的查询串:`limit` + `cursor`。 +/// +/// 与其它游标列表同一套数值与处理方式(缺省 20 / 上限 50 / `0` 取默认 / 超界截断 / 非法游标 400)—— +/// 后台名单同样是一页页翻的表格,没有理由用第二套分页口径。游标解析在事务里(模块侧纯函数), +/// 这里只透传字符串:handler 自己解析会让「HTTP 挡住的」与「落库挡住的」长出两套判据。 +#[derive(Debug, Deserialize)] +struct AdminThemeMemberListQuery { + limit: Option, + cursor: Option, +} + /// 作者软删除游戏:CAS 修订号走查询串,删除本身没有请求体。 #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -527,6 +539,13 @@ pub fn router(state: AppState) -> Router { "/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, + Extension(ctx): Extension, + Extension(_admin): Extension, + Path(theme_id): Path, + Query(query): Query, +) -> Result, 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, +) -> Value { + json!({ + "themeId": theme_id, + "totalMembers": total_members, + "members": members + .iter() + .map(admin_theme_member_row_payload) + .collect::>(), + "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::>(); + 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::>(); + 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}"); + } + } } diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index 5760c4379..240f1ca92 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -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, }; diff --git a/server-rs/crates/module-game-distribution/src/theme.rs b/server-rs/crates/module-game-distribution/src/theme.rs index f11dfae13..af2d825bb 100644 --- a/server-rs/crates/module-game-distribution/src/theme.rs +++ b/server-rs/crates/module-game-distribution/src/theme.rs @@ -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( + items: Vec>, + cursor: Option<(i64, String)>, + limit: u32, +) -> (Vec, Option) { + 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 = 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(") + .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 取默认 / 超界截断)" + ); + } } diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 188e582ed..113cfcd13 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 9f8ce922f..f6451ac7c 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index b5c32c10b..85f93a571 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -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, + 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, + pub next_cursor: Option, +} + 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 { + 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: 主题不存在"); + } } diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index c85ba9c5c..846309f0b 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -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, +} + 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 { + 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, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 05f69adc2..4330b951b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_list_input_type.rs new file mode 100644 index 000000000..8d088a259 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_list_input_type.rs @@ -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, +} + +impl __sdk::InModule for GameDistributionAdminThemeMemberListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_list_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_list_result_type.rs new file mode 100644 index 000000000..4015e4d5e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_list_result_type.rs @@ -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, + pub next_cursor: Option, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionAdminThemeMemberListResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_row_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_row_snapshot_type.rs new file mode 100644 index 000000000..ebe0ac380 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_row_snapshot_type.rs @@ -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, + pub sort_order: i64, + pub created_at_micros: i64, + pub visible: bool, + pub visibility: String, +} + +impl __sdk::InModule for GameDistributionAdminThemeMemberRowSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_theme_members_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_theme_members_and_return_procedure.rs new file mode 100644 index 000000000..bf30095bf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_theme_members_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionAdminThemeMemberListResult>( + "list_admin_game_distribution_theme_members_and_return", + ListAdminGameDistributionThemeMembersAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 830d4629d..ae20ec8ab 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -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, +} + +/// 后台主题成员行(含**当前对外不可见**的行):运营核对名单用。 +/// +/// `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, + 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, + pub next_cursor: Option, + pub error_message: Option, +} + +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, + Option, + ), + 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::>(); + 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("), "行快照是纯读"); + } }