新增游戏分发 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:
@@ -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
@@ -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} 个手拼响应类型已登记`,
|
||||
);
|
||||
Reference in New Issue
Block a user