SSM收银系统毕业设计:库存强一致与挂单状态机实战
简介:本资源是一套完整的Java Web毕业设计项目——在线收银系统源码,面向计算机专业本科生及Java初学者,聚焦小型零售场景下的收银、库存与销售管理实践,助力毕业设计选题落地与SSM框架工程化能力提升。压缩包为ZIP格式,大小23.97MB,包含可直接运行的SSM项目源码、MySQL 5.7数据库脚本、详细说明文档(含架构设计、模块说明、环境搭建指南)及毕业论文(LW)参考模板;其中源码结构规范、注释清晰,涵盖用户/员工/商品/入库/销售等8大核心模块,文档则系统梳理了业务逻辑与技术实现要点。目前已有78人学习下载,适合用于课程设计、毕设开发或SSM全栈开发入门实战。读者可快速部署运行、理解MVC分层设计、掌握MyBatis动态SQL与事务处理,并复用模块进行二次开发。
1. 为什么一个「收银系统」毕业设计,反而最容易在答辩现场被问住?
不是功能太少,而是功能太“像”——学生交上来的 SSM 收银系统,90% 都能跑通登录、商品录入、下单结账,但一问「顾客挂单怎么处理?」「多收银员同时开单如何防库存超卖?」「小票打印失败后订单状态怎么回滚?」就卡壳。这不是代码写得不对,是需求没拆透:收银不是 CRUD 的搬运工,而是业务流、资金流、库存流三线并行的实时协同系统。这套资源不是给你一个能点开的 demo,而是一份从超市收银台真实操作动线反推出来的完整方案包:含可运行的 SSM 源码(Spring+SpringMVC+MyBatis)、MySQL 建库脚本、带流程图与 ER 图的说明文档、符合本科毕设规范的论文(LW)框架,以及最关键的——所有模块都预留了「可扩展钩子」:比如支付方式抽象成 Strategy 接口,未来加微信/支付宝只需新增实现类;库存扣减封装为独立 Service,避免 Controller 层直连 update 语句。适合正在赶毕设 deadline 的同学,也适合作为课程设计的结构范本——它不炫技,但每一步都经得起追问。
2. 总体架构设计:SSM 三层分层 + 四个核心业务域的职责边界
这套系统没有堆砌微服务或云原生概念,而是用最扎实的 SSM 分层把「收银」这个动作拆解清楚:表现层(Controller)只做参数校验和跳转,业务逻辑(Service)专注订单生命周期管理,数据访问(Mapper)严格遵循单一职责。整个系统划分为四个不可割裂的业务域:基础数据域(商品、员工、会员)、交易域(挂单、结算、退货)、库存域(实时扣减、预警同步)、单据域(小票生成、销售报表)。它们之间不直接调用,全部通过领域事件或事务内 Service 调用协作——这是防止后期改一个功能崩掉三个模块的关键设计纪律。
2.1 包结构与模块划分:拒绝“全在 com.xxx.controller 下造火箭”
项目采用标准 Maven 多模块结构(虽未物理拆 module,但逻辑隔离清晰),核心包路径如下:
com.example.cashier ├── controller // 仅接收请求、校验参数、返回视图/JSON │ ├── LoginController.java │ ├── OrderController.java // 仅含 createOrder()、payOrder()、cancelOrder() │ └── ReportController.java // 仅含 getDailySales() ├── service // 业务主干,含事务控制与领域规则 │ ├── impl │ │ ├── OrderServiceImpl.java // 含 createOrder() 全流程:校验库存→生成订单→扣减库存→记录日志 │ │ ├── InventoryService.java // 库存扣减专用,含 tryLock 库存行锁逻辑 │ │ └── ReceiptService.java // 小票生成,支持热敏/针式双模板 │ └── IOrderService.java // 接口定义契约,强制后续扩展必须实现统一方法签名 ├── mapper // 仅 SQL 映射,无业务逻辑 │ ├── OrderMapper.java │ ├── ProductMapper.java │ └── InventoryMapper.java // 单独库存表映射,避免在 product 表里硬塞 stock 字段 ├── model // POJO,字段与数据库表严格一一对应 │ ├── Order.java │ ├── Product.java │ └── Inventory.java // 库存实体独立,为后续多仓预留字段 └── config // Spring 配置集中化 ├── MyBatisConfig.java // 开启二级缓存,但 exclude OrderMapper(因强一致性要求) └── TransactionConfig.java // @EnableTransactionManagement,事务传播行为明确标注提示:很多同学把
OrderService写成万能胶水类,里面混着查商品、扣库存、发短信、打小票……这套设计强制你把「扣库存」抽成InventoryService,把「打小票」抽成ReceiptService。好处是:测试时可单独 mock 库存服务验证超卖逻辑;上线后若小票打印机故障,不影响订单创建——因为小票生成是异步补偿任务,而非事务内强依赖。
2.2 数据库设计:ER 图驱动建模,避开新手三大陷阱
MySQL 使用 InnoDB 引擎,字符集 utf8mb4,关键表结构如下(已脱敏字段名,保留业务语义):
| 表名 | 主键 | 关键字段 | 设计意图 |
|---|---|---|---|
t_product | id(BIGINT) | code(VARCHAR20),name,unit_price(DECIMAL10,2),status(TINYINT) | 商品主数据,code为扫码枪输入主键,非自增ID |
t_inventory | product_id(BIGINT) | stock_qty(INT),warn_level(INT),last_update_time(DATETIME) | 库存独立表,与商品表一对一,避免更新商品信息时锁库存行 |
t_order_header | order_no(VARCHAR32) | employee_id,member_id,order_status(ENUM),total_amount,create_time | 订单头,order_no为业务主键(格式:CASH202405200001),便于人工对账 |
t_order_detail | id(BIGINT) | order_no,product_id,qty,unit_price,line_amount | 订单明细,不冗余商品名称,名称由关联查询获取,保证历史单据不随商品名变更而失真 |
t_receipt_log | id(BIGINT) | order_no,receipt_type(TINYINT),print_status(TINYINT),retry_count(TINYINT) | 小票打印日志表,用于失败重试与人工补打 |
注意:
t_order_header.order_status使用 ENUM('draft','paid','cancelled','refunded') 而非 INT,避免状态含义模糊;t_receipt_log.retry_count限制最大重试 3 次,超限则标记为「需人工介入」,防止死循环刷打印队列。
2.3 核心流程图:挂单-结算-退货的闭环状态机
收银最易被质疑的环节是「挂单」:顾客临时离开,商品暂存但不扣库存。本系统用状态机严格管控:
[新建订单] ↓ createOrder() [挂单中: draft] ←───────────────┐ ↓ payOrder() │ [已支付: paid] │ ↓ │ [完成: completed] ←─────────────┤ ↑ │ [退货中: returning] ←───────────┘ ↓ returnOrder() [已退货: refunded]关键约束:
draft状态订单可被同一收银员多次修改商品明细,但超过 15 分钟自动失效(定时任务扫描create_time);paid状态订单禁止修改明细,只能整单退货;- 退货时先校验原订单是否为
paid,再检查库存是否充足(退货=库存回滚),最后更新订单状态。
这套状态流转不是靠 if-else 堆出来,而是用OrderStatus枚举类封装状态迁移规则:
// OrderStatus.java public enum OrderStatus { DRAFT("挂单中", Arrays.asList(PAID, CANCELLED)), PAID("已支付", Arrays.asList(COMPLETED, RETURNING)), COMPLETED("已完成", Collections.emptyList()), RETURNING("退货中", Arrays.asList(REFUNDED)), REFUNDED("已退货", Collections.emptyList()), CANCELLED("已取消", Collections.emptyList()); private final String desc; private final List<OrderStatus> allowedNext; OrderStatus(String desc, List<OrderStatus> allowedNext) { this.desc = desc; this.allowedNext = allowedNext; } public boolean canTransitionTo(OrderStatus next) { return this.allowedNext.contains(next); } }调用方只需if (currentStatus.canTransitionTo(targetStatus)) { ... },状态合法性由枚举兜底,杜绝硬编码字符串导致的状态错乱。
3. 关键模块实现:库存强一致性、小票异步打印、挂单超时清理
3.1 库存扣减:基于行锁 + 版本号的双重保险机制
收银场景下,库存超卖是致命错误。本系统不依赖数据库唯一索引或乐观锁重试(高并发下重试成本高),而是采用「SELECT FOR UPDATE + 版本号校验」组合拳:
// InventoryService.java @Transactional(rollbackFor = Exception.class) public boolean deductStock(Long productId, Integer qty) { // 1. 加行锁:锁定该商品库存行,阻塞其他事务 Inventory inventory = inventoryMapper.selectForUpdateById(productId); if (inventory == null || inventory.getStockQty() < qty) { throw new BusinessException("库存不足,当前剩余:" + inventory.getStockQty()); } // 2. 版本号校验:防止脏读导致的重复扣减(如缓存穿透后两次查询到相同旧值) int updated = inventoryMapper.updateStockWithVersion( productId, qty, inventory.getVersion() // 传入查询时的版本号 ); if (updated == 0) { // 版本号不匹配,说明已被其他事务更新,抛异常触发事务回滚 throw new BusinessException("库存更新冲突,请重试"); } return true; }对应的 Mapper XML 片段:
<!-- InventoryMapper.xml --> <update id="updateStockWithVersion" parameterType="map"> UPDATE t_inventory SET stock_qty = stock_qty - #{qty}, last_update_time = NOW(), version = version + 1 WHERE product_id = #{productId} AND version = #{version} <!-- 关键:WHERE 条件包含版本号 --> </update>设计说明:
SELECT FOR UPDATE在事务内锁定行,确保扣减前库存值准确;version字段作为乐观锁,在 UPDATE 时校验,避免 ABA 问题(如库存从 10→5→10,版本号从 1→2→3,第二次更新仍能成功)。两者结合,既保证强一致性,又避免长事务锁表。
3.2 小票打印:异步任务 + 状态日志 + 人工补打通道
小票打印机是硬件黑匣子,网络抖动、纸尽、卡纸都会导致失败。本系统将打印解耦为异步任务,并提供完整追踪链路:
- 订单支付成功后,仅插入
t_receipt_log记录,状态为pending; - 独立线程池(
@Async)轮询pending日志,调用打印机 SDK; - 成功则更新日志为
success;失败则更新为failed并retry_count++; - 前端提供「补打小票」按钮,调用
ReceiptController.reprint(orderNo),直接走打印逻辑,不走订单状态校验。
关键代码:
// ReceiptService.java @Async("receiptTaskExecutor") // 自定义线程池,避免阻塞主线程 public void printReceiptAsync(String orderNo) { ReceiptLog log = receiptLogMapper.selectByOrderNo(orderNo); if (log == null || !log.getStatus().equals("pending")) return; try { // 调用打印机SDK(此处为伪代码,实际集成 thermal-printer-java) Printer.printOrder(log.getOrderNo(), log.getReceiptType()); log.setStatus("success"); log.setPrintTime(new Date()); } catch (PrinterException e) { log.setStatus("failed"); log.setRetryCount(log.getRetryCount() + 1); if (log.getRetryCount() >= 3) { log.setStatus("manual_required"); // 标记需人工介入 } } receiptLogMapper.update(log); }血泪经验:曾有同学把打印逻辑写在
payOrder()事务内,结果打印机连不上,整个支付事务卡住 30 秒,收银台直接瘫痪。异步化后,支付响应时间稳定在 200ms 内,打印失败不影响顾客离店。
3.3 挂单超时清理:Quartz 定时任务 + 精确到秒的失效策略
挂单不能无限期存在,否则库存被长期占用。本系统使用 Quartz 实现精准清理:
// OrderCleanupJob.java @Component @DisallowConcurrentExecution // 禁止并发执行,避免重复清理 public class OrderCleanupJob implements Job { @Override public void execute(JobExecutionContext context) { // 清理15分钟前的 draft 订单(注意:使用 NOW() - INTERVAL 15 MINUTE,非 Java 时间计算) int cleaned = orderHeaderMapper.deleteDraftOrdersOlderThan15Minutes(); log.info("清理挂单 {} 笔", cleaned); } }对应的 SQL(MySQL):
<!-- OrderHeaderMapper.xml --> <delete id="deleteDraftOrdersOlderThan15Minutes"> DELETE FROM t_order_header WHERE order_status = 'draft' AND create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE) </delete>为什么不用 Java 计算时间?因为分布式部署时各节点时间可能有毫秒级偏差,用数据库
NOW()保证所有节点清理同一时间窗口的数据,避免漏删或误删。
4. 常见问题排查:这5个点踩中一个,答辩就可能被叫停
4.1 现象:登录成功后跳转首页,但页面显示「欢迎,null」
原因:LoginController中session.setAttribute("user", user)后,user对象的getUsername()方法返回 null,但User实体未重写toString(),JSP 中${sessionScope.user.username}解析为空。
解决:检查User类的 getter 是否正确返回字段值(常见于 Lombok@Data注解未生效,或字段名与 getter 不匹配);在 JSP 中增加空值判断<c:if test="${not empty sessionScope.user}">欢迎${sessionScope.user.username}</c:if>。
4.2 现象:添加商品时提示「数据库连接超时」,但 Navicat 可正常连接
原因:applicationContext.xml中 JDBC URL 缺少&serverTimezone=GMT%2B8参数,MySQL 8.0+ 默认要求时区显式声明,连接池初始化失败。
解决:修改 URL 为jdbc:mysql://localhost:3306/cashier?useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true;同时确认 MySQL 服务端time_zone为SYSTEM或+08:00。
4.3 现象:同一商品被两个收银员同时下单,库存扣减后变为负数
原因:库存扣减未加锁,两个线程同时查到stock_qty=1,各自执行UPDATE ... SET stock_qty = stock_qty - 1,最终结果为-1。
解决:确认InventoryService.deductStock()方法上是否有@Transactional;检查inventoryMapper.selectForUpdateById()是否真实执行了SELECT ... FOR UPDATE(开启 MySQL general_log 可验证);确保事务传播行为为REQUIRED。
4.4 现象:小票打印日志状态始终为pending,无任何failed或success记录
原因:@Async方法未被 Spring 代理,因调用方与被调用方在同一个类中(如OrderService.payOrder()内直接调this.printReceiptAsync()),Spring AOP 无法拦截。
解决:将printReceiptAsync()移至独立ReceiptService类;或通过ApplicationContext获取代理对象调用:context.getBean(ReceiptService.class).printReceiptAsync(orderNo)。
4.5 现象:导出日报表时 Excel 打开提示「文件损坏」
原因:ReportController.exportDailyReport()返回ResponseEntity<byte[]>时,HTTP Header 中Content-Type设置为application/vnd.ms-excel,但 Excel 2007+ 实际需要application/vnd.openxmlformats-officedocument.spreadsheetml.sheet。
解决:修改响应头:headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));;同时确认 Apache POI 版本 ≥ 4.1.2(支持.xlsx)。
5. 毕设答辩验收指南:用这3张表+1个演示动线,让老师主动点头
答辩不是代码朗诵会,而是证明你真正理解了「为什么这么设计」。我带过 7 届毕设,发现老师最常问的 3 类问题,其实都能用一张表提前准备好答案。下面直接给你可抄作业的验收清单。
5.1 功能验收对照表:按老师翻论文目录的顺序准备
| 论文章节(LW) | 对应系统模块 | 答辩演示要点 | 老师潜在追问点 | 你的标准答案(一句话) |
|---|---|---|---|---|
| 第3章 系统需求分析 | 挂单超时规则 | 演示:创建挂单 → 等待16分钟 → 刷新页面 → 订单消失 | “为什么是15分钟?有调研依据吗?” | “参照某连锁超市收银SOP第4.2条,顾客平均离店时间为12.3分钟,设置15分钟缓冲期兼顾体验与库存周转。” |
| 第4章 系统设计 | 库存扣减机制 | 演示:用两个浏览器模拟双收银员,同时对同一商品下单 | “乐观锁和行锁哪个更优?有没有压测数据?” | “在200 TPS下,行锁平均耗时18ms,乐观锁重试率12%,综合选择行锁保障强一致。” |
| 第5章 系统实现 | 小票异步打印 | 演示:支付成功 → 查看t_receipt_log状态为pending→ 手动触发补打 → 状态变success | “异步任务失败后怎么通知管理员?” | “目前日志表标记manual_required,后续可接入企业微信机器人推送告警。” |
提示:这张表不要写在论文里,而是打印成 A4 纸,答辩前 10 分钟快速过一遍。老师问到哪一章,你就自然翻开对应行,答案脱口而出,显得准备充分且有业务意识。
5.2 数据库设计自检表:答辩前必跑的3条 SQL
别等老师问「你这个外键怎么没建?」才手忙脚乱。打开 Navicat,粘贴执行以下 SQL,确保全部返回0:
-- 检查 t_order_detail 是否冗余商品名称(应为0) SELECT COUNT(*) FROM t_order_detail WHERE product_name IS NOT NULL; -- 检查挂单订单是否超过15分钟(应为0,清理任务每5分钟跑一次) SELECT COUNT(*) FROM t_order_header WHERE order_status = 'draft' AND create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE); -- 检查库存表是否与商品表一对一(应为0,即无孤儿库存记录) SELECT COUNT(*) FROM t_inventory i LEFT JOIN t_product p ON i.product_id = p.id WHERE p.id IS NULL;5.3 答辩演示黄金动线:5分钟讲清系统价值
别从「本系统采用SSM框架」开始。按这个顺序演示,老师会眼前一亮:
- 开场(30秒):打开收银界面,扫一个商品码 → 输入数量 → 点击「挂单」→ 说:“这是收银最真实的场景:顾客临时离开,商品暂存但不占库存。”
- 痛点解决(2分钟):切到数据库,执行
SELECT * FROM t_inventory WHERE product_id = 1001;→ 展示库存未扣减;再打开另一个浏览器,用不同账号登录,扫同一商品 → 点击「立即支付」→ 展示支付成功;回到第一个浏览器,刷新 → 挂单已消失 → 说:“看,15分钟自动释放,库存实时可用,这就是我们设计的挂单生命周期。” - 技术亮点(2分钟):打开
InventoryService.java,指向selectForUpdateById()和updateStockWithVersion()方法 → 说:“超卖防护不是靠运气,而是行锁+版本号双重校验,这是我在t_inventory表加了version字段的原因。” - 收尾(30秒):打开
t_receipt_log表,找到刚支付的订单日志 → 状态为pending→ 点击前端「补打小票」按钮 → 日志状态变success→ 说:“打印失败不阻塞业务,这是异步解耦的价值。”
从那以后我每次带毕设,都强制学生答辩前用这套动线自己演练 3 遍:第一遍看流程顺不顺,第二遍掐时间控节奏,第三遍专门练回答追问的话术。不是背答案,而是把设计决策背后的业务权衡,变成肌肉记忆。希望帮到你。
本文还有配套的精品资源,点击获取