《互联网产品的客户端架构·规模化与架构演进》13.1 端到端:多端同一笔交易的体验一致性

同一笔已支付订单在 Web、iOS、Android、小程序和桌面回放,金额、状态与文案只要有一处不同,用户就会怀疑这笔钱去了哪里。本节用可运行的 TypeScript 场景脚本收集五端观测,把差异归为呈现、环境、契约与未知四类,并给出阻断、警告与放行的发布判据。

一笔已支付订单,在五个端上回放

某社区电商把同一笔订单投到五个端做验收。服务端返回的内容完全一样:金额 129900 分、币种 CNY、订单状态 paid、支付状态 paid。五个端却给出了三种体验:

  • Web 显示「已支付」,金额「¥1,299.00」。
  • iOS 显示「支付成功」,金额「¥1,299.00」。
  • Android 显示「支付成功」,金额「¥1299」。
  • 小程序只拿到支付状态,订单状态为空,页面显示「处理中」。
  • 桌面端把金额渲染成「1,299 元」,但没有支付状态字段。

没有端崩溃,接口也都成功。问题在于:没有人把「一致」写成可回放的场景与断言。13.1 要做的不是再点一遍页面,而是把同一笔交易在五个端上的观测收集起来,逐字段判断差异属于哪一类,再决定能不能放行。

场景脚本要固定三样东西

端到端脚本最容易写成「人工点一遍,截图发群里」。这种验收无法重复,也无法判断一次差异是文案问题还是金额问题。可执行的场景脚本至少要固定三样东西:

  1. 固定输入:同一笔订单、同一个用户、同一份服务端快照。
  2. 固定观测:每个端必须回报哪些字段,缺字段本身就是失败。
  3. 固定判据:每个字段的承诺等级,以及差异归到哪一类。

字段与承诺的对应关系直接沿用第 1 章的模型,但只保留交易链路最关键的六个字段:

字段含义承诺等级为什么
amountCents应付金额,整数分identical金额错一位就是资损
displayAmount用户可见金额文本identical展示格式错也会让用户怀疑金额
currency币种equivalent大小写可归一,语义必须相同
orderStatus订单状态identical状态决定用户能做什么
paymentStatus支付状态identical与订单状态分开,避免混淆
statusCopy用户可见状态文案equivalent措辞可不同,语义必须相同

差异则分成五类,处理方式各不相同:

类别判定条件处理
none原始取值逐字一致放行
presentation归一化后一致,但原始措辞或格式不同警告并记录
environment场景预先登记过的端环境差异警告并记录
contract承诺逐字一致却出现不同值,或语义等价归一化后仍不同阻断
unknown缺少某个端的观测,或允许差异出现未登记取值阻断

这张表的关键在最后两行:未知不能当成无害。少一个端的观测,和金额不一致一样,都不能放行。

把场景与判据写成代码

下面的模块把场景、字段承诺、差异归类与发布决策都做成纯函数。示例场景覆盖四种真实情况:五端完全一致、文案不同但语义一致、金额单位错位、某端缺字段。

// file: src/summary/multi-end-e2e.ts
// tier: L2
// verify: node --test test/summary/multi-end-e2e.test.ts

export type EndpointId = "web" | "ios" | "android" | "miniprogram" | "desktop";
export type FieldId = "amountCents" | "displayAmount" | "currency" | "orderStatus" | "paymentStatus" | "statusCopy";
export type PromiseLevel = "identical" | "equivalent" | "allowed-diff";
export type DifferenceClass = "none" | "presentation" | "environment" | "contract" | "unknown";

export type FieldAssertion = { field: FieldId; promise: PromiseLevel; allowedValues?: string[] };

export type Scenario = {
  id: string;
  title: string;
  requiredEnds: EndpointId[];
  fields: FieldAssertion[];
  environmentDiffs?: FieldId[];
  observations: Partial<Record<EndpointId, Partial<Record<FieldId, string>>>>;
};

export type FieldResult = {
  field: FieldId;
  promise: PromiseLevel;
  values: Partial<Record<EndpointId, string>>;
  missing: EndpointId[];
  difference: DifferenceClass;
  detail: string;
};

export type Decision = "pass" | "warn" | "block";
export type ScenarioResult = {
  id: string;
  title: string;
  decision: Decision;
  fields: FieldResult[];
  blockers: string[];
  warnings: string[];
};
export type ReleaseResult = {
  decision: Decision;
  passed: string[];
  warned: string[];
  blocked: string[];
};

export const ENDPOINTS: EndpointId[] = ["web", "ios", "android", "miniprogram", "desktop"];

const STATUS_ALIASES: Record<string, string> = {
  paid: "paid", success: "paid", 已支付: "paid", 支付成功: "paid", 已付款: "paid",
  shipped: "shipped", 待发货: "shipped", 已发货: "shipped",
  pending: "pending", 处理中: "pending", 待支付: "pending",
  refunding: "refunding", 退款中: "refunding", 退款处理中: "refunding",
};

export function normalizeValue(field: FieldId, value: string): string {
  const trimmed = value.trim();
  if (field === "amountCents") {
    const parsed = Number(trimmed);
    return Number.isFinite(parsed) ? String(parsed) : trimmed;
  }
  if (field === "displayAmount") return trimmed.replace(/\s+/g, " ");
  if (field === "currency") return trimmed.toUpperCase();
  const lower = trimmed.toLowerCase();
  return STATUS_ALIASES[trimmed] ?? STATUS_ALIASES[lower] ?? lower.replace(/\s+/g, "");
}

export function evaluateField(assertion: FieldAssertion, scenario: Scenario): FieldResult {
  const values: Partial<Record<EndpointId, string>> = {};
  const missing: EndpointId[] = [];
  for (const endpoint of scenario.requiredEnds) {
    const value = scenario.observations[endpoint]?.[assertion.field];
    if (value === undefined) missing.push(endpoint);
    else values[endpoint] = value;
  }
  const base = { field: assertion.field, promise: assertion.promise, values, missing };
  if (missing.length > 0) {
    return { ...base, difference: "unknown", detail: `缺少观测:${missing.join(", ")}` };
  }

  const raws = scenario.requiredEnds.map((endpoint) => values[endpoint] as string);
  const normalized = raws.map((value) => normalizeValue(assertion.field, value));
  const unique = [...new Set(normalized)];
  if (unique.length === 1) {
    const rawDiffer = new Set(raws).size > 1;
    return { ...base, difference: rawDiffer ? "presentation" : "none", detail: rawDiffer ? "归一化后一致" : "逐字一致" };
  }
  if (assertion.promise === "identical") {
    return { ...base, difference: "contract", detail: `承诺逐字一致,实际 ${unique.length} 种取值` };
  }
  if (assertion.promise === "equivalent") {
    return { ...base, difference: "contract", detail: `承诺语义等价,归一化后仍不一致:${unique.join(" / ")}` };
  }

  const allowed = new Set((assertion.allowedValues ?? []).map((v) => normalizeValue(assertion.field, v)));
  if (normalized.every((value) => allowed.has(value))) {
    return { ...base, difference: "presentation", detail: "取值均在登记的白名单内" };
  }
  return { ...base, difference: "unknown", detail: `出现未登记取值:${unique.filter((v) => !allowed.has(v)).join(" / ")}` };
}

export function evaluateScenario(scenario: Scenario): ScenarioResult {
  const environment = new Set(scenario.environmentDiffs ?? []);
  const fields = scenario.fields.map((assertion) => {
    const result = evaluateField(assertion, scenario);
    if (result.difference === "presentation" && environment.has(result.field)) {
      return { ...result, difference: "environment" as DifferenceClass, detail: "已登记的环境差异" };
    }
    return result;
  });
  const blockers = fields
    .filter((r) => r.difference === "contract" || r.difference === "unknown")
    .map((r) => `${scenario.id}.${r.field}`);
  const warnings = fields
    .filter((r) => r.difference === "presentation" || r.difference === "environment")
    .map((r) => `${scenario.id}.${r.field}`);
  const decision: Decision = blockers.length > 0 ? "block" : warnings.length > 0 ? "warn" : "pass";
  return { id: scenario.id, title: scenario.title, decision, fields, blockers, warnings };
}

export function releaseDecision(results: ScenarioResult[]): ReleaseResult {
  const passed = results.filter((r) => r.decision === "pass").map((r) => r.id);
  const warned = results.filter((r) => r.decision === "warn").map((r) => r.id);
  const blocked = results.filter((r) => r.decision === "block").map((r) => r.id);
  const decision: Decision = blocked.length > 0 ? "block" : warned.length > 0 ? "warn" : "pass";
  return { decision, passed, warned, blocked };
}

export function differenceSummary(results: ScenarioResult[]): Record<DifferenceClass, number> {
  const summary: Record<DifferenceClass, number> = {
    none: 0, presentation: 0, environment: 0, contract: 0, unknown: 0,
  };
  for (const scenario of results) {
    for (const field of scenario.fields) summary[field.difference] += 1;
  }
  return summary;
}

export function scenarioCoverage(scenario: Scenario): number {
  const covered = scenario.requiredEnds.filter((endpoint) =>
    scenario.fields.every((assertion) => scenario.observations[endpoint]?.[assertion.field] !== undefined),
  );
  return covered.length / scenario.requiredEnds.length;
}

const ALL_ENDS: EndpointId[] = ENDPOINTS;
const BASE_FIELDS: FieldAssertion[] = [
  { field: "amountCents", promise: "identical" },
  { field: "displayAmount", promise: "identical" },
  { field: "currency", promise: "equivalent" },
  { field: "orderStatus", promise: "identical" },
  { field: "paymentStatus", promise: "identical" },
  { field: "statusCopy", promise: "equivalent" },
];

function buildScenario(
  id: string,
  title: string,
  overrides: Partial<Record<EndpointId, Partial<Record<FieldId, string>>>>,
): Scenario {
  const base: Partial<Record<FieldId, string>> = {
    amountCents: "129900", displayAmount: "¥1,299.00", currency: "CNY",
    orderStatus: "paid", paymentStatus: "paid", statusCopy: "已支付",
  };
  const observations: Scenario["observations"] = {};
  for (const endpoint of ALL_ENDS) observations[endpoint] = { ...base, ...(overrides[endpoint] ?? {}) };
  return { id, title, requiredEnds: ALL_ENDS, fields: BASE_FIELDS, observations };
}

export const SAMPLE_SCENARIOS: Scenario[] = [
  buildScenario("paid-order", "已支付订单五端回放", {}),
  buildScenario("copy-only", "文案措辞不同但语义一致", {
    ios: { statusCopy: "支付成功" }, android: { statusCopy: "支付成功" },
  }),
  buildScenario("amount-unit", "金额单位错位", {
    miniprogram: { amountCents: "1299", displayAmount: "¥12.99" },
  }),
  buildScenario("missing-end", "桌面端缺少订单状态", { desktop: { orderStatus: undefined } }),
];

判据与反例

模块把发布决策压成一条规则:只要出现 contract 或 unknown,就阻断;只有 presentation 或 environment,就警告;全部 none 才直接通过。

四个示例场景的预期结果如下:

场景关键差异决策原因
paid-order无pass六个字段逐字一致
copy-only文案「已支付 / 支付成功」warn归一化后同为 paid
amount-unit小程序金额 1299、展示 ¥12.99block两个 identical 字段出现不同取值
missing-end桌面端无订单状态block缺少观测按未知处理

两个反例值得单独说。

反例一:把文案差异当阻断。 五个端的文案团队不同,「已支付」和「支付成功」都正确。如果每次措辞调整都阻断发布,团队最后会关掉检查。正确做法是给 statusCopy 定 equivalent,把措辞映射到同一语义,差异降为警告并记录。

反例二:把金额格式差异当呈现差异。 有的端渲染 ¥1,299.00,有的端渲染 ¥1299。displayAmount 的承诺是 identical,格式不同也属于契约不一致。金额的展示格式应该由同一个格式化函数产生,而不是每个端各自处理后再解释为「看起来一样」。这也是代码里把 amountCents 与 displayAmount 分开观测的原因:前者防单位错误,后者防展示漂移。

还有一个更隐蔽的边界:environment 不是万能豁免。它只对已经出现 presentation 的字段生效;如果金额或订单状态在归一化后仍不一致,即使登记为环境差异,也仍然是 contract。环境差异只能解释措辞与区域格式,不能解释业务事实。

小结

多端端到端验收的核心不是再点一遍页面,而是把同一笔交易的观测固定下来,逐字段判断差异类别。金额、展示金额与订单状态用 identical,币种与文案用 equivalent,未登记的差异一律按 unknown 阻断。

这套脚本可以直接接进发布流水线:场景通过则放行,出现警告则把差异写进发布说明,出现阻断则停止发布。它检验的不是某个端能不能用,而是五个端对同一笔交易是否给出了同一个答案。

阅读导航:上一节:12.3 迁移的退出条件与回滚 · 下一节:13.2 平台化验收:能力矩阵与治理清单 。

常见错误与边界

  • 只截一张 Web 的图,就声称五个端体验一致。
  • 把「接口返回相同」当成「端上体验相同」,忽略端上的状态映射与格式化。
  • 金额用 equivalent,把 1299 和 129900 解释成单位不同。
  • 缺少某个端的观测时默认跳过,而不是按未知阻断。
  • 文案差异直接阻断发布,逼团队关掉检查。
  • 环境差异白名单越用越宽,最后把订单状态也登记进去。
  • 场景只覆盖成功支付,不覆盖退款、超时与重复支付。
  • 差异汇总只看总数,不看类别,阻断项被警告数量掩盖。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查