
1. 私房菜上门定制系统拆开看就是一套典型的电商交易闭环先说清楚这个项目在干什么它不是一个简单的“菜谱展示网站”而是一个包含用户、厨师、管理员三类角色的完整服务平台。用户端要能浏览私房菜菜品、线上下单、预约上门服务时间、在线支付或货到付款、对服务进行评价厨师端要能接单、备餐、配送上门、查看收益管理员端要负责菜品审核、订单监管、用户管理和数据统计。如果把范围再放大一点这套系统背后的业务模型和美团外卖、本地生活服务类App是同一个套路——只是把“私房菜定制”和“上门服务”这两个垂直场景抽了出来。很多人第一次看这类项目会觉得“这不就是一个CRUD吗”但真正动手做过的人才知道它其实踩中了前后端分离开发里几乎所有高频考点跨域问题、登录鉴权、订单状态流转、文件上传、动态菜单权限、多环境部署、Nginx反向代理……把这些东西一个不落地走一遍比单纯刷十套增删改查的练习题要值得多。尤其是订单模块它天然带着状态机待接单、备餐中、配送中、已完成、已取消、超时逻辑、库存和接单并发控制这些才是面试和真实业务里真正会卡人的地方。从学习路径来说这个项目适合的人群大致有这么几类刚学完Java Web基础、想找一个完整项目来串联知识点的学生准备SpringBoot和Vue方向面试、需要一个能讲清楚业务闭环的项目的求职者还有一部分是想接私活或者做毕业设计的开发者。它不涉及微服务、消息队列、分布式事务这类偏重的组件核心就是把单体架构下的前后端分离做扎实。技术栈就是标题里那四样——SpringBoot、Vue、MyBatis、MySQL没有花哨的中间件反而更容易把每一行代码的逻辑吃透。2. 技术选型不是拼凑每个组件都卡在它该在的位置2.1 为什么是SpringBoot而不是Spring MVC如果项目用的是传统Spring MVC你需要手动配置大量的XML或JavaConfig——数据源、事务管理器、视图解析器、拦截器一堆样板代码。而SpringBoot把自动配置做到了极致一个spring-boot-starter-web依赖拉进来内嵌Tomcat写一个RestController就能跑起接口。项目的核心价值在于业务逻辑而不是消耗时间在配置地狱里。但有一点我要强调SpringBoot的“自动配置”目前解决的是常规场景不代表你不需要理解底层。举个例子SpringBoot默认会装配DataSourceAutoConfiguration它扫描到classpath里的HikariCP和MySQL驱动后会自动创建数据源。如果你在application.yml里把数据库地址写错启动会直接报错——这时候如果你连HikariCP是什么都不知道排查起来会很痛苦。所以学这个项目的过程中建议有意识地去翻一下SpringBoot的自动配置源码至少搞清楚spring.factories或AutoConfiguration.imports文件的工作机制。2.2 Vue 2还是Vue 3我的建议是2和3都别排斥标题里只写了Vue没写版本这里我直接把话说透不管你是用Vue 2的Options API还是Vue 3的组合式API核心的都是组件化思维和数据驱动。Vue 2的data、computed、watch、methods到Vue 3变成ref、reactive、computed、watchEffect思想是一脉相承的。我这个项目用的是Vue 2 Element UI原因是Element UI的组件生态比较成熟表格、表单、弹窗、分页组件开箱即用写后台管理页面效率极高。如果你更喜欢Vue 3的Composition API搭配Element Plus也是完全一样的开发思路不影响你学习这个项目的核心逻辑。前后端分离的开发模式要注意页面渲染和后端接口是完全解耦的。也就是说Vue只负责通过axios发起HTTP请求拿到JSON数据后放到data里然后模板自动更新。你写前端的时候脑子里不要再有“页面直接调用Java方法”这种旧观念一切都是资源和接口的关系。2.3 MyBatis和MySQLSQL在你的掌控之中而不是被框架绑架MyBatis在这套系统里的作用是持久层映射它没有像JPA那样把你和SQL隔离得很远反而把SQL写在XML或者注解里你可以精确控制每一条SQL的结构。这个项目我推荐用XML方式去写Mapper因为SQL步骤复杂的时候联合查询、动态SQL、批量更新XML的可读性和维护性比注解好很多。举个具体的例子管理端的订单列表通常需要多条件筛选订单号、用户手机号、订单状态、下单时间区间。如果用注解写Select条件一旦动态变化你得用script标签拼字符串看着很别扭。放到XML里直接用whereif标签逻辑一目了然select idselectOrderPage resultTypecom.example.pojo.vo.OrderVO SELECT o.*, u.nickname AS userName FROM orders o LEFT JOIN user u ON o.user_id u.id where if testorderNo ! null and orderNo ! AND o.order_no LIKE CONCAT(%, #{orderNo}, %) /if if teststatus ! null AND o.status #{status} /if if testbeginTime ! null AND o.create_time gt; #{beginTime} /if if testendTime ! null AND o.create_time lt; #{endTime} /if /where ORDER BY o.create_time DESC /selectMySQL承担的是数据存储和事务保证。这个项目涉及的InnoDB引擎、事务隔离级别、唯一索引、外键约束全都是在真实业务里需要关注的。起码要做到所有涉及金额、订单状态的操作必须放在事务里关系到唯一标识的字段比如订单编号必须加唯一索引联合查询的表连接字段要有索引否则数据量一大查询会直接变慢。3. 数据库设计就是给整个业务画骨架这部分别偷懒3.1 核心表及字段设计我设计的时候遵循了一个原则基础数据冗余一点没关系查询时要尽量减少表连接。所以订单表里不仅存了user_id还冗余了用户昵称、联系方式、收货地址快照菜品表里冗余了厨师ID和厨师名称。用户表userid、username、passwordBCrypt加密、nickname、phone、avatar、role0用户/1厨师/2管理员、status、create_time。厨师表chefid、user_id、real_name、id_card、service_area、introduction、score、status其中user_id关联用户表的角色为1的记录。菜品表dishid、chef_id、name、category、price、images、description、stock、status0下架/1上架、sales价格字段用DECIMAL(10,2)不要用float。订单表ordersid、order_no、user_id、chef_id、total_amount、status、appointment_time、address、remark、create_time。订单号我直接采用时间戳加随机数的方式生成yyyyMMddHHmmss 4位随机数再加一个全局唯一索引兜底。订单明细表order_itemid、order_id、dish_id、dish_name、price、quantity这块就是订单快照的核心菜品修改了价格历史订单里的价格不变。评价表commentid、order_id、user_id、chef_id、content、rating、create_time。地址表addressid、user_id、consignee、phone、province、city、district、detail、is_default。购物车表cartid、user_id、dish_id、quantity。3.2 订单状态流转的设计思路订单状态是这个项目的灵魂。我把它拆成了五个状态0待接单用户提交订单后厨师未操作1备餐中厨师已接单开始准备菜品2配送中厨师或配送员已出发3已完成用户确认收货或系统自动确认完成4已取消超时未接单或用户主动取消这里有一个经典的业务逻辑问题超时未接单怎么处理最简单的方式是用户在提交订单时把超时时间比如30分钟一起传进后端后端用定时任务Scheduled每分钟扫描一次订单状态把超过接单时限且状态还是0的订单自动取消Scheduled(cron 0 0/1 * * * ?) public void autoCancelExpiredOrders() { Date expireTime DateUtil.offsetMinute(new Date(), -30); orderMapper.autoCancelByStatusAndTime(0, expireTime); }这里有个细节要注意autoCancelByStatusAndTime的SQL写成UPDATE orders SET status 4 WHERE status 0 AND create_time #{expireTime}一次性批量更新效率高。但实际业务里可能需要把系统自动取消的通知推送给用户那就涉及消息推送或短信验证码这是后话但至少要留出扩展点——在订单表加一个cancel_reason字段记录是“超时未接单自动取消”还是“用户主动取消”这样用户在订单列表里能看到原因不至于产生误解。3.3 数据库设计容易踩的坑第一个坑表名单复数问题。MySQL的表名大小写敏感取决于操作系统Linux下默认区分大小写Windows下不区分。为了省事儿我统一用小写字母加下划线风格。很多人开发用Windows上线用Linux结果表名大小写不一致启动直接报Table doesnt exist这种低级错误非常影响心态。第二个坑外键。很多教学项目鼓励加物理外键但实际业务里几乎都不加所有关联关系靠代码逻辑去维护。原因很简单外键会带来额外的锁开销高并发写入时容易死锁而且一旦需要分库分表或迁移数据物理外键就是噩梦。所以在订单表里user_id、chef_id都是普通索引不建外键约束。第三个坑时间字段的类型。建议直接用datetime不要用timestamp。timestamp的范围上限是2038年对需要存“预约时间”这类未来时间的表不够稳。虽然2024年之后2038年看着还很远但为了以后维护省心直接统一用datetime。4. 后端核心模块实现每一步都对应一个真实业务诉求4.1 项目结构说明后端包结构我按“controller / service / mapper / pojo / common / config”划分各司其职com.example.privatechef ├── controller # 接口层只做参数接收和结果封装 ├── service # 业务层存放核心逻辑 │ └── impl ├── mapper # MyBatis接口 │ └── xml # Mapper映射XML ├── pojo │ ├── entity # 数据库实体 │ ├── dto # 前端传入参数对象 │ ├── vo # 返回给前端的数据对象 │ └── query # 分页、筛选条件对象 ├── common # 统一返回结果、异常、常量 ├── config # 配置类WebMvc、拦截器、跨域 └── utils # 工具类这个结构的核心思想就是分层清晰。比如从OrderController进来的请求先转成DTO再交给OrderService处理OrderService里做状态校验、金额计算、事务控制最后调用Mapper落库。返回给前端的时候可能会组装一个OrderVO只暴露前端需要的字段避免把数据库里多余字段比如update_time全量返回。4.2 登录鉴权与JWT如果不用Spring Security可以只用拦截器加JWT来实现登录鉴权。我一开始就是用的纯拦截器方案逻辑如下用户登录成功后端生成一个JWT tokentoken里包含userId和role前端把token存在localStorage每次axios请求在请求拦截器里加上Authorization: Bearer token后端定义一个JwtInterceptor在preHandle里校验token有效性并把用户信息放入ThreadLocal方便后续在Service层获取当前用户。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); if (JwtUtil.verify(token)) { Integer userId JwtUtil.getUserId(token); UserContext.set(userId); return true; } } response.setStatus(401); return false; } }这里有个很重要的细节——跨域预检请求。当你用axios从前端发起POST请求并携带Authorization头时浏览器会先发一个OPTIONS预检请求。如果你的拦截器对OPTIONS请求直接返回401前端就永远调不通接口。所以拦截器里必须对OPTIONS方法直接放行。我在项目里用一个CorsFilter配置了允许跨域的域名和请求头顺便解决了这个问题。纯拦截器方案的问题在于密码策略、会话管理、CSRF防护都要自己来。后来我在项目重构的时候换成了Spring Security JWT虽然配置复杂度上去了但用户角色判断变得很优雅——直接在接口上写PreAuthorize(hasRole(CHEF))就能限制只有厨师角色能访问接口。不过对于学习型的项目先用拦截器把token校验跑通理解整个链路再升级到Spring Security这个过渡路径是顺的。4.3 菜品上下架与图片上传厨师在自己后台添加菜品必然涉及图片上传。我采用的方式是后端接收MultipartFile把文件保存到本地磁盘的/upload/dishes/目录文件名用UUID加后缀重新生成避免重名和路径穿越问题public String upload(MultipartFile file) { if (file.isEmpty()) { throw new BusinessException(400, 上传文件不能为空); } String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName UUID.randomUUID().toString().replace(-, ) suffix; File dir new File(uploadPath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, fileName)); return /upload/dishes/ fileName; }这里要注意一个问题前端展示图片时访问的是http://localhost:8080/upload/dishes/xxx.jpg。若后端把项目打包成jar放在服务器上上传目录会跑到jar的同级目录或者/tmp下很容易丢。所以我建议用绝对路径配置比如在application.yml里指定file: upload-path: /data/private-chef/upload/然后写一个WebMvcConfig把/upload/**静态资源映射到本地磁盘路径Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceMapping(/**) .addResourceLocations(file: uploadPath); }这样无论jar在哪运行图片都能被访问到。4.4 订单生成与并发控制用户提交订单时后端需要做几件事校验菜品是否在售、库存够不够计算订单总金额生成订单号扣减库存保存订单和订单明细。这里面最经典的并发问题就是库存超卖。后端如果只是“先查库存再扣库存”在高并发下会出问题。我用的方案是直接在SQL里做条件更新UPDATE dish SET stock stock - #{quantity} WHERE id #{dishId} AND stock #{quantity}返回受影响行数如果为0说明库存不足直接抛出异常回滚事务。这个方案简单有效在单体应用里可以应对绝大多数场景。如果以后要做分布式再用Redis预扣库存或者分布式锁也不迟。还有一个因素订单金额计算不能直接信任前端传过来的金额必须在后端重新从数据库查菜品单价来计算。为什么前端传的金额可以被任意篡改你不可能让用户自己填价格。所以后端controller接收的DTO里不应该有totalAmount字段如果传了也要忽略掉一切以后端计算结果为准。4.5 评价闭环与评分更新订单完成后用户可以评价。评价表关联了订单ID同时为了保证一个订单只能评价一次我在order_id上加了唯一索引。评价提交后除了保存评价记录还要更新厨师表的评分Transactional public void addComment(CommentDTO dto) { commentMapper.insert(dto); Double avgScore commentMapper.selectAvgScoreByChefId(dto.getChefId()); chefMapper.updateScoreById(dto.getChefId(), avgScore); }这个逻辑看着简单但这里的Transactional必须加在public方法上。有人喜欢用Transactional标注在private方法上结果事务不生效排查半天发现是自调用导致代理失效——这是Spring AOP一个老生常谈的问题。另外commentMapper.selectAvgScoreByChefId要用的SQL是SELECT AVG(rating) FROM comment WHERE chef_id #{chefId}MySQL里AVG函数返回的是DECIMAL类型Java用Double接收没有问题。5. 前端Vue实践页面别堆砌核心在交互和权限控制5.1 前端页面规划前端我用Vue 2 Element UI Vue Router Vuex Axios页面规划用户端首页菜品列表分类筛选、菜品详情页、购物车页、订单确认页、订单列表页、订单详情页、评价页、个人中心页厨师端厨师工作台接单列表、接单/拒单操作、菜品管理增删改查、订单处理备餐、配送状态更新、收益统计管理端用户管理、厨师审核、菜品审核、订单总览、数据统计用户端页面风格偏C端要注重图片展示和按钮交互厨师和管理员的页面偏B端重点在表格和数据筛选。我在实际写的时候用户端和后台管理用了两套布局用户端是一套顶部导航内容的简洁布局后台用侧边栏顶栏内容的经典管理布局菜单根据角色动态生成。5.2 axios封装与请求拦截直接把axios裸用是新手最容易踩的坑。正确做法是封装一个request.jsimport axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } Message.error(error.message || 网络异常) return Promise.reject(error) } ) export default service这里做了三件事请求自动携带token、统一处理后端返回结构、401时自动跳转登录页。这可以保证不管是哪个页面调用接口鉴权和错误处理逻辑都一致。5.3 路由守卫与动态菜单前端权限控制分两层路由级控制和菜单级控制。路由级控制是用Vue Router的路由守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else if (!token) { next(/login) } else { const role localStorage.getItem(role) if (to.meta.roles !to.meta.roles.includes(role)) { next(/403) } else { next() } } })我遇到过的问题是厨师直接改地址栏URL来访问管理员页面比如输入/admin/user。调整后是给每个路由配置meta.roles比如管理员路由的meta是roles: [admin]厨师账号访问这个路由会被守卫拦截跳转到403页面。菜单级控制简单粗暴管理员登录返回的菜单列表包含“用户管理”“厨师审核”“订单总览”厨师登录返回的菜单列表包含“接单中心”“菜品管理”“收益统计”。前端根据登录时后端返回的角色字段渲染不同的菜单项这也算一种“按角色渲染视图”的方式。5.4 购物车与订单确认页的联动用户端最麻烦的是购物车和订单确认页的联动。我最初的做法是购物车数据存在前端Vuex里用户点击“去结算”时把购物车中勾选的菜品ID和数量传到订单确认页用路由参数或Vuex传递。后来在实际开发中发现一个问题用户刷新订单确认页时Vuex里的数据会丢失导致页面空白。解决方案是订单确认页从URL参数cartIds拿到购物车记录的ID列表然后调后端接口“根据购物车ID列表查询结算详情”这样即使刷新页面也能重新加载数据。这里也体现了一个思路前端状态可以被刷新丢失但后端数据不会。所以所有关键业务数据要以数据库为准前端状态只做临时展示。5.5 文件上传组件的坑Element UI的el-upload组件默认会用自己的XHR对象发请求不走我们封装的axios所以token不会被自动带上。如果不处理上传接口就会被拦截器挡在门外返回401。解决方案是在el-upload上配置headers手动把token塞进请求头el-upload :actionuploadUrl :headersuploadHeaders namefile :on-successhandleUploadSuccess el-button上传图片/el-button /el-uploadcomputed: { uploadHeaders() { return { Authorization: Bearer localStorage.getItem(token) } } }这个小坑曾经困扰过我一下午这里写出来提醒后面的人。5.6 订单列表的刷新策略厨师接单页面需要实时看到新订单。最朴素的方案是前端定时轮询每30秒调一次/chef/orders?status0发现有新订单就播放提示音并刷新列表。定时轮询的缺点是有延迟而且对服务器有一定压力但学习阶段这样实现最简单。更优的方案是用WebSocket或者SSE做服务端推送但复杂度会上升不少。我的建议是先把轮询方案跑通理解了请求-响应的基本模式后再去尝试WebSocket。轮询代码要注意页面销毁时要清除定时器否则组件销毁后定时器还在调用接口控制台会一直报错也容易造成内存泄漏。data() { return { timer: null } }, mounted() { this.fetchNewOrders() this.timer setInterval(() { this.fetchNewOrders() }, 30000) }, beforeDestroy() { if (this.timer) { clearInterval(this.timer) } }6. 部署上线完整过程从本地到服务器的每一步要点6.1 本地环境准备需要准备的东西JDK 1.8或以上版本我用的是1.8稳定省心Maven 3.6.xNode.js 14.x以上Vue 2项目这个版本够用MySQL 5.7或8.xNginx 1.18以上MySQL记得把数据库字符集设置成utf8mb4不然存emoji或者生僻字会乱码。创建数据库的时候执行CREATE DATABASE private_chef DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;6.2 Maven打包后端在项目根目录执行mvn clean package -DskipTests打包完成后target/目录下会生成一个可执行的jar包大概几十兆。这里有一个常见问题本地开发时数据库地址是localhost:3306上线后要改成服务器的MySQL地址。做法是把配置拆成多环境spring: profiles: active: prod然后在application-prod.yml里配置生产环境的数据库地址、上传路径等。用--spring.profiles.activeprod指定启动环境。另一个常见问题是打包时如果用了Lombok本地编译没问题但部署到服务器上出现“找不到getter/setter方法”的报错——多半是Maven没有把Lombok的注解处理器带上。解决方法是确认pom.xml里lombok依赖的scope不是provided或者把Maven编译器插件和Lombok版本对齐。6.3 前端构建与部署前端在项目根目录执行npm install npm run build构建完成后dist/目录下会生成静态文件index.html、css、js等。把这些文件传到服务器上某个目录比如/data/private-chef-web/然后配置Nginxserver { listen 80; server_name your-domain.com; location / { root /data/private-chef-web; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /upload/ { proxy_pass http://127.0.0.1:8080/upload/; } }这里有两个关键点。第一try_files $uri $uri/ /index.html;很重要否则前端路由用history模式时刷新子页面比如/orders/detail/123会直接404。第二/api/走反向代理到后端jar服务这样前端请求的/api/login就会被转发到后端/login路径同时解决了跨域问题。很多人不知道这一点还傻傻地在后端配置CORS其实上了Nginx后跨域根本不存在了——浏览器只和Nginx通信Nginx和后端之间是同源转发。6.4 启动后端服务服务器上用nohup启动jar包注意日志收集nohup java -jar private-chef-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod /data/logs/private-chef.log 21 查看日志用tail -f /data/logs/private-chef.log。如果启动失败大部分原因是端口被占用或者数据库连不上# 查看端口占用 netstat -tlnp | grep 8080 # 杀掉占用进程 kill -9 PID6.5 上线前必须做的几件事第一修改MySQL默认的root密码不要用空密码第二后端配置文件里的数据库密码、JWT密钥不要明文写死在代码里至少放到生产环境的配置文件里服务启动时通过环境变量注入第三前端dist目录和jar包要定期备份第四图片上传目录要定期清理防止磁盘写满。这套部署方案跑下来从本地开发到云服务器上线全程大概半小时到一小时就能撑起来。我第一次部署的时候因为跨域和history路由问题折腾了不少时间但把这些问题记录下来之后再部署同类项目就顺畅多了。6.6 部署过程中最容易卡住的几个点数据库驱动版本和MySQL版本不匹配MySQL 8.x需要com.mysql.cj.jdbc.DriverMySQL 5.x用com.mysql.jdbc.Driver如果乱配启动报错很常见。时区问题连接MySQL时serverTimezoneAsia/Shanghai这个参数不能少否则时间字段会差8小时。服务器内存不足jar包启动默认内存可能比较高可以在启动命令里限制一下java -Xms256m -Xmx512m -jar private-chef-0.0.1-SNAPSHOT.jar不然放在1G内存的小机器上jar一启动系统就卡死。7. 常见问题速查表遇到直接照做我盘点了一下这个项目从开发到部署遇到的高频问题整理成表格方便排查问题现象可能原因排查与解决思路前端请求接口报401拦截器未放行OPTIONS请求检查JwtInterceptor里是否对OPTIONS直接返回true刷新页面404Vue Router history模式未配置Nginx加try_files $uri $uri/ /index.html;上传图片后前端无法访问静态资源映射未配置写配置类把/upload/**映射到本地磁盘路径数据库中文乱码建库时未指定utf8mb4删除重建或ALTER DATABASE设置utf8mb4订单库存超卖扣库存SQL未做条件判断用UPDATE ... WHERE stock quantity定时任务不执行启动类缺少EnableScheduling在SpringBoot启动类上添加该注解接口报错“Parameter not found”MyBatis单参数未加Parammapper接口方法参数加上Param注解打包后前端无法登录后端接口路径和Nginx转发路径不一致确认proxy_pass的路径拼接规则如果你遇到的是上面的问题大部分情况下不需要改业务逻辑直接查配置就能解决。这也是这个项目很nice的一点——它把“写完能跑”和“上线不崩”之间的差距体现得很具体你遇到过一遍以后就能避开一片。8. 扩展思路这套系统还能怎么升级到这里主体功能都讲完了。最后再分享点我个人的看法——一个项目做完别急着结束给它留几个扩展方向对你自己的提升远大于再做十个增删改查。第一个方向是订单模块加分布式锁和乐观锁。现在用的是SQL条件更新防超卖这个策略没问题但只是单体架构下的容量。如果将来要支持秒杀需要引入Redis预扣减库存把峰值流量扛在前端这个演进路径值得走一遍。第二个方向是消息通知。现在订单状态变化之后用户只能主动刷新页面查看。真实场景里用户更希望收到短信或者站内信提醒“厨师已接单”“正在配送中”。可以先用一张消息表把通知记录存下来前端做轮询拉取未读消息后续再接WebSocket推送。第三个方向是数据统计。管理端现在只做了基础的数据展示可以增加图表统计每天订单量走势、厨师接单排行、菜品销量TOP10。前端用ECharts画个折线图和柱状图后端写几个聚合SQL用GROUP BY DATE(create_time)按天统计。这个内容不难但很能锻炼SQL聚合和图表联动能力。第四个方向是权限细粒度控制。现在角色就三种做的是菜单级和路由级控制。再往深处可以做到按钮级权限——比如管理员才能看到“强制取消订单”按钮普通用户看不到。这个可以用自定义指令v-permission实现也可以在路由meta里配置按钮权限码。做完这一步你对权限系统的理解会彻底不一样。我个人在实际操作中的体会是这种前后端分离的完整项目最值钱的不是那几百行核心代码而是整个链路串起来之后你对“一个功能从页面到数据库到底经过了多少层”的那种整体认知。写完这个项目SpringBoot的自动配置原理、MyBatis的SQL映射机制、Vue组件通信方式、Nginx反向代理策略这些知识点就会从“背过的面试题”变成“自己踩出来的经验”以后再遇到类似系统你心里就有底了。