# collected_data 强耦合列拆除计划

## 背景

当前 `collected_data` 同时承担了两类职责：

1. 通用采集事实表
2. 少数数据源的宽表字段承载

典型强耦合列包括：

- `country`
- `city`
- `latitude`
- `longitude`
- `value`
- `unit`

以及 API 层临时平铺出来的：

- `cores`
- `rmax`
- `rpeak`
- `power`

这些字段并不适合作为统一事实表的长期 schema。  
推荐方向是：

- 表内保留通用稳定字段
- 业务差异字段全部归入 `metadata`
- API 和前端动态读取 `metadata`

## 拆除目标

最终希望 `collected_data` 只保留：

- `id`
- `snapshot_id`
- `task_id`
- `source`
- `source_id`
- `entity_key`
- `data_type`
- `name`
- `title`
- `description`
- `metadata`
- `collected_at`
- `reference_date`
- `is_valid`
- `is_current`
- `previous_record_id`
- `change_type`
- `change_summary`
- `deleted_at`

## 计划阶段

### Phase 1：读取层去依赖

目标：

- API / 可视化 / 前端不再优先依赖宽列表字段
- 所有动态字段优先从 `metadata` 取

当前已完成：

- 新写入数据时，将 `country/city/latitude/longitude/value/unit` 自动镜像到 `metadata`
- `/api/v1/collected` 优先从 `metadata` 取动态字段
- `visualization` 接口优先从 `metadata` 取动态字段
- 国家筛选已改成只走 `metadata->>'country'`
- `CollectedData.to_dict()` 已切到 metadata-first
- 变更比较逻辑已切到 metadata-first
- 已新增历史回填脚本：
  [scripts/backfill_collected_data_metadata.py](/home/ray/dev/linkong/planet/scripts/backfill_collected_data_metadata.py)
- 已新增删列脚本：
  [scripts/drop_collected_data_legacy_columns.py](/home/ray/dev/linkong/planet/scripts/drop_collected_data_legacy_columns.py)

涉及文件：

- [backend/app/core/collected_data_fields.py](/home/ray/dev/linkong/planet/backend/app/core/collected_data_fields.py)
- [backend/app/services/collectors/base.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/base.py)
- [backend/app/api/v1/collected_data.py](/home/ray/dev/linkong/planet/backend/app/api/v1/collected_data.py)
- [backend/app/api/v1/visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py)

### Phase 2：写入层去依赖

目标：

- 采集器内部不再把这些字段当作数据库一级列来理解
- 统一只写：
  - 通用主字段
  - `metadata`

建议动作：

1. Collector 内部仍可使用 `country/city/value` 这种临时字段作为采集过程变量
2. 进入 `BaseCollector._save_data()` 后统一归档到 `metadata`
3. `CollectedData` 模型中的强耦合列已从 ORM 移除，写入统一归档到 `metadata`

### Phase 3：数据库删列

目标：

- 从 `collected_data` 真正移除以下列：
  - `country`
  - `city`
  - `latitude`
  - `longitude`
  - `value`
  - `unit`

注意：

- `cores / rmax / rpeak / power` 当前本来就在 `metadata` 里，不是表列
- 这四个主要是 API 平铺字段，不需要数据库删列

## 当前阻塞点

在正式删列前，还需要确认这些地方已经完全不再直接依赖数据库列：

### 1. `CollectedData.to_dict()`

文件：

- [backend/app/models/collected_data.py](/home/ray/dev/linkong/planet/backend/app/models/collected_data.py)

状态：

- 已完成

### 2. 差异计算逻辑

文件：

- [backend/app/services/collectors/base.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/base.py)

状态：

- 已完成
- 当前已改成比较归一化后的 metadata-first payload

### 3. 历史数据回填

问题：

- 老数据可能只有列值，没有对应 `metadata`

当前方案：

- 在删列前执行一次回填脚本：
  - [scripts/backfill_collected_data_metadata.py](/home/ray/dev/linkong/planet/scripts/backfill_collected_data_metadata.py)

### 4. 导出格式兼容

文件：

- [backend/app/api/v1/collected_data.py](/home/ray/dev/linkong/planet/backend/app/api/v1/collected_data.py)

现状：

- CSV/JSON 导出已基本切成 metadata-first

建议：

- 删列前再回归检查一次导出字段是否一致

## 推荐执行顺序

1. 保持新数据写入时 `metadata` 完整
2. 把模型和 diff 逻辑完全切成 metadata-first
3. 写一条历史回填脚本
4. 回填后观察一轮
5. 正式执行删列迁移

## 推荐迁移 SQL

仅在确认全部读取链路已去依赖后执行：

```sql
ALTER TABLE collected_data
DROP COLUMN IF EXISTS country,
DROP COLUMN IF EXISTS city,
DROP COLUMN IF EXISTS latitude,
DROP COLUMN IF EXISTS longitude,
DROP COLUMN IF EXISTS value,
DROP COLUMN IF EXISTS unit;
```

## 风险提示

1. 地图类接口对经纬度最敏感
   必须确保所有地图需要的记录，其 `metadata.latitude/longitude` 已回填完整。

2. 历史老数据如果没有回填，删列后会直接丢失这些信息。

3. 某些 collector 可能仍隐式依赖这些宽字段做差异比较，删列前必须做一次全量回归。

## 当前判断

当前项目已经完成“代码去依赖 + 历史回填 + readiness 检查”。  
下一步执行顺序建议固定为：

1. 先部署当前代码版本并重启后端
2. 再做一轮功能回归
3. 最后执行：
   `uv run python scripts/drop_collected_data_legacy_columns.py`
