481 lines
26 KiB
Markdown
481 lines
26 KiB
Markdown
# AIS 多源采集、冲突记录与聚合接口计划
|
||
|
||
**状态**:v0-v3 已实现,v3.1-v3.4 为 v4/v5 前置稳定化任务,v4 / v5 已落最小可用子集
|
||
**创建日期**:2026-04-30
|
||
**核心原则**:采集器只写原始观测;去重、合并、冲突解释放在聚合接口中完成
|
||
|
||
## 已确认决策
|
||
|
||
| 项目 | 决策 |
|
||
|-----|------|
|
||
| AISStream 接入方式 | 单独实现 WebSocket 采集器,不塞进现有 BarentsWatch HTTP collector |
|
||
| 采集器职责 | 连接上游、标准化字段、写入原始观测,不直接决定最终展示值 |
|
||
| 去重合并位置 | 放在聚合服务和聚合 API 中,而不是散落在每个 collector 的保存逻辑里 |
|
||
| 冲突处理 | 先记录冲突事实和当前选择原因,后续再开放用户规则配置 |
|
||
| 默认可信度 | 同类 AIS 数据源优先按 `delivery_mode` 评估:`realtime_stream` 优于 `batch_stream`,再优于 `polling` 和 `snapshot` |
|
||
| 过期保护 | 实时流源断流超过 freshness 窗口后,不能仅凭“实时源”身份压过更新的轮询数据 |
|
||
| 源健康状态 | 聚合时必须参考采集器健康状态,不能只看配置中的理论优先级 |
|
||
| 媒体富化 | 船只图片等媒体信息不进入 AIS 实时聚合主链路,后续单独做 enrichment |
|
||
| v4/v5 顺序 | 在聚合完整性、AISStream 实时链路、采集状态语义和基础身份信息显示修好之前,不进入策略配置和 enrichment UI |
|
||
|
||
## 背景
|
||
|
||
当前 AIS 链路以 BarentsWatch 为主。它是 HTTP polling 模式,覆盖挪威附近海域,适合作为稳定的免费起点,但不适合承担全球实时船只数据的全部职责。后续接入 AISStream 后,会出现同一个 MMSI 被多个来源同时上报的情况:
|
||
|
||
- 位置、航速、航向可能在多个来源之间存在秒级差异。
|
||
- 船名、IMO、呼号、船型、尺寸等静态字段可能不完整,甚至互相冲突。
|
||
- WebSocket 或其他实时流通常更接近实时,但也可能断流或批量延迟。
|
||
- 如果每个 collector 自己做去重合并,规则会分散、不可审计,也很难让用户后续配置“某个字段信任哪个来源”。
|
||
|
||
因此第一阶段不应让采集器直接覆盖最终船只表。更稳的方式是先保留观测事实,再由聚合接口统一给出当前展示视图。
|
||
|
||
## 目标架构
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A[BarentsWatch HTTP collector] --> D[AIS raw observations]
|
||
B[AISStream WebSocket collector] --> D
|
||
C[Custom mapped vessel_ais sources] --> D
|
||
D --> E[AIS aggregation service]
|
||
E --> F[Conflict records]
|
||
E --> G[GeoJSON vessels API]
|
||
E --> H[Vessel detail API]
|
||
I[Aggregation strategy config] --> E
|
||
```
|
||
|
||
### 原始观测层
|
||
|
||
原始观测层保存每个来源看到的事实。建议模型包含:
|
||
|
||
| 字段 | 用途 |
|
||
|-----|------|
|
||
| `target_schema` | 例如 `vessel_ais` |
|
||
| `source` | 例如 `barentswatch_vessels`、`aisstream_vessels` |
|
||
| `entity_key` | AIS 使用 MMSI |
|
||
| `delivery_mode` | `realtime_stream`、`batch_stream`、`polling`、`snapshot` |
|
||
| `transport` | `websocket`、`sse`、`http`、`file` 等 |
|
||
| `observed_at` | 上游数据时间,优先使用 AIS 消息时间 |
|
||
| `collected_at` | 本系统接收或采集时间 |
|
||
| `source_message_id` | 上游消息 ID 或可推导 ID,没有则为空 |
|
||
| `observation_hash` | 幂等去重指纹,用于防止同一来源重复写入同一条观测 |
|
||
| `normalized_payload` | 标准化后的 AIS JSON |
|
||
| `raw_payload` | 可选,保存原始或裁剪后的上游记录 |
|
||
| `quality_flags` | 观测级质量标记,例如 `stale`、`position_jump`、`future_timestamp` |
|
||
|
||
`delivery_mode` 和 `transport` 不应混为一谈。WebSocket 是传输方式;streaming 是交付模式。聚合可信度主要看 `delivery_mode`,`transport` 只作为辅助信息。
|
||
|
||
原始观测层需要做存储级幂等去重,但这里的去重不是业务合并。推荐使用 `source + entity_key + message_type + observed_at + payload_hash` 或上游稳定消息 ID 作为唯一约束,避免 WebSocket 重连、HTTP 重试或批量回放导致同一事实重复入库。
|
||
|
||
### 源健康状态
|
||
|
||
每个采集器应维护独立的健康状态,供聚合服务读取:
|
||
|
||
| 字段 | 用途 |
|
||
|-----|------|
|
||
| `source` | 采集器标识 |
|
||
| `connection_state` | `connected`、`reconnecting`、`disconnected`、`disabled` 等 |
|
||
| `last_seen_at` | 最近收到上游消息或响应的时间 |
|
||
| `last_success_at` | 最近成功写入观测的时间 |
|
||
| `last_error` | 最近错误摘要 |
|
||
| `message_rate` | 最近窗口内的消息速率 |
|
||
| `lag_seconds` | 上游观测时间与本系统接收时间的延迟 |
|
||
|
||
聚合优先级不能只看 `source_priority`。例如 `aisstream_vessels` 默认优先于 `barentswatch_vessels`,但如果它处于 `disconnected` 或 `lag_seconds` 超过 freshness 窗口,则动态字段应回退到更新的可用来源。
|
||
|
||
### 身份键边界
|
||
|
||
v1 可以继续用 MMSI 作为 `entity_key`,因为它是 AIS 动态消息里最稳定、最容易获得的主键。但文档和模型都要为后续扩展留出口:MMSI 可能复用、填错或缺少静态信息,后续身份解析应结合 `mmsi + imo + callsign + name + dimensions` 判断是否需要拆分或合并实体。
|
||
|
||
### 冲突记录层
|
||
|
||
聚合服务发现同一个实体、同一个字段存在多个非空不同值时,写入冲突记录。冲突记录不代表错误,只代表“有多个可用候选值”。
|
||
|
||
```json
|
||
{
|
||
"target_schema": "vessel_ais",
|
||
"entity_key": "257123000",
|
||
"field": "name",
|
||
"candidates": {
|
||
"barentswatch_vessels": "OSLO TRADER",
|
||
"aisstream_vessels": "OSLO TRADER II"
|
||
},
|
||
"selected_source": "aisstream_vessels",
|
||
"selected_value": "OSLO TRADER II",
|
||
"selected_reason": "delivery_mode_priority",
|
||
"resolved_by": "system",
|
||
"status": "open"
|
||
}
|
||
```
|
||
|
||
第一阶段只需要记录冲突和当前选择原因,不需要做人工逐条确认。后续 UI 的目标也不是让用户处理每条冲突,而是把冲突沉淀成字段级规则。
|
||
|
||
## 聚合规则
|
||
|
||
### 字段分类
|
||
|
||
| 类型 | 字段 | 默认策略 |
|
||
|-----|------|----------|
|
||
| 动态位置 | `lat`、`lon`、`sog`、`cog`、`heading`、`nav_status` | 优先最新 `observed_at`,同时间再按来源优先级 |
|
||
| 静态身份 | `name`、`callsign`、`imo`、`flag` | 非空优先,再按字段策略或来源优先级 |
|
||
| 静态规格 | `vessel_type`、`vessel_type_name`、`length`、`width`、`draught` | 非空优先;冲突时记录候选值 |
|
||
| 轨迹点 | `track_points` | 按时间线合并;同一时间窗口内相近点去重;保留点级 `source` |
|
||
| 元信息 | `field_sources`、`conflict_count`、`selected_reasons`、`quality_flags` | 聚合接口生成,便于调试和后续 UI 展示 |
|
||
|
||
### 默认优先级
|
||
|
||
默认优先级应使用两个维度:
|
||
|
||
```yaml
|
||
delivery_mode_priority:
|
||
- realtime_stream
|
||
- batch_stream
|
||
- polling
|
||
- snapshot
|
||
|
||
transport_priority:
|
||
- websocket
|
||
- sse
|
||
- http
|
||
- file
|
||
```
|
||
|
||
`delivery_mode_priority` 是主判断。比如 AISStream 如果提供实时推送,应标记为 `realtime_stream + websocket`;BarentsWatch 当前是 `polling + http`。
|
||
|
||
### 断流保护
|
||
|
||
实时流不能永久凭身份占优。聚合时需要 freshness 窗口:
|
||
|
||
```yaml
|
||
freshness:
|
||
realtime_stream_seconds: 900
|
||
polling_seconds: 3600
|
||
```
|
||
|
||
如果 `aisstream_vessels` 最近 15 分钟没有该 MMSI 的新观测,而 BarentsWatch 轮询源有更新位置,则位置类字段应采用 BarentsWatch 的更新观测,并记录选择原因 `newest_observation` 或 `freshness_fallback`。
|
||
|
||
### 异常位置保护
|
||
|
||
多源 AIS 接入后,聚合服务必须过滤或降权明显异常的位置观测:
|
||
|
||
- 经纬度必须在合法范围内。
|
||
- `observed_at` 不能明显来自未来。
|
||
- 同一 MMSI 短时间内跨越不合理距离时,标记 `position_jump`,默认不直接采用该点。
|
||
- 当异常点来自当前优先源时,应记录 `selected_reason = anomaly_rejected`,再回退到其他可用来源。
|
||
|
||
异常保护不应静默丢弃事实。原始观测仍应保留,聚合结果通过 `quality_flags` 和冲突记录解释为什么没有采用它。
|
||
|
||
### 轨迹聚合
|
||
|
||
轨迹接口不能简单拼接所有来源,否则前端会出现折返、抖动和重复点。默认规则:
|
||
|
||
- 以 `observed_at` 排序,生成统一时间线。
|
||
- 同一来源的完全重复点通过 `observation_hash` 去重。
|
||
- 多来源在短时间窗口内上报的相近位置视为同一轨迹点,优先选择 freshness 和 source priority 更高的一条。
|
||
- 每个轨迹点保留 `source`、`selected_reason` 和必要的 `quality_flags`。
|
||
- 对被判定为 `position_jump` 的点,默认不进入展示轨迹,但可通过调试参数查看。
|
||
|
||
## 聚合接口
|
||
|
||
状态更新:开发期已直接切换到新船只快照接口。旧 `/api/v1/visualization/geo/vessels` 路由已移除;新的 Earth 船只首屏应调用 `/api/v1/vessels/snapshot`,实时更新走 `/ws` 的 `vessels` 订阅。
|
||
|
||
现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic`。
|
||
|
||
```text
|
||
GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
|
||
GET /api/v1/visualization/vessels/{mmsi}
|
||
GET /api/v1/visualization/vessels/{mmsi}/track
|
||
GET /api/v1/visualization/vessels/{mmsi}/conflicts
|
||
```
|
||
|
||
GeoJSON properties 建议增加:
|
||
|
||
```json
|
||
{
|
||
"mmsi": 257123000,
|
||
"name": "OSLO TRADER",
|
||
"lat": 59.91,
|
||
"lon": 10.73,
|
||
"received_at": "2026-04-30T10:00:00Z",
|
||
"field_sources": {
|
||
"name": "aisstream_vessels",
|
||
"lat": "aisstream_vessels",
|
||
"lon": "aisstream_vessels",
|
||
"vessel_type": "barentswatch_vessels"
|
||
},
|
||
"selected_reasons": {
|
||
"name": "delivery_mode_priority",
|
||
"lat": "newest_observation",
|
||
"vessel_type": "non_empty_priority"
|
||
},
|
||
"quality_flags": [],
|
||
"conflict_count": 2
|
||
}
|
||
```
|
||
|
||
## 开放配置计划
|
||
|
||
### Phase 1 — 内置默认策略和只读解释
|
||
|
||
- 实现后端默认策略。
|
||
- 聚合接口返回 `field_sources`、`selected_reasons`、`conflict_count`。
|
||
- 冲突记录可查询,但不允许用户修改。
|
||
- 保持现有前端船只图层接口形状基本兼容,新增字段只作为调试和后续 UI 输入。
|
||
|
||
### Phase 2 — 系统设置中的 JSON/YAML 策略配置
|
||
|
||
新增系统设置项,例如:
|
||
|
||
```yaml
|
||
collector_aggregation:
|
||
vessel_ais:
|
||
source_priority:
|
||
- aisstream_vessels
|
||
- barentswatch_vessels
|
||
field_rules:
|
||
name:
|
||
mode: source_priority
|
||
vessel_type:
|
||
mode: source_priority
|
||
source_priority:
|
||
- barentswatch_vessels
|
||
- aisstream_vessels
|
||
lat:
|
||
mode: newest
|
||
lon:
|
||
mode: newest
|
||
```
|
||
|
||
配置校验要求:
|
||
|
||
- 未知 source 只警告,不阻断保存,便于先配置后启用。
|
||
- 未知 field 必须拒绝,避免拼写错误悄悄失效。
|
||
- 动态位置字段默认不允许被固定来源永久锁死,除非显式开启高级选项。
|
||
- 空值不覆盖非空值是全局保护,不建议开放关闭。
|
||
|
||
### Phase 3 — 冲突治理 UI
|
||
|
||
基于冲突记录提供页面或 drawer:
|
||
|
||
- 查看某个 MMSI 的冲突字段。
|
||
- 查看每个字段的候选来源和值。
|
||
- 查看当前选择原因。
|
||
- 将一次人工选择保存成字段规则,而不是只处理单条冲突。
|
||
- 支持恢复默认策略。
|
||
|
||
## AISStream 采集器计划
|
||
|
||
AISStream 采集器单独实现,建议命名为 `aisstream_vessels`。它的职责是:
|
||
|
||
- 维护 WebSocket 连接、订阅范围和重连。
|
||
- 将上游 AIS 消息标准化为 `vessel_ais` payload。
|
||
- 标记 `delivery_mode = realtime_stream`,`transport = websocket`。
|
||
- 写入原始观测层。
|
||
- 不直接 upsert 最终展示数据。
|
||
|
||
配置应放入采集器设置,而不是硬编码:
|
||
|
||
```yaml
|
||
aisstream_vessels:
|
||
api_key: "${AISSTREAM_API_KEY}"
|
||
bounding_boxes:
|
||
- [[-180, -90], [180, 90]]
|
||
message_types:
|
||
- PositionReport
|
||
- ShipStaticData
|
||
```
|
||
|
||
默认不建议直接订阅全球范围。AISStream 采集器应支持以下订阅策略:
|
||
|
||
- 使用配置的固定 `bounding_boxes`。
|
||
- 后续支持按 Earth 当前视口或关注区域动态调整订阅范围。
|
||
- 支持限制 `message_types`,避免静态信息、位置报告和扩展消息全量涌入。
|
||
- 断线后使用指数退避重连,并把连接状态写入源健康状态。
|
||
- 重连后可能收到重复或回放消息,因此必须依赖原始观测层的幂等去重。
|
||
|
||
### 媒体富化边界
|
||
|
||
VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图片、船籍详情、公司信息等后续应作为独立 enrichment 链路:
|
||
|
||
- 通过 MMSI、IMO、船名等字段异步查询。
|
||
- 使用独立缓存和授权配置。
|
||
- 不阻塞 `vessel_ais` 实时观测入库。
|
||
- 聚合接口只暴露已经缓存好的媒体引用,不在请求链路中现场抓取。
|
||
|
||
## 版本拆分
|
||
|
||
计划先按 v0-v3 建立基础能力,再用 v3.1-v3.4 修复当前稳定性缺口,最后进入 v4/v5:
|
||
|
||
### v0 — 聚合基础设施(已实现)
|
||
|
||
目标是不改变前端展示行为,先把数据底座铺好。
|
||
|
||
1. 新增原始观测模型、冲突记录模型和源健康状态模型。
|
||
2. 为现有 BarentsWatch collector 写入原始观测,同时保留现有 `vessel_position` / `vessel_static` 兼容写入。
|
||
3. 实现存储级 `observation_hash` 幂等去重。
|
||
4. 补基础管理命令或调试接口,用于查看某个 MMSI 的原始观测和冲突候选。
|
||
|
||
### v1 — 聚合读接口(已实现)
|
||
|
||
目标是让展示接口开始消费聚合结果,但前端形状保持兼容。
|
||
|
||
1. 实现 AIS 聚合服务,先兼容读取现有表,再逐步切换到原始观测层。
|
||
2. 将船只列表迁移到 `/api/v1/vessels/snapshot`,并让 `/vessels/{mmsi}` 走聚合服务。
|
||
3. 将 `/vessels/{mmsi}/track` 改为走轨迹聚合逻辑。
|
||
4. 返回 `field_sources`、`selected_reasons`、`quality_flags`、`conflict_count`。
|
||
5. 加入 freshness fallback 和异常位置保护。
|
||
|
||
### v2 — AISStream WebSocket collector(已实现)
|
||
|
||
目标是接入第二个真实 AIS 来源,并验证多源冲突和回退逻辑。
|
||
|
||
1. 实现 `aisstream_vessels` collector。
|
||
2. 支持 API key、订阅范围、消息类型、重连和限流配置。
|
||
3. 将 AISStream 写入原始观测层,不直接 upsert 最终展示表。
|
||
4. 接入源健康状态和 message rate 统计。
|
||
5. 提供 AISStream API Key 获取教程、设置页入口和连接验证支持。
|
||
6. 为重复消息、断流回退、WS 优先级写集成测试。
|
||
|
||
### v3 — AISStream 可用性与配置体验(已实现)
|
||
|
||
目标是让 AISStream 从“能采集”变成日常可观察、可调试、可配置的数据源。
|
||
|
||
1. 设置页展示 AISStream 运行状态:连接状态、最近收到、最近成功、本轮消息数、延迟和最近错误。
|
||
2. AISStream 设置页提供常用采集范围 preset,并保留自定义 Bounding Boxes JSON。
|
||
3. 聚合结果返回 `source_summary`,展示每艘船的来源、观测数量、最新观测时间、传输模式和消息类型。
|
||
4. 保留 `field_sources` 和 `selected_reasons`,用于解释动态字段来自实时流、静态字段来自可用非空来源。
|
||
5. 船名标准化会读取 AISStream `MetaData.ShipName`;船型展示会从 `vessel_type_name` 和 AIS 数字 `vessel_type` 共同归一化,保证 marker 颜色、详情卡、hover 和搜索结果一致。
|
||
6. 当前实现已转向 `/api/v1/vessels/snapshot`:必须带 bbox / zoom,默认 `limit=1000`,最大 `limit=5000`,不再支持旧 `/geo/vessels` 全量返回。
|
||
|
||
### v3.1 — 聚合完整性修复(已被受控 fallback 取代)
|
||
|
||
原目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。当前实现已经移除旧 `/geo/vessels` 路由,船只入口统一为 `/api/v1/vessels/snapshot`。snapshot 优先读取 `ais_raw_observations` 聚合结果;当当前 raw 窗口为空时,才受控回退到 `vessel_position + vessel_static` 最新点,并通过 `diagnostics.legacy_fallback_used` 标记。
|
||
|
||
因此以下旧 `/geo/vessels` 全量 merge 要求作废,保留在文档中只作为历史决策记录:
|
||
|
||
1. `/geo/vessels` 必须合并 raw observation 聚合结果和 legacy latest position 结果。
|
||
2. raw 与 legacy 同一 MMSI 同时存在时只显示一艘,优先使用 raw 聚合结果及其 `field_sources` / `selected_reasons`。
|
||
3. raw 中不存在的 BarentsWatch-only MMSI 必须从 `vessel_position + vessel_static` 补齐。
|
||
4. `bbox`、`type`、`limit` 过滤必须作用在合并后的最终集合上;不传 `limit` 或 `limit=0` 仍表示全量返回。
|
||
5. 增加诊断统计,至少能看到 raw AISStream unique MMSI、raw BarentsWatch unique MMSI、legacy unique MMSI、final merged unique MMSI 和被 legacy 补齐的数量。
|
||
6. 为 raw 只有 AISStream 子集、legacy 有更多 BarentsWatch 船只的场景补回归测试。
|
||
|
||
### v3.2 — AISStream 真实时链路(v4 前置)
|
||
|
||
目标是把 AISStream 从“一次 collector 收一批消息后结束”改成真正的 WebSocket 长连接实时数据源,并把实时变化推送到 Earth。
|
||
|
||
当前 `aisstream_vessels` 只在 collector `fetch()` 中连接 `wss://stream.aisstream.io/v0/stream`,默认收 `max_messages = 500` 条后结束。这不符合 WebSocket 流式数据源的运行语义,也不能保证新船、位置变化和航向变化实时出现在前端。
|
||
|
||
1. 为 AISStream 增加 streaming service / long-running runner,不再依赖单次 `fetch -> transform -> save -> completed` 表达实时采集。
|
||
2. 外部 AISStream WebSocket 保持长连接,断线后指数退避重连,并持续更新 `AISSourceHealth`。
|
||
3. 每条或小批量 AIS 消息标准化后写入 `ais_raw_observations`,按时间或数量短周期 commit,避免长事务堆积。
|
||
4. 将新增船只、位置变化、航向变化和静态字段补充转换成 vessel delta。
|
||
5. 通过应用内部 `/ws` 的 `vessels` channel 广播 delta,复用 `DataBroadcaster.broadcast_custom("vessels", payload)`。
|
||
6. Earth 前端订阅 `vessels` channel,`vessels.js` 支持按 MMSI upsert marker,而不是每次全量 reload。
|
||
7. 船只改变航向时,前端必须更新 course bin / marker bucket,避免 marker 方向滞后。
|
||
8. freshness 超时或 AISStream 健康异常时,动态字段可回退到 BarentsWatch 最新可用观测。
|
||
|
||
### v3.3 — Streaming 采集状态语义(v4 前置)
|
||
|
||
目标是让采集页面正确表达 AISStream 这类长连接数据源,不再使用一次性 REST collector 的完成型进度条。
|
||
|
||
REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100 -> completed`。AISStream 的自然状态应是 `connecting -> streaming -> reconnecting -> stopped/failed`,没有固定总量,也不应在收到一批消息后显示“采集完成”。
|
||
|
||
1. AISStream 采集状态使用 indeterminate / streaming 状态,而不是百分比完成进度条。
|
||
2. 设置页运行状态卡展示连接状态、已运行时长、本轮消息数、新增观测数、unique MMSI、message rate、最近消息时间、延迟和最近错误。
|
||
3. `phase_message` 使用“正在接收 AISStream 实时消息”“重连中”“已停止”等长连接语义。
|
||
4. 停止、重连和配置变更要有明确操作入口;配置变化后必须安全重订阅。
|
||
5. 后端任务状态不能因为没有 `total_records` 就长期显示 `0%` 或误判失败。
|
||
6. WebSocket 健康状态和 collector task 状态要分离:上游短暂断线是 `reconnecting`,不是普通采集任务完成或失败。
|
||
|
||
### v3.4 — 船只身份字段和名称聚合修复(v4 前置)
|
||
|
||
目标是把 MMSI、IMO、callsign 这类身份编号按字符串显示,并把仍然使用 MMSI 作为船名的记录视为信息聚合未完成,而不是正常船名。
|
||
|
||
1. 前端详情卡、hover、搜索结果和日志中的 `mmsi`、`imo`、`callsign` 必须作为 identifier 字段展示,禁止走 `toLocaleString()` 或数字千分位格式。
|
||
2. GeoJSON 可增加 `mmsi_display` / `imo_display` 等字符串字段,但前端仍必须对 identifier key 做兜底格式保护。
|
||
3. 聚合服务生成船名时,不能把 `MMSI 257123000` 当成真实 `name` 的成功结果;它只能作为 display fallback。
|
||
4. 增加诊断查询,列出所有当前仍以 MMSI 号码或 `MMSI <number>` 作为船只名称的记录,包括:
|
||
- `vessel_static.name` 为空或等于 MMSI fallback 的 MMSI;
|
||
- raw observation 中没有任何非空 `name` / `MetaData.ShipName` / `ShipStaticData.Name` 的 MMSI;
|
||
- 聚合结果最终 `name` 仍为 fallback 的 MMSI;
|
||
- 每个 MMSI 的可用来源、最近观测时间、message types 和缺失原因。
|
||
5. 对这些 fallback-name 船只建立待修复集合,优先通过 AISStream `ShipStaticData`、BarentsWatch 静态字段和后续 enrichment 缓存补齐。
|
||
6. 船只详情面板需要区分“真实船名”和“显示兜底”:真实船名缺失时展示 `MMSI <id>` 可以继续作为标题,但字段来源应标注为 `fallback`,避免误以为聚合成功。
|
||
7. 为 MMSI 千分位格式、fallback-name 诊断和名称来源解释补回归测试。
|
||
|
||
### v4 — 策略配置(v0 可用)
|
||
|
||
目标是开放系统级配置,但仍以安全默认值兜底。
|
||
|
||
已落地的最小子集:
|
||
|
||
1. 策略持久化在 `system_settings.category = 'vessel_aggregation_strategy'`,保存时自动版本递增。
|
||
2. `app/services/vessel_aggregation_strategy.py` 暴露 `load_strategy / save_strategy / reset_strategy / validate_strategy`,并维护 `DEFAULT_STRATEGY` 兜底。
|
||
3. 校验规则:
|
||
- 未知 `field_rules.<name>` → `400 unknown vessel_ais field`;
|
||
- 未知 mode → `400 mode must be one of ...`;
|
||
- 动态字段(`lat/lon/sog/cog/heading/nav_status`)使用非 `newest` mode 时必须显式 `allow_dynamic_lock=true`,否则拒绝;
|
||
- `freshness.realtime_stream_seconds` / `polling_seconds` 必须为非负整数;
|
||
- `mode=locked` 必须带非空 `locked_source`。
|
||
4. 聚合服务 `vessel_ais_aggregation.py` 在 `_select_position_observation` 中按 `freshness` 把过期实时流降级到 stale 候选;在 `_select_static_field` 中按 `field_rules.mode = source_priority / locked / newest / non_empty` 选源。
|
||
5. 聚合输出每条 vessel 携带 `aggregation_strategy_version`,并在 `/api/v1/vessels/snapshot` GeoJSON properties + `/vessels/{mmsi}` 详情中暴露。
|
||
6. API:
|
||
- `GET /api/v1/vessel-aggregation/strategy`
|
||
- `PUT /api/v1/vessel-aggregation/strategy`(校验失败 400)
|
||
- `DELETE /api/v1/vessel-aggregation/strategy`(恢复默认并 bump version)
|
||
|
||
未做项(留给 v4 后续):
|
||
|
||
- 系统设置 UI 中的策略编辑器尚未做,目前直接调 API;
|
||
- `transport_priority`、`quality_flags` 级别的策略尚未引入;
|
||
- `source_priority` 中的未知 source 不强校验,留给后续 warn-only 提示。
|
||
|
||
### v5 — 船舶资料 enrichment 与冲突治理(v0 可用)
|
||
|
||
目标是把 AIS 实时流里不稳定或低频出现的静态信息,补成可缓存、可审计的船舶资料层,同时把冲突解释变成可操作能力。
|
||
|
||
已落地的最小子集:
|
||
|
||
1. 新增模型 `app/models/vessel_enrichment.py::VesselProfileEnrichment` + `VesselMediaEnrichment`:以 `mmsi` 为主键,记录 `source / payload / fetched_at / expires_at / confidence / reference_url`;通过 `Base.metadata.create_all` 在 `init_db` 中建表。
|
||
2. 服务 `app/services/vessel_enrichment.py` 提供 `upsert_vessel_profile_enrichment` / `upsert_vessel_media_enrichment` / `get_vessel_enrichment_bundle`;读路径只读缓存,过期记录(`expires_at < now`)直接过滤为 `None`,永不联网。
|
||
3. 聚合接口在 `/api/v1/visualization/vessels/{mmsi}` 响应中追加 `enrichment.profile` 与 `enrichment.media` 字段(含 `source / fetched_at / expires_at / confidence / reference_url`);命中失败时返回 `null`,不阻塞 AIS 实时链路。
|
||
4. 冲突治理 API:
|
||
- `POST /api/v1/vessel-aggregation/conflicts/{mmsi}/{field}/promote-to-rule` 读取最近 `AISConflictRecord.selected_source`,写入 `field_rules[field] = {mode: source_priority, source_priority: [<source>]}` 并 bump version;
|
||
- `DELETE` 对应路径移除该 field 的覆盖,恢复默认。
|
||
5. 前端 Earth `info-card.js` 渲染 `船舶资料` 区块:profile.payload 标量字段平铺、媒体 `images` 数组缩略图、来源 / 更新时间 / 置信度元数据;缓存命中失败回退到 `资料缓存中`;常规字段在 `field_sources` 命中时附带来源 tag。
|
||
|
||
未做项(留给 v5 后续):
|
||
|
||
- 没有真正的异步 enrichment 抓取作业;当前依赖外部脚本/管理 API 写入缓存;
|
||
- 冲突治理 UI 还没接入设置中心,目前只暴露 API;
|
||
- enrichment 命中状态尚未广播到 `vessels` channel,详情面板首次打开时按需请求即可。
|
||
|
||
## 测试计划
|
||
|
||
- 同一来源同一 `mmsi + observed_at + lat + lon` 重复记录只聚合一次。
|
||
- 多来源同一 MMSI 的位置字段优先选择最新观测。
|
||
- 实时流和轮询源同时间冲突时,实时流优先。
|
||
- 实时流过期后,更新的轮询源可以接管动态字段。
|
||
- 实时流源健康状态异常时,动态字段可以回退到更新的可用来源。
|
||
- 静态字段不会被空值覆盖。
|
||
- 静态字段冲突会写入冲突记录。
|
||
- 明显异常位置不会进入默认展示轨迹,并会留下 `quality_flags`。
|
||
- 同一时间窗口内多来源相近轨迹点只展示一个点。
|
||
- AISStream 重连或回放导致的重复消息不会重复进入聚合结果。
|
||
- `/api/v1/vessels/snapshot` 优先读取 AIS raw observation 聚合结果;当当前 raw 窗口为空时,允许受控 fallback 到 legacy latest position。
|
||
- `/api/v1/visualization/geo/vessels` 路由已移除,客户端必须迁移到新 snapshot API。
|
||
- AISStream 长连接收到新船、位置变化和航向变化后,会通过内部 `/ws` 的 `vessels` channel 推送增量。
|
||
- AISStream streaming 状态不会显示成固定百分比完成进度条,也不会在收到一批消息后误报采集完成。
|
||
- `mmsi`、`imo`、`callsign` 等身份编号在前端不显示千分位符。
|
||
- 聚合结果中仍以 MMSI fallback 作为船名的记录可以被诊断查询完整列出,并带来源和缺失原因。
|
||
- 字段级配置可以覆盖默认来源优先级。
|
||
- 聚合接口在没有冲突表时仍可返回兼容 GeoJSON。
|
||
|
||
## 相关文件
|
||
|
||
- [实时船只监控系统计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-tracking-plan.md)
|
||
- [自定义 API 数据源与 LLM 映射系统计划](/home/ray/dev/linkong/planet/docs/plans/datasource-custom-api-mapping-plan.md)
|
||
- [BarentsWatch AIS collector](/home/ray/dev/linkong/planet/backend/app/services/collectors/vessel_ais.py)
|
||
- [船只模型](/home/ray/dev/linkong/planet/backend/app/models/vessel.py)
|
||
- [可视化 API](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py)
|