
我先把这套系统的核心思路讲清楚。航班进出港管理系统简单说就是围绕航班的计划、到达、放行、离港全流程做业务管理的系统核心要解决三件事航班信息怎么组织、进出港状态怎么流转、运行数据怎么展示。这次我基于 SpringBoot Vue MyBatis MySQL 做了一版前后端分离实现功能上把航班信息管理、进出港动态、状态流转、数据看板、权限控制都覆盖到了代码不复杂但完整跑通了一条从建表到部署的链路。这篇文章就适合正在做同类毕设或中小型信息化项目的同学参考我会把表设计、状态机思路、关键代码、部署命令全部拆开讲也会把实操中踩过的坑一并放出来。其实这套系统最值得琢磨的不是哪个框架的写法而是业务状态怎么设计。很多人一上来就直接写增删改查结果状态一乱后面全乱。我的做法是先定义清楚进出港的每个阶段和状态迁移边界再去做代码实现。这样既保证了业务逻辑清晰又让前端的操作按钮能够跟着状态动态变化用户体验明显比硬编码要顺。1. 项目概述与核心需求拆解1.1 航班进出港业务到底管什么机场每天都有大量航班在进出港一个航班从计划到落地、再从放行到起飞中间至少要经历多个业务节点。比如进港方向要经过计划、空中、落地、入位、下客、卸行李、清洁补给、完成关单出港方向要经过计划、值机、登机、行李装载、舱单确认、推出开车、起飞离港。系统要做的就是把每个节点的信息结构化记录下来让值班人员能实时看到当前有哪些航班处于什么阶段以便做调度、广播和信息展示。这和普通 CRUD 系统有个根本区别航班数据不是简单的新增、删除、修改它带有一条明确的生命周期线。每个操作都要校验当前状态是否合法比如一架还没落地的航班不能直接置为“已完成”一架已经取消的航班也不能再执行登机操作。这类业务天然适合用状态机来设计而不是写一堆 if/else 去猜当前逻辑。1.2 技术栈选型背后的考虑我选 SpringBoot Vue MyBatis MySQL 这套组合不是因为它是默认答案而是它的分层和内置约定对这类管理系统特别合适。从后端看SpringBoot 把 Web 容器、配置、依赖管理这些琐碎的事都自动处理了打成一个 jar 直接能跑部署成本低。MyBatis 写业务 SQL 很灵活航班列表的多表联查、动态条件过滤比全自动 ORM 更好控制。项目规模不大不需要微服务那一套单体应用足够稳定也足够清晰。从前端看Vue3 Element Plus 做后台管理页面非常高效表格、表单、日期选择器、标签页这些组件开箱即用。航班看板部分我用 ECharts 做了几组图表展示进出港比例、准点率和各状态航班数量交互体验比传统模板页好很多。Axios 统一封装请求方便处理 token 和错误提示。从数据库看MySQL 存业务数据完全够用。时间字段用 DATETIME 存本地时间配合 JDBC 连接串里的时区参数避免数据库和应用端的时区转换问题。有一个选型点值得展开说说就是 MyBatis 和 JPA 到底怎么选。我做过不少同类项目单独列一下我的判断对比项MyBatisJPA / Hibernate复杂查询灵活性高SQL 完全可控低复杂查询要拼 Specification多表关联维护手动写 SQL直观实体关系映射层级深了难调学习曲线平缓会 SQL 就会用需要理解缓存、懒加载等概念性能调优直接控制 SQL好定位需要在缓存和懒加载之间权衡航班进出港这类系统最大的查询压力来自多条件列表和状态统计用 MyBatis 写动态 SQL 是最顺手的方式。JPA 适合实体关系非常稳定、查询模式简单的场景但管理系统的筛选条件千变万化我还是坚定选 MyBatis。1.3 这套实现适合哪些人参考如果你正在做毕设选题或者在一家中小型公司想快速搭一套类似的运营管理系统这套实现可以直接拿来做骨架。有一定 Java 基础但没写过完整前后端分离项目的同学跟着部署流程走一遍也能把整条链路跑通。后面我会把表结构、关键代码、部署命令都贴出来属于那种看完就能动手还原的程度。2. 系统架构与功能模块设计2.1 前后端分离的交互模型整个项目分成两个独立工程。后端跑在 8080 端口提供 RESTful 接口前端开发环境跑在 5173通过 Vite 代理转发接口请求生产环境用 Nginx 托管前端静态文件并做反向代理把 /api 请求转到后端。请求链路是浏览器 → Vue 页面 → Axios → Nginx → SpringBoot → MyBatis → MySQL这种结构的核心收益是前后端可以并行开发也方便后续把大屏、小程序、移动端接到同一套接口上。对于管理后台这种场景分离之后前端不需要关心后端业务逻辑后端也不需要在页面里拼 HTML职责边界非常清楚。2.2 功能模块怎么拆分我从业务角度把系统拆成六个模块每个模块对应一组后端接口和前端页面航班信息管理录入、编辑航班基础信息包括航班号、航空公司、机型、航站楼、登机口、机位等。进出港动态管理记录每个航班的计划时间、实际到达或起飞时间并实时展示当前状态。状态流转控制根据业务规则执行状态变更比如从“计划”到“落地”必须经过指定动作。数据看板统计当日进出港数量、延误数量、准点率用图表展示。用户与权限管理员、调度员、值机员三种角色控制接口访问范围。基础字典维护管理机位、登机口、航空公司等基础数据。这里最容易做歪的是把“动态管理”做成一张万能大表单所有字段都堆在一起。我的思路是拆成“基本信息 动态记录”两张表动态记录只存时间戳和状态变更历史。这样既能查当前状态也能回溯整个变化过程值班交接和问题追责都有依据。2.3 接口设计与前端交互的约定我习惯给每个模块定义一组资源式接口以航班为例GET /api/flights?page1size10 分页查询GET /api/flights/{id} 查询详情POST /api/flights 新增航班PUT /api/flights/{id} 修改航班信息PUT /api/flights/{id}/status 状态变更DELETE /api/flights/{id} 删除航班状态变更单独一个接口不塞在通用更新里。原因是前端操作“航班到达”和“修改航班备注”是两类语义后端需要针对状态变更做专门校验和历史记录混在一起会破坏状态机的完整性。3. 数据库设计与核心表结构3.1 核心表的设计思路数据库设计是整个系统的基础如果表结构没想清楚后面代码写得再漂亮也很难救回来。我用四张核心表搭建主框架按业务层次排开看会更清楚。flight_info 航班主表字段类型说明idBIGINT主键flight_noVARCHAR航班号airline_typeVARCHAR航空公司aircraft_typeVARCHAR机型directionVARCHAR进出港方向IN 进港 / OUT 出港terminalVARCHAR航站楼gateVARCHAR机位或登机口plan_timeDATETIME计划时间actual_timeDATETIME实际时间statusVARCHAR当前状态remarkVARCHAR备注create_timeDATETIME创建时间update_timeDATETIME更新时间flight_record 状态流转记录表字段类型说明idBIGINT主键flight_idBIGINT关联航班主表old_statusVARCHAR变更前状态new_statusVARCHAR变更后状态operatorVARCHAR操作人operate_timeDATETIME操作时间remarkVARCHAR变更说明sys_user 用户表字段比较简单id、username、password、real_name、role_code、status。密码我不会明文存统一用加密工具处理成密文再入库。base_dict 基础字典表字段为 id、dict_type、dict_name、dict_value用来维护机位、登机口、航空公司等基础数据避免把字典值硬编码在代码里。主表存的是“当前事实”记录表存的是“操作轨迹”。对于需要审计的系统这两套数据缺一不可。尤其是值班换班时交接不能只看主表最新状态还要翻记录表看谁在什么时间做了什么操作。3.2 进出港方向与时间字段的处理细节航班方向用 direction 字段表示进港 IN、出港 OUT。这里我做了个取舍plan_time 和 actual_time 只保留计划时间和实际时间两个字段没有拆成进场时间和起飞时间各自一组。原因是进出港业务的时间语义不同。进港场景下最关键是“计划落地时间”和“实际落地时间”出港场景下最关键的是“计划起飞时间”和“实际起飞时间”。如果拆成 arrive_time、depart_time 两组表会变得很宽前端表单也会非常冗余。直接用 plan_time 和 actual_time配合 direction 字段去解释语义查询和展示都更轻快。真实生产系统可能需要更多时间点但做第一版迭代这样完全够用后续加字段也比重构关系简单。3.3 MyBatis 动态 SQL 和字典表关联航班列表页经常要同时显示航站楼、机位、当前状态、更新时间。这个查询我在 MyBatis XML 里用动态 SQL 实现核心片段如下select idselectFlightList resultTypemap parameterTypemap SELECT f.id, f.flight_no, f.direction, f.airline_type, f.plan_time, f.actual_time, f.status, d1.dict_name AS terminal_name, d2.dict_name AS gate_name, f.update_time FROM flight_info f LEFT JOIN base_dict d1 ON f.gate d1.dict_value AND d1.dict_type GATE LEFT JOIN base_dict d2 ON f.gate d2.dict_value AND d2.dict_type GATE where if testflightNo ! null and flightNo ! AND f.flight_no LIKE CONCAT(%, #{flightNo}, %) /if if testdirection ! null and direction ! AND f.direction #{direction} /if if teststatus ! null and status ! AND f.status #{status} /if /where ORDER BY f.plan_time LIMIT #{offset}, #{size} /select这样写的价值在哪里前端传任意组合的筛选条件都不需要为每种组合写一条 SQL。如果后面要增加“按机型筛选”“按航站楼筛选”只需要在 里加一个 维护成本很低。4. 后端核心业务逻辑实现4.1 SpringBoot 分层结构与代码组织后端代码按照 controller、service、mapper 三层组织实体类单独放 entity 包通用响应统一用 Result 对象包装。Controller 只接收参数、调用 service、返回 ResultService 里写业务规则和事务控制Mapper 只做 SQL 映射。分层的收益在项目小的时候感受不明显一旦状态流转逻辑变多效果就出来了。改一个状态分支时不需要把所有地方翻一遍只需要动状态机和对应 Service 方法。举一个例子给航班改状态的接口PutMapping(/{id}/status) public Result updateStatus(PathVariable Long id, RequestBody StatusChangeDTO dto) { return Result.success(flightService.changeStatus(id, dto)); }Controller 里不写任何业务判断。校验和状态流转都在 Service 里万一要换接口框架业务代码完全不用动。4.2 航班状态机的设计与实现这是整个后端里最核心的地方。我先把状态定义成枚举public enum FlightStatus { PLANNED(计划), AIRBORNE(空中), LANDED(已落地), DOCKED(已入位), BOARDING(登机中), DEPARTED(已起飞), CANCELLED(已取消), COMPLETED(已完成); }状态迁移规则用一个映射维护每个状态允许到达哪些状态都在配置里写清楚。为了方便以后维护我单独抽了一个状态机组件public class FlightStateMachine { private static final MapFlightStatus, SetFlightStatus TRANSITIONS new HashMap(); static { TRANSITIONS.put(FlightStatus.PLANNED, Set.of(FlightStatus.AIRBORNE, FlightStatus.CANCELLED)); TRANSITIONS.put(FlightStatus.AIRBORNE, Set.of(FlightStatus.LANDED, FlightStatus.CANCELLED)); TRANSITIONS.put(FlightStatus.LANDED, Set.of(FlightStatus.DOCKED, FlightStatus.CANCELLED)); TRANSITIONS.put(FlightStatus.DOCKED, Set.of(FlightStatus.BOARDING, FlightStatus.COMPLETED, FlightStatus.CANCELLED)); TRANSITIONS.put(FlightStatus.BOARDING, Set.of(FlightStatus.DEPARTED, FlightStatus.CANCELLED)); TRANSITIONS.put(FlightStatus.DEPARTED, Set.of(FlightStatus.COMPLETED)); TRANSITIONS.put(FlightStatus.COMPLETED, Set.of()); TRANSITIONS.put(FlightStatus.CANCELLED, Set.of()); } public static boolean canTransition(FlightStatus from, FlightStatus to) { return TRANSITIONS.containsKey(from) TRANSITIONS.get(from).contains(to); } }很多人写状态机喜欢用 if/else 一层层嵌套前几个状态还好后面一旦加了“取消”和“恢复”分支代码就开始乱。用迁移矩阵之后新增状态只需要改 Map 配置其他地方不用动。Service 里的完整逻辑大致是这样Transactional public void changeStatus(Long flightId, StatusChangeDTO dto) { FlightInfo flight flightMapper.selectById(flightId); FlightStatus current FlightStatus.valueOf(flight.getStatus()); FlightStatus target dto.getTargetStatus(); if (!FlightStateMachine.canTransition(current, target)) { throw new BusinessException(非法状态变更: current - target); } // 状态进入 LANDED/DEPARTED 时必须携带实际时间 if (target FlightStatus.LANDED || target FlightStatus.DEPARTED) { if (dto.getActualTime() null) { throw new BusinessException(该状态变更必须填写实际时间); } flight.setActualTime(dto.getActualTime()); } flight.setStatus(target.name()); flightMapper.updateById(flight); FlightRecord record new FlightRecord(); record.setFlightId(flightId); record.setOldStatus(current.name()); record.setNewStatus(target.name()); record.setOperator(dto.getOperator()); flightRecordMapper.insert(record); }有一个细节我觉得很有价值实际时间 actual_time 的赋值放在状态变更的 Service 里而不是让前端单独发一个改时间请求。原因在于“飞机落地了”这个动作本身就带时间语义状态变更和实际时间是一个事务里必须同时成立的事实。拆成两个接口数据库里就可能出现“状态已入位但落地时间还是空”的脏状态。事务注解必须加上。状态变更涉及主表和记录表两次写操作不加事务的话一旦第二步插入记录失败主表状态改了但历史记录丢了问题会非常难排查。4.3 JWT 登录与接口权限控制管理后台的接口不能裸奔我用拦截器实现统一的 token 校验和角色权限判断。用户登录成功后返回一个 token前端请求时把 token 放 Header 里。拦截器从请求头取出 token校验通过就放行不通过直接返回 401。角色权限我用注解和拦截器配合实现。在 Controller 方法上标注 RequireRole(ADMIN)拦截器里读取注解并检查当前用户角色。这样调度员和值机员能访问的接口就分开了删除航班只允许管理员状态变更调度员可用普通值机员只能看数据。JWT 我用的现成库核心就两个动作生成 token 和解析 token。需要注意一个坑token 过期时间设置后必须和前端 axios 统一处理。前端检测到 401 状态码时跳转回登录页而不是只弹一个错误提示。我第一次没处理这个会话过期后页面卡在死循环里后来加了响应拦截器检测到 401 直接清空本地用户信息并跳转 /login 才解决。5. Vue 前端页面与交互实现5.1 Vue3 工程结构和组件划分前端我用 Vite 创建 Vue3 项目配合 Vue Router 和 Pinia 管理状态。页面组件按 layout、views、components、api 几个目录组织。入口页面采用经典的顶部菜单 左侧导航 内容区布局。这个布局看起来常规但有个容易被忽视的点路由和菜单的联动。我让菜单配置和路由配置共用一份数据菜单点击跳转时高亮当前项刷新页面时也能通过当前路由恢复选中状态。如果两份配置各写各的很容易出现菜单高亮和页面内容对不上的 bug。5.2 数据看板的图表实现数据看板是这套系统的门面。我用 ECharts 做了几类图表今日进出港比例用饼图、最近 7 天航班量趋势用折线图、当前各状态航班数量用带颜色的统计卡片。图表数据来自后端聚合接口一次返回统计结果{ totalIn: 120, totalOut: 108, onTimeRate: 86.5, statusOverview: { PLANNED: 42, DOCKED: 15, BOARDING: 8 } }前端拿这个数据直接填充图表不做二次聚合。这里有个很重要的经验图表需要的统计结果尽量让后端算好不要前端拿原始列表自己算。一是减少传输数据量二是避免前端浏览器性能瓶颈三是统计口径可以做到前后端一致不会出现后端按分钟算准点率、前端按天算的乌龙。5.3 表单、日期组件和交互细节进出港管理的表单页是用户操作频率最高的地方。Element Plus 的表单校验规则我加了一些自定义逻辑例如出港航班必须填写实际起飞时间进港航班必须填写实际落地时间这样不会出现时间语义混淆。这里有一个特别值得记录的坑Element Plus 的日期时间选择器默认返回的格式是带 T 分隔符的 ISO 字符串比如 2024-03-18T14:30:00直接传给后端会导致时间解析失败。我后来在 Axios 请求拦截器里统一做格式转换把 YYYY-MM-DDTHH:mm:ss 转成 YYYY-MM-DD HH:mm:ss 再交给后端。这样全局处理每个页面都不用单独写转换逻辑。列表页我加了筛选条件和分页组件。分页参数统一用 page、size前端每切换一页或点击查询按钮就重新调用列表接口。还有一个体验细节值得做把查询条件序列化到路由 query 参数里这样用户刷新页面或复制链接给同事看到的还是同一份筛选结果。6. 部署上线全流程实操6.1 环境准备与核心配置部署环境的版本要求并不苛刻我用的是 CentOS 服务器在 Windows 和 Ubuntu 上同样能跑。需要准备的环境如下JDK 8 或 11Maven 3.6Node.js 14MySQL 5.7 或 8.xNginx生产环境用后端核心配置在 application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/flight_ms?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: root123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: trueJDBC 连接串里的 serverTimezoneAsia/Shanghai 必须写。如果不配JDBC 驱动和 MySQL 服务器之间的时区不匹配时间字段会差 8 个小时。6.2 数据库初始化与示例数据建库建表我用一个 init.sql 脚本一次执行完mysql -u root -p init.sql脚本内容包括创建数据库、建四张核心表、插入基础字典数据、插入默认管理员用户。我还会预置二三十条航班信息避免前端页面登录后一片空白影响调试效率。这里有一个建议初始化脚本一定要纳入 Git 版本管理。很多人把脚本只放在本地部署新环境的时候到处找环境反复不一致。放在仓库里拉下来一条命令就能建环境对前后端联调也很有帮助。6.3 构建、启动与反向代理后端打包命令mvn clean package -DskipTests java -jar target/flight-manage.jar --spring.profiles.activeprod前端构建npm install npm run build生产环境把 dist 目录复制到 Nginx 的 html 目录。Nginx 配置反向代理server { listen 80; server_name flight-demo.local; root /usr/share/nginx/html/flight-web; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }两个配置细节要特别注意。第一前端是 SPA 应用路由用的是 history 模式直接刷新二级路径时 Nginx 找不到对应文件会返回 404必须加 try_files 把请求重写回 index.html由前端路由接管。第二/api 的代理不要去掉路径前缀因为后端接口本身就带 /api 前缀直接透传。7. 常见故障与调试实录7.1 跨域问题的标准解法开发环境最容易碰到跨域问题。前端跑在 5173后端跑在 8080端口不同浏览器默认拦截响应。我推荐直接照做两个方案。方案一开发环境用 Vite 代理。// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })方案二后端开启全局跨域配置适合生产环境直接放开的情况。我建议只放开实际使用的域名不要用“*”让所有来源都能访问。前端用代理之后代码里请求地址写的是 /api/xxx不需要写完整主机名。这样部署到生产环境时只要 Nginx 配置处理好路径前端代码不用改动。7.2 日期时间差 8 小时的完整排查过程这个问题的典型表现是前端显示的时间比实际录入时间晚了 8 个小时或者数据库里存的数据和页面显示的不一致。排查时需要把链路分成三段浏览器、SpringBoot、MySQL。问题可能出现在任一段。浏览器本身有本地时区前端拿到的字符串如果带时区后缀会按本地时区解析。如果不带有的浏览器会当成 UTC 时间处理。SpringBoot 的 Jackson 序列化时如果没配 time-zone会按服务器本地时区输出。JDBC 连接串里的 serverTimezone 则负责和 MySQL 服务端协商会话时区。修复思路就是统一各层时区。数据库字段用 DATETIMEMySQL 连接串上加 serverTimezoneAsia/ShanghaiSpringBoot 的 Jackson 配 time-zone: GMT8前端收到字符串后不做额外转换直接展示。三层都统一到东八区后时间就不会漂移了。7.3 航班数据量变大后的分页与索引优化演示项目数据量小但实际生产环境一年可能有几十万条航班数据。列表页如果不加索引时间一长查询会明显变慢。我在 plan_time、status、direction 这三个字段上建了组合索引ALTER TABLE flight_info ADD INDEX idx_plan_status_dir (plan_time, status, direction);这样针对“按日期查某方向某状态的航班”这类典型查询MySQL 可以直接走索引。数据量继续增大时还可以考虑游标分页或延迟关联但组合索引是第一优先级要做的事。还有一个隐藏问题很多人写 MyBatis 时把 COUNT 查询和列表查询分开但 COUNT 里如果也带着各种 LIKE 条件数据量大时一样会扫表。航班号这种字段尽量用精确匹配或后缀模糊SQL 写的时候稍微注意一下即可。8. 关于这套系统的一些心里话这套系统从设计到部署我用了大约两周中间翻车最多的地方不是代码框架而是时间字段和状态流转。时间问题最后靠统一时区解决状态流转靠迁移矩阵固定规则一旦这两块理清后面所有功能都顺了。给做类似项目的人一个建议先别急着写代码把状态机画出来把表之间的关系想清楚再动手。我第一版因为边写边改状态加了“取消”功能之后一堆历史判断全部要改重构花了两天。后来把状态机抽出来系统的可维护性立刻上去了。最后再分享一个小技巧部署文档一定要跟着项目走。你觉得“这么简单不用写”的命令和步骤往往是接手项目的人最需要的。把初始化、构建、部署的流程写进 README对自己以后换服务器也有帮助。如果你正准备做一套类似的航班进出港管理系统希望这篇长文能让你少走一点弯路。