
做航班进出港系统之前我第一反应是“这不就是个CRUD吗”。真正动手之后才发现航班动态管理比普通业务系统难在数据一致性、状态流转和并发控制上。同一个机位进港航班延误了出港航班要不要顺延登机口怎么排这些牵一发动全身的逻辑不是一张表能解决的。这篇文章从一个可运行的基于SpringBootVue的航班进出港管理系统实现过程说起后端用JavaMySQLMyBatis前端用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 idqueryFlightDynamicPage resultTypecom.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 testflightNo ! null and flightNo ! AND fp.flight_no LIKE CONCAT(%, #{flightNo}, %) /if if teststatus ! null AND fd.status #{status} /if if testairportId ! null AND (fp.departure_airport_id #{airportId} OR fp.arrival_airport_id #{airportId}) /if if teststartTime ! null AND fd.plan_takeoff_time gt; #{startTime} /if if testendTime ! null AND fd.plan_takeoff_time lt; #{endTime} /if /where ORDER BY fd.plan_takeoff_time DESC LIMIT #{offset}, #{pageSize} /selectwhere标签会智能去掉第一个条件前面的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 BaseTypeHandlerFlightStatus { 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 UPDATESELECT 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 testairportId ! null and airportId ! 1 AND fp.departure_airport_id #{airportId} /if前端传来的任何airportId参数都不直接作为过滤条件而是以Token里的用户数据为准。这样可以防止垂直越权。菜单权限解决的是“能不能看到页面”数据权限解决的是“能看到哪些数据”两者不能互相替代。6. 部署与问题排查从开发机到服务器的完整链路6.1 Vue打包放进SpringBoot的两种方式项目最终要部署成一个可直接运行的Jar包最常见的方式是把Vue构建产物放进SpringBoot的静态资源目录。操作步骤Vue项目执行npm run build生成dist目录。把dist内的全部文件拷贝到src/main/resources/static目录。重新用Maven打包。如果觉得每次手动拷贝太麻烦可以用maven-resources-plugin配置自动拷贝把前端dist指定为额外资源目录。我更推荐后一种因为可以做到前端构建完之后直接mvn clean package一键出包。6.2 Vue Router history模式刷新404问题前端开发时用的是history路由刷新页面没问题。但打包放进SpringBoot后直接刷新一个非首页的路径就会404。因为SpringBoot默认只把/index.html作为首页而history模式下其他路径是前端路由没有对应物理文件。解决办法是在SpringBoot里写一个路由转发把非/api的路径转发到/index.htmlController 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?useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 directorysrc/main/resources/directory /resource resource directorysrc/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写出来那确实很轻松但当你把状态机、权限模型和并发控制都考虑进去之后这个系统的含金量就完全不一样了。希望这篇梳理能帮你少走几步弯路。