简介:在线收银系统是一套基于Java SSM框架的毕业设计完整源码包,面向小型零售企业的收银与销售管理场景,适用于计算机专业学生进行毕业设计、课程设计或Java Web开发进阶学习。压缩包约23.97MB,内部以Java源码、SSM配置文件、MyBatis映射、MySQL数据库脚本以及说明文档和LW论文为主,代码结构清晰、注释完整,可直接导入Eclipse或IDEA配合Tomcat7运行。目前已有78人学习/下载,可作为毕业设计或课程设计的直接参考。系统功能覆盖系统用户管理、员工管理、用户管理、商品类别与商品管理、入库管理、销售管理和销售统计等完整业务闭环,并配有详细说明文档;通过学习这套源码,能够深入理解SSM框架整合、MyBatis数据持久层、订单与库存业务逻辑处理等关键知识点,掌握从数据库设计到Web层调用的完整开发思路,也可为毕业设计答辩提供完整方案,适合需要快速搭建管理类项目、参考商业级代码结构的开发者使用。
1. 在线收银系统:一个把 SSM 底层操作全部串起来的实战型毕设
在超市或者奶茶店的收银台前,收银员点几下鼠标就能完成扫码、算价、收款、出小票这一串动作。把这串动作原样搬到浏览器里,就是我要拆的这套在线收银系统。它用 SSM(Spring + SpringMVC + MyBatis)和 MySQL 组合实现,覆盖商品管理、购物车收银、订单入库、会员积分、登录权限这类门店收银全流程,适合正在找 java 毕业设计、课程设计源码的同学,也适合想通过一个完整项目彻底弄懂 SSM 整合过程的人。
先说一个反直觉的结论:这套系统在功能上并不炫,它的价值在"完整"两个字。它没有堆中间件,而是把一个真实收银场景做闭环了——前台能下单,后台能做商品上下架和订单管理,压缩包里还带着说明文档和论文(LW),需求分析、数据库设计、流程图全都有落点。比起那种只贴五个增删改查页面的凑数项目,它真正把 SSM 的考点串了进去:三层架构、声明式事务、MyBatis 动态 SQL、拦截器权限控制。
拆完代码我的感受是:这类项目最怕的不是功能少,而是代码糊成一团。这套系统分层很规矩,Controller 只做参数接收和页面跳转,Service 层控制事务,Mapper 层写 SQL。顺着代码往下读,一次收银从点击按钮到扣库存的完整链路很快就能理清,这也正好是毕业答辩时最好讲的地方。
2. 技术选型与整体架构:SSM + MySQL 的经典分层是怎么搭起来的
2.1 技术栈拆解:为什么选 SSM 而不是 Spring Boot
打开压缩包里的 pom.xml,第一印象就是"熟"——spring-webmvc、mybatis、mybatis-spring、mysql-connector-java,再加一个 druid 连接池,这就是一个标准的 SSM 工程。Spring 负责管理对象和事务,SpringMVC 负责接收请求和路由,MyBatis 负责和 MySQL 打交道。前端页面用 JSP 加 jQuery,表格渲染常见的是 layui,这个组合在近几年的毕业设计里出现频率非常高。
很多人会问,现在新项目不都用 Spring Boot 吗,为什么毕设还选 SSM?我的看法是:SSM 需要你亲手写 spring-mvc.xml、spring-mybatis.xml、web.xml 这些配置文件,恰恰能展示你对框架底层的理解。Spring Boot 把一切自动配置了,答辩时老师一句"你讲讲自动配置原理",很多同学就卡住了。而 SSM 的配置是透明的,每个 bean、每条扫描规则都在 xml 里摆着,讲起来有据可依。
版本搭配上,这类工程最稳的组合是 JDK 1.8 + Maven 3.6 + Tomcat 8.5 + MySQL 5.7,Spring 4.3 或 5.x,MyBatis 3.4 左右。这套组合的兼容性最好,网上资料也全,出问题好搜。如果非要用 JDK 17 跑,大概率会在启动阶段遇到反射相关的报错,后面避坑部分会具体说。我一般建议直接按这个组合搭环境,省下的时间足够把代码读一遍。
2.2 项目目录结构:从 Controller 到 Mapper 的分层地图
拿到源码后先别急着跑,花十分钟把目录结构过一遍。这是一个标准的 Maven 工程,代码按 controller、service、mapper、entity、interceptor、utils 分层,逻辑非常清晰:
online-pos ├── pom.xml ├── sql │ └── pos.sql └── src/main ├── java │ └── com/xxx/pos │ ├── controller │ │ ├── LoginController.java │ │ ├── GoodsController.java │ │ ├── OrderController.java │ │ └── MemberController.java │ ├── service │ │ ├── GoodsService.java │ │ ├── OrderService.java │ │ └── impl │ │ ├── GoodsServiceImpl.java │ │ └── OrderServiceImpl.java │ ├── mapper │ │ ├── GoodsMapper.java │ │ ├── OrderMapper.java │ │ └── MemberMapper.java │ ├── entity │ │ ├── Goods.java │ │ ├── Order.java │ │ └── Member.java │ ├── interceptor │ │ └── LoginInterceptor.java │ └── utils │ └── OrderNoGenerator.java ├── resources │ ├── jdbc.properties │ ├── spring-mybatis.xml │ ├── spring-mvc.xml │ └── mapper │ ├── GoodsMapper.xml │ └── OrderMapper.xml └── webapp ├── index.jsp ├── static │ ├── layui │ └── js └── WEB-INF └── views ├── login.jsp ├── goods_list.jsp └── order_list.jsp这个分层其实就是 SSM 的教科书结构:Controller 层接收前端参数,调用 Service 接口;ServiceImpl 里写业务逻辑和事务注解;Mapper 接口对应 resources/mapper 下的 XML 文件,SQL 不会散落在 Java 代码里。改业务的时候从上往下找定位,效率很高。
有两个细节值得注意。第一,entity 里的字段和数据库列是驼峰映射的,MyBatis 的 mapUnderscoreToCamelCase 一般在 spring-mybatis.xml 里开启,这样 order_no 能自动映射到 orderNo,不用每个字段写 resultMap,省不少事。第二,JSP 放在 WEB-INF/views 下面,浏览器不能直接访问,只能通过 Controller 的 return "goods_list" 这种方式转发进去,这是一个经典的 MVC 隔离做法,也避免别人绕过后端直接打开页面。
2.3 数据库设计:订单、商品、会员这几张核心表怎么建模
sql 目录下的初始化脚本会把库和表都建好。收银系统的核心表,我拆到的主要是这几张:用户表 user(登录和角色控制)、商品分类表 goods_type、商品表 goods、订单主表(脚本里一般命名为 t_order,避免 order 这个保留字)、订单明细表 t_order_detail、会员表 member(余额和积分)。表不多,但关系很清晰,一张订单对应多条明细,一个会员拥有多张订单,商品挂在分类下面。
先看商品表:
CREATE TABLE `goods` ( `id` int(11) NOT NULL AUTO_INCREMENT, `barcode` varchar(32) DEFAULT NULL COMMENT '条码', `name` varchar(100) NOT NULL COMMENT '商品名称', `type_id` int(11) DEFAULT NULL COMMENT '分类ID', `purchase_price` decimal(10,2) DEFAULT NULL COMMENT '进价', `sale_price` decimal(10,2) NOT NULL COMMENT '售价', `stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1上架 0下架', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;商品表有几个设计讲究。金额字段全部用 decimal,不用 float 和 double,这是血泪教训——浮点数在累加和比较时会出现 0.1 + 0.2 != 0.3 的问题,收银系统天天和钱打交道,金额精度错了会直接在答辩时被老师问住。库存用 int,状态用 tinyint 做上下架标记,下架的商品在收银台前端搜不到,但后台还能看到记录,这比直接删数据合理。
订单主表和明细表是收银系统的核心。一次收银产生一条主表记录,存订单号、总额、实收、优惠、积分;明细表存每一件商品的数量和单价:
CREATE TABLE `t_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `cashier_id` int(11) DEFAULT NULL COMMENT '收银员ID', `member_id` int(11) DEFAULT NULL COMMENT '会员ID', `total_amount` decimal(10,2) NOT NULL COMMENT '商品总额', `discount_amount` decimal(10,2) DEFAULT '0.00' COMMENT '优惠金额', `pay_amount` decimal(10,2) NOT NULL COMMENT '实收金额', `points` int(11) DEFAULT '0' COMMENT '本次获得积分', `create_time` datetime DEFAULT NULL COMMENT '下单时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `t_order_detail` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_id` bigint(20) NOT NULL COMMENT '订单ID', `goods_id` int(11) NOT NULL COMMENT '商品ID', `quantity` int(11) NOT NULL COMMENT '数量', `price` decimal(10,2) NOT NULL COMMENT '成交单价', `subtotal` decimal(10,2) NOT NULL COMMENT '小计', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;这里有个细节:表名避开了 order 这个 MySQL 保留字,用了 t_order 前缀。很多初学同学建表直接用 order,查询、排序、group by 都有可能报 SQL 语法错误,最后只能用反引号硬扛。订单号加唯一索引是因为它是用户查单和退单的关键凭据,不能重复。至于订单明细为什么不直接塞进主表——一张订单可能买十几件商品,主表和明细分开,主表保持轻量,统计销售额时 group by create_time 也更快。
3. 核心模块拆解:从登录拦截到订单入库的完整链路
这一章按真实业务链路走一遍:登录鉴权、商品浏览、收银下单、会员积分,每个环节都是系统里一个不可跳过的关卡。
3.1 登录认证与权限拦截:收银员和管理员的访问边界
系统里有两类用户:管理员管商品和订单,收银员只负责在前台点单收银。如果收银员直接访问后台管理 URL,就需要拦截器把这层权限挡下来。项目里用 SpringMVC 的 HandlerInterceptorAdapter 实现登录和角色校验:
public class LoginInterceptor extends HandlerInterceptorAdapter { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User user = (User) session.getAttribute("loginUser"); if (user == null) { // 未登录的请求统一踢回登录页 response.sendRedirect(request.getContextPath() + "/login"); return false; } String uri = request.getRequestURI(); // 收银员角色不能访问 /admin/** 的后台管理接口 if ("cashier".equals(user.getRole()) && uri.contains("/admin/")) { request.setAttribute("msg", "权限不足,请联系管理员"); request.getRequestDispatcher("/WEB-INF/views/error.jsp").forward(request, response); return false; } return true; } }这段拦截器的逻辑很直白:先看 session 里有没有登录用户,没有就重定向到登录页;有用户再看角色,收银员碰到管理员专属路径就转发到错误页。注意重定向和转发的区别——sendRedirect 会让浏览器重新发起一次请求,request 里的提示信息会丢;forward 是服务端内部跳转,能把 msg 带到目标页面,所以权限不足的提示必须用 forward。
拦截器要在 spring-mvc.xml 里注册,并且必须给静态资源放行,否则 layui 的 css、js 全被拦下来:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <mvc:interceptor class="com.xxx.pos.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>mvc:mapping 配置拦截范围,mvc:exclude-mapping 放行登录接口和静态资源。这个配置漏了 static 路径,页面样式会全部崩掉,只剩光秃秃的 HTML。登录接口也要放行,不然用户连登录页都打不开。
3.2 商品管理:分页查询与上下架的 MyBatis 实现
商品列表是后台最常用的功能,查询条件多(名称模糊、分类、状态),还要分页。MyBatis 的动态 SQL 在这里体现得非常典型:
<select id="queryGoodsList" resultType="com.xxx.pos.entity.Goods"> SELECT g.id, g.name, g.barcode, g.sale_price, g.stock, g.status, gt.name AS type_name FROM goods g LEFT JOIN goods_type gt ON g.type_id = gt.id <where> <if test="name != null and name != ''"> AND g.name LIKE CONCAT('%', #{name}, '%') </if> <if test="typeId != null and typeId != 0"> AND g.type_id = #{typeId} </if> <if test="status != null"> AND g.status = #{status} </if> </where> ORDER BY g.id DESC LIMIT #{offset}, #{pageSize} </select>where 标签会自动处理开头的 AND,条件不满足时整个 where 不生成,查询退化成全表分页。LIMIT 后面的 offset 是起始行,pageSize 是每页条数,前端传页码 pageNum 时,Controller 里要算 offset = (pageNum - 1) * pageSize,这个计算经常有人漏。配合分页还要写一个 count 查询拿总条数,否则前端渲染不出总页码。
上下架功能更简单,一个 update 把 status 在 0 和 1 之间切换,前台收银界面只查 status = 1 的商品。这个设计比直接删除商品聪明,既保留了下架商品的历史订单数据,又不影响在售商品的搜索体验。就算要清空商品数据,也应该用逻辑删除而不是物理删除。
3.3 收银下单:订单主表与明细表如何在同一个事务里写入
收银是这个项目的核心场景。前端把购物车里的商品 ID 和数量拼成 JSON 提交到后端,OrderServiceImpl 用一个方法完成:生成订单号、写主表、写明细、扣库存、累计积分。这五个动作任何一步失败,前面写的订单数据都不能留下来,所以方法上挂了 @Transactional:
@Override @Transactional(rollbackFor = Exception.class) public void createOrder(OrderVO vo) { BigDecimal total = BigDecimal.ZERO; // 1. 遍历购物车,校验商品并计算总额 List<OrderItemVO> items = vo.getItems(); for (OrderItemVO item : items) { Goods goods = goodsMapper.selectById(item.getGoodsId()); if (goods == null || goods.getStatus() != 1) { throw new RuntimeException("商品不存在或已下架:" + item.getGoodsId()); } item.setPrice(goods.getSalePrice()); BigDecimal subtotal = goods.getSalePrice().multiply(BigDecimal.valueOf(item.getQuantity())); item.setSubtotal(subtotal); total = total.add(subtotal); } // 2. 生成订单号,插入主表拿到自增ID Order order = new Order(); order.setOrderNo(OrderNoGenerator.next()); order.setTotalAmount(total); order.setPayAmount(vo.getPayAmount()); order.setMemberId(vo.getMemberId()); orderMapper.insert(order); // 3. 写明细并扣减库存 for (OrderItemVO item : items) { OrderDetail detail = new OrderDetail(); detail.setOrderId(order.getId()); detail.setGoodsId(item.getGoodsId()); detail.setQuantity(item.getQuantity()); detail.setPrice(item.getPrice()); detail.setSubtotal(item.getSubtotal()); orderDetailMapper.insert(detail); goodsMapper.reduceStock(item.getGoodsId(), item.getQuantity()); } // 4. 给会员累计积分 if (vo.getMemberId() != null) { memberMapper.addPoints(vo.getMemberId(), total.intValue()); } }这段代码的关键在第 3 步之前:插入主表后必须立刻拿到自增主键,明细表才能通过 order_id 关联到正确的订单。MyBatis 里常见做法是在 mapper.xml 配置 useGeneratedKeys:
<insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_order (order_no, total_amount, pay_amount, member_id, create_time) VALUES (#{orderNo}, #{totalAmount}, #{payAmount}, #{memberId}, NOW()) </insert>useGeneratedKeys 表示使用数据库自增主键,插入后自动回填到 Order 对象的 id 属性,后续业务直接用 order.getId() 就行。比另一种 getLastInsertId 的写法更严谨——后者在并发高的场景下可能拿到别的线程刚插入的 ID。收银台订单密集,这个细节很容易翻车。
3.4 会员积分:一次消费怎么联动会员余额和积分
会员在收银台被识别后,下单成功要自动加积分。积分更新的 SQL 是典型的直接量操作:
<update id="addPoints"> UPDATE member SET points = points + #{points} WHERE id = #{memberId} </update>注意这里写的是 points = points + #{points},不是先 SELECT 出来加完再 UPDATE 回去,后者在并发下会丢更新。SQL 层面直接做加减,天然避免了竞态问题。同理会员储值卡扣款也是同一套路,不过扣款多了一个校验条件:
<update id="deductBalance"> UPDATE member SET balance = balance - #{amount} WHERE id = #{memberId} AND balance >= #{amount} </update>执行这条 SQL 后,如果受影响行数是 0,说明余额不足,这笔操作要抛异常回滚,前面的订单、明细、积分全部撤销。很多同学习惯在 Java 里先 SELECT 余额再比较,单用户测试时没毛病,但并发扣款时两个请求都读到了够用的余额,最终余额就变成负数。把余额检验下推到数据库的 WHERE 条件里,才是这类资金场景的标准姿势。
4. 本地跑通全流程:从导入 SQL 到 IDEA 启动的踩坑记录
拆项目最重要的是先让它跑起来。这一章我用完整流程讲清楚从拿到压缩包到浏览器里出现登录页的每一步,每一步都标注我踩过的坑。
4.1 环境准备与版本搭配:先把 JDK 和数据库版本对齐
第一次跑 SSM 项目,环境版本不对是最大的翻车点。推荐直接用这个组合:JDK 1.8、Maven 3.6.x、Tomcat 8.5、MySQL 5.7、IDEA(社区版或旗舰版都行)。这个组合是这类工程最常见的部署环境,兼容性最好。
特别提醒:如果你本机装的是 JDK 17 或更高版本,启动阶段很容易报错,比如 ClassNotFoundException 或者各种反射异常,因为老版本的 CGLIB 代理和 Tomcat 对高版本 Java 支持不完整。我一般会在机器上同时装一个 JDK 8,IDEA 里为这个项目单独把 Project SDK 和编译器都切成 8,这样最省事。
MySQL 建议用 5.7。如果你只有 8.x 也可以用,但连接驱动和连接串要改对,这个细节在第 4.4 节单独说。
4.2 导入项目与修改配置:三处必改的配置项
把压缩包解压后,里面是一个完整的 Maven 工程目录。IDEA 里 File -> New -> Project from Existing Sources,选中 pom.xml,Import Maven,等依赖下载完。首次下载依赖可能很慢,这是 Maven 中央仓库的访问速度问题,跟项目本身没关系。
等待期间先把三处配置改好。第一处是资源目录下的 jdbc.properties,这是系统能不能连上数据库的关键:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/pos?useUnicode=true&characterEncoding=utf8&useSSL=false jdbc.username=root jdbc.password=123456jdbc.url 里,pos 是数据库名,要用初始化脚本建好的库名保持一致;characterEncoding=utf8 是中文不乱码的前提;useSSL=false 是避免 MySQL 8 连接时告警或报错。
第二处是 Maven 的远程仓库配置。IDEA 用的 Maven 如果没配国内镜像源,依赖可能下到一半就断。常见做法是在 Maven 的 settings.xml 里配置一个国内仓库镜像源,把 mirrorOf 指向 central。
第三处是 Tomcat 配置。IDEA 里 Run -> Edit Configurations,新建 Tomcat Server -> Local,Deployment 里把项目 artifact 添加进去,Application context 填 /pos,这里填什么决定了访问入口。建议统一填项目名 /pos,避免和别人的访问路径混淆。
三处改完先别急着启动,把数据库脚本先跑了。用命令行或数据库客户端执行 sql 目录下的 pos.sql,确认建库建表和初始化数据都正常。注意连接 URL 里的库名和脚本里 CREATE DATABASE 的库名必须一致,这也是常见的连接失败原因。
4.3 启动与验证:从登录页到第一笔订单
配置完成后启动 Tomcat,控制台出现启动成功的日志后,浏览器访问 http://localhost:8080/pos/login。看到收银系统登录界面,第一步就成了。
登录页一般有一个初始化管理员账号,通常写在说明文档里,比如 admin / admin。登录后先进商品管理,检查列表能否正常分页,新建一个测试商品,把价格填好后去收银台搜索这个商品,加购物车,点结账。这套流程完整走通,说明数据库读写、前端静态资源、Controller 跳转、事务回滚全链路都是通的。
如果商品列表能打开但收银台页面样式乱了,八成是静态资源被拦截器拦了,回 3.1 节检查 mvc:exclude-mapping。如果下单成功但订单列表找不到记录,优先查订单主表和明细表的 ID 关联有没有配 useGeneratedKeys。
验证这一步值得多花几分钟走完整流程,而不是登录成功就收工。登录页能打开只说明入口通,真正决定项目质量的是下单这个核心事务链路能不能完整走通。
4.4 避坑:SSM 老项目启动最常见的 5 个问题
这里整理的都是跑 SSM 老项目时反复出现的问题,按现象、原因、解决三段式记录,碰到类似报错直接对号入座。
第一个坑:Tomcat 启动成功但访问页面 404。现象是启动日志正常,浏览器访问却 404。原因基本是 URL 没带 context path,或者 IDEA 部署时没把 artifact 挂到 Tomcat 上。解决:检查访问地址是不是 http://localhost:8080/pos/xxx,再看 Deployment 里有没有添加项目 artifact,没有就补上并确认 Application context。
第二个坑:ClassNotFoundException: com.mysql.jdbc.Driver。现象很直接,数据库驱动类找不到。原因是本机 MySQL 是 8.x,连接串里却用老版驱动类名。解决:8.x 改成 com.mysql.cj.jdbc.Driver,并在 URL 里加 useSSL=false 和 serverTimezone=Asia/Shanghai,时区不指定一样会报连接错误。
第三个坑:Maven 依赖下载卡死。现象是 IDEA 一直停留在 Downloading,进度条不动。原因是默认中央仓库在境外,网络不稳。解决:在 settings.xml 配国内镜像源,然后让 IDEA 重新导入 Maven。
第四个坑:启动报 BeanCreationException。现象是 Spring 容器创建某个 bean 失败,错误堆栈里能看到 CGLIB 或 Enhancer 字样。原因多半是 JDK 版本太高,老 Spring 的代理库在高版本 Java 下不兼容。解决:把项目 SDK 切到 JDK 8,同时确认 Maven 编译 target 也是 8。
第五个坑:页面中文乱码。现象是商品名称、会员姓名在页面上显示成问号。原因有两层:MySQL 连接串少了 characterEncoding=utf8,或者 JSP 页面头部没声明 UTF-8。解决:连接串补上参数,同时确认 spring-mvc.xml 里配置了 CharacterEncodingFilter,两个位置都改对,乱码才会彻底消失。
提示:报错日志如果出现多行堆栈,先看最上面的 Caused by 那一行,真正根因基本都在最底层,别被中间大段堆栈带偏。
5. 核心代码精读:Service 事务与 MyBatis 动态 SQL 的边界把握
第 3 章偏重业务链路,这一章单独把三个最容易考、也最容易出问题的技术点拿出来精读:事务边界、动态 SQL 拼接、前端 JSON 交互。
5.1 OrderService 的下单事务:边界往往就是方法边界
@Transactional 放在 createOrder 方法上,作用范围就是方法体内所有数据库操作。Spring 的声明式事务默认行为:方法进入时开启事务,正常返回时提交,方法内部抛出没有被捕获的异常时回滚。但有个关键细节——默认只回滚 RuntimeException,如果业务抛出的是受检异常(比如直接 throws Exception),事务不会回滚。所以在下单方法上写 rollbackFor = Exception.class,是给所有异常兜底。
另一个更容易翻车的点:同类内自调用导致事务失效。比如你在追加功能时写出这样一段:
@Service public class OrderServiceImpl implements OrderService { @Override public void pay(OrderVO vo) { // 内部调用同类方法,this 不是代理对象 this.createOrder(vo); } @Transactional(rollbackFor = Exception.class) public void createOrder(OrderVO vo) { // 事务操作 } }这个场景在给老项目加功能时非常容易遇到。pay 方法调用了 this.createOrder(),但 this 是对象本身,不是 Spring 生成的代理对象,createOrder 上的 @Transactional 完全没生效。表现就是下单过程中库存扣减失败,订单数据却留在了库里,产生脏数据。
正确做法是把需要事务的方法放到另一个被 Spring 管理的 bean 里,或者把 pay 的逻辑合并进 createOrder。判断依据很简单:这个事务方法是不是通过注入的代理对象来调用的。如果写的是 this.xxx(),事务大概率就没生效。
另外一个边界很容易被忽略:事务方法不能是 private。Spring AOP 基于代理,private 方法根本不会被代理织入,class 也不能是 final。这些限制在 SSM 项目里哪怕不报错,也会在数据不一致的时候坑你一次。
5.2 MyBatis 动态 SQL:where、set、foreach 的边界
动态 SQL 是 MyBatis 的灵魂,也是面试高频点。除了 3.2 节的分页查询,下单里批量插入订单明细是最需要 foreach 的地方。假设购物车里有五件商品,每次循环单条 insert 就是五次网络往返,性能差代码也难看。用 foreach 一次拼成一条批量插入:
<insert id="batchInsertDetail"> INSERT INTO t_order_detail (order_id, goods_id, quantity, price, subtotal) VALUES <foreach collection="detailList" item="item" separator=","> (#{item.orderId}, #{item.goodsId}, #{item.quantity}, #{item.price}, #{item.subtotal}) </foreach> </insert>foreach 的 collection 对应 Mapper 接口方法里 List 参数标注的 @Param 名称,item 是循环变量名,separator 是每条记录之间的分隔符,这里必须用逗号。注意:collection 值要跟 @Param("detailList") 一致,不能凭感觉写 list。批量插入 SQL 有长度上限,受数据库 max_allowed_packet 参数影响,常规收银几十条明细完全够用,不用刻意改数据库配置。
商品编辑场景则用到 set 标签。更新商品时,前端可能只改了价格没改名称,直接把整个实体覆盖更新会产生问题。set 标签能动态拼接要更新的字段,并自动去掉末尾逗号:
<update id="updateGoods"> UPDATE goods <set> <if test="name != null">name = #{name},</if> <if test="salePrice != null">sale_price = #{salePrice},</if> <if test="stock != null">stock = #{stock},</if> </set> WHERE id = #{id} </update>这里每个 if 分支末尾都带逗号,set 标签会在最后一个有效条件那里把多余逗号去掉,避免生成 "SET name = ? " 这种语法错误。跟 where 标签的 AND 处理是同一个思路——动态 SQL 的核心就是让 MyBatis 帮你处理拼接边界的脏活。
再看库存扣减。这段 SQL 本身不复杂,但条件值得留意:
<update id="reduceStock"> UPDATE goods SET stock = stock - #{quantity} WHERE id = #{goodsId} AND stock >= #{quantity} </update>UPDATE 语句里用 stock >= #{quantity} 作为条件,把库存校验和扣减合并成一步。Mapper 接口方法的返回值是 int,Service 层拿到返回值,为 0 就说明库存不足,抛异常回滚整个订单。这是并发减库存的常见写法,不用写 SELECT FOR UPDATE,也没有先查后改的竞态窗口。很多项目在 Java 里先查库存再更新,并发一旦上来库存就变负数,属于典型的"看起来对、实际会崩"。
5.3 Controller 与前端交互:AJAX 提交 JSON 数据的正确姿势
收银台页面的购物车操作是异步的,下单接口通过 AJAX 把购物车数据以 JSON 格式发给后端。Controller 这边用 @RequestBody 接收:
@RequestMapping("/order/create") @ResponseBody public Result createOrder(@RequestBody OrderVO vo) { orderService.createOrder(vo); return Result.success("下单成功"); }@RequestBody 把请求体里的 JSON 字符串反序列化成 OrderVO 对象,OrderVO 里的 List items 字段会自动映射 JSON 数组。这里最容易出的问题:前端用了表单序列化,contentType 是 application/x-www-form-urlencoded,数据是 key=value&key=value 格式,@RequestBody 根本收不到,方法直接抛异常。
前端正确写法是:
$.ajax({ url: '/pos/order/create', type: 'POST', contentType: 'application/json', data: JSON.stringify({ memberId: memberId, items: [ { goodsId: 1, quantity: 2 }, { goodsId: 3, quantity: 1 } ] }), dataType: 'json', success: function (res) { if (res.code === 0) { layer.msg('下单成功'); // 清空购物车、刷新订单列表 } else { layer.msg(res.message); } } });两个参数是绑定的:contentType 必须写 application/json,data 必须是 JSON.stringify 序列化后的字符串,少一个都不行。JSON 字段名要和 OrderVO 属性名一致,Java 里是 items,前端就不能写成 itemList,否则映射出来的 List 是空的,下单后明细表一条记录都没有。收银端的成功回调还要顺手清空购物车并刷新订单列表,否则用户会重复提交订单。
三个点其实是层层递进的:事务控制保证数据可靠性,动态 SQL 保证数据库操作高效正确,JSON 交互保证前后端数据对得上。把这三处吃透,整个项目的核心代码基本就掌握了大半。
6. 二次开发指南:把收银系统改成你想要的毕设题目
拿到一套能跑的源码只是第一步,怎么把它改成自己的东西,才是毕业设计加分的开始。基于这套系统的结构,我给出三个改造方向,按投入产出比排序。
6.1 加一个日结报表模块:用 ECharts 展示销售额趋势
在 OrderController 里加一个统计接口,按天聚合订单实收金额:
<select id="countDailySale" resultType="map"> SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS sale_date, SUM(pay_amount) AS amount FROM t_order WHERE create_time >= #{startDate} AND create_time <= #{endDate} GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY sale_date </select>DATE_FORMAT 把订单时间归整到天,SUM 聚合当天实收,GROUP BY 产出每一天的销售额。前端接一个 ECharts 折线图,几行代码就能把报表页做出来。这个模块工作量小,但能显著提升项目完整度,"销售数据可视化"在答辩时是很亮的加分项。
6.2 换一个业务场景:从超市收银改成奶茶店点单
如果不想只是做一个通用收银系统,可以给商品加"规格"字段,比如中杯、大杯、加珍珠;订单表加一个"桌号"或"取餐号"字段;再把商品分类从"饮料、零食"改成"奶茶、果茶、小食"。改动集中在数据库加字段和前端页面文案,核心的订单事务、库存扣减完全不用重写。这也是这套系统最值得利用的地方——骨架是通用的,业务语义换一换就是新题目。
6.3 答辩导向的改造顺序
建议先跑通原版,再做两个小改造,然后准备一个"你最有把握讲清楚的功能点"。在这个项目里,我最推荐重点准备下单事务链路:从 AJAX 提交 JSON、到 @Transactional 控制事务、再到 MyBatis 动态 SQL 扣库存,最后会员积分累积,这是一条完整且有深度的链路,拆成两分钟讲稿,比讲十个页面效果好十倍。
我拆这类 SSM 老项目时养成一个习惯:动手加功能前,先把关联表的唯一约束和事务边界列一遍,再动代码。这样一来,像扫码枪重复提交、并发扣库存这类问题基本能在设计阶段避开,不用上线后再返工。希望这个习惯对你跑通和改造这套系统也有帮助。
本文还有配套的精品资源,点击获取