收口 M3 复核遗留:文档如实口径、fork-source 状态说明、forkAuthorization 机器守卫、e2e 引用去行号

- 技术方案 §3.4:删掉「GET /games/{gameId} 追加 forkSourceAvailable」——实现里不存在该字段;改为如实口径:改造入口显隐只依据 forkAuthorization,真实可复刻形态由 /fork-source 的 source 回答,若 M2b 要区分「可源码级改造 / 只能参考」届时再加
- 实施计划(M1)§5:同步去掉 forkSourceAvailable 并注明「未实现、也不需要」,保留其余字段清单不动
- 技术方案 §2.6 时序图:说明字句从「GET …/fork-source 是待实现接口」改为「M2a 已实现(形状见 §3.4 登录用户表)」,并在图内加一行 Note 标注「图中字段名是 M2b 目标形状」;图属目标形态的免责声明保留
- parity 脚本:game_payload 增加 mustEmit ['forkAuthorization']——该字段 TS 侧可选(旧响应可省),但公开与作者响应必须发出,否则详情页授权徽章与改造入口会静默退化成「禁止共创」;public_game_payload 以 game_payload 为 base,因此一并受保护
- e2e 脚本头部:把全部行号引用(含已过期 2–4 行的那些)改为「文件 + 符号」引用(router()/lineage_read_or_not_found()/GameDistributionLineageNode/lineage.rs 常量与函数等),并删除「消费链路在未提交的 M2a WIP 里」这类会过期的措辞,改为「已在 M2a 落地」;只改注释,断言逻辑未动
This commit is contained in:
2026-10-05 04:40:07 +08:00
parent 6759e56454
commit f40050afb1
5 changed files with 50 additions and 40 deletions
@@ -52,7 +52,8 @@ fn sha256_hex(bytes: &[u8]) -> String {
/// 参考副本相对项目的路径:`reference/<gameId>/`。
///
/// 作品 ID 来自平台响应,只接受路径安全取值;拼接复用归档条目门禁(拒绝 `..`、盘符与
/// 绝对路径),保证参考副本永远落在项目内的子目录,不会逃出项目根。
/// 绝对路径),保证参考副本永远落在项目内的子目录,不会逃出项目根。这是参考目录的**唯一**
/// 推导处:调用方把它 join 到项目根,不再另起第二份拼接规则。
pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result<PathBuf, String> {
let game_id = game_id.trim();
// 空标识必须显式拒绝:`safe_archive_relative_path` 会把空段丢掉,`reference/` 会被
@@ -71,14 +72,6 @@ pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result<PathBuf, Str
Ok(relative)
}
/// 参考副本目录:`<project>/reference/<gameId>/`。
pub(crate) fn fork_reference_directory(
project_root: &Path,
game_id: &str,
) -> Result<PathBuf, String> {
Ok(project_root.join(fork_reference_relative_path(game_id)?))
}
/// 用平台作品的成品包建一个新项目。
///
/// 先按标准初始化生成合规脚手架,再把参考副本铺进 `<project>/reference/<gameId>/`,
@@ -215,16 +208,20 @@ mod tests {
}
#[test]
fn fork_reference_directory_stays_inside_the_project() {
let root = Path::new("C:/work/project");
fn fork_reference_relative_path_stays_inside_the_project() {
// 生产路径就是「把这唯一的相对路径 join 到项目根」,因此这里直接钉相对路径本身。
assert_eq!(
fork_reference_directory(root, "game_1").expect("safe id"),
root.join("reference").join("game_1")
fork_reference_relative_path("game_1").expect("safe id"),
Path::new("reference").join("game_1")
);
assert_eq!(
Path::new("C:/work/project").join(fork_reference_relative_path("game_1").unwrap()),
Path::new("C:/work/project/reference/game_1")
);
// 路径不安全的标识一律失败关闭,不拼出逃出项目根的路径。
for unsafe_id in ["", "..", "../escape", "a/b", "a\\b", "C:game", " "] {
assert!(
fork_reference_directory(root, unsafe_id).is_err(),
fork_reference_relative_path(unsafe_id).is_err(),
"should reject {unsafe_id:?}"
);
}
@@ -46,7 +46,7 @@
- `GameDistributionCreateGameRequest` 追加 `fork: Option<GameDistributionForkDeclaration>`(`parentGameId` / `parentVersionId`)。
- 新增 `GameDistributionSetForkAuthorizationRequest { expectedForkAuthorization, forkAuthorization }`。
- 游戏响应加 `forkAuthorization`,公开响应加 `forkCount` / `lineage` / `forkSourceAvailable`。
- 游戏响应加 `forkAuthorization`,公开响应加 `forkCount` / `lineage`。(原计划的 `forkSourceAvailable` 未实现,也不需要:改造入口的显隐依据 `forkAuthorization`,可复刻形态由 `/fork-source` 回答;若 M2b 要在详情页区分「可源码级改造 / 只能参考」,届时再加。)
### 6. `server-rs/crates/api-server/src/modules/game_distribution.rs`
@@ -177,6 +177,7 @@ sequenceDiagram
U->>AGC: 唤起客户端(方式待拍板:手工输入 / 取件码 / deep link)
AGC->>API: GET /api/game-distribution/games/X/fork-source (Bearer)
API-->>AGC: {versionId, bundleSha256, bundleBytes, downloadPath}
Note over API: 图中的字段名是 M2b 目标形状;M2a 已实现的形状见 §3.4「登录用户」表
AGC->>AGC: 下载 → 校验 SHA-256 → 解压(同模板安装门禁)→ 新建项目
AGC->>AGC: manifest 写入 forkedFrom{gameId, versionId, rootGameId, generation}
Note over AGC: 工程源包可改源码/重跑构建/发布;只有成品包则只能试玩与提取素材(§3.5.1)
@@ -184,7 +185,7 @@ sequenceDiagram
API->>API: 校验授权/来源版本/计算 generation 与 root → 写 lineage
```
> 说明:本图是**目标形态**。唤起方式尚未拍板(§7 第 11 条),`GET …/fork-source` 是**待实现**接口(§3.5.1),`fork-source` 的响应字段最终以定稿合同为准。
> 说明:本图是**目标形态**。唤起方式尚未拍板(§7 第 11 条);`GET …/fork-source` **M2a 已实现**(受鉴权元数据 + 取件本体,实际字段与状态码见 §3.4「登录用户」表),但本图里的响应字段名是 M2b 的目标形状,定稿一律以 §3.4 表格为准。
**C. 溯源与命名**
@@ -302,7 +303,7 @@ pub(crate) project_bundle_sha256: Option<String>,
| 方法 / 路径 | 说明 |
| --- | --- |
| `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)、`forkSourceAvailable`(布尔,仅表达"能否真复刻",不泄露对象键) |
| `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)。**不追加 `forkSourceAvailable`**:网页端「改造这个作品」入口的显隐只依据 `forkAuthorization`(已公开 + 非禁止即可引导去取件),真实可复刻形态由 `/games/{gameId}/fork-source` 的 `source` 字段回答,不需要在公开详情里提前判断;若 M2b 需要在详情页区分「可源码级改造 / 只能参考」,再在 M2b 里加该字段 |
| `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status }`,按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点 |
| `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 |
@@ -105,6 +105,11 @@ const RESPONSE_BUILDERS = [
{
fn: 'game_payload',
ts: 'GameDistributionGame',
// `forkAuthorization` 在 TS 里是可选的(旧响应可省略),但**公开与作者响应都必须发出**它:
// 详情页的授权徽章与「改造这个作品」入口都读它,缺了会静默退化成「禁止共创」。
// mustEmit 是唯一能表达「可选字段在这条路径上必出」的机制,所以钉在这里;
// public_game_payload / owner_game_entry_payload 都以它为 base,因此一并受保护。
mustEmit: ['forkAuthorization'],
nested: [
{ key: 'author', ts: 'GameDistributionAuthor' },
{ key: 'deviceSupport', ts: 'GameDistributionDeviceSupport' },
+30 -23
View File
@@ -10,30 +10,37 @@
// E2E_CHROMIUM_EXECUTABLE 指定 Chromium 可执行文件(默认用 Playwright 自带浏览器)
// E2E_LINEAGE_LABEL_* 覆盖前端文案(默认对齐实现里的字符串)
//
// 参考的仓库既有实现与脚本(本脚本按同一风格写成,契约形状全部来自代码而不是猜测):
// [1] 路由挂载(公开、匿名、no-store):server-rs/crates/api-server/src/modules/game_distribution.rs:419-426(public_games)
// [2] 「不可读即 404」映射:api-server/src/modules/game_distribution.rs:1090-1092(lineage_read_or_not_found)
// [3] 两个 handler:api-server/src/modules/game_distribution.rs:1096(get_game_lineage)/ :1114(get_game_derived_games)
// [4] 节点 DTO 的 7 个键:server-rs/crates/shared-contracts/src/game_distribution.rs:262-281
// 响应::283-293(LineageResponse)/ :295-301(DerivedResponse)
// [5] 上限 200 / 锚点可读性 / 稳定排序:server-rs/crates/module-game-distribution/src/lineage.rs:22 / :28-30 / :135-143
// [6] 作者软删除:api-server/src/modules/game_distribution.rs:1518-1550;事务语义(撤销当前公开版本 + deleted_at)
// server-rs/crates/spacetime-module/src/game_distribution.rs:3599-3660
// [7] 改编声明 DTO:shared-contracts/src/game_distribution.rs:241-245 + :367-368;
// 消费点 api-server/...:1611-1615;事务校验 spacetime-module/...:2155-2160 + :2250-2254
// [8] 发布链路(建版本 → 传包 → 送审 → 管理员通过)照抄 scripts/check-game-distribution-media-e2e.mjs:391-400,431-450,455-470,479-495,523-527,561-573
// [9] 封面直传 + 确认:scripts/check-game-distribution-owner-isolation.mjs:97-150
// [10] 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-ratings-e2e.mjs:15-27
// [11] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs:51-57,74-90,148-159
// [12] 前端契约:详情页入口 GameDetailPage.tsx:409「查看创作族谱」;族谱页 GameLineagePage.tsx:65-70(节点卡片类名)、
// :93「原作品已不可用」、:163-164 标题;路由 /games/lineage:src/routing/activeAppPageRoutes.ts:18
// [13] 节点作者名读时联账号表:spacetime-module/src/game_distribution.rs:4294-4308(所以脚本会先给作者改名,让「删除后不外发作者名」这条断言有意义)
// 参考的仓库既有实现(按**符号**引用,不写行号,避免随改动腐化):
// [1] 路由挂载(公开、匿名、no-store):server-rs/crates/api-server/src/modules/game_distribution.rs 的
// `router()` 里 `public_games` 子路由
// [2] 「不可读即 404」映射:api-server/src/modules/game_distribution.rs 的 `lineage_read_or_not_found()`
// [3] 两个 handler:api-server/src/modules/game_distribution.rs 的 `get_game_lineage()` / `get_game_derived_games()`
// [4] 节点 DTO 的 7 个键:server-rs/crates/shared-contracts/src/game_distribution.rs 的 `GameDistributionLineageNode`;
// 两个响应:同文件的 `GameDistributionLineageResponse` / `GameDistributionDerivedResponse`
// [5] 上限 200 / 锚点可读性 / 稳定排序:server-rs/crates/module-game-distribution/src/lineage.rs 的
// `GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT` / `lineage_anchor_readable()` / `stable_node_order()`
// [6] 作者软删除:api-server/src/modules/game_distribution.rs 的 `delete_owner_game()`;
// 事务语义(撤销当前公开版本 + 写 deleted_at):
// server-rs/crates/spacetime-module/src/game_distribution.rs 的 `delete_game_distribution_game_tx()`
// [7] 改编声明 DTO:shared-contracts/src/game_distribution.rs 的 `GameDistributionForkDeclaration`;
// 消费点 api-server/src/modules/game_distribution.rs 的 `create_game()`;
// 事务校验 spacetime-module/src/game_distribution.rs 的 `resolve_game_distribution_fork_declaration_tx()`
// [8] 发布链路(建版本 → 传包 → 送审 → 管理员通过)照抄 scripts/check-game-distribution-media-e2e.mjs 的 `main()`
// [9] 封面直传 + 确认:scripts/check-game-distribution-owner-isolation.mjs 的 `uploadCover()` / `main()`
// [10] 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-ratings-e2e.mjs 模块顶部的 dev-stack 读取
// [11] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs 的
// `loadPlaywright()` / `loginThroughWebUi()`
// [12] 前端契约:详情页入口 src/components/game-distribution/GameDetailPage.tsx 的 `GameDetailContent`
// (共创信息卡里的「查看创作族谱」按钮);族谱页 src/components/game-distribution/GameLineagePage.tsx 的
// `LineageNodeCard`(节点卡片类名、「原作品已不可用」、标题按钮);路由 /games/lineage:
// src/routing/activeAppPageRoutes.ts 的 `STAGE_ROUTE_ENTRIES`
// [13] 节点作者名读时联账号表:spacetime-module/src/game_distribution.rs 的 `game_distribution_lineage_author_name()`
// (所以脚本会先给作者改名,让「删除后不外发作者名」这条断言有意义)
//
// 依赖提示(重要):
// 改编声明 `fork: { parentGameId, parentVersionId }` 的消费链路当前在**未提交的 M2a WIP** 里
// (工作树 api-server/...:1611-1615 已接,但提交基线 4207d24f0 尚未包含)。在 M2a 落地前:
// - A 段(404 规则)与 E 段的「未公开锚点 404」可以照跑;
// - B/C 段(真实树、软删除降级)会因为创建 B 时 fork 声明不生效而失败 —— 那是依赖未就绪,不是脚本问题。
// 依赖提示:改编声明 `fork: { parentGameId, parentVersionId }` 的消费链路**已在 M2a 落地**
// (shared-contracts 的 DTO → api-server `create_game()` → spacetime-module
// `resolve_game_distribution_fork_declaration_tx()`),本脚本按已落地的形状断言;如果 B/C 段
// 因创建 B 时声明不生效而失败,那说明对应实现被回退了,不是脚本问题。
//
// 契约假设(读代码确认,不猜):
// - anchor 必须公开可读,否则 /lineage 与 /derived 都返回 **404**(不是 200 空树、也不是 409)。