Align collision flow with platform vehicle registration

This commit is contained in:
sladro 2026-03-23 11:48:51 +08:00
parent 190d62cd3f
commit fb649516ee
19 changed files with 2015 additions and 24 deletions

View File

@ -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"

View File

@ -274,4 +274,4 @@ management:
hikari: true
jvm: true
jmx:
enabled: true
enabled: true

View File

@ -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<T>(url: string, body: unknown): Promise<T> {
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<string, number>;
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<VehicleRegistryResponse>(`${API_BASE}/api/VehicleRegistry`, items);
}
export async function updateRunwayWarningRadius(value: number) {
return postJson<ConfigUpdateResponse>(
`${API_BASE}/config/runway/warning_zone_radius/aircraft`,
{ value }
);
}
export async function updateRunwayAlertRadius(value: number) {
return postJson<ConfigUpdateResponse>(
`${API_BASE}/config/runway/alert_zone_radius/aircraft`,
{ value }
);
}
export async function updateDivergingReleaseDistance(value: number) {
return postJson<ConfigUpdateResponse>(
`${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` | 修改冲突解除距离阈值 |

View File

@ -42,6 +42,13 @@
<version>${qaup.version}</version>
</dependency>
<dependency>
<groupId>com.qaup</groupId>
<artifactId>qaup-framework</artifactId>
<version>${qaup.version}</version>
<scope>test</scope>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@ -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<Map<String, Object>> registerVehicles(@RequestBody String requestBody) {
try {
JsonNode root = objectMapper.readTree(requestBody);
if (!root.isArray()) {
return error(HttpStatus.BAD_REQUEST, "Body must be an array");
}
List<String> errors = new ArrayList<>();
List<PlatformRuntimeStateService.VehicleRegistryEntry> entries = new ArrayList<>();
for (JsonNode item : root) {
validateVehicleRegistryItem(item, errors, entries);
}
if (!errors.isEmpty()) {
Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> updateCollisionDivergingReleaseDistance(@RequestBody String requestBody) {
return updateNumericConfig(requestBody, "collision", "collision.diverging_release_distance",
platformRuntimeStateService::updateCollisionDivergingReleaseDistance);
}
private void validateVehicleRegistryItem(
JsonNode item,
List<String> errors,
List<PlatformRuntimeStateService.VehicleRegistryEntry> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> invalidJson(JsonProcessingException e) {
Map<String, Object> 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<Map<String, Object>> invalidFieldType(String detail) {
Map<String, Object> 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<Map<String, Object>> error(HttpStatus status, String message) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("status", "error");
payload.put("message", message);
return ResponseEntity.status(status).contentType(MediaType.APPLICATION_JSON).body(payload);
}
private ResponseEntity<Map<String, Object>> internalError(Exception e) {
Map<String, Object> 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);
}
}

View File

@ -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;
}
/**
* 通用车辆状态缓存条目

View File

@ -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<MovingObject> 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<TransportRoute> 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<ObjectRouteAssignment> 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<ObjectRouteAssignment> 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<Coordinate> 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;
}
}

View File

@ -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<String, MovingObject> activeMovingObjectsCache;
@ -147,18 +155,43 @@ public class DataProcessingService {
// 注意采集侧只缓存发送侧在处理完成后会清理缓存避免同一通知被每秒重复推送刷屏
processFlightNotificationUpdates();
// 第五步执行路径冲突检测
pathConflictDetectionService.detectPathConflicts(currentActiveObjects);
List<MovingObject> collisionManagedObjects = filterCollisionManagedObjects(currentActiveObjects);
// 第六步执行违规检测
// 第五步将注册的无人车任务路径准备为正式路线输入
if (routePreparationService != null) {
routePreparationService.synchronizeRoutes(collisionManagedObjects);
}
// 第六步执行路径冲突检测
pathConflictDetectionService.detectPathConflicts(collisionManagedObjects);
// 第七步执行违规检测
performViolationDetection(currentActiveObjects);
// 第七步保存无人车数据到数据库
// 保存无人车数据到数据库
saveUnmannedVehicleDataPeriodically(currentActiveObjects);
log.info("周期性数据处理完成");
}
private List<MovingObject> filterCollisionManagedObjects(List<MovingObject> activeObjects) {
if (activeObjects == null || activeObjects.isEmpty()) {
return List.of();
}
List<MovingObject> 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;
}
/**
* 为所有缓存对象计算速度和方向
*/

View File

@ -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<ConflictAlertEvent> 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; }
}
}
}

View File

@ -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<String, VehicleRegistryType> 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<VehicleRegistryEntry> entries) {
Objects.requireNonNull(entries, "entries");
EnumMap<VehicleRegistryType, Integer> requestTypeCounts = new EnumMap<>(VehicleRegistryType.class);
for (VehicleRegistryEntry entry : entries) {
vehicleTypes.put(entry.vehicleID(), entry.vehicleType());
requestTypeCounts.merge(entry.vehicleType(), 1, Integer::sum);
}
Set<String> controllableVehicleIds = new TreeSet<>();
for (Map.Entry<String, VehicleRegistryType> entry : vehicleTypes.entrySet()) {
if (entry.getValue() == VehicleRegistryType.WUREN) {
controllableVehicleIds.add(entry.getKey());
}
}
Map<String, Integer> 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<String> getControllableVehicleIds() {
TreeSet<String> vehicleIds = new TreeSet<>();
for (Map.Entry<String, VehicleRegistryType> 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<String, Integer> typesCount,
List<String> controllableVehicleIDs) {
}
public enum VehicleRegistryType {
WUREN,
TEQIN,
HANGKONG,
PUTONG,
JIUYUAN
}
}

View File

@ -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"));
}
}

View File

@ -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));
}
}

View File

@ -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<TransportRoute> 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<ObjectRouteAssignment> 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());
}
}

View File

@ -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<List<MovingObject>> 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("✓ 缓存操作验证通过");
}
}
}

View File

@ -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());
}
}
@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());
}
}

View File

@ -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<ConflictAlertLog.AlertLevel> beforeUpdate =
(Optional<ConflictAlertLog.AlertLevel>) 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<ConflictAlertLog.AlertLevel> afterUpdate =
(Optional<ConflictAlertLog.AlertLevel>) ReflectionTestUtils.invokeMethod(
service,
"evaluateAlertLevel",
140.0,
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
500.0,
MovingObject.MovingObjectType.AIRCRAFT,
20.0
);
assertEquals(Optional.empty(), afterUpdate);
}
}

View File

@ -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));
}
}

View File

@ -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()
// 静态资源可匿名访问

View File

@ -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'