简介:这是一份基于 Java Swing 与 MySQL 8.0 构建的停车场管理系统源码包,适合 Java 初学者、课程设计或毕业设计参考。系统按用户与管理员双角色设计:用户端支持登录、计费标准查询、在场信息与个人历史记录查看;管理端覆盖车辆入场出场、用户注册修改充值、计费标准维护及管理员账户管理,功能划分清晰,便于二次开发。压缩包共 80 个文件,包含 25 个 Java 源文件、47 个编译后的 class 文件、SQL 数据库脚本及项目配置信息,整体仅 2.11MB,结构紧凑。数据库层提供 charger、park、station、users 四张表,配合 JDK 1.8 与 MySQL 8.0.13 环境即可运行。资源内附运行截图,方便对照界面理解代码逻辑。目前已有 728 人学习下载,适合需要快速上手桌面数据库应用开发的读者。
1. 基于Java+Swing+mysql的停车场管理系统:课程设计还是小规模商用的起点
每到课程设计季,总有人拿到这样一个题目:用Java Swing做界面,MySQL做数据存储,实现一个停车场管理系统。这个组合看起来不够“现代”,却是桌面应用教学里最经典的搭配之一。它要解决的问题很实在:车辆进场登记、车位占用管理、出场计费、历史记录查询和营收统计,覆盖了数据库增删改查和一个小型业务闭环的全部关键点。对正在找 Java 课程设计案例源码、或者想快速交付一个完整 JavaWeb 项目的人来说,这个题目是一块很好的练手阵地。反直觉的地方在于:Swing 虽然老,却是能把 MySQL 操作变成看得见、点得着的东西的最快方式,比一上来就上 Spring Boot + Vue 更适合短周期交付和答辩演示。
2. 从需求到表结构:停车场管理系统的数据库设计
2.1 功能边界:一个最小可用系统要管哪些事
一个停车场管理系统,核心就三件事:车进来、车出去、算钱。周边再扩展出车位状态展示、进场记录查询、营收统计、管理员登录。不要一开始就想着做月卡、预约、会员折扣,先把“临时车按时间计费”这条主线跑通,再加花活也不迟。
我把功能拆成五个模块:登录模块,管理员用账号密码进入系统;车位管理,初始化车位编号并显示占用或空闲;入场登记,录入车牌号、选择空闲车位、写入进场时间;出场结算,根据车牌查进场记录、算时长和金额、回填离场时间;记录查询,按车牌和日期查历史流水,还能按天汇总营收。
这样拆完之后,数据需求非常清晰:一张管理员表、一张车位表、一张停车记录表。停车记录是整个系统的核心流水,进场和出场的信息都沉淀在这张表里,后面做报表、对账、查询全部围着它转。
2.2 三张核心表的字段设计与建表SQL
三张表的字段设计,我一般遵循“够用且不过度设计”的原则。管理员表只存 id、用户名、密码;车位表存 id、车位编号、状态和备注;停车记录表存 id、车牌、车位关联、进场时间、离场时间和费用。密码不要存明文,至少用 MD5 加盐,虽然课程设计不强制,但答辩时是加分项。
建表 SQL 如下:
CREATE DATABASE IF NOT EXISTS parking_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE parking_system; CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT 'MD5后的密文' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE parking_space ( id INT PRIMARY KEY AUTO_INCREMENT, space_no VARCHAR(10) NOT NULL UNIQUE, status TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1占用', remark VARCHAR(100) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE parking_record ( id INT PRIMARY KEY AUTO_INCREMENT, plate_no VARCHAR(20) NOT NULL, space_id INT NOT NULL, entry_time DATETIME NOT NULL, exit_time DATETIME NULL, fee DECIMAL(10,2) DEFAULT NULL, INDEX idx_plate_no (plate_no), INDEX idx_entry_time (entry_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:status 用 TINYINT 而不是 VARCHAR,是为了让 SQL 条件简洁,Java 侧映射成 int 也更省事。fee 用 DECIMAL(10,2) 而不是 float 或 double,因为浮点数在金额计算时会出 0.30000000000000004 这类精度问题,这在 java 基础面试题里也是常考的点。索引方面,plate_no 和 entry_time 是查询高频字段,分别建普通索引就够,对于这个量级的系统,联合索引属于过度设计。
参数说明:字符集统一用 utf8mb4,虽然停车场业务只有中文和数字,但 utf8mb4 能兼容 emoji 和生僻字,避免后期接入其他数据源时出现乱码。DATETIME 类型不带时区,Java 侧传参时用字符串或 java.sql.Timestamp 都行,关键是要统一格式,后面会专门讲时区坑。
2.3 计费规则怎么落到数据库里:费率表还是存储过程
计费规则一般是:首小时 X 元,之后每小时 Y 元,不足一小时按一小时算;或者严苛一点,按半小时计费。有人建议把计费写成 MySQL 存储过程,数据库算好了直接返回金额。我个人的习惯是不用存储过程,原因有三:第一,课程设计场景下费率基本是写死的,不需要动态配置;第二,计费逻辑放在 Java Service 层,方便写单元测试,答辩时也能直接讲清楚算法;第三,存储过程对新手不友好,调试起来像个黑匣子,出问题难定位。
但如果你想在系统里体现“可配置”的设计,可以在数据库加一张费率表,字段为 id、规则名、首小时金额、续小时金额、是否启用。Java 启动时加载到内存,出场结算时查一下,改费率不用改代码。这个方案在真实停车场系统里很常见,加分点在于业务和代码解耦。
我一般会把费率值抽成常量放在一个类里:
public class FeeConfig { // 单位:元 public static final double FIRST_HOUR_PRICE = 5.0; public static final double ADDITIONAL_HOUR_PRICE = 3.0; // 是否按半小时计费,false 表示不足一小时按一小时计费 public static final boolean BILL_BY_HALF_HOUR = false; }这样参数改动一目了然,也方便在测试时快速切换不同费率来验证计费边界。
2.4 为什么不用外键和触发器
很多教程会给表加外键约束、写触发器在入场时自动更新车位状态。我的建议是:课程设计里尽量不用。原因不是外键不好,而是这个项目的数据量级和并发量级根本用不到,反而会带来麻烦。
外键约束要求插入停车记录时 space_id 必须存在于车位表,这没问题,但删除车位、初始化测试数据时会遇到顺序问题。触发器更像一个黑匣子,车位状态是否被自动更新,你只能去看数据库日志,排错成本高。更实在的做法是在 Java 业务代码里手动控制事务:先更新车位 status 为 1,再插入停车记录,两个操作要么都成功要么都回滚。这样逻辑透明,答辩时你能一句话说清楚,且不用依赖数据库端的隐式操作。
3. 搭建Swing+mysql项目骨架:从连接池到登录窗口
3.1 项目结构分层:避免把SQL写在按钮里
我见过太多课程设计源码,一个 JFrame 里塞了上千行,按钮点击事件里直接写 Connection 和 Statement。能跑,但改需求时想哭。推荐按分层思想组织包结构,但不引入 Spring 等框架,纯 Java 实现:
com.example.parking ├── Main.java // 启动入口 ├── view/ // Swing界面 │ ├── LoginFrame.java │ ├── MainFrame.java │ └── RecordQueryPanel.java ├── service/ // 业务逻辑 │ ├── LoginService.java │ ├── ParkingService.java │ └── FeeCalculator.java ├── dao/ // 数据访问 │ ├── AdminDao.java │ ├── SpaceDao.java │ └── RecordDao.java ├── util/ // 工具 │ ├── DBUtil.java // 连接池封装 │ └── DateUtil.java └── model/ // 实体 ├── Admin.java ├── ParkingSpace.java └── ParkingRecord.java这个结构对新手友好:view 只负责显示和收集输入,service 做业务判断,dao 只做 SQL 操作。好处是以后换成 MyBatis 或者改成 Spring Boot 接口时,service 和 model 可以原封不动搬走,只需要重写 dao 层。这也是 Java 面试题里常问的分层思想,你说得出理由就是加分项。不要贪图省事把 SQL 写在界面类里,否则后面做报表或者换数据库时会让你怀疑人生。
3.2 用Druid连接池代替DriverManager直连
不要用 DriverManager.getConnection() 写死在每个方法里,每次连接都创建物理连接,数据量大时数据库会报 Too many connections,Swing 界面操作稍微频繁一点,连接浪费非常明显。用连接池是常规操作,Druid 对新手友好,配置简单,还自带监控页。如果你的项目用 Maven 管理,添加依赖很直接:
<dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>注意:MySQL 8.0 以上要使用 com.mysql.cj.jdbc.Driver,老教程里写的 com.mysql.jdbc.Driver 已经在新的 connector 中移除了,这是最常见的报错来源之一。
然后写连接池工具类:
package com.example.parking.util; import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.io.InputStream; import java.util.Properties; public class DBUtil { private static DataSource dataSource; static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("druid.properties")) { Properties props = new Properties(); props.load(in); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError("初始化数据库连接池失败: " + e.getMessage()); } } public static DataSource getDataSource() { return dataSource; } }逻辑说明:这个类在静态代码块里加载 druid.properties 配置文件并创建连接池,整个 JVM 生命周期只有一个 DataSource 实例,所有 DAO 都从它拿连接。这种写法避免了每个方法都 new 工具类的浪费,也方便以后对连接池做监控。静态代码块里的异常用 ExceptionInInitializerError 抛出,让程序启动时第一时间暴露配置问题,而不是运行到一半才报连接失败。
参数说明:druid.properties 文件放在 src/main/resources 下,一个典型配置如下:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/parking_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username=root password=your_password initialSize=5 maxActive=10 maxWait=3000这些参数里最容易踩坑的是 useSSL 和 serverTimezone。MySQL 8 默认开了 SSL,但本机开发用不到,useSSL=false 可以避免 SSL 握手报错。serverTimezone=Asia/Shanghai 是给 JDBC 驱动用的,没有它查出来的时间会差 8 小时。allowPublicKeyRetrieval=true 是 MySQL 8 的 caching_sha2_password 认证下必须的,不然连不上。Druid 的 initialSize 是启动时初始连接数,maxActive 是最大连接数,maxWait 是获取连接的超时毫秒数,课程设计用默认这几个就够,不需要调复杂参数。
3.3 登录模块的最小实现:校验、跳转、提示
登录模块是系统的门面,也能把 Swing 的最基本套路走一遍:JFrame、JTextField、JPasswordField、JButton、事件监听、对话框提示。
写代码时,千万不要在 ActionListener 里直接查数据库,而是调用 Service 层。我一般这样写:
public class LoginFrame extends JFrame { private JTextField usernameField; private JPasswordField passwordField; private LoginService loginService = new LoginService(); public LoginFrame() { setTitle("停车场管理系统登录"); setSize(320, 180); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); // 布局和组件初始化省略,关注核心逻辑 } private void onLoginClick() { String username = usernameField.getText().trim(); String password = new String(passwordField.getPassword()); if (username.isEmpty() || password.isEmpty()) { JOptionPane.showMessageDialog(this, "用户名和密码不能为空"); return; } boolean ok = loginService.checkLogin(username, password); if (ok) { new MainFrame().setVisible(true); dispose(); } else { JOptionPane.showMessageDialog(this, "用户名或密码错误"); passwordField.setText(""); } } }逻辑说明:JPasswordField.getPassword() 返回的是 char[] 而不是 String,这是 Java 基础里常考的“为什么密码用 char[] 好”,因为 String 是不可变对象,会留在内存里,char[] 用完后可以手动置空。登录成功后再 new MainFrame() 并 dispose() 掉登录窗口,这是 Swing 窗口切换的标准写法,不会造成窗口堆积。
在 LoginService 里,查询用 PreparedStatement 防止 SQL 注入:
public boolean checkLogin(String username, String password) { String md5Password = MD5Util.md5(password); String sql = "SELECT COUNT(*) FROM admin WHERE username = ? AND password = ?"; try (Connection conn = DBUtil.getDataSource().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, md5Password); try (ResultSet rs = ps.executeQuery()) { rs.next(); return rs.getInt(1) > 0; } } catch (SQLException e) { e.printStackTrace(); return false; } }这里用 try-with-resources 自动关闭连接,实际上是把连接归还给连接池而不是物理关闭,这是连接池和 DriverManager 直连最大的区别。MD5 虽然不适合高强度安全场景,但对课程设计够用了,答辩时可以说“实际生产会用 BCrypt,这里为了简化用 MD5 加盐”。管理员账号初始化时,用同一套 MD5 算法把明文转成密文插入到 admin 表,别把明文直接写进去,否则演示时打开数据库就被看穿了。
4. 核心业务编码:车辆入场、出场计费与车位看板
4.1 入场逻辑:占车位与写记录的顺序
入场业务是:用户输入车牌号,选择一个空闲车位,点击入场按钮。系统要做两件事:把 parking_space 的 status 更新为 1,向 parking_record 插入一条进场记录。这个顺序不能反,而且必须放在同一个事务里。
如果先插记录再改车位,插入后程序崩溃,会导致有记录但车位没被占用,下一次同一车位还能进来,数据就乱了。反过来先占车位,再插记录,即使插入失败,事务回滚也能让车位恢复空闲。
代码用 Connection 手动控制事务:
public boolean entry(String plateNo, int spaceId) throws SQLException { String updateSpace = "UPDATE parking_space SET status = 1 WHERE id = ? AND status = 0"; String insertRecord = "INSERT INTO parking_record (plate_no, space_id, entry_time) VALUES (?, ?, NOW())"; try (Connection conn = DBUtil.getDataSource().getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps1 = conn.prepareStatement(updateSpace); PreparedStatement ps2 = conn.prepareStatement(insertRecord)) { ps1.setInt(1, spaceId); int rows = ps1.executeUpdate(); if (rows == 0) { conn.rollback(); return false; // 车位已被占用或不存在 } ps2.setString(1, plateNo); ps2.setInt(2, spaceId); ps2.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { conn.rollback(); throw e; } } }注意 UPDATE 条件里的 AND status=0,这个条件让数据库在更新前检查车位是否空闲,相当于用乐观锁的思路解决并发问题。如果用“先 SELECT 查 status,再 UPDATE”的做法,两台电脑同时入场同一个车位时都会查到空闲,然后都会更新成功,就爆了。加了这个条件,第二次更新受影响行数为 0,业务上做回滚即可。这就是 java 怎么保证数据一致性这个面试问题在业务代码里的实际落点。
4.2 出场计费:时长计算与金额精度
出场逻辑是:输入车牌号,查到未离场的记录,显示入场时间和当前时间,计算费用,确认收款后回填 exit_time 和 fee,并把车位置空。
时长计算我推荐用毫秒差再手动换算,而不是用 SQL 的 TIMESTAMPDIFF。原因还是业务规则要收在 Java 里。比如“不足一小时按一小时”,用 SQL 算完还得在 Java 里二次修正,不如直接算:
public static long calculateMinutes(Date entryTime, Date exitTime) { long diffMillis = exitTime.getTime() - entryTime.getTime(); return (long) Math.ceil(diffMillis / (60.0 * 1000)); } public static double calculateFee(long minutes) { if (minutes <= 60) { return FeeConfig.FIRST_HOUR_PRICE; } long extraHours = (minutes - 60 + 59) / 60; // 向上取整到小时 return FeeConfig.FIRST_HOUR_PRICE + extraHours * FeeConfig.ADDITIONAL_HOUR_PRICE; }逻辑说明:Math.ceil(diffMillis / 60000.0) 得到分钟数向上取整,解决“停了 1 分 01 秒也按 1 分钟算”的问题。extraHours 使用 (minutes - 60 + 59) / 60,这是整数向上取整的经典写法,避免使用 Math.ceil 再强转。金额用 double 参与运算,最后在写入数据库时用 BigDecimal 做四舍五入。
这里有个精度坑:如果用 float 存金额,1.5 元每小时,停 3 小时计算 3 * 1.5,结果可能是 4.5 但打印出来是 4.5000001。所以数据库字段用 DECIMAL(10,2),Java 侧在入库前把 double 转成 BigDecimal:
BigDecimal feeDecimal = BigDecimal.valueOf(actualFee) .setScale(2, RoundingMode.HALF_UP); record.setFee(feeDecimal);参数说明:RoundingMode.HALF_UP 就是四舍五入,也是日常对账最常用的舍入模式,不要用 BigDecimal 的老常量 BigDecimal.ROUND_HALF_UP,那已经标记过时了。计费时如果配置了 BILL_BY_HALF_HOUR,把 extraHours 的计算基准从 60 分钟改成 30 分钟即可,逻辑不变。
4.3 刷新车位看板:Swing的事件分发线程
车位看板是系统最直观的界面,常见做法是用一个 JPanel,每个车位一个按钮,空闲绿色,占用红色。要做到入场或出场后界面立即刷新,很多人会直接在业务代码里调用刷新方法,界面却卡死或者不更新,根本原因是触碰了 Swing 线程模型。
Swing 不是线程安全的,所有 UI 更新必须在事件分发线程(EDT)上执行。你的业务操作如果耗时较长,比如上万的记录查询,就不该直接在主线程里执行。最简单的做法是用 SwingUtilities.invokeLater 把刷新动作丢回 EDT:
private void refreshSpacePanel() { // 从DAO查出所有车位状态,比如 List<ParkingSpace> spaces List<ParkingSpace> spaces = spaceDao.findAll(); SwingUtilities.invokeLater(() -> { panel.removeAll(); for (ParkingSpace space : spaces) { JButton btn = new JButton(space.getSpaceNo()); btn.setBackground(space.getStatus() == 1 ? Color.RED : Color.GREEN); panel.add(btn); } panel.revalidate(); panel.repaint(); }); }注意:查询数据库的操作放在 invokeLater 外面,只把 UI 刷新放进去。如果你把查询也放进去,数据库响应慢时界面照样卡。正确姿势是:耗时操作放普通线程,结果回来后用 invokeLater 切回 EDT 更新界面。这是 Swing 面试里“界面卡死怎么排查”的标答。revalidate 和 repaint 都要调用,前者通知布局管理器重新计算组件位置,后者触发重绘,漏一个就会出现“按钮明明 add 了却不显示”的问题。
5. 停车场管理系统上线前的避坑清单:五个真实翻车点
5.1 MySQL连接失败:时区、SSL与socket路径
现象:运行程序时控制台报错java.sql.SQLException: The server time zone value 'CST' is unrecognized,或者Communications link failure。后者如果出现在 Linux 下,还可能是Can't connect to local MySQL server through socket '/tmp/mysql.sock',这是 mysql 命令行工具走 socket 连接失败,和 Java 程序报的 TCP 连接失败不一样,排查方向完全不同。
原因:MySQL 8 的驱动强制校验时区;SSL 握手在某些版本 JDK 下会失败;MySQL 服务没启动或端口被占用。socket 报错通常是 mysql 客户端连本地服务用的 Unix socket 文件路径不对,Java 远程连接用的是 TCP 3306 端口,两者要分开看。
解决:第一,确认 MySQL 服务已启动,Windows 看服务列表,Linux 用 systemctl status mysqld。第二,JDBC URL 按前面写的加上serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true。第三,如果还连不上,用 Navicat 或 MySQL Workbench 先测一次,排除网络和账号问题。有个经典坑是 root 账号的 host 是 localhost,而 Java 程序用 127.0.0.1 连接,MySQL 里 localhost 和 127.0.0.1 是两个账号条目,会报 Access denied,这时要么建一个 host 为 127.0.0.1 的账号,要么 URL 写成 localhost。
5.2 中文乱码:从URL到表的字符集全线对齐
现象:插入的车牌号或管理员姓名查询出来全是问号,或者“???”。数据库里查出来是正常的,但 Java 界面显示乱码,反过来界面输入乱码存到数据库也乱。
原因:字符集链路中某一环断了。链路是:IDE 源码编译字符集、JDBC URL 的 characterEncoding、MySQL 连接字符集、表字符集、字段字符集。任何一环不一致都会乱码。
解决:我的办法是一条龙。第一,建库时用 utf8mb4,表和字段显式指定 utf8mb4。第二,JDBC URL 加characterEncoding=utf8,注意驱动配置里 utf8 就映射到 utf8mb4,不需要写成 utf8mb4。第三,IDEA 里把 Project Encoding、File Encoding 都设成 UTF-8。第四,Swing 的 JTextField 默认就是 Unicode,不需要额外处理。如果还乱,在连接建立后执行一次SET NAMES utf8mb4确认连接层字符集。
5.3 界面卡死:耗时查询堵住了事件分发线程
现象:点击“查询全部记录”按钮后,窗口变成白屏无响应,Windows 提示“未响应”,等很久才恢复。数据量只有几百条时感觉不出来,但记录表累积到上万条或者没有索引,SELECT * 会把所有行加载到内存,界面自然卡死。
原因:把数据库查询直接写在了按钮的 ActionListener 里,SQL 执行期间 EDT 被阻塞。Swing 的界面刷新、按钮点击、键盘输入全都在 EDT 上排队,一个耗时操作堵住了整个队列,其他事件全部等待。
解决:查询放到普通线程或 SwingWorker。最简单的改造:
new Thread(() -> { List<ParkingRecord> records = recordDao.queryAll(); SwingUtilities.invokeLater(() -> tableModel.setDataVector(records)); }).start();如果觉得手动管理线程麻烦,可以用 SwingWorker,它自带 doInBackground 和 done 方法,适合做后台任务。我通常用普通线程加 invokeLater,因为代码量少,课程设计里也够用。另一个坑是查询结果集越滚越大,内存溢出,这个要靠 SQL 带时间条件,别允许用户第一次就查全表,比如默认查最近一周,日期范围用 JDateChooser 控件限制。
5.4 SQL注入与空值:别把用户输入直接拼进SQL
现象:输入车牌号' or '1'='1,查询出来整个表的数据;或者输入带单引号的车牌时程序直接报错。课程设计里用 Statement 字符串拼接是常见写法,但这是最容易被答辩老师抓住的问题。
原因:SQL 注入不是黑客专属,只要用户输入被当成 SQL 参与执行就会有问题。比如SELECT * FROM parking_record WHERE plate_no = '+ input +',输入一个1' OR '1'='1,拼接后就变成恒真条件。另外,车位进场时如果车牌号输入了特殊字符,还会引起 SQL 语法错误。
解决:所有 SQL 都用 PreparedStatement 占位符,参数用 setString/setInt 传入。另外,处理空值:ResultSet.getXxx 返回 null 时要处理,尤其出场记录里 exit_time 和 fee 可能为 null,直接调用 getTimestamp.toString 会 NPE。我一般在实体类里把基本类型改成包装类型,Integer、Double、Date,然后在取值时统一判空。
5.5 数据一致性:并发入场同一车位
现象:管理台有两个窗口,同时对同一个车位执行入场操作,都成功了,车位状态却是空闲;或者记录表出现两条 entry_time 几乎相同、space_id 相同的记录。
原因:没有加条件更新,也没有事务。前台的并发在真实停车场很少发生,但演示时开了两个客户端,或者点按钮手速太快,就会触发。数据库事务隔离级别默认是 REPEATABLE READ,两个会话同时执行 UPDATE 会等锁,等第一个提交后,第二个再执行。问题在于,如果代码是“先 SELECT status=0 再 UPDATE”,第二个会话在上面那条 UPDATE 阻塞期间,它之前 SELECT 到的还是 0,等锁释放后 UPDATE 又无条件执行,就覆盖了。
解决:用上一章写的UPDATE ... WHERE id = ? AND status = 0,让更新条件带乐观锁。第二个事务执行时,affected rows 是 0,代码返回 false 提示“该车位已被占用”,从而保证一致性。另外,把两步放在同一个事务里并 commit,杜绝中间状态。这个思路同样适用于 mysql 主从复制环境下的并发写场景,核心原则就是“更新时带上业务条件,不要先查再更”。
6. 让系统更像产品:报表、导出与验证方法
6.1 用SQL聚合做日营收报表
一个可用系统不能只停留在增删改查,至少加一个按日统计营收的界面。SQL 不难,一条 GROUP BY 就能解决:
SELECT DATE(entry_time) AS day, COUNT(*) AS order_count, COALESCE(SUM(fee), 0) AS total_fee FROM parking_record WHERE exit_time IS NOT NULL GROUP BY DATE(entry_time) ORDER BY day DESC;注意这里统计的是已离场的记录,入场未出场的不算营收。COALESCE 处理 SUM(fee) 为 NULL 的情况,避免报表里出现 null。DATE(entry_time) 做分组会触发全表扫描,数据量大时要提前按日期范围过滤,比如 WHERE entry_time BETWEEN ? AND ?。
6.2 把报表导出成CSV:半小时写好的功能
导出用 CSV 最实惠,Swing 用户点击按钮后,用 JFileChooser 选择保存位置,然后把结果写成 csv 文件,Excel 能直接打开。我一般这样写:
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( new FileOutputStream(file), "UTF-8"))) { writer.write("日期,订单数,营收(元)"); writer.newLine(); for (ReportRow row : rows) { writer.write(row.getDay() + "," + row.getCount() + "," + row.getFee()); writer.newLine(); } }注意 CSV 的编码坑:Excel 打开中文 CSV 经常乱码,是因为 Excel 默认用本地 ANSI 编码。解决方法是写文件时在开头加 BOM 头,也就是先写\uFEFF,这样双击打开就不会乱码。字段里如果有逗号和引号要做转义,课程设计一般用不上,但写明这个边界会让系统更稳健。
6.3 验证方法:别只点一遍就宣布完成
我交付前的验证套路是:插入 1000 条模拟数据,跑一遍入库、出库、统计,重点验证边界条件,0 分钟停车、1 分钟、59 分钟、60 分钟、61 分钟、24 小时整、跨天。计费规则用现有的,算完手算对一下。然后开两个客户端同时操作同一车位,确认第二个被拒绝。最后查一遍 MySQL 慢查询日志,看看有没有全表扫描的 SQL。这套流程下来,答辩演示基本不会翻车。
说一个我自己的教训:第一次做这类系统时,我图省事把计费用 float 算,结果对账差了 0.01 元被老师挑毛病;后来把金额全改成 BigDecimal。还有一次忘记给 record 表加索引,演示时点查询卡了十几秒,场面一度很尴尬。现在我做任何一个基于 Java+Swing+MySQL 的系统,都把数据库连接配置、字符集、索引、事务这四件事当成地基,先打好再盖楼。
这套方案不用复杂框架,花两三天就能从建库跑到导出报表,非常适合课程设计或小规模内部工具。你把它跑通之后,再回头去看 java 面试八股文里的事务隔离、PreparedStatement、Swing EDT 这些概念,都会有更具体的体感。希望帮到你。
本文还有配套的精品资源,点击获取