# 采集数据历史快照化改造方案

## 背景

当前系统的 `collected_data` 更接近“当前结果表”：

- 同一个 `source + source_id` 会被更新覆盖
- 前端列表页默认读取这张表
- `collection_tasks` 只记录任务执行状态，不直接承载数据版本语义

这套方式适合管理后台，但不利于后续做态势感知、时间回放、趋势分析和版本对比。  
如果后面需要回答下面这类问题，当前模型会比较吃力：

- 某条实体在过去 7 天如何变化
- 某次采集相比上次新增了什么、删除了什么、值变了什么
- 某个时刻地图上“当时的世界状态”是什么
- 告警是在第几次采集后触发的

因此建议把采集数据改造成“历史快照 + 当前视图”模型。

## 目标

1. 每次触发采集都保留一份独立快照，历史可追溯。
2. 管理后台默认仍然只看“当前最新状态”，不增加使用复杂度。
3. 后续支持：
   - 时间线回放
   - 两次采集差异对比
   - 趋势分析
   - 按快照回溯告警和地图状态
4. 尽量兼容现有接口，降低改造成本。

## 结论

不建议继续用以下两种单一模式：

- 直接覆盖旧数据
  问题：没有历史，无法回溯。

- 软删除旧数据再全量新增
  问题：语义不清，历史和“当前无效”混在一起，后续统计复杂。

推荐方案：

- 保留历史事实表
- 维护当前视图
- 每次采集对应一个明确的快照批次

## 推荐数据模型

### 方案概览

建议拆成三层：

1. `collection_tasks`
   继续作为采集任务表，表示“这次采集任务”。

2. `data_snapshots`
   新增快照表，表示“某个数据源在某次任务中产出的一个快照批次”。

3. `collected_data`
   从“当前结果表”升级为“历史事实表”，每一行归属于一个快照。

同时再提供一个“当前视图”：

- SQL View / 物化视图 / API 查询层封装均可
- 语义是“每个 `source + source_id` 的最新有效记录”

### 新增表：`data_snapshots`

建议字段：

| 字段 | 类型 | 含义 |
|---|---|---|
| `id` | bigint PK | 快照主键 |
| `datasource_id` | int | 对应数据源 |
| `task_id` | int | 对应采集任务 |
| `source` | varchar(100) | 数据源名，如 `top500` |
| `snapshot_key` | varchar(100) | 可选，业务快照标识 |
| `reference_date` | timestamptz nullable | 这批数据的参考时间 |
| `started_at` | timestamptz | 快照开始时间 |
| `completed_at` | timestamptz | 快照完成时间 |
| `record_count` | int | 快照总记录数 |
| `status` | varchar(20) | `running/success/failed/partial` |
| `is_current` | bool | 当前是否是该数据源最新快照 |
| `parent_snapshot_id` | bigint nullable | 上一版快照，可用于 diff |
| `summary` | jsonb | 本次快照统计摘要 |

说明：

- `collection_tasks` 偏“执行过程”
- `data_snapshots` 偏“数据版本”
- 一个任务通常对应一个快照，但保留分层更清晰

### 升级表：`collected_data`

建议新增字段：

| 字段 | 类型 | 含义 |
|---|---|---|
| `snapshot_id` | bigint not null | 归属快照 |
| `task_id` | int nullable | 归属任务，便于追查 |
| `entity_key` | varchar(255) | 实体稳定键，通常可由 `source + source_id` 派生 |
| `is_current` | bool | 当前是否为该实体最新记录 |
| `previous_record_id` | bigint nullable | 上一个版本的记录 |
| `change_type` | varchar(20) | `created/updated/unchanged/deleted` |
| `change_summary` | jsonb | 字段变化摘要 |
| `deleted_at` | timestamptz nullable | 对应“本次快照中消失”的实体 |

保留现有字段：

- `source`
- `source_id`
- `data_type`
- `name`
- `title`
- `description`
- `country`
- `city`
- `latitude`
- `longitude`
- `value`
- `unit`
- `metadata`
- `collected_at`
- `reference_date`
- `is_valid`

### 当前视图

建议新增一个只读视图：

`current_collected_data`

语义：

- 对每个 `source + source_id` 只保留最新一条 `is_current = true` 且 `deleted_at is null` 的记录

这样：

- 管理后台继续像现在一样查“当前数据”
- 历史分析查 `collected_data`

## 写入策略

### 触发按钮语义

“触发”不再理解为“覆盖旧表”，而是：

- 启动一次新的采集任务
- 生成一个新的快照
- 将本次结果写入历史事实表
- 再更新当前视图标记

### 写入流程

1. 创建 `collection_tasks` 记录，状态 `running`
2. 创建 `data_snapshots` 记录，状态 `running`
3. 采集器拉取原始数据并标准化
4. 为每条记录生成 `entity_key`
   - 推荐：`{source}:{source_id}`
5. 将本次记录批量写入 `collected_data`
6. 与上一个快照做比对，计算：
   - 新增
   - 更新
   - 未变
   - 删除
7. 更新本批记录的：
   - `change_type`
   - `previous_record_id`
   - `is_current`
8. 将上一批同实体记录的 `is_current` 置为 `false`
9. 将本次快照未出现但上一版存在的实体标记为 `deleted`
10. 更新 `data_snapshots.status = success`
11. 更新 `collection_tasks.status = success`

### 删除语义

这里不建议真的删记录。  
建议采用“逻辑消失”模型：

- 历史行永远保留
- 如果某实体在新快照里消失：
  - 上一条历史记录补一条“删除状态记录”或标记 `change_type = deleted`
  - 同时该实体不再出现在当前视图

这样最适合态势感知。

## API 改造建议

### 保持现有接口默认行为

现有接口：

- `GET /api/v1/collected`
- `GET /api/v1/collected/{id}`
- `GET /api/v1/collected/summary`

建议默认仍返回“当前视图”，避免前端全面重写。

### 新增历史查询能力

建议新增参数或新接口：

#### 1. 当前/历史切换

`GET /api/v1/collected?mode=current|history`

- `current`：默认，查当前视图
- `history`：查历史事实表

#### 2. 按快照查询

`GET /api/v1/collected?snapshot_id=123`

#### 3. 快照列表

`GET /api/v1/snapshots`

支持筛选：

- `datasource_id`
- `source`
- `status`
- `date_from/date_to`

#### 4. 快照详情

`GET /api/v1/snapshots/{id}`

返回：

- 快照基础信息
- 统计摘要
- 与上一版的 diff 摘要

#### 5. 快照 diff

`GET /api/v1/snapshots/{id}/diff?base_snapshot_id=122`

返回：

- `created`
- `updated`
- `deleted`
- `unchanged`

## 前端改造建议

### 1. 数据列表页

默认仍看当前数据，不改用户使用习惯。

建议新增：

- “视图模式”
  - 当前数据
  - 历史数据
- “快照时间”筛选
- “只看变化项”筛选

### 2. 数据详情页

详情页建议展示：

- 当前记录基础信息
- 元数据动态字段
- 所属快照
- 上一版本对比入口
- 历史版本时间线

### 3. 数据源管理页

“触发”按钮文案建议改成更准确的：

- `立即采集`

并在详情里补：

- 最近一次快照时间
- 最近一次快照记录数
- 最近一次变化数

## 迁移方案

### Phase 1：兼容式落地

目标：先保留当前页面可用。

改动：

1. 新增 `data_snapshots`
2. 给 `collected_data` 增加：
   - `snapshot_id`
   - `task_id`
   - `entity_key`
   - `is_current`
   - `previous_record_id`
   - `change_type`
   - `change_summary`
   - `deleted_at`
3. 现有数据全部补成一个“初始化快照”
4. 现有 `/collected` 默认改查当前视图

优点：

- 前端几乎无感
- 风险最小

### Phase 2：启用差异计算

目标：采集后可知道本次改了什么。

改动：

1. 写入时做新旧快照比对
2. 写 `change_type`
3. 生成快照摘要

### Phase 3：前端态势感知能力

目标：支持历史回放和趋势分析。

改动：

1. 快照时间线
2. 版本 diff 页面
3. 地图时间回放
4. 告警和快照关联

## 唯一性与索引建议

### 建议保留的业务唯一性

在“同一个快照内部”，建议唯一：

- `(snapshot_id, source, source_id)`

不要在整张历史表上强加：

- `(source, source_id)` 唯一

因为历史表本来就应该允许同一实体跨快照存在多条版本。

### 建议索引

- `idx_collected_data_snapshot_id`
- `idx_collected_data_source_source_id`
- `idx_collected_data_entity_key`
- `idx_collected_data_is_current`
- `idx_collected_data_reference_date`
- `idx_snapshots_source_completed_at`

## 风险点

1. 存储量会明显增加
   - 需要评估保留周期
   - 可以考虑冷热分层

2. 写入复杂度上升
   - 需要批量 upsert / diff 逻辑

3. 当前接口语义会从“表”变成“视图”
   - 文档必须同步

4. 某些采集器缺稳定 `source_id`
   - 需要补齐实体稳定键策略

## 对当前项目的具体建议

结合当前代码，推荐这样落地：

### 短期

1. 先设计并落表：
   - `data_snapshots`
   - `collected_data` 新字段
2. 采集完成后每次新增快照
3. `/api/v1/collected` 默认查 `is_current = true`

### 中期

1. 在 `BaseCollector._save_data()` 中改成：
   - 生成快照
   - 批量写历史
   - 标记当前
2. 将 `CollectionTask.id` 关联到 `snapshot.task_id`

### 长期

1. 地图接口支持按 `snapshot_id` 查询
2. 仪表盘支持“最近一次快照变化量”
3. 告警支持绑定到快照版本

## 最终建议

最终建议采用：

- 历史事实表：保存每次采集结果
- 当前视图：服务管理后台默认查询
- 快照表：承载版本批次和 diff 语义

这样既能保留历史，又不会把当前页面全部推翻重做，是最适合后续做态势感知的一条路径。
