# 车辆数据最小落库合约

## 目标

车辆接入系统只持久化能回答业务问题、能回放纠错、能支撑高频查询的数据。协议细节默认保存在 raw，不向上层业务表扩散。

## 第一性原则

1. raw 是证据层，必须能证明收到过什么、解析成什么。
2. Kafka 是回放层，消费者失败后优先靠 Kafka lag 追平。
3. Redis 是当前态缓存，不是历史库。
4. TDengine 只承担高写入时间序列：raw、位置历史。
5. MySQL 只承担低基数业务状态：身份映射、实时快照、日指标。
6. 同一个事实只落一个主表，其他表只存查询必要的投影。
7. 新字段先由 Gateway 一次扁平化后进入 `parsed_fields`；TDengine 物理列仍名为 `parsed_json`，只有稳定查询需求出现后才提升为列。

## 目标表边界

| 存储 | 表/Key | 保留内容 | 不保留内容 |
| --- | --- | --- | --- |
| TDengine | `raw_frames` | raw hex/text、扁平 `parsed_fields` JSON（物理列 `parsed_json`）、解析状态、协议标签、车辆标识标签 | 原始嵌套解析树、`fields_json` 等重复字段 |
| TDengine | `raw_frame_payload_chunks` | 超长 raw/parsed payload 分片 | 业务查询字段 |
| TDengine | `vehicle_locations` | 时间、VIN/协议标签、经纬度、速度、方向、SOC、总里程等位置核心字段 | 完整协议 JSON、注册鉴权信息、phone/device_id/vehicle_key 兜底标识 |
| MySQL | `vehicle_realtime_snapshot` | 每个协议+VIN 的最新事件态、合并扁平字段，以及独立的首次/前次/最新接收时间、连续间隔、样本数和证据来源 | phone、device、原始嵌套解析树、raw 报文、无限接收历史 |
| MySQL | `vehicle_realtime_location` | 每个协议+VIN 的最新位置核心字段 | raw、parsed JSON、消息头内部字段 |
| MySQL | `vehicle_daily_mileage` | 日期、VIN、协议、日里程、首末总里程、样本数 | 临时 vehicle key、泛化 metric key/value、自增 id、created_at、每帧细节、位置点列表 |
| MySQL | `vehicle_identity_binding` | 人工维护或导入的 VIN、车牌、phone、oem 映射 | 注册历史、协议状态、device_id、自增 id、created_at |
| MySQL | `jt808_registration` | JT808 phone 主键下的注册、鉴权、VIN 匹配状态、来源端点 | GB32960/MQTT 注册信息、位置历史、created_at |
| Redis | `vehicle:realtime-raw:{protocol}:{vin}` | 每协议每 VIN 最新完整 parsed 状态 | 无 VIN 临时身份、历史数据、统计结果 |
| Redis | `vehicle:rt-kv:{protocol}:{vin}:values/types/times/meta` | 每协议每 VIN 最新扁平实时字段、类型、字段级事件时间和投影元信息 | 历史字段值、统计结果、无 VIN 临时身份 |

## 当前应收敛的重复点

1. `vehicle_mileage_points` 与 `vehicle_locations.total_mileage_km` 重复。
   - 状态：Go 写入链路已停止创建和写入 `vehicle_mileage_points`。
   - 查询：不再暴露单独 `/api/history/mileage-points`，里程点直接从 `/api/history/locations` 的 `total_mileage_km` 获取。
   - 生产：ECS TDengine 历史库已在上线前重建，`vehicle_mileage_points` 不再存在。

2. `raw_frames.fields_json` 与 `raw_frames.parsed_json`、`vehicle_locations` 重复。
   - 状态：新建 raw schema 和 raw 写入已停止使用 `fields_json`。
   - 兼容：raw 查询只选择公共列，不依赖 `fields_json`；旧表保留该列也不影响查询。
   - 生产：ECS TDengine 历史库已在上线前重建，`raw_frames` 不再包含 `fields_json`。

3. `vehicle_daily_mileage` 不再接收临时 `vehicle_key`。
   - 原因：日统计是正式业务指标，应只统计已经定位到 VIN 的车辆。
   - 无 VIN 的 JT808 等数据保留在 raw/Redis/session 中用于排查和待绑定，不进入正式车辆指标。
   - 状态：schema 使用 `PRIMARY KEY (vin, stat_date, protocol)`，不保留自增 `id` 和 `created_at`；首末总里程和样本数保留为日里程计算状态。
   - 索引：主键已覆盖按 VIN 查询，不再额外维护 `idx_vin`；只保留按日期、协议日期查询需要的索引。
   - 查询：`/api/stats/daily-metrics` 默认不执行 `COUNT(*)`，`total` 表示本页返回条数；只有报表总页数等场景传 `includeTotal=true`。
   - 生产：RDS 已在上线前删除泛化 `vehicle_daily_metric`，改为专用 `vehicle_daily_mileage`。

4. `vehicle_locations` 不再接收临时身份兜底字段。
   - 原因：位置历史是正式车辆时间序列，只按 `protocol + vin` 分表和查询。
   - 无 VIN 的位置帧仍保留在 `raw_frames.parsed_json`，待 identity/binding 修复后可从 raw 重放补写。
   - 查询：`/api/history/locations` 默认不执行大表 `COUNT(*)`，`total` 表示本页返回条数；只有需要精确总数时传 `includeTotal=true`。
   - 生产：ECS TDengine `vehicle_locations` 已在上线前重建为 `protocol`、`vin` 两个 tag。

5. 历史 raw 查询默认不做精确总数。
   - 原因：`raw_frames` 是最高写入量证据层，分页/排查通常只需要最新一页数据。
   - 查询：`/api/history/raw-frames` 默认 `total` 为本页返回条数；只有导出前预估、后台管理需要总页数时才传 `includeTotal=true`。

6. MySQL realtime 当前态表不保留代理主键和创建时间。
   - 原因：`vehicle_realtime_snapshot`、`vehicle_realtime_location` 都是每协议每 VIN 一行的当前态投影，业务主键就是 `(protocol, vin)`。
   - 接入证据按接收时间单调推进，不受设备事件时间乱序影响；重复事件 ID 或回退接收时间不能增加样本数。`access_first_seen_source=snapshot_backfill` 仅表示部署时基线，只有 `live_writer` 新行能证明 writer 上线后的首次观测。
   - 状态：schema 使用 `PRIMARY KEY (protocol, vin)`，不保留自增 `id` 和 `created_at`，对外只暴露最新 `updated_at`。
   - 索引：不在 `vehicle_realtime_location` 维护经纬度组合索引；实时表只回答当前态，地理范围和轨迹类查询走 TDengine 历史位置。
   - 查询：`/api/realtime/snapshots`、`/api/realtime/locations` 默认不执行 `COUNT(*)`，`total` 表示本页返回条数；只有需要精确总数时传 `includeTotal=true`。
   - 写入：车牌从 `vehicle_identity_binding` 按 VIN 主键直查，并使用 VIN 级短 TTL 内存缓存，避免实时帧每条都打 MySQL；缓存不作为事实来源。
   - `vehicle_realtime_snapshot.parsed_json` 使用与 Redis KV 同口径的扁平字段对象，例如 `gb32960.vehicle.soc_percent`、`jt808.location.total_mileage_km`。
   - 不写 MySQL KV 投影表；实时全量字段保留在 Redis KV，历史全量字段走 TDengine raw fields。
   - 生产：项目上线前可直接重建这两张 MySQL 表，实时数据会从 Kafka 新消息继续投影。

7. Redis realtime 只服务已定位 VIN 的正式车辆。
   - 原因：在线状态和实时数据是业务当前态，不是身份排查队列。
   - 无 VIN 的 808 等帧只进入 raw 证据层和注册/绑定排查链路，不写 `vehicle:latest:*`、`vehicle:online:*`、`vehicle:realtime-raw:*`。
   - 绑定补齐 VIN 后，后续新帧自然进入实时态；历史补偿需要从 raw/Kafka 回放。

8. identity 层只保留两张表。
   - `vehicle_identity_binding`：VIN 与 plate/phone/oem 的映射，供 808 等协议反查 VIN。该表只允许外部导入或人工维护，服务运行时只读。
   - `jt808_registration`：808 注册、鉴权、最新活跃和 VIN 匹配状态。
   - 状态：`vehicle_identity_binding` 使用 VIN 主键；`jt808_registration` 使用 phone 主键；两张表都不保留代理自增主键和 `created_at`。
   - 查询：`vehicle_identity_binding` 的 phone/plate 都是唯一键，接入侧按唯一键直查 VIN，不做无意义排序。
   - 写入链路：gateway 对 binding 查 VIN 使用短 TTL 内存缓存，包含未命中缓存，避免无 VIN 高频位置帧每条都打 MySQL。
   - 生产：RDS 已在上线前删除旧 `vehicle_identity_binding_registration`、`vehicle_identity_bindings`，gateway 启动会自动创建最小 schema。

## 字段提升规则

协议字段只在 Gateway 解析、映射和扁平化一次，进入系统后分三层。`vehicle.fields.*` 事件中的所有字段名必须位于对应协议命名空间：`gb32960.*`、`jt808.*` 或 `yutong_mqtt.*`；统计层不读取内部标准化核心字段作为兼容兜底。

1. `parsed_fields`：默认入口，保存全量扁平协议字段；TDengine 继续使用兼容物理列名 `parsed_json`。
2. 核心列：只有跨协议稳定查询需要时才提升，例如经纬度、速度、SOC、总里程。
3. 指标表：只有聚合口径稳定、产品需要分页/排序/报表时才持久化。

反例：

- 不因为某个协议字段存在就增加 MySQL 列。
- 不为了方便调试在 snapshot/location 放完整 JSON。
- 不为 telemetry field 配置服务提前复制一份字段表；字段配置服务应从 raw/Kafka 回放生成自己的结果。

## 删除或迁移顺序

1. 先写测试证明旧 API 能从新主表读到同等结果。
2. 改代码停止写重复表或重复列。
3. 部署后观察 Kafka lag、writer 成功计数、API 结果。
4. 查询生产库确认旧表不再新增。
5. 做一次备份或导出。
6. 删除旧表或旧列。

任何一步无法证明安全，就停在兼容状态，不直接删生产数据。
