重构数据采集和路由查询逻辑,增强超时处理和并发控制。新增Redis缓存管理,优化航班路由参数获取,确保系统稳定性和性能。更新相关文档以反映最新功能和接口调用示例。路由第一次下发成功

This commit is contained in:
sladro 2026-02-07 10:52:37 +08:00
parent dee9bc4420
commit aa13f343ea
14 changed files with 1417 additions and 290 deletions

View File

@ -18,7 +18,7 @@
(4)Z31020**&d1231=+33
4.测试平台-- 10.100.23.10
(1)root
①Huawei@123
Dianxin@222
5.红绿灯测试平台-- 10.98.23.111 windows系统
(1)账密
①administrator
@ -40,3 +40,5 @@ mem_clients_normal:5146661
/home/project_20250804/qaup/adxp-adapter

View File

@ -0,0 +1,163 @@
package com.qaup.collision.controller;
import com.qaup.collision.datacollector.dao.DataCollectorDao;
import com.qaup.collision.datacollector.dto.AircraftRouteDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 跑道滑行路由查询调试接口可配置开关
*
* 目的
* - 用于联调验证 IN/OUT 两种拼参逻辑
* - 输出最终透传到机场滑行路由服务的 URL 与参数
*
* 开关
* - qaup.debug.runway-path-planning.enabled=true 才会注册该 Controller
*/
@Slf4j
@RestController
@RequestMapping("/debug/runway-path-planning")
@ConditionalOnProperty(name = "qaup.debug.runway-path-planning.enabled", havingValue = "true")
public class RunwayPathPlanningDebugController {
@Value("${data.collector.airport-api.glide-url}")
private String airportGlideUrl;
private final DataCollectorDao dataCollectorDao;
public RunwayPathPlanningDebugController(DataCollectorDao dataCollectorDao) {
this.dataCollectorDao = dataCollectorDao;
}
/**
* 调试查询 IN/OUT 规则拼参并可选执行真实调用
*
* 规则按需求最小实现
* - IN只透传 inRunway + contactCross + seat不透传 outRunway
* - OUT只透传 outRunway + startSeatstartSeat 缺失时用 seat 兜底不透传 inRunway
*/
@GetMapping("/query")
public ResponseEntity<Map<String, Object>> query(
@RequestParam("routeType") String routeType,
@RequestParam(value = "inRunway", required = false) String inRunway,
@RequestParam(value = "outRunway", required = false) String outRunway,
@RequestParam(value = "contactCross", required = false) String contactCross,
@RequestParam(value = "seat", required = false) String seat,
@RequestParam(value = "startSeat", required = false) String startSeat,
@RequestParam(value = "dryRun", defaultValue = "true") boolean dryRun
) {
Map<String, Object> result = new LinkedHashMap<>();
List<String> ignoredParams = new ArrayList<>();
String normalizedRouteType = normalize(routeType);
if (normalizedRouteType == null) {
return badRequest("routeType为空", result);
}
normalizedRouteType = normalizedRouteType.toUpperCase();
String nInRunway = normalize(inRunway);
String nOutRunway = normalize(outRunway);
String nContactCross = normalize(contactCross);
String nSeat = normalize(seat);
String nStartSeat = normalize(startSeat);
result.put("routeType", normalizedRouteType);
result.put("dryRun", dryRun);
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(airportGlideUrl);
Map<String, Object> effectiveParams = new LinkedHashMap<>();
AircraftRouteDTO routeData = null;
if ("IN".equalsIgnoreCase(normalizedRouteType)) {
if (nInRunway == null) {
return badRequest("IN 路由缺少必填参数: inRunway", result);
}
if (nContactCross == null) {
return badRequest("IN 路由缺少必填参数: contactCross", result);
}
if (nSeat == null) {
return badRequest("IN 路由缺少必填参数: seat", result);
}
// IN只传 inRunway + contactCross + seat不传 outRunway
ignoredParams.add("outRunway");
ignoredParams.add("startSeat");
builder.path("/runwayPathPlanningController/findArrTaxiwayByRunwayAndContactCrossAndSeat")
.queryParam("inRunway", nInRunway)
.queryParam("contactCross", nContactCross)
.queryParam("seat", nSeat);
effectiveParams.put("inRunway", nInRunway);
effectiveParams.put("contactCross", nContactCross);
effectiveParams.put("seat", nSeat);
if (!dryRun) {
routeData = dataCollectorDao.getArrivalRoute(nInRunway, nOutRunway, nContactCross, nSeat);
}
} else if ("OUT".equalsIgnoreCase(normalizedRouteType)) {
if (nOutRunway == null) {
return badRequest("OUT 路由缺少必填参数: outRunway", result);
}
// OUTstartSeat 缺失时允许 seat 兜底
String effectiveStartSeat = nStartSeat != null ? nStartSeat : nSeat;
if (effectiveStartSeat == null) {
return badRequest("OUT 路由缺少必填参数: startSeat可用 seat 兜底)", result);
}
ignoredParams.add("inRunway");
ignoredParams.add("contactCross");
builder.path("/runwayPathPlanningController/findDepTaxiwayByRunwayAndContactCrossAndSeat")
.queryParam("outRunway", nOutRunway)
.queryParam("startSeat", effectiveStartSeat);
effectiveParams.put("outRunway", nOutRunway);
effectiveParams.put("startSeat", effectiveStartSeat);
if (nStartSeat == null && nSeat != null) {
effectiveParams.put("startSeatFallbackFromSeat", true);
}
if (!dryRun) {
routeData = dataCollectorDao.getDepartureRoute(nInRunway, nOutRunway, effectiveStartSeat);
}
} else {
return badRequest("routeType只支持 IN 或 OUT", result);
}
result.put("effectiveParams", effectiveParams);
result.put("ignoredParams", ignoredParams);
result.put("url", builder.toUriString());
if (!dryRun) {
result.put("routeData", routeData);
}
return ResponseEntity.ok(result);
}
private static ResponseEntity<Map<String, Object>> badRequest(String message, Map<String, Object> body) {
body.put("error", message);
return ResponseEntity.badRequest().body(body);
}
private static String normalize(String s) {
if (s == null) {
return null;
}
String t = s.trim();
return t.isEmpty() ? null : t;
}
}

View File

@ -4,26 +4,43 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
// @Bean
// public RestTemplate restTemplate() {
// return new RestTemplate();
// }
@Bean
public RestTemplate restTemplate(ObjectMapper restTemplateObjectMapper) {
RestTemplate restTemplate = new RestTemplate();
/**
* HTTP 连接超时毫秒
* 目的避免上游接口抖动时 RestTemplate 长时间阻塞导致 @Scheduled 任务堆积线程/连接被耗尽
*/
@Value("${data.collector.http.connect-timeout-ms:1000}")
private int connectTimeoutMs;
/**
* HTTP 读取超时毫秒
* 目的避免请求卡在 read 阶段导致任务挤压
*/
@Value("${data.collector.http.read-timeout-ms:2000}")
private int readTimeoutMs;
@Bean
public RestTemplate restTemplate(ObjectMapper restTemplateObjectMapper) {
// 使用最小改动的 SimpleClientHttpRequestFactory 配置超时不引入新依赖不改变调用逻辑
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(connectTimeoutMs);
requestFactory.setReadTimeout(readTimeoutMs);
RestTemplate restTemplate = new RestTemplate(requestFactory);
restTemplate.getMessageConverters().forEach(converter -> {
if (converter instanceof MappingJackson2HttpMessageConverter) {
((MappingJackson2HttpMessageConverter) converter).setObjectMapper(restTemplateObjectMapper);
}
});
return restTemplate;
}
}
@Bean
public ObjectMapper restTemplateObjectMapper() {

View File

@ -11,10 +11,13 @@ import com.qaup.collision.datacollector.dto.FlightNotificationDTO;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
@ -31,9 +34,11 @@ import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import com.qaup.common.core.redis.RedisCache;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@ -65,11 +70,19 @@ public class DataCollectorDao {
private final RestTemplate restTemplate;
private final AuthService authService;
private final GeometryFactory geometryFactory;
private final ObjectMapper objectMapper;
/**
* Redis缓存用于在机场平台缺失 getRouteParams 接口时聚合上游事件补齐路由参数
*/
@Autowired(required = false)
private RedisCache redisCache;
public DataCollectorDao(RestTemplate restTemplate, AuthService authService) {
this.restTemplate = restTemplate;
this.authService = authService;
this.geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); // SRID 4326 for WGS84
this.objectMapper = new ObjectMapper();
}
@ -208,7 +221,7 @@ public class DataCollectorDao {
* 获取航空器进港路由
*
* @param inRunway 进港跑道编号
* @param outRunway 出港跑道编号
* @param outRunway 出港跑道编号机场侧接口要求参数key存在当缺失时上游会补齐或用占位传递
* @param contactCross 联络道口
* @param seat 目的机位
* @return 进港路由数据
@ -221,10 +234,14 @@ public class DataCollectorDao {
return null;
}
// 机场侧接口校验要求参数 key 存在Required String parameter ... is not present但允许空值
// 为满足IN 只关心 inRunway/contactCross/seat的业务逻辑同时避免 400这里对 outRunway 采用空串占位
String outRunwayForQuery = (outRunway == null ? "" : outRunway);
String url = UriComponentsBuilder.fromUriString(airportGlideUrl)
.path("/runwayPathPlanningController/findArrTaxiwayByRunwayAndContactCrossAndSeat")
.queryParam("inRunway", inRunway)
.queryParam("outRunway", outRunway)
.queryParam("outRunway", outRunwayForQuery)
.queryParam("contactCross", contactCross)
.queryParam("seat", seat)
.toUriString();
@ -235,22 +252,39 @@ public class DataCollectorDao {
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
ResponseEntity<Response<AircraftRouteDTO>> response = restTemplate.exchange(
// 注意路由服务返回格式在不同环境可能有两种
// 1) 包装格式{status,msg,data:{...}}
// 2) 裸对象{type,status,codes,geoPath,...}
// 这里先取 String再按 JSON 结构判断解析避免 UnrecognizedPropertyException(type)
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.GET,
requestEntity,
new ParameterizedTypeReference<Response<AircraftRouteDTO>>() {}
String.class
);
Response<AircraftRouteDTO> responseBody = response.getBody();
if (responseBody != null && responseBody.getStatus() == 200) {
log.info("成功获取进港路由数据: inRunway={}, outRunway={}, contactCross={}, seat={}",
inRunway, outRunway, contactCross, seat);
return responseBody.getData();
} else {
log.warn("获取进港路由数据失败: {}", responseBody != null ? responseBody.getMsg() : "未知错误");
if (!response.getStatusCode().is2xxSuccessful()) {
log.warn("获取进港路由数据请求失败: HTTP {}, inRunway={}, outRunway={}, contactCross={}, seat={}",
response.getStatusCode(), inRunway, outRunway, contactCross, seat);
log.warn("进港路由请求URL: {}", url);
return null;
}
String body = response.getBody();
AircraftRouteDTO route = parseAircraftRouteBody(body);
if (route != null) {
log.info("成功获取进港路由数据: inRunway={}, outRunway={}, contactCross={}, seat={}",
inRunway, outRunway, contactCross, seat);
} else {
log.warn("获取进港路由数据失败: 返回体为空或无法解析, inRunway={}, outRunway={}, contactCross={}, seat={}, url={}, bodySummary={}",
inRunway, outRunway, contactCross, seat, url, summarizeBody(body));
}
return route;
} catch (HttpClientErrorException | HttpServerErrorException e) {
log.error("获取进港路由数据请求失败: status={}, body={}, inRunway={}, outRunway={}, contactCross={}, seat={}",
e.getStatusCode(), e.getResponseBodyAsString(), inRunway, outRunway, contactCross, seat, e);
return null;
} catch (Exception e) {
log.error("获取进港路由数据时发生异常: inRunway={}, outRunway={}, contactCross={}, seat={}",
inRunway, outRunway, contactCross, seat, e);
@ -261,7 +295,7 @@ public class DataCollectorDao {
/**
* 获取航空器出港路由
*
* @param inRunway 进港跑道编号
* @param inRunway 进港跑道编号机场侧业务可能依赖该值当缺失时上游会补齐或用占位传递
* @param outRunway 出港跑道编号
* @param startSeat 起始机位
* @return 出港路由数据
@ -274,9 +308,13 @@ public class DataCollectorDao {
return null;
}
// 机场侧接口校验要求参数 key 存在但允许空值
// 为满足OUT 只关心 outRunway/startSeat的业务逻辑同时避免 400这里对 inRunway 采用空串占位
String inRunwayForQuery = (inRunway == null ? "" : inRunway);
String url = UriComponentsBuilder.fromUriString(airportGlideUrl)
.path("/runwayPathPlanningController/findDepTaxiwayByRunwayAndContactCrossAndSeat")
.queryParam("inRunway", inRunway)
.queryParam("inRunway", inRunwayForQuery)
.queryParam("outRunway", outRunway)
.queryParam("startSeat", startSeat)
.toUriString();
@ -287,22 +325,36 @@ public class DataCollectorDao {
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
ResponseEntity<Response<AircraftRouteDTO>> response = restTemplate.exchange(
// getArrivalRoute兼容包装/裸对象两种返回格式
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.GET,
requestEntity,
new ParameterizedTypeReference<Response<AircraftRouteDTO>>() {}
String.class
);
Response<AircraftRouteDTO> responseBody = response.getBody();
if (responseBody != null && responseBody.getStatus() == 200) {
log.info("成功获取出港路由数据: inRunway={}, outRunway={}, startSeat={}",
inRunway, outRunway, startSeat);
return responseBody.getData();
} else {
log.warn("获取出港路由数据失败: {}", responseBody != null ? responseBody.getMsg() : "未知错误");
if (!response.getStatusCode().is2xxSuccessful()) {
log.warn("获取出港路由数据请求失败: HTTP {}, inRunway={}, outRunway={}, startSeat={}",
response.getStatusCode(), inRunway, outRunway, startSeat);
log.warn("出港路由请求URL: {}", url);
return null;
}
String body = response.getBody();
AircraftRouteDTO route = parseAircraftRouteBody(body);
if (route != null) {
log.info("成功获取出港路由数据: inRunway={}, outRunway={}, startSeat={}",
inRunway, outRunway, startSeat);
} else {
log.warn("获取出港路由数据失败: 返回体为空或无法解析, inRunway={}, outRunway={}, startSeat={}, url={}, bodySummary={}",
inRunway, outRunway, startSeat, url, summarizeBody(body));
}
return route;
} catch (HttpClientErrorException | HttpServerErrorException e) {
log.error("获取出港路由数据请求失败: status={}, body={}, inRunway={}, outRunway={}, startSeat={}",
e.getStatusCode(), e.getResponseBodyAsString(), inRunway, outRunway, startSeat, e);
return null;
} catch (Exception e) {
log.error("获取出港路由数据时发生异常: inRunway={}, outRunway={}, startSeat={}",
inRunway, outRunway, startSeat, e);
@ -310,6 +362,58 @@ public class DataCollectorDao {
}
}
/**
* 解析路由接口返回体兼容两种格式
* 1) 包装格式{status,msg,data:{...}}
* 2) 裸对象{type,status,codes,geoPath,...}
*/
private AircraftRouteDTO parseAircraftRouteBody(String body) {
try {
if (body == null || body.isBlank()) {
return null;
}
JsonNode root = objectMapper.readTree(body);
if (root == null || root.isNull()) {
return null;
}
// 包装格式优先取 data 节点
if (root.has("data")) {
JsonNode dataNode = root.get("data");
if (dataNode == null || dataNode.isNull()) {
return null;
}
return objectMapper.treeToValue(dataNode, AircraftRouteDTO.class);
}
// 裸对象直接映射
return objectMapper.treeToValue(root, AircraftRouteDTO.class);
} catch (Exception e) {
log.error("解析路由返回体失败: {}", body, e);
return null;
}
}
/**
* 将返回体摘要化避免日志刷屏
* - body 为空返回 "<empty>"
* - body 非空返回 "len=<n>, head=<前200字符>"
*/
private static String summarizeBody(String body) {
if (body == null) {
return "<empty>";
}
String b = body.trim();
if (b.isEmpty()) {
return "<empty>";
}
int len = b.length();
String head = b.substring(0, Math.min(200, len));
return "len=" + len + ", head=" + head;
}
/**
* 获取航空器状态
*
@ -363,44 +467,187 @@ public class DataCollectorDao {
* @return 航班路由参数数据
*/
public AircraftRouteParamsDTO getAircraftRouteParams(String flightNo, String routeType) {
// try {
// String token = authService.getToken(airportBaseUrl);
// if (token == null) {
// log.error("无法获取有效的认证token");
// return null;
// }
//
// String url = UriComponentsBuilder.fromUriString(airportBaseUrl)
// .path("/aircraftRouteParamsController/getRouteParams")
// .queryParam("flightNo", flightNo)
// .queryParam("routeType", routeType)
// .toUriString();
//
// HttpHeaders headers = new HttpHeaders();
// headers.set("Authorization", token);
// headers.set("Content-Type", "application/json");
//
// HttpEntity<String> requestEntity = new HttpEntity<>(headers);
// ResponseEntity<Response<AircraftRouteParamsDTO>> response = restTemplate.exchange(
// url,
// HttpMethod.GET,
// requestEntity,
// new ParameterizedTypeReference<Response<AircraftRouteParamsDTO>>() {}
// );
// Response<AircraftRouteParamsDTO> responseBody = response.getBody();
// if (responseBody != null && responseBody.getStatus() == 200) {
// log.info("成功获取航班路由参数: flightNo={}, routeType={}", flightNo, routeType);
// return responseBody.getData();
// } else {
// log.warn("获取航班路由参数失败: {}", responseBody != null ? responseBody.getMsg() : "未知错误");
// return null;
// }
// } catch (Exception e) {
// log.error("获取航班路由参数时发生异常: flightNo={}, routeType={}", flightNo, routeType, e);
// return null;
// }
try {
if (flightNo == null || flightNo.isBlank()) {
log.warn("获取航班路由参数失败flightNo为空, routeType={}", routeType);
return null;
}
if (routeType == null || routeType.isBlank()) {
log.warn("获取航班路由参数失败routeType为空, flightNo={}", flightNo);
return null;
}
// 兼容上游 BizKey 格式CZ3519-A-20260206111500
// 机场路由参数接口通常按纯航班号查询因此这里做一次归一化
String normalizedFlightNo = flightNo;
if (normalizedFlightNo.contains("-")) {
String[] parts = normalizedFlightNo.split("-", 2);
if (parts.length > 0 && parts[0] != null && !parts[0].isBlank()) {
normalizedFlightNo = parts[0].trim();
}
}
final String normalizedRouteType = routeType.trim().toUpperCase();
String token = authService.getToken(airportBaseUrl);
if (token == null) {
log.error("无法获取有效的认证token跳过路由参数查询: flightNo={}, routeType={}", normalizedFlightNo, routeType);
return buildRouteParamsFromRedis(normalizedFlightNo, flightNo, normalizedRouteType, "token_null");
}
String url = UriComponentsBuilder.fromUriString(airportBaseUrl)
.path("/aircraftRouteParamsController/getRouteParams")
.queryParam("flightNo", normalizedFlightNo)
.queryParam("routeType", normalizedRouteType)
.toUriString();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", token);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
ResponseEntity<Response<AircraftRouteParamsDTO>> response = restTemplate.exchange(
url,
HttpMethod.GET,
requestEntity,
new ParameterizedTypeReference<Response<AircraftRouteParamsDTO>>() {}
);
Response<AircraftRouteParamsDTO> responseBody = response.getBody();
if (responseBody != null && responseBody.getStatus() == 200) {
AircraftRouteParamsDTO data = responseBody.getData();
log.info("成功获取航班路由参数: flightNo={} (raw={}), routeType={}",
normalizedFlightNo, flightNo, routeType);
return data;
}
log.warn("获取航班路由参数失败: flightNo={} (raw={}), routeType={}, msg={}",
normalizedFlightNo, flightNo, routeType,
responseBody != null ? responseBody.getMsg() : "未知错误");
return buildRouteParamsFromRedis(normalizedFlightNo, flightNo, normalizedRouteType, "http_non_200");
} catch (HttpClientErrorException | HttpServerErrorException e) {
log.error("获取航班路由参数请求失败: flightNo={}, routeType={}, status={}, body={}",
flightNo, routeType, e.getStatusCode(), e.getResponseBodyAsString(), e);
// 机场平台在正式环境可能未提供 getRouteParams常见404此处兜底从Redis聚合参数便于继续路由下发或定位缺字段
String normalizedFlightNo = flightNo != null ? flightNo.split("-", 2)[0] : null;
String normalizedRouteType = routeType != null ? routeType.trim().toUpperCase() : null;
return buildRouteParamsFromRedis(normalizedFlightNo, flightNo, normalizedRouteType, "http_" + e.getStatusCode());
} catch (ResourceAccessException e) {
log.error("获取航班路由参数网络异常: flightNo={}, routeType={}, msg={}",
flightNo, routeType, e.getMessage(), e);
String normalizedFlightNo = flightNo != null ? flightNo.split("-", 2)[0] : null;
String normalizedRouteType = routeType != null ? routeType.trim().toUpperCase() : null;
return buildRouteParamsFromRedis(normalizedFlightNo, flightNo, normalizedRouteType, "network_error");
} catch (Exception e) {
log.error("获取航班路由参数时发生异常: flightNo={}, routeType={}", flightNo, routeType, e);
String normalizedFlightNo = flightNo != null ? flightNo.split("-", 2)[0] : null;
String normalizedRouteType = routeType != null ? routeType.trim().toUpperCase() : null;
return buildRouteParamsFromRedis(normalizedFlightNo, flightNo, normalizedRouteType, "exception");
}
}
/**
* 当机场平台缺失 getRouteParams 接口或调用失败时 Redis 聚合路由参数
*
* Redis key 约定flight:<纯航班号>
* 字段来源由上游事件逐步补齐
* - inRunway/outRunway: RUNWAY/DFIE 等事件写入
* - contactCross: TISFLIGHT 事件写入
* - seat: CRAFTSEAT 事件写入
* - startSeat: 若未单独提供尝试使用 seat 兜底出港通常从机位推出
*
* 设计目标
* - 字段齐全则返回 DTO后续可直接查询路由并下发给前端
* - 字段不全则返回 null同时输出缺字段清单 + 当前Redis快照方便定位问题所在
*/
private AircraftRouteParamsDTO buildRouteParamsFromRedis(
String normalizedFlightNo,
String rawFlightNo,
String normalizedRouteType,
String fallbackReason) {
try {
if (redisCache == null) {
// 注意当前 logback 文件落地只保留 INFO/ERRORWARN 不会写入 /logs
// 为了便于容器环境排障这里使用 INFO 级别输出关键原因
log.info("路由参数Redis兜底不可用RedisCache未注入跳过: flightNo={}, routeType={}, reason={}",
rawFlightNo, normalizedRouteType, fallbackReason);
return null;
}
if (normalizedFlightNo == null || normalizedFlightNo.isBlank()
|| normalizedRouteType == null || normalizedRouteType.isBlank()) {
log.info("路由参数Redis兜底入参无效跳过: flightNo={} (normalized={}), routeType={}, reason={}",
rawFlightNo, normalizedFlightNo, normalizedRouteType, fallbackReason);
return null;
}
String key = "flight:" + normalizedFlightNo.trim();
Map<String, Object> snapshot = redisCache.getCacheMap(key);
String inRunway = toNonBlankString(snapshot.get("inRunway"));
String outRunway = toNonBlankString(snapshot.get("outRunway"));
String contactCross = toNonBlankString(snapshot.get("contactCross"));
String seat = toNonBlankString(snapshot.get("seat"));
String startSeat = toNonBlankString(snapshot.get("startSeat"));
// 出港如果没有startSeat尝试用seat兜底机位号通常就是推出起点
if ("OUT".equalsIgnoreCase(normalizedRouteType) && (startSeat == null || startSeat.isBlank())) {
if (seat != null && !seat.isBlank()) {
startSeat = seat;
log.info("路由参数Redis兜底startSeat缺失使用seat兜底: flightNo={}, seatAsStartSeat={}",
normalizedFlightNo, seat);
}
}
// 缺字段清单用于定位数据源/事件是否补齐
StringBuilder missing = new StringBuilder();
if (inRunway == null || inRunway.isBlank()) missing.append("inRunway,");
if (outRunway == null || outRunway.isBlank()) missing.append("outRunway,");
if ("IN".equalsIgnoreCase(normalizedRouteType)) {
if (contactCross == null || contactCross.isBlank()) missing.append("contactCross,");
if (seat == null || seat.isBlank()) missing.append("seat,");
} else if ("OUT".equalsIgnoreCase(normalizedRouteType)) {
if (startSeat == null || startSeat.isBlank()) missing.append("startSeat,");
} else {
missing.append("routeType(IN/OUT),");
}
if (missing.length() > 0) {
// 去掉末尾逗号
missing.setLength(missing.length() - 1);
// 重要字段不全是路由下发失败的根因需要在 /logs/sys-info.log 可见
log.info("路由参数Redis兜底失败字段不全: flightNo={} (raw={}), routeType={}, missing=[{}], reason={}, redis={}",
normalizedFlightNo, rawFlightNo, normalizedRouteType, missing, fallbackReason, snapshot);
return null;
}
AircraftRouteParamsDTO dto = new AircraftRouteParamsDTO();
dto.setFlightNo(normalizedFlightNo);
dto.setRouteType(normalizedRouteType);
dto.setInRunway(inRunway);
dto.setOutRunway(outRunway);
dto.setContactCross(contactCross);
dto.setSeat(seat);
dto.setStartSeat(startSeat);
dto.setTimestamp(System.currentTimeMillis());
log.info("路由参数Redis兜底成功: flightNo={} (raw={}), routeType={}, inRunway={}, outRunway={}, contactCross={}, seat={}, startSeat={}, reason={}",
normalizedFlightNo, rawFlightNo, normalizedRouteType,
inRunway, outRunway, contactCross, seat, startSeat, fallbackReason);
return dto;
} catch (Exception e) {
log.error("路由参数Redis兜底异常: flightNo={} (raw={}), routeType={}, reason={}",
normalizedFlightNo, rawFlightNo, normalizedRouteType, fallbackReason, e);
return null;
}
}
private static String toNonBlankString(Object v) {
if (v == null) return null;
String s = String.valueOf(v).trim();
return s.isEmpty() ? null : s;
}
/**
* 获取航班进出港通知

View File

@ -3,6 +3,7 @@ package com.qaup.collision.datacollector.dto;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@ -122,6 +123,28 @@ public class AircraftRouteDTO {
/**
* 路径段编码
*/
private String code;
private Object code;
/**
* code 归一化为字符串
*
* 背景机场侧 GeoJSON properties.code 在不同路段可能返回
* - "B" / "F6" / ""字符串
* - {}空对象
*
* 如果强类型使用 String会在遇到 {} 时触发 Jackson 反序列化异常导致整条路由解析失败
*/
@JsonIgnore
public String getCodeAsString() {
if (code == null) {
return "";
}
if (code instanceof String) {
String s = ((String) code).trim();
return s.isEmpty() ? "" : s;
}
// 非字符串 {} / Map / List统一视为无编码
return "";
}
}
}

View File

@ -66,6 +66,23 @@ public class AircraftRouteParamsDTO {
@JsonProperty("timestamp")
private Long timestamp;
/**
* 标记 inRunway 是否为系统补齐值true=补齐false/=来自报文/Redis原始数据
*
* 说明
* - 机场路由服务在参数缺失时会 400Required parameter not present
* - 即便允许空占位业务上可能仍需要跑道值才能返回路由
* - 因此在缺失时使用固定值补齐并将来源标记下发给前端用于联调核对
*/
@JsonProperty("inRunwayPatched")
private Boolean inRunwayPatched;
/**
* 标记 outRunway 是否为系统补齐值true=补齐false/=来自报文/Redis原始数据
*/
@JsonProperty("outRunwayPatched")
private Boolean outRunwayPatched;
/**
* 检查是否为有效的路由参数
*/

View File

@ -13,6 +13,9 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
public class AuthService {
@ -33,8 +36,23 @@ public class AuthService {
private String refreshEndpoint;
private final RestTemplate restTemplate;
private String token;
private long tokenExpiryTime;
/**
* baseUrl 维度缓存 token避免不同服务例如 8090 位置接口 vs 8099 路由接口互相串用 token 导致 403
*
* key: baseUrl例如 http://10.32.38.3:8090 http://10.32.38.3:8099
*/
private final Map<String, TokenInfo> tokenCache = new ConcurrentHashMap<>();
private static class TokenInfo {
private final String token;
private final long tokenExpiryTimeMs;
private TokenInfo(String token, long tokenExpiryTimeMs) {
this.token = token;
this.tokenExpiryTimeMs = tokenExpiryTimeMs;
}
}
public AuthService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
@ -61,10 +79,11 @@ public class AuthService {
if (response.getStatusCode().is2xxSuccessful()) {
Response<String> responseBody = response.getBody();
if (responseBody != null) {
this.token = responseBody.getData();
this.tokenExpiryTime = System.currentTimeMillis() + 3600 * 1000;
log.info("Successfully obtained new token");
return this.token;
String token = responseBody.getData();
long expiryMs = System.currentTimeMillis() + 3600 * 1000;
tokenCache.put(baseUrl, new TokenInfo(token, expiryMs));
log.info("Successfully obtained new token for baseUrl={}", baseUrl);
return token;
}
}
} catch (Exception e) {
@ -82,8 +101,15 @@ public class AuthService {
.toUriString();
try {
// 创建带有当前token的请求头
HttpEntity<?> requestEntity = new HttpEntity<>(createAuthHeader());
TokenInfo current = tokenCache.get(baseUrl);
if (current == null || current.token == null) {
return loginAndGetToken(baseUrl);
}
// 创建带有当前token的请求头注意token 本身已包含 Bearer 前缀时无需额外拼接
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", current.token);
HttpEntity<Void> requestEntity = new HttpEntity<>(headers);
ResponseEntity<Response<String>> response = restTemplate.exchange(
refreshUrl,
@ -95,10 +121,11 @@ public class AuthService {
if (response.getStatusCode().is2xxSuccessful()) {
Response<String> responseBody = response.getBody();
if (responseBody != null) {
this.token = responseBody.getData();
this.tokenExpiryTime = System.currentTimeMillis() + 3600 * 1000;
log.info("Successfully refreshed token");
return this.token;
String token = responseBody.getData();
long expiryMs = System.currentTimeMillis() + 3600 * 1000;
tokenCache.put(baseUrl, new TokenInfo(token, expiryMs));
log.info("Successfully refreshed token for baseUrl={}", baseUrl);
return token;
}
}
} catch (Exception e) {
@ -108,29 +135,24 @@ public class AuthService {
return loginAndGetToken(baseUrl);
}
//创造带有Token的请求头
private HttpHeaders createAuthHeader() {
HttpHeaders headers = new HttpHeaders();
if (token != null) {
headers.set("Authorization", token);
}
return headers;
}
//获取Token
public String getToken(String baseUrl) {
long currentTime = System.currentTimeMillis();
if (token == null) {
TokenInfo current = tokenCache.get(baseUrl);
if (current == null || current.token == null) {
return loginAndGetToken(baseUrl);
}
// 如果token已过期重新登录
if (currentTime >= tokenExpiryTime) {
if (currentTime >= current.tokenExpiryTimeMs) {
return loginAndGetToken(baseUrl);
}
// 如果token即将过期比如还有10分钟过期尝试续期
if (currentTime >= tokenExpiryTime - 600_000) {
if (currentTime >= current.tokenExpiryTimeMs - 600_000) {
return refreshToken(baseUrl);
}
return token;
return current.token;
}
}

View File

@ -31,6 +31,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
@ -73,6 +74,19 @@ public class DataCollectorService {
@Value("${data.collector.route.periodic-collection-enabled:false}")
private boolean periodicRouteCollectionEnabled;
/**
* 防止高频采集任务重入导致并发堆积上游接口被打爆日志/线程资源挤压
* 策略丢弃旧轮次保实时如果上一轮未完成本轮直接跳过
*
* 说明
* - 本类中存在 fixedRate=250ms 的高频任务且部分标记 @Async
* - 在多线程调度器下若上游抖动/超时fixedRate 会产生并发重入
* - 这里用 AtomicBoolean 做最小侵入的防重入保护不改变正常情况下的业务逻辑
*/
private final AtomicBoolean aircraftCollectInProgress = new AtomicBoolean(false);
private final AtomicBoolean vehicleCollectInProgress = new AtomicBoolean(false);
private final AtomicBoolean unmannedCollectInProgress = new AtomicBoolean(false);
@Autowired
private DataCollectorDao dataCollectorDao;
@ -415,6 +429,12 @@ public class DataCollectorService {
return;
}
// 丢弃旧轮次上一轮未完成时跳过避免任务堆积
if (!aircraftCollectInProgress.compareAndSet(false, true)) {
log.warn("航空器采集上一轮尚未完成,跳过本轮以避免堆积(保持实时性)");
return;
}
try {
List<Aircraft> newAircrafts = dataCollectorDao.collectAircraftData(airportAircraftEndpoint, airportBaseUrl);
if (newAircrafts.isEmpty()) {
@ -463,6 +483,8 @@ public class DataCollectorService {
} catch (Exception e) {
log.error("采集航空器数据异常", e);
} finally {
aircraftCollectInProgress.set(false);
}
}
@ -483,6 +505,12 @@ public class DataCollectorService {
return;
}
// 丢弃旧轮次上一轮未完成时跳过避免 fixedRate + @Async 并发堆积
if (!vehicleCollectInProgress.compareAndSet(false, true)) {
log.warn("机场车辆采集上一轮尚未完成,跳过本轮以避免堆积(保持实时性)");
return;
}
try {
List<AirportVehicle> vehicles = dataCollectorDao.collectVehicleData(airportVehicleEndpoint, airportBaseUrl);
if (vehicles.isEmpty()) {
@ -549,6 +577,8 @@ public class DataCollectorService {
} catch (Exception e) {
log.error("采集机场车辆数据异常", e);
} finally {
vehicleCollectInProgress.set(false);
}
}
@ -569,6 +599,12 @@ public class DataCollectorService {
return;
}
// 丢弃旧轮次上一轮未完成时跳过避免多车循环HTTP + fixedRate + @Async堆积
if (!unmannedCollectInProgress.compareAndSet(false, true)) {
log.warn("无人车状态采集上一轮尚未完成,跳过本轮以避免堆积(保持实时性)");
return;
}
try {
if (unmannedVehicleIds.isEmpty()) {
// 启动时可能尚未刷新到列表这里做一次快速拉取作为兜底
@ -723,6 +759,8 @@ public class DataCollectorService {
} catch (Exception e) {
log.error("采集无人车数据异常", e);
} finally {
unmannedCollectInProgress.set(false);
}
}

View File

@ -540,11 +540,11 @@ public class AdxpFlightServiceWebSocketClient implements WebSocketHandler {
String RunwayNum = getTextContent(root, "RunwayNum");
String InOut = getTextContent(root, "InOut");
//分解BizKey
String[] arr = flightNo.split("-", 3); // 最多切 3
String[] arr = (flightNo == null ? new String[0] : flightNo.split("-", 3)); // 最多切 3
FlightNotificationDTO dto = new FlightNotificationDTO();
dto.setFlightNo(flightNo);
if (InOut.equals("A")){
if ("A".equals(InOut)){
dto.setType("IN");
}else {
dto.setType("OUT");
@ -558,11 +558,13 @@ public class AdxpFlightServiceWebSocketClient implements WebSocketHandler {
redisCache.setCacheMapValue(key, "flightNumber", arr[0]);
redisCache.setCacheMapValue(key, "type", arr[1]);
redisCache.setCacheMapValue(key, "time", arr[2]);
if (InOut.equals("A")){
if (RunwayNum != null && !RunwayNum.isBlank()) {
if ("A".equals(InOut)){
redisCache.setCacheMapValue(key, "inRunway", RunwayNum);
}else {
redisCache.setCacheMapValue(key, "outRunway", RunwayNum);
}
}
log.info("成功将航班数据存储到Redis: flightNumber={}, type={}, time={}", arr[0], arr[1], arr[2]);
} catch (Exception e) {
log.error("存储航班数据到Redis失败: {}", e.getMessage());
@ -580,14 +582,16 @@ public class AdxpFlightServiceWebSocketClient implements WebSocketHandler {
log.error("inRunway-{}",inRunway);
log.error("outRunway-{}",outRunway);
//分解BizKey
String[] arr = flightNo.split("-", 3); // 最多切 3
String[] arr = (flightNo == null ? new String[0] : flightNo.split("-", 3)); // 最多切 3
FlightNotificationDTO dto = new FlightNotificationDTO();
dto.setFlightNo(flightNo);
if (arr[1].equals("A")){
if (arr.length >= 2) {
if ("A".equals(arr[1])){
dto.setType("IN");
}else {
dto.setType("OUT");
}
}
// 存储到Redis
if (arr.length >= 3) {
try {
@ -595,10 +599,10 @@ public class AdxpFlightServiceWebSocketClient implements WebSocketHandler {
redisCache.setCacheMapValue(key, "flightNumber", arr[0]);
redisCache.setCacheMapValue(key, "type", arr[1]);
redisCache.setCacheMapValue(key, "time", arr[2]);
if (!inRunway.equals("")){
if (inRunway != null && !inRunway.isBlank()){
redisCache.setCacheMapValue(key, "inRunway", inRunway);
}
if (!outRunway.equals("")){
if (outRunway != null && !outRunway.isBlank()){
redisCache.setCacheMapValue(key, "outRunway", outRunway);
}
log.info("成功将航班数据存储到Redis: flightNumber={}, type={}, time={}", arr[0], arr[1], arr[2]);
@ -615,7 +619,7 @@ public class AdxpFlightServiceWebSocketClient implements WebSocketHandler {
String code = getTextContent(root, "Code");
log.error("seat-{}",code);
//分解BizKey
String[] arr = flightNo.split("-", 3); // 最多切 3
String[] arr = (flightNo == null ? new String[0] : flightNo.split("-", 3)); // 最多切 3
FlightNotificationDTO dto = new FlightNotificationDTO();
dto.setFlightNo(flightNo);
// 存储到Redis
@ -625,7 +629,9 @@ public class AdxpFlightServiceWebSocketClient implements WebSocketHandler {
redisCache.setCacheMapValue(key, "flightNumber", arr[0]);
redisCache.setCacheMapValue(key, "type", arr[1]);
redisCache.setCacheMapValue(key, "time", arr[2]);
if (code != null && !code.isBlank()) {
redisCache.setCacheMapValue(key, "seat", code);
}
log.info("成功将航班数据存储到Redis: flightNumber={}, type={}, time={}", arr[0], arr[1], arr[2]);
} catch (Exception e) {
log.error("存储航班数据到Redis失败: {}", e.getMessage());

View File

@ -137,7 +137,9 @@ public class DataProcessingService {
try {
if (activeMovingObjectsCache == null || activeMovingObjectsCache.isEmpty()) {
log.debug("活跃对象缓存为空,跳过数据处理");
log.debug("活跃对象缓存为空,仅处理航班通知/路由重试后返回");
// 航班通知与路由下发不依赖活跃对象列表避免因为无移动对象而错过路由重试
processFlightNotificationUpdates();
return;
}
@ -518,6 +520,8 @@ public class DataProcessingService {
if (flightNotificationCache.isEmpty()) {
log.debug("航班通知缓存为空,跳过处理");
// 仍需要处理待重试的路由查询可能来自 WS 立即触发但参数未齐全
processPendingRouteQueries();
return;
}
@ -560,6 +564,8 @@ public class DataProcessingService {
}
log.info("航班通知数据处理完成,处理数量: {}", flightNotificationCache.size());
// 处理待重试的路由查询
processPendingRouteQueries();
} catch (Exception e) {
log.error("处理航班通知数据异常", e);
@ -1027,9 +1033,42 @@ public class DataProcessingService {
// ==================== 航班路由处理相关方法 ====================
// 路由获取状态缓存Key=flightNo:type:time, Value=获取时间戳
/**
* 路由获取状态缓存Key=flightNo:routeType(IN/OUT), Value=获取时间戳(ms)
*
* 说明
* - 目前路由每个航班每种类型只需获取一次即可满足前端展示/实时引导需求
* - 因为存在两种触发方式航班通知统一处理WS ARR/AXOT 立即触发这里用 flightNo+routeType 去重
*/
private final Map<String, Long> routeRetrievalCache = new ConcurrentHashMap<>();
/**
* 待重试的路由查询任务Key=flightNo:routeType(IN/OUT)
* - Redis 参数尚未齐全或路由接口返回为空时进入重试队列
* - 在周期性处理线程中按 backoff 进行重试避免在 WS 回调里阻塞/刷接口
*/
private final Map<String, PendingRouteQuery> pendingRouteQueries = new ConcurrentHashMap<>();
private static final int ROUTE_RETRY_MAX_ATTEMPTS = 20;
private static final long ROUTE_RETRY_MIN_DELAY_MS = 1_000L; // 1s
private static final long ROUTE_RETRY_MAX_DELAY_MS = 30_000L; // 30s
private static class PendingRouteQuery {
private final String flightNo;
private final String routeType; // IN / OUT
private long nextRetryAtMs;
private int attempts;
private String lastSource;
private PendingRouteQuery(String flightNo, String routeType, long nextRetryAtMs, int attempts, String lastSource) {
this.flightNo = flightNo;
this.routeType = routeType;
this.nextRetryAtMs = nextRetryAtMs;
this.attempts = attempts;
this.lastSource = lastSource;
}
}
/**
* 基于航班通知触发路由查询
*
@ -1046,90 +1085,23 @@ public class DataProcessingService {
String flightNo = flightNotification.getFlightNo();
String routeType = flightNotification.getType().name();
// 检查是否已获取过该航班事件的路由
if (isRouteAlreadyRetrieved(flightNo, routeType, flightNotification.getEventTime())) {
log.info("🔄 航班路由已获取过,跳过重复查询: 航班号={}, 类型={}, 时间={}",
// 检查是否已获取过该航班路由 flightNo + routeType 去重
if (isRouteAlreadyRetrieved(flightNo, routeType)) {
log.info("航班路由已获取过,跳过重复查询: 航班号={}, 类型={}", flightNo, routeType);
return;
}
log.info("航班通知触发路由查询(仅Redis拼参): 航班号={}, 类型={}, eventTime={}",
flightNo, routeType, flightNotification.getEventTime());
return;
}
log.info("🛫 航班通知触发路由查询: 航班号={}, 类型={}, 时间={}", flightNo, routeType, flightNotification.getEventTime());
// 获取DataCollectorDao进行路由数据查询
DataCollectorDao dataCollectorDao = applicationContext.getBean(DataCollectorDao.class);
// 步骤1: 查询航班路由参数
com.qaup.collision.datacollector.dto.AircraftRouteParamsDTO routeParams =
dataCollectorDao.getAircraftRouteParams(flightNo, routeType);
if (routeParams == null || !routeParams.isValid()) {
log.warn("⚠️ 未能获取有效的航班路由参数: flightNo={}, routeType={}", flightNo, routeType);
return;
}
log.info("✅ 成功获取航班路由参数: flightNo={}, inRunway={}, outRunway={}, contactCross={}, seat/startSeat={}",
routeParams.getFlightNo(),
routeParams.getInRunway(),
routeParams.getOutRunway(),
routeParams.getContactCross(),
routeParams.isArrivalRoute() ? routeParams.getSeat() : routeParams.getStartSeat());
// 步骤2: 基于路由参数调用相应的路由查询接口
AircraftRouteDTO routeData = null;
if (routeParams.isArrivalRoute()) {
// 进港路由查询
routeData = dataCollectorDao.getArrivalRoute(
routeParams.getInRunway(),
routeParams.getOutRunway(),
routeParams.getContactCross(),
routeParams.getSeat()
);
log.info("🛬 查询进港路由: inRunway={}, outRunway={}, contactCross={}, seat={}",
routeParams.getInRunway(), routeParams.getOutRunway(),
routeParams.getContactCross(), routeParams.getSeat());
} else if (routeParams.isDepartureRoute()) {
// 出港路由查询
routeData = dataCollectorDao.getDepartureRoute(
routeParams.getInRunway(),
routeParams.getOutRunway(),
routeParams.getStartSeat()
);
log.info("🛫 查询出港路由: inRunway={}, outRunway={}, startSeat={}",
routeParams.getInRunway(), routeParams.getOutRunway(), routeParams.getStartSeat());
}
// 步骤3: 处理路由查询结果
if (routeData != null) {
log.info("🎯 成功获取{}路由数据: 编码={}, 状态={}",
routeData.getType(), routeData.getCodes(), routeData.getStatus());
// 转换DTO为航空器路由对象
AircraftRoute aircraftRoute = convertToAircraftRoute(routeData);
if (aircraftRoute != null) {
// 保存路由到数据库
saveAircraftRouteToDatabase(flightNo, aircraftRoute);
// 更新缓存中的航空器路由信息
updateAircraftRouteInCache(flightNo, aircraftRoute, routeParams);
// 发布WebSocket路由更新事件
publishAircraftRouteUpdateEvent(flightNo, aircraftRoute, routeParams);
// 标记该航班事件的路由已获取
markRouteAsRetrieved(flightNo, routeType, flightNotification.getEventTime());
log.info("🚀 事件驱动的路由更新完成: 航班号={}, 路由类型={}, 时间={}", flightNo, routeType, flightNotification.getEventTime());
} else {
log.warn("⚠️ 路由数据转换失败: flightNo={}", flightNo);
}
} else {
log.warn("⚠️ 未获取到路由数据: flightNo={}, routeType={}", flightNo, routeType);
boolean success = tryQueryAndPublishRouteFromRedis(flightNo, routeType, "FLIGHT_NOTIFICATION");
if (!success) {
// Redis 字段未齐全或路由接口暂不可用进入重试队列下一轮再试
schedulePendingRouteQuery(flightNo, routeType, "FLIGHT_NOTIFICATION");
}
} catch (Exception e) {
log.error("航班通知触发路由查询异常: flightNo={}",
log.error("航班通知触发路由查询异常: flightNo={}",
flightNotification.getFlightNo(), e);
}
}
@ -1137,20 +1109,273 @@ public class DataProcessingService {
/**
* 检查指定航班事件的路由是否已经获取过
*/
private boolean isRouteAlreadyRetrieved(String flightNo, String type, Long time) {
String cacheKey = flightNo + ":" + type + ":" + time;
private boolean isRouteAlreadyRetrieved(String flightNo, String routeType) {
String cacheKey = flightNo + ":" + routeType;
return routeRetrievalCache.containsKey(cacheKey);
}
/**
* 标记指定航班事件的路由已获取
*/
private void markRouteAsRetrieved(String flightNo, String type, Long time) {
String cacheKey = flightNo + ":" + type + ":" + time;
private void markRouteAsRetrieved(String flightNo, String routeType) {
String cacheKey = flightNo + ":" + routeType;
routeRetrievalCache.put(cacheKey, System.currentTimeMillis());
log.debug("标记路由已获取: cacheKey={}, 当前缓存数量={}", cacheKey, routeRetrievalCache.size());
}
/**
* Redis 拼装路由参数并查询路由成功后发布路由更新事件
*
* 约束按你的要求
* - 不再调用 /aircraftRouteParamsController/getRouteParams
* - 路由参数只从 Redisflight:<flightNo>聚合
*
* @return true=成功获取并发布false=参数未齐全或路由接口失败可进入重试
*/
private boolean tryQueryAndPublishRouteFromRedis(String flightNo, String routeType, String eventSource) {
try {
if (flightNo == null || flightNo.isBlank() || routeType == null || routeType.isBlank()) {
return false;
}
String normalizedFlightNo = flightNo.contains("-") ? flightNo.split("-", 2)[0] : flightNo;
String normalizedRouteType = routeType.trim().toUpperCase();
String key = "flight:" + normalizedFlightNo.trim();
// Redis 获取参数快照
// 注意RedisCache 可能会把字符串序列化为带引号的 JSON 字符串例如 "35"甚至返回非 String 类型
// 这里统一做归一化避免 ClassCastException并保证 queryParam 不携带多余引号
String inRunway = normalizeRedisString(redisCache.getCacheMapValue(key, "inRunway"));
String outRunway = normalizeRedisString(redisCache.getCacheMapValue(key, "outRunway"));
String contactCross = normalizeRedisString(redisCache.getCacheMapValue(key, "contactCross"));
String seat = normalizeRedisString(redisCache.getCacheMapValue(key, "seat"));
String startSeat = normalizeRedisString(redisCache.getCacheMapValue(key, "startSeat"));
// OUT允许用 seat 兜底 startSeat机位推出
if ("OUT".equalsIgnoreCase(normalizedRouteType) && (startSeat == null || startSeat.isBlank())) {
if (seat != null && !seat.isBlank()) {
startSeat = seat;
}
}
// 跑道补齐策略按你的要求
// - OUT 缺少 inRunway使用固定值 "34" 补齐
// - IN 缺少 outRunway使用固定值 "34" 补齐
// 同时记录是否补齐用于下发给前端联调核对
final String PATCH_RUNWAY_VALUE = "34";
boolean inRunwayPatched = false;
boolean outRunwayPatched = false;
if ("OUT".equalsIgnoreCase(normalizedRouteType) && (inRunway == null || inRunway.isBlank())) {
inRunway = PATCH_RUNWAY_VALUE;
inRunwayPatched = true;
}
if ("IN".equalsIgnoreCase(normalizedRouteType) && (outRunway == null || outRunway.isBlank())) {
outRunway = PATCH_RUNWAY_VALUE;
outRunwayPatched = true;
}
// 参数校验字段不全返回 false交给重试机制
if ("IN".equalsIgnoreCase(normalizedRouteType)) {
// IN四个参数拼参inRunway/outRunway/contactCross/seat
if (inRunway == null || inRunway.isBlank()) {
log.info("进港路由Redis参数未齐全(缺少inRunway),等待补齐后重试: flightNo={}, inRunway={}",
normalizedFlightNo, inRunway);
return false;
}
if (outRunway == null || outRunway.isBlank()) {
log.info("进港路由Redis参数未齐全(缺少outRunway),等待补齐后重试: flightNo={}, outRunway={}",
normalizedFlightNo, outRunway);
return false;
}
if (contactCross == null || contactCross.isBlank() || seat == null || seat.isBlank()) {
// 打印当前已拼装参数便于定位到底缺了哪些字段以及跑道是否为补齐值
log.info("进港路由Redis参数未齐全等待补齐后重试: flightNo={}, inRunway={} (patched={}), outRunway={} (patched={}), contactCross={}, seat={}",
normalizedFlightNo, inRunway, inRunwayPatched, outRunway, outRunwayPatched, contactCross, seat);
return false;
}
} else if ("OUT".equalsIgnoreCase(normalizedRouteType)) {
// OUT四个参数拼参inRunway/outRunway/startSeatstartSeat 可由 seat 兜底
if (inRunway == null || inRunway.isBlank()) {
log.info("出港路由Redis参数未齐全(缺少inRunway),等待补齐后重试: flightNo={}, inRunway={}",
normalizedFlightNo, inRunway);
return false;
}
if (outRunway == null || outRunway.isBlank()) {
log.info("出港路由Redis参数未齐全(缺少outRunway),等待补齐后重试: flightNo={}, outRunway={}",
normalizedFlightNo, outRunway);
return false;
}
if (startSeat == null || startSeat.isBlank()) {
log.info("出港路由Redis参数未齐全等待补齐后重试: flightNo={}, inRunway={} (patched={}), outRunway={} (patched={}), startSeat={}, seatAsFallback={}",
normalizedFlightNo, inRunway, inRunwayPatched, outRunway, outRunwayPatched, startSeat, seat);
return false;
}
} else {
log.info("未知routeType跳过路由查询: flightNo={}, routeType={}", normalizedFlightNo, normalizedRouteType);
return false;
}
// 调用路由接口
DataCollectorDao dataCollectorDao = applicationContext.getBean(DataCollectorDao.class);
AircraftRouteDTO routeData;
com.qaup.collision.datacollector.dto.AircraftRouteParamsDTO routeParams =
new com.qaup.collision.datacollector.dto.AircraftRouteParamsDTO();
routeParams.setFlightNo(normalizedFlightNo);
routeParams.setRouteType(normalizedRouteType);
routeParams.setInRunway(inRunway);
routeParams.setOutRunway(outRunway);
routeParams.setContactCross(contactCross);
routeParams.setSeat(seat);
routeParams.setStartSeat(startSeat);
routeParams.setTimestamp(System.currentTimeMillis());
routeParams.setInRunwayPatched(inRunwayPatched);
routeParams.setOutRunwayPatched(outRunwayPatched);
if ("IN".equalsIgnoreCase(normalizedRouteType)) {
routeData = dataCollectorDao.getArrivalRoute(inRunway, outRunway, contactCross, seat);
} else {
routeData = dataCollectorDao.getDepartureRoute(inRunway, outRunway, startSeat);
}
if (routeData == null) {
// 路由服务返回空体/无法解析时打印本次请求参数含补齐标记便于容器日志直接定位
log.info("路由接口返回为空,稍后重试: flightNo={}, routeType={}, source={}, inRunway={} (patched={}), outRunway={} (patched={}), contactCross={}, seat={}, startSeat={}",
normalizedFlightNo, normalizedRouteType, eventSource,
inRunway, inRunwayPatched, outRunway, outRunwayPatched, contactCross, seat, startSeat);
return false;
}
AircraftRoute aircraftRoute = convertToAircraftRoute(routeData);
if (aircraftRoute == null) {
log.info("路由数据转换失败,稍后重试: flightNo={}, routeType={}, source={}",
normalizedFlightNo, normalizedRouteType, eventSource);
return false;
}
// 保存 + 更新缓存 + 下发前端
saveAircraftRouteToDatabase(normalizedFlightNo, aircraftRoute);
updateAircraftRouteInCache(normalizedFlightNo, aircraftRoute, routeParams);
publishAircraftRouteUpdateEvent(normalizedFlightNo, aircraftRoute, routeParams, eventSource);
// 标记已获取避免两种触发方式重复下发
markRouteAsRetrieved(normalizedFlightNo, normalizedRouteType);
pendingRouteQueries.remove(normalizedFlightNo + ":" + normalizedRouteType);
log.info("路由查询与下发成功: flightNo={}, routeType={}, source={}",
normalizedFlightNo, normalizedRouteType, eventSource);
return true;
} catch (Exception e) {
log.error("从Redis拼参查询路由异常: flightNo={}, routeType={}, source={}", flightNo, routeType, eventSource, e);
return false;
}
}
/**
* RedisCache 返回值归一化为可用字符串
* - 兼容 RedisCache JSON 方式序列化字符串导致的外层引号例如 "\"35\"" "\"SC4846\""
* - 兼容非 String 类型避免 ClassCastException
* - 统一 trim空串返回 null
*/
private static String normalizeRedisString(Object v) {
if (v == null) {
return null;
}
String s = String.valueOf(v).trim();
if (s.isEmpty()) {
return null;
}
// 去掉一层外部双引号 "35" -> 35
if (s.length() >= 2 && s.startsWith("\"") && s.endsWith("\"")) {
s = s.substring(1, s.length() - 1).trim();
}
// 去掉一层外部单引号 '35' -> 35防御性
if (s.length() >= 2 && s.startsWith("'") && s.endsWith("'")) {
s = s.substring(1, s.length() - 1).trim();
}
return s.isEmpty() ? null : s;
}
private void schedulePendingRouteQuery(String flightNo, String routeType, String source) {
try {
if (flightNo == null || flightNo.isBlank() || routeType == null || routeType.isBlank()) {
return;
}
String normalizedFlightNo = flightNo.contains("-") ? flightNo.split("-", 2)[0] : flightNo;
String normalizedRouteType = routeType.trim().toUpperCase();
String key = normalizedFlightNo + ":" + normalizedRouteType;
PendingRouteQuery existing = pendingRouteQueries.get(key);
long now = System.currentTimeMillis();
if (existing == null) {
long next = now + ROUTE_RETRY_MIN_DELAY_MS;
pendingRouteQueries.put(key, new PendingRouteQuery(normalizedFlightNo, normalizedRouteType, next, 0, source));
log.debug("新增待重试路由查询: key={}, nextRetryAtMs={}", key, next);
return;
}
// 已存在更新时间与来源不改变 attempts避免疯狂回退
existing.lastSource = source;
if (existing.nextRetryAtMs < now) {
existing.nextRetryAtMs = now + ROUTE_RETRY_MIN_DELAY_MS;
}
} catch (Exception e) {
log.error("加入待重试路由查询失败: flightNo={}, routeType={}, source={}", flightNo, routeType, source, e);
}
}
private void processPendingRouteQueries() {
try {
if (pendingRouteQueries.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
for (PendingRouteQuery pending : pendingRouteQueries.values()) {
if (pending == null) {
continue;
}
if (pending.nextRetryAtMs > now) {
continue;
}
String key = pending.flightNo + ":" + pending.routeType;
// 已成功则无需重试
if (isRouteAlreadyRetrieved(pending.flightNo, pending.routeType)) {
pendingRouteQueries.remove(key);
continue;
}
// 超过最大次数停止重试避免永久占用
if (pending.attempts >= ROUTE_RETRY_MAX_ATTEMPTS) {
log.info("路由重试达到上限,停止重试: key={}, attempts={}, lastSource={}",
key, pending.attempts, pending.lastSource);
pendingRouteQueries.remove(key);
continue;
}
boolean ok = tryQueryAndPublishRouteFromRedis(pending.flightNo, pending.routeType, "RETRY(" + pending.lastSource + ")");
if (ok) {
pendingRouteQueries.remove(key);
continue;
}
// backoff1s,2s,4s,... 上限 30s
pending.attempts++;
long delay = ROUTE_RETRY_MIN_DELAY_MS * (1L << Math.min(pending.attempts, 5)); // 1,2,4,8,16,32 -> cap
delay = Math.min(delay, ROUTE_RETRY_MAX_DELAY_MS);
pending.nextRetryAtMs = now + delay;
log.debug("路由重试失败,安排下次重试: key={}, attempts={}, nextDelayMs={}", key, pending.attempts, delay);
}
} catch (Exception e) {
log.error("处理待重试路由查询异常", e);
}
}
/**
* 将AircraftRouteDTO转换为AircraftRoute对象
* 使用JTS将多个LineString段合并为单一连续路径
@ -1179,7 +1404,7 @@ public class DataProcessingService {
// 创建路由段
AircraftRoute.RouteSegment segment = AircraftRoute.RouteSegment.builder()
.code(feature.getProperties() != null ? feature.getProperties().getCode() : "")
.code(feature.getProperties() != null ? feature.getProperties().getCodeAsString() : "")
.coordinates(points)
.build();
routeSegments.add(segment);
@ -1254,6 +1479,10 @@ public class DataProcessingService {
*/
private void updateAircraftRouteInCache(String flightNo, AircraftRoute route,
com.qaup.collision.datacollector.dto.AircraftRouteParamsDTO routeParams) {
if (activeMovingObjectsCache == null) {
log.debug("activeMovingObjectsCache 未初始化,跳过航空器路由缓存更新: flightNo={}", flightNo);
return;
}
MovingObject cachedAircraft = activeMovingObjectsCache.get(flightNo);
if (cachedAircraft != null && cachedAircraft instanceof com.qaup.collision.common.model.Aircraft) {
@ -1281,9 +1510,11 @@ public class DataProcessingService {
/**
* 发布航空器路由更新事件
*/
private void publishAircraftRouteUpdateEvent(String flightNo, AircraftRoute route,
com.qaup.collision.datacollector.dto.AircraftRouteParamsDTO routeParams) {
log.error( "发布航空器路由更新事件: flightNo={}", flightNo);
private void publishAircraftRouteUpdateEvent(String flightNo,
AircraftRoute route,
com.qaup.collision.datacollector.dto.AircraftRouteParamsDTO routeParams,
String eventSource) {
log.error("发布航空器路由更新事件: flightNo={}", flightNo);
try {
// 创建路由更新事件
com.qaup.collision.websocket.event.AircraftRouteUpdateEvent routeUpdateEvent =
@ -1294,15 +1525,19 @@ public class DataProcessingService {
.routeCodes(route.getCodes())
.aircraftStatus(routeParams.getRouteType()) // 使用路由参数中的类型
.routeGeometry(route.getGeometry() != null ? route.getGeometry().toText() : null)
.inRunway(routeParams.getInRunway())
.outRunway(routeParams.getOutRunway())
.inRunwayPatched(routeParams.getInRunwayPatched())
.outRunwayPatched(routeParams.getOutRunwayPatched())
.timestamp(System.currentTimeMillis())
.eventSource("FLIGHT_NOTIFICATION") // 标识事件来源
.eventSource(eventSource) // 标识事件来源
.build();
// 发布WebSocket事件
eventPublisher.publishEvent(routeUpdateEvent);
log.info("📡 发布航空器路由更新事件: 航班号={}, 路由类型={}, 事件来源=航班通知",
flightNo, route.getType());
log.info("📡 发布航空器路由更新事件: 航班号={}, 路由类型={}, 事件来源={}",
flightNo, route.getType(), eventSource);
} catch (Exception e) {
log.error("❌ 发布航空器路由更新事件失败: flightNo={}", flightNo, e);
@ -1313,84 +1548,25 @@ public class DataProcessingService {
* 触发查询航班号对应的路由
*/
public void ARR(String flightNo){
String key = "flight:"+flightNo;
String time =(String) redisCache.getCacheMapValue(key, "time");
log.error("time是{}",time);
// String inRunway = "35";
// String outRunway = "34";
// String contactCross = "F1";
// String seat = "138";
String inRunway = (String) redisCache.getCacheMapValue(key, "inRunway");
String outRunway = (String) redisCache.getCacheMapValue(key, "outRunway");
String contactCross = (String) redisCache.getCacheMapValue(key, "contactCross");
String seat = (String) redisCache.getCacheMapValue(key, "seat");
log.error("进港参数是{}",inRunway);
log.error("出港参数是{}",outRunway);
log.error("接触交叉参数是{}",contactCross);
log.error("座位参数是{}",seat);
// 获取DataCollectorDao进行路由数据查询
DataCollectorDao dataCollectorDao = applicationContext.getBean(DataCollectorDao.class);
AircraftRouteDTO routeData = null;
// 进港路由查询
routeData = dataCollectorDao.getArrivalRoute(
inRunway,
outRunway,
contactCross,
seat);
log.error("进港路由数据routeData是{}",routeData);
// 创建路由参数:目前有用的只有type
com.qaup.collision.datacollector.dto.AircraftRouteParamsDTO routeParams = new com.qaup.collision.datacollector.dto.AircraftRouteParamsDTO();
routeParams.setFlightNo(flightNo);
routeParams.setRouteType("IN");
routeParams.setInRunway(inRunway);
routeParams.setOutRunway(outRunway);
routeParams.setContactCross(contactCross);
routeParams.setSeat(seat);
//处理路由数据并发布
handleRouteData(flightNo, routeData, routeParams);
// 两种触发方式均启用WS 收到 ARR 立即触发参数只从 Redis 拼装
if (isRouteAlreadyRetrieved(flightNo, "IN")) {
return;
}
boolean ok = tryQueryAndPublishRouteFromRedis(flightNo, "IN", "ADXP_WS_ARR");
if (!ok) {
schedulePendingRouteQuery(flightNo, "IN", "ADXP_WS_ARR");
}
}
public void AXOT(String flightNo){
String key = "flight:"+flightNo;
String time =(String) redisCache.getCacheMapValue(key, "time");
log.error("time是{}",time);
// String inRunway = "17";
// String outRunway = "35";
// String startSeat = "201";
String inRunway = (String) redisCache.getCacheMapValue(key, "inRunway");
String outRunway = (String) redisCache.getCacheMapValue(key, "outRunway");
String startSeat = (String) redisCache.getCacheMapValue(key, "startSeat");
log.error("进港参数是{}",inRunway);
log.error("出港参数是{}",outRunway);
log.error("起始座位参数是{}",startSeat);
// 获取DataCollectorDao进行路由数据查询
DataCollectorDao dataCollectorDao = applicationContext.getBean(DataCollectorDao.class);
AircraftRouteDTO routeData = null;
// 进港路由查询
routeData = dataCollectorDao.getDepartureRoute(
inRunway,
outRunway,
startSeat);
log.error("出港路由数据是routeData是{}",routeData);
// 创建路由参数:目前有用的只有type
com.qaup.collision.datacollector.dto.AircraftRouteParamsDTO routeParams = new com.qaup.collision.datacollector.dto.AircraftRouteParamsDTO();
routeParams.setFlightNo(flightNo);
routeParams.setRouteType("OUT");
routeParams.setInRunway(inRunway);
routeParams.setOutRunway(outRunway);
routeParams.setStartSeat(startSeat);
//处理路由数据并发布
handleRouteData(flightNo, routeData, routeParams);
// 两种触发方式均启用WS 收到 AXOT 立即触发参数只从 Redis 拼装
if (isRouteAlreadyRetrieved(flightNo, "OUT")) {
return;
}
boolean ok = tryQueryAndPublishRouteFromRedis(flightNo, "OUT", "ADXP_WS_AXOT");
if (!ok) {
schedulePendingRouteQuery(flightNo, "OUT", "ADXP_WS_AXOT");
}
}
private void handleRouteData(String flightNo, AircraftRouteDTO routeData,
@ -1408,7 +1584,8 @@ public class DataProcessingService {
saveAircraftRouteToDatabase(flightNo, aircraftRoute);
// 发布WebSocket路由更新事件
publishAircraftRouteUpdateEvent(flightNo, aircraftRoute, routeParams);
// 兼容旧的辅助方法事件来源标记为 MANUAL
publishAircraftRouteUpdateEvent(flightNo, aircraftRoute, routeParams, "MANUAL");
} else {
log.warn("⚠️ 路由数据转换失败: flightNo={}", flightNo);
}

View File

@ -50,6 +50,26 @@ public class AircraftRouteUpdateEvent {
*/
private String routeGeometry;
/**
* 进港跑道编号用于前端联调查看本次路由查询参数
*/
private String inRunway;
/**
* 出港跑道编号用于前端联调查看本次路由查询参数
*/
private String outRunway;
/**
* inRunway 是否为系统补齐值true=补齐false/=来自报文/Redis原始值
*/
private Boolean inRunwayPatched;
/**
* outRunway 是否为系统补齐值true=补齐false/=来自报文/Redis原始值
*/
private Boolean outRunwayPatched;
/**
* 事件时间戳
*/

View File

@ -3,6 +3,7 @@ package com.qaup.framework.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
@ -66,6 +67,12 @@ public class SecurityConfig
@Autowired
private PermitAllUrlProperties permitAllUrl;
/**
* 调试接口开关仅开启时才放行 debug 路径避免误暴露
*/
@Value("${qaup.debug.runway-path-planning.enabled:false}")
private boolean runwayPathPlanningDebugEnabled;
/**
* 身份验证实现
*/
@ -113,6 +120,8 @@ public class SecurityConfig
requests.requestMatchers("/login", "/register", "/captchaImage").permitAll()
// WebSocket端点允许匿名访问
.requestMatchers("/collision", "/collision/**", "/VehicleCommandInfo", "/test/websocket/**").permitAll()
// 调试接口仅在显式开启开关时放行默认不放行
.requestMatchers(runwayPathPlanningDebugEnabled ? "/debug/runway-path-planning/**" : "/__debug_disabled__").permitAll()
// 静态资源可匿名访问
.requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js", "/profile/**").permitAll()
.requestMatchers("/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/druid/**").permitAll()

191
verify-jar.py Normal file
View File

@ -0,0 +1,191 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
QAUP 打包验证脚本Python
目的
验证 qaup-admin.jarSpring Boot fat jar中是否包含最新改动的关键特征字符串
通过读取 qaup-admin.jar -> BOOT-INF/lib/qaup-collision*.jar & qaup-framework*.jar
-> 读取指定 .class 条目 -> class 常量池字节中搜索关键字符串UTF-8
用法在项目根目录执行
python verify-jar.py
python verify-jar.py --jar qaup-admin/target/qaup-admin.jar
退出码
0 = PASS命中至少一个关键特征
2 = FAIL未命中关键特征通常表示不是新包
3 = FAIL jar 或缺 class或不是 Spring Boot fat jar
"""
from __future__ import annotations
import argparse
import io
import os
import sys
import zipfile
from dataclasses import dataclass
from typing import Iterable, List, Optional, Tuple
DEFAULT_ADMIN_JAR = os.path.join("qaup-admin", "target", "qaup-admin.jar")
NEEDLES: List[str] = [
# DataCollectorDao: 新增的失败参数打印
"bodySummary=",
"进港路由请求URL",
"出港路由请求URL",
# 补齐标记字段
"inRunwayPatched",
"outRunwayPatched",
# DataProcessingService: 新增的失败时参数打印包含 patched=
"patched=",
# SecurityConfig: debug 放行开关
"qaup.debug.runway-path-planning.enabled",
]
@dataclass(frozen=True)
class ClassTarget:
name: str
jar_kind: str # "collision" | "framework"
class_entry: str
CLASS_TARGETS: List[ClassTarget] = [
ClassTarget(
name="DataCollectorDao",
jar_kind="collision",
class_entry="com/qaup/collision/datacollector/dao/DataCollectorDao.class",
),
ClassTarget(
name="DataProcessingService",
jar_kind="collision",
class_entry="com/qaup/collision/dataprocessing/service/DataProcessingService.class",
),
ClassTarget(
name="AircraftRouteUpdateEvent",
jar_kind="collision",
class_entry="com/qaup/collision/websocket/event/AircraftRouteUpdateEvent.class",
),
ClassTarget(
name="SecurityConfig",
jar_kind="framework",
class_entry="com/qaup/framework/config/SecurityConfig.class",
),
]
def eprint(*args: object) -> None:
print(*args, file=sys.stderr)
def read_zip_entry(zf: zipfile.ZipFile, entry_name: str) -> bytes:
with zf.open(entry_name, "r") as f:
return f.read()
def find_first_entry_by_prefix(zf: zipfile.ZipFile, prefix: str) -> Optional[str]:
for n in zf.namelist():
if n.startswith(prefix):
return n
return None
def find_lib_jar(zf_admin: zipfile.ZipFile, lib_prefix: str) -> Optional[str]:
# BOOT-INF/lib/qaup-collision*.jar
candidates = [n for n in zf_admin.namelist() if n.startswith("BOOT-INF/lib/") and lib_prefix in os.path.basename(n)]
candidates.sort()
return candidates[0] if candidates else None
def scan_bytes_for_needles(blob: bytes, needles: Iterable[str]) -> List[str]:
hits: List[str] = []
for s in needles:
b = s.encode("utf-8", errors="strict")
if b in blob:
hits.append(s)
return hits
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Verify qaup-admin.jar contains recent route changes.")
parser.add_argument("--jar", dest="jar_path", default=DEFAULT_ADMIN_JAR, help="Path to qaup-admin.jar")
args = parser.parse_args(argv)
jar_path = os.path.abspath(args.jar_path)
print(f"JarPath: {jar_path}")
if not os.path.exists(jar_path):
eprint(f"ERROR: jar 不存在: {jar_path}")
return 3
try:
with zipfile.ZipFile(jar_path, "r") as zf_admin:
lib_dir = find_first_entry_by_prefix(zf_admin, "BOOT-INF/lib/")
if lib_dir is None:
eprint("ERROR: 未发现 BOOT-INF/lib/,可能不是 Spring Boot fat jar。")
return 3
collision_jar_entry = find_lib_jar(zf_admin, "qaup-collision")
framework_jar_entry = find_lib_jar(zf_admin, "qaup-framework")
if collision_jar_entry is None:
eprint("ERROR: 未找到 BOOT-INF/lib/qaup-collision*.jar")
return 3
if framework_jar_entry is None:
eprint("ERROR: 未找到 BOOT-INF/lib/qaup-framework*.jar")
return 3
print(f"collisionJarEntry: {collision_jar_entry}")
print(f"frameworkJarEntry: {framework_jar_entry}")
collision_bytes = read_zip_entry(zf_admin, collision_jar_entry)
framework_bytes = read_zip_entry(zf_admin, framework_jar_entry)
jars = {
"collision": zipfile.ZipFile(io.BytesIO(collision_bytes), "r"),
"framework": zipfile.ZipFile(io.BytesIO(framework_bytes), "r"),
}
any_hit = False
missing = False
print("\n=== Scan class signatures ===")
for t in CLASS_TARGETS:
zf = jars[t.jar_kind]
if t.class_entry not in zf.namelist():
print(f"MISSING class: {t.name} -> {t.jar_kind}:{t.class_entry}")
missing = True
continue
blob = read_zip_entry(zf, t.class_entry)
hits = scan_bytes_for_needles(blob, NEEDLES)
any_hit = any_hit or bool(hits)
print(f"OK class: {t.name}")
print(" hits: " + (", ".join(hits) if hits else "<none>"))
for zf in jars.values():
zf.close()
print("\n=== Result ===")
if missing:
print("FAIL: 依赖 jar 或 class 缺失,无法验证(可能打包不完整)")
return 3
if any_hit:
print("PASS: 已命中关键特征字符串(大概率是最新改动包)")
return 0
print("FAIL: 未命中任何关键特征字符串(大概率不是最新改动包)")
return 2
except zipfile.BadZipFile:
eprint("ERROR: jar 不是有效的 zip/jar 文件")
return 3
except Exception as ex:
eprint(f"ERROR: 执行失败: {ex}")
return 3
if __name__ == "__main__":
raise SystemExit(main())

203
命令.md

File diff suppressed because one or more lines are too long