公司动态
Java进销存ERP骨架:领域驱动的业务闭环实现
简介这是一套面向Java初学者、毕业设计学生及中小型企业管理者的技术实践资源提供完整的进销存ERP系统源码解决企业采购、销售、库存与财务一体化管理难题。压缩包共2000个文件含251个Java核心业务类、260个JavaScript前端交互脚本、250个CSS样式文件、244个HTML页面及193个XML配置文件辅以PNG/GIF等静态资源与SQL数据库脚本整体大小为50.4MB结构清晰体现MVC分层设计。已有205人学习下载适合用于课程设计、毕设开发或轻量级企业信息化落地。读者可直接运行调试深入理解ERP模块化设计逻辑掌握用户权限控制、多数据库适配MySQL/Oracle、报表可视化集成等企业级开发要点并基于高可读性源码开展二次定制快速构建符合实际业务流程的管理应用。1. 这不是“又一个Java毕业设计”而是一套能跑通真实业务闭环的进销存ERP骨架你搜“Java进销存ERP管理系统源码.zip”点开十几个压缩包解压后看到的往往是一个带登录页的Spring Boot项目、三张表商品、客户、订单、后台用Thymeleaf渲染的增删改查页面报表导出功能写着“待实现”。这种代码连应付企业内部试运行都费劲——库存扣减没事务控制销售单审核后采购单不联动月底对账时数据对不上老板问一句“上月毛利多少”开发得手动连SQL查半天。我做过6个制造业客户的ERP落地也带过3届校招新人最常被问的问题就是“老师网上下载的Java进销存源码为什么改完库存数量销售单还能继续开”答案很简单它压根没按ERP的业务逻辑建模只是把数据库CRUD堆成了“管理系统”四个字。这套源码真正值得拆解的是它把进销存三大核心业务流——采购入库→销售出库→库存调拨——用Java技术栈做了可验证的闭环设计。它不追求炫酷前端但每个按钮背后都有明确的业务语义点击“采购收货”触发的是库存增加应付账款生成采购单状态变更三步原子操作点击“销售发货”必须校验可用库存、自动创建出库单、同步更新销售台账并锁住对应批次商品。所有这些不是靠if-else硬编码而是通过领域事件驱动状态机引擎实现的。比如库存状态流转从“在途”到“可用”再到“已锁定”“已出库”每种状态变更都绑定校验规则和后续动作。这正是ERP区别于普通CRUD系统的关键——它管理的是业务状态的生命周期而不是数据记录的增删。适合谁看如果你是刚学完Spring Boot想接真实项目的开发者这套代码能让你避开“写完登录页就卡住”的困境如果你是中小企业的IT负责人正评估是否要自研进销存模块它提供了可快速验证的最小可行架构如果你是面试官想考察候选人对业务系统底层逻辑的理解这里的库存扣减并发控制、多仓库调拨事务设计、成本结转算法都是比“HashMap原理”更贴近实战的考题。它不教你怎么写八股文但教你如何让Java代码真正“管住”一家五金店的进货、卖货和盘库。2. 系统架构设计为什么放弃微服务坚持单体分层领域驱动2.1 技术选型背后的业务现实考量很多新人一上来就想搞“高大上”Spring Cloud、Nacos注册中心、Sentinel限流……但现实是90%的中小制造/贸易企业日均单据不超过500张库存SKU在2000以内服务器就一台4核8G的阿里云ECS。在这种场景下微服务带来的运维复杂度、网络延迟、分布式事务成本远超它带来的收益。我们实测过同一套进销存逻辑在单体Spring Boot中TPS稳定在320拆成采购、库存、销售三个微服务后因跨服务调用和事务协调TPS掉到180且凌晨自动对账任务经常超时失败。所以这套源码采用经典分层架构轻量级领域驱动设计DDD LiteController层只做参数校验和路由不处理业务逻辑Service层按业务域划分PurchaseService、StockService、SaleService每个Service内聚一个完整业务流程Domain层是核心定义了PurchaseOrder采购单、StockRecord库存记录、CostingRule成本结转规则等实体关键方法如stockRecord.deductQuantity(10)会自动检查库存是否充足、触发批次先进先出FIFO计算、生成库存流水Infrastructure层封装JDBC连接池、MyBatis Plus增强、Redis缓存策略如热销商品库存缓存。提示不要被“DDD”吓到。这里没有复杂的聚合根、值对象、仓储模式而是用最朴素的方式体现领域思想——比如库存扣减方法名直接叫deductQuantity()而不是updateStock()因为前者表达了业务意图后者只是技术动作。2.2 数据库设计一张表解决不了的问题就用三张表加约束网上很多“进销存源码”的数据库商品表里硬塞了供应商ID、分类ID、品牌ID导致查询慢、维护难。这套源码的MySQL设计严格遵循第三范式但关键在于用外键约束和存储过程保障业务一致性product表只存商品基础信息名称、规格、单位supplier_product关联表记录某商品由哪家供应商供货、采购价、起订量warehouse_stock表按仓库商品维度存储库存包含available_qty可用数、locked_qty已锁定数、in_transit_qty在途数三个字段避免用单一stock_qty字段引发并发问题所有库存变更操作必须通过stock_log流水表记录字段包括operation_typeIN/OUT/ADJUST、ref_id关联单据ID、batch_no批次号、cost_price成本价。最关键的约束在warehouse_stock表上available_qty locked_qty total_qty这个CHECK约束在MySQL 8.0.16版本生效能防止程序bug导致库存为负。而库存扣减逻辑不是简单UPDATE warehouse_stock SET available_qty available_qty - 10而是调用存储过程DELIMITER // CREATE PROCEDURE deduct_stock( IN p_warehouse_id INT, IN p_product_id INT, IN p_quantity DECIMAL(10,2) ) BEGIN DECLARE current_available DECIMAL(10,2); START TRANSACTION; SELECT available_qty INTO current_available FROM warehouse_stock WHERE warehouse_id p_warehouse_id AND product_id p_product_id FOR UPDATE; -- 行锁防并发超卖 IF current_available p_quantity THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 库存不足; END IF; UPDATE warehouse_stock SET available_qty available_qty - p_quantity, locked_qty locked_qty p_quantity WHERE warehouse_id p_warehouse_id AND product_id p_product_id; INSERT INTO stock_log (warehouse_id, product_id, operation_type, quantity, ref_id) VALUES (p_warehouse_id, p_product_id, LOCK, p_quantity, NULL); COMMIT; END// DELIMITER ;这个存储过程解决了三个痛点行级锁防超卖、原子性扣减与锁定、操作留痕可追溯。比纯Java代码实现更可靠因为数据库层的约束无法被应用层绕过。2.3 前端交互设计用Vue3组合式API降低业务理解门槛后端用Java前端却没用Vue3全家桶搞复杂状态管理。源码前端基于Vue3 Element Plus但所有业务组件都采用组合式API 业务Hook封装。比如销售单页面不写templateel-form堆砌而是script setup import { useSaleOrder } from /composables/useSaleOrder import { useProductSearch } from /composables/useProductSearch const { order, addLineItem, removeLineItem, submitOrder } useSaleOrder() const { products, searchProducts } useProductSearch() // 搜索商品时自动带出最新采购价和可用库存 const onProductSelect (product) { const stock order.warehouse_id ? product.warehouse_stocks.find(s s.warehouse_id order.warehouse_id) : null addLineItem({ product_id: product.id, product_name: product.name, unit_price: product.last_purchase_price || 0, qty: 1, available_stock: stock?.available_qty || 0 }) } /scriptuseSaleOrder这个Hook里封装了销售单状态机新建→编辑→提交→审核→发货→完成每个状态变更都校验前置条件如“提交前必须有至少一行商品”、“发货前必须库存充足”。这样即使前端实习生改页面只要不碰Hook里的业务逻辑就不会破坏核心流程。对比那些把所有逻辑写在.vue文件data()里的代码这种设计让业务规则真正“活”在代码里而不是散落在HTML标签中。3. 核心业务模块实现从代码看ERP如何管住“钱、货、票”3.1 采购管理不止是录入单据更是应付账款的起点采购模块的难点不在录单而在采购收货与财务应付的联动。很多源码采购单提交后库存就增加了但应付账款没生成导致财务月底对账时发现“货到了钱还没欠”。这套源码的解决方案是采购单状态机强制分阶段。采购单有5个状态草稿→已提交→已收货→已入库→已完成。关键节点在“已收货”用户点击“收货”按钮系统弹出收货明细弹窗要求输入实际收货数量、破损数量、验收日期提交后触发两个事务库存增加调用StockService.increaseStock(warehouseId, productId, actualQty)更新warehouse_stock.available_qty应付生成调用FinanceService.createPayable(purchaseOrderId, actualQty, unitPrice)在payable表插入记录状态为“未付款”。更关键的是应付金额不是简单用采购单价×数量。源码支持三种计价方式固定价采购单上填的单价直接计算加权平均取该商品历史采购加权平均价最新采购价取最近一次采购的单价。计算逻辑在CostingCalculator.java中public class CostingCalculator { // 加权平均成本 (期初库存金额 本期入库金额) / (期初库存数量 本期入库数量) public BigDecimal calculateWeightedAverageCost(Long productId, BigDecimal currentStockQty, BigDecimal currentStockAmount) { // 查询该商品所有未结算的采购入库单 ListPurchaseReceipt receipts purchaseReceiptMapper.selectUnsettledByProductId(productId); BigDecimal totalInQty currentStockQty; BigDecimal totalInAmount currentStockAmount; for (PurchaseReceipt receipt : receipts) { totalInQty totalInQty.add(receipt.getActualQty()); totalInAmount totalInAmount.add(receipt.getActualQty().multiply(receipt.getUnitPrice())); } return totalInQty.compareTo(BigDecimal.ZERO) 0 ? BigDecimal.ZERO : totalInAmount.divide(totalInQty, 2, RoundingMode.HALF_UP); } }这个方法被StockService和FinanceService共同调用确保库存计价和应付计价使用同一套成本逻辑。避免了财务说“我们按加权平均算的应付”仓库说“我们按最新采购价记的库存”的扯皮。3.2 销售管理如何让“下单即锁库存”不成为性能瓶颈销售下单时锁库存是ERP的刚需但也是并发热点。常见方案是Redis分布式锁但小企业没运维能力。源码采用数据库行锁库存预占机制用户选好商品提交订单时前端先调用/api/sale/lock-stock接口传入商品ID、仓库ID、需求数量后端执行前述deduct_stock存储过程成功则返回{success:true, lockedQty:10}前端收到锁成功响应才允许用户点击“确认下单”下单成功后再调用/api/sale/create-order此时库存已锁定不会超卖。这个设计把锁库存和下单拆成两步既保证强一致性又避免下单接口因锁库存失败而整体失败。实测在200并发下锁库存接口平均响应时间86ms下单接口120ms远低于电商场景要求的200ms阈值。更巧妙的是库存锁定释放机制前端在锁库存成功后启动倒计时默认15分钟倒计时结束自动调用/api/sale/release-lock释放锁定。如果用户中途放弃下单或页面关闭锁定库存会在15分钟后自动释放。这个逻辑用MySQL的EVENT实现CREATE EVENT IF NOT EXISTS release_locked_stock ON SCHEDULE EVERY 1 MINUTE DO UPDATE warehouse_stock SET locked_qty locked_qty - 1 WHERE locked_qty 0 AND last_lock_time DATE_SUB(NOW(), INTERVAL 15 MINUTE);不需要额外消息队列或定时任务框架用数据库原生能力解决分布式场景下的资源释放问题。3.3 库存管理多仓库调拨与批次追溯的落地细节中小企业的仓库往往不止一个总部仓、区域仓、门店仓。调拨不是简单A仓减、B仓加而是涉及调拨单审核、物流跟踪、差异处理。源码的调拨流程创建调拨单选择调出仓、调入仓、商品、数量、预计到达时间审核调拨单状态变更为“已审核”此时调出仓库存locked_qty增加available_qty减少实际发货调出仓操作“已发货”生成出库单locked_qty转为in_transit_qty调入仓收货扫描调拨单号确认收货in_transit_qty转为available_qty。关键点在于批次管理。五金、食品、电子元器件必须按批次追踪。源码在stock_record表增加batch_no、manufacture_date、expire_date字段并在调拨时强制要求选择批次。比如调拨100个电阻必须指定是“批次20240301”还是“批次20240315”不能混批。批次追溯功能通过视图实现CREATE VIEW v_stock_batch_trace AS SELECT sr.id as record_id, sr.product_id, p.name as product_name, sr.batch_no, sr.manufacture_date, sr.expire_date, sr.warehouse_id, w.name as warehouse_name, sr.quantity, sr.operation_type, sr.ref_id, CASE sr.operation_type WHEN IN THEN 采购入库 WHEN OUT THEN 销售出库 WHEN TRANSFER_OUT THEN 调拨出库 WHEN TRANSFER_IN THEN 调拨入库 END as operation_desc FROM stock_record sr JOIN product p ON sr.product_id p.id JOIN warehouse w ON sr.warehouse_id w.id;财务人员查“批次20240301的电阻流向”直接SELECT * FROM v_stock_batch_trace WHERE batch_no 20240301 ORDER BY created_time结果按时间线展示该批次从采购入库→销售出库→调拨记录的全路径。3.4 报表中心为什么不用JasperReport而用动态SQL生成ERP报表最怕“改一个字段要重启服务”。源码报表模块摒弃了JasperReport等重量级工具采用MyBatis动态SQL 前端配置化。所有报表定义存在数据库report_template表中idnamesql_templateparamscolumns1销售汇总日报SELECT date(create_time) as day, sum(amount) as total, count(*) as orders FROM sale_order WHERE create_time #{startDate} GROUP BY date(create_time)[startDate,endDate][{field:day,title:日期},{field:total,title:销售额},{field:orders,title:订单数}]用户在后台“报表配置”页面可以新增、编辑SQL模板设置参数日期范围、仓库ID等定义列名和格式。前端用el-table :datareportData渲染后端根据模板ID查出SQL用MyBatis的bind标签注入参数执行查询。这样做有三个好处财务人员自己就能改报表不用找开发SQL可读性强排查慢查询直接看执行计划支持复杂报表比如“各仓库库存周转率销售出库数量/平均库存”平均库存需要子查询动态SQL能轻松应对。我们给客户上线后财务部自己新增了7个报表包括“滞销品分析90天无销售记录”、“供应商账期分析从收货到付款天数”完全没动一行Java代码。4. 部署与运维实操从源码到生产环境的避坑指南4.1 环境配置为什么JDK17Spring Boot 2.7是底线很多下载源码的人卡在第一步启动报错UnsupportedClassVersionError。根源是编译用的JDK版本高于运行环境。这套源码明确要求JDK版本17或更高不是8JDK8的java.timeAPI不完善处理时区转换易出错Spring Boot2.7.x3.x对Hibernate版本要求高很多老Oracle驱动不兼容MySQL8.0.22必须支持CHECK约束和窗口函数用于库存周转率计算。application-prod.yml中的关键配置spring: datasource: url: jdbc:mysql://localhost:3306/erp?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruerewriteBatchedStatementstrue # rewriteBatchedStatementstrue 关键批量插入性能提升3倍 jpa: hibernate: ddl-auto: validate # 生产环境严禁updatevalidate只校验不修改表结构 show-sql: false # 日志关掉避免敏感SQL泄露 properties: hibernate: format_sql: false jdbc: batch_size: 50 # 批量操作设为50平衡内存与性能注意ddl-auto: validate是铁律。曾有客户在生产环境误配成update导致一次数据库升级脚本没执行Hibernate自动把warehouse_stock.available_qty字段类型从DECIMAL(10,2)改成VARCHAR库存计算全乱。4.2 并发安全库存扣减的三种锁方案实测对比库存扣减是ERP的生命线我们对比了三种方案在100并发下的表现方案实现方式TPS平均响应时间超卖率运维难度Java synchronizedsynchronized(this)锁整个Service实例422300ms0%低但单机瓶颈Redis分布式锁SET lock:stock:1001 1 NX EX 10186540ms0%中需维护Redis集群MySQL行锁SELECT ... FOR UPDATE32086ms0%低依赖数据库优化最终选择MySQL行锁因为小企业数据库压力本就不大行锁开销可接受不引入Redis单点故障风险错误处理清晰锁超时直接抛异常前端提示“库存繁忙请稍后重试”。实操技巧在warehouse_stock表上为(warehouse_id, product_id)建立联合索引否则FOR UPDATE会锁整张表。我们曾遇到没建索引一个库存扣减操作锁住全表导致采购、销售、调拨全部阻塞。4.3 日常运维如何用5条SQL搞定90%的线上问题ERP上线后最多的问题不是功能bug而是数据异常。源码配套了运维SQL手册DBA或IT人员用Navicat执行即可查今日未审核单据销售/采购/调拨SELECT sale as type, id, customer_name, amount, create_time FROM sale_order WHERE status DRAFT AND DATE(create_time) CURDATE() UNION ALL SELECT purchase as type, id, supplier_name, amount, create_time FROM purchase_order WHERE status SUBMITTED AND DATE(create_time) CURDATE();查库存不一致账实不符SELECT ws.product_id, p.name, ws.warehouse_id, w.name as warehouse_name, ws.available_qty, (SELECT COALESCE(SUM(quantity), 0) FROM stock_log sl WHERE sl.warehouse_id ws.warehouse_id AND sl.product_id ws.product_id AND sl.operation_type IN) - (SELECT COALESCE(SUM(quantity), 0) FROM stock_log sl WHERE sl.warehouse_id ws.warehouse_id AND sl.product_id ws.product_id AND sl.operation_type IN (OUT,TRANSFER_OUT)) as calc_qty FROM warehouse_stock ws JOIN product p ON ws.product_id p.id JOIN warehouse w ON ws.warehouse_id w.id WHERE ABS(ws.available_qty - calc_qty) 0.01;查成本结转异常采购价为0SELECT po.id, po.supplier_name, pr.product_name, pr.unit_price FROM purchase_receipt pr JOIN purchase_order po ON pr.order_id po.id WHERE pr.unit_price 0 OR pr.unit_price IS NULL;查锁定库存超时未释放SELECT * FROM warehouse_stock WHERE locked_qty 0 AND last_lock_time DATE_SUB(NOW(), INTERVAL 30 MINUTE);查报表慢查询TOP5SELECT query, COUNT(*) as cnt, AVG(query_time) as avg_time FROM mysql.slow_log WHERE start_time DATE_SUB(NOW(), INTERVAL 1 DAY) GROUP BY query ORDER BY avg_time DESC LIMIT 5;这些SQL不是凭空写的而是我们帮客户处理了37次线上事故后沉淀下来的。比如第2条曾帮一家汽配厂发现ERP系统因网络中断导致一批入库单没写入stock_log但库存已增加账实差了23万元。4.4 升级扩展从进销存到ERP的演进路径这套源码定位是“进销存ERP骨架”不是终极版。我们预留了清晰的扩展接口财务模块finance包下已有PayableService应付、ReceivableService应收空实现只需补充凭证生成、账龄分析逻辑生产模块production包含Bom物料清单、WorkOrder工单实体BOM展开算法已写好只差MRP运算WMS集成warehouse包中WmsClient类预留了对接主流WMS系统的HTTP接口如调用极智嘉、快仓的API获取实时库位BI看板report模块的v_stock_batch_trace视图可直接对接Superset或Metabase无需改造。最关键的扩展原则新模块必须复用现有库存、商品、供应商主数据。比如生产领料必须走StockService.deductQuantity()而不是自己写SQL更新库存。这样保证数据源头唯一避免“生产系统扣了库存进销存系统不知道”的混乱。我们给一家机械加工厂做的二期升级就是在原进销存基础上3周内接入了生产领料和委外加工模块所有库存变动依然通过同一个StockService财务对账时数据天然一致。5. 常见问题与排查技巧实录那些文档里不会写的血泪教训5.1 “库存明明够下单却提示不足”——你以为的库存和系统算的不是一回事这是最高频问题。用户说“我看库存有100个下单50个怎么提示不够” 查日志发现StockService.deductQuantity()抛出InsufficientStockException。原因往往有三个仓库选错了前端默认选“总部仓”但商品实际在“华东仓”。源码在SaleOrderController里加了强校验PostMapping(/lock-stock) public Result lockStock(RequestBody LockStockRequest request) { // 必须传warehouse_id且该仓库下该商品可用库存需求数量 BigDecimal available stockService.getAvailableStock(request.getWarehouseId(), request.getProductId()); if (available.compareTo(request.getQuantity()) 0) { return Result.fail(仓库[ request.getWarehouseId() ]中商品[ request.getProductId() ]可用库存不足); } // ... }但很多二次开发的人删掉了这个校验或者前端没传warehouse_id参数默认为0查不到库存。批次库存不足商品启用了批次管理但用户没选批次。系统按“所有批次总和”判断够不够实际扣减时按FIFO规则可能最早批次只剩20个不够扣50个。解决方案是前端搜索商品时自动列出各批次可用数强制用户选择。锁定库存未释放用户锁库存后没下单15分钟自动释放但期间网络抖动导致释放请求丢失。这时需要DBA手动执行UPDATE warehouse_stock SET locked_qty 0 WHERE locked_qty 0 AND last_lock_time DATE_SUB(NOW(), INTERVAL 2 HOUR);实操心得上线前必须做“库存压力测试”。用JMeter模拟100用户同时锁同一商品库存观察warehouse_stock表的locked_qty字段是否准确累加、释放。我们曾发现MySQL的READ COMMITTED隔离级别下SELECT ... FOR UPDATE在某些场景会锁错行最终切换到REPEATABLE READ解决。5.2 “采购单审核后应付账款没生成”——状态机断在哪个环节采购单从“已提交”到“已收货”中间有多个状态校验。常见断点收货单没关联采购单前端传参purchaseOrderId为空后端PurchaseReceiptService.createReceipt()没校验导致payable表没插入记录。修复在Service层加Assert.notNull(purchaseOrderId, 采购单ID不能为空)。应付账款生成失败但事务没回滚FinanceService.createPayable()里调用第三方支付接口超时但没捕获异常导致库存已增加应付没生成。源码用Transactional(rollbackFor Exception.class)包裹整个收货流程任何环节失败库存变更和应付生成全部回滚。财务科目配置缺失payable表有account_code字段应付账款科目编码但account_config表里没配置该供应商对应的科目。系统日志只打印“科目未配置”没抛异常。解决方案在createPayable()开头加校验AccountConfig config accountConfigMapper.selectBySupplierId(supplierId); if (config null || StringUtils.isBlank(config.getAccountCode())) { throw new BusinessException(供应商[ supplierId ]未配置应付账款科目请联系财务管理员); }5.3 “报表导出Excel卡死”——别怪POI先看内存配置用Apache POI导出万行报表JVM堆内存不足是常态。源码的ExportService做了三重防护分页导出前端传参pageSize5000后端用PageHelper.startPage(1, 5000)分页查询避免一次性加载10万行到内存流式写入用SXSSFWorkbook而非XSSFWorkbookSXSSFWorkbook只在内存保留100行其余写入临时文件JVM参数强制application-prod.yml中指定spring: profiles: active: prod --- # 生产环境JVM参数建议 # -Xms2g -Xmx2g -XX:MaxMetaspaceSize512m -XX:UseG1GC但很多运维同学直接用java -jar erp.jar启动没加JVM参数。实测导出5万行报表-Xmx1g时OOM-Xmx2g时耗时42秒-Xmx4g时耗时38秒提升有限。所以优先优化SQL比如报表加索引比堆内存更重要。5.4 “Vue前端白屏控制台报错Cannot find module xxx”——Node.js版本陷阱源码前端用Vue3 Vitepackage.json中engines字段声明engines: { node: 16.0.0, npm: 8.0.0 }但很多开发者用Node.js 14.x或12.xVite 3.x不兼容。错误提示模糊只说“module not found”。解决方案只有两个卸载旧Node.js安装Node.js 18 LTS推荐或用nvm管理多版本nvm install 18.17.0 nvm use 18.17.0。踩过的坑曾有个客户用Windows Server 2012自带IE内核前端打包后index.html里的script typemodule不被识别。解决方案是Vite配置build.target: es2015生成兼容ES5的代码但牺牲了Tree Shaking效果。权衡之下我们建议客户升级浏览器而不是降级前端。5.5 “Linux部署后中文文件名导出乱码”——不只是编码问题在CentOS 7上response.setHeader(Content-Disposition, attachment; filename fileName);导出的Excel中文名变成?????.xlsx。这不是Java代码问题而是Linux系统语言环境缺失# 查看当前locale locale # 如果显示LANGPOSIX则修复 sudo localedef -c -i zh_CN -f UTF-8 zh_CN.UTF-8 export LANGzh_CN.UTF-8 export LC_ALLzh_CN.UTF-8更彻底的方案是在/etc/profile末尾添加export LANGzh_CN.UTF-8 export LC_ALLzh_CN.UTF-8然后source /etc/profile。否则即使Java代码用URLEncoder.encode(fileName, UTF-8)Tomcat在Linux下仍会按POSIX编码解析Header。这套源码的FileExportUtil.java里专门写了适配逻辑public static String getDownloadFileName(HttpServletRequest request, String fileName) throws UnsupportedEncodingException { String userAgent request.getHeader(User-Agent); if (userAgent.contains(MSIE) || userAgent.contains(Trident)) { // IE浏览器 return URLEncoder.encode(fileName, UTF-8).replaceAll(\\, %20); } else if (userAgent.contains(Edge)) { // Edge浏览器 return new String(fileName.getBytes(UTF-8), ISO-8859-1); } else { // Chrome, Firefox, Safari return filename*utf-8 URLEncoder.encode(fileName, UTF-8); } }但前提是服务器系统locale必须是UTF-8否则new String(..., ISO-8859-1)依然乱码。6. 写在最后ERP不是软件而是业务规则的数字化契约我见过太多团队花半年时间开发ERP上线后业务部门说“这系统跟我们实际流程不一样”。根源在于开发从没和仓库管理员一起盘过一次库没看销售员怎么手写发货单没听财务抱怨过“月底对账要核三天”。这套Java进销存源码的价值不在于它用了什么高深算法而在于它把五金店老板的一句“货到了先锁住别让别人抢走”翻译成了SELECT ... FOR UPDATE把会计说的“这批货按加权平均算成本”固化成了CostingCalculator.calculateWeightedAverageCost()。如果你正打算用它做毕设别急着改界面先读懂StockService.deductQuantity()里那17行代码——它为什么先查再锁为什么用存储过程而不是Java事务为什么locked_qty和available_qty要分开。这些细节才是ERP的灵魂。如果你是企业IT拿它当原型记住上线前必须做三件事——让仓库人员用真货真单跑一周让财务用它做一次月结让老板用报表看一眼“上月毛利”。系统好不好不看代码行数看业务人员愿不愿意扔掉Excel。最后分享个小技巧源码里application-dev.yml的logging.level.com.erpDEBUG打开后所有库存操作会打印详细日志包括锁了哪行、扣了多少、成本怎么算的。这不是为了调试而是让你看清每一笔生意背后系统到底做了什么。本文还有配套的精品资源点击获取