☰
SSM财务报销系统落地避坑指南:状态机、预算校验与凭证生成
2026/10/26 9:18:24 网站建设 项目流程

简介:这是一套面向计算机专业本科生及Java初学者的ERP财务报销系统实战项目,专为毕业设计、课程设计与Web开发进阶练习打造,聚焦企业级财务流程管理能力培养。资源包含433个文件,以33个Java核心业务类、18个JSP页面、13个XML配置文件、118个JS交互脚本及41个CSS样式文件为主体,辅以MySQL数据库SQL脚本、Layui前端资源(gif/png/svg/ttf等)和完整项目说明文档,压缩包仅5.44MB,轻量易部署。已有48人学习下载,适合在JDK+IDEA环境下快速导入运行。学习者可完整掌握SSM框架三层架构落地实践:从Spring容器管理与MyBatis动态SQL编写,到Spring MVC请求分发与报销单据流转、用户权限控制、数据统计报表等真实业务模块实现,同时获得可直接复用的数据库建模逻辑与前后端联调经验。

1. 为什么财务报销系统不能只靠“能跑通”?SSM框架下ERP报销模块的落地陷阱远比你想象的多

我接手过三个用SSM(Spring + SpringMVC + MyBatis)搭的Java ERP财务报销系统,其中两个上线后三个月内被业务部门退回重做——不是功能没实现,而是报销单提交后状态卡在“待审核”,财务人员查不到审批流节点;另一个更玄:月底结账时数据库死锁频发,日志里全是Transaction rolled back because it has been marked as rollback-only,但开发说“本地测试完全没问题”。后来翻源码才发现,所有Service方法都加了@Transactional,却没人管事务传播行为,报销单创建、附件上传、预算校验三件事硬塞进一个事务里,附件超大时一失败,整个报销流程就回滚,连基础数据都没留痕。

这恰恰是“基于SSM框架的Java ERP财务报销管理系统源码及数据库”这类项目最危险的地方:它给你一套能编译、能登录、能增删改查的代码,但没告诉你财务场景的刚性约束在哪——比如报销必须支持多级审批流、必须与预算科目强绑定、必须满足会计凭证生成规则、必须留审计痕迹。而SSM本身不提供这些,全靠你在MyBatis XML里手写SQL、在Spring Service里堆逻辑、在SpringMVC Controller里拼参数。

如果你正要复现这套系统,或拿它当毕业设计/内部工具原型,别急着跑mvn clean install。先问自己:你是否清楚报销单状态机怎么设计才不会出现“已支付但未记账”的脏数据?是否知道MyBatis动态SQL里<foreach>嵌套<if>时,空集合会导致SQL语法错误?是否试过用Oracle和MySQL跑同一套Mapper XML,结果因分页写法不同直接报错?本文就从真实可运行的源码结构出发,带你把这套系统从“能跑”变成“敢用”。


2. 搭建可验证的SSM报销系统:从源码解压到数据库初始化的最小闭环

拿到“基于SSM框架的Java ERP财务报销管理系统源码及数据库”压缩包后,第一步不是导入IDEA,而是确认它是否具备可验证的最小闭环能力。很多所谓“完整源码”实际缺了关键配置,比如数据库连接池参数硬编码在applicationContext.xml里,或jdbc.properties文件被.gitignore误删。本节按一线工程师实操顺序,带你走通从解压到首页渲染的每一步,并标注每个环节的验证点。

2.1 解压与目录结构识别:先看懂源码在“说什么”

解压后,典型目录结构应包含:

src/main/java/com/erp/finance/ ├── controller/ // SpringMVC控制器,如ReimbursementController.java ├── service/ // 业务逻辑层,含ReimbursementService.java及impl ├── dao/ // MyBatis DAO接口,如ReimbursementDao.java └── entity/ // Java实体类,如Reimbursement.java、ExpenseItem.java src/main/resources/ ├── mybatis-config.xml // MyBatis核心配置 ├── mapper/ // SQL映射文件,如ReimbursementMapper.xml ├── jdbc.properties // 数据库连接配置(重点检查!) └── spring-*.xml // Spring配置文件(applicationContext.xml, springmvc.xml) WebContent/ // 或 src/main/webapp/,含JSP页面与静态资源 sql/ // 数据库脚本(重点!必须有建表+初始数据)

提示:若sql/目录下只有erp_db.sql而无init_data.sql,说明初始用户(如admin/admin)需手动插入,否则登录页永远401。务必先打开SQL脚本,确认reimbursement、expense_type、budget_account等核心表是否存在,且字段名与entity/Reimbursement.java中的属性名严格一致(如apply_datevsapplyDate),这是MyBatis自动映射的前提。

2.2 数据库初始化:用脚本而非手动建表,避开字符集与引擎坑

财务系统对数据一致性要求极高,MySQL建表必须显式指定CHARSET=utf8mb4和ENGINE=InnoDB。常见翻车点:源码SQL脚本里写的是ENGINE=MyISAM,导致事务失效;或VARCHAR(255)字段存发票号时遇到emoji,因未设utf8mb4而乱码。

执行前先检查MySQL版本兼容性:

mysql -u root -p -e "SELECT VERSION();" # SSM项目普遍适配 MySQL 5.7,若为8.0+,需确认驱动版本(见2.3节)

然后执行初始化(以Linux为例,Windows路径用反斜杠):

# 进入sql目录,假设脚本名为erp_init.sql mysql -u root -p < ./sql/erp_init.sql

关键检查点:

  • 运行后执行SHOW CREATE TABLE reimbursement;,确认输出中包含ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • 执行SELECT COUNT(*) FROM user WHERE username='admin';,返回1才说明初始数据加载成功;
  • 若报错ERROR 1071 (42000): Specified key was too long,是MySQL 5.7默认innodb_large_prefix=OFF导致索引超长,需在my.cnf中添加:
    [mysqld] innodb_large_prefix=ON innodb_file_format=Barracuda innodb_file_per_table=ON

2.3 Maven依赖与JDK版本对齐:SSM不是“版本越高越好”

SSM组合对JDK和Spring版本极其敏感。本项目源码若基于Spring 4.x(常见于老ERP系统),则严禁使用JDK 17+,否则org.springframework.web.servlet.DispatcherServlet会因字节码版本不匹配直接启动失败。

检查pom.xml关键依赖:

<properties> <spring.version>4.3.29.RELEASE</spring.version> <!-- 注意:4.3.x是最后支持JDK 8的稳定版 --> <mybatis.version>3.4.6</mybatis.version> <mysql.connector.version>5.1.47</mysql.connector.version> <!-- 若用MySQL 8.0+,必须升到8.0.28 --> </properties>

对应JDK选择:

Spring版本推荐JDKMaven编译插件配置
4.3.xJDK 8<source>1.8</source><target>1.8</target>
5.3.xJDK 11<source>11</source><target>11</target>

血泪经验:曾有个项目pom.xml里Spring写的是5.0.0.RELEASE,但实际jar包是4.3.29,因为<dependencyManagement>里覆盖了版本。务必用mvn dependency:tree -Dverbose | grep spring确认最终生效版本。

2.4 Tomcat部署与上下文路径修正:避免404的三个硬核检查点

将项目导入IDEA后,不要直接点绿色三角形运行。SSM项目需部署到Tomcat,且web.xml中<context-param>常被忽略:

<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> <!-- 必须指向正确路径 --> </context-param>

部署前必做三件事:

  1. 确认Tomcat版本:Spring 4.3.x推荐Tomcat 8.5.x,Tomcat 10+因包名从javax.*改为jakarta.*,会导致web.xml解析失败;
  2. 检查web.xml中DispatcherServlet映射:
    <servlet-mapping> <servlet-name>springmvc</servlet-name> <url-pattern>/</url-pattern> <!-- 不能写成/*.action,否则静态资源404 --> </servlet-mapping>
  3. 设置Context Path:在IDEA的Tomcat配置中,Deployment选项卡下,Application context必须设为/erp(或项目名),而非默认/,否则访问http://localhost:8080/会跳转到Tomcat首页。

验证成功标志:访问http://localhost:8080/erp/login.jsp能显示登录页,且浏览器开发者工具Network标签中,login.jsp返回状态码200,无Failed to load resource报错。


3. 核心业务逻辑拆解:报销单状态机、预算校验与凭证生成的SSM实现

财务报销不是CRUD,而是状态驱动的业务流。本节聚焦源码中最易出错的三大模块:报销单状态流转、费用类型与预算科目绑定、会计凭证自动生成。所有代码均来自真实SSM报销系统源码,已脱敏并标注关键逻辑。

3.1 报销单状态机:用枚举+状态转移表替代if-else链

很多源码把状态判断写成冗长if-else:

// ❌ 反模式:难以维护,新增状态需改多处 if("draft".equals(status)) { if(userRole.equals("employee")) updateStatus("submitted"); } else if("submitted".equals(status)) { if(userRole.equals("manager")) updateStatus("approved"); }

正确做法:定义状态枚举与转移规则表。在entity/Reimbursement.java中:

public enum ReimbursementStatus { DRAFT("草稿", Arrays.asList("SUBMITTED")), SUBMITTED("已提交", Arrays.asList("APPROVED", "REJECTED")), APPROVED("已批准", Arrays.asList("PAID", "CANCELLED")), PAID("已支付", Arrays.asList("CLOSED")), CLOSED("已关闭", Collections.emptyList()); private final String desc; private final List<String> nextStatuses; ReimbursementStatus(String desc, List<String> nextStatuses) { this.desc = desc; this.nextStatuses = nextStatuses; } public boolean canTransitionTo(String targetStatus) { return this.nextStatuses.contains(targetStatus); } }

在Service层调用:

@Service public class ReimbursementService { public void updateStatus(Long id, String newStatus, String operator) { Reimbursement old = reimbursementDao.selectById(id); // ✅ 状态校验:当前状态能否跳转到newStatus? if (!ReimbursementStatus.valueOf(old.getStatus().toUpperCase()) .canTransitionTo(newStatus.toUpperCase())) { throw new BusinessException("状态非法:" + old.getStatus() + " → " + newStatus); } // ✅ 权限校验:只有审批人能改状态 if (!hasPermission(operator, old, newStatus)) { throw new BusinessException("无权操作此状态"); } reimbursementDao.updateStatus(id, newStatus, operator); // 更新DB } }

参数说明:canTransitionTo()方法确保状态流转符合业务规则,避免“已支付”单被误点“已批准”;hasPermission()需结合角色表查询,此处省略具体实现,但必须存在,否则权限失控。

3.2 预算校验:MyBatis动态SQL实现“科目+期间”双维度控制

财务报销必须校验:该费用类型是否在当前会计期间有可用预算?源码中常见错误是只查总预算,忽略“已占用”金额。正确SQL需关联budget_account(预算科目表)与reimbursement(报销单表):

mapper/ReimbursementMapper.xml中:

<select id="checkBudgetAvailable" resultType="java.lang.Boolean"> SELECT CASE WHEN COALESCE(b.available_amount, 0) >= #{amount} THEN 1 ELSE 0 END AS canSubmit FROM budget_account b LEFT JOIN ( -- 计算当前期间已占用预算(已批准未支付的报销单) SELECT account_id, SUM(amount) as used_amount FROM reimbursement r WHERE r.status IN ('APPROVED', 'PAID') AND r.apply_date >= #{periodStart} AND r.apply_date <= #{periodEnd} GROUP BY account_id ) u ON b.id = u.account_id WHERE b.expense_type_id = #{expenseTypeId} AND b.period = #{accountingPeriod} </select>

Service层调用:

public void submitReimbursement(Reimbursement reimbursement) { // 先校验预算 boolean canSubmit = reimbursementDao.checkBudgetAvailable( reimbursement.getExpenseTypeId(), reimbursement.getAmount(), "2024-01", // 会计期间格式:YYYY-MM "2024-01-01", // periodStart "2024-01-31" // periodEnd ); if (!canSubmit) { throw new BusinessException("预算不足,请联系财务管理员"); } // 再保存报销单... }

注意:#{}是预编译占位符,防止SQL注入;COALESCE(b.available_amount, 0)处理NULL值,避免预算为NULL时校验失败。

3.3 会计凭证生成:用模板引擎拼接分录,而非硬编码SQL

报销通过后需生成凭证,如:

借:管理费用-差旅费 5000.00 贷:银行存款-工行账户 5000.00

源码中若用字符串拼接:

// ❌ 危险:字段名硬编码,易错且无法校验 String sql = "INSERT INTO voucher VALUES ('" + debitAccount + "', '" + creditAccount + "', ...)";

推荐方案:用FreeMarker模板(src/main/resources/templates/voucher.ftl):

INSERT INTO voucher (voucher_no, voucher_date, debit_account, credit_account, amount, remark) VALUES ( '${voucherNo}', '${voucherDate?string('yyyy-MM-dd')}', '${debitAccount}', '${creditAccount}', ${amount}, '报销单${reimbursementId}' );

Java中渲染:

Configuration cfg = new Configuration(Configuration.VERSION_2_3_31); cfg.setClassForTemplateLoading(this.getClass(), "/templates"); Template template = cfg.getTemplate("voucher.ftl"); Map<String, Object> data = new HashMap<>(); data.put("voucherNo", "VCH-" + System.currentTimeMillis()); data.put("voucherDate", new Date()); data.put("debitAccount", "管理费用-差旅费"); data.put("creditAccount", "银行存款-工行账户"); data.put("amount", reimbursement.getAmount()); data.put("reimbursementId", reimbursement.getId()); Writer out = new StringWriter(); template.process(data, out); String sql = out.toString(); // 得到可执行SQL voucherDao.execute(sql); // 执行插入

优势:模板与代码分离,修改分录规则无需改Java;?string内置日期格式化,避免SimpleDateFormat线程安全问题。


4. 避坑指南:SSM报销系统上线前必须排查的5个致命问题

再完整的源码,部署到生产环境也会翻车。以下是我在三个项目中踩过的坑,按现象→原因→解决整理,每条都对应真实日志和修复代码。

4.1 现象:报销单列表分页时,第2页数据重复第1页内容

原因:MyBatis分页插件PageHelper未正确配置,或Mapper XML中<select>语句含<if test="xxx != null">导致WHERE条件为空,全表扫描后分页失效。
解决:

  • 在mybatis-config.xml中确认PageHelper插件已注册:
    <plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <property name="dialect" value="mysql"/> </plugin> </plugins>
  • Mapper XML中,分页查询必须有明确WHERE条件,禁用空条件:
    <!-- ❌ 错误:当searchKey为空时,WHERE 1=1 导致全表扫描 --> <where> <if test="searchKey != null and searchKey != ''"> AND (title LIKE CONCAT('%', #{searchKey}, '%') OR code LIKE CONCAT('%', #{searchKey}, '%')) </if> <!-- ✅ 正确:加兜底条件,避免WHERE为空 --> AND status != 'DELETED' </where>

4.2 现象:上传发票图片后,数据库里存的是乱码路径,如C:\fakepath\invoice.jpg

原因:前端JSP用<input type="file">时,Chrome浏览器出于安全限制返回fakepath,后端直接取file.getOriginalFilename()未处理。
解决:

// Controller中 @RequestMapping(value = "/upload", method = RequestMethod.POST) public String uploadFile(@RequestParam("file") MultipartFile file, Model model) { if (!file.isEmpty()) { // ✅ 提取真实文件名(移除fakepath) String originalFilename = file.getOriginalFilename(); String realFilename = originalFilename.substring(originalFilename.lastIndexOf("\\") + 1); // 保存文件... String savePath = "upload/" + System.currentTimeMillis() + "_" + realFilename; file.transferTo(new File(savePath)); // 存DB时只存相对路径 reimbursement.setAttachmentPath(savePath); } }

4.3 现象:Oracle数据库下,报销单创建时间字段存入后变成1970-01-01

原因:Oracle的DATE类型精度为秒,而Javajava.util.Date包含毫秒,MyBatis默认TypeHandler未适配。
解决:

  • 在mybatis-config.xml中注册Oracle专用TypeHandler:
    <typeHandlers> <typeHandler handler="org.apache.ibatis.type.DateTypeHandler" javaType="java.util.Date" jdbcType="DATE"/> </typeHandlers>
  • 或在Mapper XML中显式指定:
    <insert id="insertReimbursement"> INSERT INTO reimbursement (apply_date, ...) VALUES (#{applyDate, jdbcType=DATE}, ...) </insert>

4.4 现象:多用户同时提交报销,出现“预算余额计算错误”,实际支出超预算

原因:预算校验与扣减未加锁,两个线程同时读到余额5000,各自扣减2000,最终余额剩1000,但实际支出了4000。
解决:

  • 在预算表budget_account上加数据库行锁:
    // Service中 @Transactional public void deductBudget(Long accountId, BigDecimal amount) { // ✅ SELECT FOR UPDATE 锁住该行 BudgetAccount account = budgetAccountDao.selectForUpdate(accountId); if (account.getAvailableAmount().compareTo(amount) < 0) { throw new BusinessException("预算不足"); } account.setAvailableAmount(account.getAvailableAmount().subtract(amount)); budgetAccountDao.update(account); }
  • 对应Mapper XML:
    <select id="selectForUpdate" resultType="BudgetAccount"> SELECT * FROM budget_account WHERE id = #{id} FOR UPDATE </select>

4.5 现象:SpringMVC接收JSON参数时,日期字段反序列化失败,报Cannot deserialize instance of java.util.Date

原因:前端传"applyDate":"2024-03-15",但Spring默认Jackson不识别String转Date。
解决:

  • 在springmvc.xml中配置Jackson:
    <bean id="mappingJackson2HttpMessageConverter" class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <property name="objectMapper"> <bean class="com.fasterxml.jackson.databind.ObjectMapper"> <property name="dateFormat"> <bean class="java.text.SimpleDateFormat"> <constructor-arg value="yyyy-MM-dd"/> </bean> </property> </bean> </property> </bean>
  • 或在实体类字段上加注解:
    @JsonFormat(pattern = "yyyy-MM-dd") private Date applyDate;

5. 进阶技巧:用MyBatis Generator逆向工程快速生成DAO,但必须手工改造的3处

MyBatis Generator(MBG)能根据数据库表自动生成Entity、DAO、Mapper XML,极大提升开发效率。但直接用MBG生成的代码在财务系统中几乎必然出错,必须手工改造。以下是我每次生成后必改的三处,已沉淀为团队Checklist。

5.1 改造点1:Entity类增加Lombok注解与业务方法

MBG生成的Entity是纯POJO,无业务逻辑。财务系统需在Entity中封装常用计算:

// 生成前:Reimbursement.java 只有private Long id; private String title; ... // 生成后手工添加: @Data // Lombok,省去getter/setter @Builder // 构建器模式 @NoArgsConstructor @AllArgsConstructor public class Reimbursement { private Long id; private String title; private BigDecimal amount; private String status; // ✅ 手工添加:状态中文描述 public String getStatusDesc() { switch (status) { case "DRAFT": return "草稿"; case "SUBMITTED": return "已提交"; case "APPROVED": return "已批准"; default: return "未知"; } } // ✅ 手工添加:金额格式化(避免前端JS格式化精度丢失) public String getAmountFormatted() { return NumberFormat.getCurrencyInstance(Locale.CHINA).format(amount); } }

注意:@Data会生成toString(),但财务系统中toString()可能暴露敏感字段(如银行卡号),建议用@ToString(exclude = {"bankCard"})排除。

5.2 改造点2:Mapper XML增加批量操作与乐观锁

MBG默认只生成单条CRUD,但报销系统常需批量审核:

<!-- 手工在ReimbursementMapper.xml中添加 --> <update id="batchApprove"> UPDATE reimbursement SET status = 'APPROVED', approved_by = #{operator}, approved_time = NOW() WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> AND status = 'SUBMITTED' <!-- ✅ 乐观锁:只更新原状态为SUBMITTED的记录 --> </update>

对应Service:

@Transactional public void batchApprove(List<Long> ids, String operator) { // 先查出这批单据,校验状态是否均为SUBMITTED List<Reimbursement> list = reimbursementDao.selectBatch(ids); for (Reimbursement r : list) { if (!"SUBMITTED".equals(r.getStatus())) { throw new BusinessException("单据" + r.getId() + "状态非待审核,无法批量批准"); } } reimbursementDao.batchApprove(ids, operator); // 执行更新 }

5.3 改造点3:Generator配置文件禁用Example类,改用Query对象

MBG生成的ReimbursementExample类用Criteria拼条件,代码臃肿且难调试。财务查询需复杂条件(如“本月已支付且费用类型为交通费”),应改用自定义Query对象:

// 手工创建 Query/ReimbursementQuery.java public class ReimbursementQuery { private String status; private String expenseType; private String startDate; // YYYY-MM-DD private String endDate; private Integer pageNum = 1; private Integer pageSize = 10; // getter/setter... }

Mapper接口改为:

// 手工修改 ReimbursementDao.java public interface ReimbursementDao { List<Reimbursement> selectByQuery(ReimbursementQuery query); int countByQuery(ReimbursementQuery query); }

对应Mapper XML:

<select id="selectByQuery" resultType="Reimbursement"> SELECT * FROM reimbursement WHERE 1=1 <if test="status != null and status != ''"> AND status = #{status} </if> <if test="expenseType != null and expenseType != ''"> AND expense_type_id = #{expenseType} </if> <if test="startDate != null and startDate != ''"> AND apply_date >= #{startDate} </if> <if test="endDate != null and endDate != ''"> AND apply_date <= #{endDate} </if> ORDER BY apply_date DESC </select>

为什么值得做:Query对象可被SpringMVC自动绑定(@RequestBody ReimbursementQuery query),前端传JSON即可,无需前端拼接example.createCriteria().andStatusEqualTo("APPROVED")这种反人类代码;且IDE能提示字段名,减少拼写错误。


6. 最后一道防线:用JUnit+H2内存数据库做报销流程的端到端测试

写完代码不等于能用,尤其财务逻辑。我坚持给每个报销核心流程写JUnit测试,用H2内存数据库隔离测试环境,既快又准。这不是“为了测试而测试”,而是给报销单状态流转、预算校验、凭证生成这三道关卡装上“后悔药”。

6.1 搭建H2测试环境:零配置,启动即用

在pom.xml中添加H2依赖(仅test scope):

<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>2.2.224</version> <scope>test</scope> </dependency>

创建测试配置src/test/resources/test-applicationContext.xml:

<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <!-- H2内存数据库 --> <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="org.h2.Driver"/> <property name="url" value="jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"/> <property name="username" value="sa"/> <property name="password" value=""/> </bean> <!-- 初始化H2表结构 --> <bean id="h2SchemaInitializer" class="org.springframework.jdbc.datasource.init.DataSourceInitializer"> <property name="dataSource" ref="dataSource"/> <property name="databasePopulator"> <bean class="org.springframework.jdbc.datasource.init.ResourceDatabasePopulator"> <property name="scripts"> <list> <value>classpath:sql/h2-schema.sql</value> <!-- 建表SQL --> <value>classpath:sql/h2-data.sql</value> <!-- 初始数据 --> </list> </property> </bean> </property> </bean> </beans>

关键点:DB_CLOSE_DELAY=-1让H2在JVM退出前不关闭,保证测试间数据可见;h2-schema.sql需从生产SQL脚本简化而来,只保留reimbursement、budget_account等核心表。

6.2 编写报销单全流程测试:从创建到支付

测试类ReimbursementFlowTest.java:

@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(locations = {"classpath:test-applicationContext.xml"}) public class ReimbursementFlowTest { @Autowired private ReimbursementService reimbursementService; @Autowired private BudgetAccountService budgetAccountService; @Test public void testFullReimbursementFlow() { // Step 1: 创建报销单(草稿) Reimbursement reimbursement = new Reimbursement(); reimbursement.setTitle("北京差旅报销"); reimbursement.setAmount(new BigDecimal("3200.00")); reimbursement.setExpenseTypeId(1L); // 交通费 reimbursement.setStatus("DRAFT"); reimbursement.setApplyDate(new Date()); // 当前日期 reimbursementService.create(reimbursement); // Step 2: 提交报销(状态变SUBMITTED) reimbursementService.submit(reimbursement.getId(), "employee001"); // Step 3: 审批通过(状态变APPROVED) reimbursementService.approve(reimbursement.getId(), "manager001"); // Step 4: 支付(状态变PAID,并生成凭证) reimbursementService.pay(reimbursement.getId(), "finance001"); // ✅ 断言:状态最终为PAID Reimbursement updated = reimbursementService.getById(reimbursement.getId()); assertEquals("PAID", updated.getStatus()); // ✅ 断言:预算已扣减 BudgetAccount account = budgetAccountService.getByExpenseType(1L); assertEquals(new BigDecimal("96800.00"), account.getAvailableAmount()); // 假设初始10万 // ✅ 断言:凭证已生成 List<Voucher> vouchers = voucherDao.selectByReimbursementId(reimbursement.getId()); assertFalse(vouchers.isEmpty()); assertEquals(new BigDecimal("3200.00"), vouchers.get(0).getAmount()); } }

为什么有效:这个测试覆盖了状态机、预算、凭证三大核心,且全部在内存中执行,1秒内完成。一旦某步失败(如approve()后状态仍是SUBMITTED),立刻定位到Service层bug,而非上线后让用户反馈。

6.3 用覆盖率报告揪出“伪覆盖”逻辑

很多人以为写了JUnit就万事大吉,但财务系统常有“伪覆盖”:测试跑了,但没测到分支。例如预算校验方法:

public boolean checkBudget(Long accountId, BigDecimal amount) { BudgetAccount account = budgetDao.selectById(accountId); if (account == null) return false; // 分支1 if (account.getAvailableAmount().compareTo(amount) < 0) return false; // 分支2 return true; // 分支3 }

若测试只覆盖account != null && available > amount,分支1和2永远不执行。用JaCoCo生成覆盖率报告:

<!-- pom.xml中添加 --> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.10</version> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <phase>test</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin>

运行mvn test jacoco:report后,打开target/site/jacoco/index.html,重点检查:

  • ReimbursementService.java中submit()方法的if (budgetCheckFailed)分支是否被覆盖;
  • ReimbursementDao.java中batchApprove()的SQL是否执行(JaCoCo能统计Mapper XML中<if>标签的覆盖率)。

我的习惯:财务核心类(Service/Dao)行覆盖率必须≥95%,分支覆盖率≥90%。低于此值,说明测试用例没打到边界条件,比如“预算为0时提交”、“审批人无权限时操作”等场景。我会立刻补测试,而不是放行。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询