docs(游戏共创): 共创主题设计定稿(§3.10 + 里程碑《共创主题与作品树-2026-10-06》)
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 / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (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 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 / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (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 web tests (pull_request) Has been cancelled
需求形态(飞书):共创 Tab 下有多个「共创主题」,点进去是一棵(或一组)作品树;**主题由平台/运营命名,不由根作品决定**;作品在游戏 Tab 里仍作为独立作品展示,详情页有 Fork 入口并能看到 / 跳到对应主题页。本轮只落设计(不写业务代码),实施清单见里程碑文件。
设计要点与理由(详见 §3.10):
- 实体:`game_distribution_theme`(`theme_id` 主键、三态 `draft|published|archived`、`sort_order`、`badge`、`created_by_user_id`)+ `game_distribution_theme_member`(确定性 `member_id = "{theme_id}:{root_game_id}"`,`theme_id` / `root_game_id` 各具名 btree 索引)。
- **成员只允许根作品**:树查询 `/games/{id}/lineage` 按根聚合,否则同一棵树会在主题页里出现多次(去重/高亮打架);代价=要挂就挂它所属的根。跳到某一代节点属 UI 层(族谱页已有 `from` 高亮)。
- **允许跨主题多归属**:主题是运营叙事,「平台精选」「双人合作」会同时收录同一个根;确定性主键只保证**同一主题内**不重复。代价=作品详情要返回多值、运营需知悉。
- **树直接复用 `/games/{id}/lineage`**,主题侧**不重建**任何树查询/树整形(没有第二套可见性/排序可漂移);主题详情只返回成员根清单(N+1 本轮接受,批量树端点留白)。
- 可见性:公开侧只见 `published` 主题;成员只出现公开未删且有当前公开版本的根(**投影跳过但不删行**,重新公开自动回来);主题不存在/未发布 → **404**;已发布但可见成员为空 → **200 + 空成员列表**。
- 公开三接口:`GET /themes`(游标分页沿用默认 20/上限 50/非法 400/末页 null)、`GET /themes/{theme_id}`(+ `roots`)、作品详情 `themes` 增量(**先取该作品的根、再按根反查**,因此第 N 代作品也能跳到主题页)。
- 后台五接口(创建/编辑/增删成员/后台列表,均幂等),鉴权**复用既有 `require_admin_auth` + `AuthenticatedAdmin`**,不自造;后台 UI 不在本轮。
- 留白 5 项(各写不做理由与将来接法):主题封面图、slug/URL 别名、埋点统计、跳到某一代的高亮、批量树端点。
- 同步收敛:§6 里程碑表 M4 行指向新里程碑;§7 第 3 条「共创主题」从**待拍板**改为**已拍板采纳**,并把「主题级排序口径」留作唯一待产品确认项。
门禁:`check:doc-index` 0(252 份 md;新里程碑在 `plans/` 下免分类);`check:encoding` 0(5383 files);`git diff --check` 0。
注:§3.10 的前约 200 行因共享工作树/索引,被上一提交 `f703bc49d`(注释修正)一并带上;本提交补齐其余章节 + 里程碑文件。
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
# 【里程碑】共创主题与作品树
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | --------------------------------------------------------------------- |
|
||||
| Version | 1.0 |
|
||||
| Status | proposed(设计已定稿于技术方案 §3.10;**本轮只落设计,未写业务代码**) |
|
||||
| Date | 2026-10-06 |
|
||||
| Parent Spec | `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md`(§3.10 共创主题) |
|
||||
|
||||
## 目标
|
||||
|
||||
共创侧出现**由平台 / 运营命名**的「共创主题」:一个主题下挂若干**根作品**,点进去能看到这些根各自的**作品树**;作品在游戏 Tab 里仍作为独立作品展示,详情页能看到自己所属的主题并跳过去。
|
||||
|
||||
一句话判据:**主题是运营叙事,树由血缘复用,作品归属是额外维度而不是替代品。**
|
||||
|
||||
## 范围
|
||||
|
||||
- 两张表:`game_distribution_theme`(主题)与 `game_distribution_theme_member`(成员,**只允许根**);迁移白名单登记与生成绑定。
|
||||
- 领域纯函数:主题状态白名单、主题公开可见性、成员公开可见性、确定性成员主键、成员 / 主题的稳定排序与游标切页。
|
||||
- 公开读接口(匿名、`no-store`):主题列表(游标分页)、主题详情(成员根清单 + 每根既有公开摘要)。
|
||||
- 作品详情增量:`themes: [{ themeId, name, badge }]`(先取该作品的**根**再反查)。
|
||||
- 后台写接口:创建主题、改名 / 简介 / 角标 / 排序 / 状态、增删成员(都幂等)、后台列表(含 `draft` / `archived`);鉴权复用既有 `require_admin_auth` + `AuthenticatedAdmin`。
|
||||
- 公开前端:共创 Tab(主题列表)、主题页(成员根清单 + 逐根渲染 `lineage` 树)、详情页主题入口。
|
||||
- 契约同步:Rust / TS DTO、DTO parity 构建器登记、数据契约表随表落地补。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- **后台管理 UI**(本轮只做后台接口;运营页另行安排)。
|
||||
- 主题**封面图**(要接平台素材链路与公开授权口径,独立小需求;本轮用 `badge` + `name` 呈现)。
|
||||
- **slug / URL 别名**(公开 URL 直接用 `theme_id`,本轮不做 slug 与唯一性约束)。
|
||||
- **埋点 / 统计**(曝光、点击、成员转化等 UI 上线后按数据定口径)。
|
||||
- 主题内「**跳到某一代节点**」高亮(UI 层能力,族谱页已有 `from` 参数)。
|
||||
- **批量树端点**(本轮接受前端 N 次 `/games/{id}/lineage`)。
|
||||
- 主题级联下架 / 归档时的成员清理(不做:归档即「不再公开」,成员行按同一口径保留)。
|
||||
- 收益分成、流量回馈、相似度反洗稿校验、关注 / 粉丝。
|
||||
|
||||
## 依赖与前置条件
|
||||
|
||||
- M1(授权与血缘骨架)已落地:`game_distribution_lineage` 表与根解析(`game_distribution_lineage().game_id().find(...).map(root_game_id).unwrap_or(game_id)`)是本里程碑「成员只允许根」与「详情页按根反查」的前置。
|
||||
- M3(族谱与衍生列表)已落地:主题页的树**直接复用** `/games/{id}/lineage`,本里程碑不重建任何树查询或树整形。
|
||||
- 公开目录同一份 `public_game_payload` 投影已存在于 api-server(主题详情与成员卡片都复用它)。
|
||||
- 游标分页惯例已由 `/my-collections`(2026-10-06)落地:默认 20 / 上限 50 / 游标 `"{micros}:{id}"` / 非法游标 400 / 末页 `nextCursor: null`。
|
||||
- 设计已由技术方案 §3.10 定稿(含四条留白)。**待产品确认的只有「主题级排序口径」**(见文末「待确认项」)。
|
||||
|
||||
## 实施清单(下一轮照此执行)
|
||||
|
||||
### A. 数据模型与迁移
|
||||
|
||||
1. 在 `server-rs/crates/spacetime-module/src/game_distribution.rs` 追加两张表(列、索引、注释按技术方案 §3.10.1 的表声明逐字落地):
|
||||
- `game_distribution_theme`:`theme_id`(PK,`theme-*`)、`name`、`summary`、`badge`、`sort_order: i64`、`status: String`、`created_by_user_id`、`created_at`、`updated_at`;具名索引 `by_game_distribution_theme_created_at`(btree `created_at`)。
|
||||
- `game_distribution_theme_member`:`member_id`(PK,`"{theme_id}:{root_game_id}"`)、`theme_id`、`root_game_id`、`sort_order: i64`、`created_at`;**两条具名 btree 索引** `by_game_distribution_theme_member_theme_id`、`by_game_distribution_theme_member_root_game_id`。
|
||||
2. 在 `server-rs/crates/spacetime-module/src/migration.rs` 的 `migration_tables!` 白名单登记两表,注释写明「主题与成员是运营业务事实,随迁移导出/导入;归档 / 下架不删除成员行」。
|
||||
3. 生成绑定:`npm run spacetime:generate`;**只保留**与本次 schema 相关的 `module_bindings/game_distribution*` 与 `module_bindings/module_bindings.rs`,其余 rustfmt 漂移 `git checkout --` 还原(与 §5.1 同处理)。
|
||||
4. 新增 procedure 的 Result/Input `SpacetimeType`(`SpacetimeType` 派生,字段用 `snake_case`,与既有 `GameDistributionCollection*` 系列同形),放在既有结果类型附近。
|
||||
|
||||
### B. 领域纯函数(`server-rs/crates/module-game-distribution/src/theme.rs`,新文件,不碰 `ReducerContext`)
|
||||
|
||||
| 函数 / 常量 | 职责 | 关键约束 |
|
||||
| --- | --- | --- |
|
||||
| `GAME_DISTRIBUTION_THEME_STATUSES: [&str; 3]` | `["draft", "published", "archived"]` | 三处共用同一个白名单(可见性、后台列表过滤、DTO 校验),不复制字面量 |
|
||||
| `game_distribution_theme_status_valid(status: &str) -> bool` | 状态合法性 | 非法值由 api-server 映射 400,不落到 axum 422 |
|
||||
| `game_distribution_theme_public_visible(status: &str) -> bool` | 主题公开可见性 | 等价于 `status == "published"`;`draft` / `archived` 一律不可见 |
|
||||
| `game_distribution_theme_member_id(theme_id: &str, root_game_id: &str) -> String` | 确定性主键 | `format!("{theme_id}:{root_game_id}")`;两个 ID 都不含 `:`(`theme-*` / `game-*`),组合单射 |
|
||||
| `game_distribution_theme_member_visible(is_deleted: bool, is_published: bool, has_public_version: bool) -> bool` | 成员公开可见性 | **委托**既有 `game_distribution_collection_visible`(同一条「未删 + 已公开 + 有公开版本」口径),不新写第二个同义判定 |
|
||||
| `game_distribution_theme_root_acceptable(has_lineage_row: bool) -> bool` | 成员只允许根 | `has_lineage_row == true`(非根)→ 拒绝;由调用方用血缘点查给出事实 |
|
||||
| `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT = 20` / `_MAX = 50` / `game_distribution_theme_page_limit(limit: u32) -> usize` | 公开列表页大小归一化 | 与 `/my-collections` 同一数值与「超界截断而非报错」口径;归一化在模块侧定义一次,日志与切页共用同一函数 |
|
||||
| `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` 允许重复) |
|
||||
|
||||
复用 `collection.rs` 里 `GameDistributionCollectionPageItem<T>` 那种「排序键与负载绑定」的写法,避免「按 A 排序、按 B 切页」的错位。
|
||||
|
||||
### C. 事务与 procedure(`server-rs/crates/spacetime-module/src/game_distribution.rs`)
|
||||
|
||||
命名沿用既有:**写** = `*_and_return`,**读** = `list_*` / `get_*`。
|
||||
|
||||
| procedure | 事务职责 |
|
||||
| --- | --- |
|
||||
| `create_game_distribution_theme_and_return` | 服务端生成 `theme_id`(`theme-` + 唯一后缀,不接受客户端传入);写 `created_by_user_id`(= admin 会话主体)、`created_at` = `updated_at` = now;返回主题行 + `replayed` |
|
||||
| `update_game_distribution_theme_and_return` | 按 `theme_id` 改名 / 简介 / 角标 / 排序 / 状态;`theme_id` 不存在 → `THEME_NOT_FOUND`;生效时刷新 `updated_at`;幂等收据键与既有写接口同族(绑定请求摘要,摘要不一致 → `THEME_IDEMPOTENCY_CONFLICT`) |
|
||||
| `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_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` 增量使用 |
|
||||
| (内部辅助)`game_distribution_theme_root_of(ctx, game_id) -> String` | 血缘点查取根,无血缘行返回自身;**必须复用** `game_distribution_lineage_entries` 用的同一写法,不复制第二份根解析 |
|
||||
|
||||
事务级不变量(必须有定向断言):① 同一主题内同一根只可能有一行(主键结构);② 非根作品不能成为成员;③ 同一根可以同时存在于多个主题(跨主题各有独立行);④ 成员行**不因作品下架 / 软删除而删除**;⑤ 归档主题的行仍在表里(只是不在公开投影里)。
|
||||
|
||||
### D. api-server 路由(`server-rs/crates/api-server/src/modules/game_distribution.rs`)
|
||||
|
||||
公开族(挂 `public_games` 那一支,带 `add_no_store_response_headers`):
|
||||
|
||||
| 方法 / 路径 | handler | 响应 | 错误 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /api/game-distribution/themes?limit=&cursor=` | `list_public_themes` | `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }` | 非法游标 → 400(平台信封) |
|
||||
| `GET /api/game-distribution/themes/{theme_id}` | `get_public_theme` | `{ themeId, name, summary, badge, memberCount, roots: [...] }` | 不存在 / 未发布 → 404(不返回空壳) |
|
||||
| `GET /api/game-distribution/games/{game_id}`(**既有,响应增量**) | `get_game` | 追加 `themes: [{ themeId, name, badge }]` | 沿用既有 404 |
|
||||
|
||||
后台族(挂 `admin` 那一支的 `route_layer(middleware::from_fn_with_state(state, require_admin_auth))`):
|
||||
|
||||
| 方法 / 路径 | 语义 | 幂等 |
|
||||
| --- | --- | --- |
|
||||
| `POST /admin/api/game-distribution/themes` | 创建(`name` 必填非空;`status` 缺省 `draft`) | 要求 `Idempotency-Key` |
|
||||
| `PUT /admin/api/game-distribution/themes/{theme_id}` | 改名 / 简介 / 角标 / 排序 / 状态 | 要求 `Idempotency-Key` |
|
||||
| `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}` | 移除成员(不存在也算成功) | 不需要幂等键 |
|
||||
|
||||
### E. 错误码映射(api-server 集中映射,沿用 `FORK_*` 那套「前缀字符串 → 状态码」写法)
|
||||
|
||||
| 领域码 | HTTP | 触发 |
|
||||
| --- | --- | --- |
|
||||
| `THEME_NOT_FOUND` | 404 | 后台按 `theme_id` 找不到主题(公开侧「不存在 / 未发布」一律 404 且不带该码) |
|
||||
| `THEME_BAD_REQUEST` | 400 | `name` 空 / 非法 `status` / 非法 `status` 过滤值 |
|
||||
| `THEME_INVALID_CURSOR` | 400 | 游标格式非法(也可直接复用既有 `bad_request` 文案,二选一并在脚本里钉住实际值) |
|
||||
| `THEME_IDEMPOTENCY_CONFLICT` | 409 | 同 `Idempotency-Key` 不同请求摘要 |
|
||||
| `THEME_MEMBER_NOT_ROOT` | 409 | 目标作品有血缘行(非根) |
|
||||
| `THEME_MEMBER_GAME_NOT_FOUND` | 404 | 目标作品不存在 |
|
||||
| (框架)未带 admin 会话 | 401 | 由 `require_admin_auth` 给出 |
|
||||
|
||||
### F. 契约与 DTO 同步
|
||||
|
||||
- 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` 增量构建器),证明这些路径确实会发出新键。
|
||||
- `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`:**随表落地时**补 `game_distribution_theme` / `game_distribution_theme_member` 小节(本轮不动该文档;表落地后 `check:spacetime-schema` 会要求)。
|
||||
|
||||
### G. 前端(公开侧;后台 UI 不在本轮)
|
||||
|
||||
| 文件 | 改动 |
|
||||
| --- | --- |
|
||||
| `src/routing/activeAppPageRoutes.ts` | 新增 `['game-themes', '/games/themes']`、`['game-theme', '/games/theme']`(详情用 `?id=<themeId>`,与 `/games/lineage?id=` 同款);同步 `SelectionStage` 类型 |
|
||||
| `src/components/platform-entry/PlatformEntryActiveFlowShell.tsx` | 新 stage 的渲染分支(与 `game-lineage` 同形) |
|
||||
| `src/components/game-distribution/GameGalleryPage.tsx` | 新增「共创」Tab(切到 `/games/themes`),分类 Tab 结构复用既有 `game-category-tabs` |
|
||||
| `src/components/game-distribution/GameThemesPage.tsx`(新) | 主题列表:卡片显示 `name` / `summary` / `badge` / `memberCount`;游标「加载更多」;空态与失败态复用既有平台错误组件 |
|
||||
| `src/components/game-distribution/GameThemePage.tsx`(新) | 主题详情:成员根清单 + **逐根**调 `/games/{id}/lineage` 渲染多棵树(N+1 是已接受的取舍);已发布但无可见成员 → 明确空态(**不是**错误);成员作品不可用时按族谱页既有降级展示 |
|
||||
| `src/components/game-distribution/GameDetailPage.tsx` | 用 `GameDetailDisplay` 的槽位渲染 `themes` 入口链(`/games/theme?id=`);无主题时不渲染空容器 |
|
||||
| `src/services/gameDistributionClient.ts` | 新增 `listThemes` / `getTheme`;`getGame` 类型增量 `themes` |
|
||||
|
||||
新增 SPA 路由后必须同步 `npm run check:nginx-spa-routes` 与 `npm run check:pingora-route-parity` 的静态路由清单(`/games/themes` 是静态前缀;`/games/theme` 若走查询串则不需要动态段放行)。
|
||||
|
||||
### H. 测试清单
|
||||
|
||||
| 层 | 用例 |
|
||||
| --- | --- |
|
||||
| 纯函数(`module-game-distribution`) | 状态白名单(合法三态 / 未知值);主题公开可见性(`draft` / `archived` false);成员可见性四组合 + 「未删已公开但无当前公开版本」必须 false;`member_id` 确定性(同输入同输出、不同主题不同行);成员只允许根(`has_lineage_row` true → 拒绝);游标编解码往返 + 非法游标(缺冒号 / 非数字 / 空 ID)+ 解析只切第一个冒号;页大小归一化(缺省 20 / 0 取默认 / 超界截断到 50);**过滤后切页不重不漏**(可见性过滤位置在排序之前);排序兜底(`sort_order` 相同按 `member_id` 升序;`created_at` 相同按 `theme_id` 升序) |
|
||||
| 事务(`spacetime-module`) | 同主题同根重复 upsert 只有一行且 `created_at` 不变;跨主题同根两行并存;非根入成员被拒;成员作品下架 / 软删除后**行还在**、公开投影不含它、重新公开后自动回来;归档主题不出现在公开列表;`theme_id` 服务端生成且形如 `theme-*`;`created_by_user_id` 取自 admin 会话主体;`updated_at` 每次更新刷新 |
|
||||
| api-server 路由 | 公开列表 200 / 匿名可读 / 非法游标 400 / 末页 `nextCursor === null`;公开详情 404(不存在、`draft`、`archived`)与 200 空 `roots`;作品详情 `themes` 增量(只含公开主题;**第 N 代作品按其根查到所属主题**);两条公开路径都带 `Cache-Control: no-store`;后台五条路由未带 admin 会话一律 401;后台写:空 `name` / 非法 `status` 400、未知 `theme_id` 404、作品不存在 404、非根 409、同键重放 `replayed: true`、同键不同请求 409、`DELETE` 重复调用 200 |
|
||||
| 契约 | DTO parity 含新构建器;TS 类型与 Rust DTO 逐键一致 |
|
||||
| 前端 | 主题列表(渲染 / 空态 / 失败态 / 加载更多);主题页多棵树(mock 两个根各自 `lineage`)与「已发布但空成员」空态;详情页 `themes` 链到主题页、无主题时不渲染;窄屏布局不撑破 |
|
||||
|
||||
### I. 门禁命令与证据
|
||||
|
||||
```bash
|
||||
# 文档与工作树(本里程碑设计产出)
|
||||
npm run check:doc-index && npm run check:encoding && git diff --check
|
||||
|
||||
# Rust 侧
|
||||
cargo test -p module-game-distribution
|
||||
cargo test -p api-server game_distribution
|
||||
cargo check --all-targets --manifest-path server-rs/Cargo.toml
|
||||
npm run check:server-rs-ddd
|
||||
|
||||
# schema 与生成物
|
||||
npm run spacetime:generate
|
||||
npm run check:generated-bindings
|
||||
npm run check:spacetime-schema
|
||||
|
||||
# 契约
|
||||
npm run check:game-distribution-dto-parity
|
||||
|
||||
# 前端
|
||||
npx vitest run src/components/game-distribution src/services/gameDistributionClient.test.ts
|
||||
npm run typecheck && npm run lint:eslint
|
||||
npm run check:nginx-spa-routes && npm run check:pingora-route-parity
|
||||
|
||||
# 真实栈端到端(新增脚本,形状照 scripts/check-game-distribution-collection-e2e.mjs:
|
||||
# 起本地完整 dev 栈 + 管理员账号,走真实 HTTP,逐条 check(...) 断言,失败非零退出)
|
||||
E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> node scripts/check-game-distribution-theme-e2e.mjs
|
||||
```
|
||||
|
||||
新增 npm script `check:game-distribution-theme-e2e` → `node scripts/check-game-distribution-theme-e2e.mjs`(与 `check:game-distribution-collection-e2e` 同族命名)。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 两张表与两条成员索引落地;schema 检查通过;生成绑定只含与本次 schema 相关的文件。
|
||||
- [ ] `theme_id` 由服务端生成、形如 `theme-*`;`member_id` 为 `"{theme_id}:{root_game_id}"`,重复添加同一 (主题, 根) 不产生第二行。
|
||||
- [ ] 成员只允许根:目标作品有血缘行(非根)时写入被拒绝且不写库;无血缘行的作品可入成员。
|
||||
- [ ] 同一根可同时属于多个主题,各主题各有独立成员行;作品详情的 `themes` 返回**多值**。
|
||||
- [ ] 公开侧只出现 `status == published` 的主题;`draft` / `archived` 主题的详情与列表均不可见。
|
||||
- [ ] 成员只出现「公开未删且存在当前公开版本」的根;成员作品下架 / 软删除后从投影中跳过但**行不删除**,重新公开后自动回到主题页。
|
||||
- [ ] 主题不存在或未发布时详情返回 404,不返回空壳;已发布但可见成员为空时返回 200 + 空成员列表(前端按空态而非错误渲染)。
|
||||
- [ ] 公开主题列表分页沿用既有游标惯例:默认 20、上限 50、超界截断;非法游标 400;`nextCursor` 为真实值且末页为 `null`;排序为 `created_at` 倒序 + `theme_id` 升序兜底,翻页不重不漏。
|
||||
- [ ] 主题详情的 `roots` 按 `sort_order` 升序 + `member_id` 升序稳定排序,逐条为既有公开作品投影(不泄露对象键、不泄露未公开作品信息)。
|
||||
- [ ] 作品详情新增 `themes`:**第 N 代作品**(非根)也能看到并跳到其所属公开主题(按根反查),且只含公开主题。
|
||||
- [ ] 主题列表与详情的 `memberCount` 等于同响应里可见成员数(`roots` 长度),不含草稿 / 已下架成员。
|
||||
- [ ] 两条公开主题路径匿名可读且带 `Cache-Control: no-store`。
|
||||
- [ ] 后台五条路由未带 admin 会话一律 401;鉴权复用既有 `require_admin_auth` + `AuthenticatedAdmin`,不自造。
|
||||
- [ ] 后台写接口错误码可区分:空名 / 非法状态 400、未知主题 404、作品不存在 404、非根 409、同键不同请求 409;同键重放如实回报幂等重放。
|
||||
- [ ] 后台 `DELETE` 成员重复调用结果相同且成功(幂等),成员不存在不是错误。
|
||||
- [ ] 后台列表含 `draft` / `archived` 主题;非法 `status` 过滤值返回 400。
|
||||
- [ ] 公开前端:共创 Tab 能列出主题并进入主题页;主题页按成员根渲染**多棵树**(复用 `/games/{id}/lineage`);已发布空主题显示空态;详情页 `themes` 能跳到主题页。
|
||||
- [ ] 新增路由在桌面与窄屏可用,且 `check:nginx-spa-routes` / `check:pingora-route-parity` 通过。
|
||||
- [ ] 契约同步:DTO parity 通过;数据契约表文档随表落地补齐。
|
||||
|
||||
## 证据要求
|
||||
|
||||
- 自动化:`module-game-distribution` 纯函数单测(可见性 / 排序 / 游标 / 根约束)、`spacetime-module` 事务断言、api-server 路由与错误码定向测试、DTO parity、schema 与生成绑定检查、前端 vitest、`check:encoding` / `check:doc-index` / `git diff --check`。
|
||||
- 运行时:真实本地栈上完成「建主题(draft)→ 加两个根成员 → 发布 → 匿名读列表与详情 → 作品详情看到主题 → 把一个成员下架(从投影消失、行仍在)→ 重新公开(自动回来)→ 归档主题(公开侧不可见、行仍在)→ 后台列表仍可见」整链;浏览器在桌面与窄屏走一遍共创 Tab 与主题页。
|
||||
- 边界:已发布但零可见成员、非根作品入成员、跨主题同根、非法游标、末页游标、未知主题 404、未带 admin 会话 401、重复成员写入、`DELETE` 不存在成员。
|
||||
- 脚本:`scripts/check-game-distribution-theme-e2e.mjs` 的 `check(...)` 计数与失败项;脚本头部按既有惯例列出「契约来源」与「与工单描述不一致、按实现断言」的条目。
|
||||
|
||||
## 待确认项(需用户 / 产品拍板)
|
||||
|
||||
- **主题级排序口径**:本方案按「沿用既有游标惯例」把公开列表排在 `created_at`(倒序)+ `theme_id`(升序兜底)上,`sort_order` 只用于**主题内成员排序**与后台列表。若产品要求共创 Tab 按运营 `sort_order` 展示,需要把游标改成 `(sort_order, theme_id)` 双键并同步前端——**这会在实现前改一次接口契约**。
|
||||
|
||||
## 已知留白
|
||||
|
||||
主题封面图、slug / URL 别名、埋点与统计、主题内「跳到某一代节点」高亮、批量树端点、主题级联归档时的成员清理、后台管理 UI。理由与将来接法见技术方案 §3.10.9 与本文件「不在范围内」。
|
||||
@@ -548,7 +548,7 @@ pub struct GameDistributionThemeMember {
|
||||
| `theme_id` 由服务端生成、形如 `theme-*` | 与 `game-*` 同族命名 | 主题是平台实体,ID 不应由调用方决定(避免运营侧的碰撞与「抢 ID」);命名同族便于日志与排障一眼区分实体 | 运营无法用自选 ID 做外部引用;本轮不做 slug(§3.10.9) |
|
||||
| `member_id` 用**确定性**主键 `"{theme_id}:{root_game_id}"` | 与 `collection_id` 同款 | 去重由**主键结构**保证,而不是靠「事务里先查后写」:同一主题内同一根不可能出现第二行,因此**不需要**再建唯一索引 | 跨主题的同一根会有**多行**(每主题一行)——这是预期的,不是重复数据(§3.10.3) |
|
||||
| `theme_id` / `root_game_id` 各建**具名** btree 索引 | 两条具名索引 | 两条真实查询路径都需要它:① 主题页按主题取成员;② 作品详情按根反查所属主题(§3.10.7)。具名(`by_game_distribution_*`)与仓库既有命名一致,生成绑定与 schema 门禁都按具名索引核对 | 写入多两条索引维护成本;主题量级小,可忽略 |
|
||||
| `status ∈ draft|published|archived` | 三态 | 运营需要「未发布」「已发布」「已归档下架」三种处置;归档不是删除(成员行保留,见 §3.10.5),语义比 `deleted_at` 更弱也更可逆 | 每次新增一态都要在三处(可见性纯函数、后台列表过滤白名单、DTO)同步,靠测试与 DTO parity 钉住 |
|
||||
| `status` 三态:`draft` / `published` / `archived` | 三态白名单 | 运营需要「未发布」「已发布」「已归档下架」三种处置;归档不是删除(成员行保留,见 §3.10.5),语义比 `deleted_at` 更弱也更可逆 | 每次新增一态都要在三处(可见性纯函数、后台列表过滤白名单、DTO)同步,靠测试与 DTO parity 钉住 |
|
||||
| `created_by_user_id` = 运营账号 | 审计字段 | 后台写接口是运营动作,出问题时必须能答「谁建的 / 谁改的」;与仓库现有 admin 操作留痕同口径 | 仅记录创建者,不记录后续每次改动者(本轮不建单独的主题修改日志) |
|
||||
|
||||
**本轮实现**:两表、两条具名索引、`migration.rs` 的 `migration_tables!` 白名单登记、生成绑定与 schema 门禁。
|
||||
|
||||
Reference in New Issue
Block a user