ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SSM财务报销系统源码与数据库设计:从表结构到事务调优

SSM财务报销系统源码与数据库设计:从表结构到事务调优 简介基于SSM框架的Java ERP财务报销管理系统完整源码包主要面向计算机相关专业毕业生及Java学习者适用于毕业设计、课程设计或期末大作业等实战场景。资源围绕企业财务报销核心业务涵盖用户权限管理、报销流程处理、统计报表等功能模块可帮助学习者理解Spring、Spring MVC与MyBatis的协作机制并掌握MySQL数据库设计、SQL脚本编写、JDK与IDEA环境搭建等关键技能。压缩包共433个文件、大小5.44MB包含33个Java类、18个JSP页面、118个JS脚本、41个CSS样式、79个GIF图片以及SQL数据库脚本、XML配置、项目说明和所需工具目录结构清晰具备完整可运行的项目骨架。目前已有48人学习下载对于正在备战毕业设计或希望进阶Java Web开发的读者这份可直接部署运行的ERP项目既能提供完整业务逻辑参考也可作为二次开发与实战练习的起点具有不错的参考价值。1. 为什么ERP财务报销系统落地时大家都会倒回去看SSM源码在企业内部做的财务报销管理系统十套里有八套是从一套现成的SSM源码起步的。原因很简单报销流程涉及部门、预算、费用类型、审批链、付款状态表面看是增删改查实际是状态机与金额准确性叠加的业务系统。而SSM框架恰好把Spring的依赖管理、Spring MVC的请求路由、MyBatis的SQL控制拧成了一套上手成本极低的组合配合MySQL数据库脚本就能快速跑出可演示的原型。但对工作五年以上的工程师来说真正有价值的部分不是登录和菜单元件而是源码里那套报销单状态流转、科目与部门维度对照、审批历史表的沉淀方式以及数据库里那些容易被人忽略的索引和初始化数据。这部分人接收到的通常是一个残缺的工程包有的只有Java代码没有数据库脚本有的建库脚本缺少初始化流程数据有的报表查询SQL在MySQL 8下直接报错。所以要基于这套标题所谓“源码及数据库”在企业里把它接住并改成生产可用得从框架边界、数据库表设计、事务配置和账期切换四个方向重新顺一遍。这一章把该做的准备说明白后面直接进入能抄的代码和参数。2. 从SSM职责切分到财务报销单据的数据库模型2.1 SSM各个层在报销业务里到底管什么很多开发者在“SSM框架”里写代码时把业务逻辑塞进Controller或者把SQL拼在Service导致报销系统后期无法维护。标准的职责切分是Spring IoC负责组装Service和DataSourceSpring MVC只做参数接收和视图转发MyBatis通过Mapper接口管理每一条业务SQL。财务报销系统里最典型的表现是Controller层拿到的应该是当前登录用户和请求参数Service层负责审批状态检查和金额计算Mapper层绝不写业务判断只执行SQL并返回结果。层次在报销系统里的具体表现常见错误Spring管理报销Service、邮件通知Service、外部财务系统接口的Bean生命周期手动new对象导致事务失效Spring MVC接收报销单列表查询参数、文件上传、登录拦截在Controller里写金额计算逻辑MyBatis报销单主表、明细表、审批记录表的select/insert/update使用JDBC拼字符串或大量foreach把这三层职责切干净之后再来读源码里的类名和XML就能对得上。例如ReimbursementController只暴露list、save、submit方法真正的ReimbursementService维护状态位ReimbursementMapper.xml里只写按条件查询的SQL。这样定位问题时从访问路径出发就能找到对应Mapper语句而不是全项目搜一个方法名。2.2 报销单表结构主表、明细表、审批记录表下载到的SSM财务报销系统源码无论包名怎么改数据库核心表基本都是三张起步报销主表记录“谁报销、什么部门、总金额、当前状态”明细表记录每一笔费用比如差旅费、办公费、业务招待费规则是明细金额之和必须等于主表总金额审批记录表记录每一次审批动作包含审批人、操作类型、审批意见和时间。这三个表的关联关系是1:N: N主表状态变迁必须在Service层锁行之后更新。下面是精简后的建表SQ L本地跑通源码时可以直接执行。为避免外键检查顺序问题我先建主表再建明细表。CREATE TABLE reimburse_main ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主单ID, bill_no VARCHAR(32) NOT NULL COMMENT 报销单号格式BX日期序号, dept_id INT NOT NULL COMMENT 部门ID, applicant_id INT NOT NULL COMMENT 申请人ID, total_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 报销总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1审批中 2已通过 3已拒绝 4已付款, apply_date DATE NOT NULL COMMENT 申请日期, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_bill_no (bill_no), KEY idx_status_apply (status, apply_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT报销主表;主表建完后明细表和审批表要参照reimburse_main的id字段。需要注意status字段使用TINYINT而不是VARCHAR这和源码里ReimbursementStatusEnum的code对应避免字符串比较带来的隐式转换。总金额字段用DECIMAL(12,2)而不是DOUBLE财务系统绝不允许浮点金额。索引方面(status, apply_date)这个联合索引用来支撑最常用的审批列表页和月末统计查询。2.3 审批状态流转的表级约束和初始数据很多人在导入数据库脚本后直接访问“提交报销”接口发现500错误报错原因是找不到expense_type表的费用类型数据。财务报销系统里如果缺少费用类型、银行账户、预算科目这三类基础数据功能跑不起来。我一般会把这三张表写成独立脚本并插入现场解析的核心数据。INSERT INTO expense_type (id, type_code, type_name, status, limit_amount) VALUES (1, TRAFFIC, 市内交通费, 1, 2000.00), (2, BUSINESS, 业务招待费, 1, 5000.00), (3, OFFICE, 办公费, 1, 1000.00), (4, TRAVEL, 差旅费, 1, 8000.00);除了基础数据审批记录表需要做一条默认的“提交申请”记录用来关联MYBATIS审签日志查询否则reimburse_log表为空前端时间轴组件会解析失败。建议在初始化脚本的末尾插入当前最新业务月的期初数这就是热搜里“成本ERP数据没有跑通”多数起因期初余额表和实际总账对不上。所以建库顺序应当是expense_type→dept_org→sys_user→reimburse_main→reimburse_detail→reimburse_log。按这个顺序执行才不会出现外键找不到引用表的问题。3. 用源码和数据库还原可运行项目的三个关键步骤3.1 别直接复制别人的jdbc.properties连接池和时区参数SSM源码包里几乎都有一个jdbc.properties但网上流传的版本经常写死为jdbc:mysql://localhost:3306/erp?useUnicodetrue。这个写法在MySQL 8.0以上会报时区错误或者因为缺少allowMultiQueriestrue导致批量SQL无法执行。财务报销系统的数据库配置需要按下面的样本调整jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/erp_finance?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowMultiQueriestruenullCatalogMeansCurrenttrue jdbc.usernameerp_app jdbc.passwordEncrypted#2024 druid.initialSize5 druid.maxActive50 druid.maxWait60000 druid.validationQuerySELECT 1serverTimezoneAsia/Shanghai解决MySQL 8的时区报错allowMultiQueriestrue允许在MyBatis的mapper XML中写多条SQL比如初始化审批日志时一条脚本同时insert日志表并更新主表状态。这里的连接池使用DruidvalidationQuerySELECT 1用来确保从池里取出的连接是有效连接否则审批高峰期会出现“Connection is not available”的异常。注意nullCatalogMeansCurrent这个参数在MySQL 8下使用DatabaseMetaData.getTables查询时如果不设置它一些旧版Druid监控页面和代码生成器会读不到表结构。3.2 最小可复现的报销单分页查询代码要验证数据库和源码是否匹配最直接的方式是跑通分页查询。财务报销列表页是高频访问接口源码通常依赖PageHelper分页插件。以下三段代码是Controller、Service和Mapper的完整配合注意MyBatis的resultMap里必须单独处理DECIMAL字段。Controller RequestMapping(/api/reimburse) public class ReimbursementController { Autowired private ReimbursementService reimbursementService; RequestMapping(/list) ResponseBody public PageResult list(RequestParam(defaultValue 1) int pageNo, RequestParam(defaultValue 10) int pageSize, Integer status, String billNo) { PageHelper.startPage(pageNo, pageSize); ListReimburseMainVO list reimbursementService.queryList(status, billNo); return new PageResult(list, new PageInfo(list).getTotal()); } }Controller只做分页参数接收具体的单据列表查询交给Service。PageHelper.startPage之后第一次执行的MyBatis查询会自动拼接LIMIT如果这里不小心在Service里执行了两条查询第二条也会被分页这是常见问题。分页必须放在Service方法调用之前且Service方法内部只允许执行一条select。public ListReimburseMainVO queryList(Integer status, String billNo) { ReimburseQueryDTO dto new ReimburseQueryDTO(); dto.setStatus(status); dto.setBillNo(billNo); return reimburseMainMapper.selectByCondition(dto); }Mapper XML里对应的SQL要返回的字段包含total_amount并且用DATE_FORMAT格式化申请日期因为前端表格要求显示yyyy-MM-dd。select idselectByCondition resultTypecom.erp.finance.vo.ReimburseMainVO SELECT r.id, r.bill_no AS billNo, d.dept_name AS deptName, u.real_name AS applicantName, r.total_amount AS totalAmount, DATE_FORMAT(r.apply_date, %Y-%m-%d) AS applyDate, r.status FROM reimburse_main r LEFT JOIN sys_dept d ON r.dept_id d.id LEFT JOIN sys_user u ON r.applicant_id u.id where if teststatus ! null r.status #{status} /if if testbillNo ! null and billNo ! AND r.bill_no LIKE CONCAT(%, #{billNo}, %) /if /where ORDER BY r.create_time DESC /selectLEFT JOIN查出来的部门名和申请人姓名是为了避免前端拿到dept_id后二次查询系统用户表。这就是SSM项目里最常见的联表查询场景。注意使用where标签而不是手工写WHERE 11前者能自动去掉第一个AND这也是代码评审时经常被检查组提出的改进点。3.3 审批流状态的手工补偿SQL数据库脚本导入完成后挺多时候页面提交的报销单停留在“审批中”但reimburse_log表里却查不到审批记录。要排查这种问题最直接的方法是模拟一次状态变更并观察受影响的行数。下面这条SQL把单号为BX20240513001的单子从草稿状态改为审批中同时插入一条审批日志。START TRANSACTION; UPDATE reimburse_main SET status 1 WHERE bill_no BX20240513001 AND status 0; INSERT INTO reimburse_log (main_id, operator_id, action_type, action_result, remark) SELECT id, applicant_id, SUBMIT, PASS, 手工补偿提交动作 FROM reimburse_main WHERE bill_no BX20240513001; COMMIT;注意UPDATE语句带上了AND status 0作为乐观锁条件。如果更新的影响行数为0说明这张单已经不在草稿状态后边插入日志就是多余操作。这种手工补偿SQL的作用不是替代Service层代码而是用来验证数据库触发器、状态枚举和日志表之间逻辑是否贯通避免“界面能提交、数据库没变化”的现象。4. 跑通财务报销系统必调的3个参数和4条排错路径4.1 事务隔离级别报销金额重复计算的源头SSM项目里默认的事务隔离级别是MySQL的REPEATABLE_READ这在报销场景下会带来一个隐蔽问题当审批人同时处理两张报销单而这两张单都更新同一个部门预算表时后更新的一个事务会等待前一个事务释放锁超时之后就报Lock wait timeout exceeded。财务报销系统的核心事务是“提交报销单”这个动作需要同时更新主表状态、明细表关联和费用预算余额。如果在Service方法上加Transactional默认只控制单个数据源事务但连接池不够、持有锁时间过长就会卡死。我一般会把提交报销单的方法单独调整事务隔离级别为READ_COMMITTED并减少更新范围。配置在Spring XML的tx:method上tx:advice idtxAdvice transaction-managertransactionManager tx:attributes tx:method namesubmit* propagationREQUIRED isolationREAD_COMMITTED rollback-forException/ tx:method nameapprove* propagationREQUIRED rollback-forException/ tx:method namequery* propagationSUPPORTS read-onlytrue/ /tx:attributes /tx:advicesubmit*通配符匹配“提交报销单”相关方法isolationREAD_COMMITTED避免了在报销查询和更新的并发场景下出现不可重复读问题同时不会因为间隙锁造成大批量阻塞。query*方法设置成read-onlytrue后MyBatis执行SELECT时不会申请写锁这在月末对账访问高峰期收益明显。4.2 Sy早排查看这四类异常日志跑源码时最常出现四类问题按照发生频率排序分别和数据库配置、事务边界、SQL兼容性和组织机构数据相关。第一类异常是java.sql.SQLException: Unknown database这是建库脚本没有完整执行或者连接串里的库名和实际不一致。第二类是Invalid bound statement (not found)检查mybatis.mapper-locations是否配置到classpath*:mapper/**/*.xml。第三类是DataIntegrityViolationException通常是审批记录表的外键指向了不存在的main_id说明初始化数据没有严格按2.3节的顺序插入。第四类是OutOfRangeException原因是报销金额字段在Java侧使用了double而数据库是DECIMAL(10,2)传入精度过高时直接报出范围错误。当遇到“数据没有跑通”这类笼统反馈时先不要慌着改代码打开MySQL的慢查询日志看实际的SQL执行计划。SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 2;然后查询mysqld-slow.log重点观察报销列表页的selectByCondition是否走了全表扫描。如果typeALL且rows数量很大直接在reimburse_main的(status, apply_date)索引基础上加上bill_no的前缀索引。4.3 成本数据没跑通期初数、业务月和汇率表的三步核对“成本ERP数据没有跑通”这个热搜词放在报销系统里直接对应的是月末报表里报销金额和总账模块对不上的现象。第一步核对期初数执行审计对账SQL检查reimburse_main里状态为“已付款”的单子加总金额是否和总账科目的ba_account表中方向一致。第二步核对业务月很多源码默认apply_date取当前日期但导入历史数据后报表月份仍显示当月要检查Mapper里是否把DATE_FORMAT(apply_date,%Y-%m)当成分组条件。第三步核对汇率表如果报销单含外币金额而数据库里的exchange_rate表没有对应月份的汇率转换后的人民币金额会被误写成0报表自然跑不通。下面这条SQL可以一次性把报销模块和总账模块的差额查出来SELECT DATE_FORMAT(a.apply_date, %Y-%m) AS biz_month, SUM(a.total_amount) AS reimburse_total, IFNULL(b.total_debit, 0) AS gl_total, SUM(a.total_amount) - IFNULL(b.total_debit, 0) AS diff_amount FROM reimburse_main a LEFT JOIN ( SELECT biz_month, SUM(debit_amount) AS total_debit FROM gl_voucher_detail WHERE account_code LIKE 6602% GROUP BY biz_month ) b ON DATE_FORMAT(a.apply_date, %Y-%m) b.biz_month WHERE a.status 4 GROUP BY biz_month, b.total_debit HAVING diff_amount 0;LEFT JOIN左侧是报销已付款单右侧是总账6开头的费用科目。这样写能直观看到哪个月有差异而不是靠人工拉Excel比对。如果查询结果里gl_total是NULL说明总账模块还没有生成对应月份的凭证如果结果是0说明报销金额没有被正确抛转成凭证。5. 在SSM报销源码上增加审计日志和账期追溯5.1 用MyBatis拦截器统一记录敏感字段变更财务报销系统的审计日志和普通操作日志不一样需要记录修改前和修改后的值特别是金额、收款账户、审批状态。手动在每个Service方法里写日志容易漏我用MyBatis Interceptor的方式统一处理拦截所有update操作把主表变更前后的快照写入audit_log表。下面是一个精简的拦截器核心逻辑Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class AuditInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; // 只拦截报销主表和明细表 if (ms.getId().contains(Reimburse)) { saveAuditRecord(ms, parameter); } return invocation.proceed(); } }在Spring配置里注册这个Bean时要注意必须设置sqlSessionFactory的插件属性不能和PageHelper的拦截器顺序颠倒。其他拦截器如PageInterceptor放在前面审计拦截器放在后面避免分页拦截器修改了MappedStatement之后导致SQL重复执行。这样即使后来人直接改数据库只要走MyBatis的Mapper都会有审计记录。5.2 财务结账后的备份脚本报销系统每个月关账之后需要把当月数据独立备份避免后续误操作污染历史账期。在Linux环境下直接写一个保留最近三个月备份的脚本放到crontab里。#!/bin/bash MONTH$(date %Y%m) mysqldump -u erp_backup -ppassword --single-transaction --set-gtid-purgedOFF \ erp_finance reimburse_main reimburse_detail reimburse_log \ --whereDATE_FORMAT(apply_date, %Y%m) $MONTH /backup/erp_finance_$MONTH.sql find /backup/ -name erp_finance_*.sql -mtime 90 -exec rm -f {} \;--single-transaction参数对InnoDB表做一致性快照不会锁住正在使用的报销单--set-gtid-purgedOFF是MySQL 8下避免恢复时报GTID冲突的关键。备份文件名带上月份恢复时直接source这个SQL文件就能把特定业务月的数据单独拉出来核对。这样生产环境即使有脏数据也能快速回滚到开账前状态。5.3 财务审核通过的凭证模板扩展点最后留一个生产里常用的追加方案报销单审核通过后需要自动生成记账凭证。SSM源码通常没有这个能力可以在reimburse_main表边上增加一张voucher_template表配置报销类型和借贷科目的映射关系再在ApproveService审核通过后调用一个独立方法用模板表生成凭证草稿。这种方式不改动原审批流程又能让财务看到每一笔报销对应的科目方向。注意凭证生成的接口要单独事务并通过消息表记录业务主键和凭证号避免重发时生成重复凭证。以后接数字化财务平台时这种“报销单凭证模板”的结构可以直接开放成标准API而不用重构底层报销单表。本文还有配套的精品资源点击获取
返回列表