diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 046d0fa24..27bc5f665 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 张表、生成绑定校验通过)。**本轮新增** `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 类型。**本轮补齐** `coverObjectKey` / `screenshots` / `publicationRevision` 四个字段进 Rust 结构体(依据是 `game_payload` 与 `private_version_payload` 实际发出的键),并删掉脚本里用来豁免它们的 `TS_ONLY_FIELDS` 白名单:现在任一方向多出字段都会让门禁失败,作者侧响应省略 `currentVersion` 这一条差异改用 TS 可选字段描述。**剩余缺口**:这两个响应仍是 `serde_json::json!` 手拼,Rust 侧没有构建它们的结构体,所以门禁只能保证类型字段一致,不能保证构建器真的按类型发键;要彻底满足这一句,需要先把响应改成结构化构建。 + - 第 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 类型。**本轮补齐** `coverObjectKey` / `screenshots` / `publicationRevision` 四个字段进 Rust 结构体(依据是 `game_payload` 与 `private_version_payload` 实际发出的键),并删掉脚本里用来豁免它们的 `TS_ONLY_FIELDS` 白名单:现在任一方向多出字段都会让门禁失败,作者侧响应省略 `currentVersion` 这一条差异改用 TS 可选字段描述。**本轮再补构建器一层**:门禁新增 `RESPONSE_BUILDERS`,解析 `server-rs/crates/api-server/src/modules/game_distribution.rs` 里 `game_payload` / `public_game_payload` / `private_version_payload` / `version_summary_payload` 的 `json!` 顶层键与顶层 `object.insert(…)`,逐键比对 TS 类型:发出的键必须都在类型里、类型的必需字段必须都发出、`public_game_payload` 还必须发出被 TS 标成可选的 `currentVersion`,`game_payload` 的 `author` / `deviceSupport` 两个嵌套字面量同样逐键比对。变异验证五种改法各自让门禁失败并指出具体键:删掉 `game_payload.publicationRevision`、删掉 `deviceSupport.touch`、把 `author.avatarUrl` 改名、把插入的 `currentVersion` 改名、给 `version_summary_payload` 加一个 TS 没有的键;恢复后通过。**剩余缺口**:门禁比对的是键而不是值的类型,也覆盖不到未登记的嵌套对象与 envelope——成功/失败 envelope 的字段由 TS 侧运行时守卫(`packages/shared/src/http.ts`、`src/services/apiClient.ts`)消费,要彻底类型化得先把这两个响应改成结构化构建。 diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 6d5bb3b2a..672cd1b2b 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -6,12 +6,16 @@ // - 映射表同时是「哪些 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 API_MODULE_FILE = + 'server-rs/crates/api-server/src/modules/game_distribution.rs'; // [Rust 类型名, TS 类型名] const PAIRS = [ @@ -52,6 +56,29 @@ const TS_ONLY_TYPES = [ 'GameDistributionCancelVersionResponse', ]; +// 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。 +// - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影 = 作者投影 + currentVersion); +// - `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'], + }, + { 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()); } @@ -98,12 +125,15 @@ function tsDefinitions(source) { /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 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+) =\s*([^;]+);/g; while ((match = unionPattern.exec(source))) { @@ -121,6 +151,152 @@ function difference(left, 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')); const ts = tsDefinitions(fs.readFileSync(TS_FILE, 'utf8')); const failures = []; @@ -162,6 +338,108 @@ for (const name of rust.keys()) { 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( @@ -176,5 +454,7 @@ if (failures.length > 0) { process.exit(1); } console.log( - `[check:game-distribution-dto-parity] OK:${PAIRS.length} 组 Rust/TS 类型一致,${TS_ONLY_TYPES.length} 个手拼响应类型已登记`, + `[check:game-distribution-dto-parity] OK:${PAIRS.length} 组 Rust/TS 类型一致,` + + `${RESPONSE_BUILDERS.length} 个手拼响应构建器键一致,` + + `${TS_ONLY_TYPES.length} 个手拼响应类型已登记`, );