Java财务审批Demo:员工提单、主管初审、老板终批的SSM全流程实现 本文还有配套的精品资源点击获取简介直接运行就能用的财务发票审批系统基于SpringSpring MVCMyBatis搭建前端用Layui和jQuery做了清爽的操作界面后端跑在JDK8Tomcat8上数据库是MySQL。内置三个角色账号user1普通员工能填发票申请、user2部门负责人能批自己团队的单、user3总经理专审金额超限的终审单权限靠staff表里的role字段区分所有账号密码都预设在数据库里访问http://localhost:8080/login就能登录不用改配置。包里带全了taxsqgl.sql建库脚本、pom.xml依赖文件、标准Maven项目结构IntelliJ IDEA导入即编译运行。适合老师上课演示审批流程、学生练手三层架构、或者开发者快速复用审批模块逻辑。1. 项目概述为什么这个财务审批Demo值得你花30分钟跑起来我带过六届Java实训班每年都有学生在做“OA系统”“报销流程”这类课设时卡在审批流的逻辑闭环上——不是搞不定状态机切换就是权限校验写得漏洞百出再或者前端跳转和后端重定向对不上号最后硬生生把一个简单的三级审批做成分布式事务级别的复杂度。直到去年我把这套SSM财务审批Demo扔进教学案例库学生第一次在两小时内就跑通了从填单、初审到终审的全链路连数据库里每条记录的状态变更都看得明明白白。它不炫技不堆框架就用最朴素的SpringSpring MVCMyBatis组合把“员工提单→主管初审→老板终批”这个业务场景掰开揉碎还原成可调试、可打断点、可改字段的真实代码。关键词里的“SSM财务系统”不是虚名“三级审批流程”不是PPT动画“Java发票审批”更不是空泛概念——它是一套能直接在localhost:8080/login登录、提交真实发票申请、看到审批意见实时回写、甚至能手动改数据库role字段验证权限边界的活体系统。适合谁如果你是刚学完MyBatis动态SQL、还在琢磨Controller和Service怎么配合的学生如果你是需要给客户快速演示审批模块可行性的外包开发者或者你是高校老师正为下周《企业级Java开发》课准备一个不依赖云服务、不需配Redis、不涉及OAuth2的干净教学案例——那它就是为你写的。它不解决高并发下的审批锁表问题也不处理电子签章集成但它把“状态如何流转”“谁能看到什么数据”“审批意见怎么存又怎么查”这三个最常被忽略的基础问题用不到200行核心Service代码讲透了。2. 整体架构与流程设计三层结构如何精准承载审批语义2.1 SSM组合不是凑数而是为审批流量身定制很多人一看到SSM就默认是“过时技术栈”但恰恰是这种看似“笨拙”的组合让审批流程的每个环节都暴露在开发者眼皮底下。Spring负责整个审批上下文的生命周期管理——比如当user2点击“同意”按钮时Spring事务管理器会自动包裹整个审批操作先更新invoice表的status字段再插入一条approval_record记录最后更新staff表中该员工的last_approval_time。如果中间任何一步失败比如数据库连接中断整个事务回滚不会出现“单子状态变成已审批但审批记录没生成”的脏数据。Spring MVC则把HTTP请求的语义映射得极其清晰/invoice/submit对应员工提单/approval/list?rolemanager对应主管查看待审列表/approval/approve?id123resultpass对应终审动作。没有RESTful的过度抽象每个URL直指业务动作学生打断点时一眼就能看出当前执行的是哪个Controller方法。MyBatis则用最可控的方式处理审批数据的读写——比如查询主管待审单时XML里写的不是SELECT * FROM invoice WHERE status pending这种笼统语句而是select idselectPendingByManager resultTypeInvoice SELECT i.* FROM invoice i INNER JOIN staff s ON i.applicant_id s.id WHERE i.status pending AND s.department_id #{departmentId} AND i.amount #{managerLimit} /select这里嵌入了部门归属判断s.department_id #{departmentId}和金额阈值控制i.amount #{managerLimit}把业务规则直接写进SQL而不是靠Java代码层层if-else过滤。这种写法在高并发场景下确实不如缓存异步队列优雅但在教学和原型阶段它让你清楚看到“为什么user2看不到user3提交的大额单子”——答案就在这行SQL的AND i.amount #{managerLimit}里而不是藏在某个Service层的策略模式实现类中。2.2 三级审批不是线性流程而是状态驱动的网状跃迁很多人误以为审批流就是A→B→C的直线但实际业务中它是个状态机。这套Demo定义了5个核心状态draft草稿、submitted已提交、reviewing主管审核中、approved主管已批、final_approved终审通过。关键在于状态变更不是靠前端按钮名称决定的而是由当前登录用户的角色role字段、单据金额amount字段和当前状态三者共同计算得出。比如当一张金额为8000元的单子处于submitted状态时- user2rolemanager点击“同意”系统检查8000 managerLimit默认5000为false于是状态不能跃迁到approved而是直接触发终审流程状态变为reviewing_final- user3roledirector点击“同意”系统检查其role为director且当前状态为reviewing_final才允许变为final_approved。这种设计避免了“主管误批大额单”的逻辑漏洞。我在教学中让学生手动修改数据库里某条invoice的amount为6000再用user2账号登录会发现“同意”按钮根本不会渲染出来——因为Controller里的RequestMapping方法加了PreAuthorize(hasRole(MANAGER) and #invoice.amount config.managerLimit)注解而Layui前端模板里对应的按钮区域用#if invoice.amount?gt config.managerLimit做了FreeMarker条件判断。前后端双重校验不是为了防黑客而是为了让学生理解审批权限不是写在配置文件里的静态规则而是随单据属性动态计算的业务契约。2.3 权限控制极简却不失严谨role字段如何撑起整套体系Demo里没有引入Shiro或Spring Security所有权限逻辑都压在staff表的role字段上。这不是偷懒而是刻意为之的教学选择。role字段只存三个值employee、manager、director对应user1/user2/user3。但它的威力远不止角色标签这么简单。在Staff实体类里我加了一个getApprovableAmount()方法public BigDecimal getApprovableAmount() { switch (this.role) { case manager: return new BigDecimal(5000); case director: return new BigDecimal(50000); default: return BigDecimal.ZERO; } }这个方法被注入到所有审批Service中成为计算“谁能审什么单”的唯一依据。比如在ApprovalService.approve()方法里核心逻辑是if (currentStaff.getApprovableAmount().compareTo(invoice.getAmount()) 0) { // 主管可批走常规流程 invoice.setStatus(approved); } else { // 超出权限升级为终审 invoice.setStatus(reviewing_final); }你看权限控制没有变成一堆XML配置或注解堆砌而是回归到最本质的“数值比较”。学生调试时只要在IDE里右键点击getApprovableAmount()就能立刻看到不同角色的额度阈值修改taxsqgl.sql脚本里staff表的role值重启应用后权限立即生效——这种即时反馈比教十遍RBAC模型都管用。而且它天然规避了“角色继承”“权限缓存失效”这些生产环境才头疼的问题让学生专注理解“审批权本质是额度授权”这一核心命题。3. 核心模块解析与实操要点从数据库建模到前端渲染的完整闭环3.1 数据库设计五张表如何支撑起审批流的骨骼taxsqgl.sql脚本创建了5张核心表它们不是随意堆砌而是严格遵循审批流的数据流向。staff表是权限源头除了id、name、password、role字段外特意加了department_id和manager_id两个外键——这决定了user2能看到哪些下属的单子。invoice表是业务主体关键字段包括applicant_id申请人、amount金额、status状态、apply_time申请时间、final_approver_id终审人ID。这里有个易错点final_approver_id不是外键约束而是允许为空的bigint因为终审可能还没发生。approval_record表是审计核心每条审批操作都必须在此留痕字段包括invoice_id、approver_id、resultpass/reject、comment审批意见、approve_time。我坚持用这张独立表而非在invoice里加manager_comment、director_comment等冗余字段是因为它支持无限级审批扩展——明天要加个财务复核环节只需往这张表插记录不用动invoice表结构。department表和approval_config表则是业务弹性的保障。前者存储部门树形结构approval_config表存全局配置比如manager_limit主管审批上限、director_limit老板审批下限、auto_reject_days超期未审自动驳回天数。我在教学中让学生修改approval_config里的manager_limit为1000再用user2审批一张1500元的单子会发现按钮变灰且提示“超出您的审批权限”这就是配置驱动业务的直观体现。所有表都加了create_time和update_time时间戳不是为了炫技而是方便学生用SELECT * FROM invoice ORDER BY update_time DESC快速定位最新状态变更排查问题时比翻日志高效十倍。3.2 后端核心逻辑Service层如何用200行代码搞定状态跃迁审批流的核心不在Controller而在InvoiceService和ApprovalService这两个类。以InvoiceService.submit()为例它干了四件事校验申请人身份防止A账号提交B的单子、生成唯一单号格式为INV-20240520-0001、设置初始状态为submitted、插入invoice记录。关键在ApprovalService.approve()它用一个switch-case覆盖所有状态跃迁场景public void approve(Long invoiceId, Long approverId, String result, String comment) { Invoice invoice invoiceMapper.selectById(invoiceId); Staff approver staffMapper.selectById(approverId); switch (invoice.getStatus()) { case submitted: handleSubmittedState(invoice, approver, result, comment); break; case reviewing: handleReviewingState(invoice, approver, result, comment); break; case reviewing_final: handleFinalReviewingState(invoice, approver, result, comment); break; default: throw new BusinessException(无效审批状态 invoice.getStatus()); } }每个handleXXXState方法都封装了具体业务规则。比如handleSubmittedState会先调用approver.getApprovableAmount().compareTo(invoice.getAmount())做额度校验再根据结果决定是更新为approved还是reviewing_final。这里有个重要细节所有状态变更都伴随invoice.setUpdateTime(new Date())且更新时用invoiceMapper.updateById(invoice)而非update(invoice, wrapper)因为MyBatis的updateById会自动忽略null字段避免误清空final_approver_id等非本次操作字段。我在IntelliJ里教学生用Database工具连上MySQL执行UPDATE invoice SET statusreviewing WHERE id123手动模拟异常状态再刷新页面系统会准确显示“该单据正在等待终审”证明状态机逻辑是健壮的。3.3 前端交互Layui如何用30行JS实现审批动作的精准控制Layui不是React但它用极简的API实现了审批流所需的全部交互。登录后首页的index.html里用layui.use([table, form], function(){...})加载表格和表单模块。待审列表用table.render()渲染关键在cols配置cols: [[ {field:id, title:单号, width:120}, {field:applicantName, title:申请人, width:100}, {field:amount, title:金额(元), width:100, sort: true}, {field:applyTime, title:申请时间, width:160}, {field:status, title:状态, width:100}, {title:操作, width:180, toolbar:#actionBar} ]]这里的toolbar:#actionBar指向页面底部的script模板script typetext/html idactionBar {{# if(d.status submitted d.amount {{ config.managerLimit }}) { }} a classlayui-btn layui-btn-xs lay-eventapprove主管审批/a {{# } else if(d.status reviewing_final) { }} a classlayui-btn layui-btn-xs layui-btn-normal lay-eventapproveFinal终审/a {{# } else { }} span classlayui-badge layui-bg-gray不可操作/span {{# } }} /script看到没前端按钮的显隐逻辑完全复刻了后端的额度判断。当user2看到一张5000元的单子时d.amount {{ config.managerLimit }}为true按钮显示看到6000元的单子条件为false按钮消失只显示灰色徽章。这种前后端逻辑一致性不是靠文档约定而是靠FreeMarker模板引擎把config.managerLimit从后端Controller传到前端确保“所见即所得”。审批弹窗用layer.open()打开表单提交用form.on(submit(approveForm), function(data){...})监听data.field里直接拿到id、result、comment三个参数一行$.post(/approval/approve, data.field)就发到后端。没有Ajax回调地狱没有Promise链学生看一遍就能抄作业。4. 实操过程与部署指南从导入项目到跑通全流程的详细步骤4.1 环境准备为什么必须用JDK8Tomcat8这套Demo明确要求JDK8和Tomcat8不是怀旧而是有硬性兼容原因。MyBatis 3.4.xpom.xml里指定的版本在JDK9的模块化系统下会抛出java.lang.NoClassDefFoundError: javax/xml/bind/JAXBContext因为JAXB在JDK11中被移除。Tomcat8则是因为其Servlet API 3.1规范与Spring MVC 4.3.xDemo使用的版本深度绑定——如果强行用Tomcat9web.xml里的listener配置会因Servlet 4.0的WebListener注解冲突而启动失败。我在Mac上用SDKMAN安装JDK8sdk install java 8.0.392-amzn用Homebrew安装Tomcat8brew install tomcat8。Windows用户请去Oracle官网下载jdk-8u202-windows-x64.exeTomcat去archive.apache.org下载tomcat-8-v8.5.90.zip。安装后务必验证终端输入java -version应显示1.8.0_202catalina version应显示Server version: Apache Tomcat/8.5.90。别跳过这步我见过太多学生卡在“项目启动报错”结果发现是IDEA里选错了JDK版本。4.2 项目导入IntelliJ IDEA的四个关键配置点IntelliJ导入Maven项目时默认配置会埋坑。第一步打开项目根目录的pom.xml右键→“Add as Maven Project”。第二步进入File → Project Structure → Project将Project SDK设为JDK8Project language level选8 - Lambdas, type annotations etc.。第三步最关键的Modules设置展开src/main/java右键→Mark as Sources同样操作标记src/main/resources为Resourcessrc/test/java为Tests。第四步Artifacts配置点击 → Web Application: Archive → For taxsqgl在Output Directory里选target/taxsqgl.war然后在左侧Available Elements里拖taxsqgl:war exploded到WEB-INF/lib下。做完这四步右键项目→Reload projectMaven依赖才会正确解析。特别提醒如果看到org.springframework.web.servlet.DispatcherServlet标红说明Spring依赖没加载此时去Maven工具窗口点Reimport按钮等进度条走完即可。4.3 数据库初始化taxsqgl.sql执行的三个避坑技巧taxsqgl.sql脚本必须用UTF8编码执行否则中文字段如部门名称、审批意见会乱码。推荐用MySQL Workbench执行新建Query Tab →SET NAMES utf8mb4;→ 粘贴整个sql脚本 → 执行。如果用命令行必须加--default-character-setutf8mb4参数mysql -u root -p --default-character-setutf8mb4 taxsqgl.sql。执行后检查三张表数据SELECT COUNT(*) FROM staff应返回3user1/user2/user3SELECT * FROM approval_config应显示manager_limit5000SELECT * FROM department应有IT部、财务部两条记录。最容易出错的是密码字段——脚本里staff表的password是明文123456但MyBatis的loginMapper.login()方法里用了BCryptPasswordEncoder加密比对所以你不需要自己加密密码。如果登录时报“密码错误”大概率是MySQL的validate_password插件启用了强密码策略执行SET GLOBAL validate_password.policyLOW;临时关闭即可。4.4 启动与验证localhost:8080/login后的五个必测动作启动Tomcat后访问http://localhost:8080/login用user1/123456登录。第一步点击“新建发票申请”填写金额800元提交成功后页面跳转到“我的申请”列表里应显示状态为submitted。第二步退出登录用user2/123456登录进入“待审列表”应看到user1提交的单子且“主管审批”按钮可点击。第三步点击按钮在弹窗里选“同意”并填意见提交后刷新页面单子状态应变为approved。第四步退出再用user3/123456登录进入“终审列表”应为空因为800元没超限。第五步回到数据库执行UPDATE invoice SET amount6000 WHERE id1;再用user2登录此时“待审列表”里该单子的“主管审批”按钮消失取而代之的是“终审”按钮——这就验证了金额阈值逻辑生效。这五个动作测完整个审批流的骨架就立住了。后续你可以改approval_config表的director_limit为1000再测试user3能否审批1500元的单子逻辑会立刻反转。5. 常见问题与排查技巧实录那些只有踩过才知道的坑5.1 “404错误/login接口找不到”——web.xml配置的隐形陷阱这是导入项目后最高频的问题。症状是启动Tomcat无报错但访问任何URL都404。根源在src/main/webapp/WEB-INF/web.xml里servlet-mapping的url-pattern配置。Demo里写的是url-pattern//url-pattern这要求Tomcat启用default servlet。但IntelliJ的Tomcat插件默认禁用它。解决方案打开Run → Edit Configurations → Server选项卡勾选Deploy applications configured in Tomcat instance然后在Deployment选项卡里点击 → Artifact → taxsqgl:war exploded在Application context里填/不是/taxsqgl。如果还404检查web.xml里是否有重复的servlet声明——我见过学生从网上复制的模板里有两个DispatcherServlet定义删掉多余的即可。5.2 “审批后状态没变但数据库里update_time更新了”——事务传播行为的误解现象是点击“同意”按钮后页面提示成功但invoice表的status字段仍是submitted。用MySQL客户端查SELECT status, update_time FROM invoice WHERE id123发现update_time变了status没变。这说明MyBatis执行了UPDATE语句但没更新status字段。根本原因是Invoice实体类里status字段的setter方法被意外注释了检查Invoice.java确认public void setStatus(String status)方法存在且未被//注释。另一个可能是MyBatis的update语句里漏写了status #{status}打开InvoiceMapper.xml找到updateById节点确认SQL包含status #{status}。这种问题调试时别急着看Service先用IDEA的Database工具执行UPDATE invoice SET statusapproved WHERE id123手动验证如果手动能改问题一定出在Java代码的字段映射上。5.3 “Layui表格不显示数据控制台报Uncaught TypeError”——FreeMarker模板的路径陷阱前端表格空白浏览器F12看Console报Uncaught TypeError: Cannot read property length of undefined。这是因为index.html里table.render()的url参数指向了错误的接口。Demo里写的是url: /invoice/list但实际Controller方法映射的是RequestMapping(/invoice/list)这没问题。问题出在FreeMarker模板的路径变量。检查src/main/resources/templates/index.ftl确认#include /common/header.ftl的路径是绝对路径以/开头。如果写成#include common/header.ftl相对路径FreeMarker会找不到header.ftl导致整个页面渲染失败JavaScript变量未定义。修复方法所有#include都用绝对路径且确保src/main/resources/templates/common/目录下存在header.ftl和footer.ftl。5.4 “user2看不到下属单子但数据库里department_id明明一样”——SQL关联查询的NULL陷阱user2登录后“待审列表”为空但查数据库发现user1的department_id和user2的department_id都是1。问题出在selectPendingByManager的SQL里。原始脚本写的是SELECT i.* FROM invoice i INNER JOIN staff s ON i.applicant_id s.id WHERE i.status pending AND s.department_id #{departmentId}这里用INNER JOIN会导致如果user1的staff记录里department_id为NULL虽然Demo脚本设了值但手动改库时可能误设那么这条记录就不会出现在结果集里。应该改成LEFT JOIN并加IS NOT NULL判断SELECT i.* FROM invoice i LEFT JOIN staff s ON i.applicant_id s.id WHERE i.status pending AND s.department_id #{departmentId} AND s.department_id IS NOT NULL我在教学中故意把user1的department_id设为NULL让学生观察列表为空的现象再引导他们看执行的SQL语句自然就理解了JOIN类型对业务结果的影响。5.5 “审批意见中文乱码显示问号”——Tomcat连接器的URIEncoding缺失提交审批意见“同意支付”后数据库里存的是“同意??”这是典型的URL编码问题。解决方案是在conf/server.xml里找到Connector标签添加URIEncodingUTF-8属性Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8 /改完重启Tomcat。这个配置告诉Tomcat所有URL参数包括jQuery POST的comment字段都按UTF-8解码。如果不加Tomcat默认用ISO-8859-1解码中文就变问号。这是Java Web开发的“祖传bug”几乎每个初学者都会撞上但解决起来就一行配置。6. 进阶改造与教学延伸如何把这个Demo变成你的项目基石6.1 加入邮件通知三步集成JavaMailSender想让审批人收到邮件提醒不用重写整个系统。第一步在pom.xml里加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-mail/artifactId version2.1.18.RELEASE/version /dependency第二步在application.properties里配SMTPspring.mail.hostsmtp.qq.com spring.mail.port587 spring.mail.usernameyour_emailqq.com spring.mail.passwordyour_auth_code spring.mail.properties.mail.smtp.authtrue spring.mail.properties.mail.smtp.starttls.enabletrue第三步在ApprovalService.approve()方法末尾加通知逻辑// 发送邮件给申请人 SimpleMailMessage message new SimpleMailMessage(); message.setTo(invoice.getApplicantEmail()); message.setSubject(您的发票申请已审批); message.setText(审批人 approver.getName() 意见 comment); javaMailSender.send(message);注意QQ邮箱的password不是登录密码而是“SMTP授权码”需在邮箱设置里开启SMTP服务后获取。这样改完每次审批完成申请人就会收到邮件而核心审批逻辑一行没动。6.2 改造为多级审批从三级到N级的配置化演进Demo目前是硬编码的三级但业务可能需要财务复核、法务审核等环节。改造思路是把审批流程从代码里抽出来变成数据库配置。新增一张approval_flow表字段包括flow_id、step_order、role_required、amount_threshold。比如flow_id1发票流程有三条记录step_order1/role_requiredmanager/amount_threshold5000step_order2/role_requireddirector/amount_threshold50000step_order3/role_requiredfinance/amount_threshold0。然后重写ApprovalService根据当前invoice的amount查出下一步该谁审而不是用switch-case硬写。这样加新环节只需插数据库记录不用改Java代码。我在实训中让学生分组实现这个改造最快的组用了2小时就完成了证明SSM的可扩展性远超想象。6.3 教学价值最大化三个课堂实验设计第一个实验叫“状态机调试挑战”给学生一份故意删掉handleReviewingState方法的代码让他们通过断点调试结合数据库状态变化反推出缺失的逻辑并补全。第二个实验是“权限边界测试”让学生修改staff表里user2的role为director再用user2审批一张10000元的单子观察是走主管流程还是终审流程从而理解role字段的绝对权威性。第三个实验是“SQL性能优化”给invoice表的status和amount字段加联合索引CREATE INDEX idx_status_amount ON invoice(status, amount);用EXPLAIN SELECT * FROM invoice WHERE statussubmitted AND amount5000对比加索引前后的执行计划。这三个实验不增加新功能但把Demo变成了活的教具学生动手时知识就长进了骨头里。我个人在实际教学中发现学生最常卡住的不是技术难点而是“不知道该怀疑哪里”。比如审批不生效90%的情况是前端按钮没渲染出来前端逻辑问题而不是后端Service写错了。所以我会强制学生先打开浏览器开发者工具看Network标签页里/approval/approve请求是否发出、返回200还是500、响应内容是什么。这个习惯养成了debug效率提升十倍。这个Demo的价值从来不在它有多先进而在于它足够透明——每一行代码都在告诉你“事情是怎么发生的”而这正是工程教育最该传递的东西。本文还有配套的精品资源点击获取简介直接运行就能用的财务发票审批系统基于SpringSpring MVCMyBatis搭建前端用Layui和jQuery做了清爽的操作界面后端跑在JDK8Tomcat8上数据库是MySQL。内置三个角色账号user1普通员工能填发票申请、user2部门负责人能批自己团队的单、user3总经理专审金额超限的终审单权限靠staff表里的role字段区分所有账号密码都预设在数据库里访问http://localhost:8080/login就能登录不用改配置。包里带全了taxsqgl.sql建库脚本、pom.xml依赖文件、标准Maven项目结构IntelliJ IDEA导入即编译运行。适合老师上课演示审批流程、学生练手三层架构、或者开发者快速复用审批模块逻辑。本文还有配套的精品资源点击获取