上周刚交付了一套给本地连锁超市做的 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是库存和价格的源头,字段我列几个最关键的:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| barcode | varchar(32) | 商品条码,有索引 |
| name | varchar(100) | 商品名称 |
| category_id | bigint | 所属分类 |
| unit | varchar(10) | 单位,例如“瓶”“袋” |
| purchase_price | decimal(10,2) | 进价/成本价 |
| sale_price | decimal(10,2) | 正常售价 |
| member_price | decimal(10,2) | 会员价 |
| stock | int | 当前库存 |
| version | int | 乐观锁版本号 |
| status | tinyint | 1上架 0下架 |
金额字段必须用decimal(10,2),绝不能用 float 或 double。二进制浮点数在计算金额时会出现 0.1 + 0.2 = 0.30000000000000004 这种问题,收银算错一分钱都会让你头疼。
商品条码字段一定要加索引,因为收银台最高频的操作就是按条码查询。
3.2 订单主表与明细表
订单一定要拆主表和明细表。主表记录一笔订单的整体信息,明细表记录这一笔单里包含哪些商品。
t_order主表字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(32) | 订单号,唯一索引 |
| store_id | bigint | 门店ID |
| cashier_id | bigint | 收银员ID |
| member_id | bigint | 会员ID,可为空 |
| item_count | int | 商品种类数 |
| total_amount | decimal(10,2) | 商品原价合计 |
| discount_amount | decimal(10,2) | 优惠总金额 |
| pay_amount | decimal(10,2) | 实际应收 |
| pay_type | tinyint | 支付方式 |
| status | tinyint | 1正常 2已退货 |
| create_time | datetime | 下单时间 |
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 会员价与促销的计算顺序
会员和促销是收银链路里最容易算错的地方。我的处理顺序是:
- 先判断是否会员,会员价取商品表里的
member_price。 - 再判断该商品是否参与单品促销,如果促销价更低,取促销价。
- 最后做整单满减优惠。
这里面要特别注意促销区间的时间判断,比如某商品 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 这套组合足够可靠,剩下的就是耐心把业务细节抠到位。希望这篇项目拆解能帮你把自己的收银系统做稳,少踩几个我已经替你踩过的坑。