SpringBoot实战:公交查询与换乘规划系统
发布时间:2026/9/28 14:46:18来源:尧图网络
每年毕业季总有一堆人卡在选题上捧着“公交查询系统”“外卖平台”“图书管理”这类老掉牙题目发愁——不是说不能做而是做得千篇一律答辩时老师一眼看穿工作量。这次我拆解的是一个稍微有点意思的题目基于SpringBoot的武夷智能公交查询与信息服务平台核心是公共交通线路规划与实时车次管理。它最大的价值不在“CRUD”而在“路线怎么规划”“实时车次怎么模拟”“数据怎么缓存”这几个点才是能从一摞毕业设计里跳出来的地方。这个题目适合谁第一类是Java方向、想要一份能讲清楚技术亮点的毕设的本科生第二类是已经在学SpringBoot但只会做增删改查、想练手真实业务场景的开发者第三类是准备面试、想拿一个“有业务深度”的项目往简历上写的人。我会从需求拆解、技术选型、核心算法、数据库设计、前后端联调一直讲到底层踩坑尽量把每一步为什么这么做讲透文章贴的是“信息系统”的框架但肚子里装的是工程实践的干货。1. 项目整体设计与思路拆解1.1 这个系统到底在解决什么问题公交查询系统不是新鲜玩意市面上高德地图、车来了早就做得很成熟了。但你作为学生做毕设不可能也不应该跟它们拼数据源、拼实时精度你要做的是把一套完整的业务闭环在自己的服务器上跑通并证明你理解这个领域里的核心问题。武夷山的公交场景有个天然特点旅游客流和本地客流叠加热门景区线路在节假日会集中爆发这给线路规划、班次调度提供了很好的业务假设。我建议把系统定位成“一个面向乘客端管理端运维端的三端平台”主链路覆盖乘客查线路、查站点、查实时车辆位置、做换乘规划管理员维护线路、站点、车辆、班次基础数据系统自动生成班次计划、模拟车辆运行轨迹并推送状态。换句话说这就是一个**“查询为主、管理为辅、规划为核心亮点”**的典型智慧交通信息服务项目。这里的“智能”不能停留在概念上——需要在某个模块里真正有算法介入我现在提前告诉你最合适的落点就是“换乘规划”和“车辆轨迹推算”后面第3节会详细展开。1.2 技术选型背后的深层次考量技术栈选择直接决定你答辩时能不能抗住追问。我见过太多人上来就列一堆技术名词结果问一句“为什么不用JSP”就答不上来。直接用这张表梳理选型逻辑选型决策我的推荐推荐理由不推荐的替代方案后端框架SpringBoot 2.7.x自动装配极大降低配置成本适合快速交付生态最成熟SSM配置繁琐招人嫌持久层MyBatis-Plus单表CRUD零SQL、分页插件好使、代码生成器快纯MyBatis开发太慢、JPA复杂查询别扭数据库MySQL 8.0稳定、资料多、答辩老师最熟悉Oracle没必要、PostgreSQL会给自己添麻烦缓存Redis线路和班次数据读多写少缓存收益明显无缓存高频查询扛不住实时通信WebSocket车次位置实时推送比HTTP轮询优雅轮询延迟高、浪费带宽前端Vue3 Element Plus前后端分离是常态组件现成管理后台开发效率高JSP模板被问“怎么还不用前后端分离”地图服务高德地图JS API免费额度够毕设用、API文档友好百度逆地理编码要单独申请选择SpringBoot还有一个答辩时的现实理由这是一个帮你把所有常见整合问题提前“标准化”的框架。你在配置Redis、接入拦截器、处理全局异常时SpringBoot都有约定俗成的写法这意味着你节省的时间可以拿去写算法、打磨业务而不是跟XML配置死磕。1.3 功能模块划分与优先级功能拆解建议按“必做、加分、可选”分三层这个思路既是做项目的方法也是后期写论文的目录骨架。必做核心支撑主链路线路管理、站点管理、车辆管理、班次管理、线路/站点查询、实时位置模拟与展示。这些做完系统就是一个完整可演示的公交信息平台。加分亮点体现“智能”基于站点换乘的路径规划算法、基于车辆到离站数据的时刻表推算、首页常用线路推荐。建议至少做出换乘规划这是答辩最容易被追问、也最能让老师眼前一亮的模块。锦上添花展示工程化能力图表统计各线路日客流估算、操作日志、数据导入导出、省市级联选择器。我强烈建议把换乘规划当成项目的心脏来对待哪怕算法不复杂也要把思路完整地写进论文里。后面的内容我会围绕这颗心脏往下拆。2. 核心数据模型与数据库设计2.1 数据表关系一张图看懂业务底子公交业务的数据模型并不复杂但表之间的关联如果设计得不好后面写SQL时会非常难受。我的建议是采用“基础数据表 关系表 运行态数据表”三层结构基础数据表bus_line线路表字段包括线路名称、起点终点站名、首末班时间、票价、线路状态。这个表是运营实体所有运行业务都围绕它展开。bus_station站点表存站名、经纬度、区域、是否首末站。经纬度字段是换乘算法的“原料”后面要用它算距离务必建。bus_vehicle车辆表记录车牌号、所属线路、座位数、车辆状态。车辆状态要区分“运营中、停运、维修”。关系表line_station_rel线路-站点关系表。公交线路是有序经过站点的所以这个表必须带station_order字段排序靠它。表中还可以冗余一个“距上一站距离”字段这样计算到站时间时不需要实时调地图API。line_transfer_rel换乘关系表可选但推荐如果算法用到了顶点关系预处理这个表能缓存结果提升查询速度。运行态数据表bus_schedule班次表一次发车记录就是一个班次字段包括所属线路、发车时间、车辆ID、当前状态待发车、运行中、已到终点。vehicle_realtime车辆实时位置表记录每个运行中车辆的上报时间、经纬度、当前站点、下一站点。这张表是WebSocket推送的数据来源需要高频写入。station_arrival_prediction到站预测表加分项存放算法计算出的某个站点未来几分钟内有哪几辆车到达的预测结果避免每次乘客查询时现算。数据库设计阶段有个高频坑我在这里先踩给你看不要用station_id直接去冗余station_name这种写法没问题但反范式要克制。比如在line_station_rel里既存station_id又存station_name虽然查询方便了但站点改名时会导致数据不一致。正确做法是核心关系全靠外键关联station_name只出现在站点表里查询时JOIN一次性能完全可接受。2.2 关键表结构设计示例可直接抄作业我给出三张最关键的表结构其他表照着这个思路扩展即可。DDL对毕设来说反而比Entity代码更值得先写好因为后面所有业务逻辑都绕不开它。-- 线路表 CREATE TABLE bus_line ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, line_name VARCHAR(50) NOT NULL COMMENT 线路名称如1路、武夷山景区专线, start_station VARCHAR(100) NOT NULL COMMENT 起点站名, end_station VARCHAR(100) NOT NULL COMMENT 终点站名, first_bus_time TIME NOT NULL COMMENT 首班时间, last_bus_time TIME NOT NULL COMMENT 末班时间, ticket_price DECIMAL(4,1) NOT NULL COMMENT 票价元, line_status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1运营 0停运, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) COMMENT 公交线路表;-- 线路-站点关系表这是最容易写错表结构的地方 CREATE TABLE line_station_rel ( id BIGINT PRIMARY KEY AUTO_INCREMENT, line_id BIGINT NOT NULL COMMENT 线路ID, station_id BIGINT NOT NULL COMMENT 站点ID, station_order INT NOT NULL COMMENT 站点在线路上的顺序从1开始, distance_to_next DECIMAL(8,1) DEFAULT NULL COMMENT 距下一站距离单位米末站为NULL, travel_time_to_next INT DEFAULT NULL COMMENT 预计行驶到下一站的秒数, KEY idx_line_order (line_id, station_order) ) COMMENT 线路站点关系表;-- 班次表 CREATE TABLE bus_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, line_id BIGINT NOT NULL COMMENT 线路ID, vehicle_id BIGINT NOT NULL COMMENT 车辆ID, depart_time DATETIME NOT NULL COMMENT 发车时间, schedule_status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1待发车 2运行中 3已完成 4已取消, current_station_id BIGINT DEFAULT NULL COMMENT 当前所在站点ID, next_station_id BIGINT DEFAULT NULL COMMENT 下一站点ID, total_stations INT NOT NULL COMMENT 线路总站数预计算冗余字段, passed_stations INT NOT NULL DEFAULT 0 COMMENT 已过站数, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 公交班次表;关于bus_schedule我要多说两句正常的公交调度在“发车间隔”上是典型的周期重复任务你不用设计复杂的发车规则引擎一个简单的策略就够线路首末班时间 工作日/节假日两套发车间隔自增生成一天的班次记录即可这个逻辑后面在“班次自动生成”里再细讲。2.3 为什么要把“经纬度”和“站间距离”当成一等公民很多学生做公交系统时站点表里只存一个站名线路表只存字符串形式的“A站-B站-C站”看起来能用但一旦要做换乘规划就完全傻眼。公交规划的本质是图上的路径搜索而图的边要有权值权值最自然的表现就是距离或时间。所以我的建议很直接站点表必须存经纬度线路站点关系表必须存站间距离和预计行驶时间。这两列就是换乘算法的边权它们不占多少存储但对系统“智能”程度的影响是决定性的。这里可以用一个生活类比帮助理解你给乘客规划换乘路线就像导航软件算路你得先把地理坐标变成地图上的点把“前山站到南源岭站是3公里”变成一条可计算的线段否则你写出来的算法只能靠猜。经纬度字段不需要通过地图API批量生成手头没数据的话用高德坐标拾取器手动采集武夷山各公交站点的真实坐标完全够用。3. 核心技术实现线路查询、实时车次与换乘规划3.1 线路/站点查询的工程化写法线路查询在业务上很简单但在工程实现上仍有很多细节值得注意。老手会直接把查询条件封装成DTO对象用条件构造器来做动态SQL而不是根据参数拼接一堆if判断。Override public IPageLineVO queryLinePage(LineQueryDTO dto, PageLine page) { LambdaQueryWrapperLine wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(dto.getLineName()), Line::getLineName, dto.getLineName()) .eq(dto.getLineStatus() ! null, Line::getLineStatus, dto.getLineStatus()) .orderByAsc(Line::getId); // 这里直接把实体转VO避免把createTime这类字段暴露给前端 IPageLine linePage lineMapper.selectPage(page, wrapper); return linePage.convert(this::toLineVO); }这里有个面试官爱问的点为什么线路名称查询要用like而不用eq。比如乘客输入“1”他可能是想查“1路”还是“11路”你不能替他决定模糊匹配更宽容但站点精确查询要区分场景如果用户点击地图上的某个站点那肯定是精确查询而不是like。条件构造器是你写动态查询最顺手的工具别自己拼SQL字符串既容易拼错又慢。3.2 实时车次模拟没有GPS硬件怎么演示实时位置这个点几乎人人都会问毕设现场没有真实GPS设备怎么演示“实时”车次不用慌这是一个非常合理的“仿真场景”你要做的只是用模拟数据驱动业务流程先设计一个后台调度线程ScheduledExecutorService或Spring自带的Scheduled每10秒执行一次扫描任务。任务内容很简单找出所有状态为“运行中”的班次记录根据该班次当前所在站点的travel_time_to_next判断是否应该推进到下一站。如果推进成功就更新current_station_id、passed_stations并把新的位置信息写入vehicle_realtime表。然后后端创建一个WebSocket端点按照线路维度维护一组订阅会话。车辆位置每次更新时把变化的数据推送到订阅了该线路的客户端。Component public class VehicleSimulator { // 模拟用的车辆运行线程池 private final ScheduledExecutorService scheduler Executors.newScheduledThreadPool(4); PostConstruct public void startSimulation() { scheduler.scheduleAtFixedRate(this::simulateOnce, 5, 10, TimeUnit.SECONDS); } private void simulateOnce() { // 查出所有运行中的班次逐个推进 ListBusSchedule runningSchedules scheduleMapper.selectList( new LambdaQueryWrapperBusSchedule() .eq(BusSchedule::getScheduleStatus, 2)); for (BusSchedule schedule : runningSchedules) { advanceVehicle(schedule); } } private void advanceVehicle(BusSchedule schedule) { // 根据schedule的当前站与下一站信息结合travel_time_to_next推进 ListLineStationRel relList lineStationRelMapper.selectByLineId(schedule.getLineId()); // 判断当前站索引是否已经是最后一站如果不是则把状态往前挪 } }这里有个容易被忽略的细节模拟数据不等于假数据。模拟线程和真实服务器跑出来的数据行为逻辑完全一致班次到了终点站就会自动置为“已完成”下一班次按计划发车时就会置为“运行中”只是触发条件由人工模拟代替了GPS上报。答辩时你完全有底气讲清楚这是仿真环境不是伪造。3.3 换乘规划算法把“图论”讲成大白话换乘规划是整个系统最核心的亮点设计时我强烈推荐用BFS广度优先搜索 换乘次数限制的组合而不是上来就上Dijkstra。原因是公交换乘业务关注的首要约束是“换乘次数要少”而不是“总距离绝对最短”——你让乘客换5次车只为了少走100米这种方案没有任何实际价值。核心实现思路把每条线路看成一组有序站点建立“站点ID → 经过该站点的线路列表”的索引从起点站开始先看经过起点站的所有线路逐条追踪到终点站方向能否到达目标站如果追踪不到就扩展一层把经过起点站的每条线路上的所有站点作为“候选换乘点”从这些站点再搜索经过它们的其他线路看是否能到终点站重复这个“以站找线、以线找站”的过程限制最大换乘次数不超过2次默认方案。BFS层数天然天然等于换乘次数第一层就是直达方案第二层就是一次换乘方案第三层就是两次换乘方案。每次找到一组可行路径后再用站间距离与预估行驶时间做路径排序优先推“更短时间”的。public ListTransferPlan planTransfer(Long fromStationId, Long toStationId) { // 1. 起点站经过的所有线路 ListLineStationRel startRelList lineStationRelMapper.selectByStationId(fromStationId); // 2. BFS队列元素是(当前站点, 已乘车线路, 换乘次数, 经过站点路径) QueueSearchNode queue new LinkedList(); SetLong visitedLine new HashSet(); ListTransferPlan results new ArrayList(); for (LineStationRel rel : startRelList) { queue.offer(new SearchNode(fromStationId, rel.getLineId(), 0, new ArrayList())); } while (!queue.isEmpty()) { SearchNode node queue.poll(); ListLineStationRel stationsOfLine lineStationRelMapper.selectByLineId(node.lineId); boolean reached findStationInLine(stationsOfLine, toStationId); if (reached) { results.add(buildPlan(node, toStationId)); // 记录一条可行方案 continue; } if (node.transferCount MAX_TRANSFER) { continue; } // 把当前线路上的所有站点作为新的出发候选点找这些站点经过的其他线路 for (LineStationRel stationRel : stationsOfLine) { ListLineStationRel linesOfStation lineStationRelMapper.selectByStationId(stationRel.getStationId()); for (LineStationRel lineOfStation : linesOfStation) { if (!visitedLine.contains(lineOfStation.getLineId())) { visitedLine.add(lineOfStation.getLineId()); queue.offer(new SearchNode(stationRel.getStationId(), lineOfStation.getLineId(), node.transferCount 1, extendPath(node, lineOfStation))); } } } } return results; }这里说一个很好用的小技巧BFS中用“已访问线路集合”来防止死循环。公交线路的图规模不大站点和线路数量都是几十/几百级别直接BFS一次的性能开销非常小。如果对自己的算法没信心还可以把换乘规划结果缓存到Redis里key用fromStationId:toStationId这样用户对同一组起终点的重复查询直接走缓存毫秒级返回。3.4 班次自动生成用策略模式替代散落的if班次是实时车次的数据源头如果手工一条条添加数据量大不说还容易出错。更合理的做法是提供“自动排班”功能管理员选择线路、日期类型、发车间隔后端按规则批量生成当日班次。这既是给管理员的效率工具也是展示你工程抽象能力的好地方。我在实现里用到了策略模式定义一个ScheduleGenerateStrategy接口提供workday和holiday两个实现类。工作日高峰时段早7-9点、晚17-19点发车间隔为10分钟平峰时段为20分钟节假日周末执行另一套间隔。你的用户或者你的演示账号执行一键生成时后端根据日期类型选出对应策略执行即可。4. 前后端联调、部署与性能优化经验4.1 接口设计规范一套能安安静静对接的RESTful API如果前后端分离接口设计规范直接决定联调效率。建议遵循这几个约定统一响应结构{ code, message, data }code200表示成功其他为业务异常码。不要在Controller里直接返回实体类加一层统一封装能让全局异常处理器接得住错误。分页参数统一pageNum、pageSize响应中包含total字段。前端表格组件Element Plus的el-table配pagination直接对接省去字段转换的扯皮。语义化URL/api/line/{id}、/api/line/{id}/stations、/api/transfer/plan?fromto。尽量用名词表达资源动词交给HTTP方法。时间格式统一传给前端的时间戳/格式化字符串必须统一建议数据库存DATETIME接口返回LocalDateTime序列化为yyyy-MM-dd HH:mm:ss。你踩过一次“前端Date显示NaN”的坑之后就会懂了。4.2 WebSocket推送的可靠性与心跳设计实时位置推送如果用原生WebSocket裸写客户端断线、后端推送失败这些问题都会冒出来。最实用的做法是引入Spring封装的SimpMessagingTemplate配合STOMP协议前端订阅/topic/line/{lineId}主题后端在有位置更新时调用messagingTemplate.convertAndSend推送。相比原生WebSocketSTOMP帮你处理了订阅关系管理、消息路由、ACK等很多琐碎问题。但这里有一个隐藏坑代理服务器Nginx默认对WebSocket的升级请求支持不太友好需要在Nginx配置里显式加上Upgrade和Connection upgrade头否则前端会一直连接失败。这是很多前后端分离项目在本地跑得好好的、一部署就白屏的经典原因。location /ws/ { proxy_pass http://backend-server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; }4.3 高频查询的Redis缓存实践实时位置表是高频写入的而线路/站点基础信息是高频读取、低频修改的。缓存策略上分成两类第一类线路与站点基础数据缓存。线路列表、线路详情、站点列表这些数据基本不变用Redis的String或Hash结构缓存,过期时间可以设到1小时甚至更长。这些数据在后台被管理员修改时主动删除对应缓存保证一致性。第二类班次与到站信息缓存。班次是分钟级变化的数据缓存时间不宜过长30秒到1分钟即可。到站预测结果可以缓存1-2分钟因为预测本身存在时间窗口用户看到的“预计3分钟到站”在1分钟后刷新是合理的。缓存击穿问题在答辩时经常被问我的建议是查询时先查缓存缓存没有则查数据库并回填热点key的过期时间加随机值避免同时过期。你把这个思路讲清楚老师就会觉得你有生产意识。4.4 从Idea到服务器一个能演示的部署流程毕设最后总归要给老师演示我的建议是本地开发用IDE跑演示用Docker Compose一键部署既省事又能体现工程化能力。一个最简的docker-compose.ymlversion: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: bus_system ports: - 3307:3306 volumes: - ./mysql-data:/var/lib/mysql redis: image: redis:7.0 ports: - 6380:6379 backend: build: ./backend ports: - 8080:8080 depends_on: - mysql - redis frontend: build: ./frontend ports: - 80:80 depends_on: - backend后端镜像的Dockerfile里包含Java环境前端镜像用Nginx托管打包后的静态资源并把/api反向代理到后端服务。整套流程一次写好后换一台服务器演示也只是拉镜像、起容器的事。演示前一天跑一遍这套流程比临时在电脑上开IDE靠谱得多。5. 典型问题与避坑实录含排查思路5.1 班次推进与位置更新不同步现象模拟线程把current_station_id推进到下一站了但前端页面上的车辆位置还停留在上一站附近。排查思路先看班次表的updated_time字段有没有更新再看WebSocket推送是否成功。我排查后发现90%的情况不是数据没更新而是前端订阅了/topic/line/{lineId}但后端消息推到了/topic/line/{id}ID字段没对上。前后端的主题拼接必须带上前缀和后缀完全一致这个最基础也最容易错。5.2 换乘规划返回了不合理的“绕路”方案现象从“武夷学院”到“高铁北站”算法竟然推荐先坐3路往反方向坐三站再换乘。原因BFS只保证“能换乘到”不保证“方向合理”。解决方式是我在生成方案后增加了一个“首段方向和末段方向校验”的过滤如果起点站在线路首站方向反而更远可以继续搜索同站对向线路但要对候选方案打分时引入“站间距离权重”优先推荐总里程短的路径。5.3 Nginx部署后WebSocket一直404现象本地前后端联调一切正常放到服务器上WebSocket握手一直失败。原因Nginx默认代理配置没升级协议头。加上第4.2节那段配置后解决。以后凡是涉及到ws前缀的路径都要检查Nginx的Upgrade配置这是部署里出现频率最高的隐蔽坑。5.4 MySQL 8.0时区导致时间错乱现象前端展示的班次时间比数据库多8小时或者少8小时。原因MySQL 8.0默认时区是UTC与我们的东八区不一致。连接参数里加上serverTimezoneAsia/Shanghai同时在后端统一使用LocalDateTime序列化时间就不会再乱。这个坑几乎每个人都会踩一次提前设好可以省一晚的调试时间。5.5 展示数据不够“像真的”这个是演示效果层面的事一堆线路叫“1路、2路、3路”老师看着没感觉。我建议把武夷山的真实公交线路如“武夷山景区专线”“9路武夷宫—星村”等做成初始化数据再把车辆名称起成“闽H·A1234”的样式。这种细节花不了多少时间但演示观感会提升一个档次答辩时也容易被视为“有调研、有场景落地意识”。6. 写在最后的个人体会与建议这个项目我断断续续做了三周左右最大感触有两点。第一毕设项目的技术深度不在“会不会用某个框架”而在“能不能把一个具体场景的共性问题抽象出来并用代码解决”。换乘规划、班次自动生成、模拟运行这些模块单独看都不难但组合在一起就是一个能讲完整故事的系统。面试官不关心你是不是用了最新版的SpringBoot 3他关心的是你有没有想过“为什么线路表要有顺序字段”“为什么实时位置用WebSocket而不是Ajax轮询”这些思考才能把你跟只会照着网课敲代码的候选人区分开。第二数据初始化决定你对这个项目的感情。如果打开后台全是“线路1”“站点A”“车辆1”你做出来的系统再强也没有灵魂。花一晚上把武夷山的真实公交线路梳理成初始化SQL脚本录几个典型站点的经纬度你会发现调试换乘算法时不再对着Excel发呆而是能实实在在验证“从武夷学院到景区南入口怎么换乘最方便”。最后分享一个实用小技巧给后台管理端加一个“一键模拟晚高峰”的按钮它的作用是按高峰期发车间隔一次性生成未来3小时的班次并让模拟车辆全部处于运行状态。演示时点一下前端大屏上瞬间出现十几辆车同时移动这个视觉效果既能撑场也能让答辩老师直观看到实时车次系统的全貌。你把这个设计写进论文的“系统验证”章节也算是一个有说服力的功能亮点。
网站建设高端定制企业官网