
开门见山我一直在说网上订餐系统是个被做烂了、但永远值得认真做的Java Web练手项目。为什么因为它麻雀虽小五脏俱全用户登录注册、菜品分类展示、购物车加减、订单状态流转、后台管理一套完整的业务闭环全都有。而这次我要拆解的这个项目技术栈是SpringBoot2 Vue3 MyBatis-Plus MySQL8.0前后端完全分离还带配套文档非常适合正在做课程设计、毕业设计或者想从SSM/JSP老技术栈往主流前后端分离架构过渡的同学。我前前后后带过不少人用这套组合做项目也帮别人排查过不少问题。说实话这套技术栈里的每一个选型都是有讲究的不是随便凑出来的。SpringBoot2保证了和大量老教程、云厂商文档的兼容性Vue3组合式API写起来比Vue2响应式逻辑更清晰MyBatis-Plus让单表CRUD基本告别手写SQLMySQL8.0则是当下生产环境最主流的关系型数据库版本。文章里我会把整套系统的设计思路、数据表结构、后端接口实现、前端页面对接、环境搭建以及那些我实际踩过的坑全部过一遍。1. 项目整体设计与技术选型思路1.1 为什么是这套技术栈而不是别的组合先说SpringBoot2。我知道现在SpringBoot3早就发布了但如果你去看各大招聘JD、老项目维护需求、以及大量现成教程SpringBoot2仍然是Java Web开发里的绝对主力。SpringBoot2基于Java 8语法不强制升级JDK17很多服务器上部署的老环境也不用做大版本调整。而且SpringCloud生态里大量组件、第三方starter对SpringBoot2的适配是最成熟的出了问题搜一下解决方案基本全覆盖。对于做课设和毕设的同学来说意味着你能找到的参考资料多、踩坑成本低。然后是Vue3。Vue3发布这么久了很多新项目早就切过去了。相比Vue2Vue3的组合式APIComposition API在逻辑复用上实在方便太多。订餐系统里有大量类似的业务逻辑比如购物车数量加减、菜品列表筛选用Vue2的mixins搞容易命名冲突用composition函数一封装清爽得很。而且Vite的冷启动速度比Webpack快了一个量级开发体验舒服。MyBatis-Plus的选择逻辑更简单省事。网上订餐系统的单表操作非常多比如按分类查菜品、按用户查订单如果用原生MyBatis每个操作都要写XML映射工作量翻倍。MyBatis-Plus内置了通用Mapper和通用Service分页、条件查询、字段填充都替你封装好了你只需要处理多表关联和复杂统计SQL。做这个体量的项目它就是效率最优解。MySQL8.0在这个组合里属于必备基座。8.0相比5.7带来了几个很实用的变化默认字符集换成utf8mb4emoji表情能直接存支持窗口函数写排名统计类SQL很方便JSON类型更成熟还有更好的索引优化器。很多人在本地装的是MySQL5.7但生产环境、云数据库默认实例基本都切到8.0了项目一开始就用8.0能少踩一次迁移的坑。1.2 系统功能模块与项目结构规划我习惯先把业务模块画清楚再动手写代码。这个订餐系统我拆成两端前端用户端和后端管理端。用户端核心功能是注册登录、浏览菜品按分类筛选/关键词搜索、查看菜品详情、加入购物车、修改购物车商品数量、提交订单、订单列表查看、取消订单、个人资料维护。管理端核心功能是管理员登录、菜品分类管理增删改查、菜品管理上架下架/库存修改、订单处理查看所有订单/修改订单状态、用户管理禁用/启用账号。整个项目分了这样几层online-order/ ├── backend/ # SpringBoot后端 │ ├── src/main/java/com/example/order │ │ ├── controller/ # 控制层接收请求 │ │ ├── service/ # 业务逻辑层接口实现 │ │ ├── mapper/ # MyBatis-Plus的Mapper层 │ │ ├── entity/ # 数据库实体类 │ │ ├── dto/ # 前后端交互的数据传输对象 │ │ ├── config/ # 配置类MyBatis-Plus、拦截器、跨域等 │ │ ├── common/ # 统一返回结果、异常处理、工具类 │ │ └── OrderApplication.java # 启动类 │ └── src/main/resources/ │ ├── application.yml │ ├── mapper/ # 自定义XML文件复杂SQL用 │ └── db/ # SQL初始化脚本 └── frontend/ # Vue3前端 ├── src/ │ ├── api/ # axios请求封装 │ ├── assets/ # 静态资源 │ ├── components/ # 通用组件 │ ├── router/ # 路由配置 │ ├── store/ # Pinia状态管理 │ ├── views/ # 页面 │ ├── utils/ # 工具函数token存取等 │ └── App.vue └── vite.config.js # Vite配置含代理后端严格按Controller - Service - Mapper三层递进前端按页面/组件/API请求分离。这样分层的好处是职责清晰比如以后想把Service层拆成微服务边界就在那里不会牵扯到Controller里的参数处理逻辑。2. 数据库设计网上订餐系统的核心表结构2.1 用户、分类、菜品三张基础表的字段设计网上订餐系统的数据库设计是整个项目的基石表设计不好后面写代码全是泪。我用了六张核心表用户表user、菜品分类表category、菜品表dish、购物车表cart、订单表orders、订单明细表order_detail外加一张地址表address用来记录用户下单的收货地址。这里我不贴全部建表SQL但把关键表和字段设计逻辑讲清楚。用户表user除了常规的id、username、password、phone、email之外我要强调三个字段avatar头像URL、status账号状态0禁用1启用、role角色0普通用户1管理员。role字段很关键前后端分离后每次请求都带token后端从token里解析出用户ID再查库拿角色避免把角色拼到token里导致权限改了token还要重签。菜品分类表category字段是id、name、sort排序权重、status是否启用。排序权重这个字段很多新手会忽略导致分类顺序乱七八糟加上sort字段后管理后台可以控制展示顺序。菜品表dish是重点。字段包括id、category_id所属分类、name、description、image、price单价、stock库存、sales销量、status0下架1上架、create_time、update_time。注意price和stock这类数值字段我用的decimal和int不要用double来存价格后面讲MyBatis-Plus映射的时候你就明白为什么了。2.2 购物车、订单与订单明细的关系建模购物车表cart字段id、user_id、dish_id、quantity、create_time。设计上我保持简单直接用user_id dish_id作为唯一键用户加同一个菜品时更新quantity。这里不用单独搞一个CartItem表否则查询逻辑多一层JOIN性能没好处。订单表orders是整个系统的核心字段涉及状态机必须仔细设计id、order_no订单号、user_id、total_price、status0待支付 1待接单 2配送中 3已完成 4已取消、address、phone、remark备注、create_time、pay_time、finish_time。order_no用时间戳加随机数生成唯一索引必须加上这个号后续用户问客服、查订单都靠它。订单明细表order_detailid、order_id、dish_id、dish_name、dish_image、price、quantity、subtotal。为什么要把dish_name和dish_image冗余存一份因为菜品信息是会变的管理员改个菜名或者换张图历史订单不能跟着变否则用户看订单记录的时候就对不上了。这就是典型的快照设计订餐、电商项目里几乎都这么做。订单表和订单明细表是一对多的关系下单时要放在同一个事务里先插orders主表拿到自增ID再循环插入order_detail从表。前端拿到的确认订单信息只是用来在页面展示真正的数据入库以后端计算为准前端传来的total_price我一般直接忽略重新算一遍总价防止有人篡改请求参数。3. 后端SpringBoot2核心实现与MyBatis-Plus实战3.1 启动工程与application.yml配置后端我是直接用Spring Initializr生成的SpringBoot2.7.x工程JDK1.8打包方式选Jar。选2.7系列的好处是兼容性最好如果你选2.3.x那很多依赖版本得自己手动降级没那个必要。pom.xml里我加了这些关键依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version3.19.2/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.18/version /dependency这里说明几个我选型的原因。Hutool工具库是国产利器生成订单号、MD5加密、日期处理全都有直接能用的API省去自己写工具类的功夫。JWT用来做登录认证轻量且无状态非常适合前后端分离项目。application.yml配置是第一步也是踩坑重灾区server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/online_order?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:mapper/*.xml驱动类必须写成com.mysql.cj.jdbc.Driver用旧的com.mysql.jdbc.Driver在MySQL8.0下会直接报错。serverTimezoneAsia/Shanghai是另一个高频坑不配会报时间差8小时的错。allowPublicKeyRetrievaltrue是因为MySQL8.0默认的caching_sha2_password认证方式可能报Public Key Retrieval is not allowed加这个参数省心。3.2 MyBatis-Plus配置与实体类注解MyBatis-Plus配置里三个东西必须优先搞定分页插件、MetaObjectHandler字段自动填充、MybatisPlusInterceptor。分页插件我单独写了一个配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }写完之后Service层里调用page方法就能自动LIMIT比如菜品分页查询public PageDish pageDish(int current, int size, Long categoryId) { PageDish page new Page(current, size); LambdaQueryWrapperDish wrapper new LambdaQueryWrapper(); wrapper.eq(categoryId ! null, Dish::getCategoryId, categoryId) .orderByDesc(Dish::getCreateTime); return dishMapper.selectPage(page, wrapper); }这个方法里eq的第一个参数是条件判断categoryId为null时不拼接这个条件这种写法能少写好几个if强烈推荐大家都用LambdaQueryWrapper。实体类上的MP注解也值得单独强调Data TableName(dish) public class Dish { TableId(type IdType.AUTO) private Long id; private Long categoryId; private String name; private String description; private String image; private BigDecimal price; private Integer stock; private Integer sales; private Integer status; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; }TableId注解的IdType.AUTO表示主键自增。createTime和updateTime用TableField的fill属性配合MetaObjectHandler实现自动填充这样insert和update时不用手动set时间。deleted字段加了TableLogic后MP会自动把delete操作转成update set deleted1查询自动过滤deleted0的数据这是最优雅的软删除方案但日志别开太猛不然打印出来的SQL容易被不懂的人误以为写错了。3.3 登录认证与JWT拦截器实现前后端分离项目里登录认证我用的JWT方案。用户登录成功后用userId和role生成一个token串返回给前端前端把token存在localStorage里每次请求在请求头带上Authorization字段。后端写一个拦截器在进入Controller之前校验token。JWT工具类核心代码不长public class JwtUtil { private static final String SECRET your-secret-key-change-it; public static String createToken(Long userId, String role) { Algorithm algorithm Algorithm.HMAC256(SECRET); return JWT.create() .withClaim(userId, userId) .withClaim(role, role) .withExpiresAt(DateUtil.offsetDay(new Date(), 7)) .sign(algorithm); } public static Long parseUserId(String token) { DecodedJWT jwt JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token); return jwt.getClaim(userId).asLong(); } }拦截器里我做了三件事从请求头取token解析token判断是否有效再把userId放到request的attribute里供后续Controller直接获取。无效token直接返回401状态码和统一错误信息。注意登录和注册接口要放行不能拦截否则用户还没登录就没法调登录接口这就是死循环了。前端一些公开的菜品列表接口也可以放行但如果你的项目要求必须先登录才能看菜单那就不放行看业务需求。关于拦截器的注册要注意排除路径的写法。我用的是addPathPatterns(/api/**).excludePathPatterns(/api/user/login, /api/user/register)这样的方式路径匹配规则一定要调试几遍我曾经因为漏了静态资源路径导致前端图片加载全都404。3.4 购物车与订单提交的事务处理购物车模块主要是加减菜品数量这个逻辑简单但要注意校验菜品是否存在、库存是否充足。菜品的库存判断不能只在数据库里查出来判断要在更新的时候用SQL条件去扣减库存这样才是原子操作。我用的扣减库存SQL是update iddeductStock update dish set stock stock - #{quantity} where id #{dishId} and stock #{quantity} /update注意这个update语句自带stock #{quantity}的条件如果影响行数为0说明库存不足就可以直接抛异常回滚。这比先select再判断再update的方式更安全不会出现高并发下超卖问题。订单提交逻辑是典型的分布式事务初级形态。虽然现在只有一个数据库但涉及orders、order_detail、dish三张表的写操作必须加Transactional注解保证要么全部成功要么全部回滚。我实际写过一堆代码后发现事务的坑往往不在注解本身而在于内部方法调用导致事务失效。比如一个类内部A方法调B方法B上挂着TransactionalA没有那B的事务是不生效的因为Spring事务是通过代理对象生效的内部调用走的是原始对象。所以订单提交这个带事务的方法我从Controller直接调用Service接口方法不让事务方法内部自调用。下面是我订单提交的完整逻辑Transactional(rollbackFor Exception.class) public Long submitOrder(OrderSubmitDTO dto) { // 1. 计算总价遍历购物车明细 BigDecimal totalPrice BigDecimal.ZERO; ListCartItemVO cartItems cartMapper.selectCartItems(dto.getUserId()); if (cartItems null || cartItems.isEmpty()) { throw new BusinessException(购物车为空); } for (CartItemVO item : cartItems) { totalPrice totalPrice.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 2. 扣减库存 for (CartItemVO item : cartItems) { int rows dishMapper.deductStock(item.getDishId(), item.getQuantity()); if (rows 0) { throw new BusinessException(菜品【 item.getDishName() 】库存不足); } } // 3. 创建订单主表记录 Orders order new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setTotalPrice(totalPrice); order.setStatus(0); order.setAddress(dto.getAddress()); order.setPhone(dto.getPhone()); order.setRemark(dto.getRemark()); ordersMapper.insert(order); // 4. 创建订单明细 for (CartItemVO item : cartItems) { OrderDetail detail new OrderDetail(); detail.setOrderId(order.getId()); detail.setDishId(item.getDishId()); detail.setDishName(item.getDishName()); detail.setDishImage(item.getDishImage()); detail.setPrice(item.getPrice()); detail.setQuantity(item.getQuantity()); detail.setSubtotal(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); orderDetailMapper.insert(detail); } // 5. 清空购物车 cartMapper.deleteByUserId(dto.getUserId()); return order.getId(); }注意total_price的计算我完全没有信任前端传来的总价而是后端根据数据库里的价格和数量重新计算这是订餐系那个必须守住的安全底线。3.5 统一返回结果与全局异常处理接口返回格式我统一用Result对象包裹Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }全局异常处理类用RestControllerAdvice配合ExceptionHandler把业务异常、参数校验异常、系统异常统一转成Result返回。这样可以避免系统报错时把一堆堆栈信息直接裸奔到前端也方便前端统一处理错误提示。业务异常我用的是自定义BusinessException抛出时指定错误信息前端catch到后弹个message就行。4. 前端Vue3实现从搭建到页面对接4.1 Vite脚手架与项目初始化前端我用的Vite Vue3 Pinia Vue Router Axios组合Node版本建议16。初始化命令npm create vitelatest frontend -- --template vue cd frontend npm install npm install axios vue-router pinia element-plusElement Plus是UI组件库订餐系统这种管理后台加前台点餐页面直接用它最快。装完后在main.js里全局注册import { createApp } from vue import { createPinia } from pinia import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue import router from ./router const app createApp(App) app.use(createPinia()) app.use(router) app.use(ElementPlus) app.mount(#app)Vite开发环境下需要配置代理把前端请求转发到后端8080端口。在vite.config.js里这么配export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这里一定要配代理不然前端请求会有跨域问题。用了代理之后前端请求写/api开头的路径就行后端接口统一也都放在/api下比如/api/dish/list、/api/order/submit。如果后端接口没有/api前缀代理配置里可以重写路径/api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) }4.2 组合式API的下单页面实战拿用户端的下单确认页来说Vue3的组合式API写起来真的比Vue2舒服太多。我先写一个获取购物车列表的composition函数// src/composables/useCart.js import { ref, computed } from vue import { getCartList, updateCartQuantity, removeCartItem } from ../api/cart export function useCart() { const cartList ref([]) const loading ref(false) const totalPrice computed(() { return cartList.value.reduce((sum, item) { return sum item.price * item.quantity }, 0) }) const totalQuantity computed(() { return cartList.value.reduce((sum, item) { return sum item.quantity }, 0) }) async function fetchCart() { loading.value true try { const res await getCartList() cartList.value res.data } finally { loading.value false } } async function changeQuantity(dishId, quantity) { if (quantity 0) return await updateCartQuantity(dishId, quantity) await fetchCart() } return { cartList, loading, totalPrice, totalQuantity, fetchCart, changeQuantity } }在页面组件里用的时候script setup import { onMounted } from vue import { useCart } from ../composables/useCart import { submitOrder } from ../api/order import { ElMessage } from element-plus const { cartList, totalPrice, totalQuantity, fetchCart, changeQuantity } useCart() const address ref() const phone ref() async function handleSubmit() { if (!address.value || !phone.value) { ElMessage.warning(请填写收货地址和电话) return } const res await submitOrder({ address: address.value, phone: phone.value }) ElMessage.success(下单成功) router.push(/order/detail/${res.data}) } onMounted(() { fetchCart() }) /script看到没这里把逻辑抽到useCart()里页面组件里就剩非常干净的数据绑定和事件处理。而且totalPrice、totalQuantity这些计算属性直接在组合函数里返回页面里用起来和普通的ref没有任何区别。这种写法和Vue2的mixins最大的区别在于多个组合函数之间可以自由组合、嵌套而mixins遇到同样的生命周期钩子还得手动合并容易出隐性问题。4.3 Pinia管理用户登录状态登录状态我用Pinia来管理定义user store// src/store/user.js import { defineStore } from pinia import { login, getUserInfo } from ../api/user export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: null }), getters: { isLoggedIn: (state) !!state.token, isAdmin: (state) state.userInfo?.role 1 }, actions: { async login(username, password) { const res await login({ username, password }) this.token res.data.token localStorage.setItem(token, this.token) await this.fetchUserInfo() }, async fetchUserInfo() { const res await getUserInfo() this.userInfo res.data }, logout() { this.token this.userInfo null localStorage.removeItem(token) } } })路由守卫里根据登录状态做跳转控制router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.isLoggedIn) { next(/login) } else if (to.meta.requiresAdmin !userStore.isAdmin) { next(/) } else { next() } })这里要注意一个细节Pinia的store在路由守卫中调用时必须确保Pinia已经被安装到应用上。也就是在main.js里先app.use(createPinia())然后才能use(router)否则在beforeEach里调用useUserStore()会报错说找不到active pinia。这个顺序我一开始就搞反过排查半天。4.4 Axios封装与请求拦截器Axios请求封装我是单独放一个request.js文件统一处理请求头、响应拦截和错误提示import axios from axios import { ElMessage } from element-plus import { useUserStore } from ../store/user import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { userStore.logout() router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request这样封装后每个API模块的请求代码就非常干净比如菜品APIimport request from ../utils/request export function getDishList(params) { return request.get(/dish/list, { params }) } export function getDishDetail(id) { return request.get(/dish/${id}) }5. MySQL8.0环境搭建与踩坑实录5.1 本地安装与Docker部署两种方式对比网上订餐系统涉及的MySQL8.0环境搭建我见过两种主流方式推荐你有条件的话直接用Docker省心太多。本地安装的坑主要在于系统和旧版本残留比如你电脑上之前装过MySQL5.7再装8.0时服务起不来多半是Data目录、配置文件的残留冲突。我用过的Docker部署方式就很干净docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD123456 \ -e MYSQL_DATABASEonline_order \ -v mysql_data:/var/lib/mysql \ mysql:8.0注意-v挂载了一个数据卷这样容器删了数据还在。启动后进入容器执行初始化SQL脚本docker exec -i mysql8 mysql -uroot -p123456 online_order db/init.sql本地安装方式如果你是Windows直接下载MySQL8.0的msi安装包一路Next记得选Developer Default就行。安装完后最关键一步是在系统环境变量path里把MySQL的bin目录加上去不然命令行里敲mysql会提示不是内部或外部命令。Linux环境用apt或者yum装装完后初始密码通常在/var/log/mysqld.log里initialized为前缀的临时密码第一次登录后必须改密。MySQL8.0默认认证插件是caching_sha2_password这导致一些老的客户端工具连不上。如果你用Navicat老版本连不上数据库可以用下面SQL改回mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 123456; FLUSH PRIVILEGES;这段命令几乎是我帮别人排查MySQL8.0连接问题使用频率最高的了。5.2 高频报错与解决方案速查表开发过程中我整理了一份踩坑表全部是真实遇到过的场景直接对照查就可以现象根本原因解决方案启动SpringBoot时报Access denied for user rootlocalhost密码错误或权限不对检查application.yml中密码确认root账号允许localhost连接连接报Public Key Retrieval is not allowedMySQL8.0认证插件问题JDBC URL加allowPublicKeyRetrievaltrue查询中文变问号数据库连接串没指定characterEncoding或者表字符集不对URL加characterEncodingutf8建表时指定utf8mb4MyBatis-Plus分页不生效查询返回全部数据没有配置PaginationInnerInterceptor添加MybatisPlusInterceptor配置类前后端联调时报CORS跨域前端端口5173后端8080端口不同优先使用Vite代理解决而不是后端CorsFilter开放所有域前端路由刷新后404部署时Web服务器没有做history路由回退Nginx配置try_files $uri $uri/ /index.html; 开发环境用createWebHashHistory临时绕开时间字段插入后差8小时JDBC时区配置不对URL加serverTimezoneAsia/Shanghai提交订单后数据没回滚Transactional在类内部自调用失效Spring事务走代理服务方法必须通过注入的Service对象调用购物车加购慢没有给user_iddish_id建联合索引ALTER TABLE cart ADD UNIQUE KEY uk_user_dish (user_id, dish_id);JWT token过期后页面无感知前端响应拦截没处理401拦截器里判断code401时跳转登录页并清空本地token5.3 MyBatis-Plus字段映射的几个隐藏坑字段映射的坑比较隐蔽我单独拎出来讲。数据库字段一般用下划线风格比如create_time、category_idJava实体类用驼峰比如createTime、categoryId。MyBatis-Plus默认开启了map-underscore-to-camel-case这个映射是正常的。但有一个坑逻辑删除字段deleted在insert时不能有值。你想想数据插入时deleted默认值是0如果实体类里没设deletedMP会自动用全局配置里logic-not-delete-value0去填充。但如果你手贱在代码里设置了deleted1那这条数据一插入就被软删了用普通查询根本查不到。我实际调试过程中遇到的诡异bug查了半天才发现是insert时把deleted设为1了。另一个坑是Mapper里自定义SQL的时候select出来的字段除非你给查询结果指定resultMap否则实体类字段和表字段的自动映射规则仍然遵循下划线转驼峰。比如你写SELECT d.id, d.category_id, c.name as category_name FROM dish d LEFT JOIN category c ON d.category_id c.idMyBatis-Plus会自动把category_id映射到categoryIdcategory_name映射到categoryName你需要一个VO类来接收这些字段。注意这个VO类里的字段命名和数据库别名要能对应上。有些新手在XML里写了category_name别名但VO类里字段叫categoryName死活查询结果映射不上其实就是因为没搞清楚MP对别名列也会做驼峰转换但转换后要找的是categoryName而不是category_name。5.4 订单号生成与防并发重复订单号生成我用的是时间戳加随机数的方案保证在单机部署下不会重复private String generateOrderNo() { return ORD System.currentTimeMillis() RandomUtil.randomNumbers(4); }格式类似ORD17188771234567234长度在20位左右。如果后续接入支付回调需要重复查询订单用订单号做唯一索引就能保证不会乱。为了防止并发下两个订单号完全一样虽然概率极低orders表的order_no字段我建了唯一索引真撞上了数据库会抛DuplicateKeyException到时候换成雪花算法即可。高并发下单场景里热点菜品库存扣减还可能遇到行锁竞争。如果这个订餐系统以后要放到真实环境里跑可以考虑用Redis预扣库存异步任务回写数据库或者用乐观锁version字段配合CAS也是常见的解决方案。但作为课设毕设项目数据库扣减库存带条件判断的方式已经足够了重点是把事务边界和业务逻辑搞清楚。5.5 前端部署与Nginx配置细节项目做完部署的时候前端Vue3项目要先执行npm run build生成dist目录然后把dist底下所有文件扔到Nginx的html目录或者自定义路径下。Nginx配置文件里最关键的location块server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { 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 / 里的try_files是Vue Router的history模式必须的否则用户访问/order/detail/123时直接刷新页面Nginx找不到这个文件就会404。location /api/ 是把前端的接口请求反向代理给Java后端。如果你的域名配了HTTPS还要加上SSL证书相关的配置。这里有个开发环境没用到、生产环境才需要注意的点Nginx的client_max_body_size默认是1m如果你做了图片上传功能上传菜品图片超过1MB就会报413错误需要加大这个限制或针对上传接口单独配置location /api/upload { client_max_body_size 10m; proxy_pass http://127.0.0.1:8080; }6. 常用问题排查思路与个人经验补充6.1 遇到接口报错时怎么快速定位项目调不通的时候我总结了一套固定的排查顺序基本上按这个套路走定位问题不会超过五分钟。先把后端日志打开MyBatis-Plus的日志配置我上面已经在application.yml里开过了所有SQL会直接打印到控制台。看SQL就能判断参数是否传递正确比如分页查询时打印出来的LIMIT参数是不是你预期的值。然后看前端浏览器的Network面板确认请求发出去了没有、响应返回了什么状态码。如果Network里请求根本看不到那就是前端路由或请求方法的问题如果请求发出去但报错用后端日志和响应体里的message配合判断。最后看数据库把SQL语句复制出来直接在MySQL客户端里执行一遍看返回结果和预期是否一致。很多所谓的程序bug最后都出在数据问题要么字段值不对要么关联关系错了。6.2 MyBatis-Plus查询条件构造的思维转变做这个项目最大的感受是从写传统MyBatis XML的一堆if标签转成MyBatis-Plus的LambdaQueryWrapper刚开始会很不习惯。但用熟之后真的回不去手写XML的日子了。我举一个菜品列表筛选的例子。需求是按分类筛选、按名称模糊搜索、只查上架商品、按销量排序。用LambdaQueryWrapper写LambdaQueryWrapperDish wrapper new LambdaQueryWrapper(); wrapper.eq(Dish::getStatus, 1) .eq(categoryId ! null, Dish::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Dish::getName, keyword) .orderByDesc(Dish::getSales) .last(limit 10);这段代码清晰明了。eq和like的条件参数怎么写决定了这个查询参数的灵活性。比如keyword为空字符串时like条件不拼进去查询就返回所有上架商品。如果所有条件都不传那就是一个全量的上架商品列表逻辑清晰而且不需要任何XML。但要注意的是复杂SQL还是要写XML别硬用wrapper拼。比如订单列表查询要关联订单明细的用户名和菜品名称这种多表JOIN你就老老实实写Mapper XML不要试图用LambdaQueryWrapper强行实现否则SQL可读性会很差排查起来也费劲。6.3 项目文档应该包含哪些内容标题里写了【含文档】我说说项目文档对做课设/毕设的重要性。一份好的项目文档不在于篇幅长而在于结构完整、逻辑清楚。我的习惯是包含这几部分需求分析部分用文字加简单的用例说明系统有哪些角色、每个角色能做什么。系统设计部分画出系统的功能模块图、技术架构图、数据库ER图和核心流程时序图。接口说明部分把每个接口的请求方式、请求参数、响应格式罗列清楚最好带上样例。部署运行部分写清楚本地怎么启动后端、怎么启动前端、怎么初始化数据库这部分是给老师或者面试官看的写得好瞬间提升项目完整度。文档里我还喜欢加一个项目亮点与不足的部分。亮点可以写手写JWT认证、使用MyBatis-Plus逻辑删除、数据库字段自动填充、统一异常处理这些。不足也要诚实写比如未接入Redis缓存、订单超时未支付自动取消未实现、并发能力有限但可以给出后续优化方向。这样写比光吹牛效果好得多毕竟体现的是工程思维。6.4 面试和答辩时怎么讲清楚这个项目最后结合我的经验说说这个项目在面试或者答辩时怎么讲才显得有水平。切忌上来就背一行行代码面试官想听的是你的设计思路和难点解决过程。我建议按这个顺序讲先说项目的业务背景和用户角色再说技术选型及原因然后挑一个核心业务流程串起来比如从用户点餐到订单生成的完整链路过程中自然地带出JWT认证、数据表设计、事务处理、库存扣减这些技术点。最后说一两个自己遇到的坑和解决思路比如MySQL8.0时区问题、MyBatis-Plus分页失效、前端刷新404等这比讲一百个背熟的原理都管用因为体现了真实的排查能力。答辩时最能加分的三个点是做订单时如何保证数据一致性做登录时为什么选JWT不选Session以及如何考虑数据库性能比如哪张表建了什么索引、为什么这样建。这三问能答上来评委基本就认定这个项目是你自己做的了而且你确实想明白了里面的关键问题。我当时答辩时光讲库存扣减用条件更新加事务保证不超卖这点就让我少被追问了十分钟。综合下来SpringBoot2 Vue3 MyBatis-Plus MySQL8.0这套组合做网上订餐系统无论是作为练手项目还是课程设计都是投入产出比很高的选择。前端页面做出来效果直观后端逻辑足够有深度数据库设计也能展示出你对业务建模的理解。真正动手做完一个完整的前后端分离项目你对SpringBoot自动配置、MyBatis-Plus的核心机制、Vue3组合式API、MySQL索引和事务这些知识点的理解绝对比看十遍教程都扎实。这就是我的真实体会。