最近在整理一套 Java Web 美食信息推荐系统的完整源码,技术栈是 SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0,配套开发文档和数据库脚本都齐全。这套项目很适合作为毕业设计,也能直接改造成面试演示项目。我把它从环境搭建、数据库初始化到前后端联调完整跑了一遍,中间确实遇到了一些坑,今天把整个实现思路和操作细节整理出来,希望能帮你节省大量调试时间。
这个系统解决的核心问题并不复杂:用户打开网站后,不再需要一页页翻美食列表,而是系统根据用户的浏览记录、收藏行为、食材偏好、评分数据等,自动推送最可能感兴趣的美食信息。用户端有推荐首页、美食详情、收藏夹、我的评价;管理端有菜品管理、菜品类别管理、用户管理、推荐规则配置等。整体功能覆盖了完整的业务闭环,不是那种只有增删改查的“空壳项目”。
我先把这套项目最值得关注的技术点放在前面:SpringBoot2提供稳定的后端服务能力,Vue3 + Element Plus构建交互层,MyBatis-Plus极大减少SQL样板代码,MySQL8.0的窗口函数和JSON能力为推荐算法扩展留了空间。这套组合在2025年的中小型Web项目中依然非常能打,下面我会把每一层的实现细节都拆开讲。
1. 项目整体设计与技术选型思路
1.1 这个美食推荐系统到底做了什么
先明确项目边界,避免一上来就陷入复杂推荐算法。它并不是做基于深度学习的AI推荐,而是采用“用户行为 + 内容标签”的混合推荐策略。系统内部维护了两类数据:一类是美食本身的画像数据,包括菜名、分类、食材清单、口味标签、热量、价格、图片、描述;另一类是用户行为数据,包括浏览记录、收藏、点赞、评分、评论。推荐引擎每天定时或者用户点击刷新时,通过计算用户对各类标签的偏好权重,召回TopN条美食,再结合热度因子排序,最终推送给用户。
整个系统模块划分非常清晰:
- 用户模块:注册、登录、个人信息维护、密码加密
- 美食内容模块:菜品分类维护、菜品信息管理、图片上传、上下架
- 推荐模块:基于标签权重的召回、热门榜、新品榜、个性化推荐列表
- 交互模块:收藏、评分、评论、浏览记录
- 后台管理模块:用户管理、菜品审核、推荐参数配置、数据统计概览
为什么要把推荐模块独立出来?因为如果推荐逻辑和业务代码耦合在一起,后期想换算法、调整权重都要动核心业务代码。放在独立Service层,前端只需要调用一个接口,后端无论怎么改,返回的数据结构保持不变。
1.2 为什么选择 SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0
很多人纠结版本选择,我的观点是:在毕业设计和中小型项目中,稳定优先于新潮。SpringBoot2.x经过多年生产验证,生态文档最多,遇到问题随便搜都有解决方案;SpringBoot3虽然已经普及,但如果你用的公共组件版本不兼容,排查成本会陡增。Vue3同样如此,组合式API写起来比Vue2清爽得多,配合Vite构建速度极快,而且Element Plus组件库已经很成熟,不需要自己造轮子。
MyBatis-Plus是我个人非常推荐的数据访问层框架。它本质上是在MyBatis之上做增强,核心价值是:单表CRUD直接继承BaseMapper完成,不用写XML;分页插件内置;逻辑删除、自动填充、乐观锁全都是注解搞定。对于这种以业务逻辑为核心的系统,MyBatis-Plus可以把数据访问代码量压缩至少三分之二。但要注意,复杂多表查询依然需要手写SQL,MyBatis-Plus的Wrapper并不适合所有场景,所以我在推荐模块的统计查询中仍然保留了自定义XML。
MySQL8.0相比5.7有几个关键升级:默认字符集utf8mb4,支持窗口函数,支持公用表表达式(CTE),JSON操作更高效。这些特性在做推荐结果排序、用户行为序列分析时非常好用。比如计算“用户最近30天浏览的所有菜品类别分布”,用窗口函数一条SQL就能搞定。
选择这套组合还有一个现实原因:招聘市场上Java后端岗位的技术要求长期集中在SpringBoot、MyBatis、MySQL、Vue这些技能上。用它做项目,简历上的关键词密度高,面试官也不陌生,沟通成本低。
1.3 工程结构怎么拆分,分包规则是什么
后端我用标准的Maven多模块思想,但考虑到项目规模,没有拆成多模块,而是单模块按包分层:
com.food.recommend ├── common // 通用返回结果、异常处理、常量、工具类 ├── config // 全局配置(跨域、MyBatis-Plus、拦截器) ├── controller // 接口层 ├── service // 业务逻辑层(含推荐算法) ├── mapper // MyBatis-Plus接口和自定义XML ├── entity // 数据库实体类 ├── dto // 请求和响应对象 └── job // 定时任务(热门榜刷新、标签权重归一化)前端Vue3部分则按页面维度拆views,components里放公共组件,api目录单独管理所有请求接口。这样前后端都能做到“改动局部不影响全局”。我建议你在拿到源码后先看文件结构,不要直接跑起来,心里有数比快速启动重要得多。
2. 数据库设计与后端核心实现
2.1 表结构设计与关系梳理
数据库命名我统一使用小写加下划线:user、food、food_category、user_favorite、user_rating、food_comment、user_browse_history、recommend_config。具体字段设计上,有几个点特别值得注意:
food表核心字段:
CREATE TABLE food ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id BIGINT NOT NULL, food_name VARCHAR(100) NOT NULL, description TEXT, main_ingredients VARCHAR(255), taste_tags VARCHAR(255), -- 例如 "辣,咸,鲜" cooking_method VARCHAR(50), -- 炒/蒸/煮/炸 calories INT, price DECIMAL(8,2), image_url VARCHAR(500), status TINYINT DEFAULT 1, -- 1上架 0下架 view_count INT DEFAULT 0, favorite_count INT DEFAULT 0, rating_score DECIMAL(3,2) DEFAULT 0, create_time DATETIME, update_time DATETIME );taste_tags字段我用逗号分隔存标签ID,虽然不符合严格第三范式,但在国内主流业务开发中很常见。因为美食标签是一次性写入、频繁整体读取的数据,拆成关联表每次推荐要多次join,反而拖慢性能。这里属于典型“用空间换时间”的取舍。
用户行为表采用“一张大表+行为类型字段”还是拆表?我选择拆成三张独立表:user_favorite、user_rating、user_browse_history。原因很简单:三张表的读写频率差异大,浏览记录增长最快,需要定期清理;收藏和评分数百条以内,查询要求快。合在一起虽然表少,但后期统计SQL复杂度高,索引设计也不好做。
特别注意:所有外键关联我都没有在数据库层面建立,只在实体映射和SQL查询中使用逻辑关联。理由是MySQL在高并发写入场景下物理外键会造成锁竞争,而且删除菜品时需要先手动处理关联表,用逻辑外键更好控制。
recommend_config表用来存推荐算法权重参数,例如标签权重衰减系数、浏览量热度因子、收藏权重、评分权重。这个表设计的出发点是:推荐策略不能写死在代码里,运营人员调整参数后,系统不需要发版就能生效。实测下来这对项目答辩很有帮助,你可以现场改一个权重数值,前端推荐结果立刻变化,这比口述算法理论要有说服力得多。
2.2 MyBatis-Plus 通用CRUD与业务封装实战
MyBatis-Plus最核心的是继承BaseMapper<T>,这是所有基础能力的入口。我在FoodMapper中的代码:
public interface FoodMapper extends BaseMapper<Food> { // 自定义多表关联查询,写XML List<FoodRecommendDTO> selectFoodsWithCategory(@Param("offset") int offset, @Param("limit") int limit); }Service层封装时不要直接透传Mapper接口给Controller,而是定义业务方法。比如分页查询菜品,用LambdaQueryWrapper:
public PageResult<FoodVO> pageFoods(int page, int size, String keyword, Long categoryId) { Page<Food> pageInfo = new Page<>(page, size); LambdaQueryWrapper<Food> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(keyword), Food::getFoodName, keyword) .eq(categoryId != null, Food::getCategoryId, categoryId) .eq(Food::getStatus, 1) .orderByDesc(Food::getCreateTime); IPage<Food> result = foodMapper.selectPage(pageInfo, wrapper); // 转VO返回,避免直接暴露数据库字段 }注意like和eq的第一个参数是布尔条件,MyBatis-Plus会在条件为false时跳过该条件,这是防止SQL注入和空条件拼接最优雅的写法。我见过太多人用字符串拼接SQL,那是绝对不可取的。
分页插件配置必须在MyBatis-Plus配置类中显式加入:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }不加这个插件,selectPage实际执行的是全表查询然后内存分页,数据量到几十万必崩。这个坑我已经见过不少人踩过。
逻辑删除也是MyBatis-Plus的亮点。在实体字段上加上@TableLogic注解:
@TableLogic private Integer deleted;之后所有deleteById操作都自动变成UPDATE ... SET deleted = 1 WHERE id = ?,查询自动追加deleted = 0条件。这样无论菜品还是评论,都能保留历史数据,万一误删还能恢复。
2.3 推荐算法的落地实现
推荐模块是本系统的灵魂,我用的是“标签偏好加权 + 发布时间/热度平滑”的召回排序策略,完整步骤如下:
第一步:计算用户标签偏好矩阵
从user_browse_history联合food表统计每个用户最近30天浏览过的食物的标签频次。举个例子,用户浏览了20道菜,其中10道是“辣”。那么“辣”的初始权重就是10。收藏行为加3倍权重,评分行为加5倍权重。这样用户的行为梯度就能拉开。
第二步:标签权重归一化与时间衰减
用户的口味会随时间变化,不能把三个月前的浏览和今天的浏览同等看待。我在SQL中直接用DATEDIFF(NOW(), create_time)计算天数差,权重乘以exp(-0.05 * 天数差)。7天前浏览的权重衰减到0.7,30天前衰减到0.22,这个衰减系数我放在推荐配置表中,方便调整。
第三步:召回候选集
遍历用户偏好标签权重最高的前5个标签,找到包含这些标签且用户没看过的菜品,按菜品综合得分排序。综合得分计算公式:
score = 0.6 * 标签匹配得分 + 0.2 * 热度得分 + 0.2 * 新鲜度得分其中标签匹配得分是当前用户对该菜品所有标签权重之和,热度得分是浏览量、收藏数、评分数归一化后的加权和,新鲜度得分按发布时间线性递减。
第四步:冷启动策略
新用户没有任何行为数据,系统直接推荐“热门榜”和“新品榜”。热门榜不是简单的浏览量排序,而是:
SELECT food_id, LOG(view_count + 1) * 0.5 + LOG(favorite_count + 1) * 0.3 + LOG(rating_count + 1) * 0.2 AS heat_score FROM food WHERE status = 1 ORDER BY heat_score DESC LIMIT 20;用LOG是为了压缩头部流量悬殊带来的差距,避免爆款永远霸占榜单。这个细节可以让推荐列表更合理,写进文档里也是加分项。
第五步:定时任务刷新推荐缓存
个性化推荐不能每次请求都现算,否则并发一高数据库就扛不住。我在FoodRecommendJob中每30分钟全量刷新一次TopN结果到Redis。如果项目环境没装Redis,也可以缓存到内存Map或recommend_result表。我建议直接存库,因为毕业设计环境中部署Redis多一个服务容易出问题,存一张表结构简单、排查直观。事实证明这个选择在答辩演示时非常稳,即使Redis没启动也不影响系统运行。
3. 前端Vue3工程实现与前后端联调
3.1 Vue3工程搭建与核心配置
前端我直接用Vite创建的Vue3项目,原因很简单:Vite启动速度快,热更新秒级响应,而且对Vue3的Composition API支持天然友好。创建命令:
npm create vite@latest food-recommend-front -- --template vue然后安装核心依赖:
npm install vue-router@4 pinia axios element-plus路由我用createWebHistory模式,但要注意部署到Nginx时如果直接访问子路由会出现404,需要配置try_files重写到index.html。如果是开发模式,Vite会自己处理,不需要担心。
跨域配置放在Vite的vite.config.js中:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }为什么用proxy而不是直接发起跨域请求?因为浏览器的CORS限制,如果前端直连后端,每次请求都要处理预检和跨域响应头,Session/Cookie传递也会反复出问题。Vite代理的方案,从浏览器视角看所有请求都是同源的,省去90%的跨域烦恼。后端同时开一层CORS配置作为兜底,双保险。
3.2 页面拆解:从推荐首页到评价闭环
推荐首页是核心页面,布局上拆成四个区块:顶部搜索栏、左侧菜品分类树、中间推荐信息流、右侧用户行为面板。中间推荐信息流使用不定高卡片列表,图片懒加载,下面展示菜品标签和评分,点击卡片跳转详情。
详情页是最容易出亮点的地方,我建议增强三个功能:相关菜品推荐、用户评分交互、评论时间线。相关菜品推荐在详情页底部展示,逻辑是“和当前菜品拥有至少一个共同标签的其他菜品”,接口由后端返回。评分交互用Vue3的reactive状态管理实时分数,点击星星后调接口提交,成功后更新平均分。
用户收藏夹和浏览历史页面都是列表页,使用ElTable或ElCard,重点是展示删除、清空操作。评价页需要弹窗表单,字段包括评分、口味标签多选、文字评论。提交后不能只刷新当前页,还要通过Pinia共享状态更新全局评论数。
3.3 登录鉴权与前后端会话保持技巧
登录模块我使用的是传统Session + Cookie方案,而不是JWT。很多人一提到前后端分离就默认用JWT,但在这个项目里Session更合适:管理端需要对用户做封禁操作,Session可以随时在服务端销毁,JWT一旦签发无法主动失效。Session模式中,前端通过axios的withCredentials: true携带Cookie,后端SpringBoot配置:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.setAllowCredentials(true); config.addAllowedHeader("*"); config.addAllowedMethod("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }这里有几个关键细节。allowedOriginPattern("*")和setAllowCredentials(true)必须同时出现,否则前端会报“请求资源不包含Access-Control-Allow-Credentials”错误。另外后端接口的Session生成依赖于Cookie中的JSESSIONID,如果前端代理路径和登录接口路径不一致,可能导致每次请求都生成新的Session。我在实际项目中就把所有接口统一放在/api前缀下,登录接口是/api/user/login,这样代理规则一致,Session才能始终保持。
Session失效的默认时间是30分钟,管理系统通常要更长。我在application.yml中设置了:
server: servlet: session: timeout: 120m3.4 Vue3状态管理与接口封装
接口封装我把每个模块的API单独建文件,比如api/food.js:
import request from '@/utils/request' export function getRecommendList(data) { return request({ url: '/food/recommend', method: 'post', data }) }request模块里做了统一拦截,请求前在header中带上Token(如果用的Session则可以忽略),响应后统一解包后端返回的{code, message, data}结构,如果code不是200直接弹错误提示。
Pinia存储用户信息和主题配置。每个页面组件中拿用户状态时,用storeToRefs保证响应式不被解构丢失。这里一定要小心,很多人习惯直接const { userInfo } = store,这样会丢失响应性,页面数据变不了。我实际开发中使用了storeToRefs(useUserStore),才不会出现登录后首页还显示未登录的诡异问题。
4. MySQL8.0安装部署与容器化实践
4.1 本机安装MySQL8.0的完整流程与踩坑记录
无论你自己开发还是部署项目,MySQL8.0都是第一道门槛。Windows安装时最常遇到的是“安装后服务无法启动”,九成是Data目录初始化失败。我建议不要用安装包向导,而是用命令行方式安装,可控性最高。
简单说下步骤:从MySQL官网下载8.0的ZIP压缩包,解压到指定目录,在根目录新建my.ini配置文件:
[mysqld] basedir=D:/mysql-8.0.36-winx64 datadir=D:/mysql-8.0.36-winx64/data port=3306 character-set-server=utf8mb4 default-storage-engine=INNODB然后用管理员权限执行:
mysqld --initialize-insecure mysqld --install net start mysql这里必须用--initialize-insecure,它会生成一个root用户且密码为空,然后用ALTER USER修改密码。如果直接用--initialize,生成的随机密码在data目录的err文件里,新手经常找不到。
连接工具方面,我推荐先用命令行测试:
mysql -u root -p如果报错Access denied for user 'root'@'localhost',多半是密码策略或host限制。MySQL8.0默认密码策略是caching_sha2_password,老版本客户端连接会报Authentication plugin 'caching_sha2_password' cannot be loaded。这个问题在SpringBoot连接时尤其常见,解决方法是创建用户时指定:
CREATE USER 'food'@'%' IDENTIFIED WITH mysql_native_password BY 'yourpassword'; GRANT ALL PRIVILEGES ON fooddb.* TO 'food'@'%'; FLUSH PRIVILEGES;如果你不想改驱动,也可以在application.yml中使用mysql-connector-j8.x驱动,它本身支持caching_sha2_password,但需要配置时区参数。
4.2 使用Docker快速启动MySQL8.0
如果本机环境比较乱,或者想在答辩前快速部署一套干净环境,Docker是更好的选择。拉取官方镜像并启动:
docker run -d --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ -e MYSQL_DATABASE=fooddb \ -v /opt/mysql-data:/var/lib/mysql \ mysql:8.0启动后需要等待几秒初始化,查看日志确认:
docker logs -f mysql8出现ready for connections就说明成功了。用Docker跑数据库最大的好处是环境隔离,不会污染宿主机,删除只需要docker rm -f mysql8。但坏处是容器默认时区是UTC,连接SpringBoot如果不设置serverTimezone=Asia/Shanghai,时间字段会和本地相差8小时。解决办法有两种:容器启动时加环境变量TZ=Asia/Shanghai,或者JDBC URL中直接指定时区。我建议两种都加,避免任何时间错乱。
Docker方式导入初始化SQL也很简单:
docker exec -i mysql8 mysql -uroot -p123456 fooddb < fooddb.sql4.3 SpringBoot连接MySQL8.0的JDBC配置详解
这是整个项目最容易报错的地方。SpringBoot2.6版本依赖的mysql-connector-java是5.x,连接MySQL8.0时经常炸。我的完整配置如下:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/fooddb?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456四个参数缺一不可:
useSSL=false:本地开发不需要SSL,否则会告警且可能握手失败serverTimezone=Asia/Shanghai:不设置默认是UTC,日期全乱allowPublicKeyRetrieval=true:解决Public Key Retrieval is not allowed错误,这是MySQL8.0的caching_sha2_password认证机制引起的characterEncoding=utf8:与后端加useUnicode=true配合,防止中文乱码
另外driver-class-name一定是com.mysql.cj.jdbc.Driver,不是com.mysql.jdbc.Driver。前者是新驱动,后者已经废弃,很多老博客还在写旧的,复制了必报ClassNotFound。
5. 常见问题与排查技巧实录
5.1 编译启动阶段高频错误
后端启动失败的第一大原因就是端口被占用。SpringBoot默认使用8080端口,如果你之前跑过其他服务,要提前查:
netstat -ano | findstr 8080然后去任务管理器根据PID结束进程。不知道你有没有遇到过APPLICATION FAILED TO START,提示Web server failed to start. Port 8080 was already in use,这种情况下改application.yml的端口也可以,但注意前端代理也要同步改。
前端依赖安装慢是国内网络环境的老问题。把npm镜像切换成国内源,可以在项目根目录新建.npmrc文件:
registry=https://registry.npmmirror.com再用npm install,速度提升肉眼可见。如果某个包安装还失败,就清缓存npm cache clean --force再试。
5.2 MyBatis-Plus使用中的经典坑
分页不生效是一个高频问题。现象是返回的total为0,或者数据不是按页返回。原因基本就是没配置PaginationInnerInterceptor。我有一次在另一个项目里发现问题出在MyBatis-Plus版本与SpringBoot2的兼容性上,后来统一使用mybatis-plus-boot-starter3.5.x版本,兼容性最好。
另一个坑是字段自动填充不生效。我设计菜品表时希望create_time和update_time自动填充,在实体类中加了@TableField(fill = FieldFill.INSERT),但忘了实现MetaObjectHandler接口:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }如果没有这个类,所有时间字段都是空。实际上每条SQL都要手动设置时间,非常痛苦。
5.3 前后端联调时的经典翻车现场
跨域配置完成后,最常见的是“会话丢失”。用户登录成功后,前端跳转页面,再请求用户信息发现未登录。排查方法是在浏览器开发者工具中查看每个请求的Cookie:登录请求返回了Set-Cookie,但后续请求没有携带JSESSIONID。
出现这种情况,检查axios实例是否设置了withCredentials: true:
const request = axios.create({ baseURL: '/api', timeout: 10000, withCredentials: true })前端设置了这个,后端CorsConfig又开了allowCredentials(true),双管齐下才能保证Cookie正常。还有一点,Cookie的属性中如果SameSite没有设置,现代浏览器默认会拦截跨域Cookie传递。我通过在启动类添加一个CookieSerializerBean解决:
@Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer = new DefaultCookieSerializer(); serializer.setSameSite("Lax"); serializer.setCookieName("JSESSIONID"); return serializer; }5.4 Vue3渲染问题与Element Plus样式异常
Vue3的数据响应式问题集中在reactive和ref的选择上。我建议所有类型为对象或数组的用reactive,基础类型用ref。但在数组操作上,Vue3不能直接用索引赋值,比如this.list[0] = newItem不会触发更新,必须用splice或Object.assign。我之前因为原生属性直接更新DOM不刷新,排查了大半天,最后才发现是直接写food.name = xxx,把响应式数据当作普通对象操作了。
Element Plus组件库偶尔样式失效,多半是引入方式不对。推荐全量引入,在main.js中:
import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' app.use(ElementPlus)按需引入虽然能减小打包体积,但毕业设计项目没必要增加复杂度。
5.5 数据库层面的常见问题速查表
我把自己在使用中遇到的MySQL问题整理成了一张速查表:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 连接失败,Access denied | 用户没创建或密码错误 | CREATE USER并用mysql_native_password指定认证插件 |
| Public Key Retrieval is not allowed | JDBC连接MySQL8.0时未加allowPublicKeyRetrieval | URL中添加allowPublicKeyRetrieval=true |
| 中文乱码 | 数据库默认字符集不是utf8mb4 | 建库时指定DEFAULT CHARACTER SET utf8mb4 |
| 时间差8小时 | 容器或JDBC未设置时区 | 容器设置TZ=Asia/Shanghai,连接URL加serverTimezone |
| [ERR] 2003 - Can't connect to MySQL server | 端口不通或服务未启动 | 检查netstat端口监听,Linux防火墙开放3306 |
这套速查表我写进了项目文档中,答辩时被问到数据库细节,直接能背出来。
6. 项目文档如何组织与二次开发方向
6.1 需求分析文档与API文档的编排
拿到源码后不要急着看代码,先看配套文档。项目的需求说明书建议包含:系统背景、角色分析(普通用户、管理员)、功能用例图、核心业务流程图、数据库ER图。 API文档我使用的是Swagger自动生成,SpringBoot依赖中加springfox-boot-starter即可,启动后访问/swagger-ui/index.html,每个接口的请求参数和响应结构一目了然。答辩时把Swagger页面投到屏幕上,比干讲代码有说服力得多。
需要注意的是,Swagger3和SpringBoot2.6有路径匹配策略冲突,需要设置:
spring: mvc: pathmatch: matching-strategy: ant_path_matcher否则启动报IllegalArgumentException: No match found。
6.2 从毕业设计到上线部署的扩展思路
这套系统目前是单体架构,如果想要扩展,有两条路径值得尝试。
第一条是推荐算法升级。现在的标签加权模型是离线批处理式的,只能做到30分钟更新一次。后续可以引入Redis实时记录用户点击事件流,配合用户画像组件,做到分钟级甚至实时推荐。实现方式不难,SpringBoot把点击事件写入Redis的SortedSet,score是当前时间戳,再写一个定时任务把这部分数据做增量合并到用户行为表。
第二条是前后端分离的部署拆分。前端打包后放到Nginx,后端打成jar包独立运行,数据库用云数据库或Docker。部署脚本我建议写成Docker Compose:
version: '3' services: mysql8: image: mysql:8.0 environment: - MYSQL_ROOT_PASSWORD=123456 - MYSQL_DATABASE=fooddb volumes: - ./mysql-data:/var/lib/mysql - ./sql:/docker-entrypoint-initdb.d ports: - "3306:3306" backend: build: ./backend depends_on: - mysql8 ports: - "8080:8080" frontend: build: ./frontend depends_on: - backend ports: - "80:80"这样一条docker-compose up -d就能把三个服务全部拉起,迁移环境时非常省心。我在本地实测过,从零开始到三个容器全部正常运行,大约需要10分钟,前提是镜像下载顺利。
一些真实的项目体会
这套系统我从拿到源码到完全跑通,前后总共花了两个下午。第一下午做环境和数据库导入,第二下午联调前端页面和推荐接口。中间最让我花时间的地方有两个:一是MySQL8.0的连接参数,二是前端Session跨域保持。这两个问题单独报错信息都很模糊,网上答案各说各话,最终是两种方案组合在一起才解决。所以我在文档里特意加了一个“快速排错清单”,把这两个坑的完整排查路径写清楚,后来再跑别的环境,照着操作基本一遍过。
另外一个小建议:如果你准备把这份源码作为毕业设计,不要只停留在“能跑”。把推荐配置表中的权重参数改一改,观察推荐结果前后的变化,然后把这一过程写进论文里,这比贴大段代码有价值得多。毕竟推荐系统的核心是策略迭代,而不是堆技术名词。
最后再分享一个我在项目里顺手做的小功能:管理员在后台可以手动干预推荐位,把某些新店菜品置顶,并设置置顶过期时间。这个功能技术上很简单,就是一张recommend_manual表,业务上却很出彩,因为它体现实操思维。答辩老师问“推荐系统如何保证商业公平性”时,我就拿这个功能举例,效果远比背算法公式好。你后续扩展这个项目时,也可以从这个方向多想一想。