
简介面向高校数据库系统课程设计场景的车站售票管理系统个人方案配套源码与安装说明书适合正在完成广工数据库课设、需要参考完整业务实现与部署流程的学生。系统围绕售票与退票、车次及时刻表查询统计展开并包含数据备份恢复、操作员管理与权限设置等维护功能可据此理解售票业务的表结构设计、功能模块划分和 Java 与数据库的协同方式。压缩包共 100 个文件、约 4.65MB以 72 个 class 编译产物和 21 个 Java 源文件为主体另含 SQL 建库脚本、项目配置、说明文档与依赖 jar便于对照源码、导入数据库并运行调试。目前已有 1159 人学习可作为课程设计选题、功能拆解与实现思路的参考样本。1. 车站售票管理系统这套数据库课设真正卡人的从来不是界面车站售票管理系统是数据库课设里出现频率最高的选题之一听着简单查车次、下单、退票三件事。真上手就发现卡点全在数据这一侧——同一趟车甲买了 1 号站到 4 号站乙还想买 3 号站到 8 号站这两张票能不能同时卖出去两张单子同时抢最后一个座位谁的扣减该失败这些问题课堂上讲隔离级别时一笔带过落到代码里就变成必答题。这份资源是围绕这个题目做的一套完整实现车次与车站维护、按乘车日期查票、售票、退票、改签管理员和售票员两种角色分开登录数据库侧带建表脚本和初始数据桌面端图形界面能直接演示。适合两类人一类是课设刚开题、需要一个能跑通的骨架往上改的同学另一类是功能写完了但想看看别人怎么划表、事务边界放哪一层的开发者。余票到底按趟存还是按区间段存是这套东西里最值得掰开看的地方。2. 技术栈与目录结构Swing 画界面JDBC 扛事务MySQL 存真相2.1 技术选型为什么课设级别选桌面端而不是 Web数据库系统课程设计的评分大头在 E-R 设计、范式、完整性约束、视图和存储过程不在前端框架有多新。常见做法是 Java Swing 拖控件两三个小时出界面JDBC 直连 MySQL 写 SQL换成后端框架加前端页面光搭环境、配跨域、写接口就能吃掉一半工期最后数据库那部分反而写得很薄。桌面端还有个隐性好处所有 SQL 都写在你眼皮底下答辩时老师问「这条余票是怎么算出来的」代码一翻就能指到行。版本组合我一般用 JDK 8 或 11、MySQL 8.0、mysql-connector-j-8.0.x.jar。这里有个高频翻车点MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver写成老的com.mysql.jdbc.Driver启动直接ClassNotFoundException连接串不带serverTimezone在某些机器上会抛时区识别失败。把连接工具类单独拎出来全局只用它取连接public final class DBUtil { // MySQL 8 驱动类名带 cj写老类名会 ClassNotFoundException private static final String DRIVER com.mysql.cj.jdbc.Driver; // characterEncoding 不写中文站名进库就是问号serverTimezone 不写部分机器直接连不上 private static final String URL jdbc:mysql://127.0.0.1:3306/train_ticket ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai allowPublicKeyRetrievaltrue; private static final String USER root; private static final String PWD 123456; // 交作业前改成读配置文件 static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(驱动没进 classpath, 检查 -cp 参数); } } /** 每次拿一条新连接调用方负责关service 层要手动控事务就靠它 */ public static Connection getConn() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } /** 关资源的顺序是 ResultSet - Statement - Connection倒着关最省心 */ public static void close(AutoCloseable... res) { for (AutoCloseable c : res) { if (c ! null) { try { c.close(); } catch (Exception ignored) { } } } } }参数逐个说清楚useUnicodetruecharacterEncodingutf8决定中文站名、乘客姓名能不能正确落库useSSLfalse是本机演示省掉证书握手serverTimezoneAsia/Shanghai解决DATETIME读写差 8 小时allowPublicKeyRetrievaltrue是 MySQL 8 默认认证插件下允许取公钥不加在某些环境会报认证失败。close用可变参数是为了少写三层 try-catch 嵌套课程设计代码量不大这样够用。2.2 目录结构与分层职责事务为什么必须放在 service 层分层不是摆样子是为了让「一个业务动作动了多张表」这件事有唯一入口。ui 只负责收参数、弹提示service 管事务的 begin/commit/rollbackdao 只拼 SQL、绑参数自己不决定提交。目录大概长这样train-ticket/ ├── sql/ │ ├── 01_schema.sql # 建库建表、索引、外键 │ ├── 02_init_data.sql # 车站、车次、站间段容量、初始账号 │ └── 03_view_proc.sql # 余票视图、退票存储过程答辩加分 ├── lib/ │ └── mysql-connector-j-8.0.33.jar ├── src/com/demo/ticket/ │ ├── App.java # main拉起登录窗口 │ ├── common/DBUtil.java │ ├── entity/ # Train / TicketOrder / Passenger / SysUser │ ├── dao/ # TrainDao / OrderDao / SegmentDao / UserDao │ ├── service/ # TicketService事务只在这一层开 │ └── ui/ # LoginFrame / TicketFrame / AdminFrame └── README.md关键在于 dao 方法签名统一带上Connection conn参数而不是内部自己DBUtil.getConn()。自己取连接意味着自己提交service 里那张订单表刚插完、库存表还没扣中间一断就是脏数据。血泪经验只要发现「两个操作明明在同一个方法里却没法一起回滚」九成是 dao 偷偷开了自己的连接。2.3 从零跑起来四条命令搞定建库、编译、启动第一步确认环境第二步导脚本第三步编译第四步运行。Windows 下find换成dir /s /b src\*.javaclasspath 分隔符是;不是:。# 1. 确认版本MySQL 建议 8.0.x java -version mysql --version # 2. 建库 灌初始数据密码换成自己机器的 mysql -uroot -p123456 sql/01_schema.sql mysql -uroot -p123456 train_ticket sql/02_init_data.sql # 3. 编译一定要带 -encoding UTF-8否则源码里的中文常量在 GBK 机器上直接报错 javac -encoding UTF-8 -cp lib/mysql-connector-j-8.0.33.jar -d out $(find src -name *.java) # 4. 启动主类名按自己包名改 java -cp out:lib/mysql-connector-j-8.0.33.jar com.demo.ticket.App几个容易漏的点-d out会按包名自动建目录别手动拷 class 文件运行时 classpath 必须同时包含编译输出目录和驱动 jar少一个就是NoClassDefFoundError或者驱动找不到。如果界面中文变方块是 JVM 字体回退问题把 Swing 全局字体设成「微软雅黑」或「宋体」即可跟数据库编码是两码事别混着排查。3. 数据库设计七张表把「区间余票」这件事说圆3.1 实体拆分与表职责这个系统里最容易设计错的是「座位/余票」这个实体。很多初稿会在车次表上挂一个remain_seat INT查票时直接读它扣减时减一看起来能跑但只要有人买中途站就全错同一趟车 1 号站到 4 号站卖出去一个座4 号站到 8 号站那个位置其实还空着可remain_seat已经减掉了。正确的建模是把一趟车按相邻车站切成若干「站间段」每段每类席别一行容量记录卖一张票就是给它覆盖到的所有段各加一次已售。表名作用关键字段说明station车站字典station_id,station_name站名唯一索引train车次主表train_no,start_station_id,end_station_id车次号做主键train_stop车次经停train_no,station_id,seq_no,arrive_timeseq_no是站序从 1 开始seat_segment站间段库存train_no,travel_date,seg_no,seat_type,capacity,sold库存的唯一真相sys_user账号user_id,username,role,salt,pwd_hash0 管理员 / 1 售票员ticket_order订单主表order_id,order_no,amount,status状态 0 待付 1 已付 2 已退 3 已改order_item订单明细order_id,passenger_name,id_card,seat_type,from_seq,to_seq一个乘客一行train_stop和seat_segment的区分要讲清楚前者描述「这趟车经过哪些站、第几站」是静态时刻数据后者描述「某天某段还剩多少」是动态库存数据必须带travel_date。把日期加进主键跨天查询才不会串。3.2 建表 SQL 与约束落点CREATE DATABASE IF NOT EXISTS train_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE train_ticket; CREATE TABLE station ( station_id INT PRIMARY KEY AUTO_INCREMENT, station_name VARCHAR(32) NOT NULL, city VARCHAR(32) NOT NULL, UNIQUE KEY uk_station (station_name) ) ENGINEInnoDB; CREATE TABLE train ( train_no VARCHAR(8) PRIMARY KEY, -- G1234 / K886 start_station_id INT NOT NULL, end_station_id INT NOT NULL, depart_time TIME NOT NULL, arrive_time TIME NOT NULL, CONSTRAINT fk_train_s FOREIGN KEY (start_station_id) REFERENCES station(station_id), CONSTRAINT fk_train_e FOREIGN KEY (end_station_id) REFERENCES station(station_id) ) ENGINEInnoDB; CREATE TABLE train_stop ( train_no VARCHAR(8) NOT NULL, station_id INT NOT NULL, seq_no INT NOT NULL, -- 站序1 表示始发 arrive_time TIME NULL, -- 始发站为空 depart_time TIME NULL, -- 终到站为空 PRIMARY KEY (train_no, seq_no), UNIQUE KEY uk_stop (train_no, station_id), CONSTRAINT fk_stop_train FOREIGN KEY (train_no) REFERENCES train(train_no) ) ENGINEInnoDB; CREATE TABLE seat_segment ( train_no VARCHAR(8) NOT NULL, travel_date DATE NOT NULL, seg_no INT NOT NULL, -- 1 表示第 1 站到第 2 站 seat_type TINYINT NOT NULL, -- 1 商务 2 一等 3 二等 capacity INT NOT NULL, sold INT NOT NULL DEFAULT 0, PRIMARY KEY (train_no, travel_date, seg_no, seat_type), CONSTRAINT ck_seg CHECK (sold 0 AND sold capacity) ) ENGINEInnoDB; CREATE TABLE ticket_order ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, train_no VARCHAR(8) NOT NULL, travel_date DATE NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_time (user_id, created_at) ) ENGINEInnoDB;几点说明。uk_stop保证同一趟车不会重复登记同一个站ck_seg这条 CHECK 是给答辩准备的口实——超卖在数据库层就被挡住而不是只靠 Java 里 if 判断。travel_date放进库存主键是因为同一车次不同天的余票完全独立。idx_user_time是为「我的订单」列表准备的按用户和时间倒序查没有这个索引数据量一上来就全表扫。3.3 余票查询与扣减一条 UPDATE 解决超卖查某天某车次从 3 号站到 7 号站还剩多少二等座就是把覆盖到的段取最小值-- 3 号站上车、7 号站下车覆盖 seg_no 3..6 共 4 段 SELECT MIN(capacity - sold) AS remain FROM seat_segment WHERE train_no G1234 AND travel_date 2025-06-01 AND seat_type 3 AND seg_no 3 AND seg_no 7;扣减不要写成「先 select 判断再 update」那中间有窗口期两个并发请求会双双通过判断然后一起减。正确写法是把条件塞进 UPDATE 的 WHERE-- 一次把覆盖到的所有段各加 1任何一段已满该行不满足 sold capacity就不会被更新 UPDATE seat_segment SET sold sold 1 WHERE train_no ? AND travel_date ? AND seat_type ? AND seg_no ? AND seg_no ? AND sold capacity;Java 侧拿executeUpdate()的返回值做判断期望影响行数等于「段数 × 乘客数」少了就说明有段已满直接rollback()并把「余票不足」返回给界面。这种做法靠 InnoDB 的行锁保证同一行不会被两个事务同时加天然避免了超卖也不用显式写SELECT ... FOR UPDATE。4. 核心业务链路一次购票从点击到落库的完整链路4.1 登录与角色分流口令别存明文课设里十有八九是明文存密码答辩老师顺手一问就尴尬。加个盐做 SHA-256 成本极低注册时生成随机盐、存salt和pwd_hash两列登录时用同样方式算一遍比对。// UserDao.login返回 null 表示用户名或密码不对对外统一提示不暴露是哪一项错 public SysUser login(String username, String rawPwd) throws SQLException { String sql SELECT user_id, username, real_name, role, salt, pwd_hash FROM sys_user WHERE username ? AND status 1; try (Connection conn DBUtil.getConn(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (!rs.next()) return null; String expect rs.getString(pwd_hash); String actual DigestUtil.sha256Hex(rs.getString(salt) rawPwd); if (!actual.equalsIgnoreCase(expect)) return null; SysUser u new SysUser(); u.setUserId(rs.getInt(user_id)); u.setRole(rs.getInt(role)); // 0 管理员, 1 售票员 u.setRealName(rs.getString(real_name)); return u; } } }参数说明ps.setString(1, username)是唯一占位符status 1过滤停用账号避免删号只能物理删。界面侧拿到role直接分窗口别在菜单里做权限判断——菜单能隐藏按钮事件可藏不住。4.2 车次查询一次 JOIN 把最低余票带出来查询要解决的是「输入出发站、到达站、日期、席别列出可买的车次和余票」。用经停表自连接判断方向再关联库存表取最小余票String sql SELECT t.train_no, s1.station_name AS from_name, s2.station_name AS to_name, t.depart_time, MIN(g.capacity - g.sold) AS remain FROM train t JOIN train_stop fs ON fs.train_no t.train_no AND fs.station_id ? JOIN train_stop ts ON ts.train_no t.train_no AND ts.station_id ? JOIN station s1 ON s1.station_id fs.station_id JOIN station s2 ON s2.station_id ts.station_id JOIN seat_segment g ON g.train_no t.train_no AND g.travel_date ? AND g.seat_type ? AND g.seg_no fs.seq_no AND g.seg_no ts.seq_no WHERE fs.seq_no ts.seq_no // 方向判断上车站必须在下车站前面 GROUP BY t.train_no, s1.station_name, s2.station_name, t.depart_time;四个占位符依次是出发站 id、到达站 id、乘车日期、席别。fs.seq_no ts.seq_no这行是核心少了它反方向的车次也会被查出来。MIN(...)配合GROUP BY得到的是「整段区间内最紧张的那一段还剩几张」跟真实售票口径一致。4.3 下单事务四条 SQL 要么全成要么全不成下单要动库存、订单主表、订单明细三处必须一个事务。事务的开关放在 serviceConnection一路传给 daopublic String buy(BuyRequest req) throws SQLException { Connection conn null; try { conn DBUtil.getConn(); conn.setAutoCommit(false); // 事务起点 int segCount req.getToSeq() - req.getFromSeq(); int expect segCount * req.getPassengers().size(); int affected segmentDao.deduct(conn, req); // 带 sold capacity 的 UPDATE if (affected ! expect) { conn.rollback(); return 余票不足请刷新后重试; // 有段已满整笔作废 } long orderId orderDao.insert(conn, req); // 订单主表 orderItemDao.batchInsert(conn, orderId, req.getPassengers()); // 明细 conn.commit(); return null; // null 表示成功 } catch (SQLException e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); // 回池前恢复默认避免污染 DBUtil.close(conn); } } }逐段说明setAutoCommit(false)之后所有 DML 都在同一事务里affected ! expect这行判断是防超卖的最后一道闸比在 Java 里先查余票再决定可靠得多finally里恢复autoCommit是习惯动作将来换成连接池不恢复会带着脏状态回到池里。BuyRequest里至少要带车次号、乘车日期、席别、上下车站序号和乘客列表。4.4 退票与改签用影响行数做幂等退票最容易出的问题是重复点击导致库存加两次。解决办法是把状态判断写进 UPDATE 的 WHERE靠影响行数判断这次请求是不是「真的改变了状态」public String refund(long orderId) throws SQLException { Connection conn null; try { conn DBUtil.getConn(); conn.setAutoCommit(false); // 只有 status1已支付才能退成 2已退重复调用第二次影响 0 行 int n orderDao.changeStatus(conn, orderId, 2, 1); if (n 0) { conn.rollback(); return 订单状态已变更请刷新后重试; } for (OrderItem it : orderItemDao.listByOrder(conn, orderId)) { segmentDao.release(conn, it); // 对应段 sold sold - 1 } conn.commit(); return null; } catch (SQLException e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); DBUtil.close(conn); } } }改签本质是「退旧 买新」但绝不能调两次独立方法那样旧票退了新票没买成乘客就亏了。做法是在同一个事务里先release旧段、再deduct新段、最后把订单状态改成 3 并写一条新订单任何一步失败整体回滚。5. 避坑与排查课设现场最容易翻车的五个点5.1 中文站名变问号编码三处都得对齐现象界面上正常进数据库查出来全是???或者反过来库里有中文、界面显示方块。原因编码在三个地方各自为政——建库时的CHARACTER SET、JDBC 连接串的characterEncoding、javac编译时的-encoding。漏一处就断链而且症状各不相同容易误判成字体问题白折腾半天。解决建库统一utf8mb4连接串固定带useUnicodetruecharacterEncodingutf8编译命令永远带-encoding UTF-8。排查时直接在 MySQL 命令行SELECT station_name FROM station LIMIT 5;看落库结果先分清是「写进去就错了」还是「只是界面显示错」。5.2 两个人同时买最后一张票居然都成功了现象并发测试跑两个线程抢同一段最后一个座位两笔订单全部落库余票变成 -1。原因dao 里每个方法自己DBUtil.getConn()各自commitservice 层的rollback根本管不到已经提交的那部分或者扣减写成了「先 select 余票if 大于 0 再 update」中间有窗口期。解决Connection从 service 传进 dao只在一处setAutoCommit(false)扣减 SQL 必须带AND sold capacity条件用影响行数判断成败。改完再跑一遍并发应该只有一个线程成功。5.3 启动就报 ClassNotFound驱动和 classpath 总有一个不对现象java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver或者No suitable driver found for jdbc:mysql://...。原因前者是驱动 jar 没进运行时 classpath或者用了 MySQL 8 却写老类名com.mysql.jdbc.Driver后者是连接串拼错少冒号、多了空格导致DriverManager匹配不上。解决确认-cp里同时有out和驱动 jarLinux 用:、Windows 用;分隔驱动类名以 MySQL 8 为准写com.mysql.cj.jdbc.Driver。用java -cp ... -verbose:class | grep mysql能看到驱动到底有没有被加载。5.4 插入订单报 1452外键顺序和初始化数据对不上现象Cannot add or update a child row: a foreign key constraint fails栈顶指向ticket_order的 insert。原因02_init_data.sql里车次的start_station_id是手写的常量跟station表自增出来的 id 不一致或者导入脚本时先跑了数据脚本、后跑建表脚本中间失败过一半。解决初始化脚本一律先插station再用SELECT station_id FROM station WHERE station_name...拿 id别写死数字。实在要临时跳过校验SET FOREIGN_KEY_CHECKS0只用于本地救急答辩演示前务必关掉并重新导一遍数据。5.5 日期差一天java.util.Date和LocalDate混着用现象买的是 6 月 1 日的票查出来落在 5 月 31 日或者报Cannot convert LocalDate to Date。原因实体里用java.time.LocalDate往PreparedStatement塞的时候直接setObject或者强转MySQL 驱动对DATE类型的处理要显式转换另一头ResultSet.getDate()返回的是java.sql.Date转LocalDate忘了toLocalDate()。解决定一条规矩——业务实体统一用LocalDate进出 JDBC 时显式转换写用ps.setDate(i, java.sql.Date.valueOf(localDate))读用rs.getDate(travel_date).toLocalDate()。全项目搜一遍new java.util.Date()多余的都清掉。6. 二次开发把能跑的课设改成能讲的课设功能跑通只是及格线想让它经得起追问往三个方向改性价比最高。第一把余票查询固化成视图把退票封成存储过程。视图v_train_remain把车次、区间和最小余票拼在一起界面直接SELECT * FROM v_train_remain WHERE ...存储过程sp_refund(IN p_order_id BIGINT, OUT p_code INT)把状态校验和库存回升包在START TRANSACTION ... COMMIT里。答辩时打开03_view_proc.sql讲「事务在数据库侧也能保证」比讲 Java 代码更贴课程主题。第二从「卖余票数」升级到「卖座位号」。加一张seat表车次、日期、车厢、座位号、席别订单明细里记seat_no扣减从改计数变成插座位占用行靠UNIQUE(train_no, travel_date, seat_no)天然防重。改造时只需要改segmentDao.deduct一个方法因为它已经是事务里唯一碰库存的入口——这也是前面坚持把事务收在 service 层的好处。第三把直连换成连接池。现在是每次DriverManager.getConnection演示时点得快一点就能看到明显卡顿。引入 HikariCPDBUtil.getConn()改成从HikariDataSource取其余代码一行不动。顺手把账号密码挪到db.properties代码里不再出现明文。改完怎么验证没改坏写个最小并发用例比手点一百次可靠// 两个线程卡在同一刻抢最后一张票预期只有一个返回 null成功 ExecutorService pool Executors.newFixedThreadPool(2); CountDownLatch gun new CountDownLatch(1); for (int i 0; i 2; i) { pool.submit(() - { gun.await(); // 发令枪让两个线程尽量同时进 service try { return service.buy(req); } catch (Exception e) { return e.getMessage(); } }); } gun.countDown(); pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); // 断言成功次数 1且库里该段 sold 恰好等于 capacity跑完别只看返回值一定去库里SELECT sold, capacity FROM seat_segment WHERE ...核一遍返回值对但数据错了的情况我见过不止一次。这套题我前后帮人看过好几遍最深的教训是先想清楚「哪个字段代表真相」再动手画界面。库存真相就只有seat_segment那张表所有扣减、退回、查询都必须绕它转一旦在别处又存了一份余票数字迟早对不上。从那以后我每次开新项目都强制先把「唯一真相表」和「事务入口方法」这两样写在纸上再敲第一行代码。希望帮到你。本文还有配套的精品资源点击获取