修复游戏买断制付费的作者豁免、价格口径与契约可空性
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
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:
@@ -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 侧必须同步)`);
|
||||
|
||||
Reference in New Issue
Block a user