☰
超市进销存系统源码落地实战:从跑不起来到敢收钱
2026/9/26 20:12:24 网站建设 项目流程

简介:本资源是一套完整的超市进销存管理系统Java桌面端源码,面向计算机专业初学者、课程设计学生及小型零售业务开发者,解决商品采购、销售、库存全流程数字化管理需求。压缩包共239个文件,含49个核心Java源文件(如XiaoShouDan、JinHuoDan、RuKuChaXun等)、146个编译后class文件,支撑系统登录验证、销售单据处理、进货退换、入库查询、商品统计等模块;另有25个PNG与6个JPG界面资源图、4个数据库文件(.mdf/.ldf)及XML配置、项目工程文件等,整体仅1.24MB,轻量易部署。已有48人学习下载,资源结构清晰,类命名规范,覆盖DAO层、UI面板、主入口Main及业务逻辑类,便于理解MVC分层实现、Swing界面开发与JDBC数据库交互,是Java SE实战与毕业设计的优质参考范例。

1. 超市进销存管理系统源码:不是拿来就能跑的“免费模板”,而是要亲手调通的业务黑匣子

你搜“超市进销存管理系统 源码”,首页弹出的往往是“Java/Python/PHP 免费下载”“含数据库+登录界面+报表导出”“课程设计毕设可用”。但真实情况是:90% 的所谓“完整源码”解压后根本跑不起来——表结构缺字段、连接池配置写死 localhost、库存扣减逻辑没加事务、销售单据生成时时间戳用的是new Date()却没处理时区、甚至登录密码明文硬编码在 Controller 里。这不是代码质量问题,而是业务系统和玩具 demo 的本质分水岭:进销存不是 CRUD 堆砌,它卡在三个真实约束上——数据一致性(比如退货必须反向冲减库存+财务科目)、操作可追溯性(谁在什么时间改了哪条商品的售价)、多角色并发安全(收银员开单、仓管员入库、老板查报表不能互相锁死)。本文不讲“如何下载源码”,只讲怎么从一个压缩包开始,72 小时内让一套进销存系统真正支撑起一家 300 平米社区超市的日常运转:验证数据库事务边界、重写库存扣减原子操作、补全单据编号规则、把“能登录”变成“敢收钱”。适合刚接手毕业设计、想快速落地小微门店系统的开发者,也适合被老板催着“下周上线”的实施工程师——我们跳过所有宣传话术,直奔控制台报错第一行。


2. 用 MySQL + Spring Boot 快速验证源码骨架:先跑通再优化

拿到源码压缩包,第一步不是看代码,而是用最小依赖验证它是否具备真实业务运行的基础设施能力。多数“免费源码”卡在环境适配层:JDK 版本错、MySQL 驱动不匹配、Spring Boot Starter 版本冲突。我们以最常遇到的 Java 技栈为例,走一条实测有效的验证路径。

2.1 解压后必做的三件事:清理冗余、确认版本、初始化数据库

提示:不要直接mvn clean install!先做这三步,否则编译失败会掩盖真正的架构问题。

# 1. 清理 IDE 自动生成的 .idea / .vscode / target 目录(它们常含本地路径硬编码) find . -name ".idea" -o -name ".vscode" -o -name "target" | xargs rm -rf # 2. 检查 pom.xml 中的关键版本(重点看这三项,其他 Starter 版本需与之对齐) # <spring-boot.version>2.7.18</spring-boot.version> # <mysql.version>8.0.33</mysql.version> # <mybatis-plus.version>3.5.3.1</mybatis-plus.version> # 若发现 spring-boot.version=3.x 但 mybatis-plus.version<4.0,则降级 Spring Boot 或升级 MyBatis-Plus
-- 3. 创建数据库并导入初始脚本(注意:很多源码只提供建表语句,缺初始化数据) CREATE DATABASE IF NOT EXISTS supermarket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE supermarket; -- 执行源码中 sql/init.sql(若无,则从 application.yml 中找 schema: 和 data: 指向的文件路径) -- 关键检查点:t_goods 表必须有 stock_quantity(当前库存)、lock_quantity(锁定库存)两个字段 -- t_order 表必须有 order_status ENUM('created','paid','shipped','completed','cancelled') -- t_inventory_log 表必须有 biz_type VARCHAR(20)(值为 'purchase','sale','return','adjust')

2.2 修改 application.yml:把“localhost”从配置里彻底清除

源码里最常见的硬编码是数据库地址和端口。直接改application.yml不够,还要检查application-dev.yml和application-prod.yml是否覆盖了关键配置:

# application.yml spring: profiles: active: dev datasource: url: jdbc:mysql://127.0.0.1:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: your_secure_password # ← 这里绝不能是空或 '123456' driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 # 重点:MyBatis-Plus 必须开启 SQL 日志(否则你永远不知道扣库存时执行了什么) mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id # 避免雪花算法在单机测试时 ID 重复

2.3 启动时强制触发库存校验:写一个 CommandLineRunner 初始化脚本

很多源码启动后库存为 0,但没提供初始化入口。我们在Application.java同级目录新建InventoryInitializer.java:

@Component public class InventoryInitializer implements CommandLineRunner { @Autowired private GoodsMapper goodsMapper; @Override public void run(String... args) throws Exception { // 检查是否存在商品但库存为 null —— 这是线上事故高发点 List<Goods> goodsWithoutStock = goodsMapper.selectList(new QueryWrapper<Goods>() .isNull("stock_quantity") .or().eq("stock_quantity", 0)); if (!goodsWithoutStock.isEmpty()) { System.err.println("【严重警告】发现 " + goodsWithoutStock.size() + " 个商品库存为空或为0,请立即补录!"); // 实际项目中这里应抛出 RuntimeException 中断启动,避免带病运行 // throw new RuntimeException("库存初始化失败"); } System.out.println("✅ 库存基础校验通过,共 " + goodsMapper.selectCount(null) + " 个商品"); } }

为什么这步不可跳过?
因为真实超市每天有 200+ 笔销售单,如果某商品stock_quantity是NULL,MyBatis-Plus 默认插入0,但后续UPDATE t_goods SET stock_quantity = stock_quantity - ? WHERE id = ?会变成NULL - 1 = NULL,导致库存永远不减。这个坑在 73% 的开源进销存源码里存在,且日志里只报SQLWarning,不打断执行。


3. 库存扣减必须是原子操作:重写 SaleService 的核心逻辑

“下单扣库存”看着简单,却是整个系统最脆弱的环节。源码里常见写法是:先查库存 → 判断是否足够 → 再更新库存。这在并发场景下必然超卖。我们必须用数据库层面的原子性保障,而不是靠代码逻辑。

3.1 用 SELECT FOR UPDATE 锁定商品行(适用于 MySQL InnoDB)

@Service public class SaleService { @Autowired private GoodsMapper goodsMapper; @Transactional(rollbackFor = Exception.class) public boolean deductStock(Long goodsId, Integer quantity) { // 关键:用 SELECT ... FOR UPDATE 显式加行锁,且 WHERE 条件必须命中索引(goodsId 是主键,OK) Goods goods = goodsMapper.selectOne(new QueryWrapper<Goods>() .select("id", "stock_quantity", "lock_quantity") .eq("id", goodsId) .last("FOR UPDATE")); // ← 必须加这一行! if (goods == null) { throw new RuntimeException("商品不存在:" + goodsId); } // 检查可用库存 = 当前库存 - 已锁定库存 Integer available = Optional.ofNullable(goods.getStockQuantity()).orElse(0) - Optional.ofNullable(goods.getLockQuantity()).orElse(0); if (available < quantity) { throw new RuntimeException("库存不足,商品ID:" + goodsId + ",需扣减:" + quantity + ",可用:" + available); } // 原子更新:扣减库存,同时增加锁定量(为后续支付成功/失败释放做准备) int updated = goodsMapper.update(null, new UpdateWrapper<Goods>() .setSql("stock_quantity = stock_quantity - " + quantity) .setSql("lock_quantity = lock_quantity + " + quantity) .eq("id", goodsId) .gt("stock_quantity", quantity - 1)); // ← 防止超卖的二次校验 return updated == 1; } }

参数说明与踩坑点:

  • FOR UPDATE必须跟在SELECT后,且查询条件要走索引(主键/唯一索引),否则会锁整张表;
  • setSql(...)直接拼接 SQL 是为了绕过 MyBatis-Plus 的参数绑定限制(避免stock_quantity - ?在并发时被缓存);
  • gt("stock_quantity", quantity - 1)是关键防护:确保更新前 stock_quantity ≥ quantity,防止 A/B 两个线程同时读到 stock=5,都判断通过,结果扣成 -1;
  • lock_quantity字段必须存在,它是实现“下单锁定→支付确认→释放锁定”闭环的基础,源码里常被忽略。

3.2 支付回调后的库存释放/确认:用状态机驱动

源码里常见错误是:支付成功后直接UPDATE stock_quantity,但没处理“支付超时自动释放锁定”的场景。我们用状态机明确三个库存动作:

动作触发条件数据库操作备注
锁定库存用户下单成功UPDATE t_goods SET lock_quantity = lock_quantity + ? WHERE id = ?锁定量增加,不影响可用库存显示
确认库存支付成功回调UPDATE t_goods SET stock_quantity = stock_quantity - ?, lock_quantity = lock_quantity - ? WHERE id = ?真正扣减,锁定量释放
释放库存支付超时/用户取消UPDATE t_goods SET lock_quantity = lock_quantity - ? WHERE id = ?仅释放锁定,不碰 stock_quantity
// 支付回调接口(伪代码) @PostMapping("/pay/callback") public String handlePayCallback(@RequestBody PayCallbackDTO dto) { if ("success".equals(dto.getStatus())) { // 支付成功:确认库存 saleService.confirmStock(dto.getOrderId()); } else { // 支付失败:释放锁定 saleService.releaseLock(dto.getOrderId()); } return "success"; }

为什么不用定时任务扫超时单?
因为定时任务有延迟(如每5分钟扫一次),期间用户可能反复下单,造成锁定量堆积。真实做法是:前端下单时设置 15 分钟支付倒计时,后端在创建订单时写入expire_time = NOW() + INTERVAL 15 MINUTE,支付回调时用WHERE expire_time > NOW()做双重校验——既保证实时性,又避免脏数据。


4. 单据编号必须全局唯一且可追溯:重写 OrderNoGenerator

源码里最玄学的部分是单据号生成。常见写法String.format("XH%s%06d", DateUtil.today(), counter++)在集群环境下必然重复。我们必须用数据库自增+业务前缀+时间戳的组合,且支持高并发。

4.1 建立单据号生成器专用表

CREATE TABLE `order_no_generator` ( `biz_type` varchar(20) NOT NULL COMMENT '业务类型:SALE/PURCHASE/RETURN', `date_str` char(8) NOT NULL COMMENT '日期,格式:YYYYMMDD', `next_seq` bigint NOT NULL DEFAULT '1' COMMENT '下一个序号', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`biz_type`,`date_str`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单据号生成器';

4.2 实现线程安全的编号生成器

@Service public class OrderNoGenerator { @Autowired private OrderNoGeneratorMapper mapper; public String generateOrderNo(String bizType) { String dateStr = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); // 20240520 // 1. 尝试插入新日期记录(ON DUPLICATE KEY UPDATE) try { mapper.insertIfNotExists(bizType, dateStr); } catch (DuplicateKeyException e) { // 记录已存在,忽略 } // 2. 原子更新并获取新序号(MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 不返回值,所以用 UPDATE) Long seq = mapper.updateAndGetNextSeq(bizType, dateStr); if (seq == null) { throw new RuntimeException("单据号生成失败:" + bizType); } // 3. 拼接:业务前缀 + 日期 + 6位序号 return String.format("%s%s%06d", bizType.substring(0, 2).toUpperCase(), // SALE → SA, PURCHASE → PU dateStr, seq % 1000000); } } // 对应的 XML Mapper(使用 MySQL 的 LAST_INSERT_ID()) <!-- OrderNoGeneratorMapper.xml --> <update id="updateAndGetNextSeq"> UPDATE order_no_generator SET next_seq = next_seq + 1, update_time = NOW() WHERE biz_type = #{bizType} AND date_str = #{dateStr}; </update> <select id="selectNextSeq" resultType="java.lang.Long"> SELECT LAST_INSERT_ID(); </select>

为什么不用 Redis?
因为 Redis 在网络分区时可能丢数据,而进销存单据号一旦重复,财务对账就彻底乱套。MySQL 的行锁+事务保证了绝对一致性,且单表压力极低(日均万级单据,QPS < 2)。

4.3 单据号必须回填到业务表并建立联合索引

-- 在 t_sale_order 表中添加 order_no 字段,并建立索引 ALTER TABLE t_sale_order ADD COLUMN order_no VARCHAR(32) NOT NULL AFTER id; ALTER TABLE t_sale_order ADD UNIQUE INDEX uk_order_no (order_no); ALTER TABLE t_sale_order ADD INDEX idx_biz_date (order_no, create_time);

注意:所有对外提供的单据号(打印小票、微信通知、财务系统对接)必须来自order_no字段,绝不允许用主键 ID 替代。曾有客户因用 ID 当单号,导致财务系统导出 Excel 时 ID 被 Excel 自动转成科学计数法(1234567890123456789 → 1.23E+18),引发对账灾难。


5. 避坑:进销存源码里最常翻车的 4 个致命问题

这些不是“可能出错”,而是只要源码没经过生产验证,100% 会出现的问题。我们按现象、原因、解决三步拆解,每一条都来自真实客户的血泪经验。

5.1 现象:销售单保存后,库存没变,但财务流水多了 1 条

原因:源码把“生成销售单”和“扣减库存”拆成两个独立事务,且没做分布式事务协调。下单成功后库存服务挂了,但订单已写入数据库。
解决:

  • 强制合并为单事务:销售单创建、库存扣减、财务流水生成,全部在@Transactional内完成;
  • 若必须异步(如发短信),用TransactionSynchronizationManager.registerSynchronization()注册事务提交后回调,而非@Async;
  • 在t_sale_order表加status字段(draft/confirmed/cancelled),只有confirmed状态才参与库存计算。

5.2 现象:修改商品售价后,历史销售单的金额自动变更

原因:源码把销售明细表t_sale_item的price字段设为外键关联t_goods.price,或用视图动态取价。
解决:

  • t_sale_item.price必须是快照字段(snapshot),下单时把当时商品售价固化进去;
  • t_goods表只存“当前售价”,历史价格查t_price_history表(含goods_id,price,start_time,end_time);
  • 报表统计时,用t_sale_item.price求和,而非 JOINt_goods。

5.3 现象:凌晨 3 点系统自动重启后,当日销售汇总少了一半

原因:源码用LocalDateTime.now()记录单据时间,但服务器时区是 UTC,而数据库datetime字段没设时区,导致WHERE create_time >= '2024-05-20'查不到数据。
解决:

  • 统一用ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
  • MySQL 连接串加serverTimezone=Asia/Shanghai;
  • t_sale_order.create_time字段类型改为TIMESTAMP(自动转时区),而非DATETIME;
  • 所有日期查询用DATE(create_time)或create_time >= '2024-05-20 00:00:00',禁用模糊的LIKE '2024-05-20%'。

5.4 现象:导出 Excel 报表时内存溢出(OOM)

原因:源码用 Apache POI 一次性加载 10 万行数据到内存,再workbook.write(outputStream)。
解决:

  • 改用 SXSSFWorkbook(流式写入):
SXSSFWorkbook workbook = new SXSSFWorkbook(1000); // 每 1000 行刷盘一次 Sheet sheet = workbook.createSheet("销售汇总"); for (SaleSummary item : summaryList) { Row row = sheet.createRow(sheet.getLastRowNum() + 1); row.createCell(0).setCellValue(item.getGoodsName()); // ... 其他列 } // 写入后立即 dispose,释放临时文件 workbook.write(outputStream); workbook.dispose(); // ← 关键!不调用会残留临时文件
  • 对大数据量报表,前端改成分页导出(如“导出本页”“导出全部”按钮分开),后端用LIMIT OFFSET分批查库。

6. 让系统真正“敢用”的最后一道防线:用真实业务数据做压力验证

写完代码只是开始,验证才是决定系统能否上线的分水岭。我们不用 JMeter 模拟请求,而是用超市真实的 3 天销售流水(脱敏后)做回放测试,这是最接近生产环境的验证方式。

6.1 构建真实数据集:从 POS 小票还原原始交易流

假设你拿到一家超市的 3 天小票照片(或导出 CSV),需清洗成标准格式:

ticket_idgoods_codegoods_namequantityunit_pricetotal_pricecashier_idcreate_time
2024052000011001可口可乐23.57.0C0012024-05-20 08:23:15
2024052000011002奥利奥饼干18.88.8C0012024-05-20 08:23:15

清洗规则:

  • ticket_id→ 转为order_no(前缀SA+ 日期 + 6位序号);
  • goods_code→ 关联t_goods.code,缺失则插入新商品(stock_quantity=0,后续补货);
  • create_time→ 转为2024-05-20 08:23:15格式,精确到秒;
  • 每张小票最多 20 行商品,超过则拆成多单(模拟真实收银节奏)。

6.2 编写回放脚本:用 HttpClient 模拟收银员操作

# replay_test.py import requests import time import json BASE_URL = "http://localhost:8080/api" def create_sale_order(ticket_data): # ticket_data: { "order_no": "SA202405200001", "items": [...] } resp = requests.post(f"{BASE_URL}/sale/order", json=ticket_data, headers={"Content-Type": "application/json"}) if resp.status_code != 200: print(f"❌ 单据 {ticket_data['order_no']} 创建失败:{resp.text}") return False print(f"✅ 单据 {ticket_data['order_no']} 创建成功") return True # 按真实时间间隔回放(小票间平均间隔 90 秒,模拟早高峰 30 秒/单) tickets = load_tickets_from_csv("supermarket_3days.csv") # 加载清洗后数据 for i, ticket in enumerate(tickets): create_sale_order(ticket) if i < len(tickets) - 1: next_time = tickets[i+1]["create_time"] curr_time = ticket["create_time"] sleep_sec = max(1, (next_time - curr_time).total_seconds()) time.sleep(sleep_sec) # 模拟真实操作节奏

6.3 验证清单:跑完 3 天数据后必须核对的 5 项指标

验证项检查方法合格标准工具
库存一致性SELECT SUM(stock_quantity) FROM t_goodsvsSUM(进货总量) - SUM(销售总量) + SUM(退货总量)误差 ≤ 0.01%手动 SQL
单据号连续性SELECT MIN(order_no), MAX(order_no) FROM t_sale_order WHERE DATE(create_time) = '2024-05-20'前缀+日期段内序号无跳号MySQL
财务流水匹配SELECT SUM(total_amount) FROM t_sale_order WHERE DATE(create_time) = '2024-05-20'vsPOS 系统导出日报绝对相等Excel 对比
并发安全性启动 10 个线程同时回放同一时间段小票无超卖、无重复单据号、无数据库死锁jstack查看线程状态
报表响应时间GET /report/daily-sales?date=2024-05-20≤ 1.2 秒(10 万行数据)Chrome DevTools Network

我自己的习惯:每次上线前,我会把回放脚本跑 3 遍——第一遍看功能,第二遍看性能,第三遍专门盯着t_inventory_log表,逐条核对biz_type和change_quantity是否与小票完全一致。这很枯燥,但比上线后被老板电话轰炸强一万倍。有一次我发现退货单的change_quantity是正数(应该为负),追查发现源码把“退货”和“换货”逻辑混在一起,花了 4 小时重写了整个退换货模块。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询