简介:这是一套面向航空物流信息化领域的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_flight | Flight | 航班舱位容量与时刻 | 被预占对象 |
| cargo_sheet | CargoSheet | 待运货单基础信息 | 分配来源 |
| cargo_allocation | AllocationRecord | 航班与货单的占用关系 | 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接口:
| Method | Path | 说明 |
|---|---|---|
| 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查看锁等待,调度系统的并发问题基本都能定位到具体行。
本文还有配套的精品资源,点击获取