1. 毕业设计选题这件事,为什么我推荐“航班进出港管理”这个方向
每年到毕设季,就有大量读者在后台问我类似的问题:SpringBoot 和 Vue 的选题一大把,到底选什么既容易过审、又有干货可写、还能在答辩时讲得出东西?我见过太多人选了“某某管理系统”的通用模板,结果做完之后自己都说不清楚里面的核心难点在哪里,答辩被老师一问就卡壳——那种体验真的很难受。
航班进出港管理系统这个选题,是我比较推荐的一个方向。原因很简单:它的业务边界非常清晰,数据模型也足够典型。航班管理涉及航班号、起降时间、起降机场、航站楼、登机口、状态流转、旅客关联等字段,天然具有“增删改查 + 状态流转变更 + 多表关联”的全套需求,而这些恰好是 Java Web 毕业设计最核心的考察点。你做完这个项目,放到简历上的描述也不是一句空洞的“实现了某管理系统”,而是能明确说清楚航班状态如何流转、航班与登机口如何动态分配、历史航班如何归档这些具体问题。
我自己在带项目时,对毕设项目有一个反复强调的选型原则:一定不要选那些“看起来炫、但实际上你根本讲不透”的方向,而是选一个中等复杂度、你能彻底吃透的业务场景。航班进出港管理恰好踩在这个点上——它的复杂度足够撑起一个完整的全栈项目,又不会难到让一个应届生无法独立交付。再加上这个题目在航空运输管理、机场运行、智慧出行等业务背景下都可以延伸,论文素材和展示素材都很容易找,属于性价比很高的选题。
这篇博文会结合我实际做过的一个完整项目来展开,内容涵盖 SpringBoot 后端、Vue 前端、SQL 脚本、接口文档这几个组成部分。我会把重点放在那些“你在学校课堂上学不到、但实际做项目一定会遇到”的细节上,比如航班状态机的设计、分页查询的性能陷阱、SpringBoot 与 Vue 联调时跨域问题的处理、SQL 脚本里数据初始化顺序的坑。你可以把这篇文章当作一份详尽的毕设参考,也可以看作是 Java Web 全栈开发的一次真实复盘。
2. 系统需求拆解:航班进出港管理到底管什么
开始写代码之前,先把业务模型理清楚。这一步是整个项目里最容易被跳过、但恰恰最值得花时间的环节。很多人在毕设里把航班进出港管理系统做成了一个单纯的数据表格增删改查,那样做出来的东西在答辩时通常撑不住——老师只要问一句“航班延误了系统里怎么体现?”,你就得临时编答案。为了避免这种尴尬,我们需要在需求层面就把核心业务逻辑定义清楚。
2.1 核心业务角色和工作流程
航班进出港管理系统的典型使用场景是机场运行控制中心的日常操作。在这个系统里,主要角色可以划分为管理员、航班调度员和查询访客三类。管理员负责基础数据维护,比如机场信息、航司信息、停机位分配等;航班调度员负责核心的航班进出港操作——添加航班计划、更新航班动态、处理延误或取消;查询访客可能是机场内部其他部门的人员,只需要查看航班状态和统计信息。
工作流程大致是这样:航班计划落地后,调度员录入航班的基本信息,包括航班号、机型、航空公司、计划起降时间、起降机场;到航班实际执行阶段,系统根据计划时间自动形成进出港任务列表,调度员在任务列表中更新实际起降时间、登机口、廊桥、行李转盘等动态信息;每次更新都会记录操作日志并触发状态变化,前端实时刷新展示最新状态。
这个流程看似简单,但设计上有几个容易出错的地方,后面我会详细展开。
2.2 状态机设计:最容易出彩也最容易翻车的部分
航班状态是整个系统的核心字段,它不能是一个随意填写的字符串,而是要遵循一套严格的流转逻辑。我设计的航班状态流转如下:
- 计划(Scheduled):航班已录入系统,但尚未开始办理手续,此时显示计划起降时间。
- 值机(Check-in):旅客开始办理值机手续,航班进入准备阶段。
- 登机(Boarding):旅客开始登机,此时登机口和登机时间必须已分配。
- 已起飞(Departed):航班已离港,记录实际起飞时间。
- 到达(Arrived):航班已降落,记录实际降落时间。
- 延误(Delayed):航班无法按计划执行,需要记录延误原因和预计调整时间。
- 取消(Cancelled):航班取消,需记录取消原因。
- 归档(Archived):航班执行完毕,数据进入历史归档。
状态之间的流转不是任意的。比如一个航班从“计划”可以直接跳到“取消”,但不能从“已起飞”跳回“值机”。这个逻辑在后端 Service 层要做校验,前端按钮的显示也要基于当前状态控制。
这种状态机的设计在答辩时非常加分,因为它是业务规则的提现,而不是简单的数据库读写。而且状态机设计好了,后端的接口逻辑会非常清晰,前端也可以基于状态来渲染操作按钮,整个项目的代码结构会因此干净很多。
2.3 数据库表设计:哪些表是必需的
根据上面的需求,数据库表大致包括这些:
- 航班信息表(flight):存储航班号、航司、机型、起降机场、计划/实际起降时间、状态等核心字段。
- 机场表(airport):存储机场三字码、机场名称、城市信息。
- 航空公司表(airline):存储航司二字码、航司名称。
- 登机口表(gate):存储登机口编号、所属航站楼、启用状态。
- 用户表(user):存储系统登录用户。
- 操作日志表(operation_log):记录航班状态变更和关键操作。
- 航班动态历史表(flight_history):存储归档后的航班历史数据。
表结构设计的核心在航班信息表。我下面给出一个在实际项目中能跑通的表结构定义,字段名称和类型是我自己的习惯,你可以按需调整:
CREATE TABLE flight ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', flight_no VARCHAR(20) NOT NULL COMMENT '航班号', airline_code VARCHAR(10) NOT NULL COMMENT '航司二字码', aircraft_type VARCHAR(20) COMMENT '机型', origin_code VARCHAR(10) NOT NULL COMMENT '起飞机场三字码', dest_code VARCHAR(10) NOT NULL COMMENT '目的机场三字码', scheduled_departure DATETIME NOT NULL COMMENT '计划起飞时间', scheduled_arrival DATETIME NOT NULL COMMENT '计划到达时间', actual_departure DATETIME COMMENT '实际起飞时间', actual_arrival DATETIME COMMENT '实际到达时间', gate_code VARCHAR(10) COMMENT '登机口编号', terminal VARCHAR(10) COMMENT '航站楼', status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED' COMMENT '航班状态', delay_reason VARCHAR(255) COMMENT '延误原因', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标记' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='航班信息表';两个细节我特别说明一下。第一,status 字段用字符串而非数字枚举,因为开源系统常见的查询和展示都要直接显示状态名,用字符串省去一次映射,虽然多占了一点存储,但开发效率明显更高。第二,deleted 字段做逻辑删除,这个在毕设里可能不是硬性要求,但我强烈建议加上,因为实际业务场景里航班数据被误删之后要恢复,物理删除会把关联日志也搞丢。
3. 技术选型的思考:为什么是 SpringBoot + Vue 这个组合
做毕设也好,做实际项目也好,技术栈选择不能只看“市面上流行什么”,更要看“这个组合能否支撑你要做的事情”。SpringBoot + Vue 的组合在 Java Web 领域里已经是事实上最主流的前后端分离组合之一,但很多人只是听说它主流,并不清楚它为什么主流。这里我可以给出一套完整的理由。
3.1 后端框架:SpringBoot 的取舍在哪里
SpringBoot 最大的价值不是它本身,而是它带来的“约定优于配置”的开发体验。在传统 SSM 框架时代,配一个 SpringMVC + MyBatis 的项目,光是 XML 配置文件就能写几百行,spring-mvc.xml、spring-mybatis.xml、web.xml 一个都不能少,而且版本稍有不一致就是各种奇怪的 jar 包冲突。
SpringBoot 把这些问题压缩成一句话:你在 application.yml 里写上数据源地址、端口号、上传大小限制等少量必要配置,然后就可以专注于写业务代码了。内嵌的 Tomcat 容器让你不需要单独部署 war 包,一个 java -jar 命令就能启动整个服务。这对毕设场景来说特别关键——省下来的时间可以用来打磨业务逻辑和写文档。
在具体模块选择上,我使用的是 SpringBoot 2.7.x + MyBatis-Plus + MySQL 8.x 的组合。MyBatis-Plus 会有代码生成器,这一条值得单独说明:它能根据数据库表结构自动生成 Entity、Mapper、Service、Controller 全套基础代码。很多人觉得用代码生成器会显得“没有技术含量”,其实完全不是这样。代码生成器解决的是繁琐的样板代码问题,帮你节约时间,让你把精力放在手写复杂 SQL(比如航班状态统计报表)和核心业务逻辑上,这是一个合格工程师的正常工作方式。
3.2 前端框架:Vue 2 还是 Vue 3
前端我用的是 Vue。具体版本上,Vue 3 + Element UI 的组合在当前项目中体验较好。Vue 3 的 Composition API 在组织复杂页面时有明显优势,比如航班动态页面需要同时维护多个筛选条件和列表状态,用组合式函数可以把这些逻辑拆得很干净。
不过这里我要提醒一句:如果你们学校毕业设计有指定技术栈,或者你本人对 Vue 2 的 Options API 风格的熟悉程度远高于 Composition API,那用 Vue 2 也没有任何问题。毕设考察的是你是否掌握了完整的开发流程,而不是前端版本多新。有些同学为了追新非要上 Vue 3 + TypeScript + Vite,结果光环境配置就折腾了一周,完全本末倒置了。
前端除了页面展示,核心要解决两个问题:通用的请求封装和组件的状态管理。请求封装这一步很关键,我一般会统一处理 token、错误码、加载状态,避免每个页面重复写 axios 逻辑。状态管理方面,航班进出港管理系统的状态共享主要在于“航班状态筛选”和“航班详情查看”,不算特别复杂,用 Vue 自带的响应式加上一个简单的 store 就足够了,不必上重量级的 Pinia 状态库。
3.3 前后端分离架构下的联调挑战
前后端分离意味着前端 Vue 跑在 8080 端口,后端 SpringBoot 跑在 8081 端口。联调时遇到的最大问题了就是跨域。在 SpringBoot 里配置跨域,我通常是用一个配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }但这里有一个跨域配置上很容易踩的坑:所有跨域相关操作放在前端处理是行不通的。有些同学在 Vue 端的 axios 里配了跨域设置,结果发现不起作用。原因是跨域校验在服务端,前端的跨域处理只在开发阶段通过 Vue CLI 的 proxy 配置生效,部署到线上之后 proxy 是不存在的。所以正确做法有两种:开发阶段用 Vue CLI 的反向代理 /api 到 8081,生产环境用 Nginx 做反向代理,或者在 SpringBoot 后端配置全局跨域。毕设阶段,在 SpringBoot 加一个全局 CorsConfig 是最省事、也最不容易出问题的方案。
4. 项目落地:目录结构、核心接口和踩坑复盘
这一节是整个项目的实际施工过程。我会按照从后端到前端、从核心功能到辅助功能的顺序来讲,每个环节覆盖“做了什么”和“为什么这么做”,最后提供避坑指南。
4.1 后端代码结构的设计逻辑
一个干净的后端目录结构能让你在写代码时少走很多弯路。我的项目结构是:
src/main/java/com/example/flight/ ├── FlightApplication.java // 启动类 ├── common/ // 通用返回结果、异常处理、常量 │ ├── Result.java │ ├── ResultCode.java │ ├── GlobalExceptionHandler.java │ └── BusinessException.java ├── config/ // 配置类:MyBatis-Plus、跨域、拦截器 ├── controller/ // 接口层 │ ├── FlightController.java │ ├── AirportController.java │ ├── AirlineController.java │ ├── GateController.java │ └── AuthController.java ├── service/ // 业务逻辑层 │ ├── FlightService.java │ └── impl/ │ └── FlightServiceImpl.java ├── mapper/ // 数据访问层 │ ├── FlightMapper.java │ ├── AirportMapper.java │ └── ... ├── entity/ // MyBatis-Plus 实体类 │ ├── Flight.java │ ├── Airport.java │ └── ... ├── dto/ // 传参对象、返回对象 │ ├── FlightQueryDTO.java │ ├── FlightStatusUpdateDTO.java │ └── ... ├── vo/ // 视图对象 │ └── FlightVO.java └── utils/ // 工具类分层的好处不用多说。Controller 只负责接收参数和返回结果,Service 处理业务规则(比如状态流转校验),Mapper 只做数据库操作。这样如果将来要改业务规则,你不需要动 Controller;如果要加字段,也不需要动 Service。毕业设计评审老师往往很看重这种分层设计意识,因为它是项目后期可维护性的基础。
实体类方面,用了 MyBatis-Plus 的注解风格,代码简洁不少:
@Data @TableName("flight") public class Flight { @TableId(type = IdType.AUTO) private Long id; private String flightNo; private String airlineCode; private String aircraftType; private String originCode; private String destCode; private LocalDateTime scheduledDeparture; private LocalDateTime scheduledArrival; private LocalDateTime actualDeparture; private LocalDateTime actualArrival; private String gateCode; private String terminal; private String status; private String delayReason; @TableLogic private Integer deleted; }这里有个细节值得注意:LocalDateTime 的类型映射。如果在 MyBatis-Plus 全局配置中不做处理,LocalDateTime 在返回给前端时默认格式是 “2025-06-01T08:30:00”,带一个 T 字母。前端拿到这个字符串要自己转格式,很麻烦。我的处理方式是在 application.yml 里加上:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这个配置能统一后端返回的时间格式,前端拿到的直接是 “2025-06-01 08:30:00”这种友好的字符串,省去很多格式化烦恼。
4.2 核心功能实现:航班分页查询和状态流转
航班分页查询是系统里最常用的能力,也是写代码时最容易出性能问题的地方。很多同学会直接在 Service 里写:
Page<Flight> page = this.page(new Page<>(pageNum, pageSize), wrapper);看起来没毛病,但实际业务中航班查询通常要支持多条件组合筛选:按航班号模糊搜索、按起降机场筛选、按日期区间筛选、按状态筛选。如果前端传 6 个参数,后端 QueryWrapper 就要动态拼 6 个条件,拼接顺序、空值判断、日期范围边界都在这里出问题。
我用一个 FlightQueryDTO 统一接收查询参数,然后在 Service 里动态构建条件:
public Page<FlightVO> queryFlightPage(FlightQueryDTO dto) { LambdaQueryWrapper<Flight> wrapper = new LambdaQueryWrapper<>(); // 航班号模糊查询 if (StringUtils.hasText(dto.getFlightNo())) { wrapper.like(Flight::getFlightNo, dto.getFlightNo()); } // 起飞机场 if (StringUtils.hasText(dto.getOriginCode())) { wrapper.eq(Flight::getOriginCode, dto.getOriginCode()); } // 状态 if (StringUtils.hasText(dto.getStatus())) { wrapper.eq(Flight::getStatus, dto.getStatus()); } // 按计划起飞日期范围 if (dto.getDepartureDateStart() != null) { wrapper.ge(Flight::getScheduledDeparture, dto.getDepartureDateStart()); } if (dto.getDepartureDateEnd() != null) { wrapper.le(Flight::getScheduledDeparture, dto.getDepartureDateEnd()); } // 按时间倒序排,最新的航班在前 wrapper.orderByDesc(Flight::getScheduledDeparture); Page<Flight> page = this.page(new Page<>(dto.getPageNum(), dto.getPageSize()), wrapper); // 实体转 VO,补充机场名称、航司名称等展示信息 return convertToVO(page); }这段代码的逻辑很直观,但里面有几个关键决策点。
第一个是日期查询用 ge 和 le 而不是 between。between 的边界是闭区间,当用户选的日期是 2025-06-01 时,如果不加 “23:59:59” 这个尾巴,查询范围就是 2025-06-01 00:00:00 到 2025-06-01 00:00:00,2025-06-01 这一天的数据进行判断,查询结果会少掉当天除零点以外的大部分数据。用 le 配合“第二天的 00:00:00”是更稳妥的做法,或者直接在使用方处理好endDate.plusDays(1)再传参。
第二个是实体转 VO 的逻辑放哪里。我选择在 Service 层做转换而不是在 SQL 里 join 拼接字符串,因为航班列表除了原始数据,通常还要显示“航空公司名称”“起飞机场城市”“到达机场城市”,这些信息在独立的机场表和航司表里。如果每个字段都用 SQL join,SQL 会变得臃肿;如果在 Service 里批量查询再补充,代码更清晰。具体实现做法是:先查出当前页的航班,去重收集 originCode 和 destCode,批量查出对应机场的信息,组装成 Map,然后再补充进 VO。这个思路对“列表展示关联字段”的场景非常通用。
接下来是状态流转接口。更新航班状态不是一个简单的 update set status = ?,而是要基于当前状态做合法性校验。比如“已起飞”的航班不能直接被改成“取消”,“已归档”的航班不允许再修改。我在 Service 里定义一个常量数组来存储合法流转路径,并用一个 Map 维护“当前状态 -> 允许的下一个状态集合”:
private static final Map<String, List<String>> STATUS_TRANSITIONS = new HashMap<>(); static { STATUS_TRANSITIONS.put("SCHEDULED", Arrays.asList("CHECK_IN", "DELAYED", "CANCELLED")); STATUS_TRANSITIONS.put("CHECK_IN", Arrays.asList("BOARDING", "DELAYED", "CANCELLED")); STATUS_TRANSITIONS.put("BOARDING", Arrays.asList("DEPARTED", "DELAYED", "CANCELLED")); STATUS_TRANSITIONS.put("DELAYED", Arrays.asList("CHECK_IN", "BOARDING", "CANCELLED")); STATUS_TRANSITIONS.put("DEPARTED", Arrays.asList("ARRIVED")); STATUS_TRANSITIONS.put("ARRIVED", Arrays.asList("ARCHIVED")); STATUS_TRANSITIONS.put("CANCELLED", Collections.emptyList()); STATUS_TRANSITIONS.put("ARCHIVED", Collections.emptyList()); }更新状态的方法大致如下:
@Transactional(rollbackFor = Exception.class) public Result updateFlightStatus(FlightStatusUpdateDTO dto) { Flight flight = this.getById(dto.getId()); if (flight == null) { throw new BusinessException("航班不存在"); } String currentStatus = flight.getStatus(); List<String> allowedStatuses = STATUS_TRANSITIONS.get(currentStatus); if (allowedStatuses == null || !allowedStatuses.contains(dto.getStatus())) { throw new BusinessException("非法状态流转:" + currentStatus + " -> " + dto.getStatus()); } // 记录操作日志 logService.record(flight.getId(), currentStatus, dto.getStatus(), dto.getRemark()); // 更新实体 flight.setStatus(dto.getStatus()); if ("DEPARTED".equals(dto.getStatus())) { flight.setActualDeparture(LocalDateTime.now()); } if ("ARRIVED".equals(dto.getStatus())) { flight.setActualArrival(LocalDateTime.now()); } this.updateById(flight); return Result.success(); }两个细节说明一下。第一,我在方法上加了**@Transactional(rollbackFor = Exception.class)**,因为状态更新涉及“更新航班状态 + 写入日志”两个操作,在打印时任何一步失败都要整体回滚,否则日志里记录了状态变更,但航班状态实际没变,会对不上账。默认情况下 Spring 事务只回滚 RuntimeException,checked 异常不会自动回滚,rollbackFor 参数强制指定了所有异常都回滚,这个习惯在写任何涉及多表写入的方法时都建议养成。
第二,航班状态里有两个关键时间字段——actualDeparture 和 actualArrival——我是由后端在状态流转时自动写入当前时间,而不是靠前端传入。这样能避免前端传一个不准确的本地时间,也能保证两条数据来源一致。
4.3 前端页面拆解:航班列表页、动态筛选和状态操作
前端部分我重点讲航班管理页面。这通常是一个包含工具条、筛选表单和数据表格的典型管理页面。我用 Element UI 的 el-table 来展示数据,并用分页组件配合后端分页。
列表页的核心逻辑在“查询条件”和“分页状态”的联动上。前端的状态大致如下:
const queryForm = reactive({ flightNo: '', originCode: '', destCode: '', status: '', dateRange: [] }); const pageInfo = reactive({ current: 1, size: 10, total: 0 }); const flightList = ref([]); async function fetchFlightList() { const params = { pageNum: pageInfo.current, pageSize: pageInfo.size, flightNo: queryForm.flightNo || undefined, originCode: queryForm.originCode || undefined, destCode: queryForm.destCode || undefined, status: queryForm.status || undefined, departureDateStart: queryForm.dateRange && queryForm.dateRange.length ? queryForm.dateRange[0] + ' 00:00:00' : undefined, departureDateEnd: queryForm.dateRange && queryForm.dateRange.length ? queryForm.dateRange[1] + ' 23:59:59' : undefined }; const res = await api.get('/flight/page', { params }); flightList.value = res.data.records; pageInfo.total = res.data.total; }特别注意上面的日期整理逻辑:我选择了把日期拼接成 “2025-06-01 00:00:00” 和 “2025-06-01 23:59:59” 两个边界时间。这样后端 SQL 中的 le 查询就能正确地覆盖整一天。而且把边界处理放在前端而不是后端,让后端的参数定义保持简单,只接收明确的 DateTime,也不需要猜测前端到底传了什么。
表格中针对状态的显示,我用 el-tag 加上不同颜色区分,比如计划是蓝色、已起飞是绿色、延误是橙色、取消是红色。操作列根据当前状态展示可执行的操作按钮:
- 状态为“计划”时,显示“开始值机”“延误”“取消”三个按钮。
- 状态为“值机”时,显示“开始登机”“延误”“取消”三个按钮。
- 状态为“登机”时,显示“起飞”“延误”“取消”三个按钮。
- 状态为“延误”时,显示“恢复值机”“恢复登机”等按钮。
按钮权限控制在前端用 v-if 加上状态判断,同时在端侧接口做权限校验,这样前端操作体验友好,后端数据也不会被绕过。
4.4 联调过程中遇到的两个经典问题
第一个问题:时间格式化不一致。后端接口返回 “2025-06-01 08:30:00”,前端直接把这个字符串显示在表格里没问题;但如果有日期组件用 moment 或 dayjs 去处理这个字符串,在一些浏览器里会无法解析带空格的日期格式,部分解析器只认 ISO 8601 标准格式,也就是带 T 的那种。这会引发表格里时间显示成 “Invalid Date” 的诡异问题。我的处理方式是:后端统一格式化输出 “yyyy-MM-dd HH:mm:ss”,前端在展示时直接用字符串,在需要做日期计算(比如计算航班延误时长)时才先用 dayjs 解析一次,并且显式指定格式。
第二个问题:Long 类型精度丢失。数据库表的主键 id 是 BIGINT,MyBatis-Plus 默认返回 Long,前端 JavaScript 的 Number 类型最大安全整数是 2^53 - 1,一旦 id 超过这个范围就会精度丢失。比如后端返回 1553386100229419009,前端拿到的可能变成 1553386100229419000,后续用这个 id 去更新接口,数据库根本查不到对应记录。解决方法是让后端在序列化时把 Long 转成字符串:
@Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder -> { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; }或者更简单的方法,在实体类的 id 字段上直接加@JsonSerialize(using = ToStringSerializer.class)。这个坑特别隐蔽,出了问题往往要排查很久,我现在写任何 SpringBoot 项目都会默认配置上。
5. SQL 脚本的准备:初始化数据比表结构更费心
毕设项目里 SQL 脚本是交付物之一。很多同学写 SQL 脚本就是建表 + 插几条测试数据,但作为一个“可用”的系统,SQL 脚本需要分层设计、明确执行顺序、考虑数据之间的外键关联与初始化依赖。这块工作的用心程度,往往决定评审老师是否能快速把项目跑起来——而“能跑起来”是评审的第一印象。
5.1 脚本的分层设计思路
我习惯把所有数据库脚本做成四个文件,命名上能直观反映执行顺序:
- 01_create_database.sql:创建数据库,设置字符集和排序规则。
- 02_create_tables.sql:创建所有表结构。
- 03_init_base_data.sql:初始化基础数据,比如机场表、航司表、登机口表、管理员账号。
- 04_demo_flight_data.sql:生成一批演示用的航班数据,方便前端页面展示。
这四个文件的顺序不能乱。先有数据库,才有表;先有基础数据(机场、航司、登机口),才能插航班数据,否则航班表里的外键或者逻辑关联字段引用了不存在的机场代码,页面会显示空名称。
5.2 演示数据怎么造才“像真的”
我建议让演示数据贴近真实情况。机场三字码直接使用真实代码:北京首都 PEK、上海浦东 PVG、广州白云 CAN、成都天府 TFU、深圳宝安 SZX;航司二码真实对应:国航 CA、东航 MU、南航 CZ、川航 3U、海航 HU。航班号比如 CA1831、MU5101、CZ3101 这类格式在现实中真实存在,会显得系统更可信。
关键是生产多条覆盖不同状态的航班数据。我的做法是生成 7 天范围内的数据,同时覆盖 SCHEDULED、CHECK_IN、BOARDING、DEPARTED、ARRIVED、DELAYED、CANCELLED 这些状态。如果全部都是未来计划航班,界面上大量显示“计划”状态,无法体现系统的状态流转功能;如果全部都是已起飞航班,又没有操作空间。数据分布合理,演示时你才能从容点出“这个航班正在值机,我们可以把它标记为延误”这样的真实操作流程。
5.3 执行脚本时最容易被忽略的三件事
字符集要做到可控。建库时指定 utf8mb4,排序规则用 utf8mb4_general_ci 或 utf8mb4_0900_ai_ci。如果不指定,在导入 SQL 脚本时有中文乱码风险。Windows 环境下还要注意脚本文件的编码格式,用 UTF-8 编码保存之后再执行。
时间字段插入的时区问题。脚本里的演示航班时间最好用相对当前时间的方式生成,或者明确写固定时间。如果全都写死为项目开发那几天的时间,几个月后导入脚本时,所有演示数据都变成了“历史数据”,列表页几乎只能看到归档或已到达的航班。稍微讲究一点的做法是脚本里用
DATE_ADD(NOW(), INTERVAL -2 DAY)这种方式生成相对时间,保证任何时候执行脚本,数据都是“昨天有起飞、今天有值机、明天有计划”的合理状态。在脚本底部清理临时表达式。MySQL 存储过程和自定义变量在重复执行时会报错,如果脚本里用了临时表,脚本末尾要补充 DROP TEMPORARY TABLE IF EXISTS 之类的清理语句,这样同一份脚本可以放心重复执行。
6. 接口文档的写作:别小看这份交付物
接口文档在毕设项目里往往被当作“附赠品”对待,但实际上它的重要性被严重低估。如果你在答辩时能讲清楚“系统的 API 是怎么设计的、为什么会这样设计”,这就超出了大部分只会说“我用了 RESTful 风格”的同学,达到区别于其他人的水平。
6.1 接口文档可以用什么形式组织
接口文档的核心是让调用方明确知道:请求路径是什么、请求方法是什么、要传什么参数、返回什么结构、错误码是什么。最贴近实际工程的做法是使用在线接口文档工具管理,比如 Apifox 或 Apipost,这类工具集成了接口管理、调试和 Mock 能力,既能写文档又能直接测接口。导出 Markdown 或 HTML 也可以作为交付物。
如果不想依赖其他工具,可以在项目里写一个 API.md 文件,把每个接口的入参、出参、错误码整理清楚。这同样能实现文档的效果,只是少了调试功能。对毕设来说,只要“文档与实际接口保持同步”这个原则做到位,形式不是最关键的。
6.2 统一返回结构的约定
我用一个统一的返回结构,所有接口都走它:
{ "code": 200, "message": "success", "data": { } }这个结构写过 Java Web 的同学都不陌生,重点是错误码的语义要一致。我的约定是:
- 200:成功。
- 400:参数校验失败。
- 401:未登录或 token 失效。
- 403:没有权限访问。
- 404:请求的资源不存在。
- 500:服务器内部错误。
对应到 Java 代码,前端 axios 响应拦截器会根据 code 决定是走成功回调还是弹出错误提示:
service.interceptors.response.use( (response) => { const res = response.data; if (res.code === 200) { return res; } if (res.code === 401) { // 跳转登录页 } ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); }, (error) => { ElMessage.error('网络异常,请稍后重试'); return Promise.reject(error); } );统一返回结构配合全局异常处理的好处是:后端代码里不需要每个方法都写 try-catch,业务异常在 Service 直接 throw BusinessException,由全局异常处理器统一转换成合适的 code 和 message 返回给前端。这样接口文档里的错误码部分也就有了系统性的依据。
6.3 接口权限和认证设计
航班管理里有一些接口是管理员专属的,比如新增航班、删除航班、更新登机口状态。普通查询访客身份登录后不应该有权限调用这些接口。
我用的是 JWT 认证 + Spring 拦截器的方案。用户登录成功后,后端签发一个带用户信息和过期时间的 token,前端在请求头里带上 Authorization: Bearer xxx。后端写一个拦截器:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源 String uri = request.getRequestURI(); if (uri.contains("/auth/login") || uri.contains("/static")) { return true; } String token = request.getHeader("Authorization"); // 校验 token,解析出用户信息存入 ThreadLocal // 校验失败则抛出未认证异常 return true; } }更细粒度的权限控制,可以用 @RequireRole 这类自定义注解 + AOP 实现。比如在 FlightController 的 delete 接口上标注@RequireRole("ADMIN"),AOP 拦截时检查用户角色。这个设计在答辩时很能体现你对权限控制的理解深度。
但这块有一个容易踩的坑:拦截器放行配置和新增接口冲突。如果新加了一个不需要登录就能访问的接口,忘记配放行路径,前端请求就会拿到 401,排查起来还得看拦截器是否放行。建议所有需要放行的接口统一在一个常量类或配置里维护,不要散落在各处。
7. 让项目在答辩时脱颖而出:常见问题准备和功能扩展
答辩环节是毕设的最后一关,也是对项目理解深度的一次检验。这一节我分享几个我在实际评审、带学生的过程中总结到的要点,能帮你在答辩时把项目讲出亮点。
7.1 围绕核心业务准备 3 类问题
第一类是“通用全栈问题”。比如“前后端分离项目是如何部署的”“如果并发量大了怎么优化”——这类问题回答的核心是展示工程思维。部署方面,我的标准回答是:开发阶段用 Vue CLI 的 proxy 做跨域,生产阶段把前端 build 出来的 dist 目录放到 Nginx 下,通过 Nginx 反向代理转发 /api 到后端 8081 端口。并发优化上,可以从索引优化(给 flight_no、scheduled_departure、status 等查询字段加索引)、分页查询避免深分页、热点数据加缓存(比如机场和航司这类基本不变化的数据,可以用 Redis 缓存)这几个角度回答。
第二类是“具体业务异常场景问题”。比如“如果航班在登机过程中突遇天气原因,需要长时间延误,系统里怎么操作”——这个问题直接对应我的状态机设计:登机状态可以流转到延误,此时前端会弹出延误原因输入框,后端记录原因同时把状态改成 DELAYED,如果后续确认取消,从 DELAYED 流转到 CANCELLED 是被允许的。这个回答逻辑清晰,比“我们系统能增删改查”高级很多。
第三类是“数据一致性场景问题”。比如“同一时间多个操作员修改同一航班会怎样”——这里可以结合乐观锁或版本号机制。我通常建议在航班表加一个 version 字段,更新时带上 version 条件,受影响行数为 0 说明数据已被他人修改,前端能提示“请刷新后再试”。添加乐观锁是最快的方式,性价比又高,是很好的答辩话题。
7.2 功能扩展方向:让项目不止于“毕设”
做完毕设不是终点。如果你有多余时间,或者想把这个项目变成简历上的亮点,可以考虑下面几个方向的扩展:
- 航班统计报表:按日起降量统计、准点率统计、航司航班量排名,用 ECharts 展示图表。这个扩展能体现你处理聚合查询的能力,也比较容易做出视觉效果。
- 消息通知机制:航班状态变更后,通过 WebSocket 推送给关注该航班的用户。这个能让系统从“操作台”升级为“信息中枢”,在面试时可以重点讲技术架构。
- 文件上传服务:假设航班动态需要上传公告文件或临时通知,可以整合 MinIO 对象存储。MinIO 本身提供 Docker 镜像,本地就能跑起来,资料也比较多,适合作为分布式存储方向的话题点。
- 多用户权限细分:在管理员角色下再分“机场地面服务”“航司地服”“旅客服务”等不同角色,通过权限矩阵控制页面按钮和接口访问。
这些方向中,报表统计是最推荐的。因为报表涉及的 SQL 聚合查询能力、前端图表的渲染、时间维度的数据分析,都是面试里经常涉及的话题,而且工作量适中,能够在毕设周期内完成。
7.3 答辩展示技巧:代码审阅少、业务链路多
最后分享一个答辩经验:很多同学准备答辩时把大量时间花在背代码上,但实际上评审老师在答辩现场很少逐行看代码,他们更关注的是“你能否把一条业务链路的完整实现讲清楚”。所以我的建议是准备一条主展示线:
- 登录系统,说明登录后 JWT 如何发放与校验。
- 进入航班管理页,演示组合条件的多条件查询。
- 选中一个“计划”状态的航班,执行“值机——登机——起飞”操作,每步操作后展示表格中状态变化和操作日志记录。
- 展示一个“延误”状态航班的录入和恢复流程,同时展示操作日志中的变更记录。
- 最后在报表(如果有)或统计数据页展示列表结果。
这条链路覆盖了登录认证、列表分页、状态流转、日志记录、数据统计分析等核心模块,每一个环节都在展示你亲手设计和实现的功能。答辩时沿着这条链路讲,你的表达会比背一段段代码自然得多。
8. 交付前的最终检查:这份项目能不能直接作为范文提交
当我用一个 SpringBoot + Vue 的航班进出港管理系统作为毕设交付物时,最后阶段我会过一遍检查清单,确认每一块交付物都达到了可用的标准,而不是“代码能跑一下就行”的程度。
项目源码:后端是 Maven 标准结构,前端是 Vue 工程,都能一键启动。后端启动只依赖 MySQL 数据库,前端依赖 npm install 安装依赖。两者互相独立,任何人在拿到代码后都能在自己机器上按文档步骤跑起来。源码里不能出现敏感信息、测试写死的本地路径、调试用的 System.out.println 或者 lod 残留的日志。
SQL 脚本:按序执行四份脚本能完整建库、建表、初始化基础数据和演示数据。演示数据量在 200 条以上,状态分布合理,时间字段基于当前时间生成,任何时间导入都有合理的数据展示。
接口文档:列出所有核心接口的路径、方法、入参、出参、错误码含义。至少覆盖航班模块的增删改查、状态流转、分页查询,另外覆盖登录认证和机场航司的基础数据查询。
项目 README:写清楚项目环境要求(JDK 版本、Node 版本、MySQL 版本)、启动步骤(先导入 SQL,再启动后端,再启动前端)、默认账号密码、端口信息。
论文结构:系统的需求分析、系统设计、数据库设计、核心功能实现、系统测试这几章内容都能从实际的开发过程中提取素材,不会出现论文内容与代码实现分离的情况。
在我实际操作中,最花时间的其实不是写代码本身,而是把 SQL 脚本和接口文档打磨到“别人照着做一定能跑通”的程度。很多人在交付前才发现 SQL 脚本里有外键依赖问题、接口文档里有参数名对不上、README 里的启动步骤缺少某一个环节。这些问题单个来看都不严重,但叠加起来就会让评审老师觉得这个项目“不太专业”。所以验收前花两天时间,照着 README 从零走一遍,是提升项目质感最高效的做法。
做毕业设计的价值从来不只是拿到一个分数。它是一次完整的“需求分析—设计—编码—测试—交付”演练,是你从学生思维切换到工程思维的第一步。把航班进出港管理系统的每一个细节都吃透,你收获的不仅是一份能过关的毕设成果,更是一段能写进简历、能在面试时从容讲述的真实项目经验。