Files
Genarrative/scripts/check-game-distribution-dto-parity.mjs
T
lhk229 b60aea4e71
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m23s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Backend tests (push) Successful in 6m19s
Project CI / Native shell tests (push) Successful in 8m7s
实现网站游戏评分与评价 (#561)
公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 1–10 分评价,评论可空且最多 4000 个 Unicode 码点;游客可分页浏览评价并查看平均分与评分人数。

- 新增私有评价表及共享 DTO/API,事务内校验游戏公开可见性、保证唯一性,并计算分页与全量评分统计;下架或封禁后关闭读写,重新公开及更新版本保留评价。
- 详情页支持编辑预填、取消、提交期间冻结输入、失败保留草稿,以及保存、翻页和账号/游戏切换时的旧响应隔离;复用现有认证与公共组件。
- 同步主规范、里程碑、实施计划、生成绑定、表目录和验收脚本。不扩展 AGC、外部 API、评论审核或评分缓存。

验证:
- 本轮审查复跑前端定向测试 40 项、评价领域测试 2 项,均通过。
- 网站 typecheck、Rust/TypeScript DTO 对齐、SpacetimeDB schema、文档索引、编码与 diff 检查通过。
- 实施文档另记录本地隔离数据库/API 49 项及桌面/移动视口浏览器 smoke 通过;本轮审查未重跑这些运行时验收,也未运行完整测试套件。

发布说明:新增 SpacetimeDB 表,部署时需同步 module 与 API。尚未部署 dev/production,待用户验收。

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/561
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-10-01 19:19:58 +08:00

489 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 结构体」的说明清单;
// - 两侧字段必须逐一对齐,任一方向多出字段都会失败(没有白名单)。
// - 服务端手拼响应的构建器(`json!` / `object.insert`)单独比对顶层键:
// 类型字段一致只说明契约写得对,这一层才有证据说明构建器真的按契约发键。
// 任何一处没有分类的新类型、新字段都会让检查失败,避免静默漂移。
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';
const ADMIN_RUST_FILE = 'server-rs/crates/shared-contracts/src/admin.rs';
const ADMIN_TS_FILE = 'apps/admin-web/src/api/adminApiTypes.ts';
const API_MODULE_FILE =
'server-rs/crates/api-server/src/modules/game_distribution.rs';
// [Rust 类型名, TS 类型名]
const PAIRS = [
[
'GameDistributionPublishMetadataSuggestionRequest',
'GameDistributionPublishMetadataSuggestionRequest',
],
[
'GameDistributionPublishMetadataSuggestion',
'GameDistributionPublishMetadataSuggestion',
],
['GameDistributionVersionStatus', 'GameDistributionVersionStatus'],
['GameDistributionVisibility', 'GameDistributionGameVisibility'],
['GameDistributionInputMode', 'GameDistributionInputMode'],
['GameDistributionOrientation', 'GameDistributionOrientation'],
['GameDistributionDeviceSupport', 'GameDistributionDeviceSupport'],
['GameDistributionAuthor', 'GameDistributionAuthor'],
['GameDistributionReview', 'GameDistributionReview'],
['GameDistributionRatingSummary', 'GameDistributionRatingSummary'],
['GameDistributionReviewsResponse', 'GameDistributionReviewsResponse'],
['GameDistributionMyReviewResponse', 'GameDistributionMyReviewResponse'],
['GameDistributionSaveReviewRequest', 'GameDistributionSaveReviewRequest'],
['GameDistributionSaveReviewResponse', 'GameDistributionSaveReviewResponse'],
['GameDistributionVersionSummary', 'GameDistributionVersionSummary'],
['GameDistributionGameSummary', 'GameDistributionGame'],
['GameDistributionListResponse', 'GameDistributionListResponse'],
['GameDistributionCreateGameRequest', 'GameDistributionCreateGameRequest'],
[
'GameDistributionCreateVersionRequest',
'GameDistributionCreateVersionRequest',
],
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'],
['AdminGameReviewGame', 'AdminGameReviewGame'],
['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'],
['AdminGameReviewsQuery', 'AdminGameReviewsQuery'],
['AdminGameReviewGameInfo', 'AdminGameReviewGameInfo'],
['AdminGameReview', 'AdminGameReview'],
['AdminGameReviewOperation', 'AdminGameReviewOperation'],
['AdminGameReviewsResponse', 'AdminGameReviewsResponse'],
['AdminGameReviewDetailResponse', 'AdminGameReviewDetailResponse'],
['AdminGameReviewModerationRequest', 'AdminGameReviewModerationRequest'],
['AdminGameReviewModerationResponse', 'AdminGameReviewModerationResponse'],
];
// 服务端逐字段手拼 JSON 的响应/请求(`public_game_payload` / `game_payload` 等),TS 里这些类型
// 没有 Rust 结构体可对:要收口得先把响应改成结构化类型,不能靠本脚本检查。
const TS_ONLY_TYPES = [
'GameDistributionCategory',
'GameDistributionRecoveryAction',
'GameDistributionFrozenScreenshot',
'GameDistributionVersionFrozenMetadata',
'GameDistributionVersionDetail',
'GameDistributionCancelVersionRequest',
'GameDistributionCancelVersionResponse',
];
// 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。
// - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影追加 currentVersion、ratingSummary);
// - `mustEmit`:TS 类型为了同时描述两种形状把该键标成可选,但这条路径必须发出。
// 只解析顶层键;需要一并比对的嵌套对象用 `nested` 显式登记,没登记的嵌套对象不在证据范围内。
const RESPONSE_BUILDERS = [
{
fn: 'game_payload',
ts: 'GameDistributionGame',
nested: [
{ key: 'author', ts: 'GameDistributionAuthor' },
{ key: 'deviceSupport', ts: 'GameDistributionDeviceSupport' },
],
},
{
fn: 'public_game_payload',
ts: 'GameDistributionGame',
base: 'game_payload',
mustEmit: ['currentVersion', 'ratingSummary'],
},
{ fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' },
{ fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' },
];
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+|AdminGameReview\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|interface) (GameDistribution\w+|AdminGameReview\w*)(?: =)? \{([\s\S]*?)\n\};?/g;
let match;
while ((match = objectPattern.exec(source))) {
const members = [];
const required = [];
for (const line of match[2].split('\n')) {
const member = /^ {2}(\w+)(\??):/.exec(line);
if (!member) continue;
members.push(member[1]);
if (member[2] !== '?') required.push(member[1]);
}
result.set(match[1], { kind: 'struct', members, required });
}
const unionPattern =
/export type (GameDistribution\w+|AdminGameReview\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));
}
// 跳过 Rust 字符串字面量,返回结束引号的下标。
function skipString(source, start) {
let index = start + 1;
while (index < source.length) {
if (source[index] === '\\') {
index += 2;
continue;
}
if (source[index] === '"') return index;
index += 1;
}
return source.length - 1;
}
// 取顶层函数的函数体文本;找不到函数或括号不闭合时返回 null。
function functionBody(source, name) {
const signature = new RegExp(`\\nfn ${name}\\(`).exec(source);
if (!signature) return null;
const open = source.indexOf('{', signature.index + signature[0].length);
if (open < 0) return null;
let depth = 0;
for (let index = open; index < source.length; index += 1) {
const char = source[index];
if (char === '"') {
index = skipString(source, index);
continue;
}
if (char === "'") {
const charLiteral = /^'(?:\\.|[^'\\])'/.exec(source.slice(index));
if (charLiteral) index += charLiteral[0].length - 1;
continue;
}
if (char === '{') depth += 1;
else if (char === '}') {
depth -= 1;
if (depth === 0) return source.slice(open + 1, index);
}
}
return null;
}
// 取第一个 `json!({ ... })` 字面量文本;没有则返回 null。
function firstJsonLiteral(body) {
const marker = 'json!({';
const start = body.indexOf(marker);
if (start < 0) return null;
const open = start + marker.length - 1;
let depth = 0;
for (let index = open; index < body.length; index += 1) {
const char = body[index];
if (char === '"') {
index = skipString(body, index);
continue;
}
if (char === '{' || char === '[' || char === '(') depth += 1;
else if (char === '}' || char === ']' || char === ')') {
depth -= 1;
if (depth === 0) return body.slice(open, index + 1);
}
}
return null;
}
// 取括号对的结束下标;不闭合返回 -1。
function matchingBracket(text, open) {
let depth = 0;
for (let index = open; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
index = skipString(text, index);
continue;
}
if (char === '{' || char === '[' || char === '(') depth += 1;
else if (char === '}' || char === ']' || char === ')') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
// 取对象字面量的直接键:只有上一个有效字符是 `{` 或 `,` 且下一字符是 `:`
// 的字符串才算键,因此嵌套对象的键与作为值的字符串不会混进来。
function objectKeys(text) {
const keys = [];
let inner = 0;
let previous = '';
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
const close = skipString(text, index);
if (
inner === 1 &&
(previous === '{' || previous === ',') &&
text[close + 1] === ':'
) {
keys.push(text.slice(index + 1, close));
previous = ':';
index = close;
continue;
}
previous = '"';
index = close;
continue;
}
if (char === '{' || char === '[' || char === '(') inner += 1;
else if (char === '}' || char === ']' || char === ')') inner -= 1;
if (!/\s/.test(char)) previous = char;
}
return keys;
}
// 取对象字面量里某个直接键对应的嵌套对象字面量文本;该键不是对象字面量时返回 null。
function nestedObjectText(text, wanted) {
let inner = 0;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
const close = skipString(text, index);
if (inner === 1 && text.slice(index + 1, close) === wanted) {
let cursor = close + 1;
while (cursor < text.length && /\s/.test(text[cursor])) cursor += 1;
if (text[cursor] === ':') {
let open = cursor + 1;
while (open < text.length && /\s/.test(text[open])) open += 1;
if (text[open] !== '{') return null;
const end = matchingBracket(text, open);
return end < 0 ? null : text.slice(open, end + 1);
}
}
index = close;
continue;
}
if (char === '{' || char === '[' || char === '(') inner += 1;
else if (char === '}' || char === ']' || char === ')') inner -= 1;
}
return null;
}
// 取函数体里顶层追加的键:`object.insert("key".to_string(), ...)`。
function insertedKeys(body) {
return [...body.matchAll(/object\.insert\(\s*"([^"]+)"/g)].map(
(item) => item[1],
);
}
const rust = rustDefinitions(
fs.readFileSync(RUST_FILE, 'utf8') +
'\n' +
fs.readFileSync(ADMIN_RUST_FILE, 'utf8'),
);
const ts = tsDefinitions(
fs.readFileSync(TS_FILE, 'utf8') +
'\n' +
fs.readFileSync(ADMIN_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 extraInTs = difference(tsDefinition.members, rustDefinition.members);
if (extraInTs.length > 0) {
failures.push(
`${tsName} 多出未登记字段/变体:${extraInTs.join(', ')}(要么在 Rust 侧补上,要么先确认真实响应不再发出)`,
);
}
}
for (const name of rust.keys()) {
if (!mappedRust.has(name)) {
failures.push(`Rust 新增 DTO ${name} 没有登记进映射表(TS 侧必须同步)`);
}
}
const apiSource = fs.readFileSync(API_MODULE_FILE, 'utf8');
const emittedByBuilder = new Map();
for (const builder of RESPONSE_BUILDERS) {
const tsDefinition = ts.get(builder.ts);
if (!tsDefinition) {
failures.push(`响应构建器 ${builder.fn} 的 TS 类型不存在:${builder.ts}`);
continue;
}
const body = functionBody(apiSource, builder.fn);
if (body === null) {
failures.push(`响应构建器 ${builder.fn} 在 ${API_MODULE_FILE} 里找不到`);
continue;
}
const literal = firstJsonLiteral(body);
const literalKeys = literal ? objectKeys(literal) : [];
const ownKeys = [...new Set([...literalKeys, ...insertedKeys(body)])];
let emitted = ownKeys;
if (builder.base) {
if (literalKeys.length > 0) {
failures.push(
`${builder.fn} 声明了 base=${builder.base},却自己也有 json! 字面量;两者只能取其一`,
);
continue;
}
if (ownKeys.length === 0) {
failures.push(
`${builder.fn} 声明了 base=${builder.base},但没有解析到任何顶层 object.insert("…")`,
);
continue;
}
const baseKeys = emittedByBuilder.get(builder.base);
if (!baseKeys) {
failures.push(
`${builder.fn} 的 base=${builder.base} 必须登记在它前面,才能复用已解析的键`,
);
continue;
}
emitted = [...new Set([...baseKeys, ...ownKeys])];
} else if (literalKeys.length === 0) {
failures.push(`${builder.fn} 没有解析到任何 json!({ … }) 顶层键`);
continue;
}
emittedByBuilder.set(builder.fn, emitted);
const unknown = difference(emitted, tsDefinition.members);
if (unknown.length > 0) {
failures.push(
`${builder.fn} 发出 ${builder.ts} 没有的键:${unknown.join(', ')}(TS 类型必须同步)`,
);
}
const missing = difference(tsDefinition.required ?? [], emitted);
if (missing.length > 0) {
failures.push(
`${builder.fn} 没有发出 ${builder.ts} 的必需字段:${missing.join(', ')}`,
);
}
const mustEmit = difference(builder.mustEmit ?? [], emitted);
if (mustEmit.length > 0) {
failures.push(`${builder.fn} 必须发出的键缺失:${mustEmit.join(', ')}`);
}
for (const nested of builder.nested ?? []) {
const nestedDefinition = ts.get(nested.ts);
if (!nestedDefinition) {
failures.push(
`${builder.fn} 的嵌套对象 ${nested.key} 对应的 TS 类型不存在:${nested.ts}`,
);
continue;
}
const nestedText = literal ? nestedObjectText(literal, nested.key) : null;
if (!nestedText) {
failures.push(
`${builder.fn} 的 ${nested.key} 不是对象字面量,无法与 ${nested.ts} 比对`,
);
continue;
}
const nestedKeys = objectKeys(nestedText);
const nestedUnknown = difference(nestedKeys, nestedDefinition.members);
if (nestedUnknown.length > 0) {
failures.push(
`${builder.fn} 的 ${nested.key} 发出 ${nested.ts} 没有的键:${nestedUnknown.join(', ')}`,
);
}
const nestedMissing = difference(
nestedDefinition.required ?? [],
nestedKeys,
);
if (nestedMissing.length > 0) {
failures.push(
`${builder.fn} 的 ${nested.key} 没有发出 ${nested.ts} 的必需字段:${nestedMissing.join(', ')}`,
);
}
}
}
for (const builder of RESPONSE_BUILDERS) {
if (!emittedByBuilder.has(builder.fn)) {
failures.push(
`响应构建器 ${builder.fn} 的键没有解析成功,无法证明与 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 类型一致,` +
`${RESPONSE_BUILDERS.length} 个手拼响应构建器键一致,` +
`${TS_ONLY_TYPES.length} 个手拼响应类型已登记`,
);