diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index e3142fea0..114b35c2d 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -163,4 +163,4 @@ - 第 1 条(owner 不能由请求伪造;其他账号不能读取私有版本、上传、提交或撤销):领域侧有 `owner_is_required_for_version_and_package_mutations`;接口侧代码在越权时返回 403/404,但**没有跨账号读/写/撤销的接口级用例**——缺的就是这一层。 - 第 4 条(一份版本只接受一份已确认内容;同 key 同请求幂等、不同请求冲突;同版本并发上传不混写):前两半已覆盖——`idempotency_replays_same_snapshot_and_rejects_digest_conflict`、`validation_failure_can_retry_same_confirmed_package`、api-server 的 `idempotency_key_requires_a_bounded_non_empty_header`;**本轮新增** `a_version_accepts_only_one_confirmed_package`(不同摘要或字节数的确认被拒为 `PackageMismatch`,重复确认同一份内容返回同一包身份;变异去掉该守卫后只有它变红)。**仍缺**「同版本并发上传不混写」:串行化在 `spacetime-module` / api-server 的 CAS 那一层,领域服务本身是同步的,需要在那一层写用例。 - 第 5 条(校验可异步恢复;响应丢失、服务进程退出与客户端重试回到原版本;确定失败与未知结果可区分):只有 api-server 的 `recovery_action_covers_every_version_status` 覆盖"状态 → 恢复动作"的映射;后两句在 game-distribution 链路没有用例。 - - 第 6 条(状态/私有查询/错误 envelope 的 Rust 与 TS DTO 一致;新增 schema、迁移、表目录与绑定一致):后半句有门禁(`npm run lint` 内的 SpacetimeDB schema guard 覆盖 85 张表、生成绑定校验通过);DTO 侧有 `public_payload_exposes_cover_and_screenshot_object_keys`、`version_detail_payload_exposes_frozen_metadata_to_owner`、`package_validation_errors_are_unprocessable`;缺口是 `packages/shared/src/contracts/gameDistribution.ts` 为手写 DTO,**没有任何 Rust↔TS 一致性检查**。 + - 第 6 条(状态/私有查询/错误 envelope 的 Rust 与 TS DTO 一致;新增 schema、迁移、表目录与绑定一致):后半句有门禁(`npm run lint` 内的 SpacetimeDB schema guard 覆盖 85 张表、生成绑定校验通过)。**本轮新增** `check:game-distribution-dto-parity`(已接进 `npm run lint`):按显式映射表逐字段/逐变体比对 14 组 Rust `shared-contracts` DTO 与手写 `packages/shared/src/contracts/gameDistribution.ts`,两个方向都做过变异验证——TS 侧把 `name` 改成 `displayName`、Rust 侧给 `GameDistributionAuthor` 加 `extra_field`,各自都让门禁失败并指出缺哪个字段;脚本同时登记了 7 个「服务端逐字段手拼 JSON、没有 Rust 结构体」的 TS 类型。**缺口**:`GameDistributionGame` / `GameDistributionPrivateVersion` 这两个响应仍有 4 个附加字段(`coverObjectKey` / `screenshots` / `publicationRevision`)不在 Rust 结构体里——`public_game_payload` / `game_payload` 是 `serde_json::json!` 手拼的;要彻底满足这一句,需要先把响应改成结构化类型,届时把脚本里的 `TS_ONLY_FIELDS` 清空即可。 diff --git a/package.json b/package.json index 4885bc135..fc3be4ecb 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml -- --check", "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", "check:generated-bindings": "node scripts/check-generated-bindings.mjs", + "check:game-distribution-dto-parity": "node scripts/check-game-distribution-dto-parity.mjs", "check:game-distribution-media-e2e": "node scripts/check-game-distribution-media-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", @@ -120,7 +121,7 @@ "check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && npm run check:module-runtime-artifact && node scripts/check-server-rs-ddd-boundaries.mjs", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", - "lint": "npm run check:encoding && npm run check:doc-index && npm run check:npm-workspaces && npm run check:git-hooks && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:generated-bindings && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck", + "lint": "npm run check:encoding && npm run check:doc-index && npm run check:npm-workspaces && npm run check:git-hooks && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:generated-bindings && npm run check:game-distribution-dto-parity && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", "format:rust": "cargo fmt --all --manifest-path server-rs/Cargo.toml && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "format": "prettier --write . && npm run format:rust", diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs new file mode 100644 index 000000000..a3d578f22 --- /dev/null +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +// 检查 game-distribution 的 Rust DTO 与手写 TS DTO 是否一致。 +// +// 为什么需要它:`packages/shared/src/contracts/gameDistribution.ts` 是手写的,没有生成绑定兜底。 +// Rust 侧加字段而 TS 侧忘改时,只有跑起来才会发现。这里用一张显式映射表逐字段/逐变体比对: +// - 映射表同时是「哪些 Rust DTO 必须在 TS 里有对应类型」的清单; +// - `TS_ONLY_TYPES` 是「服务端手拼 JSON、没有 Rust 结构体」的说明清单; +// - `TS_ONLY_FIELDS` 记录已知的、有原因的响应侧附加字段。 +// 任何一处没有分类的新类型、新字段都会让检查失败,避免静默漂移。 + +import fs from 'node:fs'; + +const RUST_FILE = 'server-rs/crates/shared-contracts/src/game_distribution.rs'; +const TS_FILE = 'packages/shared/src/contracts/gameDistribution.ts'; + +// [Rust 类型名, TS 类型名] +const PAIRS = [ + [ + 'GameDistributionPublishMetadataSuggestionRequest', + 'GameDistributionPublishMetadataSuggestionRequest', + ], + [ + 'GameDistributionPublishMetadataSuggestion', + 'GameDistributionPublishMetadataSuggestion', + ], + ['GameDistributionVersionStatus', 'GameDistributionVersionStatus'], + ['GameDistributionVisibility', 'GameDistributionGameVisibility'], + ['GameDistributionInputMode', 'GameDistributionInputMode'], + ['GameDistributionOrientation', 'GameDistributionOrientation'], + ['GameDistributionDeviceSupport', 'GameDistributionDeviceSupport'], + ['GameDistributionAuthor', 'GameDistributionAuthor'], + ['GameDistributionVersionSummary', 'GameDistributionVersionSummary'], + ['GameDistributionGameSummary', 'GameDistributionGame'], + ['GameDistributionListResponse', 'GameDistributionListResponse'], + ['GameDistributionCreateGameRequest', 'GameDistributionCreateGameRequest'], + [ + 'GameDistributionCreateVersionRequest', + 'GameDistributionCreateVersionRequest', + ], + ['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'], +]; + +// 服务端逐字段手拼 JSON 的响应/请求(`public_game_payload` / `game_payload` 等),TS 里这些类型 +// 没有 Rust 结构体可对:要收口得先把响应改成结构化类型,不能靠本脚本检查。 +const TS_ONLY_TYPES = [ + 'GameDistributionCategory', + 'GameDistributionRecoveryAction', + 'GameDistributionFrozenScreenshot', + 'GameDistributionVersionFrozenMetadata', + 'GameDistributionVersionDetail', + 'GameDistributionCancelVersionRequest', + 'GameDistributionCancelVersionResponse', +]; + +// 已知的响应侧附加字段:Rust 结构体描述请求侧视图,TS 类型描述响应视图。 +// 只允许「TS 多出」这些字段;Rust 多出字段一律报错(TS 必须跟上)。 +const TS_ONLY_FIELDS = { + GameDistributionGame: [ + 'coverObjectKey', + 'screenshots', + 'publicationRevision', + ], + GameDistributionPrivateVersion: ['publicationRevision'], +}; + +function camelCase(value) { + return value.replace(/_([a-z0-9])/g, (_, char) => char.toUpperCase()); +} + +function snakeCase(value) { + return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); +} + +// Rust 结构体字段本身就是 snake_case,枚举变体是 PascalCase; +// serde 的 rename_all 决定线上名字。 +function renamedMember(value, kind, rename) { + if (kind === 'enum') { + if (rename === 'snake_case') return snakeCase(value); + if (rename === 'camelCase') return camelCase(snakeCase(value)); + return value; + } + return rename === 'camelCase' ? camelCase(value) : value; +} + +function rustDefinitions(source) { + const result = new Map(); + const pattern = + /\n(?(?:#\[[^\n]*\]\n)*)pub (?struct|enum) (?GameDistribution\w+)(?[\s\S]*?)\n\}/g; + let match; + while ((match = pattern.exec(source))) { + const { attrs, kind, name, body } = match.groups; + const rename = /rename_all = "(?