自助化的边界
几十个团队共用一个事件平台,最忌讳的做法是让每个人都写邮件申请、每个人都找架构师审批。审批是有成本的——架构师的时间成本、开发者的等待成本、平台运营的状态同步成本。自助化的目标不是「不要审批」,而是把审批放到成本最低的节点。
自助平台的边界是:开发者能自己做的自己做(建主题命名、声明消费者),机器能拦的机器拦(命名违规、权限越级),只有确实需要人判断的才交给人(安全审计、跨域数据共享)。
工单的四阶段流转
一个事件主题的创建申请,最少可以只走两个阶段(申请→技术初审),但完整流程有四阶段:
| 阶段 | 名称 | 谁执行 | 检查什么 | 拒绝条件 |
|---|---|---|---|---|
| 1 | INIT(申请) | 开发者 | 填写主题名、域归属、初始消费者 | 命名规范不通过 |
| 2 | TECH_REVIEW | 技术审核员 | 消费者是否已声明、投影消费者是否幂等 | 消费者未声明/未幂等 |
| 3 | SECURITY | 安全审计 | 是否跨域消费、是否包含敏感字段 | 跨域审批未通过 |
| 4 | CREATED | 平台 | 创建主题、配置配额、注册到事件目录 | — |
关键设计:拒绝条件必须在前置阶段拦掉。如果 INIT 阶段就发现命名违规,不要让工单走到 TECH_REVIEW 再拒绝——那样浪费了审核员的时间。
纯 Java 工作流模拟
下面的 PlatformSelfService 模拟工单在四个阶段的流转,包括自动拒绝、等待人工审核和强制拦截。
// PlatformSelfService.java — 事件平台自助化工单状态机
import java.util.*;
public class PlatformSelfService {
enum Stage { INIT, TECH_REVIEW, SECURITY, CREATED, REJECTED }
enum Role { DEVELOPER, TECH_REVIEWER, SECURITY_AUDITOR }
record Ticket(String id, String topicName, String domain,
String consumerName, boolean declaredIdempotent,
boolean crossDomain, boolean hasSensitiveField) {
boolean namingValid() {
// domain.event.action.vN 格式
return topicName.matches("^[a-z]+\\.[a-z]+\\.[a-z]+\\.v[0-9]+$");
}
boolean consumersDeclared() {
return consumerName != null && !consumerName.isEmpty();
}
}
static final Map<String, Set<String>> DOMAIN_PERMISSIONS = Map.of(
"library", Set.of("library", "member"),
"member", Set.of("member"),
"order", Set.of("order", "payment", "inventory"),
"payment", Set.of("payment")
);
static Stage next(Stage current, Ticket ticket, Role actor) {
switch (current) {
case INIT -> {
// INIT 阶段:命名规范必须过,否则直接拒绝(成本最低)
if (!ticket.namingValid()) {
System.out.println(" [INIT] 拒绝:命名不规范 " + ticket.topicName());
return Stage.REJECTED;
}
// INIT 阶段:权限越级也直接拒绝
if (!DOMAIN_PERMISSIONS.getOrDefault(ticket.domain(), Set.of())
.contains(ticket.domain())) {
System.out.println(" [INIT] 拒绝:域 " + ticket.domain() + " 无权限");
return Stage.REJECTED;
}
return Stage.TECH_REVIEW;
}
case TECH_REVIEW -> {
if (actor != Role.TECH_REVIEWER) {
System.out.println(" [TECH_REVIEW] 等待人工审核员处理");
return Stage.TECH_REVIEW; // 卡住,等待正确角色
}
if (!ticket.consumersDeclared()) {
System.out.println(" [TECH_REVIEW] 拒绝:消费者未声明");
return Stage.REJECTED;
}
if (!ticket.declaredIdempotent() && ticket.consumerName().contains("projection")) {
System.out.println(" [TECH_REVIEW] 拒绝:投影消费者未声明幂等");
return Stage.REJECTED;
}
return Stage.SECURITY;
}
case SECURITY -> {
if (actor != Role.SECURITY_AUDITOR) {
System.out.println(" [SECURITY] 等待安全审计员处理");
return Stage.SECURITY;
}
if (ticket.crossDomain() && !ticket.hasSensitiveField()) {
// 跨域但不涉及敏感字段,可自动通过
System.out.println(" [SECURITY] 自动通过:跨域但无敏感字段");
return Stage.CREATED;
}
if (ticket.hasSensitiveField()) {
System.out.println(" [SECURITY] 拒绝:包含敏感字段须人工评审");
return Stage.REJECTED;
}
return Stage.CREATED;
}
case CREATED -> {
System.out.println(" [CREATED] 主题 " + ticket.topicName() + " 已创建");
return Stage.CREATED;
}
default -> { return Stage.REJECTED; }
}
}
static void simulate(Ticket ticket, List<Role> actions) {
Stage s = Stage.INIT;
System.out.println("\n工单 " + ticket.id() + " 主题=" + ticket.topicName());
for (Role action : actions) {
Stage prev = s;
s = next(s, ticket, action);
System.out.println(" 角色=" + action + " : " + prev + " -> " + s);
if (s == Stage.REJECTED || s == Stage.CREATED) break;
}
System.out.println(" 最终状态: " + s);
}
public static void main(String[] args) {
// 场景1:正常通过
Ticket t1 = new Ticket("T-001", "library.loan.created.v1",
"library", "loan-projection", true, false, false);
simulate(t1, List.of(Role.DEVELOPER, Role.TECH_REVIEWER, Role.SECURITY_AUDITOR));
// 场景2:命名违规,INIT 直接拒绝
Ticket t2 = new Ticket("T-002", "loan-created",
"library", "consumer-a", true, false, false);
simulate(t2, List.of(Role.DEVELOPER));
// 场景3:投影消费者未声明幂等
Ticket t3 = new Ticket("T-003", "library.stock.adjusted.v1",
"library", "stock-projection", false, false, false);
simulate(t3, List.of(Role.DEVELOPER, Role.TECH_REVIEWER));
// 场景4:跨域消费 + 敏感字段 → 安全审计拒绝
Ticket t4 = new Ticket("T-004", "payment.order.completed.v1",
"payment", "order-sync", true, true, true);
simulate(t4, List.of(Role.DEVELOPER, Role.TECH_REVIEWER, Role.SECURITY_AUDITOR));
// 场景5:跨域但无敏感字段 → 安全自动通过
Ticket t5 = new Ticket("T-005", "order.inventory.reserved.v1",
"order", "inventory-consumer", true, true, false);
simulate(t5, List.of(Role.DEVELOPER, Role.TECH_REVIEWER, Role.SECURITY_AUDITOR));
}
}
本机实测(Temurin 25.0.5+8 / 2026-10-29):
javac --release 21 PlatformSelfService.java && java PlatformSelfService
工单 T-001 主题=library.loan.created.v1
角色=DEVELOPER : INIT -> TECH_REVIEW
角色=TECH_REVIEWER : TECH_REVIEW -> SECURITY
角色=SECURITY_AUDITOR : SECURITY -> CREATED
最终状态: CREATED
工单 T-002 主题=loan-created
[INIT] 拒绝:命名不规范 loan-created
角色=DEVELOPER : INIT -> REJECTED
最终状态: REJECTED
工单 T-003 主题=library.stock.adjusted.v1
角色=DEVELOPER : INIT -> TECH_REVIEW
[TECH_REVIEW] 拒绝:投影消费者未声明幂等
角色=TECH_REVIEWER : TECH_REVIEW -> REJECTED
最终状态: REJECTED
工单 T-004 主题=payment.order.completed.v1
角色=DEVELOPER : INIT -> TECH_REVIEW
角色=TECH_REVIEWER : TECH_REVIEW -> SECURITY
[SECURITY] 拒绝:包含敏感字段须人工评审
角色=SECURITY_AUDITOR : SECURITY -> REJECTED
最终状态: REJECTED
工单 T-005 主题=order.inventory.reserved.v1
角色=DEVELOPER : INIT -> TECH_REVIEW
角色=TECH_REVIEWER : TECH_REVIEW -> SECURITY
[SECURITY] 自动通过:跨域但无敏感字段
角色=SECURITY_AUDITOR : SECURITY -> CREATED
最终状态: CREATED
审批点的最优位置
三个被拒绝的工单展示了不同的拦截时机:
| 工单 | 拒绝时机 | 拒绝理由 | 为什么在这里拦 |
|---|---|---|---|
| T-002 | INIT | 命名不规范 | 开发者自己就能判断,不需要任何人的时间投入 |
| T-003 | TECH_REVIEW | 投影未声明幂等 | 需要技术审核员对消费模式有判断力,开发者未必清楚 |
| T-004 | SECURITY | 敏感字段跨域 | 必须由安全审计员判断合规性,前两阶段无权决策 |
审批点的最优位置不是每个环节都审,而是把拒绝条件前置到成本最低的节点。 INIT 阶段的命名校验是零成本的——开发者提交前系统就跑完了正则。TECH_REVIEW 需要审核员人工判断(成本中等),SECURITY 需要安全专家(成本最高)。T-002 如果在 SECURITY 阶段再拒绝,等于前面两个审核阶段的时间全部被浪费。
现实中的自助化平台应该把 INIT 阶段的校验做成"实时反馈"——开发者在填写主题名时,输入框旁边立即显示命名是否合规,不等提交就拦掉,把成本降到接近零。
最小审批原则的应用
最小审批原则可以总结为三条规则:
- 机器能拦的先拦:命名规范、配额检查、权限校验,全部自动化,零人工。
- 业务团队能审的先审:技术初审交给业务线资深开发,不上升到平台架构师。
- 安全只审安全:安全审计只看数据合规,不看技术实现,也不看业务逻辑。
表皮是自助,内核是分权。每个角色的审批范围被精确缩小,审批效率才能提升。
事件目录的自注册
工单到达 CREATED 状态时,平台应该自动把主题注册到事件目录(2.3 节定义的结构):
# 自动生成的目录条目示例
name: library.loan.created
version: "1.0.0"
topic: library.loan.created.v1
domain: library
producers:
- service: loan-service
contact: team-library@example.com
consumers:
- service: loan-projection
declaredIdempotent: true
since: "2026-10-29"
createdBy: T-001
注册后,事件血缘(10.3 节)和配额管理(11.3 节)才能自动关联到这个主题。没有注册的主题不能出现在血缘图和配额报表里。
为什么技术初审必须由平台组负责
把审批权下放给各自业务线看似高效,但实际上会导致命名规范不统一、安全配置差异化和孤立主题泛滥。平台组负责初审不是为了官僚,而是维护全局一致性。
理想的审批流程是:平台组做技术初审(命名是否合规、权限是否合理、分区数是否匹配吞吐量),安全组做隐私初审(事件是否包含 PII、是否需要加密传输、保留期是否合规),业务线内部做业务初审(事件语义是否正确、消费者列表是否完整)。
三层审批各审一层,互不争抢。如果让一个人同时审三层,要么审不透,要么审不完。分层的本质是把拒绝条件拆到对应的专业组,把单点审批变成流水线。
本节小结
自助化的边界:开发者能做的自己做,机器能拦的机器拦,只有确实需要人判断的才交给人。
工单四阶段:INIT(命名/权限自动检查)→ TECH_REVIEW(消费者声明/幂等检查)→ SECURITY(跨域/敏感数据)→ CREATED(自动注册目录)。
拒绝条件前置:命名违规在 INIT 拦截(零成本),技术缺陷在 TECH_REVIEW 拦截(中等成本),安全问题在 SECURITY 拦截(最高成本)。
审批点的最优位置不是"每个环节都审",而是"把拒绝条件前置到成本最低的节点"。
实时命名校验可以把 INIT 阶段的拒绝成本降到接近零。
工单在 CREATED 后必须自动注册到事件目录,否则血缘和配额无法追踪。
最小审批原则三条规则:机器先拦、业务线先审、安全只审安全。
自助化的本质是分权,不是放权——每个角色的审批范围必须被精确缩小。
自助化平台必须提供异常工单通道,特殊场景不走标准流程,但需要更高层级的审批。
审批平均耗时和使用频率应作为平台治理的核心 KPI——流程本身也需要被治理。
工单系统自身的事件(如 “工单创建”、“审批通过”)也应该发到 Kafka,这样才能监控流程的健康度。
自助化平台上线三个月后应做一次回访,统计最常见的拒绝原因并优化自动拦截规则。
工单的历史数据是平台治理的宝贵输入:哪些团队的命名习惯最容易违规、哪些消费者的幂等声明总是不全,这些数据指导平台和规范迭代。
工单系统的 UI 设计应该让开发者看到当前工单在哪个阶段、被谁卡住、预计等待时间,而不是只显示"处理中"。
阅读导航:上一节 10.3 事件血缘与影响分析 | 下一节 11.2 Topic 命名与生命周期
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。