SSM财务报销系统实战:三层架构+事务+状态机深度解析
简介这是一套面向计算机专业本科生及Java初学者的ERP财务报销系统实战项目专为毕业设计、课程设计与Web开发进阶练习打造。资源基于SSMSpringSpring MVCMyBatis框架构建聚焦企业级财务报销业务流程涵盖用户权限管理、报销申请审批、数据统计报表等核心模块帮助学习者贯通Java Web开发全链路——从MVC分层设计、MySQL数据库建模与SQL脚本部署到IDEA环境配置、JSP页面渲染及前后端交互实现。压缩包共433个文件含33个Java业务逻辑类、18个JSP视图页、13个XML配置文件、118个JS交互脚本及79个GIF/28个PNG等静态资源辅以SQL建库脚本、项目说明文档与必要工具依赖整体5.44MB开箱即用。已有48人下载学习结构规范、注释完整是理解ERP子系统落地逻辑与SSM工程化实践的优质入门范例。1. 这不是又一个“Hello World”DemoSSM财务报销系统源码真能跑通、真有业务闭环、真适合Java新手啃透三层架构你手头那份标着“SSM ERP财务报销系统”的压缩包大概率不是网上泛滥的空壳模板——它带完整数据库脚本、含真实报销流程员工提交→部门审批→财务复核→出纳打款、权限粒度细到“普通员工只能看自己单据财务主管可导出全量月报”连LayUI前端都做了表单校验和分页联动。这不是教你怎么写Controller注解而是让你亲手把Spring IoC容器怎么加载Service Bean、MyBatis的foreach怎么拼接批量插入SQL、Spring MVC拦截器如何拦截未登录请求这些黑匣子一节一节拆开看。特别适合正在准备Java校招面试、但简历上只有“用SpringBoot写了博客系统”的同学这里没有自动配置魔法所有XML配置、Mapper接口与XML映射、事务传播行为都明明白白摊在你面前。我当年带实习生时就让他们先在这个项目里把报销单状态机草稿→待审→已驳回→已通过→已支付的Service层事务边界画出来再对照日志看Transactional(propagation Propagation.REQUIRED)到底在哪一层生效——比背十遍面试题管用。2. 从零部署JDKIDEAMySQL环境搭建与项目导入实操2.1 JDK与IDEA版本选择为什么必须用JDK8而非JDK17这个项目基于SSM老版本构建Spring 4.3.x / MyBatis 3.4.x其pom.xml中明确依赖spring-webmvc-4.3.29.RELEASE.jar该版本编译字节码目标为1.8。若强行用JDK17导入IDEA会报错Unsupported class file major version 61JDK17对应major version 61而Spring 4.3只支持到52。血泪经验曾有学生用IDEA 2023.3 JDK17导入后连web.xml都标红折腾三天才发现是JDK版本越界。正确做法下载Oracle JDK 8u202或OpenJDK 8u292解压后配置JAVA_HOME指向该路径IDEA中进入File → Project Structure → Project → Project SDK点击New... → JDK选择JDK8根目录验证在终端执行java -version输出应为java version 1.8.0_202提示不要用JDK8最新更新版如8u391部分高版本对javax.servlet包签名验证更严可能触发ClassFormatError。2.2 MySQL数据库初始化建库、建表、插初始数据三步到位项目提供的sql/erp_finance.sql脚本包含三部分建库语句、12张表结构user,reimbursement,approval_flow,finance_record等、以及管理员账号admin/admin123和测试员工数据。执行前需确认MySQL服务已启动且root密码已知# 1. 登录MySQL假设root密码为123456 mysql -u root -p123456 # 2. 执行建库建表脚本注意脚本内已含CREATE DATABASE语句无需手动CREATE source /path/to/your/download/erp_finance.sql # 3. 验证关键表是否创建成功 USE erp_finance; SHOW TABLES; SELECT COUNT(*) FROM user; -- 应返回至少3条admin2个测试员工参数说明source命令会逐行执行SQL文件遇到CREATE DATABASE IF NOT EXISTS erp_finance DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;时自动创建库脚本末尾的INSERT INTO user (...) VALUES (...);插入了预设账号避免首次登录404表字符集强制设为utf8mb4防止报销事由中出现emoji或生僻字乱码。2.3 IDEA导入Maven项目解决常见依赖红标与Web Facet缺失项目根目录含pom.xml但直接File → Open会导致Maven依赖不下载、webapp目录不识别为Web资源根。必须按以下顺序操作关闭自动导入File → Settings → Build → Build Tools → Maven → Importing取消勾选Import Maven projects automatically避免IDEA在依赖未全下载时错误标记强制重新导入右键项目根目录 →Maven → Reload project配置Web FacetFile → Project Structure → Modules → 选中模块 → Web点击添加WebWeb resource directory指向src/main/webappWeb.xml路径填src/main/webapp/WEB-INF/web.xml设置ArtifactsProject Structure → Artifacts → → Web Application: Archive → For xxx:war exploded确保Output Layout中WEB-INF/lib包含所有jar包关键验证点target/xxx.war能否正常生成右键项目 →Build → Build Artifactssrc/main/java/com/erp/controller/ReimbursementController.java中RequestMapping注解不报红src/main/resources/jdbc.properties里的jdbc.urljdbc:mysql://localhost:3306/erp_finance?useSSLfalseserverTimezoneAsia/Shanghai已按你本地MySQL端口修改3. 核心业务链路跑通从登录到报销单生成的全流程调试3.1 登录认证流程Spring Security还是自研拦截器这里用的是后者项目未集成Spring Security而是通过LoginInterceptor实现简易权限控制。其核心逻辑在com.erp.interceptor.LoginInterceptor.javapublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User user (User) session.getAttribute(user); // 从session取用户对象 if (user null) { // 未登录 response.sendRedirect(request.getContextPath() /login.jsp); return false; // 拦截请求 } return true; // 放行 }调试技巧在LoginController.login()方法首行加断点输入admin/admin123观察User user userService.login(username, password)是否返回非null登录成功后检查session.getAttribute(user)是否存入了User对象含id1, usernameadmin, roleADMIN访问/reimbursement/add.jsp时断点停在preHandle()确认user不为null才放行。3.2 报销单提交MyBatis动态SQL与事务边界的实战场报销单提交涉及三张表联动reimbursement主单、reimbursement_item明细、approval_flow审批流。关键代码在ReimbursementService.submitReimbursement()Transactional // 注意此注解在Service层非Controller public void submitReimbursement(Reimbursement reimbursement) { // 1. 插入主单 reimbursementMapper.insert(reimbursement); // 2. 批量插入明细使用MyBatis foreach ListReimbursementItem items reimbursement.getItems(); if (!items.isEmpty()) { reimbursementItemMapper.batchInsert(items); // 对应XML中的insert with foreach } // 3. 初始化审批流状态待审 ApprovalFlow flow new ApprovalFlow(); flow.setReimbursementId(reimbursement.getId()); flow.setStatus(PENDING); approvalFlowMapper.insert(flow); }参数与边界说明Transactional默认Propagation.REQUIRED保证三步操作要么全成功要么全回滚batchInsert对应的XML片段含foreach collectionlist itemitem separator,生成INSERT INTO ... VALUES (...),(...),(...)若明细列表为空items.isEmpty()跳过第二步避免空集合导致SQL语法错误。3.3 审批状态流转基于数据库字段的状态机驱动系统用reimbursement.status字段VARCHAR存储状态DRAFT/PENDING/APPROVED/REJECTED/PAID。审批操作在ApprovalController.approve()中RequestMapping(/approve) public String approve(RequestParam Long id, RequestParam String status) { // 1. 查询当前单据 Reimbursement reimbursement reimbursementMapper.selectById(id); // 2. 状态校验业务规则只有PENDING才能审批 if (!PENDING.equals(reimbursement.getStatus())) { request.setAttribute(msg, 单据状态异常无法审批); return error; } // 3. 更新状态并记录审批人 reimbursement.setStatus(status); // status可能是APPROVED或REJECTED reimbursement.setApproverId(getCurrentUserId()); // 从session取当前登录人ID reimbursementMapper.updateByPrimaryKey(reimbursement); return redirect:/reimbursement/list.jsp; }验证方法提交一张报销单后查reimbursement表确认statusPENDING用财务账号登录访问/approve?id1statusAPPROVED再查表确认status变为APPROVED尝试用同一URL重复提交观察是否因状态校验失败而跳转到error页面。4. 避坑指南SSM财务系统部署与调试的五个真实翻车现场4.1 现象启动Tomcat时报错java.lang.ClassNotFoundException: org.springframework.web.context.ContextLoaderListener原因Maven依赖未正确打包到WEB-INF/lib或IDEA的Artifacts配置中遗漏spring-web-4.3.29.RELEASE.jar解决检查pom.xml是否含dependencygroupIdorg.springframework/groupIdartifactIdspring-web/artifactIdversion4.3.29.RELEASE/version/dependencyProject Structure → Artifacts → Output Layout中展开WEB-INF/lib确认该jar存在若不存在右键Dependencies→Maven → Download sources and documentation再Reload project。4.2 现象登录后跳转到/index.jsp但页面空白浏览器控制台报Uncaught ReferenceError: $ is not defined原因前端JS依赖jQuery未加载webapp/js/jquery.min.js路径在login.jsp中写为script srcjs/jquery.min.js但实际文件在webapp/static/js/下解决统一静态资源路径将jquery.min.js、layui.js等移至webapp/static/js/修改所有JSP中的引用为script src${pageContext.request.contextPath}/static/js/jquery.min.js${pageContext.request.contextPath}确保路径不随部署上下文变化。4.3 现象报销单提交后明细数据未入库但主单和审批流正常插入原因reimbursement_item表的reimbursement_id字段为BIGINT而ReimbursementItem实体类中对应属性类型为Integer导致MyBatis TypeHandler转换失败解决检查ReimbursementItem.java中private Integer reimbursementId;→ 改为private Long reimbursementId;同步修改reimbursement_itemMapper.xml中result columnreimbursement_id propertyreimbursementId/的property值重启应用重新提交测试。4.4 现象MySQL插入中文报销事由时显示??数据库表字符集为utf8原因MySQL 5.7默认utf8仅支持3字节UTF-8不支持emoji而项目脚本虽设utf8mb4但MySQL服务端未启用解决修改MySQL配置文件my.cnfLinux或my.iniWindows[client] default-character-set utf8mb4 [mysqld] character-set-server utf8mb4 collation-server utf8mb4_unicode_ci init_connectSET NAMES utf8mb4 skip-character-set-client-handshake TRUE重启MySQL服务执行SHOW VARIABLES LIKE character_set%;确认全部为utf8mb4。4.5 现象IDEA调试时断点无效代码跳过不执行原因Maven编译输出路径与IDEA配置不一致或target/classes中class文件未更新解决File → Project Structure → Modules → Sources确认src/main/java标记为Sourcessrc/main/resources标记为Resources执行mvn clean compile清空target再Build → Build Project检查target/classes/com/erp/controller/LoginController.class时间戳是否最新。5. 深度验证用JUnit4跑通三个核心Service单元测试5.1 为什么必须写单元测试——脱离Web容器验证业务逻辑纯度SSM项目常被诟病“不写测试”但本项目src/test/java下已预留UserServiceTest.java等骨架。我们以ReimbursementServiceTest为例验证报销单金额计算逻辑含税金、合计是否独立于HTTP请求RunWith(SpringJUnit4ClassRunner.class) ContextConfiguration(locations {classpath:spring/applicationContext.xml}) public class ReimbursementServiceTest { Autowired private ReimbursementService reimbursementService; Test public void testCalculateTotalAmount() { // 构造测试数据2项明细金额分别为100.00和200.00税率6% Reimbursement reimbursement new Reimbursement(); ListReimbursementItem items Arrays.asList( new ReimbursementItem().setAmount(new BigDecimal(100.00)).setTaxRate(new BigDecimal(0.06)), new ReimbursementItem().setAmount(new BigDecimal(200.00)).setTaxRate(new BigDecimal(0.06)) ); reimbursement.setItems(items); // 调用待测方法 BigDecimal total reimbursementService.calculateTotalAmount(reimbursement); // 断言100*1.06 200*1.06 318.00 assertEquals(new BigDecimal(318.00), total); } }执行要点ContextConfiguration指向applicationContext.xml加载Spring容器不含Web层calculateTotalAmount()是纯Java方法无HttpServletRequest依赖可脱离Tomcat运行断言用BigDecimal避免浮点数精度误差100.00 * 1.06结果为106.00非106.00000000000001。5.2 数据库测试隔离H2内存数据库替代MySQL为避免测试污染生产库用H2替代MySQL!-- pom.xml中添加H2依赖 -- dependency groupIdcom.h2database/groupId artifactIdh2/artifactId version1.4.200/version scopetest/scope /dependency// 在test/resources/jdbc-test.properties中 jdbc.driverorg.h2.Driver jdbc.urljdbc:h2:mem:testdb;DB_CLOSE_DELAY-1;DB_CLOSE_ON_EXITFALSE jdbc.usernamesa jdbc.password优势每次测试启动新内存库CREATE TABLE和INSERT不影响本地MySQLDB_CLOSE_DELAY-1确保测试结束前库不销毁方便调试时用http://localhost:8082访问H2 Console查看数据。5.3 权限校验逻辑验证模拟不同角色调用Service报销单删除需校验仅本人或财务主管可删。测试代码模拟角色上下文Test public void testDeleteByRole() { // 场景1员工尝试删他人单据 → 应抛异常 SecurityContextHolder.getContext() .setAuthentication(new UsernamePasswordAuthenticationToken(zhangsan, , Collections.singletonList(new SimpleGrantedAuthority(ROLE_USER)))); assertThrows(PermissionDeniedException.class, () - { reimbursementService.deleteReimbursement(2L); // 单据2属于lisi }); // 场景2财务主管删任意单据 → 应成功 SecurityContextHolder.getContext() .setAuthentication(new UsernamePasswordAuthenticationToken(caiwu, , Collections.singletonList(new SimpleGrantedAuthority(ROLE_ADMIN)))); reimbursementService.deleteReimbursement(2L); // 不抛异常即成功 }关键点SecurityContextHolder模拟Spring Security上下文即使项目未集成Security此测试仍有效PermissionDeniedException需在Service中主动抛出而非靠拦截器拦截——这才是业务层真正的权限守门员。6. 进阶技巧把SSM报销系统改造成Spring Boot微服务的三步迁移法6.1 第一步剥离XML配置用Java Config重写Spring容器原applicationContext.xml中Bean定义如DataSource,SqlSessionFactoryBean可转为Configuration类Configuration public class DatabaseConfig { Value(${jdbc.url}) private String url; Value(${jdbc.username}) private String username; Value(${jdbc.password}) private String password; Bean public DataSource dataSource() { DruidDataSource dataSource new DruidDataSource(); dataSource.setUrl(url); dataSource.setUsername(username); dataSource.setPassword(password); return dataSource; } Bean public SqlSessionFactory sqlSessionFactory() throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource()); factoryBean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/*.xml)); return factoryBean.getObject(); } }迁移价值消除XML硬编码配置项统一由application.yml管理Value注入支持Profile切换dev/prod不同数据库URLSqlSessionFactoryBean自动注册无需bean标签。6.2 第二步用MyBatis-Plus替代原生MyBatis省掉90%的XML原ReimbursementMapper.xml中select idselectByIdSELECT * FROM reimbursement WHERE id #{id}/select可被MyBatis-Plus的LambdaQueryWrapper替代Service public class ReimbursementServiceImpl extends ServiceImplReimbursementMapper, Reimbursement implements ReimbursementService { Override public Reimbursement getReimbursementWithItems(Long id) { // 一行代码查主单关联明细需配置ResultMap或TableName注解 return this.getById(id); } Override public void updateStatus(Long id, String status) { // 无需写UPDATE SQL条件构造器自动生成 LambdaUpdateWrapperReimbursement wrapper new LambdaUpdateWrapper(); wrapper.eq(Reimbursement::getId, id).set(Reimbursement::getStatus, status); this.update(wrapper); } }参数说明ServiceImpl已封装CRUDthis.getById(id)自动执行SELECT * FROM reimbursement WHERE id ?LambdaUpdateWrapper避免字符串拼接字段名编译期检查字段是否存在若需关联查询用TableName(autoResultMap true)TableField(select false)控制字段映射。6.3 第三步拆分模块用Feign Client解耦审批服务原系统审批逻辑在ReimbursementService中硬编码调用approvalFlowMapper。改造为微服务后原结构微服务改造ReimbursementService.submit()内直接调用approvalFlowMapper.insert()ReimbursementService.submit()调用ApprovalClient.createFlow()所有逻辑在一个JVM进程reimbursement-service与approval-service独立部署// approval-service提供REST API RestController public class ApprovalController { PostMapping(/flows) public Result createFlow(RequestBody ApprovalFlow flow) { approvalFlowMapper.insert(flow); return Result.success(); } } // reimbursement-service中Feign Client FeignClient(name approval-service, url http://localhost:8081) public interface ApprovalClient { PostMapping(/flows) Result createFlow(RequestBody ApprovalFlow flow); } // ReimbursementService中调用 Override public void submitReimbursement(Reimbursement reimbursement) { reimbursementMapper.insert(reimbursement); // 解耦不再依赖approvalFlowMapper而是远程调用 approvalClient.createFlow(buildApprovalFlow(reimbursement)); }落地提醒Feign需引入spring-cloud-starter-openfeign依赖url http://localhost:8081是开发阶段直连生产环境应注册到Nacos/EurekabuildApprovalFlow()需确保reimbursement.getId()已生成MyBatisuseGeneratedKeystrue。从那以后我每次接手老SSM项目都先做三件事1用JUnit给核心Service写覆盖率≥70%的测试2把web.xml和spring-mvc.xml全干掉换成SpringBootApplication3把property namedriverClassName这种字符串配置全替换成ConfigurationProperties绑定对象。不是为了炫技而是让下次改需求时能确定自己改的那段代码真的只影响报销单金额计算而不是悄悄把审批流也搞崩了。希望帮到你。本文还有配套的精品资源点击获取