做农村客运服务系统这个项目是我过去几个月投入精力最多的一件事。说直白点这个基于Spring Boot的农村客运服务系统就是要把农村班线的班次管理、售票订票、车辆调度和站点信息从纸质台账和微信群聊里搬到一个正经的后台系统里来。它解决的痛点是村民不知道车几点来、客运公司不知道车上坐了多少人、调度员只能靠电话一个个确认。如果你是正在做毕业设计的学生或者乡镇客运公司的技术负责人又或者单纯想看看一个Spring Boot项目从设计到上线要踩多少坑那这篇总结应该对你有用。我把从需求梳理、技术选型、数据库设计到接口开发、前端打包部署再到上线后遇到的各类问题的过程完整写下来。里面没有那种照着官方文档念的废话全是我实际敲过代码、跑过服务的记录顺手能复制的代码和配置我也会直接贴出来。1. 项目整体设计与核心业务拆解1.1 农村客运系统到底要解决什么问题一开始我就没打算做一个大而全的“智慧交通平台”而是先围着农村客运的实际场景画了一张业务流程图。农村客运和城市公交差别非常大线路固定但班次不固定一辆车可能一天只跑两趟乘客数量波动极大乡镇站点之间距离又远。过去客运公司靠调度员拿着一张纸上面写着每个车次几点发车司机发车之前给调度打个电话汇报人数调度再决定要不要加开班车。乘客呢只能提前去站台守着或者给司机打电话问。系统要解决的第一件事是信息透明。把每天哪些班次、几点发车、途经哪些站点、余票多少全部变成可查询的数据。第二件事是调度效率。以往加开一班车要从填单子、盖章、通知司机三条线走下来现在直接在系统里处理核验客流后自动生成建议班次调度员确认一下就生效。第三件事是财务对账。农村客运很多车票是车上现金交易票款、补贴、油费混在一起系统里每笔订单都有记录月底对账就不用靠司机口述了。所以这系统不是给城里人坐地铁用的那种高并发平台而是一个贴合乡镇场景、业务规则相对复杂但并发量可控的管理系统。这也决定了我后来的技术选型思路——稳定优先别炫技。1.2 技术选型为什么是Spring Boot MyBatis Vue技术栈我基本没有犹豫后端用Spring Boot持久层用MyBatis前端用Vue。这套组合看起来普通但在农村客运这种业务逻辑偏重、并发量不高的场景中非常合适。先解释为什么不用微服务。一个乡镇客运公司全部业务跑在一台4核8G的服务器上都没问题拆微服务只会增加运维成本。Spring Boot内置Tomcat打成jar包直接跑对一线运维人员就是双击启动的事。版本我最后选了Spring Boot 2.7.18而不是最新的3.x理由在后面的排查记录里会详细说到。持久层没有用Spring Data JPA而是用MyBatis因为班次查询、订单报表这种场景要写大量自定义统计SQLMyBatis的Xml映射文件可以让我把SQL写得更直观而且动态SQL处理多条件筛选特别灵活。前端用Vue不是因为流行而是因为它可以用来快速做一个单页管理后台。Vue 3 Element Plus的组合表格、表单、弹窗都是现成的。我的项目分了两端管理端给客运公司内部人员用查询端给乘客用。乘客端我用的是Vue Router的hash模式后面嵌入Spring Boot的静态资源目录非常省事不需要配置服务端路由转发。1.3 模块划分与数据库设计要点整个系统按功能拆了六个模块线路站点管理、班次管理、售票订单管理、车辆与驾驶员管理、调度管理、统计报表。数据库表跟着模块走核心表给了很大的设计自由度。线路表、班次表、站点表这三张是基础但真正让查询变复杂的是班次站点关系表。一辆农村班车从镇里出发途中经过8个村站同样的线路可能因为停靠点不同分成A、B两个班次。我在schedule_station表里加了station_order字段用来表示某个班次经过站点的顺序同时记录预计到达时间和发车时间。这样一来查“上午10点以后从李庄去县城”的车就变成了查班次站点关系表把起点站点、终点站点、时间范围三个条件拼在一起再用子查询过滤出符合条件的班次。订单表是另一个设计重点。农村客运的订单有两种形态线上预订和车上补票。线上预订在系统里直接生成订单车上补票则是由司机在移动端录入。订单表里用order_type字段做了区分同时用status字段保存状态机待支付、已支付、已出票、已检票、已退票、已取消。每个状态变更都写进订单流水表这条流水不仅方便排查问题月底和客运公司财务对账时也一目了然。还有一张容易被忽略的是基础参数表。比如每单默认的取票过期时间、车辆座位数、里程补贴单价这些参数会频繁调整如果硬编码在业务代码里每次修改都要重新发版。我用一张sys_config表配合后端的缓存注解改动之后五秒钟生效这个设计后续给我省了不少事。2. 核心功能实现与关键细节2.1 班次查询与预订接口怎么设计班次查询接口是整个系统最核心的入口乘客端、管理端、驾驶员端都会调用。接口设计没有走复杂的CQRS模式就是普通的RESTful接口但对于查询条件做了严格拆分。请求参数如下线路ID或班次ID、出发站点ID、到达站点ID、出发日期、出发时间区间。用GET请求参数通过Spring MVC的ModelAttribute绑定到查询对象。之所以不把出发站点和到达站点简单合并成“站点ID”是因为同一辆班车沿途要停好几个站点乘客可能从第2站上车、第6站下车系统必须根据station_order来判断乘坐区间是否合法。下单时的余票判断是个关键点。最初我直接在schedule表上计算“总座位数 - 已售订单数”后来发现完全不靠谱——一个人从第2站坐到第6站另一个人从第4站坐到第8站同一条路线不同区间的座位会有重叠。我改成按区间来统计先从schedule_station表查出该班次所有站点顺序拿到要买的起终点索引然后统计所有已出票订单里与当前区间有重叠的座位数之和再用总座位数减去这个重叠数。计算逻辑不复杂但用SQL表示出来就比较绕这地方既考验业务理解也考验SQL能力。核心代码片段Service public class ScheduleService { Autowired private ScheduleMapper scheduleMapper; public PageResultScheduleDTO querySchedules(ScheduleQuery query) { if (query.getDepartDate() null) { query.setDepartDate(LocalDate.now()); } return scheduleMapper.selectScheduleList(query); } public boolean isSeatAvailable(Long scheduleId, Long fromStationId, Long toStationId, int seatCount) { // 查出起点和终点在班次站点中的顺序 int fromOrder scheduleStationMapper.getStationOrder(scheduleId, fromStationId); int toOrder scheduleStationMapper.getStationOrder(scheduleId, toStationId); // 统计重叠区间的已售座位数 int soldSeats orderMapper.countOverlapOrders(scheduleId, fromOrder, toOrder); int totalSeats scheduleMapper.selectTotalSeats(scheduleId); return (totalSeats - soldSeats) seatCount; } }这里有一个容易出错的地方订单状态必须过滤只统计已支付、已出票和已检票的记录。待支付订单超时未付款如果不释放座位会导致虚占库存。我用了Spring Boot自带的Scheduled做一个清理任务每五分钟扫一遍超过十五分钟的待支付订单把它改成已取消同时释放座位。这个方案对于农村客运的并发量完全够用不需要引入Redis分布式锁。2.2 定时任务自动生成班次计划和清理过期订单农村客运的班次不是乘客今天买票就今天随意发车而是客运公司每天晚上得确定第二天的发车安排。系统里我加了一个“智能排班”的任务每天凌晨两点扫描未来三天的季节性需求根据历史同期的售票数据生成建议班次调度员在管理端确认后自动发布。实现上用的是Spring Boot的Scheduled注解没啥高深的。但这里有个问题是任务执行时机不固定如果某天系统重启重启时的初始化阶段可能不会触发当天凌晨的排班。所以我把生成排班的逻辑单独抽成接口在应用启动完成后调用一次Component public class ScheduleGenerateTask { PostConstruct public void init() { generateNextThreeDays(); } Scheduled(cron 0 0 2 * * ?) public void generateNextThreeDays() { // 每年节假日日期存在基础表里这里只处理普通日期 ListDate dates new ArrayList(); dates.add(LocalDate.now().plusDays(1).toDate()); dates.add(LocalDate.now().plusDays(2).toDate()); dates.add(LocalDate.now().plusDays(3).toDate()); for (Date date : dates) { buildScheduleForDate(date); } } }PostConstruct和Scheduled用了同一个方法工具类里我实际写的是generateNextThreeDays()里循环调用buildScheduleForDate这里为了示例简化了。要注意的是定时任务默认在单线程执行器里串行跑如果多个任务相互依赖最好直接用Scheduled加固定延迟而不是cron表达式。我这里的排班任务涉及查历史表、生成多条班次记录执行时间可能超过几秒但如果任务被上一次执行卡住Spring Boot默认会等上一次跑完再执行下一次目前没出过问题。2.3 车辆进出站状态变更与数据闭环班车从始发站发车、沿途停靠、最后到底站这个状态流转如果只靠人工修改极容易出现漏改错改。我设计了一个“到站打卡”机制驾驶员端App上有一个简单的打卡按钮车辆到达站点后点击后端就把schedule_station的status更新为已到达并记录实际到达时间。这个模块涉及两个技术点一个是怎么保证同一班次不会被重复打卡另一个是怎么计算前后站点顺序。我在schedule_station表里加了status字段和实际到达时间字段打卡接口传入scheduleId和stationId后端先检查这条站点记录的station_order再和当前最新进度比较。只有当天处于“待发车”或“运行中”状态且当前打卡站点的order大于上一打卡站点的order才允许更新。为了减少司机误操作我做了接口幂等处理。同一个站点重复提交如果发现该站点已经是已到达就直接返回成功不重复写时间。同时用数据库的唯一索引兜底防止极端情况下的重复插入。状态枚举我放在Java里用常量类管理不直接写魔法值public class ScheduleStatus { public static final int PENDING 0; public static final int RUNNING 1; public static final int ARRIVED 2; public static final int CANCELED 3; }为什么不用数据库字典表因为状态只会在代码里判断不参与复杂查询建表反而多一次关联查询得不偿失。但订单的支付状态和班次状态不同支付状态涉及对账和退款流程我特意在数据库表里加了comment注释并且用tinyint存储因为状态枚举值就几个用字符串反而浪费空间。2.4 自动装配原理与自定义系统参数配置Spring Boot最核心的能力就是自动装配。稍微懂点原理的人都知道主类上的SpringBootApplication包含EnableAutoConfiguration它会去读spring.factories或AutoConfiguration.imports文件里注册的配置类。我在这部分没有做深度的二次开发但利用Spring Boot的ConfigurationProperties做了一套自定义参数绑定效果很好。前面提到的基础参数表sys_config我写了一个配置类Component ConfigurationProperties(prefix bus.config) public class BusConfig { private int expireMinutes 15; private String servicePhone 12328; private int maxAdvanceDays 3; // getter/setter 省略 }然后在service里注入这个Bean读取参数就直接用。参数需要动态修改时我在管理端提供一个编辑接口把新值写入sys_config表同时更新这个Bean的内存值。这里我要吐槽一个Spring Boot初学者容易犯的错把ConfigurationProperties用在Controller上这种做法虽然也能跑但会把配置逻辑和接口业务混在一起后面根本没法维护。配置类就应该单独一层业务代码只通过getter拿值要改数据源就改配置类要改业务规则就改service互不影响。再往下说自动装配的原理其实不复杂但很多人面试被问“为什么Spring Boot能自动配置”就卡住。我自己的理解是Spring Boot默认把Tomcat、数据源、MyBatis这类组件都整理成一个个AutoConfiguration类每个类上都有ConditionalOnMissingBean这类条件注解你只要往容器里手动放一个自定义Bean自动配置的那个就自动退让。这也是为什么我们在pom里引了spring-boot-starter-data-redis但没写任何Bean配置RedisTemplate就自动能用。3. 实操过程从0到1搭起来的完整流程3.1 项目初始化与依赖版本选择创建项目我用的是Spring Initializr但专门没在IDEA里用内置的Spring Boot 3.2模板而是直接选Spring Boot 2.7.18。原因很实际公司技术栈其他老项目都是JDK 8和Spring Boot 2.x如果我一上来用JDK 17和Spring Boot 3.x会导致打包部署环境和现有运维脚本全部不兼容。另外MyBatis官方starter在2.x版本下的适配最稳。我在pom.xml里放的依赖是这样的parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql-connector-j/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里有个特别需要注意的点Spring Boot 2.7.18默认连接的MySQL驱动是mysql-connector-j但一些教程还在让你引入mysql-connector-java这两个其实是指向同一份代码只是坐标不同。引入老坐标虽然也能跑但IDEA会提示版本过低而且部分新功能不可用。为了避免新手抄错我统一用新坐标。Java版本我选的JDK 8。虽然JDK 17已经出来好多年但很多乡镇客运公司的服务器还是CentOS 7自带OpenJDK 8我用JDK 17编译的jar包放上去直接跑不起来。如果项目是从零开始并且能完全掌控服务器环境那用JDK 17当然更好但在系统集成场景里兼容旧环境的价值远大于新语法特性带来的那点便利。3.2 核心数据库表与实体类设计我直接给出几张核心表的建表SQL你在自己项目里可以直接改表名复用。CREATE TABLE bus_line ( id bigint(20) NOT NULL AUTO_INCREMENT, line_name varchar(100) NOT NULL COMMENT 线路名称, start_station_name varchar(100) NOT NULL, end_station_name varchar(100) NOT NULL, base_fare decimal(8,2) DEFAULT 0.00, status tinyint(4) NOT NULL DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT农村客运线路表;CREATE TABLE bus_schedule ( id bigint(20) NOT NULL AUTO_INCREMENT, line_id bigint(20) NOT NULL, depart_date date NOT NULL, depart_time time NOT NULL, arrive_time time DEFAULT NULL, vehicle_id bigint(20) DEFAULT NULL, driver_name varchar(50) DEFAULT NULL, driver_phone varchar(20) DEFAULT NULL, total_seats int(11) NOT NULL DEFAULT 19, status tinyint(4) NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_line_date (line_id, depart_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT班次表;CREATE TABLE bus_schedule_station ( id bigint(20) NOT NULL AUTO_INCREMENT, schedule_id bigint(20) NOT NULL, station_id bigint(20) NOT NULL, station_order int(11) NOT NULL COMMENT 停靠顺序从1开始, plan_arrive_time time DEFAULT NULL, plan_depart_time time DEFAULT NULL, actual_arrive_time time DEFAULT NULL, status tinyint(4) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_schedule_station (schedule_id, station_order) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT班次停靠站点明细;实体类我偷懒用了Lombok的Data字段和表字段保持一致。这里有个小坑是MySQL里bus_schedule表的status字段命名和MyBatis的自动驼峰转换没有任何冲突但你如果在代码里写Status这种不规范命名MyBatis的驼峰映射就会失效。我统一把小写字母和下划线作为数据库字段标准实体字段用驼峰二者能自动匹配。3.3 业务层与接口层实现示例以乘客端查询班次列表为例Controller返回统一结构ResultT这个类很简单就是code、msg、data三个字段。RestController RequestMapping(/api/schedule) public class ScheduleController { Autowired private ScheduleService scheduleService; GetMapping(/list) public ResultPageResultScheduleDTO list(ScheduleQuery query) { return Result.success(scheduleService.querySchedules(query)); } }查询条件接收类我觉得用普通POJO就行不需要在Controller参数上加奇怪注解。Data public class ScheduleQuery { private Long lineId; private Long fromStationId; private Long toStationId; private LocalDate departDate; private String timeRange; // morning/afternoon/evening private int pageNum 1; private int pageSize 10; }MyBatis的Xml里写的是动态SQL三表关联查询。为了不让SQL查询结果映射到DTO时出现多层嵌套我直接用Map放中间结果然后在service层组装成DTO。农村客运数据量不大这种麻烦写法性能完全够但代码可读性比纯SQL映射好很多。public PageResultScheduleDTO querySchedules(ScheduleQuery query) { if (query.getFromStationId() null || query.getToStationId() null) { throw new BizException(请选择起终点站点); } ListScheduleDTO list scheduleMapper.selectScheduleList(query); return PageResult.of(list, query.getPageNum(), query.getPageSize()); }分页我用了PageHelper用法就一句话。我不推荐在Mapper里手写limit #{offset}, #{pageSize}因为那样统计总数和列表查询要写两遍SQL很容易出现总数和列表参数不一致的Bug。3.4 前端Vue打包后放进Spring Boot的实际操作Vue前端我用的是Vue 3 Element Plus开发时通过代理转发请求到后端上线时我没有单独部署Nginx而是把Vue打包后的静态文件直接放进Spring Boot的src/main/resources/static目录。这样做的好处是运维少维护一个服务整个系统一个jar包就能跑。具体操作步骤npm run build构建完的dist目录里会有index.html、static文件夹等把dist里的所有东西复制到src/main/resources/static根目录下。这时再去访问http://localhost:8080/就能看到前端页面。第一次这样做时会遇到两个问题。第一个是刷新页面404因为Vue Router如果开了history模式后端没有配置转发规则。解决办法有两个一是前端改用hash模式URL路径里带个#不做服务端路由处理二是在后端写一个转发Controller把非/api开头的路径都转发到forward:/index.html。我图省事直接用了hash模式上线后没人会在意地址栏好不好看。第二个问题是静态资源缓存。前端每次发布后浏览器里旧的JS文件会被缓存住导致页面更新不及时。我在application.yml里加了静态资源缓存控制发布时手动清理浏览器缓存。如果是正式环境我建议引入一个版本号参数每次发布后改一下请求地址的参数简单粗暴但有效。我贴一下application.yml的关键配置server: port: 8080 spring: mvc: static-path-pattern: /** resources: static-locations: classpath:/static/因为默认就是classpath:/static/所以这段配置其实可以不加但显式写出来能让团队里不熟悉的人一眼看懂前端文件放哪。这也是我在项目文档里坚持留下来的原因。4. 常见问题与排查经验实录4.1 Spring Boot版本太高引起的javax/jakarta包名坑这个坑我猜遇到的人不少。我用IDEA新建项目时默认选了Spring Boot 3.2.0结果项目里引入的很多老依赖全部报错最典型的是javax.servlet包找不到变成了jakarta.servlet。这是因为Spring Boot 3.x把Java EE的命名空间从javax改成了jakarta很多第三方库如果还在用javax编译时根本过不了。我当时的做法是直接把Spring Boot版本降到2.7.18。如果项目是非用3.x不可的那你必须把所有依赖一起升级到适配jakarta的版本比如MyBatis Starter要用3.0.3以上。这个改动看似简单但会牵连到shiro、redis连接池、swagger等多个依赖成本反而高。对于毕业设计或者企业内部小型系统我的建议是用Spring Boot 2.7.x不要盲目追求新版本。新版本带来的特性你大概率用不上但兼容性问题会实实在在消耗你的时间。4.2 MyBatis SQL多表查询与分页翻车我在查询班次列表时写了三表关联SQL用了PageHelper分页。第一次跑测试时发现数据总数对不上仔细排查后才发现原因PageHelper会在执行查询前自动拦截并生成count语句但如果SQL里包含了group by或子查询生成的count语句很可能不准确。我当时的SQL是SELECT s.id, s.depart_time, ss1.station_name AS from_station, ss2.station_name AS to_station, COUNT(o.id) AS sold_count FROM bus_schedule s LEFT JOIN schedule_station ss1 ON ... LEFT JOIN schedule_station ss2 ON ... LEFT JOIN bus_order o ON ... GROUP BY s.id, ss1.station_name, ss2.station_namePageHelper生成的count是SELECT count(0) FROM (原来的SQL)但原来的SQL里有GROUP BYcount出来的却是分组后的行数而不是原始记录数。这个逻辑在MySQL里有时能蒙对但数据一多就乱了。后来我改用MyBatis的PageInterceptor自定义实现或者干脆在SQL外套一层子查询手动分页。最稳妥的办法是不要在复杂SQL上依赖PageHelper自动count而是用两个独立方法queryList和queryCount自己控制参数。4.3 跨域、Session与基于Token的登录鉴权前端开发时我用Vue的devServer.proxy解决跨域但打包放进Spring Boot后就不再跨域了因为前后端同源。可如果管理端和乘客端部署在不同域名下跨域还是躲不掉。我最早用Session做了登录结果发现跨域请求时Cookie带不上。后来改成JWT Token方案登录接口返回一个Token字符串客户端存到localStorage每次请求在Header里加Authorization: Bearer xxx。后端写一个拦截器解析Token并校验有效期同时把用户信息放到ThreadLocal里方便业务层获取当前操作人。写拦截器时有个细节Component public class JwtTokenInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); // 校验逻辑 return true; } }静态资源请求也要过滤掉不然访问前端页面会被拦住。我通过在addInterceptors里配置excludePathPatterns(/static/**, /index.html)解决。4.4 定时任务重复执行与并发问题前面讲了用Scheduled做自动排班但单机运行没问题一旦部署两个实例做负载均衡同一条凌晨2点的任务会在两个机器上同时跑产生重复数据。我在正式环境用了定时任务加分布式锁的方案执行任务前先往Redis里写一个带过期时间的key只有写入成功的实例才能继续执行另一个实例直接跳过。代码大概是Scheduled(cron 0 0 2 * * ?) public void generateTask() { boolean locked redisTemplate.opsForValue() .setIfAbsent(task:schedule:gen, 1, 5, TimeUnit.MINUTES); if (!locked) { return; } try { doGenerate(); } finally { redisTemplate.delete(task:schedule:gen); } }这里要特别注意锁的过期时间必须大于任务最大执行时间。我前面第一次设了10秒结果任务跑了30秒第二个实例在锁过期后又进来跑了一遍数据库立刻出现重复班次。后来改成5分钟问题解决。4.5 宝塔部署Spring Boot服务的推荐流程客户现场服务器装的是宝塔面板我一开始直接上传jar包在宝塔的“Java项目”管理里设置启动命令效果可以但手动操作步骤多。后来我改用Docker部署把构建和分发都简化了。在项目根目录写一个DockerfileFROM openjdk:8-jre-slim COPY target/rural-bus-system.jar /app/app.jar WORKDIR /app EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]构建镜像docker build -t rural-bus-system .宝塔装好Docker后在Docker管理器里创建容器映射80端口到8080再把宿主机的/data/bus挂载到容器里用来存日志和上传的图片。这样后期升级只重新构建镜像容器配置完全不变。用Docker部署要注意时间问题容器默认UTC时区定时任务执行时间和本地差8小时。我在Dockerfile里加上ENV TZAsia/Shanghai这个不写的话凌晨2点排班任务会变成北京时间上午10点才跑轻则延后重则影响当天的数据生效。从设计到上线这套系统整体过程走了大概一个月其中有三天时间专门花在处理MyBatis分页和Spring Boot版本兼容上。现在我仍然觉得农村客运这种传统业务场景技术本身不是瓶颈真正难的是把模糊的业务规则翻译成数据库表和接口逻辑。比如“区间余票”这个概念不跟司机聊上两小时永远想不到座位重叠的问题。如果后面要扩展这个项目我会先把第三方支付接进来把线上售票和线下补票的账目完全打通。另外可以考虑给乘客端加一个实时位置跟踪功能但这需要车辆GPS设备配合属于硬件投入的问题了。至少在当前这个版本里稳定运行了几个月的服务已经说明Spring Boot认认真真做业务管理系统是能扛住事的。