news 2026/10/3 3:05:56

SpringBoot+Vue航班进出港系统实战:状态机与并发控制全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue航班进出港系统实战:状态机与并发控制全解析

做航班进出港系统之前,我第一反应是“这不就是个CRUD吗”。真正动手之后才发现,航班动态管理比普通业务系统难在数据一致性、状态流转和并发控制上。同一个机位,进港航班延误了,出港航班要不要顺延?登机口怎么排?这些牵一发动全身的逻辑,不是一张表能解决的。这篇文章从一个可运行的基于SpringBoot+Vue的航班进出港管理系统实现过程说起,后端用Java+MySQL+MyBatis,前端用Vue,把表结构设计、动态SQL写法、Vue路由与轮询、最后部署踩坑都完整梳理一遍。适合正在做毕业设计,或想快速搭一套机场内部管理后台的同学参考。

1. 航班进出港系统的需求边界:别一开始就想着大而全

1.1 先理清四个角色,再谈功能菜单

航班进出港系统的核心不是“增删改查”本身,而是围绕航班生命周期做状态管理。我一开始顺着网上通用后台模板把菜单列了一大堆,航班管理、旅客管理、行李管理、机场管理、统计报表……看着很全,实际上根本没法落地。后来换了个思路,先从使用系统的人入手,梳理出四个角色:

  • 调度员:维护航班计划,查看进出港动态,处理延误和取消。
  • 地勤人员:负责机位分配、登机口调整,确认航班实际到达/起飞时间。
  • 值机与问询人员:录入旅客人数、行李件数,在系统里查询航班状态用于现场广播和问询。
  • 系统管理员:维护机场基础数据、用户账号、角色权限、数据字典。

每个角色的使用场景不一样,功能菜单自然不一样。调度员要的是“全天航班时间轴”,地勤要的是“机位占用情况表”,问询岗要的是“某个航班当前状态”。如果你把这几个场景画成原型图,功能边界就很清楚了。

1.2 明确做哪些,不做哪些

我最后敲定的功能范围是:航班计划管理、进出港动态管理、机位与登机口分配、延误原因登记、旅客与行李数据关联、统计报表。这些功能全部围绕“进出港”这个生命周期展开。

不做的功能也写进了文档:自动对接空管雷达数据、航司运价、天气系统。因为这些需要外部数据源,而且实时性要求极高,已经超出系统本身的定位。可以预留接口,但不要把毕设或内部工具做成一个伪大厂系统。边界画清楚之后,开发周期至少节省一半。

1.3 非功能需求同样重要

技术上,这类系统的并发量不高,MySQL完全够用,不需要一上来就上Redis和消息队列。但有两个非功能需求必须重视:一是状态数据的准确性,二是操作的可追溯。航班状态一旦更新错,现场人员就会误判,所以后端必须做状态流转合法校验,不能允许“已起飞”直接跳回“计划”。所有关键操作要记录操作人和操作时间,后面追责和复盘都用得上。

2. 数据库模型设计:航班计划与航班动态为什么要拆成两张表

2.1 核心表结构拆解

航班数据最大的特点是一张计划对应多天的实际执行。比如“MU5101”每天都有一次航班,但每天的起飞时刻、机位、登机口都可能不同。如果只建一张表,把计划信息和当天执行信息混在一起,数据冗余会非常严重。所以我把核心数据拆成flight_plan和flight_dynamic两张表。

  • flight_plan:航班号、航空公司、起飞机场、到达机场、计划起飞时间、计划到达时间、机型、班期。这是相对静态的数据。
  • flight_dynamic:关联flight_plan_id,记录航班日期、实际起飞时间、实际到达时间、预计起飞/到达时间、当前状态、机位ID、登机口ID、延误原因ID、操作人、操作时间。

这样设计的好处是,查询当天进出港航班时只需要查flight_dynamic,再关联flight_plan取航班基本信息。统计某条航线一个月准点率时,直接统计flight_dynamic表,效率高,逻辑也清晰。

2.2 状态字段的状态机设计

航班状态是这类系统的灵魂。我在数据库里用一个TINYINT字段表示状态,不直接用字符串枚举,因为数据库层面数字比较更快,也方便索引。状态定义如下:

状态值含义说明
1计划航班尚未开始办理值机
2值机/登机中允许机位和登机口操作
3已起飞出港航班离开停机位
4已降落/已到达进港航班到达停机位
5延误可以记录延误原因
6取消终止本次航班

状态流转不是随意的。我在Service层写了一个状态校验方法,只允许“计划→登机中→已起飞”或“计划→延误→取消”这类合法路径。如果接口请求里传入非法流转,比如“已起飞→登机中”,直接抛业务异常。数据库层的CHECK约束在不同数据库迁移时不兼容,所以状态机校验放在代码里更适合。

2.3 索引到底怎么建

刚开始建索引容易犯两个毛病:要么只在主键上建索引,结果列表查询慢得离谱;要么每个字段都建索引,写入变慢还占空间。航班动态列表最常见的过滤条件是“航班日期 + 航班号”和“状态”。所以我建了两个组合索引:

  • idx_flight_date_no(flight_date, flight_no),优先给日期,因为业务查询必然带日期。
  • idx_status(status),状态过滤场景也比较多。

机位分配场景会按机位号查占用情况,我给机位表的stand_no建了唯一索引。因为同一时刻一个机位只能分配给一个航班。

分区和分表在这个量级完全没必要,一个机场一天进出港航班也就是几百架次,MySQL单表处理百万级数据都很轻松。不要过度设计。

2.4 航班动态历史与统计

航班动态一旦被更新,旧的记录需要留存审计。我的方案是单独建一张flight_dynamic_log,通过触发器或者业务代码在每次状态变更时插入一条日志。这样flight_dynamic表只保留当前最新状态,查询性能好,日志表留着追溯。

统计报表不需要实时计算准点率,我写了一个定时任务,每天凌晨把前一天的数据汇总到daily_flight_stat表,统计起飞架次、降落架次、准点率、平均延误时长等指标。前端图表直接从这个汇总表读数据,避免每次报表都扫全表。

3. 后端实现:SpringBoot整合MyBatis的四个关键细节

3.1 从MyBatis初始化机制看“mapper未找到”问题

很多新手第一次整合SpringBoot + MyBatis时,总会遇到Invalid bound statement (not found)。要搞懂这个错误,得先明白MyBatis在SpringBoot里的启动过程。

MybatisAutoConfiguration会创建SqlSessionFactory,核心是SqlSessionFactoryBean。它通过XMLConfigBuilder解析mybatis-config.xml,再扫描所有Mapper接口和对应的XML文件。如果你的Mapper XML文件没有被打包到classes目录,或者mapper-locations路径配置不对,Spring容器里只有接口代理,找不到真正执行SQL的映射语句,就报这个错。

我的解决方案是在application.yml里显式配置:

mybatis: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.flight.entity configuration: map-underscore-to-camel-case: true

另外注意把XML文件放在src/main/resources/mapper下,而不是Java目录下。因为Maven默认不会把Java目录下的XML打进Jar包,如果非要放Java目录,必须额外配置resource打包规则。这是打包部署才会踩到的大坑,后面专门说。

3.2 动态SQL处理复杂查询条件

航班列表查询的字段是动态的:用户可能按航班号模糊查、按状态精确查、按机场查、按时间段查。如果每个条件组合都写一个SQL,代码会爆炸。MyBatis动态SQL的<where>标签正好解决这个问题。

<select id="queryFlightDynamicPage" resultType="com.example.flight.vo.FlightDynamicVO"> SELECT fd.id, fp.flight_no, fp.airline_name, fd.flight_date, fd.plan_takeoff_time, fd.actual_takeoff_time, fd.status, fd.gate_id, fd.stand_id FROM flight_dynamic fd LEFT JOIN flight_plan fp ON fd.plan_id = fp.id <where> <if test="flightNo != null and flightNo != ''"> AND fp.flight_no LIKE CONCAT('%', #{flightNo}, '%') </if> <if test="status != null"> AND fd.status = #{status} </if> <if test="airportId != null"> AND (fp.departure_airport_id = #{airportId} OR fp.arrival_airport_id = #{airportId}) </if> <if test="startTime != null"> AND fd.plan_takeoff_time &gt;= #{startTime} </if> <if test="endTime != null"> AND fd.plan_takeoff_time &lt;= #{endTime} </if> </where> ORDER BY fd.plan_takeoff_time DESC LIMIT #{offset}, #{pageSize} </select>

<where>标签会智能去掉第一个条件前面的AND,这是我用得最多的动态SQL之一。注意时间比较符在XML里要写成&gt;=和&lt;=,不然XML解析器会报错。分页用LIMIT offset, pageSize在数据量不大时足够,如果数据量大了再考虑PageHelper。

3.3 TypeHandler处理状态枚举

数据库存储状态是数字,Java代码里我希望直接用枚举对象操作。MyBatis默认处理枚举时会调用name()方法,把枚举名字存成字符串。但我的字段是TINYINT,这样就不匹配了。

自定义一个FlightStatusTypeHandler是最好的办法:

@MappedTypes(FlightStatus.class) @MappedJdbcTypes(JdbcType.TINYINT) public class FlightStatusTypeHandler extends BaseTypeHandler<FlightStatus> { @Override public void setNonNullParameter(PreparedStatement ps, int i, FlightStatus parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getCode()); } @Override public FlightStatus getNullableResult(ResultSet rs, String columnName) throws SQLException { int code = rs.getInt(columnName); return FlightStatus.of(code); } @Override public FlightStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException { int code = rs.getInt(columnIndex); return FlightStatus.of(code); } @Override public FlightStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { int code = cs.getInt(columnIndex); return FlightStatus.of(code); } }

然后在接口方法上用@TypeHandler标注,或者在mybatis-config.xml里注册。这样Service层写flightDynamic.getStatus() == FlightStatus.已起飞就很自然,不需要到处拿int做switch。如果项目里还有数据字典,比如延误原因编码,可以用同样的方式做CodeEnumTypeHandler。

3.4 机位分配的并发控制

机位分配是一个典型的并发问题。两个地勤人员同时操作,可能把同一个机位分配给两个航班。单纯在Service方法加@Transactional是不够的,事务只保证“要么都成功要么都失败”,但不能避免两个事务同时读到机位空闲。

我在机位表里增加了一个status字段(0占用、1空闲),分配机位的SQL必须加FOR UPDATE:

SELECT id, stand_no, status FROM stand WHERE stand_no = #{standNo} LIMIT 1 FOR UPDATE;

这个查询会把机位行锁住。第二个事务执行到同一行时必须等待第一个事务提交或回滚。拿到锁之后再检查status是否为1,如果是1才更新为0并关联航班动态。虽然性能不高,但机场同时抢机位的场景很少,悲观锁完全够用。如果以后要提升并发,可以改成乐观锁version字段,但那是另一个故事了。

3.5 二级缓存为什么在航班系统里要慎用

MyBatis的一级缓存默认是SqlSession级别的,同一个SqlSession内重复查询会走缓存,但因为每次请求都会新建SqlSession,一级缓存基本没有跨请求影响。二级缓存如果开启,就是跨SqlSession共享的,问题就来了。

航班状态是一个强实时数据,地勤刚把状态改成“已起飞”,如果另一个查询接口串行读到了二级缓存里的旧状态,现场人员就会看到错误信息。虽然MyBatis会在执行任意更新语句时刷新缓存,但在高并发和复杂分布式环境下,缓存刷新时机很难控制。我的建议是:航班动态这类强实时数据,一律不用MyBatis二级缓存。批量查询其他静态数据(比如机场列表)可以自己用Caffeine做短时间缓存。

4. Vue前端组织航班数据的正确姿势

4.1 项目结构与路由设计

我的前端用Vue 3 + Vite + Pinia + Vue Router。项目结构按功能划分,而不是按技术类型堆文件:

src/ ├── api/ │ ├── flight.js │ ├── auth.js │ └── user.js ├── router/ │ └── index.js ├── store/ │ ├── user.js │ └── app.js ├── views/ │ ├── flight/ │ │ ├── FlightDynamicList.vue │ │ ├── FlightPlanList.vue │ │ └── FlightDetail.vue │ ├── stand/ │ │ └── StandAllocation.vue │ └── dashboard/ │ └── Dashboard.vue └── components/

路由没有把全量页面写死在router/index.js里,而是在用户登录后根据后端返回的菜单权限动态生成。这么做的好处是,不同角色登录看到的菜单天然不一样,前端的路由表也避免暴露未授权页面。

4.2 航班动态页的轮询与数据刷新

航班动态页不能靠手动刷新按钮,现场人员需要实时看到状态变化。我做了两个方案:最简单的是30秒轮询一次查询接口,在每个页面组件里写一个setInterval,拿到最新数据直接替换列表数据。

这里有个非常容易踩的坑:组件销毁时忘了清除定时器。Vue组件用了路由切换,页面只是暂时隐藏,但定时器还在后台继续请求,白白消耗服务器资源。所以一定要在onUnmounted里clearInterval。如果项目后续要做真正的大屏展示,可以把轮询改成WebSocket推送,但内部管理系统用轮询已经足够。

4.3 axios封装与异常处理

前后端联调阶段,我统一封装了axios实例,设了baseURL、超时时间、请求拦截器自动加Authorization头,响应拦截器统一处理HTTP状态码和业务码。业务异常弹提示,登录过期跳登录页,网络错误单独提示。

import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response?.status === 401) { router.push('/login') } ElMessage.error(error.message || '请求失败') return Promise.reject(error) } )

因为航班列表接口返回结构统一,封装之后所有页面调接口都不需要重复处理 loading 和 error,代码干净很多。

4.4 值得留的扩展点:航班大屏和视频流

如果以后要把这个后台扩展成航站楼大屏展示,可以在Vue前端接入视频流。最近很常见的做法是直接用video.js或hls.js播放m3u8格式流,Vue里播放m3u8免安装确实是hls.js最方便,不用额外插件。不过这是业务边界外的东西,我在项目里只预留了ScreenPlayer.vue的空组件,接口都封装好了,等真需要接入的时候再补。

5. 权限认证与数据权限:别再只用前端路由守卫防越权

5.1 JWT方案的选择

很多毕设项目还在用Session保存登录态,前后端分离部署时跨域和会话管理都比较麻烦。我选了JWT,后端登录成功签发Token,前端每次请求带Authorization头。JWT的好处是服务端无状态,多实例部署不用做Session共享。

JWT有几个要注意的点:密钥不能硬编码成简单字符串,我用的是AES随机密钥并放在配置中心;过期时间设成2小时,另提供刷新接口。出于安全考虑,Token里只放用户ID和角色编码,不放敏感信息。

5.2 后端拦截器校验

即使前端有动态路由,后端接口也必须单独校验权限。我在SpringBoot里实现了一个AuthInterceptor,拦截所有/api/**请求,从Header解析Token,校验签名和过期时间,然后把用户信息放入ThreadLocal供Service层使用。

对于角色权限,我做了基于注解的@RequireRole("dispatcher")简单方案。在HandlerMethod上扫描注解,如果当前角色不在允许列表里,直接返回403。真正生产级方案是集成Spring Security或Sa-Token,但做一个内部管理系统,灵活轻量的拦截器更省心。

5.3 数据权限:只看本机场还是看全局

如果系统只服务一个机场,数据权限可以不考虑。但不少高校毕业设计喜欢做成多机场版本,这时候必须防止用户通过改接口参数看到其他机场的数据。我采取的做法是在后端从Token里解析出当前用户的airportId,在查询SQL里自动拼接:

<if test="airportId != null and airportId != 1"> AND fp.departure_airport_id = #{airportId} </if>

前端传来的任何airportId参数都不直接作为过滤条件,而是以Token里的用户数据为准。这样可以防止垂直越权。菜单权限解决的是“能不能看到页面”,数据权限解决的是“能看到哪些数据”,两者不能互相替代。

6. 部署与问题排查:从开发机到服务器的完整链路

6.1 Vue打包放进SpringBoot的两种方式

项目最终要部署成一个可直接运行的Jar包,最常见的方式是把Vue构建产物放进SpringBoot的静态资源目录。操作步骤:

  1. Vue项目执行npm run build,生成dist目录。
  2. 把dist内的全部文件拷贝到src/main/resources/static目录。
  3. 重新用Maven打包。

如果觉得每次手动拷贝太麻烦,可以用maven-resources-plugin配置自动拷贝,把前端dist指定为额外资源目录。我更推荐后一种,因为可以做到前端构建完之后直接mvn clean package一键出包。

6.2 Vue Router history模式刷新404问题

前端开发时用的是history路由,刷新页面没问题。但打包放进SpringBoot后,直接刷新一个非首页的路径就会404。因为SpringBoot默认只把/index.html作为首页,而history模式下其他路径是前端路由,没有对应物理文件。

解决办法是在SpringBoot里写一个路由转发,把非/api的路径转发到/index.html:

@Controller public class ForwardController { @RequestMapping(value = {"/", "/{path:[^\\.]*}", "/{path:[^\\.]*}/**"}) public String forward() { return "forward:/index.html"; } }

注意这个转发不能拦截/api,否则会覆盖后端接口。普通页面请求交给前端路由,前端再去调接口。如果觉得麻烦,也可以把Vue路由改成hash模式,就不会有刷新404问题,代价是URL多一个#,看你们接受程度。

6.3 MySQL 8连接SSL与Public Key Retrieval错误

MySQL 8默认认证插件是caching_sha2_password,很多同学在SpringBoot连接时报Public Key Retrieval is not allowed或者SSL连接错误。解决办法是在JDBC URL上显式关闭SSL并允许获取公钥:

jdbc:mysql://localhost:3306/flight?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8

serverTimezone=Asia/Shanghai一定要加,否则默认时区可能和本地时间对不上,查出来时间差8小时。这个配置在新手项目里出现频率极高,强烈建议直接写入模板。

6.4 Maven打包时MyBatis XML文件丢失问题

本地IDEA运行一切正常,一到服务器执行java -jar就报“Invalid bound statement not found”,原因大概率是Mapper XML没有被打进Jar包。我在前面提到过,把XML放在resources/mapper下通常没问题。但如果因为某些原因XML是放在Java目录里的,必须在pom.xml的build节点里手动指定资源目录:

<build> <resources> <resource> <directory>src/main/resources</directory> </resource> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources> </build>

打包之后可以用jar tf命令查看Jar包内是否存在mapper/目录下的XML文件,排查速度能快很多。

6.5 一次航班状态刷新太慢的排查链路

最后分享一个实际遇到过的故障。上线第二天,地勤反馈航班动态列表经常几分钟不更新,后来排查链路是这样的:

第一步,先看浏览器Network面板,发现前端轮询请求返回200但数据还是旧的,说明后端有缓存。第二步,查MyBatis配置,确认二级缓存没开,排除MyBatis缓存问题。第三步,看Redis,发现代码里在Service层用Redis缓存了航班列表,且过期时间写死了30分钟。第四步,查看定时任务,发现只有航班新增时会主动清理缓存,而状态变更时漏写了清理缓存逻辑。最后把状态变更接口改成先更新数据库,再删除对应Redis key,问题解决。

这个例子说明,排查缓存问题不要只盯一个层面,从前端轮询、后端接口、框架缓存、业务缓存一层层排除,效率最高。真正的项目里,80%的问题都出在“更新数据之后缓存没有同步”这种低级但隐蔽的地方。

整套系统前后改了半个月,最值钱的不是那几万行代码,而是把“航班状态一致性”“机位并发分配”“缓存与数据同步”这些坑一个一个填平的经验。如果你只是照着一篇教程把CRUD写出来,那确实很轻松;但当你把状态机、权限模型和并发控制都考虑进去之后,这个系统的含金量就完全不一样了。希望这篇梳理能帮你少走几步弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 3:05:55

从Cursor迁移到OpenCode:OpenSpec+Superpowers+Oh-My-OpenCode完整配置指南

最近我把自己的终端 AI 编码工作流从 Cursor 迁到了 OpenCode&#xff0c;顺带折腾了 OpenSpec、Superpowers、Oh-My-OpenCode 这套组合&#xff0c;前后花了两三个晚上才把整个链路跑顺。今天把完整的配置过程和踩过的坑整理出来&#xff0c;给同样想自建一套命令行 AI 工作流…

作者头像 李华
网站建设 2026/10/3 3:05:24

DSP+FPGA异构信号处理卡深度解析:TMS320C6678与XC7V690T协同设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 3:05:08

批处理与实时ETL选型指南:从时延、成本到架构决策

上个月帮一个做网约车综合数据分析的朋友做架构评审&#xff0c;他在要不要把订单指标从T1改成实时推送这件事上纠结了很久。业务方天天催"我要看实时订单量"&#xff0c;技术团队则担心实时链路搞下去会把数仓的底子搞乱。这其实不是我第一次碰到这种纠结了——几乎…

作者头像 李华
网站建设 2026/10/3 3:04:16

GEO生成式引擎优化入门与进阶:从AI搜索引用到品牌可见度提升指南

如果你最近在关注流量增长&#xff0c;大概率会刷到一个新词&#xff1a;GEO。别急着划走&#xff0c;我第一次看到这个词也以为是地理坐标的缩写&#xff0c;结果深入研究才发现&#xff0c;它很可能成为未来两三年里最值得投入的内容优化方向。GEO全称是Generative Engine Op…

作者头像 李华
网站建设 2026/10/3 3:03:26

Linux运维实战:高频指令与排查技巧全解析

1. 终端认知与最常用的文件操作指令1.1 从终端的第一条指令说起先说个定心丸&#xff1a;Linux的指令再多&#xff0c;日常工作中真正高频使用的&#xff0c;其实不超过三四十条。很多人一上来就去啃几百页的命令手册&#xff0c;结果记住的没几条&#xff0c;遇到问题还是到处…

作者头像 李华
网站建设 2026/10/3 3:01:59

SAP ECC停维护倒计时:迁移S/4HANA的关键行动与避坑指南

最近SAP圈子里的群聊和社区&#xff0c;反复刷到同一句话&#xff1a;留给SAP ECC的时间&#xff0c;只剩最后一年了。这话不是标题党&#xff0c;SAP官方的时间表写得很清楚&#xff0c;ECC 6.0的主流维护到2027年12月31日截止。也就是说&#xff0c;如果你所在的企业还在用EC…

作者头像 李华