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和MySQL:SQL在你的掌控之中,而不是被框架绑架
MyBatis在这套系统里的作用是持久层映射,它没有像JPA那样把你和SQL隔离得很远,反而把SQL写在XML或者注解里,你可以精确控制每一条SQL的结构。这个项目我推荐用XML方式去写Mapper,因为SQL步骤复杂的时候(联合查询、动态SQL、批量更新),XML的可读性和维护性比注解好很多。
举个具体的例子,管理端的订单列表通常需要多条件筛选:订单号、用户手机号、订单状态、下单时间区间。如果用注解写@Select,条件一旦动态变化,你得用<script>标签拼字符串,看着很别扭。放到XML里直接用<where>+<if>标签,逻辑一目了然:
<select id="selectOrderPage" resultType="com.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 test="orderNo != null and orderNo != ''"> AND o.order_no LIKE CONCAT('%', #{orderNo}, '%') </if> <if test="status != null"> AND o.status = #{status} </if> <if test="beginTime != null"> AND o.create_time >= #{beginTime} </if> <if test="endTime != null"> AND o.create_time <= #{endTime} </if> </where> ORDER BY o.create_time DESC </select>MySQL承担的是数据存储和事务保证。这个项目涉及的InnoDB引擎、事务隔离级别、唯一索引、外键约束,全都是在真实业务里需要关注的。起码要做到:所有涉及金额、订单状态的操作必须放在事务里;关系到唯一标识的字段(比如订单编号)必须加唯一索引;联合查询的表连接字段要有索引,否则数据量一大,查询会直接变慢。
3. 数据库设计就是给整个业务画骨架,这部分别偷懒
3.1 核心表及字段设计
我设计的时候遵循了一个原则:基础数据冗余一点没关系,查询时要尽量减少表连接。所以订单表里不仅存了user_id,还冗余了用户昵称、联系方式、收货地址快照;菜品表里冗余了厨师ID和厨师名称。
用户表(user):id、username、password(BCrypt加密)、nickname、phone、avatar、role(0用户/1厨师/2管理员)、status、create_time。
厨师表(chef):id、user_id、real_name、id_card、service_area、introduction、score、status,其中user_id关联用户表的角色为1的记录。
菜品表(dish):id、chef_id、name、category、price、images、description、stock、status(0下架/1上架)、sales,价格字段用DECIMAL(10,2),不要用float。
订单表(orders):id、order_no、user_id、chef_id、total_amount、status、appointment_time、address、remark、create_time。订单号我直接采用时间戳加随机数的方式生成:yyyyMMddHHmmss+ 4位随机数,再加一个全局唯一索引兜底。
订单明细表(order_item):id、order_id、dish_id、dish_name、price、quantity,这块就是订单快照的核心,菜品修改了价格,历史订单里的价格不变。
评价表(comment):id、order_id、user_id、chef_id、content、rating、create_time。
地址表(address):id、user_id、consignee、phone、province、city、district、detail、is_default。
购物车表(cart):id、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 doesn't 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 token,token里包含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.js:
import 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 :action="uploadUrl" :headers="uploadHeaders" name="file" :on-success="handleUploadSuccess"> <el-button>上传图片</el-button> </el-upload>computed: { uploadHeaders() { return { Authorization: 'Bearer ' + localStorage.getItem('token') } } }这个小坑曾经困扰过我一下午,这里写出来提醒后面的人。
5.6 订单列表的刷新策略
厨师接单页面需要实时看到新订单。最朴素的方案是前端定时轮询:每30秒调一次/chef/orders?status=0,发现有新订单就播放提示音并刷新列表。定时轮询的缺点是有延迟,而且对服务器有一定压力,但学习阶段这样实现最简单。更优的方案是用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.x
- Node.js 14.x以上(Vue 2项目这个版本够用)
- MySQL 5.7或8.x
- Nginx 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.active=prod指定启动环境。
另一个常见问题是:打包时如果用了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/,然后配置Nginx:
server { 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.active=prod > /data/logs/private-chef.log 2>&1 &查看日志用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.Driver,MySQL 5.x用com.mysql.jdbc.Driver,如果乱配,启动报错很常见。 - 时区问题:连接MySQL时,
serverTimezone=Asia/Shanghai这个参数不能少,否则时间字段会差8小时。 - 服务器内存不足:jar包启动默认内存可能比较高,可以在启动命令里限制一下:
java -Xms256m -Xmx512m -jar private-chef-0.0.1-SNAPSHOT.jar不然放在1G内存的小机器上,jar一启动系统就卡死。
7. 常见问题速查表,遇到直接照做
我盘点了一下这个项目从开发到部署遇到的高频问题,整理成表格方便排查:
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 前端请求接口报401 | 拦截器未放行OPTIONS请求 | 检查JwtInterceptor里是否对OPTIONS直接返回true |
| 刷新页面404 | Vue 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单参数未加@Param | mapper接口方法参数加上@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反向代理策略,这些知识点就会从“背过的面试题”变成“自己踩出来的经验”,以后再遇到类似系统,你心里就有底了。