修复游戏买断制付费的作者豁免、价格口径与契约可空性
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- H1:购买事务在扣费前判定 `game.owner_user_id == user_id` 并失败关闭,领域 `resolve_game_purchase_decision` 新增 `is_owner` 标记返回 `OwnerCannotPurchase`(文案「作者本人无需购买」);api-server `map_purchase_error` 映射为 400 `GAME_PURCHASE_OWNER_EXEMPT`;购买路由按角色显式拒绝管理员令牌(403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED`),管理员免购买不扣费。

- M1:`version_detail_payload` 的历史版本价格改为 `frozen_version_price_mud_points(version).unwrap_or(0)`,与待审列表及审核通过后生效的游戏行价格统一;同步把既有断言改为「历史版本缺 `priceMudPoints` 按免费(0)」。

- M2:`shared-contracts::GameDistributionVersionSummary.entry_url` 改为 `#[serde(default)] Option<String>`,与公开投影对未购买者下发的 `entryUrl: null` 一致。

- M3/L5:SpacetimeDB 购买事务改为复用 `module_game_distribution::resolve_game_purchase_decision`(新增 `game_distribution_visibility` 与 `resolve_game_distribution_purchase_decision` 包装),可购买性口径统一为「活动版本存在且 `status = published`」(复用 `public_game_distribution_version`),删除事务内联判定。

- 测试:内存领域作者自购拒绝;SpacetimeDB 购买判定五分支(免费 / 未公开 / 版本未公开 / 已拥有 / 作者自购)、事务源扫描(先判定后扣费、公开投影口径)、审核通过时历史版本价格生效为 0;api-server 管理员令牌 403、公开投影 `entryUrl: null` 可被 Rust DTO 解析、错误映射补作者豁免。

- `scripts/check-game-distribution-dto-parity.mjs` 增加可空性一致性比对(TS `| null` ⟺ Rust `Option`,允许 `?` 表示可省略),已用负例验证能拦住同类漂移。

- 同步更新 docs/【后端架构】server-rs与SpacetimeDB数据契约:登记作者豁免 / 管理员 403 / 可玩性口径 / 历史价格按 0;顺带修复 `module-game-distribution` 与 `spacetime-module` 既有未格式化行。
This commit is contained in:
2026-10-05 18:00:03 +08:00
parent d40df89e2c
commit d1bfe4c08b
9 changed files with 497 additions and 88 deletions
+92 -11
View File
@@ -6,6 +6,8 @@
// - 映射表同时是「哪些 Rust DTO 必须在 TS 里有对应类型」的清单;
// - `TS_ONLY_TYPES` 是「服务端手拼 JSON、没有 Rust 结构体」的说明清单;
// - 两侧字段必须逐一对齐,任一方向多出字段都会失败(没有白名单)。
// - 两侧字段的可空性必须一致:TS 允许 `null` 时 Rust 必须是 `Option`;Rust 是 `Option` 时
// TS 必须可空或可省略,避免「Rust 契约写 String、线上实际下发 null」这类漂移。
// - 服务端手拼响应的构建器(`json!` / `object.insert`)单独比对顶层键:
// 类型字段一致只说明契约写得对,这一层才有证据说明构建器真的按契约发键。
// 任何一处没有分类的新类型、新字段都会让检查失败,避免静默漂移。
@@ -133,19 +135,49 @@ function rustDefinitions(source) {
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 });
const members = [];
const optionalFields = [];
if (kind === 'struct') {
for (const item of body.matchAll(/^\s*pub (\w+):\s*(.+?)\s*,?\s*$/gm)) {
const member = renamedMember(item[1], kind, rename);
members.push(member);
// `Option<T>` 是「允许 null」的唯一 Rust 依据。
if (/\bOption</.test(item[2])) optionalFields.push(member);
}
} else {
for (const item of body.matchAll(/^\s{4}([A-Z]\w*)(?:\(|,|\s*\{)/gm)) {
members.push(renamedMember(item[1], kind, rename));
}
}
result.set(name, { kind, members, optionalFields });
}
return result;
}
// 括号是否闭合:跨行的 TS 类型只解析到第一行,无法判断可空性,这类字段跳过比对。
function balancedType(typeText) {
let depth = 0;
for (const char of typeText) {
if (char === '{' || char === '[' || char === '(' || char === '<') depth += 1;
else if (char === '}' || char === ']' || char === ')' || char === '>') depth -= 1;
}
return depth === 0;
}
// 只认字段自身类型上的 `| null`;内联对象 / 泛型里的 `| null` 属于嵌套成员,不算。
function topLevelNullable(typeText) {
let depth = 0;
for (let index = 0; index < typeText.length; index += 1) {
const char = typeText[index];
if (char === '{' || char === '[' || char === '(' || char === '<') depth += 1;
else if (char === '}' || char === ']' || char === ')' || char === '>') depth -= 1;
else if (char === '|' && depth === 0 && /^\s*null\b/.test(typeText.slice(index + 1))) {
return true;
}
}
return false;
}
function tsDefinitions(source) {
const result = new Map();
const objectPattern =
@@ -154,13 +186,27 @@ function tsDefinitions(source) {
while ((match = objectPattern.exec(source))) {
const members = [];
const required = [];
const nullable = [];
const complete = [];
for (const line of match[2].split('\n')) {
const member = /^ {2}(\w+)(\??):/.exec(line);
const member = /^ {2}(\w+)(\??):\s*(.*)$/.exec(line);
if (!member) continue;
members.push(member[1]);
if (member[2] !== '?') required.push(member[1]);
if (balancedType(member[3])) {
complete.push(member[1]);
if (topLevelNullable(member[3])) nullable.push(member[1]);
}
}
result.set(match[1], { kind: 'struct', members, required });
const optionalFields = members.filter((name) => !required.includes(name));
result.set(match[1], {
kind: 'struct',
members,
required,
nullable,
complete,
optionalFields,
});
}
const unionPattern =
/export type (GameDistribution\w+|AdminGameReview\w*) =\s*([^;]+);/g;
@@ -369,6 +415,41 @@ for (const [rustName, tsName] of PAIRS) {
}
}
// 可空性一致性:TS 声明 `| null` 时 Rust 必须是 `Option`;Rust 是 `Option` 时 TS 必须可空(`| null`)
// 或可省略(`?`)。只比对两边都能完整解析(非跨行)的字段,避免误报。
for (const [rustName, tsName] of PAIRS) {
const rustDefinition = rust.get(rustName);
const tsDefinition = ts.get(tsName);
if (
!rustDefinition ||
!tsDefinition ||
rustDefinition.kind !== 'struct' ||
tsDefinition.kind !== 'struct'
) {
continue;
}
for (const field of rustDefinition.optionalFields) {
if (!tsDefinition.members.includes(field)) continue;
if (
!tsDefinition.nullable.includes(field) &&
!tsDefinition.optionalFields.includes(field)
) {
failures.push(
`${tsName}.${field} 必须可空(\`| null\`)或可选(\`?\`):${rustName} 的该字段是 Option`,
);
}
}
for (const field of tsDefinition.nullable) {
if (!rustDefinition.members.includes(field)) continue;
if (!tsDefinition.complete.includes(field)) continue;
if (!rustDefinition.optionalFields.includes(field)) {
failures.push(
`${tsName}.${field} 允许 null,但 ${rustName} 的该字段不是 Option,Rust 侧必须同步为可空`,
);
}
}
}
for (const name of rust.keys()) {
if (!mappedRust.has(name)) {
failures.push(`Rust 新增 DTO ${name} 没有登记进映射表(TS 侧必须同步)`);