简介:星云ERP是基于SpringBoot框架打造的开源免费进销存管理系统,面向中小企业,致力于解决开店、管理及数据统计难题,实现业务线上化、透明化与简易化。系统包含基础信息、商品中心、采购、销售、零售、库存、盘点、结算等核心模块,支持部门、角色、权限的精细配置,便于灵活适配企业组织架构。资源包共1202个文件,以Java源码为主(1056个),辅以XML配置、SQL脚本、YAML/配置文件、Shell部署脚本及Dockerfile等,涵盖后端逻辑、数据库初始化与部署运维所需内容。压缩包体积仅1.44MB,适合快速下载与二次开发。目前已有363人学习,适合需要轻量级进销存解决方案的中小企业开发者、运维人员及ERP二次开发学习者。通过阅读源码和配置,可深入理解订单流转、库存核算、结算管理等业务流程,并基于开源代码按需定制,降低企业信息化成本。
1. 星云ERP:一个SpringBoot开源进销存系统的落地拆解
星云ERP是一套基于SpringBoot框架、完全开源且永久免费的进销存ERP系统,目标用户就是那些不想在软件上砸钱的中小企业和个体门店。我上手拆过不少自称开源的ERP项目,多数要么只给一层壳、要么业务表结构乱得没法二次开发;星云ERP给我的第一印象是把采购、销售、库存、往来单位和数据统计收敛成一条完整的单据流,业务线上化、透明化、简易化管理这三个目标在代码里有明确落点。如果你是有二次开发需求的Java工程师,或者要给自家门店配进销存系统的经营者,这套源码的参考价值都很直接。下面从部署开始讲,把我实际跑通这套系统的步骤和踩过的坑一次说清楚。
2. 部署与初始化:改配置前必须先确认的三个版本红线
2.1 技术选型先看透:为什么是SpringBoot加单体模块
SpringBoot框架本身不新鲜,但用在进销存这类企业应用上依然是最稳妥的选择:它内置Web容器、自动配置和一套成熟的依赖管理,开发期不需要自己拼基础设施。星云ERP这类开源项目选SpringBoot而不是微服务,核心原因是业务规模决定架构。中小企业租户规模、并发量和数据量并没有大到必须引入注册中心、网关和分布式事务,单体应用完全扛得住,二次开发和部署都要轻一个量级。我见过太多进销存项目一上来就拆微服务,结果三个人维护十几个服务,光排查一个问题就要翻三四个服务日志,最后又悄悄退回单体。所以看这套源码时,别嫌它就一个SpringBoot应用——单体不是落后,是贴合场景。
打开源码包后,第一件事不是跑,而是先扫一眼它的项目结构。常见做法是标准的Maven多模块目录:xingyun-common放公共工具类,xingyun-admin放后台管理接口,xingyun-core放业务Service。如果下载到的版本是单模块,也问题不大,controller、service、mapper三层包结构同样能看清链路。前端一般是Vue项目单独一个目录,构建产物最终会打进SpringBoot的static目录,这个打包逻辑后面会专门踩到坑。
整理一张技术角色表,方便你对照自己项目:
| 组件 | 在星云ERP里的角色 | 常见配置 |
|---|---|---|
| SpringBoot | 应用主框架,提供自动配置与内置Tomcat | 2.7.x配JDK8/11,3.x配JDK17 |
| MyBatis | 数据访问层,SQL写在Mapper XML里 | resources/mapper目录 |
| MySQL | 业务数据存储 | 5.7或8.0,字符集utf8mb4 |
| Vue + Element UI | 后台管理界面 | 构建产物放static目录 |
这张表最右边的“常见配置”不是摆设。你下载的版本如果基于SpringBoot 3.x,JDK必须是17以上;如果是2.x,JDK8或11都能跑。判断方法很简单:打开pom.xml看spring-boot-starter-parent的版本号,别靠猜。
2.2 环境核对:JDK、Maven、MySQL的版本红线
先把本机环境确认清楚,这一步省不得:
java -version mvn -v mysql --version三条命令依次执行,缺哪个装哪个。需要强调的版本红线有三条:第一,JDK和SpringBoot必须匹配,3.x配JDK17,2.x可以用JDK8/11,如果JDK版本太低,编译时会报class file version不兼容;第二,Maven建议3.6以上,太低会遇到依赖解析不完整、插件下载失败这类问题;第三,MySQL建议8.0,虽然5.7也能跑,但8.0的驱动类名是com.mysql.cj.jdbc.Driver,配置里如果写成旧的com.mysql.jdbc.Driver,启动时会直接报驱动类找不到。这三条红线就是本节的章节名里说的“版本红线”,后面第4章第4节还会专门展开JDK不匹配的报错现象。
顺手把字符集确认了:MySQL 8默认utf8mb4问题不大,但如果是5.7,建库时务必显式指定。字符集错了最典型的症状是中文字段写入后全是问号,排查起来特别费时间,属于那种看起来是小问题、实际折腾一下午的坑。
2.3 改配置、建库、启动:从源码到可访问服务
环境没问题后,修改数据源配置。打开src/main/resources/application.yml,重点改这几行:
server: port: 8080 servlet: context-path: /xingyun spring: datasource: url: jdbc:mysql://localhost:3306/xingyun_erp?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver先说明参数含义。serverTimezone指定为Asia/Shanghai,是因为MySQL 8的时区默认不是中国时区,不设的话连接会直接抛时区异常;useUnicode和characterEncoding要一起带,保证中文不乱码;context-path加一个/xingyun前缀,等于把所有接口都放到/xingyun下面,后面反向代理时就不会和别的应用抢根路径。这个配置如果你不想要前缀,直接把这一行删掉即可,但要注意前端请求的baseURL也要同步去掉。
建库并导入初始化脚本。源码里一般都会带sql目录,常见命名是xingyun.sql或init.sql:
mysql -uroot -p -e "CREATE DATABASE xingyun_erp DEFAULT CHARACTER SET utf8mb4;" mysql -uroot -p xingyun_erp < sql/xingyun.sql第一条命令建库,第二条把脚本导入。注意脚本顺序不能乱,如果项目里有按模块拆的多个sql文件,先执行建表脚本再执行数据脚本。导入过程中如果看到一行报错,不要习惯性忽略,先截图记下来,初始化脚本里如果有不兼容的SQL,后面运行时会有更难排查的连锁反应。
启动服务:
mvn spring-boot:run看到“Started XxxxApplication”且没有报错,说明服务起来了。这时浏览器访问http://localhost:8080/xingyun,应该能看到登录页。如果启动时报端口被占用,大概率是本机已有其他服务占了8080,两条路:改application.yml里的端口,或者用启动参数覆盖。
2.4 两种运行方式:Maven直跑与打包部署
开发阶段我习惯在IDEA里直接跑:导入项目后先确认Project SDK选的是JDK对应版本,再确认Maven配置指向本机Maven目录,等依赖下载完成再启动。很多人卡在IDEA跑不起来,一半是SDK没选对,一半是Maven仓库里的依赖半残,这属于环境问题不是代码问题。
生产或测试环境部署,走打包路径:
mvn clean package -DskipTests java -jar target/xingyun-erp.jar-DskipTests只跳过单测,不影响编译。打包时间长一点是正常的,Maven第一次要把所有依赖组装进jar。启动后如果想临时换端口,不用改配置文件重新打包:
java -jar target/xingyun-erp.jar --server.port=8081 --spring.profiles.active=prod这个覆盖配置的习惯非常有用。同一份jar靠启动参数切换端口和profile,不用每个环境维护一份配置文件。我用这个方式给客户部署时,通常就在启动命令里改两三个参数,省得反复问“你改的是哪个配置文件”。IDEA里想模拟同样的效果,在Run/Debug Configuration的Program arguments里写上--server.port=8081即可,这个操作在IDEA 2026里位置更显眼,配置面板顶部就有Arguments输入框。
3. 核心业务链路:从采购入库到销售出库的三个关键设计
3.1 商品、库存、往来单位三张核心表怎么设计
进销存系统翻来覆去就是商品、库存、往来单位、单据这几件事。星云ERP这类开源项目的核心表设计,基本遵循同一套范式:商品表存静态信息,库存表按仓库维度存动态数量,往来单位表把供应商和客户合并管理。先看三张最核心的表:
CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, spec VARCHAR(64), unit VARCHAR(16), category_id BIGINT, status TINYINT DEFAULT 1 ); CREATE TABLE inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, quantity DECIMAL(18,2) NOT NULL DEFAULT 0, updated_time DATETIME ); CREATE TABLE partner ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type TINYINT NOT NULL COMMENT '1-供应商 2-客户', name VARCHAR(128) NOT NULL, phone VARCHAR(32), address VARCHAR(255) );这三张表值得细讲。第一,product用sku做唯一键,采购单据和销售单据都通过sku关联商品,规格参数拆到spec字段而不是塞进name,这样报表分组时才能按规格聚合。第二,inventory的quantity用DECIMAL(18,2)而不是Double,金额和数量都不能浮点算,0.1加0.2变0.30000000000000004这种问题在进销存里会直接导致库存对不平。第三,partner表用type字段区分供应商和客户,比拆成两张表更省事:采购入库时往来单位type=1,销售出库时type=2,下拉列表和通讯录都是一套数据。这套设计算不上惊艳,但足够稳,二次开发时按这个思路扩展就行。
库存表还有个隐藏点:warehouse_id字段。中小企业的仓库粒度可能就一个,但设计上带仓库维度,后面想按门店、按仓核算库存就不需要加表。我给客户做库存报表时,发现很多项目这里偷懒,库存表只有product_id没有warehouse_id,结果想按门店拆分统计时只能回填历史数据,那个痛苦就是典型的“当时省一步、后面补半年”。
3.2 入库和出库:用条件更新守住库存底线
进销存最核心的动作是入库和出库。正常逻辑是:采购入库单审核通过后,库存数量增加;销售出库单审核通过后,库存数量减少。但这里藏着所有进销存项目最容易翻车的点——并发扣库存。
先想明白一个场景:两个销售单同时扣同一个商品的库存,如果代码是先查库存够不够,再执行update,两个请求都查到剩余10件,各自扣6件,最终库存变成4件而不是直接报库存不足,这就是典型的超卖。避免超卖的做法是把判断数量够不够和扣减合并成一条带条件的UPDATE:
<update id="deductStock"> UPDATE inventory SET quantity = quantity - #{qty}, updated_time = NOW() WHERE product_id = #{productId} AND warehouse_id = #{warehouseId} AND quantity >= #{qty} </update>注意WHERE里的quantity >= #{qty}。这一步用数据库的行锁保证了并发安全:两个请求同时来,只有第一个能把quantity减掉,第二个的WHERE条件就不满足了,影响行数是0,Service层拿到0就知道库存不足,直接抛出业务异常。这个写法比select先查再update稳妥得多,也是MyBatis这类数据访问层最推荐的扣减方式。
配套的还有入库操作,逻辑对称。给一个完整的采购入库Service方法:
@Transactional public void purchaseInbound(PurchaseInboundDTO dto) { for (InboundItem item : dto.getItems()) { inventoryMapper.increaseStock(item.getProductId(), dto.getWarehouseId(), item.getQty()); } purchaseOrderMapper.updateStatus(dto.getOrderId(), 2); }方法上加@Transactional,保证多商品入库要么全部成功要么全部回滚。increaseStock对应的SQL就是把quantity = quantity + #{qty},没有WHERE条件限制。这里不展开全部XML,核心思想是入库用自增、出库用条件自减,两边都要按warehouse_id隔离。单据审核和库存变动放同一个事务里,单据状态和库存数量才能对得上,否则就会出现单据显示已审核、库存没变化的灵异现象。
3.3 多商户数据权限与月度统计的常见实现
星云ERP面向多商户,数据隔离是躲不开的话题。常见做法是业务表都带一个tenant_id字段,查询时强制拼接过滤条件;更细一点是后台管理端按员工分组做数据权限。看代码时先确认它的核心表有没有tenant_id或branch_id字段,有小到商户级隔离的基础都在,没有的话二次开发补起来会费不少劲。
业务统计这块,进销存系统最常用的就是按月汇总销售金额和订单数。这类报表不用上重型BI,几条聚合SQL就能搞定:
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, SUM(total_amount) AS amount, COUNT(*) AS order_count FROM sale_order WHERE status = 2 AND tenant_id = #{tenantId} GROUP BY DATE_FORMAT(create_time, '%Y-%m') ORDER BY month DESC;WHERE里限定status=2(已审核)和tenant_id,是防止把未审核的草稿单统计进去、防止跨商户数据串门。DATE_FORMAT把时间聚合到月,结果就是一份现成的月度销售趋势。想按商品维度看排行,把GROUP BY换成product_id再加一个SUM(quantity)就行。这套统计SQL是我在做进销存项目里复用频率最高的几个语句之一,线上数据量不大时响应都在毫秒级,完全够用。
4. 避坑清单:五个部署与二次开发的典型翻车点
这一章不写理论,全部是实际跑星云ERP过程中整理出来的问题,每条按现象、原因、解决三步说清。
4.1 MySQL8的SQL模式导致初始化失败
现象:导入初始化SQL时直接报错,提示“Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column”,或者初始化中途中断,后面登录时菜单加载不出来。
原因:MySQL 8默认开启ONLY_FULL_GROUP_BY,旧系统里常见的select非聚合字段写法在5.7能过,在8.0就直接被判非法。初始化脚本里如果有group by查询,就会在这个模式下翻车。
解决:两个办法。改SQL是根治,但脚本是开源的,不想到处改的话就改MySQL配置。临时生效执行:
SET GLOBAL sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';永久生效就写进my.cnf的[mysqld]段,把默认的sql_mode替换成不含ONLY_FULL_GROUP_BY的版本,注意修改后要重启MySQL服务。我一般是先看报错是不是集中在group by,如果是,优先改sql_mode,省时且不污染开源代码。
4.2 前端打包放进SpringBoot后访问404
现象:Vue项目npm run build之后,dist里的文件覆盖到SpringBoot的resources/static目录,启动后访问http://localhost:8080/xingyun,返回404,但直接访问接口路径又是通的。
原因:前端用的history路由模式下,所有页面路由都依赖index.html做分发,SpringBoot默认只做静态资源映射,不识别前端的router路径。文件是提供出来了,但没有把非API路径都转发到index.html这一步。
解决:加一个资源映射配置,把前端路由路径转发到index.html。常见做法是加一个WebMvcConfigurer:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/").setViewName("forward:/index.html"); registry.addViewController("/{path:[^\\.]*}").setViewName("forward:/index.html"); } }这里两个映射分别处理根路径和所有不带后缀的路径,带扩展名的资源文件走默认静态资源处理器,页面路由转发到index.html。加了这层之后,刷新页面就不会再白屏,这是Vue打包进SpringBoot最常踩的坑之一。如果你下载的星云ERP版本已经把前端产物打了进去,也建议检查一遍这个配置是否还在,防止二次开发时单独跑前端后路径打架。
4.3 金额字段用Double导致的对账不平
现象:订单金额显示99.99,明细加总却变成100.0;或者库存数量算下来比实际多0.0000000004。
原因:实体类和数据库字段用了Double/Float。浮点数用二进制表示小数本身就有精度损失,进销存里金额和数量是不能有半点误差的。
解决:数据库金额字段全部改成DECIMAL(18,2),Java实体对应改成BigDecimal:
private BigDecimal totalAmount; private BigDecimal quantity;BigDecimal运算不能用+、-、*、/,要调用add、subtract、multiply、divide方法。这个改动虽然琐碎,但属于进销存项目的底线。检查自己项目时,直接搜实体类里有没有double、float这种类型,一个都不要留。星云ERP本身在这一点上做得比较规矩,但我见过不少基于它改出来的项目,新人接手后用double存金额,三个月后对账不平又回头改,属于典型的血泪经验。
4.4 SpringBoot版本与JDK不匹配引发的启动报错
现象:执行java -jar启动,日志还没打印就退出,控制台报“UnsupportedClassVersionError”,再细看是class file version错误。
原因:下载的源码包SpringBoot版本太高,要求JDK17/21,而你机器上装的是JDK8或11。SpringBoot 2.x编译产物的class版本对应JDK8/11,SpringBoot 3.x必须是JDK17以上,JDK版本不够,JVM根本不认这个class文件。
解决:先看两个地方:pom.xml里spring-boot-starter-parent的版本号,本机java -version的输出。SpringBoot 3.x就装JDK17,SpringBoot 2.x用JDK8/11都行。如果项目必须用低版本JDK,另一个思路是从源码层面把SpringBoot降到2.7.x,但这里面的依赖改动不小,不如直接装对应JDK来得快。这条经验特别适合刚拿到源码包就想跑的同学,先确认版本再决定要不要升级环境,别一报错就怀疑代码有问题。
4.5 自定义配置被自动配置覆盖
现象:在application.yml里加了一个自定义开关,比如erp.debug=true,运行时通过@Value注解读出来永远是null或默认值,改配置也没用。
原因:配置key拼写问题,或者有多个配置源导致自己的配置没被加载。SpringBoot读取配置的优先级是命令行参数大于环境变量,大于application-prod.yml,大于application.yml,同名配置高优先级直接覆盖低优先级。如果你在application.yml里配了,但运行时指定了--spring.profiles.active=prod,而prod文件里没有这个key,理论上不冲突;如果prod里有旧值,那覆盖的就是你了。
解决:自定义配置建议用@ConfigurationProperties绑定,写一个独立的配置类:
@Component @ConfigurationProperties(prefix = "erp") public class ErpProperties { private boolean debug = false; private String dataDir; // getter、setter省略 }这样application.yml里的erp.debug、erp.data-dir会自动绑定到对应字段上,不用满代码找@Value。绑定不上时,先在启动日志里看使用的是哪个profile,再用命令java -jar xxx.jar --debug看配置加载详情。这一条算是我在SpringBoot项目里最常排查的问题之一,列入避坑清单不冤。
5. 二次开发实战:给星云ERP加一个库存预警的三层改动
改一个已有功能比从零写要难,难在要动别人设计好的代码结构。这里我给一个完整的扩展案例:给商品增加最低库存阈值,低于阈值自动记录预警并推送通知。
5.1 先加字段:预警阈值与预警状态落库
库存预警的前提是知道多少算低。先给核心表加字段:
ALTER TABLE product ADD COLUMN min_stock DECIMAL(18,2) DEFAULT 0; ALTER TABLE inventory ADD COLUMN is_warned TINYINT DEFAULT 0;min_stock存预警阈值,可以按商品设置;is_warned标记当前是否处于预警状态,避免每次查询库存都跑一遍预警逻辑。为什么不放在inventory表里?因为预警阈值是商品属性,和仓库无关,放product表合语义;预警状态是仓库维度,放inventory表。加完字段后,在后台商品编辑页面加一个最低库存输入框,数据实体和表结构对应,字段定好后面代码就顺了。
5.2 Service层拦截:扣库存后判断并记录预警
扣库存的核心方法在前面提过,现在在它后面加预警判断:
@Transactional public void deductStock(Long productId, Long warehouseId, BigDecimal qty) { int updated = inventoryMapper.deductStock(productId, warehouseId, qty); if (updated == 0) { throw new BusinessException("库存不足"); } Inventory inventory = inventoryMapper.findByProductIdAndWarehouse(productId, warehouseId); Product product = productMapper.findById(productId); if (inventory.getQuantity().compareTo(product.getMinStock()) < 0) { inventoryMapper.markWarned(productId, warehouseId); noticeService.saveWarnNotice(productId, warehouseId, inventory.getQuantity()); } else { inventoryMapper.clearWarned(productId, warehouseId); } }这里有几个关键点。deductStock用的条件更新已经保证了库存不会变成负数,所以不用再查一次数量够不够。扣减后重新查出库存,和商品阈值用compareTo比较,BigDecimal不能用<直接比。如果低于阈值,把is_warned置1,同时写一条预警通知;如果恢复到阈值以上,把预警状态清掉,否则一个商品低过一次库存就永远挂着预警,报表里全都是历史包袱。markWarned和clearWarned对应两条UPDATE SQL,逻辑上注意两点:预警记录和库存扣减放在同一个事务里,别分成两次提交;compareTo的语义是小于0才真小于,等于0不算低。
5.3 让预警自动推送:定时扫描与WebSocket选型
上面的逻辑是扣库存时触发,但如果商品入库时就在预警线以下、或者管理员手动改了阈值,预警永远不会触发。所以更完整的做法是加一个定时任务兜底扫描:
@Scheduled(cron = "0 */10 * * * ?") public void scanWarnedProducts() { List<Inventory> lowStockList = inventoryMapper.selectLowStockList(); for (Inventory inv : lowStockList) { noticeService.saveWarnNotice(inv.getProductId(), inv.getWarehouseId(), inv.getQuantity()); } }cron表达式“0 */10 * * * ?”表示每10分钟执行一次。这个写法不要求预警实时,但能保证任何原因导致的低库存都会被扫出来。selectLowStockList的SQL就是联查product和inventory,把quantity小于min_stock的记录捞出来。如果你要求消息实时推给前端,可以把这个扫描逻辑换成商品扣减时直接发消息给消息队列,前端通过WebSocket收;不过对进销存这种业务,10分钟刷新一次预警完全够用,没必要为实时性增加系统复杂度。我看到不少项目在这上面过度设计,预警延迟10分钟根本不影响门店决策,少引入一套消息中间件,部署成本少一大截。
实现完成后,验证路径是:把某个商品min_stock改成大于当前库存的数字,执行一次扫描任务,看预警表有没有新增记录。跑通这一步,整个模块就闭环了。
6. 改动验收:从单测回滚到对账SQL的两个验证手段
改动进销存系统最怕的不是写不出代码,是改完不知道自己改坏了什么。两个验证手段是我每次交付前必做的:一个针对代码逻辑,一个针对数据链路。
6.1 写一个跨方法的库存单测
库存扣减涉及update、查回、判断三个动作,最容易在中间某一步改出错。用SpringBoot测试框架直接连开发库跑一个事务回滚用例,是最简单的验证方式:
@SpringBootTest @Transactional class InventoryServiceTest { @Autowired private InventoryService inventoryService; @Test void testDeductStockAndWarn() { inventoryService.deductStock(1L, 1L, new BigDecimal("2")); Inventory inv = inventoryService.findByProductIdAndWarehouse(1L, 1L); assertTrue(inv.getQuantity().compareTo(BigDecimal.ZERO) >= 0); } }@Transactional保证测试跑完数据回滚,不会污染开发库。之前我习惯用Mockito把所有Mapper都mock掉,后来发现mock出来的Service测不到SQL问题,直接连库测真实SQL才是这个场景最有效的做法。这条用例跑绿,说明扣库存和预警链路没被改坏。
6.2 对账SQL:守住进销存平衡底线
代码逻辑之外,数据链路用对账SQL把关。进销存系统的基本守恒式是“期初库存+入库总量-出库总量=当前库存”,只要这个等式成立,业务流转就没乱。
SELECT i.product_id, COALESCE(SUM(CASE WHEN f.flow_type = 1 THEN f.qty END), 0) AS inbound_qty, COALESCE(SUM(CASE WHEN f.flow_type = 2 THEN f.qty END), 0) AS outbound_qty, MAX(i.quantity) AS current_qty FROM inventory i LEFT JOIN stock_flow f ON f.product_id = i.product_id AND f.tenant_id = i.tenant_id GROUP BY i.product_id这里stock_flow是库存流水表,flow_type=1代表入库,2代表出库。对账时如果发现“期初+入库-出库”不等于当前库存,先查两个地方:一是有没有单据审核了但没生成流水,二是有没有流水重复生成了。这种对账最好做成一个固定SQL或脚本,每次发版后跑一遍。我从那以后每次改进销存代码,都强制走一遍这个对账脚本再部署,这个习惯帮我拦下过好几次把库存改穿的问题。希望帮到你。
本文还有配套的精品资源,点击获取