新增游戏分发 Rust 与手写 TS DTO 的一致性门禁

- scripts/check-game-distribution-dto-parity.mjs:按显式映射表逐字段/逐变体比对 14 组 shared-contracts DTO 与 packages/shared/src/contracts/gameDistribution.ts,未分类的新类型或新字段直接失败
- 脚本登记 7 个服务端逐字段手拼 JSON 的 TS 类型,并记录 4 个已知响应侧附加字段(coverObjectKey/screenshots/publicationRevision)以便后续收口
- package.json 增加 check:game-distribution-dto-parity 并接进 npm run lint
- 变异验证:TS 改名与 Rust 加字段各自都让门禁失败并指出缺哪个字段;里程碑第 6 条写明剩余缺口(两个响应仍是 serde_json 手拼)
This commit is contained in:
kdletters
2026-09-28 17:52:11 +08:00
parent 39a7343079
commit 1b2bfa4e74
3 changed files with 198 additions and 2 deletions
@@ -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` 清空即可。
+2 -1
View File
@@ -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",
@@ -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(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+)(?<body>[\s\S]*?)\n\}/g;
let match;
while ((match = pattern.exec(source))) {
const { attrs, kind, name, body } = match.groups;
const rename = /rename_all = "(?<style>\w+)"/.exec(attrs)?.groups?.style;
const members =
kind === 'struct'
? [...body.matchAll(/^\s*pub (\w+):/gm)].map((item) =>
renamedMember(item[1], kind, rename),
)
: [...body.matchAll(/^\s{4}([A-Z]\w*)(?:\(|,|\s*\{)/gm)].map((item) =>
renamedMember(item[1], kind, rename),
);
result.set(name, { kind, members });
}
return result;
}
function tsDefinitions(source) {
const result = new Map();
const objectPattern =
/export type (GameDistribution\w+) = \{([\s\S]*?)\n\};/g;
let match;
while ((match = objectPattern.exec(source))) {
result.set(match[1], {
kind: 'struct',
members: [...match[2].matchAll(/^ {2}(\w+)\??:/gm)].map(
(item) => item[1],
),
});
}
const unionPattern = /export type (GameDistribution\w+) =\s*([^;]+);/g;
while ((match = unionPattern.exec(source))) {
if (result.has(match[1])) continue;
result.set(match[1], {
kind: 'enum',
members: [...match[2].matchAll(/'([^']+)'/g)].map((item) => item[1]),
});
}
return result;
}
function difference(left, right) {
const rightSet = new Set(right);
return left.filter((value) => !rightSet.has(value));
}
const rust = rustDefinitions(fs.readFileSync(RUST_FILE, 'utf8'));
const ts = tsDefinitions(fs.readFileSync(TS_FILE, 'utf8'));
const failures = [];
const mappedRust = new Set(PAIRS.map(([rustName]) => rustName));
const mappedTs = new Set(PAIRS.map(([, tsName]) => tsName));
for (const [rustName, tsName] of PAIRS) {
const rustDefinition = rust.get(rustName);
const tsDefinition = ts.get(tsName);
if (!rustDefinition) {
failures.push(`映射表里的 Rust 类型不存在:${rustName}`);
continue;
}
if (!tsDefinition) {
failures.push(`映射表里的 TS 类型不存在:${tsName}`);
continue;
}
if (rustDefinition.kind !== tsDefinition.kind) {
failures.push(
`${rustName} 与 ${tsName} 形状不一致(Rust=${rustDefinition.kind} TS=${tsDefinition.kind})`,
);
}
const missingInTs = difference(rustDefinition.members, tsDefinition.members);
if (missingInTs.length > 0) {
failures.push(
`${tsName} 缺少字段/变体:${missingInTs.join(', ')}(${rustName} 已有)`,
);
}
const allowlist = new Set(TS_ONLY_FIELDS[tsName] ?? []);
const extraInTs = difference(
tsDefinition.members,
rustDefinition.members,
).filter((value) => !allowlist.has(value));
if (extraInTs.length > 0) {
failures.push(
`${tsName} 多出未登记字段/变体:${extraInTs.join(', ')}(要么补进 TS_ONLY_FIELDS 并写明原因,要么在 Rust 侧补上)`,
);
}
}
for (const name of rust.keys()) {
if (!mappedRust.has(name)) {
failures.push(`Rust 新增 DTO ${name} 没有登记进映射表(TS 侧必须同步)`);
}
}
for (const name of ts.keys()) {
if (!mappedTs.has(name) && !TS_ONLY_TYPES.includes(name)) {
failures.push(
`TS 新增类型 ${name} 未分类(映射表或 TS_ONLY_TYPES 二者必居其一)`,
);
}
}
if (failures.length > 0) {
console.error('[check:game-distribution-dto-parity] 不一致:');
for (const failure of failures) console.error(` - ${failure}`);
process.exit(1);
}
console.log(
`[check:game-distribution-dto-parity] OK:${PAIRS.length} 组 Rust/TS 类型一致,${TS_ONLY_TYPES.length} 个手拼响应类型已登记`,
);