☰
基于Spring Boot+SSM的超市POS收银管理系统设计与实践
2026/9/30 8:22:05 网站建设 项目流程

上周刚交付了一套给本地连锁超市做的 POS 收银管理系统,项目代号就是标题里的 springboot_ssm866。很多朋友一听“POS”就以为只是收银台扫码收钱,实际做完才发现,它其实是把商品、库存、会员、促销、订单、报表串起来的一套收银运营系统。这套系统上线后稳定跑了三个门店,单量不算大,但每一个环节都要保证账目能对上、库存不超卖,这里面踩过的坑和总结出来的经验,值得单独写一篇。

如果你正准备做 Java 方向的毕业设计,或者公司想自建一套超市/便利店收银系统,又或者刚接手类似项目不知道从哪下手,这篇可以帮你少走不少弯路。

1. 先弄清楚:这套POS系统要解决超市的哪几件事

1.1 POS不是一张收银页面

我见过不少人把 POS 系统简化成“前端扫码 + 后端记录订单”,导致需求评审时漏了一堆核心功能。真实超市收银流程远比想象中要重:收银员扫码,商品要能立刻被识别出来;顾客有会员卡,要自动带出会员价;商品参加满减,要能在结算时自动算;一笔单子付完款,库存要同步扣减,小票还要能打印出来;晚上关店,店长要看当天的销售额、毛利、退货情况;月底还要对账。

这套 springboot_ssm866 项目的完整定位是:面向中小型超市、便利店的 Web 端收银与后台管理系统,一台普通电脑加一台小票打印机,就能支撑一个收银台。

1.2 拆出六类核心业务

我在做需求分析时,把整个系统拆成了六个核心域,这也是后续建表、分模块的底层依据:

  • 商品中心:商品分类、商品档案、条码管理、进价/售价/会员价、库存数量。
  • 收银中心:购物车、结算、支付方式(现金/微信/支付宝)、小票打印。
  • 会员中心:会员档案、会员价、积分累计、充值余额。
  • 库存中心:入库、销售出库、退货入库、盘点调整、库存流水。
  • 促销中心:整单折扣、单品促销、满减规则,跟着收银结算一起生效。
  • 报表系统:日结报表、商品销售排行、利润统计、经营趋势。

这六个域一旦理清,后面写代码就有了明确的边界。很多新手项目做不好,不是技术不行,而是上来就建表,结果业务逻辑全堆在一个 Controller 里,改一个功能牵一发动全身。

1.3 技术选型:Spring Boot + SSM 为什么够用

标题里的“springboot + SSM”其实是这样分工的:Spring Boot 负责快速搭建项目、内嵌 Tomcat、自动配置数据源;SSM 指的是 Spring、Spring MVC、MyBatis 这三件套。Spring 管对象,Spring MVC 接管 Web 请求,MyBatis 负责跟 MySQL 打交道。

选这套组合的直接原因是:团队对这套技术栈最熟,而且它是 Java 后端项目里社区资料最多的方案之一,遇到问题网上基本都能查到解决方案。对于小型超市的规模,完全不需要上 Spring Cloud 微服务那套,单体应用加一个 Tomcat 就足够,运维成本还低。

有一点要提醒:搭项目时建议用 Spring Boot 2.7 系列,别一上来就上 3.x。原因在于 Spring Boot 3 要求 JDK 17,而且把javax.*换成了jakarta.*,很多老的 MyBatis 兼容方案要跟着改。如果你用的还是 JDK 8,老老实实 Spring Boot 2.7 + MyBatis-Plus 3.5,这一套我已经跑通好几次,最省心。

2. 整体架构:从收银终端到数据库的分层设计

2.1 部署形态先想清楚

超市的收银场景和普通的后台管理系统不一样,收银台是高频操作,界面要简洁、按钮要大、回车键能快速结账。我在这个项目里选了传统 B/S 架构:前端用 Thymeleaf 模板加 Bootstrap 加 jQuery,后端负责接口和页面渲染。

新手容易纠结要不要前后端分离。我明确说:如果是单店或几台收银机的中小超市,服务端渲染就够了,省掉 Vue、跨域、JWT 这一堆事。反过来,如果要做大商城、多端开放,那再考虑 Spring Boot + Vue 前后端分离也不迟。

2.2 后端分层结构

项目目录结构我按标准的三层架构来组织:

com.cloud.pos ├── controller // 接收请求、参数校验 ├── service // 业务逻辑、事务控制 ├── mapper // MyBatis 数据持久层 ├── entity // 数据库实体 ├── dto // 前端交互对象 ├── config // 拦截器、全局异常配置 └── utils // 条码生成、小票格式化等工具

Controller 要薄,Service 要厚,事务放在 Service 层。这条原则看着简单,很多人实际写的时候就把查询逻辑直接堆在 Controller 里,后面想加事务和权限时非常痛苦。

不同角色的操作入口分为两类:收银员进入“收银台”页面,操作扫码、结算、退货;店长/老板进入“后台管理”页面,操作商品、库存、报表、员工账号。这个角色分界在权限设计里非常关键。

2.3 模块间的数据流

整个系统最核心的一条数据流是收银链路:

收银台扫码 → 根据条码查到商品 → 加入购物车 → 结算时校验会员和促销 → 计算应收金额 → 生成订单主表和明细表 → 扣减库存 → 生成支付流水 → 打印小票 → 同步累计会员积分。

这个链路里的每一步都要有对应的数据落库,不能只在前端算完就完事。否则日结时你根本无法还原某一笔交易当时到底发生了什么。

3. 数据库设计:核心表的落地方案

数据库设计是这类系统能不能稳定跑起来的分水岭。很多项目翻车都是因为表设计太随意,比如金额用 Float、库存没有版本控制、订单没有状态字段。这里我把核心表结构直接整理出来,你可以照着改。

3.1 商品相关表

商品分类表t_category字段比较简单:id、name、parent_id、sort、status。

商品表t_product是库存和价格的源头,字段我列几个最关键的:

字段类型说明
idbigint主键
barcodevarchar(32)商品条码,有索引
namevarchar(100)商品名称
category_idbigint所属分类
unitvarchar(10)单位,例如“瓶”“袋”
purchase_pricedecimal(10,2)进价/成本价
sale_pricedecimal(10,2)正常售价
member_pricedecimal(10,2)会员价
stockint当前库存
versionint乐观锁版本号
statustinyint1上架 0下架

金额字段必须用decimal(10,2),绝不能用 float 或 double。二进制浮点数在计算金额时会出现 0.1 + 0.2 = 0.30000000000000004 这种问题,收银算错一分钱都会让你头疼。

商品条码字段一定要加索引,因为收银台最高频的操作就是按条码查询。

3.2 订单主表与明细表

订单一定要拆主表和明细表。主表记录一笔订单的整体信息,明细表记录这一笔单里包含哪些商品。

t_order主表字段:

字段类型说明
idbigint主键
order_novarchar(32)订单号,唯一索引
store_idbigint门店ID
cashier_idbigint收银员ID
member_idbigint会员ID,可为空
item_countint商品种类数
total_amountdecimal(10,2)商品原价合计
discount_amountdecimal(10,2)优惠总金额
pay_amountdecimal(10,2)实际应收
pay_typetinyint支付方式
statustinyint1正常 2已退货
create_timedatetime下单时间

t_order_item明细表字段:

id、order_id、product_id、barcode、product_name、sale_price、quantity、amount、discount_amount、cost_price。

明细表里冗余存商品名称和条码很关键。商品档案以后可能改名,但历史订单要保留当时的商品信息,所以不能只存 product_id 再去关联查。

3.3 库存与库存流水表

库存字段直接冗余在商品表里,但这个方案必须有“防超卖”机制,我在第四章具体讲。同时还需要一张t_stock_log库存流水表,记录每一笔库存变动。

t_stock_log字段:

id、product_id、change_type(1采购入库、2销售出库、3退货入库、4盘点调整)、quantity、before_stock、after_stock、operator_id、create_time。

流水表存在的意义是溯源:某一天某个商品库存突然对不上,可以通过流水把每一笔变动还原出来。超市库存盘点是高频操作,没有流水表,盘点差异根本查不出发在哪一天哪一单。

3.4 编码风格与索引设计

主键统一用自增 bigint。订单号不要用自增主键直接展示,要用业务号,我这里的生成规则是:yyyyMMdd + storeId + 6位随机数/当天序号。

需要加索引的字段:barcode、order_no、order_item.order_id、stock_log.product_id、order.create_time。索引不是越多越好,但上述这些是查询高频字段,没有索引会出大事。

4. 核心代码落地:扫码、结算、会员、促销一条链路

4.1 收银页面的交互设计

收银台页面在 Web 端要做成全屏布局,中间一个大输入框,收银员光标默认停留在扫码框内。扫码枪本质上就是键盘输入设备,扫一下条码字符串再加一个回车,所以前端只需要监听回车事件。

后端查询商品接口可以做成防重查询:同样的条码在 1 秒内连续请求,直接返回缓存结果。因为顾客买同一种商品连续扫两次是很常见的,没必要每次都查数据库。

Controller 里查询商品的核心逻辑:

@Controller @RequestMapping("/cashier") public class CashierController { @GetMapping("/product") @ResponseBody public Result<ProductVO> getProduct(@RequestParam String barcode) { // 优先查Redis缓存,没有则查数据库 ProductVO product = productService.findByBarcode(barcode); if (product == null) { return Result.error("未找到该商品"); } return Result.success(product); } }

4.2 购物车与结算的事务控制

前端购物车可以放在 Session 或 Redis 里。收银员扫一个加一个,每一行展示商品名、单价、数量和小计。点击“结算”或者按快捷键 F1,前端把购物车数据提交给后端。

这里有个关键设计:购物车数据只是展示用的草稿,真正的金额计算必须在后端重新算一遍,前端传过来的单价和总价一律不信任。

后端结算伪代码:

@Service public class CheckoutServiceImpl implements CheckoutService { @Override @Transactional(rollbackFor = Exception.class) public PayResult checkout(CheckoutRequest request) { // 1. 校验收银员权限 // 2. 遍历明细,逐个查询商品最新价格 // 3. 计算会员价/促销折扣 // 4. 调扣库存方法,逐个商品做条件更新 // 5. 生成订单主表、明细表、支付流水 // 6. 累计会员积分 return PayResult.success(orderNo); } }

注意结算方法上要加@Transactional,这保证了“扣库存失败则订单不落库”这个业务约束。如果不用事务,会出现订单生成了但库存没扣,或者库存扣了但订单没生成,日结时对账必出问题。

4.3 扣库存的防超卖写法

库存扣减我用的是不带版本号的原子更新写法,本质上是一个条件更新:

<update id="deductStock"> update t_product set stock = stock - #{quantity} where id = #{productId} and stock >= #{quantity} </update>

and stock >= #{quantity}这个条件就是防超卖的关键。如果影响行数为 0,说明库存不足,业务层立即抛异常回滚。这样即使两个收银员同时买同一件最后一件商品,数据库层面的行锁也能保证只有一个人成功。

我之前还见过用程序先查库存再判断够不够的写法,并发一上来必超卖。这种校验放到 SQL 条件里,是数据库层面最便宜的防超卖方案。

4.4 会员价与促销的计算顺序

会员和促销是收银链路里最容易算错的地方。我的处理顺序是:

  1. 先判断是否会员,会员价取商品表里的member_price。
  2. 再判断该商品是否参与单品促销,如果促销价更低,取促销价。
  3. 最后做整单满减优惠。

这里面要特别注意促销区间的时间判断,比如某商品 6 月 1 日到 6 月 7 日促销,收银当天必须落在区间内才能生效。我把促销规则单独放在t_promotion_rule表里,结算时先加载生效中的规则再逐项匹配,避免把促销逻辑写死在代码里。

4.5 小票打印的落地

Web 端打印小票有两种方案:一种是前端window.print()打印 HTML 样式;另一种是后端生成纯文本拼接,走小票打印机指令。我项目里选的是前端打印方案,页面里单独放一个隐藏的小票模板区,结算成功后用 CSS 控制只打印该区域,打印结束自动清空。

小票固定输出内容:

阳光超市 单号: 20250612001 收银员: 张三 ------------------------ 商品名 数量 金额 农夫山泉 1 2.00 ------------------------ 合计: 2.00 实收: 2.00 支付方式: 微信 时间: 2025-06-12 10:30:00

小票打印是收银员每天要操作几十次的功能,宁可在开发时多花一天调样式,也不要在上线后让收银员骂一周。

5. 库存、退货、日结对账:收银系统最容易翻车的三个点

5.1 库存扣减的补充要点

前面提到的条件更新扣库存,解决了并发超卖问题,但还有两个坑要说明。

第一个坑是代码里的库存同步。如果每次下单都直接更新t_product.stock,同时还要写t_stock_log流水,这两个操作必须在同一个事务里。否则扣了库存但流水没记录,盘点时就无法追踪。

第二个坑是数据库行锁可能导致的死锁。多个商品在一个订单里同时扣库存时,要对商品 ID 做排序。比如购物车里有商品 A、B、C,要保证所有并发请求都按 A、B、C 的顺序去执行更新,而不是 A→C→B 或者 B→A→C 乱序。“边扫边结算”的场景很少,但一旦并发起来乱序更新就容易死锁回滚,重试又导致收银员体验变差。

5.2 退货逻辑怎么设计

退货在 POS 系统里极其常见,顾客买完发现不合适,回头就退。退货设计有两个主流方案:一种是直接把原订单删除或者标记退货状态,然后生成一张负数订单;另一种是从头到尾生成一张order_type = 2的退货单,正向销订单状态不变。

我推荐第二种。原因是删除订单和负数单都会让日结报表算法变得很绕,而完整的退货单可以让报表和库存流水完全串联起来。退货时,主表t_order.status置为“已退货”,同时新增一张退货单,明细中商品数量为负,库存做“退货入库”处理。

退货单和原单之间要有refund_order_no字段关联。对账时看到一笔退款,能立刻找到原始销售单;看到原始销售单,也能知道它是否已经退过,避免重复退货。

5.3 日结对账

每天关店后,店长要看到一张清晰的日结报表。报表要展示当天销售总单数、销售额、退款额、实际净销售额、商品销售排行。我实现了一个定时任务,每天凌晨跑前一天的汇总数据,写入独立的t_daily_report表。

这里特别提醒:日结报表的数据不要实时汇总订单表,因为订单表数据量大之后,每次读报表都全表扫描会拖慢收银性能。定时任务在凌晨跑一次,把结果落表,白天查报表直接读聚合结果。对于中小超市的量级,这个方案简单高效。

日结报表的 SQL 核心逻辑:

SELECT DATE(create_time) AS biz_date, COUNT(*) AS order_count, SUM(pay_amount) AS pay_amount, SUM(CASE WHEN status = 2 THEN pay_amount ELSE 0 END) AS refund_amount FROM t_order WHERE create_time >= #{date} AND create_time < DATE_ADD(#{date}, INTERVAL 1 DAY) GROUP BY DATE(create_time);

报表落表后,还要对账:系统里的销售额要和支付渠道的对账单对上。如果不一致,优先查退货单、支付流水和磁盘上的小票日志。我在这套系统里额外加了一张t_pay_log,每一笔支付成功都写一条流水,对账时逐笔比对,问题能缩小到具体某一笔交易。

6. 权限设计、操作日志与多门店支持

6.1 用户-角色-权限模型

超市入职的收银员可能很多,但权限不能人人相同。核心原则是:收银员只能进收银台,可以查自己的销售记录,但不能看成本价,也不能改后台配置;店长可以做日结、看报表、管商品;老板/管理员能管员工账号和各门店数据。

我用的是经典 RBAC(基于角色的访问控制)模型,三张表:用户表、角色表、用户角色关联表。权限点可以在代码里用注解或拦截器实现。Spring Boot 里最简单的做法是自定义一个HandlerInterceptor,在进入 Controller 前校验当前用户的角色和权限。

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { HttpSession session = request.getSession(); User user = (User) session.getAttribute("user"); if (user == null) { response.sendRedirect("/login"); return false; } // 角色和操作权限校验 return true; } }

用拦截器而不是每个 Controller 复制粘贴校验逻辑,代码能清爽很多。

6.2 操作日志

涉及钱的功能全都要记日志:登录、改价、退货、库存调整、给会员充值。我建了t_oper_log表,字段包括操作人、操作类型、操作内容、IP、时间。这些日志不仅是审计需要,更是排查问题时的重要现场。之前有一次退货金额对不上,就是靠日志还原了操作人手动改了一个折扣参数,才找到原因。

6.3 多门店的数据隔离

如果是连锁超市,多门店是最常见的需求。我在这里的设计是:所有业务表都带store_id字段,查询时强制带门店维度。商品档案可以在总部统一维护,但各门店库存独立。订单表、库存表、报表表全部按 store_id 区分。

这一步做在前面很重要。表结构已经上线再补门店维度,要改的不仅是表,还有所有查询 SQL,工作量翻倍。

7. 打包部署与上线后踩到的坑

7.1 从源码到生产环境的打包

项目基于 Maven 构建,打包命令很简单:

mvn clean package -DskipTests

打完包在target目录下会生成一个可执行 jar,比如pos-server.jar,部署方式可以直接用:

java -jar pos-server.jar --spring.profiles.active=prod

如果是正式环境,我习惯用外部配置文件和 jar 分离部署,修改数据库连接或端口时不用重新打包。配置项写在application-prod.yml里,启动命令带上 profile 就能切环境。

数据库连接串里最容易踩的坑是时区,MySQL 8 必须显式指定:

spring: datasource: url: jdbc:mysql://localhost:3306/pos_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

不写serverTimezone,Java 和 MySQL 的时区不一致,订单时间会差 8 个小时,日结报表一眼看过去全是错账。

7.2 实测踩过的三个坑

第一个坑是金额精度。初期代码里用 double 算金额,结果某天报表发现一笔订单多了一分钱。排查后发现是商品单价 3.45 元乘以数量 7,二进制浮点数计算出来是 24.150000000000002。改成 BigDecimal 后,后端所有金额计算统一走setScale(2, RoundingMode.HALF_UP),问题消失。

第二个坑是内嵌 Tomcat 的并发限制。Spring Boot 默认内嵌 Tomcat 是工作线程池配置,参数太高或太低都会出问题。中小超市收银高并发压力不大,但遇到节假日促销会有短时集中结算。我在配置里显式设置了:

server: tomcat: max-threads: 200 min-spare-threads: 20 max-connections: 10000

别小看这几个参数。默认配置在促销高峰期会排队,收银员那边按了结算半天没反应,体验非常差。

第三个坑是拿到别人的 jar 包想逆向改成自己的项目。有一次同事接手一个部署好的 jar,没有源码,只有一份可执行文件。我用了反编译工具把 class 还原成源码,大致看懂了表结构和接口,但这种操作只能用来应急学习和排查,千万别依赖它做二次开发。最好还是拿到完整源码,用 Git 管理版本,后续改需求才靠谱。

7.3 上线后的一点个人体会

这套系统上线之后,我自己回访过两次收银台。第一次去发现收银员把扫码框的输入法设置成了中文,导致条码扫码进去变成汉字,后来在页面里限制了输入法自动切换。第二次去发现小票打印经常空半张纸,排查后是打印样式里用了固定高度 div,改了样式后解决。

做这类业务系统,真正拉开差距的不是框架用得多花哨,而是能不能在收银员按下那一下“结算”的时候,把所有账算对、让库存扣准、让小票顺利打出来。Spring Boot + SSM 这套组合足够可靠,剩下的就是耐心把业务细节抠到位。希望这篇项目拆解能帮你把自己的收银系统做稳,少踩几个我已经替你踩过的坑。

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

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

立即咨询