
简介面向Java课程设计的医院挂号系统项目基于GUI/Swing与MySQL实现定位清晰适合正在完成课设或复习Java数据库编程的学生参考。项目包含完整可运行的源码与数据库脚本围绕挂号业务实现了登录验证、用户挂号、医生接诊、管理员维护科室与医生信息等基础模块逻辑完整且界面风格朴素便于理解与二次修改用于课设答辩足够。压缩包共75个文件其中14个Java源文件便于阅读修改38个class为编译产物12个png为界面截图7个xml为工程配置文件另附yygh.sql数据库脚本和登录账号密码文档整体大小仅175KB。目前已有267人学习下载具备开箱即用的特点通过源码和SQL脚本可快速复现整个系统适合需要高效完成Java课设的同学。1. 课设做到什么程度评委才会觉得“这系统能跑”医院挂号系统是 Java 课设里的常青树几乎每个学校都出过这道题。但从答辩现场看九成学生交上去的东西长得差不多一个 Swing 窗口套几个文本框点“挂号”往 MySQL 里 INSERT 一行然后演示结束。这类作品拿个及格分不难想冲优秀或者写进简历缺的不是功能而是“把边界情况当真”的意识。本文按一条可落地的路线把整个系统拆开讲先设计 MySQL 表结构再搭 Swing 界面最后处理挂号当天最容易翻车的并发和跨天问题。整个过程覆盖 Java 基础、JDBC、MySQL 事务和 Swing 事件模型正好对应课设评分表里“数据库设计、界面交互、业务完整性”三个维度。适合正在做课设的学生也适合准备 Java 后端面试时顺便把项目经历整理清楚的人。2. 设计 MySQL 表结构用 5 张表撑起一次完整挂号2.1 先想清楚挂号业务的几个硬约束挂号系统的核心不是“能查到医生”而是“同一时间同一医生不能被挂两次”。这个约束必须在数据库层面可表达、可验证否则界面做得再好也是一堆脏数据。常见的表设计是科室表、医生表、患者表、挂号记录表四张但我会加一张号源配置表理由后面细说。先看表之间的依赖关系科室和医生是 1:N医生和号源时段是 1:N患者和挂号记录是 1:N挂号记录通过医生 ID 关联到具体排班时段。字段类型上时间类型统一用DATETIME金额用DECIMAL(10,2)状态用TINYINT加注释不要用字符串存状态否则后面写 SQL 统计时全是LIKE %已挂%这种写法。CREATE TABLE department ( id INT PRIMARY KEY AUTO_INCREMENT, dept_code VARCHAR(20) NOT NULL UNIQUE, dept_name VARCHAR(50) NOT NULL, intro VARCHAR(200) ); CREATE TABLE doctor ( id INT PRIMARY KEY AUTO_INCREMENT, dept_id INT NOT NULL, doc_no VARCHAR(20) NOT NULL UNIQUE, doc_name VARCHAR(30) NOT NULL, title VARCHAR(20), fee DECIMAL(10,2) DEFAULT 0, FOREIGN KEY (dept_id) REFERENCES department(id) ); CREATE TABLE patient ( id INT PRIMARY KEY AUTO_INCREMENT, id_card VARCHAR(18) NOT NULL UNIQUE, pat_name VARCHAR(30) NOT NULL, phone VARCHAR(11) );科室表的dept_code用拼音首字母缩写或数字编码都行只要唯一。医生表里的title是职称fee是挂号费这两个字段在界面上要直接展示所以在设计阶段就把冗余信息放在医生表里避免挂号界面每次都要 JOIN 科室表才能显示全名。2.2 号源表把“今天上午有没有号”变成可查询的数据很多课设把挂号状态直接存在挂号记录表里判断逻辑写在 Java 代码中查出该医生当天所有挂号记录的数量大于某个阈值就提示“已满”。这种做法的毛病在于限号规则写死在代码里想改成“上午限号 25、下午限号 20”就得改代码重新编译。我的做法是加一张schedule表把日期、时段、总量、已挂量拆开存。字段设计上特意保留slot_date不带时分秒用来按“天”做聚合查询。CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, doctor_id INT NOT NULL, slot_date DATE NOT NULL, slot_period ENUM(AM,PM) NOT NULL, total_num INT NOT NULL DEFAULT 20, used_num INT NOT NULL DEFAULT 0, UNIQUE KEY uk_doc_date (doctor_id, slot_date, slot_period), FOREIGN KEY (doctor_id) REFERENCES doctor(id) ); CREATE TABLE registration ( id INT PRIMARY KEY AUTO_INCREMENT, reg_no VARCHAR(32) NOT NULL UNIQUE, patient_id INT NOT NULL, doctor_id INT NOT NULL, schedule_id INT NOT NULL, reg_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 0-已退号, FOREIGN KEY (patient_id) REFERENCES patient(id), FOREIGN KEY (doctor_id) REFERENCES doctor(id), FOREIGN KEY (schedule_id) REFERENCES schedule(id) );UNIQUE KEY uk_doc_date保证同一医生同一天同一时段最多只有一条号源配置记录。used_num就是“已挂量”的计数器每次挂号把它 1同时判断used_num total_num。这个字段放在 schedule 表里是反范式的但它正好把并发控制的核心收拢到单行更新上否则每次挂号都要COUNT(*)扫一遍 registration 表数据一多就慢。2.3 初始化脚本演示现场不能等用户慢慢注册课设答辩最怕的场面是老师点开“查看医生”按钮列表是空的。所以在建表之后必须写初始化脚本造出 3 个科室、5 个医生、每个医生未来 7 天每天 AM/PM 各 20 个号源的数据。写存储过程是最省事的方案虽然 MySQL 存储过程在课设里用得不多但一张批量插入的脚本比手写几十条 INSERT 更显专业。-- 用存储过程批量生成未来 7 天的号源 DELIMITER // CREATE PROCEDURE init_schedule(IN p_doctor_id INT, IN p_days INT) BEGIN DECLARE v_i INT DEFAULT 0; WHILE v_i p_days DO INSERT INTO schedule (doctor_id, slot_date, slot_period, total_num, used_num) VALUES (p_doctor_id, CURDATE() INTERVAL v_i DAY, AM, 20, 0), (p_doctor_id, CURDATE() INTERVAL v_i DAY, PM, 15, 0); SET v_i v_i 1; END WHILE; END// DELIMITER ;存储过程的参数说明p_doctor_id是医生 IDp_days是往后生成的天数。循环体里每天插两条记录上午 20 个号、下午 15 个号这个差异恰好用来说明号源配置是灵活可调的。调用时对每个医生执行一次不要写在循环里对同一医生重复调用。3. 用 Swing 搭挂号界面从窗口骨架到表格联动3.1 主窗口布局和登录态设计Swing 做界面的核心是布局管理器的组合使用。整个系统的主窗口我采用BorderLayout北边放操作按钮栏中间放JTabbedPane切换“挂号操作”“号源查询”“患者管理”三个页签。挂号页内部用一个JSplitPane左边放科室和医生的联动列表右边放号源表格和挂号按钮。这种组合的意义在课设答辩时可以直接讲BorderLayout负责整体骨架JSplitPane解决两个信息面板的比例问题JTabbedPane把不同业务场景隔离开。比用一个巨型GridBagLayout硬撑全部控件的做法清晰得多。挂号的流程必须有个身份前提——当前操作者是谁。课设里不需要做完整的登录鉴权但至少要有一个“当前患者”的上下文对象。常见做法是挂一个JFrame作为登录窗口输入身份证号查 patient 表查到就进入主界面并把 patient 对象存在一个Context工具类里。public class AppContext { private static Patient currentPatient; // 保存当前登录患者 public static void setCurrentPatient(Patient p) { currentPatient p; } public static Patient getCurrentPatient() { return currentPatient; } }这个类要注意两点static变量保存会话信息在单机 Swing 课设里够用但不要让它持有数据库连接患者对象只存内存中的业务数据。后续所有挂号按钮的事件里取AppContext.getCurrentPatient()就能保证每个操作都落到同一个患者名下。3.2 联动的核心JTable 刷新不等于重新 new 一个表科室到医生再到号源这是挂号系统界面的经典联动链。实现方案是在科室列表的ListSelectionListener里触发医生表查询再把医生表结果渲染到右侧。最常见的坑是每次刷新都重新创建JTable并add到面板上一段演示操作下来界面上叠了十几个表格。正确做法是让JTable只创建一次数据更新通过替换TableModel完成。下面这段代码展示科室列表选中后如何刷新号源表格JTable table new JTable(); // 界面上只创建这一次 DefaultTableModel model new DefaultTableModel(new Object[]{日期, 时段, 剩余/总数, 挂号费}, 0); doctorList.addListSelectionListener(e - { if (!e.getValueIsAdjusting()) { Doctor selected doctorList.getSelectedValue(); if (selected null) return; // 查询排班表返回剩余号和总号 ListSchedule schedules scheduleDao.findByDoctorAndDate(selected.getId(), LocalDate.now()); model.setRowCount(0); // 清空旧数据 for (Schedule s : schedules) { model.addRow(new Object[]{ s.getSlotDate().toString(), s.getSlotPeriod(), (s.getTotalNum() - s.getUsedNum()) / s.getTotalNum(), selected.getFee() }); } table.setModel(model); // 表结构不变只换数据模型 } });getValueIsAdjusting()是 ListSelectionModel 的一个状态位鼠标拖拽选择多行时会触发多次选择事件不加这个判断一次点击可能连续查两次数据库。每次查询前先setRowCount(0)清空模型再逐行addRow这样只会触发一次表格重绘。JTable 的刷新效率瓶颈不在渲染而在频繁创建 TableModel所以“先清空再填充”是标准操作。3.3 挂号按钮的核心逻辑先查后挂还是先挂后查挂号的动作本质是两件事检查号源是否可挂、插入挂号记录并更新号源余量。在 Swing 里这个动作直接写在ActionListener的actionPerformed方法中代码结构应该是btnRegister.addActionListener(e - { int selectedRow table.getSelectedRow(); if (selectedRow 0) { JOptionPane.showMessageDialog(frame, 请先选择一个号源时段); return; } Schedule s getScheduleFromTable(selectedRow); if (s null) return; boolean ok registerService.register(AppContext.getCurrentPatient(), s); if (ok) { JOptionPane.showMessageDialog(frame, 挂号成功流水号为 regNo); refreshScheduleTable(); } else { JOptionPane.showMessageDialog(frame, 号源已满或该时段已停用); } });注意这段代码里没有写任何 SQL业务判断全部封装在registerService.register()里。这样做的好处是按钮事件只负责界面反馈挂号规则是不是满号、能不能重复挂变化时只改 Service 层。在课设代码评审时这种分层结构本身就是加分项。4. 挂号核心逻辑预检查 事务提交把并发和跨天问题一起解决4.1 用“UPDATE 行数”判断号源是否成功而不是 SELECT 后判断这是全文最关键的一段代码。很多课设版挂号逻辑是这样写的先SELECT * FROM schedule WHERE id ?然后在 Java 里判断used_num total_num成立就执行UPDATE和INSERT。这在单线程演示下没问题但一旦同时有两个请求查到了used_num 19、total_num 20两个窗口都会进入 if 分支导致超挂。正确做法是把判断下沉到 UPDATE 的条件里利用 MySQL 的原子性保证只更新成功一行public boolean register(Patient patient, Schedule s, Connection conn) throws SQLException { // 1. 扣减号源条件里放余量判断 String lockSql UPDATE schedule SET used_num used_num 1 WHERE id ? AND used_num total_num; PreparedStatement ps conn.prepareStatement(lockSql); ps.setInt(1, s.getId()); int rows ps.executeUpdate(); if (rows 0) { conn.rollback(); return false; // 号源已满或该日期/时段被停用 } // 2. 生成唯一流水号并插入挂号记录 String regNo REG System.currentTimeMillis(); String insertSql INSERT INTO registration (reg_no, patient_id, doctor_id, schedule_id, reg_time, status) VALUES (?, ?, ?, ?, NOW(), 1); PreparedStatement ps2 conn.prepareStatement(insertSql); ps2.setString(1, regNo); ps2.setInt(2, patient.getId()); ps2.setInt(3, s.getDoctorId()); ps2.setInt(4, s.getId()); ps2.executeUpdate(); conn.commit(); return true; }这段代码的逻辑拆开讲。UPDATE ... WHERE used_num total_num是原子操作MySQL 的行锁保证同一时刻只有一个事务能对该行执行更新所以即使并发 100 个请求也只有一个能返回rows 0。先执行 UPDATE 再插入挂号记录顺序不能反如果先 INSERT 挂号记录再 UPDATE 号源失败回滚时要把两条 SQL 都撤销事务边界变大出错概率也变高。4.2 事务边界和连接复用同一个 Connection 贯穿整个方法上面代码里conn参数是外部传入的这意味着事务的开启和提交必须放在调用的外层。常见做法是在 Service 层获取连接、设置setAutoCommit(false)、调用 register 方法、在 finally 里关连接public boolean registerFlow(Patient patient, Schedule s) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 boolean ok register(patient, s, conn); if (ok) { conn.commit(); } else { conn.rollback(); } return ok; } catch (SQLException ex) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignore) {} } log.error(挂号失败, ex); return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); } catch (SQLException ignore) {} try { conn.close(); } catch (SQLException ignore) {} } } }注意finally里把autoCommit重置为 true 再关连接。很多 JDBC 连接池比如 C3P0、Druid复用一个连接时默认autoCommittrue如果你在事务里把它设为 false 但没改回来归还给连接池后下一个使用者做普通查询时会莫名提交事务。课设里如果用DriverManager.getConnection()每次新建连接这个问题不明显但当你使用连接池后这行重置代码就是必须的。4.3 跨天和退号边界slot_date加CURDATE()条件别让“昨天的号”混进来查询号源时不能只查schedule表有没有数据还要过滤日期范围。一个常见的需求是“只看未来 7 天可挂的号”SQL 里就要写成String sql SELECT * FROM schedule WHERE doctor_id ? AND slot_date CURDATE() AND slot_date CURDATE() INTERVAL 7 DAY AND used_num total_num ORDER BY slot_date, slot_period;这里的CURDATE()是在 MySQL 端计算的不要在 Java 端用字符串拼日期再传进去。让数据库算当前日期可以避免应用服务器和数据库服务器时间不一致导致的边界问题。这个查询返回的每一行都是“当前可挂”的号源界面就可以把已满的时段直接过滤掉不用再到 Java 层做判断。退号逻辑同样要处理跨天退号操作的 SQL 是UPDATE schedule SET used_num used_num - 1 WHERE id ?同时把 registration 表对应记录的status改为0。这两个操作放同一个事务里不能只改一边。另外一个细节是如果退的是“当天”的号要看医院规则是否允许课设里建议统一允许退未来任何一天的号但退号时间记录在 reg_time 字段里便于答辩时展示数据变化。4.4 排错场景启动时 MySQL 连接失败的三个排查方向课设跑不起来的场景里数据库连接失败占一半以上。现象通常是点击挂号按钮后控制台报Communications link failure或者Access denied。按下面的顺序排查大多数问题能在两分钟内定位检查项命令/位置常见问题MySQL 服务是否启动系统服务里查 mysqld或用mysql -uroot -p试连安装了 MySQL 但没启动服务JDBC URL 是否写对jdbc:mysql://localhost:3306/hospital?useSSLfalseserverTimezoneAsia/Shanghai漏了serverTimezone参数会报时区错误驱动包是否导入检查 IDE 里 External Libraries 是否有 mysql-connector-java忘记把 jar 加入项目运行时ClassNotFoundExceptionMySQL 8.x 及以上版本还要注意useSSLfalse和allowPublicKeyRetrievaltrue两个参数前者避免 SSL 握手的警告后者解决用caching_sha2_password认证时的公钥检索报错。这两个参数加在 JDBC URL 里是连接 MySQL 8 最常见的两个坑。另外关掉杀毒软件或防火墙的误拦Windows 上本地回环连接 MySQL 偶尔会被防火墙拦。5. 演示前的数据验证用 JDBC 手动模拟并发把评委可能问的边界都测一遍课设答辩的演示环节最稳的收尾不是展示“挂号成功”的弹窗而是展示系统在边界条件下的行为。构建代码并发场景有两个途径用 JUnit 写测试代码模拟多线程同时挂号或者在 MySQL 命令行里手动造数据验证约束。命令行验证方式更快。打开两个终端窗口同时执行下面的 UPDATE-- 终端 1 START TRANSACTION; UPDATE schedule SET used_num used_num 1 WHERE id 1 AND used_num total_num; -- 终端 2 START TRANSACTION; UPDATE schedule SET used_num used_num 1 WHERE id 1 AND used_num total_num;第二个窗口会阻塞直到第一个窗口执行COMMIT才返回且rowcount显示为 0。把这两个窗口的操作序列演示给评委看比口头说“我的系统支持并发”有说服力得多。Java 侧验证并发可以用CountDownLatch同时启动多个线程调用registerFlow()断言返回true的数量恰好等于剩余号源数返回false的数量等于超额请求数。这个测试跑通说明代码层的并发控制是可靠的即使以后把 Swing 换成 Web 接口这段 Service 逻辑可以直接复用。最后补一个演示数据展示技巧把 schedule 表里某个时段的used_num手动改成total_num - 1然后在界面上快速点击两次挂号第一次成功、第二次弹出“号源已满”。这个小操作在答辩现场非常直观能让评委一眼看到数据库约束和业务代码在同时起作用。本文还有配套的精品资源点击获取