企业接口照样拦得住:独立 Flyway、@RequiresFeature 与离线许可证

客户要把企业插件装进同一套 Forge Admin 工程:同一 MySQL、同一 Spring Boot 进程。第一次直觉往往是------插件 SQL 丢进主 db/migration,菜单靠角色藏一藏,许可证文件往 resources 一塞。结果常见三种现场:

  1. 主库 Flyway 报 checksum / 版本撞车,或卸载插件后主历史表留下「幽灵版本」;
  2. 超管会话权限是 * / /**,菜单藏了,接口文档仍打得开企业 API;
  3. 维保到期被误当成「功能也不能用了」,或许可证坏了把整个社区功能一起拖死。

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...)几个硬约束值得收藏:

  1. 先对原始 payload 字节做 Ed25519 验签,再反序列化;禁止「重序列化后再验」把空白/字段顺序差异洗掉;
  2. 严格 JSON:拒绝重复键、未知字段、标量胁迫、尾随 token;文档 ≤32KiB,载荷 ≤16KiB;拒绝符号链接;
  3. 载荷绑定 customerId + installationId(项目级 UUID,多节点共用);
  4. Terms 里 使用期限 validUntil 与维护期限 maintenanceUntil 刻意独立------维护到期不自动关掉永久使用权;
  5. 多文件叠加按精确 ee.* 合并;单份无效只关闭对应商业能力,社区功能不受牵连;
  6. 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 时钟重算期限,不重新读盘、不打授权服务器。

这对私有化很重要:客户机房往往无外网,签发在门户完成,运行只信任本地公钥与文件。源码交付本身也不承诺「不可破解」------签名防的是伪造授权文件,不是防改源码;系统时间由客户控制,所以产品上把「永久使用 + 年度维保」拆开,比把维保到期直接映射成功能关闭更贴合合同语义。

你可以怎么当场验证

  1. 社区默认 :不配 forge.license.enabled,带 ee. 的 Controller 应 403,文案含「当前版本未开通该功能」;feature_code 为空的老菜单仍可见。
  2. 独立历史 :安装 hello 后查库,应同时存在主 *_schema_history 与 *_plugin_hello_history;卸载/重装插件时主历史行数与 checksum 不应被插件脚本牵连。
  3. 超管双闸 :给某资源打上 ee.demo,超管菜单应消失;若故意去掉方法上的 @RequiresFeature 只留菜单过滤,按源码推演超管仍可能靠 /** 打到接口------这正是拦截器存在的理由。
  4. 许可证 :开启配置后用错 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...(离线许可证)。

相关推荐
十年Java程序媛1 小时前
Lambda 与函数式接口|别只会复制 ()->{},底层规则和坑一次性讲清
java·spring boot·后端
yunwei371 小时前
eBPF 入门开发实践教程一:Hello World,基本框架和开发流程
linux·后端·性能优化
全栈Agent 小李1 小时前
【无标题】
前端·后端·agent·ai编程·全栈·cursor·mcp
高频因子挖掘机1 小时前
历史 K 线突然少一天?用交易日历、停牌信息和数据校验逐步排查
后端·github·api
Nozokime1 小时前
ThreadLocal 在流式 Agent 里静默失效:LangChain4j 轨迹埋点的零侵入方案
java
小宋10211 小时前
OpenTelemetry GenAI可观测性实战:串起模型、工具、Token与错误
java·人工智能·算法·贪心算法
m0_587383001 小时前
工业场景设备维修维护实战技巧 全流程标准化落地与常见问题排查指南
java·spring·小程序·架构·需求分析
行者全栈架构师1 小时前
从 55% 到 6%:一个快餐营养规划器的算法迭代实录
后端·算法·架构
余槐i2 小时前
无慢查询 RT 却飙升:cProfile 定位 FastAPI 事件循环阻塞
后端·python·性能优化·fastapi·asyncio