简介:苍穹外卖是一套基于JavaWeb技术开发的在线订餐系统完整项目,面向正在学习JavaWeb、需要真实项目经验以提升开发能力的初学者与进阶开发者。项目源码与笔记由作者在一个月内完整开发整理,系统覆盖Servlet、JSP、MVC设计模式、数据库表设计与SQL查询、Spring框架集成、MyBatis持久层操作、前端页面交互、文件上传、登录鉴权与SQL注入防御、单元测试及Tomcat部署等全链路知识点,适合作为课设参考或系统练手项目。资源包共2000个文件,以388个xml配置文件、146个java源码、144个class字节码及Markdown笔记为主,整体约13.61MB,目录结构清晰,便于按模块研读与运行调试。目前已有16397人学习下载,通过源码和配套笔记可深入了解每个功能从接口设计到数据落地的实现细节,快速建立JavaWeb项目开发的完整认知。
1. 苍穹外卖:一个月啃完的 JavaWeb 单体项目,笔记和源码都在这了
如果你正在找一套能跑通完整外卖业务的 JavaWeb 项目源码,那这套「苍穹外卖」值得你花时间拆一遍。它覆盖了用户下单、商家接单、骑手配送、管理后台这种完整闭环,代码不是一堆零散 demo 拼出来的,而是一个按真实业务组织起来的前后端分离单体应用。我当时用了一个月从头写到跑通,把接口设计、订单状态流转、权限控制这些关键逻辑全部捋明白,才敢说能给别人讲清楚。这篇笔记会从技术栈、工程结构、核心链路、运行步骤到二次开发一一拆开讲,新手能照葫芦画瓢跑起来,熟手能直接拿它做毕设或者接外包项目的底子。
2. 技术选型与工程结构:Spring Boot + MyBatis + Redis 是怎么搭起外卖业务的
2.1 技术栈全景与选型理由
这套项目的主干是 Spring Boot + MyBatis + MySQL + Redis,前端有两个端:用户用的是微信小程序原生或 uni-app 风格的前端,商家和管理员用的是 Vue 后台。JWT 做无状态登录,WebSocket 做来单提醒,AOP 加注解做公共字段自动填充,整体是一个非常典型的 JavaWeb 单体项目组合。
选这套组合不是图省事,而是外卖这个场景的天然要求。菜品和店铺状态这类数据读多写少,适合用 Redis 挡缓存;订单状态变更多,状态字段走数据库事务才放心;商家端需要实时知道有人下单,所以接了 WebSocket 推送;登录校验分散在每个接口上,用拦截器加 JWT 是最省事的方案。你在简历里写「熟悉 Spring Boot + Redis 缓存 + WebSocket 实时通信」,这套代码就是活例子。
2.2 工程目录结构与分层设计
项目按前后端分离组织,后端是一个多模块 Maven 工程,结构上按「控制层 - 业务层 - 数据层」分层。我拆代码的时候习惯先把包结构看一遍,包结构能直接反映项目的业务边界。
sky-take-out/ ├── sky-server/ # 后端主模块 │ ├── src/main/java/com/delivery/ │ │ ├── controller/ # 控制层:接收请求,返回 Result │ │ ├── service/ # 业务层:核心业务逻辑 │ │ ├── mapper/ # MyBatis 数据访问层 │ │ ├── entity/ # 数据库实体 │ │ ├── dto/ # 入参对象 │ │ ├── vo/ # 出参对象 │ │ ├── config/ # 配置类:WebMvc、Redis、WebSocket │ │ ├── interceptor/ # 登录拦截器 │ │ ├── handler/ # 全局异常处理 │ │ ├── aop/ # 公共字段填充切面 │ │ └── utils/ # 工具类 │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML 文件 │ └── application.yml # 配置文件 ├── sky-ui-admin/ # 管理后台前端(Vue) ├── sky-ui-user/ # 用户端小程序 └── sql/ # 数据库初始化脚本控制层只做参数接收和结果包装,业务逻辑全部下沉到 service,mapper 层通过 MyBatis 操作数据库。dto和vo分离这一点做得比较规矩,入参和出参互不纠缠,你改接口字段的时候不用上下翻找。全局统一返回Result<T>,带上 code、msg、data 三个字段,前端拿到之后根据 code 判断业务是否成功,这套约定前后端是提前对齐的。
2.3 数据库核心表设计与订单状态机
外卖业务的核心表就那么几张:员工表、用户表、分类表、菜品表、套餐表、购物车表、订单表、订单明细表、地址簿表。我重点讲订单和菜品这两块设计。
菜品表和分类表是一对多关系,分类表存类型(菜品分类还是套餐分类),菜品表存口味、价格、状态、图片这些基础信息。套餐表和菜品表之间做了中间关联,因为套餐是菜品的组合,不能简单加个字段了事。
订单表是整个系统里字段最多的表,因为它要支撑整个流转链路的展示和查询:
CREATE TABLE orders ( id BIGINT PRIMARY KEY COMMENT '订单id', number VARCHAR(64) COMMENT '订单号(用于展示)', user_id BIGINT COMMENT '下单用户id', status INTEGER COMMENT '订单状态 1待付款 2待接单 3已接单 4派送中 5已完成 6已取消', amount DECIMAL(10, 2) COMMENT '订单总金额', order_time DATETIME COMMENT '下单时间', checkout_time DATETIME COMMENT '结账时间', phone VARCHAR(20) COMMENT '收货人手机号', address VARCHAR(255) COMMENT '收货地址', consignee VARCHAR(32) COMMENT '收货人姓名', cancel_reason VARCHAR(255) COMMENT '取消原因', rejection_reason VARCHAR(255) COMMENT '拒单原因' );订单状态用status一个字段维护,六个值对应六个状态节点。很多新手在这一步容易犯的错是把状态设计成多个字段,或者用字符串存状态名,结果业务判断时到处写魔法值。我拆的这套代码里状态流转封装在 service 层,每次改动都会校验当前状态是否允许迁移,比如待接单才能接单、已接单才能派送,这个状态机逻辑保证订单数据不会被改乱。
Redis 的键设计也值得单独说。菜品缓存用的是dish_分类ID,店铺营业状态存的是SHOP_STATUS这个键,购物车数据是直接查库的,没有做缓存。你拿这套代码做改造时,缓存键的规划可以模仿它的思路:高频读取、变化少的业务优先上缓存,强一致要求高的数据直接走数据库。
3. 核心模块拆解:一次下单从进入到返回的完整链路
3.1 用户端:JWT 登录与菜品查询
用户登录走的是微信小程序授权登录,流程是前端拿 code 换 openid,后端用 openid 查用户表,查到就直接返回 token,查不到就先注册再返回 token。
登录和鉴权这套是本项目里埋得比较深的东西,很多二次开发的人在这翻车,因为没搞清楚 token 是在哪一步被校验的。
@Component public class JwtTokenInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 从请求头获取 token,固定字段叫 token,前后端约定好的 String token = request.getHeader("token"); if (token == null || token.isEmpty()) { throw new LoginException("未登录"); } // 校验逻辑用 JwtUtil 完成,内部用的是 jjwt 工具库 Claims claims = JwtUtil.parseJWT(token); // 解析成功之后把用户 id 扔进 ThreadLocal,后续 service 层直接用 BaseContext.setCurrentId(Long.valueOf(claims.get("userId").toString())); return true; } }这段代码的逻辑是:请求进来先过拦截器,token 解析成功才放行,同时把用户 id 存在ThreadLocal里。ThreadLocal是关键点——它在一次请求线程内处处可读,但线程之间隔离,service 层拿当前登录用户 id 就靠它。我改代码的时候踩过一个坑:BaseContext.remove()忘记在拦截器afterCompletion里调用,结果 Tomcat 线程池复用时,下个请求能读到上一个用户的 id,用户数据串号了。这个坑写在这,大家引以为戒。
菜品查询的缓存策略是典型的 Cache Aside 模式:
public List<DishVO> listWithFlavor(Long categoryId) { // 缓存键按分类维度做,避免一个菜品一个 key 导致的缓存碎片 String key = "dish_" + categoryId; String cache = redisTemplate.opsForValue().get(key); if (cache != null) { return JSON.parseArray(cache, DishVO.class); } List<DishVO> list = dishMapper.listWithFlavor(categoryId); // 查到之后写缓存,带 60 分钟过期时间,防止缓存永久占用内存 redisTemplate.opsForValue().set(key, JSON.toJSONString(list), 60, TimeUnit.MINUTES); return list; }缓存查询逻辑值得注意:先查缓存,缓存没有回源数据库,再写缓存。为了防止缓存穿透,这里还加了一个细节——即使查询结果为空集合,也会缓存一个空数组。Redis 键的过期时间设为 60 分钟,是折中的方案:菜品不是频繁变动的数据,过期时间长了减少数据库压力,短了保证数据新鲜度。如果你是拿它练项目,可以试试把过期时间做成可配置项放application.yml里,比写死在代码里优雅得多。
3.2 购物车与下单:事务里到底锁了什么
购物车是纯数据库表,没有走 Redis,因为用户操作购物车频次再高也到不了压垮数据库的量级。下单接口是整个后端事务控制的核心,也是最容易出现并发问题的地方。
下单方法上挂了@Transactional注解,这是 Spring 声明式事务的标准用法。但这个注解管的是「同一事务内多表写入的一致性」,不是「并发控制」。我拆代码时发现这里一个精妙的设计——扣减库存不是在下单时做的,而是在商家接单后才做,因为用户下单到商家接单之间有一个时间窗口,用户可能取消订单,提前扣库存会造成库存虚减。
核心下单链路大致是这样:
@Transactional public OrderSubmitVO submitOrder(OrdersSubmitDTO dto) { // 1. 校验地址簿 id,防止传个不存在的地址进来 AddressBook address = addressBookMapper.getById(dto.getAddressBookId()); if (address == null) throw new OrderException("地址不存在"); // 2. 查询购物车数据,把用户选中的商品一次性查出来 List<ShoppingCart> carts = shoppingCartMapper.listByUserId(userId); // 3. 构造订单主表,状态初始为待付款(status=1) Orders order = Orders.builder() .number(String.valueOf(System.currentTimeMillis())) .status(1) .amount(totalAmount) .orderTime(LocalDateTime.now()) .build(); ordersMapper.insert(order); // 4. 把购物车明细转成订单明细,批量插入 List<OrderDetail> details = cartToDetails(carts, order.getId()); orderDetailMapper.insertBatch(details); // 5. 清空购物车 shoppingCartMapper.deleteByUserId(userId); return OrderSubmitVO.builder().id(order.getId()).orderNumber(order.getNumber()).build(); }这段代码里有两个细节提醒大家:订单号用System.currentTimeMillis()生成,在本地单机开发没问题,但如果在多实例部署环境下会重复,我一般会改成「时间戳 + 随机数」或者直接用雪花算法;事务方法里有多次数据库写操作,任何一步抛异常都会整体回滚,所以购物车不会被「删一半留一半」。这套项目虽然标注是单体架构,但设计代码时已经考虑到将来拆微服务的边界,service 之间的调用都通过接口,你后面想拆订单服务、用户服务,改动成本会小很多。
下单之后是模拟支付。本地开发没有真实微信支付回调,代码里实现了一个模拟支付接口:用户点「去支付」后,后端直接调用一个内部接口把订单状态改成待接单。真实项目里这个位置就是微信支付回调的入口,回调里验签后再改状态。我拿到这套代码后做的第一处改造就是把模拟支付接口改成可配置的,用配置项控制走模拟还是走真实支付,这样拿到别的新环境跑的时候不用改代码。
3.3 商家端:WebSocket 来单提醒与订单状态流转
商家端有一个高频交互场景:用户下单后,商家后台要立刻弹一个「新订单」提醒。这个提醒如果靠前端轮询接口,延迟和服务器压力都受不了,所以上了 WebSocket。后端在有新订单时主动推送消息给管理端,管理端收到消息后弹出提示并刷新订单列表。
WebSocket 在 Spring Boot 里的接入方式是注入一个ServerEndpointExporterBean,然后业务代码里拿到Session主动发送消息。
@Component @ServerEndpoint("/ws/order/{sid}") public class OrderWebSocketServer { // 用 ConcurrentHashMap 存在线 session,key 是商家端登录用户 id private static Map<Long, Session> sessionMap = new ConcurrentHashMap<>(); public static void sendToAll(String message) { // 遍历所有商家端连接,逐个推送,推送失败只打日志不中断主流程 sessionMap.forEach((sid, session) -> { try { session.getBasicRemote().sendText(message); } catch (IOException e) { log.error("WebSocket 推送失败, sid={}", sid); } }); } }这个推送类有几个设计要点:sessionMap用的ConcurrentHashMap,因为 WebSocket 连接是并发建立的,普通 HashMap 在并发 put 时可能丢连接;sendToAll方法只推送给所有在线商家端,没有区分具体商家,因为当前版本是单店铺模型。如果你想做成多店铺版本,要把sessionMap的 key 从商家用户 id 改成店铺 id,推送时按店铺维度定向发送。
订单状态流转是商家端的核心操作,每个操作都有一套状态校验逻辑。接单操作要求当前状态必须是待接单,送餐操作要求必须是已接单。这些校验如果漏了,用户在前端点取消、商家这边同时点接单,就会出现状态竞争。这套代码把状态流转判断收敛在 service 层,我后来改造时把状态迁移抽象成了一个OrderStateMachine,把「当前状态 + 操作 → 目标状态」的映射关系集中管理,这样新增一个操作只需要在状态机里加一条映射,不需要在业务代码里到处写 if else。
3.4 AOP 切面:公共字段填充与操作日志
这套项目里 AOP 有两个典型应用:公共字段自动填充和操作日志记录。公共字段指create_time、update_time、create_user、update_user这四个字段,几乎每张表都有。如果每个 insert 和 update 都手动写一遍这四个字段,代码量会非常冗余,而且容易漏,所以项目用自定义注解加 MyBatis 拦截器统一处理。
@Aspect @Component public class AutoFillAspect { @Before("execution(* com.delivery.mapper.*.*(..)) && @annotation(autoFill)") public void autoFill(JoinPoint joinPoint, AutoFill autoFill) { // 取方法参数,约定第一个参数是实体类 Object[] args = joinPoint.getArgs(); if (args == null || args.length == 0) return; Object entity = args[0]; // 根据操作类型区分 update 和 insert,填充不同的字段集合 LocalDateTime now = LocalDateTime.now(); Long currentId = BaseContext.getCurrentId(); if (autoFill.value() == OperationType.INSERT) { setField(entity, "createTime", now); setField(entity, "updateTime", now); setField(entity, "createUser", currentId); setField(entity, "updateUser", currentId); } else { setField(entity, "updateTime", now); setField(entity, "updateUser", currentId); } } }填充用的是反射调用setter方法,所以实体类属性名必须和方法名严格对应,否则反射会失败。另外注意一个约定——被@AutoFill标记的 Mapper 方法,第一个参数必须是实体对象,这是框架的隐含约束。你在二次开发时新增 Mapper 方法,如果忘了加@AutoFill注解,公共字段就不会自动填充,这是排查数据创建时间为空时第一个要查的点。
4. 本地运行指南:从拉下源码到小程序点出第一单
这套项目跑起来不算难,但有几个前置环境要注意。需要 JDK 8 以上、Maven 3.6+、MySQL 5.7 或 8.0、Redis 任意近两年版本,以及 Node.js 用来跑 Vue 管理后台。小程序端调试需要微信开发者工具,没有注册小程序账号也能用测试号模式跑起来。
4.1 环境初始化与数据库导入
项目里的sql目录下一般会带初始化脚本,包含建库建表语句和基础数据。如果文件比较多,通常 mysql.sql 是库表结构,data.sql 是初始化数据。导入顺序不要搞反,先建表再导数据。
mysql -uroot -p < sql/mysql.sql mysql -uroot -p < sql/data.sql导入之后验证一下表数量和关键数据,SELECT * FROM employee LIMIT 3;能查出员工数据就说明导入成功。数据库导入这一步最容易出问题的坑是 MySQL 版本兼容性,如果 SQL 脚本里用了utf8mb4或者ENGINE=InnoDB的写法,低版本 MySQL 可能不识别,导入会直接报错。
4.2 后端配置与启动
后端配置文件集中在application.yml,需要改的配置点通常只有四个:数据库连接、Redis 连接、JWT 密钥、小程序相关配置。数据库和 Redis 地址在本地开发时填127.0.0.1即可。
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/sky_take_out?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai username: root password: 123456 redis: host: 127.0.0.1 port: 6379 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.delivery.entity sky: jwt: secret: your-secret-key expire-time: 7200配置里有两个注意点:MySQL 连接串必须带serverTimezone=Asia/Shanghai,否则 JDBC 驱动和 MySQL 服务端时区不一致,LocalDateTime查询出来的时间会差 8 个小时;sky.jwt.secret是一个自定义配置项,被JwtUtil通过@Value读取,你要是改了密钥,之前签发的所有 token 都会失效,这也算是安全上的一层保障。
启动后端直接用 Maven 命令或者 IDE 跑主类。用命令行的话:
cd sky-server mvn spring-boot:run看到Started SkyApplication in x.xxx seconds这行日志就说明后端起来了。启动失败时最常遇到的三个错是:数据库连不上(检查 MySQL 是否启动、账号密码是否有误)、Redis 连不上(检查 Redis 启动状态和端口)、端口被占用(改server.port或者杀掉占用进程)。
4.3 管理后台与小程序端启动
管理后台是 Vue 项目,走标准流程:npm install装依赖,npm run dev起开发服务。默认端口是 8080,和后端 8081 端口错开,开发环境下前端通过vue.config.js里的 devServer 代理把/api请求转发到后端。
小程序端启动比管理后台多一步:要用微信开发者工具导入项目目录,然后填一个合法的小程序 AppID。没有 AppID 时点「测试号」也能编译运行,只是有些 API(比如获取用户手机号)不可用。注意小程序里配置的请求域名默认指向http://localhost:8082,如果你的后端端口改了,小程序里的request地址也要同步改。
全部启动完之后,完整的验证路径是:管理后台登录 → 新增菜品分类 → 新增菜品 → 用户端小程序浏览菜单 → 加入购物车 → 提交订单 → 模拟支付 → 管理后台收到 WebSocket 新订单提醒 → 接单 → 配送。这一条链路走通,说明前后端、数据库、Redis、WebSocket 都已经协同工作了。
5. 避坑记录:部署和联调阶段最常见的 8 个翻车现场
这套代码本身能跑通,但你在自己的机器上从零折腾的时候,大概率会遇到下面这几个问题。我把最常见的现象、原因和解决办法列出来,照着排查能省不少时间。
5.1 菜品列表一直空白,接口报 401
现象:管理后台登录成功,但打开菜品管理列表时接口返回 401 或者提示未登录。
原因:登录后拿到的 token 存在前端 localStorage 里,但请求拦截器从 localStorage 取 token 的 key 和后端校验的 key 不一致。管理后台前端代码里 token 字段名和项目文档对不上。
解决:打开浏览器开发者工具,看请求头里token字段的值,再对比后端拦截器读的字段名,改成一致。通常在request.js里加请求头的那行代码能找到问题。
5.2 WebSocket 连接一直不建立,控制台报 404
现象:管理后台能打开,但 WebSocket 一直在重连,控制台报 WebSocket connection to 'ws://.../ws/order/xxx' failed。
原因:WebSocket 握手请求是 HTTP 升级协议,如果后端配置了拦截器,没有放行/ws/**路径,请求会在进入 WebSocket 处理器之前被拦截器拦下来,表现为 404 或者 403。另一类原因是前端端口访问的是前端开发服务器,请求没有代理到后端,WebSocket 代理需要单独在 devServer 配置。
解决:在 WebMvc 配置类中对/ws/**路径 exclude 掉登录拦截器;前端vue.config.js的 devServer 里加 WebSocket 代理,或者直接在前端把 WebSocket 地址指向后端实际端口。
5.3 本地订单时间比实际快 8 小时
现象:订单列表里的下单时间比真实时间晚了 8 小时,日期对但是小时不对。
原因:MySQL 连接的serverTimezone参数没有设置,JDBC 驱动用了默认的 JVM 时区;或者存的时候是LocalDateTime直接写入,查询时分页插件做了时区转换。
解决:数据库连接串加上serverTimezone=Asia/Shanghai,并把 MySQL 服务端时区设置为东八区。改了连接串之后重启后端,清掉 Redis 里的缓存再验证。
5.4 Redis 内存不断增长,菜品缓存无法清理
现象:运行一段时间后 Redis 内存占用很高,菜品数据改了但用户端看到的还是旧数据。
原因:菜品的更新操作只更新了数据库,没有主动删除 Redis 缓存,缓存要等 60 分钟自然过期。管理后台频繁修改菜品图片和价格时,旧缓存就一直堆着。
解决:在菜品的「新增、修改、启售停售」三个 service 方法里主动删除对应分类的缓存键。代码里加一行就是:
redisTemplate.delete("dish_" + categoryId);一个小经验:缓存数据更新策略用「先删缓存再更新数据库」还是「先更新数据库再删缓存」,这套项目里采用的是后者,能避免并发读时把旧值重新写进缓存。
5.5 MyBatis 分页查询不生效,返回全表数据
现象:分页查询的接口传了 page 和 pageSize 参数,但返回的列表还是全量数据,total 字段也是 0。
原因:分页插件拦截器没有正确装配。有的项目分页插件版本和 Spring Boot 版本不兼容,或者PaginationInnerInterceptor里的DbType写错了数据库类型。
解决:检查 MyBatis 配置类里是否注册了分页拦截器,并且DbType.MYSQL要写对。一个排查技巧是看分页查询的 SQL 日志,如果 SQL 末尾没有LIMIT ?说明拦截器没生效,直接在配置类里打断点排查。
5.6 小程序端图片加载不出来
现象:小程序里菜品图片显示空白,管理后台上传的图片能看,但小程序端加载失败。
原因:图片上传到的是本机磁盘路径,比如D:/upload/xxx.jpg,小程序端访问的是http://localhost:8082/xxx.jpg,后端没有配置静态资源映射,访问路径返回 404。
解决:在 WebMvc 配置类里加静态资源映射:
registry.addResourceHandler("/upload/**") .addResourceLocations("file:D:/upload/");注意文件路径末尾的反斜杠,丢了的话 Spring 会报路径解析错误,这个细节当时卡了我两个小时。
5.7 模拟支付成功但订单状态没变成待接单
现象:用户端点模拟支付,提示支付成功,但管理后台看订单状态仍是待付款。
原因:模拟支付接口里更新订单状态的 SQL 条件写的是status = 1,但用户下单后如果已经取消再支付,状态就不是 1 了,更新 0 行不报错但状态没变。
解决:模拟支付接口调用前先查询一次订单当前状态,如果已经取消就报业务异常,正常状态再走状态流转。这里也提醒你在二次开发时,状态更新 SQL 一定要把「当前状态」作为条件拼进去,防止状态被覆盖。
5.8 管理后台 npm run dev 启动非常慢
现象:执行 npm install 花了很长时间,npm run dev 启动也要两三分钟,页面刷新还卡。
原因:npm 默认源在国外,依赖下载慢;另外 Vue 工程的 dev 模式下按需编译,首次启动确实偏慢。
解决:把 npm 源切换成国内镜像源再重新 install:
npm config set registry https://registry.npmmirror.com启动慢这点不用太担心,第二次起项目有缓存之后会快很多。
6. 二次开发路径:从跑通到能改,三个可落地的改造点
看代码和改代码之间隔着一条鸿沟,跑通只是第一步,能动手改才算真正吸收。基于这套苍穹外卖,我拆出三个难度适中、立刻能上手的改造点,每个都有明确的价值和验证方式。
第一个改造点:把店铺营业状态从 Redis 换成「数据库 + Redis 双写」。当前实现的营业状态切换后只写 Redis,如果 Redis 重启,状态就丢了,用户端会误以为店铺休息。改造思路是加一张shop_status表,状态更新事务里先写数据库再删 Redis 缓存,读取时先查缓存、缓存没有就回源数据库。改动量不大,但会让你对缓存一致性的理解上一个台阶。
第二个改造点:给订单提交接口增加幂等性校验。现在的提交订单接口没有处理重复点击,用户快速点两次提交会生成两笔相同订单。改造思路是在前端提交时生成一个requestId,后端同一事务内先查订单表是否存在相同requestId,存在则直接返回已创建的订单,不存在才走正常下单流程。这个改造能让你学会用唯一键约束和事务回滚处理并发场景。
第三个改造点:做一个菜品维度的日销量统计报表。skill 难度在 SQL 拼接上——需要写一段多表关联查询,从 order_detail 表统计当日各菜品的销售数量,关联菜品表取名称,按销量倒序排列。查询结果封装成 VO,管理后台加一个表格页面展示。做完这个,你基本掌握了这套代码从 Mapper XML 到 VO 的完整数据链路。
验证任何一个改造点,我都习惯强制走一遍完整链路:改完代码后重启后端,先造一条测试数据,再用接口工具把新增、查询、异常分支全跑一遍。比如改营业状态,要验证「关店 → 用户端点餐被拦截 → 开店 → 用户端可下单」整个循环;改幂等性,要连续快速请求两次提交接口,看第二次是否被拦截。从那以后我每次改完涉及状态流转的代码,都会强制走一遍「正反两条路径 + 并发一次」的验证序列,这个习惯救了我好多次。如果你愿意把每个改造点的思路都实践一遍,这套源码才真正算你的了。希望这份拆解能帮到你。
本文还有配套的精品资源,点击获取