diff --git a/go/vehicle-gateway/internal/identity/resolver.go b/go/vehicle-gateway/internal/identity/resolver.go index 4433c2e9..1e29c150 100644 --- a/go/vehicle-gateway/internal/identity/resolver.go +++ b/go/vehicle-gateway/internal/identity/resolver.go @@ -612,6 +612,13 @@ func (r *MySQLResolver) enrichResolvedJT808SourceMetadata(ctx context.Context, e func (r *MySQLResolver) lookupJT808Registration(ctx context.Context, phone string) (vin string, deviceID string, plate string, cacheStatus string, err error) { phone = normalizePhone(phone) now := time.Now() + // The immutable snapshot is the authoritative vehicle binding. Registration + // and authentication frames can create a short-lived local session whose VIN + // is still "unknown"; letting that session win would permanently mask a known + // snapshot because every location frame refreshes the session TTL. + if snapshot, ok := r.snapshotRegistration(phone); ok && snapshot.canResolveVehicle() { + return snapshot.vin, snapshot.deviceID, snapshot.plate, "snapshot", nil + } var stale registrationCacheEntry hasStale := false r.lookupMu.Lock() @@ -659,6 +666,11 @@ func (r *MySQLResolver) lookupJT808Registration(ctx context.Context, phone strin return entry.vin, entry.deviceID, entry.plate, "", nil } +func (e registrationCacheEntry) canResolveVehicle() bool { + vin := strings.TrimSpace(e.vin) + return (vin != "" && !strings.EqualFold(vin, "unknown")) || strings.TrimSpace(e.plate) != "" +} + func addIdentityMetadata(parsed map[string]any, source string, value string) { if parsed == nil { return diff --git a/go/vehicle-gateway/internal/identity/snapshot_test.go b/go/vehicle-gateway/internal/identity/snapshot_test.go index 558d974b..a0f7de32 100644 --- a/go/vehicle-gateway/internal/identity/snapshot_test.go +++ b/go/vehicle-gateway/internal/identity/snapshot_test.go @@ -74,6 +74,62 @@ func TestSnapshotOnlyResolverMissDoesNotQueryMySQL(t *testing.T) { } } +func TestKnownSnapshotRecoversAfterUnknownJT808SessionCache(t *testing.T) { + db, mock := newMockDB(t) + defer db.Close() + + resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{ + SnapshotOnlyLookups: true, + DisableRegistrationWrites: true, + LookupCacheTTL: 10 * time.Minute, + }) + resolver.snapshot.Store(&identitySnapshot{ + bindings: map[string]string{}, + identifiers: map[string]vehicleIdentifierMatch{}, + registrations: map[string]registrationCacheEntry{ + "64100005824": {vin: "LB9A32A2XR0LS1401", plate: "粤AGP9713"}, + }, + sources: map[string]sourceMetadata{}, + }) + + // A reconnect normally authenticates before the first location report. The + // authentication frame has no VIN and therefore creates an unknown session. + if _, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{ + Protocol: envelope.ProtocolJT808, + MessageID: JT808AuthMessageID, + Phone: "64100005824", + Parsed: map[string]any{}, + }); err != nil { + t.Fatalf("resolve authentication frame: %v", err) + } + if entry := resolver.registrationCache["64100005824"]; entry.vin != "unknown" { + t.Fatalf("authentication session vin = %q, want unknown setup", entry.vin) + } + + resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{ + Protocol: envelope.ProtocolJT808, + MessageID: "0x0200", + Phone: "64100005824", + Parsed: map[string]any{}, + }) + if err != nil { + t.Fatalf("resolve location frame: %v", err) + } + if resolved.VIN != "LB9A32A2XR0LS1401" || resolved.Plate != "粤AGP9713" { + t.Fatalf("resolved identity = vin:%q plate:%q", resolved.VIN, resolved.Plate) + } + identityMetadata, _ := resolved.Parsed["identity"].(map[string]any) + if identityMetadata["cache_status"] != "snapshot" { + t.Fatalf("identity metadata = %#v, want authoritative snapshot", identityMetadata) + } + if entry := resolver.registrationCache["64100005824"]; entry.vin != "LB9A32A2XR0LS1401" { + t.Fatalf("location frame did not heal session cache: %+v", entry) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("snapshot-only recovery should not query mysql: %v", err) + } +} + func TestRefreshSnapshotFailureKeepsLastKnownGoodSnapshot(t *testing.T) { db, mock := newMockDB(t) defer db.Close()