diff --git a/docs/architecture/production-data-plane-inventory.md b/docs/architecture/production-data-plane-inventory.md
index 2da9adf6..fbe7c106 100644
--- a/docs/architecture/production-data-plane-inventory.md
+++ b/docs/architecture/production-data-plane-inventory.md
@@ -87,11 +87,11 @@ Redis 使用 DB 50,定位为实时缓存,不作为历史事实来源。
| Key 族 | 用途 |
| --- | --- |
-| `vehicle:latest:{vehicleKey}` | 跨协议合并后的最新核心字段快照,不重复保存完整 parsed |
-| `vehicle:latest:{vehicleKey}:{protocol}` | 单协议最新轻量快照,保留核心 fields 和时间信息,不重复保存完整 parsed |
-| `vehicle:realtime-raw:{protocol}:{vehicleKey}` | 单协议最新完整 parsed 状态,是实时完整协议字段的唯一 Redis 副本 |
-| `vehicle:online:{vehicleKey}` | 在线状态和 TTL |
-| `vehicle:protocols:{vehicleKey}` | 当前车辆最近出现过的协议集合 |
+| `vehicle:latest:{vin}` | 跨协议合并后的最新核心字段快照,不重复保存完整 parsed;只写 VIN 非空车辆 |
+| `vehicle:latest:{vin}:{protocol}` | 单协议最新轻量快照,保留核心 fields 和时间信息,不重复保存完整 parsed |
+| `vehicle:realtime-raw:{protocol}:{vin}` | 单协议最新完整 parsed 状态,是实时完整协议字段的唯一 Redis 副本 |
+| `vehicle:online:{vin}` | 在线状态和 TTL |
+| `vehicle:protocols:{vin}` | 当前车辆最近出现过的协议集合 |
| `vehicle:last_seen` | 最近活跃车辆排序集合 |
审计时活跃 key 族主要是 `vehicle:latest:*`、`vehicle:realtime-raw:*`、`vehicle:online:*`、`vehicle:protocols:*`、`vehicle:last_seen`。旧文档中的 `vehicle:realtime:*`、`vehicle:merged:*` 不是当前活跃 key 族。
@@ -102,4 +102,4 @@ Redis 使用 DB 50,定位为实时缓存,不作为历史事实来源。
2. TDengine 只放高写入时序数据:RAW 证据和位置历史。
3. MySQL 只放低基数业务状态:身份、实时轻量快照、每日里程。
4. Redis 只放当前态,所有 key 都必须允许 TTL 过期后从 Kafka/TDengine/MySQL 重建。
-5. 协议新增字段默认进入 `raw_frames.parsed_json` 和 Redis realtime-raw;只有稳定查询需求出现后才提升为 TDengine/MySQL 列。
+5. 协议新增字段默认进入 `raw_frames.parsed_json`;VIN 非空车辆同步进入 Redis realtime-raw。只有稳定查询需求出现后才提升为 TDengine/MySQL 列。
diff --git a/docs/architecture/storage-minimal-contract.md b/docs/architecture/storage-minimal-contract.md
index 2d521410..a2f910de 100644
--- a/docs/architecture/storage-minimal-contract.md
+++ b/docs/architecture/storage-minimal-contract.md
@@ -26,7 +26,7 @@
| MySQL | `vehicle_daily_mileage` | 日期、VIN、协议、日里程、首末总里程、样本数 | 临时 vehicle key、泛化 metric key/value、自增 id、created_at、每帧细节、位置点列表 |
| MySQL | `vehicle_identity_binding` | 人工维护或导入的 VIN、车牌、phone、device_id 映射 | 注册历史、协议状态、自增 id、created_at |
| MySQL | `jt808_registration` | JT808 phone 主键下的注册、鉴权、VIN 匹配状态、来源端点 | GB32960/MQTT 注册信息、位置历史、created_at |
-| Redis | `vehicle:realtime-raw:{protocol}:{vin}` | 每协议最新完整 parsed 状态 | 历史数据、统计结果 |
+| Redis | `vehicle:realtime-raw:{protocol}:{vin}` | 每协议每 VIN 最新完整 parsed 状态 | 无 VIN 临时身份、历史数据、统计结果 |
## 当前应收敛的重复点
@@ -66,7 +66,12 @@
- 写入:车牌从 `vehicle_identity_binding` 按 VIN 主键直查,并使用 VIN 级短 TTL 内存缓存,避免实时帧每条都打 MySQL;缓存不作为事实来源。
- 生产:项目上线前可直接重建这两张 MySQL 表,实时数据会从 Kafka 新消息继续投影。
-7. identity 层只保留两张表。
+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/device_id 的映射,供 808 等协议反查 VIN。
- `jt808_registration`:808 注册、鉴权、最新活跃和 VIN 匹配状态。
- 状态:`vehicle_identity_binding` 使用 VIN 主键;`jt808_registration` 使用 phone 主键;两张表都不保留代理自增主键和 `created_at`。
diff --git a/docs/go-version-data-flow.html b/docs/go-version-data-flow.html
index 8224a641..2e414cf7 100644
--- a/docs/go-version-data-flow.html
+++ b/docs/go-version-data-flow.html
@@ -578,28 +578,28 @@
| Redis |
- vehicle:latest:{vehicle_key} |
+ vehicle:latest:{vin} |
realtime-api Kafka consumer |
跨协议合并后的实时字段快照 |
查 VIN 是否在线、查 VIN 实时数据 |
| Redis |
- vehicle:latest:{vehicle_key}:{protocol} |
+ vehicle:latest:{vin}:{protocol} |
realtime-api Kafka consumer |
单协议实时轻量快照 |
区分 32960/808/MQTT 的实时状态 |
| Redis |
- vehicle:realtime-raw:{protocol}:{vehicle_key} |
+ vehicle:realtime-raw:{protocol}:{vin} |
realtime-api Kafka consumer |
单协议最新 parsed 全量字段 |
实时 RAW 字段查看,不走 TDengine 历史扫描 |
| Redis |
- vehicle:online:{vehicle_key}、vehicle:last_seen |
+ vehicle:online:{vin}、vehicle:last_seen |
realtime-api Kafka consumer |
在线状态、最后接收时间、协议列表 |
在线判断和近期车辆列表 |
@@ -639,7 +639,7 @@
| VIN 是否在线 | Redis online key | TTL 内有数据即在线,默认 TTL 600 秒。 |
| VIN 实时数据 | Redis latest snapshot | 跨协议合并核心 fields;完整协议字段通过 realtime-raw 查询。 |
- | 单协议实时 RAW | Redis realtime-raw | 查看某 VIN/phone 在某协议下最新 parsed 全量字段。 |
+ | 单协议实时 RAW | Redis realtime-raw | 查看某 VIN 在某协议下最新 parsed 全量字段。 |
@@ -724,8 +724,8 @@
Redis latest snapshot 只保留统一实时核心字段;完整协议 parsed 只放 realtime-raw,避免重复缓存大 JSON。
- 身份解析降级
- VIN 优先,其次 phone、vehicle_key_hint、device_id,保证无 VIN 时仍可临时归档和查询。
+ 身份解析边界
+ 实时缓存只写 VIN 非空车辆;无 VIN 帧保留在 RAW 和注册/绑定排查链路,补齐绑定后通过新帧或回放进入实时态。
数据分层查询
diff --git a/go/vehicle-gateway/internal/realtime/repository.go b/go/vehicle-gateway/internal/realtime/repository.go
index a61c4924..cc535194 100644
--- a/go/vehicle-gateway/internal/realtime/repository.go
+++ b/go/vehicle-gateway/internal/realtime/repository.go
@@ -29,6 +29,9 @@ func NewRepository(client *redis.Client, cfg Config) *Repository {
func (r *Repository) Update(ctx context.Context, env envelope.FrameEnvelope) error {
vin := strings.TrimSpace(env.VIN)
+ if vin == "" {
+ return nil
+ }
vehicleKey := strings.TrimSpace(env.VehicleKey())
if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") {
return nil
diff --git a/go/vehicle-gateway/internal/realtime/repository_test.go b/go/vehicle-gateway/internal/realtime/repository_test.go
index 8943de04..92f44beb 100644
--- a/go/vehicle-gateway/internal/realtime/repository_test.go
+++ b/go/vehicle-gateway/internal/realtime/repository_test.go
@@ -297,7 +297,7 @@ func TestRepositoryOnlineStatus(t *testing.T) {
}
}
-func TestRepositoryUpdatesPhoneOnlyVehicleKey(t *testing.T) {
+func TestRepositorySkipsFramesWithoutVIN(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
ctx := context.Background()
@@ -315,23 +315,18 @@ func TestRepositoryUpdatesPhoneOnlyVehicleKey(t *testing.T) {
t.Fatalf("Update() error = %v", err)
}
- vehicleKey := "JT808:13307811170"
- merged, err := repo.GetMerged(ctx, vehicleKey)
- if err != nil {
- t.Fatalf("GetMerged() error = %v", err)
+ if _, err := repo.GetMerged(ctx, "JT808:13307811170"); err == nil {
+ t.Fatal("phone-only frame should not create a merged realtime snapshot")
}
- if merged.VehicleKey != vehicleKey || merged.VIN != "" {
- t.Fatalf("unexpected identity: %#v", merged)
+ if _, err := repo.GetRealtimeRaw(ctx, "JT808:13307811170", envelope.ProtocolJT808); err == nil {
+ t.Fatal("phone-only frame should not create realtime-raw")
}
- if merged.Fields[envelope.FieldTotalMileageKM] != 10003.7 {
- t.Fatalf("merged fields = %#v", merged.Fields)
- }
- status, err := repo.IsOnline(ctx, vehicleKey)
+ status, err := repo.IsOnline(ctx, "JT808:13307811170")
if err != nil {
t.Fatalf("IsOnline() error = %v", err)
}
- if !status.Online || status.VehicleKey != vehicleKey {
- t.Fatalf("online status = %#v", status)
+ if status.Online {
+ t.Fatalf("phone-only frame should not mark realtime online: %#v", status)
}
}
@@ -404,7 +399,7 @@ func TestHandlerReturnsMergedSnapshot(t *testing.T) {
}
}
-func TestHandlerReturnsPhoneOnlyVehicleKeySnapshot(t *testing.T) {
+func TestHandlerReturnsNotFoundForPhoneOnlyVehicleKey(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
if err := repo.Update(context.Background(), envelope.FrameEnvelope{
@@ -420,12 +415,9 @@ func TestHandlerReturnsPhoneOnlyVehicleKeySnapshot(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles/JT808:13307811170", nil)
rec := httptest.NewRecorder()
NewHandler(repo).ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
+ if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
- if !stringsContains(rec.Body.String(), `"vehicle_key":"JT808:13307811170"`) {
- t.Fatalf("unexpected body: %s", rec.Body.String())
- }
}
func TestHandlerReturnsRealtimeRaw(t *testing.T) {