SSM框架实战:网上报销系统源码深度解析 简介基于Java SSMSpring、SpringMVC、MyBatis与MySQL实现的网上报销系统面向毕业设计、课程设计及Java Web初学者可用于学习SSM整合、审批流程和数据库设计。资源共303个文件约12.17MB包含43个Java源码、43个JAR依赖、41个Vue前端页面、30个JSON配置、11个XML映射及1个SQL脚本等覆盖控制层、服务层、持久层、前端界面和部署脚本便于快速搭建并理解分层架构。已有121人学习浏览。通过该资源可掌握员工登录注册、报销单创建、审批流转、财务统计等完整功能实现同时了解Maven环境配置、Tomcat部署、单元测试与项目打包流程。代码注释清晰、目录规范适合作为参照模板进行二次开发也能帮助初学者理清SSM框架在真实业务中的协作方式提升综合实践能力。1. 为什么“网上报销系统”成了SSM入门的最佳练手项目收到这份基于Java SSM MySQL的网上报销系统源码时别急着双击preview.bat看效果。我见过不少人第一眼看到Spring SpringMVC MyBatis MySQL的组合就发怵觉得自己连mvn命令都没敲顺这个项目肯定啃不动。实际上报销系统的业务复杂度卡在一个很舒服的位置既有员工登录、角色权限、报销单状态流转这些Web开发绕不开的核心逻辑又不会像商城系统那样堆一大堆优惠券、库存、秒杀的边角功能。Java基础入门的人能靠它把SSM框架的请求流转、面向接口编程、数据库事务一次串起来工作三五年的人也能从它的源码里反推出很多底层机制。这个项目真正值钱的部分不在页面而在三处applicationContext.xml里的Bean装配方案、t_reimburse表中状态字段的流转设计、以及MyBatis XML里那些动态SQL的边界条件处理。读完这篇你可以自己动手把登录拦截器换成Shiro把静态数据源换成Druid连接池哪怕答辩时被问到“如果报销单特别多怎么优化”也能回答到点子上。2. SSM三框架如何协同从pom依赖到请求流转先看懂再改代码2.1 Spring容器管理了什么核心配置与注解扫描SSM项目里Spring是地基。打开pom.xml会看到spring-context、spring-jdbc、spring-webmvc、mybatis-spring这些核心坐标。版本搭配方面Spring 5.1.x对应Java 8Spring 5.3.x也能跑在Java 8上但换成Spring 6就必须Java 17以上。如果你用的是自带JDK 1.8的课程设计环境我一般会把Spring锁在5.1.8.RELEASE或5.3.20避免编译时出现UnsupportedClassVersionError。Spring的职责集中在applicationContext.xml里主要做三件事配置数据源、开启注解扫描、把SqlSessionFactory交给Spring托管。看一段典型的配置context:component-scan base-packagecom.reimburse.service / bean iddataSource classorg.springframework.jdbc.datasource.DriverManagerDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver / property nameurl valuejdbc:mysql://localhost:3306/reimburse?useUnicodetrueamp;characterEncodingutf8 / property nameusername valueroot / property namepassword valueroot / /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource / property nameconfigLocation valueclasspath:mybatis-config.xml / /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.reimburse.mapper / /bean这里最容易被忽略的是MapperScannerConfigurer它会把com.reimburse.mapper包下所有接口直接代理成MyBatis的Mapper省去每个Mapper都要写一次getMapper()调用的模板代码。注意url里必须写成amp;不写的话Maven打包时会报XML解析错误。另外serverTimezone没配的话高版本MySQL驱动连库时直接报时区异常我习惯在url后面追加serverTimezoneAsia/Shanghai。2.2 SpringMVC的请求路径DispatcherServlet与Controller映射SpringMVC是Web层的门面。web.xml里配置了DispatcherServlet它拦截/路径下所有请求。看一个典型配置servlet servlet-namespringmvc/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namespringmvc/servlet-name url-pattern//url-pattern /servlet-mappingload-on-startup设为1表示Tomcat启动时就初始化SpringMVC容器否则要等第一个请求进来才初始化用户会明显感觉首页加载慢。spring-mvc.xml里开启mvc:annotation-driven/再用context:component-scan base-packagecom.reimburse.controller/扫描Controller配合内部资源视图解析器指向/WEB-INF/views/目录下的JSP。Controller层的映射规则决定了URL结构和前端调用方式。比如报销单列表页常见的写法是Controller RequestMapping(/reimburse) public class ReimburseController { Resource private ReimburseService reimburseService; RequestMapping(/list) public String list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, Model model) { ListReimburseVO list reimburseService.queryApplyRecord(pageNum, pageSize); model.addAttribute(list, list); return reimburse/list; } }RequestParam(defaultValue 1)是必须的它让前端不带pageNum参数时也能正常访问否则直接抛MissingServletRequestParameterException。Model是SpringMVC向视图传数据的标准入口对应JSP里用${list}取值。整个流程就是浏览器请求/reimburse/listDispatcherServlet根据RequestMapping找到ReimburseController方法执行结束后返回逻辑视图名视图解析器拼出实际JSP路径渲染HTML回给浏览器。如果你把url-pattern改成*.do所有访问路径后面都要带上.do后缀这是很多老项目的历史包袱。2.3 MyBatis如何嵌入SpringSqlSessionFactory与Mapper代理MyBatis以Mapper接口和XML文件为操作数据库的载体。mybatis-config.xml里通常会配置驼峰映射和默认别名configuration settings setting namemapUnderscoreToCamelCase valuetrue / /settings typeAliases package namecom.reimburse.entity / /typeAliases /configuration开了mapUnderscoreToCamelCase后数据库字段reimburse_no才能自动映射到Java属性reimburseNo不用每个字段都手写resultMap。这个开关对连接查询里带别名的字段也生效但前提是别名必须保留下划线风格比如SELECT r.reimburse_no AS reimburse_no FROM t_reimburse r。一个Mapper接口对应一个XML文件例如ReimburseMapper.xml里的namespace必须写接口的全限定名id必须与接口方法名一致。这是MyBatis动态代理的约定写错了会在调用时抛Invalid bound statement (not found)这是SSM项目里最容易排查半天的错误。检查顺序是namespace、id、接口方法签名、parameterType和resultType是否齐全。层次框架核心配置典型对象表现层SpringMVCspring-mvc.xmlController、HandlerInterceptor业务层SpringapplicationContext.xmlService、ServiceImpl持久层MyBatismybatis-config.xml、Mapper.xmlMapper接口、SqlSessionTemplate三个配置文件的扫描范围绝对不能重叠Spring只扫ServiceSpringMVC只扫Controller。如果一个类同时被两个容器扫描事务注解和AOP切面可能出现重复代理启动时有概率报NoUniqueBeanDefinitionException。注意同时改动pom.xml里的Spring或MyBatis版本时务必先执行mvn clean旧class文件残留是很多诡异报错的来源。3. 报销系统数据库设计从员工表到审批记录的范式权衡3.1 核心表结构与字段类型网上报销系统的核心表主要有员工表、报销单表、审批记录表、部门表。设计时别从完美范式出发先画出业务流程员工提交报销单上级审批财务打款每一步留下记录。表结构可以收敛成下面这种CREATE TABLE t_employee ( emp_id INT PRIMARY KEY AUTO_INCREMENT, emp_no VARCHAR(20) UNIQUE NOT NULL COMMENT 员工编号, emp_name VARCHAR(50) NOT NULL, dept_id INT, password VARCHAR(64) DEFAULT 123456, role TINYINT DEFAULT 0 COMMENT 0员工1审批人2财务, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_reimburse ( reimburse_id INT PRIMARY KEY AUTO_INCREMENT, reimburse_no VARCHAR(32) UNIQUE NOT NULL COMMENT 单据号, emp_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL, reason VARCHAR(500), status TINYINT DEFAULT 0 COMMENT 0草稿1已提交2审批中3已通过4已驳回, apply_time DATETIME, update_time DATETIME ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE t_approval_record ( record_id INT PRIMARY KEY AUTO_INCREMENT, reimburse_id INT NOT NULL, approver_id INT NOT NULL, action TINYINT COMMENT 2提交3通过4驳回, comment VARCHAR(500), record_time DATETIME DEFAULT CURRENT_TIMESTAMP );DECIMAL(10,2)是金额字段的规范选择它比FLOAT精确不会出现23.9变成23.899999这种二进制浮点误差。金额经过多次累加后FLOAT会差出几分钱财务对账时非常难看。status字段用TINYINT保存状态码让状态流转在应用层控制数据库只负责保存最新值审批记录单独建表是为了能追溯每一步操作由谁做了什么决定。3.2 外键与索引设计保障数据一致性t_reimburse.emp_id引用t_employee.emp_idt_approval_record.reimburse_id引用t_reimburse.reimburse_id。在这个项目里物理外键可加可不加但逻辑外键必须通过索引补齐。如果MySQL是5.7及以上版本物理外键会带来锁竞争审批记录频繁插入时每次都要检查父表高流量下容易成为瓶颈。更好的做法是表之间只保留字段关系索引覆盖查询条件一致性由Service层校验保证。索引设计关注三个维度等值查询、排序、唯一性。员工登录走emp_no所以建唯一索引报销单列表按emp_id status过滤按apply_time排序所以推荐组合索引ALTER TABLE t_reimburse ADD INDEX idx_emp_status (emp_id, status); ALTER TABLE t_approval_record ADD INDEX idx_rid_time (reimburse_id, record_time);组合索引idx_emp_status (emp_id, status)能同时服务emp_id单独查询和emp_id status联合查询因为MySQL遵循最左前缀原则。如果SQL里单独查status这个索引就失效了此时需要额外建一个单列索引。很多初学者看到EXPLAIN里typeALL就慌其实是没搞清楚索引方向。另一个常见的坑是把时间范围条件加在组合索引的最后面比如(emp_id, status, apply_time)如果查询只带上emp_id和apply_time这一组索引就会退化。3.3 MyBatis XML中的动态SQL多条件查询报销单报销单列表页通常有多个查询条件单据号、状态、员工编号、金额区间。这些条件可填可不填用MyBatis的where标签能优雅处理拼接问题select idsearch resultTypecom.reimburse.entity.ReimburseVO SELECT r.reimburse_no, r.amount, r.status, e.emp_name, DATE_FORMAT(r.apply_time, %Y-%m-%d %H:%i:%s) AS apply_time FROM t_reimburse r LEFT JOIN t_employee e ON r.emp_id e.emp_id where if testempId ! null and empId ! AND r.emp_id #{empId} /if if teststatus ! null AND r.status #{status} /if if testminAmount ! null AND r.amount gt; #{minAmount} /if if testmaxAmount ! null AND r.amount lt; #{maxAmount} /if /where ORDER BY r.apply_time DESC /selectwhere会自动去除第一个条件前面的AND比手工写WHERE 11的拼接方式安全得多。gt;和lt;是XML转义写法不转义的话XML解析器会把理解为标签结尾。#{empId}是PreparedStatement参数占位符能防止SQL注入如果用${empId}直接拼字符串登录接口上就存在被 OR 11突破的风险。这是Java面试题里绕不开的考点但在这个项目里你写一次就能记住原因。这里的ReimburseVO是视图层专用对象它比实体类多一个empName字段用于在前端直接展示员工姓名。很多课程设计在resultType里直接写实体类查询完发现缺字段再循环查一次员工表性能就浪费在无意义的反复查询上。为列表页单独定义VO把JOIN出来的字段都放进去一条SQL解决展示问题读代码的人也清爽。4. 报销单审批流程与业务逻辑实现4.1 登录与权限控制拦截器还是Spring Security报销系统涉及员工、审批人、财务三种角色权限控制是最容易暴露水平的模块。SSM项目里最常见的是使用SpringMVC的HandlerInterceptor实现登录拦截和角色校验而不是一上来就引入Spring Security。Security配置链路长要管UserDetailsService、PasswordEncoder、FilterChain对这个体量的项目来说学习成本高于维护成本。自定义拦截器十行代码解决问题public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Employee emp (Employee) request.getSession().getAttribute(loginUser); if (emp null) { response.sendRedirect(request.getContextPath() /login); return false; } return true; } }在spring-mvc.xml里注册拦截规则mvc:interceptors mvc:interceptor mvc:mapping path/reimburse/**/ mvc:mapping path/employee/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/static/**/ /mvc:interceptor /mvc:interceptorsexclude-mapping里的/static/**必须写否则CSS、JS、图片资源全被拦截页面会变成裸HTML看起来像系统坏了。登录验证密码时不要明文比对虽然这个项目默认密码是123456但至少要用MD5加盐存储。前面建表时把password VARCHAR(64)就是为了同时容纳MD5和SHA-256十六进制字符串之后升级不用改表结构。角色校验可以在同一个preHandle里继续判断从Session里取出loginUser.getRole()然后根据handler对象的RequiresRole注解做对应判断。如果只在Controller方法里写if判断代码会重复很多也不好维护。4.2 报销单状态机草稿、已提交、审批中、已通过/驳回报销单的status字段不能随便改要设计成状态机。我用0草稿、1已提交、2审批中、3已通过、4已驳回。状态迁移规则如下当前状态允许动作下一状态备注0草稿提交1已提交填写不完整不给提交1已提交审批人开始审批2审批中锁定单据员工不可再改2审批中通过3已通过审批意见写入记录表2审批中驳回4已驳回员工可编辑后重新提交在Service层实现提交动作时要先查当前状态再更新否则并发下两个请求同时从草稿提交可能出现状态覆盖。加一个乐观锁字段version是常见做法UPDATE t_reimburse SET status 1, version version 1 WHERE reimburse_id #{id} AND status 0 AND version #{version}这个SQL执行后返回的影响行数如果为0说明状态已经不是草稿Service层直接抛出业务异常提示“单据已被他人处理”。这是高并发场景下防止重复提交的经典解法也是网上很多Java面试大全里说的“乐观锁应用”。注意不能用先SELECT再UPDATE的思路两个请求同时查到status0会各自执行UPDATE产生脏写。带条件的UPDATE把校验和修改合并到一条SQL数据库行锁会挡住第二个请求。4.3 财务报表统计使用SUM与GROUP BY实现聚合财务报表模块通常要按月份和费用类型统计报销总额。费用类型可以在t_reimburse里加一列expense_type比如1差旅、2餐饮、3办公用品。聚合查询写成SELECT DATE_FORMAT(apply_time, %Y-%m) AS month, expense_type, COUNT(*) AS reimburse_count, COALESCE(SUM(amount), 0) AS total_amount FROM t_reimburse WHERE status 3 GROUP BY month, expense_type ORDER BY month DESC, total_amount DESC;COALESCE(SUM(amount), 0)把NULL转成0是为了避免报表前端图表显示空白。只统计status3即已通过的单据把报销中不算数的金额也打进去财务会直接找你。在MyBatis中返回这个结果单独定义一个ReportVO里面放month、expenseType、reimburseCount、totalAmount四个属性。Mapper接口返回ListReportVO即可不用写resultMap前提是开篇提到的驼峰映射是开启状态。如果是课程设计演示还可以加一个折线图接口SQL换成按日分组SELECT DATE(apply_time) AS day, SUM(amount) AS day_amount FROM t_reimburse WHERE status 3 AND apply_time #{startDate} GROUP BY DATE(apply_time)这里传给Mapper的是String类型日期MySQL的DATE()函数会隐式转换。但为了让索引生效我一般会把条件写成apply_time #{startDate} AND apply_time DATE_ADD(#{endDate}, INTERVAL 1 DAY)避免对字段使用函数导致索引失效。这个细节在生产系统里是必须的在这个课程设计里写进文档也算是一个可展示的深度优化点。如果遇到慢查询先EXPLAIN看type是不是ALL或index调整索引或改写SQL之后再看行数从几万降到几十这个对比可以在答辩时直接展示。5. 从源码到可运行Maven构建、Tomcat部署与常见踩坑5.1 环境配置顺序与验证命令下载解压后会看到preview.bat、deploy.bat、build.bat这些批处理文件。它们本质上还是把Maven命令包装成了双击执行。但建议不要直接双击最好打开命令行手动敲看到真实输出才能定位问题。第一步是确认JDK和Maven已经装好并配好环境变量验证命令是java -version mvn -vmvn -v如果提示找不到命令说明MAVEN_HOME没配或者path里没有加%MAVEN_HOME%\bin。网络上一堆java环境变量配置教程核心就三行JAVA_HOME指向JDK解压目录MAVEN_HOME指向Maven目录path里追加%JAVA_HOME%\bin和%MAVEN_HOME%\bin。配置完重新开一个CMD窗口验证旧窗口不会刷新环境变量。接着导入数据库。先在MySQL里执行CREATE DATABASE reimburse DEFAULT CHARACTER SET utf8mb4;然后使用source导入项目里sql目录下的脚本。数据库初始化完成后修改applicationContext.xml里的账号密码。MySQL 8默认认证插件是caching_sha2_password老版本Maven项目中如果还是mysql-connector-java 5.1.x会报Unable to load authentication plugin。解决办法是将驱动升级到mysql-connector-java 8.0.x并且URL里必须写com.mysql.cj.jdbc.Driver。5.2 三个典型报错及对策报错一Invalid bound statement (not found)。前面提过先检查Mapper接口全限定名与XML的namespace是否一致。很多IDE在改动XML后没有重新编译资源文件target/classes里还是旧文件。执行mvn clean compile再启动能解决一半这类问题。报错二The server time zone value Öйú±ê׼ʱ¼ä is unrecognized。这是MySQL驱动与系统时区不对齐。直接在jdbc:mysql的URL末尾加上?serverTimezoneAsia/Shanghai中文乱码再用characterEncodingutf8控制。报错三Tomcat启动后访问页面返回404。先看IDEA控制台的部署上下文路径Tomcat Deployment里Application context如果是/reimburse访问URL就要带上下文http://localhost:8080/reimburse。再检查DispatcherServlet的url-pattern如果配的是*.do而页面里引用的请求路径没有.do后缀就会全部找不到映射。最后一个调试习惯启动时看到Map日志里包含ReimburseController - /reimburse/list这种映射记录说明Controller已被成功扫描。这个日志是判断SSM整合是否正常最快的手段比对着页面看半天快得多。线上排查MyBatis实际执行的SQL时把日志级别调到DEBUG看Preparing和Parameters两个位置比人工猜测SQL拼错要省力很多。本文还有配套的精品资源点击获取