我最近在整理之前做过的毕设项目源码,发现当初做“医院线上诊疗管理系统”时踩过的坑、总结的经验,很多同学还在重复踩。正好借这个机会,把这个基于 SpringBoot 的 Java 毕设项目从设计到落地完整复盘一遍,包括数据模型怎么拆、权限怎么控、预约流程的状态机怎么设计,以及那些调试了两三天才搞定的典型问题。
这个系统不是我凭空想的,它是一个非常典型的 Java Web 全栈项目:患者线上挂号、医生处理门诊、书写电子病历、开处方、药品库存管理、后台统一管控。适合正在做毕设的同学参考,也适合想系统梳理 SpringBoot 开发流程的初级程序员阅读。
我在最初拿到这个题目时,第一反应不是急着写代码,而是把整个医院诊疗的业务链路先在纸上过了一遍。因为毕设项目有一道隐形门槛:功能要足够“真”,但技术复杂度又不能失控。这一步怎么权衡,直接决定了后续开发的顺利程度。
1. 项目重构思路与技术选型
1.1 为什么选 SpringBoot 而不是 SSM 或微服务
这个项目最早其实有人建议我用 SSM(Spring + SpringMVC + MyBatis)来做,说是更“传统经典”,但我最终选了 SpringBoot,而且我非常建议做毕设的同学优先考虑它。
核心原因有三个:
一是开发效率差距肉眼可见。SSM 里配置数据源、事务、扫描包要写一大堆 XML,光是配置文件就有五六七个,SpringBoot 用自动配置把这些都收进了起步依赖里,一个spring-boot-starter-web就把 Controller 加内置 Tomcat 全带上了,配置文件集中到 application.yml 一个文件里搞定。
二是单体应用足够承载毕设场景。线上诊疗系统虽然业务链路不短,但并发量、数据量都远没有到需要微服务拆分的程度。微服务架构光解决服务间调用的网络问题、事务一致性问题就能把精力耗尽,而且答辩时极容易被老师追问服务治理细节。单体 SpringBoot 把复杂度控制在合理范围,业务逻辑能讲透,反而更容易拿高分。
三是生态和资料极其丰富。遇到任何问题,搜索引擎里几乎都能找到现成的解决思路,这对没有太多项目经验的同学来说是隐形的效率加成。
选型上我最终确定的技术栈是:SpringBoot 2.x + MyBatis-Plus + MySQL 8.x + Lombok + Hutool,前端用的 Thymeleaf 加 Bootstrap,没有强行上前后端分离。
提示:如果是有一定基础的同学,可以把前端换成 Vue 3 + Element Plus,用接口方式对接,项目会显得更有分量。但如果你时间紧迫,服务端模板渲染的 Thymeleaf 方案能让你把核心精力集中在 Java 后端逻辑上。
1.2 系统边界怎么划,不能只有 CRUD
毕设项目最容易犯的错误是“用力过猛”,把系统做成一张表加几个增删改查接口就草草收尾。医院线上诊疗管理系统必须把业务做透,但也不能贪多嚼不烂。
我在规划时划定了三个核心角色,围绕他们的日常操作来展开业务:
- 患者:注册登录、查看科室与医生排班、在线预约挂号、查看个人病历、在线缴费。
- 医生:查看当日挂号队列、接诊患者、书写病历、开立处方、申请检查检验。
- 管理员:科室管理、医生信息审核、药品目录维护、排班管理、就诊数据统计。
这三个角色的业务交互形成了完整的诊疗闭环:患者预约 -> 医生接诊 -> 病历记录 -> 处方开药 -> 缴费完成。同时我还加了一个隐藏的小模块——药品库存预警,当库存量低于阈值时系统自动发送通知给管理员,这个设计在答辩时非常有话题性。
在规划系统边界时,我有意识地砍掉了两个容易拖垮进度的功能:在线支付对接和短信通知。真实医院的支付和短信接口需要企业资质,毕设阶段完全可以用“模拟支付”和“站内信”来代替,效果一样但省去大量联调时间。
1.3 工程目录结构:按业务模块分包,不按层分包
很多同学的工程结构是controller、service、mapper这种纯技术分层方式。项目小的时候没事,一旦业务一多,一个 UserController 里可能同时存在挂号、开药、改密码的接口,二百多行的 Controller 让人头皮发麻。
我采用的是按业务模块分包,模块内部再分层的方式:
com.hospital.platform ├── common -- 通用返回结果、异常处理、常量 ├── config -- 配置类(MyBatis-Plus、WebMvc、跨域) ├── security -- JWT、拦截器、权限注解 ├── modules │ ├── auth -- 登录注册 │ ├── user -- 用户管理 │ ├── department -- 科室管理 │ ├── schedule -- 排班管理 │ ├── appointment -- 预约挂号 │ ├── medical -- 病历与处方 │ ├── drug -- 药品管理 │ └── statistics -- 统计报表这样每个业务模块独立演进,模块之间通过 Service 层互相调用,职责清晰。答辩时老师问你“处方模块在哪”,你直接指向 medical 包下的 prescription 子包就好,这种组织方式本身就是“没白学”的最好证明。
2. 核心业务流程与功能模块拆解
2.1 挂号预约:业务中最容易出状态混乱的环节
挂号预约是整个系统的门面,也是逻辑密度最高的地方。患者进入系统后选定科室,看到该科室下医生的排班信息,选择一个时间段提交预约。系统这里要处理一个核心矛盾:一个排班时段只能被预约固定人次,涉及并发场景,虽然不是高并发,但绝对不能出现超卖。
我的做法是给排班表schedule增加两个字段:total_count(总号源)和remain_count(剩余号源)。预约时不是简单查剩余数是否大于0,而是使用 MyBatis-Plus 提供的条件更新能力:
boolean success = scheduleMapper.update(null, new LambdaUpdateWrapper<Schedule>() .eq(Schedule::getId, scheduleId) .gt(Schedule::getRemainCount, 0) .setSql("remain_count = remain_count - 1")) > 0; if (success) { // 创建预约记录 } else { // 号源不足,返回友好提示 }这条 SQL 在数据库层面完成了“判断剩余库存并扣减”这两个操作,是原子性的,比先查再改安全得多。哪怕没有开事务,也不用担心并发时出现负库存。
预约状态我设计成了枚举:待就诊->已完成/已取消/已过期。为了不让“已过期”的号长期占用资源,我写了一个定时任务,每半小时扫描一次超过就诊时间仍未就诊的预约记录,自动将状态置为“已过期”,同时回补排班的剩余号源。
2.2 医生接诊与电子病历:数据结构比界面美观更重要
医生端核心是接诊工作台。医生看到当日待就诊患者列表,点击“开始接诊”,就进入病历书写页面。
电子病历的数据结构需要单独建一张medical_record表,字段包括:患者 ID、医生 ID、就诊时间、主诉、现病史、既往史、体格检查、初步诊断、处理意见、病历状态(暂存/提交)。同时为了满足毕设查重要求,我加入了“病历模板”功能——医生可以保存常用模板,开诊时一键套用再微调。
处方则是独立的prescription表,关联病历表和医生 ID,明细放在prescription_item表里。为什么明细要单独拆表?这是关系数据库设计的基本功:一张处方可能有多条药品项,每条药品项有自己的数量、剂量、金额,如果塞在同一张表里会产生大量重复数据,违背第二范式。拆开后用处方主键关联明细,是教科书级的设计,也是老师喜欢看到的地方。
在字段设计上有一条重要经验:诊断结果不适合用单个字符串字段存储。我一开始写成diagnosis VARCHAR(255),后来发现医生可能给出多个诊断,比如“高血压”加“2型糖尿病”,中间用顿号拼接,统计时完全没法按病种分组。后来改为诊断表,用逗号分隔多个诊断编码,才把这块跑通。如果时间紧张,至少也要做成 JSON 数组字符串。
2.3 药品库存与处方校验:一个容易暴露逻辑漏洞的地方
开处方时,系统需要做三个层面的校验:
- 药品是否存在且状态正常(禁用药品不可下单)
- 库存是否充足,不足时提示医生可换药或通知库房补货
- 金额计算,单价乘以数量乘以疗程天数,保留两位小数
我在药品库存的扣减上沿用了号源扣减的思路,也采用条件更新的原子操作。这里有一个插曲:第一次联调时发现患者缴费成功后药品库存已经扣掉了,但如果之后管理员手动作废了这个处方,库存不会自动回补。后来我在“处方作废”接口里加了库存回滚逻辑,才算把这个闭环补完整。
注意:凡涉及金额和库存的操作,都必须在同一次数据库事务内完成,绝对不能出现“扣库存成功但生成缴费单失败”的中间状态。在 Service 方法上标注
@Transactional(rollbackFor = Exception.class)是最基本的保障。
2.4 管理后台:别忽略统计功能这个加分项
管理后台如果只做简单的 CRUD 就很平庸。我加了一个“运营数据看板”,用 ECharts 展示近一周各科室挂号量趋势、医生接诊量排行、药品消耗 TOP10。实现方式并不复杂,就是写几条带GROUP BY和日期函数的统计 SQL,把结果组装成前端要的 JSON 格式。
统计模块最考验的还是 SQL 能力,比如“近7天每日挂号量”:
SELECT DATE(create_time) AS day, COUNT(*) AS cnt FROM appointment WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(create_time) ORDER BY day这类查询在答辩时一定要能当场讲明白:用了哪个聚合函数、日期范围怎么圈定、为什么用DATE_SUB。你讲得越细,老师越觉得工作量饱和。
3. 数据库设计:核心表结构与关系梳理
3.1 用户体系设计:一个用户表还是拆多个角色表
这是很多同学纠结的问题。我的方案是:一张用户主表 + 两张角色详情表。
sys_user表只存放登录必备字段:用户名、加密密码、手机号、角色类型(USER/DOCTOR/ADMIN)、状态(正常/锁定)、创建时间。患者和医生的个性化信息分别放到patient_info和doctor_info表里,用user_id关联主表。
这样设计的好处有三层:
- 登录逻辑只需查询一张表,效率高;
- 角色扩展性好,以后加“护士”“药师”只需增加详情表,不用动主表;
- 避免大量空字段。如果把医生职称、科室、执业编号等字段也放用户主表,普通患者注册时这些字段全是 NULL,既浪费空间又让代码里出现大量空值判断。
3.2 核心业务表一览
| 表名 | 用途 | 关键字段 |
|---|---|---|
| sys_user | 登录账号 | username, password, role, status |
| patient_info | 患者档案 | real_name, id_card, phone, address |
| doctor_info | 医生档案 | user_id, dept_id, title, introduce |
| department | 科室 | name, description, status |
| schedule | 医生排班 | doctor_id, dept_id, date, period, total_count, remain_count |
| appointment | 预约挂号 | patient_id, schedule_id, status, create_time |
| medical_record | 病历 | patient_id, doctor_id, diagnosis, content, status |
| prescription | 处方单 | record_id, doctor_id, total_amount, status |
| prescription_item | 处方明细 | prescription_id, drug_id, quantity, amount |
| drug | 药品库 | name, specification, unit, stock, price, status |
| payment_record | 缴费记录 | appointment_id, amount, method, status |
我在设计表时有一个习惯:金额字段一律用DECIMAL(10,2),不用 FLOAT 或 DOUBLE。浮点数在二进制下存在精度误差,比如 0.1 + 0.2 在 Java 的 double 里并不精确等于 0.3。涉及钱一脸严肃,必须用定点数。
排序、分页等功能需要的数据字段,在建表时就想清楚,比如挂号记录按时间排序,create_time必须有索引。数据库层面这些小细节平时看不见,等数据量大了或者答办展示慢查询时,就成了关键差异点。
3.3 状态字段选型:枚举值是“写死”还是存字典
我建表时所有状态字段都直接存tinyint,但 Java 里用了枚举来做代码层面的强约束。例如预约状态:
public enum AppointmentStatus { PENDING(0, "待就诊"), FINISHED(1, "已完成"), CANCELLED(2, "已取消"), EXPIRED(3, "已过期"); private final Integer code; private final String desc; }数据库里看到的 0、1、2、3 各是什么含义,前端展示时统一走枚举的 desc 字段。这样做既避免了在 Java 代码里到处写魔法数字“0”和“1”,又比单独建一张字典表简单直观。
如果要进一步提升专业感,可以在状态上加数据库乐观锁版本号字段version,做更新操作时带上旧版本号条件。虽然毕设阶段用处不大,但这个概念在简历上是一个加分点。
4. 关键技术实现与代码示例
4.1 JWT 登录认证:从点到面实现无状态会话
之前用 Session 做登录,每次请求靠 Cookie 里的 JSESSIONID 去服务端找会话记录。毕设阶段单机部署问题不大,但用 JWT 的“无状态”思路更有跟界感,也方便今后扩展成前后端分离模式。
我的实现链路是:
- 用户提交用户名密码,校验通过后生成 JWT 返回前端;
- 前端把 Token 存到 localStorage,每次请求放在
Authorization请求头里; - 后端写一个拦截器,对所有需要登录的接口解析 Token,解析成功则把用户 ID 放到 ThreadLocal 里,后面的 Controller 和 Service 直接取。
生成 JWT 的核心工具类大致如下:
public class JwtUtil { private static final SecretKey KEY = Keys.hmacShaKeyFor( "your-secret-key-your-secret-key!".getBytes(StandardCharsets.UTF_8)); public static String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 120)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); } }提示:实际项目里 SecretKey 不能硬编码在类里,我为了演示简化了。毕设项目至少做到从配置文件读取,答辩时这点小细节非常加分。
ThreadLocal 的使用有个大坑:必须在请求结束后清理,否则线程池复用线程时,会把上一个请求的用户信息带到下一个请求里去。我在拦截器的afterCompletion方法里调用UserContext.clear(),这一点易错但常被忽略。
4.2 自定义权限注解:比 Spring Security 更轻量的方案
Spring Security 功能强大但对毕设项目来说学习曲线偏陡,配置 SecurityFilterChain 时容易踩坑。我选择自己写一个轻量级的权限控制:自定义@RequireRole注解,加在 Controller 方法上,配合拦截器实现角色校验。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); }拦截器中:
if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole != null) { String role = UserContext.getCurrentUser().getRole(); if (!Arrays.asList(requireRole.value()).contains(role)) { throw new BusinessException(403, "无权访问"); } } }这样在写接口时只需要一句话声明:
@RequireRole("DOCTOR") @PostMapping("/medicalRecord") public Result createRecord(@RequestBody MedicalRecord record) { ... }这种方式代码透明、容易讲清楚,整个 JWT + 拦截器 + 自定义注解的组合,本身就是一条完整的权限认证知识链,比单纯依赖框架更能体现功底。
4.3 密码加密:别再用 MD5 裸存储了
网上很多使用教程还在用 MD5 直接存密码。这种方式已经非常不安全:因为彩虹表的存在,攻击者可以倒查大量常见密码的 MD5 值。我在项目中引入了 Spring Security 自带的BCryptPasswordEncoder:
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); String encodedPwd = encoder.encode(rawPassword); // 校验 boolean matches = encoder.matches(rawPassword, encodedPwd);BCrypt 是加盐哈希算法,同一密码每次生成的哈希值都不同,它能显著提升暴力破解的成本。虽然项目引入了 Spring Security 的加密工具类,但不引入完整的过滤链配置,就不至于陷入整套安全框架的配置泥潭。这也算一种“按需引入”的工程思路。
4.4 MyBatis-Plus 使用体验:自动填充与逻辑删除
MyBatis-Plus 对毕设项目极其友好,内置insert和update方法,省去大量手写 SQL。有两点配置需要重点掌握。
一是自动填充创建时间和更新时间。实现MetaObjectHandler接口,在插入和更新时统一填值,这样业务代码里就不需要手动 set 时间字段了:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }二是逻辑删除。不需要真正把数据从表里删掉,而是在实体字段上加@TableLogic注解,删除操作就自动变成UPDATE ... SET deleted = 1。对病历这类有存档价值的业务数据,逻辑删除是对的方案。
5. 实操过程:从零搭建与核心接口实现三件套
5.1 项目初始化与依赖配置
创建 SpringBoot 项目我用的是 Spring Initializr,选择 Java 8 版本(兼容性最稳),依赖加入 Spring Web、MyBatis-Plus、MySQL Driver、Lombok。
pom.xml关键依赖如下:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.2</version> </dependency>application.yml配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0注意:数据库连接串里的
serverTimezone=Asia/Shanghai必须加,否则 8.x 版本的 MySQL 驱动会和本地时区对不上,报时间类型转换错误。
5.2 统一返回结果与全局异常处理
为了让前端拿到的数据格式一致,我定义了一个Result<T>类:
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static <T> Result<T> error(Integer code, String message) { Result<T> r = new Result<>(); r.code = code; r.message = message; return r; } }配合@RestControllerAdvice写全局异常捕获,业务中抛BusinessException,框架异常转成友好提示。顺手挡掉 500 状态给用户的“白页”尴尬,也让日志里可控异常与不可控异常分开。
5.3 核心接口实现:以“医生提交病历并开处方”为例
这个接口涉及两张表写入,适合边写边讲。
@Transactional(rollbackFor = Exception.class) public void submitRecordAndPrescription(MedicalRecord record, List<PrescriptionItem> items) { // 1. 保存病历主表 record.setDoctorId(UserContext.getCurrentUser().getUserId()); record.setStatus(1); medicalRecordMapper.insert(record); // 2. 创建处方单 Prescription prescription = new Prescription(); prescription.setRecordId(record.getId()); prescription.setDoctorId(record.getDoctorId()); BigDecimal total = calculateTotal(items); prescription.setTotalAmount(total); prescriptionMapper.insert(prescription); // 3. 批量保存处方明细 for (PrescriptionItem item : items) { item.setPrescriptionId(prescription.getId()); prescriptionItemMapper.insert(item); } // 4. 扣减库存 for (PrescriptionItem item : items) { boolean ok = drugMapper.update(null, new LambdaUpdateWrapper<Drug>() .eq(Drug::getId, item.getDrugId()) .ge(Drug::getStock, item.getQuantity()) .setSql("stock = stock - " + item.getQuantity())) > 0; if (!ok) { throw new BusinessException("药品库存不足"); } } }@Transactional保证这四步要么全部成功,要么全部回滚。我在开发过程中专门做了一次“半路抛异常”测试,验证了库存和病历不会出现不一致。这个测试过程可以写进毕设文档的测试章节,会是答辩时的亮点。
5.4 前端页面对接:用 Thymeleaf 怎么处理登录态
Thymeleaf 方案下,登录成功后把 Token 放在 Cookie 里,页面跳转时 JavaScript 端统一从 Cookie 读取并放在 AJAX 请求头。静态资源路径用th:href="@{/css/style.css}"方式引用,加 ContextPath 前缀,部署到服务器后不会因为路径问题丢样式。
页面可以做得简洁但功能要全。我的页面清单里包括:登录注册页、患者首页(科室列表+医生列表)、预约页(选医生+选时段)、我的预约页、医生工作台(队列+病历+处方)、管理后台(科室+医生+药品+排班+统计)。
6. 常见问题排查与避坑经验
6.1 MyBatis-Plus 扫描不到 Mapper 接口
现象是启动项目后提示Invalid bound statement (not found),这是毕设阶段最常见的问题。排查步骤只有两步:
- 启动类上有没有加
@MapperScan("com.hospital.platform.modules.*.mapper"); - Mapper 接口上有没有加
@Mapper注解。
两个只要有一个就能被扫描到。同时确认 XML 文件放的路径与mapper-locations配置一致。如果是 Maven 项目,XML 放在src/main/java下时构建阶段不会被复制到 classes 目录,需要在 pom 里加 resources 配置。
6.2 LocalDateTime 返回给前端变成数组
SpringBoot 默认序列化 LocalDateTime 时会转成一串数字数组,前端根本没法直接用。解决方式是在 application.yml 里做全局 JSON 配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8以及给实体时间字段加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")。这里我个人的经验是:全局配置和字段注解要同步加,只加全局配置的话,某些场景下依然会沿用默认序列化,双保险最省心。
6.3 数据库连接把本地部署的 MySQL 拒绝了
MySQL 8.x 默认使用caching_sha2_password认证插件,如果本地客户端驱动版本太低,会报认证失败。解决方式是在 pom 里使用新版驱动:
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>另外数据库建库时字符集务必设置 utf8mb4,否则中文内容存入数据库后显示乱码:
CREATE DATABASE hospital_platform DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;6.4 端口被占用导致启动失败
这是新手的经典绊脚石。排查用的命令:
# Windows netstat -ano | findstr 8080 # Linux/Mac lsof -i :8080找到占用进程的 PID 后直接 kill。为了避免反复出现,我习惯在 application.yml 里把端口改成 8088,避开日常开发中常见的默认端口冲突。
6.5 Lombok 在编译期报错找不到 getter/setter
Lombok 属于编译期注解处理器,IDE 里报错通常是没有安装插件或没有启用注解处理。IDEA 里需要在 Preferences -> Build -> Compiler -> Annotation Processors 勾选 Enable annotation processing。如果是新版 IDEA,Lombok 插件一般内置,但还是偶尔因为版本不匹配出问题。减少此类干扰,也可以用 IDE 自带的getter/setter快捷键生成,或者把核心实体类改成手写。
7. 可以继续优化的方向
如果学有余力,下面几个方向能显著提升项目的完成度,也方便在简历上重点标注:
一是引入 Redis 缓存。医生排班这种高频读取、低频修改的数据,非常契合 Redis 缓存场景。把 schedule 数据在首次查询后写入 Redis,设置过期时间,预约扣减号源时同步更新缓存的剩余号源,再通过定时任务做持久化。这部分和毕设项目结合自然,不会显得生硬。
二是科室工作量统计报表。用定时任务每天跑一次统计,生成“科室接诊量日报”,以图表形式展示在管理后台。这种功能真实医院也有需求,做出来以后能直观体现“全流程管理”的价值。
三是把代码规范的检查纳入项目。例如强制开启 MyBatis-Plus 的 SQL 日志,确保所有 SQL 都能被解释。又比如统一使用@ControllerAdvice处理异常,避免 Controller 里到处 try-catch。这些不只是项目完成度,还反映工程习惯,答辩时不愁没得聊。
回头再看这个项目,真正让我觉得没白做的,不是代码写了多少行,而是把“挂号—接诊—病历—处方—缴费”这条业务链完整走了一遍。医院诊疗系统其实是个很磨人的领域,每个模块单独看不难,但串起来后涉及的状态流转、库存扣减、事务边界,都是能写进简历的真实项目经验。如果你是照着这个思路在做毕设,记住一件事——业务闭环比技术炫技重要得多,先让数据在系统里转起来,再谈高性能、高可用。