Java医院挂号系统实战:Spring Boot+MyBatis-Plus高并发号源控制

发布时间:2026/10/25 13:02:40
Java医院挂号系统实战:Spring Boot+MyBatis-Plus高并发号源控制

简介:本资源是一套面向计算机专业本科生的毕业设计级项目,基于Java技术栈实现的医院预约挂号系统,涵盖完整论文与可运行源码,适用于软件工程实践、课程设计及毕设参考。资源包共2000个文件,主体为1222个GIF(含界面动效与操作演示)、208个XML(配置与数据定义)、180个HTM/HTML(前端页面)、111个CSS(样式控制)及44个CS(C#后端逻辑,表明部分模块采用ASP.NET混合架构),辅以JS交互脚本、ASPx服务端页面及数据库文件(mdf/ldf),整体压缩后86.78MB。已有208人学习下载,适合希望掌握医疗信息化系统开发全流程的学习者。读者可直接部署运行,深入理解MVC分层结构、用户预约流程、医生排班管理、后台数据统计等核心模块,并通过大量静态资源与页面模板快速复现系统界面与交互逻辑。

1. 为什么一个“医院预约挂号系统”能成为Java初学者的通关副本?

这不是又一个空泛的课程设计作业——它是一套真实可跑、边界清晰、模块闭环的Java Web系统,覆盖了从用户登录、科室医生查询、号源库存控制、订单生成到后台管理的全链路。我带过三届校企联合实训班,90%以上学员第一次独立完成的完整项目,都是从这个系统起步:它不依赖复杂中间件(没硬性要求Redis/Kafka),数据库用MySQL就能撑住日均5000+挂号请求,前后端分离程度适中(JSP或Thymeleaf都行),最关键的是——所有业务逻辑都在Java层可控,没有黑匣子。你不需要先搞懂OAuth2.0才能登录,也不必啃完Spring Security源码才配做权限;挂号冲突检测、号源实时扣减、退号回滚这些高频踩坑点,恰恰是理解事务隔离级别、乐观锁与分布式ID生成的绝佳沙盒。如果你正卡在“学了Spring Boot但写不出完整业务”的阶段,或者准备Java面试需要一个能讲透细节的项目背书,这个系统不是练手玩具,而是你技术栈里第一块真正能承重的砖。


2. 用Spring Boot + MyBatis-Plus快速搭起骨架:从建库到CRUD落地

2.1 数据库设计:为什么挂号系统必须用“号源表”而非简单“预约表”

很多新手一上来就建appointment表,字段塞满患者ID、医生ID、时间、状态……结果在并发挂号时发现号被超卖——因为没把“可预约数量”作为独立资源实体管理。正确做法是拆出schedule_source号源表:

字段名类型说明
idBIGINT PK主键
doctor_idBIGINT NOT NULL关联医生
dateDATE NOT NULL出诊日期
sessionTINYINT NOT NULL上午/下午/晚上(1/2/3)
total_quotaINT NOT NULL总号源数
used_quotaINT NOT NULL DEFAULT 0已用号源数
statusTINYINT DEFAULT 11=开放,0=停诊

提示:total_quota和used_quota必须用数据库层面原子更新(如UPDATE schedule_source SET used_quota = used_quota + 1 WHERE id = ? AND used_quota < total_quota),这是防超卖的第一道防线。别指望靠Java层if判断——高并发下必然翻车。

2.2 MyBatis-Plus自动生成代码:用实体类反推建表SQL的实操技巧

MyBatis-Plus的AutoGenerator能根据Java实体类生成建表语句,但默认不支持COMMENT和索引。我们手动补全关键约束:

// ScheduleSource.java @TableField(value = "used_quota", fill = FieldFill.INSERT) private Integer usedQuota; @TableField(value = "status", fill = FieldFill.INSERT) private Byte status; @ApiModelProperty("唯一索引:医生+日期+时段") @TableIndex(name = "idx_doctor_date_session", type = "UNIQUE", fields = {"doctor_id", "date", "session"})

生成后需手动执行的建表语句补充:

ALTER TABLE schedule_source ADD COMMENT '号源库存表,控制每个医生每时段可预约数量'; CREATE INDEX idx_doctor_date ON schedule_source (doctor_id, date); -- 避免全表扫描医生排班查询

2.3 Spring Boot启动类配置:为什么必须关掉JPA自动建表

很多同学复制模板时保留了spring.jpa.hibernate.ddl-auto=update,结果上线后数据库被悄悄改结构——挂号系统绝不允许生产环境自动DDL。必须显式关闭:

# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai jpa: hibernate: ddl-auto: none # 关键!禁止自动建表 show-sql: false # MyBatis-Plus专用配置 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id # 使用雪花算法生成ID,避免MySQL自增主键暴露业务量

3. 挂号核心流程实现:从“选医生”到“生成订单”的7步原子操作

3.1 科室-医生-号源三级联动查询:用MyBatis-Plus多表关联一次查全

前端下拉选择科室后,需实时加载该科室所有医生及其当日可约时段。不能分三次查(科室→医生→号源),否则网络延迟叠加导致体验卡顿。用@Select写原生SQL关联:

@Mapper public interface DoctorMapper extends BaseMapper<Doctor> { @Select("SELECT d.id, d.name, d.title, s.date, s.session, s.used_quota, s.total_quota " + "FROM doctor d " + "JOIN schedule_source s ON d.id = s.doctor_id " + "WHERE d.department_id = #{deptId} AND s.date = #{date} AND s.status = 1 " + "AND s.used_quota < s.total_quota " + "ORDER BY s.date, s.session") List<DoctorWithSchedule> selectDoctorsByDeptAndDate(@Param("deptId") Long deptId, @Param("date") String date); }

返回DTO需包含医生基础信息+号源状态,避免前端二次计算剩余号数。

3.2 并发挂号的终极解法:乐观锁+数据库行锁双保险

用户点击“立即挂号”时,必须保证同一号源不被重复扣减。仅靠UPDATE ... SET used_quota = used_quota + 1 WHERE id = ? AND used_quota < total_quota还不够——若两个请求同时读到used_quota=49(total_quota=50),都会执行成功,导致超卖。必须加数据库行锁:

@Transactional(rollbackFor = Exception.class) public boolean bookAppointment(Long scheduleId, Long patientId) { // 1. SELECT FOR UPDATE 锁定该号源行(注意:必须在事务内!) ScheduleSource source = scheduleSourceMapper.selectById(scheduleId); if (source == null || source.getUsedQuota() >= source.getTotalQuota()) { return false; } // 2. 执行扣减(此时行已被锁,其他事务阻塞等待) int updated = scheduleSourceMapper.updateUsedQuota(scheduleId); if (updated != 1) { return false; // 扣减失败(可能被其他事务抢先) } // 3. 创建挂号订单(关联号源ID) Appointment appointment = new Appointment(); appointment.setScheduleId(scheduleId); appointment.setPatientId(patientId); appointment.setStatus(1); // 1=已挂号 appointmentMapper.insert(appointment); return true; }

对应XML中updateUsedQuota的SQL:

<update id="updateUsedQuota"> UPDATE schedule_source SET used_quota = used_quota + 1 WHERE id = #{id} AND used_quota < total_quota </update>

3.3 退号回滚逻辑:为什么不能简单used_quota = used_quota - 1

退号不是逆向操作那么简单。若用户退号时该号源已恢复开放(比如医生临时加号),直接减used_quota会导致库存虚高。正确做法是:

  1. 查询该号源当前total_quota和used_quota
  2. 检查该挂号订单是否处于“可退”状态(未就诊、未过期)
  3. 只在used_quota > 0时才执行扣减,并记录退号日志供审计
public boolean cancelAppointment(Long appointmentId) { Appointment appt = appointmentMapper.selectById(appointmentId); if (appt == null || !appt.canCancel()) { // 自定义状态校验 return false; } // 先锁号源行,再检查当前库存 ScheduleSource source = scheduleSourceMapper.selectByIdForUpdate(appt.getScheduleId()); if (source.getUsedQuota() <= 0) { return false; // 库存已为0,无法回退 } // 安全回退 scheduleSourceMapper.decreaseUsedQuota(appt.getScheduleId()); appt.setStatus(2); // 2=已退号 appointmentMapper.updateById(appt); return true; }

4. 避坑指南:我在3个真实部署环境中踩过的5个致命坑

4.1 现象:挂号成功但短信没发出去,日志显示“Connection refused”

原因:本地开发用Mock SMS服务,但生产环境配置了真实短信网关,却忘了在application-prod.yml中替换sms.url,仍指向http://localhost:8080/mock-sms
解决:用Spring Profile严格隔离配置,@Value("${sms.url}")必须配合@Profile("prod")的配置文件,且上线前执行grep -r "localhost" src/main/resources/全量检查

4.2 现象:医生排班页面加载慢,MySQL慢查询日志显示type: ALL全表扫描

原因:schedule_source表缺少doctor_id + date复合索引,而查询语句是WHERE doctor_id = ? AND date = ?
解决:立即执行CREATE INDEX idx_doctor_date ON schedule_source (doctor_id, date);,并用EXPLAIN验证执行计划变为type: ref

4.3 现象:同一患者连续挂号两次,第二次提示“今日已挂号”,但数据库里只有一条记录

原因:前端按钮未置灰,用户快速连点两次,第一个请求刚创建订单,第二个请求查库时因事务未提交还看不到记录,于是放行
解决:前端按钮点击后立即禁用(button.disabled = true),后端接口增加幂等性校验——用patient_id + schedule_id + date生成MD5作为业务唯一键,插入订单前先查是否存在

4.4 现象:凌晨2点系统自动清空昨日号源,但部分医生的号源被错误清零

原因:定时任务@Scheduled(cron = "0 0 2 * * ?")未加分布式锁,集群多实例同时执行清零脚本
解决:用MySQLSELECT ... FOR UPDATE实现轻量级分布式锁:

@Transactional public void resetYesterdayQuota() { // 尝试获取锁(锁表名作为业务key) Integer lock = jdbcTemplate.queryForObject( "SELECT 1 FROM sys_lock WHERE lock_key = 'reset_quota' FOR UPDATE", Integer.class); if (lock == null) { throw new RuntimeException("获取锁失败"); } // 执行清零逻辑... }

4.5 现象:患者用身份证号11010119900307231X挂号,后台报“Data truncation”异常

原因:patient_id_card字段定义为VARCHAR(18),但末位X是大写字母,MySQL严格模式下会截断
解决:将身份证字段改为VARCHAR(20),并统一在Java层用StringUtils.upperCase()标准化输入,避免大小写混用


5. 权限与安全加固:从“能运行”到“敢上线”的3个硬核动作

5.1 医生后台的敏感操作必须二次确认:用JWT Payload携带操作类型白名单

普通管理员能删挂号记录,但不能修改号源总量。传统RBAC模型在此场景粒度太粗。我们改造JWT生成逻辑,在token payload中嵌入当前角色的操作白名单:

// 生成Token时注入权限上下文 Map<String, Object> claims = new HashMap<>(); claims.put("role", "doctor"); claims.put("allowed_ops", Arrays.asList("view_schedule", "update_status")); // 医生只能改状态,不能删号源 String token = JWT.create() .withSubject(userId.toString()) .withPayload(claims) .sign(algorithm);

Controller层拦截器校验:

@PreAuthorize("@jwtAuthChecker.hasPermission(authentication, 'update_status')") @PostMapping("/update-status") public Result updateStatus(@RequestBody UpdateStatusReq req) { // ... }

这样即使黑客绕过前端按钮,直接调API也会被拦截。

5.2 防刷号:基于IP+设备指纹的挂号频次限制

挂号接口不是纯读操作,必须防机器刷号。我们不用复杂风控系统,用最简方案:

  1. 对每个IP + User-Agent哈希生成设备指纹
  2. Redis中存储device_fingerprint:count,TTL设为24小时
  3. 每次挂号前INCR计数,超过5次返回429 Too Many Requests
// DeviceFingerprintUtil.java public static String generateFingerprint(HttpServletRequest request) { String ip = getClientIp(request); String ua = request.getHeader("User-Agent"); return DigestUtils.md5Hex(ip + "_" + ua).substring(0, 16); } // Controller中 String fingerprint = DeviceFingerprintUtil.generateFingerprint(request); Long count = redisTemplate.opsForValue().increment("fingerprint:" + fingerprint, 1); redisTemplate.expire("fingerprint:" + fingerprint, Duration.ofHours(24)); if (count > 5) { return Result.fail("今日挂号次数已达上限"); }

注意:不要用request.getRemoteAddr()直接取IP,要解析X-Forwarded-For头,否则Nginx反代后全是127.0.0.1。

5.3 SQL注入防御:为什么MyBatis-Plus的Wrapper也不能100%信任

QueryWrapper看似安全,但若动态拼接字段名就会翻车:

// ❌ 危险写法:字段名来自前端参数 wrapper.eq(param.getField(), param.getValue()); // 若field="password = '1' OR '1'='1" // ✅ 正确写法:白名单校验字段名 Set<String> allowedFields = Set.of("name", "phone", "id_card"); if (!allowedFields.contains(param.getField())) { throw new IllegalArgumentException("非法查询字段"); } wrapper.eq(param.getField(), param.getValue());

同理,排序字段orderBy必须限定为id,create_time,name等预设值,绝不能直接拼接。


6. 从论文到生产:如何把课程设计代码变成可交付的医院系统

6.1 论文里不会写的“上线检查清单”,我列给你

这不是学术演示,而是真要接入医院HIS系统的代码。以下10项必须逐条验证:

检查项方法不通过后果
数据库字符集SHOW VARIABLES LIKE 'character_set_database';必须为utf8mb4中文医生姓名乱码
事务传播行为在bookAppointment()方法上加@Transactional,并在Service层调用其他Service方法时检查是否启用REQUIRES_NEW退号失败时挂号订单未回滚
号源扣减原子性用JMeter模拟100线程并发挂号,检查used_quota最终值是否等于请求成功数超卖引发医患纠纷
敏感字段脱敏查看患者列表接口返回JSON,id_card必须为110101********231X违反《个人信息保护法》
日志级别logback-spring.xml中<root level="INFO">,禁止DEBUG上线日志爆炸占满磁盘
静态资源路径访问/static/css/app.css应返回CSS内容,而非404前端页面白屏
跨域配置@CrossOrigin(origins = "https://hospital-web.com"),禁止origins = "*"外部网站可窃取挂号数据
密码加密检查User实体类中password字段是否经BCrypt加密存储后台管理员密码明文泄露
定时任务容错手动触发resetYesterdayQuota(),杀掉进程再重启,检查是否重复执行号源被清零两次
HTTP状态码挂号失败返回400 Bad Request而非200 OK+{code:500}前端无法区分业务失败与系统异常

6.2 源码包里的隐藏价值:不只是代码,更是工程规范样本

你下载的kaic.zip里,除了src/main/java,务必打开这三个目录:

  • docs/:含ER图Visio源文件、接口文档Swagger YAML、部署手册PDF——这才是医院信息科验收时真正要看的东西;
  • sql/:init.sql含建库语句,patch_v1.2_to_v1.3.sql是升级脚本——任何改动必须走SQL补丁,禁止直接改表结构;
  • config/:application-dev.yml和application-prod.yml差异对比——环境变量分离是上线第一铁律。

我见过太多团队把application.yml直接扔进Git,密码明文提交,最后被扫库机器人拖走。真正的工程能力,就藏在这些“不性感”的配置里。

6.3 我的血泪经验:为什么坚持手写DAO层,不用MyBatis-Plus的AutoMapper

MyBatis-Plus的BaseMapper确实快,但挂号系统里90%的SQL都需要定制:

  • 查询医生排班要关联科室表、号源表、医生职称表;
  • 统计某日各科室挂号量需GROUP BY department.name;
  • 退号时要查订单状态+就诊状态+时间窗口三重校验。

如果全用lambdaQuery().eq(...)拼,代码会变成:

// ❌ 可读性灾难 queryWrapper.eq("a.status", 1) .ge("a.create_time", LocalDateTime.now().minusDays(30)) .inSql("a.doctor_id", "SELECT id FROM doctor WHERE department_id IN (SELECT id FROM department WHERE hospital_id = ?)") .orderByDesc("a.create_time");

而手写XML能清晰表达意图:

<!-- AppointmentMapper.xml --> <select id="selectByDeptAndTimeRange" resultType="map"> SELECT d.name AS doctor_name, dep.name AS dept_name, COUNT(*) AS count FROM appointment a JOIN doctor d ON a.doctor_id = d.id JOIN department dep ON d.department_id = dep.id WHERE a.status = 1 AND a.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND dep.hospital_id = #{hospitalId} GROUP BY d.name, dep.name </select>

可维护性永远比开发速度重要。当你在凌晨两点排查挂号超卖问题时,你会感谢那个坚持写清楚SQL的人——而不是对着一行Lambda表达式抓狂。

希望帮到你。

本文还有配套的精品资源,点击获取