SpringBoot+Vue+MyBatis实现轻量级智能推荐卫生健康系统

发布时间:2026/9/14 8:55:24
SpringBoot+Vue+MyBatis实现轻量级智能推荐卫生健康系统 做这个系统之前我一直觉得“推荐”这个词在业务项目里多数时候是营销噱头。后来接了个卫生健康领域的平台需求才意识到推荐在这里不是锦上添花一个糖尿病用户的饮食方案、一个术后康复者的运动强度、一个过敏体质人群的健康资讯如果统一列表糊弄过去轻则被投诉重则出问题。整个项目的核心矛盾就是怎么在数据量不大、没有用户规模优势的前提下仍然让每个用户拿到差异化的内容。经过反复取舍我用 SpringBoot Vue MyBatis MySQL 实现了这套前后端分离的智能推荐卫生健康系统。后端负责用户画像、推荐计算和接口服务前端负责交互展示和内容播放编译产物直接交给 Nginx 托管源码和部署流程今天一起整理出来。这篇内容适合两类人一是准备做前后端分离项目的学生或转行者想看看一个完整项目从设计到部署要经过哪些环节二是需要在自己系统里加“推荐”能力但又不想引入重型大数据组件的开发者这套用 MySQL 和 MyBatis 就能跑起来的轻量方案可以作为起点。1. 项目整体设计卫生健康领域的推荐场景和功能边界1.1 业务角色与核心流程这个系统有三个角色普通用户、健康管理师、管理员。普通用户进来先做一份可跳过的体质问卷填写年龄区间、慢病标签、过敏原、运动习惯、饮食偏好。健康管理师在后台上传内容库包括食谱、运动课程、健康文章和短视频。管理员负责用户审核、内容合规检查和基础数据维护。推荐引擎不是用户点了“推荐”按钮才运行的而是每天凌晨通过定时任务跑一次。任务读取用户最新的健康档案和近七天的行为记录生成新的推荐结果写入推荐结果表前端首页接口直接查表返回。这样做的最大好处是接口响应快不需要在用户请求时现算也方便做 A/B 测试——同一批用户可以先拿旧结果另一批用户拿新结果对比点击率差异。从流程上看整体是一个“采集特征 - 计算向量 - 召回候选 - 过滤约束 - 排序输出”的闭环。数据量不大所以没有引入消息队列也不依赖实时计算引擎定时任务加上 SQL 聚合就能撑住。如果以后用户量上来只需要把定时任务替换成异步计算表结构基本不用动。1.2 功能模块划分与数据流闭环系统在功能上拆成五个模块用户中心、健康档案、内容管理、推荐引擎、数据看板。用户中心管注册登录和 Token 鉴权健康档案管问卷、体检指标和维护记录内容管理是后台的食谱、课程、文章、视频上传与分类推荐引擎是核心负责画像计算和结果生成数据看板给管理员看推荐内容的曝光量、点击量和用户反馈。这里有一个容易被忽视的设计细节推荐结果不要只存内容 ID 和用户 ID还要存推荐理由、推荐场景和失效时间。存推荐理由是因为前端要展示“因为你有高血糖标签所以推荐了低 GI 食谱”用户感知会更可信存失效时间是为了避免用户健康档案更新后还在看旧推荐。比如用户刚在档案里增加了一个“痛风”标签当天就应该触发重新计算而不是等第二天凌晨。这些模块之间的数据流是用户行为表记录浏览、点击、收藏、不感兴趣四类动作定时任务把行为数据折算成特征权重推荐引擎根据特征权重生成推荐列表消息表把“您的推荐已更新”推给用户。整个闭环里最核心的不是算法模型而是用户行为表的设计——没有行为数据再花哨的算法也跑不起来。2. 技术选型复盘为什么是 SpringBoot Vue MyBatis MySQL2.1 前后端分离让联调和部署都更干净以前做 SSM 项目页面用 JSP 混在 Java 代码里改一个按钮都要重启 Tomcat更别提前端设计师和后端开发在同一个工程里互相覆盖文件。前后端分离之后Vue 项目独立维护后端只提供 JSON 接口两边通过接口文档约定字段。我这个项目里前端团队只关心/api/recommend/list返回什么结构后端只关心数据库查出来怎么组装调试效率高了很多。部署上也干净。后端 SpringBoot 打成一个可执行 JAR内置 Tomcatjava -jar就能跑前端 Vue 打包后是纯静态文件丢到 Nginx 里就行。没有额外的 Web 服务器配置成本。这里需要注意开发环境和生产环境的接口地址不同我习惯在前端项目里放.env.development和.env.production两个文件分别配置代理地址和线上地址。2.2 MyBatis 的价值是让 SQL 可控选 MyBatis 而不是 JPA主要是出于两方面的考虑。第一推荐系统里有大量多表关联查询和动态拼 SQL 的场景比如筛选菜谱时要同时满足“低脂”和“不含花生”并且食材标签个数可变MyBatis 的if标签可以优雅地拼出条件而 JPA 的 Specification 写起来相对繁琐。第二团队里成员对 SQL 更熟悉遇到慢查询可以直接把日志里的 SQL 复制到 Navicat 里 EXPLAIN排查路径更短。MyBatis 不受欢迎的点大家都懂XML 文件多了以后维护成本高。我的做法是严格分层每张表的增删改查放在XxxMapper.xml复杂的统计和推荐查询单独建一个RecommendMapper.xml避免所有 SQL 堆在一个文件里。实际项目维护半年下来这个规则帮了不少忙。2.3 为什么没上微服务和大数据组件这是每次技术评审都会被问的问题。坦率说对一个日活几百、内容量几千条的卫生健康推荐系统来说Spring Cloud 那一套注册中心、网关、配置中心再加 Spark 或 Flink 做推荐属于典型的过度设计。微服务解决的问题是团队规模大、模块独立部署、流量突增而这些在这个项目里都不存在。引入微服务只会让部署从“一个 JAR”变成“五个服务”排查问题还得翻好几套日志。大数据组件同样没必要。协同过滤和标签匹配的算法在数据量几千条的情况下用 Java 遍历加 MySQL 查询单次计算也就是几百毫秒。定时任务跑在凌晨对性能更不敏感。我见过不少项目为了用 Redis 而用 Redis为了上 Flink 而上 Flink最后维护成本远超收益。技术选型不是越新越好而是匹配场景。3. 推荐引擎落地方案从协同过滤到标签相似度的工程实现3.1 为什么放弃协同过滤项目初期我确实考虑过协同过滤。UserCF 的逻辑是“跟你兴趣相似的用户喜欢什么就给你推荐什么”听起来很合理。但卫生健康领域有一个特殊问题用户不能拿自己的身体做实验。一个高血压用户跟一个健身达人有相似的浏览历史如果系统把健身达人的高蛋白增肌食谱推荐给高血压用户后果就很严重。协同过滤的另一个问题是冷启动。新用户没有任何行为记录系统无法计算相似用户只能推荐热门内容。健康场景里热门内容往往是大而全的通用内容对个体的参考价值很低。相比之下基于标签的内容匹配只要用户完成了体质问卷哪怕没有任何行为记录也能算出初步推荐结果。这也是最终选型的关键原因。3.2 特征向量与相似度计算的 Java 实现在实现上我把用户画像和内容都抽象成一组标签向量。比如用户 A 的标签是{高血糖: 1.0, 减脂: 0.8, 膝盖脆弱: 0.6}一个食谱的标签是{低GI: 1.0, 减脂: 0.7, 无运动禁忌: 0.9}。计算相似度就变成两个向量的夹角余弦。公式部分我用最标准的形式similarity cos(θ) Σ(u_i * v_i) / (√Σ(u_i²) * √Σ(v_i²))Java 实现时我先把标签统一映射成固定维度的数组然后循环计算点积和模长。示例代码如下public double calculateSimilarity(MapString, Double userVector, MapString, Double itemVector) { double dotProduct 0; double userNorm 0; double itemNorm 0; SetString commonTags new HashSet(userVector.keySet()); commonTags.retainAll(itemVector.keySet()); for (String tag : commonTags) { dotProduct userVector.get(tag) * itemVector.get(tag); } for (Double value : userVector.values()) { userNorm value * value; } for (Double value : itemVector.values()) { itemNorm value * value; } if (userNorm 0 || itemNorm 0) { return 0; } return dotProduct / (Math.sqrt(userNorm) * Math.sqrt(itemNorm)); }这个实现里需要注意一个坑权重不能只看标签是否命中还要看标签的强度。用户档案里的“偶尔运动”和“从不运动”在运动标签上的权重不能给同样的值。我的做法是在健康档案录入时就把选项映射成 0 到 1 之间的权重而不是等计算时再处理。3.3 健康约束过滤不让推荐结果“好心办坏事”相似度算完之后不能直接取 TopN 就返回中间还要过一道“硬约束过滤”。这是卫生健康推荐区别于电商推荐的核心地方。我维护了一张禁忌规则表基本逻辑是“如果内容包含某个禁忌标签而用户档案包含某个疾病标签则直接排除”。比如内容表里有一个“高蛋白增肌餐”标签是{高蛋白, 增肌}同时关联了禁忌标签{肾功能不全, 痛风}。用户 B 的档案里有“痛风”在召回阶段计算出相似度再高这条例也会在过滤阶段被剔除。硬约束的优先级永远高于相似度这是我在文档里反复强调的一条铁律。过滤之后再按相似度降序排列取前 20 条写入推荐结果表。排序时我加了一个微调策略给近三天有正面行为收藏、完整播放的内容类型加 10% 的权重分让用户的近期兴趣在推荐列表里体现得更明显。这样既保持了健康约束的刚性又保留了个性化的柔性。4. 后端要点用户行为表、MyBatis 动态 SQL 和缓存使用4.1 核心表结构设计后端设计里我认为最值得说的是三张表健康档案表、用户行为表、推荐结果表。健康档案表如下设计CREATE TABLE health_profile ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, chronic_tags VARCHAR(255) COMMENT 慢病标签逗号分隔, allergy_tags VARCHAR(255) COMMENT 过敏原标签, exercise_frequency TINYINT COMMENT 运动频率 0-3, diet_preference VARCHAR(255) COMMENT 饮食偏好, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_id (user_id) );用户行为表要特别设计一个 action_type 字段用 TINYINT 存 1 浏览、2 点击、3 收藏、4 不感兴趣。为什么不用字符串因为后续统计时要用SUM(CASE WHEN action_type 3 THEN 1 ELSE 0 END)这类语句数字比字符串更高效。每个行为记录都有场景字段 scene用来区分行为发生在“首页推荐”还是“搜索结果”这对评估推荐效果很有用。推荐结果表则存了用户 ID、内容 ID、内容类型、推荐分值、推荐理由、场景、失效时间。失效时间用 datetime 类型每天定时任务跑完后会把昨天的数据标记为失效前端接口只查未失效的数据。这个设计保证了用户档案更新到新推荐生效之间不会出现旧推荐“超期服役”的情况。4.2 MyBatis 动态 SQL 处理多条件筛选推荐系统里最常见的查询是“根据多个可选条件筛选内容”。内容列表页可能有分类、难度、热量范围、标签多个筛选条件用户可能只填其中一部分。MyBatis 的if标签是处理这种场景的利器。select idsearchItems resultTypecom.demo.entity.HealthItem SELECT * FROM health_item where if testcategory ! null and category ! AND category #{category} /if if testmaxCalorie ! null AND calorie lt; #{maxCalorie} /if if testtagList ! null and tagList.size() 0 AND id IN ( SELECT item_id FROM item_tag_rel WHERE tag_id IN foreach collectiontagList itemtagId open( separator, close) #{tagId} /foreach ) /if /where ORDER BY recommend_score DESC /select这里有两个细节。第一where标签会自动去掉第一个条件前面多余的 AND不要手写成WHERE 11虽然也能跑但不够优雅。第二符号在 XML 里要转义成lt;这个报错非常经典控制台提示 “The content of elements must consist of well-formed character data”第一次遇到会一脸懵。4.3 MyBatis 缓存什么时候开什么时候别开MyBatis 自带一级缓存和二级缓存。一级缓存是 SqlSession 级别的同一个 SqlSession 中执行相同的查询会直接命中缓存。Spring 集成的场景里每次请求创建新的 SqlSession一级缓存的意义其实不大。二级缓存是 namespace 级别的多个 SqlSession 可以共享。我的建议是查询频率高、数据更新不频繁的内容分类表可以开二级缓存行为表和推荐结果表千万别开。用户行为是持续写入的如果开了二级缓存用户刚点了一个“不感兴趣”下次查询可能还是旧结果。推荐结果表每个用户每天都不一样缓存命中率极低还白白增加内存占用。二级缓存的配置也很简单在 Mapper XML 里加一行cache evictionLRU flushInterval60000 size512 readOnlytrue/。readOnly 设为 true因为缓存对象是只读数据避免序列化拷贝的性能损耗。5. 前端联调关键路径Vue Router 参数、Token 拦截和 M3U8 播放5.1 axios 封装与 Token 请求拦截前后端分离后前端最绕不开的问题就是 Token 怎么带、过期了怎么处理。我封装了一个 request.js在请求拦截器里从 localStorage 读 token加到请求头service.interceptors.request.use(config { const token localStorage.getItem(health_token) if (token) { config.headers[Authorization] Bearer token } return config })响应拦截器里处理 401。后端统一返回 401 表示 Token 失效或未登录前端收到后清掉本地登录态跳转到登录页。这里有个体验问题如果用户在填一个很长的问卷突然跳登录页数据就丢了。我的做法是先弹提示“登录已过期请重新登录”然后保存当前路由和页面数据到 sessionStorage重新登录后再用redirect参数跳回来。路由守卫是另一道防线。router.beforeEach里检查白名单和登录态页面刷新时 token 还存在 localStorage 里就不需要重新登录。这个逻辑看似简单但顺序容易写错先判断白名单再判断 token最后判断路由 meta 里的角色要求。5.2 Vue Router 参数传递列表页到详情页的三条路项目里最常见的跳转是推荐列表进详情页。很多人第一反应是用query传参this.$router.push({ path: /detail, query: { id: 1 } })。这种方式简单但参数会出现在 URL 上刷新不丢缺点是参数多了 URL 又长又丑。我更推荐用动态路由加props解耦路由配置成{ path: /detail/:id, component: Detail, props: true }跳转时this.$router.push({ name: Detail, params: { id: 1 } })组件里直接props: [id]接收。这样组件不依赖$route对象复用性更好。要注意的是用 params 传参时不能用 path必须用 name否则参数传不过去。还有一种场景是推荐列表页到用户健康档案页需要带整个画像对象过去。这种大对象不适合放路由参数我一般用 Vuex 或者 sessionStorage 暂存。刷新页面时从 store 里读store 没数据再从接口拉保证数据一致性。5.3 健康短视频的 M3U8 播放video.js hls.js项目里健康科普视频用的是 M3U8 格式切片这种格式在 PC 端 Safari 浏览器原生支持Chrome 和 Firefox 需要借助 hls.js 转流。前端播放我选的是 video.js配合videojs-contrib-hls插件。安装命令npm install video.js videojs-contrib-hls播放器初始化this.player videojs(this.$refs.videoPlayer, { sources: [{ src: videoUrl, type: application/x-mpegURL }], controls: true, fluid: true, autoplay: false })用的时候有一个实际体会M3U8 视频跨域请求很严格Nginx 里一定要配add_header Access-Control-Allow-Origin *否则播放器报跨域错误视频加载不出来。另外video.js 的样式会覆盖页面上其他video标签的样式建议给播放器容器加一个独立的 class避免污染页面上其他视频元素。6. 部署完整链路从 MySQL 初始化到 Nginx 托管前后端6.1 MySQL 安装与建库初始化整个部署流程里MySQL 环境的坑是最多的。我建议直接装 MySQL 8.x注意两个点版本对应的驱动名是com.mysql.cj.jdbc.Driver旧的com.mysql.jdbc.Driver已废弃连接 URL 必须带时区参数否则报serverTimezone错误。生产环境我用了 MySQL 8.0.36连接配置如下spring: datasource: url: jdbc:mysql://localhost:3306/health_recommend?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_password driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrievaltrue这个参数也是 MySQL 8.x 特有的坑不加的话有时候会报Public Key Retrieval is not allowed。建库语句很简单CREATE DATABASE health_recommend DEFAULT CHARACTER SET utf8mb4;然后把项目里的schema.sql直接导入。如果数据里有 emoji记得数据库连接编码和表编码都要用 utf8mb4否则 emoji 存进去会变成问号。6.2 SpringBoot 打包与启动后端用的是 Maven 构建打包命令mvn clean package -DskipTests打包前要检查application-prod.yml里的数据库地址是否改成线上地址不要把开发库地址带到生产环境。打出来的 JAR 在target目录下启动时我习惯指定生产配置java -jar health-recommend-server.jar --spring.profiles.activeprod后台运行用nohup加上日志重定向nohup java -jar health-recommend-server.jar --spring.profiles.activeprod app.log 21 启动后先用curl http://localhost:8080/api/health探活确认端口正常再继续配前端。6.3 Vue 打包与 Nginx 反向代理前端打包前需要改两处。第一是.env.production里的接口地址写成线上域名比如VUE_APP_BASE_URLhttps://api.example.com。第二是vue.config.js里的publicPath如果用子路径部署就写/health/如果用根域名就写/这个配置直接决定了打包后静态资源的加载路径。打包命令npm run build产物在dist目录。Nginx 配置示例server { listen 80; server_name example.com; root /data/www/health-front; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; 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; } }try_files那一行是关键Vue Router 用 history 模式时必须配置否则刷新详情页 URL 会报 404。如果不想让刷新 404也可以退回 hash 模式URL 带个#看起来没那么干净但省心。7. 踩坑排查链路三个让我卡到凌晨的问题7.1 MyBatis 单个数字字符比较查不出数据不是因为 SQL 错这个问题在项目联调时出现过一次。前端传过来的筛选条件是level1后端 Mapper 里写的是if testlevel 1 AND difficulty 1 /if结果死活不触发这个条件。排查了半天控制台打印的 SQL 里根本没有 AND difficulty 这一段。问题出在 MyBatis 对 OGNL 表达式类型的判断上level是 String 类型字符串1和数字1比较时OGNL 会把它当成字符1而不是数字导致类型不一致判断不成立。把判断改成level 1就能解决。更规范的做法是在前端就把参数转成数字类型或者在后端 DTO 里用 Integer 接收。这个坑在 MyBatis 里特别隐蔽因为代码看着完全没问题但条件就是不生效。排查方法是把 Mapper 的日志级别调到 DEBUG直接看打印出来的 SQL 和参数类型。7.2 Vue 打包后布局异常静态资源路径问题开发环境一切正常npm run build之后放到 Nginx 上页面能打开但 CSS 和 JS 全部 404整个页面裸奔。看浏览器 Network 面板发现资源请求路径是/css/app.css但我部署在子路径/health/下实际文件在/health/css/app.css。根因就是publicPath配置问题。开发环境下 webpack-dev-server 默认从根路径加载打包后如果publicPath是/部署到子路径就找不到资源。解决办法是配置相对路径或子路径// vue.config.js module.exports { publicPath: process.env.NODE_ENV production ? /health/ : / }如果部署在根域名publicPath直接写/就行。这个问题一般不会在本地开发时暴露只有部署到服务器才出现所以每次打包前我都会先确认部署路径。7.3 MySQL 8.x 驱动与时区问题项目从 MySQL 5.7 迁移到 8.x 时后端启动直接报错The server time zone value Öйú±ê׼ʱ¼ä is unrecognized or represents more than one time zone错误信息是乱码本质是数据库时区和 JVM 时区不一致。办法有两个一个是在连接 URL 加serverTimezoneAsia/Shanghai另一个是在 MySQL 里执行SET GLOBAL time_zone 8:00。我推荐两个都做避免以后其他地方踩同样的坑。还有驱动依赖也要同步升级5.7 时代用的mysql-connector-java5.x在 8.x 下不兼容。8.x 的 Maven 依赖坐标也变了不再是mysql:mysql-connector-java而是com.mysql:mysql-connector-j这个细节容易被人忽视。最后分享一个推荐评估的小技巧系统上线后不要只看用户有没有点推荐内容一定要记录曝光量。我在推荐结果表里加了曝光字段前端在卡片渲染完成后调用一个/api/exposure接口把当前屏幕展示的内容 ID 传回来。这样就能算出真实的点击率CTR而不是只看点击次数。没有曝光量的点击数据是没有参照系的只有知道“推了 100 次被点了 5 次”才能判断推荐效果是好是坏。这个小改动开发量不大但对后续调权重、换算法提供了最重要的数据依据。如果你也要做类似系统建议尽早把曝光埋点加上等数据积累到一定量级再去优化推荐策略比拍脑袋调参靠谱得多。