SpringBoot2+Vue3+MyBatis-Plus美食推荐商城全栈开发实践 开门见山说一句如果你正在找Java Web方向的毕业设计或课程设计题目或者想系统练一次前后端分离的全栈开发这套“SpringBoot2 Vue3 MyBatis-Plus MySQL8.0”的美食推荐商城是个很不错的参照。它不是一个只跑通“增删改查”的玩具项目而是把一个电商类系统该有的主链路都串起来了用户端的美食浏览与推荐、购物车、订单结算管理端的菜品与分类维护再加上JWT登录鉴权、数据库表设计、部署文档基本覆盖了一次完整项目开发会遇到的绝大多数问题。我自己搭这套系统的时候踩了不少坑也参考过网上各种残缺不全的源码最后整理成一套能独立跑通、能写进论文、能现场演示的完整项目。下面把选型思路、数据库设计、后端模块、前端交互、联调部署的细节和坑按实际开发顺序逐层拆开讲。无论你是打算照着复现还是只取其中一部分技术点都值得往下看。1. 项目定位与技术选型为什么是这套组合1.1 这套系统到底做了什么先明确需求边界。美食推荐商城核心不是“推荐算法有多智能”而是把用户、美食、订单、评价这几类业务数据串起来形成一个可运营的闭环。前台用户能注册登录、浏览分类美食、查看推荐列表、搜索菜品、加入购物车、提交订单后台管理员能管理菜品分类、维护菜品上下架、查看订单列表、处理发货状态。技术选型上这套系统前后端完全分离后端只提供RESTful API前端是独立部署的单页应用。这样做的直接好处是论文里的“系统架构图”可以画得很清晰答辩时逻辑也容易讲明白请求从Vue页面发出通过axios到SpringBoot的ControllerService处理业务Mapper通过MyBatis-Plus操作MySQL数据库整个过程职责边界明确。1.2 后端框架SpringBoot2还是SpringBoot3我选的是SpringBoot2具体版本用的2.7.x。原因很简单直接生态成熟、资料足够多、兼容性好。现在SpringBoot3已经发布但它基于Jakarta EE规范很多老教程和第三方库的兼容性还有坑。对于毕设和课程设计这种“求稳”的场景SpringBoot2.7依然是当前最不容易出问题的选择。另外一个实际考虑是Java版本。SpringBoot2.7可以跑在Java 8上而SpringBoot3最低要求Java 17。大部分学校的实验环境和个人电脑默认还是JDK8为了不折腾环境SpringBoot2是更省事的选择。我的思路是如果项目演示完还有余力再考虑升级SpringBoot3但核心功能不会因此有变化。技术栈的稳定性和可复现性永远比“版本最新”重要。SpringBoot2的另一个优势是自动配置非常成熟。引入spring-boot-starter-web就具备内嵌Tomcat引入spring-boot-starter-validation就自动加载校验器配合spring-boot-starter-test写单元测试也方便。这些对于快速搭建项目骨架非常实用。1.3 数据访问层为什么选MyBatis-Plus很多人纠结用JPA还是MyBatis我的建议是如果你主要目的是快速开发、减少手写SQL的工作量同时又要保留SQL可控性MyBatis-Plus是当前比较均衡的选择。它在MyBatis基础上封装了通用Mapper和通用Service单表CRUD完全不用写SQL用LambdaQueryWrapper或QueryWrapper就能完成条件查询。比如查询某个分类下所有上架菜品并按销量排序一行条件构造器的事ListDish list dishMapper.selectList( new LambdaQueryWrapperDish() .eq(Dish::getCategoryId, categoryId) .eq(Dish::getStatus, 1) .orderByDesc(Dish::getSales) );单表操作用内置方法多表关联自己写XML里的SQL灵活度足够代码量也比纯MyBatis少很多。尤其是分页MyBatis-Plus自带的分页插件用起来非常简单配置一个MybatisPlusInterceptor然后在业务层调用Page对象即可。1.4 前端为什么用Vue3而不是Vue2前端选择Vue3核心原因是组合式APIComposition API在组织复杂业务逻辑时确实比选项式API更清晰。这套商城系统里购物车的状态管理、推荐列表的请求联动、登录态的判断如果都用data、methods、watch这种分散写法代码会越写越乱。用setup函数加ref、reactive、computed同一个功能的逻辑可以集中在一块后期维护体验明显更好。配套的构建工具我用的是Vite而不是Vue CLI。Vite开发服务器启动速度极快依赖预构建和按需编译让热更新基本无感。对于需要反复调试页面样式和接口联调的开发过程开发体验差距是巨大的。UI组件库选Element Plus它是Element UI的Vue3版本表单、表格、对话框、分页这类后台管理高频组件都有直接引入就能用能省出大量前端工作量。2. 数据库设计与建模细节2.1 核心表结构与字段说明数据库设计决定了整个项目能走多远。这套商城我一开始设计了六张核心表后面又根据业务补了两张。核心表分别是用户表、菜品分类表、菜品表、购物车表、订单表、订单明细表后面补的是收藏表和菜品浏览记录表用来支撑推荐功能。用户表字段相对简单id、username、password、nickname、avatar、phone、create_time。密码字段存的是BCrypt加密后的密文绝不允许明文入库这是安全底线。菜品分类表包含id、name、sort排序权重、status。菜品表字段比较多id、category_id、name、description、image、price、stock、sales、status、create_time。我用一张表展示菜品表的完整设计思想字段类型说明idbigint主键雪花算法生成category_idbigint关联分类表namevarchar(100)菜品名称建普通索引descriptionvarchar(500)菜品描述imagevarchar(255)图片访问路径pricedecimal(10,2)价格避免用doublestockint库存数量salesint销量用于排序推荐statustinyint1上架 0下架create_timedatetime创建时间价格字段这里特别提醒一下一定用decimal不用double。浮点类型做金额运算会出现0.10.2不等于0.3这种精度问题电商系统对金额精度要求很严格用decimal(10,2)配合Java端BigDecimal才稳。2.2 订单与购物车的表设计要注意什么订单这块是很多初学者容易设计翻车的地方。如果只建一张订单表把所有信息塞进去后续统计、退款、状态流转都会很痛苦。正确的做法是订单主表和订单明细表分离主表存订单总金额、订单状态、收货信息等汇总数据明细表存每一个菜品项的单价、数量、小计。订单主表字段id、order_no订单编号、user_id、total_amount、status、receiver_name、receiver_phone、receiver_address、create_time。订单明细表字段id、order_id、dish_id、dish_name冗余存储防止菜品改名后订单数据失真、dish_image、price、quantity、subtotal。这里有一个关键设计决策明细表为什么要冗余dish_name和dish_image因为订单是历史事实数据如果用户下单后管理员修改了菜品名称或下架了菜品订单详情还是要能还原当时的信息。这种以“空间换准确性”的冗余在业务建模中是合理的。购物车表相对简单id、user_id、dish_id、quantity、create_time、update_time。一个用户对同一菜品只保留一条记录数量累加。这里可以对user_id和dish_id建联合唯一索引从数据库层面防止重复数据。2.3 推荐系统需要哪些数据支撑“美食推荐”这个功能如果只按销量排个序其实不太有说服力。我增加了两张轻量级的表来支撑个性化推荐收藏表favorite和浏览记录表browse_history。收藏表记录用户收藏了哪些菜品浏览记录表记录用户每次打开菜品详情页的时间戳。推荐逻辑的基本思路是根据用户的历史行为收藏、浏览次数计算其对不同分类的偏好权重然后在该用户偏好权重最高的几个分类里按“销量×折扣系数”选出候选菜品再过滤掉用户已经收藏或浏览过的菜品形成最终的推荐列表。这套逻辑不复杂但对于一个商城系统已经足够展示“推荐”的完整能力答辩时也能把思路讲清楚。重要的是它需要的数据在数据库层面完全能支撑不用引入额外的搜索引擎或大数据组件。3. 后端核心模块的落地过程3.1 JWT登录鉴权与全局拦截用户登录模块用的是JWTJSON Web Token方案。流程是用户提交用户名密码后端校验通过后生成一个包含用户ID和用户名的token返回给前端前端把token存在本地存储里后续每次请求在请求头中带上Authorization字段。后端用一个拦截器统一处理token校验避免在每个Controller里重复写鉴权逻辑。拦截器里要做的事有三件检查token是否存在、校验token的签名和过期时间、解析出用户信息放到当前请求上下文中。核心代码大致是这样Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains(/user/login)) { return true; } String token request.getHeader(Authorization); if (token null || token.isEmpty()) { throw new BusinessException(未登录请先登录); } // 解析token失败会抛出异常 Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); return true; } }这里有个实操细节错误处理不能让异常直接抛给Tomcat默认错误页否则前端拿到的不是JSON。我在全局异常处理器RestControllerAdvice里统一捕获BusinessException和其他异常返回结构统一的JSON响应体。这样前端axios拦截器只需要判断返回码就能统一跳转登录页。密码加密用的是BCrypt算法Spring Security框架虽然没整个引入但spring-security-crypto这个工具包可以单独引用来做BCrypt加密足够。3.2 基于MyBatis-Plus的业务开发效率MyBatis-Plus对开发效率的提升是实打实的。前端增删改查、后台管理模块80%的代码直接用BaseMapper和IService提供的现成方法不需要写一行SQL。真正需要手写SQL的场景只有两类多表关联查询如订单详情查询需要同时查主表和明细表和复杂统计如销量排行。举一个实际业务场景后台管理端的分页查询菜品列表支持按名称模糊查询和按分类筛选。用MyBatis-Plus的条件构造器写出来非常清爽public PageDish pageDish(int page, int size, String keyword, Long categoryId) { PageDish page new Page(page, size); LambdaQueryWrapperDish wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(keyword), Dish::getName, keyword) .eq(categoryId ! null, Dish::getCategoryId, categoryId) .orderByDesc(Dish::getCreateTime); return dishMapper.selectPage(page, wrapper); }like和eq方法中的布尔参数是MyBatis-Plus的一个特性第一个条件是true才拼接该查询条件。这样避免了写一堆if判断代码非常简洁还不会产生“空条件影响性能”的问题。分页插件的配置也别漏了Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }不配这个分页插件selectPage查出来的数据就是全表数据只是表面“分页”实际性能会随数据量增长急剧下降。这是很多照着抄代码的人最容易忽略的坑。3.3 美食推荐的核心逻辑推荐模块的实现思路我上面已经提到过先算用户对不同分类的偏好权重再从高权重分类中选热门菜品。具体实现拆解成三步。第一步查询用户的收藏记录和浏览记录汇总每个分类被交互的次数。第二步用交互次数除以总交互次数得到每个分类的偏好权重。第三步遍历偏好权重最高的两个分类在每个分类里按销量降序取前N个菜品再把用户已经收藏过的、最近浏览过的菜品过滤掉。这里有一个细节值得注意新用户没有任何行为数据怎么推荐我的处理方式是直接返回全站销量最高的前12个菜品并标注“热门美食”的语义。代码层面就是根据用户ID查行为表如果结果为空走一个默认的推荐SQL。推荐结果在数据库层面用一条带limit的查询解决不搞复杂的实时计算。对于一个毕设级别的项目这样的实现已经具备可解释性和可扩展性——将来如果要上更复杂的协同过滤算法只需要替换推荐模块的实现不需要改变前端接口。3.4 订单与库存的数据一致性订单提交是整个项目中并发风险最高的操作。用户在页面上点了“提交订单”后端要做的事是从购物车取出选中菜品、计算总金额、扣减库存、创建订单主记录、创建订单明细记录、清空购物车。这个过程中只要任何一步失败库存和订单数据就会不一致。我的做法是用Transactional注解把整个方法包成事务。Spring的声明式事务默认遇到RuntimeException会回滚所以方法内任何一步抛出异常前面执行的SQL都会回滚。另外库存扣减不能用“先查库存判断够不够再update”这种三步骤因为并发请求下会出现超卖情况。正确做法是在一条update语句里做条件判断int updated dishMapper.update(null, new LambdaUpdateWrapperDish() .setSql(stock stock - 1) .eq(Dish::getId, dishId) .gt(Dish::getStock, 0));这条SQL的含义是“只有当库存大于0时才减库存”返回值updated如果是0说明库存不足直接抛出业务异常触发回滚。这种方式在数据库层保证了原子性比先查后改要可靠得多。4. Vue3前端的页面组织与核心交互4.1 基于Vite的初始化与项目结构前端项目使用Vite创建命令一行搞定。我习惯把目录按功能模块拆而不是按页面类型拆src/ ├── api/ // 按业务域封装接口请求 ├── assets/ // 静态资源 ├── components/ // 公共组件 ├── router/ // 路由配置 ├── stores/ // Pinia状态管理 ├── views/ │ ├── home/ // 首页与推荐 │ ├── dish/ // 菜品详情 │ ├── cart/ // 购物车 │ ├── order/ // 订单相关 │ └── admin/ // 后台管理 ├── utils/ // 工具函数 └── App.vueapi目录下的每一个文件对应一个业务域。比如api/dish.js统一封装菜品相关的所有请求页面组件里只调用方法不直接写axios。这样做的好处是接口路径变更时只需要改一个文件而且每个接口的调用方一目了然。axios实例需要单独封装核心是请求拦截器和响应拦截器。请求拦截器负责从Pinia或localStorage取出token加到请求头响应拦截器负责统一处理后端返回的数据结构如果后端返回未登录状态码直接清空本地token并跳转登录页。4.2 Pinia状态管理下的购物车与登录态这套系统里我用了Pinia管理两个全局状态用户信息和购物车。对比VuexPinia的API更简洁去掉了mutations这一层直接在actions里改状态而且天然支持组合式API写法。购物车store的核心逻辑是这样的从后端接口拉取当前用户的购物车数据存入state页面上任何对购物车的增删改操作先调用后端接口成功后再更新本地state。为什么不在前端仅靠本地状态模拟因为购物车数据必须跟用户账号绑定换设备登录也要能同步这是前后端分离项目的基本要求。用Composition API写购物车Store有一点很舒服可以在组件里用storeToRefs解构出响应式数据然后用computed派生计算总价和总数量不需要在模板里写复杂的计算表达式。4.3 首页推荐列表与菜品详情的联动首页是这个项目最需要“有颜值”的页面。我的页面布局是顶部导航栏、搜索框、分类选项卡、推荐菜品瀑布流。分类选项卡切换时重新请求该分类下的菜品列表推荐区域请求推荐接口展示个性化推荐。这里有一个交互细节菜品列表和推荐列表的请求状态管理。我用ref记录加载状态配合Element Plus的el-skeleton骨架屏在数据返回前显示占位避免页面一闪而过的空白感。加载完成后背景平滑过渡体验好了很多截图放进论文里也更好看。菜品详情页从路由参数拿dishId调菜品详情接口同时向后端“浏览记录”接口提交一条浏览记录供推荐算法使用。详情页里除了菜品图片、价格、描述还有“加入购物车”“立即购买”两个主操作按钮。加入购物车时可以选择数量立即购买则直接跳到订单确认页。这个设计很短小但把“推荐—浏览—收藏—加购—下单”这条完整的用户行为链路串起来了数据闭环对推荐准确率的提升是实实在在的。4.4 购物车与订单结算的前端逻辑购物车页面的核心状态是“选中项”。一顿操作下来我发现直接用一个SetLong存选中的购物车项ID是最省心的方案配合computed计算选中项的总金额。全选、单选、取消选中、删除、修改数量这些都是基于这个Set做增删。订单确认页接收选中的购物车项数据展示订单商品清单、总金额、收货地址表单。提交订单按钮点击后前端调后端/order/create接口成功后清空购物车中对应的项跳转到订单支付成功页。在“支付”这个环节因为系统定位是演示项目我没有接真实的微信支付或支付宝支付而是做了一个“模拟支付”按钮生成支付成功状态。如果有时间这个点可以扩展成对接一个真实的支付沙箱但用于毕业设计展示模拟支付的业务流程已经足够完整。把支付接口设计成独立模块后续接入真实支付只改一个实现类就行。5. 联调、部署与避坑实录5.1 跨域问题前后端分离项目联调第一关就是跨域。前端跑在http://localhost:5173后端跑在http://localhost:8080端口不同浏览器默认会拦截请求。解决方式有两种前端代理或后端CORS配置。开发环境我推荐前端代理在vite.config.js里配置export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })前端请求路径统一以/api开头Vite开发服务器会把这个请求转发到后端。这样浏览器看到的请求是同源的不会触发跨域问题也不需要后端额外配置。生产环境部署时则用Nginx做反向代理同样把/api路径转发到后端服务。如果非要用后端CORS配置也可以但要注意allowCredentials为true的时候allowedOrigins不能使用*必须指定具体的源地址否则浏览器还是会拦截。5.2 图片上传与静态资源映射菜品图片是商城系统绕不开的功能。后台上传图片时前端把文件以multipart/form-data格式POST到后端后端把文件保存到服务器本地磁盘目录返回一个可访问的URL路径保存到数据库。关键问题在于本地磁盘路径怎么映射成URLSpringBoot默认只会把classpath:/static/目录下的资源映射为静态资源如果文件保存到服务器任意磁盘路径需要对SpringMVC的资源配置做扩展Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 将 /upload/** 映射到本地磁盘的 upload 目录 registry.addResourceHandler(/upload/**) .addResourceLocations(file: System.getProperty(user.dir) /upload/); } }这样配置以后保存的文件URLhttp://localhost:8080/upload/xxx.jpg就能直接被浏览器访问。如果文件上传到服务器硬盘的其它绝对路径把System.getProperty(user.dir)换成实际路径就行。实测中还有一个坑文件大小限制。SpringBoot默认单文件最大1MB上传菜品高清图很容易超。需要配置spring.servlet.multipart.max-file-size10MB和max-request-size20MB否则上传会被静默拦截前端收到的错误信息还不明确。5.3 时间字段的格式化和时区问题MySQL8.0的datetime类型本身不包含时区信息但Java后端的LocalDateTime和前端交互时会出现序列化格式问题。默认情况下SpringBoot返回的LocalDateTime序列化成JSON格式比较奇怪前端处理起来麻烦。解决方案是统一时间格式在配置文件中指定spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8date-format对java.util.Date生效对LocalDateTime需要配合JSR310模块。如果你的Jackson版本较新直接加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)注解到时间字段上也能实现。两种方案二选一但一定要做到前后端格式统一。数据库连接串也要注意加serverTimezoneAsia/Shanghai否则MySQL驱动连接时会报时区错误或时间偏差8小时。5.4 生产环境的打包部署项目的前后端部署方式后端打成可执行Jar包前端用npm run build构建出静态文件由Nginx托管静态文件并代理后端API。后端打包时有个容易踩的坑默认的SpringBoot Maven插件打出来的包可能不是可执行的需要在pom.xml里显式配置build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build打包完成后用java -jar target/dish-0.0.1-SNAPSHOT.jar就能启动。如果服务器内存有限可以加JVM参数限制内存java -Xms256m -Xmx512m -jar。前端构建完的dist目录在Nginx配置里需要做两点一是将/api路径代理到后端服务端口二是配置try_files让前端路由在刷新页面时不返回404location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; }try_files那段尤其重要。Vue3用的history路由模式如果在Nginx里不加这个配置用户访问/dish/1这种子路径时刷新Nginx会去找服务器上不存在的物理路径直接返回404。这个问题我第一次部署时排查了好一阵子。5.5 项目文档与演示准备最后说下文档。这套项目之所以“含文档”价值高是因为很多毕设项目源码能跑但论文写不出来。建议项目里的数据库设计文档、接口文档、部署说明从一开始就随代码一起维护不要等最后写论文时才补。接口文档可以用Apifox或Postman导出按模块分组记录每个接口的请求参数、返回示例。数据库设计文档重点写表结构设计说明、ER图、字段含义。这些内容直接能移植到毕业设计论文的第4章、第5章省下的时间非常可观。演示准备时建议准备一组预制数据比如一个分类里的菜品覆盖“便宜家常、精致大菜、甜品饮品”几个档次用户账号下的购物车和收藏数据也预先放几条这样现场演示时不需要临时造数据流程顺畅很多。我实际演示时就吃了这个亏临时造数据手忙脚乱后来重新调整了数据初始化脚本演示效果明显好了。这套项目做完之后我的整体感受是它胜在“完整”二字前后端职责清楚、数据库设计规范、核心业务链路闭环而且每一个模块都有继续深挖的空间。如果你只改部分业务比如把美食换成图书、衣物结构调整成本很低如果你想往推荐算法方向做深入现在的行为数据表也已经把地基打好了。照着这套思路一步步搭下来你收获的不只是一个能交差的毕设而是一次真正的全栈开发实战经验。