前后端分离的美食推荐商城,说实话我已经帮人重构过好几版了。SpringBoot 3.x + Vue3 + MyBatis + MySQL这套组合,在当下Java Web生态里基本属于“标准答案”,毕设用它、小团队内部项目用它、甚至部分外包项目的首选也是它。但标准答案不代表没有坑:跨域、鉴权、字段映射、SQL性能、打包部署,任何一个环节出问题都够你排查半天。这篇文章我会把从零搭建这个项目的完整思路、关键实现和踩坑记录都摊开讲清楚,包含用户端点餐、管理员后台、推荐算法三个核心模块,适合正在做JavaWeb毕设或者想快速上手SpringBoot+Vue3全栈开发的读者参考。
1. 项目整体设计与技术选型思路
1.1 为什么选SpringBoot+Vue3+MyBatis这套组合
先给结论:这套组合最大的优势不是某个单点技术最强,而是生态最顺、学习曲线最平滑、找参考案例最容易。
先看后端。SpringBoot把Spring家族的配置自动化做到了极致,内嵌Tomcat,打一个jar包就能跑,不像传统SSH项目要装一堆XML配置、手动部署外部容器。SpringBoot 3.x基于Java 17,接口开发走RESTful风格,配合注解效率非常高。我常跟人打比方:传统Spring写一个接口要准备三件套(配置文件、实现类、XML装配),SpringBoot就是“写一个Controller注解类,启动就能调”,省掉的全是重复劳动。
再看ORM层。MyBatis和JPA/Hibernate的争论一直没停过,我的看法是:像美食推荐商城这种业务逻辑复杂、SQL定制化程度高的项目,MyBatis的灵活性优势非常明显。比如推荐列表要根据用户标签、菜品评分、销量、时间衰减四个维度组合排序,这种SQL用JPA写要堆一大堆Criteria,用MyBatis直接写原生SQL,一个XML文件就搞定,性能还可控。所以项目毫不犹豫选了MyBatis,配合PageHelper做分页。
前端选Vue3是趋势问题。Vue2虽然还有大量存量项目,但Vue3 + Vite + Composition API已经是新项目的默认起点,构建速度快、组合式逻辑复用方便。这个项目用的是JavaScript版本,没引入TypeScript,原因是毕设或小团队项目不需要那么重的类型约束,JS足够应付,开发效率更高。
1.2 前后端分离架构与模块划分
架构上我把项目拆成了三个独立的Maven模块加一个Vue3前端工程:
text复制food-recommendation
├── food-common # 公共模块:统一返回体、异常、工具类
├── food-server # 后端服务:Controller/Service/Mapper
├── food-admin # 前端管理员端(Vue3工程,可独立部署)
└── food-client # 前端用户端(Vue3工程,可独立部署)
这样拆的好处是职责清晰:common统一放Result响应体、JwtUtil、常量类,避免server模块和前端工程耦合;admin和client虽然是两个前端工程,但共用一套后端接口,只是路由和页面权限不同。有些同学会问为什么不拆成微服务,我只能说:单机就能扛住的业务体量拆微服务纯属给自己找麻烦,模块化已经是这个项目的最优解。
后端接口按资源路径划分:
/api/user/**:用户注册登录、个人信息、收藏列表/api/food/**:菜品列表、分类、详情、推荐/api/order/**:下单、订单列表、取消/api/admin/**:菜品管理、分类管理、用户管理、数据统计
其中/api/user/**和/api/order/**需要登录后才能访问,/api/admin/**需要管理员角色。这个权限控制通过SpringSecurity + JWT实现,后面会详细讲。
前端Vue3工程的路由设计用了两级结构:一级路由是布局组件(Layout),二级路由是具体页面。管理员端有Dashboard、菜品管理、分类管理、订单管理、用户管理五个模块;用户端有首页、分类、购物车、订单、个人中心五个菜单。路由守卫负责跳转判断,没有token一律先走登录页。
这里特别强调一个细节:前后端分离项目的“分离”不只是代码分离,更是开发模式分离。后端专注接口,前端专注页面,两边只对着接口文档联调。这就引出一个核心约定——接口返回结构必须统一。项目里所有接口都返回同一个格式:
json复制{
"code": 200,
"message": "success",
"data": {}
}
这个统一返回体在food-common里定义,叫Result。只要接口文档约定好,前端就能写一个通用的请求拦截器,统一处理code非200的情况,不用每个页面都单独写错误提示。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与后端核心实现
2.1 MySQL表结构设计与美食推荐逻辑
数据库设计是业务系统的地基。美食推荐商城的核心表我设计了8张,这里重点说四张骨架级的表:
sql复制-- 用户表
CREATE TABLE `user` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL UNIQUE,
`password` VARCHAR(100) NOT NULL,
`nickname` VARCHAR(50),
`avatar` VARCHAR(255),
`role` TINYINT DEFAULT 0 COMMENT '0-普通用户 1-管理员',
`points` INT DEFAULT 0 COMMENT '积分',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制-- 菜品表
CREATE TABLE `food` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`category_id` BIGINT NOT NULL,
`name` VARCHAR(100) NOT NULL,
`description` TEXT,
`price` DECIMAL(10,2) NOT NULL,
`image` VARCHAR(255),
`tags` VARCHAR(255) COMMENT '逗号分隔的标签',
`sales_count` INT DEFAULT 0 COMMENT '销量',
`rating` DECIMAL(2,1) DEFAULT 5.0 COMMENT '评分',
`status` TINYINT DEFAULT 1 COMMENT '1-上架 0-下架',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制-- 用户行为表(用于推荐)
CREATE TABLE `user_behavior` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`food_id` BIGINT NOT NULL,
`behavior_type` TINYINT NOT NULL COMMENT '1-浏览 2-收藏 3-下单 4-评价',
`weight` INT DEFAULT 1,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制-- 订单表
CREATE TABLE `order` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`order_no` VARCHAR(32) NOT NULL,
`user_id` BIGINT NOT NULL,
`food_id` BIGINT NOT NULL,
`quantity` INT DEFAULT 1,
`total_price` DECIMAL(10,2),
`status` TINYINT DEFAULT 0 COMMENT '0-待支付 1-已完成 2-已取消',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
推荐逻辑在设计时我做了一个务实取舍。学校项目、小团队产品,不可能上协同过滤或深度学习那一套,所以用的是“行为加权 + 标签匹配”的轻量方案。整体逻辑分三步:
- 用户对某个菜品产生行为(浏览/收藏/下单/评价),在
user_behavior表里记录一条带权重的行为; - 后台查询当前用户所有行为对应的菜品标签,按权重累加,得到用户的标签偏好向量;
- 推荐时取偏好分值最高的前3个标签,在这些标签对应的菜品中,按“评分×0.4 + 销量权重×0.4 + 时间衰减×0.2”排序,取前N条。
这个方案不复杂但实测效果不错。冷启动阶段按全局销量和评分推荐热门菜品,等用户行为积累到10条以上就开始走个性化推荐。一句话总结:推荐系统的核心不是算法多高级,而是行为采集是否到位。
2.2 SpringBoot接口开发与MyBatis实战要点
后端工程搭建我强烈建议用Spring Initializr生成基础骨架,勾选Spring Web、MyBatis Framework、MySQL Driver、Validation和Lombok,版本选SpringBoot 3.2.x。这里有几个配置细节要特别注意。
第一,application.yml里的MyBatis配置。 很多新手在这踩坑:MyBatis下划线字段映射不到驼峰属性。必须加上:
yaml复制mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.food.entity
configuration:
map-underscore-to-camel-case: true
map-underscore-to-camel-case: true这一行是灵魂。MySQL里字段通常叫create_time、category_id,Java实体类属性是createTime、categoryId,没有这行配置,查出来的值永远为null,关键是不报错,排查起来非常恼火。
第二,数据源连接串的时区设置。 MySQL 8.x的连接串必须加时区参数,否则会报The server time zone value is unrecognized错误。正确写法是:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/food_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
username: root
password: yourpassword
driver-class-name: com.mysql.cj.jdbc.Driver
第三,@MapperScan扫描包路径。 在启动类上加上@MapperScan("com.food.mapper"),否则MyBatis找不到Mapper接口。有人喜欢在每个Mapper接口上写@Mapper注解,效果一样,但我建议统一用@MapperScan,更清爽、不容易漏。
MyBatis的核心玩法在XML里。以推荐列表为例,需要一个动态SQL来处理标签匹配和排序字段拼接:
xml复制<select id="selectRecommendFoods" resultType="com.food.entity.Food">
SELECT f.* FROM food f
WHERE f.status = 1
<if test="tags != null and tags.size() > 0">
AND (
<foreach collection="tags" item="tag" separator="OR">
FIND_IN_SET(#{tag}, f.tags)
</foreach>
)
</if>
ORDER BY
<choose>
<when test="sortType == 'rating'">f.rating DESC</when>
<when test="sortType == 'sales'">f.sales_count DESC</when>
<otherwise>f.create_time DESC</otherwise>
</choose>
LIMIT #{limit}
</select>
这个XML用到了三个MyBatis标签:if做条件判断、foreach遍历标签集合、choose做多分支选择。很多同学背了一堆MyBatis面试题,却不知道实际项目里动态SQL几乎天天都在写。建议花半小时把if、where、foreach、choose、set这5个标签练熟,基本能覆盖90%的业务SQL场景。
3. Vue3前端开发与页面交互
3.1 前端工程化搭建与路由权限设计
前端采用Vite + Vue3 + Pinia + Vue Router这套组合。脚手架推荐用的是Vue3官方升级后的create-vue:
bash复制npm create vue@latest food-client
选择时勾选Vue Router、Pinia、ESLint,其他看需要。这里提醒一下,很多教程还在教vue create(对应Vue CLI2),那是老黄历了。Vite构建速度快到让人上瘾,开发期热更新几乎毫秒级,体验完全不同。
装依赖:
bash复制npm install
npm install axios element-plus
UI组件库选了Element Plus,它是Vue3的官方配套组件库,表单、表格、弹窗、消息提示都有现成的,做后台管理类页面效率极高。用户端页面(首页、菜品列表)可以自己写CSS,但后台管理页直接上Element Plus的表格和表单,一天就能搭完。
路由权限设计是前端工程很重要的部分,用了路由守卫配合Pinia做登录态判断:
javascript复制// router/index.js
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next({ path: '/login', query: { redirect: to.fullPath } })
} else {
next()
}
})
很多新手的误区是觉得后端做权限控制就够了,前端路由守卫可有可无。实际上前端守卫的意义是防止用户直接输URL跳到未登录页面,产生一堆401报错,属于“减少无用请求、提升体验”的手段。真正的安全防线还是后端接口校验,这个定位要搞清楚。
Axios请求封装额外做了两件事:请求拦截器自动带token,响应拦截器统一处理HTTP 401和业务码非200的情况:
javascript复制// utils/request.js
service.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
service.interceptors.response.use(res => {
const code = res.data.code
if (code === 200) return res.data
if (code === 401) {
localStorage.removeItem('token')
router.push('/login')
}
ElMessage.error(res.data.message || '请求失败')
return Promise.reject(new Error(res.data.message))
})
3.2 商城核心页面实现与接口联调
前端核心页面是首页、菜品详情、购物车、订单结算和个人中心。以首页为例,页面结构是顶部搜索栏 + 分类导航 + 推荐菜品瀑布流,用Vue3的<script setup>语法:
vue复制<script setup>
import { ref, onMounted } from 'vue'
import { getRecommendFoods } from '@/api/food'
const foodList = ref([])
const loading = ref(false)
const loadData = async () => {
loading.value = true
try {
const res = await getRecommendFoods({ limit: 20 })
foodList.value = res.data
} finally {
loading.value = false
}
}
onMounted(loadData)
</script>
这里有个新手常踩的坑:把foodList.value = res.data写成了foodList = res.data,页面永远不更新。原因在于Vue3的ref返回的是响应式对象,修改时需要通过.value赋值,但模板里会自动解包,所以模板中要写foodList而不是foodList.value。我记这个规则的技巧是:在JavaScript代码里操作ref都要带.value,在模板里永远不带。
菜品详情页还有一个亮点功能,下单后会自动调用后端“行为上报”接口,把这次下单记录写入user_behavior表,用于后续推荐更新:
javascript复制await reportBehavior({ foodId: detail.value.id, type: 3 })
这个上报是异步的,不需要阻塞用户操作,但要做好失败静默处理,不能因为上报接口报错影响用户下单体验。
前后端联调时,最核心的是在vite.config.js里配置开发代理,把前端请求转发到后端8080端口:
javascript复制// vite.config.js
export default defineConfig({
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
很多人问开发时为什么不用axios直接请求后端地址。两个原因:一是跨域,浏览器同源策略会拦请求;二是配置代理后API地址写/api/xxx就行,不用写死IP和端口,后续部署通过Nginx反向代理指向不同环境也方便。开发代理是前后端分离项目绕不开的基础操作。
4. 推荐模块与搜索功能的实现细节
4.1 基于标签与行为数据的轻量推荐
推荐模块是项目名字里“推荐”二字的重点,算法虽然轻量,但设计思路值得展开讲。
整体流程分三步走。
第一步是收集行为。用户浏览、收藏、下单、评价菜品时,分别记录到user_behavior表。行为类型对应权重不同:浏览1分、收藏2分、下单3分、评价4分。权重设计的逻辑很简单:一个下单用户对菜品的偏好肯定比随便看一眼的用户强得多。
第二步是聚合标签偏好。后端的RecommendService查询当前用户最近30天的行为记录,关联出每条行为对应的菜品标签,按权重累加。比如用户A最近的记录里,川菜标签累计了22分,粤菜8分,甜品5分,那他的偏好画像就是川菜为主。
第三步是生成推荐列表。根据偏好标签,从food表里选出带这些标签的菜品,按评分和销量的综合分排序:
java复制// 推荐排序:评分占40%,销量占40%,时间衰减占20%
double score = food.getRating() * 0.4
+ Math.log10(food.getSalesCount() + 1) * 0.4
+ food.getCreateTime().getTime() / 1e12 * 0.2;
用Math.log10对销量做平滑处理,避免一款销量爆表的菜品碾压所有新品。这个细节看似微小,但实际推荐效果差距很大——不用log的直出排序会让推荐列表永远被几款热门食品霸占,新上架的优质菜品永远排不上来。
冷启动问题前面提过了,再细化一下:新用户没有任何行为数据,推荐模块走defaultRecommend(),直接按全局销量降序出前20条热销菜品。对个人项目来说这套足够,不用纠结算法论文里的“用户冷启动/物品冷启动”。
4.2 搜索功能的SQL设计与性能优化
搜索功能看似简单,但做到能用且快要考虑几个点。项目用了模糊匹配+分类筛选+分页三段式设计:
xml复制<select id="searchFoods" resultType="com.food.entity.Food">
SELECT * FROM food
<where>
<if test="keyword != null and keyword != ''">
AND (
name LIKE CONCAT('%', #{keyword}, '%')
OR description LIKE CONCAT('%', #{keyword}, '%')
)
</if>
<if test="categoryId != null">
AND category_id = #{categoryId}
</if>
AND status = 1
</where>
ORDER BY
<choose>
<when test="orderBy == 'price_asc'">price ASC</when>
<when test="orderBy == 'price_desc'">price DESC</when>
<when test="orderBy == 'rating'">rating DESC</when>
<else>sales_count DESC</else>
</choose>
</select>
这里有一个容易被忽略的性能知识点:LIKE '%关键词%'这种写法在数据量大时走不了索引,会触发全表扫描。美食商城的菜品数据一般只有几百上千条,MySQL全表扫描耗时完全可以忽略。但数据量到了几十万条,就得考虑FULLTEXT全文索引或者引入Elasticsearch。建议记一个边界:5万条以内用LIKE没问题,超过再上高级方案。
分页采用PageHelper插件,在Service层调用:
java复制PageHelper.startPage(pageNum, pageSize);
List<Food> list = foodMapper.searchFoods(param);
PageInfo<Food> pageInfo = new PageInfo<>(list);
这个插件是MyBatis生态里的元老级分页方案,原理是拦截Executor,在原有SQL后面自动拼接LIMIT。关键注意点:PageHelper.startPage必须紧跟第一条需要分页的Mapper查询,中间不能插入其他SQL查询,否则分页会作用到错误的查询上。
5. 常见问题与排查技巧实录
5.1 前后端联调跨域问题的三种解法
跨域问题在前后端分离项目里基本100%会遇到,三种解法按推荐优先级整理,可以根据环境自己权衡。
方案一:开发代理(前端解决)。 上面写了vite.config.js配proxy,适用于开发环境。这个方案最干净,前端代码不感知跨域,因为代理的请求是服务端到服务端,不存在浏览器同源限制。
方案二:后端CORS配置(最快见效)。 SpringBoot加一个配置类:
java复制@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
这个方案的问题是allowCredentials(true)配合addAllowedOriginPattern("*")存在安全风险,生产环境要改成具体的域名白名单。新人不上生产环境的话,方案二最快。
方案三:Nginx反向代理(生产推荐)。 部署时用Nginx把/api转发到SpringBoot,静态资源指向Vue打包产物,既解决跨域又实现动静分离,还能顺带解决前端history路由刷新404问题。Nginx核心配置:
nginx复制location /api/ {
proxy_pass http://127.0.0.1:8080/;
}
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
try_files ... /index.html这行是Vue路由history模式正常刷新页面的关键,不写的话刷新二级页面会404。
5.2 MyBatis 字段映射、Mapper扫描与SQL报错排查
把实际开发里高频遇到的三类MyBatis报错整理成一张排查表:
| 报错现象 | 根本原因 | 解决办法 |
|---|---|---|
Invalid bound statement (not found) |
Mapper接口和XML没有绑定 | 检查mapper-locations路径、XML的namespace是否等于接口全限定名 |
| 查询结果全是null | 下划线字段没映射到驼峰属性 | 配置map-underscore-to-camel-case: true |
java.sql.SQLSyntaxErrorException |
XML里有保留字或SQL拼接漏空格 | order是MySQL保留字要加反引号;检查if拼接处是否有空格 |
Invalid bound statement这个报错特别具有迷惑性,它报在Service调用Mapper那一行,很多人以为是接口没实现。实际排查顺序:一看target/classes/mapper目录下有没有XML文件;二看application.yml的mapper-locations路径;三看XML的namespace。Maven构建时XML不打包也是一个坑,默认Maven只打包src/main/resources下的文件,如果把XML放在src/main/java目录下,需要在pom.xml配置<resources>标签把XML纳入打包范围。我的习惯是XML一律放在resources/mapper目录,从根源规避。
5.3 MySQL连接、时区与中文乱码问题
数据库连接这一块除了时区问题,还有两个常见坑。一个是Public Key Retrieval is not allowed,出现在MySQL 8.0连接时,可以在连接串加allowPublicKeyRetrieval=true解决,不过要注意这个参数只在非加密连接下有意义。另一个是Communications link failure,多半是连接池的max-lifetime超过了MySQL的空闲超时时间,把连接池的max-lifetime设置成比MySQL的wait_timeout小即可。
中文乱码排查有一个容易忽略的点:即使数据库连接串加了characterEncoding=utf8,如果建表时没指定CHARSET=utf8mb4,一样有乱码风险。utf8mb4和utf8的区别在于前者能存储emoji等四字节字符,MySQL 5.5.3之后强烈建议统一用utf8mb4,创建数据库时就把字符集定死:
sql复制CREATE DATABASE food_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
5.4 打包部署时的三大注意事项
最后说交付环节最容易出问题的打包部署。
后端打包直接Maven的package命令,在food-server模块执行:
bash复制mvn clean package -DskipTests
产出target/food-server-1.0.0.jar后用java -jar运行。三个细节提醒:一是SpringBoot打包必须配置spring-boot-maven-plugin且在food-server模块的pom里,否则打出的jar不带主类;二是多模块项目里common模块被依赖,需要先mvn install把common装进本地仓库;三是运行环境的JDK版本必须和编译版本一致,SpringBoot 3.x强制要求Java 17+,装在Java 8服务器上直接失败,报UnsupportedClassVersionError,这个踩坑概率非常高。
前端打包在food-client目录执行npm run build,产物在dist目录。部署到Nginx时注意两点:一是内容要放在nginx的root目录而不是整个dist文件夹,否则访问路径会多一层;二是Vue Router用history模式的话,nginx要配try_files回退到index.html。
我实际部署时遇到过一件很糟心的事:前端打包后接口全通,但刷新页面就404,排查半天发现是把try_files写成了try_files $uri =404,等于没配置。正确写法是try_files $uri $uri/ /index.html;,顺序不能反,/index.html是兜底,不是优先匹配。
数据库部署建议用mysqldump导出SQL文件,然后在生产库执行:
bash复制mysqldump -uroot -p food_db > food_db_backup.sql
mysql -uroot -p -e "CREATE DATABASE food_db DEFAULT CHARACTER SET utf8mb4"
mysql -uroot -p food_db < food_db_backup.sql
注意导出的SQL文件里如果没有CREATE DATABASE语句,需要手动先建库,否则导入时报No database selected。
另外,这套系统里最值得琢磨的不是CRUD,而是数据流动的整体链路。带新人做类似项目时,我最常叮嘱的一句话是:先确认数据流动的每一环(前端请求→路由映射→Service→Mapper→SQL→数据库),再动手改代码。报错不可怕,可怕的是没有章法地乱试。
最后再分享一个小技巧:做完推荐模块后,我手动往数据库塞了500条模拟行为数据(用存储过程批量生成),用来验证推荐列表排序是否符合预期。这一步很多同学会忽略,但正是它帮我发现了一个bug——时间衰减因子把早期行为权重压得太低,导致推荐结果几乎只认最近两天的行为。后来把时间衰减从线性改成分段式(7天内不衰减,超过7天按天衰减),结果立刻稳定了。以后做推荐系统,建议也先用模拟数据把逻辑跑通再上线,别等真实用户一进来就暴露问题,那可就影响口碑了。
