做了几年的医疗信息化相关项目SpringBootVue3MyBatis这套组合在医院病历管理系统里算是很成熟的路子了。这次把整个项目的设计思路、表结构、前后端实现细节和踩过的坑完整梳理一遍给正在做类似系统或者准备拿这类项目练手的朋友做个参考。这套系统解决的核心问题很明确把医院里纸质病历电子化让医生能快速写病历、查历史、开处方让管理员能管科室、管账号、看数据。技术层面覆盖了RBAC权限控制、复杂查询分页、事务处理、前后端分离联调、文件上传这些高频场景学完这套东西市面上大部分管理系统的开发套路基本都能拿下来。适合看这篇内容的人有三类一是正在学Java后端、想完整跑通一个全栈项目的同学二是准备面试、要拿真实项目经验讲细节的求职者三是做医疗信息化周边产品、需要快速理解病历系统业务模型的产品和技术人员。1. 项目整体定位与技术选型为什么这套组合最适合病历管理1.1 病历管理系统到底在管什么先把业务边界理清楚。病历管理系统不是单纯的CRUD它围绕一次就诊这条主线展开患者先挂号建档医生接诊后书写病历主诉、现病史、既往史、体格检查、初步诊断然后开医嘱或处方最后形成一份完整的就诊记录。整个流程里涉及的数据有患者基本信息、医生信息、科室信息、病历文书、诊断结果、处方明细还有病历模板、附件影像等辅助数据。从角色权限看系统至少要分三类人管理员管全院的基础数据和账号、医生读写自己科室的病历、护士或门诊人员可能只读部分信息。权限模型直接决定了后台接口的设计方式前端菜单也要跟着角色动态渲染。这个业务模型非常有代表性它包含了主数据管理 核心业务流转 多角色权限 数据统计四大模块跟电商后台、教务系统、项目管理系统在架构上没有本质区别。所以做完这套病历系统你收获的不是医疗知识而是一整套企业级管理系统的方法论。1.2 技术栈选型的三个核心理由先说SpringBoot。病历系统这种业务表的数量在二三十张左右接口数量上百个SpringBoot的自动装配和Starter机制能极大减少配置工作量。更重要的是它的生态整合MyBatis、Spring Security或者JWT、文件上传、定时任务基本都有现成方案团队协作时上手成本也低。项目里我遇到过一次因为Jackson序列化配置不对导致日期格式全乱的场景SpringBoot里一行配置就能解决换成别的框架可能得折腾半周。再聊Vue3。前端这边的核心痛点是病历编辑和患者列表这种高频交互页面。Vue3的组合式API让逻辑复用变得非常舒服比如患者搜索的逻辑、分页逻辑、表单校验逻辑都可以抽成自定义hook多个页面共用。加上Element Plus的组件库非常全表格、表单、弹窗、上传组件都有开发效率比手写UI高一个量级。我实际开发里用的是Vite作为构建工具冷启动速度比Webpack快太多改完代码热更新几乎是秒级体验很好。最后说MyBatis。有人会问为什么不直接用MyBatis Plus或者JPA。病历系统有一个特点报表统计、病历检索、多条件组合查询特别多比如查询某科室某时间段内、某诊断类型的病历数量这类动态SQL用MyBatis的XML写if判断非常顺手SQL的可控性是最强的。MyBatis Plus虽然在单表CRUD上有优势但遇到复杂查询最后还是得写XML为了减少依赖我在这个项目里直接用原生MyBatis配合PageHelper做分页效果很好。1.3 前后端分离架构的收益和代价前后端分离现在几乎是新项目的默认选项。前后端团队或两个人分别开发可以并行推进后端定义好接口文档就能开工前端用Mock数据同步开发工期压缩很明显。部署上也灵活后端打包成Jar包前端构建成静态文件扔到Nginx里后端挂了前端页面照样能打开只是接口请求失败而已。但这套架构也有几个坑要提前想到。第一个是跨域问题前端跑在5173Vite默认端口后端跑在8080两者端口不同必然触发浏览器同源策略。我用的方案是前端Vite配置代理把/api开头的请求转发到http://localhost:8080这样浏览器看到的请求是同源的不在后端做CORS全局配置减少安全风险。第二个是鉴权前后端分离后Session不太好用我用JWTJSON Web Token方式登录成功后后端返回token前端存起来每次请求在Header里带着后端拦截器统一校验。第三个是接口文档的维护项目里我用了SwaggerSpringBoot 3.x对应的是springdoc-openapi自动根据注解生成接口文档前端直接对照文档联调省了大量口头沟通。2. 数据库核心设计与表模型拆解2.1 以患者主索引为中心的表关系设计病历系统的表设计有一条隐形主线患者主索引Patient Master Index。所有跟这个患者相关的记录都要通过患者ID关联起来。我设计的核心表结构大概是这样用户表sys_user存医生、管理员、护士等系统用户的账号、密码、姓名、科室ID。密码字段我用BCrypt加密存储绝不能明文。角色表sys_role和用户角色关联表sys_user_role管理用户的角色前端菜单和后端接口都按角色来控制。患者表patient存储患者的姓名、性别、出生日期、身份证号、联系电话、过敏史等基本信息。身份证号要有唯一约束这是患者主索引的关键。科室表dept科室名称、编码、负责人等信息。病历主表medical_record一次就诊一份主记录包含就诊时间、患者ID、医生ID、科室ID、主诉、现病史、既往史、体格检查、初步诊断、就诊状态等。这是最核心的表。诊断表diagnosis病历主表关联诊断明细因为一次就诊可能有多个诊断主要诊断和次要诊断所以单独拉一张表包含ICD编码、诊断名称、诊断类型。处方表prescription和处方明细表prescription_item处方头部存医嘱类型、开具时间、医生ID明细表存药品名称、规格、数量、用法用量。病历模板表medical_template存各类病历的书写模板比如门诊初诊模板、住院大病历模板方便医生写病历时直接套用。附件表medical_attachment存检查报告图片、影像文件的路径和关联的病历ID。这个设计的核心思想是一中心多张业务表。无论系统以后怎么扩展加住院模块、加检验模块只要患者ID不变所有的历史数据都能通过这个ID串联起来。实际落地的时候我特别建议画一张实体关系图ER图再建表。纸上谈兵五分钟后面省的是几天的返工。比如医生和病历的关系一开始我只设计了创建人ID后来发现一个病历可能需要多个医生协作经治医生和主任查房才在病历表里加了主治医生ID和记录医生ID两个字段。2.2 几个关键字段的设计权衡病历内容字段的类型选择。病历主表里的现病史和体格检查我最终用的是TEXT类型存富文本HTML。有些系统用JSON结构存比如把体格检查拆分成体温、血压、心率等结构化字段好处是便于统计但代价是灵活性差医生在临床上习惯按段落写一个文本框能表达的事拆成二十个字段会很痛苦。折中方案是主文本用TEXT存富文本同时把体温、血压这种高频统计字段单独拿出来存两条路都兼顾。逻辑删除还是物理删除。医疗数据有合规要求历史病历不能随便删。所以核心业务表病历表、处方表我都加了is_deleted字段默认0删除时更新为1查询时统一加where is_deleted 0条件。只有用户表、角色表这种管理类数据才考虑物理删除。时间字段的统一约定。我用的是create_time和update_time两个字段类型为datetime后端用MyBatis的自动填充功能insert时填create_time和update_timeupdate时只填update_time这样代码里不用手动维护时间。还有一个容易忽略的点所有时间字段都要约定好时区MySQL连接串上必须加serverTimezoneAsia/Shanghai否则查询出来的时间默认差8小时。这个坑我见太多了每次新同事遇到日期不对的问题基本都是这个原因。状态字段的设计。我用tinyint类型0和1是两个基础状态比如就诊状态0待诊1就诊中2已完成3已归档。状态字段最好用枚举类在后端做一层映射避免业务代码里到处是魔法数字。2.3 核心SQL示例多条件分页查询和时间线病历列表查询是系统里最核心、也是最容易写差的SQL之一。需求是按患者姓名、科室、诊断类型、就诊时间段组合查询分页返回还要带出患者姓名和医生姓名。先用PageHelper分页核心SQL用MyBatis的XML写动态条件大致结构如下select idselectRecordPage resultTypemap SELECT mr.id, mr.visit_time, p.name AS patient_name, u.name AS doctor_name, d.name AS dept_name, mr.primary_diagnosis, mr.status FROM medical_record mr LEFT JOIN patient p ON mr.patient_id p.id LEFT JOIN sys_user u ON mr.doctor_id u.id LEFT JOIN dept d ON mr.dept_id d.id where if testpatientName ! null and patientName ! AND p.name LIKE CONCAT(%, #{patientName}, %) /if if testdeptId ! null AND mr.dept_id #{deptId} /if if teststartTime ! null AND mr.visit_time gt; #{startTime} /if if testendTime ! null AND mr.visit_time lt; #{endTime} /if AND mr.is_deleted 0 /where ORDER BY mr.visit_time DESC /select这里有个实战细节动态SQL里用 做条件拼接时最后一个AND mr.is_deleted 0务必放在 标签内部这样如果前面没有条件MyBatis自动去掉开头的AND不会生成语法错误。另外LIKE查询用CONCAT拼接百分号不用$直接注入避免SQL注入风险。按患者查看历史就诊时间线也是一个高频功能其实就是一条SQLSELECT id, visit_time, primary_diagnosis, status FROM medical_record WHERE patient_id #{patientId} AND is_deleted 0 ORDER BY visit_time DESC前端把返回结果按时间倒序渲染成时间线组件患者每次就诊记录一目了然。这个功能虽然简单但在用户体验上非常加分。3. 后端SpringBoot关键模块的实现细节3.1 工程分层与统一返回结构整个后端工程我按经典的Controller-Service-Mapper三层结构组织包名按业务模块划分大体长这样com.example.medical ├── common // 通用类Result、全局异常、常量 ├── config // 配置类Swagger、MyBatis、拦截器注册 ├── controller // 接口层只做参数接收和结果返回 ├── service // 业务层写核心业务逻辑和事务 ├── mapper // 数据访问层接口XML ├── entity // 实体类 ├── dto // 接收参数的DTO对象 └── vo // 返回给前端的VO对象还有一个容易被忽视但非常影响开发体验的部分统一返回结构。我的Result类包含code、message、data三个字段成功返回code200业务异常code400未登录或token过期code401。这样做的直接好处是前端Axios响应拦截器可以统一处理所有接口的返回不用每个接口单独判断状态。全局异常处理我用RestControllerAdvice配合自定义业务异常类。Service里只要throw new BusinessException(患者不存在)异常处理器自动捕获并返回给前端标准格式。这个机制让Controller里的代码非常干净不用每个接口都写try-catch。3.2 登录认证与权限控制实现登录流程用的是JWT 拦截器方案。用户提交账号密码后端BCrypt验证密码登录时用PasswordEncoder.matches方法校验验证通过后用用户ID和角色生成token返回给前端。生成token我用的jjwt库密钥放在application.yml里为了安全项目里至少要换成32位以上的随机字符串用环境变量注入不要写死在代码里。拦截器是整个鉴权的核心。我在WebMvcConfig里注册一个LoginInterceptor对所有接口生效排除登录接口和Swagger文档接口。拦截器里做的事很简单取请求头里的Authorization字段去掉Bearer前缀解析token验证签名和过期时间通过后在ThreadLocal里存当前用户信息后续的Service层从ThreadLocal取值这样接口不需要在参数里来回传用户ID。权限控制这块我用了一个自定义注解RequireRole标注在Controller方法上比如管理员接口可以这样写RequireRole(ADMIN) GetMapping(/stats) public Result getStats() { // ... }拦截器里解析完token后拿当前用户的角色列表跟注解要求的角色比对不匹配直接返回403。这种做法比Spring Security简单直接对这类系统完全够用而且代码量少、容易理解。如果以后权限模型变复杂了再平滑迁移到更重的权限框架也不难。因为医疗数据极其敏感我特意对两个点做了加强一是登录错误不区分用户不存在和密码错误统一返回用户名或密码错误防止账号探测二是密码必须用BCrypt加密不能用MD5虽然MD5加盐也能用但BCrypt自带随机盐且计算速度可调安全性高得多。3.3 病历CRUD里的关键事务与数据权限新增病历是整个系统里事务最复杂的一个接口。医生保存一份完整病历后端要同时写三张表病历主表插入一条记录诊断表插入N条诊断处方表插入一条处方头部信息处方明细表插入M条药品明细。任何一步失败都应该整体回滚。我用Transactional注解标记在Service方法上默认遇到RuntimeException就会回滚这里必须注意一个问题自己catch住异常后不重新抛出事务是不会回滚的。正确的做法是catch里记录日志后继续抛或者干脆不catch让全局异常处理去兜底。数据权限是我在这个系统里特意做的需求普通医生只能看到自己科室的病历科主任能看全科管理员能看全院。实现方式不复杂在SQL查询条件里动态拼数据范围if (userRole.equals(DOCTOR)) { // 只查自己deptId当前用户的科室 query.setDeptId(currentUser.getDeptId()); } else if (userRole.equals(DEPT_MANAGER)) { // 也只查自己科室但要能看所有医生的病历 }控制前端是一次会话只能看一个科室后端SQL里永远要带权限条件。后期如果扩展到全院只要把deptId传 -1不拼接就行。我一直坚持后端永远不能信任前端传过来的参数所有权限判断都必须在后端做第二道校验。病历查询接口我实际开发时也加了同样的限制否则知道接口地址的人可以直接遍历患者数据。3.4 MyBatis使用心得XML与注解的选择项目里我的Mapper接口分层两种简单的单表操作直接用注解比如Select(select * from dept)这种复杂的动态SQL一律用XML。选择依据很简单SQL超过三行或者需要动态条件就交给XML。原因是注解里写动态SQL要把