资讯动态

SpringBoot+Vue美食推荐商城管理系统:从建表到上线完整实现

发布时间:2026/9/30 4:58:18 来源:尧图企业网站定制
1. 为什么这个项目值得做从选题到技术栈定下来的一笔账2025年再回头看前后端分离的管理系统已经不是什么新鲜东西但SpringBootVue这套组合依然是课程设计和毕业设计里出现频率最高的方案。年初我整理了一套基于SpringBootVue的美食推荐商城设计与实现管理系统技术栈包括MyBatis和MySQL前后端代码和数据库脚本都齐全。这篇内容就是把我自己从选题、建表、后端接口到前端页面的完整思路讲清楚适合正在做毕设、课程设计或者单纯想练一个完整全栈项目的同学。1.1 美食推荐商城到底解决了什么问题市面上常见的美食类应用大多是静态展示用户只能看没法真正参与“选菜—下单—评价”这条完整链路。而一个带推荐功能的美食商城管理系统核心要处理的是四件事用户怎么注册登录、菜品怎么分类展示、系统怎么给不同用户推荐他想吃的东西、用户下单之后订单状态怎么流转。这个项目好就好在它足够“像真实业务”。它不是一个单纯的CRUD练习而是包含了推荐逻辑、购物车、库存扣减、评价联动这些真实电商场景。哪怕你后续不从事Java开发做完这套系统后对“一个软件是怎么从零长出来”的认知也会比看十遍教程更扎实。1.2 为什么选SpringBootVueMyBatisMySQL这套组合选技术栈的时候我也纠结过一阵最终定下这套组合不是因为它们是“最潮的”而是因为它们各自的优势恰好匹配这个项目。SpringBoot负责后端业务层它解决了传统SSM整合时一堆XML配置的问题。内嵌Tomcat、自动装配、约定优于配置一个开发者可以少写很多样板代码。Vue负责前端展示组件化开发让页面拆成一个个独立的卡片、轮播、购物车模块维护起来比一坨JSP清晰太多。MyBatis是我刻意选的它的半自动SQL让复杂查询不需要写大量JPA方法名尤其是做推荐排序和条件筛选时SQL就在XML里明明白白写着出问题一眼能定位。MySQL则是免费开源里最稳定的选择配个数据库管理工具就能把表结构看得清清楚楚。1.3 拿到源码后先别急着跑你要确认的版本矩阵我见过太多同学把源码拉下来一启动直接报错第一反应是代码有bug最后发现是JDK版本和框架版本不匹配。这里先给大家一个2025年实测可用的版本组合省得走弯路。组件建议版本说明JDK17SpringBoot 3.x必须JDK17起步别再用JDK8硬跑SpringBoot3.2.x稳定版支持JDK17依赖集成顺畅Vue3.xVite构建Vue2已进入维护期新项目直接上Vue3Node.js18.19以上Vite 5要求较高版本安装完记得npm config看下源MyBatis3.5.x配合mybatis-spring-boot-starterMySQL8.x推荐8.0以上utf8mb4字符集对中文友好Element Plus2.x组件库比Element UI更适合Vue32. 数据库设计先用表格把整个系统的骨架搭出来我习惯先画表再敲代码。后台代码写得再漂亮如果表结构不合理后面接口就会越改越别扭。美食推荐商城看起来功能多实际上核心表就六张用户表、菜品分类表、菜品表、购物车表、订单表、订单明细表另外可以加一张评价表。下面是我在项目里实际用到的字段设计思路。2.1 核心表结构用户、菜品、分类、购物车、订单、评价用户表不需要太复杂但要给后续扩展留空间。id主键自增username设置唯一索引password字段存加密后的密文不是明文。avatar存头像路径phone和email用于找回密码和接收通知。我加了一个status字段1为正常0为禁用比直接删用户更稳妥。菜品表是整个系统的核心。category_id指向分类表name、description、image、price、stock、sales_count、rating这些字段都要有。特别注意rating存的是平均评分这样推荐列表排序时可以直接用不用每次联表查评分表再求平均值。sales_count同理作为热销榜的排序依据。create_time和update_time统一用datetime类型方便后面做数据统计。购物车表其实是一个“用户和菜品的关联表”。一个用户对应多个菜品一个菜品也可以出现在多人的购物车里。字段就user_id、dish_id、quantity、checked。这里不需要存价格快照因为购物车里的价格随着菜品价格变动可以接受真正要锁定价格的是订单表。订单表和订单明细表是典型的1对多关系。订单表存order_no、user_id、total_amount、status、address、remark、create_time。status用tinyint表示0待支付、1已支付、2已发货、3已完成、4已取消这些状态后续在接口里要定义成常量。订单明细表存order_id、dish_id、dish_name、dish_image、price、quantity、subtotal。这里一定要冗余一份dish_name和dish_image因为菜品后续可能改名或删图订单历史记录不能跟着变。评价表解决的是“用户买完之后可以打分”的功能。字段有id、order_item_id、user_id、dish_id、rating、content、create_time。设计时要把order_item_id做成唯一索引防止一个订单明细被重复评价。2.2 推荐逻辑依赖的字段设计很多人做“美食推荐”只知道在接口层随机查几条数据这样用户每次看到的都不一样但并没有“推荐感”。我这里的推荐逻辑用了比较朴素但很有效的方案综合加权排序。核心依赖就是菜品表里的category_id、sales_count、rating、stock再加上评价表的评分。具体做法是在SQL查询时把用户点击过的分类作为条件先过滤一遍然后按sales_count * 0.6 rating * 0.4计算推荐分值最后按分值降序。为什么不直接用复杂的协同过滤因为一个课程设计/毕设级别的项目用户量和数据量都很小你引入Spark机器学习反而难自圆其说。用加权SQL既能在答辩时把推荐的“原理”讲清楚又能真实跑出效果。2.3 建表SQL中的索引与约束心得这里分享几条我踩过坑后的心得。第一不要急着加物理外键。以前写作业喜欢FOREIGN KEY一个个关联后来发现后端代码里手动控制逻辑关系比数据库外键更好调试。外键在做批量删除时会绑手绑脚尤其在订单和菜品这种历史数据关系上我更倾向于只建普通索引。第二status这类状态字段必须加默认值建表时顺手DEFAULT 1或者DEFAULT 0否则后续每次插入都要手动带上。第三所有时间字段建议DEFAULT CURRENT_TIMESTAMP避免后端忘记传值。我贴一段菜品表的关键建表SQL大家可以参考字段类型的选择。CREATE TABLE t_dish ( id INT NOT NULL AUTO_INCREMENT COMMENT 主键ID, category_id INT NOT NULL COMMENT 分类ID, name VARCHAR(100) NOT NULL COMMENT 菜品名称, description VARCHAR(500) DEFAULT COMMENT 菜品描述, image VARCHAR(255) DEFAULT COMMENT 菜品图片URL, price DECIMAL(10,2) NOT NULL COMMENT 价格, stock INT NOT NULL DEFAULT 0 COMMENT 库存, sales_count INT NOT NULL DEFAULT 0 COMMENT 销量, rating DECIMAL(3,2) NOT NULL DEFAULT 5.00 COMMENT 平均评分, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1上架0下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_category_id (category_id), KEY idx_sales_rating (sales_count, rating) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品表;idx_sales_rating这个联合索引是我专门为推荐排序加的这样查询ORDER BY sales_count DESC, rating DESC就能直接走索引数据量大一点也不会慢。3. 后端模块拆解SpringBootMyBatis实现业务层的正确姿势后端是整个系统的“大脑”我按功能模块拆成用户、菜品、购物车、订单、评价这几大块。包结构看起来不复杂但每一层的职责要划清楚否则写着写着就变成一个大Controller包揽所有逻辑。3.1 项目目录与分层我的后端目录结构大致如下com.example.food ├── common // 通用类返回结果、异常处理、常量 ├── config // 配置类跨域、拦截器、静态资源映射 ├── controller // 接口层接收参数、返回JSON ├── service // 业务层核心逻辑写在这里 │ └── impl ├── mapper // MyBatis数据访问层 ├── entity // 实体类对应数据库表 ├── dto // 数据传输对象接收前端参数、返回前端数据 └── util // 工具类JWT工具、加密工具这里有一个容易忽略的点实体类entity尽量不要直接当作接口入参。比如注册接口只需要用户名和密码但User实体里还有头像、手机号这些字段直接接收会把前端不该传的字段也暴露出来。我在项目里专门用了RegisterDTO和LoginDTO这样参数清晰安全性也好一点。3.2 用户登录鉴权我选JWT而不是Session用户登录这个模块传统做法是用Session但前后端分离项目里Session处理跨域麻烦。我选的是JWT。流程很简单用户登录成功后后端用密钥生成一个带用户ID和过期时间的token返回给前端前端每次请求在Header里带上Authorization: Bearer token后端用一个拦截器校验token校验通过才放行。JWT的核心依赖是jjwt在pom.xml里加这一段就能用dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency拦截器里我从Header取token解析用户ID放到ThreadLocal里业务层需要当前用户ID时直接从ThreadLocal拿不用每个接口都把user_id作为参数传来传去。这里有个坑ThreadLocal用完一定要调用remove()否则Tomcat线程池复用时会串数据我调试时遇到过一对奇怪的Bug排查到最后就是这里。3.3 美食推荐接口的SQL与算法美食推荐是整个项目里最能聊的一部分。我只用了一个接口就实现了“今日推荐”和“热销榜”两个场景。推荐逻辑我定义为按用户浏览记录中点击次数最多的分类筛选再结合销量和评分排序。如果没有浏览记录就返回全部菜品里综合分最高的前八条。对应到MyBatis的mapper XML核心SQL长这样select idselectRecommendList resultTypecom.example.food.entity.Dish SELECT d.*, (d.sales_count * 0.6 d.rating * 0.4) AS recommend_score FROM t_dish d WHERE d.status 1 AND d.stock 0 if testcategoryId ! null AND d.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (d.name LIKE CONCAT(%, #{keyword}, %) OR d.description LIKE CONCAT(%, #{keyword}, %)) /if ORDER BY recommend_score DESC LIMIT #{offset}, #{pageSize} /selectrecommend_score这个计算列不需要存到表里直接在SQL里算出来就行。如果你对推荐效果不满意可以调整权重比例比如更看重销量就调成0.7 * sales_count 0.3 * rating。对于这个体量的项目这套方案完全够用答辩时把“为什么选这两个指标”解释清楚比背一堆算法名词可信得多。3.4 菜品管理、购物车、订单事务处理菜品管理就是典型的CRUD但有两个细节要注意。一是图片上传我后端提供一个通用的/api/upload接口用MultipartFile接收图片存到服务器本地指定目录返回访问URL。二是菜品上架下架我建议用status字段做逻辑删除而不是直接DELETE因为历史订单明细里还引用着菜品名称和图片物理删除会留一堆空引用。购物车接口逻辑相对简单加购时先查数据库有没有同用户同菜品的记录有就累加数量没有就新增。购物车列表接口要把菜品的最新价格、名称、图片一起返回所以Mapper里联查了t_dish表。订单接口是核心中的核心我用了Transactional注解保证原子性。一个下单操作至少要做这几步生成订单号、写订单主表、写订单明细表、扣减菜品库存、清空购物车对应项。如果其中任何一步失败整个事务回滚避免出现“订单生成了但库存没扣”这种数据不一致的情况。Transactional(rollbackFor Exception.class) public Long createOrder(CreateOrderDTO dto) { // 1. 生成订单号 // 2. 计算总金额 // 3. 插入订单表 // 4. 批量插入订单明细 // 5. 调用dishMapper.decreaseStock() // 6. 删除购物车记录 // 返回订单ID }库存扣减我特意没用简单的UPDATE t_dish SET stock stock - 1而是加了库存充足判断UPDATE t_dish SET stock stock - #{quantity} WHERE id #{dishId} AND stock #{quantity}这样即使高并发场景下多个用户同时下单也不会出现超卖。UPDATE影响行数为0时说明库存不足直接抛异常回滚提示“菜品库存不足”。4. 前端Vue实现从首页到下单的完整交互前端我用的是Vue3 Vite Element Plus Pinia。初始化项目用npm create vuelatest组件库用Element Plus状态管理用Pinia。很多同学困惑“Vue2和Vue3到底学哪个”我的建议很直接如果你2025年还从零开始写新项目直接学Vue3。Vue3的Composition API配合script setup代码组织起来比Vue2的Options API顺手太多。4.1 环境与项目初始化Node.js版本一定要确认在18以上。Vite创建项目后第一件事是安装依赖。这里推荐配置一下npm镜像源不然Element Plus这种大包会下载到怀疑人生。安装核心依赖的命令大致是npm create vuelatest npm install npm install element-plus npm install axios npm install pinia装完之后我会把项目目录清一下只保留src/views、src/components、src/router、src/store、src/api。别把Vite脚手架自动生成的HelloWorld组件留着看着乱。4.2 首页轮播图、菜品卡片、分类筛选首页是用户第一眼看到的东西我拆成了三个组件顶部导航栏、分类侧边栏、菜品卡片网格。顶部导航栏负责搜索框和购物车图标分类侧边栏调/api/category/list接口拿分类数据点击某个分类就把分类ID传给菜品列表组件菜品列表组件再调推荐接口拿对应分类下的菜品。菜品卡片是复用最多的组件。卡片里展示图片、名称、价格、销量、评分和“加入购物车”按钮。整个系统里菜品会在首页、搜索结果页、推荐页反复出现所以我把菜品卡片抽成公共组件数据通过props传入加入购物车事件通过emit抛给父组件处理。这样复用性高后期改卡片样式只需要改一处。这里有个Vue的细节图片加载失败时可以用error事件设置一个默认图片免得页面上一堆裂图。我在菜品卡片里是这么处理的img :srcdish.image alt菜品图片 errorhandleImageError /handleImageError里把event.target.src换成项目内置的一张占位图。这个细节不大但演示的时候很加分。4.3 购物车状态管理与订单确认购物车状态我用Pinia管理比在每个页面各自维护ref变量干净。Store里存一个cartItems数组加购操作触发addToCart方法如果菜品已在购物车中数量加一否则新增一条记录。这里要区分前端展示和真实购物车表前端状态管理是为了让用户不加刷新就能看到角标数量变化后端购物车表是为了用户下次登录还能看到之前的未下单商品。订单确认页要展示购物车中选中的商品、总金额、收货地址。收货地址这块我没做太复杂直接用一个文本输入框让用户填。真正要注意的是提交订单前要二次确认总金额前端显示的总价只能参考后端接口会重新从数据库读取价格计算。所以前端传DTO给后端时只需要传购物车选中的条目ID列表和地址不要传总金额否则用户篡改金额就麻烦了。4.4 与后端接口的联调联调阶段最常见的问题就是跨域。开发环境下我是通过Vite代理解决的在vite.config.js里配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端所有请求都写相对路径/api/xxxVite开发服务器把请求转发到后端的8080端口浏览器看起来是同源请求不会触发跨域。这里要注意后端用拦截器校验token时如果从请求里读不到token就返回401前端Axios需要封装一层响应拦截器遇到401自动跳回登录页并清除本地存储的token。我在src/api/request.js里封装了Axios实例统一设置baseURL和请求头const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })5. 部署、调试与常见坑那些教程不会告诉你的细节这个项目我在本地跑通之后又完整部署了一次中间遇到不少“教程不会告诉你”的问题。这一节专门整理出来希望你少熬夜。5.1 本地跑起来要修改的配置后端跑起来前必须改的配置在application.yml里。数据库地址、数据库名、用户名、密码每一项都要检查。我用的配置片段spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/food_recommend?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password很多人一上来报错十有八九是serverTimezone没设置MySQL 8默认时区和本地时区不一致会报The server time zone value йʱ is unrecognized。加上Asia/Shanghai就解决了。另外driver-class-name在MySQL 8里必须是com.mysql.cj.jdbc.Driver不要记成旧的com.mysql.jdbc.Driver。5.2 MyBatis mapper.xml里容易出的问题MyBatis的XML里最容易踩的有三个坑。第一个是resultType返回值类型写错。如果Mapper方法返回的是ListDishXML里的resultType直接写实体类全限定名不需要写List。有些同学照着别的项目写成了resultTypejava.util.List结果爆出一堆类型转换异常。第二个是SQL里的符号。在XML中是标签开始的标志所以WHERE quantity 5必须转义成lt;或者把整个判断包进![CDATA[ ]]里。实际项目里我习惯把动态判断尽量用if标签做一旦遇到特殊符号就直接上CDATA省得记转义规则。第三个是模糊查询。以前写LIKE %${keyword}%会有SQL注入风险正确姿势是LIKE CONCAT(%, #{keyword}, %)。使用#{}预编译安全可靠。这些MyBatis的细节在面试里也常考所以恶意项目顺带复习了mybatis面试题里的高频考点。5.3 跨域、token、图片上传虽然开发环境用Vite代理解决了跨域但部署后前后端可能不在同一个域名下后端还是得配置CORS。我写了一个WebMvc配置类Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)时前端Axios也要设置withCredentials: true否则请求发出去了但响应会被浏览器拦截。图片上传路径是个隐藏坑。开发环境我上传到D:/upload/通过后端配置的静态资源映射让/images/**访问本地目录。部署到Linux服务器后如果没有这个目录或者路径分隔符写死反斜杠就会上传失败。我的建议是上传路径放到配置文件里file.upload-dir/data/food/upload代码里用Paths.get(uploadDir).toAbsolutePath().normalize()处理路径。Linux和Windows都兼容。5.4 打包上线前端执行npm run build生成dist目录里面是纯静态文件。最简单的部署方式是用Nginx托管前端同时把/api路径反向代理到后端服务。Nginx配置片段server { listen 80; server_name your-domain.com; location / { root /data/food/dist; 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; } }后端就是标准的SpringBoot打包执行mvn clean package -DskipTests后得到jar包用nohup java -jar food-recommend-1.0.0.jar 启动。如果端口被占用--server.port8080指定。try_files那行一定要写否则用户刷新非首页路由时Nginx会返回404。6. 从“做完”到“做好”一些拓展与优化思路基础版本跑通之后这个系统还有很大的升级空间。我可以说说我自己后续打算做的优化以及我在实际操作中的体会。6.1 把简单推荐升级成基于标签的召回现在的加权排序方案虽然能用但“个性化”程度不高。我想在菜品表旁边再加一个标签表比如“麻辣”“清淡”“低脂”“川菜”“粤菜”这些。用户在看菜品时可以点“喜欢”按钮收集偏好标签推荐时先按标签召回一批候选菜品再用销量和评分排序。这种方案的好处是逻辑透明面试官问起来你能讲清楚召回、排序两个阶段比硬上深度学习模型更能展现你的工程思维。6.2 用Redis做缓存和热销榜项目如果上了几个并发用户每次刷新首页都查一次数据库其实没必要。可以引入Redis把首页推荐列表缓存十分钟菜品详情缓存五分钟。另一个很实用的场景是热销榜使用Redis的ZSet每次下单成功后给对应菜品INCRBY score 1然后用REVRANGE取前几名。这比每次都去MySQL里按sales_count排序快得多也是真实电商系统里的常规套路。引入Redis后要注意缓存和数据库的一致性。我的做法是更新菜品或订单时先更新数据库再删除对应缓存简单直接。不推荐更新完数据库马上更新缓存因为并发情况下的覆盖顺序很难控制。6.3 从源码复现中学会的几件事最后说几句掏心窝的话。我当初也买过不少源码、看过不少教程最深的体会是拿到源码不是终点能把它的表结构、接口设计、前端状态管理讲明白才是真正的收获。如果自己跑代码遇到困难我建议按这个顺序排查先看数据库表有没有建全再看后端控制台有没有SQL报错最后看前端Network面板里请求有没有发出、响应是什么。大部分“跑不起来”的案例都不是代码问题而是环境配置和版本不匹配。SpringBoot的报错信息其实写得很清楚别看到红字就慌从第一条Exception开始往上翻很快就能定位。另外做项目的时候一定要养成写注释的习惯。这个美食推荐商城的代码量不算大但一个学期之后再回来看都可能记不清当初的联动逻辑更别说过两个月再看。我每完成一个接口就会在方法上写清楚参数、返回值、注意事项这不仅是给未来的自己看也是毕业答辩时最直观的素材。这个项目做完之后你不仅能回答“SpringBoot怎么整合MyBatis”“Vue怎么做登录状态保持”这类问题还能把一条完整的数据流——从用户注册到下单再到评价——讲成一个流畅的故事。这也是我做这套系统最大的收获。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑