资讯动态

SpringBoot+Vue+MyBatis+MySQL:前后端分离美食网站系统实战

发布时间:2026/10/13 8:40:19 来源:尧图企业网站定制
1. 这个“BS美食网站系统”到底在做什么前后端分离的BS美食网站系统凡是拿 SpringBootVueMyBatisMySQL 这套组合来做的本质上都在解决同一件事用户能浏览菜品、按分类筛选、搜索、看详情、写评论管理员能通过后台维护分类和菜品、处理评论、管理用户信息。项目标题看起来像课程设计但它把Web开发最常用的一条链路完整串起来了前端展示、后端接口、数据库持久化、登录鉴权、打包上服务器。这也是它最适合当作练手项目的原因。所谓BS就是Browser/Server浏览器/服务器模式。用户不用装客户端打开浏览器输入地址就行管理端也不用单独安装后台页面一样走浏览器访问。前后端分离之后前端工程和后端工程可以独立启动、独立部署后端只提供JSON接口前端只管页面渲染。前期开发可以一台机器起两个服务线上再用Nginx做反向代理把两个服务合成一个对外地址。如果你是后端新手重点看接口设计和MyBatis的SQL写法如果你是前端新手重点看Vue Router和Axios封装如果你已经能写增删改查但没完整上线过可以直接跳到第6节的部署流程。这个项目叫美食网站但它真正锻炼的是“把一个业务拆成表、接口、页面”的能力换一个电商、内容社区主题套路完全一样。2. 为什么偏偏是这一套组合2.1 SpringBoot解决的是“搭框架”的重复劳动早些年做Java Web要配web.xml、Spring配置、数据源、事务管理器项目还没写业务配置文件先占了一大半。SpringBoot把这些都收拢成“约定大于配置”一个启动类加几个注解内嵌Tomcat一跑服务就能出接口。美食网站这种中小型系统用SpringBoot最直观的好处是你不用再关心容器怎么装依赖怎么管理写Controller、Service、Mapper三层就行。用生活里的类比传统配置型开发像自己从买菜、切菜、生火开始做饭SpringBoot像直接租了一个后厨灶台、锅碗、基础调料都备好了你只需要炒自己那一盘菜。所以现在个人项目、企业内部小系统、毕设题目几乎默认首选SpringBoot。2.2 MyBatis比JPA更适合“动态条件查询”Spring Data JPA也很好但遇到多条件搜索、动态排序、连表查询时要么写JPQL要么在Service里拼Query总感觉绕。MyBatis的XML文件可以直接写原生SQLespecially像“菜名模糊搜索 分类筛选 销量排序”这种需求一个where加几个if就解决了。MyBatis还有个好处是SQL执行过程透明出问题可以直接拿日志里的SQL去数据库里跑一遍排查成本很低。MySQL则是这套系统里的“仓库”。菜品、分类、评论都是结构化数据用关系型数据库管理最稳妥个人服务器扛一个中小型美食网站的读写量完全没问题。2.3 Vue负责把页面交互变得清爽Vue的核心是组件化和数据驱动。页面上的菜品卡片、分页器、后台表格都可以拆成独立组件用户点击搜索、切换分页、提交评论时只需要修改数据视图自动跟着变不需要手动操作DOM。项目里如果要做后台管理界面再配合现成的UI组件库开发效率非常可观。这里多说一句如果你现在从零开始做新项目可以用Vue3 Vite性能更好但很多课程项目、培训项目还在用Vue2代码写起来差异不大。下面内容按Vue2思路讲原理对Vue3同样成立。2.4 这套组合的清晰分工SpringBoot提供RESTful接口处理业务逻辑、登录鉴权、参数校验。Vue负责用户端和管理端的页面展示通过Axios调接口。MyBatis负责数据库操作的SQL映射复杂查询写在XML里。MySQL存储分类、菜品、评论、用户等核心数据。四者各司其职边界清楚。出了问题也容易定位页面渲染不对找前端接口报错找后端数据不对先查SQL磁盘不够再看MySQL。对一个“系统”而言这种分工本身就是一种可维护性。3. 数据表设计和功能模块拆分3.1 前台和后台的边界要提前划清美食网站的核心功能可以拆成两个端用户端前台首页展示推荐菜品、分类导航。菜品列表支持按分类筛选、按关键词搜索、分页。菜品详情展示图片、价格、描述、评分和评论列表。用户注册登录后可以发表评论。热门菜品/新品推荐区域。管理端后台登录后才能进入。分类管理新增、编辑、删除分类。菜品管理新增、编辑、上下架、删除菜品。评论管理查看评论删除违规评论。用户管理查看注册用户列表停用异常账号。前后端分离之后所有功能都变成“前端页面 后端接口 数据库操作”三件事搞清功能边界才知道哪些接口要给用户端用哪些接口需要管理员权限。3.2 核心表结构设计与SQL参考我不建议一开始就把表建得特别复杂先围绕业务对象建核心表。美食网站最核心的对象是用户、分类、菜品、评论。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL, nickname varchar(50) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, role tinyint DEFAULT 1 COMMENT 1用户 2管理员, status tinyint DEFAULT 1 COMMENT 1正常 0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE category ( id bigint NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL, sort int DEFAULT 0 COMMENT 排序值越小越靠前, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品分类表; CREATE TABLE dish ( id bigint NOT NULL AUTO_INCREMENT, category_id bigint NOT NULL, name varchar(100) NOT NULL, price decimal(10,2) NOT NULL COMMENT 价格/元, image varchar(255) DEFAULT NULL COMMENT 图片相对路径, description text COMMENT 菜品描述, status tinyint DEFAULT 1 COMMENT 1上架 0下架, sales int DEFAULT 0 COMMENT 销量, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id), CONSTRAINT fk_dish_category FOREIGN KEY (category_id) REFERENCES category (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品表; CREATE TABLE comment ( id bigint NOT NULL AUTO_INCREMENT, dish_id bigint NOT NULL, user_id bigint NOT NULL, content varchar(500) NOT NULL, score tinyint DEFAULT 5 COMMENT 评分1-5, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_dish (dish_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品评论表;建表时有几个关键点要特别留意。一是字符集评论里可能有emoji表情也有生僻字用utf8mb4才存得完整用旧的utf8会出现乱码或报错。二是引擎用InnoDB支持事务和外键。三是菜品表和分类表之间保留外键约束分类不能随便删如果分类下还有菜品删除会被数据库挡住。四是核心查询字段要建索引比如评论区用dish_id查询不加索引数据量一大就会走全表扫描。3.3 表之间怎么配合菜品表通过category_id关联分类表前端展示分类导航后根据分类ID查菜品。评论表通过dish_id关联菜品详情页先把菜品查出来再查这个菜品的评论列表。用户注册登录后评论时把user_id带上后台才能追溯是谁发的评论。如果以后要扩展购物车和订单再加cart、order、order_item三张表就行现有表不需要推翻重来。这也是数据模型设计要“先核心、后扩展”的原因。4. 后端接口和关键代码实现4.1 工程结构长什么样后端用一个标准Maven多模块或单模块工程都行。单模块已经够用分包按职责来food-server ├── src/main/java/com/food/demo │ ├── FoodApplication.java │ ├── config // 跨域、WebMvc配置、拦截器注册 │ ├── controller // 接口层 │ ├── service // 业务逻辑 │ ├── mapper // MyBatis Mapper接口 │ ├── entity // 实体类 │ ├── common // 统一返回结果、异常处理 │ └── interceptor // JWT登录拦截器 ├── src/main/resources │ ├── mapper // MyBatis XML文件 │ └── application.yml所有接口统一以/api开头好处是方便前端代理也方便Nginx做路径转发。比如用户端地址是/api/dish/page管理端接口是/api/admin/dish/save一眼就能区分权限范围。4.2 接口设计参考功能请求方式接口路径说明用户登录POST/api/user/login返回JWT Token用户注册POST/api/user/register注册后默认用户角色分类列表GET/api/category/list前台展示所有分类菜品分页列表GET/api/dish/page支持keyword、categoryId、pageNum、pageSize菜品详情GET/api/dish/{id}返回菜品信息评论列表发表评论POST/api/comment/add需要登录状态菜品新增POST/api/admin/dish/save管理员权限菜品修改PUT/api/admin/dish/update管理员权限菜品删除DELETE/api/admin/dish/{id}管理员权限分页接口是美食系统最常写的接口建议把参数固定成pageNum和pageSize前端传过来后端再算offset。如果引入PageHelper插件也可以那句PageHelper.startPage(pageNum, pageSize)很简洁但手动limit能更直观看到SQL是怎么执行的。4.3 分页和动态条件查询的MyBatis实现菜品列表最难的不是分页而是“搜索关键词、分类筛选、排序方式”同时存在且优先级不一样。这种场景用MyBatis XML里的动态SQL处理最合适。select idpageDish resultTypecom.food.demo.entity.Dish SELECT d.id, d.name, d.price, d.image, d.description, d.sales, c.name AS category_name FROM dish d LEFT JOIN category c ON d.category_id c.id where if testkeyword ! null and keyword ! AND d.name LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND d.category_id #{categoryId} /if /where ORDER BY choose when testorderBy salesd.sales DESC/when otherwised.create_time DESC/otherwise /choose LIMIT #{offset}, #{pageSize} /selectwhere会自动处理掉第一个条件前面的AND避免手写WHERE 11这种难看查询。排序用choose做白名单判断不要直接把前端传的orderBy拼接进SQL否则可能被恶意传参造成SQL注入。Service里再统计总数public PageResultDish page(DishQuery query) { int offset (query.getPageNum() - 1) * query.getPageSize(); ListDish list dishMapper.pageDish( query.getKeyword(), query.getCategoryId(), offset, query.getPageSize(), query.getOrderBy()); long total dishMapper.countDish(query.getKeyword(), query.getCategoryId()); return new PageResult(list, total); }4.4 登录鉴权为什么用JWT而不是Session传统单体项目用Session天然适合同域名、同服务。但前后端分离之后前端可能部署在静态服务器后端在另一个端口Session的Cookie跨域处理起来很麻烦。JWT的登录逻辑更简单登录成功后端把用户ID、角色、过期时间加密成一个Token返回前端存到本地后续每个请求在Header里带上Authorization: Bearer token后端拦截器解析Token解析成功就放行。实现拦截器时最容易被忽略的是放行规则public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 跨域预检请求直接放行 if (OPTIONS.equals(request.getMethod())) { return true; } // 登录、注册、菜品列表、菜品详情、评论列表不需要登录 String uri request.getRequestURI(); if (uri.endsWith(/user/login) || uri.endsWith(/user/register) || uri.startsWith(/api/dish) || uri.startsWith(/api/category)) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { try { Claims claims Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token.substring(7)) .getBody(); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { // 解析失败走统一异常 } } response.setContentType(application/json;charsetUTF-8); response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; }JWT解决了分布式部署时的会话共享问题但它的缺陷是“无法主动失效”。如果用户要退出登录不能靠后端删除Token只能让前端删除本地Token或者额外维护一个黑名单。对美食网站这种场景来说完全够用不过你要知道这个边界。4.5 统一返回结构和全局异常接口层不要直接返回null或者裸的Map定义一个统一的ResultTpublic class ResultT { private Integer code; private String msg; private T data; }前端Axios拦截器可以统一判断code不需要每个页面单独写错误弹窗。全局异常用RestControllerAdvice处理业务异常返回code500参数校验失败返回code400未登录返回code401。这样前后端协作时只需要认一套状态码省掉大量“接口到底返回了什么”的沟通成本。5. 前端页面和接口打通的关键细节5.1 Vue项目的路由划分前端工程结构可以这样安排food-web ├── src │ ├── api // 按模块封装的请求 │ ├── router // 路由配置 │ ├── views │ │ ├── site // 用户端页面 │ │ └── admin // 管理端页面 │ ├── components // 公共组件 │ ├── utils/request.js // Axios实例 │ ├── App.vue │ └── main.js用户端路由用/开头后台路由统一加/admin前缀用嵌套路由和子组件布局。后台页面不能直接通过URL访问要在路由守卫里判断本地Token和用户角色router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path.startsWith(/admin) !token) { next(/login) } else { next() } })这里有个容易被忽略的细节只判断“有没有Token”是不够的服务端接口还要继续校验Token是否有效、角色是否有权限。前端路由守卫只是优化体验不能当作安全边界。5.2 Axios封装和跨域代理后端接口地址如果写死在每个页面后期改域名、加Token都很痛苦。统一封装逻辑如下import axios from axios 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 }) request.interceptors.response.use( response { const res response.data if (res.code 401) { router.push(/login) } return res.data }, error { return Promise.reject(error) } ) export default request开发环境下前端服务跑在8080端口后端跑在9090端口直接请求会跨域。最简单的办法是让Vue开发服务器代理// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:9090, changeOrigin: true } } } }这样前端页面里写的/api/dish/page开发时会被代理到http://localhost:9090/api/dish/page线上再由Nginx做同样的转发。整个链路里前端代码几乎不用改。5.3 菜品列表页的关键交互菜品列表页不是简单调一次接口就完了它包含搜索框、分类Tab、分页器、加载状态。建议把“查询条件”统一放在一个对象里data() { return { query: { keyword: , categoryId: null, pageNum: 1, pageSize: 12 }, list: [], total: 0, loading: false } }搜索框输入时加一个防抖避免每敲一个字就发一次请求watch: { query.keyword() { clearTimeout(this.timer) this.timer setTimeout(() { this.query.pageNum 1 this.fetchList() }, 300) } }分页切换时只修改query.pageNum然后重新调用fetchList()。这个过程看起来简单但很多人的页面越写越乱是因为查询条件、分页条件、请求函数没有集中管理。5.4 后台管理页的表格和图片上传管理端用现成UI组件库搭表格非常快分类管理就是一个表格加新增编辑弹窗菜品管理除了表格还有图片上传和富文本描述。菜品图片建议用两种方式本地存储上传到后端指定的upload目录数据库保存相对路径比如/upload/dish/202501011230.png。对象存储传到云存储返回完整URL数据库存URL。不管哪种都不要把图片转成base64直接塞进数据库。base64会让数据表膨胀查询变慢也没必要。后台接口的权限要在后端卡死所有/api/admin/开头的接口拦截器里不仅解析Token还要确认role是管理员否则返回403。前端隐藏入口只是第一步真正的权限校验永远在后端。6. 从本地到服务器部署全流程6.1 环境和版本建议组件版本建议说明JDK8 或 11Spring Boot 2.x对应JDK83.x需要JDK17Maven3.6.3用于后端打包MySQL5.7 或 8.0实际使用8.0更常见Node14 或 16Vue2项目不建议太新的Node版本Nginx1.20做静态资源服务和反向代理6.2 数据库初始化和配置文件先在服务器上建库建表。SQL文件准备好后执行mysql -u root -p food_db.sqlapplication.yml是后端能不能启动的关键尤其注意数据库连接信息server: port: 9090 spring: datasource: url: jdbc:mysql://localhost:3306/food_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: trueserverTimezone必须加上否则MySQL 8会报时区错误。map-underscore-to-camel-case设为true后数据库字段create_time可以自动映射成实体类的createTime省掉大量手动映射。6.3 后端打包启动本地或服务器上执行mvn clean package -DskipTests打包后在target目录下会生成food-server.jar然后用nohup java -jar food-server.jar food.log 21 nohup和保证关闭终端后进程不退。日志重定向到food.log方便之后排查问题。6.4 前端打包npm install npm run build构建成功后dist目录就是纯静态资源。把dist里的内容上传到服务器目录例如/usr/share/nginx/food/。6.5 Nginx配置静态资源加反向代理前后端分离后Nginx承担两个职责给前端页面提供静态资源访问把/api开头的请求反向代理到后端服务。一个基础配置如下server { listen 80; server_name your_domain_or_ip; root /usr/share/nginx/food; index index.html; location /api/ { proxy_pass http://127.0.0.1:9090; 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 $uri $uri/ /index.html; } location /upload/ { alias /data/food/upload/; } }这里最关键的是try_files $uri $uri/ /index.html。Vue Router如果用History模式用户直接访问/dish/3或刷新后台页面时如果Nginx没有这行配置会返回404加上这行所有找不到的路径都会回到index.html再由前端路由接管。如果后端端口不想对外暴露还可以在云服务器安全组或防火墙里只放行80端口9090只允许本机访问这样更安全。6.6 上传目录和静态资源目录要分开部署时最常踩的坑就是图片上传。很多人把上传目录直接放在前端dist里面结果前端一重新打包上传的图片全没了。正确做法是把上传目录放到项目目录之外比如/data/food/upload/然后通过Nginx的/upload/映射去访问。后端在代码里把上传路径配置到/data/food/upload/数据库只存/upload/xxx.png这样前端打包、后端升级都不会误删图片。7. 常见问题与排查实录7.1 启动类问题的速查表现象常见原因处理方法后端启动报Access denied for user rootlocalhost数据库用户名或密码错误检查application.yml先在MySQL命令行手动试一下报Unknown database food_db没建库或库名不对执行建库语句CREATE DATABASE food_db ...报The server time zone value Öйú±ê׼ʱ¼ä数据库连接没加时区URL加上serverTimezoneAsia/Shanghai报Invalid bound statement (not found)Mapper接口和XML没匹配上检查接口方法名和XML的id一致看target里有没有XML前端请求接口报404接口路径或代理配置不对先直接访问后端接口确认可用再看代理前缀前端刷新页面报404Nginx没有处理History路由location / 加try_files图片上传成功但页面不显示Nginx没配置upload目录或目录权限不对检查location /upload和alias路径管理端接口返回403Token里没有管理员角色检查登录用户的role值管理接口单独校验权限7.2 一个真实的排查过程我遇到过最典型的案例是后端和前端本地都能跑一上服务器菜品列表能出数据但图片全是裂图。一开始以为是路径问题但数据库里存的路径明明有/upload/dish/xxx.png浏览器访问http://服务器IP/upload/dish/xxx.png却打不开。后来看Nginx错误日志发现访问/upload/路径时一直在找dist目录下的upload目录因为配置文件里location顺序不对/upload/规则被前面的location /吞掉了。调整后在location /api/和location /之间单独加上location /upload/并且用alias指向实际目录图片立刻恢复正常。定位这类问题我建议先做三步在浏览器里直接打开图片地址确认是前端路径问题还是后端文件不存在。看Nginx访问日志确认请求落到哪个location。看后端日志确认上传时是不是用了相对路径导致文件位置不对。不要一上来就改代码。先确定问题出在前端、后端、数据库还是服务器层面能省掉大量无用功。7.3 一些容易忽略的部署细节账号密码不要用root裸奔至少建一个业务专用账号只授权food_db库。数据库定期备份简单的方式是写个定时任务执行mysqldump。后端打包前先mvn clean避免旧class文件残留。npm install之前清掉旧的node_modules防止本地依赖和服务器不一致。前端打包后检查一下dist里有没有生成旧的index.html如果构建失败Nginx可能还在服务旧版本。8. 动手之前我还想多说几句这套系统真正值钱的地方不在“美食”两个字而在“SpringBootVueMyBatisMySQL”这条链路的完整度。做项目最容易陷入的误区是贪多一会想加Redis缓存一会想加消息队列一会想接第三方登录结果核心功能还没跑通先被一堆中间件拖垮了。我个人的习惯是先让一个最简单的“分类到菜品再到评论”的链路完整跑通再考虑优化。第一版能用第二版才去谈性能如果第一版就连登录、分页、上传、部署都没搞顺后面加再多东西都是空中楼阁。如果你准备拿这个项目练手或交付我建议你的节奏是先复刻数据表和接口把用户端页面跑通再把后台管理的增删改查补齐最后部署到服务器让朋友通过公网地址访问一次。只要这个过程走完你对前后端分离项目的理解会比看十遍教程都扎实。最后分享一个小技巧做前后端联调时接口的返回结构一定要先定死。code、msg、data三层结构虽然老套但就是好用。前端拿到res.code非0时统一弹错成功时直接取res.data这样每个页面的请求代码几乎一样排查问题也能少走弯路。这个项目我做了不止一版每次栽跟头几乎都在这些“约定”上而不是技术本身。

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

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

免费获取报价 →
↑