简介:本资源是一套完整的超市进销存管理系统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_id | goods_code | goods_name | quantity | unit_price | total_price | cashier_id | create_time |
|---|---|---|---|---|---|---|---|
| 202405200001 | 1001 | 可口可乐 | 2 | 3.5 | 7.0 | C001 | 2024-05-20 08:23:15 |
| 202405200001 | 1002 | 奥利奥饼干 | 1 | 8.8 | 8.8 | C001 | 2024-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 小时重写了整个退换货模块。希望帮到你。
本文还有配套的精品资源,点击获取