这是 AI 开发阶段的一篇:讲我怎么用一道提交前门禁,守住编译器和 lint 都管不着的「跨层接缝」。它属于我把 AI 协同研发做成工程体系的一部分,也就是契约门禁。
我最怕的 bug,从来不是某个函数写错,而是那种「编译器管不着、lint 管不着、只有跑到线上某条真实路径才炸」的跨层错误。
举个例子。前端调一个 /api/orders/detail,后端最近重构,路由注解改成了 /api/order/detail,少了个 s。结果就是:编译两边都绿,单测各自模块都过,然后某个周二下午运营说小程序白屏了。
这类 bug 的特点:每一层看着都对,错在层与层的「接缝」上。编译器只保证语法契约成立,保证不了「前端发的请求」真的能落到「后端注册的那个路由」上。
这件事我专门写过《编译能过 ≠ 代码正确》------一次 270 行并发守卫被静默删掉的事故,讲的就是同类的跨层盲区。所以我在提交前架了四道「契约闸」。

错在层与层的接缝上
先讲清楚「跨层契约」到底指什么------它有四个典型类别,每一类都曾让我们吃过亏:
| 跨层契约 | 事实源(权威方) | 被校验对象 | 编译器能管吗 | 典型事故 |
|---|---|---|---|---|
| URL ↔ 路由 | 后端 @RequestMapping |
前端 axios 调用 | ❌ | 路由改名少个 s,前端 404 |
| SQL ↔ DDL | Flyway 建表 DDL | Mapper 里的 SQL | ❌ | 表名拼错,运行时 NPE |
| 权限 key ↔ 种子数据 | 迁移脚本的权限 seed | 代码里的 @RequirePermission |
❌ | key 拼错,权限等于没配 |
| 迁移版本号唯一 | 文件命名约定 | 所有 V{N}__*.sql |
❌ | 两个 V12,Flyway 启动失败 |
这四样东西,编译器一个都不会管。它们都是「跨层的、声明式的、靠人对齐」的契约,而人和 AI(尤其是 AI)最不擅长对齐这种东西------AI 改后端时不知道前端在调什么,改前端时不知道后端路由叫什么。
AI 尤其不擅长跨层对齐,有个根本原因:AI 的上下文是局部的。它改后端 Controller 时,上下文里是 Controller 的代码,前端的 axios 调用不在它的视野里;它改前端时,后端的路由注解也不在视野里。两个文件同时打开都未必对得上,更别说 AI 一次只看一个文件。所以 AI 改了后端路由,它不会主动去同步前端------不是它懒,是它根本不知道前端在调这个路由。契约闸的价值在这里就体现了:AI 不需要知道,脚本替它查,查不过就拦下,逼它补全。这比在 prompt 里反复强调「改路由记得同步前端」可靠------prompt 是建议,AI 可能忘;脚本是强制,它忘不了。
这四类跨层契约还有一个共同特点:它们都是「声明式」的。路由是注解声明、表名是 SQL 声明、权限是注解声明、版本号是文件名声明。声明式的契约,意味着事实源是固定的、可解析的------脚本能从固定位置读到「正确答案」,再去 diff「实际引用」。这是契约闸能自动化的前提:如果契约是散落在逻辑里的(比如「这个接口要校验用户 VIP 等级」),没有单一事实源,脚本就 diff 不了,只能靠测试。所以契约闸守的是「声明式跨层契约」,行为类的正确性不归它管,得靠测试和评审来兜底,别指望它管自己管不了的事。
四道闸分别守什么
说穿了就是一个 pre-commit 脚本里跑四层扫描,每一层守住一类跨层契约。下面逐个展开。
闸一:前端 URL ↔ 后端路由注解
前端代码里所有请求路径,后端都得有对应的 @RequestMapping / @GetMapping 等路由注解接住;反过来,后端注册了但前端从没调过的路由也会标出来------通常是改名漏改,或接口已废弃。
python
# scripts/check_url_route.py(简化版骨架)
import re, os, sys
def extract_frontend_urls(frontend_dir):
"""扫前端代码,提取所有 axios/fetch 调用的 URL"""
urls = set()
for root, _, files in os.walk(frontend_dir):
for f in files:
if not f.endswith(('.ts', '.js', '.vue')):
continue
with open(os.path.join(root, f)) as fp:
# 匹配 axios.get('/api/xxx') / fetch('/api/xxx')
urls.update(re.findall(r"""['"]/(api/[^'"]+)['"]""", fp.read()))
return urls
def extract_backend_routes(backend_dir):
"""扫后端 Controller,提取类级+方法级拼装的真实路由"""
routes = set()
for root, _, files in os.walk(backend_dir):
for f in files:
if not f.endswith('.java'):
continue
with open(os.path.join(root, f)) as fp:
content = fp.read()
# 类级前缀
cls_m = re.search(r'@RequestMapping\(["\']([^"\']+)["\']\)', content)
cls_prefix = cls_m.group(1) if cls_m else ''
# 方法级路由,拼上类级前缀
for method_m in re.finditer(r'@(?:Get|Post|Put|Delete)Mapping\(["\']([^"\']*)["\']\)', content):
full = (cls_prefix + method_m.group(1)).replace('//', '/')
routes.add(full.lstrip('/'))
return routes
urls = extract_frontend_urls('frontend/src')
routes = extract_backend_routes('backend/src/main/java')
missing = urls - routes # 前端调了但后端没有
unused = routes - urls # 后端有但前端没调(可能是废弃)
if missing:
print(f'❌ 前端调用了不存在的路由: {missing}')
sys.exit(1)
# unused 只警告,不 fail(可能是有外部调用方)
if unused:
print(f'⚠️ 后端有未被前端调用的路由: {unused}')
print(f'✅ URL↔路由契约一致 ({len(urls)} 个)')
关键细节 :类级 @RequestMapping("/api") + 方法级 @GetMapping("/order") 拼起来才是 /api/order。朴素正则只抓方法级,会把真实路径判错------这是我吃过亏的地方,后面单独说。
闸一还有个常见的漏检场景:动态路由 。有些路由不是注解里写死的,而是 @GetMapping("/{id}") 这种带路径变量的。前端调 /api/order/123,后端注册的是 /api/order/{id},如果脚本只做字符串精确匹配,会判「不存在」。解法是扫到带 { 的路由时,把它转成正则模式(/api/order/(\d+)),前端 URL 匹配这个模式就算命中。动态路由不处理,闸一会误报一堆「不存在的路由」,开发者被误报烦了就会关掉闸------门禁被误报杀死的案例比比皆是。
闸一还该处理一种情况:外部调用方。有些后端路由不是前端调的,是给第三方系统回调用的(比如支付回调、微信通知)。这些路由在「unused」列表里会一直标黄,干扰判断。我们的做法是维护一个「外部回调白名单」,白名单里的路由不报 unused,避免噪音淹没真问题。
闸二:SQL 表名 ↔ Flyway DDL
Mapper 里 SELECT * FROM xxx 的那张表,Flyway 迁移脚本里必须真的建过。少建一张、拼错一个表名,当场拦下。
python
# scripts/check_sql_ddl.py(简化版)
def extract_sql_tables(backend_dir):
"""扫 Mapper.xml 和 @Select 注解,提取所有引用的表名"""
tables = set()
# 扫 *.xml 里的 FROM/JOIN
for xml_file in glob('backend/**/*Mapper.xml'):
content = open(xml_file).read()
tables.update(re.findall(r'(?:FROM|JOIN)\s+(\w+)', content, re.IGNORECASE))
# 扫 @Select("...FROM xxx")
for java_file in glob('backend/**/*.java'):
content = open(java_file).read()
tables.update(re.findall(r'(?:FROM|JOIN)\s+(\w+)', content, re.IGNORECASE))
# 排除 SQL 关键字
return tables - {'dual', 'sqlite_master'}
def extract_ddl_tables(migration_dir):
"""扫 Flyway 迁移脚本,提取所有 CREATE TABLE 的表名"""
tables = set()
for sql_file in glob(f'{migration_dir}/*.sql'):
content = open(sql_file).read()
tables.update(re.findall(r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)', content, re.IGNORECASE))
return tables
sql_tables = extract_sql_tables('backend/src')
ddl_tables = extract_ddl_tables('backend/src/main/resources/db/migration')
missing = sql_tables - ddl_tables
if missing:
print(f'❌ SQL 引用了不存在的表: {missing}')
sys.exit(1)
这条逮过好几次 Mapper 里表名拼错------user_order 写成 user_orders,编译照过(MyBatis 是运行时解析 SQL),但上线一查直接 NPE。
闸二有个局限要诚实说:它只查表名,不查字段名 。Mapper 里 SELECT user_name FROM users,如果表里字段叫 username 而不是 user_name,闸二查不出来------它只对表名做 diff,不对字段名做。字段名拼错的 bug,要靠 IT(集成测试,跑真实 SQL)来抓。这不是闸二的设计缺陷,是静态扫描的天花板------字段名太多、SQL 里引用方式太杂(有的在 SELECT 里、有的在 WHERE 里、有的在 ORDER BY 里),静态扫描覆盖不全。认清这个边界,才不会对契约闸有过高期待,该靠测试的地方还是老老实实靠测试,别指望静态扫描能替代。
闸二还有个实战细节:要排除非业务表 。SQL 里偶尔会引用 dual(Oracle 的虚拟表)、information_schema(元数据查询)这些系统表,它们不在 Flyway DDL 里,但不拼错。脚本要维护一个排除集合,把这些系统表过滤掉,否则每次扫描都报「dual 不存在」,噪音太大。
闸三:权限 key ↔ 种子数据
代码里 @RequirePermission("order:export") 用到的 key,在权限初始化的种子数据里得存在。否则权限配了等于没配,线上谁都能调。
python
# scripts/check_permission_key.py
def extract_permission_keys(backend_dir):
"""扫 @RequirePermission 注解,提取所有 key"""
keys = set()
for java_file in glob(f'{backend_dir}/**/*.java'):
content = open(java_file).read()
keys.update(re.findall(r'@RequirePermission\(["\']([^"\']+)["\']\)', content))
return keys
def extract_seed_keys(migration_dir):
"""扫 Flyway 迁移脚本里的权限 seed INSERT,提取所有 key"""
keys = set()
for sql_file in glob(f'{migration_dir}/*.sql'):
content = open(sql_file).read()
# 匹配 INSERT INTO sys_permission ... VALUES (..., 'order:export', ...)
keys.update(re.findall(r"""INSERT\s+INTO\s+sys_permission.*?['"]([\w:]+)['"]""", content, re.IGNORECASE))
return keys
code_keys = extract_permission_keys('backend/src')
seed_keys = extract_seed_keys('backend/src/main/resources/db/migration')
missing = code_keys - seed_keys
if missing:
print(f'❌ 代码引用了未注册的权限 key: {missing}')
print(' 请在迁移脚本里补充对应的权限 seed 数据')
sys.exit(1)
这条闸最容易救命 。权限 key 拼错(order:export 写成 order:exoprt),权限等于没配,线上谁都能调这个接口------这是越权风险,出事就是大事。脚本一扫,提交前就拦下。
闸三还有个进阶用法:检查权限 key 的命名规范 。我们的规范是 {系统}:{资源}:{动作} 三段式,闸三除了检查 key 存不存在,还检查 key 符不符合命名规范------比如 order_refund 这种下划线写法(不符合三段式)也会标黄。命名规范检查看着是小事,但它保证了权限 key 的可读性和可维护性------半年后看到 ops:order:refund,不用查文档就知道这是「运营后台/订单/退款」;看到 order_refund 就得猜它是哪个系统的、什么粒度。规范不强制,权限 key 就会五花八门,后续管理成本指数上升。
闸三对 AI 的约束力特别强。AI 加接口时写权限注解,最容易犯两个错:一是 key 拼错(闸三直接拦),二是复用相邻接口的 key(比如审批接口复用退款接口的 order:refund,权限被放大)。第一个闸三能拦,第二个闸三拦不住(key 在种子里存在),但可以在 review 时配合检查------闸三拦「不存在」,review 拦「不精确」。两者配合,权限 key 的正确性基本兜住,越权风险被前置到提交前拦下。
闸四:Flyway 迁移版本号唯一
两个 V12__xxx.sql 抢同一个版本号,看着低级,但合并代码时真的会发生 ,而且 Flyway 起飞失败能把整个服务拖挂。详见 Flyway 迁移治理。
python
# scripts/check_flyway_uniqueness.py
import re, glob, os, sys
versions = {}
for f in sorted(glob.glob('backend/src/main/resources/db/migration/*.sql')):
base = os.path.basename(f)
m = re.match(r'^V(\d+)__.*\.sql$', base)
if not m:
print(f'❌ 命名不规范: {base}')
sys.exit(1)
ver = m.group(1)
versions.setdefault(ver, []).append(base)
dup = {v: fs for v, fs in versions.items() if len(fs) > 1}
if dup:
print(f'❌ 版本号冲突: {dup}')
sys.exit(1)
这条之前已经独立成篇讲过,这里归到四道闸里统一编排。
闸四虽然简单,但它拦的是「合并冲突」类的事故。两个开发者各自开了分支,都加了 V12__xxx.sql,各自分支里版本号不冲突;合并到主干时,两个 V12 并存,Flyway 启动失败,整个服务起不来。这种 bug 在多人协作(尤其多 Agent 并行)时高频出现------每个 Agent 不知道别的 Agent 加了什么版本号的迁移。闸四在 pre-commit 就拦住,比等 Flyway 启动失败才发现早得多。
闸四还有个延伸:检查版本号连续性。如果已有 V1-V12,新加的是 V14(跳过了 V13),闸四会警告「版本号不连续」。这不一定是 bug(可能 V13 被删了),但跳号往往意味着某个迁移被误删或版本号分配混乱,值得人工确认。连续性检查是软告警(警告不阻断),避免误拦合法的跳号场景。
为什么放在 pre-commit
一个字:早。
跨层 bug 越往后修越贵,这是铁律。把修复代价随发现阶段递增的趋势画出来,「为什么一定要卡在 pre-commit」就不用解释了:

这张图的核心信息是:修复代价不是线性增长,是指数增长。pre-commit 发现,改几行注解 5 秒搞定;到了 CI,要重新走构建和 PR 流程半小时;到了测试环境,联调同学要排查半天还可能影响下游;到了线上,用户已经感知,可能要回滚,事故复盘。从 pre-commit 到线上,代价差了几个量级。pre-commit 这道闸的存在意义,就是把发现时机卡在代价最低的那个点上------不是「发现 bug」,是「在 bug 还不值钱的时候发现它」。
| 发现阶段 | 修复代价 | 典型场景 |
|---|---|---|
| pre-commit(本闸) | 重写几行注解 / 改个表名 | 本地一跑就知道,5 秒搞定 |
| CI(合并前) | 群里 @ 同学,重新提 PR | 等构建跑完才发现,半小时过去了 |
| 测试环境 | 联调同学排查半天 | 影响下游进度 |
| 线上 | 事故复盘,可能要回滚 | 用户已经感知,事故已发生 |
反馈越早越便宜。关键是 pre-commit 这道闸跑得要快,所以我没有把它写成跑全套测试(那是提交后的事),只是几个轻量静态扫描:解析注解、解析 SQL、对一对 key,秒级搞定。四个脚本串起来,在我们项目里跑完不到 8 秒,完全可以接受。
串起来的 pre-commit 配置
bash
# .git/hooks/pre-commit(或用 husky / pre-commit-framework 管理)
#!/bin/bash
echo "=== 四道契约闸扫描 ==="
echo "闸 1/4: URL ↔ 路由"
python3 scripts/check_url_route.py || exit 1
echo "闸 2/4: SQL ↔ DDL"
python3 scripts/check_sql_ddl.py || exit 1
echo "闸 3/4: 权限 key ↔ 种子"
python3 scripts/check_permission_key.py || exit 1
echo "闸 4/4: 迁移版本号唯一"
python3 scripts/check_flyway_uniqueness.py || exit 1
echo "✅ 四道契约闸全过"
四道闸全过才允许提交,任一红就拦下。Agent 想提交代码,得过这四关------这就把「跨层契约一致」从口头约定变成了机器化的硬约束。
四道闸的执行流程画出来,「什么时候跑、谁拦谁放」一目了然:

这张图里有个设计要点:四道闸是「或」关系,不是「且」关系------任何一道红,整体就红,不允许「这道不过但其他过了先提交」。因为四道闸守的是四类不同的跨层契约,任何一类破了都是 bug,不存在「这个 bug 可以先放行」的妥协空间。有些团队会把门禁设成「警告但不阻断」,结果大家习惯了警告,门禁形同虚设。四道闸必须硬阻断,不给自己留「先提交后修复」的余地------前者在实践中几乎等于「永远不修复」,留着迟早会出事。
实现要点:把「声明」当事实源
这四道闸能立起来,核心是找一个「事实源」,然后用代码去 diff。谁是事实源?在我的设计里,就是声明本身:
- 路由:后端注解是事实源,前端调用的 URL 是被校验对象。
- 权限 key:迁移脚本里的种子数据是事实源,代码里的引用是被校验对象。
- SQL 表名:Flyway DDL 是事实源,Mapper 里的引用是被校验对象。
两边对得上才算契约成立,对不上就是 bug。这个思路的价值在于:它让门禁变得确定且可自动化。不是靠人脑去「看看两处对不对」,而是脚本一把 diff,红就红、绿就绿。
事实源选择的判断标准
不是所有东西都能当事实源。能当事实源的,要满足三个条件:
- 声明式的:写在一个固定位置(注解、DDL、配置文件),不是散落在逻辑里。
- 权威的:这一处的定义就是"对的",其他地方以它为准。
- 机器可读的:脚本能解析,不用人理解语义。
Flyway 的 DDL 完美满足这三条------表结构声明在 SQL 文件里,权威且可解析。但「业务逻辑的正确性」就不满足(散落在代码各处,没有单一事实源)------所以业务逻辑得靠测试,不能靠静态扫描。
判断标准:能用静态扫描兜的,必须是声明式的跨层契约;行为类的正确性,老老实实写测试。
事实源的选择还有个实战中的陷阱:事实源本身可能错 。Flyway DDL 是事实源,但如果 DDL 里表名拼错了(比如 CREATE TABLE user_orde 少了个 r),SQL 引用 user_order 会被判「不存在」------但根因不是 SQL 错了,是 DDL 错了。脚本只会报「SQL 引用了不存在的表」,不会告诉你「是 DDL 拼错了」。遇到这种情况,开发者第一反应是「我的 SQL 对啊」,要反应过来「是 DDL 错了」需要点时间。我们的做法是在错误信息里同时显示「SQL 引用了 X,DDL 里建了 Y」,让开发者一眼看到两边的差异,自己判断哪边错了。事实源不是绝对正确的,它只是「以它为准」,但「以它为准」和「它是对的」是两回事。
翻车案例:朴素正则会骗你
这套东西也踩过坑,但坑的本质很清楚:契约扫描本身也是个会骗人的程序。
最早实现路由扫描那层,图省事用了个朴素正则去匹配 @RequestMapping 这类注解。大部分情况没问题,直到漏检了一个类级注解拼上方法级注解的路由。
具体是这样的:@RequestMapping("/api") 在类上,@GetMapping("/order") 在方法上,真实路径是 /api/order。
- 正则只抓了方法级那一段
/order; - 前端调
/api/order,它判定「不存在」; - 前端调
/order,它判定「存在」。
全反了,而且静默。这种"门禁以为没问题但实际有问题"的状态,比没门禁还危险------因为它给了「已经检查过」的虚假安全感。后来我把路由那层从「正则匹配」改成「把源码当文本做全文解析」,老老实实把类级 + 方法级拼起来再对,才堵住这类漏检。
三种扫描精度对照
| 扫描方式 | 实现成本 | 精度 | 适用 |
|---|---|---|---|
| 朴素正则 | 低 | 易漏检(类级+方法级拼接、注释里的注解) | 简单项目 |
| 全文解析 + 拼接 | 中 | 高(类级 + 方法级拼装) | 我们当前用法 |
| AST 解析(javaparser / tree-sitter) | 高 | 极高(能区分注解和注释) | 大型项目 |
中小项目用「全文解析 + 拼接」够了,AST 解析是杀鸡用牛刀。但绝对不能用朴素正则------那种"看起来在检查、实际漏检"的状态最危险。
这也提醒一件事:门禁脚本写得糙,等于没门禁,甚至比没有更危险------虚假安全感比真实的无保障更糟。
这个教训后来沉淀成了一条规矩:门禁脚本本身也要测。我们给契约闸写了一组「反例测试」------故意构造一个路由不匹配的测试用例,跑闸,期望它红;再构造一个匹配的,期望它绿。如果反例测试过不了(闸没拦住该拦的),说明脚本有 bug,要修脚本本身。门禁脚本也是代码,是代码就会有 bug,不测它就等于用可能有 bug 的脚本去拦可能有 bug 的代码------两层都不靠谱。给门禁脚本写测试,看着多余,实则是保证门禁自身可信的必要投入。
边界:脚本是脆的,得有别的兜着
最后得诚实说一句:这套 pre-commit 脚本是脆的。
它依赖路径约定、依赖注解写法、依赖大家不手贱去删它。事实上,这个契约脚本之前真的被误删过一次(那篇事故复盘里有写,后来恢复并升级成了四层)。一个能被 rm 删掉的「门禁」,本质上还是不可靠的。
所以我没指望脚本独力扛住一切。真正兜底有两层:
- 架构测试(ArchUnit 那种),强制 Controller → Service → Repository 分层,违反就构建失败。
- 多 Agent 评审回路 ,动态的、能理解语义的,补脚本那种「只会做字符串 diff」的静态盲区。这套我写过《没有 CI 的日子里》。
三层叠加------脚本拦粗的(声明式跨层契约)、架构测试拦结构的(分层依赖方向)、评审回路拦语义的(行为正确性)------才敢说「跨层契约基本兜住了」。
这三层的分工有个判断标准:按「能不能用机器确定性地判断」分层。脚本和架构测试是确定性的------注解在不在、表名对不对、依赖方向对不对,这些都是非黑即白的,脚本能给出确定答案,适合机器化。评审回路是不确定性的------「这段代码的行为对不对」「这个设计合不合理」,这些需要理解语义,机器(即使是 AI)只能给建议,不能给确定答案,适合人(或 AI 辅助人)来判断。确定性的事交给机器,不确定性的事交给人,这个分工让每层都做自己擅长的事,不越界。
三层兜底还有一个现实考量:成本从低到高。脚本跑 8 秒,几乎零成本,能拦住 70% 的跨层 bug;架构测试跑几十秒,成本略高,再拦 20%;评审回路最贵(要人或 AI 逐条看),拦最后 10%。低成本的前置层拦掉大部分问题,高成本的后置层只处理漏网的少数,整体效率最高。如果反过来------一上来就全靠人工评审,成本爆炸,而且 70% 的低级问题浪费人的注意力,真该人看的反而看不过来。
契约的本质:把接缝 bug 从"线上发现"提前到"提交前拒绝"
契约闸不是银弹,但它把那一类「编译器不管、人最容易漏」的接缝 bug,从「线上才发现」提前到了「提交前就拒绝」。光这一条,就值回票价,也值得把它当成 AI 协同研发流程里的标准配置,长期坚持下去。
这套门禁对 AI 协同研发的价值尤其大。AI 改代码时,最常犯的就是跨层不一致------它改了后端路由,忘了同步前端调用;加了权限注解,忘了补种子数据;写了新 SQL,忘了建表。这些都不是 AI「不会」,是它「不知道」------它不知道前端在调什么、不知道种子数据长什么样。契约闸补的就是这个「不知道」:AI 不需要知道,脚本会替它检查,检查不过就拦下,逼它补全。这比在 prompt 里反复强调「记得同步前端调用」可靠得多------prompt 是建议,脚本是强制,建议会被忘,强制不会。
还有一个值得强调的点:契约闸的维护成本很低。四道脚本写完之后,日常几乎不用改------路由扫描规则、SQL 表名提取逻辑,这些是稳定的,不随业务变化。新加接口、新加表,脚本自动扫到,不用改脚本。这种「一次编写、长期生效」的特性,是契约闸性价比高的根本原因。对比测试用例(每个新功能都要写新测试),契约闸的维护成本几乎为零,但它拦住的 bug 类型和测试不同------测试管「行为对不对」,契约闸管「接缝对不对」,两者互补。