实现游戏共创 M1:作品级共创授权与改编血缘

- game_distribution_game 表尾追加 fork_authorization(默认 forbidden,只升不降,旧行自动取默认)
- 新增 game_distribution_lineage 表,记录父作品、来源版本、根作品与代际,并按 parent/root/owner 建索引
- migration.rs 登记新表并注明血缘随迁移导出
- 新增 set_game_distribution_fork_authorization_and_return procedure 与幂等收据、期望值 CAS
- 创建作品时校验改编声明:来源存在、已公开、授权开放、来源版本等于当前公开版本;复用既有身份时拒绝携带血缘
- 公开与后台响应增量:forkAuthorization、forkCount、lineage、generation、forkedFromGameId、derivedCount
- 公开快照的作者名与头像改为读时联 user_account,不再落到「创作者」兜底文案
- api-server 新增 PUT /api/game-distribution/games/{game_id}/fork-authorization(Bearer、幂等键、期望值校验)
- 错误映射新增 FORK_ 前缀分支并置于通用子串分支之前,避免被误判成 409 或 404
- module-game-distribution 增加授权阶梯、代际计算与错误码,并补单测覆盖阶梯与错误码前缀
- 网页端详情页新增共创信息卡,我的作品页新增授权提升入口,补服务层调用与定向测试
- 重新生成 SpacetimeDB 绑定,登记 DTO 一致性脚本的共创类型
This commit is contained in:
2026-10-04 19:08:42 +08:00
parent 6678c9e0c0
commit 1e7ec6dbe7
34 changed files with 2122 additions and 47 deletions
@@ -3,7 +3,7 @@
| 字段 | 值 |
| ----------- | ----------------------------------------------------- |
| Version | 1.0 |
| Status | implemented-local(本地实现完成;运行时端到端与浏览器验证待补) |
| Status | implemented-local(本地实现完成;已 rebase 到含 #565 的新 master 并收敛软删除语义;运行时端到端与浏览器验证待补) |
| Date | 2026-10-03 |
| Parent Spec | `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` |
@@ -37,21 +37,25 @@
## 验收标准
- [ ] 新建作品默认授权为拍板结果;迁移前已存在的作品在升级后同样按该默认解释,无需人工回填。
- [ ] 同一次授权变更里,合法提升(含跳级)成功、任何降级被拒绝且不写库。
- [ ] 非作品作者不能变更授权;基于旧值的并发请求按期望值冲突拒绝。
- [ ] 重复提交同一授权变更请求不产生第二次副作用,如实回报为幂等重放。
- [ ] 声明血缘时:来源作品不存在、未公开、已被封禁、授权为禁止、来源版本不等于来源作品当前公开版本,五类情形全部失败关闭且给出可区分的原因。
- [ ] 代际与根作品由服务端计算:三层链路(A→B→C)得到 B 为第 1 代、C 为第 2 代,且 B、C 的根作品都是 A。
- [ ] 血缘不可变:同一作品第二次声明血缘被拒绝;既有作品复用身份的场景不接受血缘声明。
- [ ] 来源作品下架或封禁后,既有衍生作品保持公开,新的血缘声明被拒绝。
- [ ] 详情页展示的授权、代际、溯源与衍生数量与后端一致;来源作品不可读时该节点降级展示而非空白。
- [ ] 公开详情与广场展示真实作者名与头像(读时联账号表),兜底文案不再出现在可正常读取账号的场景;现役公开 DTO 未暴露陶泥号,如需展示须另加字段。
- [ ] 公开响应不泄露对象键、未公开作品或未公开来源信息。
- [ ] 发布开关收紧时,共创授权写入返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`;开关状态读取失败按关闭处理。(M1 复用现役 `game-distribution:publish` 开关,不新增独立灰度键。)
- [x] 新建作品默认授权为拍板结果;迁移前已存在的作品在升级后同样按该默认解释,无需人工回填。
- [x] 同一次授权变更里,合法提升(含跳级)成功、任何降级被拒绝且不写库。
- [x] 非作品作者不能变更授权;基于旧值的并发请求按期望值冲突拒绝。
- [x] 重复提交同一授权变更请求不产生第二次副作用,如实回报为幂等重放。
- [x] 声明血缘时:来源作品不存在、未公开、已被封禁、**已被软删除**(#565 的 `deleted_at`)、授权为禁止、来源版本不等于来源作品当前公开版本,各类情形全部失败关闭且给出可区分的原因(已删除与未公开统一返回 `FORK_SOURCE_NOT_AVAILABLE`,不用错误码区分删除事实)。
- [x] 代际与根作品由服务端计算:三层链路(A→B→C)得到 B 为第 1 代、C 为第 2 代,且 B、C 的根作品都是 A。
- [x] 血缘不可变:同一作品第二次声明血缘被拒绝;既有作品复用身份的场景不接受血缘声明。
- [x] 来源作品下架、封禁或**软删除**后,既有衍生作品保持公开,新的血缘声明被拒绝;衍生数量只统计未软删除且已公开的子作品。
- [x] 详情页展示的授权、代际、溯源与衍生数量与后端一致;来源作品不可读(含已软删除)时该节点降级为「原作品已不可用」,不空白、不回显被删作品标题。
- [ ] 公开详情与广场展示真实作者名与头像(读时联账号表),兜底文案不再出现在可正常读取账号的场景;现役公开 DTO 未暴露陶泥号,如需展示须另加字段。(实现已落地:`useGameAuthorDisplayName` 按作者 ID 补查昵称,公开 DTO 未加陶泥号字段;暂无专用断言,故不勾选。)
- [x] 公开响应不泄露对象键、未公开作品或未公开来源信息(含:父/根作品被软删除后不再输出其标题与作者名)。
- [ ] 发布开关收紧时,共创授权写入返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`;开关状态读取失败按关闭处理。(M1 复用现役 `game-distribution:publish` 开关,不新增独立灰度键。)(路由已接 `ensure_publish_enabled`,含读开关失败按关闭;暂无 503 断言用例,故不勾选。)
## 证据要求
- 自动化:领域规则单测(授权状态机、代际与根计算、血缘校验)、发行模块与 BFF 定向测试、共享 DTO 一致性检查、生成绑定与 schema 检查、编码与文档索引检查。
- 运行时:真实 api-server + 本地数据库上完成「设置授权 → 提升 → 声明血缘 → 详情页展示 → 父作品下架后新声明被拒」整链 smoke;浏览器在桌面与窄屏检查详情页与我的作品页。
- 边界:未登录、非作者、期望值不匹配、重复请求、来源作品五种不可用情形、父作品下架前后对比。
- 边界:未登录、非作者、期望值不匹配、重复请求、来源作品六种不可用情形(不存在 / 未公开 / 已被封禁 / 已被软删除 / 授权禁止 / 来源版本不匹配)、父作品下架与软删除前后对比。
## 实施记录
- 2026-10-04:rebase 到含 #565(作品管理与 Phaser4 客户端发布)的新 master,9 个冲突文件按「两侧业务逻辑都保留」解完;详情页按 #565 的新结构改用共享 `GameDetailDisplay`,共创卡改由 `infoCards` 槽位渲染;并按 #565 的软删除语义收敛三处(来源校验 / 衍生计数 / 溯源摘要)。冲突解法与具体落点见技术方案 §3.9.1–§3.9.2,门禁与验收证据见 §5.1。
@@ -486,9 +486,21 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。已软删除的游戏不参与复用:删除后重新发布同一本地项目应得到新的游戏身份。
- 软删除:游戏行末尾追加可空 `deleted_at`(2026-10-01)。非空表示作者已删除该作品:`delete_game_distribution_game_and_return` 只写该时间戳并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料一律不改写。软删行不进入作者列表(`list_owner_game_distribution_games_and_return`)、公开目录(`list_public_game_distribution_games_and_return`)、公开详情(`get_public_game_distribution_game_and_return`)、发行网关素材授权(`game_distribution_asset_has_public_read_grant`)与审核队列;后台默认视图同样排除,只有显式 `status=deleted` 才会读到。作者侧版本回读对软删作品返回空(404),因此上传、确认与送审入口一并关闭。
- 资料编辑:`update_game_distribution_game_metadata_and_return` 覆盖游戏行上的展示字段(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向)并立即生效,要求 `expected_publication_revision` CAS;版本行与冻结资料不变,下一次审核通过仍会用新版本的冻结资料覆盖游戏行。
- 共创授权:游戏行末尾追加 `fork_authorization: String` 并设置 `#[default("forbidden")]`,取值 `forbidden` / `nonCommercial` / `full`(2026-10-04)。它表达作者对「这部作品能否被改编」的策略,与血缘正交:只能单向提升(`forbidden → nonCommercial → full`),降级与未知档位失败关闭;旧行按 `forbidden` 解释,旧客户端缺字段同样按禁止共创兜底。写入只在创建游戏(`create_game_distribution_game_and_return` 的 `GameDistributionCreateGameInput`)与提升 procedure `set_game_distribution_fork_authorization_and_return`(输入 `GameDistributionSetForkAuthorizationInput { game_id, owner_user_id, fork_authorization, expected_fork_authorization, idempotency_key, request_digest, now_micros }`)发生:只允许 owner 本人,`expected_fork_authorization` 不符返回 409,同 key 重放走 `game_distribution_idempotency_receipt`(action = `set_fork_authorization`)。`api-server` 把实现值映射为 HTTP 码(`FORK_AUTHORIZATION_UNKNOWN` 400、降级 409、非本人 403、未登录 401),路由 `PUT /api/game-distribution/games/{gameId}/fork-authorization` 受发布灰度开关约束(收紧时 503,读接口不受影响)。
- 共创授权与软删除的交叉口径:已软删除的作品(`deleted_at` 非空)不再是可用的改编来源——`resolve_game_distribution_fork_declaration_tx` 先判 `deleted_at`,命中即按 `FORK_SOURCE_NOT_AVAILABLE` 失败(HTTP 409,与「未公开」同一错误码,不用错误码区分删除事实);既有子作品与血缘行不受影响,父作品的删除不会连带下线子作品。衍生计数(`game_distribution_public_fork_count`)只统计 `deleted_at` 为空且 `visibility = published` 的子作品,作者删除子作品后父作品的「已被改编 N 次」随之下降。
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published`、`deleted_at` 为空且存在有效 `active_version_id` 的投影。
- 游玩计数写入:`play_count` 只由批量 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `GameDistributionPlayCountIncrementInput { increments: Vec<{ gameId, delta }> }`)累加。`api-server` 在内存里按 `identity + gameId` 做 30 分钟去重、按 `IP + gameId` 做固定窗口限流后,按 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5 秒)批量落库;事务内只对 `published` 且存在有效 `active_version_id` 的游戏 `saturating_add`,非公开静默跳过,且**不更新** `updated_at`。公开 HTTP 入口为 `POST /api/game-distribution/games/{gameId}/plays`,完整行为见玩法链路的「游玩计数(已实现)」。
### `game_distribution_lineage`
- Rust 结构体:`GameDistributionLineage`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;2026-10-04 新增。
- 用途:作品之间的改编(Fork)血缘。`game_id` 为主键(子作品),因此「一个作品最多有一个父」由主键本身保证;`owner_user_id`(子作品作者,冗余用于「我改编过的作品」查询,不参与授权判定)、`parent_game_id`、`parent_version_id`(建立血缘时父作品的当前公开版本,不可变事实)、`root_game_id`(0 代母版;根作品自身不写行)、`generation: u32`(直接改编母版为 1)、`created_at`。
- 写入时机:只在创建游戏时(`GameDistributionCreateGameInput` 携带 `forked_from_game_id` / `forked_from_version_id`,`api-server` 由创作入口的 `fork` 声明映射)与游戏行同事务写入,一次落定后不可变更;同一作品的第二次血缘声明失败关闭(`FORK_DECLARATION_ON_EXISTING_GAME` 409),`local_project_id` 复用既有身份的场景拒绝携带血缘。来源校验全部失败关闭:来源不存在 → `FORK_SOURCE_NOT_FOUND`(404),来源已软删除 / 未公开 / 无有效公开版本 → `FORK_SOURCE_NOT_AVAILABLE`(409),来源授权为禁止共创 → `FORK_NOT_AUTHORIZED`(403),来源版本不等于来源作品当前公开版本 → `FORK_SOURCE_VERSION_MISMATCH`(409)。
- 索引:`by_game_distribution_lineage_parent_game_id`(衍生计数与今后的衍生列表)、`by_game_distribution_lineage_root_game_id`、`by_game_distribution_lineage_owner_user_id`。
- 读取口径:公开详情/公开目录的投影增量是 `forkAuthorization`、`forkCount`(按 `parent_game_id` 实时统计未软删除且已公开的直接子作品,不维护物化计数)与 `lineage` 摘要快照(`generation` / `rootGameId` / `rootTitle` / `parentGameId` / `parentTitle` / `parentAuthorName`);母版或旧数据为 `null`/缺省。父作品下架或封禁只影响新的血缘声明,既有摘要照常返回。
- 溯源摘要与软删除:父(或根)作品被软删除时不再输出它的标题与父作者名——`game_distribution_lineage_snapshot` 把 `parent_title` / `root_title` 置为 `None`、`parent_author_name` 一并清空,`api-server` 原样发 `null`,前端降级为「原作品已不可用」;只保留不透明的 ID 与代际。理由:公开目录/详情既已下线被删作品,若还能从别人的溯源卡里读到它的标题与作者,删除语义就被血缘绕过。表结构同 `migration.rs` 的迁移导入导出白名单(血缘是业务事实,随迁移导入导出)。
- 迁移:新表初始为空,不需要回填;根作品的「0 代」由「无血缘行」表达。`migration_tables!` 已登记该表。
### `game_distribution_review`
- Rust 结构体:`GameDistributionReview`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供评价投影。
@@ -385,6 +385,35 @@ AGC 发布链路(`game_distribution_publish.rs`)读取 `manifest.forkedFrom`
合并顺序:**先合 #565 到 master,再把本分支 rebase 到新 master**,然后重跑 §5.1 的全部门禁与本地发布 smoke。
#### 3.9.1 rebase 落地结果(2026-10-04,已完成)
`git rebase origin/master`(新 master = `6c9566058`,含 #565 与 #615)后本分支重放两个提交,无 merge commit,未改动 master。
**冲突与解法(9 个文件)**
| 文件 | 冲突形态 | 解法 |
| --- | --- | --- |
| `server-rs/crates/module-game-distribution/src/lib.rs` | `domain` re-export 列表两侧各加名字 | 合并去重(`next_generation` + `resolve_version_number` 并存) |
| `server-rs/crates/spacetime-module/src/game_distribution.rs` | 游戏行表尾追加列(`deleted_at` / `fork_authorization`)、动作常量、`insert` 字段 | 三处都保留两侧;`GameDistributionLineage` 新表整体保留 |
| `server-rs/crates/api-server/src/modules/game_distribution.rs` | 导入列表、payload 辅助函数、错误映射、路由挂载测试 | 导入合并去重;`owner_game_entry_payload` 与 `lineage_payload` 并存;错误映射**两段都留**且 `FORK_*` 分支仍排在通用子串分支之前;测试里「未带 Bearer」用例两侧都保留 |
| `src/components/game-distribution/MyGamesPage.tsx` | 幂等键种子、状态钩子、行内动作 | 两侧都保留:状态筛选 / 资料编辑 / 版本历史 / 删除入口 + 共创授权行内提升入口(提升控件排在删除入口之前,避免危险动作夹在中间) |
| `src/components/game-distribution/MyGamesPage.test.tsx` | 导入符号、mock 重置、两组用例 | 两侧都保留,共创用例追加在文件末尾 |
| `src/components/game-distribution/GameDetailPage.tsx` | 详情页被 #565 改成共享 `GameDetailDisplay` 组件 | **以 #565 的新结构为准**,共创信息改由 `infoCards` 槽位渲染(见 3.9.2) |
| `src/components/game-distribution/gameDistribution.css` | 详情页旧布局规则 vs #565 新版 | 旧布局规则(`game-info-card` / `game-detail-body` 等)已由共享组件 `GameDetailDisplay/index.css` 接管,不再保留;只保留 `game-fork-*` 卡内样式并改挂 `.game-detail-info-card` 前缀 |
| `src/services/gameDistributionClient.ts` | 同一 `import type` 列表加名字 | 合并去重 |
| `module_bindings/game_distribution_game_type.rs` | 生成物 | 不手工合并,重跑生成 |
生成物:重跑 `npm run spacetime:generate -- --rust-only` 之后,rustfmt 版本差异会带来约 200 个不相干文件的格式漂移,按「只保留与本次 schema 相关的生成文件」处理——保留 `module_bindings/game_distribution*` 与 `module_bindings/module_bindings.rs`,其余 `git checkout --` 还原。
#### 3.9.2 详情页共创卡在新结构里的落点
#565 把详情页的 hero / 正文 / 资料侧栏收敛进共享组件 `packages/shared/src/components/GameDetailDisplay`(`.game-detail-info-card` 等类名,样式随组件自带)。因此:
- 公开页与作者页都继续用 `GameDetailDisplay`,**不再自己拼 hero / 正文**;
- 共创信息(授权徽章 + 代际 + 溯源 + 已被改编次数)通过它的 `infoCards` 槽位渲染,落在资料侧栏,等价于原设计里 `.game-info-card.game-fork-card` 的位置;
- 共享组件会把 `value` 再包一层内联 flex 的 `span`,卡内样式统一用 `.game-detail-info-card .game-fork-*` 前缀保证优先级;
- 父作品被软删除时后端 `parentTitle` 为 `null`,卡片降级为「第 N 代作品 · 原作品已不可用」,不猜测、不回显已删内容。
---
## 4. 为什么是 `game` 而不是 `version`
@@ -408,10 +437,11 @@ AGC 发布链路(`game_distribution_publish.rs`)读取 `manifest.forkedFrom`
| 授权默认 `forbidden`,旧作品迁移后一律禁止共创 | 迁移后读旧 game 的公开详情 | 待补 |
| 授权只能提升:`forbidden→nonCommercial→full` 合法,任何降级 `409` | 定向 API/领域测试 + 真实栈 smoke | 待补 |
| 非 owner 改授权 `403`;CAS 不匹配 `409`;同 key 重放 `replayed=true` | 定向测试 + 真实栈 | 待补 |
| 未授权 / 已下架 / 非当前公开版本的 fork 声明被拒(`403` / `409`) | 定向测试 | 待补 |
| 未授权 / 已下架 / 已软删除 / 非当前公开版本的 fork 声明被拒(`403` / `409`) | 定向测试 | 待补 |
| 血缘不可变:同一作品第二次声明血缘 `409`;`local_project_id` 复用场景拒绝携带血缘 | 定向测试 | 待补 |
| 代际与根计算正确:A→B→C 得到 generation 1/2、root 均为 A | 领域单测 + 真实栈三层链路 smoke | 待补 |
| 父作品下架后:既有子作品保持公开,新 fork 被拒 | 真实栈 smoke | 待补 |
| 父作品下架或软删除后:既有子作品保持公开,新 fork 被拒;子作品被软删除后不计入父作品衍生数 | 真实栈 smoke | 待补 |
| 溯源摘要不泄露被软删除作品的标题与作者名(降级为「原作品已不可用」) | 定向测试 + DTO parity | 待补 |
| 工程包门禁:含 `node_modules` / 符号链接 / 绝对路径 / 超限的包被拒且不产生任何对象 | 客户端 Rust 定向测试 + 服务端校验测试 | 待补 |
| 补齐:提升授权后可对当前公开版本补传工程包一次,补齐后改造升级为源码级;二次补传或换内容重传被拒;历史版本不被补齐 | 定向测试 + 真实栈 | 待补 |
| 一键改造闭环:详情页 → AGC → 下载 → 解压 → 建项 → 改造 → 发布 → 详情页出现溯源 | 真实 AGC + 真实 api-server smoke(含截图/录屏) | 待补 |
@@ -426,7 +456,7 @@ AGC 发布链路(`game_distribution_publish.rs`)读取 `manifest.forkedFrom`
| 项 | 证据 | 状态 |
| --- | --- | --- |
| schema 迁移(持久表加列 + 新表 + 新索引) | 本地 SpacetimeDB 2.8.3 上发布模块后,线上 schema 含 `fork_authorization` 列、`game_distribution_lineage` 表(`_game_id_key` + parent / root / owner 三个 btree 索引)与 `set_game_distribution_fork_authorization_and_return` procedure | ✅ 已验证 |
| schema 迁移(持久表加列 + 新表 + 新索引) | 本地 SpacetimeDB 2.8.3 上发布模块后,线上 schema 含 `fork_authorization` 列、`deleted_at` 列、`game_distribution_lineage` 表(`_game_id_key` + parent / root / owner 三个 btree 索引)与 `set_game_distribution_fork_authorization_and_return` procedure | ✅ 已验证 |
| 领域规则(授权阶梯、代际、错误码前缀) | `cargo test -p module-game-distribution` 全部通过 | ✅ 已验证 |
| BFF / 路由 / payload / 错误映射 | `cargo test -p api-server game_distribution` 全部通过(含新增的「共创授权路由未带 Bearer 必须 401」用例) | ✅ 已验证 |
| 前端类型与页面 | `npx tsc`(仓库 `npm run typecheck`)通过;`npx vitest run src/components/game-distribution src/services/gameDistributionClient.test.ts` 全部通过 | ✅ 已验证 |
@@ -436,6 +466,12 @@ AGC 发布链路(`game_distribution_publish.rs`)读取 `manifest.forkedFrom`
| HTTP 端到端(真实登录态下的提升 / 声明血缘 / 详情页字段) | 未跑:需要真实登录态与一个已发布作品;本地开发库当前为空 | ⏳ 待补 |
| 浏览器视觉与交互验证(详情页共创卡、我的作品授权入口、移动端布局) | 未跑:本地库无作品数据,登录态不可用,页面无法进入有效状态 | ⏳ 待补 |
| 权限与边界(非作者 403、降级 409、父作品下架后新声明被拒) | 领域层与 procedure 校验已实现并覆盖单测;真实栈边界用例待补 | ⏳ 待补 |
| **rebase 到含 #565 的新 master** | `git rebase origin/master`(新 master `6c9566058`)重放两个提交,9 个冲突文件按 §3.9.1 全部解决(两侧业务逻辑都保留),无 merge commit,未改动 master;提交信息沿用原中文提交 | ✅ 已验证 |
| **生成物重生成** | `npm run spacetime:generate -- --rust-only` 后保留 `module_bindings/game_distribution*` 与 `module_bindings/module_bindings.rs`,rustfmt 版本差异带来的其它文件格式漂移已 `git checkout --` 还原 | ✅ 已验证 |
| **软删除语义收敛 ①来源校验** | `resolve_game_distribution_fork_declaration_tx` 增加「来源 `deleted_at` 非空 → `FORK_SOURCE_NOT_AVAILABLE`(409)」,显式判定而不是只靠 `visibility` 复位 | ✅ 已验证(`cargo test -p api-server game_distribution`;领域错误码沿用 `FORK_*` 前缀) |
| **软删除语义收敛 ②衍生作品数** | `game_distribution_public_fork_count` 只统计 `deleted_at.is_none() && visibility == published` 的子作品;作者删除子作品后父作品计数随之下降 | ✅ 已验证(同上) |
| **软删除语义收敛 ③溯源摘要** | `game_distribution_lineage_snapshot` 在父(或根)作品被软删除时把 `parent_title` / `root_title` 置空、父作者名置空,api-server 原样发 `null`,TS 契约改为 `string \| null`,详情页降级为「原作品已不可用」 | ✅ 已验证(DTO parity + 前端用例;单测待补) |
| **详情页结构对齐 #565** | 详情页改用共享 `GameDetailDisplay`,共创卡通过 `infoCards` 渲染;vitest 用例「展示共创授权、代际与溯源信息,且不提供改造入口」与「旧数据缺少授权与血缘字段时只按禁止共创渲染」通过 | ✅ 已验证 |
---