1. 别急着写代码:超市收银的业务链路与模块边界
如果你正在准备"基于SpringBoot的超市收银系统"这类项目——不管是毕业设计还是练手作品——我猜你第一件事可能就是打开IDEA,创建一个Spring Initializr项目,然后开始写商品表、用户表、订单表的增删改查。这个思路倒也不算错,但落地之后你会发现,收银系统最值钱的部分根本不在"增删改查"里,而在"一次结算过程中,数据库里的数据是怎么保持一致性的"。
我在做这个项目之前,先花了整整两天画业务链路,白天画流程图,晚上翻超市收银场景的细节,最后才动手建工程。事实证明这个决定非常正确。因为超市收银系统和普通的图书管理系统、会议室预约系统有本质区别:后者是记录型系统,前者是交易型系统。交易意味着金额不能错、库存不能负、流水不能丢,这三条一旦出问题,整个系统就失去意义了。
1.1 一条完整交易链路上都有谁
把"超市收银"翻译成系统语言,它实际上是这样一条链路:
顾客挑选商品,拿到收银台;收银员用扫码枪逐个扫描商品条码,或者手动输入条码;系统实时显示商品名称、单价和数量;商品全部录入后,系统汇总金额;收银员收取现金或引导顾客出示付款码;系统生成订单,扣减库存,记录流水;如果顾客是会员,还要同步累计积分。
这条链路里有四个核心角色,对应的就是系统的四个核心模块:
- 收银员:只关心扫得快、算得准、收款后有凭证。对应的功能是收银台、订单查询、退货操作。
- 商品:要有条码、名称、进价、售价、库存、状态。对应商品管理和库存管理。
- 会员:手机号、余额、积分,决定了一单能优惠多少、能积多少分。对应会员模块。
- 店长/管理员:关心哪些商品卖得好、每天营业额多少、哪个收银员操作异常。对应统计报表和系统管理。
想清楚这四个角色,你就知道这个系统至少需要哪些菜单、哪些页面、哪些接口了。
1.2 核心功能模块清单
我整理了一份当时我给这个项目划定的功能边界,供你参考:
| 模块 | 核心功能 | 典型页面/接口 |
|---|---|---|
| 商品中心 | 商品录入、条码维护、上下架、价格修改 | 商品列表、商品表单、条码查询接口 |
| 收银台 | 扫码加购、数量修改、结算、现金/扫码收款、挂单 | 收银台页面、结算接口、挂单清除接口 |
| 订单管理 | 订单查询、退货退款、小票重打 | 订单列表、订单详情、退货接口 |
| 库存管理 | 入库、出库、盘点、库存预警、库存流水 | 入库单、库存调整、流水列表 |
| 会员管理 | 开卡、储值、消费积分、积分抵扣 | 会员信息页、积分明细、充值接口 |
| 统计报表 | 销售额趋势、热销排行、客单价、收银员业绩 | 报表页、聚合查询接口 |
| 系统管理 | 用户登录、角色权限、操作日志 | 登录页、用户管理、日志列表 |
这里需要说明的是,作为一个SpringBoot单体项目,你不需要把所有模块都做成满配。比如权限管理,用Spring Security或者简单的拦截器加角色判断就行;比如报表,不需要对接大数据组件,写几条MyBatis聚合查询就够了。
1.3 哪些需求应该做减法
很多同学在做这种系统时会不自觉地往里面加东西:加个供应商管理、加个多门店切换、加个微信小程序端。我建议你这个阶段先忍住。
我最初也列了十几张表的计划,后来发现真正影响主流程的只有六张表左右。多余的功能会让联调成本翻倍,还会让答辩或演示时手忙脚乱。把主链路做扎实——扫码、结算、扣库存、打印小票——比堆砌一堆半成品菜单更有说服力。
提示:如果项目要求里有"多角色权限",优先用固定角色+拦截器实现,不要上来就设计RBAC五张表,等主链路通了再加权限,你会发现轻松得多。
2. 技术选型逻辑:SpringBoot 2.7 + MyBatis + Vue这套组合怎么定下来的
选型这件事,常见的误区是"什么火用什么",结果就是项目里塞了一堆自己都讲不清楚的依赖。我的建议是:每一项技术都要能回答"为什么是它"。
2.1 为什么是SpringBoot,以及版本怎么选
SpringBoot解决的最大痛点是Spring时代繁琐的XML配置。自动装配机制让数据源、事务管理器、Web容器这些基础设施全部变成"约定优于配置"。对这类单体项目来说,你只需要一个spring-boot-starter-web、一个mybatis-spring-boot-starter,再加上数据库驱动,整个后端骨架就搭起来了。
版本方面,我推荐SpringBoot 2.7.18。这是2.x分支最后一个稳定版本,兼容Spring 5.3,配合JDK 8或JDK 11都能顺畅编译。3.x虽然已经发布很长时间,但它要求JDK 17起步,而且MyBatis、PageHelper等一批老依赖的兼容性在升级期容易出幺蛾子。如果是为了毕设或稳定交付,没必要赶这个时髦。
2.2 MyBatis比JPA更适合这种项目
超市收银系统里最复杂的是查询和统计,比如"按天汇总销售额"、"商品销量排行前20"、"某个时间段内各收银员的订单数"。这类SQL本质上是面向结果集的,用MyBatis写XML映射,你心里对SQL的执行计划完全可控。
我用过一段时间JPA,它在单表CRUD时确实方便,但一旦遇到多表关联加分组聚合,要么写JPQL要么写原生SQL混在一起,结果还容易出现懒加载异常。MyBatis配合MyBatis-Plus(或者干脆只用原生MyBatis加PageHelper)在这个项目里是更省心的选择。
2.3 前端用Vue不是赶时髦,是收银台交互逼出来的
收银台页面是什么形态?左侧商品列表,可以实时搜索筛选;右侧购物车,要支持数量增减、单项删除、小计实时变化;结算时要弹出收款弹窗,选择支付方式,输入实收金额,计算找零;如果使用会员手机号,还会联动显示积分和会员折扣。
这种高密度交互如果用Thymeleaf做服务端渲染,每操作一下都要刷新页面或者发Ajax局部更新,开发效率和用户体验都会很别扭。所以前端我选了Vue 2或者Vue 3都行,配Element UI,用前后端分离的方式开发。后端只出JSON接口,前端独立跑在Node环境或者打包后由Nginx托管,接口联调用knife4j生成的Swagger文档记录。
2.4 辅助依赖的最小集合
辅助依赖我控制在很小的范围里:MySQL 8.0做存储、Redis做热点缓存、PageHelper做分页、Lombok简化实体类、knife4j生成接口文档、Hutool做通用工具(比如订单号生成、金额计算)。Druid或者HikariCP选一个就行,SpringBoot默认的HikariCP已经完全够用。
Redis在这个项目里的定位是"锦上添花":缓存商品列表、缓存登录会话的token、用Redis原子自增生成部分订单号。如果面试或答辩问到,你可以说清楚Redis用了什么、为什么用,比堆一堆没跑通的组件强得多。
3. 数据库建模:一张商品表和一串流水表决定项目成败
建表是收银系统最不能返工的环节。表结构一旦定下来,后面改一处字段,前端、后端、SQL全都要跟着动。我在建模时坚持的原则是:核心交易表尽量精简,流水表尽量齐全。
3.1 先画核心表和它们的血缘关系
一个收银系统最核心的四张表是:
- product(商品表):承载商品基本信息,包括条码、名称、进价、售价、库存、预警阈值、状态。
- orders(订单主表):一次结算一条记录,存订单号、订单总金额、实收金额、找零、支付方式、收银员ID、会员ID、订单状态。
- order_item(订单明细表):一个订单对应多条商品记录,存商品ID、商品名称快照、单价快照、数量、小计。
- user(系统用户表):登录账号、姓名、角色、密码哈希。
订单明细里存"商品名称快照"和"单价快照"是这类系统里非常关键的设计——订单生成后,商品改价或改名称不能影响历史订单的展示。如果你只在明细表里存productId,联查时一旦商品改名,历史小票打印出来就对新名称了,这在实际业务里是事故。
扩展表则是围绕这些核心表建立的支撑:
- member(会员表):手机号、姓名、积分、累计消费金额。
- stock_log(库存流水表):每一次库存变动都要落一条流水,包括商品ID、变动前数量、变动后数量、变动类型(入库/销售/退货/盘点)、关联订单号。
3.2 字段设计的几个关键细节
我整理过一份建表注意事项,每条都是踩过坑或看别人踩过坑总结出来的:
- 金额一律用
DECIMAL(10,2)。不要用float或double,0.1加0.2在浮点数里有精度误差,金额算错哪怕一分钱都是严重的业务事故。 - 商品条码加唯一索引,因为扫码枪查询全靠条码直接命中。
- 订单号字段建唯一索引,订单号一旦重复,后续对账和打印全乱。
- 软删除字段
deleted,商品下架用状态字段控制,而不是物理删除。 - 所有表带
create_time和update_time,做统计和排查问题都离不开时间。 - 时间字段统一用
datetime,并保证数据库连接串里serverTimezone=Asia/Shanghai,否则容器部署后容易出现时间差8小时的问题。
3.3 库存流水表:为什么必须建
你可能觉得库存量在product表里有一个字段就够了,为什么还要单独建一张流水表?因为有一天你会遇到这种情况:月底盘点发现某件商品库存对不上,账面还有10件,实物只有7件。这时候你要能回答"库存去哪了"。
有了stock_log表,你可以按时间范围查出来:3号入库50件,5号销售出库12件,6号退货回库1件,8号盘点调整少了2件……每一步都有记录和关联单号,问题就能定位到具体环节。没有流水表,你很可能会陷进"改库存数字"的泥潭里,改完也不知道为什么变没了。
提示:入库、销售、退货、盘点调整这四种库存变动,一定要区分操作类型。我建议在
stock_log表里加一个change_type枚举字段,这样统计口径清晰,将来写报表也顺手。
4. 收银台核心交易:从扫码到出小票的完整链路
这一节是整个系统的心脏。如果你能把"一次结算"这个流程完整跑通,并且保证任何异常情况下数据都不混乱,这个项目就成功了八成。
4.1 页面上收银员到底在操作什么
收银台页面上,扫码枪扫描条码后,会像键盘输入一样快速输出一串数字并自动回车。前端监听这个回车事件,把条码发给后端查询商品接口;拿到商品信息后,把商品加进购物车数组里。如果购物车里已有同一条码的商品,就把数量加1,单价不变。
购物车数据放在前端内存或Vue的响应式数据里就够了,不需要在后端建一张购物车表。超市收银的场景是"一个收银台同时只服务一个顾客",购物车状态跟着页面生命周期走,刷新页面就清空,简单可靠。
当收银员点击"结算"按钮时,前端把[{productId, quantity}, ...]这个数组、支付方式、实收金额、会员手机号一起提交给后端。这个接口是整个项目里唯一一个写核心交易的方法,其余都是辅助。
4.2 交易接口只信两样东西:productId和quantity
这是我特别想强调的一点:后端结算接口绝对不能信任前端传过来的价格。价格可能需要按会员等级打折、活动优惠或临时调价,这些逻辑都应该在后端根据productId重新查询后计算。前端传价格的话,任何一次前端被篡改或数据残留都会导致金额错误。
所以结算请求对象做得很简单:
public class SettleRequest { private List<ItemParam> items; // 商品明细 private Integer paymentType; // 1现金 2扫码支付 private BigDecimal receiveAmount; // 实收金额 private String memberPhone; // 会员手机号,可为空 } public class ItemParam { private Long productId; // 只信这个 private Integer quantity; // 和这个 }4.3 事务方法里的七步走
结算接口对应的Service方法我加了@Transactional(rollbackFor = Exception.class),整个方法要么全部成功提交,要么全部回滚。步骤如下:
@Transactional(rollbackFor = Exception.class) public SettleResult settle(SettleRequest request) { // 1. 校验参数:items 非空、quantity 大于 0 // 2. 遍历 items,用 SELECT ... FOR UPDATE 锁定商品行 // 3. 检查库存是否充足,不足则抛业务异常 // 4. 按商品售价计算总金额,有会员则按会员折扣重新计算 // 5. 执行 UPDATE stock = stock - quantity WHERE id = ? AND stock >= quantity // 6. 生成订单主表记录(含订单号、应收、实收、找零) // 7. 生成订单明细记录、会员积分流水、库存流水 return settleResult; }第2步的SELECT ... FOR UPDATE加上第5步的stock >= quantity条件判断,是防超卖的保险锁。第4步为什么用数据库里的售价,原因我在4.2已经说了。第6步的找零计算是实收 - 应收,如果实收小于应收,直接抛异常提示收银员重新收款——这一步很容易被忽略,但不校验的话就会出现负数找零的脏数据。
4.4 订单号别用UUID
订单号是给收银员和财务看的,不是给程序员看的。UUID那种"几十位无规律字符串"在打印小票、口头报单号时都非常不友好。我用的方案是:yyyyMMddHHmmss + 4位随机数 + 收银员ID后两位,数据库唯一索引兜底,重复概率极低,而且一眼能看出下单时间。
如果同一秒并发量非常大,也可以引入Redis自增编号,但超市单体收银系统完全用不到,别为了炫技增加复杂度。
5. 库存扣减的并发问题:超卖、锁和事务的实战处理
这一章应该是最能体现"这个系统到底有没有认真做"的部分。因为增删改查谁都会写,但并发扣库存能直接把一个普通的CRUD项目从"会跑"和"能扛事"区分开。
5.1 那个经典的超卖现场
假设库存只有5件,两个收银员同时在两个终端卖同一件商品。经典错误写法是:
- 先查库存:
SELECT stock FROM product WHERE id = 1,结果是5。 - 程序判断5 > 0,允许售卖。
- 执行扣减:
UPDATE product SET stock = stock - 1 WHERE id = 1。
两个收银员如果同时走到第1步,都查到5,都判断可以卖,都执行第3步,最终库存变成3,但实际卖掉了2件——账面看起来没问题。但如果放大成100个并发请求同时抢库存,就会出现大量请求基于同一个旧库存值做判断,最终库存变成负数。
这就是经典的"先查后改"竞争条件。数据库的单条UPDATE是原子的,但"查-判断-改"这三步合在一起不是。
5.2 三种防超卖方案横向对比
我在项目里梳理过三种常用方案,你可以根据自己的场景选:
| 方案 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 悲观锁 | SELECT ... FOR UPDATE锁定商品行,其他事务等待 | 逻辑直观,绝对安全 | 并发高时排队明显,锁时间长 | 收银系统、单机低并发 |
| 乐观锁 | 库存表加version字段,更新时校验version | 读多写少时性能好 | 冲突时需重试,代码稍复杂 | 电商秒杀等读多写少 |
| 条件更新 | 一条SQL扣减并校验非负 | 实现简单,原子性天然 | 无法得知具体失败原因 | 大多数库存扣减场景 |
对于这个项目,我最推荐的组合是:事务里先SELECT FOR UPDATE锁定商品行,再更新库存,UPDATE语句同时加上AND stock >= #{quantity}条件。双重保险的好处是:即使你漏掉了锁,条件更新也能挡住超卖;即使条件更新因为某些原因失效,锁也能保证同一时刻只有一个线程在改这一行。
5.3 多商品结算时小心死锁
当一笔订单包含多个商品时,事务里会锁定多行商品。如果两个订单的商品顺序刚好相反,比如A事务先锁商品1再锁商品2,B事务先锁商品2再锁商品1,就可能出现互相等待的死锁。
解决方式很简单:在代码层把所有待扣库存的productId按升序排序,再统一加锁。这样所有事务都按同一个顺序加锁,就不会出现循环等待。这种细节在单商品测试时完全看不出来,但并发一上来就会遇到。
5.4 用压测结果说话
我写完后用JMeter跑了一个简单的并发测试:准备10件库存,启动100个线程同时买1件。不加任何防护的版本跑出了库存变成负数的情况;加上条件更新后,100个请求里只有10个成功返回,其余全部抛"库存不足",最终库存为0,账单和流水完全对上。
这种验证方式的价值是:让你对"数据库并发控制"有一个直观认识,而不只是背书上的概念。面试或答辩时你如果能说"我用100个并发压过,10个成功90个被正确拒绝",比任何概念描述都有说服力。
6. 小票打印、会员积分和销售统计的落地经验
三个非核心但很出彩的功能,做完这套功能,你会觉得项目一下子"完整了"。
6.1 小票打印:前端模板+热敏打印的方案组合
小票打印有几种实现路线:一是后端生成图片或PDF再调用打印控件;二是前端直接调用浏览器打印;三是通过escpos指令走串口或网口直接驱动热敏打印机。
我选的是前端方案:Vue渲染一张58mm宽的小票模板,样式严格控制字号和中文字体,然后调用打印插件输出。这里最坑的是中文对齐——热敏纸宽度有限,中文在monospace字体下经常错位,需要对每个字段做字节长度计算,让品名、单价、小计的宽度比例固定下来。
小票模板里要包含:店铺抬头、订单号、时间、每件商品名/单价/数量/小计、应收金额、实收、找零、会员积分提示、底部广告语。为了演示效果,我还在小票里生成了一维码格式的商品条码和订单号条码,用的前端条码库输出到模板里。
6.2 会员积分的加分与冲正
积分规则我定为消费1元累积1积分,在结算事务里同步写入member_points_log。需要注意的点是:积分计算必须基于后端最终确认的应付金额,而不是前端展示的金额。
退货时更麻烦。原订单如果走了积分,退货退单时必须把已加积分扣回去。我的处理是:退货接口里新增一条积分类型=-1的积分流水,注明关联原订单号和退货原因。这样会员的积分明细永远可追溯,不会出现退了货积分还没扣的情况。
6.3 销售统计:聚合查询和分页工具的正确用法
统计报表是答辩时的加分项。我写了三个接口:按天统计营业额和订单数、按商品统计销量排行TOP10、按收银员统计业绩。
这类聚合查询是MyBatis最擅长的场景,比如按天统计的SQL大概长这样:
SELECT DATE(create_time) AS day, COUNT(*) AS order_count, SUM(receive_amount) AS total_amount FROM orders WHERE create_time BETWEEN #{start} AND #{end} GROUP BY DATE(create_time) ORDER BY day分页插件PageHelper在这个项目里也要用,但有一个大坑要记住:PageHelper.startPage()必须紧跟第一条查询语句,中间不要插入别的数据库操作。我见过很多人把PageHelper放在业务方法开头,结果它作用到了别的查询上,导致莫名其妙的多页和错页。正确用法是:
PageHelper.startPage(pageNum, pageSize); List<OrderVO> list = orderMapper.selectOrderPage(condition); PageInfo<OrderVO> pageInfo = new PageInfo<>(list);另外,统计类的聚合查询不要走分页——报表页通常只需要展示汇总值,没必要把每一天都拆到分页里。
7. 打包部署与Docker化:一个能演示的收银系统才算做完
项目在本地IDEA里跑通只是第一步。真正要拿出去演示、答辩或者部署到服务器上,你还需要把环境配置、构建、容器化这三件事做干净。
7.1 配置分离与环境变量
我习惯把src/main/resources下的application.yml拆成三个:application-dev.yml(本地开发)、application-prod.yml(服务器部署)、主配置文件里通过spring.profiles.active指定。数据库地址、Redis地址、端口这些敏感信息在prod配置里用${DB_HOST}这种环境变量占位,部署时在服务器上注入实际值。
这样做的原因是:你肯定不希望本地数据库连不上导致项目启动失败,更不希望把服务器数据库地址写死在代码里。
7.2 用Docker Compose把MySQL、Redis、App一起拉起来
后端打包成可执行jar后,写一份Dockerfile就够了,核心内容就是把java -jar封装进容器。然后写一份docker-compose.yml,让MySQL、Redis、后端App三个容器一起启动,前端打包后的静态文件可以用Nginx容器挂载,也可以直接用nginx镜像加载dist目录。
services: mysql: image: mysql:8.0 environment: - TZ=Asia/Shanghai volumes: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql app: build: . environment: - DB_HOST=mysql - REDIS_HOST=redis ports: - "8080:8080"这里有个细节很值得注意:MySQL容器启动时要加TZ=Asia/Shanghai环境变量,同时在后端JDBC连接串里写上serverTimezone=Asia/Shanghai。否则容器默认的UTC时间会导致订单时间、统计报表全部慢8小时,这个坑我真实踩过一次,排查了很久才反应过来。
7.3 最容易栽的三个部署细节
除了时区,还有三个部署细节很容易翻车:第一,前端打包后要检查请求路径有没有带/api前缀——Nginx转发后端接口时,location规则写错了会404;第二,MySQL初始化脚本用的是docker-entrypoint-initdb.d挂载,只会首次启动时执行,想改表结构必须重新建卷;第三,防火墙和云服务器安全组要放行8080端口,否则本地访问不了。
把Docker部署跑通之后,整个项目的完整度就上了一个台阶。你可以在一台新服务器上做到"一条docker-compose up -d命令拉起整个系统",这种交付感是很踏实的。
最后分享一点个人体会。做"基于SpringBoot的超市收银系统"这类项目,最容易走偏的是陷入"框架怎么用"的细节里,背了一堆自动装配原理和注解,结果核心交易链路写得稀烂。反过来,如果你把业务链路想清楚,把库存扣减和订单生成这两个关键点写稳,哪怕项目里只有一个简单的登录页加一个收银台,它也是一个能立得住的系统。我在实际打磨时还有一个习惯:每写完一个模块就用异常场景折腾一遍——库存扣到负数、订单重复提交、付款金额不够、商品中途下架,把每一个异常都拖出来处理一遍,成长速度会比闷头写十个功能页面快得多。