diff --git a/doc/frontend_platform_http_api_integration.md b/doc/frontend_platform_http_api_integration.md index 6243af2..cbf411f 100644 --- a/doc/frontend_platform_http_api_integration.md +++ b/doc/frontend_platform_http_api_integration.md @@ -16,8 +16,8 @@ - 如果前端是浏览器并且跨域访问,后续可能还需要后端补 CORS - 配置修改仅在内存中生效 - 服务重启后恢复默认配置 -- `VehicleRegistry` 是“增量更新” - - 本次未传入的历史对象不会被清空 +- `VehicleRegistry` 是“全量覆盖更新” + - 每次请求都会覆盖当前运行时注册表,未传入的历史对象会被清空 建议前端统一配置接口基础地址,例如: @@ -35,7 +35,7 @@ POST /api/VehicleRegistry ### 3.2 用途 -前端将“当前关心的对象及其类型”增量同步给后端。 +前端将“当前关心的对象及其类型”整体同步给后端。 该接口会直接影响: @@ -82,8 +82,8 @@ Content-Type: application/json #### `TEQIN` +- 加入“可控车辆集合” - 加入“受管车辆集合” -- 不属于“可控车辆集合” #### `HANGKONG` @@ -107,13 +107,13 @@ Content-Type: application/json "status": "success", "updatedAt": 1742280000000, "updated": 3, - "controllableCount": 1, + "controllableCount": 2, "typesCount": { "WUREN": 1, "TEQIN": 1, "HANGKONG": 1 }, - "controllableVehicleIDs": ["QN001"] + "controllableVehicleIDs": ["QN001", "TQ001"] } ``` @@ -124,9 +124,9 @@ Content-Type: application/json | `status` | `string` | 固定为 `success` | | `updatedAt` | `number` | 毫秒时间戳 | | `updated` | `number` | 本次成功处理的条目数 | -| `controllableCount` | `number` | 当前系统内全部 `WUREN` 总数 | +| `controllableCount` | `number` | 当前系统内全部可控车辆总数,目前包含 `WUREN` 和 `TEQIN` | | `typesCount` | `object` | 本次请求内各类型数量统计 | -| `controllableVehicleIDs` | `string[]` | 当前系统内全部可控车辆 ID 列表,即所有 `WUREN` | +| `controllableVehicleIDs` | `string[]` | 当前系统内全部可控车辆 ID 列表,目前包含所有 `WUREN` 和 `TEQIN` | ### 3.10 错误响应示例 diff --git a/qaup-collision/src/main/java/com/qaup/collision/datacollector/model/dto/ExternalVehicleCommandRequest.java b/qaup-collision/src/main/java/com/qaup/collision/datacollector/model/dto/ExternalVehicleCommandRequest.java new file mode 100644 index 0000000..e7d2b44 --- /dev/null +++ b/qaup-collision/src/main/java/com/qaup/collision/datacollector/model/dto/ExternalVehicleCommandRequest.java @@ -0,0 +1,33 @@ +package com.qaup.collision.datacollector.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.qaup.collision.datacollector.model.enums.CommandReason; +import com.qaup.collision.datacollector.model.enums.CommandType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExternalVehicleCommandRequest { + + private String transId; + private long timestamp; + + @JsonProperty("vehicleID") + private String vehicleId; + + private CommandType commandType; + private CommandReason commandReason; + private double latitude; + private double longitude; + private String signalState; + private String intersectionId; + private double relativeSpeed; + private double relativeMotionX; + private double relativeMotionY; + private double minDistance; +} 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 04662bd..878a79c 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 @@ -209,7 +209,11 @@ public class DataCollectorService { private void initVehicleManagerWebSocketClient() { if (vehicleManagerProperties == null || !vehicleManagerProperties.isWsEnabled()) { - log.info("车辆管理 WebSocket 已禁用(仅使用HTTP拉取无人车数据)"); + log.info("车辆管理 WebSocket 已禁用(当前仅使用 HTTP 采集无人车状态)"); + return; + } + if (!vehicleManagerProperties.isWsConfigurationReady()) { + log.warn("车辆管理 WebSocket 配置不完整,已回退到 HTTP 采集无人车状态"); return; } if (vehicleManagerWebSocketClient != null) { @@ -289,6 +293,9 @@ public class DataCollectorService { } private void handleVehicleManagerMessage(JsonNode message) { + if (vehicleManagerProperties == null || !vehicleManagerProperties.isWsEnabled() || !vehicleManagerProperties.isWsConfigurationReady()) { + return; + } if (message == null || message.isNull()) { return; } @@ -298,7 +305,6 @@ public class DataCollectorService { } return; } - String messageName = getText(message, "messageName"); JsonNode dataNode = message; diff --git a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListener.java b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListener.java index 3d75cff..8e1c811 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListener.java +++ b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/event/ConflictAlertEventListener.java @@ -1,13 +1,9 @@ package com.qaup.collision.pathconflict.event; import com.qaup.collision.pathconflict.model.dto.ConflictAlertEvent; -import com.qaup.collision.pathconflict.service.VehicleCommandService; +import com.qaup.collision.pathconflict.model.entity.ConflictAlertLog; import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher; import com.qaup.collision.websocket.message.PathConflictAlertMessage; -import com.qaup.collision.pathconflict.model.entity.ObjectRouteAssignment; -import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository; -import com.qaup.collision.pathconflict.model.entity.ConflictAlertLog; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; @@ -17,63 +13,47 @@ import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.math.RoundingMode; -/** - * 冲突告警事件监听器 - * 负责处理路径冲突告警事件,发送WebSocket消息和车辆控制指令 - * - * @author AI Assistant - * @version 1.0 - * @since 2025-01-17 - */ @Slf4j @Component @RequiredArgsConstructor public class ConflictAlertEventListener { - + private final RuleEventWebSocketPublisher webSocketPublisher; - private final VehicleCommandService vehicleCommandService; - private final ObjectRouteAssignmentRepository objectRouteAssignmentRepository; - - /** - * 处理冲突告警事件 - */ + @Async @EventListener public void handleConflictAlert(ConflictAlertEvent event) { - log.info("处理路径冲突告警事件: conflictId={}, alertType={}, alertLevel={}", - event.getConflictId().map(String::valueOf).orElse("N/A"), - event.getAlertType().map(Enum::name).orElse("N/A"), - event.getAlertLevel().map(Enum::name).orElse("N/A")); - - // 只有当告警级别为 WARNING, CRITICAL, EMERGENCY 时才发送给前端和车辆指令 - if (event.getAlertLevel().isPresent() && - (event.getAlertLevel().get() == ConflictAlertLog.AlertLevel.WARNING || - event.getAlertLevel().get() == ConflictAlertLog.AlertLevel.CRITICAL || - event.getAlertLevel().get() == ConflictAlertLog.AlertLevel.EMERGENCY)) { + log.info( + "Handling path conflict alert event: conflictId={}, alertType={}, alertLevel={}", + event.getConflictId().map(String::valueOf).orElse("N/A"), + event.getAlertType().map(Enum::name).orElse("N/A"), + event.getAlertLevel().map(Enum::name).orElse("N/A") + ); + + if (event.getAlertLevel().isPresent() + && (event.getAlertLevel().get() == ConflictAlertLog.AlertLevel.WARNING + || event.getAlertLevel().get() == ConflictAlertLog.AlertLevel.CRITICAL + || event.getAlertLevel().get() == ConflictAlertLog.AlertLevel.EMERGENCY)) { try { - // 发送WebSocket消息到控制台 sendWebSocketAlert(event); - - // 向涉及的无人车发送控制指令 - sendVehicleCommands(event); - } catch (Exception e) { - log.error("处理冲突告警事件失败: conflictId={}", event.getConflictId().orElse(null), e); + log.error("Failed to handle conflict alert event: conflictId={}", event.getConflictId().orElse(null), e); } } else { - log.debug("告警级别不符合发送条件,不发送WebSocket告警和车辆指令: conflictId={}, alertLevel={}", event.getConflictId().orElse(null), event.getAlertLevel().orElse(null)); + log.debug( + "Skipping WebSocket alert because alert level is not actionable: conflictId={}, alertLevel={}", + event.getConflictId().orElse(null), + event.getAlertLevel().orElse(null) + ); } } - - /** - * 发送WebSocket告警消息到控制台 - */ + private void sendWebSocketAlert(ConflictAlertEvent event) { try { PathConflictAlertMessage message = PathConflictAlertMessage.builder() .conflictId(event.getConflictId().orElse(null)) - .alertType(event.getAlertType().orElse(null)) // 直接传递 Optional 里的枚举对象 - .alertLevel(event.getAlertLevel().orElse(null)) // 直接传递 Optional 里的枚举对象 + .alertType(event.getAlertType().orElse(null)) + .alertLevel(event.getAlertLevel().orElse(null)) .message(event.getMessage()) .object1(PathConflictAlertMessage.ConflictObject.builder() .objectName(event.getObject1Name()) @@ -83,94 +63,24 @@ public class ConflictAlertEventListener { .objectName(event.getObject2Name()) .objectType(event.getObject2Type().name()) .build()) - .position(event.getConflictPointLatitude() != null && event.getConflictPointLongitude() != null ? - PathConflictAlertMessage.Position.builder() + .position(event.getConflictPointLatitude() != null && event.getConflictPointLongitude() != null + ? PathConflictAlertMessage.Position.builder() .latitude(event.getConflictPointLatitude()) .longitude(event.getConflictPointLongitude()) - .build() : null) - .object1Distance(new BigDecimal(event.getObject1Distance()).setScale(2, RoundingMode.HALF_UP).doubleValue()) // 内联四舍五入 - .object2Distance(new BigDecimal(event.getObject2Distance()).setScale(2, RoundingMode.HALF_UP).doubleValue()) // 内联四舍五入 - .timeToConflict1(event.getEstimatedTimeToConflictObj1()) // 新增 - .timeToConflict2(event.getEstimatedTimeToConflictObj2()) // 新增 - .timeGap(event.getTimeGapSeconds()) // 新增 + .build() + : null) + .object1Distance(new BigDecimal(event.getObject1Distance()).setScale(2, RoundingMode.HALF_UP).doubleValue()) + .object2Distance(new BigDecimal(event.getObject2Distance()).setScale(2, RoundingMode.HALF_UP).doubleValue()) + .timeToConflict1(event.getEstimatedTimeToConflictObj1()) + .timeToConflict2(event.getEstimatedTimeToConflictObj2()) + .timeGap(event.getTimeGapSeconds()) .eventTime(event.getEventTime()) .build(); - - // 发送WebSocket消息 + webSocketPublisher.publishPathConflictAlert(message); - - log.info("WebSocket告警消息已发送: conflictId={}", event.getConflictId().map(String::valueOf).orElse(null)); - + log.info("WebSocket path conflict alert sent: conflictId={}", event.getConflictId().map(String::valueOf).orElse(null)); } catch (Exception e) { - log.error("发送WebSocket告警消息失败: conflictId={}", event.getConflictId().map(String::valueOf).orElse(null), e); + log.error("Failed to send WebSocket path conflict alert: conflictId={}", event.getConflictId().map(String::valueOf).orElse(null), e); } } - - /** - * 向涉及的无人车发送控制指令 - */ - private void sendVehicleCommands(ConflictAlertEvent event) { - try { - // 向对象1发送指令(如果是无人车) - objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc( - event.getObject1Name(), ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) - .ifPresent(assignment -> sendVehicleCommand(event.getObject1Name(), event)); - - // 向对象2发送指令(如果是无人车) - objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc( - event.getObject2Name(), ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE) - .ifPresent(assignment -> sendVehicleCommand(event.getObject2Name(), event)); - - } catch (Exception e) { - log.error("发送车辆控制指令失败: conflictId={}", event.getConflictId().orElse(null), e); - } - } - - /** - * 向指定无人车发送控制指令 - */ - private void sendVehicleCommand(String vehicleId, ConflictAlertEvent event) { - try { - String commandType; - String commandMessage; - - // 根据告警级别确定指令类型 - if (event.isEmergencyAlert()) { - commandType = "EMERGENCY_STOP"; - commandMessage = String.format("紧急告警:检测到路径冲突,立即停车!对象1距离冲突点:%.1f米,对象2距离冲突点:%.1f米,时间差:%.1f秒", - event.getObject1Distance(), event.getObject2Distance(), event.getTimeGapSeconds()); - } else if (event.isAlert()) { - commandType = "SLOW_DOWN"; - commandMessage = String.format("路径冲突告警:前方对象1预计%d秒,对象2预计%d秒到达冲突点,请减速行驶,时间差:%.1f秒", - event.getEstimatedTimeToConflictObj1(), event.getEstimatedTimeToConflictObj2(), event.getTimeGapSeconds()); - } else if (event.isWarning()) { - commandType = "CAUTION"; - commandMessage = String.format("路径冲突预警:前方检测到潜在冲突,请注意观察。对象1距离冲突点:%.1f米,对象2距离冲突点:%.1f米", - event.getObject1Distance(), event.getObject2Distance()); - } else { - // 如果是非警告、非关键、非紧急的告警(即旧的INFO级别),则不发送指令。 - // 在PathConflictDetectionService中已经过滤了INFO级别的事件生成, - // 所以理论上此处不会进入此分支,但作为防御性编程,避免意外情况。 - log.warn("收到非WARNING/CRITICAL/EMERGENCY级别的告警事件,不发送车辆指令: conflictId={}, alertLevel={}", - event.getConflictId().orElse(null), event.getAlertLevel().orElse(null)); - return; // 不发送指令 - } - - // 发送指令到无人车 - boolean success = vehicleCommandService.sendCommand( - vehicleId, commandType, commandMessage, event.getConflictId().map(String::valueOf).orElse(null)); - - if (success) { - log.info("车辆控制指令已发送: vehicleId={}, commandType={}, conflictId={}", - vehicleId, commandType, event.getConflictId().map(String::valueOf).orElse(null)); - } else { - log.warn("车辆控制指令发送失败: vehicleId={}, conflictId={}", - vehicleId, event.getConflictId().map(String::valueOf).orElse(null)); - } - - } catch (Exception e) { - log.error("发送车辆控制指令时出错: vehicleId={}, conflictId={}", - vehicleId, event.getConflictId().map(String::valueOf).orElse(null), e); - } - } -} \ No newline at end of file +} 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 5f33f6e..e99e68d 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 @@ -41,6 +41,7 @@ public class PathConflictDetectionService { private final ApplicationEventPublisher eventPublisher; private final CoordinateSystemService coordinateSystemService; private final PlatformRuntimeStateService platformRuntimeStateService; + private final VehicleCommandService vehicleCommandService; private static final int MAX_PREDICTION_TIME_SECONDS = 300; private static final double MIN_TIME_GAP_SECONDS = 30.0; @@ -72,6 +73,12 @@ public class PathConflictDetectionService { event.getConflictId(), event.getAlertType(), event.getAlertLevel()); } + try { + vehicleCommandService.synchronizeConflictCommands(detectedAlertEvents); + } catch (Exception e) { + log.error("Failed to synchronize vehicle commands for conflict events", e); + } + log.info("Path conflict detection completed, {} events published", detectedAlertEvents.size()); } diff --git a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/VehicleCommandService.java b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/VehicleCommandService.java index bb831a0..036663f 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/VehicleCommandService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/pathconflict/service/VehicleCommandService.java @@ -1,190 +1,358 @@ package com.qaup.collision.pathconflict.service; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qaup.collision.common.model.MovingObject.MovingObjectType; +import com.qaup.collision.datacollector.model.dto.ExternalVehicleCommandRequest; +import com.qaup.collision.datacollector.model.enums.CommandReason; +import com.qaup.collision.datacollector.model.enums.CommandType; +import com.qaup.collision.pathconflict.model.dto.ConflictAlertEvent; +import com.qaup.collision.service.PlatformRuntimeStateService; +import com.qaup.collision.service.PlatformRuntimeStateService.VehicleRegistryType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.Point; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; -import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; -/** - * 车辆指令服务 - * 负责向无人车发送各种控制指令 - * - * @author AI Assistant - * @version 1.0 - * @since 2025-01-17 - */ @Slf4j @Service @RequiredArgsConstructor public class VehicleCommandService { - - /** - * 向指定车辆发送控制指令 - * - * @param vehicleId 车辆ID - * @param commandType 指令类型 (EMERGENCY_STOP, SLOW_DOWN, CAUTION, INFO) - * @param message 指令消息 - * @param conflictId 关联的冲突ID - * @return 发送成功返回true - */ - public boolean sendCommand(String vehicleId, String commandType, String message, String conflictId) { - try { - log.info("向车辆发送指令: vehicleId={}, commandType={}, message={}, conflictId={}", - vehicleId, commandType, message, conflictId); - - // TODO: 实际实现时需要根据车辆通信协议发送指令 - // 这里可能包括: - // 1. HTTP接口调用 - // 2. MQTT消息发送 - // 3. WebSocket推送 - // 4. CAN总线通信 - // 5. 其他车联网协议 - - VehicleCommand command = VehicleCommand.builder() - .vehicleId(vehicleId) - .commandType(commandType) - .message(message) - .conflictId(conflictId) - .timestamp(LocalDateTime.now()) - .priority(getCommandPriority(commandType)) - .build(); - - // 模拟发送指令的过程 - boolean success = sendCommandToVehicle(command); - - if (success) { - log.info("车辆指令发送成功: vehicleId={}, commandType={}", vehicleId, commandType); - // 可以在这里记录指令发送日志到数据库 - recordCommandLog(command, true, null); - } else { - log.warn("车辆指令发送失败: vehicleId={}, commandType={}", vehicleId, commandType); - recordCommandLog(command, false, "发送失败"); + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final PlatformRuntimeStateService platformRuntimeStateService; + + private final Map activeCommandStates = new ConcurrentHashMap<>(); + private final AtomicLong transIdSequence = new AtomicLong(); + + @Value("${data.collector.vehicle-api.base-url:}") + private String vehicleApiBaseUrl; + + @Value("${data.collector.vehicle-api.endpoints.vehicle-command:}") + private String vehicleCommandEndpoint; + + public void synchronizeConflictCommands(List activeConflicts) { + Map currentCommands = new LinkedHashMap<>(); + for (ConflictAlertEvent event : activeConflicts) { + VehicleConflictCommand candidate = toVehicleConflictCommand(event); + if (candidate == null) { + continue; } - - return success; - - } catch (Exception e) { - log.error("发送车辆指令时出错: vehicleId={}, commandType={}", vehicleId, commandType, e); - recordCommandLog(VehicleCommand.builder() - .vehicleId(vehicleId) + + currentCommands.merge( + candidate.vehicleId(), + candidate, + (left, right) -> left.level().priority() >= right.level().priority() ? left : right + ); + } + + for (VehicleConflictCommand command : currentCommands.values()) { + synchronizeVehicleCommand(command); + } + + List trackedVehicleIds = new ArrayList<>(activeCommandStates.keySet()); + for (String vehicleId : trackedVehicleIds) { + if (!currentCommands.containsKey(vehicleId)) { + sendResumeIfNeeded(vehicleId); + } + } + } + + private void synchronizeVehicleCommand(VehicleConflictCommand currentCommand) { + ActiveVehicleCommandState previousState = activeCommandStates.get(currentCommand.vehicleId()); + if (previousState == null) { + issueInitialRiskCommand(currentCommand); + return; + } + + // De-escalation is intentionally handled by a separate recovery flow. WARNING follow-up behavior is not implemented here yet. + if (previousState.level() == VehicleRiskLevel.WARNING && currentCommand.level() == VehicleRiskLevel.ALERT) { + issueAlertAndParking(currentCommand); + return; + } + + if (previousState.level() == VehicleRiskLevel.ALERT + && !previousState.parkingIssued() + && currentCommand.level() == VehicleRiskLevel.ALERT) { + + boolean parkingIssued = sendCommand(currentCommand, CommandType.PARKING, CommandReason.AIRCRAFT_CROSSING); + if (parkingIssued) { + activeCommandStates.put( + currentCommand.vehicleId(), + new ActiveVehicleCommandState(VehicleRiskLevel.ALERT, true) + ); + } + } + } + + private void issueInitialRiskCommand(VehicleConflictCommand command) { + if (command.level() == VehicleRiskLevel.WARNING) { + if (sendCommand(command, CommandType.WARNING, CommandReason.AIRCRAFT_CROSSING)) { + activeCommandStates.put(command.vehicleId(), new ActiveVehicleCommandState(VehicleRiskLevel.WARNING, true)); + } + return; + } + + issueAlertAndParking(command); + } + + private void issueAlertAndParking(VehicleConflictCommand command) { + boolean alertIssued = sendCommand(command, CommandType.ALERT, CommandReason.AIRCRAFT_CROSSING); + if (!alertIssued) { + return; + } + + boolean parkingIssued = sendCommand(command, CommandType.PARKING, CommandReason.AIRCRAFT_CROSSING); + activeCommandStates.put(command.vehicleId(), new ActiveVehicleCommandState(VehicleRiskLevel.ALERT, parkingIssued)); + } + + private void sendResumeIfNeeded(String vehicleId) { + ActiveVehicleCommandState previousState = activeCommandStates.get(vehicleId); + if (previousState == null) { + return; + } + + VehicleConflictCommand resumeContext = new VehicleConflictCommand( + vehicleId, + previousState.level(), + null, + null, + resolveRegistryType(vehicleId) + ); + + boolean resumeIssued = sendCommand(resumeContext, CommandType.RESUME, CommandReason.RESUME_TRAFFIC); + if (resumeIssued) { + activeCommandStates.remove(vehicleId); + } + } + + private VehicleConflictCommand toVehicleConflictCommand(ConflictAlertEvent event) { + if (event == null) { + return null; + } + + VehicleRiskLevel level = toRiskLevel(event); + if (level == null) { + return null; + } + + VehicleConflictCommand leftCandidate = toVehicleConflictCommand( + event.getObject1Name(), + event.getObject1Type(), + level, + event.getConflictPoint() + ); + if (leftCandidate != null) { + return leftCandidate; + } + + return toVehicleConflictCommand( + event.getObject2Name(), + event.getObject2Type(), + level, + event.getConflictPoint() + ); + } + + private VehicleConflictCommand toVehicleConflictCommand( + String vehicleId, + MovingObjectType objectType, + VehicleRiskLevel level, + Point conflictPoint) { + + if (vehicleId == null || vehicleId.isBlank()) { + return null; + } + + VehicleRegistryType registryType = resolveRegistryType(vehicleId); + if (registryType != VehicleRegistryType.WUREN && registryType != VehicleRegistryType.TEQIN) { + return null; + } + + if (registryType == VehicleRegistryType.WUREN && objectType != MovingObjectType.UNMANNED_VEHICLE) { + return null; + } + if (registryType == VehicleRegistryType.TEQIN && objectType != MovingObjectType.SPECIAL_VEHICLE) { + return null; + } + + if (conflictPoint == null) { + log.warn("Skipping vehicle command because conflict point is missing: vehicleId={}", vehicleId); + return null; + } + + return new VehicleConflictCommand(vehicleId, level, conflictPoint, objectType, registryType); + } + + private VehicleRiskLevel toRiskLevel(ConflictAlertEvent event) { + if (event.isAlert() || event.isEmergencyAlert()) { + return VehicleRiskLevel.ALERT; + } + if (event.isWarning()) { + return VehicleRiskLevel.WARNING; + } + return null; + } + + private boolean sendCommand( + VehicleConflictCommand command, + CommandType commandType, + CommandReason commandReason) { + + if (!isCommandEndpointConfigured()) { + log.warn("Skipping vehicle command because vehicle API endpoint is not configured: vehicleId={}", command.vehicleId()); + return false; + } + + try { + ExternalVehicleCommandRequest request = ExternalVehicleCommandRequest.builder() + .transId(nextTransId()) + .timestamp(System.currentTimeMillis()) + .vehicleId(command.vehicleId()) .commandType(commandType) - .message(message) - .conflictId(conflictId) - .build(), false, e.getMessage()); + .commandReason(commandReason) + .latitude(command.conflictPoint() != null ? command.conflictPoint().getY() : 0.0) + .longitude(command.conflictPoint() != null ? command.conflictPoint().getX() : 0.0) + .signalState("") + .intersectionId("") + .relativeSpeed(0.0) + .relativeMotionX(0.0) + .relativeMotionY(0.0) + .minDistance(0.0) + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = restTemplate.exchange( + vehicleApiBaseUrl + vehicleCommandEndpoint, + HttpMethod.POST, + new HttpEntity<>(request, headers), + String.class + ); + + if (response.getStatusCode().value() != 200) { + log.warn( + "Vehicle command rejected by HTTP status: vehicleId={}, commandType={}, status={}", + command.vehicleId(), + commandType, + response.getStatusCode().value() + ); + return false; + } + + JsonNode responseBody = parseResponseBody(response.getBody()); + if (responseBody == null || !responseBody.has("code") || !responseBody.has("msg")) { + log.warn( + "Vehicle command response missing required fields: vehicleId={}, commandType={}, body={}", + command.vehicleId(), + commandType, + response.getBody() + ); + return false; + } + + int responseCode = responseBody.path("code").asInt(Integer.MIN_VALUE); + if (responseCode != 200) { + log.warn( + "Vehicle command rejected by upstream response code: vehicleId={}, commandType={}, code={}, msg={}", + command.vehicleId(), + commandType, + responseCode, + responseBody.path("msg").asText() + ); + return false; + } + + log.info( + "Vehicle command sent successfully: vehicleId={}, registryType={}, commandType={}, reason={}", + command.vehicleId(), + command.registryType(), + commandType, + commandReason + ); + return true; + } catch (Exception e) { + log.error( + "Failed to send vehicle command: vehicleId={}, commandType={}, reason={}", + command.vehicleId(), + commandType, + commandReason, + e + ); return false; } } - - /** - * 实际发送指令到车辆 - * TODO: 这里需要根据实际的车辆通信方式实现 - */ - private boolean sendCommandToVehicle(VehicleCommand command) { - // 根据指令类型选择不同的发送策略 - switch (command.getCommandType()) { - case "EMERGENCY_STOP": - return sendEmergencyStopCommand(command); - case "SLOW_DOWN": - return sendSlowDownCommand(command); - case "CAUTION": - return sendCautionCommand(command); - case "INFO": - return sendInfoCommand(command); - default: - log.warn("未知的指令类型: {}", command.getCommandType()); - return false; + + private JsonNode parseResponseBody(String body) { + if (body == null || body.isBlank()) { + return null; + } + + try { + return objectMapper.readTree(body); + } catch (Exception e) { + log.warn("Failed to parse vehicle command response body: {}", body, e); + return null; } } - - /** - * 发送紧急停车指令 - */ - private boolean sendEmergencyStopCommand(VehicleCommand command) { - // TODO: 实现紧急停车指令发送 - // 这通常需要最高优先级和最快的通信方式 - log.info("发送紧急停车指令: vehicleId={}", command.getVehicleId()); - - // 模拟发送成功 - return true; + + private boolean isCommandEndpointConfigured() { + return vehicleApiBaseUrl != null + && !vehicleApiBaseUrl.isBlank() + && vehicleCommandEndpoint != null + && !vehicleCommandEndpoint.isBlank(); } - - /** - * 发送减速指令 - */ - private boolean sendSlowDownCommand(VehicleCommand command) { - // TODO: 实现减速指令发送 - log.info("发送减速指令: vehicleId={}", command.getVehicleId()); - - // 模拟发送成功 - return true; + + private VehicleRegistryType resolveRegistryType(String vehicleId) { + return platformRuntimeStateService.getVehicleRegistryType(vehicleId); } - - /** - * 发送注意指令 - */ - private boolean sendCautionCommand(VehicleCommand command) { - // TODO: 实现注意指令发送 - log.info("发送注意指令: vehicleId={}", command.getVehicleId()); - - // 模拟发送成功 - return true; + + private String nextTransId() { + long serialValue = System.currentTimeMillis() * 1000 + Math.floorMod(transIdSequence.incrementAndGet(), 1000); + return Long.toHexString(serialValue); } - - /** - * 发送信息指令 - */ - private boolean sendInfoCommand(VehicleCommand command) { - // TODO: 实现信息指令发送 - log.info("发送信息指令: vehicleId={}", command.getVehicleId()); - - // 模拟发送成功 - return true; + + private record VehicleConflictCommand( + String vehicleId, + VehicleRiskLevel level, + Point conflictPoint, + MovingObjectType objectType, + VehicleRegistryType registryType) { } - - /** - * 获取指令优先级 - */ - private int getCommandPriority(String commandType) { - switch (commandType) { - case "EMERGENCY_STOP": - return 1; // 最高优先级 - case "SLOW_DOWN": - return 2; - case "CAUTION": - return 3; - case "INFO": - return 4; // 最低优先级 - default: - return 5; + + private record ActiveVehicleCommandState( + VehicleRiskLevel level, + boolean parkingIssued) { + } + + private enum VehicleRiskLevel { + WARNING(1), + ALERT(2); + + private final int priority; + + VehicleRiskLevel(int priority) { + this.priority = priority; + } + + public int priority() { + return priority; } } - - /** - * 记录指令发送日志 - * TODO: 实际实现时可以保存到数据库 - */ - private void recordCommandLog(VehicleCommand command, boolean success, String errorMessage) { - log.debug("记录指令日志: vehicleId={}, commandType={}, success={}, error={}", - command.getVehicleId(), command.getCommandType(), success, errorMessage); - - // 这里可以保存到数据库中的指令日志表 - // 包括发送时间、成功状态、错误信息等 - } - - /** - * 车辆指令内部类 - */ - @lombok.Data - @lombok.Builder - @lombok.NoArgsConstructor - @lombok.AllArgsConstructor - public static class VehicleCommand { - private String vehicleId; - private String commandType; - private String message; - private String conflictId; - private LocalDateTime timestamp; - private int priority; - } -} \ 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 index ba9a76c..af10bc7 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/service/PlatformRuntimeStateService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/service/PlatformRuntimeStateService.java @@ -37,6 +37,8 @@ public class PlatformRuntimeStateService { public VehicleRegistryUpdateResult updateVehicleRegistry(List entries) { Objects.requireNonNull(entries, "entries"); + vehicleTypes.clear(); + EnumMap requestTypeCounts = new EnumMap<>(VehicleRegistryType.class); for (VehicleRegistryEntry entry : entries) { vehicleTypes.put(entry.vehicleID(), entry.vehicleType()); @@ -45,7 +47,7 @@ public class PlatformRuntimeStateService { Set controllableVehicleIds = new TreeSet<>(); for (Map.Entry entry : vehicleTypes.entrySet()) { - if (entry.getValue() == VehicleRegistryType.WUREN) { + if (entry.getValue() == VehicleRegistryType.WUREN || entry.getValue() == VehicleRegistryType.TEQIN) { controllableVehicleIds.add(entry.getKey()); } } @@ -69,7 +71,17 @@ public class PlatformRuntimeStateService { public List getControllableVehicleIds() { TreeSet vehicleIds = new TreeSet<>(); for (Map.Entry entry : vehicleTypes.entrySet()) { - if (entry.getValue() == VehicleRegistryType.WUREN) { + if (entry.getValue() == VehicleRegistryType.WUREN || entry.getValue() == VehicleRegistryType.TEQIN) { + vehicleIds.add(entry.getKey()); + } + } + return Collections.unmodifiableList(new ArrayList<>(vehicleIds)); + } + + public List getTrafficLightRecipientVehicleIds() { + TreeSet vehicleIds = new TreeSet<>(); + for (Map.Entry entry : vehicleTypes.entrySet()) { + if (entry.getValue() == VehicleRegistryType.WUREN || entry.getValue() == VehicleRegistryType.TEQIN) { vehicleIds.add(entry.getKey()); } } diff --git a/qaup-collision/src/main/java/com/qaup/collision/websocket/handler/VehicleCommandInfoWebSocketHandler.java b/qaup-collision/src/main/java/com/qaup/collision/websocket/handler/VehicleCommandInfoWebSocketHandler.java index b524755..803472a 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/websocket/handler/VehicleCommandInfoWebSocketHandler.java +++ b/qaup-collision/src/main/java/com/qaup/collision/websocket/handler/VehicleCommandInfoWebSocketHandler.java @@ -1,5 +1,6 @@ package com.qaup.collision.websocket.handler; +import com.qaup.collision.service.PlatformRuntimeStateService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.lang.NonNull; @@ -27,6 +28,11 @@ public class VehicleCommandInfoWebSocketHandler implements WebSocketHandler { private static final Logger LOGGER = LoggerFactory.getLogger(VehicleCommandInfoWebSocketHandler.class); private final Map sessions = new ConcurrentHashMap<>(); + private final PlatformRuntimeStateService platformRuntimeStateService; + + public VehicleCommandInfoWebSocketHandler(PlatformRuntimeStateService platformRuntimeStateService) { + this.platformRuntimeStateService = platformRuntimeStateService; + } @Override public void afterConnectionEstablished(@NonNull WebSocketSession session) { @@ -67,27 +73,6 @@ public class VehicleCommandInfoWebSocketHandler implements WebSocketHandler { public void sendSignalState(int phaseColor) { String signalState = (phaseColor == 1) ? "GREEN" : "RED"; - String commandJson = String.format( - "{" + - "\"messageUniqueId\": \"%s\"," + - "\"timestamp\": %d," + - "\"vehicleID\": \"AET01\"," + - "\"commandType\": \"SIGNAL\"," + - "\"commandReason\": \"TRAFFIC_LIGHT\"," + - "\"signalState\":\"%s\"," + - "\"intersectionId\":\"002\"," + - "\"latitude\": 343.23," + - "\"longitude\": 343.23," + - "\"relativeSpeed\": 3," + - "\"relativeMotionX\": 2002.12," + - "\"relativeMotionY\":100.12," + - "\"minDistance\":10.5" + - "}", - UUID.randomUUID().toString(), - System.currentTimeMillis(), - signalState - ); - LOGGER.info("🚦 [VehicleCommandInfo] 发送车辆控制指令: phaseColor={}, signalState={}", phaseColor, signalState); LOGGER.info("🚦 [VehicleCommandInfo] 当前连接数: {}", sessions.size()); @@ -96,9 +81,45 @@ public class VehicleCommandInfoWebSocketHandler implements WebSocketHandler { return; } - // 向所有连接的客户端发送 - sessions.values().forEach(session -> sendMessage(session, commandJson)); - LOGGER.info("🚦 [VehicleCommandInfo] 已向 {} 个客户端发送消息", sessions.size()); + java.util.List vehicleIds = platformRuntimeStateService.getTrafficLightRecipientVehicleIds(); + if (vehicleIds.isEmpty()) { + LOGGER.info("🚦 [VehicleCommandInfo] 当前无已注册的无人/特勤车辆,跳过发送红绿灯信号"); + return; + } + + int sentMessages = 0; + for (String vehicleId : vehicleIds) { + String commandJson = buildSignalCommandJson(vehicleId, signalState); + for (WebSocketSession session : sessions.values()) { + sendMessage(session, commandJson); + sentMessages++; + } + } + LOGGER.info("🚦 [VehicleCommandInfo] 已向 {} 个注册车辆发送红绿灯信号,共发送 {} 条消息", vehicleIds.size(), sentMessages); + } + + private String buildSignalCommandJson(String vehicleId, String signalState) { + return String.format( + "{" + + "\"messageUniqueId\": \"%s\"," + + "\"timestamp\": %d," + + "\"vehicleID\": \"%s\"," + + "\"commandType\": \"SIGNAL\"," + + "\"commandReason\": \"TRAFFIC_LIGHT\"," + + "\"signalState\":\"%s\"," + + "\"intersectionId\":\"002\"," + + "\"latitude\": 343.23," + + "\"longitude\": 343.23," + + "\"relativeSpeed\": 3," + + "\"relativeMotionX\": 2002.12," + + "\"relativeMotionY\":100.12," + + "\"minDistance\":10.5" + + "}", + UUID.randomUUID(), + System.currentTimeMillis(), + vehicleId, + signalState + ); } /** 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 index 17ffbe3..ba75765 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationControllerTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/controller/PlatformIntegrationControllerTest.java @@ -40,11 +40,12 @@ class PlatformIntegrationControllerTest { .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.status").value("success")) .andExpect(jsonPath("$.updated").value(3)) - .andExpect(jsonPath("$.controllableCount").value(1)) + .andExpect(jsonPath("$.controllableCount").value(2)) .andExpect(jsonPath("$.typesCount.WUREN").value(1)) .andExpect(jsonPath("$.typesCount.TEQIN").value(1)) .andExpect(jsonPath("$.typesCount.HANGKONG").value(1)) - .andExpect(jsonPath("$.controllableVehicleIDs[0]").value("QN001")); + .andExpect(jsonPath("$.controllableVehicleIDs[0]").value("QN001")) + .andExpect(jsonPath("$.controllableVehicleIDs[1]").value("TQ001")); } @Test 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 index b62de07..cdde514 100644 --- 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 @@ -27,7 +27,8 @@ class PathConflictDetectionServiceRuntimeConfigTest { mock(ConflictAlertLogRepository.class), mock(ApplicationEventPublisher.class), mock(CoordinateSystemService.class), - runtimeStateService + runtimeStateService, + mock(VehicleCommandService.class) ); @SuppressWarnings("unchecked") diff --git a/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTest.java b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTest.java new file mode 100644 index 0000000..6f993c7 --- /dev/null +++ b/qaup-collision/src/test/java/com/qaup/collision/pathconflict/service/VehicleCommandServiceTest.java @@ -0,0 +1,174 @@ +package com.qaup.collision.pathconflict.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qaup.collision.common.model.MovingObject; +import com.qaup.collision.pathconflict.model.dto.ConflictAlertEvent; +import com.qaup.collision.pathconflict.model.entity.ConflictAlertLog; +import com.qaup.collision.service.PlatformRuntimeStateService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; +import org.springframework.test.web.client.MockRestServiceServer; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class VehicleCommandServiceTest { + + private static final String COMMAND_URL = "http://vehicle.example/api/VehicleCommandInfo"; + + private VehicleCommandService vehicleCommandService; + private MockRestServiceServer server; + private PlatformRuntimeStateService runtimeStateService; + private GeometryFactory geometryFactory; + + @BeforeEach + void setUp() { + RestTemplate restTemplate = new RestTemplate(); + server = MockRestServiceServer.bindTo(restTemplate).build(); + runtimeStateService = new PlatformRuntimeStateService(200.0, 100.0, 40.0); + vehicleCommandService = new VehicleCommandService(restTemplate, new ObjectMapper(), runtimeStateService); + ReflectionTestUtils.setField(vehicleCommandService, "vehicleApiBaseUrl", "http://vehicle.example"); + ReflectionTestUtils.setField(vehicleCommandService, "vehicleCommandEndpoint", "/api/VehicleCommandInfo"); + geometryFactory = new GeometryFactory(); + } + + @Test + void shouldSendWarningOnceAndResumeAfterConflictClears() { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN) + )); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("WARNING")) + .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) + .andExpect(jsonPath("$.latitude").value(36.3544)) + .andExpect(jsonPath("$.longitude").value(120.085)) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("UV-001")) + .andExpect(jsonPath("$.commandType").value("RESUME")) + .andExpect(jsonPath("$.commandReason").value("RESUME_TRAFFIC")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + ConflictAlertEvent warningEvent = buildEvent( + "UV-001", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.WARNING + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(warningEvent)); + vehicleCommandService.synchronizeConflictCommands(List.of(warningEvent)); + vehicleCommandService.synchronizeConflictCommands(List.of()); + + server.verify(); + } + + @Test + void shouldEscalateToAlertAndParkingOnlyOnceForSpecialVehicle() { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("SP-001", PlatformRuntimeStateService.VehicleRegistryType.TEQIN) + )); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("SP-001")) + .andExpect(jsonPath("$.commandType").value("WARNING")) + .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("SP-001")) + .andExpect(jsonPath("$.commandType").value("ALERT")) + .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo(COMMAND_URL)) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.vehicleID").value("SP-001")) + .andExpect(jsonPath("$.commandType").value("PARKING")) + .andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING")) + .andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON)); + + ConflictAlertEvent warningEvent = buildEvent( + "SP-001", + MovingObject.MovingObjectType.SPECIAL_VEHICLE, + ConflictAlertLog.AlertLevel.WARNING + ); + ConflictAlertEvent alertEvent = buildEvent( + "SP-001", + MovingObject.MovingObjectType.SPECIAL_VEHICLE, + ConflictAlertLog.AlertLevel.CRITICAL + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(warningEvent)); + vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent)); + vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent)); + + server.verify(); + } + + @Test + void shouldIgnoreVehiclesOutsideCommandRegistry() { + ConflictAlertEvent warningEvent = buildEvent( + "UV-999", + MovingObject.MovingObjectType.UNMANNED_VEHICLE, + ConflictAlertLog.AlertLevel.WARNING + ); + + vehicleCommandService.synchronizeConflictCommands(List.of(warningEvent)); + + server.verify(); + } + + private ConflictAlertEvent buildEvent( + String vehicleId, + MovingObject.MovingObjectType vehicleType, + ConflictAlertLog.AlertLevel alertLevel) { + + Point conflictPoint = geometryFactory.createPoint(new Coordinate(120.0850, 36.3544)); + + return ConflictAlertEvent.builder() + .conflictId(Optional.of(1L)) + .alertType(Optional.of( + alertLevel == ConflictAlertLog.AlertLevel.WARNING + ? ConflictAlertLog.AlertType.CONFLICT_WARNING + : ConflictAlertLog.AlertType.CONFLICT_ALERT + )) + .alertLevel(Optional.of(alertLevel)) + .message("test") + .object1Name(vehicleId) + .object1Type(vehicleType) + .object2Name("CA1234") + .object2Type(MovingObject.MovingObjectType.AIRCRAFT) + .conflictPoint(conflictPoint) + .object1Distance(12.0) + .object2Distance(35.0) + .estimatedTimeToConflictObj1(10) + .estimatedTimeToConflictObj2(20) + .timeGapSeconds(10.0) + .eventTime(LocalDateTime.now()) + .build(); + } +} 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 index e046c33..4aede58 100644 --- a/qaup-collision/src/test/java/com/qaup/collision/service/PlatformRuntimeStateServiceTest.java +++ b/qaup-collision/src/test/java/com/qaup/collision/service/PlatformRuntimeStateServiceTest.java @@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class PlatformRuntimeStateServiceTest { @Test - void shouldKeepPreviousVehicleRegistrationsOnIncrementalUpdate() { + void shouldReplacePreviousVehicleRegistrationsOnFullUpdate() { PlatformRuntimeStateService service = new PlatformRuntimeStateService(200.0, 100.0, 40.0); PlatformRuntimeStateService.VehicleRegistryUpdateResult first = service.updateVehicleRegistry(List.of( @@ -22,8 +22,8 @@ class PlatformRuntimeStateServiceTest { )); assertEquals(3, first.updated()); - assertEquals(1, first.controllableCount()); - assertEquals(List.of("QN001"), first.controllableVehicleIDs()); + assertEquals(2, first.controllableCount()); + assertEquals(List.of("QN001", "TQ001"), first.controllableVehicleIDs()); assertEquals(1, first.typesCount().get("WUREN")); assertEquals(1, first.typesCount().get("TEQIN")); assertEquals(1, first.typesCount().get("HANGKONG")); @@ -33,8 +33,12 @@ class PlatformRuntimeStateServiceTest { )); assertEquals(1, second.updated()); - assertEquals(2, second.controllableCount()); - assertEquals(List.of("QN001", "QN002"), second.controllableVehicleIDs()); + assertEquals(1, second.controllableCount()); + assertEquals(List.of("QN002"), second.controllableVehicleIDs()); + assertFalse(service.isRegisteredForCollision("QN001", MovingObject.MovingObjectType.UNMANNED_VEHICLE)); + assertTrue(service.isRegisteredForCollision("QN002", MovingObject.MovingObjectType.UNMANNED_VEHICLE)); + assertEquals(List.of("QN002"), service.getControllableVehicleIds()); + assertEquals(List.of("QN002"), service.getTrafficLightRecipientVehicleIds()); } @Test @@ -68,5 +72,7 @@ class PlatformRuntimeStateServiceTest { assertFalse(service.isRegisteredForCollision("QN001", MovingObject.MovingObjectType.SPECIAL_VEHICLE)); assertFalse(service.isRegisteredForCollision("PT001", MovingObject.MovingObjectType.NORMAL_VEHICLE)); assertFalse(service.isRegisteredForCollision("UNKNOWN", MovingObject.MovingObjectType.AIRCRAFT)); + assertEquals(List.of("QN001", "TQ001"), service.getControllableVehicleIds()); + assertEquals(List.of("QN001", "TQ001"), service.getTrafficLightRecipientVehicleIds()); } } diff --git a/qaup-collision/src/test/java/com/qaup/collision/websocket/handler/VehicleCommandInfoWebSocketHandlerTest.java b/qaup-collision/src/test/java/com/qaup/collision/websocket/handler/VehicleCommandInfoWebSocketHandlerTest.java new file mode 100644 index 0000000..745c32b --- /dev/null +++ b/qaup-collision/src/test/java/com/qaup/collision/websocket/handler/VehicleCommandInfoWebSocketHandlerTest.java @@ -0,0 +1,63 @@ +package com.qaup.collision.websocket.handler; + +import com.qaup.collision.service.PlatformRuntimeStateService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class VehicleCommandInfoWebSocketHandlerTest { + + private PlatformRuntimeStateService runtimeStateService; + private VehicleCommandInfoWebSocketHandler handler; + private WebSocketSession session; + + @BeforeEach + void setUp() throws Exception { + runtimeStateService = new PlatformRuntimeStateService(200.0, 100.0, 40.0); + handler = new VehicleCommandInfoWebSocketHandler(runtimeStateService); + session = mock(WebSocketSession.class); + when(session.getId()).thenReturn("session-1"); + when(session.isOpen()).thenReturn(true); + handler.afterConnectionEstablished(session); + } + + @Test + void shouldSendSignalOnlyToRegisteredWurenAndTeqin() throws Exception { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("QN001", PlatformRuntimeStateService.VehicleRegistryType.WUREN), + new PlatformRuntimeStateService.VehicleRegistryEntry("TQ001", PlatformRuntimeStateService.VehicleRegistryType.TEQIN), + new PlatformRuntimeStateService.VehicleRegistryEntry("AC001", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG) + )); + + handler.sendSignalState(1); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(TextMessage.class); + verify(session, times(2)).sendMessage(messageCaptor.capture()); + List sentMessages = messageCaptor.getAllValues(); + assertTrue(sentMessages.stream().anyMatch(message -> message.getPayload().contains("\"vehicleID\": \"QN001\""))); + assertTrue(sentMessages.stream().anyMatch(message -> message.getPayload().contains("\"vehicleID\": \"TQ001\""))); + assertTrue(sentMessages.stream().noneMatch(message -> message.getPayload().contains("\"vehicleID\": \"AC001\""))); + } + + @Test + void shouldSkipWhenNoRegisteredRecipients() throws Exception { + runtimeStateService.updateVehicleRegistry(List.of( + new PlatformRuntimeStateService.VehicleRegistryEntry("AC001", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG) + )); + + handler.sendSignalState(3); + + verify(session, never()).sendMessage(org.mockito.ArgumentMatchers.any(TextMessage.class)); + } +}