☰
SpringBoot+Vue私人诊所管理系统:协同过滤算法落地与源码复盘
2026/10/22 5:05:11 网站建设 项目流程

从“一个小诊所搞什么推荐算法”的质疑说起。这套系统最初被领导质疑“花架子”,但真正跑起来之后,整个团队发现最大的价值不是“推荐”这个噱头,而是它迫使我们把患者、医生、号源、病历、药品、收费这些散落的数据全部串在了一条线上。标题里的“企业级协同过滤算法私人诊所管理系统管理系统源码”虽然长了点,但核心就两件事:一套完整的诊所业务闭环,外加一个用协同过滤做医生推荐的算法模块。我把它从零开始实现并整理成了一套SpringBoot+Vue+MyBatis+MySQL的完整源码,这篇文章就基于这套源码做一次深度复盘,适合正在学习全栈项目、想了解推荐算法如何落地的同学,也适合准备做医疗管理系统二次开发的朋友参考。

1. 从“要不要做推荐系统”到“必须做推荐系统”:项目需求还原

1.1 私人诊所的真实痛点:不是缺系统,是缺“协调”

私人诊所和大型医院不一样,没有海量号源给你排,也不能靠“随便挂个号”让患者自己碰运气。我调研了几家私人诊所,发现最典型的场景是这样的:患者进门就问“我想看鼻炎,挂哪个医生”“我上次那个张医生在吗”,前台护士需要靠人工记忆去匹配。患者多的时候,护士一边接电话一边回答问询,忙得不可开交。而诊所内部,医生擅长的领域并不写在墙上,内科医生可能也兼看儿科,外科医生偶尔处理慢性病,这些信息全在Excel表格和人的脑子里。

这就造成了几个问题:一是患者不满意度高,挂错科室或者找错医生,排队排了半天结果医生看不了;二是医生资源分配不均,口碑好的医生号源秒光,其他医生闲得发慌;三是诊所运营方没有数据支撑,不知道什么疾病组合是高频的,也不知道患者流失率为什么高。所以我一开始和诊所管理人员聊需求时,他们原本只想要一套“记账+预约”的系统,但聊到第三轮,所有人都不约而同地提到“能不能给患者推荐对口的医生”。这才是真正的需求起点:管理系统要解决的不仅是“记录”,而是“协调”,协同过滤算法就是协调医患关系的一种手段。

1.2 功能列表的收敛与优先级排序

有了需求,接下来就是拆解系统功能。基于私聊和现场观察,我把系统拆成了六大模块:患者档案、医生排班、预约挂号、电子病历、药品管理、收费统计。每个模块又拆出子功能,最终归纳成一张表:

模块核心功能点优先级
患者档案建档、编辑、就诊历史、标签(过敏史、慢性病)P0
医生管理医生信息、坐诊时间、擅长科室、职称P0
排班管理周期排班、临时停诊、号源数配置P0
预约挂号在线预约、取消、状态流转(待就诊/已完成/已取消)P0
电子病历主诉、诊断、处方、检查记录P1
药品库存入库、出库、库存预警、处方关联P1
收费管理费用明细、发票号、支付渠道P1
推荐服务基于协同过滤生成“推荐医生”接口P1
数据分析就诊量、疾病分布、医生负载P2

P0是第一版必须完成的,P1是第二版,P2留到最后。很多做管理系统的人一上来就堆功能,结果每个模块都做不深。这套源码里我把P0和P1全部实现了,P2只留了基础的统计接口,没有过度设计。

1.3 协同过滤算法为什么会进入需求清单

最初我设定的推荐逻辑是“医生标签匹配”,比如患者选了“鼻科”,就推荐所有擅长鼻科的医生。但实际操作中发现,医生标签是运营人员手工维护的,很多医生自己都不知道该给自己贴什么标签,而且同一个科室里每个医生的处理风格差异很大。有的医生擅长慢性病调理,有的擅长急性发作处理,患者根本分不清。这时候协同过滤就有了优势:它不依赖人工标签,只依赖历史行为数据。只要患者预约了某位医生,并在就诊后给出了评分或满意度反馈,系统就能自动发现“经常一起被预约”或“相似患者都喜欢”的医生组合。这个思路不需要准确的画像标签,非常适合业务数据不准确的私人诊所场景。

当然,协同过滤也面临冷启动问题,新患者没有行为数据,新医生没有预约记录。源码里我用了“热门兜底”策略:新患者先进热门医生列表,新医生则按科室和职称映射到相似医生,再参与后续推荐。后面我会单独讲这部分实现细节。

2. 系统架构全景:前后端分离下的业务模块与数据流

2.1 一张数据流图讲清楚整个系统怎么转

整个系统可以抽象成一条流水线:患者通过前端(Vue)进入系统,填写登录信息后,前端把请求交给后端(SpringBoot)的接口层;后端对接MySQL数据库,做业务处理后返回JSON数据;前端渲染页面,用户在页面上完成预约、查看病历等操作。推荐模块作为后端的一个子服务,定时从数据库里拉取行为数据,计算完候选集后写回推荐表,前端直接查询即可。

我习惯把数据流画成下面这样:

前端Vue页面 -> 请求封装Axios -> Controller层 -> Service层 -> Mapper层(MyBatis) -> MySQL数据库 ↓ 推荐服务(定时任务) ↓ 推荐候选集表 -> 前端'推荐医生'接口

这个图是简化版,但能帮理解职责边界。Controller层只做参数接收和结果封装,不处理业务;Service层负责事务、校验和算法;Mapper层只写SQL。推荐服务单独放在一个job包里,使用Spring自带的@Scheduled定时触发,不再占用请求线程。

2.2 后端模块划分:从认证到业务闭环

后端的包结构是这样组织的:

  • config:Spring配置、跨域配置、拦截器注册
  • security:JWT工具类、过滤器、自定义UserDetails
  • controller:按业务模块拆成AuthController、PatientController、DoctorController、ScheduleController、AppointmentController、MedicalRecordController、DrugController、PaymentController、RecommendController
  • service:每个Controller对应一个Service接口和实现类,推荐服务单独抽成RecommendService
  • mapper:MyBatis的Mapper接口,XML文件放resources/mapper下
  • entity:数据库实体类,不使用 Lombok 的后果就是代码冗长,但这套源码用了Lombok,所以很简洁
  • common:统一返回结果Result、状态码枚举、异常类
  • job:定时推荐任务、药房库存预警任务

这样的分包方式,新同学上手很快。Controller层的方法名几乎和前端调用的URL一一对应,比如GET /api/doctors?page=1&size=10就是医生分页列表,POST /api/appointments就是新建预约。我见过很多项目把Controller堆了上百个方法,这里我尽量克制,每一个Controller只处理本模块的请求,保证可维护性。

2.3 前端模块划分:路由-视图-状态管理

前端用的是Vue全家桶。源码结构上,src/views下面按模块区分:login、dashboard、patient、doctor、appointment、medical-record、drug、payment、recommend。每个view文件夹里又分为index.vue(主页面)和components(子组件)。

路由部分,我通过router/index.js配置了路由表,并利用beforeEach守卫做权限控制。因为系统有四种角色(超管、医生、护士、患者),不同角色能看到的功能菜单不一样。我采用的方案是:登录成功后从后端获取一个role字段,然后在前端动态计算accessibleRoutes,再通过router.addRoutes动态挂载。这里不建议把所有路由写在静态路由表里然后靠v-if去隐藏,那样刷新后会闪一下菜单,体验很糟糕。

Vuex状态管理只存用户基本信息、token和侧边栏折叠状态,不存业务数据。业务数据全部通过API接口获取,存入组件自身的data中。这是因为业务数据状态复杂,全局状态维护成本太高,而且多个用户共用全局状态还会造成串数据。原则是:用户状态全局化,业务数据局部化。

3. 协同过滤算法在私人诊所里的落地姿势:从行为数据到推荐候选集

3.1 特征工程怎么做?直接用“预约-医生”矩阵

很多推荐系统的书,一上来就提用户-物品评分矩阵,但在诊所业务里,患者和医生之间的“评分”从来不是直接问卷。我定义了两类行为数据:

第一类是隐式反馈:用户预约了哪位医生、预约过几次、是否按时就诊、是否取消了预约。这些行为可以从预约表里直接统计。第二类是显式反馈:就诊结束后,患者通过系统给医生打星(1-5星)或者评价“很满意”。由于私人诊所复诊率高,加上前端做得很轻量,患者愿意打星的比率接近60%,数据量够用。

我不建议把所有行为丢进矩阵就完事,而是做加权处理。比如:完成一次就诊权重为1.0,取消预约权重为-0.5,打星分数乘以0.8(归一化到1-5分),再叠加一个时间衰减因子(三个月前的行为乘0.7,一年前乘0.3)。这些权重组合不是一次拍脑袋定的,而是在测试阶段我用A/B对比了一下,发现加权后推荐的点击率比不加权高了不少。源码里把这部分权重设计成了常量,放在RecommendConstants类里,方便调整。

最终得到的是一个稀疏矩阵,行是患者,列是医生,值是加权后的偏好强度。

3.2 相似度计算:余弦相似度还是皮尔逊系数?

在Java中实现协同过滤,最核心的就是相似度计算。这套源码里我实现了两种:余弦相似度和皮尔逊相关系数。对于大多数稀疏的场景,余弦相似度更稳定,因为皮尔逊系数对“两个用户只共同预约过一个医生”的情况会得出无意义的结果(分母为0或相关性满值)。但皮尔逊系数能够消除用户评分尺度差异,比如一个患者总是打5星,另一个总打3星,皮尔逊能校准这种偏差。

我最后采用的是改进的余弦相似度:先对每个患者做均值中心化,再计算余弦相似度。这样既避免了评分尺度问题,又不会像皮尔逊那样出现分母为零的窘境。公式如下:

sim(userA, userB) = sum((r_ai - avg_a) * (r_bi - avg_b)) / (sqrt(sum((r_ai - avg_a)^2)) * sqrt(sum((r_bi - avg_b)^2)))

是不是有点晕?我用一个类似“两个人喜欢的医生重叠度”来解释:如果患者A和患者B都把5星给了同一个医生,在彼此都没有别的评分的情况下,算法认为他们很像;但当其中一个人的评分普遍偏高时,算法不会盲目认为他和谁都像,而是减去各自的平均值后再比,这就像身高要减去国别平均水平之后再比谁更高一样。

计算矩阵维度是患者数×医生数。私人诊所的患者可能只有几千人,医生几十人,所以直接用双重循环去遍历也能接受。我在源码中额外加了一层缓存:SIMILARITY_CACHE用ConcurrentHashMap存计算结果,只有行为数据变化时才清空。

3.3 推荐流程:候选集生成、过滤、排序、多样性控制

整个推荐流程分四步:

  1. 候选集生成。对目标患者,先找到相似度最高的K个患者(K=20),采集这20个患者预约过的医生列表,去掉目标患者已经预约过的医生,得到候选医生集合。
  2. 评分预测。对候选医生,用加权公式计算预测得分:score = sum(sim(user, neighbor) * rating) / sum(sim(user, neighbor))。如果有显式评分数据就用评分,没有就用隐式反馈权重。
  3. 过滤。过滤掉停诊的医生、排班不匹配的医生、患者过敏科室内不合适的医生。这一步非常关键,否则推荐再精准,患者也挂不上号。
  4. 排序与多样性。按预测得分降序排列,同时从不同科室里各取Top1,避免推荐结果全都是内科医生。我踩过这个坑:初版推荐列表前三个全是同一科室,患者觉得系统疯了。

由于推荐结果不需要实时计算,我做成一个每小时的定时任务:扫描全部患者,为每个患者生成Top10候选医生,存入recommend_result表,字段包括patient_id、doctor_id、score、rank、reason_type(相似患者喜欢/热门均衡)。前端推荐接口直接查这张表,性能非常好。

3.4 冷启动与稀疏矩阵的兜底方案

私有诊所的业务数据积累慢,系统上线的第一个月只有不到100条预约,矩阵稀疏度超过99%。如果不处理,相似度计算全是噪声。我的做法是:

  • 对新患者(行为数小于3),不计算协同过滤,直接返回热门医生和评分最高的医生。
  • 对新医生(预约数小于5),先用内容匹配过渡:根据科室、疾病关键词匹配患者的历史病历主诉,等积累足够行为后再切到协同过滤。
  • 如果患者确实没有相似邻居,就用ItemCF(基于医生的相似度)兜底:计算“和患者历史预约的医生相似的其他医生”,因为医生集合小,item-item矩阵更容易稠密。

这套策略放在RecommendFallbackStrategy类中,逻辑不复杂,但确实让推荐在空数据时期不至于空白。我后来发现,很多开源项目把协同过滤做得花里胡哨,却不考虑冷启动,实际跑起来连个推荐列表都输出不了,我觉得这是本末倒置。

4. 后端代码骨架:SpringBoot+MyBatis的事务边界与权限模型

4.1 JWT鉴权的完整链路

源码的认证模块用的是JWT(JSON Web Token)。很多新手问:“为什么不用Session?”因为前后端分离场景下,JWT无需在服务器端存会话状态,扩展方便。流程是:登录成功后,后端签发一个token,有效期设24小时,前端存在localStorage里,每次请求在Authorization头带上。后端写了一个JwtAuthenticationFilter,继承Spring Security的OncePerRequestFilter,在每次请求到达Controller之前从前端传来的token里解析出用户名和角色,然后交给Spring Security的SecurityContext。

这里面有一个容易踩的坑:JWT的密钥、过期时间要配置在application.yml里,而不是写在代码里。源码里我用@ConfigurationProperties读取jwt.secret、jwt.expire-in-seconds,这样部署到不同环境时不需要改代码。但要注意secret必须足够长,低于32字节容易被暴力破解,我这里设了一个64字节的随机字符串。

4.2 用Spring Security实现RBAC权限控制

私人诊所系统的角色权限比较简单,我基于Spring Security的注解@PreAuthorize实现了RBAC控制。例如:

  • @PreAuthorize("hasRole('ADMIN')"):超管才能访问用户管理、排班设置
  • @PreAuthorize("hasRole('DOCTOR')"):医生才能查看自己的排班、写病历
  • @PreAuthorize("hasRole('PATIENT')或hasRole('ADMIN')"):患者和管理员都能查询预约

需要注意的是,Spring Security默认角色名需要加ROLE_前缀,如果你的数据库里角色字段存的是“ADMIN”,就需要在UserDetailsServiceImpl里拼成“ROLE_ADMIN”。这个坑很小,但不改就会报403。

另外,这套源码没有把权限字段塞进Controller里硬编码,而是通过一个PermissionChecker工具类来统一判断“当前登录用户是否对某个患者/医生有操作权限”。比如护士可以改病历,但只能改自己诊所的病历;患者只能看自己的就诊记录。这类数据级权限用注解无法覆盖,我的做法是在Service层调用checkPatientAccess(patientId),如果当前用户不是该患者也不属于诊所角色,就抛出一个自定义的BusinessException。

4.3 预约模块的事务边界:锁号与防超卖

预约模块是整个系统最容易出并发问题的地方。同时候诊的号源只有20个,如果两个患者同时点预约,数据库如果不加控制,就会出现超卖。源码里我在AppointmentService.createAppointment方法上加了@Transactional,并且用SELECT FOR UPDATE锁住排班行:

SELECT * FROM schedule WHERE id = #{scheduleId} FOR UPDATE;

然后再判断剩余号源是否大于0,如果大于0就插入预约记录并扣减号源,否则抛出“号源已满”。这里有一个细节:@Transactional必须加在public方法上,并且不能在同一个类内部调用,否则代理失效。我单独写了ScheduleLockService来封装锁和扣减操作,保证了事务边界清晰。

很多人会觉得这种小诊所不会遇到并发,但预约高峰时一个号源被抢的情况我真正遇到过。用FOR UPDATE会锁行,对性能和吞吐有一定影响,但对于这个体量的系统完全够用。

4.4 MyBatis动态SQL的经典场景

MyBatis在这套系统里最大的价值是动态SQL。医生筛选页面支持按科室、职称、排班状态、评价星级等条件组合查询,我通过<if>标签拼SQL,不会出现拼接字符串的SQL注入风险。举一个例子:

<select id="selectDoctorPage" resultMap="DoctorResultMap"> SELECT d.* FROM doctor d WHERE 1=1 <if test="department != null and department != ''"> AND d.department LIKE CONCAT('%', #{department}, '%') </if> <if test="title != null and title != ''"> AND d.title = #{title} </if> <if test="minRating != null"> AND d.rating >= #{minRating} </if> ORDER BY d.rating DESC </select>

这里WHERE 1=1是MyBatis动态SQL中常见写法,能避免每个if都要判断是否已经存在WHERE关键字。有些团队为了好看会用<where>标签替代,效果是一样的,我选择了1=1,因为它的语义更直白。但是要注意:这些动态条件如果没有数据库索引,查询会慢,所以后面第5节会专门讲索引设计。

4.5 全局异常处理与统一返回结构

错误处理分三层。第一层是Controller方法参数校验,通过@Validated注解和实体类上的@NotBlank等约束来拦截;第二层是Service业务异常,抛出BusinessException;第三层是兜底异常,例如数据库死锁、空指针。我在GlobalExceptionHandler里用@RestControllerAdvice统一捕获:

@ExceptionHandler(BusinessException.class) public Result<?> handleBusinessException(BusinessException e) { log.warn("业务异常: {}", e.getMessage()); return Result.error(e.getCode(), e.getMessage()); }

返回结构统一是Result<T>,包含code、message、data三个字段。前端Axios的响应拦截器拿到的永远是这三个字段,不需要判断后端错误是HTTP 500还是200,只要code不等于200就弹错误提示。

这套设计被很多项目借鉴过,但它真正的价值在于:出现问题的时候,日志里能看到业务异常和具体信息,不会像裸Throwable那样把堆栈抛给前端。

5. 数据库设计:ER图背后的索引策略与隐私保护

5.1 核心表结构与关联关系

数据库是MySQL 5.7,存储引擎InnoDB,默认字符集utf8mb4。一共12张表,我把核心表列出来:

  • sys_user:用户表,包含username、password(BCrypt加密)、role、status
  • patient:患者表,关联sys_user的user_id,包含name、phone、allergy_history、chronic_disease
  • doctor:医生表,关联sys_user,包含name、department、title、bio、rating、total_visits
  • schedule:排班表,包含doctor_id、work_date、morning_available_count、afternoon_available_count、status
  • appointment:预约表,包含patient_id、schedule_id、appointment_date、time_slot、status(PENDING/CONFIRMED/COMPLETED/CANCELLED)
  • medical_record:病历表,包含appointment_id、diagnosis、prescription、created_by
  • drug:药品表,包含name、specification、price、stock_quantity、warning_threshold
  • prescription_item:处方明细表,关联病历和药品,包含dosage、frequency
  • payment:收费表,包含appointment_id、amount、payment_method、status
  • rating:评分表,包含patient_id、doctor_id、score、comment、create_time
  • recommend_result:推荐结果表,包含patient_id、doctor_id、score、rank、reason_type
  • operation_log:操作日志表,用于审计

ER关系上,sys_user和patient/doctor是一对一;doctor和schedule是一对多;schedule和appointment是一对多;appointment和medical_record一对一;medical_record和prescription_item一对多;patient和rating是一对多;rating和doctor多对一。

5.2 索引设计的经验:不要在区分度低的字段上建索引

最核心的查询场景是:患者查医生列表、按日期查排班、查某个人某天的预约记录。所以我在这些字段上加了索引:

  • doctor.department:普通索引,虽然区分度不高,但会用于科室筛选
  • schedule.work_date:普通索引,日期查询频率最高
  • schedule.doctor_id:普通索引,同时该字段本身是外键
  • appointment.patient_id和appointment.schedule_id:联合索引 (patient_id, status)
  • rating.doctor_id:普通索引,因为推荐算法要统计某个医生的所有评分
  • recommend_result.patient_id:普通索引,推荐接口直接查

一个常见的误区是给status字段单独建索引。其实预约状态只有四五个取值,区分度极低,单独建索引大概率不会被优化器选中,反而增加维护成本。我采取的方案是用联合索引(patient_id, status),这样既能快速定位某个患者的预约,又能按状态过滤。

5.3 患者隐私与数据安全:加密是底线

私人诊所的医疗数据涉及隐私,这套系统在安全上做了三层处理:

  • 密码加密:使用Spring Security的BCryptPasswordEncoder,数据库里永远不存明文密码。
  • 敏感字段加密:患者的过敏史、慢性病、病历诊断等用AES对称加密后存库。密钥配置在服务端环境变量中,不落库。这里有个细节:加密字段不能被LIKE模糊查询,所以搜索患者只能靠姓名和手机号,而这两个字段不加密。
  • 访问审计:通过AOP切面记录每个用户的操作日志,包括操作时间、方法名、参数MD5摘要。一旦发生信息泄露,可以回溯源头。

有些开源系统为了省事,病历表直接明文存,一旦数据库被拖库,患者隐私就完全暴露。我强烈建议所有涉及医疗数据的系统都要考虑加密和审计,这不是锦上添花,是底线。

5.4 数据库备份与恢复脚本

日常运维中,我写了一个简单的备份脚本,每天凌晨3点执行mysqldump,保留最近7天的备份文件:

#!/bin/bash BACKUP_DIR=/data/backup/mysql DATE=$(date +%Y%m%d) mysqldump -u backup_user -p$BACKUP_PASS --single-transaction --routines clinic > $BACKUP_DIR/clinic_$DATE.sql find $BACKUP_DIR -name "*.sql" -mtime +7 -exec rm -f {} \;

恢复的时候直接mysql clinic < backup.sql即可。这里要特别注意--single-transaction参数,如果用InnoDB,不加这个参数会锁表,影响正在使用的诊所业务。

6. 前端Vue工程要点:路由守卫、状态管理与长列表优化

6.1 从Vue2+Element UI落地的一套成熟模板

源码前端使用的是Vue2 + Element UI。我不是没考虑过Vue3,但Element UI生态和一套老代码的稳定性让我选择先做业务跑通。如果你打算参考,也可以用Vue3+Element Plus,核心逻辑完全兼容。

页面布局是左侧菜单+右侧内容区的经典结构。菜单项根据角色动态渲染,医护人员登录后看到的是“排班管理”“病历管理”,患者登录后看到的是“医生列表”“我的预约”,超管则能看到全部。这个动态菜单的逻辑写在store/modules/menu.js中,通过解析后端传的permissions数组生成。

6.2 前端路由守卫:无token一律踢到登录页

路由守卫是我比较得意的部分。在router.beforeEach里,我做了三件事:

  1. 判断是否已登录(localStorage中是否有token)。
  2. 判断是否初次进入系统,是则从后端拉取用户信息和权限数据并写入Vuex。
  3. 判断当前路由是否需要特定角色,如果用户角色无权限,则重定向到404。

一个新手常犯的错误是只在前端判断路由易被绕过,所以我后端接口同样做了权限校验,前端路由守卫只是用户体验层面的过滤,真正的安全边界永远在后端。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); return; } if (token && to.path === '/login') { next('/dashboard'); return; } next(); });

6.3 Axios封装:请求拦截器与401统一处理

请求封装在src/utils/request.js中。每次请求自动带上Authorization: Bearer <token>,响应码如果为401,说明token失效或过期,此时自动清空本地token并跳转登录页。还有一个被很多人忽略的细节:后端返回的日期格式在不同浏览器解析不一致,我在响应拦截器里把ISO字符串统一转成YYYY-MM-DD HH:mm:ss格式,避免时区显示问题。

6.4 医生列表页的性能优化

医生列表页是系统访问最频繁的页面,它同时加载医生信息、当前排班状态和“推荐指数”。如果直接加载所有医生,并且每行都去请求排班接口,页面会卡到无法使用。我的优化方案是:

  • 后端提供聚合接口/api/doctors/overview,一次性返回分页后的医生列表和对应排班状态,前端不再发N次请求。
  • 列表采用虚拟滚动,尽管数据量不大(不超过100条),但我用Element UI的el-table加height属性固定表格高度,让滚动更流畅。
  • 推荐位单独提出来,放在页面顶部,复用recommend_result表的数据,而不是在列表里强行置顶。这样要推荐和要全量列表的解耦,互不影响。

6.5 状态管理的边界:别把所有东西都塞进Vuex

很多全栈项目把挂号流程、患者信息、医生列表全部塞进Vuex,导致状态混乱。我的这套系统里,Vuex只承担四个字段:token、userInfo、roles和dynamicRoutes。所有服务端数据都放在组件局部data中,配合computed和watch来响应。这样的好处是组件销毁时数据自动释放,不会出现内存泄漏,也更方便做单元测试。

如果你接手这套源码,请记住一条经验:不要看到某个数据在多个组件里用就把它放Vuex,真正的共享状态只有用户身份信息。其余完全可以子组件之间通过props和emit交互,避免全局状态依赖。

7. 运行与部署全流程:从MySQL初始化到Nginx反向代理

7.1 环境准备:JDK、Node、MySQL缺一不可

这套系统对运行环境的要求很低,但有几处版本匹配要注意:

  • JDK必须用1.8及以上(我用的1.8,Spring Boot 2.7)
  • Node.js用14或16版本,Vue CLI构建时不要用Node 17+,否则会在OpenSSL问题上报错
  • MySQL 5.7或8.0均可,但连接串里要显式加useSSL=false&serverTimezone=Asia/Shanghai,不然启动时会报SSL警告或时区异常

克隆源码后,首先创建数据库并执行项目根目录的doc/sql/init.sql,该脚本建库、建表、插入基础管理员账号(默认admin/admin123,首次登录后强制改密)。然后修改application.yml中的数据库连接、Redis连接(本项目没有强制用Redis,推荐结果直接存MySQL,所以不需要Redis也能跑)、JWT密钥。

7.2 后端打包与启动

后端用Maven管理,直接执行:

mvn clean package -DskipTests java -jar target/clinic-system.jar --spring.profiles.active=prod

如果要把jar包放到服务器上,建议使用systemd管理进程,这样崩了能自动重启。我写了一个clinic.service单元文件,Out of Memory时也会自动拉起。这一步虽然简单,但很实用。

7.3 前端构建与Nginx配置

前端构建输出到dist目录:

npm install npm run build

然后将dist目录上传到服务器的/var/www/clinic/dist,配置Nginx反向代理/api/请求到后端端口8080,同时开启gzip压缩,让首屏加载从3秒降到1秒内。配置片段如下:

server { listen 80; server_name clinic.example.com; gzip on; gzip_types text/plain text/css application/json application/javascript; location / { root /var/www/clinic/dist; index index.html index.htm; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

这里try_files $uri $uri/ /index.html;是SPA路由的关键,没有它刷新页面时会出现404。我第一次部署时忘了加这一行,测试环境一切正常,一上服务器刷新就404,排查了很久。

7.4 部署上线的三个常见坑

实跑过程中有几个高频坑,我单独列出来:

  • 跨域问题:如果前端用localhost:8080访问后端的localhost:8081,必须在前端跨域代理或者后端配置CorsFilter。源码中我配置了后端允许http://localhost:8080来源,如果线上域名不同,需要改CorsConfig里的allowedOrigin。
  • MySQL连接超时:默认的wait_timeout是8小时,后端使用连接池后,超过8小时没有请求,连接会被MySQL断开。我的application.yml里配置了spring.datasource.hikari.max-lifetime=120000,比MySQL的wait_timeout小,避免Communications link failure。
  • 时区问题:如果不指定serverTimezone=Asia/Shanghai,插入时间会少8个小时,推荐任务会在凌晨执行但你以为是下午执行的。

8. 源码复盘:几个“当年没想明白”的问题

8.1 为什么协同过滤没有一开始就带来转化率提升

系统上线前两周,推荐模块的点击率其实低于热门医生推荐。我差点要把协同过滤下掉,后来排查数据发现:新患者占比大,行为数据不足,冷启动策略兜底太保守。我把冷启动阈值从“行为数小于3”改成“小于1”,也就是只要有累计两次预约就进入协同过滤计算,效果立刻好了很多。这个教训说明,调参前一定要看清数据分布。

8.2 为什么用MyBatis而不是JPA

JPA在实体关系映射上很省事,但多表关联查询时容易产生N+1和隐式性能问题。比如医生列表要显示今天的排班班次,如果用JPA的@ManyToOne懒加载,很容易在循环里一条条查数据库。用MyBatis则可以在XML里写一条LEFT JOIN查询,性能可控。开发速度上,MyBatis的XML略繁琐,但我更看重查询的可理解性。尤其是推荐模块的统计SQL,写复杂子查询时,MyBatis比JPA更直观。

8.3 为什么不用微服务拆分用户和推荐服务

私人诊所规模撑不起微服务,如果一上来就上Spring Cloud,会增加部署和运维成本。我最终把推荐逻辑放在job包中,和业务模块同库同进程,每小时定时计算,虽然计算过程可能占用一些CPU,但当前几千用户数据规模,完全没有问题。如果未来要扩展到连锁诊所,可以把这个任务拆成独立服务,但那是后话了。

8.4 前端和网速搭台,算法唱的是什么戏

最后想分享一个和算法无关但很重要的经验:推荐系统的价值并不全在模型本身,而在于展示方式。我把推荐结果放在医生列表顶部,加上一句文案“根据相似患者的选择推荐给你”,患者点击率提升了一倍。这背后的原因是透明度:患者知道为什么被推荐,信任感就会增加。所以在源码中,recommend_result表专门存了reason_type字段,前端据此展示不同的推荐理由。这套系统最好的地方不是用了协同过滤,而是把算法结果包装成了患者能理解的推荐理由,让技术真正服务于业务。

以后如果再让我做类似的诊所系统,我会在数据埋点上下更多功夫——记录用户何时点击、何时跳过推荐结果,这些行为数据反过来能让推荐算法更精准。技术上的协同过滤算法并不是终点,它只是起点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询