当你准备把“SpringBoot电子病历共享系统”作为毕业设计题目时,恭喜你,你选了一个非常“能打”的方向。它既有医疗信息化真实业务场景,又把权限管理、数据共享、文件存储、日志审计这些后端高频考点全部串在了一起。我陪跑过不少毕业设计项目,发现真正让评委眼前一亮的,往往不是那种千篇一律的商城系统,而是这种“业务有深度、技术有落点”的平台型项目。
这篇内容不是给你丢一坨源码让你自己拼,而是把我实际搭建和调试这个系统时踩过的坑、反复权衡过的方案、以及可以直接复制过去改改用的代码片段,全部捋一遍。不管你是刚把SpringBoot跑通的小白,还是准备拿它去答辩的准毕业生,按着这条思路走下来,至少能少折腾一个月。
先记住一句话:这个系统的灵魂不是“写病历”,而是“共享”两个字。病历管理只是基础能力,跨科室、跨机构、甚至跨医院的数据授权和访问,才是你区别于普通增删改查项目的核心亮点。
1. 项目整体设计与思路拆解
1.1 先想清楚:这个系统到底要解决什么问题
去医院看病的人都有体会:在这家医院做的检查,换一家医院就不认了,要么重新排队检查,要么把纸质报告翻出来拍照发给医生。根源在于医院系统各自独立,患者病历被锁在单个机构内部,形成数据孤岛。电子病历共享系统的目标,就是把病历的“所有权”还给患者,把“查看权”交给患者信任的医生。
放到毕业设计的语境里,这个项目背后的价值可以归纳成三点:
一是业务链条完整。系统覆盖患者注册、医生接诊、病历录入、共享申请、授权审批、病历查看、操作追踪,闭环很长,能展示你全栈设计能力。
二是权限需求多样。同一个系统里,患者、普通医生、科室负责人、医院管理员、系统管理员,看到的页面和能操作的功能完全不同,这正是RBAC权限模型最天然的落地场景。
三是数据关系复杂。病历和患者、医生、医院、共享申请、操作日志之间都有明确的依赖关系,数据库设计难度适中,既不会简单到没东西写,也不至于复杂到做不完。
1.2 技术栈选型:为什么最终选择了SpringBoot + MyBatis-Plus
后端框架可选很多,但SpringBoot几乎是当前毕业设计场景的“标准答案”。理由很朴素:官方文档多、社区教程多、第三方生态完善,遇到问题随手能查到解决方案,这一点在赶论文的时候比什么都重要。
持久层我推荐MyBatis-Plus,而不是原生MyBatis或者Spring Data JPA。很多人纠结选型,我给出的理由非常实际:
- MyBatis-Plus在MyBatis基础上封装了通用CRUD方法,单表增删改查不需要手写SQL,节省大量时间。
- 它支持LambdaQueryWrapper,做条件查询、分页查询极其方便,比如“根据患者姓名模糊搜索”这种需求,链式调用几行代码写完。
- 国内面试和实际工作中碰到的技术栈,MyBatis系的比例明显偏高,写在简历上比JPA更有辨识度。
数据库选MySQL是绝大多数人的选择,原因不用多说。我建议额外引入Redis,哪怕只做验证码缓存和Token黑名单,也能让设计文档里的“高可用”“分布式会话”词汇变得有据可依。
1.3 角色与权限模型设计
我见过很多同学把权限写死在if else里,比如“当前用户是患者就跳转A页面”,一旦角色增多,代码直接变成一坨。这里推荐用经典的RBAC(基于角色的访问控制)模型,三张核心表就能说清楚:
用户表存储账号、密码、昵称、角色ID;角色表定义医生、护士、患者、管理员等;权限表定义具体操作,比如“病历_查看”“病历_导出”。中间加用户角色关联表和角色权限关联表,就是完整的RBAC。
实际开发时不需要把五张表全部做出来,毕业设计更看重逻辑清晰。我的做法是简化成用户表带角色字段,然后把“权限判断”集中在接口层拦截器里处理。举个典型场景:
医生A要查看患者B的病历,系统先验证医生A的登录Token,再验证医生A与患者B是否存在有效的共享授权记录,两个条件同时满足才放行。这种“用户身份校验 + 业务数据授权校验”的双层校验机制,是答辩时能不能把项目讲出深度的分水岭。
2. 数据库设计:表结构决定系统上限
2.1 核心数据表一览
数据库设计是评审最先关注的部分,表字段是否合理能直接反映你有没有做过真实项目。我整理了一份常用核心表的字段设计,你可以直接作为参考起点:
| 表名 | 核心字段 | 设计要点 |
|---|---|---|
| user | id, username, password, role, status, create_time | 密码存MD5加盐或BCrypt密文,不要存明文 |
| patient | id, user_id, real_name, id_card, phone, address | 关联用户表,一患者一用户 |
| doctor | id, user_id, real_name, hospital_id, dept_id, title | 医生与医院、科室关联 |
| medical_record | id, patient_id, doctor_id, record_type, diagnosis, content, create_time | 核心业务表,后面单独讲 |
| sharing_record | id, patient_user_id, doctor_user_id, record_id, status, expire_time | 共享授权记录表,关键设计见2.3 |
| operation_log | id, user_id, action, target_type, target_id, detail, create_time | 审计日志,答辩加分项 |
主键一律用自增id还是雪花ID?建议直接使用MyBatis-Plus自带的ASSIGN_ID雪花算法生成,避免后期分表时主键冲突,这一点在你写论文时可以展开讲。
关联字段比如patient_id、doctor_id、hospital_id,我建议不建物理外键,而是通过普通索引和业务级校验维护一致性。理由是外键会让插入、更新、删除操作产生额外锁,真实项目里也很少用物理外键,用逻辑关联更灵活。
2.2 病历表设计里那些容易被忽视的坑
medical_record表是整个系统的数据核心。不少同学一上来就设计成“一条记录代表一次就诊”,既存主诉、诊断,又存医嘱、检查结果,最后所有内容堆在几个大文本字段里,看起来能跑,实际上把关系型数据库用成了文档库。
我的建议是拆开设计,至少分成主表和明细表两部分。主表存一次就诊的概要信息:患者ID、接诊医生ID、就诊类型、就诊时间、主诉、初步诊断。明细表存具体的检查项目、药品处方、检验结果等,主从表之间通过medical_record_id关联。这样一来,列表页查概要只需要扫主表,详情页再根据ID带出明细,性能比全表TEXT字段好得多。
还要考虑病历的“版本”问题。医生录入病历后,患者在其他医院就诊时,原医院又补充了诊断结果,如果直接覆盖,之前的记录就丢了。我的处理方式是增加一个version字段,每次修改生成新记录或者额外存储修改历史,前端查看时默认加载最新版本,但可以展开历史版本。这个设计思路虽然简单,但能有效拉开你和其他“CRUD选手”的差距。
2.3 共享授权表:整个系统的“灵魂”所在
很多人把共享做成了“患者点一下分享,生成一个链接,医生打开链接看”。这完全不符合医疗场景的合规要求。共享必须具备明确的授权对象、授权时间、授权范围和状态流转,所以必须单独设计一张共享记录表。
我的共享授权表字段如下:
- id:主键
- patient_user_id:患者用户ID,表示“谁来授权”
- doctor_user_id:被授权的医生用户ID,表示“授权给谁”
- record_id:授权指向的病历ID,为空则代表该患者全部病历可查看
- status:0待患者确认,1已通过,2已拒绝,3已撤销
- expire_time:授权截止时间,超过后自动失效
- create_time、audit_time:申请时间和处理时间
表设计好之后,共享的完整流程就非常清晰了:
- 医生发起共享申请,插入一条status为0的记录。
- 患者在“共享申请列表”中看到该申请,点击同意后status变为1。
- 医生查看病历时,系统校验该医生与患者之间存在status=1且未过期的共享记录,校验通过才返回数据。
- 患者可以在系统中随时撤销授权,status变为3。
这套状态机模型解决了“谁允许谁看什么看到什么时候”的问题,也是你项目答辩时最值得深挖的业务亮点。
3. 后端实战:从搭骨架到核心接口实现
3.1 项目初始化和配置文件
创建SpringBoot项目时,我习惯用Maven管理依赖。下面是我整理的核心pom依赖,你可以对照自己的版本调整:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>application.yml里最需要注意的是数据库时区、编码和MyBatis-Plus配置:
spring: datasource: url: jdbc:mysql://localhost:3306/emr_share?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: id-type: assign_id logic-delete-field: deleted为什么url里要加serverTimezone=Asia/Shanghai?因为新版MySQL驱动默认时区是UTC,不加这个配置,本地连数据库时经常会报server timezone异常,浪费大量排查时间。
3.2 登录认证:用JWT代替Spring Security
我们常用到Spring Security,但对毕业设计来说,Spring Security的过滤器链路、配置方式、密码加密方案都过于繁琐,答辩时很难在三分钟内讲清楚。我推荐改用JWT + 拦截器,能更直观地展示你掌握认证原理,代码量也少得多。
登录接口的逻辑很简单:用户名密码校验成功,生成一个包含用户ID和角色的JWT字符串返回给前端。前端请求需要认证的接口时在HTTP头里携带Authorization: Bearer 。后端写一个HandlerInterceptor,在preHandle方法里解析JWT并校验有效期,校验通过就把userId放进ThreadLocal或请求头,方便Controller直接取用。
核心拦截器逻辑可以这样写:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); if (JwtUtils.verify(token)) { String userId = JwtUtils.getUserId(token); request.setAttribute("currentUserId", userId); return true; } } response.setStatus(401); return false; } }这里有个很常见的坑:拦截器放行路径没配置好。登录、注册、验证码获取这些接口不能被拦截,否则你永远登录不进去。建议在注册拦截器的配置类里,先放行/login、/register、/captcha以及静态资源路径,再拦截其他路径。
3.3 病历查询与CRUD接口实现
MyBatis-Plus最香的地方就是写单表CRUD时几乎不需要SQL映射。定义一个实体类,再用BaseMapper继承一下:
@Data @TableName("medical_record") public class MedicalRecord { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long patientId; private Long doctorId; private String recordType; private String diagnosis; private String content; private LocalDateTime createTime; }Controller里查询列表时,用LambdaQueryWrapper动态拼接条件:
@GetMapping("/list") public Result list(@RequestParam Long patientId, @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { LambdaQueryWrapper<MedicalRecord> wrapper = Wrappers.lambdaQuery(); wrapper.eq(MedicalRecord::getPatientId, patientId); if (StrUtil.isNotBlank(keyword)) { wrapper.like(MedicalRecord::getDiagnosis, keyword) .or().like(MedicalRecord::getContent, keyword); } Page<MedicalRecord> pageResult = medicalRecordMapper.selectPage(new Page<>(page, size), wrapper); return Result.ok(pageResult); }强烈建议统一返回Result对象,包含code、message、data三个字段。这样前端统一处理成功失败逻辑,后端项目显得更规范。我见过不少同学的Controller里到处返回Map,最后前端对接时命名对不上,极其痛苦。
3.4 共享申请和授权校验的核心流程
共享申请流程需要两张接口相互配合。医生端调用申请接口,插入一条共享记录,状态为0:
@PostMapping("/apply") public Result apply(@RequestBody ShareApplyRequest req) { SharingRecord rec = new SharingRecord(); rec.setPatientUserId(req.getPatientUserId()); rec.setDoctorUserId(CurrentUser.getId()); rec.setRecordId(req.getRecordId()); rec.setStatus(0); rec.setExpireTime(LocalDateTime.now().plusDays(7)); sharingRecordMapper.insert(rec); return Result.ok(); }患者端查看待处理申请,点击同意后更新状态为1。这部分代码不多,但务必加上逻辑校验:只有申请记录的patientUserId等于当前登录用户ID,才允许操作,否则存在严重越权漏洞。
医生查看病历时,除了走拦截器校验登录状态,还要在业务层再查一次共享记录:
boolean authorized = sharingRecordMapper .exists(new LambdaQueryWrapper<SharingRecord>() .eq(SharingRecord::getPatientUserId, patientId) .eq(SharingRecord::getDoctorUserId, currentDoctorId) .eq(SharingRecord::getStatus, 1) .gt(SharingRecord::getExpireTime, LocalDateTime.now())); if (!authorized) { return Result.fail("无访问权限"); }这层校验一定要放在Service里,只依赖拦截器是不够的。拦截器只能证明“你是合法登录用户”,不能证明“你有权查看这条病历数据”。
3.5 用Redis缓存验证码和热点数据
毕业设计里加Redis,不需要做太复杂的分布式缓存,只需要两个亮点:
第一,登录时用图形验证码或短信验证码,验证码存到Redis里,设置5分钟过期。比存session更规范,也更符合分布式应用场景。
第二,把系统里查询频次高、更新频率低的配置数据放进缓存。比如常见的就诊类型字典、科室列表,直接用Spring自带的@Cacheable注解即可。
@Cacheable(value = "dict", key = "#type") public List<DictItem> getDictByType(String type) { return dictMapper.selectList(...); }为了防止缓存穿透,空值也要存储或者设置短过期时间。这个细节一讲,答辩评委就会知道你真正理解缓存,而不是只会在项目简介里堆名词。
4. 从“能跑”到“能过答辩”的进阶细节
4.1 跨医院共享怎么做数据隔离
很多同学的共享只做了“同一个系统里不同用户”的共享,还没有真正体现“跨医院”。我的做法是用户表上增加hospital_id字段,所有业务数据都通过这个字段进行逻辑隔离。
比如医生A属于医院X,患者B的病历来自医院Y,当医院X的医生请求查看医院Y的病历时,共享记录只需要明确记录请求方医生ID、归属方患者ID即可。查看病历时,后端返回的数据里并不需要暴露其他与该病历无关的患者信息。这样设计既简单又安全,答辩时可以配合流程图讲解“数据不落库,只做授权映射”的思路。
4.2 检查报告文件上传与附件管理
病历系统中经常要上传检验报告、影像图片。Spring Boot接收MultipartFile,存到服务器本地目录或者对象存储,再把文件访问路径写入attach表。附件表字段一般是:id、record_id、file_name、file_url、file_size、upload_time。
文件上传有几个安全细节需要注意:
- 限制扩展名,只允许jpg、png、pdf、docx等白名单类型。
- 限制文件大小,Spring Boot在application.yml里用max-file-size和max-request-size配置。
- 存储路径不要包含用户上传时的原始路径,统一用UUID重命名文件,避免路径穿越攻击。
4.3 操作日志与审计
病历属于敏感数据,任何查看行为都应当被记录。用Spring AOP做个切面,拦截标注了@LogAction注解的Controller方法,在方法执行后记录操作人、操作类型、目标对象和操作时间。
日志表字段比较简单,核心是user_id、action、target_type、target_id、detail。这里多说一句:不要在日志表里存明文密码,也不要在detail里存完整的病历文本,只存摘要或业务ID,否则日志表越来越大,本身也变成了隐私风险点。
4.4 前端页面的快速搭建建议
如果前端不想从零写,可以直接用Vue + ElementUI做后台管理系统套壳。不需要复杂到微前端,按角色拆成患者端、医生端、管理端三个页面组即可。
登录页面埋一套Token管理逻辑;患者端重点展示“我的病历”“共享申请”“授权记录”;医生端重点展示“患者查询”“发起共享”“病历查看”“共享中列表”。前端调用后端时,统一在axios请求拦截器里加Authorization头,在响应拦截器里处理401跳转登录页。这些代码网上模板很多,但想拿高分,我建议至少手写一遍请求和响应拦截器,面试和答辩被问到的概率极高。
5. 常见问题与排查技巧实录
5.1 数据库连接报timezone异常
这个问题几乎每隔几届都会有人踩。解决办法有三个,我推荐第二个:
- 数据库连接url加serverTimezone=Asia/Shanghai
- 使用MySQL Connector/J 8.0以上,并把驱动类换成com.mysql.cj.jdbc.Driver
- 在数据库端执行set global time_zone = '+8:00'
排查方法很简单:启动项目看报错第一行,只要出现serverTimezone关键词,直接去改url参数就能解决。
5.2 跨域问题导致前后端联调失败
前后端分离开发时,前端地址是localhost:5173,后端地址是localhost:8080,端口不同就会触发跨域。表现形式是浏览器里POST请求发送后报CORS error,或者axios拿不到响应。
直接的解法是配置CorsFilter:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意,如果你同时使用了拦截器或Spring Security,跨域配置必须放在最外层,否则预检请求OPTIONS会被拦截器拦截,直接返回401,这种坑排查起来很容易心态爆炸。
5.3 MyBatis-Plus分页不生效
很多人配置了PaginationInnerInterceptor之后,分页查询始终返回全量数据。多半是在高版本中没配new PaginationInnerInterceptor(DbType.MYSQL),或者没有把插件放进MybatisPlusInterceptor里。正确写法:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }5.4 时间字段格式化不对齐
后端返回LocalDateTime格式是“2024-12-20T10:30:00”,前端拿到带T的字符串,显示很丑。在字段上加@JsonFormat注解解决。
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime;批量处理的话,也可以统一在application.yml里配置spring.jackson.date-format和time-zone,让全局生效。
5.5 逻辑删除字段导致查询数据莫名丢失
使用@TableLogic注解配置逻辑删除之后,MyBatis-Plus会自动在查询条件里拼接deleted=0。如果你在数据库表里没有手动加deleted字段,插一条数据时这个字段值为NULL,查询时NULL和0不相等,数据就“消失”了。所以逻辑删除字段一定要设置默认值为0,或者插入时显式赋值。
6. 写在最后的几点实操体会
这个项目我实际跟做过好几轮,最大的感受是:技术难点并不在某个框架的API调用上,而在于把“共享授权”这条业务线想通透。只要你愿意把共享状态机画清楚,把权限校验写完整,把日志审计加上,这个系统的完成度会明显超出普通毕业设计一大截。
答辩前,我建议你把这几类用例准备好:患者注册并上传基本信息、医生创建病历、医生申请跨院共享、患者同意授权、医生读取共享病历、患者撤销授权、管理员查看操作日志。演示的时候按这条主线走,评委能非常直观地看到整个系统的业务闭环。
再提醒一句,项目文档里一定要写清楚“共享记录表”和“授权状态机”的设计理由。很多同学答辩被问倒,不是代码运行失败了,而是说不出“为什么这张表要加状态字段”“为什么病历和共享记录不合并”。回答的时候抓住“可追溯、可控制、可撤销”这三点,基本不会出差错。
最后给你一个小建议:把项目的SQL脚本、初始化数据写到项目的resources目录里,再写一条清晰的README,说明如何建库、改配置、启动。技术能力到答辩阶段大家的差距不会特别夸张,谁的项目文档干净、演示流畅、边界问题想得全,谁就能拿到不错的评价。这个习惯也会让你以后进团队协作时少挨很多骂。