
客户要把企业插件装进同一套 Forge Admin 工程:同一 MySQL、同一 Spring Boot 进程。第一次直觉往往是------插件 SQL 丢进主 db/migration,菜单靠角色藏一藏,许可证文件往 resources 一塞。结果常见三种现场:
- 主库 Flyway 报 checksum / 版本撞车,或卸载插件后主历史表留下「幽灵版本」;
- 超管会话权限是
*//**,菜单藏了,接口文档仍打得开企业 API; - 维保到期被误当成「功能也不能用了」,或许可证坏了把整个社区功能一起拖死。
Forge Admin 在 forge-starter-plugin 里把这件事拆成三道闸门 :插件独立 Flyway 历史、接口/菜单共用的 FeatureGate、可开关的离线运行时许可证。下面按源码讲现象、根因和改法;推断处标「按源码推演」。仓库:gitee.com/ForgeLab/fo...
现象一:同一 DataSource,迁移历史不能「合租」
主工程 Flyway 表名默认类似 forge_schema_history(以工程前缀为准)。插件若把脚本塞进主 locations,按源码推演会出现:
- 多插件各自有
V1.0.0,版本号在同一张历史表里冲突; - 卸载/重装插件时,主历史里已有校验和,改脚本或删目录会触发 validate 失败;
- 启动中途某个插件 DDL 失败,前面插件已半落地,却和主库迁移揉成同一条失败故事,难回滚。
Forge 的做法不是再开一套数据源,而是共用 DataSource,换独立 location + 独立 history 表 ,并且必须塞进 Boot 的 FlywayMigrationStrategy,跟主迁移同一初始化阶段跑完。
关键在 PluginFlywayMigrationStrategy(commit 32c6f4dc...):
java
public void migrate(Flyway flyway) {
flyway.migrate(); // 先主库
Configuration main = flyway.getConfiguration();
// 先完整校验计划,再执行任何插件,避免后面非法表名造成前面半安装
for (PluginMigrationPlan plan : factory.plan(main, registry.getPlugins())) {
migratePlugin(main, plan);
}
}
失败时直接抛 IllegalStateException,注释写明:MySQL DDL 可能已落库,不能自动 clean/repair 掩盖失败或删客户数据。
历史表名不是描述文件随便写的字符串,而是 PluginMigrationPlan.from(mainTable, pluginId) 算出来的受控标识符:
java
// 主表必须是合法的 *_schema_history
String table = prefix + "_plugin_" + pluginId.replace('-', '_') + "_history";
// location 固定:classpath:db/plugin/<pluginId>
例如主表 forge_schema_history、插件 hello → forge_plugin_hello_history,脚本目录 classpath:db/plugin/hello。工厂在复制主配置时还会清掉主工程的 Java migrations / resolvers,避免「只改 locations 却把主 Java 迁移带进插件实例」------这是注释里点名的坑。
自动配置顺序也值钱:PluginFlywayAutoConfiguration 标了 before = FlywayAutoConfiguration.class,保证策略 Bean 在 flywayInitializer 创建前登记。按源码推演,这是为了堵住「依赖 flywayInitializer 的 Bean 先于插件建表」的竞态(仓库踩坑记录里 jobAutoRegistrar 一类依赖正是这种形态)。
表 1:主迁移 vs 插件迁移(可收藏)
| 维度 | 主工程 Flyway | 插件 Flyway(starter-plugin) |
|---|---|---|
| 历史表 | *_schema_history |
*_plugin_<id>_history(由主表前缀推导) |
| 脚本位置 | 主 db/migration |
classpath:db/plugin/<pluginId> |
| 执行时机 | Strategy 内先 flyway.migrate() |
主成功后按 pluginId 排序逐个 migrate |
| 失败策略 | 中断启动 | 中断启动;不自动 clean/repair |
| 无 SQL 插件 | --- | 不建历史表、不跑迁移 |
| 多实例 | Flyway 锁 + 历史表 | 同 DataSource,同样依赖 Flyway 锁与历史表幂等 |
社区示例 forge-plugin-hello 的 SQL 头注释写得很直白:「使用 hello 独立迁移历史,不登记进主库 migration;不自动授予任何角色权限。」菜单/接口资源靠插件自己的 V1.0.0__add_hello_resources.sql 幂等插入,角色授权仍交给实施同学------避免「装了插件等于全员开通」。
现象二:菜单藏了,超管照样能打企业接口
权限模型里有个硬事实(spec 与实现一致):
- 超管菜单/权限侧会拿到通配(如
*、*:*:*); UserLoadServiceImpl.loadApiPermissions对超管直接写入/**。
所以:只靠菜单隐藏和 RBAC,拦不住知道 URL 的超级管理员。 功能使用权必须在 RBAC 之外再立一扇门。
Forge 引入扩展点 FeatureGate,社区默认实现极简:
java
public boolean isEnabled(String featureCode) {
return featureCode == null || !featureCode.strip().startsWith("ee.");
}
规则是:空编码=不受控;ee. 前缀=企业功能,社区默认关闭。 接口层用 @RequiresFeature,由 FeatureGateInterceptor(order=4,在登录/RBAC 之后)统一拦截:
java
RequiresFeature requirement = findRequirement(method);
if (requirement == null || featureGate.isEnabled(requirement.value())) {
return true;
}
// 403 + 「当前版本未开通该功能:xxx」
示例插件 Controller 同时挂了功能门与权限注解------两边都要过:
java
@RestController
@RequestMapping("/plugin/hello")
@RequiresFeature("community.hello")
public class HelloPluginController {
@GetMapping("/info")
@SaCheckPermission("plugin:hello:info")
public RespInfo<HelloPluginInfoVO> info() { ... }
}
菜单与登录会话也不能漏。sys_resource 增加可空列 feature_code(迁移 V1.0.209):存量 NULL 保持原行为,不批量改写授权。SysResourceServiceImpl.getUserResources 无论超管还是普通用户,最后都走:
java
.filter(resource -> featureGate.isEnabled(resource.getFeatureCode()))
注释写明:超管菜单也受功能使用权限制;通配权限仍由接口 RequiresFeature 单独把关。 登录加载按钮权限/API 权限时同样先 featureGate.isEnabled,避免「未开通资源的 perms 被塞进会话快照,以后 Gate 关了会话里还留着」。

表 2:三道闸门对照(可收藏)
| 闸门 | 回答的问题 | 典型实现 | 挡不住什么(需其他层) |
|---|---|---|---|
| RBAC / Sa-Token | 你是谁、角色允不许 | @SaCheckPermission、会话 permissions |
超管通配;「版本有没有买」 |
FeatureGate + @RequiresFeature |
当前发行版开不开这功能 | CommunityFeatureGate / 许可证 Gate / 自定义 Bean |
租户数据隔离、业务状态机 |
| 离线运行时许可证 | 这个客户项目有没有签到这些 ee.* |
RuntimeLicenseFeatureGate |
源码防破解;即时在线吊销 |
接口注释也划了边界:@RequiresFeature 不能代替权限注解,也不能保护非 HTTP 后台调用 。按源码推演,定时任务/消息消费者若直接调 Service,仍要自己问 FeatureGate 或抽共享校验。
现象三:许可证文件「放了」≠ 功能一定开
企业授权叙事要的是:开源仓库不绑死许可证;客户工程默认关闭;打开后离线验签、绑定客户与项目,而不是绑 IP/MAC。
RuntimeLicenseAutoConfiguration 仅在 forge.license.enabled=true 时生效,且 before = PluginAutoConfiguration,用 @ConditionalOnMissingBean(FeatureGate.class) 替换社区默认 Gate------用户自己的 FeatureGate Bean 永远优先。
验签链路(commit b2e191f7...)几个硬约束值得收藏:
- 先对原始 payload 字节做 Ed25519 验签,再反序列化;禁止「重序列化后再验」把空白/字段顺序差异洗掉;
- 严格 JSON:拒绝重复键、未知字段、标量胁迫、尾随 token;文档 ≤32KiB,载荷 ≤16KiB;拒绝符号链接;
- 载荷绑定
customerId+installationId(项目级 UUID,多节点共用); Terms里 使用期限validUntil与维护期限maintenanceUntil刻意独立------维护到期不自动关掉永久使用权;- 多文件叠加按精确
ee.*合并;单份无效只关闭对应商业能力,社区功能不受牵连; - Loader 日志不打印载荷、客户标识、路径或密钥材料,失败时降级为「商业功能关闭」。
Gate 查询时社区 ee. 以外仍放行;企业码需存在可用许可证且 scope.featureCodes 命中:
java
if (community.isEnabled(featureCode)) {
return true;
}
return licenses.stream().anyMatch(license -> usable(license)
&& license.payload().scope().featureCodes().contains(featureCode.strip()));
配置形态(部署指南,按源码推演用于客户工程外置目录):
yaml
forge:
license:
enabled: true
customer-id: "数字客户ID"
installation-id: "项目固定小写UUID"
files:
- /opt/customer/license/license-one.json
public-keys:
forge-2026-01: /opt/customer/license/forge-2026-01-public.pem
改文件/公钥/绑定后需要重启节点;诊断页刷新只重算期限,不热加载文件------避免「页面显示有效、Gate 用另一份快照」的分裂(RuntimeLicenseState 注释强调诊断与 Gate 共用同一套状态机)。
插件描述:启动时一次装完,装不完就失败
三道闸门之上,还有一层「这个 jar 到底是不是合法插件」。PluginRegistry 扫描 classpath*:META-INF/forge-plugin.json,校验 id/版本/edition/features 前缀、requiresCore 语义化范围,描述 >64KiB 或 id 重复直接让启动失败。PluginDescriptorValidator 要求:edition=ee 时功能码必须以 ee. 开头,社区版则禁止该前缀------从元数据阶段就避免「社区包声明企业功能」。
hello 示例描述:
json
{
"id": "hello",
"edition": "community",
"requiresCore": ">=1.2.0 <2.0.0",
"features": ["community.hello"],
"server": { "module": "forge-plugin-hello" }
}
整条链路可以记成一句话:描述声明功能 → 独立迁移落菜单/资源 → FeatureGate 过滤菜单与会话 → Interceptor 盯接口 →(可选)许可证 Gate 打开 ee.*

工厂为什么要「清空」主配置里的 Java 迁移
只换 locations 和 table 看起来够了,但 PluginFlywayFactory.configuration 里还有一串刻意的「减法」:
java
return Flyway.configure(main.getClassLoader()).configuration(main)
.dataSource(main.getDataSource())
.locations(plan.location())
.table(plan.historyTable())
.baselineVersion("0")
.javaMigrations()
.javaMigrationClassProvider(List::of)
.resolvers(new MigrationResolver[0])
.resourceProvider(null)
.skipDefaultResolvers(false);
注释写的是:配置复制会带入主工程的 Java migrations / provider,仅换 locations 不足以隔离这些显式来源 。按源码推演,若不清空,插件 Flyway 实例可能在插件历史表里再次解析主工程的 Java 迁移类,轻则空跑困惑,重则把主版本号写进插件历史,彻底污染隔离约定。baselineVersion("0") 则让非空库首次挂插件时能从 0 基线接到插件自己的 V1.0.0,而不必和主库当前 baseline 对齐------集成测试里也覆盖了「两插件同版本号互不冲突、重启不重复执行」这类断言。
计划阶段 factory.plan 会先扫 classpath*:db/plugin/<id>/**/*,只有真正存在匹配后缀的 SQL 才进入计划,再按 pluginId 排序。策略层要求先算出全部 plan 并校验表名,再开始 migratePlugin :这样不会出现「A 插件已成功、B 插件表名非法」的半安装窗口。对运维来说,启动日志里的 执行插件迁移,pluginId=...,historyTable=... 就是最好的核对点。
许可证状态机:诊断页和 Gate 必须说同一种话
RuntimeLicenseState 把结果收成五种:valid / not_yet_valid / expired / binding_mismatch / invalid。评价顺序是:载荷空 → 绑定不符 → 未到生效时间 → 是否在使用期内。注释强调:不能出现页面有效而业务不放行的两套规则。因此插件中心「运行时授权」诊断复用启动时验签得到的不可变快照,刷新只拿同一个 UTC 时钟重算期限,不重新读盘、不打授权服务器。
这对私有化很重要:客户机房往往无外网,签发在门户完成,运行只信任本地公钥与文件。源码交付本身也不承诺「不可破解」------签名防的是伪造授权文件,不是防改源码;系统时间由客户控制,所以产品上把「永久使用 + 年度维保」拆开,比把维保到期直接映射成功能关闭更贴合合同语义。
你可以怎么当场验证
- 社区默认 :不配
forge.license.enabled,带ee.的 Controller 应 403,文案含「当前版本未开通该功能」;feature_code为空的老菜单仍可见。 - 独立历史 :安装 hello 后查库,应同时存在主
*_schema_history与*_plugin_hello_history;卸载/重装插件时主历史行数与 checksum 不应被插件脚本牵连。 - 超管双闸 :给某资源打上
ee.demo,超管菜单应消失;若故意去掉方法上的@RequiresFeature只留菜单过滤,按源码推演超管仍可能靠/**打到接口------这正是拦截器存在的理由。 - 许可证 :开启配置后用错
installation-id应得到binding_mismatch类关闭;维护期到期但validUntil为空的永久许可,allowsUse仍应为 true(以单测与Terms语义为准)。
starter 主代码大约四十个 Java文件、一千八百余行,测试代码量与之相当------底座小,但把「能挂插件」和「挂了不会祸害主库 / 不会被超管打穿」这两件事钉死了。
落地时容易踩的 5 个点
| # | 现象 | 先查什么 | 依据(源码) |
|---|---|---|---|
| 1 | 插件表不存在但主库已迁完 | Strategy 是否生效;脚本是否在 db/plugin/<id> |
PluginFlywayMigrationStrategy / PluginFlywayFactory |
| 2 | 历史表名非法启动失败 | 主表是否 *_schema_history;pluginId 是否 2--32 位小写 |
PluginMigrationPlan |
| 3 | 超管菜单仍见企业项 | 资源 feature_code 是否 ee.*;当前 Gate 实现 |
filterEnabledResources |
| 4 | 超管菜单没了但仍 200 | 接口是否漏了 @RequiresFeature |
Interceptor;超管 /** |
| 5 | 许可证开启仍全是社区 | enabled、绑定、公钥 keyId、文件是否符号链接;是否自定义了 FeatureGate |
RuntimeLicenseLoader / AutoConfiguration |
另外提醒:改 forge-plugin-* 模块后只 spring-boot:run admin-server,可能仍加载 ~/.m2 旧 jar------这是仓库 backend 踩坑记录里的经典「代码已改、行为未变」,与插件底座正交但排障时极常见。
小结
Forge Admin 的插件底座不是「再写一个插件市场 UI」,而是先把可卸载的迁移历史、可替换的功能使用权、默认可关的离线许可证做成 starter 级扩展点:
- 独立 Flyway:同库不同 history,主先插件后,失败不假装干净;
@RequiresFeature+ 菜单/会话过滤:补上超管通配捅破的洞;- 离线 Ed25519 许可证:绑定客户项目、使用与维保期限分离、坏一份不影响社区。
如果你也在做「开源核心 + 源码交付的企业插件」,这三处比先堆插件中心页面更值得先钉死。欢迎在评论区聊聊你们是把插件 SQL 合进主历史,还是一开始就拆表------以及对超管通配有没有单独的功能门。
仓库与文档:gitee.com/ForgeLab/fo...
相关提交锚点:7b1a75c1...(T1 底座)、32c6f4dc...(独立迁移)、ed197302...(菜单/登录过滤)、b2e191f7...(离线许可证)。