ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

超市进销存系统源码落地实战:从跑不起来到敢收钱

超市进销存系统源码落地实战:从跑不起来到敢收钱 简介本资源是一套完整的超市进销存管理系统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.version2.7.18/spring-boot.version # mysql.version8.0.33/mysql.version # mybatis-plus.version3.5.3.1/mybatis-plus.version # 若发现 spring-boot.version3.x 但 mybatis-plus.version4.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,adjust2.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?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue 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.javaComponent public class InventoryInitializer implements CommandLineRunner { Autowired private GoodsMapper goodsMapper; Override public void run(String... args) throws Exception { // 检查是否存在商品但库存为 null —— 这是线上事故高发点 ListGoods goodsWithoutStock goodsMapper.selectList(new QueryWrapperGoods() .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是NULLMyBatis-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 InnoDBService 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 QueryWrapperGoods() .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 UpdateWrapperGoods() .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 两个线程同时读到 stock5都判断通过结果扣成 -1lock_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 idupdateAndGetNextSeq UPDATE order_no_generator SET next_seq next_seq 1, update_time NOW() WHERE biz_type #{bizType} AND date_str #{dateStr}; /update select idselectNextSeq resultTypejava.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.23E18引发对账灾难。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 连接串加serverTimezoneAsia/Shanghait_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_time2024052000011001可口可乐23.57.0C0012024-05-20 08:23:152024052000011002奥利奥饼干18.88.8C0012024-05-20 08:23:15清洗规则ticket_id→ 转为order_no前缀SA 日期 6位序号goods_code→ 关联t_goods.code缺失则插入新商品stock_quantity0后续补货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, jsonticket_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[i1][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-20vsPOS 系统导出日报绝对相等Excel 对比并发安全性启动 10 个线程同时回放同一时间段小票无超卖、无重复单据号、无数据库死锁jstack查看线程状态报表响应时间GET /report/daily-sales?date2024-05-20≤ 1.2 秒10 万行数据Chrome DevTools Network我自己的习惯每次上线前我会把回放脚本跑 3 遍——第一遍看功能第二遍看性能第三遍专门盯着t_inventory_log表逐条核对biz_type和change_quantity是否与小票完全一致。这很枯燥但比上线后被老板电话轰炸强一万倍。有一次我发现退货单的change_quantity是正数应该为负追查发现源码把“退货”和“换货”逻辑混在一起花了 4 小时重写了整个退换货模块。希望帮到你。本文还有配套的精品资源点击获取
返回列表