基于SpringBoot的航空散货调度系统设计与实现
2026/9/16 16:51:10 网站建设 项目流程

简介:这是一套面向航空物流信息化领域的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版本用17,SpringBoot选3.2.x,数据库MySQL 8.x,持久层用MyBatis-Plus。

pom中的核心依赖如下:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

mybatis-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.1+0.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 BaseMapper<Flight> { @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); } List<Flight> 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true 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,生产环境切never
schema-locations建表脚本位置classpath:db/schema.sql
continue-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 BaseMapper<AllocationRecord> { @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 ApiResult<T> { private Integer code; private String message; private T data; private Long timestamp; public static <T> ApiResult<T> ok(T data) { ApiResult<T> r = new ApiResult<>(); r.code = 0; r.message = "success"; r.data = data; r.timestamp = System.currentTimeMillis(); return r; } public static <T> ApiResult<T> fail(Integer code, String message) { ApiResult<T> r = new ApiResult<>(); r.code = code; r.message = message; r.timestamp = System.currentTimeMillis(); return r; } }

业务异常类BizException持有code和message,Service抛出的错误在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/reserve:

@RestController @RequestMapping("/api") public class AllocationController { private final AllocationService allocationService; public AllocationController(AllocationService allocationService) { this.allocationService = allocationService; } @PostMapping("/allocation/reserve") public ApiResult<AllocationRecord> reserve(@RequestBody ReserveRequest req) { return ApiResult.ok(allocationService.reserve(req.getFlightId(), req.getSheetId())); } }

ReserveRequest至少包含flightId、sheetId两个字段,并加上@NotNull参数校验。如果还有“应收运价”之类的字段,不要放进reserve请求体,那是发货模块的职责。

5.3 跨域处理

Vue前端和SpringBoot后端分开部署时,跨域问题首选用Filter在入口统一处理,而不是在每个Controller上写@CrossOrigin:

@Component 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_status=1的记录应该只有一条;被拒绝的那张货单状态应保持0。如果两条都成功,先查reserve方法是否加了@Transactional,再看selectByIdForUpdate是否真的走到了数据库层。用MyBatis-Plus的BaseMapper.selectById不会生成FOR UPDATE,必须显式在FlightMapper里定义加锁方法。

6.2 批量调度的锁释放与事务边界

批量导入货单时,不要在单个大事务里循环调用reserve。锁持有时间会随记录数线性增长,一张表锁到几十行,其他货控就会看到等待超时。常见做法是把reserve当作独立事务的入口,批量方法里逐条调用并捕获业务异常,每成功一条自然提交一条:

public BatchResult batchReserve(List<ReserveRequest> 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日志,启动后就能看到具体卡在哪条DDL:

logging: level: root: info org.springframework.jdbc.datasource.init: debug com.aircargo.mapper: debug

看日志时留意两点。第一,schema.sql第二次启动必须全部走IF NOT EXISTS分支,否则重启必报Table already exists;如果报外键删除顺序问题,把脚本改成SET FOREIGN_KEY_CHECKS=0再重新建。第二,Mapper的SQL日志只要看到“==>”“<==”就能确认预占时的锁SQL确实发出,再配合SHOW ENGINE INNODB STATUS查看锁等待,调度系统的并发问题基本都能定位到具体行。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询