《互联网产品的客户端架构·规模化与架构演进》13.2 平台化验收:能力矩阵与治理清单

平台化不是把组件数量堆上去,而是让承诺、组件、交付、观测、安全与治理六个维度都能拿出证据。本节用成熟度矩阵给每个条目打分,按缺口排名生成改进顺序,再用维度均值、单项底线、证据要求与加权总分四道门槛完成验收,并说明为什么平均分与权重都不能代替单项底线。

平台化验收,最怕开成组件数量汇报

某公司做平台化一年,验收会上展示了这些数字:组件库 46 个、共享包 12 个、覆盖 5 个端、接入 3 条业务线。看起来成果不少,但评审只问了三个问题:

  1. 哪条一致性承诺写进了发布门禁?
  2. 哪个组件契约在版本升级时真正拦下过不兼容改动?
  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 分。 验收时只认后者。

缺口排名与四道门槛

有了分数,还要能回答「先补哪一个」。缺口排名按分值从低到高排,同分时按维度顺序排:承诺、组件、交付、观测、安全、治理。这样做的原因是低分项的风险更直接,而承诺与组件是其他维度的上游。

验收门槛有四道,缺一不可:

  1. 维度覆盖:六个必需维度都不能为空,空维度直接阻断。
  2. 维度均值:每个维度的平均分不得低于 3。
  3. 单项底线:任何条目不得低于 3;刚好 3 分进入警告,提示下一轮改进。
  4. 证据与总分:每个条目必须有证据,加权总分不得低于 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 分项被高分项拉平。
  • 证据写成「已评审」「已确认」,无法复查。
  • 为了让平台过线而调整权重,而不是补证据。
  • 缺口排名只按分数排,忽略承诺与组件是下游维度的上游。
  • 验收一次就结束,没有把矩阵接进每个版本的发布流程。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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