简介:这套基于 Java 开发的开源 ERP 系统源码,面向 Java Web 学习者、企业信息化开发者以及需要参考 ERP 实现思路的技术人员,可用于了解企业管理系统从界面、配置到业务逻辑的典型组织方式。压缩包为 rar 格式,共 1444 个文件、约 2.16MB,其中 Java 源文件 1361 个,是阅读业务实现的主体;另有 XML 配置、HTM 页面、properties 配置及少量 txt 说明,能较完整地呈现源码、配置与界面资源的分层关系。已有 4004 人学习,说明其在 Java 开源项目参考场景中具有一定关注度。读者可重点借鉴目录结构、实体与业务代码写法、配置文件组织以及表单重复提交错误提示等 Web 细节,适合作为课程设计、毕业设计或二次开发前的参考底稿,也可用于梳理 ERP 类系统的通用实现脉络,按模块筛选关键代码后更易形成整体认识。
1. 开箱之前先想清楚:这套 ERP 开源 Java 系统能接住什么活
接手一套 ERP 开源 Java 系统,第一件事往往不是看代码行数,而是判断它能接住多少真实业务。很多团队选型时盯着功能清单,上线三个月后才发现库存对不上、单据状态回滚、权限串了部门。这类问题不是功能多少能回答的,得看代码里单据流转、库存扣减和数据隔离怎么写的。这套 Java 版开源 ERP 适合有二次开发能力的中小团队:采购、销售、库存、财务主链路齐全,技术栈是常见的 Spring Boot + MyBatis + MySQL + Redis,前后端分离。不适合想开箱即用、零代码配置的人。下面的内容按跑通、改核心链路、避坑三段拆,每一步都落到能复现的命令和参数上。
2. 架构与选型:Spring Boot + MyBatis 这套组合为什么适合改
2.1 模块拆分与依赖方向
ERP 的代码量通常集中在单据和库存,模块拆分如果做不好,改一个采购单会牵动销售和财务。这套系统的常见做法是按业务域拆模块:基础数据(物料、供应商、客户、仓库)、采购、销售、库存、财务、系统管理。每个模块一个 Maven 子模块,依赖方向单向:业务模块依赖基础数据模块,基础数据模块不反向依赖业务模块。
为什么强调单向依赖?因为 ERP 里基础数据会被所有业务引用,一旦基础模块反向依赖采购模块,编译期就会形成环,改一个字段要全量重编,时间一长没人敢动。判断依赖方向是否干净,最直接的办法是看 Maven 依赖树。
# 在项目根目录执行,输出依赖树并过滤业务模块之间的引用 mvn dependency:tree -Dincludes=com.example.erp:* -DoutputFile=dep-tree.txt这段命令把项目内模块之间的依赖关系单独导到文件里。-Dincludes的参数按你实际的 groupId 改,常见是公司域名倒写加项目名;-DoutputFile只是避免终端刷屏。打开文件后重点看两点:基础数据模块有没有出现在业务模块的依赖列表里,以及有没有模块同时被采购和销售依赖却又不属于基础层。前者说明依赖方向反了,后者往往是公共工具类没抽干净。
参数上真正要调的是-Dincludes的范围。如果项目用的是多级 groupId,比如com.example.erp.base,写成com.example.erp.*会把所有子模块都带出来,文件会很长但不会漏。我一般会先跑全量依赖树,再按模块逐个过滤,因为第一次看这套代码时你并不知道模块命名习惯。
裁剪模块时不要直接删pom.xml里的依赖,先看有没有被其他模块的 mapper 或实体引用。常见做法是先用 Maven 的dependency:analyze找出声明但未使用的依赖,再人工确认一遍,避免删掉运行时才用到的驱动。
2.2 数据访问层选型:MyBatis 与 JPA 的取舍
ERP 的查询有两个极端:一端是单据列表的复杂条件分页,另一端是库存扣减这种单行更新。MyBatis 的优势在复杂 SQL 可控,JPA 的优势在单表 CRUD 省代码。这套系统选 MyBatis 为主、少量 JPA 或 MyBatis-Plus 辅助,是偏工程落地的选择。
为什么不是全 JPA?ERP 的报表查询经常要跨五六张表做聚合,用 JPA 的 Criteria 或 JPQL 写出来又长又难调,DBA 看到执行计划也没法直接改。MyBatis 把 SQL 放在 XML 或注解里,慢查询能直接拿 SQL 去数据库里 EXPLAIN,排错路径短。代价是单表增删改要手写或依赖代码生成器,这部分通常用 MyBatis-Plus 的 BaseMapper 补。
可执行的判断方法是看 mapper 文件的分布。如果项目里*Mapper.xml集中在resources/mapper下,且每个业务模块有自己的子目录,说明 SQL 是按域管理的,后续加字段不容易冲突。如果所有 SQL 挤在一个大 XML 里,二次开发时两个人同时改同一个文件就会频繁冲突。
<!-- 典型的单据分页查询,注意 where 条件用 <if> 包裹而不是字符串拼接 --> <select id="selectOrderPage" resultType="com.example.erp.purchase.dto.PurchaseOrderDTO"> SELECT o.id, o.order_no, o.supplier_id, o.status, o.total_amount FROM pur_order o <where> <if test="supplierId != null"> AND o.supplier_id = #{supplierId} </if> <if test="status != null"> AND o.status = #{status} </if> <if test="startTime != null"> AND o.create_time >= #{startTime} </if> </where> ORDER BY o.create_time DESC </select>这段 XML 里有三个参数要盯住:supplierId、status、startTime。<where>标签会自动处理第一个AND,不要手写WHERE 1=1,后者在老代码里常见但会让索引选择变差。>=是 XML 转义,直接写>=在部分解析器里会报错。分页不要在这段 SQL 里写LIMIT,交给分页插件统一处理,否则 count 语句和列表语句容易对不上。
2.3 缓存与消息中间件的介入时机
ERP 不是所有查询都适合加 Redis。基础数据(物料、仓库、供应商)读多写少,适合缓存;单据列表和库存余额变化频繁,缓存反而增加不一致风险。这套系统的常见做法是只缓存基础数据字典和权限树,库存余额走数据库行锁或乐观锁。
消息中间件在 ERP 里通常用于两件事:单据审核后的异步通知(邮件、站内信)和跨模块的库存流水落库。如果没有这两个场景,不建议为了“架构完整”硬加 MQ,因为消息重复消费和顺序问题在 ERP 里会直接体现为账不平。常见做法是给每条消息带业务单据号和事件类型作为消费幂等键,消费前先查流水表有没有同键记录,而不是只靠 MQ 的“至少一次”语义。
判断缓存该不该加,看一个指标:同一个 key 在业务高峰期的读请求是否远大于写请求,且能容忍秒级不一致。权限树符合,库存余额不符合。库存余额的正确做法是数据库层扣减,缓存只做展示加速并设置短过期时间。
3. 本地跑通:环境、数据库与启动参数
3.1 JDK/Maven/MySQL/Redis 版本对齐
跑不起来的第一大原因是版本不对齐。这套 Java ERP 常见的技术栈是 JDK 8 或 JDK 17、Maven 3.6+、MySQL 8、Redis 6+。JDK 版本不要凭感觉选,先看pom.xml里的maven.compiler.source和spring-boot-starter-parent的版本。Spring Boot 2.x 默认带 JDK 8 兼容,Spring Boot 3.x 要求 JDK 17,混用会在启动时报UnsupportedClassVersionError。
MySQL 版本要注意的是字符集和时区。ERP 的物料名称、客户地址常有中文,库和表统一用utf8mb4,连接串里加characterEncoding=utf8和serverTimezone=Asia/Shanghai。Redis 版本影响不大,但连接池参数要改:默认的max-active在批量导入物料时会不够用。
# 检查本机版本,三项都对上再往下走 java -version mvn -v mysql --version redis-cli --version这四行不是走形式。java -version看的是运行时,mvn -v会同时输出 Maven 用的 JDK,两者不一致时编译能过但启动会挂。mysql --version确认是 8.x,5.7 在窗口函数和 JSON 字段上有差异,部分报表 SQL 会直接报语法错。redis-cli --version主要确认能连上,版本号本身不敏感。
3.2 初始化 SQL 与配置文件改写
数据库初始化一般分两步:建库建表脚本和基础数据脚本。建表脚本在sql/或doc/sql/目录下,基础数据脚本通常带init或data字样。执行顺序不能反,先建表再灌数据,否则外键和唯一索引会报错。
配置文件重点改三处:数据源、Redis、文件上传路径。数据源不要只改url,username和password也要跟着改,driver-class-name在 MySQL 8 下是com.mysql.cj.jdbc.Driver,老驱动名会提示废弃。
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/erp_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: erp_user password: change_me driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 database: 0url里的serverTimezone=Asia/Shanghai必须加,不加在部分环境会把datetime读成 UTC,单据时间整体偏八小时。allowPublicKeyRetrieval=true是 MySQL 8 默认加密插件下的连接需要,本地开发加上,生产环境按 DBA 要求决定是否保留。database: 0是 Redis 的库编号,如果本机还有别的项目在用 Redis,改成其他编号避免 key 冲突。
3.3 启动顺序与首个管理员账号
启动顺序影响首次登录能不能成功。常见做法是先起 MySQL 和 Redis,再起后端,最后起前端。后端启动时会初始化权限缓存和字典缓存,如果 Redis 没起来,登录接口会在校验验证码时超时。
首个管理员账号一般由初始化 SQL 插入,密码是加密后的固定值。不要直接改数据库里的明文密码字段,先看sys_user表的password字段长度和加密方式。常见是 BCrypt,密文以$2a$开头,长度 60。如果你用 MD5 去改,登录会一直失败。
-- 查看管理员账号的加密方式,不要直接改 password 字段 SELECT id, username, LENGTH(password) AS pwd_len, LEFT(password, 4) AS pwd_prefix FROM sys_user WHERE username = 'admin';这条查询看两个值:pwd_len和pwd_prefix。长度 60 且前缀$2a$说明是 BCrypt,要改密码得用 BCrypt 工具生成新密文再更新。长度 32 且全是十六进制说明是 MD5,这种老系统常见,但二次开发时建议换成 BCrypt,因为 MD5 撞库成本太低。pwd_prefix不要在生产库上随便查,这里只是本地初始化时确认用。
前端启动前确认vue.config.js或vite.config.js里的代理地址指向后端端口,常见是 8080。代理没配时前端能打开但所有接口 404,很多人会误以为是后端没起。
4. 核心链路:采购订单到入库的代码走读
4.1 单据状态机与表结构
ERP 的单据不是一张表走到底,采购订单、入库单、应付单各自有状态字段。状态机设计不好,会出现“已入库还能改数量”这种账务漏洞。这套系统的常见做法是状态字段用枚举值,流转通过服务层方法控制,不允许前端直接传状态。
以采购订单为例,表里通常有status字段,取值为草稿、待审核、已审核、部分入库、已完成、已取消。关键约束是:只有草稿能改明细,只有待审核能审核,已审核才能生成入库单。这些约束写在服务层,不写在 Controller。
// 状态流转的入口方法,先校验再更新,避免并发下两个请求同时审核 public void audit(Long orderId, Long auditorId) { PurchaseOrder order = orderMapper.selectById(orderId); if (order == null) { throw new BizException("单据不存在"); } if (!OrderStatus.PENDING_AUDIT.getCode().equals(order.getStatus())) { throw new BizException("当前状态不允许审核"); } // 用带状态条件的更新,影响行数为 0 说明已被其他请求改过 int rows = orderMapper.updateStatus(orderId, OrderStatus.PENDING_AUDIT.getCode(), OrderStatus.AUDITED.getCode(), auditorId); if (rows == 0) { throw new BizException("单据状态已变化,请刷新后重试"); } }这段代码的重点在updateStatus的四个参数:单据 ID、期望的旧状态、目标新状态、审核人。SQL 里要写成UPDATE ... SET status = #{newStatus} WHERE id = #{id} AND status = #{oldStatus},用旧状态做乐观锁。返回 0 行不是异常,是并发下另一个请求已经改了状态,提示用户刷新即可。如果先查再无条件更新,两个审核请求会同时通过,重复生成入库单。
4.2 库存扣减的并发控制
库存扣减是 ERP 里最容易出账务问题的地方。常见错误是先查库存再更新,查和更新之间没有锁,并发下会超卖。正确做法有两种:数据库乐观锁(版本号或条件更新)和悲观锁(SELECT ... FOR UPDATE)。中小团队用条件更新更简单。
-- 出库扣减,用 available_qty >= #{qty} 做条件,避免扣成负数 UPDATE inv_stock SET available_qty = available_qty - #{qty}, version = version + 1 WHERE material_id = #{materialId} AND warehouse_id = #{warehouseId} AND available_qty >= #{qty};这条 SQL 的关键是available_qty >= #{qty},它把校验和扣减放在同一条语句里,数据库保证原子性。返回 0 行说明库存不足或并发被抢先,业务层要回滚整张出库单,不能只回滚这一行。version字段不是必须,但加上后排查问题时能看到扣减次数。
为什么不用SELECT ... FOR UPDATE?行锁在库存扣减这种高频短事务里可以用,但 ERP 的出库单往往一次扣多行物料,按物料 ID 排序加锁能避免死锁,排序逻辑写错就会互相等待。条件更新不需要显式排序,数据库自己处理。
4.3 权限与数据权限过滤
ERP 的权限分两层:功能权限(能不能进采购模块)和数据权限(能看哪些仓库的单据)。功能权限用 RBAC,用户挂角色,角色挂菜单和按钮。数据权限常见做法是在查询里拼部门或仓库条件,不能只靠前端隐藏。
这套系统的数据权限通常通过 MyBatis 拦截器或服务层参数实现。拦截器的方式对业务代码侵入小,但要小心它把系统初始化的查询也拦了。服务层显式传参更可控,代价是每个查询方法都要带权限参数。
// 数据权限参数由登录上下文取,不要从请求参数里取,否则可被伪造 Long deptId = SecurityContext.getCurrentDeptId(); List<PurchaseOrderDTO> list = orderMapper.selectOrderPage( query.getSupplierId(), query.getStatus(), query.getStartTime(), deptId);这里deptId必须从服务端登录上下文取,不能从query里取。从请求参数取的话,改一个 URL 参数就能看别的部门单据,属于典型越权。selectOrderPage的 XML 里要加AND o.dept_id = #{deptId},且这个条件不能被前端的动态查询覆盖。
5. 避坑与排查:五个让 ERP 上线翻车的细节
5.1 时区与日期字段漂移
现象:本地测试单据创建时间是下午三点,部署到测试环境后列表显示早上七点,导出 Excel 又正常。
原因:数据库连接串没配serverTimezone,或者 JVM 默认时区是 UTC,MySQL 驱动按 UTC 解释datetime。导出正常是因为导出走的是另一条连接或另一套格式化逻辑。JVM 时区由操作系统和启动参数共同决定,容器里默认 UTC 的情况很常见。
解决:连接串统一加serverTimezone=Asia/Shanghai,启动参数加-Duser.timezone=Asia/Shanghai。已经写入的错误时间不要直接改库,先确认是展示层偏移还是存储层偏移:用SELECT NOW()和应用里new Date()对比,差八小时就是时区问题,不是数据问题。
5.2 库存超卖与重复扣减
现象:并发压测时同一个物料库存扣成负数,或者一张出库单点两次提交扣了两次。
原因:扣减 SQL 没有带available_qty >= qty条件,或者接口没做幂等,前端重复提交直接落到服务层。还有一种情况是扣减放在事务提交之后执行,事务回滚时库存已经改了。
解决:扣减语句改成条件更新,影响行数为 0 就抛业务异常回滚。接口幂等用单据号加唯一索引,或者 Redis 的setnx加过期时间做提交锁。注意锁的 key 要带单据类型和单据号,不能只用用户 ID,否则同一用户不同单据会互相阻塞。
5.3 多数据源下事务不生效
现象:采购入库方法上加了@Transactional,入库单写成功但库存流水没写,两个操作不在同一个事务里。
原因:项目配了多数据源,@Transactional默认只认主数据源的事务管理器,从库或第二数据源的操作不受管。也可能是同类内部方法调用,Spring 代理没生效。
解决:多数据源场景显式指定事务管理器,比如@Transactional(transactionManager = "secondaryTransactionManager")。同类调用改成注入自身代理或拆到另一个 Bean。排查时在方法里打日志看连接对象,两个操作拿到的Connection不是同一个就说明事务没覆盖。
5.4 缓存与数据库不一致
现象:改了物料名称,列表页还是旧名字,重启 Redis 后才正常。
原因:更新数据库后没删缓存,或者删了缓存但另一个节点又回填了旧值。基础数据缓存没设过期时间时,这个问题会一直存在。
解决:更新逻辑统一成“先更新库,再删缓存”,不要更新缓存。缓存加过期时间兜底,基础数据可以设 30 分钟到 2 小时。如果用了本地缓存加 Redis 两级,删缓存要两级都删,只删 Redis 会导致本机继续读旧值。
5.5 前端打包后路由 404
现象:本地开发前端路由正常,打包部署到 Nginx 后刷新页面 404,直接访问登录页正常。
原因:前端用的是 history 路由模式,Nginx 没配try_files,刷新时请求的是真实路径,服务器找不到对应文件。
解决:Nginx 配置里加try_files $uri $uri/ /index.html;,把未匹配的路径回退到入口文件。如果部署在子路径下,还要配publicPath或base,否则静态资源也会 404。排查时看浏览器 Network 里失败的请求是 JS 还是 HTML,JS 失败是路径问题,HTML 失败是回退规则没配。
6. 二次开发验证清单:改完代码怎么确认没把账做乱
改 ERP 代码和改普通业务系统不一样,普通系统看接口返回对不对,ERP 要看账平不平。我一般在每次改动采购、库存、财务相关代码后,跑一遍固定验证清单,不依赖测试环境的手工点。
| 验证项 | 操作 | 通过标准 |
|---|---|---|
| 单据状态 | 审核后重复审核 | 第二次提示状态已变化,不生成第二张入库单 |
| 库存扣减 | 并发 50 线程扣同一物料 | 最终库存等于初始减扣减总量,不为负 |
| 数据权限 | 用 A 部门账号查 B 部门单据 | 列表为空,直接改 URL 参数也查不到 |
| 时区 | 新建单据后查库和应用展示 | 两边时间一致,导出 Excel 一致 |
| 事务 | 入库时故意让流水表报错 | 入库单和库存都不落库 |
这张表不是走形式,每一项都对应前面章节里的一个实现点。单据状态看乐观锁,库存扣减看条件更新,数据权限看参数来源,时区看连接串,事务看管理器配置。
代码层面我习惯加一个最小的集成测试,不追求覆盖率,只守住“审核只成功一次”和“库存不扣负”两条:
// 只验证两条底线:重复审核拦截、库存不扣负 @Test void auditTwiceShouldFail() { Long orderId = createPendingOrder(10); orderService.audit(orderId, 1L); assertThrows(BizException.class, () -> orderService.audit(orderId, 1L)); }这个测试方法体里的createPendingOrder(10)是造一张数量为 10 的待审核单,audit调两次,第二次必须抛异常。不要在这里断言异常文案,文案会变,断言异常类型就够。库存扣负的测试同理,造库存 5、扣 8,断言抛异常且库存仍是 5。
血泪经验是:ERP 的 bug 很少是单个接口报错,多数是“接口都成功但账不对”。从那以后我每次改完库存相关代码,都会把上面这张表走一遍,哪怕改动只有一行。这套开源 Java ERP 更适合当底座用,开新项目时从它上面裁模块,比从零搭省事;你要是也在找能改得动的 ERP 底座,可以按第 3 章的启动路径先跑起来,再拿第 6 章这张表压一遍,能过再往下深入。希望帮到你。
本文还有配套的精品资源,点击获取