三个平台都接了一个部署需求
2026 年 10 月,arch-lab 的订单团队要给一个新服务接部署流水线。他们先找 CI 组,CI 组说流水线模板归交付平台;交付平台说运行环境归基础设施;基础设施说发布审批归 SRE。三张工单最后各交付了一段:CI 给了 Jenkinsfile,基础设施给了命名空间,SRE 给了审批表。没有一张工单对“新服务两周内能安全上线”负责。
这不是协作态度问题,而是平台没有产品边界。每个团队都在自己的能力范围内接单,但没有人定义这项能力的入口、出口、非目标和唯一责任人。平台工程最常见的失败不是技术选型错误,而是把平台做成了工单队列:需求来了就接,接了就定制,定制完无法复用。平台工程 讨论过平台团队的组织形态,最小治理 讨论过决策权;本节只回答一个更靠前的问题:一项能力凭什么算“平台产品”。
能力目录先写“不做什么”
平台即产品,第一个交付物不是控制台,而是一份可评审的能力目录。每项能力至少要有唯一 ID、负责人、服务等级、交付 tier、生命周期状态、消费方、进入条件、退出条件、非目标、依赖项和退役替换。
| 字段 | 缺失时的典型后果 | 本节校验规则 |
|---|---|---|
| 负责人 | 出问题无人拍板,工单在团队间转 | 必填 |
| 进入/退出条件 | 谁都能用,谁都不知道怎么下线 | 两者都必填 |
| 非目标 | 同一类定制需求反复出现 | 至少一条 |
| 生命周期 | 计划中的能力被当成已上线 | planned 不得列消费方 |
| 退役替换 | 宣布退役却没有迁移目的地 | deprecated/retired 必须指向可用替代 |
| 依赖 | 目录内部成环,边界没有拆清 | 未知依赖与环都报错 |
非目标这一栏最容易被省略,也最能区分产品与项目。“部署平台”如果不写“不负责数据库迁移”,每个新服务都会来问一次;写了之后,拒绝就有了依据,而不是靠某个人当天的心情。退役替换同理:只宣布退役、不给出 GA 或 beta 的替代品,等于把迁移成本原样推给消费方。
能力之间还有依赖。部署依赖数据库,数据库依赖网络;目录里出现环,通常说明两个团队仍在共享同一份职责。本节用深度优先遍历检测环,而不是等到排期时才发现“谁都等谁”。
需求路由:五种结果,不是“接单/不接单”
有了目录,平台才能对需求做路由,而不是对每个请求都开一个新项目。Decide 只看四个输入:请求团队、目标能力、是否需要自助、是否新消费方。
| 请求信号 | 路由 | 含义 | 后续动作 |
|---|---|---|---|
| 没有对应能力 | build | 承认目录有空白 | 进入平台产品待办,而不是悄悄定制 |
| 能力已退役或弃用 | migrate | 旧入口不再扩展 | 返回替代能力,迁移列入双方计划 |
| 能力仍是 planned | wait | 未发布能力不承诺日期 | 登记需求,不占用交付资源 |
| 需要自助但 tier 不支持 | extend | 平台自己的产品缺口 | 补自助化,不是给单团队开特例 |
| ga/beta 且匹配 | adopt | 正常消费 | beta 新消费方需负责人签字 |
用户旅程要用同一只时钟
能力是否存在,和团队能否顺利使用,是两件事。arch-lab 把一次能力采纳拆成发现、申请、审批、开通、验证五个阶段,每阶段记录进入时间、离开时间和是否人工交接。
两个指标比总时长更有用。第一是空闲时间:阶段之间的等待不属于任何阶段,却往往是 lead time 的主要部分;第二是人工交接次数,每次交接都可能丢上下文。旅程统计必须传入一个固定的 now,否则同一份数据在不同时间跑出不同结果,无法比较。
同包的 journey.go 用显式 now 计算总时长、活动时间、空闲时间、最慢阶段和人工交接数。固定时钟测试的结果是:总时长 65 分钟,活动 50 分钟,排队 15 分钟,最慢阶段是人工审批的 20 分钟。这个分解直接指出该优化审批,而不是笼统地说“平台慢”。
可运行实现:能力目录与需求路由
下面的 catalog 部分只依赖标准库。Validate 返回全部违规而不是第一条;Decide 不修改目录,只返回路由与理由。
// file: internal/platform/idp/catalog.go
// tier: L1
// verify: go test ./internal/platform/idp/ -count=1
package idp
import (
"errors"
"fmt"
"strings"
)
type CapabilityTier string
const (
TierSelfService CapabilityTier = "self-service"
TierSupported CapabilityTier = "supported"
TierConsulting CapabilityTier = "consulting"
)
type CapabilityStatus string
const (
StatusPlanned CapabilityStatus = "planned"
StatusBeta CapabilityStatus = "beta"
StatusGA CapabilityStatus = "ga"
StatusDeprecated CapabilityStatus = "deprecated"
StatusRetired CapabilityStatus = "retired"
)
var (
validTiers = map[CapabilityTier]bool{TierSelfService: true, TierSupported: true, TierConsulting: true}
validStates = map[CapabilityStatus]bool{StatusPlanned: true, StatusBeta: true,
StatusGA: true, StatusDeprecated: true, StatusRetired: true}
)
type Capability struct {
ID, Name, Owner string
Tier CapabilityTier
Status CapabilityStatus
Consumers []string
SLA, Entry, Exit string
NonGoals, DependsOn []string
Replacement string
}
type Catalog struct {
Version string
Capabilities []Capability
}
var (
ErrUnknownCapability = errors.New("idp: unknown capability")
ErrInvalidRequest = errors.New("idp: invalid request")
)
func blank(s string) bool { return strings.TrimSpace(s) == "" }
func (c Catalog) find(id string) (Capability, bool) {
for _, cap := range c.Capabilities {
if cap.ID == id {
return cap, true
}
}
return Capability{}, false
}
func (c Catalog) Validate() []error {
var errs []error
if blank(c.Version) {
errs = append(errs, errors.New("catalog: version is empty"))
}
byID := make(map[string]Capability, len(c.Capabilities))
for i, cap := range c.Capabilities {
where := fmt.Sprintf("capability[%d]", i)
if blank(cap.ID) {
errs = append(errs, fmt.Errorf("%s: id is empty", where))
continue
}
where = fmt.Sprintf("capability %q", cap.ID)
if _, dup := byID[cap.ID]; dup {
errs = append(errs, fmt.Errorf("%s: duplicate id", where))
continue
}
byID[cap.ID] = cap
switch {
case blank(cap.Name) || blank(cap.Owner) || blank(cap.SLA) || blank(cap.Entry) || blank(cap.Exit):
errs = append(errs, fmt.Errorf("%s: name, owner, sla, entry and exit are required", where))
case !validTiers[cap.Tier]:
errs = append(errs, fmt.Errorf("%s: invalid tier %q", where, cap.Tier))
case !validStates[cap.Status]:
errs = append(errs, fmt.Errorf("%s: invalid status %q", where, cap.Status))
case len(cap.NonGoals) == 0:
errs = append(errs, fmt.Errorf("%s: no non-goals declared", where))
case cap.Status == StatusPlanned && len(cap.Consumers) > 0:
errs = append(errs, fmt.Errorf("%s: planned capability already lists consumers", where))
case (cap.Status == StatusDeprecated || cap.Status == StatusRetired) && blank(cap.Replacement):
errs = append(errs, fmt.Errorf("%s: %s without replacement", where, cap.Status))
}
}
for _, cap := range c.Capabilities {
if cap.ID == "" {
continue
}
for _, dep := range cap.DependsOn {
if _, ok := byID[dep]; !ok {
errs = append(errs, fmt.Errorf("capability %q: unknown dependency %q", cap.ID, dep))
}
}
if cap.Replacement != "" {
repl, ok := byID[cap.Replacement]
if !ok {
errs = append(errs, fmt.Errorf("capability %q: unknown replacement %q", cap.ID, cap.Replacement))
} else if repl.Status != StatusGA && repl.Status != StatusBeta {
errs = append(errs, fmt.Errorf("capability %q: replacement %q is not available", cap.ID, repl.ID))
}
}
}
color := make(map[string]int, len(byID))
var visit func(string, []string)
visit = func(id string, path []string) {
color[id] = 1
for _, dep := range byID[id].DependsOn {
if _, ok := byID[dep]; !ok {
continue
}
if color[dep] == 1 {
errs = append(errs, fmt.Errorf("catalog: dependency cycle: %s", strings.Join(append(path, dep), " -> ")))
} else if color[dep] == 0 {
visit(dep, append(path, dep))
}
}
color[id] = 2
}
for _, cap := range c.Capabilities {
if cap.ID != "" && color[cap.ID] == 0 {
visit(cap.ID, []string{cap.ID})
}
}
return errs
}
type FitRequest struct {
Team string
CapabilityID string
NeedsSelfService bool
NewConsumer bool
}
type Decision string
const (
DecisionAdopt Decision = "adopt"
DecisionExtend Decision = "extend"
DecisionBuild Decision = "build"
DecisionMigrate Decision = "migrate"
DecisionWait Decision = "wait"
)
type DecisionResult struct {
Decision Decision
Target string
Reason string
}
func (c Catalog) Decide(req FitRequest) (DecisionResult, error) {
if blank(req.Team) {
return DecisionResult{}, fmt.Errorf("%w: team is empty", ErrInvalidRequest)
}
if blank(req.CapabilityID) {
return DecisionResult{Decision: DecisionBuild, Reason: "no capability covers the need"}, nil
}
cap, ok := c.find(req.CapabilityID)
if !ok {
return DecisionResult{}, fmt.Errorf("%w: %s", ErrUnknownCapability, req.CapabilityID)
}
switch cap.Status {
case StatusRetired, StatusDeprecated:
return DecisionResult{Decision: DecisionMigrate, Target: cap.Replacement, Reason: "capability is being retired"}, nil
case StatusPlanned:
return DecisionResult{Decision: DecisionWait, Target: cap.ID, Reason: "capability is not available yet"}, nil
}
if req.NeedsSelfService && cap.Tier != TierSelfService {
return DecisionResult{Decision: DecisionExtend, Target: cap.ID, Reason: "self-service requested, tier is " + string(cap.Tier)}, nil
}
if req.NewConsumer && cap.Status == StatusBeta {
return DecisionResult{Decision: DecisionAdopt, Target: cap.ID, Reason: "beta adoption needs owner sign-off"}, nil
}
return DecisionResult{Decision: DecisionAdopt, Target: cap.ID, Reason: "capability matches request"}, nil
}
Validate 的检查顺序刻意把“结构错误”和“依赖错误”分开:前者在单条能力上就能判断,后者需要完整目录。这样一条错误不会掩盖另一条,评审时能一次改完。Decide 先判断能力是否存在,再判断生命周期,最后才看 tier;顺序反了会把“已退役”误判成“需要自助化”。
测试:五种边界违规与八种路由
测试用一张最小目录覆盖 GA、退役、planned、beta 四种状态,并逐个注入非目标缺失、重复 ID、依赖环、退役无替换、planned 带消费方五类错误。
// file: internal/platform/idp/catalog_test.go
// tier: L1
// verify: go test ./internal/platform/idp/ -count=1 -run 'TestCatalog|TestDecide|TestJourney'
package idp
import (
"errors"
"strings"
"testing"
)
func capOf(id string, tier CapabilityTier, status CapabilityStatus) Capability {
return Capability{ID: id, Name: id, Owner: "platform", Tier: tier, Status: status,
SLA: "99.9%", Entry: "team owns a schema", Exit: "export and delete",
NonGoals: []string{"analytics queries"}}
}
func validCatalog() Catalog {
c := Catalog{Version: "2026-11-01", Capabilities: []Capability{
capOf("cap-db", TierSelfService, StatusGA),
capOf("cap-deploy", TierSupported, StatusGA),
capOf("cap-legacy", TierConsulting, StatusRetired),
capOf("cap-queue", TierSelfService, StatusPlanned),
capOf("cap-cache", TierSelfService, StatusBeta),
}}
c.Capabilities[1].DependsOn = []string{"cap-db"}
c.Capabilities[1].Consumers = []string{"order"}
c.Capabilities[2].Replacement = "cap-deploy"
return c
}
func TestCatalogValidate(t *testing.T) {
if errs := validCatalog().Validate(); len(errs) != 0 {
t.Fatalf("valid catalog: %v", errs)
}
cases := []struct {
name string
mut func(*Catalog)
want string
}{
{"no_non_goals", func(c *Catalog) { c.Capabilities[0].NonGoals = nil }, "no non-goals"},
{"duplicate", func(c *Catalog) { c.Capabilities[3].ID = "cap-db" }, "duplicate id"},
{"cycle", func(c *Catalog) { c.Capabilities[0].DependsOn = []string{"cap-deploy"} }, "dependency cycle"},
{"retired_without_replacement", func(c *Catalog) { c.Capabilities[2].Replacement = "" }, "retired without replacement"},
{"planned_with_consumers", func(c *Catalog) { c.Capabilities[3].Consumers = []string{"order"} }, "planned capability"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := validCatalog()
tc.mut(&c)
for _, err := range c.Validate() {
if strings.Contains(err.Error(), tc.want) {
t.Logf("%s -> %v", tc.name, err)
return
}
}
t.Fatalf("want error containing %q, got %v", tc.want, c.Validate())
})
}
}
func TestDecide(t *testing.T) {
c := validCatalog()
cases := []struct {
name string
req FitRequest
want Decision
err error
}{
{"no_team", FitRequest{CapabilityID: "cap-db"}, "", ErrInvalidRequest},
{"no_capability", FitRequest{Team: "order"}, DecisionBuild, nil},
{"unknown", FitRequest{Team: "order", CapabilityID: "cap-nope"}, "", ErrUnknownCapability},
{"retired", FitRequest{Team: "order", CapabilityID: "cap-legacy"}, DecisionMigrate, nil},
{"planned", FitRequest{Team: "order", CapabilityID: "cap-queue"}, DecisionWait, nil},
{"needs_self_service", FitRequest{Team: "order", CapabilityID: "cap-deploy", NeedsSelfService: true}, DecisionExtend, nil},
{"beta_signoff", FitRequest{Team: "order", CapabilityID: "cap-cache", NewConsumer: true}, DecisionAdopt, nil},
{"adopt", FitRequest{Team: "order", CapabilityID: "cap-db"}, DecisionAdopt, nil},
}
for _, tc := range cases {
got, err := c.Decide(tc.req)
if !errors.Is(err, tc.err) || got.Decision != tc.want {
t.Fatalf("%s: got %+v, %v; want %s, %v", tc.name, got, err, tc.want, tc.err)
}
t.Logf("%s -> %s %s (%s)", tc.name, got.Decision, got.Target, got.Reason)
}
}
本机实测
环境为 macOS darwin/arm64、Go 1.26.0。命令与原始输出节选如下,完整日志保存在 .local/reports/be-arch-gov/ch11-full.txt。
$ gofmt -l .
$ go vet ./...
$ go test ./internal/platform/idp/ -count=1 -v -run 'TestCatalog|TestDecide|TestJourney'
=== RUN TestCatalogValidate
catalog_test.go:53: duplicate -> [capability "cap-db": duplicate id]
catalog_test.go:53: cycle -> [catalog: dependency cycle: cap-db -> cap-deploy -> cap-db]
catalog_test.go:53: retired_without_replacement -> [capability "cap-legacy": retired without replacement]
catalog_test.go:53: planned_with_consumers -> [capability "cap-queue": planned capability already lists consumers]
--- PASS: TestCatalogValidate (0.00s)
=== RUN TestDecide
catalog_test.go:84: no_capability -> build (no capability covers the need)
catalog_test.go:84: retired -> migrate cap-deploy (capability is being retired)
catalog_test.go:84: planned -> wait cap-queue (capability is not available yet)
catalog_test.go:84: needs_self_service -> extend cap-deploy (self-service requested, tier is supported)
catalog_test.go:84: beta_signoff -> adopt cap-cache (beta adoption needs owner sign-off)
--- PASS: TestDecide (0.00s)
=== RUN TestJourneyAssess
journey_test.go:30: total=1h5m0s active=50m0s idle=15m0s worst=approve(20m0s) manual=1
--- PASS: TestJourneyAssess (0.00s)
PASS
ok archlab/internal/platform/idp 0.460s
这些数字的边界也要说清楚:目录校验是纯内存、单进程的结构检查,不验证 SLA 是否可达,也不验证负责人是否真的在岗;旅程时长来自固定构造的时间点,不代表真实工单的分布。生产上应把真实工单的时间戳导入同一结构重跑,而不是引用本节的合成值。
常见错误与边界
- 把能力目录写成服务清单。服务清单回答“有哪些进程”,能力目录回答“谁能为哪类需求负责到什么程度”,两者不能互相替代。
- 只写目标不写非目标。没有非目标,能力边界会随着每个新需求扩张,最后又回到工单队列。
- 用 planned 能力接真实流量。计划中的能力没有 SLA 和退出路径,提前消费会把平台的产品待办变成生产事故。
- 退役只发公告不给替代。没有 GA/beta 替换目标时,
migrate路由无法落地,消费方只能继续用旧能力。
小结
平台即产品的第一件事,是把“我们提供什么”写成带负责人、SLA、进入退出条件、非目标和依赖的能力目录。目录不是文档装饰,它让 build、extend、migrate、wait、adopt 五种路由有据可依,也让“不接这个需求”成为产品决策而不是个人态度。
用户旅程则补上另一半:能力存在不等于可用。用固定时钟把总时长拆成活动、空闲和最慢阶段,才能看出该优化审批、开通还是文档。下一节把目录里的某项能力落成黄金路径模板,讨论如何把工单变成一条可执行、可回滚的自助状态机。
阅读导航:上一节:10.3 密钥、审计与合规边界 · 下一节:11.2 黄金路径与自助服务:从工单到模板 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。