毕业设计选医院门诊信息管理系统的人,十个里面得占四五个。原因很简单:业务够熟、场景够全、体量适中,而且拿Spring Boot来做这个题目,几乎成了计设圈的默认配置。我最近也帮几个朋友把这个题目从头到尾捋过一遍,所以这篇文章就从一个实际能跑通的角度,把系统从技术选型、数据库设计、核心接口实现到后端高频踩坑点,一条线讲透。你如果是拿这个题做毕设,或者想快速搭一套门诊管理后台,可以直接把这里面的表结构和代码思路当参考底稿。
1. 项目落地的第一步:业务边界和技术选型怎么定
1.1 门诊系统到底要管哪些事
很多人在做这个题目时,第一反应是上去就建Spring Boot项目、写Controller。这个顺序其实是错的。做管理类系统,第一件事是把业务边界划清楚,否则代码写一半你会发现需求越来越模糊,表结构改来改去,最后全乱套。
医院门诊的业务流其实是一条非常典型的“主线+支线”模型。主链路就是:患者建档、挂号、医生看诊、开处方、缴费、药房发药。这个流程在真实医院里每天重复几千遍,逻辑非常成熟,搬到系统里就是一张张状态流转的数据库记录。支线则是围绕主链路服务的基础数据,包括科室管理、医生排班、号源维护、药品字典、收费退费、统计报表,还有登录认证和角色权限。
按角色拆一下更清楚:前台护士负责建档和挂号收费,医生负责看诊和开方,药房人员负责发药和库存管理,系统管理员维护基础数据和账号权限。这样拆完之后,模块边界就非常明确了,后端Controller层级也可以按此划分,每个角色对应一组独立接口,互不干扰。
1.2 为什么这套技术栈是毕业设计里的“标准答案”
技术选型这件事,每年都有同学纠结。我的建议很直接:
- Spring Boot版本:选2.7.x,不要追新。3.x系列要求JDK 17,很多毕设用的还是JDK 1.8环境,2.7.x依然是兼容性最稳的选择。网上资料、公司里用的、学长写过的代码,全是2.x的语法,遇到问题搜索一下就能解决,这个优势非常实际。
- 持久层框架:MyBatis-Plus是主流,内置BaseMapper把单表CRUD都做完了,分页插件也顺手,能省掉大量重复的XML和SQL。如果你愿意用JPA也行,但多表关联查询和复杂统计报表写起来没有MyBatis-Plus顺手。
- 前端部分:Vue + Element UI是“毕业设计三件套”,学习曲线低、组件丰富,后台管理系统页面一天能搭完。前后端分离的好处是答辩的时候可以拆开演示,后端接口、前端页面各自独立,也方便后面扩展。
- 接口文档与鉴权:接口文档用knife4j(Swagger的增强版),登录认证用JWT,再配一个拦截器做白名单放行,这几个组合在毕设里很成熟,网上案例一抓一大把。
这套组合选型下来,核心是“稳”。毕设最怕的不是技术不够新,而是项目推不下去、答辩出了问题无法解释。在就业市场上,Spring Boot+MyBatis+Vue也是后端初级岗位的通用要求,做这个等于顺带把面试基础语法刷了一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:先理清这10张核心表再动手
2.1 主业务表的划分与关联关系
数据库设计是整个项目的地基。表设计如果合理,后面的Service代码会写得非常顺;表设计不合理,写到一个模块就要回头加字段、改关联,心态很容易崩。
我按照门诊主链路把核心表整理成了这份清单,可以直接参考着搭建:
| 序号 | 表名 | 业务作用 | 关键字段 |
|---|---|---|---|
| 1 | department | 科室表 | id、科室名称、位置、简介 |
| 2 | doctor | 医生表 | id、姓名、科室id、职称、简介 |
| 3 | schedule | 排班表 | id、医生id、日期、时段、总号源、剩余号源、挂号费 |
| 4 | patient | 患者表 | id、姓名、身份证、手机号、性别、出生日期 |
| 5 | registration | 挂号记录表 | id、患者id、排班id、医生id、挂号日期、状态 |
| 6 | diagnosis | 诊断记录表 | id、挂号id、患者id、主诉、诊断结论、建议 |
| 7 | prescription | 处方单表 | id、诊断id、患者id、医生id、开方时间、状态 |
| 8 | prescription_item | 处方明细表 | id、处方id、药品id、药品名快照、数量、单价、用法 |
| 9 | drug | 药品表 | id、药品名、规格、库存、单价、厂家 |
| 10 | fee_record | 收费记录表 | id、业务类型、业务单号、应收金额、实收金额、支付状态 |
| 11 | sys_user | 系统用户表 | id、用户名、密码、姓名、角色、状态 |
这几张表之间的关联也不复杂:doctor通过department_id挂到科室;schedule通过doctor_id关联医生;registration同时关联患者、排班和医生;diagnosis关联挂号记录;prescription关联诊断记录,再由prescription_item把处方和药品关联起来。收费记录用一个“业务类型+业务单号”的通用字段设计,可以同时承接挂号费和药品费。
2.2 号源扣减和患者去重怎么在表设计上打底
两个业务细节必须在建表阶段就想清楚,不然后面写代码会非常痛苦。
第一个是号源扣减。排班表里的“总号源数”和“剩余号源数”这两个字段是必须有的。挂号的时候,先查剩余号源是否大于0,再执行扣减,不能只在前端做判断。真实医院里专家号几秒钟就被挂完,系统如果不在数据库层面限制住,必然出现超号。后面代码部分我会专门讲怎么用行锁来处理。
第二个是患者去重。患者的id_card(身份证号)字段要建唯一索引。前台录入患者信息时,先按身份证查一下是否已建档,如果已存在就直接复用,避免一个患者建出三条档案,统计数据全部失真。这个字段设计层面加唯一约束,哪怕代码漏了判断,数据库也能兜底。
再补充一个建表习惯:金额字段用DECIMAL(10,2),不要用float或double;处方明细里的“药品名、单价”要做快照存储。什么意思?就是开了处方之后,哪怕药品字典里改了价格,这张历史处方单上的单价也应该保持不变,否则月底对账会出现莫名其妙的数据误差。
3. 核心功能实现:挂号、看诊、缴费主链路拆解
3.1 挂号接口:扣号源时为什么要加数据库行锁
挂号是整个系统里最容易出并发问题的地方,也是最能在答辩时讲出技术含量的功能。先看错误写法:查剩余号源、判断、扣减三步分开做。
java复制// 错误示范
Schedule schedule = scheduleMapper.selectById(scheduleId);
if (schedule.getRemaining() <= 0) {
throw new BusinessException("号源不足");
}
schedule.setRemaining(schedule.getRemaining() - 1);
scheduleMapper.updateById(schedule);
这段代码在并发场景下必出问题。两个请求同时查到剩余号源=1,都通过了判断,都执行了扣减,最后剩余号源变成-1,这就是超号。解决方式是在查询时对这条排班记录加数据库行锁,让同一时间只有一个请求能操作这条排班数据。
java复制// 正确写法:selectForUpdate,把查询和锁绑定
@Transactional(rollbackFor = Exception.class)
public void doRegister(RegisterRequest req) {
// 1. 加行锁查询排班记录
Schedule schedule = scheduleMapper.selectByIdForUpdate(req.getScheduleId());
if (schedule == null || schedule.getRemaining() <= 0) {
throw new BusinessException("号源不足");
}
// 2. 扣减剩余号源
schedule.setRemaining(schedule.getRemaining() - 1);
scheduleMapper.updateById(schedule);
// 3. 创建挂号记录
Registration registration = new Registration();
registration.setPatientId(req.getPatientId());
registration.setDoctorId(schedule.getDoctorId());
registration.setScheduleId(schedule.getId());
registration.setStatus(0); // 0-待就诊
registrationMapper.insert(registration);
// 4. 生成挂号费收费单
FeeRecord fee = new FeeRecord();
fee.setBizType("REGISTRATION");
fee.setBizId(registration.getId());
fee.setAmount(schedule.getFee());
fee.setStatus(0); // 0-待支付
feeRecordMapper.insert(fee);
}
这段代码有四个关键点值得展开讲:
第一,selectByIdForUpdate,它和普通查询的区别就是追加了FOR UPDATE,在MySQL的InnoDB引擎下,这条SQL执行后,会对命中的行加排他锁,直到事务提交或回滚才释放。同一时刻其他事务再执行同样的FOR UPDATE查询,就得排队等待。这就是解决超号的核心机制。
第二,整个流程必须包在同一个事务里。@Transactional(rollbackFor = Exception.class)的作用是让扣号源、插挂号记录、插收费单这三个数据库操作要么全部成功,要么全部回滚。如果挂号记录插入失败但号源已经扣了,事务回滚后号源也会自动恢复,不会出现“号扣了但没挂上”的情况。
第三,rollbackFor一定要指定Exception.class。因为Spring默认只回滚运行时异常(RuntimeException),如果你在Service里抛了一个自定义的BusinessException且它继承的是Exception,不加rollbackFor就不会回滚,数据就乱了。
第四,实体类的状态字段最好用Integer,不要用布尔值或没注释的int。比如挂号状态0待就诊、1已就诊、2已退号、3已完成,用数字才能扩展,而且要在字段上加@ApiModelProperty或注释说明,不然一个月后你自己都记不清2代表什么。
3.2 就诊记录与处方单的状态流转
挂完号之后,主链路进入医生看诊环节。医生打开一个待就诊的挂号记录,填写主诉,录入诊断结论和建议,然后开处方单。这个环节相对简单,但处方单的主表和明细表操作必须保持同一个事务。
处方单结构是典型的主子表结构:prescription主表记录整体信息(患者、医生、诊断、总金额、状态),prescription_item明细表记录每种药品、数量、单价、用法。开方接口的写法如下:
java复制@Transactional(rollbackFor = Exception.class)
public Long createPrescription(PrescriptionRequest req) {
// 1. 校验挂号状态,必须是待就诊
Registration reg = registrationMapper.selectById(req.getRegistrationId());
if (reg == null || reg.getStatus() != 0) {
throw new BusinessException("当前挂号记录不能开处方");
}
// 2. 插入主表
Prescription p = new Prescription();
p.setDiagnosisId(req.getDiagnosisId());
p.setPatientId(reg.getPatientId());
p.setDoctorId(reg.getDoctorId());
p.setStatus(0); // 0-未收费
prescriptionMapper.insert(p);
// 3. 批量插入明细表(含药品名和价格的快照)
BigDecimal total = BigDecimal.ZERO;
for (PrescriptionItemReq item : req.getItems()) {
Drug drug = drugMapper.selectById(item.getDrugId());
PrescriptionItem pi = new PrescriptionItem();
pi.setPrescriptionId(p.getId());
pi.setDrugId(drug.getId());
pi.setDrugName(drug.getName()); // 快照
pi.setPrice(drug.getPrice()); // 快照,防止改价影响历史单
pi.setQuantity(item.getQuantity());
pi.setUsage_("每日3次,每次1片");
prescriptionItemMapper.insert(pi);
total = total.add(drug.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())));
}
// 4. 更新主表总金额
p.setTotalAmount(total);
prescriptionMapper.updateById(p);
// 5. 修改挂号状态为已就诊
reg.setStatus(1);
registrationMapper.updateById(reg);
return p.getId();
}
这个过程中最容易被忽略的是状态联动。开完处方后,挂号状态要从“待就诊”变成“已就诊”,否则患者下次再去挂号或者医生再去看诊时,会发现状态还是待就诊,界面上乱成一团。每个模块的状态变更都要在代码里明确标注,我建议做一个状态说明的枚举类,把状态码和含义集中管理。
3.3 缴费退费时的事务边界怎么控制
缴费的核心是把待缴费的收费单状态改成已支付,同时更新业务单状态。比如处方单支付成功后,处方状态变为“已收费”,药房才能看到发药列表。退费的逻辑反过来,但校验条件更严格:已发药的不给退、已就诊的不给退、退费要恢复号源。
我用缴费接口举个例子:
java复制@Transactional(rollbackFor = Exception.class)
public void pay(Long feeId) {
// 1. 查询收费单
FeeRecord fee = feeRecordMapper.selectByIdForUpdate(feeId);
if (fee == null || fee.getStatus() != 0) {
throw new BusinessException("收费单不存在或已支付");
}
// 2. 更新收费单状态
fee.setStatus(1); // 已支付
fee.setPayTime(LocalDateTime.now());
feeRecordMapper.updateById(fee);
// 3. 根据业务类型联动更新
if ("PRESCRIPTION".equals(fee.getBizType())) {
prescriptionMapper.updateStatusByPay(fee.getBizId());
} else if ("REGISTRATION".equals(fee.getBizType())) {
// 挂号费支付后无需额外联动,挂号记录已创建
}
// 4. 记录支付流水(可写到另一个表,省略)
}
这里依然要用selectByIdForUpdate,别小看这一步。如果用户连续快速点击两次“立即支付”,两个请求同时读到“待支付”状态,就会出现重复支付。加锁之后,第二个请求只能等第一个事务提交后才能读到结果,此时状态已经是已支付,直接抛出“已支付”的异常提示。
退号接口也是同理。退号时要把挂号的收费单改成已退费,把挂号记录改成已退号,同时把排班表里的剩余号源加回去。这三个操作必须是一个事务。如果只退了钱但是没恢复号源,号源就凭空少了一个;如果只恢复号源没退钱,患者就白亏了一笔挂号费。
4. 接口联调与权限控制:Swagger、JWT和跨域
4.1 用knife4j把接口文档一次性搞定
前后端分离之后,接口文档就是前后端沟通的语言。Spring Boot项目里用knife4j(Swagger增强版)开接口文档,前后端联调时效率会高很多。
引入依赖时要注意版本匹配。Spring Boot 2.7.x对应knife4j 4.x,依赖如下:
xml复制<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
然后在启动类或配置类里加一个Docket的Bean,同时配置好扫描的包路径。做完之后启动项目,访问/doc.html就能看到可视化接口文档页面。每个Controller里的接口参数、返回结构、实体类字段说明都会自动解析展示,前端同学直接照着调就行。
但用Swagger有个坑:不配置的话,拦截器会把接口文档的请求也拦截掉。一旦你加上了JWT登录拦截器,前端联调时打开文档页面就变成空白或者401。解决方案是在拦截器配置里把放行的路径加全,通常包括登录接口、Swagger的资源路径、错误页面,具体如下:
java复制registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/login", // 登录接口
"/doc.html", // knife4j 文档页面
"/webjars/**", // 静态资源
"/swagger-resources/**",
"/v2/api-docs",
"/error"
);
还有一个真实踩过的问题:knife4j集成后,接口文档页面上如果字段注释不显示,基本都是实体类上没加@ApiModelProperty注解。这个注解加上后,前端就能直接看到每个字段的含义和约束,减少沟通成本。
4.2 JWT登录认证与白名单设计
登录认证这块我推荐直接用JWT(JSON Web Token)。它的核心逻辑是:用户登录成功后,后端生成一个包含用户信息的Token返回给前端,前端每次请求在请求头里带上Authorization: Bearer <token>,后端拦截器校验Token是否有效、是否过期。
JWT的好处是无状态的,不需要在服务端保存Session,前端拿到Token之后可以任意存储,对毕业设计里的前后端分离架构非常友好。生成Token的代码大致是:
java复制String token = Jwts.builder()
.setSubject(String.valueOf(user.getId()))
.claim("username", user.getUsername())
.claim("role", user.getRole())
.setExpiration(new Date(System.currentTimeMillis() + 12 * 60 * 60 * 1000))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
这里有两个细节要注意。一个是secretKey不要写在代码里,放到application.yml配置文件中,答辩时可以解释为“敏感信息配置化”;另一个是HS256要求密钥长度不能太短,值太短会报错,你预先准备一个16位以上的字符串就好。
拦截器里校验Token的逻辑也很简单:从请求头取Token,解析失败就返回401状态码,前端收到401就跳转到登录页。要注意放行路径必须包含登录接口本身和相关静态资源,否则会出现“还没登录就一直被拦截”的尴尬循环。
5. 实测高频踩坑记录:这类项目最容易翻车的几个点
5.1 数据库字段命名和关键字冲突
门诊系统里很多字段名容易踩MySQL的关键字坑,最常见的是user、order、desc、level、usage。比如有一张系统用户表,如果你把表名直接叫user,某些SQL执行的时候会用英文半角反引号包住才不报错。我在项目里踩过一次后就学乖了:基础表名统一加前缀,比如sys_user、biz_order,字段里需要用level这种单词时,统一改成user_level或doctor_level,从命名上规避冲突。
另外要注意MyBatis-Plus的自动驼峰映射。如果数据库字段用了下划线命名,比如create_time,实体类字段是createTime,需要确保application.yml里有这一项配置:
yaml复制mybatis-plus:
configuration:
map-underscore-to-camel-case: true
这个配置缺失的话,查询结果里所有带下划线的字段全部是null,开发时非常容易忽视。
5.2 时间类型前后端格式化不一致
这个项目里涉及大量时间字段:挂号时间、就诊时间、支付时间、排班日期。后端如果用LocalDateTime返回给前端,默认序列化格式是类似2025-01-06T10:30:00这种带T的格式,前端显示起来非常难看,用户看到会觉得是Bug。
解决方式是在application.yml里配置统一的时间格式:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
实际在用到LocalDateTime时,只加date-format并不能每次都生效,更稳妥的方案是在实体类的时间字段上加@JsonFormat注解:
java复制@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
顺带提醒一句:时区一定要配。如果服务器是UTC时区,而你在东八区,时间字段会出现8小时的偏移,表现在界面上就是“挂号时间比实际时间晚了8个小时”,这个问题在很多线上项目里都出现过,务必在一开始就配置好。
5.3 MyBatis-Plus分页不生效和字段映射问题
MyBatis-Plus的分页插件是个高频坑。很多人按教程引入了PaginationInnerInterceptor,但分页查询就是不生效,查出来还是全量数据。原因一般是配置类没有正确扫描到Mapper,或者没有把分页插件注册成Bean。
正确的配置方式是在某个@Configuration类里显式声明分页插件:
java复制@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
另一个常见问题是字段映射不上。因为MyBatis-Plus默认按实体类字段名转下划线的规则去查列名,如果字段名和列名对不上又没加@TableName和@TableField注解,查询返回的实体对象某些字段就会是null。排查思路很简单:打开SQL日志,看实际的SQL语句里查了哪些列,逐一对照实体类字段。开发阶段建议把SQL日志打印打开:
yaml复制mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
这样每次执行SQL都能在控制台看到完整的参数和结果,排查效率能提升很多。
5.4 Spring事务失效的几个经典场景
做这种管理系统,事务问题是最容易在答辩时被追问的。我在帮朋友排查代码时,发现事务失效基本逃不出这几个原因:
- 同类内部调用:在一个Service类里,A方法调B方法,B上有
@Transactional,但B的事务不生效。因为Spring的事务是基于AOP代理实现的,内部方法调用走的是this而不是代理对象,事务拦截器根本没机会介入。 - 异常被吞掉:Service方法里用了
try-catch把异常捕获了,事务并不知道发生了错误,自然不会回滚。 - 方法不是public:
@Transactional只对public方法生效,这个很多人不知道。 - rollbackFor没配置:如果抛出的异常是受检异常(比如
Exception的子类),Spring默认不会回滚。
这些事情听上去很细,但在答辩时被问到“你的系统在高并发下怎么保证数据一致性”,能把这几个场景讲清楚,就是明显的加分项。我当时帮朋友做这个项目时,特意在挂号接口里模拟了两个线程并发抢同一个号源,结果正确表现确实是只有一个成功,另一个抛“号源不足”,这也成了项目演示环节最亮眼的一部分。
6. 答辩怎么讲,以及这个项目后续还能怎么扩展
6.1 把“技术难点”讲成“项目亮点”的三个话术
做完项目之后,答辩环节很多人不知道讲什么重点。我建议从三个角度准备:
第一是并发控制。主动提挂号接口的并发场景:两个患者同时抢最后一个号源,数据库通过select ... for update行锁解决超号问题。这句话一出口,评委就知道你的项目不是纯CRUD。
第二是事务一致性。讲清楚挂号、开处方、缴费、退号这些跨表操作如何通过@Transactional保证数据一致性,以及特殊情况下如何用rollbackFor覆盖所有异常类型。
第三是表结构设计思维。提两点:处方明细里的价格快照设计防止历史数据被改动,患者身份证唯一索引防止重复建档。这种设计细节比“我用的是Spring Boot”更有说服力。
话术不需要背稿,但一定要理解背后的原理,因为评委大概率会追问“为什么行锁能解决”“如果不用锁会怎样”,你要能现场画一个简单的流程图把问题讲明白。
6.2 往微服务、消息队列方向扩展的可行性
如果答辩时间充裕,可以说一下项目的扩展空间。门诊系统是很标准的业务系统,后续可以往几个方向演化:
- 引入Redis缓存:热门科室列表、医生排班信息可以缓存到Redis,减少数据库压力。
- 引入消息队列:挂号成功后通过MQ通知患者短信提醒,或者预约挂号模块中做异步任务处理。
- 数据统计可视化:门诊量、科室收入、药品消耗量的统计报表可以用ECharts做可视化大屏。
- 微服务拆分:把用户、挂号、处方、药品拆成独立服务,用Nacos注册中心聚合,这个方向比较重,但可以作为“未来展望”提一嘴。
这些点不用真做,但说明了你有技术视野,对毕设评价会有加成。
最后再分享一个经验:做这种管理系统,别一上来就盯着最炫的界面和最新版本框架,先把主链路跑通。我见过太多同学卡在环境配置、版本冲突、数据库连接这些基础问题上,折腾一周连登录都出不来。Spring Boot 2.7.x + JDK 1.8 + MyBatis-Plus + MySQL这套组合,所有依赖版本都能在网上找到直接对应的教程,照着搭一遍,把患者建档、挂号、医生看诊、缴费、药房发药这条线走通,你的毕设就已经站住脚了。后面的优化和扩展,都是在这个地基上添砖加瓦。
