# Location Resolver Shared Pipeline Plan **状态**:已实现,当前用户流程见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) 的 Earth 位置候选采集章节,开发接口见 [通用位置估算管线开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-development.md)。 ## Goal 把"给定一条记录,决定它的 lat/lon"这件事抽象成一条统一的可插拔管线,让算力中心、BGP 观测站、BGP 事件——以及未来任何需要位置估算的实体——共用同一套接口。新算法(peeringdb 设施查询、IXP 表、用户认领的精确点位等)通过实现一个 Resolver 类即可挂入,不需要改任何上层调用方。 ## Background ### 实施前现状 - **算力中心** (`backend/app/services/compute_center_locations.py`) 早期曾使用源坐标 → 本地 JSON 注册表 → 城市兜底 → Nominatim 在线地理编码。后续为避免硬编码位置污染事实链路,算力中心本地注册表已移除;主地图只使用源坐标,手动候选采集使用 ROR 和 Nominatim。 - **BGP 观测站** (`collectors/bgp_common.py:RIPE_RIS_COLLECTOR_COORDS`) 是一张写死的字典,26 个 RIPE RIS collector 的城市级坐标。新增 collector / 升级到设施级精度都得改 Python。 - **BGP 事件**继承所属 collector 的城市级坐标(`BGPObservation.collector_geo`)。 - 用户原本以为 BGP 观测站位置是通过 iptoasn 推断的——其实 iptoasn 只用于前缀级国家归属(`bgp_enrichment.py`),不影响 marker 坐标。 ### 痛点 1. 算力中心那条 4 层链路写死在算力中心模块里,BGP 想用得复制一遍。 2. 三类实体各走各的坐标策略,缺统一抽象。 3. 未来要插更精的算法(peeringdb / IXP / 用户认领),现在没有挂入点。 ## Design ### 接口契约 `backend/app/services/location/`: - `models.py` —— `LocationQuery`(输入)、`LocationCandidate`(候选)、`ResolverOutput`(单 resolver 输出)、`ResolutionResult`/`ResolutionDiagnostic`(管线最终结果) - `pipeline.py` —— `LocationResolver` Protocol、`LocationPipeline` 编排器 - `resolvers/source_coordinates.py` —— 记录自带 lat/lon 时直通 - `resolvers/registry.py` —— 本地 JSON 注册表(locations + city_fallbacks),按别名得分 - `resolvers/nominatim.py` —— 通用 Nominatim 客户端(rate-limited + LRU 缓存)+ 可注入 query plan - `resolvers/inherit.py` —— 从外部回调取候选(事件继承 collector 用) - `text.py` —— 文本规范化共享工具 核心 Protocol: ```python class LocationResolver(Protocol): name: str def resolve(self, query: LocationQuery) -> ResolverOutput: ... ``` `LocationPipeline.collect_candidates()` 跑全部 resolver,聚合所有候选,按 `(source_rank, precision_rank, -confidence)` 排序去重;`resolve_best()` 选 top 候选。 ### 各领域管线 ```python # compute_center_locations.py(重构后,公共 API 不变) COMPUTE_CENTER_PIPELINE = LocationPipeline([ SourceCoordinatesResolver(), ]) COMPUTE_CENTER_COLLECTION_PIPELINE = LocationPipeline([ SourceCoordinatesResolver(), ROROrganizationResolver(), NominatimResolver(query_plan_builder=_compute_center_query_plan, geocoder=lambda q: _geocode_online(q)), ]) # bgp_collector_locations.py(新) BGP_COLLECTOR_PIPELINE = LocationPipeline([ SourceCoordinatesResolver(), StoredCollectorLocationResolver(), ]) BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline([ SourceCoordinatesResolver(), NominatimResolver(query_plan_builder=_bgp_collector_query_plan, geocoder=lambda q: _geocode_online(q)), ]) # bgp_event_locations.py(新) BGP_EVENT_PIPELINE = LocationPipeline([ SourceCoordinatesResolver(), InheritFromAnotherEntityResolver(source_lookup=_inherit_from_owning_collector), # 占位:将来插 ASNFacilityResolver / PrefixGeoResolver ]) ``` ### 关键设计决策 1. **算力中心公共 API 完全不变**:`resolve_compute_center_location()`、`collect_location_candidates()`、`ComputeCenterLocation` dataclass、`_geocode_online` 模块级符号都保留,前端 / 上层调用方零改动;现有 19 个回归测试全绿。 2. **`_geocode_online` 用 lambda 晚绑定**:`NominatimResolver(geocoder=lambda q: _geocode_online(q))` 能让测试 `monkeypatch.setattr(module, "_geocode_online", fake)` 继续生效。 3. **`RIPE_RIS_COLLECTOR_COORDS` 自动从 DB-backed cache 重建**:启动时 seed/refresh `bgp_collector_locations` 维表,再原地刷新旧 `{rrcXX → {city, country, lat, lon}}` 字典。下游消费者(`bgp_collectors.py`、序列化、detector)不动即可获得新元数据。 4. **修复隐藏 bug**:BGP collector 不再通过 registry/operator 模糊匹配晋升候选,避免 `operator="RIPE NCC"` 让每个事件都落到 `rrc00`。 5. **事件继承走严格名字查询**:事件继承不跑 collector 的完整 pipeline,改成直接查 DB-backed cache。"改进位置"用户触发流程只跑源坐标和在线地理编码候选。 ## Files ### 新增 - `backend/app/services/location/__init__.py` - `backend/app/services/location/models.py` - `backend/app/services/location/pipeline.py` - `backend/app/services/location/text.py` - `backend/app/services/location/resolvers/__init__.py` - `backend/app/services/location/resolvers/source_coordinates.py` - `backend/app/services/location/resolvers/registry.py` - `backend/app/services/location/resolvers/nominatim.py` - `backend/app/services/location/resolvers/inherit.py` - `backend/app/services/bgp_collector_locations.py` - `backend/app/services/bgp_event_locations.py` - `backend/app/models/bgp_collector_location.py` - `backend/tests/test_location_pipeline.py`(16 用例) - `backend/tests/test_bgp_collector_locations.py`(11 用例) ### 修改 - `backend/app/services/compute_center_locations.py` —— 改为薄包装 - `backend/app/services/collectors/bgp_common.py` —— 删除写死字典,改调 `resolve_bgp_event_geo_dict()` - `backend/app/api/v1/bgp.py` —— 新增 `POST /api/v1/bgp/collectors/{collector_id}/collect-location` - `frontend/public/earth/js/info-card.js` —— `renderComputeCenterCollectSection` → `renderLocationCollectSection`,BGP collector 走通用化路径 - `frontend/public/earth/js/compute-centers.js` —— 新增通用 `collectLocationCandidates(endpoint, payload)` - `frontend/public/earth/js/main.js` —— `previewComputeCenterCandidate` → `previewLocationCandidate`,事件名改为 `earth:preview-location-candidate` ## Verification - `uv run pytest backend/tests/test_visualization_compute_centers.py` —— 19 个用例全绿(公共 API 未改) - `uv run pytest backend/tests/test_location_pipeline.py backend/tests/test_bgp_collector_locations.py` —— 16 + 11 用例全绿 - 抽象可插拔性测试:`test_pluggability_custom_resolver_works_without_changing_pipeline` —— 临时实现 `_PeeringDBStubResolver` 直接接入 `LocationPipeline`,验证管线不需要改一行就能识别新 source ## Out of scope - 持久化用户认领的精确坐标(写回 JSON 注册表)—— `suggested_registry_entry` 字段已就绪,工作流单独立项 - 真正实现 `ASNFacilityResolver` / `PrefixGeoResolver` —— 接口已留好,具体算法(peeringdb / IXP 表 / iptoasn 升级)单独立项 - 算力中心 / 观测站 marker 合并避让 —— 上一轮已用 `SURFACE_AVOIDANCE_PROFILES.city` + halo 收敛解决