ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue实现汽车票网上预订与管理系统

SpringBoot+Vue实现汽车票网上预订与管理系统 做客运站信息化这行也有年头了市面上的汽车票系统大多是给大型客运集团用的商业化产品个头大、费用高、部署还特别重。中小型客运站、校办运输队或者毕业设计、课设这种场景往往是既想要一套能完整跑通的在线预订管理流程又不想背太重的架构包袱。所以我基于SpringBootVueMySQLMyBatis这套组合完整实现了汽车票网上预订与管理系统前后端分离、权限分级、核心业务全部落地。下载源码、配置完环境就能直接跑起来拿来改一改也能嵌进更复杂的业务。这套系统解决的实际问题很明确把窗口排队买票搬到线上乘客自主查询班次、选座下单、订单管理管理员后台维护车次信息、处理订单、管理用户。技术栈非常贴合国内主流Java开发路线SpringBoot负责后端接口Vue负责页面交互MySQL存数据MyBatis管SQL一层一层清爽利落。如果你是Java学习者、想快速掌握前后端分离项目全流程或者手头正需要一个能演示、能答辩、能二次开发的车票预订系统这篇内容可以帮你少走很多弯路。1. 项目整体思路与架构设计1.1 业务痛点与系统定位汽车客运站的日常工作管车、管人、管票三件事看着简单串起来其实很琐碎。传统窗口售票模式下乘客必须到站查询班次、排队购票车站得配专门的票务人员售票数据靠手工登记或简单的Excel汇总到了节假日高峰期窗口排队动辄半小时起步乘客体验差车站统计压力也大。这套系统在设计时我把它拆成两条业务主线乘客前台和车站后台。前台解决的是查得到、订得上、退得了——乘客输入出发地、目的地、日期能立刻看到当天的班次列表选班次、选座位、提交订单、在线支付这里用模拟支付方便本地演示和学习然后在我的订单里管理已买的车票需要时还能申请退票。后台解决的是管得住、看得清——管理员维护线路站点信息、班次和票价、车辆座位数量审核和处理退票请求还能查看用户列表和订单统计。双端结构的好处是边界清楚。乘客端和管理端的数据看似在同一套系统里但通过角色权限分开前端页面完全独立后端接口也做了细分。对新手来说这种结构最容易理解为什么要有权限控制对实际项目来说后期扩展也很方便比如再增加一个检票员角色只需要在权限层加一类角色配置就行。1.2 技术选型的底层逻辑技术选型不是越新越好也不是越花哨越好而是要匹配业务规模、团队熟悉度和部署维护成本。这套系统我选择了SpringBoot Vue MySQL MyBatis的组合原因很实在。SpringBoot负责后端核心框架。对比传统的SSMSpring SpringMVC MyBatis项目SpringBoot最大的优势是简化了配置不用再写一堆XML配置文件内嵌Tomcat打好Jar包就能跑。项目里的Controller、Service、Mapper分层扫描注入开发效率确实高很多。这里我用的SpringBoot 2.7.x版本稳定性和第三方兼容性都经过了足够多的生产验证不推荐一上来就追3.x很多老版本的MyBatis、插件可能跟不上。MyBatis作为持久层框架非常适合这类以自定义SQL为核心的业务系统。车次查询、订单统计、退票状态更新都涉及多表联查和复杂条件拼接MyBatis的XML映射能用最直观的SQL语法实现排错也容易。另一套方案是MyBatis-Plus单表CRUD确实省代码但像查询某个日期内所有班次的已售座位数这种场景还是得手写SQL而MyBatis本身就擅长这个。Vue做前端选Vue很简单上手快、社区资源丰富、中文文档完善。我这里用的是Vue 2 Element UI组合因为Element UI的表格、表单、日期选择器组件非常成熟做一个管理后台几乎不用自己写复杂的交互组件。当然如果你愿意折腾Vue 3 Element Plus也不难迁移但旧项目在稳定性和资料查询上优势明显。MySQL做数据存储不用多说开源、免费、稳定5.7和8.0都可以我项目里用的5.7兼容性最好Navicat一连就能操作数据库。这四样东西组合起来达到的效果是开发效率高、排错简单、学习曲线平缓、部署成本低。一台普通电脑、一个IDEA、一个Navicat环境就齐了。2. 数据库设计与核心表结构2.1 核心业务表设计数据库设计是系统的地基这块如果设计得不好后面写功能时处处掣肘。我总共设计了五张核心表覆盖用户、站点线路、班次、订单和一些基础配置。先看表清单总览方便建立全局概念表名作用关键字段sys_user系统用户表乘客和管理员共用id, username, password, role, name, phonet_station站点表存储出发地和目的地信息id, station_name, city, addresst_schedule班次表存储线路、发车时间、票价、余票id, station_from_id, station_to_id, depart_date, depart_time, price, total_seats, left_seatst_order订单表存储购票记录和订单状态id, order_no, user_id, schedule_id, seat_no, status, create_timet_route线路表作为班次的补充信息id, route_name, distance, duration核心的思路是这样的乘客下单时选择出发地A和目的地B前端先通过站点表查出两端站点再根据日期过滤班次。班次表里保存出发站和到达站的ID价格和总座位数锁定在班次上而不是放在线路表里这样才能支持同一条线路不同时间段不同票价的情况比如早班车和晚班车价格不一样就很常见。订单表是业务流的交汇点。一个订单关联一个用户、一个班次还要记录用户选择了哪个座位号。这里的seat_no字段用varchar存储比如1-15表示第1排第15座前端可以渲染成座位图。status字段是订单流程的核心我定义成0-已支付1-已退票2-已取票3-已取消。申请表设计时我特意补充了这几个字段order_no是订单编号这个必须唯一我采用时间戳加随机数的方式生成因为订单号既要可读性好又要避免并发冲突。create_time用MySQL的DEFAULT CURRENT_TIMESTAMP自动填充减少后端代码量。left_seats余票字段是冗余设计后面我会单独讲为什么这个字段对性能非常重要。2.2 表关系与关键字段解析表之间的关系相对清晰班次表通过站点ID关联出发站和到达站订单表通过用户ID关联用户表通过班次ID关联班次表。如果画ER图就是一张典型的主外键关系网。关键设计细节有几点都是实操中踩过坑后才加的用户表不分乘客和管理员。sys_user表里有个role字段值为ADMIN就是管理员值为USER就是普通乘客。这种单表设计对小型系统来说完全够用而且登录/权限判定只要查一次表就知道身份。如果你分两张表登录逻辑反而要查两遍权限控制也要写两套徒增复杂度。权限校验在后端用拦截器前端用路由守卫两边同时限制才算完整。余票字段是刻意冗余的。如果每次查询车次列表都去order表里count一下已售座位数然后拿总座位数减去已售数量在小数据量下没问题但数据一多就卡了。我在schedule表里加了left_seats字段下单成功就减一、退票就加一配合事务控制查询性能高且逻辑直观。当然这种设计需要加锁来防止并发超售后面在订单实现部分详细讲。日期和时间的拆分。班次的depart_date是date类型depart_time是time类型没合并成datetime原因在于乘客查询场景是按日期查全天班次日期过滤最常见拆开后索引效果更好而且当天发车时间便于单独展示和排序。这个设计如果重来一次我会加一个t_route_station中间表来支持多站经停的线路目前的设计是直达线路优先。如果你想扩展成中途站点上下车需要在表结构上进一步调整但基础框架是兼容的。3. 后端核心功能实现3.1 项目分层与包结构后端项目我采用标准的Controller-Service-Mapper三层架构。包结构如下com.abuseticket ├── controller # 接收请求、参数校验、返回结果 ├── service # 业务逻辑层事务控制都在这一层 ├── mapper # MyBatis数据访问接口 ├── entity # 数据库实体类 ├── dto # 前端交互参数对象 ├── vo # 返回给前端的视图对象 ├── config # 跨域、拦截器、MyBatis配置 ├── common # 统一返回结果、异常处理、工具类 └── interceptor # 登录拦截器Controller层我不写任何业务逻辑只做三件事接收前端参数、调用Service层、封装返回结果。Service层是业务核心事务全部加在这层比如下单时要同时生成订单和扣减余票任一失败都要回滚所以我直接在Service方法上标注Transactional。Mapper层就是接口加XML映射文件SQL都写在XML里便于集中管理。实体类按照数据库字段一一对应但和前端交互时不直接暴露实体而是用DTO和VO做隔离。比如前端传过来的登录表单用LoginDTO接收返回给前端展示的班次信息可能需要拼接站点名称、余票、票价使用ScheduleVO。这样做的好处是调整前端展示时不会破坏数据库实体结构前后端接口字段也变得比较稳定。统一返回结果是这个项目比较顺手的一个设计。我定义了一个CommonResult类包含code、msg、data三个字段code为200表示成功400表示参数错误401表示未登录500表示系统异常。所有接口返回的都是这个结构前端可以统一处理报错信息不用每个接口单独判断。3.2 用户登录与权限拦截登录功能看起来简单但有两个点必须做对密码不能明文存数据库后端必须验证登录状态不能光靠前端隐藏按钮来解决权限问题。密码处理方式我采用的是BCrypt加密Spring Security中自带的BCryptPasswordEncoder可以直接拿来用没必要整个引入Spring Security一个工具类就够。注册时加密存储登录时用matches方法校验明文密码和密文是否匹配。BCrypt的每次加密结果都不一样即使两个用户密码相同数据库中存的值也不同安全性比MD5加盐还要强些。登录成功后我用JWTJSON Web Token生成一个令牌返回给前端token里面包含用户ID、用户名、角色有效期设置为24小时。前端拿到token后存到localStorage每次请求时在请求头加Authorization: token字符串。后端写了一个拦截器LoginInterceptor在preHandle方法里读取请求头校验token有效性再把用户信息放到ThreadLocal中后续业务代码要拿当前登录用户ID时直接LocalUser.get()就拿到了。需要特别提醒的一个细节拦截器要配置放行路径。登录接口、注册接口、查询站点列表这些不需要登录就能访问一定记得在excludePathPatterns里放行否则前端连登录页面都调不通接口。我就是一开始没配好折腾了半天所有接口都报401最后才想起来需要配置白名单。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || token.isEmpty()) { // 返回401 renderUnauthorized(response); return false; } try { UserInfo user JwtUtil.parseToken(token); LocalUser.set(user); return true; } catch (Exception e) { renderUnauthorized(response); return false; } } }3.3 车次查询与订单管理核心逻辑车次查询是所有业务里使用频率最高的接口查询条件包括出发地、目的地、出发日期。这三个条件拼起来的SQL在数据库里走了索引响应速度很快。select idqueryScheduleList resultMapScheduleVOMap SELECT s.id, s.depart_date, s.depart_time, fs.station_name AS from_station_name, ts.station_name AS to_station_name, s.price, s.left_seats, s.total_seats FROM t_schedule s LEFT JOIN t_station fs ON s.station_from_id fs.id LEFT JOIN t_station ts ON s.station_to_id ts.id WHERE fs.id #{fromStationId} AND ts.id #{toStationId} AND s.depart_date #{departDate} ORDER BY s.depart_time ASC /select这里用LEFT JOIN把班次表里的出发站ID和到达站ID转换成站点名称前端直接展示from_station_name和to_station_name不需要前端再做一次名称映射。排序按发车时间升序符合乘客看班次的习惯。写这条SQL时有个小坑MySQL如果表名起得不好和系统关键字冲突了要加反引号我建表时特意避开了order、user这类保留字全都加了前缀就是这个原因。下单是整个系统最核心的事务操作涉及两个数据变化生成订单记录、扣减余票。我使用的关键代码逻辑如下Transactional(rollbackFor Exception.class) public Long createOrder(CreateOrderDTO dto, Long userId) { // 1. 查询班次信息 Schedule schedule scheduleMapper.selectById(dto.getScheduleId()); if (schedule null) { throw new BusinessException(班次不存在); } // 2. 校验余票 if (schedule.getLeftSeats() 0) { throw new BusinessException(该班次余票不足); } // 3. 生成订单号插入订单表 String orderNo generateOrderNo(); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setScheduleId(dto.getScheduleId()); order.setSeatNo(dto.getSeatNo()); order.setStatus(0); orderMapper.insert(order); // 4. 扣减余票原子操作 int updateRows scheduleMapper.decreaseLeftSeats(schedule.getId()); if (updateRows 0) { throw new BusinessException(余票不足下单失败); } return order.getId(); }关于并发超售这里用的是乐观锁思路。decreaseLeftSeats的SQL是UPDATE t_schedule SET left_seats left_seats - 1 WHERE id #{scheduleId} AND left_seats 0由于UPDATE影响行数为0时说明left_seats已经不大于0此时抛出异常提示余票不足事务回滚后订单也会被撤销。这种方案比select加锁简单得多也是这类项目的标准做法。退票逻辑是下单的反向操作更新订单状态为已退票、释放座位、把余票加回班次。这里同样放在一个事务里保证订单和余票数据最终一致。退票我设计成只能退未取票状态的订单取票后的订单在业务上等同于已核销不能再退。这点在开发时一定要和业务方确认好我见过不少系统因为忽略了状态流转导致取票后还能退票产生对账问题后台数据直接乱成一片。4. 前端页面与交互开发4.1 Vue项目结构与路由设计前端项目基于Vue 2 Vue Router Element UI搭建。项目结构如下src ├── api # axios封装和所有接口定义 ├── assets # 静态资源 ├── components # 公共组件 ├── router # 路由配置 ├── store # 用户信息状态管理 ├── views │ ├── admin # 管理端页面 │ │ ├── ScheduleManage.vue │ │ ├── OrderManage.vue │ │ └── UserManage.vue │ └── user # 乘客端页面 │ ├── Login.vue │ ├── Register.vue │ ├── Home.vue │ ├── TicketSearch.vue │ └── MyOrder.vue ├── App.vue └── main.js路由设计上做了懒加载处理每个页面的组件在访问时才真正加载打开系统首页的速度快很多。路由守卫是必须的我在router.beforeEach里检查用户要访问的页面是否匹配当前登录角色的权限。比如/admin/schedule这个路由meta中标记requiresAdmin: true如果当前用户不是管理员直接跳转到首页并提示无权限。使用Vue Router的meta字段做权限标记是我在项目里常用的方式。这种方式比在main.js里写一堆if判断要清晰得多每个路由自己说明自己能给谁访问查看权限路径时非常直观。4.2 核心页面功能拆解车次查询页是本系统最重要的用户端页面。页面布局为顶部轮流切换的出发地和目的地输入框支持城市模糊搜索中间是日期选择器下面展示符合条件的所有班次。班次列表我用Element UI的el-table组件实现每一行展示发车时间、到达时间、站点名称、价格、余票数和预定按钮。余票数小于等于5时用el-tag标成红色警示用来引导用户尽快下单。选座操作是票务系统的一个体验亮点。乘客点预定后弹出选座对话框我用一个二维数组渲染座位图1号座位在左下角按排和座号排列。司机的驾驶座在左上角用特殊样式标出。已售座位从后端接口查询渲染时置灰并禁止点击。用户点击一个座位后座位号回填到订单表单比如3-08表示第3排第8号。座位图的实现不算复杂但要注意每个班次的总座位数来自schedule表的total_seats字段。前端根据这个值动态渲染座位矩阵而不是写死。这样后台修改了总座位数前端自动适配不需要改动代码。订单支付页在真实项目中对接支付宝或微信支付很复杂需要商户号、证书密钥、回调地址等一堆东西。我这个项目里做成了模拟支付点击确认支付按钮前端模拟一个3秒的loading状态随后调后端确认支付接口把订单状态从待支付更新为已支付。对于学习演示来说这个设计足够如果你想接真实支付只需要替换支付接口这一步订单流程代码完全不用改。4.3 前后端联调与跨域配置前后端分离项目联调必不可少。我用axios封装了一个request工具统一配置baseURL、请求超时时间、请求拦截器带上token和响应拦截器统一处理错误码。开发时Vue项目跑在8081端口后端SpringBoot跑在8080端口必然遇到跨域问题。解决方案有两种思路一种是后端接口加CrossOrigin注解一种是前端配置Vue项目的devServer代理。我推荐用前端代理方案原因是不用让后端代码嵌入跨域逻辑而且代理后前端只访问/api开头的路径看起来就像访问同源地址一样。具体配置写法// vue.config.js module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这样设置后前端的请求地址是/api/login代理会把请求转发到http://localhost:8080/api/login。后端接口统一以/api开头也方便统一做拦截器路径匹配。实际联调过程中最大的坑往往是参数格式不一致。前端传的是JSON对象后端用的RequestBody接收原则上没问题但有人会不小心把date类型传成字符串后端解析时直接报错。我的做法是前端日期都格式化为yyyy-MM-dd字符串后端实体统一用String接收再手动转LocalDate这样能避免时区、格式带来的玄学问题。5. 部署上线与常见问题排查5.1 本地运行与打包部署本地运行这套系统核心是三部曲初始化数据库、启动后端、启动前端。第一步用Navicat创建数据库bus_ticket_db导入项目里附带的bus_ticket_db.sql脚本。这个脚本我一开始就设计成完整版包含建表语句、初始站点数据、测试用户账号。其中默认管理员的账号是admin/admin123普通乘客账号是user/user123。我建议你入库后立刻改掉默认密码免得演示时被别人随手登上去改数据别问我怎么知道的。第二步在IDEA中导入后端项目修改application.yml里的数据库配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/bus_ticket_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 你的数据库密码 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.abuseticket.entity数据库连接串里的serverTimezoneAsia/Shanghai不能少不少环境因为时区不对导致查询报错。还有MySQL 8.0以下版本要把com.mysql.cj.jdbc.Driver改回com.mysql.jdbc.Driver否则启动时也会提示找不到驱动类。第三步前端在命令行执行npm install npm run serve依赖安装失败是最常见的问题建议优先配置npm镜像源在用户目录.npmrc里写好registryhttps://registry.npmmirror.com。装完依赖启动成功后浏览器访问http://localhost:8081走登录页面进系统。生产环境部署时不推荐直接把前后端拆两台服务器跑。我习惯的做法是前端项目执行npm run build生成dist静态文件复制到SpringBoot项目的src/main/resources/static目录下再把SpringBoot打包成单个Jar服务器上一条java -jar bus-ticket.jar命令就完成部署。这样部署一个服务就能同时提供页面和接口运维成本大大降低。注意如果走这种方式前端的baseURL就要改成相对路径不要再用/api代理了。5.2 高频踩坑与解决办法这套系统开发过程中我记录了不少坑其中有几个很典型几乎每个人复现项目时都会遇到。第一个坑MyBatis XML映射文件没被扫描到。明明接口方法写了启动却报Invalid bound statement (not found)。排查思路很简单检查application.yml里的mapper-locations是否配置了XML路径检查XML文件里的namespace是否和Mapper接口全类名一致再检查Mapper接口的方法名是否和XML里的id一致。三个地方有一个对不上就报错几乎全是粗心所致。第二个坑前端接口一直404。页面能打开但调接口全部404。最可能的原因是前端代理没生效或者后端接口路径和前端请求路径不一致。我在项目里约定好所有Controller类上统一加RequestMapping(/api/xxx)前端request工具baseURL设为空字符串请求路径写全/api/xxx这样两边始终对齐很少出现路径找不到的尴尬。第三个坑日期显示总是相差8小时。前端传到后端的日期正确但查询结果和预期差了一天。本质上是时区问题。解决办法有两处MySQL连接串加serverTimezoneAsia/Shanghai后端实体类的Date类型统一用LocalDate或LocalDateTime配合Jackson的JsonFormat(pattern yyyy-MM-dd HH:mm:ss)注解。我后来全面改用LocalDate没再发生过这个问题。第四个坑下单成功但余票没变或超卖。这个问题如果不在数据库层面做约束几乎一定会踩。前面提到过decreaseLeftSeats用条件更新实现原子扣减如果发现余票没变先确认事务有没有生效Transactional有没有正确加在Service方法上再检查Mapper的update语句返回值有没有被判断。如果返回值是0说明了更新失败这个时候必须抛异常不能忽略。这里列一个排查速查表方便复现时快速定位现象排查方向解决办法启动报驱动类找不到MySQL版本和驱动类不匹配8.0以上用cj驱动5.7用mysql.jdbc.Driver前端接口401token未传或失效检查请求拦截器和登录后token存储逻辑中文乱码数据库字符集问题建库时指定utf8mb4连接串加characterEncodingutf8页面表格不显示数据realm数据格式和后端不一致检查后端返回CommonResult结构和前端取值的层级打包后页面白屏静态资源路径问题配置Vue的publicPath为相对路径./排查这些问题时有个经验养成看浏览器控制台和后端日志的习惯。前端F12看网络请求和响应体后端看控制台输出SQL和异常栈。这套系统能直观看到MyBatis打印的SQL排查数据问题方便得很。6. 一些实操心得和后续扩展建议我个人实际操作中体会最深的一点是这套系统的设计边界划分得清楚后端Controller薄、Service厚、SQL集中在XML前端页面每块职责单一。这种结构遇到问题非常好定位我给别人讲代码时也容易讲明白——先说表结构再讲Service里的业务规则最终落到页面交互。最后再分享一个实用小技巧这里我配置了MyBatis的SQL日志打印开发阶段把mybatis.configuration.log-impl设置为org.apache.ibatis.logging.stdout.StdOutImpl控制台就能看到每条SQL的完整执行过程。排查数据哪一步不对时特别有用上线前再改成不输出日志免得刷爆控制台影响性能。如果你后续想在这个项目上继续扩展建议优先做这几个方向乘客端的座位自动分配逻辑搜索页直接给推荐座位、后台的销售数据报表用ECharts画折线图、订单的Ajax轮询或WebSocket实时刷新余票。每个方向都不难但都能显著提升系统的完整度和实用性。系统的源码结构我已经按这个思路整理清楚了拿到代码后对照这篇内容看一遍一天时间足够把整个项目吃透。
返回列表