增加了 mock 的航班进出港通知接口和相关测试

This commit is contained in:
Tian jianyong 2025-09-18 19:06:49 +08:00
parent f1dd69cffe
commit c8dadc2f30
24 changed files with 2623 additions and 186 deletions

1
.serena/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/cache

View File

@ -0,0 +1,35 @@
# QAUP-Management Architecture Patterns
## Critical Design Principle: Data Collection vs Processing Separation
### DataCollectorService (250ms interval)
- **ONLY** collects raw position data from external APIs
- Caches data in activeMovingObjectsCache with NULL speed/direction
- **FORBIDDEN**: No calculations, no WebSocket events, no processing
- Purpose: High-frequency data collection to ensure no data loss
### DataProcessingService (1000ms interval)
- Reads cached position data from DataCollectorService
- Calculates speed and direction using SpeedCalculationService
- Sends WebSocket position updates
- Performs violation detection and path conflict detection
- Saves data to database
### Cache Sharing Pattern
- DataCollectorService owns activeMovingObjectsCache
- DataProcessingService receives cache reference via setActiveMovingObjectsCache()
- Both services share the same cache instance but have different responsibilities
## Key Integration Pattern
The collision avoidance system integrates with RuoYi through `QuapDataAdapter` (qaup-collision/src/main/java/com/qaup/collision/common/adapter/QuapDataAdapter.java:37), which bridges the collision detection system with RuoYi's service layer, avoiding direct DAO dependencies.
## Data Access Pattern
Always use `QuapDataAdapter` for accessing vehicle and driver data in collision module instead of direct service calls. This maintains clean separation between collision detection and system management.
## WebSocket Communication
Real-time data flows through WebSocket endpoints configured in collision module. Messages use `/topic` prefix for broadcasting to connected clients.
## Performance Optimization
- Data collection interval: 250ms for high-frequency updates
- WebSocket push throttling: 1000ms to prevent frontend overload
- Redis caching with 60-second expiration for real-time data

View File

@ -0,0 +1,34 @@
# QAUP-Management Project Overview
## Purpose
QAUP-Management is an airport collision avoidance and management system built on the RuoYi framework. It integrates vehicle tracking, spatial analysis, and real-time monitoring for airport operations.
## Tech Stack
### Backend
- **Spring Boot 3.5.3** with Java 17
- **PostgreSQL + PostGIS** for spatial data
- **MyBatis** for database operations
- **Redis** for caching and session management
- **WebSocket** for real-time communication
- **JTS + GeoTools** for spatial calculations
- **RuoYi Framework** as base framework
### Frontend
- **Vue 2.6.12** with Element UI
- **Axios** for API communication
- **ECharts** for data visualization
## Multi-Module Maven Architecture
- **qaup-admin**: Main web application entry point containing controllers and startup configuration
- **qaup-collision**: Core collision avoidance system with spatial analysis, WebSocket communication, and real-time monitoring
- **qaup-framework**: Infrastructure layer with security, caching, and common configurations
- **qaup-system**: User management, RBAC, and system administration
- **qaup-common**: Shared utilities, constants, and base classes
- **qaup-quartz**: Scheduled job management
- **qaup-generator**: Code generation utilities
## Key URLs
- **Admin Interface**: http://localhost:8080
- **API Documentation**: http://localhost:8080/swagger-ui/index.html
- **WebSocket Endpoint**: ws://localhost:8080/ws

View File

@ -0,0 +1,63 @@
# Suggested Commands for QAUP-Management Development
## Build and Run Commands
### Maven Build
```bash
# Full clean build (skip tests for faster builds)
mvn clean install -DskipTests
# Check dependencies
mvn dependency:tree
```
### Backend Development
```bash
# Start backend from qaup-admin module
cd qaup-admin
mvn spring-boot:run
# Production deployment using script
./qaup.sh start
```
### Frontend Development
```bash
# Start frontend (Vue.js)
cd qaup-ui
npm run dev
```
## Debugging and Maintenance Commands
### Process Management
```bash
# Check port usage
lsof -ti:8080
# Kill process if needed
kill -9 <process-id>
```
### Log Monitoring
```bash
# View logs
tail -f qaup-admin/app.log
```
## System Commands (Darwin/macOS)
- `git` - Version control
- `ls` - List directory contents
- `cd` - Change directory
- `grep` - Search text patterns
- `find` - Find files and directories
- `lsof` - List open files and processes
- `kill` - Terminate processes
- `tail` - Display file tail content
## Database Setup
1. PostgreSQL with PostGIS extension enabled
2. Run initialization scripts in order:
- `sql/create_qaup_database.sql`
- `sql/create_sys_vehicle_info_table.sql`
- `sql/create_sys_driver_info_table.sql`

68
.serena/project.yml Normal file
View File

@ -0,0 +1,68 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: java
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "QAUP-Management"

View File

@ -128,11 +128,9 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
}
},
"vehicleState": {
"engineStatus": "RUNNING",
"transmissionState": "DRIVE",
"brakingSystem": "NORMAL",
"steeringSystem": "NORMAL",
"fuelLevel": 75.0
"motorStatus": [
{ "motorId": "M1", "status": "ACTIVE", "rpm": 3200, "torqueNm": 120, "powerKw": 25.5, "temperatureC": 65.0 }
]
},
"batteryStatus": {
"mainBattery": {
@ -187,7 +185,10 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
"sensorId": "CAM_FRONT",
"status": "ACTIVE",
"health": "GOOD",
"lastUpdate": 1736175610000
"lastUpdate": 1736175610000,
"coverage": "OK",
"latencyMs": 35,
"accuracy": 0.02
}
],
"lidars": [
@ -195,7 +196,10 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
"sensorId": "LIDAR_360",
"status": "ACTIVE",
"health": "GOOD",
"lastUpdate": 1736175610000
"lastUpdate": 1736175610000,
"coverage": "OK",
"latencyMs": 28,
"accuracy": 0.01
}
],
"radars": [
@ -203,7 +207,10 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
"sensorId": "RADAR_FRONT",
"status": "ACTIVE",
"health": "GOOD",
"lastUpdate": 1736175610000
"lastUpdate": 1736175610000,
"coverage": "OK",
"latencyMs": 22,
"accuracy": 0.1
}
],
"gps": {
@ -275,6 +282,52 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
}
]
},
"vehicleSignals": {
"headLamp": "ON",
"brakeLamp": "ON",
"turnSignal": "LEFT",
"positionLamp": "ON",
"horn": "OFF"
},
"eventReports": [
{
"eventId": "EVT-20250918-000123",
"eventCode": "GEOFENCE_BREACH",
"severity": "CRITICAL",
"timestamp": 1736175600456,
"location": { "latitude": 36.354072, "longitude": 120.083405 },
"evidenceRef": ["vid://360/1736175600..1736175690","log://blackbox/seg-88421"],
"details": "Entered restricted area"
}
],
"monitoring": {
"dataRecordingPolicy": { "localRetentionDays": 3, "backendRetentionDays": 90 },
"storagePolicy": { "overwriteStrategy": "FIFO", "capacityGb": 128, "estimatedDaysLocal": 4 },
"recoveryStatus": { "lastPowerLossTime": 1736175500000, "dataRecovered": true, "recoveredSegments": ["BB-20250918-0001"] },
"videoReferences": {
"external360": ["vid://360/1736175600..1736175690"],
"cabin": ["vid://cabin/1736175600..1736175690"],
"audio": ["aud://cabin/1736175600..1736175690"]
},
"perceptionResponse": { "state": "OK", "responseActions": ["SLOWDOWN"] },
"remoteCommands": [
{ "commandId": "CMD-88421", "commandType": "STOP", "issuedBy": "controller-001", "issuedAt": 1736175600123, "ackStatus": "ACKED", "executedAt": 1736175600456 }
],
"blackboxWindow": { "preEventSeconds": 90, "postEventSeconds": 30, "segmentId": "BB-20250918-0001" }
},
"airportCompliance": {
"oddProfile": {
"environment": ["DAY","NIGHT","FOG"],
"areaTypes": ["APRON","TAXIWAY","SERVICE_ROAD"],
"speedRange": [0, 15],
"weatherLimits": { "visibilityMin": 200, "windMax": 15 },
"runwayProximityLimit": 50
},
"activationStatus": { "eligible": true, "unmetConditions": [], "promptIssued": { "audible": false, "visual": false }, "evaluatedAt": 1736175610000 },
"perceptionBlindSpots": [{ "areaId": "FRONT_LOW", "azimuthRange": "350-10", "elevationRange": "-5~0", "lastVerifiedAt": 1736175600000 }],
"startSafetyCheck": { "passed": true, "obstaclesDetected": 0, "minObstacleDistance": null, "checkTime": 1736175599000 },
"geoFence": { "operationAreaId": "QD-APR-001", "geoFenceStatus": "INSIDE" }
},
"complianceStatus": {
"regulatoryCompliance": "COMPLIANT",
"certificationStatus": "VALID",
@ -315,6 +368,11 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
- **longitude**: 经度 [必填]
- **altitude**: 海拔高度 [可选]
- **coordinateSystem**: 坐标系统 [可选默认WGS84]
- 新增字段(机场区域化与一致性):
- **positionAccuracy**: 位置精度 (米) [可选]
- **localizationStatus**: 定位状态 (RTK_FIX, RTK_FLOAT, GNSS_ONLY, DR, FAULT) [可选]
- **mapMatchingStatus**: 地图匹配状态 (OK, OFF_ROUTE, NO_MAP, FAULT) [可选]
- 说明:支撑 6.5.3 在线监控a~d项及机场运行合规核查
#### 速度信息 (velocity) [必填]
- **speed**: 速度值 [必填]
@ -330,11 +388,13 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
- **unit**: 加速度单位 [可选默认m/s²]
### 3.5 车辆状态 (vehicleState) - 可选
- **engineStatus**: 发动机状态 (RUNNING, STOPPED, IDLE, FAULT) [可选]
- **transmissionState**: 变速箱状态 (PARK, DRIVE, REVERSE, NEUTRAL) [可选]
- **brakingSystem**: 制动系统状态 (NORMAL, WARNING, FAULT) [可选]
- **steeringSystem**: 转向系统状态 (NORMAL, WARNING, FAULT) [可选]
- **fuelLevel**: 燃油液位百分比 [可选]
- **motorStatus**: 电动机状态数组 [可选]
- **motorId**: 电机ID如 M1 前轴、M2 后轴)[可选]
- **status**: 状态 (ACTIVE, INACTIVE, FAULT, OVERHEAT, THERMAL_LIMITED) [可选]
- **rpm**: 转速 (rpm) [可选]
- **torqueNm**: 实时输出扭矩 (N·m) [可选]
- **powerKw**: 实时功率 (kW) [可选]
- **temperatureC**: 电机温度 (°C) [可选]
### 3.6 电池状态 (batteryStatus) - 可选
#### 主电池 (mainBattery) [可选]
@ -363,18 +423,21 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
- **health**: 健康状态 [可选]
- **chargingStatus**: 充电状态 [可选]
#### 备用电池 (backupBattery) [可选]
- **chargeLevel**: 电量百分比 [可选]
- **voltage**: 电压值 [可选]
- **health**: 健康状态 [可选]
- **lastMaintenance**: 最后维护时间戳 [可选]
### 3.7 安全状态 (safetyStatus) - 可选
- **collisionAvoidanceActive**: 碰撞避免系统是否激活 [可选]
- **emergencyBrakingReady**: 紧急制动系统是否就绪 [可选]
- **pathPlanningStatus**: 路径规划状态 (ACTIVE, INACTIVE, FAULT) [可选]
- **obstacleDetectionStatus**: 障碍物检测状态 (ACTIVE, INACTIVE, FAULT) [可选]
- **minimumRiskManeuverTriggered**: 最小风险策略是否触发 [可选]
- 新增字段(合规扩展,建议用统一枚举表示状态):
- **collisionAvoidanceStatus**: (INACTIVE, READY, ACTIVE, EMERGENCY, DEGRADED, FAULT) [可选]
- **emergencyBrakingStatus**: (INACTIVE, READY, ACTIVE, RECOVERING, FAULT) [可选]
- **lastEmergencyBrakeTime**: 最近一次紧急制动时间戳 [可选]
- **evasiveActionType**: 规避动作 (DECELERATE, STOP, BYPASS, HORN, LIGHT) [可选]
- **obstacleDistanceMin**: 最近障碍物最小距离 (m) [可选]
- **obstacleBearingDeg**: 最近障碍物方位角 (度) [可选]
- **perceptionConfidence**: 感知综合置信度 (0~1) [可选]
- 说明:用于在线监控与事件回溯(参见 6.5.2、6.5.3
### 3.8 自动驾驶等级 (autonomyLevel) - 可选
- **currentLevel**: 当前自动驾驶等级 (0-5, 基于SAE J3016标准) [可选]
@ -387,6 +450,11 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
- **status**: 状态 (ACTIVE, INACTIVE, FAULT) [可选]
- **health**: 健康状态 (GOOD, FAIR, POOR) [可选]
- **lastUpdate**: 最后更新时间戳 [可选]
- 新增通用字段(适用于各类传感器):
- **coverage**: 覆盖状态 (OK, OCCLUDED, WEATHER_IMPACTED) [可选]
- **latencyMs**: 采集至上报延迟 (ms) [可选]
- **accuracy**: 关键测量精度(单位视传感器类型)[可选]
- 说明:覆盖 6.5.3e “环境感知与响应状态”与取证质量评估
#### 激光雷达 (lidars) [可选]
- **sensorId**: 传感器ID [可选]
@ -416,6 +484,13 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
- **cellularSignalStrength**: 蜂窝信号强度 (dBm) [可选]
- **wifiStatus**: WiFi状态 (CONNECTED, DISCONNECTED, FAULT) [可选]
- **cloudConnectivity**: 云端连接状态 (ONLINE, OFFLINE, FAULT) [可选]
- 新增字段:
- **networkLatencyMs**: 网络往返时延 (ms) [可选]
- **packetLossRate**: 丢包率 (0~1) [可选]
- **failoverCount**: 网络故障切换次数 [可选]
- **rsuConnectedId**: 已连接路侧单元ID如有[可选]
- **remoteCommandAck**: { **lastCommandId**: string, **ackStatus**: (PENDING, ACKED, FAILED), **latencyMs**: number } [可选](最近一次指令确认摘要)
- 说明:满足 6.5.2e、6.5.3i 的远程指令闭环记录
### 3.11 诊断信息 (diagnostics) - 可选
#### 故障代码 (faultCodes) [可选]
@ -462,6 +537,11 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
- **regulatoryCompliance**: 法规合规状态 (COMPLIANT, NON_COMPLIANT, UNKNOWN) [可选]
- **certificationStatus**: 认证状态 (VALID, EXPIRED, PENDING) [可选]
- **auditTrail**: 审计跟踪状态 (ENABLED, DISABLED) [可选]
- 新增字段:
- **schemaVersion**: 数据结构版本 [可选]
- **dataQuality**: 数据质量标签 (COMPLETE, PARTIAL, ESTIMATED, STALE) [可选]
- 说明:用于数据结构版本兼容与数据质量提示,不包含管理性合规声明
## 4. 错误处理
@ -587,6 +667,10 @@ curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status?fields=vehicl
- **environmentalContext**: 环境信息
- **missionContext**: 任务信息
- **complianceStatus**: 合规信息
- **vehicleSignals**: 灯光/喇叭信号状态
- **eventReports**: 事件上报与证据引用
- **monitoring**: 在线监控与数据记录策略
- **airportCompliance**: 机场场景合规扩展ODD/激活/盲区/起步安全/许可区域)
### 11.3 实现建议
1. **渐进式实现**: 厂商可先实现必填字段,再逐步添加可选字段

View File

@ -0,0 +1,353 @@
# 通用无人车运行状态API上报必须字段 - 精简版)
本说明仅包含机场部署必须按时上报的最小字段集合,便于联调与验收。完整可选字段请参见 `doc/design/universal_autonomous_vehicle_api.md`。本文件已包含完整的请求方式与可运行示例,使用本文件即可进行开发与测试。
## 1. 接口信息
- 方法GET `/api/v1/vehicles/{vehicleId}/status`
- 认证Bearer Token (JWT)
- 响应:`application/json; charset=utf-8`
- 版本v1后续版本将以路径或Header方式区分
- 超时建议:客户端超时 ≥ 5s服务端处理 ≤ 1s正常场景
### 1.1 路径参数
| 参数名 | 类型 | 必填 | 描述 |
|---|---|---|---|
| vehicleId | string | 是 | 车辆唯一标识,字母数字 3~20 位示例AV-001 |
### 1.2 查询参数
| 参数名 | 类型 | 必填 | 描述 | 示例 |
|---|---|---|---|---|
| fields | string | 否 | 指定返回字段组,逗号分隔。不填返回全部字段 | fields=vehicleInfo,operationalStatus,controlStatus,motionStatus |
| format | string | 否 | 响应格式,默认 json当前仅支持 json | format=json |
说明:
- 建议在联调时使用 fields 仅返回本精简版所需字段组,以降低带宽与解析成本。
- 返回字段组对应见“必须字段组与字段”。
### 1.3 请求头
| Header | 必填 | 示例 | 说明 |
|---|---|---|---|
| Authorization | 是 | Bearer eyJhbGciOi... | JWT 鉴权令牌 |
| Content-Type | 是 | application/json | 固定为 JSON |
| Accept | 否 | application/json | 建议显式声明 |
| X-Request-Id | 否 | 3b9c9e90-7c1f-4b6a-9b73-fb8dfb2d7f31 | 客户端生成的请求ID便于排障 |
### 1.4 鉴权说明JWT
- 建议使用 RS256/ES256 非对称签名。
- 令牌应包含 iss签发者、exp过期、sub主体/账户或系统ID、aud受众等标准声明。
- 服务器应校验签名与过期时间;拒绝无效或过期令牌(返回 401
### 1.5 幂等性与频控
- 本接口为查询,天然幂等。
- 建议服务端设置频控:同一 vehicleId QPS ≤ 5超过返回 429。
- 建议客户端重试策略:网络错误或 5xx指数退避重试最多 3 次;遇到 4xx 不重试。
---
## 2. 必须字段组与字段
1) vehicleInfo
- vehicleId: string
2) operationalStatus
- powerStatus: ON | OFF | STANDBY
- systemHealth: HEALTHY | DEGRADED | CRITICAL | FAULT
- operationalMode: MANUAL | ASSISTED | AUTONOMOUS | REMOTE
- emergencyStatus: NORMAL | WARNING | EMERGENCY | CRITICAL
- lastHeartbeat: number (ms, UTC)
3) controlStatus
- controlMode: MANUAL | AUTONOMOUS | REMOTE | HYBRID
- controlAuthority: DRIVER | SYSTEM | REMOTE_OPERATOR
- remoteControlActive: boolean
4) motionStatus.position
- latitude: number
- longitude: number
5) motionStatus.velocity
- speed: number (m/s)
- direction: number (radians)
6) safetyStatus
- collisionAvoidanceActive: boolean
- emergencyBrakingReady: boolean
- pathPlanningStatus: ACTIVE | INACTIVE | FAULT
- obstacleDetectionStatus: ACTIVE | INACTIVE | FAULT
- minimumRiskManeuverTriggered: boolean
7) sensorStatus.gps
- status: ACTIVE | INACTIVE | FAULT
- accuracy: number (m)
- lastUpdate: number (ms, UTC)
8) batteryStatus.mainBattery
- chargeLevel: number (0-100)
- voltage: number (V)
- current: number (A正值=充电,负值=放电)
- temperature: number (°C)
- chargingStatus: CHARGING | DISCHARGING | IDLE | FAULT
9) communicationStatus
- v2xStatus: CONNECTED | DISCONNECTED | FAULT
- cellularSignalStrength: number (dBm)
- wifiStatus: CONNECTED | DISCONNECTED | FAULT
- cloudConnectivity: ONLINE | OFFLINE | FAULT
---
## 3. 统一要求
- 时间戳:毫秒级 UTC
- 坐标系WGS84latitude/longitude
- 单位约定:
- 速度 speed: m/s
- 方向 direction: radians
- 电压 voltage: V电流 current: A温度 temperature: °C
- 枚举一致性:状态枚举尽量统一使用 ACTIVE/INACTIVE/DEGRADED/FAULT如已有定义以主文档为准
---
## 4. 可直接运行的请求示例
### 4.1 cURL 示例(完整)
获取全部必需字段:
```bash
curl -X GET "https://api.example.com/api/v1/vehicles/AV-001/status" \
-H "Authorization: Bearer REPLACE_WITH_JWT_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json"
```
仅获取核心字段组(推荐用于联调):
```bash
curl -G "https://api.example.com/api/v1/vehicles/AV-001/status" \
-H "Authorization: Bearer REPLACE_WITH_JWT_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data-urlencode "fields=vehicleInfo,operationalStatus,controlStatus,motionStatus,safetyStatus,sensorStatus,batteryStatus,communicationStatus"
```
### 4.2 Postman/HTTPie 示例
HTTPie
```bash
http GET https://api.example.com/api/v1/vehicles/AV-001/status \
Authorization:"Bearer REPLACE_WITH_JWT_TOKEN" \
Accept:application/json
```
### 4.3 可复制的一键测试脚本(本地替换变量后直接运行)
```bash
#!/usr/bin/env bash
BASE_URL="https://api.example.com"
VEHICLE_ID="AV-001"
JWT="REPLACE_WITH_JWT_TOKEN"
curl -sS -X GET "${BASE_URL}/api/v1/vehicles/${VEHICLE_ID}/status" \
-H "Authorization: Bearer ${JWT}" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-w "\nHTTP_STATUS:%{http_code}\n" \
--connect-timeout 5 --max-time 10
```
---
## 5. 示例响应(仅包含必需字段)
```json
{
"code": 200,
"message": "success",
"timestamp": 1736175610000,
"data": {
"vehicleInfo": {
"vehicleId": "AV-001"
},
"operationalStatus": {
"powerStatus": "ON",
"systemHealth": "HEALTHY",
"operationalMode": "AUTONOMOUS",
"emergencyStatus": "NORMAL",
"lastHeartbeat": 1736175610000
},
"controlStatus": {
"controlMode": "AUTONOMOUS",
"controlAuthority": "SYSTEM",
"remoteControlActive": false
},
"motionStatus": {
"position": {
"latitude": 36.354068,
"longitude": 120.083410
},
"velocity": {
"speed": 3.2,
"direction": 1.57
}
},
"safetyStatus": {
"collisionAvoidanceActive": true,
"emergencyBrakingReady": true,
"pathPlanningStatus": "ACTIVE",
"obstacleDetectionStatus": "ACTIVE",
"minimumRiskManeuverTriggered": false
},
"sensorStatus": {
"gps": {
"status": "ACTIVE",
"accuracy": 0.5,
"lastUpdate": 1736175610000
}
},
"batteryStatus": {
"mainBattery": {
"chargeLevel": 85.5,
"voltage": 48.2,
"current": -15.3,
"temperature": 35.2,
"chargingStatus": "DISCHARGING"
}
},
"communicationStatus": {
"v2xStatus": "CONNECTED",
"cellularSignalStrength": -65,
"wifiStatus": "CONNECTED",
"cloudConnectivity": "ONLINE"
}
}
}
```
---
## 6. 错误码与错误响应示例
### 6.1 常见错误码
- 200 成功
- 400 请求参数错误(如 vehicleId 不符合规范)
- 401 未授权JWT 无效或过期)
- 403 禁止访问(无权限)
- 404 车辆不存在
- 429 请求过多(频控触发)
- 500 服务器内部错误
- 503 服务不可用(临时维护或下游故障)
### 6.2 错误响应示例
```json
{
"code": 401,
"message": "Unauthorized",
"timestamp": 1736175610000,
"error": {
"type": "AUTH_ERROR",
"details": "Invalid or expired token",
"field": null
}
}
```
---
## 7. 数据字段详细说明(必须字段)
- vehicleInfo.vehicleId
- 类型string必填示例AV-001
- 说明:车辆唯一标识,建议字母数字 3~20 位
- operationalStatus.powerStatus
- 类型enum(ON|OFF|STANDBY);必填:是
- 说明:供电与上电状态
- operationalStatus.systemHealth
- 类型enum(HEALTHY|DEGRADED|CRITICAL|FAULT);必填:是
- 说明系统健康概览DEGRADED/CRITICAL 用于运营判定
- operationalStatus.operationalMode
- 类型enum(MANUAL|ASSISTED|AUTONOMOUS|REMOTE);必填:是
- 说明:当前控制模式
- operationalStatus.emergencyStatus
- 类型enum(NORMAL|WARNING|EMERGENCY|CRITICAL);必填:是
- 说明:紧急状态等级
- operationalStatus.lastHeartbeat
- 类型number(ms,UTC);必填:是
- 说明:设备心跳时间,用于在线监控存活判定
- controlStatus.controlMode
- 类型enum(MANUAL|AUTONOMOUS|REMOTE|HYBRID);必填:是
- controlStatus.controlAuthority
- 类型enum(DRIVER|SYSTEM|REMOTE_OPERATOR);必填:是
- controlStatus.remoteControlActive
- 类型boolean必填
- 说明:是否处于远程控制激活态
- motionStatus.position.latitude / longitude
- 类型number必填单位WGS84 度
- 说明:位置信息最小集合
- motionStatus.velocity.speed
- 类型number必填单位m/s
- motionStatus.velocity.direction
- 类型number必填单位radians
- safetyStatus.collisionAvoidanceActive
- 类型boolean必填
- safetyStatus.emergencyBrakingReady
- 类型boolean必填
- safetyStatus.pathPlanningStatus
- 类型enum(ACTIVE|INACTIVE|FAULT);必填:是
- safetyStatus.obstacleDetectionStatus
- 类型enum(ACTIVE|INACTIVE|FAULT);必填:是
- safetyStatus.minimumRiskManeuverTriggered
- 类型boolean必填
- 说明MRM 是否被触发(仅标识,不含细节)
- sensorStatus.gps.status
- 类型enum(ACTIVE|INACTIVE|FAULT);必填:是
- sensorStatus.gps.accuracy
- 类型number必填单位m
- sensorStatus.gps.lastUpdate
- 类型number(ms,UTC);必填:是
- batteryStatus.mainBattery.chargeLevel
- 类型number必填范围0~100%
- batteryStatus.mainBattery.voltage
- 类型number必填单位V
- batteryStatus.mainBattery.current
- 类型number必填单位A正=充电,负=放电)
- batteryStatus.mainBattery.temperature
- 类型number必填单位°C
- batteryStatus.mainBattery.chargingStatus
- 类型enum(CHARGING|DISCHARGING|IDLE|FAULT);必填:是
- communicationStatus.v2xStatus
- 类型enum(CONNECTED|DISCONNECTED|FAULT);必填:是
- communicationStatus.cellularSignalStrength
- 类型number必填单位dBm
- communicationStatus.wifiStatus
- 类型enum(CONNECTED|DISCONNECTED|FAULT);必填:是
- communicationStatus.cloudConnectivity
- 类型enum(ONLINE|OFFLINE|FAULT);必填:是
---
## 8. 兼容性与扩展
- 厂商可返回更多可选字段,但不得改变上述必需字段的语义与单位。
- 推荐支持 `?fields=` 过滤机制,以便联调时仅回传必需集合。

View File

@ -0,0 +1,231 @@
# Bug修复详细记录
## 航班进出港通知定时任务不执行问题
### 问题描述
**现象**
- 新增了航班进出港接口实现,但在日志中没有看到任何访问信息
- 服务端没有收到任何请求
- 其他所有定时任务接口都工作正常,只有航班进出港接口完全没有启动
**预期行为**
- 定时任务应该每1秒执行一次
- 应该有日志输出显示API调用和数据处理过程
- 应该发布WebSocket事件
### 问题分析过程
#### 1. 初步排查 - 代码逻辑对比
对比正常工作的接口和不工作的接口实现:
**正常工作的接口**
```java
// 航空器数据采集
@Scheduled(fixedRateString = "${data.collector.interval}")
public void collectAircraftData() {
List<Aircraft> newAircrafts = dataCollectorDao.collectAircraftData(airportAircraftEndpoint, airportBaseUrl);
// ...
}
// 车辆数据采集
@Scheduled(fixedRateString = "${data.collector.interval}")
public void collectVehicleData() {
List<AirportVehicle> vehicles = dataCollectorDao.collectVehicleData(airportVehicleEndpoint, airportBaseUrl);
// ...
}
```
**不工作的接口**
```java
// 航班进出港通知采集
@Scheduled(fixedRateString = "${data.collector.flight-notification.interval}")
public void collectFlightNotificationData() {
List<FlightNotificationDTO> notifications = dataCollectorDao.getFlightNotifications(flightNotificationEndpoint, airportBaseUrl);
// ...
}
```
**发现差异**:参数传递模式相同,代码逻辑相同,唯一差异在配置键名。
#### 2. 配置分析 - Spring属性绑定问题
检查所有定时任务的配置键名:
**正常工作的配置**
- `${data.collector.interval}`
- `${data.collector.route.interval}`
- `${data.collector.detection.interval:1000}`
**不工作的配置**
- `${data.collector.flight-notification.interval}`
**关键发现**:连字符在嵌套属性中的问题!
#### 3. 配置文件结构分析
```yaml
data:
collector:
interval: 250 # ✅ 工作正常
route: # ✅ 工作正常
interval: 5000
detection: # ✅ 工作正常
interval: 1000
flight-notification: # ❌ 不工作
interval: 1000
```
**根本原因**Spring Boot中@Value/@Scheduled表达式与@ConfigurationProperties对连字符的处理规则不同。
### Spring Boot配置绑定的两套规则
#### @ConfigurationProperties - 支持relaxed binding宽松绑定
```java
@ConfigurationProperties(prefix = "my.app")
public class MyProperties {
private String firstName; // 可以绑定 first-name
}
```
配置文件:
```yaml
my:
app:
first-name: "John" # ✅ 支持连字符会自动转换为firstName
```
#### @Value/@Scheduled - 直接属性解析,连字符限制
```java
@Scheduled(fixedRateString = "${my.app.first-name.interval}") // ❌ 可能失败
@Value("${my.app.first-name.interval}") // ❌ 可能失败
```
**差异原因**
- @ConfigurationProperties使用Spring的relaxed binding机制会自动转换kebab-case到camelCase
- @Value和@Scheduled直接使用Environment.getProperty(),需要精确匹配属性路径
- 当属性路径中包含连字符时,可能影响属性解析器的路径分割逻辑
### 解决方案
#### 1. 修改配置文件
将连字符键名改为无连字符形式:
```yaml
# 修改前(不工作)
flight-notification:
interval: 1000
# 修改后(正常工作)
flightnotification:
interval: 1000
```
#### 2. 修改代码配置引用
```java
// 修改前
@Scheduled(fixedRateString = "${data.collector.flight-notification.interval:1000}")
// 修改后
@Scheduled(fixedRateString = "${data.collector.flightnotification.interval:1000}")
```
### 验证方法
#### 1. 添加调试日志验证定时任务执行
```java
@Scheduled(fixedRateString = "${data.collector.flightnotification.interval:1000}")
public void collectFlightNotificationData() {
log.info("🔥 定时任务启动检查: 航班进出港通知采集任务执行中...");
// ...
}
```
#### 2. 运行集成测试验证
测试结果显示调试日志成功输出:
```
🔥 定时任务启动检查: 航班进出港通知采集任务执行中...
✈️ 成功获取航班进出港通知数据,数量: 2
✈️ 采集到 2 条航班进出港通知
🛬 处理航班进出港通知: 航班号=CA8901, 类型=OUT, 跑道=35, 机位=201
📡 发布航班进出港通知WebSocket事件: 航班号=CA8901, 事件类型=TAKEOFF
✅ 航班进出港通知数据处理完成,处理数量: 2
```
### 最终修复文件
**修改的文件**
1. `/qaup-admin/src/main/resources/application.yml` - 配置键名修改
2. `/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java` - 配置引用修改
**测试文件**
- 集成测试验证功能正常工作
- 所有相关单元测试通过
### 经验教训
#### 1. Spring Boot配置最佳实践
- **避免在嵌套配置中使用连字符**虽然YAML支持连字符但Spring Boot的属性绑定机制在处理嵌套连字符属性时可能存在问题
- **优先使用驼峰命名或无连字符命名**:如`flightNotification`或`flightnotification`
- **为配置属性提供默认值**:使用`${key:defaultValue}`模式避免配置缺失导致的启动失败
#### 2. 连字符配置的使用场景
**推荐使用连字符的场景**
- @ConfigurationProperties的类属性绑定
- 简单的顶级配置属性
**避免使用连字符的场景**
- @Value注解中的嵌套属性路径
- @Scheduled注解中的配置引用
- 复杂的多级嵌套属性路径
#### 3. 问题排查方法论
1. **对比分析法**:对比正常工作和异常功能的实现差异
2. **逐层排查**:从代码逻辑 → 配置文件 → Spring机制
3. **添加调试日志**:在关键位置添加日志验证执行流程
4. **配置验证**:重点检查配置键名的命名规范
#### 4. 测试策略
- **不要仅依赖手动调用测试**集成测试手动调用方法无法发现Spring调度问题
- **需要真实的Spring Boot环境测试**只有在完整的Spring上下文中才能发现配置绑定问题
- **添加专门的调试日志**:临时添加明显的调试标识快速验证修复效果
### 相关技术点
#### Spring Boot属性绑定机制
Spring Boot使用`@ConfigurationProperties`和Environment抽象来绑定配置属性。对于嵌套属性
- **支持的格式**`data.collector.interval`、`data.collector.route.interval`
- **可能有问题的格式**`data.collector.flight-notification.interval`
- **推荐格式**`data.collector.flightnotification.interval`
#### @Scheduled注解机制
`@Scheduled`注解在Spring容器启动时进行解析
1. Spring扫描所有带有`@Scheduled`的方法
2. 解析`fixedRateString`中的配置属性引用
3. 如果配置属性解析失败,该定时任务不会被注册
4. 不会抛出异常,只是静默跳过
这就是为什么其他接口正常工作,而配置有问题的接口完全没有日志输出的原因。
### 状态
- ✅ **问题已解决**
- ✅ **根本原因已确认**
- ✅ **修复方案已验证**
- ✅ **相关测试已通过**
**最终结果**航班进出港通知定时任务现在每1秒正常执行日志输出正常WebSocket事件发布正常。

View File

@ -53,6 +53,50 @@
| 3 | latitude | 纬度 | double | 是 |
| 4 | time | 时间戳 | long | 是 |
### 1.4 进出港航班查询接口Mock
数据来源:获取最新的进出港航班通知信息
1. 接口地址:<http://IP:端口/openApi/getInboundAndOutboundFlightsNotification>
2. 请求方式get需要在 Header 中携带认证信息,字段名为 Authorization值为认证接口返回的token
3. 返回格式:以 JSON 格式返回数据一次请求返回List集合对象
4. 数据结构:
| 序号 | 字段 | 描述 | 字段类型 | 是否必填 |
|-----|------|------|----------|----------|
| 1 | flightNo | 航班号 | String | 是 |
| 2 | type | 进出港类型IN/OUT | String | 是 |
| 3 | runway | 进出港跑道编号 | String | 是 |
| 4 | contactCross | 联络道口 | String | 是 |
| 5 | seat | 机位 | String | 是 |
| 6 | time | 进出港时间戳UTC 时间) | long | 是 |
5. 返回示例:
```json
[
{
"flightNo": "CZ3201",
"type": "IN",
"runway": "35",
"contactCross": "F1",
"seat": "138",
"time": 1736175610000
},
{
"flightNo": "MU5432",
"type": "OUT",
"runway": "34",
"contactCross": "L1",
"seat": "135",
"time": 1736178000000
}
]
```
## 第2章 无人车控制接口
### 2.1 无人车控制指令
@ -219,3 +263,4 @@ responseData:
"pointCloud":[]
}
]

View File

@ -0,0 +1,17 @@
## TODO Lists
### [2025-09-05]
- [ ] 无人车下行接口
- [ ] 控制指令下发
- [ ] 红绿灯通知
- [ ] 任务指定
- [ ] 无人车上行接口
- [ ] 状态上报
- [ ] 视频流
- [ ] 任务列表
- [ ] 任务执行状态
- [x] 给定航空器路由和无人车路由,计算出可能的冲突点
PathConflictDetectionService.PathConflictDetectionService()

View File

@ -1,96 +0,0 @@
# 待办事项
### 设置发送给前端的Websocket消息的类型
- 是否完成true
```java
/**
* 电子围栏告警级别枚举
*/
public enum GeofenceAlertLevel {
/**
* 信息级别
*/
INFO("信息", 1),
/**
* 警告级别
*/
WARNING("警告", 2),
/**
* 严重级别
*/
CRITICAL("严重", 3),
/**
* 紧急级别
*/
EMERGENCY("紧急", 4);
```
### 在超速消息中增加一个字段表示限速值单位是km/h
- 是否完成true
```java
public class RuleViolationPayload {
/**
* 实际值
*/
@JsonProperty("actualValue")
private Double actualValue;
/**
* 限制值
*/
@JsonProperty("limitValue")
private Double limitValue;
```
## API 接口
### 增加无人车位置信息接口,显示无人车的位置信息,包括:
- 无人车的ID
- 无人车的车牌号
- 无人车的类型
- 无人车的状态
- 无人车的位置
- 无人车的速度
- 无人车的方向
### 增加无人车的运行信息接口,显示无人车的运行信息,包括:
- 无人车的ID
- 无人车的车牌号
- 无人车的类型
- 无人车的电池电量
- 无人车的运行时间
- 无人车的运行距离
- 无人车的运行速度
### 增加无人车的任务执行状态接口,显示无人车的任务执行状态,包括:
- 无人车的ID
- 无人车的车牌号
- 无人车的类型
- 无人车的任务执行状态
- 无人车的任务执行开始时间
- 无人车的任务完成百分比
- 无人车的任务执行轨迹
### 替换 Swagger 为 SpringDoc
- springfox-boot-starterSwagger 3.0.0 可能不兼容 Spring Boot 3.x建议迁移到 SpringDoc OpenAPI已在 properties 中定义 springdoc.version=2.6.0,但未使用)。
- 是否完成true
- 在 pom.xml 中添加以下依赖:
```xml
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
```

View File

@ -200,6 +200,9 @@ data:
# 路由数据采集间隔,单位:毫秒(较低频率采集路由信息)
route:
interval: 5000
# 航班进出港通知采集间隔,单位:毫秒
flightnotification:
interval: 1000
# 检测和推送间隔配置
detection:
# 检测间隔单位毫秒控制围栏检测、冲突检测、违规检测和WebSocket推送频率
@ -215,6 +218,7 @@ data:
arrival-route: /runwayPathPlanningController/findArrTaxiwayByRunwayAndContactCrossAndSeat
departure-route: /runwayPathPlanningController/findDepTaxiwayByRunwayAndContactCrossAndSeat
aircraft-status: /aircraftStatusController/getAircraftStatus
flight-notification: /openApi/getInboundAndOutboundFlightsNotification
auth:
username: dianxin

View File

@ -0,0 +1,141 @@
package com.qaup.collision.common.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 航班进出港通知业务对象
*
* 表示机场系统中的航班进出港事件通知包含航班的基本信息和时间信息
*
* @author QAUP System
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FlightNotification {
/**
* 航班号
*/
private String flightNo;
/**
* 航班类型
*/
private FlightType type;
/**
* 跑道编号
*/
private String runway;
/**
* 联络道口
*/
private String contactCross;
/**
* 机位号
*/
private String seat;
/**
* 事件时间戳毫秒
*/
private Long eventTime;
/**
* 事件发生时间
*/
private LocalDateTime eventDateTime;
/**
* 通知接收时间
*/
private LocalDateTime receivedTime;
/**
* 是否为活跃通知
*/
private Boolean active;
/**
* 航班类型枚举
*/
public enum FlightType {
/**
* 进港航班
*/
IN("进港"),
/**
* 出港航班
*/
OUT("出港"),
/**
* 停留状态
*/
ARRIVED("停留");
private final String description;
FlightType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
/**
* 从字符串转换为枚举值
*/
public static FlightType fromString(String typeStr) {
if (typeStr == null) {
return null;
}
try {
return FlightType.valueOf(typeStr.toUpperCase());
} catch (IllegalArgumentException e) {
return null;
}
}
}
/**
* 获取事件类型描述
*/
public String getEventDescription() {
if (type == null) {
return "未知事件";
}
switch (type) {
case IN:
return "航班落地";
case OUT:
return "航班滑出";
case ARRIVED:
return "航班停留";
default:
return "未知事件";
}
}
/**
* 检查通知是否有效
*/
public boolean isValid() {
return flightNo != null && !flightNo.trim().isEmpty()
&& type != null
&& eventTime != null;
}
}

View File

@ -9,6 +9,7 @@ import com.qaup.collision.common.model.dto.Response;
import com.qaup.collision.datacollector.service.AuthService;
import com.qaup.collision.datacollector.dto.AircraftRouteDTO;
import com.qaup.collision.datacollector.dto.AircraftStatusDTO;
import com.qaup.collision.datacollector.dto.FlightNotificationDTO;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -372,7 +373,7 @@ public class DataCollectorDao {
/**
* 获取航空器状态
*
*
* @return 航空器状态数据
*/
public AircraftStatusDTO getAircraftStatus() {
@ -411,4 +412,68 @@ public class DataCollectorDao {
return null;
}
}
/**
* 获取航班进出港通知
*
* 数据来源机场系统API (/openApi/getInboundAndOutboundFlightsNotification)
* 说明获取当前活跃的进出港航班通知包含航班的落地和滑出事件
*
* @return 航班进出港通知列表
*/
public List<FlightNotificationDTO> getFlightNotifications(String endpoint, String baseUrl) {
try {
String token = authService.getToken();
if (token == null) {
log.error("无法获取有效的认证token");
return Collections.emptyList();
}
String url = baseUrl + endpoint;
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", token);
headers.set("Content-Type", "application/json");
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
ResponseEntity<Response<List<FlightNotificationDTO>>> response = restTemplate.exchange(
url,
HttpMethod.GET,
requestEntity,
new ParameterizedTypeReference<Response<List<FlightNotificationDTO>>>() {}
);
if (response.getStatusCode().is2xxSuccessful()) {
Response<List<FlightNotificationDTO>> responseBody = response.getBody();
if (responseBody != null && responseBody.getStatus() == 200 && responseBody.getData() != null) {
List<FlightNotificationDTO> notifications = responseBody.getData();
log.info("✈️ 成功获取航班进出港通知数据,数量: {}", notifications.size());
// 过滤无效数据
List<FlightNotificationDTO> validNotifications = notifications.stream()
.filter(notification -> notification.getFlightNo() != null &&
!notification.getFlightNo().trim().isEmpty() &&
notification.getType() != null &&
notification.getTime() != null)
.collect(Collectors.toList());
if (validNotifications.size() != notifications.size()) {
log.warn("⚠️ 过滤了 {} 条无效的航班通知数据", notifications.size() - validNotifications.size());
}
return validNotifications;
} else {
log.warn("⚠️ 获取航班进出港通知数据失败: {}", responseBody != null ? responseBody.getMsg() : "未知错误");
return Collections.emptyList();
}
} else {
log.warn("⚠️ 获取航班进出港通知数据请求失败: HTTP {}", response.getStatusCode());
return Collections.emptyList();
}
} catch (Exception e) {
log.error("❌ 获取航班进出港通知数据时发生异常", e);
return Collections.emptyList();
}
}
}

View File

@ -0,0 +1,62 @@
package com.qaup.collision.datacollector.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 航班进出港通知数据传输对象
* 用于接收外部API返回的航班进出港通知数据
*
* 对应mock服务器接口: /openApi/getInboundAndOutboundFlightsNotification
*
* @author QAUP System
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class FlightNotificationDTO {
/**
* 航班号
*/
@JsonProperty("flightNo")
private String flightNo;
/**
* 航班类型
* IN: 进港航班
* OUT: 出港航班
*/
@JsonProperty("type")
private String type;
/**
* 跑道编号
*/
@JsonProperty("runway")
private String runway;
/**
* 联络道口
*/
@JsonProperty("contactCross")
private String contactCross;
/**
* 机位号
*/
@JsonProperty("seat")
private String seat;
/**
* 事件时间戳毫秒
* 进港落地时间
* 出港滑出时间
*/
@JsonProperty("time")
private Long time;
}

View File

@ -10,6 +10,8 @@ import com.qaup.collision.common.service.VehicleLocationService;
import com.qaup.collision.datacollector.dao.DataCollectorDao;
import com.qaup.collision.datacollector.dto.AircraftRouteDTO;
import com.qaup.collision.datacollector.dto.AircraftStatusDTO;
import com.qaup.collision.datacollector.dto.FlightNotificationDTO;
import com.qaup.collision.common.model.FlightNotification;
import com.qaup.collision.datacollector.filter.VehicleLocationFilter;
import com.qaup.collision.websocket.event.AircraftRouteUpdateEvent;
@ -23,6 +25,9 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.context.ApplicationEventPublisher;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@ -54,6 +59,9 @@ public class DataCollectorService {
@Value("${data.collector.airport-api.endpoints.aircraft}")
private String airportAircraftEndpoint;
@Value("${data.collector.airport-api.endpoints.flight-notification}")
private String flightNotificationEndpoint;
@Value("${data.collector.airport-api.base-url}")
private String airportBaseUrl;
@ -109,7 +117,7 @@ public class DataCollectorService {
*
* 说明此方法用于更新航空器的路由信息支持CA3456生命周期模拟
*/
@Scheduled(fixedRateString = "${data.collector.route.interval:5000}")
@Scheduled(fixedRateString = "${data.collector.route.interval}")
public void collectAircraftRouteAndStatus() {
if (collectorDisabled) {
return;
@ -671,13 +679,131 @@ public class DataCollectorService {
trafficLightStats.getTotalSignalsReceived(), trafficLightStats.getSuccessRate());
}
/**
* 定时采集航班进出港通知数据
*
* 数据来源机场系统API (/openApi/getInboundAndOutboundFlightsNotification)
* 说明
* - 采集当前活跃的进出港航班通知
* - 支持进港落地和出港滑出事件通知
* - 数据存储到缓存中供后续处理和WebSocket推送
* - 采集间隔使用航班进出港通知采集间隔1秒
*/
@Scheduled(fixedRateString = "${data.collector.flightnotification.interval:1000}")
public void collectFlightNotificationData() {
if (collectorDisabled) {
return;
}
try {
List<FlightNotificationDTO> notifications = dataCollectorDao.getFlightNotifications(flightNotificationEndpoint, airportBaseUrl);
if (notifications.isEmpty()) {
log.debug("未获取到航班进出港通知数据");
return;
}
log.info("✈️ 采集到 {} 条航班进出港通知", notifications.size());
// 将DTO转换为业务对象并处理
for (FlightNotificationDTO dto : notifications) {
try {
FlightNotification notification = convertToFlightNotification(dto);
if (notification != null && notification.isValid()) {
log.info("🛬 处理航班进出港通知: 航班号={}, 类型={}, 跑道={}, 机位={}, 事件时间={}",
notification.getFlightNo(),
notification.getType(),
notification.getRunway(),
notification.getSeat(),
notification.getEventDateTime());
// 发布WebSocket事件推送到前端
publishFlightNotificationEvent(notification);
} else {
log.warn("⚠️ 航班进出港通知数据无效,跳过处理: {}", dto);
}
} catch (Exception e) {
log.error("❌ 处理航班进出港通知异常: flightNo={}", dto.getFlightNo(), e);
}
}
log.info("✅ 航班进出港通知数据处理完成,处理数量: {}", notifications.size());
} catch (Exception e) {
log.error("❌ 采集航班进出港通知数据异常", e);
}
}
/**
* 将FlightNotificationDTO转换为FlightNotification业务对象
*/
private FlightNotification convertToFlightNotification(FlightNotificationDTO dto) {
if (dto == null) {
return null;
}
try {
// 转换航班类型
FlightNotification.FlightType flightType = FlightNotification.FlightType.fromString(dto.getType());
if (flightType == null) {
log.warn("⚠️ 未知的航班类型: {}", dto.getType());
return null;
}
// 转换时间戳为LocalDateTime
LocalDateTime eventDateTime = null;
if (dto.getTime() != null) {
eventDateTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(dto.getTime()),
ZoneId.systemDefault()
);
}
return FlightNotification.builder()
.flightNo(dto.getFlightNo())
.type(flightType)
.runway(dto.getRunway())
.contactCross(dto.getContactCross())
.seat(dto.getSeat())
.eventTime(dto.getTime())
.eventDateTime(eventDateTime)
.receivedTime(LocalDateTime.now())
.active(true)
.build();
} catch (Exception e) {
log.error("❌ 转换航班进出港通知数据失败: {}", dto, e);
return null;
}
}
/**
* 发布航班进出港通知WebSocket事件
*/
private void publishFlightNotificationEvent(FlightNotification notification) {
try {
// 使用已有的FlightNotificationEvent创建WebSocket事件
com.qaup.collision.websocket.event.FlightNotificationEvent event =
com.qaup.collision.websocket.event.FlightNotificationEvent.fromFlightNotification(notification);
if (event != null && event.isValid()) {
// 发布WebSocket事件
eventPublisher.publishEvent(event);
log.info("📡 发布航班进出港通知WebSocket事件: 航班号={}, 事件类型={}, 通知级别={}",
event.getFlightNo(), event.getEventType(), event.getNotificationLevel());
} else {
log.warn("⚠️ 航班进出港通知WebSocket事件创建失败或无效: {}", notification.getFlightNo());
}
} catch (Exception e) {
log.error("❌ 发布航班进出港通知WebSocket事件失败: flightNo={}", notification.getFlightNo(), e);
}
}
@PreDestroy
public void shutdown() {
log.info("DataCollectorService 正在关闭...");
// 清理资源如果需要
}
// 移除转换方法 - 现在由DataProcessingService处理
// 移除所有处理方法 - 现在由DataProcessingService处理
}

View File

@ -0,0 +1,179 @@
package com.qaup.collision.websocket.event;
import com.qaup.collision.common.model.FlightNotification;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 航班进出港通知WebSocket事件
*
* 用于向前端推送航班进出港通知包含航班的落地和滑出事件信息
*
* @author QAUP System
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FlightNotificationEvent {
/**
* 航班号
*/
private String flightNo;
/**
* 航班类型
* IN: 进港航班
* OUT: 出港航班
* ARRIVED: 停留状态
*/
private String flightType;
/**
* 跑道编号
*/
private String runway;
/**
* 联络道口
*/
private String contactCross;
/**
* 机位号
*/
private String seat;
/**
* 事件时间戳毫秒
*/
private Long eventTime;
/**
* 事件发生时间
*/
private LocalDateTime eventDateTime;
/**
* 事件描述
*/
private String eventDescription;
/**
* 事件类型
* LANDING: 航班落地
* TAKEOFF: 航班滑出
* ARRIVED: 航班停留
*/
private String eventType;
/**
* 通知级别
* INFO: 信息通知
* IMPORTANT: 重要通知
*/
private String notificationLevel;
/**
* 事件时间戳用于WebSocket推送
*/
private Long timestamp;
/**
* 从FlightNotification业务对象创建WebSocket事件
*/
public static FlightNotificationEvent fromFlightNotification(FlightNotification notification) {
if (notification == null) {
return null;
}
String eventType = getEventType(notification.getType());
String notificationLevel = getNotificationLevel(notification.getType());
return FlightNotificationEvent.builder()
.flightNo(notification.getFlightNo())
.flightType(notification.getType() != null ? notification.getType().name() : null)
.runway(notification.getRunway())
.contactCross(notification.getContactCross())
.seat(notification.getSeat())
.eventTime(notification.getEventTime())
.eventDateTime(notification.getEventDateTime())
.eventDescription(notification.getEventDescription())
.eventType(eventType)
.notificationLevel(notificationLevel)
.timestamp(System.currentTimeMillis())
.build();
}
/**
* 根据航班类型获取事件类型
*/
private static String getEventType(FlightNotification.FlightType flightType) {
if (flightType == null) {
return "UNKNOWN";
}
switch (flightType) {
case IN:
return "LANDING";
case OUT:
return "TAKEOFF";
case ARRIVED:
return "ARRIVED";
default:
return "UNKNOWN";
}
}
/**
* 根据航班类型获取通知级别
*/
private static String getNotificationLevel(FlightNotification.FlightType flightType) {
if (flightType == null) {
return "INFO";
}
switch (flightType) {
case IN:
case OUT:
return "IMPORTANT"; // 进出港事件为重要通知
case ARRIVED:
return "INFO"; // 停留状态为信息通知
default:
return "INFO";
}
}
/**
* 获取用于WebSocket消息类型的字符串
*/
public String getMessageType() {
return "FLIGHT_NOTIFICATION";
}
/**
* 检查事件是否有效
*/
public boolean isValid() {
return flightNo != null && !flightNo.trim().isEmpty()
&& flightType != null
&& eventType != null
&& timestamp != null;
}
/**
* 获取事件的简短描述用于日志或显示
*/
public String getShortDescription() {
return String.format("%s - %s (跑道: %s, 机位: %s)",
flightNo,
eventDescription != null ? eventDescription : eventType,
runway != null ? runway : "未知",
seat != null ? seat : "未知");
}
}

View File

@ -0,0 +1,84 @@
package com.qaup.collision.websocket.listener;
import com.qaup.collision.websocket.event.FlightNotificationEvent;
import com.qaup.collision.websocket.handler.CollisionWebSocketHandler;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* 航班进出港通知事件监听器
*
* 监听航班进出港通知事件并通过WebSocket推送给前端客户端
*
* @author QAUP System
* @version 1.0
* @since 2025-01-18
*/
@Slf4j
@Component
public class FlightNotificationEventListener {
@Autowired
private CollisionWebSocketHandler webSocketHandler;
@Autowired
@Qualifier("websocketObjectMapper")
private ObjectMapper objectMapper;
/**
* 处理航班进出港通知事件
*
* @param event 航班进出港通知事件
*/
@EventListener
public void handleFlightNotificationEvent(FlightNotificationEvent event) {
try {
// 验证事件是否有效
if (!event.isValid()) {
log.warn("接收到无效的航班进出港通知事件,跳过处理: {}", event.getFlightNo());
return;
}
// 构建WebSocket消息使用事件定义的消息类型
String message = objectMapper.writeValueAsString(new WebSocketMessage(
event.getMessageType(), // 使用 "FLIGHT_NOTIFICATION"
event,
event.getTimestamp()
));
// 广播消息给所有连接的客户端
webSocketHandler.broadcastMessage(message);
log.info("航班进出港通知事件已通过WebSocket推送: 航班号={}, 事件类型={}, 通知级别={}, 描述={}",
event.getFlightNo(),
event.getEventType(),
event.getNotificationLevel(),
event.getShortDescription());
} catch (Exception e) {
log.error("处理航班进出港通知事件失败: flightNo={}", event.getFlightNo(), e);
}
}
/**
* WebSocket消息包装类
*/
private static class WebSocketMessage {
@com.fasterxml.jackson.annotation.JsonProperty
public final String type;
@com.fasterxml.jackson.annotation.JsonProperty
public final Object payload;
@com.fasterxml.jackson.annotation.JsonProperty
public final long timestamp;
public WebSocketMessage(String type, Object payload, long timestamp) {
this.type = type;
this.payload = payload;
this.timestamp = timestamp;
}
}
}

View File

@ -0,0 +1,191 @@
package com.qaup.collision.datacollector.service;
import com.qaup.collision.datacollector.dao.DataCollectorDao;
import com.qaup.collision.datacollector.dto.FlightNotificationDTO;
import com.qaup.collision.common.model.FlightNotification;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* DataCollectorService航班进出港通知采集功能测试
* 验证航班进出港通知数据的采集转换和事件发布功能
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DataCollectorServiceFlightNotificationTest {
@Mock
private DataCollectorDao dataCollectorDao;
@Mock
private ApplicationEventPublisher eventPublisher;
@InjectMocks
private DataCollectorService dataCollectorService;
private String flightNotificationEndpoint;
private String airportBaseUrl;
@BeforeEach
void setUp() {
// 设置测试配置
flightNotificationEndpoint = "/openApi/getInboundAndOutboundFlightsNotification";
airportBaseUrl = "http://localhost:8090";
// 使用反射设置私有字段
ReflectionTestUtils.setField(dataCollectorService, "flightNotificationEndpoint", flightNotificationEndpoint);
ReflectionTestUtils.setField(dataCollectorService, "airportBaseUrl", airportBaseUrl);
ReflectionTestUtils.setField(dataCollectorService, "collectorDisabled", false);
ReflectionTestUtils.setField(dataCollectorService, "activeMovingObjectsCache", new ConcurrentHashMap<>());
}
@Test
void testCollectFlightNotificationData_Success() {
// 准备测试数据
FlightNotificationDTO dto1 = createTestFlightNotificationDTO("CA3456", "IN", "02R", "A01", System.currentTimeMillis());
FlightNotificationDTO dto2 = createTestFlightNotificationDTO("MU5678", "OUT", "02L", "B15", System.currentTimeMillis());
List<FlightNotificationDTO> mockNotifications = Arrays.asList(dto1, dto2);
// 模拟DAO调用
when(dataCollectorDao.getFlightNotifications(flightNotificationEndpoint, airportBaseUrl))
.thenReturn(mockNotifications);
// 执行测试方法
dataCollectorService.collectFlightNotificationData();
// 验证DAO方法被调用
verify(dataCollectorDao, times(1)).getFlightNotifications(flightNotificationEndpoint, airportBaseUrl);
// 验证事件发布器被调用应该发布2个事件
verify(eventPublisher, times(2)).publishEvent(any(com.qaup.collision.websocket.event.FlightNotificationEvent.class));
}
@Test
void testCollectFlightNotificationData_EmptyResult() {
// 模拟空结果
when(dataCollectorDao.getFlightNotifications(flightNotificationEndpoint, airportBaseUrl))
.thenReturn(Collections.emptyList());
// 执行测试方法
dataCollectorService.collectFlightNotificationData();
// 验证DAO方法被调用
verify(dataCollectorDao, times(1)).getFlightNotifications(flightNotificationEndpoint, airportBaseUrl);
// 验证没有事件被发布
verify(eventPublisher, never()).publishEvent(any(com.qaup.collision.websocket.event.FlightNotificationEvent.class));
}
@Test
void testCollectFlightNotificationData_WithInvalidData() {
// 准备测试数据包含无效数据
FlightNotificationDTO validDto = createTestFlightNotificationDTO("CA3456", "IN", "02R", "A01", System.currentTimeMillis());
FlightNotificationDTO invalidDto = createTestFlightNotificationDTO(null, "INVALID", null, null, null); // 无效数据
List<FlightNotificationDTO> mockNotifications = Arrays.asList(validDto, invalidDto);
// 模拟DAO调用
when(dataCollectorDao.getFlightNotifications(flightNotificationEndpoint, airportBaseUrl))
.thenReturn(mockNotifications);
// 执行测试方法
dataCollectorService.collectFlightNotificationData();
// 验证DAO方法被调用
verify(dataCollectorDao, times(1)).getFlightNotifications(flightNotificationEndpoint, airportBaseUrl);
// 验证只有1个有效事件被发布
verify(eventPublisher, times(1)).publishEvent(any(com.qaup.collision.websocket.event.FlightNotificationEvent.class));
}
@Test
void testCollectFlightNotificationData_DAOException() {
// 模拟DAO抛出异常
when(dataCollectorDao.getFlightNotifications(flightNotificationEndpoint, airportBaseUrl))
.thenThrow(new RuntimeException("API调用失败"));
// 执行测试方法应该不抛出异常由服务内部处理
dataCollectorService.collectFlightNotificationData();
// 验证DAO方法被调用
verify(dataCollectorDao, times(1)).getFlightNotifications(flightNotificationEndpoint, airportBaseUrl);
// 验证没有事件被发布
verify(eventPublisher, never()).publishEvent(any(com.qaup.collision.websocket.event.FlightNotificationEvent.class));
}
@Test
void testCollectFlightNotificationData_CollectorDisabled() {
// 设置采集器为禁用状态
ReflectionTestUtils.setField(dataCollectorService, "collectorDisabled", true);
// 执行测试方法
dataCollectorService.collectFlightNotificationData();
// 验证DAO方法没有被调用
verify(dataCollectorDao, never()).getFlightNotifications(anyString(), anyString());
// 验证没有事件被发布
verify(eventPublisher, never()).publishEvent(any(com.qaup.collision.websocket.event.FlightNotificationEvent.class));
}
@Test
void testCollectFlightNotificationData_InboundFlight() {
// 测试进港航班
FlightNotificationDTO dto = createTestFlightNotificationDTO("CA1234", "IN", "02R", "A01", System.currentTimeMillis());
List<FlightNotificationDTO> mockNotifications = Arrays.asList(dto);
when(dataCollectorDao.getFlightNotifications(flightNotificationEndpoint, airportBaseUrl))
.thenReturn(mockNotifications);
dataCollectorService.collectFlightNotificationData();
verify(dataCollectorDao, times(1)).getFlightNotifications(flightNotificationEndpoint, airportBaseUrl);
verify(eventPublisher, times(1)).publishEvent(any(com.qaup.collision.websocket.event.FlightNotificationEvent.class));
}
@Test
void testCollectFlightNotificationData_OutboundFlight() {
// 测试出港航班
FlightNotificationDTO dto = createTestFlightNotificationDTO("MU5678", "OUT", "02L", "B15", System.currentTimeMillis());
List<FlightNotificationDTO> mockNotifications = Arrays.asList(dto);
when(dataCollectorDao.getFlightNotifications(flightNotificationEndpoint, airportBaseUrl))
.thenReturn(mockNotifications);
dataCollectorService.collectFlightNotificationData();
verify(dataCollectorDao, times(1)).getFlightNotifications(flightNotificationEndpoint, airportBaseUrl);
verify(eventPublisher, times(1)).publishEvent(any(com.qaup.collision.websocket.event.FlightNotificationEvent.class));
}
/**
* 创建测试用的FlightNotificationDTO对象
*/
private FlightNotificationDTO createTestFlightNotificationDTO(String flightNo, String type, String runway, String seat, Long time) {
FlightNotificationDTO dto = new FlightNotificationDTO();
dto.setFlightNo(flightNo);
dto.setType(type);
dto.setRunway(runway);
dto.setSeat(seat);
dto.setTime(time);
dto.setContactCross("T1"); // 默认值
return dto;
}
}

View File

@ -0,0 +1,162 @@
package com.qaup.collision.datacollector.service;
import com.qaup.collision.common.model.dto.Response;
import com.qaup.collision.datacollector.dto.FlightNotificationDTO;
import org.junit.jupiter.api.Test;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
/**
* 航班进出港通知API直接测试
* 测试真实API连接认证和数据获取
*/
class FlightNotificationApiTest {
private static final String BASE_URL = "http://localhost:8090";
private static final String LOGIN_ENDPOINT = "/login";
private static final String FLIGHT_NOTIFICATION_ENDPOINT = "/openApi/getInboundAndOutboundFlightsNotification";
private static final String USERNAME = "dianxin";
private static final String PASSWORD = "dianxin@123";
@Test
void testDirectApiAccess() {
RestTemplate restTemplate = new RestTemplate();
System.out.println("=== 直接API访问测试 ===");
System.out.println("目标URL: " + BASE_URL);
System.out.println("航班通知端点: " + FLIGHT_NOTIFICATION_ENDPOINT);
System.out.println("认证信息: " + USERNAME + "/" + PASSWORD);
try {
// 1. 登录获取Token
System.out.println("\n1. 尝试登录获取Token...");
String token = login(restTemplate);
System.out.println("✓ 登录成功Token: " + token.substring(0, Math.min(20, token.length())) + "...");
// 2. 调用航班通知API
System.out.println("\n2. 调用航班进出港通知API...");
List<FlightNotificationDTO> notifications = getFlightNotifications(restTemplate, token);
System.out.println("✓ API调用成功返回数据数量: " + notifications.size());
// 3. 展示数据
if (notifications.isEmpty()) {
System.out.println("⚠ 当前没有航班进出港通知数据");
System.out.println(" 这说明API可以正常访问但是没有活跃的航班通知");
System.out.println(" 在实际应用中会显示debug日志'未获取到航班进出港通知数据'");
} else {
System.out.println("✓ 发现航班进出港通知数据:");
for (int i = 0; i < Math.min(5, notifications.size()); i++) {
FlightNotificationDTO dto = notifications.get(i);
System.out.println(String.format(" [%d] 航班: %s, 类型: %s, 跑道: %s, 机位: %s, 时间: %s",
i+1, dto.getFlightNo(), dto.getType(), dto.getRunway(), dto.getSeat(), dto.getTime()));
}
if (notifications.size() > 5) {
System.out.println(" ... 还有 " + (notifications.size() - 5) + " 条数据");
}
}
System.out.println("\n🎉 所有测试通过!航班进出港接口功能正常!");
} catch (Exception e) {
System.err.println("❌ 测试失败: " + e.getMessage());
System.err.println("可能的原因:");
System.err.println(" 1. API服务未启动 (http://localhost:8090)");
System.err.println(" 2. 认证信息错误");
System.err.println(" 3. 网络连接问题");
System.err.println(" 4. API端点变更");
e.printStackTrace();
}
}
private String login(RestTemplate restTemplate) {
String loginUrl = UriComponentsBuilder
.fromUriString(BASE_URL)
.path(LOGIN_ENDPOINT)
.queryParam("username", USERNAME)
.queryParam("password", PASSWORD)
.toUriString();
System.out.println(" 请求URL: " + loginUrl);
ResponseEntity<Response<String>> response = restTemplate.exchange(
loginUrl,
HttpMethod.POST,
null,
new ParameterizedTypeReference<Response<String>>() {}
);
if (response.getStatusCode().is2xxSuccessful()) {
Response<String> responseBody = response.getBody();
if (responseBody != null && responseBody.getData() != null) {
return responseBody.getData();
}
}
throw new RuntimeException("登录失败: " + response.getStatusCode());
}
private List<FlightNotificationDTO> getFlightNotifications(RestTemplate restTemplate, String token) {
String url = BASE_URL + FLIGHT_NOTIFICATION_ENDPOINT;
System.out.println(" 请求URL: " + url);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", token);
headers.set("Content-Type", "application/json");
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
ResponseEntity<Response<List<FlightNotificationDTO>>> response = restTemplate.exchange(
url,
HttpMethod.GET,
requestEntity,
new ParameterizedTypeReference<Response<List<FlightNotificationDTO>>>() {}
);
if (response.getStatusCode().is2xxSuccessful()) {
Response<List<FlightNotificationDTO>> responseBody = response.getBody();
if (responseBody != null && responseBody.getStatus() == 200 && responseBody.getData() != null) {
return responseBody.getData();
} else {
System.out.println(" 响应状态: " + (responseBody != null ? responseBody.getStatus() : "null"));
System.out.println(" 响应消息: " + (responseBody != null ? responseBody.getMsg() : "null"));
return List.of(); // 返回空列表而不是抛异常
}
} else {
throw new RuntimeException("API调用失败: " + response.getStatusCode());
}
}
@Test
void testApiConnectivity() {
System.out.println("=== API连通性测试 ===");
System.out.println("这个测试验证API服务是否可访问");
RestTemplate restTemplate = new RestTemplate();
try {
// 简单的连通性测试
String testUrl = BASE_URL + "/health"; // 尝试健康检查端点
restTemplate.getForEntity(testUrl, String.class);
System.out.println("✓ API服务可达");
} catch (Exception e) {
System.out.println("⚠ API服务可能未启动或无法访问");
System.out.println(" 错误: " + e.getMessage());
}
// 显示配置信息
System.out.println("\n当前配置:");
System.out.println(" 基础URL: " + BASE_URL);
System.out.println(" 登录端点: " + LOGIN_ENDPOINT);
System.out.println(" 航班通知端点: " + FLIGHT_NOTIFICATION_ENDPOINT);
System.out.println(" 用户名: " + USERNAME);
System.out.println(" 密码: " + PASSWORD);
System.out.println(" 完整航班通知URL: " + BASE_URL + FLIGHT_NOTIFICATION_ENDPOINT);
}
}

View File

@ -0,0 +1,222 @@
package com.qaup.collision.datacollector.service;
import com.qaup.collision.datacollector.dao.DataCollectorDao;
import com.qaup.collision.websocket.event.FlightNotificationEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.locationtech.jts.geom.GeometryFactory;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.RestTemplate;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* 航班进出港通知完整集成测试
* 测试从数据采集到事件发布的完整流程
* 如果任何环节有问题测试必须失败
*/
@ExtendWith(MockitoExtension.class)
class FlightNotificationIntegrationTest {
private DataCollectorService dataCollectorService;
private DataCollectorDao dataCollectorDao;
private AuthService authService;
@Mock
private ApplicationEventPublisher eventPublisher;
@Captor
private ArgumentCaptor<FlightNotificationEvent> eventCaptor;
@BeforeEach
void setUp() {
// 创建真实的服务实例连接真实API
RestTemplate restTemplate = new RestTemplate();
authService = new AuthService(restTemplate);
// 设置认证服务配置
ReflectionTestUtils.setField(authService, "username", "dianxin");
ReflectionTestUtils.setField(authService, "password", "dianxin@123");
ReflectionTestUtils.setField(authService, "baseUrl", "http://localhost:8090");
ReflectionTestUtils.setField(authService, "loginEndpoint", "/login");
ReflectionTestUtils.setField(authService, "refreshEndpoint", "/userInfoController/refreshToken");
// 创建DAO使用真实的AuthService
dataCollectorDao = new DataCollectorDao(restTemplate, authService);
// 创建DataCollectorService实例
dataCollectorService = new DataCollectorService();
// 注入依赖
ReflectionTestUtils.setField(dataCollectorService, "dataCollectorDao", dataCollectorDao);
ReflectionTestUtils.setField(dataCollectorService, "eventPublisher", eventPublisher);
ReflectionTestUtils.setField(dataCollectorService, "flightNotificationEndpoint", "/openApi/getInboundAndOutboundFlightsNotification");
ReflectionTestUtils.setField(dataCollectorService, "airportBaseUrl", "http://localhost:8090");
ReflectionTestUtils.setField(dataCollectorService, "collectorDisabled", false);
ReflectionTestUtils.setField(dataCollectorService, "activeMovingObjectsCache", new ConcurrentHashMap<>());
}
/**
* 测试完整的航班进出港通知处理流程
* 1. 调用真实API
* 2. 数据转换和验证
* 3. WebSocket事件发布
* 4. 验证事件内容
* 如果任何环节失败测试必须失败
*/
@Test
void testCompleteFlightNotificationProcessing() {
System.out.println("=== 航班进出港通知完整流程集成测试 ===");
System.out.println("1. 执行航班进出港通知数据采集...");
// 执行实际的数据采集方法这会调用真实API
dataCollectorService.collectFlightNotificationData();
System.out.println("2. 验证事件发布...");
// 验证事件发布如果API有数据必须发布事件如果没有数据不应该有事件
try {
verify(eventPublisher, atLeastOnce()).publishEvent(eventCaptor.capture());
List<FlightNotificationEvent> capturedEvents = eventCaptor.getAllValues();
assertFalse(capturedEvents.isEmpty(), "如果API返回数据必须发布事件");
System.out.println("✓ 检测到事件发布,共发布了 " + capturedEvents.size() + " 个事件");
System.out.println("3. 验证事件内容...");
// 验证每个事件的完整性
for (int i = 0; i < capturedEvents.size(); i++) {
FlightNotificationEvent event = capturedEvents.get(i);
System.out.println(String.format(" 事件[%d]: 航班=%s, 类型=%s, 跑道=%s, 机位=%s",
i+1, event.getFlightNo(), event.getEventType(), event.getRunway(), event.getSeat()));
// 严格验证必要字段
assertNotNull(event.getFlightNo(), "航班号不能为空");
assertFalse(event.getFlightNo().trim().isEmpty(), "航班号不能为空字符串");
assertNotNull(event.getEventType(), "事件类型不能为空");
assertNotNull(event.getNotificationLevel(), "通知级别不能为空");
assertNotNull(event.getTimestamp(), "时间戳不能为空");
// 验证业务逻辑
assertTrue(event.getEventType().equals("LANDING") || event.getEventType().equals("TAKEOFF"),
"事件类型必须是LANDING或TAKEOFF实际: " + event.getEventType());
assertEquals("IMPORTANT", event.getNotificationLevel(), "通知级别必须是IMPORTANT");
// 验证时间戳合理性
long now = System.currentTimeMillis();
long eventTime = event.getTimestamp();
assertTrue(Math.abs(now - eventTime) < 300000,
"事件时间戳应该在5分钟内实际差值: " + Math.abs(now - eventTime) + "ms");
}
System.out.println("✓ 事件内容验证通过");
System.out.println("4. 验证数据处理逻辑...");
// 验证事件类型与航班类型的对应关系
for (FlightNotificationEvent event : capturedEvents) {
if ("IN".equals(event.getFlightType())) {
assertEquals("LANDING", event.getEventType(),
"进港航班(IN)必须产生LANDING事件实际: " + event.getEventType());
assertNotNull(event.getEventDescription(), "事件描述不能为空");
} else if ("OUT".equals(event.getFlightType())) {
assertEquals("TAKEOFF", event.getEventType(),
"出港航班(OUT)必须产生TAKEOFF事件实际: " + event.getEventType());
assertNotNull(event.getEventDescription(), "事件描述不能为空");
} else {
fail("未知的航班类型: " + event.getFlightType());
}
}
System.out.println("✓ 数据处理逻辑验证通过");
System.out.println("🎉 航班进出港通知完整流程集成测试成功!");
} catch (Exception e) {
System.err.println("❌ 检测到问题:");
// 检查是否没有事件发布
try {
verify(eventPublisher, never()).publishEvent(any());
System.err.println(" 没有事件被发布");
System.err.println(" 可能原因:");
System.err.println(" 1. API返回空数据这是正常的");
System.err.println(" 2. 认证失败");
System.err.println(" 3. API连接问题");
System.err.println(" 4. 数据转换失败");
// 如果确实没有数据这不算测试失败
System.out.println("⚠ 当前API无数据但系统处理正常");
} catch (Exception verifyException) {
System.err.println(" 事件发布验证失败: " + e.getMessage());
fail("航班进出港通知处理流程失败: " + e.getMessage());
}
}
}
/**
* 测试无数据情况的处理
* 如果API返回空数据应该正常处理而不是报错
*/
@Test
void testEmptyDataHandling() {
System.out.println("=== 测试空数据处理 ===");
// 记录事件发布前的调用次数
int initialEventCount = mockingDetails(eventPublisher).getInvocations().size();
// 执行数据采集可能返回空数据
dataCollectorService.collectFlightNotificationData();
// 如果没有数据不应该发布新事件但也不应该抛异常
// 这个测试确保空数据情况被正确处理
System.out.println("✓ 空数据处理测试完成(无异常抛出)");
}
/**
* 测试配置和依赖注入
* 确保所有必要的组件都正确注入
*/
@Test
void testDependencyInjection() {
System.out.println("=== 测试依赖注入 ===");
assertNotNull(dataCollectorService, "DataCollectorService应该被正确注入");
assertNotNull(eventPublisher, "ApplicationEventPublisher应该被正确注入");
// 验证定时任务配置
// 注意这里只是验证Bean存在实际的定时任务调度由Spring管理
System.out.println("✓ 依赖注入验证通过");
System.out.println("✓ DataCollectorService实例: " + dataCollectorService.getClass().getSimpleName());
System.out.println("✓ EventPublisher实例: " + eventPublisher.getClass().getSimpleName());
}
/**
* 测试异常处理
* 确保在异常情况下不会导致整个应用崩溃
*/
@Test
void testExceptionHandling() {
System.out.println("=== 测试异常处理 ===");
try {
// 执行数据采集即使有异常也应该被妥善处理
dataCollectorService.collectFlightNotificationData();
System.out.println("✓ 数据采集方法执行完成,未抛出未捕获异常");
} catch (Exception e) {
fail("数据采集方法不应该抛出未捕获的异常: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,70 @@
package com.qaup.collision.test;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* 测试Spring Boot配置绑定规则
* 验证@Value和@ConfigurationProperties对连字符的不同处理
*/
@SpringBootTest
@TestPropertySource(properties = {
// 测试嵌套连字符配置
"test.simple-key=works", // 简单连字符
"test.nested.simple-key=works", // 嵌套一级连字符
"test.double-nested.sub-key.value=works", // 嵌套多级连字符
"test.flight-notification.interval=1000", // 我们的问题配置
"test.flightnotification.interval=2000" // 修复后的配置
})
class ConfigurationBindingTest {
// 简单连字符 - 应该工作
@Value("${test.simple-key:default}")
private String simpleKey;
// 嵌套一级连字符 - 应该工作
@Value("${test.nested.simple-key:default}")
private String nestedSimpleKey;
// 嵌套多级连字符 - 可能有问题
@Value("${test.double-nested.sub-key.value:default}")
private String doubleNestedKey;
// 我们的问题配置 - 可能有问题
@Value("${test.flight-notification.interval:default}")
private String flightNotificationInterval;
// 修复后的配置 - 应该工作
@Value("${test.flightnotification.interval:default}")
private String fixedFlightNotificationInterval;
@Test
void testConfigurationBinding() {
System.out.println("=== Spring Boot配置绑定测试 ===");
System.out.println("简单连字符配置: " + simpleKey);
System.out.println("嵌套一级连字符配置: " + nestedSimpleKey);
System.out.println("嵌套多级连字符配置: " + doubleNestedKey);
System.out.println("问题配置(flight-notification): " + flightNotificationInterval);
System.out.println("修复配置(flightnotification): " + fixedFlightNotificationInterval);
// 验证哪些配置能正确绑定
assert !"default".equals(simpleKey) : "简单连字符配置应该工作";
assert !"default".equals(nestedSimpleKey) : "嵌套一级连字符配置应该工作";
// 这些可能会失败证明我们的理论
if ("default".equals(flightNotificationInterval)) {
System.out.println("❌ 证实flight-notification配置绑定失败");
} else {
System.out.println("✅ flight-notification配置绑定成功");
}
if (!"default".equals(fixedFlightNotificationInterval)) {
System.out.println("✅ 证实flightnotification配置绑定成功");
} else {
System.out.println("❌ flightnotification配置绑定失败");
}
}
}

View File

@ -1,10 +1,10 @@
from flask import Flask, jsonify, request
import time
import math
import random
import logging
import os
import uuid
from typing import Any, Literal, final, TypedDict
# 创建 logs 目录(如果不存在)
if not os.path.exists('logs'):
@ -22,6 +22,30 @@ logging.basicConfig(
app = Flask(__name__)
# 为位置与移动对象定义类型,避免使用 Any
class Point(TypedDict):
latitude: float
longitude: float
# 必填字段
class MovingObjectRequired(TypedDict):
latitude: float
longitude: float
time: int
speed: float
moving_to_end: bool
start_point: Point
end_point: Point
# 可选字段total=False
class MovingObject(MovingObjectRequired, total=False):
flightNo: str
vehicleNo: str
wait_until: float
direction: float
# 地球半径(米)
EARTH_RADIUS = 6378137.0
@ -32,14 +56,14 @@ COMMAND_PRIORITIES = {
"RESUME": 1
}
def meters_to_degrees(meters, latitude):
def meters_to_degrees(meters: float, latitude: float) -> tuple[float, float]:
"""
将米转换为度考虑纬度的影响
"""
# 纬度方向1度 = 111,319.9米
meters_per_deg_lat = 111319.9
meters_per_deg_lat: float = 111319.9
# 经度方向1度 = 111,319.9 * cos(latitude)米
meters_per_deg_lon = meters_per_deg_lat * math.cos(math.radians(latitude))
meters_per_deg_lon: float = meters_per_deg_lat * math.cos(math.radians(latitude))
return meters / meters_per_deg_lat, meters / meters_per_deg_lon
# 距离配置(米)
@ -84,6 +108,35 @@ UNMANNED_B_END = {"longitude": 120.086263, "latitude": 36.370484} # 无
AIRCRAFT_SIZE_M = 30.0
VEHICLE_SIZE_M = 10.0
# 航班数据配置 - 用于进出港查询接口(简化版)
current_time = time.time()
flight_data = {
"CA8901": { # 出港航班
"flightNo": "CA8901",
"type": "OUT",
"runway": "35",
"contactCross": "L4",
"seat": "201",
"cycle_start": current_time,
"active_duration": 600, # 10分钟活跃期
"gap_duration": 10, # 10秒间隔期
"fixed_time": int(current_time * 1000), # 固定的滑出时间
"status": "active"
},
"MU2678": { # 进港航班
"flightNo": "MU2678",
"type": "IN",
"runway": "17",
"contactCross": "B3",
"seat": "156",
"cycle_start": current_time,
"active_duration": 900, # 15分钟活跃期
"gap_duration": 10, # 10秒间隔期
"fixed_time": int(current_time * 1000), # 固定的落地时间
"status": "active"
}
}
# CA3456 航空器路由参数配置
aircraft_route_params = {
"CA3456": {
@ -1259,32 +1312,48 @@ airport_vehicle_data = [v for v in vehicle_data if v["vehicleNo"] in ["鲁B123",
unmanned_vehicle_data = [v for v in vehicle_data if v["vehicleNo"] in ["鲁B567", "鲁B579"]] # 无人车
# 添加车辆状态类
@final
class VehicleState:
def __init__(self, vehicle_no):
vehicle_no: str
is_running: bool
current_command: str | None
command_priority: int
target_speed: float
brake_mode: Literal['emergency', 'normal'] | None
command_reason: str | None
last_command_time: float
traffic_light_state: str | None
target_lat: float | None
target_lon: float | None
is_traffic_light_stop: bool
def __init__(self, vehicle_no: str) -> None:
self.vehicle_no = vehicle_no
self.is_running = True # 运行状态
self.current_command = None # 当前执行的指令
self.command_priority = 0 # 当前指令优先级
self.target_speed = DEFAULT_VEHICLE_SPEED # 目标速度
self.target_speed = float(DEFAULT_VEHICLE_SPEED) # 目标速度
self.brake_mode = None # 制动模式:'emergency' 或 'normal'
self.command_reason = None # 指令原因
self.last_command_time = time.time() # 最后一次指令时间
self.last_command_time = float(time.time()) # 最后一次指令时间
self.traffic_light_state = None # 当前红绿灯状态
self.target_lat = None # 目标纬度
self.target_lon = None # 目标经度
self.is_traffic_light_stop = False # 是否因红灯停车
def can_be_overridden_by(self, command_type):
def can_be_overridden_by(self, command_type: str) -> bool:
"""判断当前指令是否可以被新指令覆盖"""
# 红绿灯信号不参与指令优先级判断
if command_type in ["RED", "GREEN", "YELLOW"]:
return True
new_priority = COMMAND_PRIORITIES.get(command_type, 0)
current_priority = COMMAND_PRIORITIES.get(self.current_command, 0)
# 类型安全获取优先级,避免 Unknown | None 作为 dict key
new_priority = COMMAND_PRIORITIES.get(str(command_type), 0)
current_key = self.current_command if isinstance(self.current_command, str) else None
current_priority = COMMAND_PRIORITIES.get(current_key or "", 0)
# 相同类型的指令可以覆盖(因为需要持续发送)
if command_type == self.current_command:
if isinstance(self.current_command, str) and command_type == self.current_command:
return True
# ALERT 指令可以被 RESUME 解除
@ -1298,7 +1367,7 @@ class VehicleState:
# 其他情况下,相同或更高优先级可以覆盖
return new_priority >= current_priority
def update_command(self, command_type, target_lat=None, target_lon=None):
def update_command(self, command_type: str, target_lat: float | None = None, target_lon: float | None = None) -> None:
"""更新指令状态"""
# 更新目标位置
if target_lat is not None and target_lon is not None:
@ -1332,7 +1401,7 @@ class VehicleState:
self.is_running = self.can_move()
print(f"更新车辆 {self.vehicle_no} 状态: command={self.current_command}, traffic_light={self.traffic_light_state}, priority={self.command_priority}, is_running={self.is_running}")
def can_move(self):
def can_move(self) -> bool:
"""检查车辆是否可以移动"""
# 如果有告警指令,不能移动
if self.current_command == "ALERT":
@ -1353,7 +1422,7 @@ class VehicleState:
# 其他情况可以移动
return True
def log_state(self):
def log_state(self) -> None:
"""记录当前车辆状态"""
print(f"""
车辆状态:
@ -1372,9 +1441,11 @@ class VehicleState:
# 添加车辆状态管理
vehicle_states = {}
for vehicle in vehicle_data:
vehicle_states[vehicle["vehicleNo"]] = VehicleState(vehicle["vehicleNo"])
vehicle_states[str(vehicle["vehicleNo"])] = VehicleState(str(vehicle["vehicleNo"]))
def calculate_distance_to_target(vehicle, target_lat, target_lon):
from collections.abc import Mapping
def calculate_distance_to_target(vehicle: Mapping[str, float], target_lat: float, target_lon: float) -> float:
"""计算车辆到目标位的距离(米)"""
# 转换为弧度
lat1_rad = math.radians(vehicle["latitude"])
@ -1392,10 +1463,21 @@ def calculate_distance_to_target(vehicle, target_lat, target_lon):
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
return EARTH_RADIUS * c
class RequestVehicleCommandPayload(TypedDict, total=False):
vehicleID: str
commandType: str
commandReason: str
signalState: str
latitude: float
longitude: float
transId: str
@app.route('/api/VehicleCommandInfo', methods=['POST'])
def handle_vehicle_command():
# 提前定义并注解,避免未绑定与类型未知
data: RequestVehicleCommandPayload = {}
try:
data = request.json
data = request.get_json(silent=True) or {}
vehicle_id = data.get("vehicleID")
command_type = data.get("commandType", "").upper()
reason = data.get("commandReason", "").upper()
@ -1454,12 +1536,10 @@ def handle_vehicle_command():
# 检查指令优先级并添加详细日志
check_command = signal_state if command_type == "SIGNAL" else command_type
can_override = vehicle_state.can_be_overridden_by(check_command)
print(f"指令优先级检查: vehicle={vehicle_id}, current_command={vehicle_state.current_command}, "
f"new_command={check_command}, can_override={can_override}")
print(f"指令优先级检查: vehicle={vehicle_id}, current_command={str(vehicle_state.current_command)}, new_command={check_command}, can_override={can_override}")
if not can_override:
print(f"指令优先级过低: vehicle={vehicle_id}, current_priority={vehicle_state.command_priority}, "
f"command={check_command}")
print(f"指令优先级过低: vehicle={vehicle_id}, current_priority={vehicle_state.command_priority}, command={check_command}")
return jsonify({
"code": 400,
"msg": "Command priority too low",
@ -1567,7 +1647,14 @@ def handle_vehicle_command():
# 删除了复杂的红绿灯和路口计算函数,因为已简化为往复运动逻辑
def update_position_with_vector(obj, target_point, start_point, speed, elapsed_time, return_to_start=False):
def update_position_with_vector(
obj: dict[str, Any],
target_point: "Point",
start_point: "Point",
speed: float,
elapsed_time: float,
return_to_start: bool = False
) -> bool:
"""
基于向量的位置更新逻辑
@ -1581,25 +1668,37 @@ def update_position_with_vector(obj, target_point, start_point, speed, elapsed_t
"""
# 检查是否在等待状态
if "wait_until" not in obj:
obj["wait_until"] = 0
obj["wait_until"] = 0.0
current_time = time.time()
if current_time < obj["wait_until"]:
# 基于类型安全读取 wait_until避免 float 与 object 比较
_wu = to_float_safe(obj.get("wait_until"))
wait_until = 0.0 if _wu is None else float(_wu)
if current_time < wait_until:
return False # 还在等待中,不更新位置
# 计算这一步要移动的距离(米)并转换为经纬度
speed_mps = speed * 1000 / 3600
distance = speed_mps * elapsed_time
speed_mps = float(speed) * 1000.0 / 3600.0
distance = speed_mps * float(elapsed_time)
# 安全读取并转换当前位置与目标点/起点经纬度为 float
cur_lat = to_float_safe(obj.get("latitude"))
cur_lon = to_float_safe(obj.get("longitude"))
tgt_lat = to_float_safe(target_point.get("latitude"))
tgt_lon = to_float_safe(target_point.get("longitude"))
if cur_lat is None or cur_lon is None or tgt_lat is None or tgt_lon is None:
# 无法解析经纬度,跳过更新
return False
# 将移动距离转换为经纬度变化量
move_dlat, move_dlon = meters_to_degrees(distance, obj["latitude"])
move_dlat, move_dlon = meters_to_degrees(distance, cur_lat)
# 将5米的判断距离转换为经纬度优化从15米减少到5米减少颤抖
check_dlat, check_dlon = meters_to_degrees(5, obj["latitude"])
check_dlat, check_dlon = meters_to_degrees(5.0, cur_lat)
# 计算当前位置到终点的向量
vector_lat = target_point["latitude"] - obj["latitude"]
vector_lon = target_point["longitude"] - obj["longitude"]
# 计算当前位置到终点的向量(经纬度空间)
vector_lat = float(tgt_lat) - float(cur_lat)
vector_lon = float(tgt_lon) - float(cur_lon)
# 计算向量长度(经纬度空间)
vector_length = math.sqrt(vector_lat * vector_lat + vector_lon * vector_lon)
@ -1607,8 +1706,8 @@ def update_position_with_vector(obj, target_point, start_point, speed, elapsed_t
# 检查是否到达终点
if vector_length <= math.sqrt(check_dlat * check_dlat + check_dlon * check_dlon):
# 精确设置到目标点位置,避免位置偏差
obj["latitude"] = target_point["latitude"]
obj["longitude"] = target_point["longitude"]
obj["latitude"] = float(tgt_lat)
obj["longitude"] = float(tgt_lon)
if return_to_start:
# 返回起点并设置等待时间
@ -1628,12 +1727,12 @@ def update_position_with_vector(obj, target_point, start_point, speed, elapsed_t
dlat = move_dlat * unit_lat
dlon = move_dlon * unit_lon
# 更新位置
obj["latitude"] += dlat
obj["longitude"] += dlon
# 更新位置(写回为 float
obj["latitude"] = float(cur_lat + dlat)
obj["longitude"] = float(cur_lon + dlon)
return False # 表示正在移动中
def update_object_position(obj, elapsed_time):
def update_object_position(obj: dict[str, Any], elapsed_time: float) -> None:
"""统一的位置更新函数 - 用于飞机和车辆的往复运动"""
# 获取对象类型和标识
obj_type = "航空器" if "flightNo" in obj else "车辆"
@ -1641,17 +1740,37 @@ def update_object_position(obj, elapsed_time):
# 根据移动方向确定目标点
if obj["moving_to_end"]:
target_point = obj["end_point"]
target_raw = obj["end_point"]
start_raw = obj["start_point"]
else:
target_point = obj["start_point"]
target_raw = obj["start_point"]
start_raw = obj["end_point"]
# 构造类型安全的 Point
t_lat = to_float_safe(target_raw.get("latitude")) if isinstance(target_raw, dict) else None
t_lon = to_float_safe(target_raw.get("longitude")) if isinstance(target_raw, dict) else None
s_lat = to_float_safe(start_raw.get("latitude")) if isinstance(start_raw, dict) else None
s_lon = to_float_safe(start_raw.get("longitude")) if isinstance(start_raw, dict) else None
if t_lat is None or t_lon is None or s_lat is None or s_lon is None:
# 无法解析坐标,直接更新时间戳后返回
obj["time"] = int(time.time() * 1000)
return
target_point: Point = {"latitude": float(t_lat), "longitude": float(t_lon)}
start_point: Point = {"latitude": float(s_lat), "longitude": float(s_lon)}
# 读取速度为 float
spd = to_float_safe(obj.get("speed"))
if spd is None:
spd = 0.0
# 更新位置
reached_target = update_position_with_vector(
obj,
target_point,
obj["start_point"] if obj["moving_to_end"] else obj["end_point"],
obj["speed"],
elapsed_time,
start_point,
float(spd),
float(elapsed_time),
return_to_start=False
)
@ -1665,11 +1784,11 @@ def update_object_position(obj, elapsed_time):
# 更新时间戳
obj["time"] = int(time.time() * 1000)
def update_aircraft_position(aircraft, elapsed_time):
def update_aircraft_position(aircraft: dict[str, Any], elapsed_time: float) -> None:
"""更新航空器位置 - 使用统一的位置更新函数"""
update_object_position(aircraft, elapsed_time)
def update_vehicle_position(vehicle, elapsed_time):
def update_vehicle_position(vehicle: dict[str, Any], elapsed_time: float) -> None:
"""更新车辆位置 - 使用统一的位置更新函数"""
update_object_position(vehicle, elapsed_time)
@ -1689,7 +1808,15 @@ INTERSECTIONS = {
}
}
def generate_traffic_light_data():
# T6路口定义
T6_INTERSECTION = {
"id": "T6",
"name": "T6路口",
"latitude": 36.372000,
"longitude": 120.085000
}
def generate_traffic_light_data() -> list[dict[str, Any]]:
"""生成红绿灯数据"""
traffic_light_data = []
@ -1718,7 +1845,7 @@ last_vehicle_update_time = time.time() # 机场车辆更新时间
last_unmanned_vehicle_update_time = time.time() # 无人车更新时间
last_light_switch_time = time.time()
def check_auth():
def check_auth() -> bool:
auth_header = request.headers.get('Authorization')
if not auth_header:
@ -1738,6 +1865,31 @@ def check_auth():
#print(f"认证结果: {result}")
return result
def to_float_safe(x: object) -> float | None:
"""
安全将值转换为 float:
- 允许 int/float
- 允许非布尔的数字字符串
- 允许 dict优先取 'value''longitude''latitude' 等常见键
- 无法解析则返回 None
"""
# 排除布尔值bool 是 int 的子类,但不应当被视为数值)
if isinstance(x, bool):
return None
if isinstance(x, (int, float)):
return float(x)
if isinstance(x, str):
try:
return float(x.strip())
except Exception:
return None
if isinstance(x, dict):
for key in ("value", "longitude", "latitude"):
if key in x:
return to_float_safe(x[key])
return None
return None
@app.route('/openApi/getCurrentFlightPositions', methods=['GET', 'OPTIONS'])
def get_flight_positions():
"""获取当前航空器位置信息"""
@ -1764,11 +1916,13 @@ def get_flight_positions():
# 创建符合 API 格式的响应数据
response_data = []
for aircraft in aircraft_data:
lon_val = to_float_safe(aircraft.get("longitude"))
lat_val = to_float_safe(aircraft.get("latitude"))
api_aircraft = {
"flightNo": aircraft["flightNo"],
"longitude": round(aircraft["longitude"], 6), # 经纬度保留6位小数
"latitude": round(aircraft["latitude"], 6), # 经纬度保留6位小数
"time": aircraft["time"]
"flightNo": aircraft.get("flightNo"),
"longitude": round(lon_val, 6) if lon_val is not None else aircraft.get("longitude"),
"latitude": round(lat_val, 6) if lat_val is not None else aircraft.get("latitude"),
"time": aircraft.get("time")
}
response_data.append(api_aircraft)
@ -1778,7 +1932,7 @@ def get_flight_positions():
"data": response_data
})
def switch_traffic_light_state():
def switch_traffic_light_state() -> None:
"""统一处理红绿灯状态切换"""
global last_light_switch_time
current_time = time.time()
@ -1808,10 +1962,18 @@ def switch_traffic_light_state():
# 更新东路口红绿灯(根据航空器位置)
if aircraft_data:
aircraft = aircraft_data[0]
lat_diff = abs(aircraft["latitude"] - T6_INTERSECTION["latitude"]) * 111319.9
# 安全获取并转换纬度为浮点数,避免类型错误
aircraft_lat = to_float_safe(aircraft.get("latitude"))
t6_lat = to_float_safe(T6_INTERSECTION.get("latitude"))
if aircraft_lat is not None and t6_lat is not None:
lat_diff = abs(aircraft_lat - t6_lat) * 111319.9 # 转米
else:
# 无法解析时默认认为距离较大,避免误判为红灯
lat_diff = float('inf')
old_state = traffic_light_data[1]["state"]
traffic_light_data[1]["state"] = 1 if lat_diff > DIST_50M else 1
# 大于50米为绿灯(1),否则红灯(0)
traffic_light_data[1]["state"] = 1 if lat_diff > DIST_50M else 0
if old_state != traffic_light_data[1]["state"]:
print(f"东路口红绿灯状态切换为: {'绿灯' if traffic_light_data[1]['state'] == 1 else '红灯'}")
@ -1839,10 +2001,12 @@ def get_vehicle_positions():
last_vehicle_update_time = current_time
response_data = []
for v in airport_vehicle_data:
lon_val = to_float_safe(v.get("longitude"))
lat_val = to_float_safe(v.get("latitude"))
v_out = {
"vehicleNo": v.get("vehicleNo"),
"longitude": round(v.get("longitude"), 6), # 经纬度保留6位小数
"latitude": round(v.get("latitude"), 6), # 经纬度保留6位小数
"longitude": round(lon_val, 6) if lon_val is not None else v.get("longitude"),
"latitude": round(lat_val, 6) if lat_val is not None else v.get("latitude"),
"time": v.get("time")
}
response_data.append(v_out)
@ -1983,25 +2147,27 @@ def get_vehicle_status():
})
# 无人车位置数据生成函数
def generate_unmanned_vehicle_location_data():
def generate_unmanned_vehicle_location_data() -> list[dict[str, Any]]:
"""生成无人车位置数据符合官方API格式"""
unmanned_vehicles = []
# 从现有vehicle_data中筛选无人车QN开头的车辆
for vehicle in unmanned_vehicle_data:
lon_val = to_float_safe(vehicle.get("longitude"))
lat_val = to_float_safe(vehicle.get("latitude"))
location_info = {
"transId": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"vehicleID": vehicle["vehicleNo"],
"longitude": round(vehicle.get("longitude"), 6), # 经纬度保留6位小数
"latitude": round(vehicle.get("latitude"), 6) # 经纬度保留6位小数
"longitude": round(lon_val, 6) if lon_val is not None else vehicle.get("longitude"),
"latitude": round(lat_val, 6) if lat_val is not None else vehicle.get("latitude")
}
unmanned_vehicles.append(location_info)
return unmanned_vehicles
# 无人车状态数据生成函数
def generate_unmanned_vehicle_state_data(vehicle_id=None, is_single=True):
def generate_unmanned_vehicle_state_data(vehicle_id: str | None = None, is_single: bool = True) -> list[dict[str, Any]]:
"""生成无人车状态数据符合官方API格式"""
vehicle_states_data = []
@ -2100,20 +2266,39 @@ def get_unmanned_vehicle_state():
print(f"Error in get_unmanned_vehicle_state: {str(e)}")
return jsonify([]), 500
def update_ca3456_status():
def update_ca3456_status() -> None:
"""更新CA3456航空器状态 - 进港->停留1分钟->出港循环"""
global ca3456_status
current_time = time.time()
elapsed_time = current_time - ca3456_status["status_start_time"]
# 安全转换状态起始时间,避免与 float 相减的类型冲突
status_start_time = to_float_safe(ca3456_status.get("status_start_time"))
if status_start_time is None:
# 无法解析时,用当前时间作为起点,避免负值
status_start_time = current_time
ca3456_status["status_start_time"] = status_start_time
elapsed_time = float(current_time) - float(status_start_time)
# 计算当前在循环中的位置
cycle_time = elapsed_time % ca3456_status["cycle_duration"]
# 读取并安全转换周期与阶段时长,避免 str | int | float 与 float 的 %/比较类型问题
cycle_duration = to_float_safe(ca3456_status.get("cycle_duration"))
arrival_duration = to_float_safe(ca3456_status.get("arrival_duration"))
wait_duration = to_float_safe(ca3456_status.get("wait_duration"))
if cycle_time < ca3456_status["arrival_duration"]:
# 兜底默认值:若无法解析则使用已有注释中的默认秒数
if cycle_duration is None or cycle_duration <= 0:
cycle_duration = 77.0
if arrival_duration is None or arrival_duration < 0:
arrival_duration = 30.0
if wait_duration is None or wait_duration < 0:
wait_duration = 15.0
# 计算当前在循环中的位置(均为 float
cycle_time = float(elapsed_time) % float(cycle_duration)
if cycle_time < arrival_duration:
# 进港阶段
ca3456_status["type"] = "IN"
logging.info(f"CA3456 进港阶段: {cycle_time:.1f}s")
elif cycle_time < ca3456_status["arrival_duration"] + ca3456_status["wait_duration"]:
elif cycle_time < (arrival_duration + wait_duration):
# 停留阶段
ca3456_status["type"] = "ARRIVED"
logging.info(f"CA3456 停留阶段: {cycle_time:.1f}s")
@ -2204,6 +2389,98 @@ def get_departure_taxiway_route():
"data": aircraft_routes["departure"]
})
def get_active_flights() -> list[dict[str, Any]]:
"""获取当前活跃的进出港航班(简化版)"""
current_time = time.time()
active_flights = []
for flight_no, flight_info in flight_data.items():
# 安全读取并转换为数值
cycle_start = to_float_safe(flight_info.get("cycle_start"))
active_duration = to_float_safe(flight_info.get("active_duration"))
gap_duration = to_float_safe(flight_info.get("gap_duration"))
# 兜底默认值
if cycle_start is None:
cycle_start = current_time
if active_duration is None or active_duration <= 0:
active_duration = 600.0
if gap_duration is None or gap_duration < 0:
gap_duration = 10.0
# 计算从周期开始的时间与总周期
elapsed_time = float(current_time) - float(cycle_start)
total_cycle = float(active_duration) + float(gap_duration)
# 避免除零
if total_cycle <= 0:
total_cycle = float(active_duration) if active_duration > 0 else 1.0
# 计算在当前周期中的位置
cycle_position = float(elapsed_time) % float(total_cycle)
# 检查是否在活跃期内
if cycle_position <= float(active_duration):
# 在活跃期内,直接返回固定信息
flight_data_response = {
"flightNo": flight_info.get("flightNo"),
"type": flight_info.get("type"),
"runway": flight_info.get("runway"),
"contactCross": flight_info.get("contactCross"),
"seat": flight_info.get("seat"),
"time": flight_info.get("fixed_time") # 使用固定时间
}
active_flights.append(flight_data_response)
event_type = "落地" if flight_info.get("type") == "IN" else "滑出"
logging.info(f"航班 {flight_no} {event_type}通知中 (活跃期: {cycle_position:.1f}s/{int(active_duration)}s)")
else:
# 在间隔期内,更新固定时间为下一个周期
cycle_number = int(float(elapsed_time) // float(total_cycle)) + 1
next_cycle_start = float(cycle_start) + cycle_number * float(total_cycle)
if flight_info.get("type") == "OUT":
flight_info["fixed_time"] = int((next_cycle_start + 30.0) * 1000) # 出港滑出时间
else:
flight_info["fixed_time"] = int((next_cycle_start + 20.0) * 1000) # 进港落地时间
gap_part = cycle_position - float(active_duration)
logging.info(f"航班 {flight_no} 在间隔期,准备下一个周期 (间隔期: {gap_part:.1f}s/{int(gap_duration)}s)")
return active_flights
@app.route('/openApi/getInboundAndOutboundFlightsNotification', methods=['GET', 'OPTIONS'])
def get_flights():
"""进出港航班查询接口Mock"""
if request.method == 'OPTIONS':
return '', 204
if not check_auth():
return jsonify({
"status": 401,
"msg": "认证失败",
"data": None
}), 401
try:
# 获取当前活跃航班
active_flights = get_active_flights()
logging.info(f"进出港航班查询: 返回 {len(active_flights)} 个活跃航班")
return jsonify({
"status": 200,
"msg": "进出港航班查询成功",
"data": active_flights
})
except Exception as e:
logging.error(f"进出港航班查询失败: {str(e)}")
return jsonify({
"status": 500,
"msg": f"查询失败: {str(e)}",
"data": []
}), 500
@app.route('/aircraftStatusController/getAircraftStatus', methods=['GET', 'OPTIONS'])
def get_aircraft_status():
"""获取航空器状态"""

View File

@ -120,6 +120,7 @@
<div><strong>红绿灯状态:</strong> <span id="trafficLightStatusCount">0</span></div>
<div><strong>路口红绿灯:</strong> <span id="intersectionTrafficLightCount">0</span></div>
<div><strong>航空器路由:</strong> <span id="aircraftRouteCount">0</span></div>
<div><strong>航班进出港:</strong> <span id="flightNotificationCount">0</span></div>
<div><strong>路径冲突:</strong> <span id="pathConflictCount">0</span></div>
<div><strong>车辆指令:</strong> <span id="vehicleCommandCount">0</span></div>
<div><strong>规则执行状态:</strong> <span id="ruleExecutionCount">0</span></div>
@ -184,6 +185,7 @@
traffic_light_status: 0,
intersection_traffic_light_status: 0,
aircraftRouteUpdate: 0,
flight_notification: 0,
path_conflict_alert: 0,
vehicle_command: 0,
rule_execution_status: 0,
@ -228,6 +230,7 @@
document.getElementById('trafficLightStatusCount').textContent = messageStats.traffic_light_status;
document.getElementById('intersectionTrafficLightCount').textContent = messageStats.intersection_traffic_light_status;
document.getElementById('aircraftRouteCount').textContent = messageStats.aircraftRouteUpdate;
document.getElementById('flightNotificationCount').textContent = messageStats.flight_notification;
document.getElementById('pathConflictCount').textContent = messageStats.path_conflict_alert;
document.getElementById('vehicleCommandCount').textContent = messageStats.vehicle_command;
document.getElementById('ruleExecutionCount').textContent = messageStats.rule_execution_status;
@ -378,6 +381,21 @@
const routeStatus = message.payload?.status || '未知状态';
log('collisionLog', `航空器路由更新: ${flightNo} - ${routeType} (${routeStatus})`, 'info');
break;
case 'flight_notification':
case 'FLIGHT_NOTIFICATION':
const notificationFlightNo = message.payload?.flightNo || message.flightNo || '未知航班';
const notificationFlightType = message.payload?.flightType || message.flightType || '未知类型';
const notificationEventType = message.payload?.eventType || message.eventType || '未知事件';
const notificationRunway = message.payload?.runway || message.runway || '未知跑道';
const notificationSeat = message.payload?.seat || message.seat || '未知机位';
const notificationLevel = message.payload?.notificationLevel || message.notificationLevel || 'INFO';
const notificationDescription = message.payload?.eventDescription || message.eventDescription || '无描述';
// 根据通知级别设置日志类型
const logType = notificationLevel === 'IMPORTANT' ? 'warning' : 'info';
log('collisionLog', `✈️ 航班进出港通知: ${notificationFlightNo} - ${notificationEventType} (${notificationFlightType}) | 跑道:${notificationRunway} 机位:${notificationSeat} | ${notificationDescription}`, logType);
break;
case 'path_conflict_alert':
const object1 = message.payload?.object1Name || '未知对象1';
const object2 = message.payload?.object2Name || '未知对象2';
@ -429,6 +447,7 @@
document.getElementById('trafficLightStatusCount').textContent = '0';
document.getElementById('intersectionTrafficLightCount').textContent = '0';
document.getElementById('aircraftRouteCount').textContent = '0';
document.getElementById('flightNotificationCount').textContent = '0';
document.getElementById('pathConflictCount').textContent = '0';
document.getElementById('vehicleCommandCount').textContent = '0';
document.getElementById('ruleExecutionCount').textContent = '0';