平台化验收,最怕开成组件数量汇报
某公司做平台化一年,验收会上展示了这些数字:组件库 46 个、共享包 12 个、覆盖 5 个端、接入 3 条业务线。看起来成果不少,但评审只问了三个问题:
- 哪条一致性承诺写进了发布门禁?
- 哪个组件契约在版本升级时真正拦下过不兼容改动?
- 如果明天一个核心维护者离职,谁能接手?
三个问题都没有当场答案。组件数量说明「做了东西」,不说明「平台能承接变更」。平台化验收要看的不是产出清单,而是六个维度的能力是否有证据、是否被执行、是否有责任人。
六个维度,每个都要证据
平台成熟度拆成六个维度。权重不是拍脑袋的重要度,而是出错后的修复成本:安全一旦失守,代价最高;承诺一旦失真,会直接变成资损或投诉。
| 维度 | 要回答的问题 | 证据形态 | 权重 |
|---|---|---|---|
| 承诺 | 一致性等级是否写进发布门禁? | 承诺矩阵、跨端契约用例 | 1.2 |
| 组件 | 契约、版本兼容与多品牌隔离是否可验证? | 契约测试、兼容矩阵 | 1.0 |
| 交付 | 多端发布、灰度与回滚是否统一? | 流水线记录、回滚演练 | 1.0 |
| 观测 | 最小字段契约与告警闭环是否覆盖五端? | 字段白名单、响应记录 | 1.0 |
| 安全 | 数据分级与第三方 SDK 是否登记? | 分级表、权限差异报告 | 1.3 |
| 治理 | ADR、所有权与技术债是否有责任人? | ADR 目录、评审覆盖率 | 1.1 |
每个条目打 0 到 5 分。分数不是感觉,而是有明确锚点:
| 分值 | 含义 |
|---|---|
| 0 | 没有开始,也没有负责人 |
| 1 | 有想法或文档草稿,未落地 |
| 2 | 在单个端或单条业务线试点 |
| 3 | 全端执行,但存在未登记的例外 |
| 4 | 全端执行,有证据,并进入发布门禁 |
| 5 | 自动执行,变更可追溯,例外必须审批 |
3 分与 4 分的差别最容易被夸大。「大家都在用」是 3 分,「不用就发不出去」才是 4 分。 验收时只认后者。
缺口排名与四道门槛
有了分数,还要能回答「先补哪一个」。缺口排名按分值从低到高排,同分时按维度顺序排:承诺、组件、交付、观测、安全、治理。这样做的原因是低分项的风险更直接,而承诺与组件是其他维度的上游。
验收门槛有四道,缺一不可:
- 维度覆盖:六个必需维度都不能为空,空维度直接阻断。
- 维度均值:每个维度的平均分不得低于 3。
- 单项底线:任何条目不得低于 3;刚好 3 分进入警告,提示下一轮改进。
- 证据与总分:每个条目必须有证据,加权总分不得低于 3.5。
这四道门槛分别防四种作弊:只做容易的维度、用高分条目拉平均、把 2 分说成「基本可用」、以及用「已经评审过」代替证据。
把矩阵写成代码
下面的模块把评分、缺口排名与验收门禁都做成纯函数。示例平台有 12 个条目,覆盖六个维度,其中主题隔离、SDK 登记与所有权表只有 2 分。
// file: src/summary/platform-matrix.ts
// tier: L1
// verify: node --test test/summary/platform-matrix.test.ts
export type DimensionId = "promise" | "component" | "delivery" | "observability" | "security" | "governance";
export const DIMENSIONS: DimensionId[] = [
"promise", "component", "delivery", "observability", "security", "governance",
];
export const DIMENSION_TITLES: Record<DimensionId, string> = {
promise: "承诺", component: "组件", delivery: "交付",
observability: "观测", security: "安全", governance: "治理",
};
const WEIGHTS: Record<DimensionId, number> = {
promise: 1.2, component: 1, delivery: 1, observability: 1, security: 1.3, governance: 1.1,
};
export type MaturityItem = {
id: string;
dimension: DimensionId;
title: string;
score: number;
evidence: string;
blocker?: boolean;
};
export type DimensionScore = {
dimension: DimensionId;
title: string;
count: number;
average: number;
min: number;
weak: string[];
};
export type GapSeverity = "blocker" | "major" | "minor";
export type Gap = {
id: string;
dimension: DimensionId;
title: string;
score: number;
severity: GapSeverity;
};
export type AcceptancePolicy = {
minDimensionAverage: number;
minItemScore: number;
minOverall: number;
requireEvidence: boolean;
requiredDimensions: DimensionId[];
};
export type AcceptanceResult = {
passed: boolean;
overall: number;
dimensions: DimensionScore[];
blockers: string[];
warnings: string[];
gaps: Gap[];
};
export const DEFAULT_POLICY: AcceptancePolicy = {
minDimensionAverage: 3, minItemScore: 3, minOverall: 3.5,
requireEvidence: true, requiredDimensions: DIMENSIONS,
};
export function validateItem(item: MaturityItem): string[] {
const issues: string[] = [];
if (item.id.trim() === "") issues.push("条目 id 不能为空");
if (!Number.isInteger(item.score) || item.score < 0 || item.score > 5) {
issues.push(`分值必须是 0 到 5 的整数:${item.id}`);
}
if (item.evidence.trim() === "") issues.push(`缺少证据:${item.id}`);
if (!DIMENSIONS.includes(item.dimension)) issues.push(`未知维度:${item.id}`);
return issues;
}
export function scoreDimension(
items: MaturityItem[],
dimension: DimensionId,
minItemScore = 3,
): DimensionScore {
const scoped = items.filter((item) => item.dimension === dimension);
return {
dimension,
title: DIMENSION_TITLES[dimension],
count: scoped.length,
average: scoped.length === 0 ? 0 : scoped.reduce((acc, item) => acc + item.score, 0) / scoped.length,
min: scoped.length === 0 ? 0 : Math.min(...scoped.map((item) => item.score)),
weak: scoped.filter((item) => item.score < minItemScore).map((item) => item.id),
};
}
export function maturityMatrix(items: MaturityItem[], minItemScore = 3): Record<DimensionId, DimensionScore> {
const matrix = {} as Record<DimensionId, DimensionScore>;
for (const dimension of DIMENSIONS) matrix[dimension] = scoreDimension(items, dimension, minItemScore);
return matrix;
}
export function dimensionAverages(items: MaturityItem[]): Record<DimensionId, number> {
const matrix = maturityMatrix(items);
const averages = {} as Record<DimensionId, number>;
for (const dimension of DIMENSIONS) averages[dimension] = matrix[dimension].average;
return averages;
}
export function weightedOverall(items: MaturityItem[]): number {
let weighted = 0;
let weight = 0;
for (const dimension of DIMENSIONS) {
const score = scoreDimension(items, dimension);
if (score.count === 0) continue;
weighted += score.average * WEIGHTS[dimension];
weight += WEIGHTS[dimension];
}
return weight === 0 ? 0 : Math.round((weighted / weight) * 100) / 100;
}
export function gapRanking(items: MaturityItem[], target = 4): Gap[] {
const order = new Map(DIMENSIONS.map((dimension, index) => [dimension, index]));
return items
.filter((item) => item.score < target)
.map((item) => ({
id: item.id,
dimension: item.dimension,
title: item.title,
score: item.score,
severity: item.score < 3 ? "blocker" : item.score === 3 ? "major" : "minor",
}))
.sort((a, b) => a.score - b.score ||
(order.get(a.dimension) ?? 99) - (order.get(b.dimension) ?? 99) ||
a.id.localeCompare(b.id));
}
export function acceptanceGate(items: MaturityItem[], policy: AcceptancePolicy = DEFAULT_POLICY): AcceptanceResult {
const blockers: string[] = [];
const warnings: string[] = [];
for (const item of items) {
for (const issue of validateItem(item)) blockers.push(`invalid:${item.id}:${issue}`);
}
for (const dimension of policy.requiredDimensions) {
const score = scoreDimension(items, dimension, policy.minItemScore);
if (score.count === 0) blockers.push(`missing-dimension:${dimension}`);
else if (score.average < policy.minDimensionAverage) {
blockers.push(`dimension-average:${dimension}=${score.average.toFixed(2)}`);
}
}
for (const item of items) {
if (item.score < policy.minItemScore) blockers.push(`item:${item.id}=${item.score}`);
else if (item.score === policy.minItemScore) warnings.push(`at-threshold:${item.id}`);
if (policy.requireEvidence && item.evidence.trim() === "") blockers.push(`evidence:${item.id}`);
}
const overall = weightedOverall(items);
if (overall < policy.minOverall) blockers.push(`overall:${overall.toFixed(2)}`);
return {
passed: blockers.length === 0,
overall,
dimensions: DIMENSIONS.map((dimension) => scoreDimension(items, dimension, policy.minItemScore)),
blockers: [...new Set(blockers)],
warnings: [...new Set(warnings)],
gaps: gapRanking(items),
};
}
export function topGaps(items: MaturityItem[], limit = 5): Gap[] {
return gapRanking(items).slice(0, limit);
}
const item = (
id: string, dimension: DimensionId, title: string, score: number, evidence: string,
): MaturityItem => ({ id, dimension, title, score, evidence });
export const PLATFORM_ITEMS: MaturityItem[] = [
item("promise-matrix", "promise", "五端一致性承诺矩阵", 4, "承诺矩阵评审记录与跨端契约用例"),
item("promise-enforcement", "promise", "承诺在发布门禁中强制执行", 3, "发布流水线中的字段断言结果"),
item("component-contract", "component", "组件契约与版本兼容规则", 4, "契约测试与版本兼容矩阵"),
item("theme-isolation", "component", "多品牌主题隔离", 2, "主题变量泄漏用例"),
item("release-pipeline", "delivery", "多端统一发布流水线", 4, "流水线运行记录与灰度比例"),
item("rollback-drill", "delivery", "灰度回滚演练", 3, "最近一次回滚演练报告"),
item("field-contract", "observability", "端上观测字段最小契约", 4, "字段白名单与采样配置"),
item("alert-loop", "observability", "从告警到定位的闭环", 3, "告警响应时长与复盘记录"),
item("data-classification", "security", "数据分级与采集白名单", 3, "数据分级表与采集评审"),
item("sdk-registry", "security", "第三方 SDK 登记与下线", 2, "SDK 清单与权限差异报告"),
item("adr-log", "governance", "架构决策记录", 3, "ADR 目录与最近三次决策"),
item("ownership-map", "governance", "代码所有权与评审责任", 2, "所有权表与评审覆盖率"),
];
判据与反例
示例平台的加权总分是 3.06,低于 3.5 的总分门槛;安全与治理两个维度均值只有 2.50,也低于维度门槛;三个 2 分条目触发单项底线。门禁因此给出阻断,并把改进顺序排成:主题隔离 → SDK 登记 → 所有权表。
两个反例说明了为什么要四道门槛一起用。
反例一:平均分掩盖单项失守。 某维度两个条目 5 分、一个条目 1 分,平均 3.67,看起来达标。但那个 1 分可能就是支付回调的幂等校验。只看均值,平台会带着一个致命缺口通过验收;单项底线就是为了拦住这种情况。
反例二:证据写成态度。 「已评审」「已确认」「团队都清楚」不是证据。证据必须是可复查的产物:一份承诺矩阵、一条流水线记录、一次回滚演练报告。模块把空证据直接计为阻断,而不是扣分了事。
还有一个边界:权重不是用来「调到达标」的。如果调整权重后平台刚好过线,说明分数本身不可信,应该先修证据,而不是修权重。
小结
平台化验收用六个维度回答「平台能不能承接变更」:承诺、组件、交付、观测、安全、治理。每个条目 0 到 5 分,必须有证据;缺口按分数排序;验收同时检查维度覆盖、维度均值、单项底线与加权总分。
这套矩阵的价值不在于给出一个漂亮分数,而在于让「先补什么」有依据。当平台拒绝通过验收时,它同时给出了下一轮的工作顺序。
阅读导航:上一节:13.1 端到端:多端同一笔交易的体验一致性 · 下一节:13.3 全书收官:四卷回顾与下一站 。
常见错误与边界
- 用组件数量、共享包数量代替平台能力验收。
- 六个维度只挑容易的做,安全与治理长期缺位。
- 把「全端都在用」当成 4 分,实际没有进入发布门禁。
- 只看维度平均分,不设单项底线,1 分项被高分项拉平。
- 证据写成「已评审」「已确认」,无法复查。
- 为了让平台过线而调整权重,而不是补证据。
- 缺口排名只按分数排,忽略承诺与组件是下游维度的上游。
- 验收一次就结束,没有把矩阵接进每个版本的发布流程。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。