首页 / 资讯中心 / 文章详情

JSP+SSM智能出差报销管理系统:课程设计实战与避坑指南

JSP+SSM智能出差报销管理系统:课程设计实战与避坑指南 ★ FEATURED ARTICLE
简介这份资源是面向计算机相关专业学生与Java Web初学者的课程设计完整交付包主题为基于JSP与SSMSpring、Spring MVC、MyBatis框架的智能出差报销管理系统可用于毕业设计参考、课程实践或自学练手。压缩包共10个文件约89.63MB包含项目源码压缩包、数据库SQL脚本、需求与开发报告文档、环境配置说明文本以及多张运行截图和两段实操录像覆盖从环境搭建到功能演示的完整链路。系统实现用户登录与权限管理、出差申请与审批、报销单创建提交与审核、报表统计及系统后台维护等模块按普通员工、部门主管、财务、管理员等角色划分功能。配套文档记录了需求分析、系统设计、测试与部署过程录像可辅助还原关键配置与调试步骤。目前已有47人学习适合需要一套可直接运行、文档齐全的SSM实战案例的读者参考借鉴。1. 从一份课程设计说起JSPSSM 智能出差报销管理系统到底解决什么问题很多同学做课程设计时选题都绕不开「XX 管理系统」但真正动手才发现最难的从来不是写增删改查而是把一套业务规则跑通。出差报销就是典型一张报销单背后牵扯出差申请、行程明细、费用科目、审批流、财务打款任何一个环节没对齐数据就是错的。这套基于 JSP SSMSpring SpringMVC MyBatis的智能出差报销管理系统核心目标就是把「员工提交—主管审批—财务复核—归档统计」这条链路用代码固化下来让报销从纸质单据变成可追溯的线上流程。它适合正在做课程设计、需要一套完整可运行 SSM 项目的同学也适合想借这个场景练手 Spring 事务、MyBatis 多表关联和 JSP 页面渲染的开发者。下面我按实际搭建顺序把选型、建表、核心代码和踩坑点讲清楚。2. 技术选型与工程骨架为什么这套系统用 SSM 而不是 SpringBoot2.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 groupIdorg.springframework/groupId artifactIdspring-context/artifactId version5.3.30/version /dependency !-- SpringMVC处理 web 请求和视图解析 -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.30/version /dependency !-- MyBatis 与 Spring 整合包 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.1.2/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- JSTLJSP 页面循环和判断要用 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency /dependencies这段配置的逻辑是Spring 容器负责管理 Service 和 Mapper 的 BeanSpringMVC 负责 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 和 MapperDispatcherServlet加载spring-mvc.xml管 Controller 和视图解析两者不能混。下面这段是web.xml的关键部分。!-- web.xml注册 Spring 根容器和 SpringMVC 前端控制器 -- listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param servlet servlet-namedispatcher/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-namedispatcher/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 已驳回。所有业务判断都围绕这个字段展开比如「只有 status0 才能编辑」「status3 之后禁止删除」。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 报销明细表一张单子多条费用 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 审批记录表记录每一轮审批动作 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明total_amount用DECIMAL(10,2)而不是FLOAT因为金额计算用浮点会出现0.10.20.30000000000000004这种精度问题财务场景绝对不能接受。idx_status索引是为了审批列表按状态筛选时走索引数据量上千条后差别很明显。happen_date用DATE而不是DATETIME因为费用发生日期只需要精确到天。3.3 MyBatis 多表关联查询的写法报销单列表页需要同时显示申请人姓名和明细条数这就涉及多表关联。我一般用 MyBatis 的resultMap做映射而不是靠autoMapping硬猜。!-- ReimburseMapper.xml主表关联用户表和明细统计 -- resultMap idMainVoMap typecom.demo.entity.ReimburseMainVo id propertyid columnid/ result propertytotalAmount columntotal_amount/ result propertystatus columnstatus/ result propertyapplyUserName columnuser_name/ result propertydetailCount columndetail_count/ /resultMap select idselectMainPage resultMapMainVoMap 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, ListReimburseDetail 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是前端传过来的报销单 IDdetails是明细列表。如果明细为空直接抛业务异常前端会收到提示。批量插入这里用循环单条插入数据量大时可以改成 MyBatis 的foreach批量插入但课程设计量级没必要。4.2 审批接口的状态机判断审批操作不能随便改状态必须按状态机走只有 status1 才能被主管审批只有 status2 才能被财务复核。这个判断放在 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); }逻辑说明先查当前状态再判断避免并发情况下两个审批人同时操作。result2表示驳回直接置为 4主管通过置为 2 转财务财务通过置为 3 结束。审批记录单独插入保证每次审批都有痕迹后面做审批历史页面直接查approve_record就行。参数result用 int 而不是 boolean是为了以后扩展「退回修改」等更多结果类型。opinion是审批意见前端用 textarea 提交长度限制在 255 以内。4.3 JSP 页面渲染报销单列表JSP 页面是这套系统的展示层用 JSTL 循环渲染列表是最常见的做法。下面这段是报销单列表页的核心片段。!-- reimburse_list.jsp用 JSTL 遍历后端传来的 mainList -- table border1 tr th单号/thth申请人/thth总金额/thth状态/thth操作/th /tr c:forEach items${mainList} varm 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 tda hrefdetail?id${m.id}查看/a/td /tr /c:forEach /table逻辑说明c:forEach的items属性对应后端Model里放的mainListvar是循环变量。c:choose做状态到中文的映射比在 Java 里拼字符串更清晰。${m.totalAmount}直接输出金额如果需要格式化可以用fmt:formatNumber。注意 JSP 页面顶部要引入% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %否则标签不生效。5. 避坑与排查这套系统最容易翻车的五个地方5.1 中文乱码从表单到数据库全链路排查现象JSP 页面提交的中文备注在数据库里显示成问号或者页面显示乱码。原因通常有三处JSP 页面pageEncoding没设 UTF-8、web.xml没配CharacterEncodingFilter、数据库连接 URL 没加characterEncodingutf8。解决方法是三处一起改JSP 顶部写% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %web.xml里加 Spring 的CharacterEncodingFilter并设forceEncodingtrueJDBC URL 写成jdbc:mysql://localhost:3306/reimburse?useUnicodetruecharacterEncodingutf8。三处缺一处都可能乱码别只改一处就以为好了。5.2 事务不回滚Transactional 失效的三种情况现象提交报销单时明细插入失败但主表状态已经改了数据不一致。原因通常是Transactional失效一是方法不是 publicSpring 代理不生效二是同类内部方法直接调用没走代理三是异常被 catch 了没抛出去。解决方法是确保注解方法为 public、通过注入的 Bean 调用而不是this.调用、catch 块里要么重新抛出要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。这个坑我在课程设计答辩现场见过不止一次。5.3 MyBatis 字段映射为 null驼峰与下划线没对上现象查询出来的applyUserName是 null但 SQL 在数据库里执行有值。原因是数据库字段是user_name下划线风格Java 实体是applyUserName驼峰风格MyBatis 默认不自动转换。解决方法有两种在applicationContext.xml里开启mapUnderscoreToCamelCasetrue或者手写resultMap显式映射。我一般推荐开启自动转换省得每个字段都写一遍但涉及多表关联别名时还是手写resultMap更稳。5.4 JSP 页面 404url-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是否一致。我习惯在测试库里造三条不同状态的单子每次改完代码都跑一遍比写单元测试快也比只测一条路径靠谱。这套系统真正值钱的地方不是页面多漂亮而是状态流转不出错、金额算得准、审批有痕迹。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站