简介:一套基于Java Swing与MySQL的医院预约挂号系统完整源码,整体采用MVC架构,适合正在学习Java桌面应用开发、数据库编程,或需要完成课程设计、毕业设计等实践项目的读者。压缩包共含172个文件,主体为java源码与class编译文件,另附jar依赖库、png界面截图、properties配置文件及md说明文档,包体仅3.54MB,下载后可直接导入开发环境查看与运行。系统界面基于Swing构建,内置视图切换监听器,可管理标签页等界面组件的状态变化;后台通过MySQL实现预约、挂号、科室、医生、患者等信息的持久化存储,覆盖用户端、医生端和管理端的典型业务流程。代码注释详细、命名规范,目录结构清晰,有助于读者掌握MVC分层思想、JDBC数据库操作、事件监听与界面布局等关键技能,也可作为二次开发的基础。目前已有62人学习,适合作为课程设计或企业实训的参考项目。
1. JavaSwing + MySQL 的医院预约挂号系统:这门课设为啥值得认真做完
A同学拿到一个源码包,标题是“基于 JavaSwing 和 MySQL 的医院预约挂号系统.zip”,第一反应是“都什么年代了还在用 Swing”。但真去某医院信息科或校医院转一圈你会发现,桌面端挂号登记、门诊排班、退号管理这类内网工具,依然大量跑在 Swing 和 Java 8 上。不是因为技术新,而是因为部署简单:不用装 Web 服务器、不用配浏览器环境,双击一个 jar 包就能接入现有数据库。源码里一般已经包含建表 SQL、JDBC 连接层和几个核心界面。这篇笔记就按这套源码最常见的组织方式,把 Swing 界面怎么搭、MySQL 表怎么设计、预约和退号的事务怎么写、哪些参数最容易改崩,逐一拆开。想交课设、想把桌面预约项目跑通、或者正准备接手这类老系统的开发者,都可以照着做。
2. Swing 界面骨架:把登录、挂号、退号组织成一个能跑的主窗口
拿到源码包第一步不是打开 Java 文件乱看,而是先把窗口导航理清楚。Swing 项目的界面层很容易写成一坨:每个功能弹一个新窗口,结果预约、退号、管理各开各的框,连登录态都没法传。一个能跑的挂号系统,主界面通常用 JFrame 承载,内部用 CardLayout 切换面板,只有登录态校验通过后才进入操作台。
2.1 Swing 做桌面预约系统的边界:适合什么场景,不适合什么场景
要判断这套源码值不值得沿用,先看它的适用边界。Swing 适合内网、单机或小并发环境,比如一个科室一台登记电脑、一天几百个号源,完全够用;代码的思维模型是事件驱动:用户点按钮、触发监听器、调用 DAO、刷新表格,这条链路对初学者非常直观。它的短板也很明显:界面风格偏老、并发能力一般、不容易做移动端对接。所以如果你是要做面向公众的线上预约平台,这个源码只是“理解业务”的跳板,而不是最终答案;如果只是做课设、院内工具或验证业务逻辑,它反而比 Spring Boot 那套更轻、更容易在一周内跑通。
源码的典型模块划分是登录面板、挂号面板、退号面板和医生排班管理面板。它们之间通过主窗口的 CardLayout 切换,而不是各自 new JFrame,这样登录信息、当前患者、当前排班记录才能在同一个上下文里传递。
2.2 主窗口骨架:用 JFrame + CardLayout 搭出导航容器
我一般会先把主窗口骨架写好,再往里填业务面板。核心代码长这样:
public class MainFrame extends JFrame { private CardLayout cardLayout; private JPanel cardPanel; private LoginPanel loginPanel; private AppointmentPanel appointmentPanel; private RefundPanel refundPanel; public MainFrame() { setTitle("医院预约挂号系统"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(1000, 700); setLocationRelativeTo(null); cardLayout = new CardLayout(); cardPanel = new JPanel(cardLayout); loginPanel = new LoginPanel(this); appointmentPanel = new AppointmentPanel(this); refundPanel = new RefundPanel(this); cardPanel.add(loginPanel, "login"); cardPanel.add(appointmentPanel, "appointment"); cardPanel.add(refundPanel, "refund"); setContentPane(cardPanel); cardLayout.show(cardPanel, "login"); } public void showAppointment() { cardLayout.show(cardPanel, "appointment"); } }这里的逻辑是:登录面板不单独 new 一个窗口,而是作为首个卡片;登录成功后调用 showAppointment() 切到挂号面板。CardLayout 的好处是面板创建一次、切换时不会重建,Swing 的事件监听器和表格数据都能保留。setLocationRelativeTo(null) 让窗口居中,setDefaultCloseOperation 用 EXIT_ON_CLOSE 方便课设演示,但真实院内工具一般会改成 DISPOSE_ON_CLOSE 配合退出确认。
两个需要注意的参数:窗口尺寸 1000x700 是为了容纳 JTable 的号源列表和右侧操作区;卡片名字符串“login”“appointment”“refund”要统一维护,拼错一个就会遇到点击按钮没任何反应的“玄学问题”。
2.3 JTable 做号源展示:不可编辑表格与选中行取值
号源列表是整个挂号界面的核心。Swing 里最常见的翻车方式是把 JTable 直接 setValueAt 让用户改单元格,结果号源数量被手改没了。正确做法是让表格只读,用按钮触发“预约”动作。
DefaultTableModel model = new DefaultTableModel() { @Override public boolean isCellEditable(int row, int column) { return false; } }; model.setColumnIdentifiers(new Object[]{"排班ID", "医生", "科室", "日期", "时段", "剩余号"}); JTable table = new JTable(model); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setRowHeight(28); JScrollPane scrollPane = new JScrollPane(table); add(scrollPane, BorderLayout.CENTER);isCellEditable 返回 false 之后,用户只能选中行,不能修改内容。setSelectionMode 设为 SINGLE_SELECTION,防止按住 Ctrl 选多行导致预约时取到多个排班 ID。取选中行的排班 ID 时,要按 model 坐标取,不要用 view 坐标直接拿,否则表格排序或移动列之后取值会错位:
int viewRow = table.getSelectedRow(); if (viewRow >= 0) { int modelRow = table.convertRowIndexToModel(viewRow); int scheduleId = (int) model.getValueAt(modelRow, 0); }如果源码里已经做了“预约记录”和“退号记录”两个 tab,表格结构的复用就很重要。把表格模型封装成一个方法,传入 SQL 结果集自动填充列,比每个面板里复制删改 setModel 要省事得多。Swing 的 UI 线程规则是:所有对组件的修改必须在事件分发线程上执行,数据查询可以放后台线程,查到结果后用 SwingUtilities.invokeLater 回填。不这么写的话,界面会在查询大表时卡成白屏。这套界面层逻辑理清了,再往下就是数据库表结构。
3. 数据表设计与 JDBC 连接:先定表,再谈怎么查
界面能打开只是第一步。预约挂号系统的核心不在按钮,而在数据约束:一个号源不能被两个患者同时挂走、退号后号源要回补、排班不能重复。这些约束要么靠应用代码硬写,要么靠表结构兜底。我建议两层都做:表上建唯一约束防止重复排班,代码里用事务保证预约的原子性。
3.1 核心表结构:患者、医生、排班、预约单怎么关联
这套源码一般围绕五张表:department、doctor、schedule、patient、appointment。排班表是关键中的关键,它记录某个医生某天某个时段有多少个剩余号;预约单表记录谁挂了哪个排班。建表 SQL 常见写法如下:
CREATE DATABASE hospital_db DEFAULT CHARACTER SET utf8mb4; USE hospital_db; CREATE TABLE department ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE doctor ( id INT AUTO_INCREMENT PRIMARY KEY, department_id INT NOT NULL, name VARCHAR(50) NOT NULL, title VARCHAR(30), CONSTRAINT fk_doctor_dept FOREIGN KEY (department_id) REFERENCES department(id) ); CREATE TABLE schedule ( id INT AUTO_INCREMENT PRIMARY KEY, doctor_id INT NOT NULL, work_date DATE NOT NULL, time_slot VARCHAR(20) NOT NULL, remaining INT NOT NULL DEFAULT 10, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_doctor_date_slot (doctor_id, work_date, time_slot), CONSTRAINT fk_schedule_doctor FOREIGN KEY (doctor_id) REFERENCES doctor(id) ); CREATE TABLE patient ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, id_card VARCHAR(18) NOT NULL UNIQUE, phone VARCHAR(20), password_hash CHAR(64) NOT NULL ); CREATE TABLE appointment ( id INT AUTO_INCREMENT PRIMARY KEY, patient_id INT NOT NULL, schedule_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_app_patient FOREIGN KEY (patient_id) REFERENCES patient(id), CONSTRAINT fk_app_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id), KEY idx_status (status) );这张表结构里几个设计决策值得说明。schedule 表用 UNIQUE KEY (doctor_id, work_date, time_slot) 防止同一位医生在同一天同一时段被插入两条排班,这是数据库层面的兜底;remaining 字段就是号源库存,预约时对它做条件更新;version 字段是乐观锁版本号,后面做并发优化时会用到。appointment 表的 status 用 TINYINT,1 表示已预约,2 表示已就诊,3 表示已退号,比直接存字符串更省空间、查询更快。
密码字段用 password_hash CHAR(64),存的是 SHA-256 十六进制串,不是明文。很多课设源码直接明文存 password,演示没问题,但只要遇到带数据库审计的场景就会被直接打回,建议新建项目时就把哈希逻辑带上。
3.2 JDBC 连接参数:时区、编码、SSL 三个必调项
源码里通常会有一个 DBUtil 类集中管理连接。连接 URL 的写法是血泪经验的集中区,少一个参数就会出现启动时数据库连接报错、中文问号乱码、或者 SSL 握手失败:
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/hospital_db" + "?useUnicode=true" + "&characterEncoding=utf8" + "&serverTimezone=Asia/Shanghai" + "&useSSL=false" + "&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }参数表说明:
| 参数 | 作用 | 常见问题 |
|---|---|---|
| characterEncoding=utf8 | 保证中文写入读取不乱码 | 不设时中文变问号 |
| serverTimezone=Asia/Shanghai | 指定服务器时区 | 不设时 MySQL 8 直接报时间戳错误 |
| useSSL=false | 关闭 SSL 握手 | 不关时本地环境常出现 SSL 警告或握手超时 |
| allowPublicKeyRetrieval=true | MySQL 8 允许客户端获取公钥 | 不设时 caching_sha2_password 登录失败 |
Class.forName 在 MySQL 8 的驱动下其实可以省略,但保留它能让代码兼容老版本的 mysql-connector-java 5.x。USER 和 PASSWORD 如果源码里写死,数据库改密后只能改代码重新编译;更省事的做法是把这四个值放进一个 properties 文件,运行时读取,避免改一行代码就重新打包 jar。
3.3 查询与防注入:PreparedStatement 是底线不是加分项
Swing 项目里最常见的操作是把用户输入直接拼进 SQL 字符串,然后 executeQuery。这在课设演示时不会立刻出问题,但一旦排班查询条件里有单引号或特殊字符,轻则查询报错,重则被注入。预防办法在 JDBC 层面很简单:全部走 PreparedStatement,参数用 ? 占位。
以号源列表查询为例:
public List<Schedule> findAvailableSchedules(String departmentName, String date) { String sql = "SELECT s.id, d.name AS doctor_name, dep.name AS dept_name, " + "s.work_date, s.time_slot, s.remaining " + "FROM schedule s " + "JOIN doctor d ON s.doctor_id = d.id " + "JOIN department dep ON d.department_id = dep.id " + "WHERE s.remaining > 0 AND s.work_date >= CURDATE() " + "AND dep.name = ? AND s.work_date = ? " + "ORDER BY s.work_date, s.time_slot"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, departmentName); ps.setString(2, date); try (ResultSet rs = ps.executeQuery()) { List<Schedule> list = new ArrayList<>(); while (rs.next()) { Schedule s = new Schedule(); s.setId(rs.getInt("id")); s.setDoctorName(rs.getString("doctor_name")); s.setDeptName(rs.getString("dept_name")); s.setWorkDate(rs.getString("work_date")); s.setTimeSlot(rs.getString("time_slot")); s.setRemaining(rs.getInt("remaining")); list.add(s); } return list; } } catch (SQLException e) { e.printStackTrace(); return Collections.emptyList(); } }这里用 try-with-resources 管理 Connection、PreparedStatement、ResultSet,方法结束时自动关闭,避免老代码里 finally 块忘关连接把连接池耗尽。占位符参数和列名映射是另一个容易踩坑的点:rs.getInt("id") 里的大小写不敏感,但 getString("dept_name") 必须和 SQL 别名完全一致,否则报 column not found。日期参数用 String 传入没问题,前提是数据库 date 列接收的字符串符合 yyyy-MM-dd 格式;如果源码里传入的是带时分秒的格式,会出现“能查但查不到”的诡异现象。
表和连接层搞定后,下一步就是最核心的业务逻辑:预约和退号。
4. 预约业务实现:事务、锁号与角色分发不能只有界面
Swing 界面只是外壳,预约注册逻辑才决定这个系统可用不可用。常见的错误实现是这样:先查剩余号,如果大于 0 就 UPDATE 减 1,再 INSERT 预约单。这个流程在单机演示时没问题,但两个窗口同时操作时就会出现超卖。正确姿势是把“查余号、扣余号、插预约单”放进同一个数据库事务,让三条语句要么全部成功、要么全部回滚。
4.1 登录与角色分发:SHA-256 校验和卡片跳转
患者和管理员共用同一个登录接口,区别在 patient 表的 role 字段(或者单独管理员表)。登录逻辑先拿身份证号查用户,比对密码哈希,成功后通过 MainFrame 的切换方法进入不同面板:
public static String sha256(String text) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] bytes = digest.digest(text.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } public boolean login(String idCard, String password) { String sql = "SELECT id, password_hash, role FROM patient WHERE id_card = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, idCard); try (ResultSet rs = ps.executeQuery()) { if (rs.next() && sha256(password).equals(rs.getString("password_hash"))) { currentPatientId = rs.getInt("id"); currentRole = rs.getString("role"); return true; } } } catch (SQLException e) { e.printStackTrace(); } return false; }String.format("%02x", b) 保证每个字节都输出两位十六进制,否则哈希串长度不稳定。角色字段建议直接用字符串 "patient"/"admin",比数字可读性好;判断角色后跳转的代码写在事件线程里,不要在验证方法里直接操作 Swing 组件,避免跨线程更新界面导致的偶发卡死。
4.2 预约挂号的核心事务:先锁行,再条件更新,再插入预约单
在 MySQL InnoDB 下用 SELECT ... FOR UPDATE 锁住排班行,是这套系统防超卖最直接的手段。配合条件更新 remaining > 0,等于上了双保险。完整代码如下:
public boolean makeAppointment(int patientId, int scheduleId) { String checkSql = "SELECT remaining FROM schedule WHERE id = ? FOR UPDATE"; String updateSql = "UPDATE schedule SET remaining = remaining - 1 " + "WHERE id = ? AND remaining > 0"; String insertSql = "INSERT INTO appointment(patient_id, schedule_id) VALUES(?, ?)"; try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); try (PreparedStatement checkPs = conn.prepareStatement(checkSql)) { checkPs.setInt(1, scheduleId); try (ResultSet rs = checkPs.executeQuery()) { if (!rs.next() || rs.getInt("remaining") <= 0) { conn.rollback(); return false; } } } try (PreparedStatement updatePs = conn.prepareStatement(updateSql)) { updatePs.setInt(1, scheduleId); int updated = updatePs.executeUpdate(); if (updated == 0) { conn.rollback(); return false; } } try (PreparedStatement insertPs = conn.prepareStatement(insertSql)) { insertPs.setInt(1, patientId); insertPs.setInt(2, scheduleId); insertPs.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { e.printStackTrace(); return false; } }这里三个关键点。第一,setAutoCommit(false) 之后,任何一条语句失败都不会自动提交,必须显式 commit 或 rollback。第二,FOR UPDATE 锁的是排班行,第二个预约同一排班的事务会阻塞等待,直到第一个事务提交或回滚,这样就不会出现两个事务同时读到 remaining=1 的情况。第三,UPDATE 里的 remaining > 0 条件是兜底,即使 FOR UPDATE 那行没判断,条件更新也会保证库存不会变成负数。
事务隔离级别用 TRANSACTION_READ_COMMITTED 而不是默认的 REPEATABLE_READ,是为了减少锁的范围和间隙锁的开销。单机课设体现不出差别,但接入真实数据库后,repeatable read 下 FOR UPDATE 加间隙锁可能造成不必要的死锁。如果源码里没用事务,而是三个独立 connection 执行,一定要改成这种写法,这是整个预约系统“能不能用”的分水岭。
4.3 退号与号源回补:状态更新和库存回补要一起提交
退号不等于删掉预约记录,而是把 appointment 的 status 改成 3,同时把 schedule 的 remaining 加 1。这个操作同样要在一个事务里完成:
public boolean refundAppointment(int appointmentId) { String updateAppSql = "UPDATE appointment SET status = 3 " + "WHERE id = ? AND status = 1"; String updateSchedSql = "UPDATE schedule s " + "SET s.remaining = s.remaining + 1 " + "WHERE s.id = (SELECT a.schedule_id FROM appointment a WHERE a.id = ?)"; try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps1 = conn.prepareStatement(updateAppSql)) { ps1.setInt(1, appointmentId); if (ps1.executeUpdate() == 0) { conn.rollback(); return false; } } try (PreparedStatement ps2 = conn.prepareStatement(updateSchedSql)) { ps2.setInt(1, appointmentId); ps2.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { e.printStackTrace(); return false; } }updateAppSql 里的 status = 1 条件很关键,它保证只有“已预约”状态能退号,已经“已就诊”的记录不能重复退。如果源码里没有这个条件,患者把同一张预约单退两次,号源会被加回去两次。退号时没有 FOR UPDATE 是因为回补操作本身是幂等加一,真正的并发风险在于同一张预约单被两个窗口同时退;UPDATE 条件 status=1 配合事务能保证只有一个窗口执行成功。
业务层到这里已经闭环:登录、查号、预约、退号。剩下的是把隐藏的坑一个个填平。
5. 避坑指南:运行时异常、中文乱码与并发超卖的 5 个坑
这部分是长时间调试这类源码攒出来的记录,每一条都有现象、原因和解决方式。建议在做课设选题或接老系统维护时,先对着这份清单过一遍,能省下大量排查时间。
5.1 一启动就报错:The server time zone value 'Öйú±ê׼ʱ¼ä'
现象:程序启动后第一次 getConnection 就抛 SQLException,提示 server time zone value 无法识别或转换。控制台里中文乱码,时区名显示成乱码字符。
原因:MySQL 8 之前的驱动可以不指定时区,MySQL 8 之后连接握手时要求明确 serverTimezone。数据库系统时区是中文环境时,驱动解析本地时区名失败。
解决:在 JDBC URL 里显式加上 serverTimezone=Asia/Shanghai,并确保 characterEncoding=utf8 放在 URL 参数中。如果改了还报,检查是不是 MySQL 驱动版本太老,换成 mysql-connector-java 8.0.30 左右的新版本。这条是整个源码包最容易遇到的启动拦路虎。
5.2 界面按钮点了没反应:JTable 取错行号或监听器没注册
现象:点击“预约”按钮没有任何提示,或者弹窗里显示的排班 ID 是另一行的数据。多数情况下按钮事件执行了,但业务方法里拿到了错误的 ID。
原因:JTable 在排序、列移动后,视图坐标和模型坐标不一致。直接用 getSelectedRow() 当 model 下标用,取到的就是视觉上的行号,一旦用户点过列头排序,数据就错位。
解决:无论源码里有没有排序功能,都统一用 table.convertRowIndexToModel(viewRow) 转换。另外监听器要用 table.getSelectionModel().addListSelectionListener,而不是在按钮事件里重新 getModel,后者容易复用到旧模型实例。
5.3 中文全部变成问号:建库字符集和连接字符集不一致
现象:界面显示正常,但插入数据库的中文全是“???”。检查数据库表字符集是 utf8,连接 URL 也带了 characterEncoding=utf8,问题还在。
原因:MySQL 的 utf8 字符集只支持基本多语言平面,连 emoji 都不支持,且部分老版本库实际存储用的是 latin1。连接层字符集覆盖不到服务端建表字符集。
解决:建库时统一用 utf8mb4,CREATE DATABASE ... DEFAULT CHARACTER SET utf8mb4;连接 URL 保持 characterEncoding=utf8,实际驱动会按 utf8mb4 处理。已有表的,用 ALTER TABLE xx CONVERT TO CHARACTER SET utf8mb4 迁移。这条对医院系统里患者姓名带生僻字的场景特别重要。
5.4 打 jar 包后 ClassNotFoundException: com.mysql.cj.jdbc.Driver
现象:IDE 里跑得好好的,导出 Runnable JAR 后换机器双击,报找不到 JDBC 驱动类。
原因:JDBC 驱动 jar 没有被打进最终产物。IDE 运行时依赖开发环境的 classpath,导出时默认不包含第三方 jar。
解决:两种方案,一是 Eclipse/IDEA 里导出 Runnable JAR,选择 “Package required libraries into generated JAR”;二是用 Maven Shade 插件或 Assembly 插件打 fat jar。如果是手动 javac 编的,classpath 里要把 mysql-connector-j 的 jar 路径写全。换机器还要注意 JRE 版本,MySQL 8 驱动要求 Java 8 以上,给学校机房部署时先查 java -version。
5.5 并发预约超卖:两个窗口同时挂到最后一个号
现象:同一时段号源只剩 1 个,两个患者同时点预约,结果两张预约单都创建成功,排班 remaining 变成负数。
原因:代码没有事务保护,或者虽然用了事务,但先 SELECT 再 UPDATE 的过程里没有锁,两个连接同时读到 remaining=1,然后各自执行减一。
解决:按第 4 章的写法,在整个预约流程里使用 SELECT ... FOR UPDATE 锁行,并用 UPDATE ... SET remaining = remaining - 1 WHERE remaining > 0 做条件更新。此外可以在 schedule 表上建立 CHECK (remaining >= 0) 约束,MySQL 8.0.16 以上会生效,多一层数据库兜底。如果不想动表结构,至少把业务代码改成单条条件更新,去掉先查后改的窗口期。
避坑清单到这里基本覆盖了启动、界面、编码、打包、并发五个高频问题。剩下的工作不是加功能,而是把系统从“能跑”推到“经得起验证”。
6. 优化与验证:并发压测与一致性,落地前的最后一步别偷懒
很多拿到源码包的同学,把界面点通之后就认为项目完工。但预约系统和其他课设不一样,它的核心是数据一致性,不是按钮好不好看。我一般会在最后阶段补两类工作:一是给排班扣号加乐观锁兜底,二是写几条验证 SQL 检查号源数据有没有超卖或漏号。
乐观锁改造很简单,在预约事务里把 UPDATE 语句带上 version 条件:
UPDATE schedule SET remaining = remaining - 1, version = version + 1 WHERE id = ? AND remaining > 0 AND version = ?;执行前先查出当前 version,执行后检查 updated 行数,如果为 0 说明数据已被别人改过,提示用户重试。这样即使某天有人把 FOR UPDATE 删掉,乐观锁也能挡住并发冲突。
验证阶段可以写一组只读 SQL 做体检。查询所有已预约数加上剩余数不等于初始号源数的排班:
SELECT s.id, s.work_date, s.time_slot, s.remaining, COUNT(a.id) AS booked_count, s.remaining + COUNT(a.id) AS total_check FROM schedule s LEFT JOIN appointment a ON a.schedule_id = s.id AND a.status = 1 GROUP BY s.id HAVING total_check <> 10;如果这条 SQL 查出任何一行,说明该排班的初始号源不是 10,或者某张预约单状态流转出了问题。数值 10 只是示例,实际以排班表录入时的初始号为准。退号逻辑的验证则看 status=3 的预约单,对应的号源是否已经回补。
最后打包之前,强烈推荐把数据库初始化脚本、JDBC 配置示例和启动说明整理进一个 txt 文档。这类基于 Swing 的老项目,最大的“技术债”不是代码逻辑,而是接手的人不知道数据库密码、不知道怎么改连接串。把这些写清楚,整个源码包的可信度会立刻上一个台阶。我自己接手过好几个这类项目,每次都是先看有没有建表 SQL、再看有没有启动说明、最后才看代码逻辑,顺序反了就会在一个错误密码上消耗半天。希望这些习惯和踩坑记录能帮到你,让你在这个项目上少走一段弯路。
本文还有配套的精品资源,点击获取