简介:这份资源是面向计算机相关专业学生与Java Web初学者的课程设计完整交付包,主题为基于JSP与SSM(Spring、Spring MVC、MyBatis)框架的智能出差报销管理系统,可用于毕业设计参考、课程实践或自学练手。压缩包共10个文件,约89.63MB,包含项目源码压缩包、数据库SQL脚本、需求与开发报告文档、环境配置说明文本,以及多张运行截图和两段实操录像,覆盖从环境搭建到功能演示的完整链路。系统实现用户登录与权限管理、出差申请与审批、报销单创建提交与审核、报表统计及系统后台维护等模块,按普通员工、部门主管、财务、管理员等角色划分功能。配套文档记录了需求分析、系统设计、测试与部署过程,录像可辅助还原关键配置与调试步骤。目前已有47人学习,适合需要一套可直接运行、文档齐全的SSM实战案例的读者参考借鉴。
1. 从一份课程设计说起:JSP+SSM 智能出差报销管理系统到底解决什么问题
很多同学做课程设计时,选题都绕不开「XX 管理系统」,但真正动手才发现,最难的从来不是写增删改查,而是把一套业务规则跑通。出差报销就是典型:一张报销单背后牵扯出差申请、行程明细、费用科目、审批流、财务打款,任何一个环节没对齐,数据就是错的。这套基于 JSP + SSM(Spring + SpringMVC + MyBatis)的智能出差报销管理系统,核心目标就是把「员工提交—主管审批—财务复核—归档统计」这条链路用代码固化下来,让报销从纸质单据变成可追溯的线上流程。它适合正在做课程设计、需要一套完整可运行 SSM 项目的同学,也适合想借这个场景练手 Spring 事务、MyBatis 多表关联和 JSP 页面渲染的开发者。下面我按实际搭建顺序,把选型、建表、核心代码和踩坑点讲清楚。
2. 技术选型与工程骨架:为什么这套系统用 SSM 而不是 SpringBoot
2.1 SSM 三层结构在报销场景里的分工
SSM 是 Spring + SpringMVC + MyBatis 的组合,在课程设计里出现频率极高,原因是它把「控制层—业务层—持久层」的边界划得很清楚,正好对应报销系统的三类职责。SpringMVC 的 Controller 负责接收 JSP 表单提交的报销数据、做参数校验和页面跳转;Spring 的 Service 层承载业务规则,比如「同一张报销单不能重复提交」「审批通过后金额不可修改」,这些逻辑必须放在 Service 而不是 Controller,否则事务控制会失效;MyBatis 的 Mapper 负责和数据库打交道,报销单主表、明细表、审批记录表之间的关联查询都靠它。
我一般会把包结构分成controller、service、service.impl、mapper、entity、util六层,JSP 页面统一放在WEB-INF/jsp下,避免被直接访问。这样分层之后,后面加审批流或者导出 Excel 都不会把代码搅成一团。
2.2 用 Maven 搭出可运行的工程骨架
课程设计最常见的问题是环境跑不起来,所以第一步必须把依赖和目录固定下来。下面是我常用的pom.xml核心依赖片段,版本按你本地仓库实际情况调整,不要照抄一个不存在的版本号。
<!-- pom.xml 核心依赖,SSM 三件套 + JSP 支持 --> <dependencies> <!-- Spring 核心,提供 IoC 和事务管理 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.30</version> </dependency> <!-- SpringMVC,处理 web 请求和视图解析 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.30</version> </dependency> <!-- MyBatis 与 Spring 整合包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- JSTL,JSP 页面循环和判断要用 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>这段配置的逻辑是:Spring 容器负责管理 Service 和 Mapper 的 Bean,SpringMVC 负责 DispatcherServlet 的请求分发,MyBatis 通过SqlSessionFactoryBean接管数据库会话。参数上要注意mysql-connector-java8.x 必须配com.mysql.cj.jdbc.Driver,用老版本驱动名会直接报ClassNotFoundException。JSTL 的 1.2 版本是 JSP 页面里<c:forEach>能生效的前提,漏了它页面会原样输出标签。
2.3 web.xml 与 spring 配置的最小可用组合
工程骨架里最容易翻车的是web.xml和 Spring 配置的对应关系。ContextLoaderListener加载applicationContext.xml(管 Service 和 Mapper),DispatcherServlet加载spring-mvc.xml(管 Controller 和视图解析),两者不能混。下面这段是web.xml的关键部分。
<!-- web.xml:注册 Spring 根容器和 SpringMVC 前端控制器 --> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <servlet> <servlet-name>dispatcher</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>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>逻辑说明:ContextLoaderListener先启动,把 Service、Mapper、数据源装配好;DispatcherServlet随后启动,只扫描@Controller。参数上url-pattern用/而不是/*,因为/*会拦截 JSP 请求导致页面 404,这是新手最常踩的坑之一。load-on-startup设为 1 让 Servlet 随容器启动,避免第一次访问时初始化超时。
3. 数据库设计与报销单核心表:把审批流落到字段上
3.1 报销单主表、明细表、审批记录表的关系
报销系统的数据模型决定了后面代码好不好写。我一般拆三张核心表:reimburse_main(报销单主表,存申请人、总金额、状态、提交时间)、reimburse_detail(明细表,存费用科目、金额、发生日期、备注)、approve_record(审批记录表,存审批人、审批结果、审批意见、审批时间)。主表和明细表是一对多,主表和审批记录表也是一对多,这样一张单子可以有多条费用明细和多轮审批记录。
状态字段status是整个系统的灵魂,常见取值是:0 草稿、1 待主管审批、2 待财务复核、3 已通过、4 已驳回。所有业务判断都围绕这个字段展开,比如「只有 status=0 才能编辑」「status=3 之后禁止删除」。
3.2 建表 SQL 与索引设置
下面这段 SQL 可以直接在 MySQL 里执行,字段类型和长度按课程设计的量级设定,不需要过度设计。
-- 报销单主表:一张单子对应一次出差报销 CREATE TABLE reimburse_main ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_user_id BIGINT NOT NULL COMMENT '申请人ID', total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '报销总金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1待主管 2待财务 3通过 4驳回', submit_time DATETIME COMMENT '提交时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_apply_user (apply_user_id), INDEX idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 报销明细表:一张单子多条费用 CREATE TABLE reimburse_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, main_id BIGINT NOT NULL COMMENT '关联主表ID', expense_type VARCHAR(32) NOT NULL COMMENT '费用科目:交通/住宿/餐饮', amount DECIMAL(10,2) NOT NULL, happen_date DATE NOT NULL COMMENT '费用发生日期', remark VARCHAR(255), INDEX idx_main_id (main_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 审批记录表:记录每一轮审批动作 CREATE TABLE approve_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, main_id BIGINT NOT NULL, approver_id BIGINT NOT NULL, result TINYINT NOT NULL COMMENT '1通过 2驳回', opinion VARCHAR(255) COMMENT '审批意见', approve_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_main_id (main_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:total_amount用DECIMAL(10,2)而不是FLOAT,因为金额计算用浮点会出现0.1+0.2=0.30000000000000004这种精度问题,财务场景绝对不能接受。idx_status索引是为了审批列表按状态筛选时走索引,数据量上千条后差别很明显。happen_date用DATE而不是DATETIME,因为费用发生日期只需要精确到天。
3.3 MyBatis 多表关联查询的写法
报销单列表页需要同时显示申请人姓名和明细条数,这就涉及多表关联。我一般用 MyBatis 的resultMap做映射,而不是靠autoMapping硬猜。
<!-- ReimburseMapper.xml:主表关联用户表和明细统计 --> <resultMap id="MainVoMap" type="com.demo.entity.ReimburseMainVo"> <id property="id" column="id"/> <result property="totalAmount" column="total_amount"/> <result property="status" column="status"/> <result property="applyUserName" column="user_name"/> <result property="detailCount" column="detail_count"/> </resultMap> <select id="selectMainPage" resultMap="MainVoMap"> SELECT m.id, m.total_amount, m.status, u.user_name, (SELECT COUNT(*) FROM reimburse_detail d WHERE d.main_id = m.id) AS detail_count FROM reimburse_main m LEFT JOIN sys_user u ON u.id = m.apply_user_id WHERE m.status = #{status} ORDER BY m.submit_time DESC LIMIT #{offset}, #{limit} </select>逻辑说明:resultMap里property是 Java 实体字段名,column是 SQL 查询出的列名,两者必须一一对应,否则字段会是 null。子查询统计明细条数比GROUP BY更直观,数据量不大时性能足够。参数#{status}、#{offset}、#{limit}用#{}而不是${},前者是预编译占位符能防 SQL 注入,后者是字符串拼接,课程设计里也要养成这个习惯。
4. 核心业务实现:从提交报销单到审批通过
4.1 Service 层事务控制与金额校验
报销单提交是整个系统最关键的一步,因为它要同时写主表、写明细表、更新状态,任何一步失败都必须回滚。这就是 Spring 声明式事务的用武之地。
// ReimburseServiceImpl.java:提交报销单,主表+明细表在同一事务 @Service public class ReimburseServiceImpl implements ReimburseService { @Autowired private ReimburseMainMapper mainMapper; @Autowired private ReimburseDetailMapper detailMapper; @Override @Transactional(rollbackFor = Exception.class) public void submit(Long mainId, List<ReimburseDetail> details) { // 1. 校验明细不能为空,且金额必须大于0 if (details == null || details.isEmpty()) { throw new BizException("报销明细不能为空"); } BigDecimal total = BigDecimal.ZERO; for (ReimburseDetail d : details) { if (d.getAmount() == null || d.getAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new BizException("费用金额必须大于0"); } total = total.add(d.getAmount()); } // 2. 更新主表总金额和状态 ReimburseMain main = new ReimburseMain(); main.setId(mainId); main.setTotalAmount(total); main.setStatus(1); // 1=待主管审批 main.setSubmitTime(new Date()); mainMapper.updateById(main); // 3. 批量插入明细 for (ReimburseDetail d : details) { d.setMainId(mainId); detailMapper.insert(d); } } }逻辑说明:@Transactional(rollbackFor = Exception.class)里的rollbackFor必须显式写Exception.class,因为 Spring 默认只对RuntimeException回滚,如果抛出的是受检异常,事务不会回滚,这是血泪经验。金额累加用BigDecimal.add()而不是+,保证精度。状态从 0 改成 1 表示进入审批流,后续审批操作都基于这个状态判断。
参数上mainId是前端传过来的报销单 ID,details是明细列表。如果明细为空直接抛业务异常,前端会收到提示。批量插入这里用循环单条插入,数据量大时可以改成 MyBatis 的foreach批量插入,但课程设计量级没必要。
4.2 审批接口的状态机判断
审批操作不能随便改状态,必须按状态机走:只有 status=1 才能被主管审批,只有 status=2 才能被财务复核。这个判断放在 Service 里,防止前端绕过页面直接调接口。
// ApproveServiceImpl.java:审批动作,严格按状态机流转 @Override @Transactional(rollbackFor = Exception.class) public void approve(Long mainId, Long approverId, int result, String opinion) { ReimburseMain main = mainMapper.selectById(mainId); if (main == null) { throw new BizException("报销单不存在"); } // 只有待主管审批(1)或待财务复核(2)才能被审批 if (main.getStatus() != 1 && main.getStatus() != 2) { throw new BizException("当前状态不允许审批"); } // 写入审批记录 ApproveRecord record = new ApproveRecord(); record.setMainId(mainId); record.setApproverId(approverId); record.setResult(result); record.setOpinion(opinion); recordMapper.insert(record); // 根据审批结果和当前状态决定下一个状态 int nextStatus; if (result == 2) { nextStatus = 4; // 驳回 } else if (main.getStatus() == 1) { nextStatus = 2; // 主管通过,转财务复核 } else { nextStatus = 3; // 财务通过,流程结束 } mainMapper.updateStatus(mainId, nextStatus); }逻辑说明:先查当前状态再判断,避免并发情况下两个审批人同时操作。result==2表示驳回,直接置为 4;主管通过置为 2 转财务;财务通过置为 3 结束。审批记录单独插入,保证每次审批都有痕迹,后面做审批历史页面直接查approve_record就行。
参数result用 int 而不是 boolean,是为了以后扩展「退回修改」等更多结果类型。opinion是审批意见,前端用 textarea 提交,长度限制在 255 以内。
4.3 JSP 页面渲染报销单列表
JSP 页面是这套系统的展示层,用 JSTL 循环渲染列表是最常见的做法。下面这段是报销单列表页的核心片段。
<!-- reimburse_list.jsp:用 JSTL 遍历后端传来的 mainList --> <table border="1"> <tr> <th>单号</th><th>申请人</th><th>总金额</th><th>状态</th><th>操作</th> </tr> <c:forEach items="${mainList}" var="m"> <tr> <td>${m.id}</td> <td>${m.applyUserName}</td> <td>${m.totalAmount}</td> <td> <c:choose> <c:when test="${m.status == 1}">待主管审批</c:when> <c:when test="${m.status == 2}">待财务复核</c:when> <c:when test="${m.status == 3}">已通过</c:when> <c:otherwise>已驳回</c:otherwise> </c:choose> </td> <td><a href="detail?id=${m.id}">查看</a></td> </tr> </c:forEach> </table>逻辑说明:<c:forEach>的items属性对应后端Model里放的mainList,var是循环变量。<c:choose>做状态到中文的映射,比在 Java 里拼字符串更清晰。${m.totalAmount}直接输出金额,如果需要格式化可以用<fmt:formatNumber>。注意 JSP 页面顶部要引入<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>,否则标签不生效。
5. 避坑与排查:这套系统最容易翻车的五个地方
5.1 中文乱码:从表单到数据库全链路排查
现象:JSP 页面提交的中文备注在数据库里显示成问号,或者页面显示乱码。原因通常有三处:JSP 页面pageEncoding没设 UTF-8、web.xml没配CharacterEncodingFilter、数据库连接 URL 没加characterEncoding=utf8。解决方法是三处一起改:JSP 顶部写<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>,web.xml里加 Spring 的CharacterEncodingFilter并设forceEncoding=true,JDBC URL 写成jdbc:mysql://localhost:3306/reimburse?useUnicode=true&characterEncoding=utf8。三处缺一处都可能乱码,别只改一处就以为好了。
5.2 事务不回滚:@Transactional 失效的三种情况
现象:提交报销单时明细插入失败,但主表状态已经改了,数据不一致。原因通常是@Transactional失效:一是方法不是 public,Spring 代理不生效;二是同类内部方法直接调用,没走代理;三是异常被 catch 了没抛出去。解决方法是确保注解方法为 public、通过注入的 Bean 调用而不是this.调用、catch 块里要么重新抛出要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。这个坑我在课程设计答辩现场见过不止一次。
5.3 MyBatis 字段映射为 null:驼峰与下划线没对上
现象:查询出来的applyUserName是 null,但 SQL 在数据库里执行有值。原因是数据库字段是user_name下划线风格,Java 实体是applyUserName驼峰风格,MyBatis 默认不自动转换。解决方法有两种:在applicationContext.xml里开启mapUnderscoreToCamelCase=true,或者手写resultMap显式映射。我一般推荐开启自动转换,省得每个字段都写一遍,但涉及多表关联别名时还是手写resultMap更稳。
5.4 JSP 页面 404:url-pattern 写成 /* 的后果
现象:Controller 能访问,但 JSP 页面一直 404。原因是web.xml里DispatcherServlet的url-pattern写成了/*,把所有请求包括 JSP 都拦截了。解决方法是改成/,让默认 Servlet 处理 JSP 请求。这个坑很隐蔽,因为 Controller 请求正常,只有 JSP 出问题,新手容易以为是视图解析器配错了。
5.5 金额精度丢失:用 double 存金额的代价
现象:报销总金额出现1234.5600000000001这种值。原因是实体类里金额字段用了double或float。解决方法是实体、数据库、计算全程用BigDecimal,数据库字段用DECIMAL(10,2)。BigDecimal比较大小用compareTo而不是equals,因为equals会比较精度,new BigDecimal("1.0").equals(new BigDecimal("1.00"))返回 false,这个细节不注意会写出错误的状态判断。
6. 进阶技巧:用状态机枚举和审批日志把系统做扎实
课程设计做到能跑只是及格线,想拿高分或者真正能用在实习里,得在可维护性上下功夫。我一般会做两件事:把状态从魔法数字改成枚举,把审批日志做成可追溯的时间线。
先看状态枚举。前面代码里到处是status == 1、status == 2,过两周自己都忘了数字含义。改成枚举之后,代码自解释,也方便加新状态。
// ReimburseStatus.java:用枚举替代魔法数字 public enum ReimburseStatus { DRAFT(0, "草稿"), WAIT_MANAGER(1, "待主管审批"), WAIT_FINANCE(2, "待财务复核"), PASSED(3, "已通过"), REJECTED(4, "已驳回"); private final int code; private final String desc; ReimburseStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } // 根据 code 反查枚举,用于数据库值转对象 public static ReimburseStatus of(int code) { for (ReimburseStatus s : values()) { if (s.code == code) return s; } throw new IllegalArgumentException("未知状态: " + code); } }逻辑说明:of方法做 code 到枚举的反查,Service 里拿到数据库的 int 值后直接ReimburseStatus.of(main.getStatus())就能拿到带描述的对象。参数code和desc用 final 修饰,保证枚举不可变。这样 JSP 页面渲染状态时可以直接用${status.desc},不用再写<c:choose>。
第二件事是审批时间线。审批记录表已经有数据了,但列表页只显示当前状态,看不到历史。我一般加一个详情页,按approve_time升序把审批记录列出来,每条显示审批人、结果、意见、时间。查询语句很简单:
-- 查询某张报销单的完整审批时间线 SELECT a.approve_time, u.user_name, a.result, a.opinion FROM approve_record a LEFT JOIN sys_user u ON u.id = a.approver_id WHERE a.main_id = #{mainId} ORDER BY a.approve_time ASC;这个查询配合 JSP 的<c:forEach>就能渲染成时间线。参数mainId从 URL 传入,注意在 Controller 里做非空校验,防止传 null 导致全表扫描。
还有一个容易被忽略的点:报销单的编辑权限。草稿状态(0)只有申请人自己能编辑,待审批状态(1、2)谁都不能改,已通过(3)和已驳回(4)也不能改。这个判断我一般放在 Controller 入口处,用拦截器或者 AOP 统一处理,而不是每个方法里写一遍。课程设计里如果时间紧,至少在 Service 的 update 方法里加一句状态校验,别让前端传个 id 就能改已通过的单子。
最后说个验证方法:把状态流转的每条路径都手动走一遍,草稿→提交→主管驳回→修改→再提交→主管通过→财务通过,看数据库里status和approve_record是否一致。我习惯在测试库里造三条不同状态的单子,每次改完代码都跑一遍,比写单元测试快,也比只测一条路径靠谱。这套系统真正值钱的地方不是页面多漂亮,而是状态流转不出错、金额算得准、审批有痕迹。希望帮到你。
本文还有配套的精品资源,点击获取