
简介这份PDF面向需要在客户内网环境部署收费软件、又希望控制授权范围的Java开发者与运维人员围绕SpringBoot与TrueLicense的整合讲解License授权与服务器许可管理的完整实现思路。压缩包内仅含1个PDF文件大小约4.13MB适合按章节对照代码与配置进行学习。目前已有4993人学习下载。内容覆盖依赖引入、License校验参数与创建参数设计、Linux与Windows服务器硬件信息获取、自定义LicenseManager及创建/安装/校验流程并涉及keytool生成公私钥证书库、证书配置与客户端启动校验等环节。读者可据此理解如何把IP、MAC、CPU序列号、主板序列号等条件纳入许可限制并搭建可落地的授权方案同时获得排错与模块拆分参考。1. 内网交付绕不开的授权文件SpringBoot 整合 TrueLicense 能解决什么给客户内网交付一套 SpringBoot 系统代码给出去、数据库也搬进去最怕的不是需求变更是尾款还没结清对方服务器上已经跑了大半年。这种场景下账号登录校验有效期根本不管用——应用连不上你的外网账号体系都在客户手里。真正能卡住的是服务器许可文件应用启动时读本地 license校验签名、有效期再比对 IP、MAC、CPU 序列号、主板序列号对不上就拒绝启动或限制关键功能。这套 SpringBoot 整合 TrueLicense 的示例就是把上面这条链路完整走了一遍依赖引入、校验参数建模、Linux 和 Windows 两套硬件信息采集、自定义 LicenseManager 的创建与校验、keytool 生成密钥库、Controller 暴露发证接口。适合做 To B 私有化交付的 Java 开发也适合想搞清楚「license 到底怎么签、怎么验」的人照着跑一遍。2. 工程骨架与技术选型TrueLicense 1.33 在 SpringBoot 里怎么落位先把依赖和目录定下来后面所有代码才有地方放。TrueLicense 这个库本身不复杂核心是LicenseManager、LicenseContent、LicenseCreator这几个接口但它默认只认「生效时间」和「过期时间」业务上要的机器绑定必须自己扩展。理解这一点整个工程的分层就很自然一部分是框架适配层一部分是业务参数层一部分是暴露给运维的 HTTP 入口。2.1 依赖与版本为什么是 truelicense-core 且 scope 为 provideddependency groupIdde.schlichtherle.truelicense/groupId artifactIdtruelicense-core/artifactId version1.33/version scopeprovided/scope /dependency这里scope用provided是个容易被忽略的细节。truelicense-core内部依赖了truelicense-jar、truelicense-xml等一票兄弟包而且它自带一套 SPI 加载机制对 JDK 版本和类加载器比较敏感。用provided意味着编译期能拿到类运行期由容器或你自己打包时统一提供避免和 SpringBoot 的 fat jar 类加载产生冲突。实际项目里如果发现启动时报LicenseManager相关类找不到八成就是打包插件把 provided 的包漏掉了需要在spring-boot-maven-plugin里显式 include。版本 1.33 是这套示例使用的版本它对 Java 8 友好LicenseContent的字段结构也稳定。如果升级到更高版本注意LicenseParam的构造参数可能有变化别直接照抄。2.2 目录分层license 包下按职责切四块工程里的 license 相关代码集中在com.example.demo.license包下按职责可以分成四块分层代表类职责参数模型LicenseCheckModel、LicenseCreatorParam承载发证与校验需要的字段硬件采集AbstractServerInfos及 Linux/Windows 子类抽象出跨平台的机器指纹采集许可操作自定义 LicenseManager、证书 Service创建、安装、校验、卸载对外入口LicenseCreatorController供发证方调用的 HTTP 接口这种切法的好处是硬件采集和许可逻辑解耦将来换绑机器指纹策略比如从 CPU 序列号换成磁盘序列号只动采集层发证接口和校验逻辑分开发证方在你自己手里校验逻辑随应用下发到客户环境两边代码可以复用同一套参数模型。2.3 为什么必须自定义 LicenseManagerTrueLicense 自带的verify只做三件事验签、看生效时间、看过期时间。它不知道什么叫「这台机器的 MAC 地址」。而客户内网部署的痛点恰恰是机器绑定——license 文件被复制到另一台服务器上照样能用那授权就是纸糊的。扩展点在于LicenseContent里有一个extra字段可以塞任意可序列化对象。把LicenseCheckModel作为 extra 写进证书校验时再从证书里读出来和当前机器的实际硬件信息逐项比对。常见做法是继承LicenseManager覆写validate方法在调用super.validate之后追加自定义比对。我一般会把比对逻辑单独抽成一个checkLicense私有方法失败时抛出LicenseContentException这样上层能统一捕获并返回明确错误码。3. 参数建模与硬件指纹采集跨平台拿到可信的机器信息License 能不能真正绑住机器全看这一章的采集逻辑靠不靠谱。参数模型是「约定」采集实现是「事实」两者字段必须严格对齐否则验签通过了、比对永远失败排查起来就是黑匣子。3.1 LicenseCheckModel四个字段定下绑定粒度Data public class LicenseCheckModel implements Serializable { private static final long serialVersionUID 1L; /** 可被允许的 IP 地址 */ private ListString ipAddress; /** 可被允许的 MAC 地址 */ private ListString macAddress; /** 可被允许的 CPU 序列号 */ private String cpuSerial; /** 可被允许的主板序列号 */ private String mainBoardSerial; }IP 和 MAC 用List而不是单值是血泪经验客户服务器经常多网卡Docker 环境还会多出虚拟网卡写死一个值换台机器就废。存列表、校验时取交集判断「至少有一项命中」更稳妥。CPU 序列号和主板序列号是单值因为它们在一台物理机上通常唯一且稳定适合做硬绑定。serialVersionUID显式声明为 1L因为这类对象会被序列化进证书将来字段增减如果 UID 变了老证书反序列化会直接抛异常。3.2 LicenseCreatorParam发证方需要的全部输入Data public class LicenseCreatorParam implements Serializable { private static final long serialVersionUID 1L; /** 证书 subject */ private String subject; /** 密钥私钥别称 */ private String privateAlias; /** 私钥密码需要妥善保管不能让使用者知道 */ private String keyPass; /** 访问密钥库的密码 */ private String storePass; /** 证书生成路径 */ private String licensePath; /** 私钥库存储路径 */ private String privateKeysStorePath; /** 证书生效时间 */ JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private Date issuedTime new Date(); /** 证书失效时间 */ JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private Date expiryTime; /** 用户类型 */ private String consumerType user; /** 用户数量 */ private Integer consumerAmount 1; /** 描述信息 */ private String description ; /** 额外的 License 校验参数 */ private LicenseCheckModel licenseCheckModel; }这里有几个参数值得单独说。keyPass和storePass是两个不同的东西前者是私钥条目本身的密码后者是打开整个密钥库keystore的密码keytool 生成时如果设成一样容易混建议分开设。licensePath和privateKeysStorePath是发证方本地的路径不要配到客户能访问的目录。issuedTime默认取当前时间expiryTime必须由调用方传没有默认值——过期时间忘了传签出来的证书立刻失效这种翻车见过不止一次。consumerType和consumerAmount对应 TrueLicense 的消费主体概念简单场景写死user和 1 就行复杂授权按并发数计费可以在这里扩展。3.3 AbstractServerInfos模板方法把跨平台差异挡在外面Slf4j public abstract class AbstractServerInfos { public LicenseCheckModel getServerInfos() { LicenseCheckModel result new LicenseCheckModel(); try { result.setIpAddress(this.getIpAddress()); result.setMacAddress(this.getMacAddress()); result.setCpuSerial(this.getCPUSerial()); result.setMainBoardSerial(this.getMainBoardSerial()); } catch (Exception e) { log.error(获取服务器硬件信息失败, e); } return result; } protected abstract ListString getIpAddress() throws Exception; protected abstract ListString getMacAddress() throws Exception; protected abstract String getCPUSerial() throws Exception; protected abstract String getMainBoardSerial() throws Exception; protected ListInetAddress getLocalAllInetAddress() throws Exception { ListInetAddress result new ArrayList(4); for (EnumerationNetworkInterface networkInterfaces NetworkInterface.getNetworkInterfaces(); networkInterfaces.hasMoreElements(); ) { NetworkInterface iface networkInterfaces.nextElement(); for (EnumerationInetAddress inetAddresses iface.getInetAddresses(); inetAddresses.hasMoreElements(); ) { InetAddress inetAddr inetAddresses.nextElement(); // 排除回环、链路本地、组播地址保留真实网卡地址 if (!inetAddr.isLoopbackAddress() !inetAddr.isLinkLocalAddress() !inetAddr.isMulticastAddress()) { result.add(inetAddr); } } } return result; } protected String getMacByInetAddress(InetAddress inetAddr) { try { byte[] mac NetworkInterface.getByInetAddress(inetAddr).getHardwareAddress(); StringBuilder sb new StringBuilder(); for (int i 0; i mac.length; i) { if (i ! 0) { sb.append(-); } String temp Integer.toHexString(mac[i] 0xff); if (temp.length() 1) { sb.append(0).append(temp); } else { sb.append(temp); } } return sb.toString().toUpperCase(); } catch (SocketException e) { e.printStackTrace(); } return null; } }模板方法模式在这里用得恰到好处getServerInfos固定了「组装四个字段」的流程具体怎么取交给子类。IP 采集统一走getLocalAllInetAddress注意代码里原版注释掉了!inetAddr.isSiteLocalAddress()这个条件——把内网地址也保留下来因为客户的服务器本来就是内网 IP过滤掉 SiteLocal 会导致拿不到任何地址。这是很典型的「默认值不适合私有化场景」的调整。getMacByInetAddress里mac[i] 0xff这一步不能省Java 的 byte 是有符号的不转无符号十六进制会拼出ffffff开头的怪字符串。补齐两位、统一大写是为了让发证和校验两侧的字符串严格一致——大小写不一致导致比对失败的坑一踩一个准。3.4 Linux 与 Windows 子类命令行的稳定性差异public class LinuxServerInfos extends AbstractServerInfos { Override protected String getCPUSerial() throws Exception { String serialNumber ; // dmidecode 读取处理器 ID 字段 String[] shell {/bin/bash, -c, dmidecode -t processor | grep ID | awk -F : {print $2} | head -n 1}; Process process Runtime.getRuntime().exec(shell); process.getOutputStream().close(); BufferedReader reader new BufferedReader(new InputStreamReader(process.getInputStream())); String line reader.readLine(); if (line ! null line.trim().length() 0) { serialNumber line.trim(); } reader.close(); return serialNumber; } Override protected String getMainBoardSerial() throws Exception { String serialNumber ; // 主板序列号同样依赖 dmidecode String[] shell {/bin/bash, -c, dmidecode | grep Serial Number | awk -F : {print $2} | head -n 1}; Process process Runtime.getRuntime().exec(shell); process.getOutputStream().close(); BufferedReader reader new BufferedReader(new InputStreamReader(process.getInputStream())); String line reader.readLine(); if (line ! null line.trim().length() 0) { serialNumber line.trim(); } reader.close(); return serialNumber; } }Linux 侧完全依赖dmidecode这个命令需要 root 权限普通用户执行会返回空或报错。实际部署时要么让应用以有权限的用户运行要么在启动脚本里预先把硬件信息写进配置文件。另外dmidecode | grep Serial Number会命中很多条目机箱、电池都有序列号head -n 1取第一条不一定是主板稳定做法是dmidecode -t baseboard精确定位。示例代码够用但生产上建议改精确。public class WindowsServerInfos extends AbstractServerInfos { Override protected String getCPUSerial() throws Exception { String serialNumber ; // WMIC 查询处理器 ID Process process Runtime.getRuntime().exec(wmic cpu get processorid); process.getOutputStream().close(); Scanner scanner new Scanner(process.getInputStream()); if (scanner.hasNext()) { scanner.next(); // 跳过表头 } if (scanner.hasNext()) { serialNumber scanner.next().trim(); } scanner.close(); return serialNumber; } Override protected String getMainBoardSerial() throws Exception { String serialNumber ; // WMIC 查询主板序列号 Process process Runtime.getRuntime().exec(wmic baseboard get serialnumber); process.getOutputStream().close(); Scanner scanner new Scanner(process.getInputStream()); if (scanner.hasNext()) { scanner.next(); } if (scanner.hasNext()) { serialNumber scanner.next().trim(); } scanner.close(); return serialNumber; } }Windows 侧用wmic注意scanner.next()第一次是跳过输出表头这个细节漏了就会把ProcessorId这个单词当成序列号。另外近几年的 Windows Server 版本里wmic已被标记弃用新环境可能要用 PowerShell 的Get-CimInstance替代这是升级系统时会遇到的隐性坑。工厂选择哪套实现常见做法是读System.getProperty(os.name)判断含win走 Windows 实现否则走 Linux 实现。4. 证书签发与校验链路从 keytool 到启动时验签参数齐了接下来是真正生成 license 文件和验证 license 文件。这条链路分两段发证方在自己机器上生成密钥库、调用创建接口产出 license客户端拿到 license 后放进指定目录应用启动时加载校验。两段用的密钥必须是同一对公钥随应用下发私钥只留在发证方手里。4.1 keytool 生成公私钥证书库# 生成私钥库发给客户的是从里面导出的公钥证书 keytool -genkeypair \ -alias privateKey \ -keysize 1024 \ -validity 3650 \ -keyalg RSA \ -dname CNlocalhost, OUxxx, Oxxx, Lxxx, STxxx, Cxxx \ -keypass 123456 \ -keystore privateKeys.store \ -storepass 123456 # 从私钥库导出公钥证书这个文件要随应用一起打到客户端 keytool -export \ -alias privateKey \ -keystore privateKeys.store \ -storepass 123456 \ -file publicCerts.cer-dname里的 CN 建议和LicenseCreatorParam.subject保持一致虽然 TrueLicense 主要靠密钥对验签但 subject 不一致在某些校验路径下会报错。-keysize 1024是示例值生产上 RSA 建议 2048 起步。-validity是密钥库本身的有效期和 license 的过期时间是两回事别混淆——密钥库过期了所有基于它签发的 license 都会验不过。导出后的publicCerts.cer不是直接给 TrueLicense 用的还需要转成CertStore能加载的格式常见做法是把它再导入一个只含公钥的 keystore这部分在自定义 LicenseManager 初始化时处理。4.2 自定义 LicenseManager创建与校验两条路径public class CustomLicenseManager extends LicenseManager { // 校验时追加硬件信息比对 Override protected synchronized void validate(LicenseContent content) throws LicenseContentException { super.validate(content); // 先走框架的验签 时间校验 LicenseCheckModel expected (LicenseCheckModel) content.getExtra(); LicenseCheckModel actual getServerInfos(); if (!checkIp(expected, actual) || !checkMac(expected, actual)) { throw new LicenseContentException(IP 或 MAC 地址不匹配); } if (!Objects.equals(expected.getCpuSerial(), actual.getCpuSerial())) { throw new LicenseContentException(CPU 序列号不匹配); } if (!Objects.equals(expected.getMainBoardSerial(), actual.getMainBoardSerial())) { throw new LicenseContentException(主板序列号不匹配); } } private boolean checkIp(LicenseCheckModel expected, LicenseCheckModel actual) { if (expected.getIpAddress() null || expected.getIpAddress().isEmpty()) { return true; // 未配置则不校验兼容宽松授权 } return expected.getIpAddress().stream() .anyMatch(ip - actual.getIpAddress().contains(ip)); } }super.validate(content)这一步不能省它负责验签和有效期跳过它等于自己实现一遍签名算法。content.getExtra()拿到的就是发证时塞进去的LicenseCheckModel。比对策略上示例里 IP/MAC 用「至少命中一项」CPU 和主板用「全等」这是个合理的宽松度客户换网卡不至于立刻失效但换机器一定废。如果客户环境 IP 会变DHCP建议把 IP 校验做成可选只绑 MAC 和主板。异常统一抛LicenseContentException上层捕获后转成业务错误码比直接抛 RuntimeException 好排查。4.3 LicenseCreatorService把参数组装成证书Service public class LicenseCreatorServiceImpl implements LicenseCreatorService { Override public boolean createLicense(LicenseCreatorParam param) { try { // 1. 初始化 LicenseParam LicenseParam licenseParam new LicenseParam() { // 省略subject、keyStoreParam、consumerType 等字段装配 }; // 2. 用密钥创建 LicenseManager LicenseManager manager new CustomLicenseManager(licenseParam); // 3. 把硬件参数写进 extra LicenseContent content new LicenseContent(); content.setSubject(param.getSubject()); content.setIssued(param.getIssuedTime()); content.setNotBefore(param.getIssuedTime()); content.setHolder(new X500Principal(CN param.getSubject())); content.setConsumerType(param.getConsumerType()); content.setConsumerAmount(param.getConsumerAmount()); content.setInfo(param.getDescription()); content.setExtra(param.getLicenseCheckModel()); // 4. 生成并写入 licensePath manager.create(content); return true; } catch (Exception e) { log.error(生成 license 失败, e); return false; } } }LicenseContent的字段对应 TrueLicense 的规范issued是签发时间notBefore是生效起始holder必须是X500Principal格式错了直接抛异常。extra赋值为LicenseCheckModel是整个扩展的核心——它会被序列化进 license 文件客户端校验时原样取回。manager.create(content)内部会完成签名和写文件。4.4 Controller 与启动校验发证入口和拦截时机RestController RequestMapping(/license) public class LicenseCreatorController { Resource private LicenseCreatorService licenseCreatorService; PostMapping(/create) public Result create(RequestBody LicenseCreatorParam param) { // 发证接口只应在发证方环境暴露不要随客户端部署 return licenseCreatorService.createLicense(param) ? Result.ok(生成成功) : Result.fail(生成失败检查密钥库路径与密码); } }这个接口建议在发证时临时启用生产交付版本的配置里直接关掉否则客户能自己造 license。校验时机放在应用启动阶段更可靠常见做法是实现ApplicationRunner或CommandLineRunner启动时加载 license 文件并调用manager.verify失败则打印错误并退出进程如果业务上允许降级运行就改为设置一个全局licenseValid标志关键接口前拦一道。5. 避坑排查license 校验失败时先看这五处这套东西跑通一次不难难的是客户现场失效时能快速定位。下面几条是我自己踩过或者帮人排查过的按「现象 → 原因 → 解决」记一下。提示排查时优先看日志里抛出的LicenseContentException具体信息它通常会指明是时间、签名还是硬件比对失败。现象启动时报LicenseContentException: The license has expired但过期时间明明还没到。原因多半是服务器系统时间和发证机时间不一致或者时区没对齐。issuedTime和expiryTime序列化时带了GMT8服务器如果是 UTC 时区算出来的生效点会偏移 8 小时。解决统一容器时区-Duser.timezoneGMT8或挂载/etc/localtime并确认 NTP 同步正常。现象验签失败报签名不匹配或InvalidKeyException。原因通常是客户端加载的公钥证书和发证用的私钥对不上常见于改过 alias 或者重新生成过密钥库但忘了替换客户端的 cer 文件。解决核对发证机的privateKeys.store和客户端随包下发的公钥证书是否同一对重新导出并替换。现象硬件比对永远失败日志显示 CPU 序列号为 null 或空串。Linux 侧是dmidecode没有权限Windows 侧是wmic被弃用或输出表头没跳过。解决Linux 给执行用户加权限或用sudo包装命令Windows 换用 PowerShell 的Get-CimInstance Win32_Processor并调整解析逻辑同时在采集失败时打 warn 日志而不是静默返回空。现象开发机本地验证通过客户服务器上 IP 比对失败。原因多网卡环境采集到的 IP 列表和发证时记录的列表顺序或内容不同或者客户机器有额外的虚拟网卡。解决发证前让客户提供目标机器的ip addr/ipconfig输出直接按实际地址填校验逻辑用集合交集而不是列表相等。另外注意 IPv6 地址getHostAddress可能返回fe80::开头的地址采集时最好过滤掉。现象license 文件放到指定目录应用仍然提示未授权。原因是licensePath配的是相对路径工作目录不同导致读不到文件或者文件名被改动TrueLicense 默认按配置的路径精确查找。解决配置用绝对路径或classpath:前缀明确来源启动日志里把实际读取的路径打出来。别小看这一条生产上因为启动脚本cd到别的目录导致读不到 license 的情况相当常见。6. 二次开发把发证流程做成可运维的授权服务示例工程是「能跑通」的形态真要用到项目里我一般会做三件事改造。第一是密钥管理privateKeys.store绝不能和代码一起进 Git哪怕是私有仓库。做法是放到独立的密钥管理服务或者发证机的加密目录通过环境变量注入路径和密码密码在配置里用占位符启动时解密。第二是 license 内容扩展除了硬件绑定还可以在extra里塞授权功能列表比如「允许导出报表」「允许并发 50」校验通过后把LicenseCheckModel挂到 Spring 的ApplicationContext里业务代码注入即可判断功能开关比单纯的「能用/不能用」灵活得多。第三是发证流程自动化。手动调 Controller 发证容易出错尤其是每次都要手填机器指纹。我的做法是客户端提供一个只读的诊断接口/license/machine-code返回当前机器的 IP/MAC/CPU/主板信息这个接口不需要许可就能访问运维把这段 JSON 发给发证方发证方解析后填进LicenseCreatorParam.licenseCheckModel调用发证接口生成 license 文件。整个链路里人只负责传一段文本不手抄序列号。验证改造是否成功最直接的方法不是看代码是跑三组用例用例操作预期正常激活用 A 机器的指纹发证A 机器启动校验通过功能可用换机复制把 A 的 license 拷到 B 机器启动失败日志明确提示硬件不匹配时间篡改把 A 机器系统时间调到过期之后启动失败提示已过期这三组跑通链路上基本没有隐藏问题。还有一个技巧值得说给 license 加一个「宽限期」字段塞进extra到期后不是立刻拒绝而是进入只读模式并每天打日志提醒。这个设计在续费谈判时非常好用客户能看见数据但改不了比突然停服温和得多也不容易被运维拿「影响生产」当理由施压。我自己踩得最深的一次是客户把服务器从物理机迁到了虚拟机主板序列号全变了license 当场失效现场运维又没法立刻联系上我。从那以后我每次发证前都强制走一遍「指纹采集 人工比对 宽限期兜底」这三步宁可多发一次也不要现场失联。希望这些经验帮到你拿到源码后先把 keytool 那段跑一遍再改硬件采集顺序别反。本文还有配套的精品资源点击获取