☰
医院预约挂号系统 Java 实战:从数据库设计到并发预约核心实现
2026/10/31 5:55:10 网站建设 项目流程

简介:这是一套面向计算机、通信、人工智能、自动化等专业学生与从业者的医院预约挂号系统毕业设计资源,基于Java实现前后端功能,可直接用于期末课程设计、课程大作业或毕业设计参考。压缩包共189个文件,包含55个Java源码、39个JSP页面、17个CSS样式、9个XML配置、5个JavaScript脚本及3个SQL数据库脚本,另有大量PNG/JPG图片辅助界面预览和文档说明,整体仅1.81MB,目录结构清晰,便于快速部署和学习。项目已在答辩评审中获得98分,代码经过调试测试可正常运行,适合小白学习进阶,也方便基础较好的读者在此基础上修改扩展。资源附带完整数据库脚本和说明文档,能帮助理解预约挂号、科室管理、用户登录等核心模块的实现思路与数据表设计,具有较强的学习借鉴价值。目前已有126人学习下载,可用于系统备赛、毕设参考或教学演示。

1. 医院预约挂号系统,为什么值得拿来做毕业设计

医院预约挂号系统是每年毕业设计里最不缺人做的方向,原因很简单:它看起来是一个真实业务,功能边界却又足够清晰。患者注册登录、查科室、看医生排班、预约挂号、查记录;医生端管理排班和处理号源;管理员维护基础数据。这套流程放在任何一所医院的公众号或App里都能对应上,不像“网上书店”那样一看就是课程作业,也不像“电商秒杀”那样把并发问题拖进自己挖的坑里。需求真实,规模可控,能力展示面广,这是它适合做毕设的核心原因。标题里的“java实现+数据库+文档说明”也正好对应了评审最看重的三件事:代码能跑、表结构合理、论文有东西可写。

但很多同学做这个题目翻车,不是因为业务复杂,而是把系统理解成了“增删改查的堆叠”。挂号不是一个简单的insert操作,它背后是号源管理、预约状态流转、用户与号源的绑定关系,还有最容易被忽略的时间边界问题。这篇文章我会按照我实际做过的方案,把技术选型、数据库设计、核心预约流程、常见坑和答辩经验一条条讲清楚,所有代码都是可以直接复现的最小可用版本,新手走完能跑,熟手看参数也能看出取舍。

2. 技术选型与需求拆解:为什么是Spring Boot + MyBatis + MySQL

2.1 选型理由:不炫技,但要覆盖主流技术栈

医院预约挂号系统的技术选型,常见方案是 Spring Boot + MyBatis(或 MyBatis-Plus)+ MySQL + Vue(或 Thymeleaf)。选这套不是因为它是“默认答案”,而是因为它在毕业设计的评审体系里收益最高。Spring Boot 让配置收敛,不用像 SSH 时代那样写一堆 XML;MyBatis 让 SQL 显式可控,排班查询和号源扣减这类逻辑写起来比 JPA 更直接;MySQL 是关系型数据库里最熟悉的选择,面试和答辩都会被问到,用它不会在环境搭建上浪费太多时间。

前端这块,如果你不是计算机专业里前端能力特别强的学生,我建议用 Thymeleaf 做服务端渲染,而不是强行上前后端分离的 Vue。原因很实际:毕业设计答辩时,老师更关心的是“业务逻辑是否闭环”“数据库设计是否合理”,而不是“你有没有用 axios 封装请求”。前后端分离意味着你要多维护一套接口文档、一套跨域配置、一套前端构建流程,任何一个环节出问题,都可能在演示当天突然黑屏。Thymeleaf 把页面和 Controller 直接打通,代码量少,演示时也稳。当然,如果你自己已经在用 Vue 做过项目,选择前后端分离也能接受,但不要把精力耗在脚手架的版本问题上。

2.2 功能边界:哪些模块必须做,哪些可以砍掉

预约挂号系统的核心链路是:患者登录 → 浏览科室 → 查看医生排班 → 选择号源 → 提交预约。支撑这条链路的后台功能是:管理员维护科室和医生信息、医生维护自己的排班和出诊状态。用户端还需要具备“我的预约”和历史查询,医生端需要能看到某天来了多少患者。这是系统的主干,不能缺。

可以砍掉或者弱化的部分是支付、在线问诊、处方开具、短信通知。支付涉及第三方接口和资质,毕设里用“预约成功即锁定号源、爽约则释放”来代替即可;短信通知用控制台打印代替;问诊和处方属于另一个业务域,塞进来只会让表设计变得臃肿。真正值得多做一点点的是“取消预约”和“号源释放”,这两个功能虽然看上去不起眼,却是体现你理解预约业务的加分项。老师问“患者临时来不了怎么办”时,你有功能可以演示,而不是只能说“后续可以扩展”。

2.3 用户角色与权限:三张表还是五张表

常见做法是设计三种角色:患者(普通用户)、医生、管理员。数据库层面我建议不要只建一张“用户表+角色字段”了事,因为医生和患者天然存在属性差异。医生需要关联科室和职称;患者需要关联身份证号和手机号。把这两类人塞进同一张表,要么堆一堆 null 字段,要么为了兼容写出很别扭的查询。

我的做法是建三张用户相关表:user(登录账号、密码、角色标识)、patient(患者扩展信息)、doctor(医生扩展信息)。管理员不需要单独建表,在 user 表里用 role 字段标识即可。这个设计的优点是既能用统一的登录逻辑处理认证,又能在业务表里明确区分医生和患者。缺点是 join 会多一些,但对毕设规模的数据量来说,性能没有任何压力。更重要的是,答辩时老师问“为什么患者和医生不放在同一张表”,你可以从字段差异和责任边界两个角度给出回答,这比“方便”两个字有说服力得多。

需求定型后,就可以开始设计数据库了。这是整个系统能否撑住业务流程的地基,也往往是第一次翻车的高发区。

3. 数据库设计:六张核心表与建表 SQL 落地

3.1 表结构总览:从实体关系到字段约束

医院预约挂号系统的数据模型围绕“谁在什么时间约了哪个医生的号”展开。核心表我分成六张:用户表(user)、患者信息表(patient)、科室表(department)、医生表(doctor)、排班表(schedule)、预约记录表(appointment)。

用户表负责登录认证,患者表和医生表负责业务扩展信息,科室表维护树形结构(如果你愿意,可以做父子科室,但毕设做一级科室就够),排班表是号源的载体,预约记录表是业务的主线。这六张表的关系非常清晰:排班表关联医生,一个排班产生多个号源;预约记录关联排班和患者,一个患者对一个排班只能有一条有效预约。

这里有一个设计取舍需要提前说清楚:号源要不要单独建一张“号源表”?我的答案是不要。很多新手会为了体现“专业”给每个号源编号,但实际上预约系统的号源是排班表上一个可约数量字段,预约成功就是“该排班已约数+1”,数量达到排班容量即约满。单独建号源表意味着你要维护上万个号源实体的创建、锁定、释放状态,对毕设来说既增加代码量,又容易在事务上出问题。用数量字段控制号源,逻辑直接、好演示、也够用。

3.2 建表 SQL:字段说明与设计理由

下面给出可以直接执行的建表 SQL。我用的 MySQL 版本是 8.x,字符集统一采用 utf8mb4,避免存储特殊字符时出现乱码。每条 SQL 后面的说明会讲清楚为什么要这么设。

-- 用户表:统一登录入口,role 区分身份 CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT '加密后的密码(BCrypt)', `real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `role` TINYINT NOT NULL DEFAULT 3 COMMENT '角色:1=管理员,2=医生,3=患者', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=正常,0=禁用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

password 字段用 BCrypt 加密存储是标准做法,不要以明文保存密码。登录校验时用BCryptPasswordEncoder.matches(rawPassword, encodedPassword)比对。role 用 TINYINT 而不是字符串,一方面节省空间,另一方面避免拼写错误。status 字段用于禁用账号,是一个容易被新手漏掉但很实用的字段。

-- 患者表:扩展用户表中医患属性,一个用户只对应一条患者记录 CREATE TABLE `patient` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` BIGINT NOT NULL COMMENT '关联user表主键', `id_card` VARCHAR(18) DEFAULT NULL COMMENT '身份证号', `birthday` DATE DEFAULT NULL COMMENT '出生日期', `gender` TINYINT DEFAULT NULL COMMENT '性别:1=男,2=女', `address` VARCHAR(255) DEFAULT NULL COMMENT '联系地址', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者信息表';

患者表与 user 表一对一关系,所以 user_id 加了唯一约束。需要注意:不要把身份证号设成主导查询字段,因为患者登录用的还是 username,身份证号只是建档信息。address 用 VARCHAR(255) 是常态做法,够用就行。

-- 科室表:一级科室即可,支持扩展 parent_id CREATE TABLE `department` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `dept_name` VARCHAR(100) NOT NULL COMMENT '科室名称', `dept_desc` VARCHAR(500) DEFAULT NULL COMMENT '科室简介', `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=启用,0=停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_dept_name` (`dept_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科室表';

科室表的 dept_name 做了唯一约束,因为同名科室在业务上没有意义。sort_order 字段用于前台展示排序,避免用创建时间排序这种不够灵活的做法。如果后续想扩展二级科室,加一个 parent_id 字段即可,但不建议在毕设阶段引入树形结构的递归查询。

-- 医生表:关联用户表和科室表 CREATE TABLE `doctor` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` BIGINT NOT NULL COMMENT '关联user表主键', `dept_id` BIGINT NOT NULL COMMENT '关联department表主键', `title` VARCHAR(50) DEFAULT NULL COMMENT '职称,如主任医师、副主任医师', `intro` VARCHAR(500) DEFAULT NULL COMMENT '医生简介', `avg_score` DECIMAL(2,1) DEFAULT 5.0 COMMENT '平均评分', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_id` (`user_id`), KEY `idx_dept_id` (`dept_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生表';

医生表必须关联 user_id 而不是直接冗余姓名,这样登录后的身份判定能用 join 查出医生信息。avg_score 字段可以预置一个默认值,不用真的做评价系统;做入院时间、出诊天数之类的扩展字段也可以,但核心是这五列。

-- 排班表:号源载体,决定某天某个医生有没有号、有多少号 CREATE TABLE `schedule` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `doctor_id` BIGINT NOT NULL COMMENT '关联doctor表主键', `schedule_date` DATE NOT NULL COMMENT '出诊日期', `period` TINYINT NOT NULL COMMENT '时段:1=上午,2=下午', `total_number` INT NOT NULL DEFAULT 20 COMMENT '总号源数', `used_number` INT NOT NULL DEFAULT 0 COMMENT '已预约数', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=可预约,0=停诊', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_doctor_date_period` (`doctor_id`, `schedule_date`, `period`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';

排班表是整个系统里最值得讲的一张表。注意uk_doctor_date_period这个组合唯一约束,它保证了同一个医生在同一天同一个时段只会有一条排班记录,这是防止重复排班的第一道防线。total_number 是号源总量,used_number 是已约数量,预约成功就是“used_number + 1”,当used_number >= total_number时该排班不可再约。这个设计叫作“数量型号源”,在查询上天然高效,不需要扫描数万行号源记录。

-- 预约记录表:系统的核心业务表 CREATE TABLE `appointment` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `patient_id` BIGINT NOT NULL COMMENT '关联patient表主键', `schedule_id` BIGINT NOT NULL COMMENT '关联schedule表主键', `appoint_no` VARCHAR(32) NOT NULL COMMENT '预约号,业务编号', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=已预约,2=已取消,3=已完成,4=爽约', `visit_date` DATE NOT NULL COMMENT '就诊日期(冗余,方便查询)', `visit_period` TINYINT NOT NULL COMMENT '就诊时段(冗余,方便查询)', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '预约时间', `cancel_time` DATETIME DEFAULT NULL COMMENT '取消时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_appoint_no` (`appoint_no`), KEY `idx_patient_id` (`patient_id`), KEY `idx_schedule_id` (`schedule_id`), KEY `idx_visit_date` (`visit_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约挂号记录表';

预约记录表把 visit_date 和 visit_period 冗余进来,表面上违背了“避免冗余”的规范,但实际上这是为了查询“某患者某天有没有预约”时不用 join 排班表。预约号和主键分离,因为主键自增 id 不适合暴露给业务层面,用一个随机生成的 appoint_no(如202501011201234)更好。status 字段的四种状态是预约流程流转的核心,后面讲实现时会详细展开。

3.3 初始化数据:测试账号与科室医生样例

表建好后,需要初始化一些数据才能演示。密码不能用明文,这里用 BCrypt 加密后的字符串。演示账号我通常会准备三个:一个管理员 admin、一个医生 doctor01、一个患者 patient01。

-- 初始化三个账号,密码统一为 123456(BCrypt加密) INSERT INTO `user` (username, password, real_name, role) VALUES ('admin', '$2a$10$mWqYc3P8nZpT6Kq0pVHhUOz4LJlPfY0q1W7Yg3hMHQzAKnD7VXygu', '系统管理员', 1), ('doctor01', '$2a$10$mWqYc3P8nZpT6Kq0pVHhUOz4LJlPfY0q1W7Yg3hMHQzAKnD7VXygu', '张医生', 2), ('patient01', '$2a$10$mWqYc3P8nZpT6Kq0pVHhUOz4LJlPfY0q1W7Yg3hMHQzAKnD7VXygu', '李小明', 3); -- 初始化科室和医生、排班数据时请结合业务自行补充

实际开发中,BCrypt 加密值可以通过 Spring Security 的BCryptPasswordEncoder().encode("123456")在启动时打印生成。不要从网上随便复制加密串,不同盐值生成的密文不同,如果密文和明文不匹配,登录会直接翻车。

建表完成、数据初始化好,就要进入核心实现环节了。预约流程是整篇代码里最需要动脑的地方,也是答辩时最容易被打高分的地方。

4. 核心实现:从排班查询到预约落库的完整链路

4.1 项目结构与分层:Controller、Service、Mapper 的边界

我的项目结构采用标准的三层架构:controller 层负责接收请求、参数校验、数据封装;service 层负责业务逻辑与事务控制;mapper 层负责 SQL 操作。实体类(entity)放在 entity 包,VO(视图对象)放在 vo 包,配置类放在 config 包。

这是一张典型的包结构:

com.hospital.appointment ├── controller │ ├── LoginController.java │ ├── PatientController.java │ └── DoctorController.java ├── service │ ├── AppointmentService.java │ └── ScheduleService.java ├── mapper │ ├── UserMapper.java │ ├── ScheduleMapper.java │ └── AppointmentMapper.java ├── entity │ ├── User.java │ ├── Schedule.java │ └── Appointment.java ├── vo │ └── AppointmentVO.java └── config └── SecurityConfig.java

三层分层的核心价值不是“代码看着规范”,而是出了问题时不用从头翻到尾。比如预约失败的报错,先看 Controller 的参数校验,再看 Service 的事务边界,最后看 Mapper 的 SQL,一条链路排查下去,十分钟内定位问题。很多同学喜欢把业务逻辑直接写在 Controller 里,代码短的时候确实省事,但一旦涉及事务和并发,就会踩“同一个方法里两个数据操作只成功一半”的坑,这是新手最常见的翻车点之一。

4.2 获取可预约排班:带条件的联表查询

患者端的核心操作是查“哪个科室有哪些医生、哪天有号”。这个查询需要 join 医生表和排班表,同时过滤掉约满的排班。Mapper 里的 SQL 如下:

<select id="findAvailableSchedules" resultType="com.hospital.appointment.vo.ScheduleVO"> SELECT s.id AS scheduleId, s.schedule_date AS scheduleDate, s.period AS period, s.total_number AS totalNumber, s.used_number AS usedNumber, d.real_name AS doctorName, e.title AS doctorTitle FROM schedule s INNER JOIN doctor e ON s.doctor_id = e.id INNER JOIN user d ON e.user_id = d.id <where> <if test="deptId != null"> AND e.dept_id = #{deptId} </if> <if test="startDate != null"> AND s.schedule_date &gt;= #{startDate} </if> <if test="status != null"> AND s.status = #{status} </if> </where> ORDER BY s.schedule_date ASC, s.period ASC </select>

这段 SQL 的要点在于<where>标签自动处理了条件拼接,不需要手动加WHERE 1=1。日期字段使用了>=过滤,要求前端传入的开始日期不能早于今天,否则患者会看到昨天的过期排班。ORDER BY 保证了展示顺序稳定,避免因为数据库默认排序导致同一天上午下午的排班忽前忽后。

Service 层要注意一点:不要把 Mapper 返回的数据直接塞给前端。像医生姓名、职称这类字段属于医生表的扩展数据,不属于排班表本身,所以这里用了一个 ScheduleVO 来封装查询结果。这是一个很小的设计细节,但答辩时老师会注意到你没有把实体类直接暴露给视图层。

4.3 提交预约:事务、号源扣减与幂等控制

提交预约是整个系统里唯一一个真正需要“动脑子”的功能。前端传来的数据只有 scheduleId(排班主键)和 patientId,后端要完成三步操作:查询排班当前 used_number 是否小于 total_number;插入一条 appointment 记录;更新 schedule 表的 used_number 加 1。这三个操作必须在一个事务里完成,任何一个失败都不能留下半截数据。

代码实现如下:

@Service public class AppointmentService { @Autowired private ScheduleMapper scheduleMapper; @Autowired private AppointmentMapper appointmentMapper; @Transactional(rollbackFor = Exception.class) public AppointmentBookResult bookAppointment(Long patientId, Long scheduleId) { // 1. 查询排班信息并锁定该行(悲观锁) Schedule schedule = scheduleMapper.selectByIdForUpdate(scheduleId); if (schedule == null) { return AppointmentBookResult.fail("排班不存在"); } if (schedule.getStatus() != 1) { return AppointmentBookResult.fail("该排班已停诊,无法预约"); } if (schedule.getUsedNumber() >= schedule.getTotalNumber()) { return AppointmentBookResult.fail("该时段号源已约满"); } // 2. 幂等校验:同一患者是否已预约该排班 int count = appointmentMapper.countActiveOne(scheduleId, patientId); if (count > 0) { return AppointmentBookResult.fail("您已预约该时段,请勿重复操作"); } // 3. 插入预约记录 Appointment appointment = new Appointment(); appointment.setPatientId(patientId); appointment.setScheduleId(scheduleId); appointment.setAppointNo(generateAppointNo()); appointment.setStatus(1); appointment.setVisitDate(schedule.getScheduleDate()); appointment.setVisitPeriod(schedule.getPeriod()); appointmentMapper.insert(appointment); // 4. 可用号源减一(乐观更新) int rows = scheduleMapper.increaseUsedNumber(scheduleId, schedule.getUsedNumber()); if (rows == 0) { throw new RuntimeException("号源更新失败,请重试"); } return AppointmentBookResult.success(appointment.getAppointNo()); } }

这段代码里有几个设计要点需要重点说明。

第一,selectByIdForUpdate是悲观锁查询,对应的 SQL 是SELECT ... FROM schedule WHERE id = ? FOR UPDATE。这个锁保证了事务提交前,其他事务不能修改这条排班记录,从而避免“两个患者同时看到剩余 1 个号,同时预约成功”的超卖问题。在毕设数据量下,悲观锁的性能损耗可以忽略不计,而且逻辑最容易理解。如果你追求高并发,可以改用乐观锁(UPDATE 时带WHERE used_number = 旧值),但毕设答辩时讲悲观锁已经够用了。

第二,increaseUsedNumber这条 SQL 不是简单的SET used_number = used_number + 1,而是带条件的版本。这是为了在极端情况下做第二道防线,SQL 如下:

UPDATE schedule SET used_number = used_number + 1 WHERE id = #{scheduleId} AND used_number < total_number

即使前端的悲观锁因为某个疏漏没生效,这条 SQL 也能保证“只有没满时才加一”。返回的受影响行数为 0 时,说明号源已经满了,抛出异常让事务回滚。这就是典型的“乐观更新兜底”。

第三,幂等校验用了一个countActiveOne,它的 SQL 是查询 status=1 的记录数。这样即使患者手滑点了两次提交,第二次请求也会被拦截,不会产生两条同样的有效预约。注意这里的幂等是“业务幂等”,不是“接口幂等”,它保证的是同一个患者对同一个排班只能有一条有效预约。

第四,@Transactional(rollbackFor = Exception.class)是必修课。Spring 事务默认只在 RuntimeException 上回滚,如果业务中抛出了受检异常(如自定义的 BizException),不指定 rollbackFor 就会导致事务提交,号源扣了但订单没插进去。这个坑每年都有大量同学踩,而且很难排查。

4.4 取消预约:状态流转与号源归还

取消预约的逻辑和预约正好相反:把 appointment 的 status 从 1 改成 2,同时把 schedule 的 used_number 减 1。但这里有一个容易翻车的业务细节:取消预约是否需要判断时间?

我的建议是:在就诊日期前一天及以前可以取消,当天不可取消。这样实现起来需要查询 schedule 的 schedule_date 与当前日期比较。一个简化的实现:

@Transactional(rollbackFor = Exception.class) public boolean cancelAppointment(Long patientId, Long appointmentId) { Appointment appointment = appointmentMapper.selectById(appointmentId); if (appointment == null || !appointment.getPatientId().equals(patientId)) { throw new BizException("预约记录不存在"); } if (appointment.getStatus() != 1) { throw new BizException("当前状态不可取消"); } // 就诊当天不可取消,爽约处理 Schedule schedule = scheduleMapper.selectById(appointment.getScheduleId()); if (schedule.getScheduleDate().isBefore(LocalDate.now())) { // 已过日期直接标记爽约(可选) appointmentMapper.updateStatus(appointmentId, 4); throw new BizException("就诊日期已过,预约失效"); } if (schedule.getScheduleDate().equals(LocalDate.now())) { throw new BizException("就诊当天不可取消,如需就诊请按时到院"); } // 释放号源 appointmentMapper.updateStatus(appointmentId, 2); scheduleMapper.decreaseUsedNumber(schedule.getId()); return true; }

取消时做“时间判断”的意义,不只是为了业务合理,更是为了向老师展示你对状态机的理解。预约记录的状态不是随便改的,每个状态迁移都有前置条件。在文档和答辩 PPT 里画一张状态流转图(注意正文不放 mermaid,在论文里可用图片),会比贴代码更直观。

到这里,主流程的代码已经能跑通了。但“能跑通”和“演示时不翻车”之间还有一段距离。下面这章里的坑,都是我见过和踩过的真实案例。

5. 避坑手册:预约系统最常见的 6 个问题与排查方法

5.1 问题一:日期比较用错类型,查不出今天的排班

现象: 排班表里明明有今天的数据,但页面上查不到,怎么刷新都不出现。

原因: 最常见的错误是在代码里用new Date()和数据库的DATE类型直接比较。Java 的java.util.Date带时分秒,而数据库的DATE只存年月日,直接比较时会因为时分秒不一致匹配不上。另一个错误是前端传参时把日期字符串传成了String类型,MyBatis 在比较时做了隐式转换,结果不可控。

解决: 统一用LocalDate作为日期字段的 Java 类型,和 MySQL 的DATE天然对应。查询时使用LocalDate.now()初始化 startDate。前端传参时用@DateTimeFormat(pattern = "yyyy-MM-dd")注解确保字符串正确解析。不要再用java.util.Date碰日期。

5.2 问题二:并发预约时号源超卖,数据库出现 used_number > total_number

现象: 测试时用两个浏览器同时点预约,结果两个患者都预约成功,排班的 used_number 超过了 total_number。

原因: 没有加锁或锁的粒度不对。如果你的代码是“先 select 看有没有号,再 insert 预约记录,再 update used_number”,在高并发下两个请求可能同时读到 used_number = 19(总数 20),然后都认为有号,各自插入一条记录并执行 update,最终 used_number 变成 21。

解决: 用第 4 章里写到的两种策略叠加。第一步SELECT ... FOR UPDATE锁行,事务结束才释放;第二步 UPDATE 语句里加WHERE used_number < total_number做兜底。两层都做了以后,即使第一层锁因配置问题失效,第二层也能挡住超卖。验证方法:用 Postman 或 JMeter 并发发 10 个请求,观察最终 used_number 是否等于实际预约成功的数量。

5.3 问题三:事务没生效,预约记录插进去了,号源没扣减

现象: 预约接口返回失败,但数据库里 appointment 表多了一条记录,schedule 表号源没变。

原因: 这是 Spring 事务的经典坑。常见场景有三种:方法被同类内部调用,this.bookAppointment()导致 @Transactional 不生效;方法不是 public 的;异常被 try-catch 吞掉了,事务管理器根本不知道出了问题。

解决: 严格遵守三条纪律。事务方法必须是 public,必须从外部调用(注入 Service 的 Bean 再调用),事务方法内不要 try-catch 吞异常。如果确实需要捕获异常做日志,一定要throw new RuntimeException(e)让事务感知。可以使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚,但这不是好习惯,不如直接抛出。

5.4 问题四:MyBatis 字段映射失效,查出来的对象全是 null

现象: 数据库表字段是schedule_date,Java 属性是scheduleDate,查询结果里 scheduleDate 总是 null。

原因: MyBatis 默认开启驼峰映射需要在配置文件中显式声明。Spring Boot 的 application.yml 里缺少map-underscore-to-camel-case: true配置,导致数据库的下划线字段无法自动对应到 Java 的驼峰属性。

解决: 在application.yml中加入配置。这里的配置项在 MyBatis 不同版本下写法略有差异,但 Spring Boot 2.x 一般放在mybatis.configuration.map-underscore-to-camel-case下。另一个更保险的做法是给每条 SQL 的查询字段显式指定别名,如SELECT schedule_date AS scheduleDate。前者省事,后者显式可控,我一般两个都用。

5.5 问题五:BCrypt 密码校验失败,登录提示密码错误

现象: 初始化数据时从网上复制了一段 BCrypt 加密串,页面填 123456 怎么也登不进去。

原因: BCrypt 加密串是“密码+盐”一起加密的结果,同一明文在不同盐值下会产生不同密文。复制来的密文对应的明文是什么没人知道,自然校验不过。

解决: 不要复制网上的密文。在项目里写一个临时接口或启动时执行System.out.println(new BCryptPasswordEncoder().encode("123456")),把打印出的密文复制到 SQL 初始化脚本里。每次换了盐值重新生成即可。另外注意密文可能包含$符号,在 SQL 里不存在转义问题,但如果你写在注解 SQL 或配置文件中,要注意字符串截断。

5.6 问题六:页面乱码或中文变问号

现象: 数据库里的中文正常,但页面显示乱码,或者通过 HTTP 接口返回的中文全部变成???。

原因: 这是字符集三层不统一造成的。数据库连接串没加characterEncoding=utf8、Tomcat 的 URI 编码不是 UTF-8、页面本身的 content-type 声明成 ISO-8859-1,任何一层断掉都会出问题。

解决: 检查三处。数据库连接串统一加?useUnicode=true&characterEncoding=utf8;Spring Boot 的 server.tomcat.uri-encoding=UTF-8;前端模板或接口响应的 Content-Type 显式声明为text/html; charset=utf-8。建表时的 utf8mb4 也要保持,这三层全部对齐后乱码基本消失。如果还有问题,重点检查是不是有过滤器或者拦截器把编码覆盖了。

这些坑有个共同特征:都是“代码写对了但环境不对”或“逻辑对了但时序不对”,比业务本身更难预料。排查方法也很固定——先看控制台有没有 SQL 异常,再看数据库事务有没有提交,最后看前端拿到的数据到底长什么样。不要把时间花在怀疑框架上,框架没你想的那么脆弱。

6. 答辩与进阶:怎么从“能跑”升级到“值得被问”

系统做完只是第一步,毕业设计最后拼的是“你能说出来为什么这么做”。这一章讲三个层面的进阶方向:并发控制的升级路径、文档与论文的写作组织、演示话术的打磨。

并发控制的升级路径是最好讲也最值得讲的点。当前你用的是悲观锁 + 乐观更新兜底,这个方案在毕设里完全够用。但如果老师追问“线上系统怎么做”,你可以回答一个标准的两阶段方案:第一阶段用 Redis 的 DECR 操作做号源预扣减,解决秒杀场景下的瞬时高并发;第二阶段对预约记录做异步落库,通过消息队列削峰。核心要点是“不把数据库当作第一道防线,而是最终一致性保障”。但记住,这只是答给老师听的,千万不要在毕设里真上 Redis,那会让你的复杂度爆炸到难以收场。

文档和论文的组织,我给你的建议是按“需求分析 → 总体设计 → 详细设计 → 系统实现 → 系统测试”的结构展开。其中“总体设计”重点写架构图和数据表 ER 图,这是老师看得最仔细的部分;“详细设计”按“排班管理模块”“预约挂号模块”“用户管理模块”分开写,每个模块包含时序图和核心代码片段;“系统测试”除了写功能用例,一定要加入“并发预约测试”这一节,描述你如何验证超卖问题被解决了。标题里的“文档说明”指的就是这套文档,它和代码一样重要。

演示话术的核心是设计一条“没有惊喜”的路径。提前准备好演示数据:某科室两个医生,一个今天有号一个今天约满,这样你在演示时既能展示成功流程,又能顺便展示约满时的错误提示。演示时不要东点一下西点一下,按“患者登录 → 查科室 → 选医生 → 选时段 → 预约成功 → 取消预约 → 号源归还”这条主链路走完,再展示一次“重复预约被拦截”。老师问什么答什么,不问就不展开,这是答辩最稳的策略。

最后说一个我自己带学生时最常提醒的习惯:做完系统后,把数据库删掉重新初始化一遍,再从头走一遍流程。数据库里的 datetime 字段、初始化数据的密码加密串、排班日期是否在有效期内,这些最不起眼的地方往往才是演示当天翻车的元凶。我当年第一次演示预约系统时,就是因为前一天写死的测试排班日期过期了,现场一片空白,事后想想完全可以在前一天晚间巡检时发现。有预演习惯的人不一定做得最好,但一定不会在关键时候掉链子。希望这些思路能帮你把系统做完、写透、讲清楚。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询