Files
planet/docs/plans/earth-vessel-ais-aggregation-plan.md
linkong 8c204717cd
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
release: bump version to 0.70.0
2026-06-04 17:16:23 +08:00

481 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AIS 多源采集、冲突记录与聚合接口计划
**状态**v0-v3 已实现v3.1-v3.4 为 v4/v5 前置稳定化任务v4 / v5 已落最小可用子集;`0.70.0` 起 Earth 展示路径已转向 `vessel_current_state` 当前状态表和全球快照,不再由 raw observation 聚合结果直接驱动首屏显示。
**创建日期**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 / zoomEarth 运行时使用全球 bbox 和 `limit=3000`,后端最大 `limit=5000`,不再支持旧 `/geo/vessels` 全量返回。
### v3.1 — 聚合完整性修复(已被当前状态表取代)
原目标是先保证“所有已采集到的船都能显示”BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。当前实现已经移除旧 `/geo/vessels` 路由,船只入口统一为 `/api/v1/vessels/snapshot`。snapshot 现在读取 `vessel_current_state` 当前状态表;`ais_raw_observations` 继续保留给轨迹、审计和态势分析,不再由展示接口临时扫描聚合。
因此以下旧 `/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)