☰
收银系统源码实战:数据模型、事务与库存扣减避坑
2026/10/14 23:59:00 网站建设 项目流程

简介:数据库设计是业务系统的基石,而事务一致性则保障了数据在并发场景下的可靠。在零售收银场景中,商品、订单与明细如何建模直接影响后续的报表与对账;结算流程中事务边界不清则易引发库存超卖、金额误差。本文围绕收银系统源码,从数据模型的三表拆分、JDBC事务内完成加购扣库存、金额以'分'存储等工程实践切入,剖析扫码枪输入、小票打印等落地细节,并提供对账SQL与高频避坑指南,为课程设计与入门开发者提供可复用的实现参考。

1. 超市收银系统源码:先看表,再谈运行

拿到一份超市收银系统源码,先别急着启动。这类项目在课设和练手场景里流传极广,压缩包打开往往是一堆包名、几个界面类加一串依赖,能跑通是运气,跑不通是常态。真正决定这套源码能不能用的,是三层东西:数据模型、结算主流程、库存与金额的边界处理。源码里的界面只是外壳,把商品、订单、订单明细这三张表的关系理顺,再确认扫码进店、结算、扣库存这条链路没有断点,这套代码才算真正掌握。这篇笔记适合两类人:正在改课设的在校生,和想快速搭一个单机收银 Demo 验证流程的入门开发者。

2. 收银系统源码的核心是数据模型:商品、订单、订单明细三张表怎么建

2.1 判断源码能不能用的第一步:找关键词

收银系统源码的类再多,收敛到最后还是围着几个表名转。我拿到源码的第一件事,不是打开界面类看布局,而是按住 Ctrl+F 搜三个关键词:product、orders、order_item(或者order_detail)。这三个词同时出现,说明作者至少理解了“一笔订单对应多行商品明细”这个基础关系。

很多源码表面上界面齐全,打折、会员、换购都有入口,但点开数据库脚本一看,只有一个goods表加一个sale_record表,sale_record里用逗号拼接商品 ID 和数量。这种设计在小票打印和库存回滚时极其痛苦。所谓“收银系统的核心是数据模型”,指的就是订单头和订单明细必须拆成两张表:orders记录一笔交易的整体信息,order_item记录这一笔交易里每个商品、每行金额。没有这层拆分,后面做对账、退换货、按时间段统计单品销量,全都要写字符串切割代码。

面向对象的同学到这里常问一句:为什么不能直接在内存里用List<CartItem>表示订单,存盘时序列化成一个 BLOB?可以,但如果数据库里查不到“某天卖了几个可乐”这种问题,收银系统就失去了报表和审计能力。脱离表结构谈收银功能,等于让数据模型适配界面,方向反了。

2.2 三张核心表的字段设计与选择理由

三张表的字段选择,决定了后续所有代码的写法。product表要关注四个字段:barcode、price_cents、stock、status。条码是扫码枪查询的唯一入口,必须建唯一索引;价格单位建议直接用整数“分”,而不是浮点数“元”,理由后面避坑部分展开;status用来做下架标记,删除商品不能物理 DELETE,否则历史订单的明细关联会断裂。

orders表需要把order_no单独拿出来说。订单号不能用数据库自增 ID 直接展示给收银员,一是容易暴露当日单量,二是自增 ID 在数据迁移、分表后可能重复。常见做法是用时间戳加随机后缀生成,业务上叫“流水号”。total_cents是冗余字段,因为列表页展示订单金额时不需要 JOIN 明细表逐行求和,但冗余是有代价的,它必须始终等于明细表的quantity * price_cents之和,这条约束靠后面对账脚本保证。

order_item表的关键点是快照价格。明细里存price_cents必须是下单那一刻商品表里的价格,而不是 JOINproduct表实时查出来的价格。商品改价以后,历史订单的金额不能被影响,这是对账和审计的基本要求。字段清单如下:

表核心字段关键约束说明
productbarcode, name, price_cents, stock, statusbarcode 唯一价格单位用分,避免浮点误差
ordersorder_no, total_cents, pay_type, create_timeorder_no 唯一冗余总价,列表页免 JOIN
order_itemorder_id, product_id, quantity, price_centsorder_id 索引保存下单时刻的价格快照

2.3 建表 SQL 与两组初始化数据

以下 SQL 是收入系统最常用的建表脚本。去掉了一些复杂项目的会员、优惠券字段,保留收银链路最核心的部分。MySQL 8.x 和 5.7 都能直接执行。

CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(32) NOT NULL UNIQUE COMMENT '商品条码', name VARCHAR(64) NOT NULL COMMENT '商品名称', price_cents INT NOT NULL DEFAULT 0 COMMENT '售价,单位分', stock INT NOT NULL DEFAULT 0 COMMENT '库存数量', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单流水号', total_cents INT NOT NULL COMMENT '订单总额,单位分', pay_type VARCHAR(16) NOT NULL DEFAULT 'cash' COMMENT 'cash现金 / card刷卡 / alipay扫码', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL COMMENT '订单ID', product_id INT NOT NULL COMMENT '商品ID', quantity INT NOT NULL COMMENT '购买数量', price_cents INT NOT NULL COMMENT '成交单价快照,单位分', KEY idx_order (order_id) );

建表时有两点要注意。第一,price_cents用INT而不是DECIMAL(10,2),各有各的道理,整数在 Java 和 JavaScript 里都不存在精度问题,对账脚本里求和、比较也只会出现整数运算。第二,order_item的order_id一定要建普通索引,不用复合索引。业务查询大多是从订单查明细,或从商品查销售记录,复合索引在这个场景收益极小。初始化数据也只给商品表提供两组即可,库存留足余量:

INSERT INTO product (barcode, name, price_cents, stock) VALUES ('6901234567890', '550ml矿泉水', 200, 100), ('6901234567891', '原味薯片', 650, 50);

3. 收银主流程怎么写:从扫码到小票的完整代码路径

3.1 扫码枪输入为什么能当键盘用

很多第一次接触收银系统的人都会问一个问题:扫码枪扫出来的数据,到底是从哪里进到程序里的?答案是键盘缓冲区。市面上绝大多数 USB 扫码枪被系统识别为标准键盘设备,扫一个条码,等价于以极快的速度敲击一串数字或字母,最后自动补一个回车键。这套机制决定了前端不用做任何特殊协议对接。

我在做这类桌面收银系统时,商品输入框直接监听回车键提交,不需要“确定”按钮。原因很实际:收银员的手在扫码枪和键盘之间来回切换,多按一个按钮,高峰期就会多浪费零点几秒。代码也不复杂:

inputField.addActionListener(e -> { long now = System.currentTimeMillis(); if (now - lastScanTime < 300) { return; // 防抖:扫码枪回车和按钮事件可能双触发 } lastScanTime = now; String barcode = inputField.getText().trim(); addToCart(barcode); // 按条码查商品并加入购物车 inputField.setText(""); });

这段代码的逻辑分三层:addActionListener把输入框的回车事件绑定到加购方法;trim()去掉扫码枪尾部可能带上的空格或换行;lastScanTime是一个时间戳成员变量,用来过滤 300 毫秒内的重复触发。这里先埋一个伏笔,重复触发的问题在避坑章节还会详细展开。

3.2 结算核心方法:一个事务里完成加购、扣库存、生成订单

结算方法是整个收银系统源码里最不能写错的部分。我见过太多代码把“写订单表”“扣库存”“写明细表”拆成三个独立方法在界面层挨个调用,结果第二步抛异常,订单有了但库存没扣,或者明细不全。正确做法是把整单结算收敛到一个事务方法里,要么全部成功,要么全部回滚。

以下是一个 JDBC 手写事务的结算方法,去掉了数据源框架,保留事务边界,方便直接对照理解:

public void checkout(List<CartItem> cart) { Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); int totalCents = 0; String orderNo = genOrderNo(); // 时间戳 + 随机后缀 for (CartItem item : cart) { Product p = findById(conn, item.getProductId()); if (p == null || p.getStatus() != 1) { throw new RuntimeException("商品不存在或已下架:" + item.getBarcode()); } if (item.getQuantity() > p.getStock()) { throw new RuntimeException("库存不足:" + p.getName()); } int subtotal = p.getPriceCents() * item.getQuantity(); insertOrderItem(conn, orderNo, p.getId(), item.getQuantity(), p.getPriceCents()); int updated = updateStock(conn, p.getId(), item.getQuantity()); if (updated == 0) { throw new RuntimeException("库存扣减失败:" + p.getName()); } totalCents += subtotal; } insertOrder(conn, orderNo, totalCents, "cash"); conn.commit(); printReceipt(orderNo, totalCents, cart); } catch (Exception e) { conn.rollback(); throw e; } }

逐段解释关键参数和逻辑。setAutoCommit(false)是事务的起点,后面所有 SQL 必须等commit()才真正落盘。genOrderNo()生成订单流水号,我对它的要求是 19 位以内,时间戳到毫秒加三位随机数,库里再加唯一约束,双保险。insertOrderItem里保存的priceCents来自p的当前价格,而不是前端传过来的价格,前端只能传商品 ID 和数量,价格必须由服务端查表确定,否则改价漏洞会出现。

updateStock是库存扣减的关键 SQL,写成UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?。这里不是在 Java 里先读出stock再减一写回,而是把“库存够不够”的判断交给数据库完成,后面避坑章节会详细展开。事务方法中任何一步抛出异常,rollback()会把前面插入的明细、扣减的库存全部撤回。

3.3 找零、小票和界面之间的逻辑划分

结算完成后,找零属于界面层逻辑,不属于服务端。收银员在界面输入实收金额,界面自己算change = cashPaid - totalCents,这个动作不碰数据库。真正碰数据库的只有“生成订单和扣减库存”那一笔事务。这个边界要划清楚,很多源码把实收金额和找零也存进订单表,反而让对账时多出一堆空字段。

小票打印是收银系统里“看起来简单、做起来浑身是坑”的环节。58mm 热敏纸一行能容纳的字符数有限,一般是 32 个西文字符宽度,中文要按双字节宽度计算。打印内容就是字符串拼接,但在 Java 里最常犯的错是打印输出流用了默认编码,导致热敏打印机上的中文变成乱码:

PrintStream out = new PrintStream(socket.getOutputStream(), true, "GBK"); out.println("欢迎光临"); out.printf("%-10s %4d %8.2f%n", "商品名", quantity, price / 100.0);

GBK是热敏打印机最常见的支持编码。%-10s左对齐商品名,不足 10 个字符补空格,%8.2f右对齐金额。这里的参数需要按实际小票宽度反复调,宽了换行,窄了数字挤压。如果商品名超长,要做截断处理,中文按两字符宽度截,否则打印出来列对不齐。

4. 把源码跑起来:本地部署步骤、配置改动清单与启动日志排查

4.1 Spring Boot 和 Swing 版怎么选:决定你接下来三小时的体验

超市收银系统源码在网上流传的版本大致分两类:一类是 Java Swing 桌面版,双击或命令启动,界面停留在本地窗口;另一类是 Spring Boot + Vue 的 Web 版,浏览器访问。选哪个不是越新越好,而是看你要拿去干什么。

如果是课程设计或单人练手,我建议选 Swing 版。理由很直接:无需前后端分离,不必同时维护两个工程,一个 main 方法跑起来,数据库只要一个 MySQL。Web 版看着高级,但部署链路过长,前端 node 依赖、后端 Maven 依赖、跨域配置、端口占用,任何一个环节出问题都会耗掉大半天。如果你所在的环境要求必须用 Web 技术栈展示,那再考虑 Spring Boot 版。

判断你拿到的是哪一版,不用看文档,看工程目录就够了。Swing 版通常是一堆.java文件加一个Main.java;Web 版有pom.xml和src/main/resources/application.yml。接下来的部署步骤以 Swing 版为例写,因为它的配置项更集中,踩坑规律更通用。

4.2 建库导数据:SQL 文件执行的两种方式和环境检查

源码里一般自带一个supermarket.sql或db.sql。拿到后先别急着执行,用文本编辑器打开看编码,凡是中文注释乱码的 SQL 文件,执行前先转成 UTF-8。用命令行导入最稳:

mysql -u root -p < supermarket.sql

导入成功后执行SHOW TABLES;,确认三张表都在。如果输入mysql命令提示找不到,是 MySQL 的bin目录没加到 PATH 环境变量,直接用绝对路径或打开 MySQL 安装目录下的命令行工具即可。如果导入时报Unknown database 'supermarket',说明 SQL 文件里没有CREATE DATABASE语句,需要手动先建库再导:

mysql -u root -p -e "CREATE DATABASE supermarket DEFAULT CHARSET utf8mb4;" mysql -u root -p supermarket < supermarket.sql

这里有一个高频翻车点:MySQL 8.x 默认认证插件是caching_sha2_password,老版本的 JDBC 驱动不认,会报Unable to load authentication plugin。解决方式是换 8.x 对应版本的驱动包,或者在 MySQL 里把账号认证方式改回mysql_native_password。我一般直接换驱动,因为改账号认证会影响其他工具连接。

4.3 配置文件要改的四处:数据库、时区、文件路径与打印参数

Swing 版源码的配置通常集中在db.properties或application.properties里。需要改的核心项如下:

jdbc.url=jdbc:mysql://localhost:3306/supermarket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.user=root jdbc.password=你的数据库密码 printer.width=32

第一行的characterEncoding=utf8控制中文不乱码,serverTimezone=Asia/Shanghai是 MySQL 8.x 的硬性要求,不写会报时区错误。useSSL=false是为了消除本地连接的 SSL 警告。printer.width是小票打印宽度,按打印机的实际规格调整,58mm 纸对应 32,80mm 纸对应 48。

除了数据库配置,还要检查源码里是否有硬编码的文件路径。比如商品图片默认从D:/images读取,换个电脑就是 FileNotFound。这类路径搜索代码里的/或盘符字符,改成相对路径。激进一点的做法是用用户目录拼路径,System.getProperty("user.home") + "/supermarket/images",这样换电脑不炸。

4.4 启动失败的三个高频信号:看日志里的哪一行

启动不成功时,不要瞎调。先看控制台输出,高频报错就三种。

信号一:ClassNotFoundException: com.mysql.cj.jdbc.Driver。这意味着 JDBC 驱动包没在 classpath 里。很多源码只给了源码文件,lib 目录里的驱动 jar 是空的。解决方式是去本机 Maven 仓库或网上下载对应版本的mysql-connector-java,放进工程引用的 lib 目录。

信号二:Access denied for user 'root'@'localhost'。配置文件的密码和本机 MySQL 不一致,或者 MySQL 服务没启动。后者在 Windows 上表现为Communications link failure,先到系统服务里确认 MySQL 服务在运行。

信号三:Unknown column 'create_time' in 'field list'。代码和数据库表结构对不上。常见于源码里的实体类/表结构是旧版本,而导入的 SQL 是新版本,或反过来。这种情况不要手工改代码硬编译,直接把 SQL 里的建表语句和实体类的字段名对齐,缺哪个补哪个。

5. 避坑指南:金额精度、库存并发和扫码输入的三个经典翻车现场

5.1 浮点数的奇妙旅程:double 结算多出 0.0000000001 元

现象:用double计算金额,找零变成 9.99999999999999 元,库存金额对账差几分钱。

原因:double是二进制浮点数,0.1 在二进制里无法精确表示,参与多次乘加运算后误差被放大。收银系统最敏感的就是金额,这种误差在单笔上不明显,累积到一天几百笔订单的对账里必然暴露。

解决:金额全部用整数“分”存储和计算,只在显示时转一次元。入库时用BigDecimal从字符串构造,乘以 100 转成int,或者干脆前端就传“分”。我在项目里定了一条规矩:所有 Java 代码里禁止出现double price,接口参数和数据库字段全部整数。

5.2 库存扣减被覆盖:不加条件更新,超卖随之而来

现象:同一商品,两个收银台同时结账,库存从 10 变成 9,而不是 8。

原因:代码是先把stock查出来,在 Java 里减 1,再写回数据库。两个请求同时执行时,后写回的覆盖先写回的,丢失一次扣减。这类问题叫更新丢失,本地单机测试几乎复现不出来,两台机器同时操作才暴露。

解决:库存扣减必须写成条件更新,UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?,让数据库判断库存是否充足,返回受影响行数为 0 就说明库存不够,直接回滚整单。同时给product表的stock字段加>= 0的检查约束兜底,双保险才能拦住所有入口。

5.3 扫码枪输入多一个回车造成重复加购

现象:扫一次条码,购物车出现两行相同商品,小票和库存都被双倍影响。

原因:扫码枪扫完码默认发送一个回车。如果商品输入框绑定了回车事件,同时窗体又把某个按钮设为默认按钮,回车会同时触发两个动作。第一次调试时非常像玄学,实际是事件双触发。

解决:给输入框的加购事件加时间戳防抖,300 毫秒内重复触发直接忽略。另外检查窗体里是否有按钮setDefaultButton(),有的话去掉。这个 300 毫秒参数不要设太大,否则人工快速扫码时会被误拦。

5.4 小票中文乱码、列宽对不齐的解决路径

现象:小票上中文变成问号,或者品名和金额不在同一列,像波浪一样弯曲。

原因:乱码是打印流编码和打印机不匹配,Java 默认 UTF-8,很多热敏打印机固件只认 GBK。列宽问题是中文字符按 1 个字符宽度计算,实际上中文在打印头里占 2 个西文字符宽度。

解决:打印输出流强制指定GBK编码,商品名做截断补齐。截断时按字节宽度处理,中文算 2 字节,超长直接切掉加省略号。参数方面,58mm 热敏纸一行内容不要超过 32 个半角字符,超过了就换行,宁少勿挤。

5.5 订单号并发撞车:时间戳重复引发的 Duplicate Key

现象:日志里偶尔出现Duplicate entry for key order_no,订单没生成,但前面扣的库存因为事务回滚没有丢。

原因:订单号用yyyyMMddHHmmss格式化生成,同一毫秒内两个请求拿到相同字符串。单机收银很少遇到,一旦搞 Web 版多人同时收银,撞车概率直线上升。

解决:生成规则改成时间戳加随机短码,System.currentTimeMillis()加三位随机数,再不行加一个循环重试逻辑。同时保留数据库唯一索引,它不是用来防业务的,而是最后一道保险。遇到撞车,回滚整单让收银员重新扫一遍就是正确的容错行为。

6. 验证收银正确性:用一段 SQL 把昨天的订单全部对一遍账

6.1 订单总价与明细金额的对账脚本

收银系统上线后最怕两件事:某笔订单金额错了,或者库存和销售记录对不上。与其信代码,不如在数据库层写一段对账 SQL。下面的脚本把orders表冗余的总价和order_item明细求和逐行对比:

SELECT o.id, o.order_no, o.total_cents, SUM(oi.quantity * oi.price_cents) AS detail_total FROM orders o LEFT JOIN order_item oi ON oi.order_id = o.id WHERE o.create_time >= '2025-01-06' AND o.create_time < '2025-01-07' GROUP BY o.id HAVING o.total_cents <> detail_total;

查询结果为空,说明这些订单的总价和明细一致;有结果,差值就是算错或被改过的订单。日期范围可以换成任意一天,>=和<的组合比BETWEEN更安全,能避开00:00:00边界问题。

6.2 结合当前商品价格追溯历史订单

对完总账,还要看单品价格是否被改价影响。这个脚本 JOIN 商品表,把卖出的分毛利润和按当前售价计算的毛利放一起:

SELECT oi.order_no, p.name, oi.price_cents AS sold_price, p.price_cents AS current_price FROM order_item oi JOIN orders o ON o.id = oi.order_id JOIN product p ON p.id = oi.product_id WHERE oi.price_cents <> p.price_cents LIMIT 20;

执行结果能看出哪些订单成交价和现价不一致。这通常是促销改价导致的,属于正常业务;但如果某商品没有参与任何活动却出现差异,就要回头查结算方法的快照逻辑。

6.3 我为什么把单位定成分:一个对账习惯的由来

有一点必须承认,把金额统一成“分”的做法第一次让人很不适应,总在脑子里面把 200 当成两块。但坚持下来的原因很简单:整数永远没有精度问题。把这两段 SQL 存成对账脚本,每次改完代码就跑一遍,心里就有了底。它帮你把“代码写对了没有”转化成“数据库里的钱对得上没有”,后者才是收银系统真正要交付的东西。一个项目做久了会让人养成一些偏执,我现在的习惯是改完任何一处金额逻辑,第一件事不是打开界面看效果,而是先跑对账脚本,再拿一段小票打印作对照。希望帮到你。

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

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

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

立即咨询