做这个系统之前,我一直觉得“推荐”这个词在业务项目里多数时候是营销噱头。后来接了个卫生健康领域的平台需求,才意识到推荐在这里不是锦上添花:一个糖尿病用户的饮食方案、一个术后康复者的运动强度、一个过敏体质人群的健康资讯,如果统一列表糊弄过去,轻则被投诉,重则出问题。整个项目的核心矛盾,就是怎么在数据量不大、没有用户规模优势的前提下,仍然让每个用户拿到差异化的内容。
经过反复取舍,我用 SpringBoot + Vue + MyBatis + MySQL 实现了这套前后端分离的智能推荐卫生健康系统。后端负责用户画像、推荐计算和接口服务,前端负责交互展示和内容播放,编译产物直接交给 Nginx 托管,源码和部署流程今天一起整理出来。这篇内容适合两类人:一是准备做前后端分离项目的学生或转行者,想看看一个完整项目从设计到部署要经过哪些环节;二是需要在自己系统里加“推荐”能力但又不想引入重型大数据组件的开发者,这套用 MySQL 和 MyBatis 就能跑起来的轻量方案,可以作为起点。
1. 项目整体设计:卫生健康领域的推荐场景和功能边界
1.1 业务角色与核心流程
这个系统有三个角色:普通用户、健康管理师、管理员。普通用户进来先做一份可跳过的体质问卷,填写年龄区间、慢病标签、过敏原、运动习惯、饮食偏好。健康管理师在后台上传内容库,包括食谱、运动课程、健康文章和短视频。管理员负责用户审核、内容合规检查和基础数据维护。
推荐引擎不是用户点了“推荐”按钮才运行的,而是每天凌晨通过定时任务跑一次。任务读取用户最新的健康档案和近七天的行为记录,生成新的推荐结果写入推荐结果表,前端首页接口直接查表返回。这样做的最大好处是接口响应快,不需要在用户请求时现算,也方便做 A/B 测试——同一批用户可以先拿旧结果,另一批用户拿新结果,对比点击率差异。
从流程上看,整体是一个“采集特征 -> 计算向量 -> 召回候选 -> 过滤约束 -> 排序输出”的闭环。数据量不大,所以没有引入消息队列,也不依赖实时计算引擎,定时任务加上 SQL 聚合就能撑住。如果以后用户量上来,只需要把定时任务替换成异步计算,表结构基本不用动。
1.2 功能模块划分与数据流闭环
系统在功能上拆成五个模块:用户中心、健康档案、内容管理、推荐引擎、数据看板。用户中心管注册登录和 Token 鉴权;健康档案管问卷、体检指标和维护记录;内容管理是后台的食谱、课程、文章、视频上传与分类;推荐引擎是核心,负责画像计算和结果生成;数据看板给管理员看推荐内容的曝光量、点击量和用户反馈。
这里有一个容易被忽视的设计细节:推荐结果不要只存内容 ID 和用户 ID,还要存推荐理由、推荐场景和失效时间。存推荐理由是因为前端要展示“因为你有高血糖标签,所以推荐了低 GI 食谱”,用户感知会更可信;存失效时间是为了避免用户健康档案更新后,还在看旧推荐。比如用户刚在档案里增加了一个“痛风”标签,当天就应该触发重新计算,而不是等第二天凌晨。
这些模块之间的数据流是:用户行为表记录浏览、点击、收藏、不感兴趣四类动作;定时任务把行为数据折算成特征权重;推荐引擎根据特征权重生成推荐列表;消息表把“您的推荐已更新”推给用户。整个闭环里最核心的不是算法模型,而是用户行为表的设计——没有行为数据,再花哨的算法也跑不起来。
2. 技术选型复盘:为什么是 SpringBoot + Vue + MyBatis + MySQL
2.1 前后端分离让联调和部署都更干净
以前做 SSM 项目,页面用 JSP 混在 Java 代码里,改一个按钮都要重启 Tomcat,更别提前端设计师和后端开发在同一个工程里互相覆盖文件。前后端分离之后,Vue 项目独立维护,后端只提供 JSON 接口,两边通过接口文档约定字段。我这个项目里,前端团队只关心/api/recommend/list返回什么结构,后端只关心数据库查出来怎么组装,调试效率高了很多。
部署上也干净。后端 SpringBoot 打成一个可执行 JAR,内置 Tomcat,java -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(Map<String, Double> userVector, Map<String, Double> itemVector) { double dotProduct = 0; double userNorm = 0; double itemNorm = 0; Set<String> 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 id="searchItems" resultType="com.demo.entity.HealthItem"> SELECT * FROM health_item <where> <if test="category != null and category != ''"> AND category = #{category} </if> <if test="maxCalorie != null"> AND calorie <= #{maxCalorie} </if> <if test="tagList != null and tagList.size() > 0"> AND id IN ( SELECT item_id FROM item_tag_rel WHERE tag_id IN <foreach collection="tagList" item="tagId" open="(" separator="," close=")"> #{tagId} </foreach> ) </if> </where> ORDER BY recommend_score DESC </select>这里有两个细节。第一,<where>标签会自动去掉第一个条件前面多余的 AND,不要手写成WHERE 1=1,虽然也能跑但不够优雅。第二,<符号在 XML 里要转义成<,这个报错非常经典,控制台提示 “The content of elements must consist of well-formed character data”,第一次遇到会一脸懵。
4.3 MyBatis 缓存:什么时候开,什么时候别开
MyBatis 自带一级缓存和二级缓存。一级缓存是 SqlSession 级别的,同一个 SqlSession 中执行相同的查询会直接命中缓存。Spring 集成的场景里,每次请求创建新的 SqlSession,一级缓存的意义其实不大。二级缓存是 namespace 级别的,多个 SqlSession 可以共享。
我的建议是:查询频率高、数据更新不频繁的内容分类表可以开二级缓存,行为表和推荐结果表千万别开。用户行为是持续写入的,如果开了二级缓存,用户刚点了一个“不感兴趣”,下次查询可能还是旧结果。推荐结果表每个用户每天都不一样,缓存命中率极低,还白白增加内存占用。
二级缓存的配置也很简单,在 Mapper XML 里加一行<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>。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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrieval=true这个参数也是 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.active=prod后台运行用nohup加上日志重定向:
nohup java -jar health-recommend-server.jar --spring.profiles.active=prod > app.log 2>&1 &启动后先用curl http://localhost:8080/api/health探活,确认端口正常再继续配前端。
6.3 Vue 打包与 Nginx 反向代理
前端打包前需要改两处。第一是.env.production里的接口地址,写成线上域名,比如VUE_APP_BASE_URL=https://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 错
这个问题在项目联调时出现过一次。前端传过来的筛选条件是level=1,后端 Mapper 里写的是:
<if test="level == 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 加serverTimezone=Asia/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 次”,才能判断推荐效果是好是坏。
这个小改动开发量不大,但对后续调权重、换算法提供了最重要的数据依据。如果你也要做类似系统,建议尽早把曝光埋点加上,等数据积累到一定量级再去优化推荐策略,比拍脑袋调参靠谱得多。