diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..b673c70 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "QAUP_Management" + +[setup] +script = "" + +[[actions]] +name = "运行" +icon = "run" +command = "mvn -pl qaup-admin -am clean package -DskipTests" diff --git a/deploy/config.yml b/deploy/config.yml index b7c88d0..ac3442e 100644 --- a/deploy/config.yml +++ b/deploy/config.yml @@ -274,4 +274,4 @@ management: hikari: true jvm: true jmx: - enabled: true \ No newline at end of file + enabled: true diff --git a/doc/frontend_platform_http_api_integration.md b/doc/frontend_platform_http_api_integration.md new file mode 100644 index 0000000..6243af2 --- /dev/null +++ b/doc/frontend_platform_http_api_integration.md @@ -0,0 +1,536 @@ +# 前端接入文档:新增平台 HTTP 接口 + +## 1. 背景 + +本次后端新增了一组 HTTP 接口,供前端在现有 WebSocket 交互之外,补充以下能力: + +- 同步“当前关心的对象及其类型” +- 运行时修改部分碰撞检测相关参数 + +## 2. 通用约定 + +- 所有接口均为 `POST` +- 所有请求和响应均为 `application/json` +- 当前接口无鉴权、无 token、无签名校验 +- 当前后端未补 CORS + - 如果前端是浏览器并且跨域访问,后续可能还需要后端补 CORS +- 配置修改仅在内存中生效 + - 服务重启后恢复默认配置 +- `VehicleRegistry` 是“增量更新” + - 本次未传入的历史对象不会被清空 + +建议前端统一配置接口基础地址,例如: + +```ts +const API_BASE = 'http://<后端IP>:8080'; +``` + +## 3. 接口一:对象注册同步 + +### 3.1 接口地址 + +```http +POST /api/VehicleRegistry +``` + +### 3.2 用途 + +前端将“当前关心的对象及其类型”增量同步给后端。 + +该接口会直接影响: + +- 哪些对象参与碰撞检测 +- 哪些车辆可能被系统下发控制命令 + +### 3.3 请求头 + +```http +Content-Type: application/json +``` + +### 3.4 请求体 + +```json +[ + { "vehicleID": "QN001", "vehicleType": "WUREN" }, + { "vehicleID": "TQ001", "vehicleType": "TEQIN" }, + { "vehicleID": "AC001", "vehicleType": "HANGKONG" } +] +``` + +### 3.5 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `vehicleID` | `string` | 是 | 对象唯一标识,不能为空 | +| `vehicleType` | `string` | 是 | 对象类型,只允许固定枚举值 | + +### 3.6 `vehicleType` 可选值 + +- `WUREN` +- `TEQIN` +- `HANGKONG` +- `PUTONG` +- `JIUYUAN` + +### 3.7 业务语义 + +#### `WUREN` + +- 加入“可控车辆集合” +- 加入“受管车辆集合” + +#### `TEQIN` + +- 加入“受管车辆集合” +- 不属于“可控车辆集合” + +#### `HANGKONG` + +- 加入“已选中航空器集合” +- 用于参与航空器相关碰撞/安全区计算 + +#### `PUTONG` + +- 仅记录类型 +- 不进入可控/受管/已选航空器集合 + +#### `JIUYUAN` + +- 仅记录类型 +- 不进入可控/受管/已选航空器集合 + +### 3.8 成功响应 + +```json +{ + "status": "success", + "updatedAt": 1742280000000, + "updated": 3, + "controllableCount": 1, + "typesCount": { + "WUREN": 1, + "TEQIN": 1, + "HANGKONG": 1 + }, + "controllableVehicleIDs": ["QN001"] +} +``` + +### 3.9 响应字段说明 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `status` | `string` | 固定为 `success` | +| `updatedAt` | `number` | 毫秒时间戳 | +| `updated` | `number` | 本次成功处理的条目数 | +| `controllableCount` | `number` | 当前系统内全部 `WUREN` 总数 | +| `typesCount` | `object` | 本次请求内各类型数量统计 | +| `controllableVehicleIDs` | `string[]` | 当前系统内全部可控车辆 ID 列表,即所有 `WUREN` | + +### 3.10 错误响应示例 + +#### 请求体不是数组 + +```json +{ + "status": "error", + "message": "Body must be an array" +} +``` + +#### 参数非法 + +```json +{ + "status": "error", + "message": "Invalid request", + "errors": [ + "item must be an object", + "missing vehicleID", + "missing vehicleType", + "vehicleID must be string", + "vehicleType must be string", + "vehicleID must be non-empty", + "invalid vehicleType=xxx" + ] +} +``` + +#### 非法 JSON + +```json +{ + "status": "error", + "message": "Invalid JSON", + "detail": "..." +} +``` + +### 3.11 前端调用示例 + +```ts +await fetch(`${API_BASE}/api/VehicleRegistry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([ + { vehicleID: 'QN001', vehicleType: 'WUREN' }, + { vehicleID: 'TQ001', vehicleType: 'TEQIN' }, + { vehicleID: 'AC001', vehicleType: 'HANGKONG' } + ]) +}); +``` + +### 3.12 接入建议 + +- 页面首次拿到关注对象列表后调用一次 +- 当前关注对象发生变化时再次调用 +- 请求体必须是数组,即使只有一条数据也必须传数组 +- 后端是“增量更新”,不要假设未传对象会被清空 + +## 4. 接口二:跑道区域航空器预警区半径 + +### 4.1 接口地址 + +```http +POST /config/runway/warning_zone_radius/aircraft +``` + +### 4.2 用途 + +前端运行时修改跑道区域的“航空器预警区半径”。 + +### 4.3 请求头 + +```http +Content-Type: application/json +``` + +### 4.4 请求体 + +```json +{ "value": 300 } +``` + +### 4.5 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `value` | `number` | 是 | 必须是大于 0 的有限数值 | + +### 4.6 成功响应 + +```json +{ + "status": "success", + "area": "runway", + "field": "warning_zone_radius.aircraft", + "old": 200.0, + "new": 300.0 +} +``` + +### 4.7 错误响应示例 + +#### 缺少字段 + +```json +{ + "status": "error", + "message": "Missing field: value" +} +``` + +#### 值非法 + +```json +{ + "status": "error", + "message": "Invalid value: must be a finite number greater than 0" +} +``` + +#### 非法 JSON + +```json +{ + "status": "error", + "message": "Invalid JSON", + "detail": "..." +} +``` + +#### 字段类型错误 + +```json +{ + "status": "error", + "message": "Invalid field type", + "detail": "..." +} +``` + +### 4.8 前端调用示例 + +```ts +await fetch(`${API_BASE}/config/runway/warning_zone_radius/aircraft`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 300 }) +}); +``` + +## 5. 接口三:跑道区域航空器告警区半径 + +### 5.1 接口地址 + +```http +POST /config/runway/alert_zone_radius/aircraft +``` + +### 5.2 用途 + +前端运行时修改跑道区域的“航空器告警区半径”。 + +### 5.3 请求体 + +```json +{ "value": 200 } +``` + +### 5.4 成功响应 + +```json +{ + "status": "success", + "area": "runway", + "field": "alert_zone_radius.aircraft", + "old": 150.0, + "new": 200.0 +} +``` + +### 5.5 前端调用示例 + +```ts +await fetch(`${API_BASE}/config/runway/alert_zone_radius/aircraft`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 200 }) +}); +``` + +### 5.6 说明 + +该接口的请求规则、错误规则与“预警区半径”接口一致,唯一差异是: + +- 路径不同 +- 成功响应中的 `field` 固定为 `alert_zone_radius.aircraft` + +## 6. 接口四:冲突解除距离阈值 + +### 6.1 接口地址 + +```http +POST /config/collision/diverging_release_distance +``` + +### 6.2 用途 + +前端运行时修改“冲突解除距离阈值”。 + +### 6.3 请求体 + +```json +{ "value": 50 } +``` + +### 6.4 成功响应 + +```json +{ + "status": "success", + "area": "collision", + "field": "collision.diverging_release_distance", + "old": 40.0, + "new": 50.0 +} +``` + +### 6.5 前端调用示例 + +```ts +await fetch(`${API_BASE}/config/collision/diverging_release_distance`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 50 }) +}); +``` + +### 6.6 说明 + +该接口的请求规则、错误规则与前两个配置接口一致,唯一差异是: + +- 路径不同 +- 成功响应中的: + - `area` 固定为 `collision` + - `field` 固定为 `collision.diverging_release_distance` + +## 7. 推荐前端封装 + +### 7.1 通用 POST JSON 请求 + +```ts +async function postJson(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + + const data = await response.json(); + + if (!response.ok) { + throw data; + } + + return data as T; +} +``` + +### 7.2 类型定义 + +```ts +type VehicleType = 'WUREN' | 'TEQIN' | 'HANGKONG' | 'PUTONG' | 'JIUYUAN'; + +interface VehicleRegistryItem { + vehicleID: string; + vehicleType: VehicleType; +} + +interface VehicleRegistryResponse { + status: 'success'; + updatedAt: number; + updated: number; + controllableCount: number; + typesCount: Record; + controllableVehicleIDs: string[]; +} + +interface ConfigUpdateResponse { + status: 'success'; + area: string; + field: string; + old: number; + new: number; +} + +interface ErrorResponse { + status: 'error'; + message: string; + detail?: string; + errors?: string[]; +} +``` + +### 7.3 接口封装示例 + +```ts +export async function syncVehicleRegistry(items: VehicleRegistryItem[]) { + return postJson(`${API_BASE}/api/VehicleRegistry`, items); +} + +export async function updateRunwayWarningRadius(value: number) { + return postJson( + `${API_BASE}/config/runway/warning_zone_radius/aircraft`, + { value } + ); +} + +export async function updateRunwayAlertRadius(value: number) { + return postJson( + `${API_BASE}/config/runway/alert_zone_radius/aircraft`, + { value } + ); +} + +export async function updateDivergingReleaseDistance(value: number) { + return postJson( + `${API_BASE}/config/collision/diverging_release_distance`, + { value } + ); +} +``` + +## 8. 前端联调注意事项 + +### 8.1 `VehicleRegistry` + +- 请求体必须是数组 +- `vehicleID` 不能为空字符串 +- `vehicleType` 必须严格使用大写枚举值 +- 该接口是“增量更新”,未传对象不会被后端自动删除 + +### 8.2 配置接口 + +- `value` 必须是 `number` +- 不要传字符串,例如 `"300"` 是错误的 +- 必须大于 `0` + +### 8.3 跨域问题 + +- 当前后端未配置 CORS +- 如果前端页面和后端接口不是同域同端口,浏览器可能拦截 +- 如出现跨域报错,需要后端后续补 CORS 配置 + +### 8.4 生效方式 + +- 这 3 个配置接口修改后立即在当前服务进程内生效 +- 服务重启后恢复默认值,不会持久化 + +## 9. 联调建议流程 + +### 9.1 第一步:先调对象注册接口 + +确认前端能成功将当前关注对象同步给后端。 + +### 9.2 第二步:再调配置接口 + +确认预警半径、告警半径、冲突解除距离可运行时修改。 + +### 9.3 第三步:观察业务联动 + +重点观察: + +- 碰撞检测结果是否按新对象范围生效 +- 跑道区域预警/告警范围是否按新阈值变化 +- 冲突解除逻辑后续接入时是否读取当前运行时配置 + +## 10. 最小联调示例 + +```ts +async function initPlatformObjects() { + await syncVehicleRegistry([ + { vehicleID: 'QN001', vehicleType: 'WUREN' }, + { vehicleID: 'TQ001', vehicleType: 'TEQIN' }, + { vehicleID: 'AC001', vehicleType: 'HANGKONG' } + ]); +} + +async function updateRuntimeConfig() { + await updateRunwayWarningRadius(300); + await updateRunwayAlertRadius(200); + await updateDivergingReleaseDistance(50); +} +``` + +## 11. 接口清单汇总 + +| 接口 | 方法 | 用途 | +|---|---|---| +| `/api/VehicleRegistry` | `POST` | 同步当前关心对象及类型 | +| `/config/runway/warning_zone_radius/aircraft` | `POST` | 修改跑道区域航空器预警区半径 | +| `/config/runway/alert_zone_radius/aircraft` | `POST` | 修改跑道区域航空器告警区半径 | +| `/config/collision/diverging_release_distance` | `POST` | 修改冲突解除距离阈值 | diff --git a/qaup-collision/pom.xml b/qaup-collision/pom.xml index f43361b..03fc063 100644 --- a/qaup-collision/pom.xml +++ b/qaup-collision/pom.xml @@ -42,6 +42,13 @@ ${qaup.version} + + com.qaup + qaup-framework + ${qaup.version} + test + + org.springframework.boot diff --git a/qaup-collision/src/main/java/com/qaup/collision/controller/PlatformIntegrationController.java b/qaup-collision/src/main/java/com/qaup/collision/controller/PlatformIntegrationController.java new file mode 100644 index 0000000..85bd65a --- /dev/null +++ b/qaup-collision/src/main/java/com/qaup/collision/controller/PlatformIntegrationController.java @@ -0,0 +1,219 @@ +package com.qaup.collision.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qaup.collision.service.PlatformRuntimeStateService; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE) +public class PlatformIntegrationController { + + private final ObjectMapper objectMapper; + private final PlatformRuntimeStateService platformRuntimeStateService; + + public PlatformIntegrationController( + ObjectMapper objectMapper, + PlatformRuntimeStateService platformRuntimeStateService) { + this.objectMapper = objectMapper; + this.platformRuntimeStateService = platformRuntimeStateService; + } + + @PostMapping(path = "/api/VehicleRegistry", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> registerVehicles(@RequestBody String requestBody) { + try { + JsonNode root = objectMapper.readTree(requestBody); + if (!root.isArray()) { + return error(HttpStatus.BAD_REQUEST, "Body must be an array"); + } + + List errors = new ArrayList<>(); + List entries = new ArrayList<>(); + for (JsonNode item : root) { + validateVehicleRegistryItem(item, errors, entries); + } + + if (!errors.isEmpty()) { + Map payload = new LinkedHashMap<>(); + payload.put("status", "error"); + payload.put("message", "Invalid request"); + payload.put("errors", errors); + return ResponseEntity.badRequest().contentType(MediaType.APPLICATION_JSON).body(payload); + } + + PlatformRuntimeStateService.VehicleRegistryUpdateResult result = + platformRuntimeStateService.updateVehicleRegistry(entries); + + Map payload = new LinkedHashMap<>(); + payload.put("status", "success"); + payload.put("updatedAt", result.updatedAt()); + payload.put("updated", result.updated()); + payload.put("controllableCount", result.controllableCount()); + payload.put("typesCount", result.typesCount()); + payload.put("controllableVehicleIDs", result.controllableVehicleIDs()); + return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(payload); + } catch (JsonProcessingException e) { + return invalidJson(e); + } catch (Exception e) { + return internalError(e); + } + } + + @PostMapping(path = "/config/runway/warning_zone_radius/aircraft", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> updateRunwayWarningZoneRadiusAircraft(@RequestBody String requestBody) { + return updateNumericConfig(requestBody, "runway", "warning_zone_radius.aircraft", + platformRuntimeStateService::updateRunwayWarningZoneRadiusAircraft); + } + + @PostMapping(path = "/config/runway/alert_zone_radius/aircraft", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> updateRunwayAlertZoneRadiusAircraft(@RequestBody String requestBody) { + return updateNumericConfig(requestBody, "runway", "alert_zone_radius.aircraft", + platformRuntimeStateService::updateRunwayAlertZoneRadiusAircraft); + } + + @PostMapping(path = "/config/collision/diverging_release_distance", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> updateCollisionDivergingReleaseDistance(@RequestBody String requestBody) { + return updateNumericConfig(requestBody, "collision", "collision.diverging_release_distance", + platformRuntimeStateService::updateCollisionDivergingReleaseDistance); + } + + private void validateVehicleRegistryItem( + JsonNode item, + List errors, + List entries) { + + if (!item.isObject()) { + errors.add("item must be an object"); + return; + } + + JsonNode vehicleIdNode = item.get("vehicleID"); + JsonNode vehicleTypeNode = item.get("vehicleType"); + + if (vehicleIdNode == null) { + errors.add("missing vehicleID"); + } + if (vehicleTypeNode == null) { + errors.add("missing vehicleType"); + } + if (vehicleIdNode == null || vehicleTypeNode == null) { + return; + } + + if (!vehicleIdNode.isTextual()) { + errors.add("vehicleID must be string"); + } + if (!vehicleTypeNode.isTextual()) { + errors.add("vehicleType must be string"); + } + if (!vehicleIdNode.isTextual() || !vehicleTypeNode.isTextual()) { + return; + } + + String vehicleId = vehicleIdNode.asText(); + String vehicleType = vehicleTypeNode.asText(); + + if (vehicleId.trim().isEmpty()) { + errors.add("vehicleID must be non-empty"); + return; + } + + try { + PlatformRuntimeStateService.VehicleRegistryType parsedType = + PlatformRuntimeStateService.VehicleRegistryType.valueOf(vehicleType); + entries.add(new PlatformRuntimeStateService.VehicleRegistryEntry(vehicleId, parsedType)); + } catch (IllegalArgumentException e) { + errors.add("invalid vehicleType=" + vehicleType); + } + } + + private ResponseEntity> updateNumericConfig( + String requestBody, + String area, + String field, + NumericUpdater updater) { + + try { + JsonNode root = objectMapper.readTree(requestBody); + if (!root.isObject()) { + return invalidFieldType("Request body must be a JSON object"); + } + + JsonNode valueNode = root.get("value"); + if (valueNode == null) { + return error(HttpStatus.BAD_REQUEST, "Missing field: value"); + } + if (!valueNode.isNumber()) { + return invalidFieldType("Field 'value' must be a number"); + } + + double newValue = valueNode.asDouble(); + if (!Double.isFinite(newValue) || newValue <= 0) { + return error(HttpStatus.BAD_REQUEST, "Invalid value: must be a finite number greater than 0"); + } + + double oldValue = updater.update(newValue); + Map payload = new LinkedHashMap<>(); + payload.put("status", "success"); + payload.put("area", area); + payload.put("field", field); + payload.put("old", oldValue); + payload.put("new", newValue); + return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(payload); + } catch (JsonProcessingException e) { + return invalidJson(e); + } catch (Exception e) { + return internalError(e); + } + } + + private ResponseEntity> invalidJson(JsonProcessingException e) { + Map payload = new LinkedHashMap<>(); + payload.put("status", "error"); + payload.put("message", "Invalid JSON"); + payload.put("detail", e.getOriginalMessage()); + return ResponseEntity.badRequest().contentType(MediaType.APPLICATION_JSON).body(payload); + } + + private ResponseEntity> invalidFieldType(String detail) { + Map payload = new LinkedHashMap<>(); + payload.put("status", "error"); + payload.put("message", "Invalid field type"); + payload.put("detail", detail); + return ResponseEntity.badRequest().contentType(MediaType.APPLICATION_JSON).body(payload); + } + + private ResponseEntity> error(HttpStatus status, String message) { + Map payload = new LinkedHashMap<>(); + payload.put("status", "error"); + payload.put("message", message); + return ResponseEntity.status(status).contentType(MediaType.APPLICATION_JSON).body(payload); + } + + private ResponseEntity> internalError(Exception e) { + Map payload = new LinkedHashMap<>(); + payload.put("status", "error"); + payload.put("message", "Internal error"); + payload.put("detail", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(MediaType.APPLICATION_JSON) + .body(payload); + } + + @FunctionalInterface + private interface NumericUpdater { + double update(double newValue); + } +} diff --git a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java index 141f3c8..04662bd 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java @@ -744,7 +744,8 @@ public class DataCollectorService { .missionStartTime(currentMission.getStartTime()) .estimatedEndTime(currentMission.getEstimatedEndTime()) .progress(currentMission.getProgress()) - .totalMileage(currentMission.getTotalMileage()); + .totalMileage(currentMission.getTotalMileage()) + .missionStatus(resolveMissionStatus(statusData, missionContext)); } // 提取路径点信息 @@ -984,6 +985,7 @@ public class DataCollectorService { unmannedVehicle.setEstimatedEndTime(currentMission.getEstimatedEndTime()); unmannedVehicle.setProgress(currentMission.getProgress()); unmannedVehicle.setTotalMileage(currentMission.getTotalMileage()); + unmannedVehicle.setMissionStatus(resolveMissionStatus(null, missionContext)); } // 更新路径点信息 @@ -1084,6 +1086,53 @@ public class DataCollectorService { } return null; } + + private UnmannedVehicle.MissionStatus resolveMissionStatus( + com.qaup.collision.datacollector.model.dto.UniversalVehicleStatusDTO statusData, + MissionContextDTO missionContext) { + + if (missionContext == null || missionContext.getCurrentMission() == null) { + return UnmannedVehicle.MissionStatus.NONE; + } + + MissionContextDTO.CurrentMissionDTO currentMission = missionContext.getCurrentMission(); + if (currentMission.getMissionId() == null || currentMission.getMissionId().isBlank()) { + return UnmannedVehicle.MissionStatus.NONE; + } + + if (currentMission.getProgress() != null && currentMission.getProgress() >= 100.0d) { + return UnmannedVehicle.MissionStatus.COMPLETED; + } + + if (missionContext.getWaypoints() != null && !missionContext.getWaypoints().isEmpty()) { + boolean allFinished = missionContext.getWaypoints().stream() + .allMatch(waypoint -> { + String status = waypoint.getStatus(); + return "COMPLETED".equalsIgnoreCase(status) || "SKIPPED".equalsIgnoreCase(status); + }); + if (allFinished) { + return UnmannedVehicle.MissionStatus.COMPLETED; + } + } + + if (statusData != null && statusData.getOperationalStatus() != null) { + String operationalMode = statusData.getOperationalStatus().getOperationalMode(); + if (operationalMode != null) { + String normalized = operationalMode.trim().toUpperCase(); + if (normalized.contains("CANCEL")) { + return UnmannedVehicle.MissionStatus.CANCELLED; + } + if (normalized.contains("PAUSE")) { + return UnmannedVehicle.MissionStatus.PAUSED; + } + if (normalized.contains("IDLE")) { + return UnmannedVehicle.MissionStatus.ASSIGNED; + } + } + } + + return UnmannedVehicle.MissionStatus.IN_PROGRESS; + } /** * 通用车辆状态缓存条目 diff --git a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePreparationService.java b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePreparationService.java new file mode 100644 index 0000000..5b96a27 --- /dev/null +++ b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/RoutePreparationService.java @@ -0,0 +1,300 @@ +package com.qaup.collision.datacollector.service; + +import com.qaup.collision.common.model.MovingObject; +import com.qaup.collision.common.model.UnmannedVehicle; +import com.qaup.collision.datacollector.util.RouteGeometryProcessor; +import com.qaup.collision.pathconflict.model.entity.ObjectRouteAssignment; +import com.qaup.collision.pathconflict.model.entity.TransportRoute; +import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; +import com.qaup.collision.pathconflict.repository.TransportRouteRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.PrecisionModel; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Prepares route inputs before conflict detection. + * This service standardizes unmanned vehicle task waypoints into formal routes and assignments. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RoutePreparationService { + + private static final String CREATED_BY = "RoutePreparationService"; + + private final TransportRouteRepository transportRouteRepository; + private final ObjectRouteAssignmentRepository objectRouteAssignmentRepository; + private final RouteGeometryProcessor routeGeometryProcessor; + + private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); + + @Transactional + public void synchronizeRoutes(List activeObjects) { + if (activeObjects == null || activeObjects.isEmpty()) { + return; + } + + for (MovingObject movingObject : activeObjects) { + if (movingObject instanceof UnmannedVehicle unmannedVehicle) { + synchronizeUnmannedVehicleRoute(unmannedVehicle); + } + } + } + + @Transactional + public void synchronizeUnmannedVehicleRoute(UnmannedVehicle unmannedVehicle) { + if (unmannedVehicle == null || unmannedVehicle.getObjectId() == null || unmannedVehicle.getObjectId().isBlank()) { + return; + } + + String objectName = normalizeObjectName(unmannedVehicle); + if (!shouldParticipateInConflictDetection(unmannedVehicle)) { + deactivatePreparedRouteIfPresent(objectName); + return; + } + + LineString routeGeometry = buildUnmannedVehicleRouteGeometry(unmannedVehicle); + + if (routeGeometry == null) { + deactivatePreparedRouteIfPresent(objectName); + return; + } + + String routeName = buildUnmannedVehicleRouteName(unmannedVehicle); + Optional existingRouteOptional = transportRouteRepository + .findByRouteNameAndRouteType(routeName, TransportRoute.RouteType.UNMANNED_VEHICLE); + + TransportRoute savedRoute = existingRouteOptional + .map(existing -> saveUpdatedRouteIfNeeded(existing, routeGeometry, unmannedVehicle)) + .orElseGet(() -> transportRouteRepository.save(createNewRoute(routeName, routeGeometry, unmannedVehicle))); + + saveAssignmentIfChanged(objectName, savedRoute.getId()); + deactivateOtherPreparedRoutes(objectName, savedRoute.getId()); + } + + private TransportRoute createNewRoute(String routeName, LineString routeGeometry, UnmannedVehicle unmannedVehicle) { + return TransportRoute.builder() + .routeName(routeName) + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .description(buildRouteDescription(unmannedVehicle, routeGeometry)) + .routeGeometry(routeGeometry) + .maxSpeedKph(30.0) + .typicalSpeedKph(unmannedVehicle.getCurrentSpeed() != null ? unmannedVehicle.getCurrentSpeed() : 15.0) + .status(TransportRoute.RouteStatus.ACTIVE) + .isBidirectional(false) + .createdBy(CREATED_BY) + .updatedBy(CREATED_BY) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + private TransportRoute updateExistingRoute(TransportRoute existing, LineString routeGeometry, UnmannedVehicle unmannedVehicle) { + existing.setRouteGeometry(routeGeometry); + existing.setDescription(buildRouteDescription(unmannedVehicle, routeGeometry)); + existing.setTypicalSpeedKph(unmannedVehicle.getCurrentSpeed() != null ? unmannedVehicle.getCurrentSpeed() : existing.getTypicalSpeedKph()); + existing.setStatus(TransportRoute.RouteStatus.ACTIVE); + existing.setUpdatedBy(CREATED_BY); + existing.setUpdatedAt(LocalDateTime.now()); + return existing; + } + + private TransportRoute saveUpdatedRouteIfNeeded(TransportRoute existing, LineString routeGeometry, UnmannedVehicle unmannedVehicle) { + String newDescription = buildRouteDescription(unmannedVehicle, routeGeometry); + Double newTypicalSpeed = unmannedVehicle.getCurrentSpeed() != null ? unmannedVehicle.getCurrentSpeed() : existing.getTypicalSpeedKph(); + + boolean sameGeometry = sameLineString(existing.getRouteGeometry(), routeGeometry); + boolean sameDescription = Objects.equals(existing.getDescription(), newDescription); + boolean sameTypicalSpeed = Objects.equals(existing.getTypicalSpeedKph(), newTypicalSpeed); + boolean alreadyActive = existing.getStatus() == TransportRoute.RouteStatus.ACTIVE; + + if (sameGeometry && sameDescription && sameTypicalSpeed && alreadyActive) { + return existing; + } + + TransportRoute updatedRoute = updateExistingRoute(existing, routeGeometry, unmannedVehicle); + return Optional.ofNullable(transportRouteRepository.save(updatedRoute)).orElse(existing); + } + + private void saveAssignmentIfChanged(String objectName, Long routeId) { + Optional existingAssignment = objectRouteAssignmentRepository + .findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc( + objectName, + ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE + ); + + if (existingAssignment.isPresent() && Objects.equals(existingAssignment.get().getAssignedRouteId(), routeId)) { + return; + } + + objectRouteAssignmentRepository.save(ObjectRouteAssignment.builder() + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .objectName(objectName) + .assignedRouteId(routeId) + .assignedAt(LocalDateTime.now()) + .build()); + } + + private void deactivatePreparedRouteIfPresent(String objectName) { + objectRouteAssignmentRepository + .findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc( + objectName, + ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE + ) + .flatMap(assignment -> transportRouteRepository.findById(assignment.getAssignedRouteId())) + .filter(this::isPreparedUnmannedVehicleRoute) + .filter(route -> route.getStatus() == TransportRoute.RouteStatus.ACTIVE) + .ifPresent(route -> { + route.setStatus(TransportRoute.RouteStatus.INACTIVE); + route.setUpdatedBy(CREATED_BY); + route.setUpdatedAt(LocalDateTime.now()); + transportRouteRepository.save(route); + }); + } + + private void deactivateOtherPreparedRoutes(String objectName, Long activeRouteId) { + List assignments = objectRouteAssignmentRepository + .findByObjectNameAndObjectTypeOrderByAssignedAtDesc( + objectName, + ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE + ); + + for (ObjectRouteAssignment assignment : assignments) { + if (Objects.equals(assignment.getAssignedRouteId(), activeRouteId)) { + continue; + } + transportRouteRepository.findById(assignment.getAssignedRouteId()) + .filter(this::isPreparedUnmannedVehicleRoute) + .filter(route -> route.getStatus() == TransportRoute.RouteStatus.ACTIVE) + .ifPresent(route -> { + route.setStatus(TransportRoute.RouteStatus.INACTIVE); + route.setUpdatedBy(CREATED_BY); + route.setUpdatedAt(LocalDateTime.now()); + transportRouteRepository.save(route); + }); + } + } + + private boolean isPreparedUnmannedVehicleRoute(TransportRoute route) { + return route != null + && route.getRouteType() == TransportRoute.RouteType.UNMANNED_VEHICLE + && CREATED_BY.equals(route.getCreatedBy()); + } + + private boolean shouldParticipateInConflictDetection(UnmannedVehicle unmannedVehicle) { + if (unmannedVehicle.getMissionStatus() == UnmannedVehicle.MissionStatus.COMPLETED + || unmannedVehicle.getMissionStatus() == UnmannedVehicle.MissionStatus.CANCELLED + || unmannedVehicle.getMissionStatus() == UnmannedVehicle.MissionStatus.NONE) { + return false; + } + + if (unmannedVehicle.getProgress() != null && unmannedVehicle.getProgress() >= 100.0d) { + return false; + } + + if (unmannedVehicle.getMissionId() == null || unmannedVehicle.getMissionId().isBlank()) { + return false; + } + + if (unmannedVehicle.getWaypoints() == null || unmannedVehicle.getWaypoints().isEmpty()) { + return false; + } + + boolean allFinished = unmannedVehicle.getWaypoints().stream() + .allMatch(waypoint -> waypoint != null + && (waypoint.getStatus() == UnmannedVehicle.WaypointStatus.COMPLETED + || waypoint.getStatus() == UnmannedVehicle.WaypointStatus.SKIPPED)); + return !allFinished; + } + + private String buildUnmannedVehicleRouteName(UnmannedVehicle unmannedVehicle) { + String missionId = unmannedVehicle.getMissionId(); + if (missionId != null && !missionId.isBlank()) { + return "UV_ROUTE_" + unmannedVehicle.getObjectId() + "_" + missionId; + } + return "UV_ROUTE_" + unmannedVehicle.getObjectId(); + } + + private String buildRouteDescription(UnmannedVehicle unmannedVehicle, LineString routeGeometry) { + return String.format( + "Unmanned vehicle route prepared from mission context, vehicle=%s, mission=%s, points=%d", + unmannedVehicle.getObjectId(), + unmannedVehicle.getMissionId(), + routeGeometry.getNumPoints() + ); + } + + private String normalizeObjectName(UnmannedVehicle unmannedVehicle) { + if (unmannedVehicle.getObjectName() != null && !unmannedVehicle.getObjectName().isBlank()) { + return unmannedVehicle.getObjectName(); + } + return unmannedVehicle.getObjectId(); + } + + private LineString buildUnmannedVehicleRouteGeometry(UnmannedVehicle unmannedVehicle) { + List coordinates = new ArrayList<>(); + + if (unmannedVehicle.getCurrentPosition() != null && !unmannedVehicle.getCurrentPosition().isEmpty()) { + coordinates.add(unmannedVehicle.getCurrentPosition().getCoordinate()); + } + + if (unmannedVehicle.getWaypoints() != null) { + for (UnmannedVehicle.WaypointInfo waypoint : unmannedVehicle.getWaypoints()) { + if (waypoint == null || waypoint.getLatitude() == null || waypoint.getLongitude() == null) { + continue; + } + Coordinate coordinate = new Coordinate(waypoint.getLongitude(), waypoint.getLatitude()); + if (coordinates.isEmpty() || !sameCoordinate(coordinates.get(coordinates.size() - 1), coordinate)) { + coordinates.add(coordinate); + } + } + } + + if (coordinates.size() < 2) { + return null; + } + + LineString route = geometryFactory.createLineString(coordinates.toArray(Coordinate[]::new)); + if (!routeGeometryProcessor.isValidLineString(route)) { + return null; + } + return routeGeometryProcessor.simplifyLineString(route, 0.000001d); + } + + private boolean sameCoordinate(Coordinate left, Coordinate right) { + return left != null + && right != null + && Double.compare(left.x, right.x) == 0 + && Double.compare(left.y, right.y) == 0; + } + + private boolean sameLineString(LineString left, LineString right) { + if (left == null || right == null) { + return false; + } + + Coordinate[] leftCoordinates = left.getCoordinates(); + Coordinate[] rightCoordinates = right.getCoordinates(); + if (leftCoordinates.length != rightCoordinates.length) { + return false; + } + + for (int i = 0; i < leftCoordinates.length; i++) { + if (!sameCoordinate(leftCoordinates[i], rightCoordinates[i])) { + return false; + } + } + return true; + } +} diff --git a/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java b/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java index 4c40b53..f39ed33 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java @@ -6,6 +6,7 @@ import com.qaup.collision.common.model.UnmannedVehicle; import com.qaup.collision.common.model.spatial.VehicleLocation; import com.qaup.collision.common.adapter.QuapDataAdapter; import com.qaup.collision.datacollector.service.VehicleDataPersistenceService; +import com.qaup.collision.datacollector.service.RoutePreparationService; import com.qaup.collision.dataprocessing.parser.TrafficLightSignalParser; import com.qaup.collision.websocket.event.PositionUpdateEvent; import com.qaup.collision.websocket.message.PositionUpdatePayload; @@ -14,6 +15,7 @@ import com.qaup.collision.websocket.message.VehicleStatusUpdatePayload; import com.qaup.collision.websocket.event.VehicleStatusUpdateEvent; import com.qaup.collision.datacollector.service.DataCollectorService; import com.qaup.collision.common.model.FlightNotification; +import com.qaup.collision.service.PlatformRuntimeStateService; import com.qaup.collision.websocket.event.FlightNotificationEvent; import com.qaup.common.core.redis.RedisCache; import com.qaup.system.domain.SysVehicleInfo; @@ -75,6 +77,9 @@ public class DataProcessingService { @Autowired private VehicleDataPersistenceService vehicleDataPersistenceService; + @Autowired + private RoutePreparationService routePreparationService; + @Autowired private QuapDataAdapter quapDataAdapter; @@ -88,6 +93,9 @@ public class DataProcessingService { @Autowired private RedisCache redisCache; + @Autowired + private PlatformRuntimeStateService platformRuntimeStateService; + // 从DataCollectorService获取缓存的引用 private Map activeMovingObjectsCache; @@ -147,18 +155,43 @@ public class DataProcessingService { // 注意:采集侧只缓存;发送侧在处理完成后会清理缓存,避免同一通知被每秒重复推送(刷屏)。 processFlightNotificationUpdates(); - // 第五步:执行路径冲突检测 - pathConflictDetectionService.detectPathConflicts(currentActiveObjects); + List collisionManagedObjects = filterCollisionManagedObjects(currentActiveObjects); - // 第六步:执行违规检测 + // 第五步:将注册的无人车任务路径准备为正式路线输入 + if (routePreparationService != null) { + routePreparationService.synchronizeRoutes(collisionManagedObjects); + } + + // 第六步:执行路径冲突检测 + pathConflictDetectionService.detectPathConflicts(collisionManagedObjects); + + // 第七步:执行违规检测 performViolationDetection(currentActiveObjects); - // 第七步:保存无人车数据到数据库 + // 第八步:保存无人车数据到数据库 saveUnmannedVehicleDataPeriodically(currentActiveObjects); log.info("周期性数据处理完成"); } + private List filterCollisionManagedObjects(List activeObjects) { + if (activeObjects == null || activeObjects.isEmpty()) { + return List.of(); + } + + List filteredObjects = activeObjects.stream() + .filter(Objects::nonNull) + .filter(object -> platformRuntimeStateService == null + || platformRuntimeStateService.isRegisteredForCollision(object.getObjectId(), object.getObjectType())) + .collect(Collectors.toList()); + + if (filteredObjects.size() != activeObjects.size()) { + log.debug("碰撞检测对象已按注册表过滤: 原始数量={}, 过滤后数量={}", + activeObjects.size(), filteredObjects.size()); + } + return filteredObjects; + } + /** * 为所有缓存对象计算速度和方向 */ diff --git a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/PathConflictDetectionService.java b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/PathConflictDetectionService.java index a778ea4..5f33f6e 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/PathConflictDetectionService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/PathConflictDetectionService.java @@ -10,6 +10,7 @@ import com.qaup.collision.pathconflict.model.entity.TransportRoute; import com.qaup.collision.pathconflict.repository.ConflictAlertLogRepository; import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; import com.qaup.collision.pathconflict.repository.TransportRouteRepository; +import com.qaup.collision.service.PlatformRuntimeStateService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.locationtech.jts.geom.Coordinate; @@ -39,9 +40,8 @@ public class PathConflictDetectionService { private final ConflictAlertLogRepository conflictAlertLogRepository; private final ApplicationEventPublisher eventPublisher; private final CoordinateSystemService coordinateSystemService; + private final PlatformRuntimeStateService platformRuntimeStateService; - private static final double WARNING_DISTANCE_THRESHOLD = 200.0; - private static final double ALERT_DISTANCE_THRESHOLD = 100.0; private static final int MAX_PREDICTION_TIME_SECONDS = 300; private static final double MIN_TIME_GAP_SECONDS = 30.0; @@ -76,7 +76,7 @@ public class PathConflictDetectionService { } private Optional detectConflictBetweenObjects(MovingObject obj1, MovingObject obj2) { - if (obj1.getObjectType() != MovingObjectType.UNMANNED_VEHICLE && obj2.getObjectType() != MovingObjectType.UNMANNED_VEHICLE) { + if (!isSupportedConflictPair(obj1.getObjectType(), obj2.getObjectType())) { return Optional.empty(); } @@ -200,6 +200,7 @@ public class PathConflictDetectionService { return assignmentOptional .map(assignment -> routeRepository.findById(assignment.getAssignedRouteId()).orElse(null)) + .filter(route -> route.getStatus() == TransportRoute.RouteStatus.ACTIVE) .orElse(null); } @@ -288,27 +289,25 @@ public class PathConflictDetectionService { double distance2, MovingObjectType obj2Type, double timeGap) { - - boolean obj1IsUnmannedVehicle = MovingObjectType.UNMANNED_VEHICLE.equals(obj1Type); - boolean obj2IsUnmannedVehicle = MovingObjectType.UNMANNED_VEHICLE.equals(obj2Type); - - if (!obj1IsUnmannedVehicle && !obj2IsUnmannedVehicle) { + if (!isSupportedConflictPair(obj1Type, obj2Type)) { return Optional.empty(); } double distanceToEvaluate; - - if (obj1IsUnmannedVehicle && obj2IsUnmannedVehicle) { - distanceToEvaluate = Math.min(distance1, distance2); - } else if (obj1IsUnmannedVehicle) { + if (obj1Type == MovingObjectType.AIRCRAFT) { + distanceToEvaluate = distance2; + } else if (obj2Type == MovingObjectType.AIRCRAFT) { distanceToEvaluate = distance1; } else { - distanceToEvaluate = distance2; + return Optional.empty(); } - if (distanceToEvaluate <= ALERT_DISTANCE_THRESHOLD && timeGap <= MIN_TIME_GAP_SECONDS) { + double alertDistanceThreshold = platformRuntimeStateService.getRunwayAlertZoneRadiusAircraft(); + double warningDistanceThreshold = platformRuntimeStateService.getRunwayWarningZoneRadiusAircraft(); + + if (distanceToEvaluate <= alertDistanceThreshold && timeGap <= MIN_TIME_GAP_SECONDS) { return Optional.of(ConflictAlertLog.AlertLevel.CRITICAL); - } else if (distanceToEvaluate <= WARNING_DISTANCE_THRESHOLD && timeGap <= MIN_TIME_GAP_SECONDS * 2) { + } else if (distanceToEvaluate <= warningDistanceThreshold && timeGap <= MIN_TIME_GAP_SECONDS * 2) { return Optional.of(ConflictAlertLog.AlertLevel.WARNING); } else { return Optional.empty(); @@ -370,6 +369,13 @@ public class PathConflictDetectionService { return dot >= HEADING_ALIGNMENT_MIN_COS; } + private boolean isSupportedConflictPair(MovingObjectType left, MovingObjectType right) { + return (left == MovingObjectType.UNMANNED_VEHICLE && right == MovingObjectType.AIRCRAFT) + || (left == MovingObjectType.AIRCRAFT && right == MovingObjectType.UNMANNED_VEHICLE) + || (left == MovingObjectType.SPECIAL_VEHICLE && right == MovingObjectType.AIRCRAFT) + || (left == MovingObjectType.AIRCRAFT && right == MovingObjectType.SPECIAL_VEHICLE); + } + private Point toLocalPoint(Point wgs84Point) { try { double[] local = coordinateSystemService.convertToLocalCoordinate(wgs84Point.getX(), wgs84Point.getY()); @@ -518,4 +524,4 @@ public class PathConflictDetectionService { public MovingObjectType getObject2Type() { return object2Type; } } -} \ No newline at end of file +} diff --git a/qaup-collision/src/main/java/com/qaup/collision/service/PlatformRuntimeStateService.java b/qaup-collision/src/main/java/com/qaup/collision/service/PlatformRuntimeStateService.java new file mode 100644 index 0000000..ba9a76c --- /dev/null +++ b/qaup-collision/src/main/java/com/qaup/collision/service/PlatformRuntimeStateService.java @@ -0,0 +1,148 @@ +package com.qaup.collision.service; + +import com.qaup.collision.common.model.MovingObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class PlatformRuntimeStateService { + + private final ConcurrentHashMap vehicleTypes = new ConcurrentHashMap<>(); + + private volatile double runwayWarningZoneRadiusAircraft; + private volatile double runwayAlertZoneRadiusAircraft; + private volatile double collisionDivergingReleaseDistance; + + public PlatformRuntimeStateService( + @Value("${qaup.runtime-config.runway.warning-zone-radius.aircraft:200.0}") double runwayWarningZoneRadiusAircraft, + @Value("${qaup.runtime-config.runway.alert-zone-radius.aircraft:100.0}") double runwayAlertZoneRadiusAircraft, + @Value("${qaup.runtime-config.collision.diverging-release-distance:40.0}") double collisionDivergingReleaseDistance) { + + this.runwayWarningZoneRadiusAircraft = runwayWarningZoneRadiusAircraft; + this.runwayAlertZoneRadiusAircraft = runwayAlertZoneRadiusAircraft; + this.collisionDivergingReleaseDistance = collisionDivergingReleaseDistance; + } + + public VehicleRegistryUpdateResult updateVehicleRegistry(List entries) { + Objects.requireNonNull(entries, "entries"); + + EnumMap requestTypeCounts = new EnumMap<>(VehicleRegistryType.class); + for (VehicleRegistryEntry entry : entries) { + vehicleTypes.put(entry.vehicleID(), entry.vehicleType()); + requestTypeCounts.merge(entry.vehicleType(), 1, Integer::sum); + } + + Set controllableVehicleIds = new TreeSet<>(); + for (Map.Entry entry : vehicleTypes.entrySet()) { + if (entry.getValue() == VehicleRegistryType.WUREN) { + controllableVehicleIds.add(entry.getKey()); + } + } + + Map responseTypeCounts = new LinkedHashMap<>(); + for (VehicleRegistryType type : VehicleRegistryType.values()) { + Integer count = requestTypeCounts.get(type); + if (count != null && count > 0) { + responseTypeCounts.put(type.name(), count); + } + } + + return new VehicleRegistryUpdateResult( + System.currentTimeMillis(), + entries.size(), + controllableVehicleIds.size(), + responseTypeCounts, + new ArrayList<>(controllableVehicleIds)); + } + + public List getControllableVehicleIds() { + TreeSet vehicleIds = new TreeSet<>(); + for (Map.Entry entry : vehicleTypes.entrySet()) { + if (entry.getValue() == VehicleRegistryType.WUREN) { + vehicleIds.add(entry.getKey()); + } + } + return Collections.unmodifiableList(new ArrayList<>(vehicleIds)); + } + + public VehicleRegistryType getVehicleRegistryType(String vehicleId) { + if (vehicleId == null || vehicleId.isBlank()) { + return null; + } + return vehicleTypes.get(vehicleId); + } + + public boolean isRegisteredForCollision(String vehicleId, MovingObject.MovingObjectType objectType) { + VehicleRegistryType registryType = getVehicleRegistryType(vehicleId); + if (registryType == null || objectType == null) { + return false; + } + + return switch (registryType) { + case WUREN -> objectType == MovingObject.MovingObjectType.UNMANNED_VEHICLE; + case TEQIN -> objectType == MovingObject.MovingObjectType.SPECIAL_VEHICLE; + case HANGKONG -> objectType == MovingObject.MovingObjectType.AIRCRAFT; + case PUTONG, JIUYUAN -> false; + }; + } + + public double getRunwayWarningZoneRadiusAircraft() { + return runwayWarningZoneRadiusAircraft; + } + + public double updateRunwayWarningZoneRadiusAircraft(double newValue) { + double oldValue = runwayWarningZoneRadiusAircraft; + runwayWarningZoneRadiusAircraft = newValue; + return oldValue; + } + + public double getRunwayAlertZoneRadiusAircraft() { + return runwayAlertZoneRadiusAircraft; + } + + public double updateRunwayAlertZoneRadiusAircraft(double newValue) { + double oldValue = runwayAlertZoneRadiusAircraft; + runwayAlertZoneRadiusAircraft = newValue; + return oldValue; + } + + public double getCollisionDivergingReleaseDistance() { + return collisionDivergingReleaseDistance; + } + + public double updateCollisionDivergingReleaseDistance(double newValue) { + double oldValue = collisionDivergingReleaseDistance; + collisionDivergingReleaseDistance = newValue; + return oldValue; + } + + public record VehicleRegistryEntry(String vehicleID, VehicleRegistryType vehicleType) { + } + + public record VehicleRegistryUpdateResult( + long updatedAt, + int updated, + int controllableCount, + Map typesCount, + List controllableVehicleIDs) { + } + + public enum VehicleRegistryType { + WUREN, + TEQIN, + HANGKONG, + PUTONG, + JIUYUAN + } +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationControllerTest.java b/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationControllerTest.java new file mode 100644 index 0000000..17ffbe3 --- /dev/null +++ b/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationControllerTest.java @@ -0,0 +1,117 @@ +package com.qaup.collision.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qaup.collision.service.PlatformRuntimeStateService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PlatformIntegrationControllerTest { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + PlatformRuntimeStateService runtimeStateService = new PlatformRuntimeStateService(200.0, 150.0, 40.0); + PlatformIntegrationController controller = + new PlatformIntegrationController(new ObjectMapper(), runtimeStateService); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void shouldAcceptVehicleRegistryPayload() throws Exception { + mockMvc.perform(post("/api/VehicleRegistry") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + [ + { "vehicleID": "QN001", "vehicleType": "WUREN" }, + { "vehicleID": "TQ001", "vehicleType": "TEQIN" }, + { "vehicleID": "AC001", "vehicleType": "HANGKONG" } + ] + """)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.updated").value(3)) + .andExpect(jsonPath("$.controllableCount").value(1)) + .andExpect(jsonPath("$.typesCount.WUREN").value(1)) + .andExpect(jsonPath("$.typesCount.TEQIN").value(1)) + .andExpect(jsonPath("$.typesCount.HANGKONG").value(1)) + .andExpect(jsonPath("$.controllableVehicleIDs[0]").value("QN001")); + } + + @Test + void shouldRejectInvalidVehicleRegistryPayload() throws Exception { + mockMvc.perform(post("/api/VehicleRegistry") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + [ + { "vehicleID": "", "vehicleType": "WUREN" }, + { "vehicleID": "QN002", "vehicleType": "UNKNOWN" }, + 1 + ] + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.message").value("Invalid request")) + .andExpect(jsonPath("$.errors[0]").value("vehicleID must be non-empty")) + .andExpect(jsonPath("$.errors[1]").value("invalid vehicleType=UNKNOWN")) + .andExpect(jsonPath("$.errors[2]").value("item must be an object")); + } + + @Test + void shouldRejectNonArrayVehicleRegistryPayload() throws Exception { + mockMvc.perform(post("/api/VehicleRegistry") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"vehicleID\":\"QN001\",\"vehicleType\":\"WUREN\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Body must be an array")); + } + + @Test + void shouldUpdateRunwayWarningZoneRadius() throws Exception { + mockMvc.perform(post("/config/runway/warning_zone_radius/aircraft") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"value\":300}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.area").value("runway")) + .andExpect(jsonPath("$.field").value("warning_zone_radius.aircraft")) + .andExpect(jsonPath("$.old").value(200.0)) + .andExpect(jsonPath("$.new").value(300.0)); + } + + @Test + void shouldRejectMissingNumericConfigValue() throws Exception { + mockMvc.perform(post("/config/collision/diverging_release_distance") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Missing field: value")); + } + + @Test + void shouldRejectInvalidNumericConfigValueType() throws Exception { + mockMvc.perform(post("/config/runway/alert_zone_radius/aircraft") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"value\":\"oops\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Invalid field type")); + } + + @Test + void shouldRejectInvalidJson() throws Exception { + mockMvc.perform(post("/config/runway/alert_zone_radius/aircraft") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"value\":")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Invalid JSON")); + } +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationSecurityTest.java b/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationSecurityTest.java new file mode 100644 index 0000000..3483265 --- /dev/null +++ b/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationSecurityTest.java @@ -0,0 +1,116 @@ +package com.qaup.collision.controller; + +import com.qaup.framework.config.SecurityConfig; +import com.qaup.framework.config.properties.PermitAllUrlProperties; +import com.qaup.framework.security.filter.JwtAuthenticationTokenFilter; +import com.qaup.framework.security.handle.AuthenticationEntryPointImpl; +import com.qaup.framework.security.handle.LogoutSuccessHandlerImpl; +import com.qaup.collision.service.PlatformRuntimeStateService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.filter.CorsFilter; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = PlatformIntegrationController.class) +@ContextConfiguration(classes = { + PlatformIntegrationSecurityTest.TestApplication.class, + PlatformIntegrationController.class, + SecurityConfig.class +}) +@Import(SecurityConfig.class) +class PlatformIntegrationSecurityTest { + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestApplication { + } + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private PlatformRuntimeStateService platformRuntimeStateService; + + @MockitoBean + private UserDetailsService userDetailsService; + + @MockitoBean + private AuthenticationEntryPointImpl authenticationEntryPoint; + + @MockitoBean + private LogoutSuccessHandlerImpl logoutSuccessHandler; + + @MockitoBean + private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; + + @MockitoBean + private CorsFilter corsFilter; + + @MockitoBean + private PermitAllUrlProperties permitAllUrlProperties; + + @BeforeEach + void setUp() throws Exception { + Mockito.when(permitAllUrlProperties.getUrls()).thenReturn(List.of()); + Mockito.when(userDetailsService.loadUserByUsername(Mockito.anyString())) + .thenReturn(User.withUsername("user").password("{noop}pwd").authorities("ROLE_USER").build()); + + Mockito.doAnswer(invocation -> { + HttpServletRequest request = invocation.getArgument(0); + HttpServletResponse response = invocation.getArgument(1); + FilterChain chain = invocation.getArgument(2); + chain.doFilter(request, response); + return null; + }).when(jwtAuthenticationTokenFilter).doFilter( + Mockito.any(HttpServletRequest.class), + Mockito.any(HttpServletResponse.class), + Mockito.any(FilterChain.class) + ); + + Mockito.doAnswer(invocation -> { + HttpServletRequest request = invocation.getArgument(0); + HttpServletResponse response = invocation.getArgument(1); + FilterChain chain = invocation.getArgument(2); + chain.doFilter(request, response); + return null; + }).when(corsFilter).doFilter( + Mockito.any(HttpServletRequest.class), + Mockito.any(HttpServletResponse.class), + Mockito.any(FilterChain.class) + ); + + Mockito.when(platformRuntimeStateService.updateCollisionDivergingReleaseDistance(Mockito.anyDouble())) + .thenReturn(40.0); + } + + @Test + void shouldAllowAnonymousAccessToNewPlatformEndpoints() throws Exception { + mockMvc.perform(post("/config/collision/diverging_release_distance") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"value\":50}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.old").value(40.0)) + .andExpect(jsonPath("$.new").value(50.0)); + } +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePreparationServiceTest.java b/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePreparationServiceTest.java new file mode 100644 index 0000000..4f201db --- /dev/null +++ b/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/RoutePreparationServiceTest.java @@ -0,0 +1,228 @@ +package com.qaup.collision.datacollector.service; + +import com.qaup.collision.common.model.UnmannedVehicle; +import com.qaup.collision.datacollector.util.RouteGeometryProcessor; +import com.qaup.collision.pathconflict.model.entity.ObjectRouteAssignment; +import com.qaup.collision.pathconflict.model.entity.TransportRoute; +import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; +import com.qaup.collision.pathconflict.repository.TransportRouteRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RoutePreparationServiceTest { + + @Mock + private TransportRouteRepository transportRouteRepository; + + @Mock + private ObjectRouteAssignmentRepository objectRouteAssignmentRepository; + + private RoutePreparationService routePreparationService; + + private final GeometryFactory geometryFactory = new GeometryFactory(); + + private RoutePreparationService service() { + if (routePreparationService == null) { + routePreparationService = new RoutePreparationService( + transportRouteRepository, + objectRouteAssignmentRepository, + new RouteGeometryProcessor() + ); + } + return routePreparationService; + } + + @Test + void shouldPrepareFormalRouteForUnmannedVehicleMission() { + UnmannedVehicle vehicle = buildVehicleWithWaypoints("UV-100", "MISSION-1"); + + when(transportRouteRepository.findByRouteNameAndRouteType( + "UV_ROUTE_UV-100_MISSION-1", + TransportRoute.RouteType.UNMANNED_VEHICLE + )).thenReturn(Optional.empty()); + when(transportRouteRepository.save(any(TransportRoute.class))).thenAnswer(invocation -> { + TransportRoute route = invocation.getArgument(0); + route.setId(11L); + return route; + }); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc( + "UV-100", + ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE + )).thenReturn(Optional.empty()); + when(objectRouteAssignmentRepository.findByObjectNameAndObjectTypeOrderByAssignedAtDesc( + "UV-100", + ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE + )).thenReturn(List.of()); + + service().synchronizeUnmannedVehicleRoute(vehicle); + + ArgumentCaptor routeCaptor = ArgumentCaptor.forClass(TransportRoute.class); + verify(transportRouteRepository).save(routeCaptor.capture()); + TransportRoute savedRoute = routeCaptor.getValue(); + assertEquals(TransportRoute.RouteType.UNMANNED_VEHICLE, savedRoute.getRouteType()); + assertEquals(TransportRoute.RouteStatus.ACTIVE, savedRoute.getStatus()); + assertEquals("UV_ROUTE_UV-100_MISSION-1", savedRoute.getRouteName()); + assertNotNull(savedRoute.getRouteGeometry()); + assertTrue(savedRoute.getRouteGeometry().getNumPoints() >= 2); + + ArgumentCaptor assignmentCaptor = ArgumentCaptor.forClass(ObjectRouteAssignment.class); + verify(objectRouteAssignmentRepository).save(assignmentCaptor.capture()); + assertEquals("UV-100", assignmentCaptor.getValue().getObjectName()); + assertEquals(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE, assignmentCaptor.getValue().getObjectType()); + assertEquals(11L, assignmentCaptor.getValue().getAssignedRouteId()); + } + + @Test + void shouldDeactivatePreparedRouteWhenMissionPathMissing() { + UnmannedVehicle vehicle = UnmannedVehicle.builder() + .objectId("UV-200") + .objectName("UV-200") + .build(); + + ObjectRouteAssignment latestAssignment = ObjectRouteAssignment.builder() + .objectName("UV-200") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(21L) + .build(); + TransportRoute existingRoute = TransportRoute.builder() + .id(21L) + .routeName("UV_ROUTE_UV-200_MISSION-X") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .createdBy("RoutePreparationService") + .routeGeometry(geometryFactory.createLineString(new Coordinate[]{ + new Coordinate(120.0, 36.0), + new Coordinate(120.1, 36.1) + })) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc( + "UV-200", + ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE + )).thenReturn(Optional.of(latestAssignment)); + when(transportRouteRepository.findById(21L)).thenReturn(Optional.of(existingRoute)); + + service().synchronizeUnmannedVehicleRoute(vehicle); + + verify(transportRouteRepository).save(existingRoute); + assertEquals(TransportRoute.RouteStatus.INACTIVE, existingRoute.getStatus()); + verify(objectRouteAssignmentRepository, never()).save(any(ObjectRouteAssignment.class)); + } + + private UnmannedVehicle buildVehicleWithWaypoints(String vehicleId, String missionId) { + Point currentPosition = geometryFactory.createPoint(new Coordinate(120.0834104, 36.35406879)); + return UnmannedVehicle.builder() + .objectId(vehicleId) + .objectName(vehicleId) + .missionId(missionId) + .missionStatus(UnmannedVehicle.MissionStatus.IN_PROGRESS) + .currentPosition(currentPosition) + .waypoints(List.of( + UnmannedVehicle.WaypointInfo.builder() + .waypointId("1") + .latitude(36.35416879) + .longitude(120.0835104) + .status(UnmannedVehicle.WaypointStatus.PENDING) + .build(), + UnmannedVehicle.WaypointInfo.builder() + .waypointId("2") + .latitude(36.35426879) + .longitude(120.0836104) + .status(UnmannedVehicle.WaypointStatus.PENDING) + .build() + )) + .build(); + } + + @Test + void shouldNotRewriteRouteWhenPreparedInputIsUnchanged() { + UnmannedVehicle vehicle = buildVehicleWithWaypoints("UV-300", "MISSION-3"); + TransportRoute existingRoute = TransportRoute.builder() + .id(31L) + .routeName("UV_ROUTE_UV-300_MISSION-3") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .description("Unmanned vehicle route prepared from mission context, vehicle=UV-300, mission=MISSION-3, points=2") + .routeGeometry(new GeometryFactory().createLineString(new Coordinate[]{ + new Coordinate(120.0834104, 36.35406879), + new Coordinate(120.0836104, 36.35426879) + })) + .typicalSpeedKph(15.0) + .status(TransportRoute.RouteStatus.ACTIVE) + .createdBy("RoutePreparationService") + .build(); + + when(transportRouteRepository.findByRouteNameAndRouteType( + "UV_ROUTE_UV-300_MISSION-3", + TransportRoute.RouteType.UNMANNED_VEHICLE + )).thenReturn(Optional.of(existingRoute)); + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc( + "UV-300", + ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE + )).thenReturn(Optional.of(ObjectRouteAssignment.builder() + .objectName("UV-300") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(31L) + .build())); + when(objectRouteAssignmentRepository.findByObjectNameAndObjectTypeOrderByAssignedAtDesc( + "UV-300", + ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE + )).thenReturn(List.of()); + + service().synchronizeUnmannedVehicleRoute(vehicle); + + verify(transportRouteRepository, never()).save(any(TransportRoute.class)); + verify(objectRouteAssignmentRepository, never()).save(any(ObjectRouteAssignment.class)); + } + + @Test + void shouldDeactivatePreparedRouteWhenMissionIsCompleted() { + UnmannedVehicle vehicle = buildVehicleWithWaypoints("UV-400", "MISSION-4"); + vehicle.setMissionStatus(UnmannedVehicle.MissionStatus.COMPLETED); + + ObjectRouteAssignment latestAssignment = ObjectRouteAssignment.builder() + .objectName("UV-400") + .objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) + .assignedRouteId(41L) + .build(); + TransportRoute existingRoute = TransportRoute.builder() + .id(41L) + .routeName("UV_ROUTE_UV-400_MISSION-4") + .routeType(TransportRoute.RouteType.UNMANNED_VEHICLE) + .status(TransportRoute.RouteStatus.ACTIVE) + .createdBy("RoutePreparationService") + .routeGeometry(new GeometryFactory().createLineString(new Coordinate[]{ + new Coordinate(120.0, 36.0), + new Coordinate(120.1, 36.1) + })) + .build(); + + when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc( + "UV-400", + ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE + )).thenReturn(Optional.of(latestAssignment)); + when(transportRouteRepository.findById(41L)).thenReturn(Optional.of(existingRoute)); + + service().synchronizeUnmannedVehicleRoute(vehicle); + + verify(transportRouteRepository).save(existingRoute); + assertEquals(TransportRoute.RouteStatus.INACTIVE, existingRoute.getStatus()); + } +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/VehicleStatusUpdateIntegrationTest.java b/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/VehicleStatusUpdateIntegrationTest.java index 09c91f9..98ee684 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/VehicleStatusUpdateIntegrationTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/datacollector/service/VehicleStatusUpdateIntegrationTest.java @@ -12,6 +12,7 @@ import com.qaup.collision.dataprocessing.service.SpeedCalculationService; import com.qaup.collision.datacollector.service.VehicleDataPersistenceService; import com.qaup.collision.pathconflict.service.PathConflictDetectionService; import com.qaup.collision.rule.service.RuleExecutionEngine; +import com.qaup.collision.service.PlatformRuntimeStateService; import com.qaup.common.core.redis.RedisCache; import com.qaup.collision.websocket.event.VehicleStatusUpdateEvent; import org.junit.jupiter.api.BeforeEach; @@ -55,6 +56,7 @@ class VehicleStatusUpdateIntegrationTest { private QuapDataAdapter quapDataAdapter; private RuleExecutionEngine ruleExecutionEngine; private RedisCache redisCache; + private PlatformRuntimeStateService platformRuntimeStateService; @Mock private ApplicationEventPublisher eventPublisher; @@ -97,6 +99,7 @@ class VehicleStatusUpdateIntegrationTest { quapDataAdapter = mock(QuapDataAdapter.class); ruleExecutionEngine = mock(RuleExecutionEngine.class); redisCache = mock(RedisCache.class); + platformRuntimeStateService = mock(PlatformRuntimeStateService.class); lenient().when(speedCalculationService.calculateRealtimeSpeed(anyString(), anyDouble(), anyDouble(), anyLong())) .thenReturn(20.0); @@ -117,7 +120,11 @@ class VehicleStatusUpdateIntegrationTest { ReflectionTestUtils.setField(dataProcessingService, "quapDataAdapter", quapDataAdapter); ReflectionTestUtils.setField(dataProcessingService, "ruleExecutionEngine", ruleExecutionEngine); ReflectionTestUtils.setField(dataProcessingService, "redisCache", redisCache); + ReflectionTestUtils.setField(dataProcessingService, "platformRuntimeStateService", platformRuntimeStateService); dataProcessingService.setActiveMovingObjectsCache(activeMovingObjectsCache); + + lenient().when(platformRuntimeStateService.isRegisteredForCollision(anyString(), any())) + .thenReturn(true); } private UniversalVehicleStatusDTO buildSampleStatus(String vehicleId) { @@ -280,6 +287,40 @@ class VehicleStatusUpdateIntegrationTest { } } + @Test + void testPathConflictDetectionUsesRegisteredObjectsOnly() { + String registeredUvId = "QN001"; + String unregisteredUvId = "QN999"; + + activeMovingObjectsCache.put(registeredUvId, MovingObject.builder() + .objectId(registeredUvId) + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .objectName(registeredUvId) + .currentPosition(geometryFactory.createPoint(new Coordinate(120.0834104, 36.35406879))) + .altitude(0.0) + .build()); + activeMovingObjectsCache.put(unregisteredUvId, MovingObject.builder() + .objectId(unregisteredUvId) + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .objectName(unregisteredUvId) + .currentPosition(geometryFactory.createPoint(new Coordinate(120.0844104, 36.35506879))) + .altitude(0.0) + .build()); + + when(platformRuntimeStateService.isRegisteredForCollision(eq(registeredUvId), eq(MovingObjectType.UNMANNED_VEHICLE))) + .thenReturn(true); + when(platformRuntimeStateService.isRegisteredForCollision(eq(unregisteredUvId), eq(MovingObjectType.UNMANNED_VEHICLE))) + .thenReturn(false); + + dataProcessingService.performPeriodicDataProcessing(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> movingObjectsCaptor = ArgumentCaptor.forClass(List.class); + verify(pathConflictDetectionService).detectPathConflicts(movingObjectsCaptor.capture()); + assertEquals(1, movingObjectsCaptor.getValue().size()); + assertEquals(registeredUvId, movingObjectsCaptor.getValue().get(0).getObjectId()); + } + /** * 测试无活跃车辆情况的处理 * 如果没有活跃车辆,应该跳过处理而不是报错 @@ -380,4 +421,4 @@ class VehicleStatusUpdateIntegrationTest { System.out.println("✓ 缓存操作验证通过"); } -} \ No newline at end of file +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionDirectionalTest.java b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionDirectionalTest.java index 294e6f1..b06d210 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionDirectionalTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionDirectionalTest.java @@ -8,6 +8,7 @@ import com.qaup.collision.pathconflict.model.entity.TransportRoute; import com.qaup.collision.pathconflict.repository.ConflictAlertLogRepository; import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; import com.qaup.collision.pathconflict.repository.TransportRouteRepository; +import com.qaup.collision.service.PlatformRuntimeStateService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.locationtech.jts.geom.Coordinate; @@ -46,6 +47,9 @@ class PathConflictDetectionDirectionalTest { @Mock private CoordinateSystemService coordinateSystemService; + @Mock + private PlatformRuntimeStateService platformRuntimeStateService; + @InjectMocks private PathConflictDetectionService pathConflictDetectionService; @@ -131,4 +135,34 @@ class PathConflictDetectionDirectionalTest { verify(conflictAlertLogRepository, never()).save(any()); verify(eventPublisher, never()).publishEvent(any()); } -} \ No newline at end of file + + @Test + void shouldIgnoreUnmannedVehicleAndSpecialVehiclePairWithoutAircraft() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(); + + MovingObject obj1 = MovingObject.builder() + .objectId("UV-1") + .objectName("UV-1") + .objectType(MovingObjectType.UNMANNED_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(0.0, 0.0))) + .currentSpeed(20.0) + .currentHeading(0.0) + .altitude(0.0) + .build(); + + MovingObject obj2 = MovingObject.builder() + .objectId("SV-1") + .objectName("SV-1") + .objectType(MovingObjectType.SPECIAL_VEHICLE) + .currentPosition(geometryFactory.createPoint(new Coordinate(0.0, 1.0))) + .currentSpeed(20.0) + .currentHeading(180.0) + .altitude(0.0) + .build(); + + pathConflictDetectionService.detectPathConflicts(List.of(obj1, obj2)); + + verify(conflictAlertLogRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionServiceRuntimeConfigTest.java b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionServiceRuntimeConfigTest.java new file mode 100644 index 0000000..b62de07 --- /dev/null +++ b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/PathConflictDetectionServiceRuntimeConfigTest.java @@ -0,0 +1,64 @@ +package com.qaup.collision.pathconflict.service; + +import com.qaup.collision.common.model.MovingObject; +import com.qaup.collision.dataprocessing.service.CoordinateSystemService; +import com.qaup.collision.pathconflict.model.entity.ConflictAlertLog; +import com.qaup.collision.pathconflict.repository.ConflictAlertLogRepository; +import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; +import com.qaup.collision.pathconflict.repository.TransportRouteRepository; +import com.qaup.collision.service.PlatformRuntimeStateService; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +class PathConflictDetectionServiceRuntimeConfigTest { + + @Test + void shouldUseRuntimeThresholdsWhenEvaluatingAlertLevel() { + PlatformRuntimeStateService runtimeStateService = new PlatformRuntimeStateService(200.0, 150.0, 40.0); + PathConflictDetectionService service = new PathConflictDetectionService( + mock(TransportRouteRepository.class), + mock(ObjectRouteAssignmentRepository.class), + mock(ConflictAlertLogRepository.class), + mock(ApplicationEventPublisher.class), + mock(CoordinateSystemService.class), + runtimeStateService + ); + + @SuppressWarnings("unchecked") + Optional beforeUpdate = + (Optional) ReflectionTestUtils.invokeMethod( + service, + "evaluateAlertLevel", + 140.0, + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + 500.0, + MovingObject.MovingObjectType.AIRCRAFT, + 20.0 + ); + + assertEquals(Optional.of(ConflictAlertLog.AlertLevel.CRITICAL), beforeUpdate); + + runtimeStateService.updateRunwayAlertZoneRadiusAircraft(100.0); + runtimeStateService.updateRunwayWarningZoneRadiusAircraft(130.0); + + @SuppressWarnings("unchecked") + Optional afterUpdate = + (Optional) ReflectionTestUtils.invokeMethod( + service, + "evaluateAlertLevel", + 140.0, + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + 500.0, + MovingObject.MovingObjectType.AIRCRAFT, + 20.0 + ); + + assertEquals(Optional.empty(), afterUpdate); + } +} diff --git a/qaup-collision/src/test/java/com/qaup/collision/service/PlatformRuntimeStateServiceTest.java b/qaup-collision/src/test/java/com/qaup/collision/service/PlatformRuntimeStateServiceTest.java new file mode 100644 index 0000000..e046c33 --- /dev/null +++ b/qaup-collision/src/test/java/com/qaup/collision/service/PlatformRuntimeStateServiceTest.java @@ -0,0 +1,72 @@ +package com.qaup.collision.service; + +import com.qaup.collision.common.model.MovingObject; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PlatformRuntimeStateServiceTest { + + @Test + void shouldKeepPreviousVehicleRegistrationsOnIncrementalUpdate() { + PlatformRuntimeStateService service = new PlatformRuntimeStateService(200.0, 100.0, 40.0); + + PlatformRuntimeStateService.VehicleRegistryUpdateResult first = service.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("QN001", PlatformRuntimeStateService.VehicleRegistryType.WUREN), + new PlatformRuntimeStateService.VehicleRegistryEntry("TQ001", PlatformRuntimeStateService.VehicleRegistryType.TEQIN), + new PlatformRuntimeStateService.VehicleRegistryEntry("AC001", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG) + )); + + assertEquals(3, first.updated()); + assertEquals(1, first.controllableCount()); + assertEquals(List.of("QN001"), first.controllableVehicleIDs()); + assertEquals(1, first.typesCount().get("WUREN")); + assertEquals(1, first.typesCount().get("TEQIN")); + assertEquals(1, first.typesCount().get("HANGKONG")); + + PlatformRuntimeStateService.VehicleRegistryUpdateResult second = service.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("QN002", PlatformRuntimeStateService.VehicleRegistryType.WUREN) + )); + + assertEquals(1, second.updated()); + assertEquals(2, second.controllableCount()); + assertEquals(List.of("QN001", "QN002"), second.controllableVehicleIDs()); + } + + @Test + void shouldUpdateRuntimeThresholdValuesInMemory() { + PlatformRuntimeStateService service = new PlatformRuntimeStateService(200.0, 150.0, 40.0); + + assertEquals(200.0, service.updateRunwayWarningZoneRadiusAircraft(300.0)); + assertEquals(300.0, service.getRunwayWarningZoneRadiusAircraft()); + + assertEquals(150.0, service.updateRunwayAlertZoneRadiusAircraft(220.0)); + assertEquals(220.0, service.getRunwayAlertZoneRadiusAircraft()); + + assertEquals(40.0, service.updateCollisionDivergingReleaseDistance(55.0)); + assertEquals(55.0, service.getCollisionDivergingReleaseDistance()); + } + + @Test + void shouldOnlyAllowRegisteredCollisionTypesToParticipate() { + PlatformRuntimeStateService service = new PlatformRuntimeStateService(200.0, 100.0, 40.0); + service.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("QN001", PlatformRuntimeStateService.VehicleRegistryType.WUREN), + new PlatformRuntimeStateService.VehicleRegistryEntry("TQ001", PlatformRuntimeStateService.VehicleRegistryType.TEQIN), + new PlatformRuntimeStateService.VehicleRegistryEntry("AC001", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG), + new PlatformRuntimeStateService.VehicleRegistryEntry("PT001", PlatformRuntimeStateService.VehicleRegistryType.PUTONG) + )); + + assertTrue(service.isRegisteredForCollision("QN001", MovingObject.MovingObjectType.UNMANNED_VEHICLE)); + assertTrue(service.isRegisteredForCollision("TQ001", MovingObject.MovingObjectType.SPECIAL_VEHICLE)); + assertTrue(service.isRegisteredForCollision("AC001", MovingObject.MovingObjectType.AIRCRAFT)); + + assertFalse(service.isRegisteredForCollision("QN001", MovingObject.MovingObjectType.SPECIAL_VEHICLE)); + assertFalse(service.isRegisteredForCollision("PT001", MovingObject.MovingObjectType.NORMAL_VEHICLE)); + assertFalse(service.isRegisteredForCollision("UNKNOWN", MovingObject.MovingObjectType.AIRCRAFT)); + } +} diff --git a/qaup-framework/src/main/java/com/qaup/framework/config/SecurityConfig.java b/qaup-framework/src/main/java/com/qaup/framework/config/SecurityConfig.java index fc438fb..5a0b53a 100644 --- a/qaup-framework/src/main/java/com/qaup/framework/config/SecurityConfig.java +++ b/qaup-framework/src/main/java/com/qaup/framework/config/SecurityConfig.java @@ -120,6 +120,12 @@ public class SecurityConfig requests.requestMatchers("/login", "/register", "/captchaImage").permitAll() // WebSocket端点,允许匿名访问 .requestMatchers("/collision", "/collision/**", "/VehicleCommandInfo", "/test/websocket/**").permitAll() + .requestMatchers( + "/api/VehicleRegistry", + "/config/runway/warning_zone_radius/aircraft", + "/config/runway/alert_zone_radius/aircraft", + "/config/collision/diverging_release_distance" + ).permitAll() // 调试接口:仅在显式开启开关时放行(默认不放行) .requestMatchers(runwayPathPlanningDebugEnabled ? "/debug/runway-path-planning/**" : "/__debug_disabled__").permitAll() // 静态资源,可匿名访问 diff --git a/命令.md b/命令.md index 17c7a28..25ffa01 100644 --- a/命令.md +++ b/命令.md @@ -374,3 +374,7 @@ Date: Sat, 28 Feb 2026 10:56:18 GMT root@root:/home/project_20250804/qaup# echo $TOKEN Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NzIzNjA0NTYsInVzZXJuYW1lIjoiZGlhbnhpbiJ9.kTPxkoFR64eJT7eZOZWSN_ed-qvWbMFqr2WlGofBE60 root@root:/home/project_20250804/qaup# + + + +docker logs --since 5m qaup-app 2>&1 | grep '处理航空器数据并更新缓存' | grep -E '([A-Z]{1,3})?4963\b'