简介这是一套面向计算机专业本科生与Java初学者的数据库课程设计实战项目聚焦机票预订业务场景完整覆盖用户登录、航班查询、座位选择、订单生成等核心流程助力学习者打通Java GUI开发、MySQL数据库设计与JDBC交互的全链路能力。资源包共37个文件含7个核心Java源码如Airplane.java、Ticket.java、7个编译后class文件、15个XML配置及SQL脚本含test.sql建表语句与初始化数据辅以说明书.doc和IDEA工程配置文件整体仅208KB轻量易部署。已有8302人学习下载说明其结构清晰、运行稳定、适配性强。读者可直接导入IDE运行获得可交互的Swing/FX界面系统说明书详述模块职责与数据库第三范式设计逻辑SQL脚本与Java类一一对应便于理解DAO层实现与事务控制要点是掌握JavaMySQL工程化开发的高性价比入门范例。 做机票预订系统这个项目我前前后后带过不少学生也帮朋友优化过几版。说实话这个选题在JavaWeb里属于经典中的经典但绝大多数人做出来的东西都停留在能跑就行的层面控制台里打印个订单页面上查个航班连登录都没做完整。面试官一问你的机票余票是怎么扣的支支吾吾答不上来。这篇文章我想跟你聊的不是怎么复制一段CRUD代码而是把一套真正能写在简历上、敢拿给面试官看的机票预订系统从选型到数据库设计、从后端核心链路到前端联调、再到部署避坑完整走一遍。我选的方案是SpringBoot MyBatis-Plus MySQL Vue3前后端分离。这套组合在目前的Java岗位需求里覆盖面很广也是最近几年项目实战类话题里出现频率最高的技术栈。下面每一块都是实操过的内容不是教材上抄来的概念。1. 做机票预订系统之前先想清楚这三层问题1.1 为什么选SpringBoot Vue而不是传统JSP很多人一听到机票预订系统第一反应是JSP Servlet JDBC因为学校课程设计就是这么教的。但如果你去翻一下招聘网站上关于Java开发工程师的要求会发现现在几乎没有公司还在用JSP做新项目。SpringBoot Vue前后端分离已经是主流连很多传统行业的信息化项目都在往这个方向迁。选SpringBoot的理由很直接内嵌Tomcat不用装额外的服务器自动配置省掉一堆XML结合MyBatis-Plus操作数据库几乎不用写SQL。对做实战项目来说它能让你把时间花在业务逻辑上而不是耗在环境配置里。Vue则解决了前端开发效率的问题组件化写法比JSP里嵌Java代码清晰太多数据响应式更新也比手动操作DOM舒服得多。这里有个容易忽略的点如果你是为了毕业设计或课程设计选这套技术栈等于提前预习了企业开发模式如果你是为了面试面试官看到SpringBoot Vue MySQL的组合至少愿意多问你几句业务细节而不是两句就把你打发走。1.2 一套能上简历的功能边界怎么划机票预订系统听起来简单但功能边界如果划不清很容易做成一团乱麻。我建议按用户端 管理端两条线来切这也是大多数真实电商系统的基本形态。用户端要有的功能注册登录、航班查询、下单支付、订单管理、乘机人管理。这里支付不用真的对接支付宝微信做成模拟支付就行但下单后24小时内未支付的订单要能自动取消这个逻辑必须有。管理端要有的功能航班信息维护、订单查看与出票操作、乘客信息核对。管理员的权限和普通用户要做区分至少登录接口要能识别出你是以什么身份进来的。我个人还建议加一个城市管理虽然业务上不复杂但前端做航班查询的时候需要一个城市下拉列表数据源直接在代码里写死城市列表太low了搞成一张表才是工程化思维。功能边界划清楚之后数据库表结构、后端接口、前端页面才能一一对应起来不会做着做着就跑偏。1.3 环境准备JDK、MySQL、Node这三样怎么配最省心如果你本机还没搭过Java开发环境我建议按下面的顺序一次配好不要零散地装东西。JDK我推荐用1.8虽然现在已经出到17、21了但很多公司生产环境还在用JDK8SpringBoot 2.x系列对JDK8的支持也最稳定用起来不会踩版本坑。安装完之后记得配JAVA_HOME环境变量在命令行执行java -version能看到版本号就算成功。这个步骤属于基础中的基础但几乎每个初学者都会卡在环境变量配置上。MySQL我建议直接用MySQL 8.0虽然5.7在旧项目中依然常见但8.0在性能、JSON支持、窗口函数上都好太多从零开始的项目没必要用老版本。安装时要注意MySQL 8.0默认的认证插件是caching_sha2_password如果JDBC版本太老会连接不上所以后面引入依赖时尽量用mysql-connector-java8.0以上版本。前端部分需要装Node.jsVue3的构建工具Vite要求Node版本不能太低建议装16以上。装完Node之后npm会自带不需要额外装。用npm config set registry https://registry.npmmirror.com把镜像源切到国内否则装依赖的时候那个速度能让你怀疑人生。这三样装完之后可以先用spring initializr快速生成一个空SpringBoot项目确认能在http://localhost:8080访问到默认接口再往下做业务。2. 数据库设计机票预订的业务骨架在这里定生死2.1 六张核心表谁在系统里扮演什么角色数据库设计是整个系统里最值得花时间的一步。很多新手上来就建一张ticket表把航班信息、价格、余票全塞进去后面做订单的时候发现数据对不上只能推倒重来。我带的项目基本都按下面六张表来建模你可以根据自己的需要微调但核心结构不要动。第一张是user用户表。字段包括id主键、username用户名、password密码记得用加密存储不能存明文、real_name真实姓名、id_card身份证号、phone手机号、role角色0表示普通用户1表示管理员、create_time创建时间。第二张是city城市表。字段很简单id、city_name城市名。这张表主要给前端下拉列表用也让航班表里的城市字段有据可查而不是随手写的字符串。第三张是flight航班表这是核心中的核心。字段包括id、flight_no航班号比如CA1834、departure_city出发城市、arrival_city到达城市、departure_airport出发机场、arrival_airport到达机场、departure_time出发时间、arrival_time到达时间、economy_price经济舱价格、business_price商务舱价格、economy_stock经济舱余票数、business_stock商务舱余票数、status状态0停飞1正常。第四张是orders订单表。字段包括id、order_no订单编号全局唯一、user_id下单用户ID、flight_id关联航班ID、passenger_count乘机人数、total_price订单总金额、status状态0待支付1已支付待出票2已出票3已取消4已退票、create_time下单时间、pay_time支付时间。第五张是passenger乘机人表。字段包括id、order_id所属订单ID、name乘机人姓名、id_card身份证号、phone联系电话。为什么乘客要单独建表而不是把名字塞进订单表因为一个订单可以买多张票比如一家三口出行三个乘客对应一个订单一对多关系天然存在开一张子表是最规范的建模方式。第六张是operation_log操作日志表这个表是可选的但我强烈建议加。字段包括id、operator_id操作人ID、action操作类型、detail操作详情、create_time。别小看它后面你排查线上问题、写面试项目经验的时候这张表能成亮点。2.2 余票字段设计为什么必须用整型而不是字符串余票字段设计上有一个很常见的错误有人为了在管理端显示经济舱余5张/共50张这种效果直接存一个字符串比如5/50前端拿到数据再拆分。这种设计在真实项目里绝对不允许因为一旦你需要对余票做数学运算扣减、回补、统计字符串会让你寸步难行。正确的做法是economy_stock和business_stock都用int类型只存当前剩余票数。航班总票数可以用一个固定常量或者通过另一个字段economy_total来记录这样管理端想显示剩余/总量时两个整型字段一拼就行。这里还有一个关键细节economy_stock字段要加索引吗我的建议是要加。因为用户下单时最核心的查询条件是出发城市、到达城市和日期而航班表的查询量远大于写操作在高并发下单场景下余票字段被频繁读取和条件更新加上索引能明显提升查询效率。MySQL里用ALTER TABLE flight ADD INDEX idx_stock (economy_stock);就能搞定。2.3 订单状态流转一张状态机把需求钉死订单状态字段我用了四个数字0待支付、1已支付待出票、2已出票、3已取消、4已退票。这五个状态之间的关系在动手写业务代码之前一定要想清楚否则后面会出现订单出现各种神奇状态的情况。正常链路是用户提交订单系统创建订单记录状态为0待支付同时锁定航班余票扣减库存。用户模拟支付成功后订单状态从0变为1。管理端看到已支付订单核验乘机人信息后点击出票状态从1变为2。异常链路有两条一是用户下单后一直没支付超过24小时系统定时任务扫描到超时订单先把订单状态改成3已取消再把之前锁定扣减的余票回补到航班上。二是用户支付后想退票此时订单状态从2或1变为4已退款同时余票也要回补这里需要区分退票是管理员操作还是用户自助操作边界要小心。为什么要先把状态机设计清楚因为订单状态变化直接决定了哪些库存操作需要执行而库存操作是最容易出错、最容易带来业务损失的地方。我在实际做这个项目时就是在状态流转上吃过亏一开始没退款状态用户说退票我直接把订单删了结果余票没回补航班余票越来越不准确。后来改成状态机驱动每种状态变化都对应一个明确的操作方法问题就解决了。3. 后端核心链路登录鉴权、航班查询、下单扣票3.1 登录注册与JWT鉴权登录这块如果只做用户名密码比对那这个项目写出来也没多大价值。我建议接入JWTJSON Web Token做无状态鉴权用户登录成功后后端生成一个token返回给前端前端保存token后续每次请求在请求头里带上Authorization: Bearer token后端通过拦截器验证token有效性再从token里解析出用户ID和角色。JWT的原理说白了就是把一段用户信息用签名加密后给前端前端下次带着它回来后端验签通过即信任。好处是不用像Session那样在服务端存状态对前后端分离的架构非常友好。具体实现时用io.jsonwebtoken:jjwt这个库生成token时的payload里塞userId和role两个字段过期时间设置为24小时。拦截器里的逻辑要写全先从请求头取token取不到直接返回401解析token异常过期或伪造也返回401解析成功后把userId放到ThreadLocal或请求attribute里方便Controller里直接用。有一个坑是OPTIONS请求跨域预检请求通常不带token拦截器要放行这种请求否则前端调用接口时在浏览器里就会报跨域错误。注册功能相对简单前端传用户名、密码、手机号后端先查用户名是否已存在不存在则用BCryptPasswordEncoder加密密码再插入用户表。注意密码一定不能明文入库这是安全红线哪怕只是个人项目也不能破例。3.2 航班查询条件组合SQL是第一个门槛航班查询是用户使用频率最高的功能接口设计成POST/api/flight/search请求体传departureCity、arrivalCity、departureDate三个条件。这里有一个细节用户选的是日期但航班表存的是完整的datetime类型比如2025-06-10 08:30:00。查询时不能直接用等于而是要用范围查询——出发时间大于等于当天零点小于等于当天23点59分59秒。用MyBatis-Plus的LambdaQueryWrapper可以这样写LambdaQueryWrapperFlight wrapper new LambdaQueryWrapper(); wrapper.eq(Flight::getDepartureCity, req.getDepartureCity()) .eq(Flight::getArrivalCity, req.getArrivalCity()) .between(Flight::getDepartureTime, req.getDepartureDate() 00:00:00, req.getDepartureDate() 23:59:59) .eq(Flight::getStatus, 1) .orderByAsc(Flight::getDepartureTime);如果查询条件为空则默认查当天所有航班。这里给一个建议条件组合查询最好用MyBatis-Plus的LambdaQueryWrapper代码可读性高且不会出现SQL字符串拼接的注入风险。如果你用XML写动态SQL也行但既然是SpringBoot项目能用框架简化就别手写复杂XML。查询结果返回到前端时建议只返回页面需要的字段比如航班号、起降城市、起降时间、经济舱价格、商务舱价格、余票数量。有些项目直接把整个实体类序列化返回把status、createTime这些无关字段也暴露出去既浪费流量又不安全不是一个工程化的做法。3.3 下单扣票并发场景下的超卖防线下单是整个系统里技术含量最高的一环没有之一。你在面试时只要能把防超卖这件事讲清楚这个项目就能立住。什么是超卖就是两个人同时下单系统里只剩1张票但两个人都支付成功了。这在实际业务里是绝对不允许发生的。防超卖的思路不是把扣余票的代码写在if (stock 0)判断里就完了因为并发情况下两个线程可能同时通过判断然后同时执行扣减导致库存变成负数。正确的做法是让扣减操作本身具备原子性我放在事务里执行Transactional(rollbackFor Exception.class) public Order createOrder(OrderCreateReq req) { // 1. 查航班 Flight flight flightMapper.selectById(req.getFlightId()); if (flight null || flight.getStatus() ! 1) { throw new BizException(航班不存在或已停飞); } // 2. 扣减余票条件更新MySQL的行锁保证原子性 int rows flightMapper.deductStock(req.getFlightId(), req.getPassengerCount()); if (rows 0) { throw new BizException(余票不足); } // 3. 创建订单 // 4. 插入乘机人 return order; }关键在第二步deductStock对应的SQL是UPDATE flight SET economy_stock economy_stock - #{count} WHERE id #{flightId} AND economy_stock #{count}这条SQL在MySQL里执行时会对命中的行加行锁第二个事务到来时会被阻塞第一个事务提交后再执行。如果更新影响行数为0说明当前余票不足直接抛业务异常事务回滚。这样做比select if判断再加update的方式安全得多。另外订单号生成不能用数据库自增ID因为自增ID有规律容易被竞争对手根据号段推算订单量。我建议用时间戳 随机数 用户ID后四位拼接保证唯一性即可。如果要求更高可以用雪花算法但在这个项目里时间戳方案已经完全够用。4. 订单定时失效、模拟出票与后台管理4.1 超时未支付自动取消定时任务的正确打开方式用户下单后如果一直不支付库存一直被占着航班可售票数就会越来越少所以必须有一个定时任务来处理超时订单。SpringBoot里用Scheduled注解就能实现定时任务在启动类上加上EnableScheduling然后写一个定时方法每分钟扫描一次待支付且创建时间超过24小时的订单。Scheduled(cron 0 0/1 * * * ?) public void cancelTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusHours(24); ListOrders timeoutOrders ordersMapper.selectList( new LambdaQueryWrapperOrders() .eq(Orders::getStatus, 0) .lt(Orders::getCreateTime, deadline) ); for (Orders order : timeoutOrders) { // 每个订单单独开启新事务处理避免一个失败影响全部 handleCancelOrder(order.getId()); } }这里有几个实现上面必须注意的点。第一扫描时只查状态为待支付且创建时间小于截止时间的订单不能把所有订单捞出来然后在内存里判断时间数据量一大就扛不住。第二取消订单时要同时回补余票这个操作要保证在同一事务里否则出现订单取消了但余票没加回来航班可售数就再也对不上账了。第三处理每个订单时最好单独开事务不要把整个扫描循环包在一个大事务里否则一个订单处理失败会导致后面全部阻塞。如果你想在项目里更进一步可以把这段逻辑放在handleCancelOrder方法上写一个独立的事务传播行为或者用TransactionTemplate手动控制这样能体现你对事务边界的理解。面试时讲到这里比单纯说我用了Scheduled要有说服力得多。4.2 模拟出票与订单状态机驱动出票这个动作在真实业务里是要跟航空公司GDS系统对接的但我们做项目模拟就可以了。管理端在订单列表里看到已支付状态状态1的订单点击出票按钮后端把订单状态从1改为2并在操作日志表里记录一条出票操作。出票接口的权限要控制好只允许管理员角色访问。这里又回到JWT的另一个用途不仅验证你是谁还要验证你能干什么。在Controller上加一个RequireAdmin这样的自定义注解或者在拦截器里判断role 1二选一都可以。我建议在拦截器里做因为这样不必在业务代码里反复写权限判断。订单状态变化的时候最好使用状态机驱动不推荐在业务代码里乱七八糟地直接order.setStatus(2)。可以定义一个枚举类OrderStatusEnum把每个状态转移条件、前后置动作都封装进去。比如从已支付到已出票前置条件是当前状态必须为1后置动作是写日志。这样做的好处是状态流转逻辑集中管理后续加新功能比如自动出票时只需要在状态机里加一条规则不用到处找散落的setStatus代码。4.3 管理端航班管理增删改查并不简单管理端的航班管理看起来就是常规的增删改查但有一个细节值得展开航班修改和删除时要考虑到已存在订单的航班信息不能被随意改动。如果一份订单已经出票管理员把航班时间改了乘客按原时间到机场发现航班没了这就会出大问题。保守方案是航班被下单锁定后不允许修改起飞时间和航班号只允许修改价格和余票。实现上可以用version乐观锁字段修改前先检查当前版本号版本不一致就提示该航班已被他人修改请刷新后再试。这个点麻雀虽小五脏俱全用上乐观锁能让项目显得专业不少。删除航班也不能物理删除要用逻辑删除。我给航班表加了一个deleted字段0未删除1已删除MyBatis-Plus里加一个TableLogic注解删除操作就自动变成更新操作历史订单关联的航班数据不会丢查询时也会自动带上deleted 0条件。别小看这个细节真实企业项目里物理删除是极其谨慎的涉及财务和订单的业务数据都不可能硬删。5. 前端Vue工程页面结构、接口对接、跨域5.1 页面规划与路由设计后端接口设计好了前端这块不擅长Java的人往往会拖后腿。但是Vue3 Element Plus这套组合应对机票预订系统这种规模的页面已经完全够用。我的建议是提前把路由规划好不要边写边加页面。用户端页面包括首页航班查询大表单、航班列表页、订单确认页、模拟支付页、个人订单列表页、登录页、注册页。管理端页面包括管理后台首页、航班管理页、订单管理页。路由设计时把用户端和管理端分开管理端所有路由都套一层requireAdmin路由守卫没有管理员token就跳回登录页。用Vue Router的createRouter创建路由加一个全局前置守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next(/login); } else { next(); } });页面组件统一放在views目录下公共组件放components目录。个人经验是这些小项目不必上Pinia状态管理库跨页面传个用户ID、token用localStorage就够了把状态管理引入进来反而增加学习成本。如果以后要扩展购物车这种复杂状态再考虑上Pinia也不迟。5.2 axios封装与跨域处理前后端分离开发时前端跑在8081端口后端跑在8080端口浏览器直接请求会报跨域错误。解决方法有三种后端配置CORS、前端用Vite代理、部署时用Nginx反向代理。开发阶段我推荐用Vite代理因为你只需要在vite.config.js里加一段配置就能解决后端代码完全不用动server: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }axios请求统一封装在utils/request.js里设置baseURL为/api请求拦截器里读取localStorage中的token拼到请求头响应拦截器里统一处理401跳转登录、500弹出错误提示。这个封装是一次性的但后面每个页面调接口时都用它代码会干净很多request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; });5.3 核心交互航班查询列表和下单弹窗航班查询页面是整个前端最核心的交互。用Element Plus的表单组件收集三个查询条件点击查询按钮后调用/api/flight/search接口返回结果用el-table展示。表格里每一行放一个预订按钮点击后弹出下单对话框。下单对话框里要处理几件事先显示航班基本信息不可编辑再让用户输入乘机人姓名、身份证号、手机号这里可以做简单的正则校验身份证号18位手机号11位。接着根据乘机人数量联动计算总价价格从后端返回的航班接口里取不能在前端写死。提交订单成功后跳转到模拟支付页页面上显示订单号、订单金额、航班信息放一个模拟支付成功的按钮。点击后调用/api/order/pay接口后端把订单状态从待支付改成已支付。这个交互流程走通后整个系统的用户核心链路就完整了。6. 打包部署与避坑记录从本地跑通到交付6.1 后端打包与启动本地开发跑通了项目还要能打包部署给别人演示这一步很多初学者会卡住。后端用Maven打包在项目根目录执行mvn clean package -DskipTests注意-DskipTests一定要加否则单元测试跑挂了你甚至都不知道为什么。打包完成后target目录下会生成一个xxx.jar文件执行java -jar xxx.jar就能启动。如果你想把部署过程做得更专业一点可以参考下面这个顺序先检查application.yml里的数据库连接配置把localhost改成目标服务器的IP或域名账号密码也要改成生产环境的。确认MySQL中已经建好数据库并且执行过初始化SQL脚本表结构和基础数据都要在。用java -jar启动后先访问后端的健康检查接口或登录接口确认后端服务正常再部署前端。生产环境下给SpringBoot配置一个正式的环境变量spring.profiles.activeprod区分开发环境和生产环境配置。6.2 前端构建与Nginx配置前端写完要发布不能像开发时那样靠Vite代理跑。执行npm run buildVite会生成一个dist目录里面是纯静态文件。把这些文件放到Nginx的html目录下同时Nginx配置一段反向代理把/api开头的请求转发到后端Java服务。Nginx配置片段server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里有一个前端路由的经典坑如果你用Vue Router的history模式直接访问http://your-domain.com/orders会404因为Nginx找不到orders这个物理文件。解决方法是上面的try_files $uri $uri/ /index.html;让所有前端路由都回到index.html再由Vue Router接管页面渲染。如果不想处理这个也可以用hash模式URL会带一个#看起来没那么优雅但省事。6.3 我踩过的四个典型坑第一个坑是MySQL 8.0驱动类名变了。MySQL 5.7时代用的是com.mysql.jdbc.Driver8.0变成了com.mysql.cj.jdbc.Driver。如果按照旧教程配置启动时会报ClassNotFoundException。你还需要在pom.xml里显式指定Connector/J的版本号不能只写runtimeOnly mysql:mysql-connector-java否则SpringBoot可能引入一个跟你MySQL版本不兼容的驱动。第二个坑是时区问题。MySQL 8.0默认时区是UTC而中国用户用的是Asia/Shanghai如果你的JDBC连接串里没加serverTimezoneAsia/Shanghai从数据库里查出来的时间会比本地时间少8个小时。航班时间差8小时你查出来的航班全是错的。解决方法是连接串改成jdbc:mysql://localhost:3306/air_ticket?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai第三个坑是Maven打包测试跳不过。如果你在项目里写了单元测试直接mvn package时测试类如果报错比如连不上测试数据库打包就会失败。所以我每次都在命令后面加-DskipTests先保证打包能出jar包。测试单独运行排查问题不阻塞交付。第四个坑是前端npm run build报语法错误或内存溢出。这个多出现在老版本Node上Vue3 Vite要求Node 16Node 14有时会报各种奇怪问题。遇到这种问题先node -v看一下版本不够就升。还有一次我遇到构建内存溢出那是因为在低配置服务器上构建需要调整Node内存NODE_OPTIONS--max-old-space-size4096 npm run build做这个项目最大的体会是一个看似普通的业务系统真正落地时会牵扯出很多平时不接触的边缘问题。环境配置、数据库设计、并发安全、前后端联调、部署上线每一个环节都值得较真。如果你正在做类似的项目或者准备拿这个题目去面试希望这部分内容能帮你少走点弯路。尤其是下单扣余票那段别嫌简单那才是这个项目真正值钱的地方。本文还有配套的精品资源点击获取