资讯动态

SpringBoot+PF4J+Shiro:打造可插拔高权限发票管理系统

发布时间:2026/9/7 1:32:47 来源:尧图企业网站定制
简介一份面向计算机科学与技术及相关专业本科、专科毕业生的原创学士学位论文选题为基于SpringBootPF4JShiro的发票管理系统设计与实现。论文未入库、可过查重适合毕业论文写作、学术研究及SpringBoot技术学习。压缩包共1个docx文档大小约30KB包含从封面、摘要、目录至正文与参考文献的完整内容。目前已有208人浏览学习。论文围绕框架核心展开SpringBoot的自动配置、起步依赖与内嵌服务器降低项目搭建难度PF4J提供插件化模块管理实现功能热插拔与隔离Shiro负责身份验证、授权、会话管理和数据加密增强系统安全性。系统采用前后端分离架构设计用户管理、发票管理、报表统计、插件管理等模块并从需求分析、系统设计、编码实现到性能测试给出完整流程。对准备毕业设计的学生既可借鉴技术选型与架构分层也可参考论文的章节组织与写作逻辑具有较强的实用价值。 做发票管理系统这个项目起因其实特别朴素公司每个月的进项、销项发票量过了千张之后Excel 表格已经完全撑不住了。最初的想法是找个现成的发票系统但看了几个之后发现定制化需求太多——不同区域的开票渠道接口不统一、企业内部审批流又各不相同、后续还要持续接入新的开票方式。纠结了几天我干脆决定自己搭一套技术栈就定为 SpringBoot PF4J Shiro。最后这个方案不仅顺利上线而且后续接新渠道、改业务规则的时候省了非常大的人力。这篇文章就是我对整个项目从设计到落地的完整复盘希望能给同样在做发票管理系统或者正在纠结架构选型的朋友一些参考。整套系统要解决的不仅是发票数据录入这么简单。发票涉及开票、查验、红冲、作废、预警、统计等多个环节业务规则繁琐且需要与外部渠道频繁交互。我从一开始就确定了一个原则核心底座要稳定扩展边界要清晰权限控制要严格。于是 SpringBoot 负责整个应用的自动配置与业务容器PF4J 负责把频繁变化的功能插件化Shiro 负责身份认证与细粒度权限控制这套组合最后跑出来的效果超出预期。1. 为什么是 SpringBootPF4JShiro 这套组合1.1 发票管理系统真正的痛点很多人以为发票系统就是个 CRUD真正做进去才发现它远不止如此。第一道坎是渠道对接。以开票为例航天信息、百旺、电子发票平台、自建开票服务每家接口协议、签名方式、返回字段都不一样。如果把这些写死在业务代码里每次新增一个渠道都要改主程序、重新发版风险极高。第二道坎是规则和状态的复杂度。发票从申请到开具要经过审批、额度校验、商品编码匹配、价税计算中间任何一步失败都要有回滚机制。红冲、作废、部分退回等操作会改变发票生命周期状态状态机设计稍有不慎就会产生脏数据。第三道坎是权限的敏感性。发票直接关联企业资金和税务数据财务人员、业务人员、管理员、审计人员看到的界面和可操作范围必须严格区分。如果权限模型太粗很容易出现越权查看或误操作。这三道坎决定了技术选型的核心方向底座要稳、扩展要灵活、权限要严谨。只靠 SpringBoot 本身能解决前两点的一半但扩展性还是不够只靠 Shiro 能解决权限但改代码的频率还是降不下来。这时候 PF4J 的插件化方案恰好能补上那块短板。1.2 三者分工与选型对比在我这套架构里三个框架各管一摊边界非常清晰。SpringBoot 是业务底座负责把 Spring 容器、MyBatis、Redis、任务调度、消息队列这些基础设施全部拉起来同时承载核心业务服务比如发票主数据管理、审批流引擎、报表统计。选它而不是传统 SSM最大原因就是自动配置和生态成熟度开发效率完全不在一个量级。SpringBoot 的 starter 机制让我不用再花大量时间在配置 XML 上。PF4J 是插件中枢负责把所有可能变化的业务能力做成可插拔组件。选 PF4J 之前我对比过 OSGi 和 Spring 自带的插件机制。OSGi 功能强但类加载模型复杂、学习曲线陡对我们这种小团队来说性价比太低。Spring 的插件机制SpringPlugin、ExtensionPoint虽然与 Spring 容器结合紧密但动态加载和卸载能力还是偏弱。PF4J 轻量、易上手、天然支持运行时动态加载插件 jar和 Spring 容器可以通过扩展点的方式优雅整合最终我选了它。Shiro 是安全闸门负责认证、授权、会话管理。对比 Spring SecurityShiro 的 API 更直白权限模型Subject、SecurityManager、Realm更容易理解对单体应用和中小型系统非常友好。而且 Shiro 天然支持将 Session 持久化到 Redis为后期多实例部署做集群时不用推倒重来。2. 项目整体设计与业务模块拆解2.1 核心业务流程梳理发票管理系统的业务主线有两条销项发票和进项发票。销项发票是面向开票侧的流程大致是业务人员提交开票申请系统校验客户信息、商品编码、税率、金额走审批流审批通过后调用对应渠道的开票接口成功后回写发票号码、发票代码、开票日期生成开票记录。后续如果发生退货或开票错误走红冲或者作废流程。进项发票是面向收票侧的流程大致是从扫描件、PDF、税控盘导出文件等渠道采集发票数据系统调用查验接口做真伪校验校验通过后进入待认证池财务人员确认认证状态最终关联到对应业务单据和会计凭证同时做重复报销、连号发票、异常供应商等风险预警。两条主线之外还需要查询统计功能支撑财务月底对账比如按期间统计销项/进项金额、按税率汇总、按客户汇总等。报表模块如果也做成插件就可以针对不同企业定制不同的统计口径而不用动主程序。2.2 插件化拆分哪些功能天生适合做插件这一块是我在整个项目中收益最大的设计。总结下来有四类功能非常适合拆成插件第一类是开票渠道适配器。每个渠道都实现同一个扩展点接口输入统一的开票请求对象输出统一的开票结果对象内部差异全封装在插件里。新增渠道时只需要开发一个新插件 jar丢到 plugins 目录系统界面点一下加载就能生效。第二类是发票查验通道。查验接口主要有税局公共服务通道和第三方商业通道不同通道的认证方式、调用限额、协议都不一样。我的方式是把每一个查验通道做成一个插件业务层通过查验策略去选择走哪个通道如果某个通道配额用完还可以自动降级到备用通道。第三类是业务规则引擎。比如不同客户群体适用不同的开票审批策略、不同发票类型有不同的有效性校验规则。这些规则变化频繁放在代码里改一次发一次版太痛苦。做成规则插件后业务人员通过系统页面启停对应插件就能实现规则切换。第四类是报表模板。每个企业的统计口径不一样报表插件可以定义自己的查询逻辑和模板输出结构化的数据给前端展示或导出。实际上你完全可以把这个思路迁移到其他任何管理系统中凡是高频变化和多版本共存的模块都值得插件化。2.3 数据库设计与核心表结构核心表我大致分为五组发票主数据、流程数据、插件数据、权限数据、日志数据。发票主数据主要包括发票主表invoice_master存发票基础信息发票代码、发票号码、开票日期、购方信息、销方信息、含税金额、税率、税额、发票状态、关联业务单号。发票明细表invoice_detail存商品明细行商品名称、规格、数量、单价、金额、税额。开票申请表invoice_apply存业务人员提交的原始开票请求以及审批状态。流程数据包括审批记录表approval_record、状态流转表invoice_status_log。插件数据包括插件注册表plugin_registry、插件配置表plugin_config。权限数据沿用 Shiro 经典的 RBAC 五张表用户表、角色表、权限表、用户角色关联表、角色权限关联表。日志数据则包含操作审计日志、接口调用日志、异常日志。核心表结构设计时特别要注意两点发票号码和发票代码要建立联合唯一索引防止重复开票所有金额字段统一使用 Decimal避免浮点精度问题。此外金额字段还要区分含税和不含税方便做价税分离计算。3. 核心细节解析与实操要点3.1 PF4J 集成与插件生命周期管理PF4J 的核心理念非常简洁定义扩展点ExtensionPoint插件Plugin里实现扩展点插件管理器PluginManager负责加载、启动、停止、卸载插件。先定义扩展点接口public interface InvoiceChannelPlugin { // 渠道类型标识例如AISINO、BAIWANG、SELF_BUILT String channelType(); // 开票 InvoiceResult issueInvoice(InvoiceRequest request); // 发票查验 VerifyResult verifyInvoice(VerifyRequest request); // 红冲 InvoiceResult redInvoice(RedRequest request); }然后开发一个插件实现类以航天信息渠道为例public class AiSinoChannelPlugin implements Plugin { private final PluginWrapper wrapper; public AiSinoChannelPlugin(PluginWrapper wrapper) { this.wrapper wrapper; } Override public void start() { // 初始化 HTTP 客户端、加载渠道证书 } Override public void stop() { // 关闭连接池、释放证书资源 } Extension public static class AiSinoInvoiceChannel implements InvoiceChannelPlugin { Override public String channelType() { return AISINO; } Override public InvoiceResult issueInvoice(InvoiceRequest request) { // 封装对接航天信息开票接口的逻辑 return null; } Override public VerifyResult verifyInvoice(VerifyRequest request) { // 封装对接税局查验接口的逻辑 return null; } Override public InvoiceResult redInvoice(RedRequest request) { // 红冲逻辑 return null; } } }这里有两个实操细节必须注意。第一插件的 META-INF/MANIFEST.MF 文件必须包含 plugin-id、plugin-version、plugin-class 等条目否则 PF4J 无法识别这是一个插件。建议直接用 PF4J 的 Maven 插件来生成插件 jar避免手写出错。第二插件start()和stop()方法里的资源管理是最容易踩坑的地方特别是 HTTP 连接池和证书文件如果只在stop()里断开连接而不释放文件句柄插件热卸载几次后就会把文件描述符耗尽。宿主应用中的插件管理器建议做成单例Service public class PluginRegistryService { private final PluginManager pluginManager; public PluginRegistryService() { this.pluginManager new DefaultPluginManager(); pluginManager.loadPlugins(); pluginManager.startPlugins(); } Autowired private ApplicationContext applicationContext; PostConstruct public void registerExtensions() { ListInvoiceChannelPlugin extensions pluginManager.getExtensions(InvoiceChannelPlugin.class); // 将扩展点实例注册到 Spring 容器或者存入 Map 供业务调用 MapString, InvoiceChannelPlugin channelMap new ConcurrentHashMap(); for (InvoiceChannelPlugin extension : extensions) { channelMap.put(extension.channelType(), extension); } } }3.2 Shiro 集成与 Redis 会话共享Shiro 的集成在单机场景不难麻烦的是集群场景下的会话共享。因为热词里提到了 spring5 shiro redis session我在项目里也正是用 Redis 替换了 Shiro 默认的内存 SessionDAO。Shiro 默认使用 DefaultWebSessionManager 管理会话所有 Session 保存在 JVM 内存中应用重启或者多实例部署时Session 就丢了。换成 Redis 的完整思路是自定义一个 RedisSessionDAO 覆盖原来的 MemorySessionDAO让 Session 的创建、读取、删除都走 Redis。核心配置如下Configuration public class ShiroConfig { Bean public Realm realm() { return new JwtRealm(); } Bean public RedisSessionDAO redisSessionDAO(RedisTemplateString, Object redisTemplate) { RedisSessionDAO sessionDAO new RedisSessionDAO(redisTemplate); sessionDAO.setKeyPrefix(shiro:session:); sessionDAO.setExpireTime(1800); // 30分钟过期单位秒 return sessionDAO; } Bean public DefaultWebSessionManager sessionManager(RedisSessionDAO redisSessionDAO) { DefaultWebSessionManager sessionManager new DefaultWebSessionManager(); sessionManager.setSessionDAO(redisSessionDAO); sessionManager.setGlobalSessionTimeout(1800000L); // 与 Redis 过期时间保持一致 sessionManager.setDeleteInvalidSessions(true); return sessionManager; } Bean public SecurityManager securityManager(Realm realm, DefaultWebSessionManager sessionManager) { DefaultWebSecurityManager securityManager new DefaultWebSecurityManager(); securityManager.setRealm(realm); securityManager.setSessionManager(sessionManager); return securityManager; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); MapString, String filterChainDefinitionMap new LinkedHashMap(); // 登录接口和静态资源放行 filterChainDefinitionMap.put(/login, anon); filterChainDefinitionMap.put(/static/**, anon); // 插件管理接口需要登录且需要管理员权限 filterChainDefinitionMap.put(/api/plugin/**, authc, roles[admin]); // 其余接口都需要登录 filterChainDefinitionMap.put(/**, authc); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return factoryBean; } }这里有一个非常关键的坑DefaultWebSessionManager的全局超时时间必须和 Redis 中 session 的过期时间保持一致。如果 Shiro 认为 Session 还有效但 Redis 里的 key 已经过期了用户会被强制踢下线体验非常差。我最初就是因为这两个值不一致排查了整整一天。另外Redis 的 key 序列化建议使用 StringRedisSerializervalue 使用 Jackson 序列化。如果都用 JdkSerializationRedisSerializer会出现 key 带乱码、不好排查的问题也会因为序列化后的体积过大浪费内存。3.3 权限模型设计从用户到数据权限模型沿用 Shiro 经典的 RBAC用户-角色-权限但在真实发票系统里光靠角色权限还不够。比如财务主管和财务专员都有查询发票的权限但主管需要看到本部门全部数据专员只能看到自己负责的客户数据。这种数据权限就不能靠 Shiro 的 URL 拦截直接解决而要在业务查询条件里拼上数据范围。我的做法是在 Realm 中把角色的数据权限范围写成多个维度比如全部、本部门、本人存到 Shiro 的主体属性里业务层取出来作为查询过滤条件。这样设计之后权限逻辑集中在认证层和统一的数据过滤层业务代码不需要关心具体用户能看哪些数据只需要调用公共的数据权限工具类。4. 实操过程与核心环节实现4.1 项目初始化与依赖配置项目基于 SpringBoot 2.7 构建JDK 使用 1.8核心依赖如下表所示依赖版本用途spring-boot-starter-web2.7.10Web 容器spring-boot-starter-data-redis2.7.10Redis 客户端与序列化mybatis-plus-boot-starter3.5.3ORM 与分页shiro-spring-boot-web-starter1.11.0Shiro 权限框架pf4j3.9.0插件化框架pf4j-spring0.9.0PF4J 与 Spring 整合mysql-connector-j8.0.32MySQL 驱动hutool-all5.8.18工具类lombok1.18.26简化代码工程目录按 maven 多模块拆分主程序模块app负责 SpringBoot 启动、Shiro 配置、基础工具和数据库访问扩展点模块api只包含接口定义提供给插件开发方引用各种插件模块plugin-aisino、plugin-baiwang、plugin-verify-tax、plugin-verify-third独立打包成可运行的插件 jar。这种结构的好处是各团队负责各自插件时不需要互相等待改完独立发布即可。4.2 插件动态管理接口实现插件加载和卸载接口是系统比较有代表性的一个功能点也是我对外演示时最喜欢展示的部分。RestController RequestMapping(/api/plugin) public class PluginManagementController { private final PluginManager pluginManager; public PluginManagementController(PluginManager pluginManager) { this.pluginManager pluginManager; } PostMapping(/load) public Result loadPlugin(String pluginPath) { PluginDescriptor pluginDescriptor pluginManager.loadPlugin(pluginPath); pluginManager.startPlugin(pluginDescriptor.getPluginId()); return Result.ok(插件加载成功); } PostMapping(/stop) public Result stopPlugin(String pluginId) { pluginManager.stopPlugin(pluginId); return Result.ok(插件已停止); } PostMapping(/unload) public Result unloadPlugin(String pluginId) { pluginManager.unloadPlugin(pluginId); return Result.ok(插件已卸载); } GetMapping(/list) public Result listPlugins() { ListPluginWrapper plugins pluginManager.getPlugins(); return Result.ok(plugins.stream() .map(p - Map.of( id, p.getDescriptor().getPluginId(), state, p.getPluginState().toString(), version, p.getDescriptor().getPluginVersion())) .toList()); } }这个接口在后台系统里就是四个按钮加载、启动、停止、卸载。实际操作中如果插件正在被业务线程使用直接停掉会抛异常。为了避免这种问题我在插件服务里加了一个引用计数业务调用插件扩展点之前acquire()结束之后release()插件管理器在 stop 之前会等待所有引用释放超时才强制停止。4.3 开票核心流程实现开票流程我用状态机来管理状态流转核心状态包括草稿DRAFT、待审批PENDING_APPROVAL、已审批APPROVED、开票中ISSUING、已开票ISSUED、已红冲RED、已作废CANCELED、开票失败FAILED。事务边界设计在这里非常重要。我的原则是本地事务只管理本地业务操作外部渠道调用绝对不放在同一个事务里。开票流程的具体步骤如下保存开票申请单状态为待审批。审批通过后状态改为开票中。调用发票渠道插件的开票接口这里是远程调用耗时可能几秒到几十秒。根据远程返回结果更新本地状态成功则写入发票主表和明细表状态改为已开票失败则记录错误日志状态改为开票失败支持重试。如果外部调用和本地事务放在同一个事务里远程接口超时会导致数据库连接长时间被占用在高并发下很容易把连接池耗尽。这一点是我在压测阶段真实踩过的坑调整之后系统稳定性有了明显提升。5. 常见问题与排查技巧实录5.1 插件加载失败排查插件加载失败是我在开发期遇到最多的问题90% 的原因集中在以下几类。第一类是 jar 包缺少 MANIFEST.MF 描述信息。PF4J 是通过插件描述符来识别插件的如果 jar 里没有 plugin-id、plugin-class 这些条目加载时直接被忽略。排查方法很简单用jar xf xxx.jar META-INF/MANIFEST.MF看内容就知道。解决方案是引入 pf4j-maven-plugin 来打包并生成描述文件。第二类是插件依赖的第三方库没有打包进来。插件中引用了 httpclient、fastjson 等库但宿主程序里没有而插件 jar 又没把依赖打进去运行时就会报 NoClassDefFoundError。解决方案是把插件需要的依赖用 maven-shade-plugin 打进插件 jar但要注意排除掉 PF4J 自身的类否则会出现类重复加载的问题。第三类是插件内的扩展点类没有Extension注解。PF4J 默认只扫描带Extension注解的类漏掉注解不会报错但扩展点就是注入不进来。这类问题通过开启 PF4J 的调试日志pf4j.debugtrue可以很快定位。5.2 Shiro 会话失效与登录跳转异常Shiro 集成 Redis 后最常见的两种异常我都碰到过。第一种是用户登录后不久就被踢下线。排查思路先看 Redis 里shiro:session:*的过期时间再看DefaultWebSessionManager的全局超时时间两者必须一致。还有一个坑是 Shiro 的 session 验证默认有sessionValidationInterval它决定多长时间检查一次过期 session如果这个值设置得过大会导致过期 session 不能及时清理Redis 里堆积很多垃圾 key。第二种是未登录接口返回 302 而不是 JSON 状态码。Shiro 默认未登录会跳转登录页但对前后端分离项目来说前端期望的是 401 状态码加 JSON 提示。解决办法是自定义UserFilter重写onAccessDenied方法输出 JSON 响应。这两个优化做完之后会话管理的体验才真正稳定下来前后端联调也顺畅了很多。5.3 插件里的 Bean 无法注入 Spring 容器插件是独立 classloader 加载的使用Service、Component这些注解的类Spring 容器扫描不到直接注入必然失败。我最初把整个插件的实现类都标成 Spring 的 Service启动后一大堆注入报错。正确做法是插件实现类不依赖 Spring 注解而是通过 PF4J 的扩展点机制暴露给宿主宿主拿到扩展点实例后统一注册到自己的 Map 或者 Spring 容器中。如果插件内部确实需要 Spring 的 Bean比如 MyBatis Mapper可以让宿主提供一个 BeanProvider插件从里面按类型取所需依赖。这样既保持了插件的类加载隔离又没有切断 Spring 的能力。5.4 问题排查速查表问题现象可能原因解决措施插件加载后扩展点为 null缺少 Extension 注解或插件描述文件检查 MANIFEST.MF 与注解调用插件方法报 NoClassDefFoundError插件依赖未打包进插件 jar使用 maven-shade-plugin 打包用户频繁被踢下线Session 超时与 Redis 过期时间不一致对齐两者时间前端收到 302 而不是 401Shiro 默认跳转登录页自定义 UserFilter 输出 JSON插件热卸载后内存占用上升连接池或文件句柄未释放在 stop() 中显式释放资源发票金额对不上浮点数运算导致精度丢失使用 Decimal 存储金额最后留一个实用提示如果把插件包和主程序纳入同一套 CI/CD 流程每次构建时都自动执行插件描述符校验和依赖冲突检查可以避免很多生产环境才暴露的插件加载问题。我做这个项目最大的体会是插件化不是一种炫技而是把业务的不确定性隔离在可控的边界内让核心系统保持稳定权限设计也不能只做表面拦截要深入数据层。这套基于 SpringBoot PF4J Shiro 的方案如果你手头也遇到一个业务变化快、权限要求高的管理系统建议认真考虑一下。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价