
简介这是一套面向航空物流信息化领域的Spring Boot毕业设计资源适合计算机相关专业学生、课程设计使用者及货运调度方向的入门开发者。项目基于主流前后端分离架构后端以Java完成航班计划、散货登记、调度方案生成与异常监控等功能前端由Vue实现货物追踪、统计看板和多模式运输整合等交互界面整体覆盖从用户权限到运营数据分析的完整业务链路可直接作为选题参考或二次开发底座。资源包共431个文件其中以124个Java源码、107个Vue页面、43个JavaScript脚本及XML、CSS、PNG等文件为主同时包含SQL和YML配置便于快速搭建运行环境压缩包整体约9.88MB结构紧凑、目录层次清晰。目前已有96人学习下载适合需要在较短时间内理解航空散货调度核心模块、学习Spring Boot与Vue整合流程并借助论文和PPT完善毕业设计文档的读者。1. 航空散货调度系统的业务场景与边界航空散货调度是把一批货单按件数、重量、体积和目的站分配到出港航班的舱位上。它和整机包舱不一样散货单小量大一张货单可以拆到同一天多个航班每个航班又有独立最大载重、最大容积和配载截止时间。很多人第一版先写CRUD再补分配逻辑结果发现真正难的并不是增删改查而是同一个航班的剩余舱位被两个货控同时发起预占时怎么在数据库层面保证不超售。用SpringBoot和Java做这套系统核心是设计清楚一套以预占、确认、释放为生命周期的调度闭环管理的是“舱位可用量”而不是一张排定不变的装载单。这套系统适合作为毕业设计完整骨架也可以改造成航企出港舱位预分配原型。接下来按工程搭建、领域实体、调度Service、持久层、接口层和并发排错逐段展开每个环节都给出可改的代码与配置。2. SpringBoot工程初始化与领域实体划分2.1 SpringBoot 3 工程结构与关键依赖单模块单体是毕业设计最稳的结构。controller、service、mapper、entity四层就够了没有必要引入网关和注册中心。真正能把调度讲清楚的是服务和持久层代码工程组织太散反而影响论文正文叙述。Java版本用17SpringBoot选3.2.x数据库MySQL 8.x持久层用MyBatis-Plus。pom中的核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesmybatis-plus-spring-boot3-starter 3.5.7这一档适配的是SpringBoot 3.x如果项目落在SpringBoot 2.7要换成mybatis-plus-boot-starter 3.5.x。版本号不要直接抄网上最新值先确认starter与自己项目的Spring Boot大版本一致否则会出现Mapper扫描不到或分页插件不生效两种典型故障。2.2 航班、货单、配载记录三个实体实体按职责拆分调度核心由三张表支撑表名实体类担当角色关联方式cargo_flightFlight航班舱位容量与时刻被预占对象cargo_sheetCargoSheet待运货单基础信息分配来源cargo_allocationAllocationRecord航班与货单的占用关系flightId sheetId航班实体保存最大重量和最大体积货单实体保存总重量和总体积配载记录把两者关联起来并冗余了本次分配的重量与体积。冗余看似违背三范式实际是为了保留历史快照航班容量后续被手工调整时已确认配载记录仍然能还原当时占用情况避免join之后数值漂移。Data TableName(cargo_flight) public class Flight { TableId(type IdType.AUTO) private Long id; private String flightNo; private LocalDateTime departTime; private LocalDateTime cutOffTime; private Integer maxWeightKg; private BigDecimal maxVolumeM3; private String destStation; private LocalDateTime createTime; }Data TableName(cargo_sheet) public class CargoSheet { TableId(type IdType.AUTO) private Long id; private String sheetNo; private String originStation; private String destStation; private Integer pieceCount; private BigDecimal totalWeightKg; private BigDecimal totalVolumeM3; private Integer status; private LocalDateTime createTime; }Data TableName(cargo_allocation) public class AllocationRecord { TableId(type IdType.AUTO) private Long id; private Long flightId; private Long sheetId; private BigDecimal allocWeightKg; private BigDecimal allocVolumeM3; private Integer allocStatus; private LocalDateTime allocTime; }三个实体都用Lombok的Data减少样板代码重量和体积字段用BigDecimal而不是double因为调度过程要反复累加和比较double在累积多笔后会出现0.10.2这类精度问题。2.3 两套状态货单状态与配载记录状态货单状态和配载记录状态不能共用一套枚举。一张货单可以拆到多个航班每个航班上的预占独立释放、独立确认货单只要还有任一有效预占就处于已预占状态全部确认后才结束。用一套状态无法表达“同一货单在不同航班上状态不同”的情况。public final class SheetStatus { public static final int PENDING 0; public static final int RESERVED 1; public static final int CONFIRMED 2; public static final int FINISHED 3; public static final int CANCELLED 4; private SheetStatus() {} } public final class AllocationStatus { public static final int RELEASED 0; public static final int RESERVED 1; public static final int CONFIRMED 2; private AllocationStatus() {} }这两组状态是后续Service方法的判断基础。状态转换规则集中在Service层校验不要在Controller里直接改int字段否则漏掉“只有已预占才能确认”这类约束系统就会出现逻辑漏洞。3. 舱位分配调度核心从占用规则到Service实现3.1 调度约束拆解散货调度的约束通常有四个重量、体积、截止时间、目的站匹配。前两个控制舱位不超卖第三个控制时效第四个控制路径可达性。约束维度校验位置不满足时的表现重量/体积reserve方法内抛出“超出航班载重/容积”截止时间reserve方法最前返回“已过配载截止”目的站匹配查询候选航班时前端不显示不可达航班货单状态reserve方法内返回“货单不可预占”目的站匹配虽然可以在查询时过滤掉但Service层仍要再校验一次防止有人绕过列表接口直接对不可达航班发起预占。货单状态则保证同一张货单不会被并发调度到两个航班。3.2 预占、确认、释放三段式调度三段式是散货调度的核心状态机预占锁定容量但不落地确认代表货单可以装板释放则把舱位归还航班。先看FlightMapper的设置它比BaseMapper自带的selectById多一个行锁public interface FlightMapper extends BaseMapperFlight { Select(SELECT * FROM cargo_flight WHERE id #{id} FOR UPDATE) Flight selectByIdForUpdate(Param(id) Long id); }reserve方法是整个系统的关键路径Transactional public AllocationRecord reserve(Long flightId, Long sheetId) { Flight flight flightMapper.selectByIdForUpdate(flightId); if (flight null) { throw new BizException(1001, 航班不存在); } if (LocalDateTime.now().isAfter(flight.getCutOffTime())) { throw new BizException(1002, 已过配载截止时间); } CargoSheet sheet cargoSheetMapper.selectById(sheetId); if (sheet null || sheet.getStatus() ! SheetStatus.PENDING) { throw new BizException(1005, 货单不可预占); } BigDecimal usedWeight allocationMapper.sumWeightByFlight(flightId); BigDecimal usedVolume allocationMapper.sumVolumeByFlight(flightId); if (usedWeight.add(sheet.getTotalWeightKg()) .compareTo(BigDecimal.valueOf(flight.getMaxWeightKg())) 0) { throw new BizException(1003, 超出航班最大载重); } if (usedVolume.add(sheet.getTotalVolumeM3()) .compareTo(flight.getMaxVolumeM3()) 0) { throw new BizException(1004, 超出航班最大容积); } AllocationRecord rec new AllocationRecord(); rec.setFlightId(flightId); rec.setSheetId(sheetId); rec.setAllocWeightKg(sheet.getTotalWeightKg()); rec.setAllocVolumeM3(sheet.getTotalVolumeM3()); rec.setAllocStatus(AllocationStatus.RESERVED); allocationMapper.insert(rec); cargoSheetMapper.updateStatus(sheetId, SheetStatus.RESERVED); return rec; }这里有两个关键细节值得在代码注释和论文中同时写清楚。第一selectByIdForUpdate对航班行加了写锁锁住“读取已用容量”到“插入配载记录”之间的窗口防止两个并发事务都读到相同剩余舱位。第二这个锁要依赖Transactional开启的数据库事务事务提交时锁才释放所以不要把大量预占逻辑塞进同一个长事务。BigDecimal之间的比较用compareTo而不是大于小于号。confirm和release是对称操作Transactional public void confirm(Long allocationId) { AllocationRecord rec allocationMapper.selectById(allocationId); if (rec null || rec.getAllocStatus() ! AllocationStatus.RESERVED) { throw new BizException(1006, 只能确认预占状态的配载记录); } allocationMapper.updateStatus(allocationId, AllocationStatus.CONFIRMED); cargoSheetMapper.updateStatus(rec.getSheetId(), SheetStatus.CONFIRMED); } Transactional public void release(Long allocationId) { AllocationRecord rec allocationMapper.selectById(allocationId); if (rec null || rec.getAllocStatus() ! AllocationStatus.RESERVED) { return; } allocationMapper.updateStatus(allocationId, AllocationStatus.RELEASED); if (!allocationMapper.existsActiveBySheet(rec.getSheetId())) { cargoSheetMapper.updateStatus(rec.getSheetId(), SheetStatus.PENDING); } }release方法里对一张货单拆多航班的情况做了处理只在不存在任何有效配载记录时才把货单状态回退到待配载。否则只释放当前这一个航班上的记录货单仍保持已预占。3.3 候选航班排序用空闲率替代固定优先当一张货单有多个可装航班时按“空闲率”降序排列把货单优先放到占用率更低、余量更足的航班上能减少后续大单找不到舱位的概率。private double freeRatio(Flight f) { BigDecimal weight allocationMapper.sumWeightByFlight(f.getId()); BigDecimal volume allocationMapper.sumVolumeByFlight(f.getId()); double usedW weight.doubleValue() / f.getMaxWeightKg(); double usedV volume.doubleValue() / f.getMaxVolumeM3().doubleValue(); return 1.0 - Math.max(usedW, usedV); } ListFlight sorted candidates.stream() .filter(f - freeRatio(f) 0.05) .sorted(Comparator.comparingDouble(this::freeRatio).reversed()) .collect(Collectors.toList());这个排序逻辑的意义在于平衡两个瓶颈维度只看载重率会选出一个重量利用率高但容积大量空闲的航班后续轻抛货就没地方只看体积则反过来。用两个维度的最大值代表占用瓶颈空闲率低于0.05的航班直接从候选集剔除。每张货单都实时跑一遍汇总查询在数据量不大时没有问题量大了再考虑把航班已用容量缓存到Redis这是论文里可以写的优化点。4. MyBatis持久层、自动建表与查询优化4.1 三张表DDL与索引设计schema.sql是系统可复现的基础所有建表语句都写成IF NOT EXISTS这样SpringBoot每次启动执行都不会报错。以窄体机典型参数为例最大重量用INT容积用DECIMAL(10,3)重量和体积的精度控制在0.001立方米。CREATE TABLE IF NOT EXISTS cargo_flight ( id BIGINT AUTO_INCREMENT PRIMARY KEY, flight_no VARCHAR(32) NOT NULL, depart_time DATETIME NOT NULL, cut_off_time DATETIME NOT NULL, max_weight_kg INT NOT NULL, max_volume_m3 DECIMAL(10,3) NOT NULL, dest_station VARCHAR(16) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_depart_time (depart_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE IF NOT EXISTS cargo_sheet ( id BIGINT AUTO_INCREMENT PRIMARY KEY, sheet_no VARCHAR(32) NOT NULL, origin_station VARCHAR(16) NOT NULL, dest_station VARCHAR(16) NOT NULL, piece_count INT NOT NULL, total_weight_kg DECIMAL(10,3) NOT NULL, total_volume_m3 DECIMAL(10,3) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_dest (dest_station), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE IF NOT EXISTS cargo_allocation ( id BIGINT AUTO_INCREMENT PRIMARY KEY, flight_id BIGINT NOT NULL, sheet_id BIGINT NOT NULL, alloc_weight_kg DECIMAL(10,3) NOT NULL, alloc_volume_m3 DECIMAL(10,3) NOT NULL, alloc_status TINYINT NOT NULL DEFAULT 1, alloc_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_flight (flight_id, alloc_status), KEY idx_sheet (sheet_id), CONSTRAINT fk_alloc_flight FOREIGN KEY (flight_id) REFERENCES cargo_flight(id), CONSTRAINT fk_alloc_sheet FOREIGN KEY (sheet_id) REFERENCES cargo_sheet(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;cargo_allocation的联合索引顺序是flight_id alloc_status因为所有舱位汇总查询都以航班为过滤条件status字段帮助快速排除已释放记录。外键在开发场景里保留生产环境如果追求导入速度可以改成普通索引但要自己保证引用完整性。4.2 SpringBoot启动时自动建表自动建表的关键配置是spring.sql.init而不是旧版本的database-initializer。配置写在application.yml里spring: datasource: url: jdbc:mysql://localhost:3306/air_cargo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaicreateDatabaseIfNotExisttrue username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver sql: init: mode: always schema-locations: classpath:db/schema.sql continue-on-error: false encoding: utf-8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl配置项作用开发期建议mode是否执行初始化脚本always生产环境切neverschema-locations建表脚本位置classpath:db/schema.sqlcontinue-on-error单条SQL失败是否继续false尽早暴露createDatabaseIfNotExist驱动自动建库true配合脚本建表提示MySQL 8驱动类名要写com.mysql.cj.jdbc.Driver写旧的com.mysql.jdbc.Driver会启动失败时区参数用Asia/Shanghai否则LocalDateTime在写入和读取时会发生8小时偏移。4.3 Mapper汇总查询与分页配置预占方法里的容量汇总用注解SQL实现逻辑清晰不需要额外XML文件public interface AllocationMapper extends BaseMapperAllocationRecord { Select(SELECT COALESCE(SUM(alloc_weight_kg), 0) FROM cargo_allocation WHERE flight_id #{flightId} AND alloc_status IN (1, 2)) BigDecimal sumWeightByFlight(Param(flightId) Long flightId); Select(SELECT COALESCE(SUM(alloc_volume_m3), 0) FROM cargo_allocation WHERE flight_id #{flightId} AND alloc_status IN (1, 2)) BigDecimal sumVolumeByFlight(Param(flightId) Long flightId); Select(SELECT COUNT(1) FROM cargo_allocation WHERE sheet_id #{sheetId} AND alloc_status IN (1, 2) LIMIT 1) boolean existsActiveBySheet(Param(sheetId) Long sheetId); }三个SQL都只统计alloc_status为1或2的记录因为这两个状态都实际占用舱位释放状态0要排除否则释放后的舱位还留在已用容量里。existsActiveBySheet加上LIMIT 1只要存在一条有效记录就立即返回不用扫完全部索引。分页查询用MyBatis-Plus自带的selectPage但分页插件需要在配置类中显式注册否则分页参数不生效Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这是新手最容易漏的一步接口返回数据但不是按页切分最后发现是Interceptor没有注册。PaginationInnerInterceptor属于MyBatis-Plus扩展不注册它selectPage只能做全量查询。5. 调度接口设计REST约定与前端联调5.1 统一返回结构前后端分离项目的接口返回必须统一结构前端axios拦截器只解析code和message。timestamp放在最外层方便排查响应延迟。Data public class ApiResultT { private Integer code; private String message; private T data; private Long timestamp; public static T ApiResultT ok(T data) { ApiResultT r new ApiResult(); r.code 0; r.message success; r.data data; r.timestamp System.currentTimeMillis(); return r; } public static T ApiResultT fail(Integer code, String message) { ApiResultT r new ApiResult(); r.code code; r.message message; r.timestamp System.currentTimeMillis(); return r; } }业务异常类BizException持有code和messageService抛出的错误在Controller统一转成ApiResult.fail不要在每个类里重复try-catch。5.2 接口清单与错误码系统最小闭环至少要提供八个HTTP接口MethodPath说明GET/api/flights按日期、目的站查可配载航班POST/api/sheets新建货单GET/api/sheets分页查询货单GET/api/flights/{id}/allocation查航班已配载明细POST/api/allocation/reserve预占舱位POST/api/allocation/confirm确认配载POST/api/allocation/release释放预占GET/api/allocation/{sheetId}查货单拆分分配结果错误码集中用常量类或枚举管理不要散落魔法数字。一套建议的错误码表code含义0成功1001航班不存在1002航班已截止配载1003超出航班载重1004超出航班容积1005货单状态不可预占1006配载记录状态不允许确认Controller中reserve接口如写成如下形式前端的调用路径就固定为POST /api/allocation/reserveRestController RequestMapping(/api) public class AllocationController { private final AllocationService allocationService; public AllocationController(AllocationService allocationService) { this.allocationService allocationService; } PostMapping(/allocation/reserve) public ApiResultAllocationRecord reserve(RequestBody ReserveRequest req) { return ApiResult.ok(allocationService.reserve(req.getFlightId(), req.getSheetId())); } }ReserveRequest至少包含flightId、sheetId两个字段并加上NotNull参数校验。如果还有“应收运价”之类的字段不要放进reserve请求体那是发货模块的职责。5.3 跨域处理Vue前端和SpringBoot后端分开部署时跨域问题首选用Filter在入口统一处理而不是在每个Controller上写CrossOriginComponent public class SimpleCorsFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp (HttpServletResponse) response; resp.setHeader(Access-Control-Allow-Origin, *); resp.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); resp.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization); if (OPTIONS.equalsIgnoreCase(((HttpServletRequest) request).getMethod())) { resp.setStatus(HttpServletResponse.SC_OK); return; } chain.doFilter(request, response); } }通配符*适合开发联调环境上线时应该替换成前端的实际域名。OPTIONS预检请求直接返回200不进入业务处理。6. 并发压测、事务边界与启动排错6.1 并发预占超售验证把某个flightId的剩余舱位压到只有200公斤两个终端同时执行reserve。如果数据表里出现两条有效分配说明行锁没生效。在两个终端分别执行curl -X POST http://localhost:8080/api/allocation/reserve \ -H Content-Type: application/json \ -d {flightId: 1, sheetId: 2}curl -X POST http://localhost:8080/api/allocation/reserve \ -H Content-Type: application/json \ -d {flightId: 1, sheetId: 3}然后查cargo_allocation表按flightId过滤且alloc_status1的记录应该只有一条被拒绝的那张货单状态应保持0。如果两条都成功先查reserve方法是否加了Transactional再看selectByIdForUpdate是否真的走到了数据库层。用MyBatis-Plus的BaseMapper.selectById不会生成FOR UPDATE必须显式在FlightMapper里定义加锁方法。6.2 批量调度的锁释放与事务边界批量导入货单时不要在单个大事务里循环调用reserve。锁持有时间会随记录数线性增长一张表锁到几十行其他货控就会看到等待超时。常见做法是把reserve当作独立事务的入口批量方法里逐条调用并捕获业务异常每成功一条自然提交一条public BatchResult batchReserve(ListReserveRequest requests) { int success 0; for (ReserveRequest req : requests) { try { reserve(req.getFlightId(), req.getSheetId()); success; } catch (BizException e) { log.warn(sheet {} reserve failed: {}, req.getSheetId(), e.getMessage()); } } return new BatchResult(requests.size(), success); }这里有一个Spring AOP代理的坑reserve和batchReserve不能在同一类里互相调用否则Transactional代理不会加入锁的提交时机完全不可控。真实项目里这类批量方法单独抽一层或直接放到Service接口的另一个实现类中。6.3 自动建表日志定位表老是建不出来时打开数据源初始化日志和SQL日志启动后就能看到具体卡在哪条DDLlogging: level: root: info org.springframework.jdbc.datasource.init: debug com.aircargo.mapper: debug看日志时留意两点。第一schema.sql第二次启动必须全部走IF NOT EXISTS分支否则重启必报Table already exists如果报外键删除顺序问题把脚本改成SET FOREIGN_KEY_CHECKS0再重新建。第二Mapper的SQL日志只要看到“”“”就能确认预占时的锁SQL确实发出再配合SHOW ENGINE INNODB STATUS查看锁等待调度系统的并发问题基本都能定位到具体行。本文还有配套的精品资源点击获取