简介:基于Java SSM(Spring、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 8,Spring 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-package="com.reimburse.service" /> <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver" /> <property name="url" value="jdbc:mysql://localhost:3306/reimburse?useUnicode=true&characterEncoding=utf8" /> <property name="username" value="root" /> <property name="password" value="root" /> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource" /> <property name="configLocation" value="classpath:mybatis-config.xml" /> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.reimburse.mapper" /> </bean>这里最容易被忽略的是MapperScannerConfigurer,它会把com.reimburse.mapper包下所有接口直接代理成MyBatis的Mapper,省去每个Mapper都要写一次getMapper()调用的模板代码。注意url里&必须写成&,不写的话Maven打包时会报XML解析错误。另外,serverTimezone没配的话,高版本MySQL驱动连库时直接报时区异常,我习惯在url后面追加&serverTimezone=Asia/Shanghai。
2.2 SpringMVC的请求路径:DispatcherServlet与Controller映射
SpringMVC是Web层的门面。web.xml里配置了DispatcherServlet,它拦截/路径下所有请求。看一个典型配置:
<servlet> <servlet-name>springmvc</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>springmvc</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>load-on-startup设为1,表示Tomcat启动时就初始化SpringMVC容器,否则要等第一个请求进来才初始化,用户会明显感觉首页加载慢。spring-mvc.xml里开启<mvc:annotation-driven/>,再用<context:component-scan base-package="com.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) { List<ReimburseVO> list = reimburseService.queryApplyRecord(pageNum, pageSize); model.addAttribute("list", list); return "reimburse/list"; } }@RequestParam(defaultValue = "1")是必须的,它让前端不带pageNum参数时也能正常访问,否则直接抛MissingServletRequestParameterException。Model是SpringMVC向视图传数据的标准入口,对应JSP里用${list}取值。整个流程就是:浏览器请求/reimburse/list,DispatcherServlet根据@RequestMapping找到ReimburseController,方法执行结束后返回逻辑视图名,视图解析器拼出实际JSP路径,渲染HTML回给浏览器。如果你把url-pattern改成*.do,所有访问路径后面都要带上.do后缀,这是很多老项目的历史包袱。
2.3 MyBatis如何嵌入Spring:SqlSessionFactory与Mapper代理
MyBatis以Mapper接口和XML文件为操作数据库的载体。mybatis-config.xml里通常会配置驼峰映射和默认别名:
<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true" /> </settings> <typeAliases> <package name="com.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是否齐全。
| 层次 | 框架 | 核心配置 | 典型对象 |
|---|---|---|---|
| 表现层 | SpringMVC | spring-mvc.xml | Controller、HandlerInterceptor |
| 业务层 | Spring | applicationContext.xml | Service、ServiceImpl |
| 持久层 | MyBatis | mybatis-config.xml、Mapper.xml | Mapper接口、SqlSessionTemplate |
三个配置文件的扫描范围绝对不能重叠:Spring只扫Service,SpringMVC只扫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_id,t_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里type=ALL就慌,其实是没搞清楚索引方向。另一个常见的坑是把时间范围条件加在组合索引的最后面,比如(emp_id, status, apply_time),如果查询只带上emp_id和apply_time这一组,索引就会退化。
3.3 MyBatis XML中的动态SQL:多条件查询报销单
报销单列表页通常有多个查询条件:单据号、状态、员工编号、金额区间。这些条件可填可不填,用MyBatis的<where>标签能优雅处理拼接问题:
<select id="search" resultType="com.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 test="empId != null and empId != ''"> AND r.emp_id = #{empId} </if> <if test="status != null"> AND r.status = #{status} </if> <if test="minAmount != null"> AND r.amount >= #{minAmount} </if> <if test="maxAmount != null"> AND r.amount <= #{maxAmount} </if> </where> ORDER BY r.apply_time DESC </select><where>会自动去除第一个条件前面的AND,比手工写WHERE 1=1的拼接方式安全得多。>=和<=是XML转义写法,不转义的话XML解析器会把>=理解为标签结尾。#{empId}是PreparedStatement参数占位符,能防止SQL注入;如果用${empId}直接拼字符串,登录接口上就存在被' OR 1=1突破的风险。这是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:interceptors>exclude-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的思路,两个请求同时查到status=0,会各自执行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,是为了避免报表前端图表显示空白。只统计status=3即已通过的单据,把报销中不算数的金额也打进去,财务会直接找你。在MyBatis中返回这个结果,单独定义一个ReportVO,里面放month、expenseType、reimburseCount、totalAmount四个属性。Mapper接口返回List<ReportVO>即可,不用写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末尾加上?serverTimezone=Asia/Shanghai,中文乱码再用characterEncoding=utf8控制。
报错三: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拼错要省力很多。
本文还有配套的精品资源,点击获取