简介:一套面向小型超市的收银系统源码,以添加商品、商品销售、商品查询和计算器小工具为核心,覆盖了收银结算、库存跟踪、会员积分、促销折扣等日常运营场景,适合作为计算机专业毕业设计选题,也适合开发者快速上手商业管理类项目。压缩包为rar格式,约3.3MB,据资源说明内含源代码、数据库配置及用户手册等配套内容,可支撑从环境搭建到功能调试的完整学习。系统按业务模块划分清晰,在商品管理部分关注条形码快速录入与库存预警,在销售部分突出多种支付方式和退货退款流程,同时具备报表分析与员工权限设计,便于理解小型超市的前台收银与后台管理一体化思路。通过研读这套源码,能学习数据库表结构设计、前端交互界面开发、后端业务逻辑处理以及系统集成方法,并借鉴其目录结构与工程组织方式。目前已有3114人学习,资源热度较高,适合课程实践或作为商业项目开发入门参考。
1. 超市收银系统源码:一套能跑起来的收银台,比想象中少一半工作量
在技术社区里搜“超市收银系统源码”,结果一大把,但能扛住早高峰收银台的没几个。很多代码只是把商品表包了一层增删改查,扫码枪一接就乱码,库存扣重,小票缺行,日结对不上账。其实一套最小可用的超市收银系统核心只有四件事:扫码加购、结算收钱、扣库存、出报表。下面按从业者常走的路线,从选型到建表,从交易链路到部署排错,把源码跑通需要知道的细节一次讲透。这篇笔记适合准备做课程设计、小店换系统的同行,也适合第一次接收银项目外包的开发者。
2. 收银系统源码怎么选型:Java Spring Boot 还是 Python Django,先看你的场景
2.1 单体应用为主流,前后端分离不是必选项
很多收银源码一上来就是 Vue + Spring Boot 前后端分离,看着现代,但对小店反而是累赘。收银台屏幕就一块触屏,交互密度远低于后台管理系统,页面不需要那么多异步刷新;部署在本地 Windows 收银机上,一套内嵌页面比两个服务更省心。常见做法是用单体架构,后端渲染收银员页面,数据接口和页面同在一个进程里,出一张工单就能解决全部问题。
选型可以从三条线判断:店员培训成本、维护复杂度和未来扩展空间。如果后面要接微信支付宝扫码支付、电子秤、会员系统,Spring Boot 生态的支付 SDK 和串口通信资料最全;如果只是想快速改现有源码、用 Python 写得更顺,Django 自带 admin 后台能少写很多页面。但我的建议是 Java,理由不是性能,而是市面上能搜到的收银系统源码、商业开源项目大多集中在这个栈,你踩过的坑别人早就踩过,查答案更容易。
如果你手上的源码是 PHP 写的,也不是不能跑。PHP 部署简单,二手收银机装上 Apache 就能用,但后续接摄像头扫码识别、电子秤串口、离线缓存都比较费劲,短生命周期模型让常驻服务和队列变得别扭,做小票打印时经常要挂第三方扩展。所以我的排序是 Java > C# > Python > PHP,优先级完全按“收银硬件驱动的成熟度”来排。
2.2 用 Spring Boot 搭出最小可运行工程
以 Spring Boot 3.x + MySQL 8.0 + Thymeleaf 为例,建一个叫 pos-server 的 Maven 工程。先不管界面,只要把一个商品查询接口跑通,骨架就立住了。用命令行生成工程骨架最省事:
mvn archetype:generate -DgroupId=com.supermarket \ -DartifactId=pos-server \ -DarchetypeArtifactId=maven-archetype-quickstart \ -DinteractiveMode=false这条命令把 Maven 标准骨架拉下来,生成的工程不算现代,所以大多数人还是去 Spring Initializr 页面生成。重点不是骨架命令,而是 pom.xml 里必须带上 spring-boot-starter-web、spring-boot-starter-thymeleaf、mybatis-plus 和 mysql-connector-j 这四个依赖。mybatis-plus 能省掉大量单表 CRUD,收银这类业务的核心逻辑在交易,不应该把时间耗在商品表的增删改查上。
骨架出来后,写一个最小 Controller 验证工程能跑:
@RestController @RequestMapping("/api/goods") public class GoodsController { private final GoodsMapper goodsMapper; public GoodsController(GoodsMapper goodsMapper) { this.goodsMapper = goodsMapper; } @GetMapping("/barcode/{barcode}") public Goods findGoods(@PathVariable String barcode) { return goodsMapper.findByBarcode(barcode); } }这段代码逻辑很直白:扫码枪扫到的条码作为路径参数传进来,Mapper 去商品表查对应记录。注意这里故意没有做空值判断,条码不存在时返回 null,页面拿不到对象会直接白屏。这是许多接手的收银源码翻车的第一个点,第 5 章会专门讲怎么兜底。参数 barcode 在 URL 里出现时,条码基本是纯数字,路径参数问题不大,但还是建议改成 GET 参数?barcode=xxx更稳妥。
第一次跑起来后,控制台看到Tomcat started on port 8080就说明工程没问题。用浏览器访问/api/goods/barcode/6901028105653,能返回一条商品 JSON 就是通了。此时数据库里那条商品的 barcode 要对得上,否则返回 null 也正常,问题多半出在初始化数据没导全。
2.3 源码目录结构:收银台、后台、报表各自归位
拿到源码先别急着跑,先看包结构。一个值得在上面改业务的收银系统源码,目录至少是这种形态:
pos-server ├── controller # 收银台接口、后台接口、报表接口 ├── service # 交易、库存、会员、报表业务 ├── mapper # MyBatis 数据访问 ├── entity # 商品、订单、订单明细、流水实体 ├── config # 拦截器、打印配置、支付配置 └── resources ├── mapper # XML 里放复杂报表 SQL ├── templates # 收银台页面 └── application.yml如果拿到手的源码把商品增删改查全写在 Controller 里,或者 SQL 直接埋在 Service 里,那改库存逻辑时会非常难受。收银系统的核心是交易链路,链路中间任何一步改动都会牵动商品、订单、库存、流水四张表,分层不清的源码改一处炸一处。判断源码质量的第一个动作,就看 trade 相关代码有没有独立成 service,而不是散落在 Controller 和 Mapper 里。
resources/mapper 目录也不要只放 ORM 生成的 CRUD XML。日结报表、毛利统计这类 SQL 要放到这里,用 MyBatis 的 XML 写,不然在 Java 里拼字符串 SQL 很难维护。你拿到源码时可以打开这个目录看,如果里面只有 BaseResultMap 和 selectByPrimaryKey,那这个源码的业务基本没沉淀下来,后面所有报表都得自己补。
2.4 收银机运行环境:Windows 是默认主场,Linux 是报表服务器
收银机普遍是 Windows,因为小票打印机和扫码枪的驱动基本都是 Windows 优先。源码如果只提供 Linux 部署脚本,接上打印机就是一场灾难。最省心的方案是:MySQL 装在一台旧电脑当服务器,Windows 收银机通过局域网连接,Java 服务就部署在那台 Windows 收银机上。这么做的好处是断外网也能收银,因为数据库和收银服务都在一张局域网里。很多人一开始就把系统部署在云服务器上,结果收银机一断外网整个门店停摆,这是选型时就该避开的坑。数据访问层的配置建议把 localhost 换成 MySQL 的实际 IP,方便以后扩第二台收银机。
数据库备份也要在选型时考虑。Windows 服务器上直接用 mysqldump 加计划任务,每天晚上 22 点导出一次,保留最近 7 份。别把鸡蛋放一个篮子里,收银系统的账本出问题,比收银机硬盘坏了严重得多。
3. 数据库设计先行:商品、库存、订单与流水的核心表和索引
3.1 商品表与库存表:条码是唯一索引,不是主键
商品表是收银系统的地基。大部分源码会用自增 id 做主键,条码字段加唯一索引,这是正确的做法。先看建表语句:
CREATE TABLE t_goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL, spec VARCHAR(64), -- 规格,如 500ml/瓶 unit VARCHAR(8), -- 单位:个、瓶、包 sale_price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 1, -- 1上架 0下架 category_id BIGINT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_barcode (barcode) );这里有个容易混淆的点:为什么不用条码当主键?因为条码规则不在自己手里,供应商一种商品可能发好几个条码,散装商品有时候是称重后临时生成的电子秤条码,这种条码在数据库里是重复创建的。自增主键虽然没业务含义,但做订单明细外键关联时查询更快。sale_price 必须用 DECIMAL(10,2),不要用 FLOAT,小数运算会累积误差。商品表里不要放库存字段,库存是变化量,每次销售都要 UPDATE,放在商品表里会让商品查询和库存扣减互相锁行,早高峰容易卡。
category_id 可以做普通索引,也可以不做,因为超市商品分类就那几个,全表扫描也比走索引快。真正要关注的是 status 字段,查询时默认带上status = 1,下架商品不能出现在扫码结果里。很多源码忘了这个条件,导致旧条码还能扫出商品,店员还以为系统出了 bug。
3.2 订单主表与明细表:为什么必须拆两张表
订单保存时不拆主表和明细表,是新手源码最常见的病。只存一张表,意味着退货、挂单、报表统计全都绕不开重复数据。正确做法是两张表,主表存一次交易的公共信息,明细表存这次交易里每个商品的一行记录。
先看订单主表:
CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, total_amount DECIMAL(10,2) NOT NULL, discount_amount DECIMAL(10,2) DEFAULT 0.00, payable_amount DECIMAL(10,2) NOT NULL, payment_type TINYINT NOT NULL, -- 1现金 2微信 3支付宝 4会员卡 cashier_id BIGINT NOT NULL, store_id BIGINT, status TINYINT DEFAULT 1, -- 1正常 2已退货 3挂单作废 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) );然后订单明细表:
CREATE TABLE t_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, barcode VARCHAR(32), goods_name VARCHAR(128), price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL, KEY idx_order_id (order_id) );明细表里冗余 barcode 和 goods_name,不是为了省一次关联查询,而是为了历史订单可追溯。商品价格今天可以改,但三个月前的订单里,那瓶水就值当时的价格。如果只存 goods_id,报表系统重新关联商品表,一旦商品被删或改价,历史订单就错乱了。重点看 goods_name 和 price 这种冗余字段,这是商用系统必备的设计,很多课设源码里没有。
注意 total_amount、discount_amount、payable_amount 这三个字段是有关系的:total_amount 是原价合计,discount_amount 是整单折扣,payable_amount 是最终实收。不要让 payable_amount 直接等于前端传进来的数字,后端要用 total 减 discount 重新算一遍。这也是对账不平的最大来源之一。
3.3 库存表与流水表:扣减和核对都靠它们
库存不放在商品表,单独开一张库存表:
CREATE TABLE t_stock ( goods_id BIGINT PRIMARY KEY, quantity INT NOT NULL DEFAULT 0, warn_quantity INT DEFAULT 10, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );库存变化必须记流水。收银系统不只是卖货,还有入库、退货、盘点、报损。没有流水表的源码,一旦库存对不上,只能人肉翻订单。流水表的常见设计:
CREATE TABLE t_stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, change_quantity INT NOT NULL, -- 正数入库,负数出库 before_quantity INT NOT NULL, after_quantity INT NOT NULL, biz_type VARCHAR(16) NOT NULL, -- SALE/RETURN/PURCHASE/CHECK order_no VARCHAR(32), operator_id BIGINT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );流水表必须记录 before_quantity 和 after_quantity,这两个字段不是冗余,而是对账时能还原每一个操作的历史。比如一次退货的 change_quantity 是 +1,如果没有 before 和 after,你只能知道加回来了,但不知道当时库存基数。流水表不要加唯一索引,因为同一秒可能有多笔交易,查询时按 create_time 排序就行。
在应用层约定 biz_type 的取值:SALE 的 change_quantity 是负数,RETURN 是正数,PURCHASE 是正数,CHECK 是盘点差异的绝对值。把 biz_type 用枚举写在代码里,禁止用魔法数字 1、2、3,否则三个月后你自己都看不懂。读流水时如果发现 after - before != change_quantity,那这笔流水一定写错了,调账时第一时间查这种坏数据。
3.4 索引与事务边界:别把收银事务拖成慢查询
索引设计围绕高频查询。收银台最高频动作是扫码,走 t_goods 的 uk_barcode;订单查询走 uk_order_no;报表日结按天扫 t_order,走 create_time 索引。规划一张索引清单:
| 所属表 | 索引名 | 字段 | 用途 |
|---|---|---|---|
| t_goods | uk_barcode | barcode | 扫码秒查 |
| t_order | uk_order_no | order_no | 订单查询、退款关联 |
| t_order | idx_create_time | create_time | 日结、时段报表 |
| t_order_item | idx_order_id | order_id | 按订单查明细 |
不要对 t_stock_flow 的 order_no 建索引,流水表一天几万条,索引维护成本高,日结查询用 create_time 范围即可。这里容易犯的错是每个字段都建索引,把 t_order_item 的 goods_id、barcode、name 都建一遍,写入时因为索引维护太多变慢。收银系统的写入峰值是排队结账那几分钟,不要让索引数量拖垮 INSERT。
另一个重点是事务边界。一次收银会插入订单主表、插入明细、扣库存、写库存流水,这一整套必须在一个事务里。把“扣库存”单独抽出去,等订单插入成功后再另起事务扣,那中间任何一步失败,库存和订单就永久不一致。事务里也不要执行外部调用,比如打印小票、推送通知,这些应该放到事务提交后做,否则打印机卡住,数据库事务一直持有锁,后面所有收银都卡住。
4. 收银核心流程的实现:从扫码到小票打印的完整链路
4.1 扫码添加商品:人为按回车还是自动识别条码
收银台最常被误解的是扫码枪。它本质是一个键盘,扫出来的内容最后会在输入框里模拟一遍按键。很多源码的条码输入框只监听 keypress,遇到中文输入法就出乱码。更稳的做法是监听 keydown,用事件里的 key 构造缓冲,检测到 Enter 时才触发查询。参考实现:
const barcodeInput = document.getElementById('barcode'); let barcodeBuffer = ''; barcodeInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') { const barcode = barcodeBuffer.trim(); if (barcode) { addGoodsByBarcode(barcode); } barcodeBuffer = ''; e.preventDefault(); } else { barcodeBuffer += e.key; } }); async function addGoodsByBarcode(barcode) { const goods = await fetch(`/api/goods/barcode/${barcode}`).then(r => r.json()); if (!goods || !goods.id) { alert('商品不存在:' + barcode); return; } cart.add(goods); renderCart(); }为什么用 keydown 而不是 keypress?高速扫码枪在 keypress 阶段可能丢事件,keydown 对中英文输入法的兼容更好。barcodeBuffer 的作用是,扫码枪不是一次把条码复制进输入框,而是逐字模拟按键;如果监听元素的 change 事件触发查询,扫码枪还没扫完,change 根本不会触发。这是社区源码里最常见的翻车点。
4.2 购物车与结算:折扣、抹零、挂单怎么算
购物车放前端还是后端?单机收银台放前端最快,但多收银机统一结算要放后端。常见做法是前端维护购物车,结算时把整单提交后端,后端负责计算金额和创建订单。折扣和抹零必须放在后端算,不要信任前端传过来的最终金额,否则能改请求的人就能改付款金额。结算接口的简化代码:
@PostMapping("/api/order/checkout") public Result checkout(@RequestBody CheckoutRequest req) { List<Item> items = req.getItems(); BigDecimal total = items.stream() .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal discount = req.getDiscountAmount(); BigDecimal payable = total.subtract(discount).setScale(2, RoundingMode.HALF_UP); payable = payable.setScale(1, RoundingMode.DOWN); Order order = OrderService.createOrder(req, total, payable); return Result.success(order); }上面代码里setScale(1, RoundingMode.DOWN)就是“分以下抹零”,把 12.89 抹成 12.80;如果商家要四舍五入,改成 HALF_UP。金额计算全部用 BigDecimal,不能用 double,否则 0.1+0.2 会打印出 0.30000000000000004。折扣是按整单减还是按单品打折,两类超市都有。整单打折简单,但后续退货要按比例分摊折扣,非常麻烦;给单品加 discount 字段,每行的 subtotal 已经是折后价,退货时按明细退就行。建议源码实现尽量走单品折扣,日后会轻松很多。
4.3 支付与打印:对接支付通道前,先让本地记账闭环
很多源码宣称已对接微信支付,但没有商户号根本验不了。真实开店场景里,很多小店用的是 POS 机或扫码盒子收钱,收银系统只需要记录付款方式,把“已收款”作为打印小票的前提。也就是说支付动作在线下完成,系统记账在线上,二者通过支付参考号关联。这个设计虽然不叫支付对接,但已经满足店面财务需要,比硬接一套支付 SDK 更可靠。
小票打印又是一个坑。小票打印机大多走 ESC/POS 指令,通过串口或网口发送。在 Java 里常见做法是用 jpos 或用系统打印命令。最小可用方案是把小票排版成纯文本,通过系统打印命令输出:
printf "商品名 数量 金额\n%s" "$line" | lp -d receipt_printerWindows 收银机上更常见的做法是页面调 window.print(),让浏览器弹打印窗口。浏览器打印的好处是不用装驱动,坏处是用户能改打印设置。商用场景建议直接用 ESC/POS 指令,但打印模块必须独立成服务,别和结算逻辑耦合,否则打印机型号一换,整个收银流程都要动。
4.4 库存扣减:先记账再扣库存,且扣减必须带条件
库存扣减是全部源码里最容易写错的一处。顺序要固定在“创建订单 → 扣库存 → 写库存流水”这一个事务内。参考实现:
@Transactional public Order createOrderWithStock(Order order, List<Item> items) { orderMapper.insert(order); for (Item item : items) { int affected = stockMapper.deduct(item.getGoodsId(), item.getQuantity()); if (affected == 0) { throw new RuntimeException("库存不足:" + item.getGoodsName()); } } return order; }配合的 SQL 是关键,不能用裸 UPDATE:
UPDATE t_stock SET quantity = quantity - #{quantity} WHERE goods_id = #{goodsId} AND quantity >= #{quantity};这条 UPDATE 的作用是让数据库在扣减的同一时刻判断库存是否足够,affect rows 为 0 说明库存不足或商品不存在。不要先 SELECT 再判断再 UPDATE,那种写法在并发收银时必然超卖。等 UPDATE 成功后,流水表里的 after_quantity 可以直接用当前库存加回 quantity 推算出,不必再查一次。写流水也要放在同一个事务里,事务提交失败时流水回滚,库存和订单一起回到原状。
4.5 挂单与退货:收银台上的两个高频操作
挂单和退货最容易被课设源码漏掉。挂单就是当前顾客还没凑齐商品,先接待下一位,之后恢复购物车重新收银。最简单的实现是建一张 hang_order 表,把购物车 JSON 序列化存进去,恢复时反序列化。注意挂单表要记录挂单时间、收银员和购物车数据,恢复时只能由同一账号操作,避免交接班时互相覆盖。
退货不能直接删原订单。正确做法是在原订单上标记退货状态,同时生成一张退货单记录原订单号,再把商品库存加回来。为什么不能删订单?一旦删了,日结报表、库存流水、财务审计全部断链。如果源码里只有“删除订单然后恢复库存”,直接判死刑,这家系统换掉是早晚的事。
5. 部署与避坑:收银系统跑不起来的 5 个常见坑
5.1 本地部署最小步骤:先跑通课设源码,再谈换生产系统
在换掉现有收银机之前,先在本地把源码跑起来。需要 JDK 17、MySQL 8.0,不需要 Redis。最小部署命令:
git clone <你找到的源码仓库> cd pos-server mvn clean package -DskipTests java -jar target/pos-server.jar --spring.profiles.active=local大多数课设源码自带 init.sql,先执行它建库建表,再启动服务。启动前把本地的数据源配上:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/pos?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root thymeleaf: cache: false mybatis-plus: configuration: map-underscore-to-camel-case: true配置里最容易踩的坑是数据库驱动版本和 MySQL 8 认证方式。MySQL 8 默认用 caching_sha2_password,老项目的 mysql-connector-java 5.x 连不上,会报 unable to load authentication plugin。遇到这种问题把依赖换成 mysql-connector-j 8.x。启动后访问 http://localhost:8080/index 登录收银台,如果白屏,优先看后端日志里的模板路径和数据库 SQL 报错,多半是表名前缀或驼峰映射没配好。
5.2 坑一:扫码枪乱码,同一件商品扫出两个名字
现象:切到中文输入法后扫码,商品名变成一串乱码,或者同一件商品不同收银员扫出来的结果不一样。
原因:扫码枪输出的是 ASCII 字符,中文输入法把键盘事件组合成汉字或吞掉部分按键。很多收银系统源码只监听 keypress,输入法状态下 keypress 拿到的是组合字符,不是原始扫码值。
解决:前端监听 keydown,用 e.code 判断物理按键,不要用 e.key 的字符值。再给条码输入框加 autocomplete="off" 和 style="ime-mode: disabled"(旧内核有效),更可靠的是在扫码枪驱动里把输出模式调成英文。如果扫码枪支持配置后缀,把后缀改成一个不常用的按键比如 Tab,也能减少误触发。最直接的办法是让收银员固定使用英文输入法,但这个靠制度不如靠代码。
5.3 坑二:并发收银超卖,库存变负数
现象:早高峰两台收银机同时扫同一件饮料,结完账后库存变 -2。
原因:代码先 SELECT 再 UPDATE,两个事务读到同一个库存值,都判断库存够,然后各自扣减。
解决:换成 4.4 里那条带库存条件的 UPDATE 语句。注意这里事务隔离级别默认 REPEATABLE_READ 不影响,行锁在 UPDATE 执行时才加,条件里写 quantity >= #{quantity} 会让第二个并发事务阻塞,等第一个提交后再判断,库存不足时 affected rows 为 0,直接抛异常回滚订单。压测时可以把库存设成 1,再并发下两个单,验证最终库存不是负数。
5.4 坑三:小票打印缺行或走纸异常
现象:小票中间多空一行,或者一单走了两次纸,偶尔第一行被切掉。
原因:模板里用了<br>导致打印驱动多走一行;打印指令没发初始化命令,打印机保留上一次排版状态;Windows 打印队列里上一单没清空。
解决:每次打印前先发送 ESC @(十六进制 1B 40)初始化打印机,再发文本,最后发切纸命令。如果走纸量不对,检查打印驱动里纸张类型是 58mm 还是 80mm。浏览器打印方案则要用 window.onload 或等渲染完成再触发 window.print(),否则页面还没铺满,用户看到的就是缺行。
5.5 坑四:交接班对账不平,订单金额和流水对不上
现象:早班交班时,系统里订单金额总和比收银员手里现金少 3.5 元,而且定位不到是哪一单。
原因:结算时用 double 计算金额,或者做整单折扣时没分摊到明细,导致明细加总不等于订单汇总。对账脚本把 HAVING 条件写错比较漏掉。
解决:金额统一切成 DECIMAL + BigDecimal,打折先算到每个商品再合计,确保 t_order_item.subtotal 之和等于 t_order.total_amount。用下面这条 SQL 找出差异单:
SELECT order_id, SUM(subtotal) AS item_total, total_amount FROM t_order_item GROUP BY order_id HAVING item_total != total_amount;这条 SQL 在日结前跑一次,能直接列出对不上的订单。如果你接手的源码连 HAVING 都还没有,说明作者根本没想过对账这回事。
5.6 坑五:定时任务重复跑,日结报表翻倍
现象:第二天早上看日结报表,营业额是前一天的两倍,或者库存被重复扣了一次。
原因:定时任务没有幂等控制,到点后两个实例各跑一次日结,或者因为数据库锁等待被调度器重试。
解决:日结任务加批次号,每次跑之前先查任务表有没有相同日期批次记录;有就跳过,没有先插入再生成报表。更简单粗暴也有效的办法是在任务表日期字段上建唯一索引,重复插入会报错,捕获这个 DuplicateKeyException 就说明已有任务跑完。注意日结生成报表、更新库存预警、清缓存这三步必须放在一起,全部成功后才标记完成,否则第二天库存同步就会漏。
6. 进阶优化:从能收到能用,再到敢换掉原来的系统
6.1 用并发脚本验证收银接口,提前发现超卖
源码跑起来之后,先别急着接真机。写一个简单脚本,模拟 20 个并发请求同时下单,看库存是否为负。参考脚本:
import concurrent.futures import requests def checkout(i): payload = {"items": [{"goodsId": 1, "quantity": 1}], "discountAmount": 0} r = requests.post("http://localhost:8080/api/order/checkout", json=payload) return r.json() with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool: results = list(pool.map(checkout, range(20))) failed = [r for r in results if r.get("code") != 0] print("fail count:", len(failed))跑完去查 t_stock 表,如果 quantity 小于 0,说明扣库存逻辑没带条件;如果 fail count 远大于预期,可能存在重复订单或风控拦截。压测前把商品库存恢复到一个已知值,否则结果没法判断。这个脚本已经是半个接口测试,建议保留下来,每次改动交易链路都跑一遍。
6.2 离线收银与断网兜底
超市收银系统最怕断网。源码里如果没有离线缓存,断网时收银台一片空白。常见做法是本地 SQLite 缓存商品表和订单表,网络恢复后把订单同步到服务器。最小实现是收银台页面联机时每 5 分钟把商品全量表拉到内存或本地,断网后扫码走本地库,订单先写本地,恢复后批量上传。这个功能复杂度不低,但它是老板敢换系统的关键。如果你的源码没有离线能力,至少把 MySQL 和收银服务部署在同一局域网里,能扛住外网抖动。
6.3 操作日志与权限:后端把关,别只藏在前端菜单
权限模型两张大表就能搞定:t_user、t_role。收银员只拥有收银台菜单权限,店长拥有报表和商品建档权限。注意权限校验必须放在后端拦截器里,不能只在前端菜单上隐藏按钮,因为收银机是共用的,有人直接改 URL 就能访问后台接口。我见过最离谱的源码,后台接口没有任何校验,扫码枪一改 URL 直接进数据库。这样的系统上线等于把账本摊在柜台上。
所有改价、退货、挂单恢复、库存调整操作,都要写操作日志。日志表至少包含 operator_id、action、before_value、after_value、create_time。改价这件事尤其重要,没有日志的改价等于给收银员留了一扇门。
6.4 源码转生产系统前,必须检查的 5 个边界
| 检查项 | 必须达到的行为 | 常见源码通病 |
|---|---|---|
| 断电恢复 | 收银机重启后未打印小票能补打 | 订单只放内存,断电丢单 |
| 退货 | 退货要关联原订单号,不修改原单 | 直接删除原单再恢复库存 |
| 改价 | 店长可改价,但留下改价日志 | 任意收银员都能改价 |
| 会员价 | 会员结算按等级折扣,明细可追溯 | 只在总金额上打折,明细对不上 |
| 盘点 | 盘点冻结库存,按实盘数回写 | 直接改库存表,无审批 |
这些边界不要求第一版全做,但源码里至少要预留字段和模块位置。我自己接手过好几套收银系统源码,最容易翻车的不是功能不够,而是把课设代码直接当生产系统用。如果你准备从源码起步,按上面几章的路径先本地跑通,再用并发脚本和这张边界清单过一遍,再决定要不要替换现有收银机。希望帮到你。
本文还有配套的精品资源,点击获取