本文件是 Route Forge for Spring Boot 的对外功能承诺。 跨语言共享的端点契约(
schemeVersion = 1)与 Laravel 适配包逐字段一致,权威描述见route-forge/php-laravel的.docs/SPEC.md;本文件承载三件事: ① 契约的 Java 侧落地口径;② Spring 生态独有概念到 forge 语义的映射;③ Java 专属扩展与差异。状态:P0 骨架。标注
⟨Pn⟩的条目在对应阶段落地时补细节,未落地前不承诺。
给 Spring Boot + Vue/React SPA 项目提供与 Laravel 版等价的命名路由全链路方案:后端按层级(level/tier)
暴露路由元信息,前端凭 层级名 + 路由名 构造 URL 与发请求,类型由后端这个唯一真相源生成。
- 模块分层:
forge-core(框架无关算法,零 Spring/Jackson 依赖)+forge-spring-boot-starter(适配层)。 对应家族的route-forge/common与route-forge/laravel。 - 前端三包(
@route-forge/core|vue|react≥ 3.1.0)零改动:契约即插件点。
{
"schemeVersion": 1,
"levels": {
"admin": { "description": "...", "load": "lazy", "route_count": 27,
"route": { "uri": "/_forge/routes/admin", "methods": ["GET", "HEAD"] } }
},
"config": { "strict_mode": false, "endpoint_prefix": "/_forge/routes",
"url_prefix": null, "cache_ttl": 3600 }
}Java 侧落地口径:
levels与config必须存在(前端auto-discovery对二者不做可选链,缺失即 TypeError)。levels为按层级名索引的对象;unassigned特殊层级恒定存在(strict_mode=true时route_count为 0)。endpoint_prefix下发值经规范化(前导/、去尾/),与端点实际注册路径、d.ts 文件头注释三处同源。cache_ttl恒为int|null:null不缓存、0永久、负值归一为null。- 字段名大小写混合是既有契约(
schemeVersion/route.uri/route.methods为 camel 或点分,config.*为 snake), Java 侧用显式@JsonProperty钉死,不依赖命名策略。
{ "level": "admin",
"routes": { "admin.users.show": { "uri": "admin/users/{user}", "methods": ["GET","HEAD"],
"parameters": ["user"], "parameter_defaults": {} } } }routes按路由名索引;层级下无路由时序列化为{}(不得输出[])。methods大写;GET 映射补HEAD(对齐 Laravel 行为)。- URI 模板必须剥离 Spring 正则约束:
{user:\\d+}→{user}。前端占位符正则为/\{([^{}]+)\}/g, 含冒号的占位符无法被替换,会原样残留在 URL 里。 - 可选段输出 Laravel 风格
{page?},来源见 §4.3。
字段集与语义与 Laravel 适配包一一对应,只有下列形态点按 Spring Boot 的习惯走,不为了逐字节相同而改造 Spring:
| 项 | Laravel 侧 | 本包(Spring 侧) | 理由 |
|---|---|---|---|
路由 uri 前导斜杠 |
不带(admin/users/{user}) |
保留(/admin/users/{user}) |
Spring 的 mapping 天然带 /,去掉是为了模仿参照实现,不是契约要求;前端两种都吃 |
uri/url_prefix 里的斜杠转义 |
HTTP 端点 json_encode 默认把 / 转义成 \/ |
Jackson 原样输出 / |
传输层字节差异,语义等价:\/ 与 / 在任意 JSON 解析器(含前端)里解出的字符串完全相同。不强行让 Jackson 模仿 PHP 转义 |
| 非 ASCII(中文描述等) | HTTP 端点转义成 \uXXXX |
Jackson 原生 UTF-8 字节 | 同上,字节不同、解码后等价;对等验收以「前端 JSON.parse 结果一致」为准,不追字节 |
schemeVersion / 键名 / 空层级 {} / 剥正则 |
—— | 完全一致 | 这些是前端消费契约,必须一致 |
HTTP golden 对等(P6 实测结论,2026-10-09):以
new Illuminate\Http\JsonResponse真实编码 vs 本包端点逐字节断言体(MockMvc 走真 Jackson 转换器)比同一份数据,差异只落在上两行的斜杠/非-ASCII 转义(外加已文档化的 uri 前导斜杠),键序、空层级{}、methods、< > &(实测 Laravel 不做JSON_HEX转义)均一致。判定:JSON.parse后逐字段等价即达对等标准,不修。 附带一处本包内部差异(如实记):HTTP 端点用 Jackson(不转义/与 unicode),而--forge:types --json/ 内嵌摘要用核心JsonWriter(刻意模仿 PHPjson_encode的转义)。二者各自对其 PHP counterpart 一致,但两条 Java 路径之间的字节形态不同——都是合法 JSON,前端无感。
前缀 forge,键名与 config/forge.php 保持 snake→kebab 的机械对应,便于跨语言迁移配置。
| 键 | 类型 | 默认 | 说明 |
|---|---|---|---|
forge.levels.<name>.description |
string |
"" |
层级描述 |
forge.levels.<name>.match.prefix |
string[] |
[] |
URI 前缀,按段匹配 |
forge.levels.<name>.match.middleware |
string[] |
[] |
中间件标签(仅归类),见 §4.4 |
forge.levels.<name>.match.middleware-match |
any|all|DNF |
any |
DNF 为 List<List<Integer>> |
forge.levels.<name>.load |
eager|lazy |
lazy |
前端预加载提示 |
forge.levels.<name>.endpoint-middleware |
string[] |
[] |
该层级端点访问要求,声明值,见 §4.4 |
forge.endpoint-prefix |
string |
/_forge/routes |
端点前缀 |
forge.url-prefix |
string? |
null |
下发给前端的 URL 前缀 |
forge.endpoint-middleware |
string[] |
[] |
摘要端点访问要求,声明值,见 §4.4 |
forge.cache-ttl |
int? |
3600 |
统一 TTL |
forge.cache-driver |
memory|redis |
memory |
Java 侧驱动名,语义见 §4.6。redis 走 spring-data-redis(可选依赖,值用 JDK 序列化不引 Jackson);classpath 缺 spring-data-redis 或无 RedisConnectionFactory bean 时启动即抛 RF_BE_003,绝不静默退回内存 |
forge.strict-mode |
bool |
false |
严格模式 |
forge.scheme-version |
int |
1 |
摘要格式版本 |
forge.aliases |
map<string,string> |
{} |
别名:键=别名,值=真实路由名 |
forge.exclude-uri-prefixes |
string[] |
[] |
追加的 URI 维排除前缀(在内置 /error、/actuator 之外做加法),见 §4.5 |
forge.manager.enabled |
bool |
false |
管理器页面开关(Java 侧多一道显式开关) |
forge.manager.allowed-ips |
string[] |
[127.0.0.1, ::1] |
IP 白名单,* 放行、空=不限制 |
- 数组型配置接受单值(
forge.levels.admin.match.prefix=api/admin等价于单元素列表),与 PHP 侧归一口径一致。 classifier在 Java 侧是 bean(RouteClassifier),不进配置文件,因而管理器可安全保存 PHP 侧禁存的场景在这里天然消失。forge.exclude-uri-prefixes是 Java 专属扩展:Laravel 靠路由名前缀(storage.*)排框架内部路由,Spring 侧 框架路由全未命名、无名字可匹,只能按 URI 段前缀排。宿主值是追加到内置默认集{/error, /actuator}(并集, 默认集不可被清空),防止宿主配一个值就把/error的排除丢了、strict 又 500。详见 §4.5。
Framework 7 实测事实(由 SpringRoutingModelSpikeTest 7 例钉成回归,SDK 升级若改动会当场失败):
RequestMappingInfo#getName()公开可读,值即@RequestMapping(name=...)及其派生注解上的 name—— 所以「复用 Spring 原生 name 属性」这条路成立,不必借道 OpenAPI 的 operationId; 未标注时给 null(不是空串),判缺省按 null 认;- meta-annotated
@RequestMapping的组合注解能正常注册映射,@AliasFor透传name / value / path / method / params / headers / consumes / produces / version全部生效(FW7@RequestMapping是这 9 个属性); version条件在未配置ApiVersionStrategy的映射上会直接注册失败 (API version specified, but no ApiVersionStrategy configured):本包照常透传它, 但用不用由宿主自己配策略,forge 不代配;- Spring 模板语法拒绝
{name?}(PatternParseException: Char '?' is not allowed in a captured variable name), 因此 Laravel 风格的可选标记只能由@ForgeRoute(optional=...)承载,适配层再拼进产物 URI; PathPattern不再公开getVariableNames()(只剩getPatternString()),参数名一律由核心层自解析模板取得;- GET 映射的声明条件只含 GET,但运行期 HEAD 请求实测能打通(Servlet 语义:
doHead走doGet并抑制响应体)。 因此下发methods时给 GET 附上HEAD是如实描述 Spring 的能力,不是模仿 Laravel 的形态; - 无 path 条件的映射(只按
params/headers匹配)实测只匹配根路径:Spring 会把它 materialize 成["", "/"]两个 pattern。二者是同一个地址,扫描时归一为一个/并去重——不去重就会产出两条记录, 其中一条 URI 是空串; - 一个映射带多个路径(
path = {"/a","/b"})展开成多条路由记录,与 Laravel「一条 URI 一条路由」的形态对齐; - 未声明 method 的映射(
@RequestMapping("/x"))实测任何方法都能匹配。forge 侧对这种路由下发完整标准方法集 (GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS/TRACE),不写空数组——空数组会让前端pickMethod拿不到方法而报错, 也与「不限方法」的真实语义相反。
命名三通道,优先级显式 > 派生:
@ForgeRoute(name = "admin.users.show", ...)—— 组合注解,meta-annotated@RequestMapping,@AliasFor全量透传原生条件属性。@Forge(name = "admin.users.show")—— 副注解,叠加在@GetMapping等原生 mapping 上(复杂条件逃生舱)。RouteNamingStrategybean —— 按 handler 类/方法派生,默认关闭。
宿主直接在 @RequestMapping(name=...) 上给的名字也认(等价第 1 通道的原生形态,不强制要求 forge 注解)。
三通道同时命中同一 handler 方法且给出不同事实 → 启动期 fail-fast(RF_BE_010),不静默择一。
五级优先级与 Laravel 完全一致:方法级 tier(显式)> 类级/包级 @ForgeTier(继承,类覆盖包)>
RouteClassifier bean > forge.levels.*.match > unassigned 兜底。
UriTemplate(forge-core)是 Java 侧独有部件,PHP 家族无对等实现:Laravel 的 Route::uri() 不带
正则约束(约束走 ->where())、也没有 {*path} 捕获段,因此这里没有可参照的产物,只按前端消费契约自证
(前端占位符正则 /\{([^{}]+)\}/g:凡它替换不了的形态都不许下发)。
- 前导斜杠按 Spring 原样保留(Java 侧与 Laravel 的形态差异,见 §2.3):Spring 的 mapping 一律以
/开头, 本包不为了对齐 Laravel 的相对形态而去掉它——契约以 Spring 习惯为准,前端对两种形态都能正确拼接; - 参数名由本类自解析模板取得,且必须按花括号深度配对:
{id:\d{4}}里的{4}是量词, 按「第一个右花括号」切会把名字读成id:\d; - 反斜杠转义必须整对吞掉:实测 Spring 接受
{code:[a-z\}]+}(要匹配字面}必须转义), 不处理转义就会把\}当成占位段结束,名字切坏、尾巴掉进字面量; - 模板语法的可用边界由 Spring 自己界定(
SpringRoutingModelSpikeTest钉住):{code:[a-z}]+}(未转义的})、{9lives}(数字开头)、{a+b}(含+)都会被PathPatternParser直接拒绝,即这类模板不可能来自 Spring;解析器对它们仍按「原样保留、不计参数」兜底, 防御非 Spring 来源的输入; {*path}这类前端填不了的占位段原样保留但不进parameters;裸**同理,SPEC 注明前端不可参数化;- 可选段:Spring 语法拒绝
{name?},故可选性只能由@ForgeRoute(optional = {...})声明, 再由withOptional拼回{name?};声明的名字不在模板参数里 → 直接抛异常,不静默忽略。 - 可选路径参数:
@ForgeRoute(optional = {"page"}, defaults = {"page=1"})→ 输出xxx/{page?}+parameter_defaults。声明的名字不在 URI 模板内 → fail-fast。 - 参数名来源是 URI 模板本身,不依赖
-parameters;-parameters仍全仓强制(配置构造绑定需要)。
Spring 侧没有「逐路由中间件」,但有两条与 Laravel 等价的守卫声明主流写法。按 GitHub 公开 Java 代码
字面命中量级实测(2026-10-06,单位是命中文件数、含 fork 重复计数,只看量级与相对比例):
authorizeHttpRequests 34.8 万、requestMatchers( 30.2 万、@PreAuthorize("hasRole 27.0 万、
@PreAuthorize("hasAuthority 8.3 万、@PreAuthorize("@ss.hasPermi(若依派权限码)3.6 万、
@RequiresPermissions(Shiro)5.7 万、@SaCheckPermission(Sa-Token)0.85 万。
即 URL 规则与方法注解是并列的两大主流,第三方框架是长尾(不为它们设计通道)。
官方口径(Spring Security 7.1 文档原话,三条都直接影响设计):
- request-level =
coarse-grained / declared in a config class / DSL,method-level =fine-grained / local to method declaration / Annotations,二者互补,取舍只是 "where you want your authorization rules to live"; - "By default, Spring Security requires that every request be authenticated.";
- "when you use annotation-based Method Security, then unannotated methods are not secured"—— 所以注解派生出来的标签永远不能自称安全边界。
三条铁律与一条差异,据此定死:
- 标签只做归类,不构成安全边界(不变):真实鉴权始终归宿主的
SecurityFilterChain。 - 不要求宿主重复声明:路由的
middleware优先取显式通道(@ForgeRoute(middleware=...)/@Forge(middleware=...));两者都没给时,由第四通道从 handler 上已有的守卫注解派生。 按 URL 配 Security 的那批宿主用不到它——他们的层级按match.prefix归类,与requestMatchers天然同构,两边零声明。 - starter 不代宿主配 Security:不注册、不改写、不排序任何
SecurityFilterChain,也不反射读别人的 规则。包自身端点交宿主按requestMatchers("/_forge/**")自己配(Boot 默认已要求认证)。 - 与 PHP 侧的实质差异(必须记,不许静默分叉):
forge.endpoint-middleware在 Laravel 侧是真的挂到 路由上生效,Java 侧只作声明值——接受、按原样出现在配置产物里、不静默丢弃,但不产生任何行为。 因此 Java 侧不引spring-security-*依赖(连compileOnly都不需要,注解按名字识别)。 P5 唯一相关的动作是:classpath 上没有 Security 时启动打一条 WARN,明示/_forge/*端点当前无鉴权保护 (✅ 已落地:ForgeSecurityAdvisory,探针SecurityFilterChain,只提醒、不改行为)。
派生规则(GuardLabels,forge-spring-boot-starter)。识别一律按注解类型全名匹配,
故零编译期依赖;就近语义复用 MergedAnnotations + TYPE_HIERARCHY(方法级覆盖类级,
与 Spring Security 文档一致),并因此自动吃透组合注解(宿主自建的 @AdminOnly meta-annotate
@PreAuthorize 也算):
| 声明 | 派生标签 | 说明 |
|---|---|---|
@PreAuthorize("hasRole('ADMIN')") |
ADMIN |
一律取注解里的字面值,不补也不剥 ROLE_ 前缀——加工会把 hasRole('ADMIN') 与 hasRole('ROLE_ADMIN') 这两种不同要求悄悄归一,那是读错 |
@PreAuthorize("hasAuthority('system:user:list')") |
system:user:list |
权限码字面值 |
@PreAuthorize("hasRole('A') and hasAuthority('p:read')") |
A、p:read |
顶层 and 是合取(都要求),拆成标签并集 |
@PreAuthorize("hasAnyRole('A','B')") |
expression: 兜底 |
或语义;拆开会放宽归类,属于读错,宁可原样 |
@PreAuthorize("hasRole('A') and #id == authentication.name") |
expression: 兜底 |
混入不可分类的项时整条原样——规则是「要么全懂,要么不猜」 |
@PreAuthorize("authenticated()") / isAuthenticated() |
__authenticated |
只要求登录、无权限名;__ 前缀是本包保留字面量 |
@PreAuthorize("permitAll()") / denyAll() |
__permit_all / __deny_all |
同上 |
@Secured("ROLE_ADMIN") |
ROLE_ADMIN |
字面值。注意:@Secured 的值按 Spring 习惯带 ROLE_,而 hasRole 不带,同一份权限两种写法会出两个标签——这是如实反映声明差异,不做归一 |
@RolesAllowed("ADMIN")(jakarta 与 javax 两种包名都认) |
ADMIN |
同上;多值 → expression: 兜底(@Secured 多值是或还是与未实测,不猜) |
@PermitAll / @DenyAll |
__permit_all / __deny_all |
JSR-250 |
| 其余任何表达式 | expression: + 原文 |
可读但不可归类;若依的 @ss.hasPermi('x') 全落这里 |
派生的两条边界,写在这里免得被当成 bug:
- 不校验 method security 是否真被启用(
@EnableMethodSecurity及其securedEnabled/jsr250Enabled开关):宿主把注解写成装饰物时,派生标签会跟着装饰它。这是标签只做归类的直接后果, 也是第 1 条铁律存在的原因。 - 显式通道与派生结果不一致时不报错(守卫可以有很多个名字,撞不上是常态),只经
WarningSink出一条提示。 装配层是无条件接 sink 的(不按strict-mode收口——那个 sink 被严格模式扫描等多处共用,一刀切关掉会 连带别的告警一起哑掉),要静音由宿主覆盖WarningSinkbean;提示频率=真正扫描频率(缓存 miss),debug=true旁路缓存时退化成每请求一条。 - 标签集合的可见面只有两处:
--forge:list(P4,走RouteRepository.allRoutesWithTiers())与 管理器条目(P5)。层级端点的行字段集按 §2.2 是uri/methods/parameters/parameter_defaults四项, 不含middleware——那是前端消费的契约字段集,不许为了后端自检而扩字段(由契约测试的整文断言钉住)。
forge.manager.* / 层级与摘要端点自身一律不进任何元信息。排除有两个维度,缺一都有真实自伤:
- 按注册来源:本包 controller(其路由名带
forge.routes./forge.manager.前缀,被名字维排除); - 按规范化后的
endpoint_prefix做 URI 段级前缀:Spring 侧包自身端点全部未命名、无名字可判, 若宿主管该层级配了endpoint-middleware,带中间件的GET {endpoint_prefix}/{level}会被 match 命中—— 不按 URI 排除,strict_mode=true时包会把自己的端点报成宿主的配置错误、端点必 500。
URI 维还并入一批 Boot 框架内部路由的默认排除集 {/error, /actuator}(BasicErrorController 与 actuator
基路径),段级匹配、未引对应依赖时空转无害。根因同「包自身端点」:这些路由也全未命名,宿主一旦把某层级
match.prefix 写宽(/ 或 /api),/error、/actuator/** 就整批落进 missing_name,把严格模式刷满 500,
且宿主无法靠给它们命名来自救(CI 门禁被永久打断、报错指向框架路由、排查方向是错的)。
Laravel 侧没这个维度可借——它靠路由名前缀 storage.* 排框架内部路由(ForgeServiceProvider 的
LARAVEL_INTERNAL_ROUTE_PREFIXES),而 Spring 框架路由无名。故 URI 默认排除是 Java 专属扩展,宿主可经
forge.exclude-uri-prefixes 追加(并集语义,内置默认不可清空,见 §3)。刻意不纳入 springdoc
(/v3/api-docs 等):第三方路径多变可配、变体众多(swagger-ui/**、.yaml 等),写进默认集是「假完备」,
更应让宿主按需显式追加。边界:URI 维只作用于未命名路由;宿主显式命名的路由(哪怕 URI 恰好是 /error)
不受影响——排除从不静默吞掉一个被有意命名的业务路由。
CacheStore SPI 自写(不接 Spring Cache 抽象),键 route-forge:<level> / route-forge:summary /
route-forge:_keys 索引;forgetLevel 必须连带失效 summary。
ForgeRouteRegistry 为单例,只做装配(层级解析器、别名解析器、过滤器、缓存的构造与注入),
不持有扫描结果;所有取数一律经缓存,miss 才真正扫。于是缓存层在 Java 侧不是多余的第二层,
而是唯一的失效点——TTL 三态、clear --level 连带失效 summary、Redis 多实例共享这些语义
才与 PHP 侧逐条可比(PHP 是每请求新建的进程模型,跨请求复用本来就全靠缓存)。
失效入口只有三个:--forge:clear、管理器保存配置后、管理器与端点的显式刷新。
开发环境判据 = Spring 的 debug=true(与 Laravel 的 APP_DEBUG 同构,一根线管三处):
| 受控行为 | 判据 | 说明 |
|---|---|---|
| 缓存读写旁路 | debug=true |
保证改路由/改配置即时生效;clear 不旁路(否则关回 debug 时旧缓存复活) |
RF_BE_009 结构化 violations 下发 |
debug=true |
清单是宿主越界路由的名字与 URI 目录,生产只给 code + message |
| 管理器页面注册 | debug=true 且 forge.manager.enabled=true |
后者默认 false——误开 debug 最多旁路缓存,不会凭空暴露可写配置的端点;再叠加 IP 白名单共三道 |
debug=true 生效时启动日志打一条 WARN 横幅,明示「缓存已旁路,每个请求重扫路由表」,避免把它当成性能开关长期开着。
参照 Laravel 的 ForgeRouteRegistrar::__destruct(声明了属性却无消费方 → 记日志、刻意不抛)。Spring 侧的
对应误用是:forge 标记落在永远不会成为 handler 的位置,于是静默失效——路由进不了任何端点、--forge:list、
d.ts 或严格扫描,宿主却以为生效了。可检测的两类(高信噪比、近零误报):
@Forge标在一个没有请求映射的方法上(该方法永不成为 handler);@ForgeRoute(meta-@RequestMapping)标在一个@Component而非@Controller/@RestController的 bean 上 (类不是 handler,映射不生效)。
机制:HandlerMethodRouteSource 只枚举已注册 handler,结构上看不见「没成为 handler 的注解」,故本检查反向
在 bean 定义层面做——SmartInitializingSingleton 启动后跑一次,拿全量已注册 handler 的方法签名集,再看每个 bean
本类局部声明的、带 @Forge/@ForgeRoute 的方法是否落在集外;集外者经 WarningSink 出一条提示。绝不抛异常。
边界与取舍(按用户 2026-10-07 拍板,均选 A):
- 只在
debug=true或strict=true时启用(排查配置问题的两个场景),生产两者皆非则 bean 存在但直接返回、零反射; - severity 恒为
warning——不给WarningSink扩 error 通道。这类位置本就进不了 forge 产物,无法成为可 catch 的 strict error;与 Laravel 的 strict→error / 否则 warning 分级是本处有意差异,据此保持 §4.4「WarningSink无条件接、 要静音由宿主覆盖该 bean」的既有口径。 - 刻意不纳入:
@ForgeTier的类/包级误放(继承、package-info、懒加载下误报面大);完全没被任何 stereotype 纳入容器的类(连 bean 都不是——那要 classpath 扫描,代价与误报都不可接受)。
记下来免得下个会话当缺口重做。四条里三条与 PHP 等价、一条刻意不做,另有一条 Java 多出的时机面,再加一条 Spring 模型带来的口径差:
- 层级名合法性(
RF_BE_002vs PHPUnknownLevelException):PHP 在三个定义入口(宏 / Registrar /updateGroupStack)即查config('forge.levels')抛;Java 在TierResolver抛RF_BE_002。Laravel 的路由文件 每次请求都要重新注册,所以两侧都是「同一次请求内、响应之前」——形态等价,不需要提前到启动期。 - 悬空别名 / 别名撞车 / 同名跨层级重复:PHP 全部延迟到扫描期(
AliasResolver、RouteAnalyzer),撞车时真实路由赢、 别名丢弃并 warning——Java 已同形(核心层移植自同一份代码)。 - 不做启动期预热扫描:PHP 没有任何启动期全表扫描。Java 若为了「更早报错」在启动期扫一遍,会连带把 strict 聚合
提前到启动期,宿主行为从「请求 500」变成「启不来」——不要做。§4.8 的误放扫描是安全的例外:它只经
WarningSink出提示、绝不抛,既不参与也不提前RF_BE_009的判定,故不触碰这条禁令。 - Java 多出的时机面:
GuardLabels的显式/派生不一致提示无条件经WarningSink(§4.4 已记),以及 §4.8 的误放 提示(debug或strict时启动扫一次)。 - Spring 的 fixed-strict 现实(口径差,非缺口):
strict-mode是启动期绑定的属性,单个 JVM 内不可运行时翻转; 因此 PHP 那句「strict 下 500 → 关 strict 取 200 → 再开 strict 仍 500」的翻转测试,在 Spring 的一个上下文里 结构上不可能发生。契约测试据此钉的是等价事实:strict 取数路径从不落缓存(每请求恒 500,不会因缓存命中 跳过预扫描而被「洗白」)、debug=true整体旁路缓存(缓存 bean 报disabled)、两个端点同口径。
--forge:list / --forge:types / --forge:clear,选项与退出码语义对齐 artisan:
--level= --json --unassigned --aliases --unnamed --out=。
严格模式违规时:list 表格照常 + 红色清单 + 退出码 1;types 不产出产物 + 清单走 stderr + 退出码 1。
执行载体与退出(Java 专属扩展):路由表来自 RequestMappingHandlerMapping,只能在 web 上下文里取到,
因此 CLI 是宿主 web 应用里的一个 ApplicationRunner,复用注册表(--forge:list/--forge:types 走
ForgeRouteRegistry#analyze(),--forge:clear 走 RouteCache),绝不在命令层再装一套 resolver/filter 重扫。
--forge:* flag 缺席即完全 no-op(正常启动与不带这些参数的 @SpringBootTest 不受影响);命中命令则
跑完 System.exit(退出码) 落地——所以宿主正常启服务时勿误带这些 flag(会被当运维命令跑完即退,与 artisan 同语义)。
命令逻辑抽成纯对象(收已解析选项 + out/err 两个 writer → 返回退出码),退出仅在其外层薄壳,便于整文单测。
流口径(stdout = 机器可消费产物;stderr = 一切反馈):
- 走 stdout 的只有产物本身:list 的表格文本、list
--json的 JSON、types 无--out时的 d.ts / JSON; - 走 stderr 的是所有诊断:list
--json的违例红色清单、types 的未知层级 /[code] 消息/ warnings / 违例清单 /Written to:确认;clear无产物,其状态信息走 stdout。 - 与 PHP 的一处有意分歧:artisan 把
$this->error/Written to发 stdout,Java 侧统一发 stderr, 目的是让 stdout 恒为可安全重定向的纯产物(--forge:list --json > x.json不会混进错误文本)。
产物纯净性的现实约束:Boot 的 banner 与启动日志默认写 stdout,故 --forge:types > x.d.ts 仍会被启动期
输出污染。因此 --out= 是干净产物的推荐主路径(写盘 UTF-8、stdout 保持空),stdout 直出仅作跨语言对等与脚本消费。
命令写出一律显式 UTF-8(AGENTS 编码铁律,Windows GBK 会污染中文产物并造成假失败)。
形态与着色:命令名是参数式 --forge:xxx(非 route:forge:list 这类 Artisan 命令名),属 Java 专属扩展,
P6 的 Laravel golden 比对里别当差异查。--json 产物 = json_encode(JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)
(核心层 JsonWriter.pretty)。Laravel 表格的 ANSI 着色(品红/黄/绿/红)仅存在于表格模式、非跨语言 golden 产物;
Java 表格渲染为纯文本对齐框、不落 ANSI,层级/别名/撞车/未归级语义改由列文字表达。d.ts 文件头时间戳生产取
TypeGenerator#currentTimestamp,做成可注入入参以便测试整文断言。
TypeGenerator 移植自 common,输出结构逐行对齐:文件头三行注释 → ForgeLevel 联合 →
ForgeRouteName<L> → ForgeRouteMeta → declare module '@route-forge/core' 的二级映射 →
ForgeRoutes 别名与四个工具类型。method 取首个非 HEAD 方法;params 类型恒 string | number;
body 仅 POST/PUT/PATCH 出现;response 恒 unknown;空层级输出 key: { }。
两处照抄上游的偶然形态(改了就会与 Laravel 后端产物不一致,注明而非隐藏):文件头「生成时间」来自
date('Y-m-d\TH:i:s.000\Z')——毫秒位恒为字面量 .000、Z 为字面量、时区取进程默认;
--json 在目标层级为 0 时顶层输出 [](只有层级块做了对象强转),消费侧要能容忍 [] 与 {}。
GET /_forge/manager(自包含 HTML、零构建/零 CDN 依赖)+ GET /_forge/manager/api/routes(层级 × 路由视图,
数据源=ForgeRouteRegistry#allRoutesWithTiers)+ PUT /_forge/manager/api/config(保存并生效)。
三道门禁(§4.7):仅当 debug=true 且 forge.manager.enabled=true 时整个 ManagerConfiguration 才装配
(bean 不注册、不新增 /_forge/manager/** 路由,故生产/未开者零副作用);再叠加 forge.manager.allowed-ips
IP 白名单(第三道,控制器内施加)。IP 经 HttpServletRequest.getRemoteAddr() 取——Spring MVC 无 servlet-free
的取来源 IP 途径,而本装配是 @ConditionalOnWebApplication(SERVLET),servlet-api 恒由宿主 webmvc 提供(编译期
compileOnly)。allowed-ips 语义:"*" 放行任意、空列表不限制、否则命中且 IPv6 回环归一(0:0:0:0:0:0:0:1=::1)。
反向代理须宿主自启 ForwardedHeaderFilter,否则 getRemoteAddr() 反映代理而非真实来源(本包不自己读 X-Forwarded-For,避免伪造)。
保存=热生效(决策 D1):PUT 的 levels 为全量覆盖(非增量)。一次调用内按「写文件成功 → 换内存
LevelsConfig → clearAllCache」次序执行,于是同进程立即按新层级重算,无需重启;次序刻意「先落盘、后换内存」,
落盘失败绝不造成内存/文件分叉。写盘纪律三条:写前备份(forge-levels.yml.bak)、原子写(临时文件 + ATOMIC_MOVE)、
写后回读比对,比对失配则用备份回滚并报错。
文件位置与读回(决策 D2):固定写工作目录 ./forge-levels.yml,顶层 forge: { levels: {...} } 形态。要让重启后
仍生效,宿主需加 spring.config.import=optional:file:./forge-levels.yml;优先级 caveat:Spring 里被 import 的
文档优先级低于声明 import 的 application.yml,故若要保存的层级在重启后作数,须把 forge.levels 只放在
forge-levels.yml、不要在 application.yml 里重复(否则重启被 application.yml 盖回)。热生效路径不受此影响。
错误口径:管理器的请求/写盘错误用 HTTP 状态码 + {"error":{"message":…}}(400 缺 levels、403 白名单外、
500 写盘失败),不占用 RF_BE_0xx——那是跨语言元信息契约的码族,管理器是 Java 侧 dev 工具。
已知取舍(待定项,非缺口):
api/routes走allRoutesWithTiers(),与摘要/层级端点同为严格违例时抛RF_BE_009的取数面,故开 strict 且宿主有未命名违例时该端点会 500——与其余端点一致,但会让「用管理器去修违例」成死循环。 若要管理器在违例下仍可浏览,需另接一条容错视图(走analyze()),留作后续。
核心层 SummaryRenderer#render(Object)(forge-core,P1f 落地)产出可直接放进 HTML <head> 的一段
<script>(window.__ROUTE_FORGE__ 一次性自删访问器,JSON 过 JsonWriter+JsSafeEncoder 两层安全编码)。
适配层把它包成框架无关的纯 Java API:ForgeSummaryEmbed#script()(bean,注入即用),摘要只来自
registry.summary()——与摘要端点同一 producer、复用同一 RouteCache(缓存 / debug 旁路 / 包自身排除全继承)。
宿主用法(必须原样输出、不得二次转义):
- 任意栈:注入
ForgeSummaryEmbedbean,取script()走非转义通道写进响应; - Thymeleaf(可选方言):classpath 有 Thymeleaf 时
ThymeleafSummaryConfiguration(@ConditionalOnClass)注册ForgeSummaryDialect,模板里<div th:utext="${#forgeSummary.summary}"></div>(th:utext=unescaped); 没引 thymeleaf 依赖也能用 Bean 引用th:utext="${@forgeSummaryEmbed.script()}"。
thymeleaf 仅 compileOnly(只用核心 dialect SPI,不引 thymeleaf-spring;Boot 自动把 classpath 上任意 IDialect
bean 收进宿主 TemplateEngine),纯 SPA 宿主零依赖负担。安全边界如实:一次性自删只缩小运行时驻留面,摘要仍随 HTML
源码可见,非抗 XSS/抗窃取硬边界;且 strict-mode 有违例时与端点同规抛 RF_BE_009(不静默降级)。
沿用 RF_BE_001~RF_BE_009 与原文消息(跨语言一致),Java 侧不实现 007、另新增 010:
| code | 触发场景 | HTTP |
|---|---|---|
RF_BE_001 |
单条语义保留:TierResolver.resolve() 直接调用时,严格模式下命名路由未归级 |
500 |
RF_BE_002 |
请求的层级名不在 levels 配置中 | 404 |
RF_BE_003 |
配置的 cache-driver 不可用 |
500 |
RF_BE_004 |
classifier 回调自身抛错(包装保留 cause) | 500 |
RF_BE_005 |
单条语义保留:严格模式下设了层级却无路由名 | 500 |
RF_BE_006 |
classifier 返回的层级名不在 levels 配置中 | 500 |
| — | RF_BE_007 在 Spring 侧无对应物:该码源于 PHP 的 Registrar 析构期告警(尾部链式属性丢弃), |
|
| Spring 的注解是声明期的、不存在「属性挂了但无人消费」的生命周期。码位保留、不复用、不实现 | — | |
RF_BE_008 |
别名指向的路由名不存在(悬空别名) | 500 |
RF_BE_009 |
严格模式整表违规聚合(missing_name / unassigned 一次报全,unresolved 作信息附录) |
500 |
RF_BE_010 |
Java 专属:命名三通道对同一 handler 方法给出冲突事实(详见 §4.1) | 500 |
RF_BE_009 的结构化 violations 仅在 debug=true 时随错误体下发(清单本身是宿主越界路由的名字与 URI 目录,
属内部结构信息),与 Laravel 侧 APP_DEBUG 口径同构。
| 维度 | 覆盖点 |
|---|---|
| 构建约定 | Java 17 基线(产物 class major=61,由 BuildConventionTest 读自身 class 头钉死)、-parameters、UTF-8 默认字符集(已落地) |
| 层级分配 | ⟨P1/P3⟩ 显式 / 类级包级继承 / classifier / match / 多命中取最后 / unassigned |
| 中间件匹配 | ⟨P1⟩ any / all / DNF(越界索引、空子句、未知模式降级 any)、prefix 按段 |
| 别名 | ⟨P1/P3⟩ 宏优先 config / 撞车忽略 / 悬空 RF_BE_008 / 跨层级铺开 / 计数不叠加 |
| 严格模式 | ⟨P3⟩ RF_BE_009 聚合一次报全、包自身路由豁免 |
| 端点响应 | ⟨P2⟩ 摘要与层级结构、空层级 {}、自定义前缀规范化、缓存命中、404 |
| Spring 特有 | ⟨P2/P3⟩ 组合注解映射生效、双通道冲突、正则剥离、可选参数、守卫注解→标签派生(§4.4 规则表逐行) |
| CLI | ⟨P4⟩ 输出格式、退出码、违规不产出产物 |
| 跨语言对等 | ⟨P6⟩ PHP 实跑 golden fixture 逐字段断言 |
Java 适配自成一条版本线(当前 0.1.0),不跟随 PHP/npm 的版本号;schemeVersion 恒 1,
只在摘要响应格式发生不兼容变更时递增。
语言基线为 Java 17(options.release 锁定;Boot 4 官方只要求 17,且 17 是 JDK 的 LTS,
大量宿主升 Boot 不升 JDK,故基线取 17 而非 21)。Spring Boot 侧承诺如下三行,逐字为准:
| 面向 | 承诺 | 依据 |
|---|---|---|
| Boot 4.x | tested:编译、全量测试、示例联调都跑在 4.1 上,出问题按 bug 修 | 主门禁 ./gradlew build |
| Boot 3.5+ | 物理兼容、untested:src/main 零 Jackson、零 Security;P5 引入了 jakarta.servlet(getRemoteAddr)、snakeyaml(写 yml)、可选 thymeleaf(内嵌摘要方言)、可选 spring-data-redis(Redis 缓存驱动),这些在 Boot 3.5 对应版本上都有同样的稳定 API。故 compileBoot35SentinelJava(哨兵类路径逐一镜像 src/main 的 compile/impl 依赖)能对着 Boot 3.5.16(实测 = FW 6.2.19) 编过——但不跑测试、不承诺运行期行为 |
Boot 3.5 编译哨兵(只证明可编译,不证明语义) |
| Boot 2 及以下 | 明确排除:javax.* 命名空间 + 无 AutoConfiguration/MergedAnnotations 等,物理上不兼容 |
—— |
唯一会真正分叉的运行期点是
YamlShape依赖的 Boot Binder「全数字键索引 Map」形态({0=/admin}, 本仓在 Boot 4 上实测得来),3.5 上未做运行验证。所以 3.5 那一行的口径只能停在「物理兼容、untested」, SPEC/README 一律不得写成「支持 Boot 3」。