ARTICLE DETAIL

资讯详情

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

SSM框架下的ERP财务报销管理系统设计与实践

SSM框架下的ERP财务报销管理系统设计与实践 简介基于SSM框架的Java ERP财务报销管理系统源码与数据库面向计算机相关专业毕设学生和Java学习者适合个人学习、课程设计或期末大作业。项目以Spring、Spring MVC、MyBatis三大框架整合为基础覆盖MySQL数据库设计、JDK与IDEA环境配置通过财务报销流程、用户权限管理、数据统计报表等模块帮助读者掌握企业级Web应用从持久层到前端展示的完整实现思路。压缩包共433个文件约5.44MB主要包含JS、JSP、Java、XML等代码文件CSS、PNG、GIF等资源用于页面与交互展示附带SQL脚本可直接初始化数据库另有项目说明和配置信息目录组织合理。该资源已有48人学习浏览内含项目源码、数据库脚本、软件工具与部署说明方便本地运行和二次开发适合需要系统实战练习的初中级Java开发者。1. 从一个报销单到一套 ERPSSM 框架到底帮你省掉了什么任何一个做过企业信息化的工程师都绕不开财务报销这个场景。员工填单、主管审批、财务复核、出纳付款看起来是四条流程真正落地时却牵扯到部门树、费用科目、预算控制、凭证生成、多级审批、单据状态机还有和总账对账的数据口径。如果从零手写光是把「报销单主表 明细行 审批记录 附件表」这套关系模型理清楚就要花掉不少时间再加上 SSMSpring Spring MVC MyBatis这种经典的 Java 企业级组合很多人第一反应是「老技术了没什么可看的」。但恰恰是这个组合在国产 ERP 二次开发、高校课程设计、中小公司内部系统里一直有真实需求它结构清晰、源码容易读懂、数据库设计直观特别适合做财务报销这类「业务流程驱动 权限敏感 报表要求高」的系统。这篇文章要聊的不是把 SSM 的 Hello World 再抄一遍而是从「基于 SSM 框架的 Java ERP 财务报销管理系统源码及数据库」这个标题里拆出一套能直接落到项目里的做法数据模型怎么设计、权限怎么和报销流程绑定、金额计算和状态流转的坑在哪里、以及拿到一套源码后怎么快速改成自己公司能用的系统。无论你是要做数据库课程设计还是接手了一个 ERP 二次开发任务下面这套思路都能让你少踩几个隐形的地雷。2. SSM 在财务报销场景里的角色划分与三层架构落地2.1 为什么财务报销系统适合用 SSM 而不是 Spring Boot 单体先回答一个很多人会问的问题现在新项目都上 Spring Boot 了SSM 还有没有价值如果你的目标是快速搭建一个原型Spring Boot 确实省事但如果你的目标是理解 ERP 的分层逻辑、方便二次开发、或者你手头已经有一套 SSM 的源码要维护那 Spring Boot 的自动配置反而会让你看不清「谁在管事务、谁在管 SQL、谁在管权限」。SSM 的好处是每一层都裸露在外Spring 管 Bean 和事务Spring MVC 管请求分发MyBatis 管 SQL 映射。财务报销系统恰恰需要这种「透明感」因为金额相关的逻辑必须能一行行追踪到数据库字段不能靠框架的黑盒魔法。从 ERP 的角度看报销模块只是整个系统里的一环它要跟部门、员工、会计科目、付款记录产生关联。SSM 的分层结构天然适合把这种关联拆成独立的 Service 和 Mapper而不是把业务逻辑堆在 Controller 里。我在做这类项目时通常遵循这样的分包原则com.company.erp ├── controller # 接收 HTTP 请求做参数校验 ├── service # 业务逻辑审批流、金额计算、凭证生成 ├── dao # MyBatis 的 Mapper 接口 ├── entity # 数据库实体对应的 POJO ├── dto # 前端交互的数据传输对象 └── utils # 日期、金额、权限等工具类这里有一个容易被忽略的细节实体类entity和 DTO 不要混用。报销单列表页需要显示「申请人姓名 部门名 报销类型」但报销单表里只存了 user_id 和 expense_type_id如果直接用 entity 去接收联表查询结果要么在实体里加冗余字段要么把 MyBatis 的返回类型设成 Map这两种做法在后续维护时都会让人头疼。我一般会为列表页单独建一个 ReimburseVO字段和页面展示一一对应这样 SQL 的关联查询结果就能直接映射到视图对象上。2.2 Spring 事务边界报销单保存与金额明细的一致性财务系统的第一原则是要么全部成功要么全部失败。一张报销单包含主表报销单号、申请人、总金额、状态和明细表费用类型、金额、发票号、备注如果主表插入了但明细失败就会出现一张「没有内容」的空单这在财务上是不可接受的。SSM 里控制这个行为的核心是 Spring 的事务注解通常在 Service 实现类的方法上声明。Service public class ReimburseServiceImpl implements ReimburseService { Autowired private ReimburseMapper reimburseMapper; Autowired private ReimburseDetailMapper detailMapper; Override Transactional(rollbackFor Exception.class) public int saveReimburse(ReimburseVO vo) { // 1. 生成报销单号例如 REB yyyyMMdd 流水号 String reimburseNo generateReimburseNo(); Reimburse main new Reimburse(); main.setReimburseNo(reimburseNo); main.setApplicantId(vo.getApplicantId()); main.setTotalAmount(vo.getTotalAmount()); main.setStatus(0); // 0 草稿, 1 审批中, 2 通过, 3 驳回 reimburseMapper.insert(main); // 2. 插入明细行校验明细金额合计是否等于主表金额 BigDecimal sum BigDecimal.ZERO; for (ReimburseDetail detail : vo.getDetails()) { detail.setReimburseId(main.getId()); detailMapper.insert(detail); sum sum.add(detail.getAmount()); } if (sum.compareTo(vo.getTotalAmount()) ! 0) { throw new RuntimeException(明细金额合计与总金额不一致); } return main.getId(); } }这段代码里有几个关键点值得注意。第一rollbackFor Exception.class必须写因为 Spring 默认只回滚 RuntimeException如果金额不一致时抛的是 Exception事务不会回滚数据库里会留下脏数据。第二金额计算用 BigDecimal 而不是 double这是财务系统的基本常识double 的浮点误差在累计报销金额时会被放大。第三先插入明细后校验总和这里有一个性能上的考量如果先校验再插入需要两次遍历集合先插入再抛异常回滚在数据量不大时更简洁但如果明细达到几百条建议先做内存校验再落库避免无意义的事务回滚。事务的粒度也需要根据业务调整。比如审批通过时要同时更新报销单状态、写入审批记录、锁定预算占用这三件事是同一个事务。但如果是驳回操作可能需要额外给申请人生成一条通知记录通知失败不应该导致驳回失败所以通知逻辑要放在事务外面或者用事务同步器处理。这个边界在 ERP 系统里经常被搞混最常见的错误是把所有操作都塞进一个事务结果一个非核心的附件上传失败把整个审批状态回滚了。2.3 MyBatis 的动态 SQL报销查询的条件组合与分页报销管理页面几乎都有查询条件按申请人、按部门、按时间范围、按金额区间、按状态。如果为每个组合写一条 SQL那会写出几十条几乎一样的语句。MyBatis 的动态 SQL 是解决这个问题的标准方式where标签可以自动去掉多余的 AND 或 ORif控制条件是否拼入语句。select idselectReimbursePage resultTypemap SELECT r.id, r.reimburse_no, u.real_name AS applicantName, d.dept_name AS deptName, r.total_amount, r.status, r.create_time FROM reimburse r LEFT JOIN sys_user u ON r.applicant_id u.id LEFT JOIN sys_dept d ON u.dept_id d.id where if testapplicantName ! null and applicantName ! AND u.real_name LIKE CONCAT(%, #{applicantName}, %) /if if teststatus ! null AND r.status #{status} /if if teststartTime ! null AND r.create_time gt; #{startTime} /if if testendTime ! null AND r.create_time lt; #{endTime} /if /where ORDER BY r.create_time DESC LIMIT #{offset}, #{pageSize} /select这里的 LEFT JOIN 是有意为之报销单必须显示出来即使某个用户的部门已经被删掉也不能让历史单据查不到。如果换成 INNER JOIN部门被删除后这条报销记录会直接消失这在财务审计里就是严重事故。另外注意时间比较用了gt;和lt;因为在 XML 里必须转义这是一个非常容易踩的 MyBatis 坑编译器不会报错但 SQL 解析会失败。分页参数 offset 和 pageSize 由前端传入但这种手写 LIMIT 的方式在数据量超过十万条时会出现性能问题。更常见的做法是使用 PageHelper 插件在查询前调用PageHelper.startPage(pageNum, pageSize)MyBatis 会自动拦截 SQL 生成 COUNT 查询和分页 LIMIT。不过需要注意PageHelper 是线程局部变量紧跟其后必须只能有一个 select 查询如果中间插入了其他查询分页会作用到错误的 SQL 上这是 PageHelper 使用中频率最高的误操作。3. 数据库设计报销、审批、预算三张核心表的建模与关联3.1 主表、明细表、审批记录表的关系模型财务报销管理系统的数据库设计是整个项目的地基。如果把 Controller 和 Service 写得再好表结构设计不合理后期改起来就是伤筋动骨。基于 ERP 的通用做法我会把报销相关的表拆成四张报销主表reimburse、报销明细表reimburse_detail、审批记录表approval_record、费用类型表expense_type。主表与明细表是一对多主表与审批记录表是一对多费用类型表被明细表引用。下面是一个可以直接套用的建表 SQL 核心片段CREATE TABLE reimburse ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, reimburse_no varchar(32) NOT NULL COMMENT 报销单号, applicant_id bigint NOT NULL COMMENT 申请人ID, dept_id bigint 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_time datetime NOT NULL COMMENT 申请时间, finish_time datetime DEFAULT NULL COMMENT 完成时间, remark varchar(500) DEFAULT NULL COMMENT 备注, create_by bigint DEFAULT NULL, update_by bigint DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_reimburse_no (reimburse_no), KEY idx_applicant (applicant_id), KEY idx_status (status), KEY idx_apply_time (apply_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT报销主表; CREATE TABLE reimburse_detail ( id bigint NOT NULL AUTO_INCREMENT, reimburse_id bigint NOT NULL COMMENT 报销主表ID, expense_type_id bigint NOT NULL COMMENT 费用类型ID, amount decimal(12,2) NOT NULL COMMENT 本行金额, invoice_no varchar(64) DEFAULT NULL COMMENT 发票号, occur_date date DEFAULT NULL COMMENT 费用发生日期, description varchar(255) DEFAULT NULL COMMENT 费用说明, PRIMARY KEY (id), KEY idx_reimburse_id (reimburse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT报销明细表;主表里冗余了 dept_id这看上去违反了规范化的直觉因为通过 applicant_id 关联用户表就能找到部门。但实际上员工的部门可能会变动报销单一旦提交它所属的部门就应该被固定下来否则三个月后员工调岗财务统计部门费用时这张单子就算错部门了。这是 ERP 设计里典型的「快照」思维——主表字段保存的是业务发生那一刻的状态不是实时关联出来的状态。在报销单这个场景快照字段包括dept_id、dept_name、total_amount、apply_time。这些字段在单据提交之后不允许随基础资料变化只有单据自身的信息如状态、审批时间可以修改。审批记录表则负责记录流程的历史轨迹CREATE TABLE approval_record ( id bigint NOT NULL AUTO_INCREMENT, reimburse_id bigint NOT NULL, approver_id bigint NOT NULL COMMENT 审批人ID, approval_level tinyint NOT NULL COMMENT 审批层级1一级审批 2二级审批, approval_result tinyint NOT NULL COMMENT 结果1通过 2驳回, approval_comment varchar(500) DEFAULT NULL COMMENT 审批意见, approval_time datetime NOT NULL COMMENT 审批时间, PRIMARY KEY (id), KEY idx_reimburse (reimburse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT审批记录表;这张表的核心价值在于可追溯。财务审计时需要知道每一步是谁在什么时间做了什么决定、留了什么批注。不要试图在 reimburse 主表里用多个字段记录审批人比如 approver1、approver2、approver3这种设计一旦遇到流程变更从二级审批变成三级、或者加一个财务复核就要改表结构而独立审批记录表可以灵活适配任意层级的流程。3.2 报销单号生成策略日期 流水号的并发安全写法报销单号在 ERP 里不仅是标识还是审计对账的线索。常见的格式是 REB 年月日 四位流水例如 REB202501150012。这个需求看着简单写起来却有并发问题如果两个用户同时提交报销单都查到当前最大流水号是 11那么都会生成 0012主键不冲突因为主键是自增 ID但业务单号重复后续对账就会出问题。解决办法有两种一种是在 reimburse 表上给 reimburse_no 建唯一索引插入时靠数据库报错来阻止重复另一种是使用独立的流水号表通过SELECT ... FOR UPDATE锁行来保证原子性。我倾向于第二种因为第一种在插入失败时会产生一个被回滚的自增 ID 空洞对业务没有影响但会打乱单号连续性。流水号表的设计如下CREATE TABLE sys_sequence ( seq_name varchar(50) NOT NULL COMMENT 序列名称, current_value bigint NOT NULL DEFAULT 0 COMMENT 当前值, updated_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (seq_name) ) ENGINEInnoDB COMMENT序号生成表;对应的生成逻辑在 Service 层public String generateReimburseNo() { String today LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); // 开启事务后锁住该序列行避免并发拿到相同编号 sequenceMapper.lockSequence(reimburse_no); Long current sequenceMapper.selectValue(reimburse_no); Long next current 1; sequenceMapper.updateValue(reimburse_no, next); String sequence String.format(%04d, next); return REB today sequence; }这里的关键是lockSequence在 MyBatis 里对应的是SELECT current_value FROM sys_sequence WHERE seq_name #{name} FOR UPDATE然后紧跟更新整个操作放在一个事务里。这样并发请求会排队等待行锁生成的单号不会重复。注意这里不能使用select identity或 MyBatis 的useGeneratedKeys因为那些只对主键自增有效和业务单号无关。另外如果系统部署在集群环境多个应用实例共享同一个数据库这种锁策略依然有效因为锁是数据库层面的。如果后续量再大可以换成 Redis 的 INCR 命令但要额外处理 Redis 持久化和数据恢复的问题初期用数据库锁最稳妥。3.3 预算占用与释放报销审批通过时如何锁定成本中心的额度ERP 里的报销不只是「把钱付出去」还涉及成本控制。很多企业要求每个部门的差旅费、招待费有月度预算提交报销时不能超过剩余额度审批通过后预算被占用驳回或撤销时要释放额度。这个逻辑用数据库实现要特别小心因为它是典型的「先查再写」竞态问题。假设部门月预算总额是 50,000当前已用 30,000员工提交一张 25,000 的报销单单独看 25,000 没有超过 50,000但加上已用的 30,000 就超了。如果用「先查已用再判断再插入」的顺序并发情况下两次请求可能同时读到 30,000都判断 25,000 不超限结果实际占用变成 80,000。正确的做法是把「查询已用 更新占用」合并成一个原子 SQLUPDATE dept_month_budget b SET b.used_amount b.used_amount #{amount} WHERE b.dept_id #{deptId} AND b.month #{month} AND b.used_amount #{amount} lt; b.total_amount这个 Update 的返回值是关键如果返回 1说明额度充足并且占用成功如果返回 0说明现有额度不足事务回滚报销单不能提交。这种方式不需要显式加锁数据库的行锁和行级判断保证了对同一行数据并发更新的互斥。如果更新成功但后续明细插入失败整体事务回滚used_amount 也会自动回滚不会出现额度被占但没有报销单的问题。预算释放则是在审批驳回或单据作废时执行反向操作used_amount used_amount - #{amount}同样放在同一事务中。这里有一个常被忽略的点预算控制要在「提交审批」时执行而不是在「创建草稿」时执行。草稿可以反复修改不应该占用预算只有正式提交时才锁定评估。4. 从零跑通 SSM Maven Tomcat 的报销系统最小可运行版本4.1 项目骨架与依赖版本搭配这里给出一个不需要 IDE 图形界面也能跑起来的搭建路径。首先准备 JDK 8、Maven 3.6 以上、Tomcat 8.5 或 9。SSM 的依赖组合非常讲究版本一致性Spring 用 5.1.20.RELEASE最后一个修复大量 CVE 的 5.1 版本MyBatis 用 3.5.5MyBatis-Spring 用 2.0.5这样搭配在 JDK 8 和 Tomcat 8.5 上经过大量项目验证。使用 Spring Boot 时版本由 parent 管理但 SSM 需要手动控制直接把下面这段依赖放到 pom.xml 里properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target spring.version5.1.20.RELEASE/spring.version /properties dependencies !-- Spring 核心context 会连带引入 spring-core, spring-beans, spring-aop -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency !-- Spring MVC -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- Spring 事务管理DataSourceTransactionManager 的载体 -- dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency !-- MyBatis 核心与 Spring 集成 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.5/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.5/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency !-- Servlet APITomcat 提供的这里设置 provided 避免冲突 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- 数据库连接池druid 同时提供监控能力适合 ERP 系统 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.1.23/version /dependency !-- JSON 序列化ResponseBody 返回对象时使用 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.9.10/version /dependency /dependencies版本搭配上有一个容易踩的坑mysema-connector-java 不要用 8.x 版本因为 8.x 的驱动类名是com.mysql.cj.jdbc.Driver并且要求显式设置 serverTimezone否则连接时会报时区错误。如果你沿用 5.x 的配置模板可以在 8.x 下加上serverTimezoneAsia/Shanghai解决但我建议直接用 5.1.49省去这些不必要的兼容性问题。另一个坑是 spring-webmvc 5.x 和 Jackson 版本如果低于 2.9ResponseBody 在返回 LocalDateTime 类型时会直接报序列化错。为了省事接口层放 Date 类型避免配置 JavaTimeModule 的麻烦。4.2 web.xml、Spring 根容器、Spring MVC 子容器的三层初始化SSM 是三个框架的整合很多人第一次搭建失败就失败在容器的初始化顺序上。整个机制是这样的Tomcat 启动时先加载web.xml里面配置了两个监听器或 Servlet。ContextLoaderListener负责创建 Spring 的根容器装载 Service、Dao、数据源、事务DispatcherServlet负责创建 Spring MVC 的子容器装载 Controller、拦截器、视图解析器。子容器可以访问父容器的 Bean但父容器不能访问子容器的 Bean所以 Service 不能注入 Controller 之外的 Controller而 Controller 可以注入 Service。如果配置反了会出现频繁的NoSuchBeanDefinitionException。web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 !-- 加载 Spring 根容器 -- context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-context.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener !-- 加载 Spring MVC 子容器 -- servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/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 /web-app注意DispatcherServlet的url-pattern//url-pattern会拦截所有请求包括静态资源所以 spring-mvc.xml 里必须配置mvc:resources mapping/static/** location/static//否则 CSS 和 JS 都会 404。另外contextConfigLocation的路径是 classpath 下的 spring 目录我习惯把 spring-context.xml 和 spring-mvc.xml 分开显得更清晰如果你喜欢一个文件也行但根容器会把 Controller 也扫进 Service 容器导致 AOP 代理混乱所以还是分开扫包更稳。在 spring-context.xml 里需要配置component-scan排除 Controller配置数据源和SqlSessionFactoryBean再配置MapperScannerConfigurer扫描 Dao 接口。关键片段如下context:component-scan base-packagecom.company.erp context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/erp_reimburse?useUnicodetrueamp;characterEncodingutf8mb4/ property nameusername valueroot/ property namepassword valueyour_password/ property nameinitialSize value5/ property nameminIdle value5/ property namemaxActive value50/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ !-- 指定 XML 映射文件的位置 -- property namemapperLocations valueclasspath:mapper/*.xml/ !-- 实体类起别名 -- property nametypeAliasesPackage valuecom.company.erp.entity/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.company.erp.dao/ property namesqlSessionFactoryBeanName valuesqlSessionFactory/ /beanDruid 连接池的参数里initialSize 是启动时建立的连接数maxActive 是最大连接数maxWait 建议设置成 10000毫秒当连接池耗尽时等待 10 秒就报错而不是无限等待导致请求堆积。这里有一个很常见的隐藏问题Druid 在 WallFilter 的默认配置下会拦截SELECT ... FOR UPDATE吗不会拦截但如果你开启了 SQL 防火墙的selectAllow为 false 时测试就会失败排查时先看 Druid 的监控页面http://localhost:8080/druid/index.html就能看到被拦截的 SQL 明细。spring-mvc.xml 里要开启注解驱动和组件扫描context:component-scan base-packagecom.company.erp.controller/ mvc:annotation-driven/ bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean内网 ERP 系统用 JSP 做渲染已经足够但现在更多是前后端分离后端返回 JSON。实际上 JSON 接口条件下InternalResourceViewResolver可以不用配ResponseBodyMappingJackson2HttpMessageConverter就能搞定。需要注意mvc:annotation-driven会在 5.x 版本里自动注册默认的 Jackson 转换器但如果你的 Controller 方法返回的是 String 类型又设置了produces text/html;charsetUTF-8中文会有编码问题统一用 JSON 返回更省心。4.3 手写 Controller Service Mapper 完成一次报销单提交的完整链络把最小核心链路串一遍从浏览器提交表单到数据库落库每一步都对应到 SSM 的一个环节。首先写 Mapper 接口和 XML接口方法名必须和 XML 里的 id 精确匹配这是 MyBatis 的硬性规则。public interface ReimburseMapper { int insert(Reimburse reimburse); Reimburse selectById(Long id); ListReimburseVO selectPage(Param(applicantName) String applicantName, Param(status) Integer status, Param(offset) int offset, Param(pageSize) int pageSize); }对应的 XML 中的insert要设置useGeneratedKeys和keyProperty才能把数据库生成的自增主键回填到 Reimburse 对象的 id 字段上insert idinsert parameterTypecom.company.erp.entity.Reimburse useGeneratedKeystrue keyPropertyid INSERT INTO reimburse ( reimburse_no, applicant_id, dept_id, total_amount, status, apply_time, remark ) VALUES ( #{reimburseNo}, #{applicantId}, #{deptId}, #{totalAmount}, #{status}, #{applyTime}, #{remark} ) /insertController 负责接收 JSON 请求并调用 ServiceRestController RequestMapping(/api/reimburse) public class ReimburseController { Autowired private ReimburseService reimburseService; PostMapping(/submit) public Result submit(RequestBody ReimburseSubmitDTO dto) { ReimburseVO vo new ReimburseVO(); BeanUtils.copyProperties(dto, vo); Long id reimburseService.saveReimburse(vo); return Result.success(id); } }RestController是 Spring 4 以后的便捷注解等于ControllerResponseBody接口的返回值会通过消息转换器序列化成 JSON。这里我用了RequestBody接收 JSON 而不是表单参数因为报销单明细是一个数组结构表单变量难以表达。这种设计在 ERP 系统里越来越主流好处是前端可以用 Vue 的 axios、Element UI 的 table 直接提交整个对象不用手动拼接多个同名参数。对应的 Service 实现我们在前面已经写过了里面包含了事务和金额校验。这里有一个新手容易犯的错RequestBody接收的对象 DTO如果里面的金额字段类型是 String而数据库是 decimal序列化时 Jackson 会把字符串转成 BigDecimal 吗需要配置DeserializationFeature.ACCEPT_FLOAT_AS_INT等特性但更简单的做法是 DTO 里直接声明成 BigDecimal前端提交数字即可。如果要兼容前端传字符串金额可以在 DTO 的 setter 里做一次转换避免在 Controller 到处写 try-catch。4.4 调试数据准备初始化 SQL 与测试账号为了让系统跑起来就能看到效果数据库脚本里至少要包含以下基础数据部门表财务部、技术部、市场部、用户表每个部门至少两个用户模拟申请人、审批人、费用类型表差旅费、餐饮费、办公用品、一个默认的预算额度以及一个拥有不同角色的测试账号。下面是初始化片段INSERT INTO sys_dept (id, dept_name, parent_id) VALUES (1, 技术部, 0), (2, 财务部, 0), (3, 市场部, 0); INSERT INTO sys_user (id, username, password, real_name, dept_id, role_code) VALUES (1, zhangsan, MD5(123456), 张三, 1, EMPLOYEE), (2, lisi, MD5(123456), 李四, 1, DEPT_MANAGER), (3, wangwu, MD5(123456), 王五, 2, FINANCE); INSERT INTO expense_type (id, type_name, budget_flag) VALUES (1, 差旅费, 1), (2, 餐饮费, 1), (3, 办公用品, 0);测试时用张三登录提交报销张三属于技术部一级审批人是该部门经理李四。这里 role_code 只是最简单的角色标识真正做权限控制时要结合 Spring Security 或 Shiro放到下一章细说。5. 权限模型与审批流的四种常见实现以及报表统计的联动5.1 基于 RBAC 数据权限的 SSM 权限控制ERP 系统的权限不只是「谁能访问哪些菜单」更重要的是「谁能看哪些数据」。技术部的李四可以审批技术部所有人的报销单但不能看到财务部的单子财务部的王五可以看全部单据但不能审批非自己的报销。这种需求用 RBAC基于角色的访问控制加上数据权限范围判断是最标准的解决方案。权限的表模型通常有五张表用户表、角色表、菜单/权限表、用户角色关联表、角色菜单关联表。Spring MVC 侧实现时可以写一个拦截器HandlerInterceptor做登录校验和角色校验也可以直接集成 Apache Shiro。SSM 项目里我更推荐 Shiro因为它和 Spring 的整合比较轻量使用注解就能控制接口权限。RestController RequestMapping(/api/approval) public class ApprovalController { GetMapping(/todo) RequiresRoles(DEPT_MANAGER) public Result getTodoList() { // 当前登录用户在 ThreadLocal 中由 Shiro filter 设置 Integer currentDeptId UserContext.getDeptId(); ListReimburseVO list approvalService.selectTodoList(currentDeptId); return Result.success(list); } }这里的RequiresRoles(DEPT_MANAGER)是 Shiro 的注解作用在 Controller 方法上。但它只做角色校验数据范围还要靠业务 SQL 控制。selectTodoList(currentDeptId)在 Mapper 里会拼接AND r.dept_id #{deptId}这样李四只能查到本部门的待审批单。数据权限放在 SQL 层做而不是在 Java 内存里过滤是为了防止分页时数据错乱和性能问题。如果你用 MyBatis 拦截器也可以实现自动数据权限过滤但需要非常小心 SQL 拼接的注入风险建议在初期直接显式传参可读性更强。Shiro 在 SSM 里的集成有几个默认行为的坑。第一个是注解需要配置AuthorizationAttributeSourceAdvisor才能生效否则RequiresRoles完全没作用。第二个是 Shiro 默认的 Session 存储是内存重启应用后用户登录态全部丢失ERP 系统建议把 Session 持久化到 Redis但需要额外引入 shiro-redis 依赖。第三个是密码加密初始化 SQL 里我用了 MD5实际生产环境至少用MD5 Salt或者在 Shiro 的CredentialsMatcher里自定义散列次数。数据库泄露后没有加盐的 MD5 等于明文这套系统如果接入真实财务数据密码加密方式必须升级成 PBKDF2 或 BCrypt。5.2 审批流的硬编码状态机与灵活配置的取舍财务报销审批流程在不同公司差别很大有的公司一个部门经理批完就完事有的要经过财务初审、部门经理审批、总经理审批三个阶段金额超过 5 万的还要多一层董事会审批。设计时不能不考虑可扩展性但也不能一上来就上工作流引擎如 Activiti那样会让 SSM 的源码变得异常复杂。常见的做法是用状态字段 层级记录实现「半硬编码」审批流把审批顺序配置放到流程配置表里。流程配置表approval_flow字段包括expense_type_id、amount_threshold、level、role_code、next_level。比如差旅费小于 5000 元走一级审批DEPT_MANAGER大于等于 5000 元走二级审批DEPT_MANAGER FINANCE。审批逻辑在 Service 层用一个简单的循环实现Transactional(rollbackFor Exception.class) public void approve(Long reimburseId, Integer currentLevel, boolean result, String comment) { Reimburse r reimburseMapper.selectById(reimburseId); // 当前待审批的层级 Integer waitingLevel r.getCurrentApprovalLevel(); if (!waitingLevel.equals(currentLevel)) { throw new BusinessException(当前层级不是该审批人的待办层级); } if (result) { // 查询下一层级的角色 ApprovalFlow nextFlow approvalFlowMapper.selectNextLevel( r.getExpenseTypeId(), r.getTotalAmount(), currentLevel); if (nextFlow null) { r.setStatus(2); // 审批通过 } else { r.setCurrentApprovalLevel(nextFlow.getLevel()); } } else { r.setStatus(3); // 驳回 } reimburseMapper.updateStatus(r); // 写入审批记录 insertApprovalRecord(reimburseId, currentLevel, result, comment); }这种设计的边界是金额阈值改变、审批层级改变时需要改数据库配置而不改代码但流程分支条件和流转逻辑本身还是写死在 if 里。如果公司未来引入「会签」「或签」「动态加签」这套方案就不够了需要升级成工作流引擎。作为 ERP 财务报销系统的起步阶段先做成配置驱动比直接堆流程引擎更可控因为流程引擎的学习和维护成本很高而且出了问题很难排查节点状态。5.3 报销金额按部门、费用类型的统计报表 SQLERP 系统的报表是管理层最关心的一环。报销系统至少要有两类统计按部门统计每月报销总额、按费用类型统计各类费用占比。下面的 SQL 是基于明细表聚合的写法SELECT d.dept_name, DATE_FORMAT(r.apply_time, %Y-%m) AS month, SUM(r.total_amount) AS total_amount FROM reimburse r LEFT JOIN sys_dept d ON r.dept_id d.id WHERE r.status 2 -- 只统计已审批通过的报销单 AND r.apply_time #{startTime} AND r.apply_time #{endTime} GROUP BY d.dept_name, DATE_FORMAT(r.apply_time, %Y-%m) ORDER BY month DESC, total_amount DESC注意这里统计的是主表的 total_amount而不是把明细表 group by 之后 join因为在「一张报销单多行明细」的场景下直接聚合明细表会重复计数。如果你要按费用类型统计应该 sum 明细表的 amount 并 group by expense_type_id。这里还有一个数据一致性校验主表 total_amount 必须等于该主表下所有明细 amount 的和最有效的校验方式是定期跑一条 SQL 对比SELECT r.id, r.reimburse_no, r.total_amount, SUM(d.amount) AS detail_sum FROM reimburse r LEFT JOIN reimburse_detail d ON r.id d.reimburse_id GROUP BY r.id, r.reimburse_no, r.total_amount HAVING r.total_amount ! detail_sum这条 SQL 跑出来的任何一条结果都说明数据有问题要么是插入明细时没有更新主表金额要么是历史发生了数据变更。在 ERP 系统部署上线前把这条校验 SQL 放进初始化脚本里是很有必要的。报表性能方面数据量超过 50 万条时建议按月分表或使用索引覆盖在 reimburse_detail 的(reimburse_id, expense_type_id)上建立联合索引避免回表扫描。6. 最后再往深走一步让一套 SSM 报销源码变成能扛住审计的财务系统6.1 操作日志与审计追踪记录谁在什么时间改了什么金额财务系统上线后审计人员会问一个问题这张单子的金额从 3500 改成 2800是谁在什么时候改的原值是什么如果只在数据库里存最新值这些问题根本无法回答。所以要在 reimburse_detail 表上增加金额变更日志表amount_change_log或者直接用通用字段记录操作前后快照。更轻量的做法是在主表加version字段用乐观锁控制并发更新每次修改时 version 加 1如果更新时 version 不匹配就拒绝防止两个审批人同时修改导致覆盖。6.2 用 xxl-job 做月末自动对账和未完成单提醒很多 ERP 系统的报表要等月末统一生成外加提醒长时间未审批的报销单。你可以在 SSM 项目里注册一个定时任务在每月 1 日凌晨扫描上月所有状态为「审批中」的单据发送待办提醒给对应审批人同时把已经完成的数据归档到年度历史表。Spring 的Scheduled注解可以做到单机定时如果未来部署了多实例要对定时任务加分布式锁如数据库select for update或者 Redis 锁避免同一个任务被多个节点重复执行。Component public class ReimburseScheduleTask { Autowired private ReimburseMapper reimburseMapper; Scheduled(cron 0 30 0 1 * ?) public void sendMonthlyReminder() { ListLong pendingIds reimburseMapper.selectPendingIds(); for (Long id : pendingIds) { // 生成站内信或者调用消息服务 notifyService.sendApprovalReminder(id); } } }Spring Task 的 cron 表达式和 Quartz 略有差别如果你照抄了 Quartz 的六位表达式会有编译不报错但运行不触发的现象所以0 30 0 1 * ?是标准 Spring 风格星期位用?表示不指定。这种月末批处理放在生活里看是「报表跑批」本质上就是用代码把财务月结的重复劳动自动化。6.3 拿到源码后的二次开发检查清单最后给你一份可以直接用的落地清单适用于你拿到任何一套 SSM 报销系统源码后上线前逐项排查检查所有select是否带LIMIT避免有人硬查全表检查所有金额字段是否用 BigDecimal 接受检查事务注解是否写清楚rollbackFor检查审批操作前是否有乐观锁或者状态校验检查报表 SQL 是否已过滤「草稿」和「已驳回」状态检查数据库连接串是否开启useSSLfalse、characterEncodingutf8mb4。把这些检查完这套系统基本能达到生产可用的底线。本文还有配套的精品资源点击获取
返回列表