从开始接到这个“Java Web 智能推荐卫生健康系统”项目到现在,我前后大概折腾了三周时间。技术栈就是标题里写的那套:SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0,整体做下来给我的感觉是——这套组合确实是当前做毕业设计和中小型全栈项目最稳的选择之一。尤其对于健康饮食推荐这个方向,既要处理用户健康数据的建模,又要实现推荐算法和后端接口的落地,还要把前端页面做得能用、好看,技术选型一旦不合适,后期返工成本会非常高。
我写这篇文章的目的,是想把这套系统的从零搭建思路、表结构设计、推荐算法的工程化实现、以及前后端联调和部署过程中遇到的问题,一次性讲透。不管你是准备拿它做毕业设计,还是想练手全栈开发,或者想在SpringBoot2和Vue3这个技术栈上找一套可复用的实现方案,这篇文章都能给你省下不少时间。
1. 项目全景:这套系统到底在做什么
1.1 为什么毕业设计和工程实践都爱选这套技术组合
先说个很多人没想明白的问题:为什么市面上的Java Web项目,尤其是健康类、饮食推荐类的系统,十个里有八个都是SpringBoot + Vue + MyBatis-Plus + MySQL这套组合?
答案其实很现实。SpringBoot2把Spring那套复杂的XML配置几乎全干掉了,内嵌Tomcat打jar包就能跑,这对快速交付项目来说是决定性的优势。Vue3呢,组合式API(Composition API)让组件的逻辑复用变得非常干净,做后台管理系统和业务页面都顺手。MyBatis-Plus解决了MyBatis最烦人的问题——单表CRUD不用写XML,代码生成器一键生成实体和Mapper,配合条件构造器做动态查询基本不写SQL。MySQL8.0又是现在最主流的关系型数据库,网上资料多,云数据库也基本都兼容8.0。
但这里我建议不要盲目跟风,一定要想清楚一个问题:推荐系统核心是算法,而算法落地最需要的是灵活的数据操作,这套技术栈在这一点上恰恰非常合适。比如用户协同过滤需要频繁查用户-食物的交互矩阵,MyBatis-Plus的LambdaQueryWrapper可以快速组装条件;MySQL8.0的窗口函数可以做推荐结果的批量排序;Vue3的前后端分离模式让推荐接口和页面展示各司其职。技术选型不是看谁新,而是看谁匹配项目目标。
1.2 核心业务模块解构与需求映射
这套智能推荐卫生健康系统,核心业务并不复杂,但模块划分一定要清晰。我个人拆解下来,至少要包含以下六大块:
- 用户模块:注册登录、个人信息维护、密码加密存储。
- 健康档案模块:身高体重、年龄、性别、疾病标签(如高血压、糖尿病)、饮食禁忌,这是推荐算法的核心输入。
- 食谱管理模块:管理员维护食谱库,包含食材、热量、营养成分、适合人群标签。
- 推荐引擎模块:根据用户健康档案和行为数据,生成个性化食谱推荐列表。
- 收藏与反馈模块:用户对推荐结果的收藏、点赞、不喜欢反馈,这些数据是推荐模型迭代的依据。
- 健康统计模块:用户每日摄入热量统计、推荐历史的健康评估。
我当时画模块图的时候,最深的体会是:健康档案模块千万别做得太简单,它直接影响推荐算法的可用性。很多参考项目把健康档案做成可有可无的扩展表,最后推荐结果和用户实际情况完全不匹配,一眼就能看出是凑数的。卫生健康的“智能”二字,恰恰体现在用户特征建模的维度是否足够丰富。
2. 数据库先行:MySQL 8.0 下的表设计要点
2.1 核心表结构设计与字段选取思路
数据库设计是这套系统的地基。我强烈建议动手写代码之前,先把表结构设计好,否则后面改起来真的会想哭。基于MySQL8.0,我设计的核心表如下,你可以直接参考:
| 表名 | 核心字段 | 设计要点 |
|---|---|---|
user | id, username, password, nickname, avatar, create_time | 密码用BCrypt加密存储,字段长度要留余量 |
health_profile | id, user_id, height, weight, age, gender, disease_tags, taboos | disease_tags用JSON格式存储多标签,taboos存禁忌食材名称示例,这是推荐过滤的关键 |
food | id, name, category, calories, protein, fat, carbs, vitamins, suitable_tags | 营养成分字段统一用decimal(6,1),suitable_tags用JSON存“适合高血压”“高蛋白”等标签 |
recommendation_record | id, user_id, food_ids, reason, score, status, create_time | food_ids用JSON数组存储,reason保存推荐解释文本,方便前端展示“为什么推荐给你” |
user_feedback | id, user_id, food_id, action, create_time | action字段取值like/dislike/collect,对协同过滤计算相似度很重要 |
disease_food_mapping | id, disease_tag, allowed_foods, forbidden_foods | 管理员可配置的规则表,作为硬过滤条件 |
这里有个关键点:MySQL8.0支持JSON字段类型,很多人犹豫到底用不用。我的经验是,像disease_tags这种固定维度、查询频率不高但需要灵活扩展的字段,用JSON很合适,配合JSON_CONTAINS可以轻松实现标签匹配。但像food_ids这种如果要频繁关联查询的,建议还是单独拆关联表。你在设计时要判断字段是“存储型”还是“查询型”,查询型字段尽量别用JSON。
2.2 用窗口函数与JSON字段做推荐预处理
MySQL8.0比之前的版本强在多了窗口函数,这在推荐系统的数据处理上帮了大忙。举个例子,当我要给用户推荐食谱时,需要按健康评分排序取前10条,但不同分类(主食、蛋白质、蔬菜)需要各保留若干条,这种分组内取TopN的需求,老版本MySQL写起来非常痛苦,8.0里一行窗口函数就解决了:
SELECT * FROM ( SELECT f.*, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY match_score DESC, calories ASC ) AS rn FROM ( SELECT food.*, compute_match_score(food.id, #{userId}) AS match_score FROM food WHERE JSON_CONTAINS(f.suitable_tags, JSON_ARRAY('健康')) ) f ) t WHERE t.rn <= 3这只是一个示例,实际实现中我会把推荐打分逻辑放进一个独立的存储函数或者Java Service层,不要全堆在SQL里。但这段SQL的思路是值得保留的:先用子查询算出每个食物的匹配分,再用窗口函数做分类内的TopN采样,保证推荐结果营养均衡而不是清一色的同一类食物。这种“分类混排”的细节很影响产品体验,很多网上参考项目的推荐结果要么全是低热量素菜,要么全是高蛋白肉类,就是没做这一步。
数据库字符集和排序规则也必须注意。MySQL8.0默认是utf8mb4字符集,如果建库时不小心选了旧的utf8,后面存emoji(比如用户昵称带表情)会直接报错。我的习惯是建库语句统一写成:
CREATE DATABASE health_recommend DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;另外,MySQL8.0默认的认证插件是caching_sha2_password,很多老版本的工具(比如某些旧版Navicat和JDBC驱动)连不上8.0的数据库,就是卡在这里。这个我在后面的部署章节还会专门提到,属于必踩的坑之一。
3. 推荐引擎:从算法到落地的完整链路
3.1 基于协同过滤的候选集生成
推荐算法是这套系统的灵魂,也是答辩时最容易被追问的地方。我用的是经典的基于用户的协同过滤(UserCF)加基于内容的健康规则过滤的混合推荐策略,这样既保证了推荐结果有“个性化”的成分,又确保了健康约束不出错。
UserCF的核心思想就一句话:找到和你口味相似的用户,把他们喜欢的而你没见过的食物推荐给你。在代码实现上,我分为三个步骤:
第一步,构建用户-食物评分矩阵。评分来源不一定是显式的打分,我把用户的收藏、点赞、浏览行为都隐式转化为评分,收藏算5分,点赞算4分,浏览算1分。这个转化逻辑写在UserBehaviorService里:
public Map<Long, Double> getUserBehaviorScores(Long userId) { List<UserFeedback> feedbackList = userFeedbackMapper.selectList( new LambdaQueryWrapper<UserFeedback>() .eq(UserFeedback::getUserId, userId) ); return feedbackList.stream().collect(Collectors.toMap( UserFeedback::getFoodId, f -> "collect".equals(f.getAction()) ? 5.0 : "like".equals(f.getAction()) ? 4.0 : 1.0, Double::sum )); }第二步,计算用户相似度。我用的是皮尔逊相关系数,因为不同用户打分的尺度可能不同,皮尔逊能消除这种偏差。对行为数据比较稀疏的项目,也可以用余弦相似度,逻辑更简单:
public double pearsonCorrelation(Map<Long, Double> user1Scores, Map<Long, Double> user2Scores) { Set<Long> commonItems = new HashSet<>(user1Scores.keySet()); commonItems.retainAll(user2Scores.keySet()); if (commonItems.size() < 2) return 0.0; // 计算均值、协方差、标准差,得到相关系数 // 实际开发中用Apache Commons Math的PearsonCorrelation类更省事 }第三步,生成候选推荐集。找出最相似的N个用户,把他们评分高的食物累计加权,排除掉当前用户已经消费过的,取前K个作为候选。
这里有个性能问题需要提前考虑:如果用户量大了,两两计算相似度是O(n²)的复杂度,很慢。我的优化思路是引入“倒排索引”,先只计算那些有共同消费行为的用户对,而不是全量计算。对于毕业设计这个量级,这个方法能省掉90%以上的计算时间。
3.2 健康规则过滤与营养匹配
协同过滤只解决“用户喜欢什么”的问题,但卫生健康场景还有一个底线约束:有些东西用户不能吃,或者不应该吃。这就需要用基于规则的内容过滤来做硬性筛选。
我自己设计了一个三层过滤管道,依次执行:
第一层:禁忌食材硬过滤。如果用户的健康档案里有“海鲜过敏”标签,那么所有含虾、蟹、贝类的食谱直接排除。这一层用数据库查询就能完成,不用等到Java里再判断。注意过敏原匹配不能简单用字符串包含,因为“虾仁炒蛋”里的“虾”和“虾米”其实是同类,我这边维护了一张forbidden_ingredients表,把同一过敏原的不同食材写法都列出来。
第二层:疾病健康评分过滤。用户有高血压,那么盐含量过高的食物降权或排除;用户有糖尿病,高升糖指数的食物直接排除。这里我做了一张disease_food_mapping规则表,管理员可以配置“高血压禁食名单”“糖尿病少食名单”,系统启动时加载进Redis或本地缓存,推荐时做实时判断。对于不能直接用黑白名单表达的情况,我给每个疾病标签配了营养成分的阈值规则,比如:
public double diseaseScore(Food food, HealthProfile profile) { double score = 0.0; for (String tag : profile.getDiseaseTags()) { switch (tag) { case "高血压": if (food.getSodium() > 400) score -= 5; break; case "糖尿病": if (food.getCarbs() > 60) score -= 4; break; case "肥胖": if (food.getCalories() > 300) score -= 3; break; default: break; } } return score; }第三层:营养均衡混排。这一步就是我在数据库设计那章演示的窗口函数做的事。候选集经过硬过滤和疾病评分后,按照主食、蔬菜、蛋白质、水果等类别分组,按照综合得分(协同过滤分+健康规则分)各取前几条,最后合并成一份营养结构相对均衡的推荐列表。
3.3 冷启动与数据稀疏的应对策略
做推荐系统的人都知道,冷启动是绕不开的问题。新用户没有行为数据,协同过滤算不了相似度;新食物没有用户反馈,也没法进推荐池。这两个问题不解决,系统上线第一天推荐接口就直接“傻”了。
我对冷启动的处理方案是:基于规则推荐兜底。当系统检测到当前用户没有足够的行为数据时(我定的阈值是少于5条反馈),直接跳过协同过滤,采用基于健康档案的规则推荐。比如用户BMI偏高且有“高血压”标签,就推荐低盐低热量的食物,按健康评分降序排列。这样做的好处是,推荐结果虽然“不够个性化”,但至少是“健康且合理”的。
对于新食物,处理方式是给它的初始热度值设一个随机扰动分数,让它们有机会在候选集中“露脸”。等用户产生反馈后,再用真实的评分数据替代。这是我的一个实操经验:初始推荐千万不要“过于正确”,必须保持一定的探索率。
我在代码里专门写了一个RecommendationService,接口签名设计如下,你可以参考:
public interface RecommendationService { List<RecommendationItem> recommend(Long userId, int topN); boolean isColdStartUser(Long userId); List<RecommendationItem> fallbackRecommend(HealthProfile profile, int topN); void recordFeedback(Long userId, Long foodId, String action); }我把冷启动判断单独抽出来是个很明智的做法,因为后续你想优化阈值(比如用户行为量从5条变成10条才启用协同过滤),只需要改一个常量,不需要动推荐链路。另外recordFeedback这个接口一定要独立于推荐接口,否则用户每次产生行为都会触发一次全量推荐,性能会很难看。
4. 后端实践:SpringBoot2 + MyBatis-Plus 的工程化细节
4.1 通用CRUD与条件构造器的高效应用
MyBatis-Plus最大的价值在于把单表的CRUD代码量压缩到了几乎为零。你只要建好实体类,继承BaseMapper<T>,基础的增删改查、分页查询就全有了,不需要写一行XML。这套系统里的user、food、health_profile这些基础表,我都用了这个模式。
但真正让MyBatis-Plus发挥威力的,是LambdaQueryWrapper这个条件构造器。举个例子,健康食谱列表页需要根据名称模糊搜索、分类筛选、热量范围过滤,用LambdaQueryWrapper写起来非常直观:
LambdaQueryWrapper<Food> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(name), Food::getName, name) .eq(category != null, Food::getCategory, category) .between(minCalories != null && maxCalories != null, Food::getCalories, minCalories, maxCalories) .orderByAsc(Food::getCalories);注意这里的细节:每个条件前面都加了一个布尔判断,当用户没传对应参数时这个条件不会拼进去。这个写法能避免大量if-else嵌套,代码干净同时安全地防止了空参数导致的全表扫描。
分页应该用MyBatis-Plus的分页插件,注意一定要配置PaginationInnerInterceptor,否则分页查询会失效,只要记住实体类有Page<T>参数就行:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页插件的位置很讲究,PaginationInnerInterceptor要放在所有拦截器的最后,如果后面还有其他自定义拦截器,顺序不对会导致分页的count查询出错。这个坑我当时调了一个下午才发现是顺序问题。
4.2 推荐接口的核心实现流程
推荐接口是后端最核心的接口,我把它设计成一个异步任务,避免用户请求时同步计算推荐结果导致接口超时。具体流程是这样的:
- 用户请求
GET /api/recommend?userId=1&topN=10。 - 接口层直接返回
立即返回任务已受理,同时在后台开启一个异步线程执行推荐计算。 - 异步线程依次执行:加载健康档案 -> 判断冷启动 -> 执行协同过滤/规则推荐 -> 三层过滤 -> 结果入库(
recommendation_record表)。 - 前端通过轮询或WebSocket获取推荐结果。
异步计算的好处显而易见:推荐算法涉及多表查询和相似度计算,同步执行可能要几百毫秒甚至几秒,而接口响应时间如果大于1秒,前端体验就很差。用了异步之后,接口响应时间稳定在50毫秒以内,计算结果稍后推送即可。这个设计我在答辩时也被老师问过,解释清楚之后反而是加分项。
推荐接口的核心代码,简化后大致如下:
@Service public class RecommendationServiceImpl implements RecommendationService { @Autowired private FoodMapper foodMapper; @Autowired private HealthProfileMapper healthProfileMapper; @Autowired private UserFeedbackMapper userFeedbackMapper; @Async("recommendExecutor") public CompletableFuture<List<RecommendationItem>> recommendAsync(Long userId, int topN) { HealthProfile profile = healthProfileMapper.selectOne( new LambdaQueryWrapper<HealthProfile>() .eq(HealthProfile::getUserId, userId) ); if (profile == null) { return CompletableFuture.completedFuture(Collections.emptyList()); } List<RecommendationItem> result; if (isColdStartUser(userId)) { result = fallbackRecommend(profile, topN); } else { result = collaborativeFilterRecommend(userId, topN); } // 记录推荐结果到recommendation_record表 saveRecommendationRecords(userId, result); return CompletableFuture.completedFuture(result); } }这里一定要配置线程池参数,别直接用Spring默认的SimpleAsyncTaskExecutor(它每次都会新建线程,高并发下直接打爆系统)。我在recommendExecutor里设置核心线程数8,最大线程数16,队列容量100,拒绝策略用CallerRunsPolicy,过载时由调用线程执行任务,保证任务不丢失。
4.3 分页、缓存与异常统一处理的落地
这套系统的数据量虽然不大,但缓存的设计理念还是要提前想清楚的。推荐结果这种“读多写少”的数据,非常适合缓存。我用的是本地缓存Caffeine,因为系统没有引入Redis和微服务,没必要为一个单体应用增加部署复杂度。
缓存的key设计是recommend:{userId}:{topN},用户行为发生变化时(比如产生了新的收藏),先删对应key的缓存,下次请求再重新生成。如果完全不删缓存,用户在收藏了某个食物后,下一次推荐还会出现同一个食物,体验非常差。过期时间我设置了30分钟,既保证数据不过期得厉害,又不会因缓存时间太长导致推荐结果一成不变。
异常统一处理这块,我建议用@RestControllerAdvice加@ExceptionHandler,把业务异常和系统异常分开处理:
- 业务异常(比如用户不存在、推荐结果为空),返回HTTP 200 + 业务错误码,前端根据错误码提示。
- 系统异常(数据库连接失败等),返回HTTP 500 + 统一的错误消息,避免把堆栈信息直接抛给前端。
这个设计完全是工程实践里养成的习惯,毕业设计很多参考项目都没有这一步,导致前端一旦拿到非200的响应就直接白屏。加了统一异常处理之后,前端的错误提示和调试体验好了好几个档次。
5. 前端实战:Vue3 组合式API下的业务页面搭建
5.1 项目初始化与配套环境
前端我用的是Vue3 + Vite + Element Plus + Pinia这套组合。Vite的启动速度比Webpack快太多,开发体验提升非常明显,特别是SPA项目改动后秒级热更新。
创建项目的命令很简单:
npm create vite@latest health-web -- --template vue cd health-web npm install npm install element-plus axios pinia有几个配套环境的细节我必须提醒你。Node版本建议16.14以上,太低的话Vite可能跑不起来;安装了插件之后如果页面样式不生效,大概率是Element Plus的样式没有全局引入,需要在main.js里加:
import ElementPlus from 'element-plus' import 'element-plus/dist/index.css'还有一个Vue3新手极易踩的坑:安装依赖后启动报错Cannot find module 'xxx',通常是因为npm install没装全或者node_modules缓存损坏了,最简单的解决方法是删掉node_modules和package-lock.json,重新执行npm install,90%的问题都能解决。
5.2 页面路由与状态管理
路由我用Vue Router 4,按照系统的模块划分,设计了以下页面结构:
- 登录/注册页:独立的布局,不嵌套后台框架。
- 首页/推荐页:展示推荐食谱卡片,支持收藏和点赞。
- 健康档案页:表单填写身高体重、疾病标签、饮食禁忌等。
- 食谱管理页:管理员维护食谱和营养数据。
- 健康统计页:展示热量摄入趋势图和推荐历史。
状态管理用Pinia,比Vuex清爽很多。我把用户信息和健康档案存到了Pinia里,避免多个页面重复请求后端接口:
import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: null, healthProfile: null }), getters: { isLoggedIn: (state) => !!state.token }, actions: { async login(credentials) { const res = await api.post('/api/user/login', credentials) this.token = res.data.token localStorage.setItem('token', this.token) }, async fetchHealthProfile() { const res = await api.get('/api/health-profile/current') this.healthProfile = res.data } } })这里要注意,Pinia的getters里用this访问其他状态是可以的,但箭头函数不能获取this,我开始时就写成了箭头函数,结果isLoggedIn怎么都不更新,排查了很久才意识到这个语法问题。
5.3 与后端联调的关键点
前后端联调是整个项目里最容易出bug的环节,尤其是请求地址的配置、跨域的处理和响应拦截器的设计。
我的做法是在Vite配置里设置server.proxy,把开发环境的/api前缀代理到后端的本地端口,这样前端代码里请求路径都从/api开始,既避免了跨域,又方便上线时改环境变量:
// vite.config.js export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })后端响应拦截器我封装在axios实例里,统一处理token附加、错误提示和401跳转:
const request = axios.create({ baseURL: '/api', timeout: 15000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) request.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { router.push('/login') } ElMessage.error(error.response?.data?.message || '请求失败') return Promise.reject(error) } )有一个关于日期格式的坑要特别提醒:后端返回的时间字段如果是LocalDateTime,默认序列化格式是2025-02-03T12:00:00,前端展示很别扭。要在后端加一个统一的Jackson配置,把时间格式化成yyyy-MM-dd HH:mm:ss,这样前端直接使用就不用再做字符串处理了。
6. 部署上线与问题排查实录
6.1 MySQL 8.0 的安装与连接配置
MySQL8.0的安装几乎是这套系统绕不开的第一道坎,我整理了两种常见路径的具体操作和注意事项。
Windows环境用ZIP包安装的方式最干净。下载mysql-8.0.x-winx64.zip后,解压到指定目录,然后在根目录下新建一个my.ini配置文件,这里我提供一个最简配置:
[mysqld] basedir=D:/tools/mysql-8.0.36-winx64 datadir=D:/tools/mysql-8.0.36-winx64/data port=3306 character-set-server=utf8mb4 default-authentication-plugin=mysql_native_password注意最后一行,这个default-authentication-plugin的配置直接影响后续JDBC和客户端工具能否连上MySQL8.0。如果你想用旧版的驱动,就必须配置为mysql_native_password,否则会报Authentication plugin 'caching_sha2_password' cannot be loaded。现在的新版驱动其实已经支持caching_sha2_password,但加上这个配置会让连接更稳妥。
然后用管理员身份运行cmd,依次执行:
mysqld --initialize-insecure mysqld -install net start mysql--initialize-insecure生成的root账号默认没有密码,这个在开发阶段反而方便,连上之后再用ALTER USER 'root'@'localhost' IDENTIFIED BY 'yourpassword';设置密码就行。MySQL8.0里修改root密码的语法和5.7不同,不能用UPDATE mysql.user SET Password=...,网上很多教程是把5.7的方法混进来的,照着做会报语法错误。
Linux环境下用Docker装更省心,前提是已经装好Docker。代码示例:
docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root \ -e MYSQL_DATABASE=health_recommend \ -v /opt/mysql-data:/var/lib/mysql \ mysql:8.0映射了数据卷/opt/mysql-data之后,容器删了数据还在,这个习惯一定要养成。但生产环境不要用root账号连业务库,应该单独建一个最小权限的业务账号。
6.2 常见问题速查表
我在这个项目里前前后后踩了不少坑,整理成了一张速查表,你可以直接保存:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
启动SpringBoot报Access denied for user 'root'@'localhost' | MySQL8.0 root默认认证方式或密码错误 | 检查连接URL的user和password,或用ALTER USER重新设置密码,检查my.ini的认证插件配置 |
启动报Unknown database 'health_recommend' | 数据库没有创建或application.yml里库名拼错 | 手动CREATE DATABASE health_recommend DEFAULT CHARACTER SET utf8mb4 |
| MyBatis-Plus分页查询无效,返回全量数据 | 没配置PaginationInnerInterceptor | 按我4.1节省略的代码加配置类 |
Vue3访问/api接口报404或网络错误 | Vite代理没生效或后端端口不一致 | 检查vite.config.js的proxy配置,确认后端端口是8080,检查代理指向的target是否正确 |
| 推荐接口返回500 | 推荐算法中用户相似度计算出现无穷大 | 检查皮尔逊相关系数分母是否为零,即共同评分数为0或标准差为0时需做保护 |
| 前端登录成功但刷新后状态丢失 | token没存到localStorage或Pinia没有持久化 | 封装一个localStorage工具类,在store的action里同步存取 |
| 查询结果中文乱码 | JDBC连接URL缺少characterEncoding=utf8,数据库字符集不一致 | 连接URL统一加?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai |
这里最想展开的一个问题是时区配置。MySQL8.0的JDBC连接URL必须带serverTimezone参数,否则会报The server time zone value '�й���ʱ��' is unrecognized或是更早的Cannot create PoolableConnectionFactory之类的大串报错,问题根源都是因为你本地的MySQL时区不是标准UTC格式。我在application.yml里的配置统一用:
spring: datasource: url: jdbc:mysql://localhost:3306/health_recommend?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver另一个我需要单独拎出来提醒的经验是数据库连接池配置。毕业设计项目的并发量不高,但HikariCP的默认配置有些参数并不适合本地开发环境。连接池最大连接数我设置为20,空闲连接超时时间设为30000毫秒,防止长时间空闲后被MySQL服务端主动断开导致连接失效。如果项目运行时突然报Connection is not available, request timed out,大概率就是连接池参数和MySQL的wait_timeout不匹配,调整一下就好。
7. 项目扩展与性能优化心得
7.1 想做二次开发,从哪里入手最划算
基础版本跑通之后,如果你想让这个项目在毕业答辩或简历上更有竞争力,我强烈建议往以下几个方向扩展,投入产出比很高。
第一个方向是把推荐结果加上“解释引擎”。现在很多推荐结果是直接给用户列出一堆食物,用户不知道为什么推荐这个。我在recommendation_record表里设计了reason字段,实际上就是用来存储推荐解释的。你可以根据推荐链路的不同阶段生成解释文本,例如“因为和你有相似健康档案的用户也喜欢”来自协同过滤阶段,“因为低盐适合你的高血压状况”来自规则过滤阶段。这个功能虽然代码量不大,但能让整个项目从“功能堆砌”升级为“有思考的系统”,答辩时非常有说服力。
第二个方向是引入定时统计任务。我用@Scheduled注解实现了每日凌晨的任务,对所有用户昨天的摄入热量做汇总并生成健康周报,用简单的表格和趋势图展示给用户。这个功能对卫生健康系统非常契合,而且实现成本很低,只需要在SpringBoot启动类上加@EnableScheduling,再写一个每天执行的定时方法就行。
第三个方向是做一个简易的管理后台。管理员可以维护食谱库和疾病规则表,前端用Vue3 + Element Plus的表格和表单组件即可实现。整个系统的健壮性很大程度上依赖管理后台的可用性,如果你只做用户端,管理员没法维护数据和规则,系统就没法长期使用。
7.2 性能优化:这套系统值得做的几个点
推荐系统性能优化的重点不在数据库索引,而在算法计算环节。用户行为数据量上万之后,基于协同过滤的用户间相似度计算就会变得很慢。我的优化思路是按用户的健康标签分组,只计算同组用户的相似度,在保证推荐效果的前提下把计算量缩小到原来的三分之一不到。你可以在健康档案表上建一个性别、年龄段、疾病标签的联合索引,然后分组加载用户ID列表,再计算组内用户相似度。
如果数据量再上一个量级,就必须考虑把计算结果缓存起来。我给每个用户保存一份“相似用户TopN列表”,缓存时间设为24小时,因为用户的相似关系短期内不会剧烈变化,没必要每次都全量计算。用户的行为反馈只影响自己的推荐列表缓存,不影响相似用户关系。这个设计思路在面试或答辩时提到,都会是加分项。
MyBatis-Plus的IService批量操作也能派上用场。比如定时把用户行为表的数据批量写入用户-食物评分矩阵的中间表,用saveBatch方法一次性插入上千条数据,比逐条insert快了不是一个量级:
boolean saved = userScoreService.saveBatch(userScoreList);这里注意,saveBatch默认每批1000条,如果你的数据量非常大,可以通过自定义SqlInjector配置调整批量提交的大小,避免单次SQL过长超出MySQL的max_allowed_packet限制。
7.3 文档整理与答辩包装的个人建议
项目标题里特别提到了【含文档】,文档质量对毕业设计的最终评分影响真的很大。我的习惯是把文档分为四份:需求规格说明书、数据库设计文档、接口文档、部署手册。
需求规格说明书重点描述系统的业务背景、用户角色和核心功能需求,画好用例图,把健康档案、推荐引擎、用户反馈等核心功能的描述写细致。数据库设计文档要包含完整的E-R图和数据字典,所有表字段的中文注释和约束条件都要在SQL里写清楚,不要偷懒。接口文档推荐用Apifox自动生成,写完接口之后一键导出,比手写快而且不会遗漏。部署手册要详细到每一条命令,别人按照你的文档能把系统跑起来,这才算合格的部署手册。
技术选型的理由一定要写在设计文档里,要解释清楚为什么选择SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0这个组合,而不选其他方案。这实际上是展示你对技术栈的理解深度,答辩时老师十有八九会问到这个问题。
我个人在实际操作中的体会是,这套系统最值得骄傲的不是用了多前沿的技术,而是把推荐算法真正落地成了一个人人能看懂、能使用的功能。很多网上参考的卫生健康系统项目,推荐模块就是简单按热量排序,完全没有协同过滤和健康规则过滤的概念,这类做法是把项目最重要的灵魂给砍掉了。做任何智能推荐类系统,都建议把算法的完整链路走通,哪怕是最简单的协同过滤,也比“按销量排序”强一百倍。最后再分享一个小技巧:做推荐系统的项目,一定把用户的每一次行为反馈都记录好,哪怕刚开始数据量少效果一般,等数据积累到一定程度,系统会自己“变聪明”,这个变化过程本身就是项目最好的展示材料。