尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Spring Boot的家庭医生服务管理系统:签约随访转诊全链路实践

基于Spring Boot的家庭医生服务管理系统:签约随访转诊全链路实践 这个项目我来复盘一下。它的切入点不大但牵扯到的业务流和工程细节一点都不少签约、建档、随访、转诊再加上医护人员的权限、文件存储、定时提醒任何一个环节做粗糙了系统上线之后都会被投诉淹没。我做完这套基于Spring Boot的家庭医生服务管理系统之后最大的感受是——这类业务系统真正考验人的不是某个炫技功能而是把整个履约链路串起来之后还能稳定跑、能维护、能扩展。下面我从需求拆分讲起把技术选型、数据设计、核心链路、权限体系、部署踩坑和优化调整全部展开尽量把关键决策背后的“为什么”也讲清楚。1. 需求先行家庭医生服务管理系统到底在管什么1.1 业务场景拆解从“签约”到“履约”的五条主线家庭医生服务不是“挂号看诊”那么简单它的核心是长期、连续、主动的健康管理。我刚开始梳理需求时业务方提了三十多个功能点听起来都很急但如果直接照着做系统一定会变成一个四不像。后来我们把业务拆成了五条主线签约、建档、随访、转诊、考核统计。签约是起点解决“居民和哪个医生团队建立了服务关系”的问题建档解决“居民的基础信息和健康状况底账”的问题随访解决“签约之后医生团队有没有按时主动联系居民”的问题转诊解决“基层看不了向上级医院转稳定后再接回基层”的问题考核统计是给管理方看的用来衡量服务量、履约率、慢病管理率。这五条主线有严格的先后关系没有签约就不该有后续的健康干预行为没有健康档案随访和转诊就缺少数据支撑。所以数据库表结构设计和接口设计都不能把这几条线做成孤岛。1.2 用户角色画像与核心诉求这个系统里的用户不能简单分成“管理员”和“普通用户”至少要看清楚五类角色居民端用户通过小程序或公众号查看签约状态、接收随访提醒、查询健康档案。他们的诉求是“不用跑腿、信息别泄露、提醒别太频繁”。家庭医生要维护签约居民档案、填写随访记录、发起转诊。诉求是“录入要快、别重复填表、手机也能操作”。公卫护士/助手很多随访工作由护士先做初步筛查再由医生确认。诉求是“任务清单清晰、表单可复用”。基层机构管理员管理本机构的医生团队、分配签约任务、审核转诊申请。诉求是“能管住人、能看清数据”。卫健委/医院集团管理者看区域报表按机构、按团队对比签约数和履约率。诉求是“报表准确、可穿透查询”。我在设计权限模型时不是一开始就做很细的“按钮权限”而是先把这五类角色对应的菜单、接口能力、数据范围定清楚后面再往细里加。1.3 功能边界哪些必须做哪些第一版可以不做需求评审时最容易犯的错是“什么都要”。我当时的判断是第一版只做四个必须闭环的功能电子签约、健康档案、随访管理、双向转诊。配套做用户权限、文件上传、消息提醒、数据统计。第一版明确不做的有三块在线问诊和签约服务虽然有关联但涉及诊疗行为复杂度和合规要求完全不同、药品配送物流交互太复杂、对接医保结算接口依赖地方医保平台不确定因素太多。这三块放到二期但在数据库设计时预留了扩展字段和关联标识比如签约订单表留了“服务包类型编码”后续接在线问诊时不用改大表结构。这个收敛思路很重要否则以Spring Boot的快速开发能力很容易一周就堆出几十张表和一百多个接口但每个都是半成品。2. 技术选型为什么要拿Spring Boot当底座2.1 Spring Boot在这个项目里解决的核心问题这类业务系统团队成员的Java水平可能参差不齐有刚毕业的也有从PHP转过来的。Spring Boot最值钱的地方不是“写接口快”而是把Spring家族里那些复杂的配置变成了约定俗成的启动逻辑让整个团队可以只关注业务代码。我特别看重三点。第一是依赖管理spring-boot-starter-parent把常用第三方库的版本统一管理避免出现Jar包冲突和ClassNotFoundException。第二是自动配置引入spring-boot-starter-web、spring-boot-starter-data-redis、mybatis-plus-boot-starter之后只要在application.yml里写连接参数就能直接注入对应的客户端Bean。第三是内嵌Tomcat部署时一个jar直接跑起来避免了传统Servlet容器版本和应用的纠缠。Spring Boot的自动装配原理我建议项目组成员至少理解到“EnableAutoConfiguration配合spring.factories / AutoConfiguration.imports文件按条件装配Bean”这一层。否则遇到“为什么我加了依赖还是Autowired报错”就会一头雾水。2.2 自动装配原理与常用starter的选择我在项目中实际使用的核心依赖基本就是下面这张表每个都写明用途和选择理由依赖作用选择理由spring-boot-starter-webWeb服务、REST接口内置Tomcat开箱即用spring-boot-starter-validation参数校验用注解替代手工if判断mybatis-plus-boot-starterORM、分页、条件构造减少单表CRUD代码量mysql-connector-j数据库驱动与MySQL 8.x配套spring-boot-starter-data-redis缓存、验证码、分布式锁高并发下的热点数据和幂等控制spring-boot-starter-websocket服务端主动推送提醒随访任务生成后实时通知医生端minio-java对象存储健康档案文件可本地化部署spring-boot-starter-quartz定时任务随访计划需要支撑cron表达式jjwtJWT令牌生成与校验无状态登录态适合前后端分离这里要特别注意版本匹配。我一开始用了Spring Boot 3.x加旧版mybatis-plus结果报了一堆和javax与jakarta命名空间有关的错误。Spring Boot 3.x开始把javax.servlet迁到了jakarta.servlet很多老库如果不升级会直接启动失败。如果团队不熟悉这次迁移稳妥的选择是用Spring Boot 2.7.x并且把mybatis-plus、minio、quartz的版本统一从maven中央仓库里查兼容版本。2.3 关键中间件MySQL、Redis、MinIO、WebSocketMySQL用的是8.0字符集utf8mb4排序规则utf8mb4_0900_ai_ci这样才能安全存居民的姓名生僻字和体检报告里的特殊符号。Redis在系统里的角色不是纯缓存还承担了三件脏活累活一是登录验证码和短信验证码的临时存储设置过期时间和发送冷却时间二是签约提交时的幂等处理用“居民ID服务包ID当天日期”作为key配合setIfAbsent做防重点三是医生端今日待办列表的短期缓存减少数据库压力。MinIO用来存签约协议扫描件、体检报告PDF、随访照片。为什么不直接存MySQL的BLOB因为文件越存越多数据库备份会变得非常沉重而且业务查询不应该把大字段拉出来。MinIO部署在公司内网一台4核8G的机器上就够了bucket按机构划分对象名用“机构ID/居民ID/时间戳.pdf”这种方式方便排查。WebSocket用在两个地方医生端有新的随访任务时实时弹提示居民端签约审核通过后小程序端实时刷新状态。为了避免服务器集群环境下WebSocket连接分散的问题我第一版做了单节点部署并在Nginx配置了ip_hash业务量上来之后再引入消息中间件广播。3. 数据库与状态设计把业务流程变成可落地的表结构3.1 核心表关系与字段设计要点我第一版设计了二十多张表核心关系可以用一句话描述居民基础档案是一等公民签约表、随访表、转诊表都通过居民ID和医生团队ID与它关联。重点说几张表的设计思路。居民健康档案表resident_profile不直接存所有详情字段而是分成“基础身份信息”和“健康扩展信息”。基础字段包括姓名、身份证号、手机号、现住址、户籍地址、血型、过敏史健康扩展字段包括慢病标签高血压、糖尿病等、既往手术史、家族病史、生活嗜好。因为扩展字段会随着体检数据不断补充所以单独建表主键关联居民ID。签约订单表sign_order是整条业务线的核心。字段上除了居民ID、医生团队ID、服务包ID、签约开始日期、结束日期还必须有状态字段和签约来源字段。状态我用了字符串类型存待审核、已生效、已解约、已过期比数字枚举可读性好排查数据问题的时候不用翻译数字。随访记录表follow_up_record除了记录随访日期、随访方式电话/上门/门诊、随访医生ID、随访结论之外还要记录“本次随访对应的计划任务ID”这样才能把“计划生成”和“执行结果”串起来统计履约率才不会算错。转诊表referral_order除了常规的转诊原因、转出机构、转入机构、转诊时间、转诊科室之外还要有“回写状态”字段。上级医院看完之后是否回传了诊疗意见是否建议转回基层这个闭环不记录转诊就成了单向发射。3.2 状态机设计签约、随访、转诊的流转控制业务状态不能靠每个接口里随意update我单独写了状态校验逻辑简单说就是“只允许从合法前置状态流转到目标状态”。签约状态流转待审核 → 已生效 待审核 → 已驳回 已生效 → 已解约 已生效 → 已过期随访任务状态流转待执行 → 已完成 待执行 → 已逾期 待执行 → 已取消 已完成 → 已复核医生对护士的记录做二次确认转诊状态流转待审核 → 已转出 待审核 → 已驳回 已转出 → 已接诊 已接诊 → 已回写我在service层抽了一个StateValidator用Map把当前状态和允许动作维护在代码里。这样别人接手代码时看这个Map就能快速理解业务规则不需要翻文档。3.3 数据隔离与软删除设计家庭医生数据是敏感度很高的个人健康信息所以数据隔离不能只靠代码里写where条件。我在每张业务主表都加了org_id机构ID和team_id团队ID。MyBatis-Plus有拦截器机制可以自动往查询条件里拼接数据权限片段。我们给医生账号配置的是“本团队数据”给机构管理员配置的是“本机构数据”给区域管理者配置的是“区域下所有机构数据”。但有个坑mybatis-plus的自动填充和拦截器如果一起用容易把分页计数的SQL也拼上奇怪的条件。我最终没有过度依赖拦截器而是在service层通过DataScopeHelper显式传入数据范围参数接口可读性更好也方便测试。软删除我用的是逻辑删除字段deleted默认0删除时置1。注意所有涉及唯一索引的字段要拼上deleted否则同一居民重复建档时会因为索引冲突报错。签约表里我对(resident_id, deleted)做联合索引简历的“重启签约”需求就不会出幺蛾子。4. 核心链路实战签约、建档、随访、转诊一条龙4.1 家庭医生签约与身份校验签约流程看起来只有“选医生团队、选服务包、提交”实际上涉及几个容易忽略的校验。第一是年龄校验。不同年龄段的签约服务包不同老年人有免费体检包孕产妇有产检随访包儿童有预防接种提醒包。后端不能只信前端传来的服务包ID要根据居民身份证号解析出的年龄反查服务包适用范围不一致直接拒绝。第二是重复签约校验。一个居民在同一个服务周期内不能同时签约两个同类型的团队。我用Redis做前置判断数据库再加唯一索引兜底。Redis的key设置过期时间为签约周期加一天防止到期解约后无法重新签约。第三是待审核状态锁。居民提交签约后如果机构管理员还没审核居民不能再次提交。这个状态机校验放在签约接口的第一行养成习惯能少很多脏数据。签约提交接口的关键代码逻辑大致是这样的Transactional public SignOrderResult submitSign(SignSubmitRequest request) { // 1. 幂等校验同居民同服务包同日内不能重复提交 String idempotentKey request.getResidentId() _ request.getPackageId() _ LocalDate.now(); Boolean first redisTemplate.opsForValue().setIfAbsent(idempotentKey, 1, Duration.ofHours(24)); if (Boolean.FALSE.equals(first)) { throw new BizException(请勿重复提交签约申请); } // 2. 年龄校验、重复签约校验、前置状态校验 ... // 3. 写入签约订单表状态为待审核 // 4. 发送审核通知给机构管理员 }这里有个经验Transactional只能管数据库事务管不了Redis的回滚。如果后面数据库写失败Redis里的幂等key已经存在了用户会以为自己没提交成功再点一次会被幂等挡住。所以我在捕获异常后主动删除幂等key保证失败后可以重试。4.2 健康档案上传与MinIO文件服务集成健康档案资料分为两类一类是居民自己上传的身份证照片、协议签字页另一类是体检机构回传的PDF报告。我在后端做了统一的上传接口文件先传到临时目录校验大小和类型后再传到MinIO然后把对象名、URL、md5写入file_record表。MinIO接入Spring Boot的要点引入minio-java后在配置类里用MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build()创建Bean。bucket不存在时用bucketExists检查不存在则makeBucket。这个逻辑要在启动时跑一次别等用户上传时才暴露配置错误。上传时用putObject最好把contentType一起传进去否则默认的application/octet-stream会导致浏览器打开PDF时变成下载。下载时如果需要鉴权不能用MinIO默认的预签名URL一放了之应该走业务接口校验用户对该档案的访问权限再通过getObject流式返回。不然任何人拿到URL都能看居民的健康报告这是严重的数据泄露事件。文件大小和类型的校验不能放前端必须后端做。我设置了图片最大5MB、PDF最大20MB超出就拒绝。上传接口要同时限制spring.servlet.multipart.max-file-size和max-request-size后者经常被忽略导致一个请求包含多个文件时明明总量不大却报错。4.3 随访任务的定时生成与提醒随访任务不是医生手动一个个建的而是根据签约服务包自动生成。比如高血压慢病管理包要求“每季度至少随访一次”糖尿病包要求“每两月一次”老年人健康管理包要求“每年一次体检并随访”。我用Quartz做了两个定时任务每日凌晨2点扫描所有处于“已生效”状态的签约订单根据服务包的随访频率计算出本周期内应生成的随访任务数量把缺失的任务插入到follow_up_task表。每日早上8点扫描当天到期未执行的随访任务通过WebSocket推送提醒给对应医生团队。这里有一个很实际的问题Quartz在集群环境下会重复执行。如果后面部署到多节点一定要给Job加分布式锁。我用Redis的setIfAbsent做锁key为job:followup:generatevalue为节点ID设置了30秒过期执行完主动释放。单节点部署时这个锁可以省但代码里留着以后上集群不用改逻辑。随访任务生成之后医生在移动端看到的待办列表就不是“自己想起来的”而是系统排好的。这极大提升了使用率因为基层医生确实忙没人天天记着哪个居民该随访了。4.4 双向转诊记录与状态回写转诊链路我在第一版只做了“基层发起转诊”和“接收方确认接诊”两个动作。居民从基层转出后如果上级医院已经处理完需要把处理意见回传到基层系统。我的做法是提供一套open API接收方通过appId/appSecret签名调用Spring Boot端用过滤器校验签名后把结果写入转诊记录表。签名方案不复杂调用方用appSecret对请求体做HMAC-SHA256再把签名值放到Header的X-Sign字段。服务端用同样的算法重算一遍一致则放行。这个方案比直接传token裸奔安全得多而且实现成本低。转诊回写的核心接口逻辑public ReferralResult referralCallback(ReferralCallbackRequest request) { // 1. 签名校验 // 2. 根据转诊单号查出原转诊记录 // 3. 校验当前状态必须是“已接诊”才能回写 // 4. 更新转诊结论、回写时间、是否建议回转 // 5. 如果是“建议回转基层”自动生成一条新的“接收转诊”待办给原机构 }这个闭环做完之后管理方在报表里查“转诊回写率”才有意义。不然只能看到转出去多少单后续结果是什么全黑盒。5. 权限体系家庭医生场景下的RBAC与数据权限5.1 五级角色与操作权限矩阵权限设计我采用了RBAC模型但特别注意了“菜单权限”和“数据权限”要分开。菜单权限决定了用户能看到哪些页面、调用哪些接口数据权限决定了用户在某个接口里能看到哪些机构/团队的数据。角色权限矩阵我维护在数据库的sys_role_menu表里前端根据用户返回的权限编码列表动态渲染菜单。后端每个需要鉴权的接口都用PreAuthorize(hasAuthority(sign:audit))之类的注解声明所需权限。这里有个细节权限编码的命名要统一模块冒号动作比如sign:audit、followup:perform、referral:callback。别用数字ID当权限标识数据库查起来和权限追踪都不方便。5.2 JWT登录态与Token刷新策略登录认证没有用Session因为小程序端和医生App端都是前后端分离架构Session在跨端、跨域名场景下不好用。我选了JWT无状态令牌。签发JWT时payload里放userId、roleId、teamId、orgId过期时间设置为2小时。但2小时强制重新登录对医生来说难以接受所以我加了刷新令牌机制accessToken过期后前端拿refreshToken调用刷新接口refreshToken有效期7天存在Redis里并关联用户ID。这样用户七天内的操作基本无感续期。刷新令牌时要注意不能允许旧的refreshToken无限刷新必须判断Redis里的当前版本和提交的是否一致。每次刷新后把旧的删掉写入新的refreshToken和新的accessToken。这个设计能防止令牌被盗后的长期横向扩散。5.3 数据权限拦截的实现思路我最终采用的是“注解ThreadLocal”的方案定义DataScope(type TEAM / ORG / REGION)注解在Controller方法上声明切面里根据当前登录用户的角色把数据范围条件解析成SQL片段放到一个ThreadLocal对象里Service层获取到这个SQL片段后拼接进查询条件。举例医生查待随访列表时数据范围是“本团队”拼接条件为team_id 当前用户所属团队ID。机构管理员的数据范围是“本机构”拼接条件为org_id 当前用户所属机构ID。区域管理员范围更广通常用region_id org_id in (...)。这个方案比每个Service方法手写if (role xxx)要清爽很多。但要注意ThreadLocal的参数必须在请求结束后清理我是在过滤器finally里调remove()防止线程池复用导致下一个请求拿到上一个请求的数据范围。6. 部署与踩坑记录我在实际项目中遇到的问题6.1 Spring Boot版本太高带来的依赖坑我一开始图省事直接用了当时最新的Spring Boot版本。结果启动时频繁报错后来定位出是第三方starter还不支持新的Jakarta命名空间。尤其是涉及javax.servlet.Filter、javax.validation相关代码的老项目升级Spring Boot大版本不是改个版本号就行。个人建议如果是业务型管理系统不要追新选Spring Boot 2.7.x这种长期维护版本生态兼容性最好。等到将来必须升级3.x时再系统性处理javax到jakarta的迁移。6.2 文件存储路径与MinIO联调问题MinIO的配置上我踩过一个很低级的坑endpoint在服务器上写的是外网IP但容器或者内网请求从另一个内网IP访问MinIO会把生成的预签名URL里的host写成配置的内网地址导致前端下载地址访问不通。解决方式是把MinIO的endpoint统一配置成对外可访问的域名或公网地址并在Nginx里把相应端口反代到MinIO。另外上传文件时Content-Type很重要。有一次体检报告PDF上传后被浏览器直接下载而不是预览排查半天发现putObject时没传contentTypeMinIO存成了application/octet-stream。加上contentType application/pdf之后问题解决。6.3 定时任务重复执行与服务端时区问题Quartz任务刚开始只在一台服务器上部署倒没发生重复执行。后来加了第二台做负载均衡发现凌晨的随访任务生成了两份居民收到重复随访提醒。这个就是前面说的分布式锁没加后来通过Redis锁把生成逻辑变成全局唯一执行。另一个是关于时间的问题服务器timezone如果设置成UTCLocalDate.now()拿到的日期会比北京时间早8小时。凌晨任务在UTC时间跑会在北京时间早上8点才执行但当天任务已经被判定为过期。这个排查费了不少时间。最终解决方案是在Java启动参数里加-Duser.timezoneAsia/Shanghai同时把MySQL连接的serverTimezone也显式写成Asia/Shanghai双保险。6.4 用反编译工具定位“代码和线上行为不一致”的问题有一次线上出现一个诡异Bug本地跑得好好的服务器上却走到了一条预期外的分支。我怀疑部署的Jar包不是最新构建的产物。当时没有打包操作记录也没法直接确认。为了快速定位我用反编译工具CFR / JD-GUI打开线上部署的Jar包找到对应Controller和Service的class文件反编译后和本地源码逐行对比。确认了线上Jar包里的代码确实比本地源码旧缺失了某个状态判断。问题根因清楚了重新构建部署后恢复。这里必须强调反编译只能用于自己的项目或者排查开源依赖不能拿它去逆向别人的商业系统。作为Debug手段它是有效的尤其是你怀疑“打包环境脏、代码和源码不同步”时能省下大把排查时间。7. 实测后的优化清单从“能跑”到“好用”7.1 配置热更新与多环境管理系统上线后最怕的就是改配置要重新发布。我用了Spring Cloud Config或者Nacos来做集中配置但考虑到这个项目规模不算大用Nacos有点重。我改用了一个轻量方案把经常变的配置抽到数据库表sys_config用RefreshScope配合定时刷新缓存来读取。比如签约审核时限、随访逾期判定天数、WebSocket心跳间隔都放在配置表里。管理员页面可以改不用重启服务。只有数据库连接、MinIO密钥这类基础配置才留在application.yml里。7.2 慢查询与Redis缓存优化系统运行了一周后我看了MySQL慢查询日志发现两个高频慢SQL一个是在首页统计签约人数时对签约表做了全表count另一个是查询居民列表时like模糊匹配用了未加索引的字段。首页统计改成定时任务每小时把汇总数据计算好存入Redis查询接口直接读缓存居民姓名、手机号的模糊查询给(name, mobile)建了联合索引并把查询逻辑从%xxx%改成xxx%走前缀索引性能提升明显。7.3 异常统一处理与日志链路追踪业务系统接口在外网环境跑最怕用户上报问题时线上日志无法还原现场。我做了两件事一是全局异常处理器RestControllerAdvice把所有BizException返回统一格式{code, message, traceId}系统异常也记录成结构化日志。二是在过滤器中生成traceId放入MDC所有logback输出自动带上这个ID。用户报问题时只要提供traceId就能在日志平台里把整个调用链路捞出来。日志打印要控制敏感信息居民身份证、手机号在日志里要做脱敏。我之前出现过一次日志把完整身份证打印出来的情况虽然只是内网日志但数据安全这根弦不能松。7.4 移动端适配与前端联调经验虽然技术栈是Spring Boot但这类系统的使用高峰在医生手机上前端用的是Vue3加Vant。前后端联调时最大的坑是接口返回结构不统一有的接口直接返回数据对象有的返回{code, data, msg}前端每次要做兼容判断代码乱得很。后来我定了统一规范所有接口返回ResultT成功code0业务异常code非0HTTP状态码只表示请求是否到达服务端。前端只需判断res.data.code即可不需要为每个接口单独处理。这个规范看起来很基础但能减少大量联调返工。WebSocket推送的消息体也做了版本字段方便以后扩展消息类型。如果推送体和业务代码强耦合以后加一种提醒就要改前端解析逻辑维护成本会高很多。整体做下来这个项目最耗精力的不是某个算法或框架技巧而是各种状态流转和异常分支的补齐。只要把签约、建档、随访、转诊的闭环跑通权限和数据安全做到位再配上一套清晰的后端规范这个系统的骨架就立住了后续往上加功能也顺理成章。
返回列表