简介:面向JavaWeb课程设计与毕业设计的必胜客在线订餐系统项目,完整模拟从用户注册登录、菜品浏览、购物车到下单支付的在线订餐流程,同时提供后台菜品管理、订单处理与用户信息管理功能,适合学习者对照系统需求练习完整开发链路。资源包共1307个文件,大小约66.88MB,核心内容包含79个java源文件、126个class编译类、44个jsp页面、788张png界面素材、前端css/js文件、jar依赖库以及数据库SQL脚本,结构完整,导入IDE并配置数据库后即可运行学习。已有2516人浏览学习。项目中多个Servlet与DaoImpl类体现了控制层与数据访问层的清晰分离,结合源码可深入理解MVC设计模式、JDBC或ORM数据库操作、AJAX异步交互、参数校验与SQL注入防护等关键知识点;从数据库表设计到订单状态流转,也能获得需求分析、编码实现和调试排错的实战经验。配套界面图片与动效素材有助于还原页面效果,是系统复习JavaWeb开发流程、撰写课程报告或搭建毕业设计框架的实用参考资料。
1. 从毕业设计到生产备选:这个在线订餐系统能做什么
朋友在一家连锁餐饮公司做后端维护,天天被门店点餐系统的改造成本搞得心烦:第三方系统按年收费,定制接口还要排期;想自己改,又找不到一套代码结构清楚的JavaWeb项目做底子。我拆完这份在线订餐系统源码后,直接把它推给了他——这就是一个标准的、能跑完整个点餐闭环的JavaWeb工程。前台会员注册登录、按分类浏览菜单、加购、下单、查看订单,后台管理员维护商品、分类、处理订单,前后端功能全部打通,而且是Maven工程,依赖和构建都替你整理好了。对正在学JavaWeb的人来说,它是理解三层架构、会话管理、事务控制的绝佳样本;对想拿项目去二次开发的人,它的SSM分层和权限拦截也够资格作为起点。别因为界面长得朴实就低估它,内核才是值钱的部分。
2. 项目骨架与选型:Maven + SSM 的目录结构逐层拆
2.1 技术选型为什么是 SSM 而不是 Spring Boot
这个项目没有跟风上Spring Boot,而是老老实实用了Spring + SpringMVC + MyBatis(SSM)这套组合。很多人拿到源码第一句会问“怎么不用Boot”,其实答案藏在业务场景里。在线订餐的核心操作是连环数据变更:加购要查库存、下单要扣库存、订单要写主表和明细表。MyBatis能让你精确控制每一条SQL,Spring负责声明式事务,SpringMVC把路由和参数绑定理顺,三者分工明确,出了问题能几分钟定位到是SQL写错还是业务逻辑有漏洞。
从二次开发角度看,SSM反而比Spring Boot更“透明”。依赖注入用@Autowired,事务用@Transactional,请求映射用@RequestMapping——这些注解在Boot里同样有,但SSM没有自动配置帮你把Bean都藏起来,你能清楚看到每个Bean在哪定义、在哪个XML里被扫描。想加一个满减活动,先改Service再改Controller最后加个页面,整个动线很直观。如果你还没完全理解Servlet容器、事务传播和MyBatis会话,直接上Boot容易陷入“配置好多,出错了不知道是谁干的”的困境。这个项目选SSM,本质是牺牲一点配置上的便利,换取每一层都能被看见、被改动。
2.2 三层目录结构与请求流转路径
解压工程后,顶层是标准的Maven webapp结构,核心Java代码集中在src/main/java下,按controller、service、mapper、entity、interceptor、util分包。我习惯把这个结构比喻成一条“请求从浏览器到数据库再回来的高速公路”,每一层只负责一段路。
src/main/java/com/pizza/onlineorder ├── controller │ ├── member # 前台会员端控制器 │ │ ├── UserController.java │ │ ├── CartController.java │ │ └── OrderController.java │ └── admin # 后台管理端控制器 │ ├── ProductController.java │ ├── CategoryController.java │ └── AdminOrderController.java ├── service │ ├── UserService.java # 接口定义 │ ├── CartService.java │ ├── OrderService.java │ └── impl # 实现类 ├── mapper # MyBatis Mapper接口 │ ├── UserMapper.java │ ├── ProductMapper.java │ ├── CartMapper.java │ └── OrderMapper.java ├── entity # 实体类:User, Product, Cart, Orders, OrderItem ├── interceptor # 登录拦截器、后台权限拦截器 ├── common # 常量、分页对象、统一返回ResultBean └── util # MD5工具、订单号生成器、金额计算工具src/main/resources下是jdbc.properties、mybatis-config.xml、spring-mvc.xml、applicationContext.xml四个核心配置文件。src/main/webapp放JSP页面、静态资源、WEB-INF的web.xml。整个请求流转路径是:浏览器发出URL,Tomcat把请求交给DispatcherServlet,DispatcherServlet根据HandlerMapping找到Controller方法,Controller调用Service接口,ServiceImpl注入Mapper,Mapper执行XML里的SQL拿到结果,再一层层返回到ModelAndView,最终由JSP渲染响应给浏览器。没有奇怪的跳转封装,每一层都能被下一层调用,反着也能被追踪。
2.3 核心配置文件与关键参数
先看数据库连接配置,这套系统依赖MySQL,所有表在一个名为online_order的库里,连接参数集中在jdbc.properties中。
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/online_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=123456参数说明:useUnicode=true&characterEncoding=utf8保证中文正常写入与读取,这行参数直接影响菜单名称和用户地址会不会变成问号。serverTimezone=Asia/Shanghai是给MySQL 8.0准备的,旧版本不写也行,但新版本不写会在首次建立连接时直接报时区异常。useSSL=false单纯是去除警告,不影响功能。如果你是MySQL 8.0,驱动类建议改成com.mysql.cj.jdbc.Driver,避免控制台打印大段已过时的警告。
然后是spring相关配置。这套项目用XML配置,虽然繁一点,但胜在直观。核心的spring-mvc.xml里有一段配置决定了哪种请求能进Controller,哪种会被放行。
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/static/**"/> <mvc:exclude-mapping path="/index"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/register"/> <bean class="com.pizza.onlineorder.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors> <mvc:annotation-driven/> <mvc:resources location="/static/" mapping="/static/**"/>配置说明:<mvc:interceptor>定义了登录拦截范围,/**表示拦截所有路径,下面几个exclude-mapping把静态资源、首页、登录注册页面排除掉,避免用户未登录时连CSS都加载不出来。<mvc:resources>是SpringMVC处理静态资源的开关,如果你忘了加这一行,即使拦截器放行了,静态文件也会因为被前端控制器接管而404。这个配置在整个项目里属于“基础保障”,很多只跑过Boot的人第一次玩SSM,页面样式突然全丢,多半就是这里没配好。
3. 核心模块拆解:登录、购物车与下单的事务链路
3.1 登录鉴权:拦截器与Session的多端身份识别
系统里有两种身份:前台会员和后台管理员。如果不对后台地址做保护,任何人猜到路由就能直接访问商品管理页,系统就成了黑匣子。项目用LoginInterceptor统一处理两件事:检查用户是否登录,以及访问后台时是否有管理员权限。拦截器本身是一个SpringMVC提供的HandlerInterceptor接口,在Controller方法执行前拦下一手。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User currentUser = (User) session.getAttribute("currentUser"); if (currentUser == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } String uri = request.getRequestURI(); if (uri.contains("/admin/") && !"admin".equals(currentUser.getRole())) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "没有后台访问权限"); return false; } return true; } }逻辑说明:preHandle里的代码在Controller方法执行前运行。首先从Session取用户,取不到就重定向到登录页,防止未登录用户绕过页面直接访问下单接口。第二段针对后台路径/admin/,只有role字段是admin才放行,否则直接返回403错误码。如果你要增加“店长”这类中间角色,可以在用户表加一个level字段,然后在这里追加更细的判断,比如只有level大于等于3才能改商品价格。
登录成功时,Controller设置了Session,这步和拦截器是配套的。
@PostMapping("/login") public String login(String username, String password, HttpSession session) { User user = userService.login(username, MD5Util.md5(password)); if (user == null) { return "redirect:/login?error=true"; } session.setAttribute("currentUser", user); return "redirect:/index"; }参数说明:password在Controller被加密后再传给Service,项目里统一用MD5加盐的MD5Util,避免明文密码落库。redirect:/login?error=true这种写法是重定向,浏览器地址栏会变成有error=true的URL,登录页拿到这个参数后显示“账号或密码错误”。需要留意的点是,Session里的currentUser是整个会话期间的后台身份凭证,如果修改了用户角色,需要重新登录才能拿到最新值。
3.2 购物车设计:从Session到数据库的存储决策
购物车是点餐系统里最能体现设计取舍的模块。教学版一般把购物车放Session里,内存操作快,但一关浏览器就蒸发。这个项目选择了更接近真实业务的做法:购物车数据持久化到数据库的cart表和cart_item表,同时用Session记住当前用户。这么做的好处是用户换个浏览器登录,购物车还在,体验和主流电商一致。
public CartVO getCartByUserId(Integer userId) { Cart cart = cartMapper.selectByUserId(userId); if (cart == null) { cart = new Cart(); cart.setUserId(userId); cart.setCreateTime(new Date()); cartMapper.insert(cart); } List<CartItem> items = cartItemMapper.selectByCartId(cart.getId()); BigDecimal totalAmount = BigDecimal.ZERO; for (CartItem item : items) { BigDecimal lineAmount = item.getPrice().multiply(new BigDecimal(item.getQuantity())); totalAmount = totalAmount.add(lineAmount); } CartVO vo = new CartVO(); vo.setCartId(cart.getId()); vo.setItems(items); vo.setTotalAmount(totalAmount.setScale(2, RoundingMode.HALF_UP)); return vo; }逻辑说明:selectByUserId先检查当前用户有没有购物车,没有就新建一条,确保每个用户永远只有一条有效购物车主记录,之后的操作都以这个cart_id为基准。selectByCartId查出购物车里所有明细物品,再用BigDecimal逐行计算小计并累加,最后设置两位小数。这里刻意不用double,否则会出现1.0加2.0等于3.0000000000000004的精度问题,金额计算必须用BigDecimal而且是底线操作。
加购接口的入参通常是productId和quantity,Service层处理顺序是这样的:
public void addCartItem(Integer userId, Integer productId, Integer quantity) { Product product = productMapper.selectByPrimaryKey(productId); if (product == null || product.getStatus() == 1) { throw new RuntimeException("商品不存在或已下架"); } int currentStock = product.getStock(); if (currentStock < quantity) { throw new RuntimeException("库存不足,当前库存为" + currentStock); } Cart cart = cartMapper.selectByUserId(userId); CartItem item = cartItemMapper.selectByCartIdAndProductId(cart.getId(), productId); if (item == null) { item = new CartItem(); item.setCartId(cart.getId()); item.setProductId(productId); item.setQuantity(quantity); item.setPrice(product.getPrice()); cartItemMapper.insert(item); } else { int newQuantity = item.getQuantity() + quantity; item.setQuantity(newQuantity); cartItemMapper.updateQuantity(item.getId(), newQuantity); } }参数说明:加购前先查商品是否存在、是否下架、库存是否充足,三个条件任意不通过都抛业务异常,Controller捕获后返回提示信息。如果同一商品已经在购物车里,走更新数量分支;否则新增一条。这套逻辑保证了购物车在数据库层面不会出现重复的同商品记录,也为后面下单时的数据一致性省了很多麻烦。
3.3 下单链路:库存扣减、订单生成与@Transactional
下单是整个系统事务性最强的代码块,涉及的动作包括:插入订单主表、批量插入订单明细表、扣减商品库存、清空购物车明细。任何一个动作失败,都不允许数据库留下“订单存在但明细不全”或者“库存扣了但订单没生成”的脏数据。项目在OrderServiceImpl.createOrder方法上加了@Transactional,把所有写操作纳入同一个事务。
@Transactional(rollbackFor = Exception.class) public Orders createOrder(OrderDTO dto, User user) { BigDecimal totalAmount = dto.getCartTotalAmount(); Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(user.getId()); order.setTotalAmount(totalAmount); order.setStatus(0); order.setCreateTime(new Date()); orderMapper.insert(order); List<CartItem> cartItems = cartItemMapper.selectByCartId(dto.getCartId()); for (CartItem cartItem : cartItems) { Product product = productMapper.selectByPrimaryKey(cartItem.getProductId()); if (product.getStock() < cartItem.getQuantity()) { throw new RuntimeException("商品" + product.getName() + "库存不足"); } productMapper.reduceStock(cartItem.getProductId(), cartItem.getQuantity()); OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getName()); orderItem.setPrice(product.getPrice()); orderItem.setQuantity(cartItem.getQuantity()); orderItemMapper.insert(orderItem); } cartItemMapper.deleteByCartId(dto.getCartId()); return order; }逻辑说明:方法先插入订单主表,拿到自增主键order.getId(),然后循环处理购物车里的每一项。每次循环先查商品最新库存,不够就抛出运行时异常;够则扣减库存并插入订单明细。全部处理完,最后清空购物车。rollbackFor = Exception.class的意思是:方法里任何RuntimeException,包括库存不足、数据库异常、空指针,都会让Spring事务管理器把前面已经执行的所有SQL回滚掉。这比默认的回滚策略更安全,默认策略只回滚运行时异常,而把受检异常也纳入回滚能避免“明明失败了,却留下半成品数据”的场面。
参数说明:OrderDTO里传了cartId和cartTotalAmount,但方法内部并没有盲信这个总金额,而是从cart_item里把每个商品的数据库实时价格查出来,重新计算总价。如果你要二次开发,记住一条原则:前端传的金额一律只当展示数据,参与计算的金额必须从数据库重新读取,否则手快的用户改一下页面的金额参数,就能用1分钱下单成功。这个漏洞在很多自己写的商城系统里都存在,改订单模块时一定要用数据库价格覆盖前端价格。
4. 从零跑通:MySQL 初始化、Tomcat 部署与常见问题排查
4.1 环境准备与MySQL建库建表
跑这套项目前,先把环境对齐:JDK 1.8、Maven 3.6以上、Tomcat 8.5以上、MySQL 5.7或8.0。我用的是JDK 8 + Tomcat 8.5 + MySQL 8.0的组合,除了驱动类要换,其他没有兼容性问题。项目里自带一份初始化SQL脚本,通常命名为online_order.sql,包含了建库、建表和初始数据。为了避免数据库字符集踩坑,我建议先手工执行建库语句,再导入数据。
CREATE DATABASE IF NOT EXISTS online_order DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE online_order; CREATE TABLE `product` ( `id` int(11) NOT NULL AUTO_INCREMENT, `category_id` int(11) NOT NULL, `name` varchar(64) NOT NULL, `description` varchar(255) DEFAULT NULL, `price` decimal(10,2) NOT NULL, `stock` int(11) NOT NULL DEFAULT '0', `image` varchar(255) DEFAULT NULL, `status` tinyint(1) DEFAULT '0', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;建表说明:product表是菜单表,status字段0表示上架、1表示下架,price用decimal(10,2)而不是float或double,这是金额字段的标准做法。stock在点餐场景代表当日库存,后台可以手工设置。导入SQL后,建议立刻查一下user表里有没有初始化好的管理员账号,很多同学跳过了这一步,后台登录时死活想不起密码。初始化账号一般写在SQL数据段里,密码是admin123的MD5值,直接查表就能看到。
4.2 部署到Tomcat的详细步骤
我把整个流程走一遍,照着做就能跑起来。第一步,用IDEA打开项目,等待Maven下载依赖;第二步,修改jdbc.properties里的数据库用户名和密码,改成你本地的值;第三步,在IDEA右侧Maven面板执行clean和package,target目录下会生成一个online-order-1.0.0.war文件。
如果你用独立Tomcat,就把这个war包复制到Tomcat的webapps目录,然后启动Tomcat。启动时会自动把war解压成同名目录,比如online-order-1.0.0。接着打开浏览器访问:
http://localhost:8080/online-order-1.0.0/index看到披萨菜单页面就说明部署成功。这里有个小知识点:URL里必须带项目上下文路径,也就是online-order-1.0.0这一段,除非你手动把war改名为ROOT.war,才能直接用http://localhost:8080/index访问。如果Tomcat启动时提示8080端口被占用,打开conf/server.xml把HTTP端口改成9090,重新启动即可。
如果你习惯用IDEA集成Tomcat,配置方式是在Run菜单里新建Tomcat Server,Deployment里选择这个war的Artifact,然后在Server标签页把Application context设为/online-order。这种方式方便调试JSP和断点,推荐在开发阶段使用。
4.3 记录几个典型运行期故障与排查方法
这套系统跑起来后,报错无外乎几类,我按踩坑频率整理成笔记。
现象1:启动Tomcat后首页能打开,但点击登录按钮报404。
原因:DispatcherServlet拦截了请求,但没有匹配到对应的Controller方法。常见于@RequestMapping路径写错,或者spring-mvc.xml里的<context:component-scan>没有扫描到controller包。
解决:先对比浏览器URL和Controller类上的@RequestMapping,注意类级路径和方法级路径是拼接到一起的;确认无误后检查spring-mvc.xml的base-package是否覆盖了com.pizza.onlineorder.controller。如果还不行,看web.xml里DispatcherServlet的<url-pattern>,这个项目用/,不要改成*.do,否则JSP返回的页面路径会不匹配。
现象2:下单时页面弹出“HTTP 500 Error”,Tomcat日志输出空指针异常。
原因:最常见的是Session里没有用户导致currentUser.getId()返回null;另一种是购物车明细为空时直接循环遍历,空指针。也有可能是orderItemMapper.insert对应的SQL字段名写错,导致MyBatis映射失败。
解决:先看localhost.日期.log的完整异常栈,定位到具体行号。如果是空指针,在调用处打印一下对象或判空;如果是MyBatis映射报错,在mybatis-config.xml里把日志级别设为DEBUG,重启后控制台会打印完整SQL,把SQL复制到Navicat执行,数据库就能告诉你具体哪列有问题。
现象3:后台新增商品后,页面显示中文乱码。
原因:数据库字符集、连接字符串、页面编码三处有一处不一致。常见是jdbc.properties漏写了characterEncoding=utf8,或者表结构用了latin1。
解决:统一三层编码。数据库表统一用utf8mb4;jdbc.url上加上useUnicode=true&characterEncoding=utf8;JSP页面头部写上pageEncoding="UTF-8"。改完数据库字符集后要重建表或转换表字符集,只改连接字符串治标不治本。
现象4:首页图片全部不显示,控制台报静态资源404。
原因:图片请求被拦截器或前端控制器拦截了,也可能路径里少了项目上下文。常见是JSP里写了src="/static/xxx.png",实际部署后需要src="${ctx}/static/xxx.png"。
解决:在spring-mvc.xml确认配了<mvc:resources location="/static/" mapping="/static/**"/>,并且拦截器exclude-mapping里放行/static/**。页面里用<c:set var="ctx" value="${pageContext.request.contextPath}"/>统一取根路径,所有静态链接和跳转链接都加上${ctx}前缀,避免部署在不同context下时路径失效。
5. 数据模型与接口约定:数据库表和前后端联调规范
5.1 数据库表设计:从商品表到订单表的关联关系
读这个项目的代码前,先把数据库表关系理顺,会轻松很多。核心表一共8张:user(前台会员)、admin(后台管理员)、category(菜品分类)、product(菜品)、cart(购物车主表)、cart_item(购物车明细)、orders(订单主表)、order_item(订单明细)。cart和orders是一对多的核心,cart_item和order_item分别是它们的明细,全部通过外键逻辑关联,没有使用数据库物理外键,而是代码层维护。
| 表名 | 核心字段 | 说明 |
|---|---|---|
| user | id, username, password, nickname, phone, address, role | role区分会员和管理员 |
| cart | id, user_id, create_time | 每个用户一条有效购物车 |
| cart_item | id, cart_id, product_id, quantity, price | 记录加入购物车时的快照价格 |
| product | id, category_id, name, price, stock, status | status 0上架 1下架 |
| orders | id, order_no, user_id, total_amount, status, create_time | status 0未支付 1已支付 2配送中 3已完成 4已取消 |
| order_item | id, order_id, product_id, product_name, price, quantity | 记录下单时的商品快照 |
从表结构能看到一个很稳的设计:order_item里冗余了product_name和price,这样即使后来商品改名或调价,历史订单里的商品名称和价格也能保持为下单那一刻的值。这种快照式设计比订单一查就关联当时的商品表更可靠,推荐在电商类系统里照抄。
5.2 前后端接口约定:返回JSON还是跳转页面
这个项目是典型的服务端渲染,前台页面用JSP配合JSTL渲染数据,因此大多数Controller方法返回视图名字符串,配合ModelAndView携带数据。而后台有一些操作采用异步请求,返回Map<String,Object>经过@ResponseBody序列化成JSON。项目里接口遵循两条不成文规范:页面跳转类统一返回视图名;数据操作类统一返回业务码结构。
@RequestMapping("/admin/product/updateStock") @ResponseBody public Map<String, Object> updateStock(@RequestParam Integer productId, @RequestParam Integer newStock) { Map<String, Object> result = new HashMap<>(); if (productId == null || newStock == null || newStock < 0) { result.put("code", 400); result.put("msg", "参数非法"); return result; } productMapper.updateStockById(productId, newStock); result.put("code", 200); result.put("msg", "success"); return result; }参数说明:@RequestParam强制要求前端传productId和newStock,缺参直接报错。这里做了一个简单的参数校验,库存为负数直接返回code=400,避免把脏数据写进数据库。实际项目里建议把这个Map封装成ResultBean<T>泛型,统一包含code、msg、data三个字段,这样前端JS只需要判断code === 200,不用关心具体业务差异。
如果你将来要做前后端分离,改造路径也很明确:Controller层把@Controller改成@RestController,方法返回类型从视图名改成ResultBean<T>,Service层的逻辑完全不用动,只需要把原来的ModelAndView.addObject替换成ResultBean.success(data)。JSP页面就可以整体替换成HTML+Vue或React了。
6. 二次开发改造:会话持久化、Redis 缓存与订单超时关闭
6.1 让购物车真正“记住了”:游客购物车合并到登录账号
当前项目的购物车和用户绑定,未登录用户没法加购,这在真实门店场景里会流失用户。一个务实的改造方案是引入cart_token机制:游客访问系统时,后端生成一个UUID放入Cookie,cart表增加一个可空的user_id字段和一个token字段,登录时把带token的游客购物车合并到账号下。合并时的核心逻辑是:同一种商品判断两边数量,相加并更新;不同商品直接插入;最后清除游客会话的Cookie。
public void mergeCartAfterLogin(Integer userId, String token) { Cart guestCart = cartMapper.selectByToken(token); if (guestCart == null) { return; } Cart userCart = cartMapper.selectByUserId(userId); List<CartItem> guestItems = cartItemMapper.selectByCartId(guestCart.getId()); for (CartItem guestItem : guestItems) { CartItem exist = cartItemMapper.selectByCartIdAndProductId( userCart.getId(), guestItem.getProductId()); if (exist == null) { guestItem.setCartId(userCart.getId()); cartItemMapper.insert(guestItem); } else { cartItemMapper.updateQuantity(exist.getId(), exist.getQuantity() + guestItem.getQuantity()); } } cartItemMapper.deleteByCartId(guestCart.getId()); }逻辑说明:mergeCartAfterLogin先查游客购物车,没数据就直接返回;有数据则遍历游客的购物项,在账号购物车里查同款商品,存在就数量相加,不存在就直接把游客项改挂到账号购物车下。处理完之后清空游客购物车明细。这个改造方案不破坏现有表结构逻辑,只需加字段和几个Mapper方法,是性价比较高的体验升级。
6.2 热点菜单加缓存:用 Redis 给详情页减压
当门店开始做活动时,商品菜单是最频繁被请求的数据,如果每个访客都打数据库,MySQL压力会很高。常见做法是引入Redis,把商品列表按分类缓存起来。改造时不要动Controller,只动Service实现类,这样接口签名不变,上层无需感知缓存存在。缓存Key可以设计为menu:category:{categoryId},value是商品的JSON数组,过期时间30分钟。
public List<Product> getProductsByCategory(Integer categoryId) { String key = "menu:category:" + categoryId; String cachedJson = redisTemplate.opsForValue().get(key); if (cachedJson != null) { return JSON.parseArray(cachedJson, Product.class); } List<Product> products = productMapper.selectByCategory(categoryId); redisTemplate.opsForValue().set(key, JSON.toJSONString(products), 30, TimeUnit.MINUTES); return products; }参数说明:redisTemplate来自Spring Data Redis,opsForValue().get和set分别对应Redis的GET和SETEX命令。这里用JSON.toJSONString把对象数组序列化成字符串,读取时再反序列化回List<Product>。需要注意:后台修改了商品价格或上下架状态时,必须主动删除对应分类的缓存Key,否则用户看到的还是半个钟头前的价格。我踩过这个坑后,习惯在所有后台增删改商品的方法里追加一行redisTemplate.delete("menu:category:" + categoryId),叫做“写数据库后清缓存”,手动保证缓存一致性。
6.3 订单超时未支付自动关闭:定时任务的落地写法
下单不支付会占用库存,这个项目没有做自动关单功能,算是一个比较明显的业务缺口。一个不引入额外中间件的方案是用Spring的@Scheduled注解写定时任务,每分钟扫描一次超过30分钟未支付的订单,把状态置为“已取消”,同时回补库存。这个方案在SSM项目里落地成本最低,不需要改web.xml,也不需要铺Quartz,几行代码就能用。
@Scheduled(cron = "0 * * * * ?") public void closeExpiredOrders() { List<Orders> expiredOrders = orderMapper.selectExpiredOrders(30); for (Orders order : expiredOrders) { int updated = orderMapper.updateStatusWithCondition(order.getId(), 2, 0); if (updated > 0) { List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId()); for (OrderItem item : items) { productMapper.addStock(item.getProductId(), item.getQuantity()); } } } }参数说明:cron = "0 * * * * ?"表示每分钟的第0秒触发一次。selectExpiredOrders(30)查询创建时间距今超过30分钟且状态为0的订单。这里最关键的一点是updateStatusWithCondition(order.getId(), 2, 0),它的SQL本质是UPDATE orders SET status=2 WHERE id=? AND status=0,利用数据库的行锁保证只有状态仍为0的订单才能被取消。这样即使用户在最后一秒点了支付,定时任务和支付回调同时进行,也不会出现取消后又支付成功的冲突。库存回补放在updated > 0的判断里,只有真正取消了订单才回补库存,避免重复加库存。
做这个改造时我特意把状态条件写进SQL,是因为之前自己犯过懒,直接updateStatus(order.getId(), 2),结果用户刚好在这时取消订单,定时任务也执行,两边同时操作导致库存被回补了两次。从那以后我每次写状态变更SQL,都强制把当前状态放进where条件里,交给数据库去防并发,而不是在应用层加锁。这个习惯帮我避免了很多次线上翻车,希望也能帮到你。
本文还有配套的精品资源,点击获取