同一个订单,五个端给出三种答案
某内容社区把交易能力铺到 Web、iOS、Android、小程序和桌面。上线后发现一个奇怪的现象:同一笔已支付订单,在 Web 上显示「待发货」,在 iOS 上显示「已支付」,在小程序上显示「处理中」。
三个端都没有崩溃,接口也都能返回数据。问题出在没人定义过「一致」是什么意思:
- Web 把服务端的
paid直接映射成「已支付」。 - iOS 团队认为「已支付」不够友好,改成了「待发货」。
- 小程序只拿到了支付状态,没有拿到订单状态,于是显示「处理中」。
这不是三个 bug,而是缺少一份承诺。多端架构的第一份文档不该是共享组件清单,而应该是一致性承诺矩阵:哪些行为在五个端上必须逐字一致,哪些只要结果等价,哪些允许按端不同。
承诺分三级,不是「一致/不一致」
把「一致」当成一个布尔值,会逼团队在两种坏选择里二选一:要么强行让所有端像素级相同,要么放弃约束各写各的。更实用的是三级:
| 等级 | 含义 | 例子 | 验收方式 |
|---|---|---|---|
identical | 同一输入得到同一结果、同一文案、同一顺序 | 下单金额、支付结果、退款状态 | 跨端契约测试,逐字段比对 |
equivalent | 结果语义相同,呈现可以不同 | 订单列表排序、通知文案、页面结构 | 场景测试,断言关键字段而非全文 |
allowed-diff | 端能力不同,允许缺失或替代 | 桌面端无推送、小程序分享卡片形态 | 明示降级路径,不做等价断言 |
identical 的成本最高,所以只给少数「错了要人工介入」的行为:金额、订单状态、支付结果、退款进度。把这些放进 identical,其余尽量落在 equivalent。
一个反例是把「商品详情页布局」定成 identical。这会让设计系统被迫接受最弱端的限制,五个端都退化成小程序的能力上限。
承诺矩阵
矩阵的行是功能,列是端。每一格填承诺等级,空格表示该端不支持这个功能。
| 功能 | Web | iOS | Android | 小程序 | 桌面 |
|---|---|---|---|---|---|
| 下单与支付 | identical | identical | identical | identical | equivalent |
| 订单与退款记录 | identical | identical | identical | equivalent | equivalent |
| 交易通知 | allowed-diff | equivalent | equivalent | equivalent | allowed-diff |
| 分享与邀请 | equivalent | equivalent | equivalent | allowed-diff | allowed-diff |
| 离线查看订单 | allowed-diff | equivalent | equivalent | allowed-diff | equivalent |
这张表最重要的作用不是展示,而是在评审时被迫回答「为什么这一格是这个等级」。如果答不上来,说明承诺是拍脑袋定的,后面一定会在联调时吵架。
矩阵还要能算出缺口。给出一个最低门槛,比如「所有端至少 equivalent」,就能自动列出不达标的端与功能,作为版本准入条件。
把矩阵写成代码
矩阵不能只活在文档里。下面这个模块把端、功能、承诺等级与缺口计算都变成可测试的纯函数:
// file: src/platform/endpoint-matrix.ts
// tier: L1
// verify: node --test test/platform/endpoint-matrix.test.ts
export type Endpoint = "web" | "ios" | "android" | "miniprogram" | "desktop";
export type PromiseLevel = "identical" | "equivalent" | "allowed-diff";
export type FeatureSpec = {
id: string;
title: string;
promises: Partial<Record<Endpoint, PromiseLevel>>;
};
export const ENDPOINTS: Endpoint[] = ["web", "ios", "android", "miniprogram", "desktop"];
const RANK: Record<PromiseLevel, number> = {
identical: 3,
equivalent: 2,
"allowed-diff": 1,
};
export const FEATURES: FeatureSpec[] = [
{
id: "checkout",
title: "下单与支付",
promises: { web: "identical", ios: "identical", android: "identical", miniprogram: "identical", desktop: "equivalent" },
},
{
id: "order-history",
title: "订单与退款记录",
promises: { web: "identical", ios: "identical", android: "identical", miniprogram: "equivalent", desktop: "equivalent" },
},
{
id: "push",
title: "交易通知",
promises: { web: "allowed-diff", ios: "equivalent", android: "equivalent", miniprogram: "equivalent", desktop: "allowed-diff" },
},
{
id: "share",
title: "分享与邀请",
promises: { web: "equivalent", ios: "equivalent", android: "equivalent", miniprogram: "allowed-diff", desktop: "allowed-diff" },
},
{
id: "offline",
title: "离线查看订单",
promises: { web: "allowed-diff", ios: "equivalent", android: "equivalent", miniprogram: "allowed-diff", desktop: "equivalent" },
},
];
export function promiseRank(level: PromiseLevel): number {
return RANK[level];
}
export function strongerPromise(a: PromiseLevel, b: PromiseLevel): PromiseLevel {
return RANK[a] >= RANK[b] ? a : b;
}
export function featureById(id: string): FeatureSpec | undefined {
return FEATURES.find((f) => f.id === id);
}
export function consistencyFor(id: string, endpoint: Endpoint): PromiseLevel | undefined {
return featureById(id)?.promises[endpoint];
}
export function parityGaps(endpoint: Endpoint, minimum: PromiseLevel = "equivalent"): string[] {
const need = RANK[minimum];
return FEATURES.filter((f) => {
const level = f.promises[endpoint];
return level === undefined || RANK[level] < need;
}).map((f) => f.id);
}
export function missingPromises(endpoint: Endpoint): string[] {
return FEATURES.filter((f) => f.promises[endpoint] === undefined).map((f) => f.id);
}
export function endpointCoverage(): Record<Endpoint, number> {
const out = {} as Record<Endpoint, number>;
for (const endpoint of ENDPOINTS) {
out[endpoint] = FEATURES.filter((f) => f.promises[endpoint] !== undefined).length;
}
return out;
}
export function weakestEndpoint(): Endpoint {
const coverage = endpointCoverage();
return ENDPOINTS.reduce((worst, e) => (coverage[e] < coverage[worst] ? e : worst), ENDPOINTS[0]);
}
parityGaps 是这份代码的核心:它不接受「差不多」,而是按门槛逐格比较。门槛可以按版本调整,缺口清单则成为发布检查的输入。
测试与真实输出
✔ 五类端形态全部登记 (0.048458ms)
✔ 支付承诺在四个端上完全一致 (0.073208ms)
✔ 桌面端支付只承诺等价而非一致 (0.055125ms)
✔ 未知功能返回 undefined 而不是默认值 (0.064833ms)
✔ 承诺等级可以排序 (0.038417ms)
✔ 更强的承诺胜出 (0.053125ms)
✔ 同等级承诺保持不变 (0.042292ms)
✔ 小程序在分享上只承诺允许差异 (0.058542ms)
✔ 桌面端通知被标记为允许差异 (0.045208ms)
✔ 按等价门槛,桌面端缺两个功能 (0.061792ms)
✔ 按一致门槛,桌面端五个功能都不达标 (0.068083ms)
✔ 按允许差异门槛,没有端缺口 (0.052375ms)
✔ 当前矩阵没有未声明承诺的端 (0.049292ms)
✔ 每个端的覆盖率都是五个功能 (0.044875ms)
✔ 功能可以通过 id 找回 (0.036708ms)
✔ 覆盖率并列时返回第一个端 (0.033375ms)
ℹ tests 16
ℹ suites 0
ℹ pass 16
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 141.578042
注意最后一个用例:覆盖率并列时返回第一个端。这是刻意定的规则,而不是实现细节——当所有端覆盖率相同,报「最弱端」没有意义,但函数必须给出稳定结果,否则每次构建的告警对象都会变。
一次「统一文案」的复盘
某团队看到三个端文案不一致,决定把所有文案收进服务端,由接口返回。上线后出现两个新问题:
- 服务端不懂端的语境。桌面端需要更长的解释,小程序需要更短的按钮,同一句话在五个端都不合适。
- 文案改动要发服务端,响应速度从小时级变成天级。
复盘结论不是「不该统一」,而是统一错了层级。金额、状态名这类 identical 字段应该统一;解释性文案属于 equivalent,应该由各端基于同一语义本地化。矩阵如果一开始就区分了这两类,这个方案不会被提出来。
谁维护这张矩阵
矩阵最常见的死法不是写错,而是没人维护。上线三个月后,新增了一个端、下线了一个功能,矩阵还是旧的,评审时引用的承诺早已失效。
维护责任要落到具体角色,而不是「架构组」这种模糊主体:
| 内容 | 负责人 | 变更时机 |
|---|---|---|
| 功能行 | 该功能的产品负责人 | 功能立项、下线 |
| 端列 | 各端技术负责人 | 新增端、端能力重大变化 |
| 承诺等级 | 架构评审会 | 涉及资金、状态、合规时 |
| 缺口清单 | 发布负责人 | 每个版本准入时 |
一个可执行的做法是把矩阵放进代码仓库,与 core 内核同目录,修改走代码评审。这样「改承诺」和「改实现」在同一个 PR 里被看见,而不是一份文档悄悄过期。
矩阵还要有版本号。承诺变更是破坏性变更:从 identical 降到 equivalent 意味着某个端的验收标准变松,必须让下游知道。版本号不必复杂,按「主版本.次版本」记录即可,主版本变化要求所有端重新确认。
差异的三类来源
同样是「不一致」,来源不同,处理方式也不同。把差异分成三类,能避免用同一种办法解决所有问题:
能力差异。 端本身不具备某种能力,例如桌面端没有推送、小程序没有生物识别。这类差异无法消除,只能降级或替代,对应 allowed-diff。
交互差异。 平台的交互惯例不同,例如 iOS 的左滑返回、Android 的系统返回键、Web 的浏览器后退。这类差异应当保留,强行统一反而伤害体验,对应 equivalent。
实现差异。 同一语义被不同团队实现成不同结果,例如三个端各写一份金额分摊。这类差异是缺陷,必须消除,对应 identical。
分类之后,评审问题会变得具体:这一格是能力差异还是实现差异?如果是实现差异,为什么没有共享内核?如果是能力差异,降级路径是什么?
承诺与测试的对应
不同承诺等级需要不同强度的验证。用同一套端到端测试覆盖所有等级,成本高且抓不住重点:
| 承诺等级 | 主要验证 | 失败意味着 |
|---|---|---|
identical | 跨端契约测试,逐字段比对 | 实现分叉,必须修 |
equivalent | 场景测试,断言关键语义 | 语义漂移,需要评审 |
allowed-diff | 降级路径测试 | 降级缺失或文案错误 |
identical 的测试应该在共享内核层完成,而不是在五个端各跑一遍 UI 测试。内核返回同一结果,端的展示差异才不会影响事实。
equivalent 的测试要断言「关键字段」:订单列表的排序可以不同,但每笔订单的 orderId、状态、金额必须正确。只断言「页面能打开」的测试没有价值。
allowed-diff 的测试最容易被忽略。桌面端没有推送,测试应该验证「没有推送时,用户仍能在应用内看到通知」,而不是简单跳过。
从矩阵到接口契约
矩阵定义「承诺什么」,接口契约定义「怎么验证承诺」。两者之间需要一个映射:每个 identical 功能至少对应一个契约测试,每个 allowed-diff 至少对应一条降级路径测试。
契约测试的写法与普通单元测试不同。它不测某个端的实现,而是测「所有端都必须满足的输入输出」。例如支付结果:
| 输入 | 所有端必须得到 | 允许不同 |
|---|---|---|
| 渠道返回成功 | 订单状态 paid、支付状态 succeeded | 成功页文案、跳转路径 |
| 渠道返回失败 | 支付状态 failed,订单仍 pending_payment | 失败提示语、是否自动返回 |
| 渠道超时 | 支付状态 unknown,不提供重试支付 | 查询按钮样式 |
契约测试跑在 core 层,用同一组输入断言同一组输出。端的 UI 测试只需要验证「把 core 的结果正确展示出来」,不需要重复验证业务判断。这样,五个端不需要各写一套支付状态测试,成本从五份降到一份。
契约还要覆盖「不允许的组合」。例如订单已取消时支付成功,所有端都必须进入异常处理,而不是各自决定显示什么。把禁止组合写进契约,比写在文档里更容易被执行。
一次跨端验收会
某团队在版本发布前开跨端验收会,五个端各自演示。Web 演示了完整的退款流程,iOS 演示了支付成功页,Android 演示了优惠券叠加,小程序演示了分享,桌面端演示了订单导出。会议开了两小时,结论是「都挺好」。
上线后第一周,用户反馈「小程序退款入口找不到」。查下来,小程序的退款入口在二级菜单,其他端在一级页面。验收会没人发现,因为每个端只演示了自己实现的部分,没有人对照矩阵逐格检查。
改进后的验收会只做一件事:打开矩阵,逐行问「这一行在五个端分别是什么样」,现场切换设备演示同一操作。两小时只能覆盖三行,但覆盖的三行是真正一致的。
这个教训与测试无关,与流程有关:验收的对象是承诺,不是各端的完成度。各端演示自己的功能,永远会得到「都挺好」的结论。
何时不该一致
一致性有成本,所以有些地方应该主动允许差异。
平台惯例。 返回手势、系统菜单、通知样式,跟随平台比统一更符合用户预期。把这些定成 identical,会让用户觉得「这不是原生的」。
屏幕与输入。 桌面端有键盘与悬停,移动端有触摸与手势,小程序有宿主限制。同一功能在不同输入方式下的交互必然不同,承诺应该是 equivalent:结果一样,路径不同。
合规与区域。 不同地区对隐私提示、支付方式、内容展示的要求不同。这类差异由法律决定,不应为了「一致」而统一。
实验与灰度。 实验组与对照组本来就应该不同。承诺矩阵描述的是「稳定版本的行为」,不是「所有用户看到的一样」。
把这四类写进矩阵的备注,评审时就不会有人提出「为什么桌面端和手机端不一样」这种问题——答案是「因为它们本来就不该一样」。
一个版本准入清单
矩阵最终要变成发布前可勾选的清单:
- 本版本涉及的功能,矩阵行是否已更新?
- 每个
identical功能是否有跨端契约测试? - 每个
allowed-diff是否有降级路径与文案? - 新增端是否填满了所有功能列?
- 承诺降级是否通知了下游端?
- 五个端的验收是否按矩阵逐行演示?
六条都勾上,才允许进入灰度。清单不长,但它把「一致」从口号变成了发布条件。
版本准入清单不必由架构组独占。产品负责人确认功能行,端负责人确认端列,发布负责人确认缺口,三方签字比一个人拍板可靠。
承诺矩阵与需求文档
需求文档描述「做什么」,矩阵描述「做到什么程度」。两者应该一起评审,而不是先写需求、上线前才补矩阵。
一个可操作的流程是:需求评审时,产品负责人当场填写功能行;技术负责人当场确认端列与承诺等级;如果某格填不出来,说明需求还没想清楚。
矩阵也能反向约束需求。产品提出「五个端完全一致」时,矩阵会立刻显示成本:identical 需要共享内核、契约测试、同步发布。如果这个功能不涉及资金与状态,团队可以有理有据地把它降为 equivalent。
需求文档里的「支持多端」是一句空话,矩阵里的五行三列才是承诺。把空话变成表格,是架构评审最有价值的一部分。
记录一次承诺变更
承诺变更要留下记录,格式不必复杂:
日期:2026-10-20
功能:离线查看订单
端:小程序
变更:allowed-diff -> equivalent
原因:本地缓存能力补齐,已验证断网可读
影响:小程序需要新增离线缓存测试
确认人:小程序端负责人、发布负责人
这段记录回答三个问题:改了什么、为什么改、谁确认。没有它,半年后就没人知道这一格为什么和别的端不同。
变更记录与代码放在一起,随版本走。它比会议纪要可靠,因为会议纪要不会在评审时被打开。
小结
多端架构的起点是承诺,不是共享。
- 承诺分
identical、equivalent、allowed-diff三级,只有资金与状态类行为值得identical。 - 矩阵要能被代码读取,缺口要能自动算出来,否则它只是一张过期的图。
- 承诺等级决定实现方式:
identical要求共享内核与契约测试,equivalent允许各端适配,allowed-diff要求明示降级路径。
先写矩阵,再谈共享代码。顺序反了,共享出来的只会是妥协。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 共享内核与端上适配层 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。