package identity import ( "context" "database/sql" "errors" "fmt" "strings" "sync" "sync/atomic" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) type Resolver interface { Resolve(context.Context, envelope.FrameEnvelope) (envelope.FrameEnvelope, error) } type NoopResolver struct{} func (NoopResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) { return env, nil } type TimeoutResolver struct { Delegate Resolver Timeout time.Duration } func (r TimeoutResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) { if r.Delegate == nil { return env, nil } if r.Timeout <= 0 { return r.Delegate.Resolve(ctx, env) } resolveCtx, cancel := context.WithTimeout(ctx, r.Timeout) defer cancel() return r.Delegate.Resolve(resolveCtx, env) } type MySQLResolver struct { db *sql.DB table string snapshotOnlyLookups bool snapshotRefreshMu sync.Mutex snapshot atomic.Pointer[identitySnapshot] locationTouchInterval time.Duration locationTouchRetryInterval time.Duration registrationWriteAttempts int registrationWriteRetryDelay time.Duration lookupCacheTTL time.Duration staleLookupTTL time.Duration cacheCleanupInterval time.Duration maxCacheEntries int sourceCodeLookup bool lookupMu sync.Mutex lookupCache map[string]lookupCacheEntry registrationCache map[string]registrationCacheEntry sourceCodeCache map[string]sourceCodeCacheEntry nextLookupCleanup time.Time touchMu sync.Mutex locationTouches map[string]time.Time locationTouchFailures map[string]time.Time nextTouchCleanup time.Time registrationWriteQueue chan jt808RegistrationWriteJob registrationWriteClosed chan struct{} registrationWriteDone chan struct{} registrationWriteOnce sync.Once registrationWriteWG sync.WaitGroup registrationWriteTimeout time.Duration registrationEnqueueTimeout time.Duration disableRegistrationWrites bool onRegistrationWriteError func(error) onRegistrationWriteResult func(RegistrationWriteResult) } type MySQLResolverOptions struct { SnapshotOnlyLookups bool LocationTouchInterval time.Duration LocationTouchRetryInterval time.Duration RegistrationWriteAttempts int RegistrationWriteRetryDelay time.Duration LookupCacheTTL time.Duration StaleLookupTTL time.Duration CacheCleanupInterval time.Duration MaxCacheEntries int SourceCodeLookup bool AsyncRegistrationWrites bool RegistrationWriteQueueSize int RegistrationWriteWorkers int RegistrationWriteTimeout time.Duration RegistrationEnqueueTimeout time.Duration DisableRegistrationWrites bool OnRegistrationWriteError func(error) OnRegistrationWriteResult func(RegistrationWriteResult) } type RegistrationWriteResult struct { Mode string Status string Err error } type CacheStats struct { LookupEntries int RegistrationEntries int SourceCodeEntries int LocationTouchEntries int LocationTouchFailureEntries int RegistrationWriteQueueDepth int RegistrationWriteQueueCap int SnapshotBindingEntries int SnapshotIdentifierEntries int SnapshotRegistrationEntries int SnapshotSourceEntries int SnapshotReady bool SnapshotRefreshedAt time.Time MaxEntries int } type jt808RegistrationWriteJob struct { query string args []any locationTouchPhone string locationTouchAt time.Time } var ErrRegistrationWriteQueueClosed = errors.New("jt808 registration write queue is closed") var ErrRegistrationWriteQueueTimeout = errors.New("jt808 registration write queue enqueue timeout") type lookupCacheEntry struct { vin string sourceCode string platformName string sourceKind string notFound bool expiresAt time.Time staleAt time.Time } type registrationCacheEntry struct { vin string deviceID string plate string authToken string notFound bool expiresAt time.Time staleAt time.Time } type sourceCodeCacheEntry struct { sourceCode string platformName string sourceKind string notFound bool expiresAt time.Time staleAt time.Time } type sourceMetadata struct { SourceCode string PlatformName string SourceKind string } type vehicleIdentifierMatch struct { VIN string SourceCode string PlatformName string } var errAmbiguousIdentifier = errors.New("ambiguous vehicle identifier") func NewMySQLResolver(db *sql.DB, table string) *MySQLResolver { return NewMySQLResolverWithOptions(db, table, MySQLResolverOptions{}) } func NewMySQLResolverWithOptions(db *sql.DB, table string, opts MySQLResolverOptions) *MySQLResolver { if db == nil { panic("identity db must not be nil") } table = strings.TrimSpace(table) if table == "" || !safeIdentifier(table) { table = "vehicle_identity_binding" } interval := opts.LocationTouchInterval if interval <= 0 { interval = 10 * time.Minute } touchRetryInterval := opts.LocationTouchRetryInterval if touchRetryInterval == 0 { touchRetryInterval = 5 * time.Second } if touchRetryInterval < 0 { touchRetryInterval = 0 } if touchRetryInterval > interval { touchRetryInterval = interval } writeAttempts := opts.RegistrationWriteAttempts if writeAttempts <= 0 { writeAttempts = 2 } writeRetryDelay := opts.RegistrationWriteRetryDelay if writeRetryDelay == 0 { writeRetryDelay = 20 * time.Millisecond } if writeRetryDelay < 0 { writeRetryDelay = 0 } lookupTTL := opts.LookupCacheTTL if lookupTTL <= 0 { lookupTTL = 10 * time.Minute } staleLookupTTL := opts.StaleLookupTTL if staleLookupTTL == 0 { staleLookupTTL = time.Hour } if staleLookupTTL < 0 { staleLookupTTL = 0 } cleanupInterval := opts.CacheCleanupInterval if cleanupInterval <= 0 { cleanupInterval = time.Minute } maxEntries := opts.MaxCacheEntries if maxEntries <= 0 { maxEntries = 300000 } resolver := &MySQLResolver{ db: db, table: table, snapshotOnlyLookups: opts.SnapshotOnlyLookups, locationTouchInterval: interval, locationTouchRetryInterval: touchRetryInterval, registrationWriteAttempts: writeAttempts, registrationWriteRetryDelay: writeRetryDelay, lookupCacheTTL: lookupTTL, staleLookupTTL: staleLookupTTL, cacheCleanupInterval: cleanupInterval, maxCacheEntries: maxEntries, sourceCodeLookup: opts.SourceCodeLookup, lookupCache: map[string]lookupCacheEntry{}, registrationCache: map[string]registrationCacheEntry{}, sourceCodeCache: map[string]sourceCodeCacheEntry{}, locationTouches: map[string]time.Time{}, locationTouchFailures: map[string]time.Time{}, onRegistrationWriteError: opts.OnRegistrationWriteError, onRegistrationWriteResult: opts.OnRegistrationWriteResult, disableRegistrationWrites: opts.DisableRegistrationWrites, } if opts.AsyncRegistrationWrites && !opts.DisableRegistrationWrites { queueSize := opts.RegistrationWriteQueueSize if queueSize <= 0 { queueSize = 100000 } workers := opts.RegistrationWriteWorkers if workers <= 0 { workers = 4 } timeout := opts.RegistrationWriteTimeout if timeout <= 0 { timeout = 5 * time.Second } enqueueTimeout := opts.RegistrationEnqueueTimeout if enqueueTimeout == 0 { enqueueTimeout = 50 * time.Millisecond } if enqueueTimeout < 0 { enqueueTimeout = 0 } resolver.registrationWriteQueue = make(chan jt808RegistrationWriteJob, queueSize) resolver.registrationWriteClosed = make(chan struct{}) resolver.registrationWriteDone = make(chan struct{}) resolver.registrationWriteTimeout = timeout resolver.registrationEnqueueTimeout = enqueueTimeout resolver.registrationWriteWG.Add(workers) for i := 0; i < workers; i++ { go resolver.registrationWriteWorker() } go func() { resolver.registrationWriteWG.Wait() close(resolver.registrationWriteDone) }() } return resolver } func (r *MySQLResolver) CacheStats() CacheStats { if r == nil { return CacheStats{} } r.lookupMu.Lock() stats := CacheStats{ LookupEntries: len(r.lookupCache), RegistrationEntries: len(r.registrationCache), SourceCodeEntries: len(r.sourceCodeCache), MaxEntries: r.maxCacheEntries, } if snapshot := r.snapshot.Load(); snapshot != nil { stats.SnapshotBindingEntries = len(snapshot.bindings) stats.SnapshotIdentifierEntries = len(snapshot.identifiers) stats.SnapshotRegistrationEntries = len(snapshot.registrations) stats.SnapshotSourceEntries = len(snapshot.sources) stats.SnapshotReady = true stats.SnapshotRefreshedAt = snapshot.refreshedAt } r.lookupMu.Unlock() r.touchMu.Lock() stats.LocationTouchEntries = len(r.locationTouches) stats.LocationTouchFailureEntries = len(r.locationTouchFailures) r.touchMu.Unlock() if r.registrationWriteQueue != nil { stats.RegistrationWriteQueueDepth = len(r.registrationWriteQueue) stats.RegistrationWriteQueueCap = cap(r.registrationWriteQueue) } return stats } func (r *MySQLResolver) Close() error { if r == nil || r.registrationWriteClosed == nil { return nil } r.registrationWriteOnce.Do(func() { close(r.registrationWriteClosed) }) <-r.registrationWriteDone return nil } func (r *MySQLResolver) EnsureSchema(ctx context.Context) error { if _, err := r.db.ExecContext(ctx, identityBindingTableSQL(r.table)); err != nil { return err } for _, statement := range identityBindingAlterSQL(r.table) { if _, err := r.db.ExecContext(ctx, statement); err != nil { if !isIgnoredIdentityBindingAlterError(err) { return err } } } if _, err := r.db.ExecContext(ctx, vehicleTableSQL); err != nil { return err } if _, err := r.db.ExecContext(ctx, vehicleIdentifierTableSQL); err != nil { return err } return EnsureJT808RegistrationSchema(ctx, r.db) } func identityBindingTableSQL(table string) string { if table == "" || !safeIdentifier(table) { table = "vehicle_identity_binding" } return `CREATE TABLE IF NOT EXISTS ` + table + ` ( vin VARCHAR(32) PRIMARY KEY, plate VARCHAR(32) NULL, phone VARCHAR(32) NULL, oem VARCHAR(64) NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_identity_plate (plate), UNIQUE KEY uk_identity_phone (phone) )` } func identityBindingAlterSQL(table string) []string { if table == "" || !safeIdentifier(table) { table = "vehicle_identity_binding" } return []string{ `ALTER TABLE ` + table + ` ADD COLUMN oem VARCHAR(64) NULL AFTER phone`, `ALTER TABLE ` + table + ` DROP INDEX uk_identity_device`, `ALTER TABLE ` + table + ` DROP COLUMN device_id`, } } func isIgnoredIdentityBindingAlterError(err error) bool { text := strings.ToLower(strings.TrimSpace(fmt.Sprint(err))) return strings.Contains(text, "duplicate column") || strings.Contains(text, "error 1060") || strings.Contains(text, "can't drop") || strings.Contains(text, "check that column/key exists") || strings.Contains(text, "error 1091") } const vehicleTableSQL = `CREATE TABLE IF NOT EXISTS vehicle ( vin VARCHAR(32) PRIMARY KEY, plate VARCHAR(32) NULL, oem VARCHAR(64) NULL, enabled TINYINT(1) NOT NULL DEFAULT 1, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_vehicle_plate (plate), KEY idx_vehicle_enabled (enabled) )` const vehicleIdentifierTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_identifier ( protocol VARCHAR(32) NOT NULL, source_code VARCHAR(64) NOT NULL DEFAULT '', identifier_type VARCHAR(32) NOT NULL, identifier_value VARCHAR(128) NOT NULL, vin VARCHAR(32) NOT NULL, plate VARCHAR(32) NULL, oem VARCHAR(64) NULL, raw_value VARCHAR(128) NULL, enabled TINYINT(1) NOT NULL DEFAULT 1, latest_import_file VARCHAR(255) NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (protocol, source_code, identifier_type, identifier_value), KEY idx_identifier_lookup (protocol, identifier_type, identifier_value, enabled), KEY idx_identifier_vin (vin), KEY idx_identifier_plate (plate) )` const jt808RegistrationTableSQL = `CREATE TABLE IF NOT EXISTS jt808_registration ( phone VARCHAR(32) PRIMARY KEY, device_id VARCHAR(64) NULL, plate VARCHAR(32) NULL, vin VARCHAR(32) NULL DEFAULT 'unknown', province VARCHAR(16) NULL, city VARCHAR(16) NULL, manufacturer VARCHAR(64) NULL, device_type VARCHAR(64) NULL, plate_color VARCHAR(16) NULL, auth_token VARCHAR(128) NULL, auth_imei VARCHAR(64) NULL, auth_software_version VARCHAR(64) NULL, source_endpoint VARCHAR(64) NULL, source_ip VARCHAR(64) NULL, first_registered_at DATETIME NULL, latest_registered_at DATETIME NULL, latest_authenticated_at DATETIME NULL, latest_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_jt808_registration_device (device_id), KEY idx_jt808_registration_plate (plate), KEY idx_jt808_registration_vin (vin), KEY idx_jt808_registration_source_ip (source_ip), KEY idx_jt808_registration_seen (latest_seen_at) )` var jt808RegistrationAlterSQL = []string{ "ALTER TABLE jt808_registration ADD COLUMN source_ip VARCHAR(64) NULL AFTER source_endpoint", "ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip (source_ip)", } const jt808RegistrationSourceIPBackfillSQL = `UPDATE jt808_registration SET source_ip = SUBSTRING_INDEX(TRIM(source_endpoint), ':', 1) WHERE (source_ip IS NULL OR TRIM(source_ip) = '') AND source_endpoint IS NOT NULL AND TRIM(source_endpoint) <> ''` func isIgnoredJT808RegistrationAlterError(err error) bool { text := strings.ToLower(strings.TrimSpace(fmt.Sprint(err))) return strings.Contains(text, "duplicate column") || strings.Contains(text, "duplicate key name") || strings.Contains(text, "error 1060") || strings.Contains(text, "error 1061") } func safeIdentifier(value string) bool { if value == "" { return false } for _, r := range value { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { continue } return false } return true } func (r *MySQLResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) { env.Phone = normalizePhone(env.Phone) if strings.TrimSpace(env.VIN) != "" { if err := r.trackJT808(ctx, env); err != nil { return env, err } return env, nil } source, err := r.sourceMetadataForEnvelope(ctx, env) if err != nil { return env, err } applyEnvelopeSourceMetadata(&env, source) candidates := CandidateKeys(env) for _, candidate := range candidates { if env.Protocol == envelope.ProtocolJT808 { vin, identitySource, cacheStatus, identitySourceMetadata, err := r.lookupVehicleIdentifier(ctx, env.Protocol, source.SourceCode, candidate) if err != nil { if !errors.Is(err, sql.ErrNoRows) && !errors.Is(err, errAmbiguousIdentifier) { return env, err } } else if strings.TrimSpace(vin) != "" { env.VIN = strings.TrimSpace(vin) overrideEnvelopeSourceMetadata(&env, identitySourceMetadata) addEnvelopeIdentityMetadataWithCacheStatus(&env, identitySource, candidate.Value, cacheStatus) env.EventID = env.StableEventID() if err := r.trackJT808(ctx, env); err != nil { return env, err } return env, nil } } vin, cacheStatus, err := r.lookup(ctx, candidate.Column, candidate.Value) if err != nil { if errors.Is(err, sql.ErrNoRows) { continue } return env, err } if strings.TrimSpace(vin) != "" { env.VIN = strings.TrimSpace(vin) addEnvelopeIdentityMetadataWithCacheStatus(&env, candidate.Column, candidate.Value, cacheStatus) env.EventID = env.StableEventID() if err := r.trackJT808(ctx, env); err != nil { return env, err } return env, nil } } resolved, ok, err := r.resolveFromJT808Registration(ctx, env, source.SourceCode) if err != nil { return env, err } if ok { env = resolved if err := r.trackJT808(ctx, env); err != nil { return env, err } return env, nil } if err := r.trackJT808(ctx, env); err != nil { return env, err } return env, nil } func (r *MySQLResolver) resolveFromJT808Registration(ctx context.Context, env envelope.FrameEnvelope, sourceCode string) (envelope.FrameEnvelope, bool, error) { if env.Protocol != envelope.ProtocolJT808 || env.MessageID != "0x0200" || strings.TrimSpace(env.Phone) == "" { return env, false, nil } vin, deviceID, plate, cacheStatus, err := r.lookupJT808Registration(ctx, env.Phone) if err != nil { if errors.Is(err, sql.ErrNoRows) { return env, false, nil } return env, false, err } env.DeviceID = firstNonEmpty(env.DeviceID, deviceID) env.Plate = firstNonEmpty(env.Plate, plate) if strings.TrimSpace(vin) != "" && !strings.EqualFold(strings.TrimSpace(vin), "unknown") { env.VIN = strings.TrimSpace(vin) env.EventID = env.StableEventID() r.enrichResolvedJT808SourceMetadata(ctx, &env, env.VIN) addEnvelopeIdentityMetadataWithCacheStatus(&env, "jt808_registration.vin", env.Phone, cacheStatus) return env, true, nil } value := strings.TrimSpace(env.Plate) if value == "" { return env, false, nil } vin, source, cacheStatus, metadata, err := r.lookupVehicleIdentifier(ctx, env.Protocol, sourceCode, CandidateKey{Column: "plate", Value: value}) if err == nil && strings.TrimSpace(vin) != "" { env.VIN = strings.TrimSpace(vin) overrideEnvelopeSourceMetadata(&env, metadata) env.EventID = env.StableEventID() addEnvelopeIdentityMetadataWithCacheStatus(&env, source, value, cacheStatus) return env, true, nil } if err != nil && !errors.Is(err, sql.ErrNoRows) && !errors.Is(err, errAmbiguousIdentifier) { return env, false, err } vin, cacheStatus, err = r.lookup(ctx, "plate", value) if err != nil { if errors.Is(err, sql.ErrNoRows) { return env, false, nil } return env, false, err } if strings.TrimSpace(vin) == "" { return env, false, nil } env.VIN = strings.TrimSpace(vin) env.EventID = env.StableEventID() addEnvelopeIdentityMetadataWithCacheStatus(&env, "jt808_registration.plate", value, cacheStatus) return env, true, nil } func (r *MySQLResolver) enrichResolvedJT808SourceMetadata(ctx context.Context, env *envelope.FrameEnvelope, vin string) { if r == nil || env == nil || env.Protocol != envelope.ProtocolJT808 || strings.TrimSpace(vin) == "" { return } for _, candidate := range CandidateKeys(*env) { if candidateIdentifierType(candidate.Column) == "" { continue } matchedVIN, _, _, metadata, err := r.lookupVehicleIdentifier(ctx, env.Protocol, "", candidate) if err != nil || strings.TrimSpace(matchedVIN) == "" { continue } if !strings.EqualFold(strings.TrimSpace(matchedVIN), strings.TrimSpace(vin)) { continue } overrideEnvelopeSourceMetadata(env, metadata) return } } 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() var stale registrationCacheEntry hasStale := false r.lookupMu.Lock() cached, ok := r.registrationCache[phone] if ok && now.Before(cached.expiresAt) { r.lookupMu.Unlock() if cached.notFound { return "", "", "", "", sql.ErrNoRows } return cached.vin, cached.deviceID, cached.plate, "", nil } if ok && cached.canServeStale(now) { stale = cached hasStale = true } r.lookupMu.Unlock() if snapshot, ok := r.snapshotRegistration(phone); ok { return snapshot.vin, snapshot.deviceID, snapshot.plate, "snapshot", nil } if r.snapshotOnlyLookups { return "", "", "", "", sql.ErrNoRows } var vinValue, deviceValue, plateValue sql.NullString err = r.db.QueryRowContext(ctx, "SELECT vin, device_id, plate FROM jt808_registration WHERE phone = ?", phone). Scan(&vinValue, &deviceValue, &plateValue) if err != nil && !errors.Is(err, sql.ErrNoRows) { if hasStale { return stale.vin, stale.deviceID, stale.plate, "stale", nil } return "", "", "", "", err } entry := registrationCacheEntry{ vin: strings.TrimSpace(vinValue.String), deviceID: strings.TrimSpace(deviceValue.String), plate: strings.TrimSpace(plateValue.String), notFound: errors.Is(err, sql.ErrNoRows), expiresAt: now.Add(r.lookupCacheTTL), } entry.staleAt = r.staleUntil(entry.expiresAt, entry.notFound) r.cacheRegistration(phone, entry, now) if entry.notFound { return "", "", "", "", sql.ErrNoRows } return entry.vin, entry.deviceID, entry.plate, "", nil } func addIdentityMetadata(parsed map[string]any, source string, value string) { if parsed == nil { return } identity, _ := parsed["identity"].(map[string]any) if identity == nil { identity = map[string]any{} } identity["resolved"] = true identity["source"] = source identity["value"] = value parsed["identity"] = identity } func addEnvelopeIdentityMetadata(env *envelope.FrameEnvelope, source string, value string) { addEnvelopeIdentityMetadataWithCacheStatus(env, source, value, "") } func addEnvelopeIdentityMetadataWithCacheStatus(env *envelope.FrameEnvelope, source string, value string, cacheStatus string) { if env == nil { return } if env.Parsed == nil { env.Parsed = map[string]any{} } addIdentityMetadata(env.Parsed, source, value) if cacheStatus != "" { if identity, ok := env.Parsed["identity"].(map[string]any); ok { identity["cache_status"] = cacheStatus } } } func (r *MySQLResolver) sourceMetadataForEnvelope(ctx context.Context, env envelope.FrameEnvelope) (sourceMetadata, error) { if !r.sourceCodeLookup || env.Protocol != envelope.ProtocolJT808 { return sourceMetadata{}, nil } sourceIP := normalizeEndpointIP(env.SourceEndpoint) if sourceIP == "" { return sourceMetadata{}, nil } protocolValue := string(env.Protocol) cacheKey := protocolValue + "\x00" + sourceIP if snapshot, ok := r.snapshotSource(protocolValue, sourceIP); ok { return snapshot, nil } if r.snapshotOnlyLookups { return sourceMetadata{}, nil } now := time.Now() var stale sourceCodeCacheEntry hasStale := false r.lookupMu.Lock() cached, ok := r.sourceCodeCache[cacheKey] if ok && now.Before(cached.expiresAt) { r.lookupMu.Unlock() if cached.notFound { return sourceMetadata{}, nil } return cached.sourceMetadata(), nil } if ok && cached.canServeStale(now) { stale = cached hasStale = true } r.lookupMu.Unlock() var sourceCode, platformName, sourceKind sql.NullString err := r.db.QueryRowContext(ctx, `SELECT source_code, platform_name, source_kind FROM vehicle_data_source WHERE protocol = ? AND source_ip = ? AND enabled = 1 AND ( (source_code IS NOT NULL AND TRIM(source_code) <> '') OR source_kind IN ('PLATFORM', 'DIRECT') OR (platform_name IS NOT NULL AND TRIM(platform_name) <> '') ) ORDER BY updated_at DESC LIMIT 1`, protocolValue, sourceIP).Scan(&sourceCode, &platformName, &sourceKind) if err != nil { if errors.Is(err, sql.ErrNoRows) || isOptionalSourceCodeLookupError(err) { r.cacheSourceMetadata(cacheKey, sourceMetadata{}, true, now) return sourceMetadata{}, nil } if hasStale { return stale.sourceMetadata(), nil } return sourceMetadata{}, err } value := sourceMetadata{ SourceCode: strings.TrimSpace(sourceCode.String), PlatformName: strings.TrimSpace(platformName.String), SourceKind: strings.TrimSpace(sourceKind.String), } r.cacheSourceMetadata(cacheKey, value, value.SourceCode == "" && value.PlatformName == "" && value.SourceKind == "", now) return value, nil } func (r *MySQLResolver) cacheSourceMetadata(cacheKey string, metadata sourceMetadata, notFound bool, now time.Time) { r.lookupMu.Lock() r.sourceCodeCache[cacheKey] = sourceCodeCacheEntry{ sourceCode: strings.TrimSpace(metadata.SourceCode), platformName: strings.TrimSpace(metadata.PlatformName), sourceKind: strings.TrimSpace(metadata.SourceKind), notFound: notFound, expiresAt: now.Add(r.lookupCacheTTL), } entry := r.sourceCodeCache[cacheKey] entry.staleAt = r.staleUntil(entry.expiresAt, entry.notFound) r.sourceCodeCache[cacheKey] = entry r.cleanupLookupCachesLocked(now, false) r.lookupMu.Unlock() } func (entry sourceCodeCacheEntry) sourceMetadata() sourceMetadata { return sourceMetadata{ SourceCode: strings.TrimSpace(entry.sourceCode), PlatformName: strings.TrimSpace(entry.platformName), SourceKind: strings.TrimSpace(entry.sourceKind), } } func applyEnvelopeSourceMetadata(env *envelope.FrameEnvelope, metadata sourceMetadata) { applyEnvelopeSourceMetadataMode(env, metadata, false) } func overrideEnvelopeSourceMetadata(env *envelope.FrameEnvelope, metadata sourceMetadata) { applyEnvelopeSourceMetadataMode(env, metadata, true) } func applyEnvelopeSourceMetadataMode(env *envelope.FrameEnvelope, metadata sourceMetadata, override bool) { if env == nil { return } if value := strings.TrimSpace(metadata.SourceCode); value != "" && (override || env.SourceCode == "") { env.SourceCode = value } if value := strings.TrimSpace(metadata.PlatformName); value != "" && (override || env.PlatformName == "") { env.PlatformName = value } if value := strings.TrimSpace(metadata.SourceKind); value != "" && (override || env.SourceKind == "") { env.SourceKind = value } if env.Parsed == nil || (env.SourceCode == "" && env.PlatformName == "" && env.SourceKind == "") { return } identity, _ := env.Parsed["identity"].(map[string]any) if identity == nil { identity = map[string]any{} } if env.SourceCode != "" { identity["source_code"] = env.SourceCode } if env.PlatformName != "" { identity["platform_name"] = env.PlatformName } if env.SourceKind != "" { identity["source_kind"] = env.SourceKind } env.Parsed["identity"] = identity } func normalizeEndpointIP(endpoint string) string { return envelope.NormalizeSourceEndpointKey(endpoint) } func isOptionalSourceCodeLookupError(err error) bool { if err == nil { return false } text := strings.ToLower(strings.TrimSpace(fmt.Sprint(err))) return strings.Contains(text, "error 1146") || strings.Contains(text, "doesn't exist") || strings.Contains(text, "unknown table") } func (r *MySQLResolver) lookupVehicleIdentifier(ctx context.Context, protocol envelope.Protocol, sourceCode string, candidate CandidateKey) (string, string, string, sourceMetadata, error) { identifierType := candidateIdentifierType(candidate.Column) if identifierType == "" { return "", "", "", sourceMetadata{}, sql.ErrNoRows } value := normalizeIdentifierValue(identifierType, candidate.Value) if value == "" { return "", "", "", sourceMetadata{}, sql.ErrNoRows } protocolValue := string(protocol) sourceCode = strings.TrimSpace(sourceCode) if sourceCode != "" { vin, source, cacheStatus, metadata, err := r.lookupVehicleIdentifierInScope(ctx, protocolValue, sourceCode, identifierType, value) if err == nil || !errors.Is(err, sql.ErrNoRows) { return vin, source, cacheStatus, metadata, err } } return r.lookupVehicleIdentifierInScope(ctx, protocolValue, "", identifierType, value) } func (r *MySQLResolver) lookupVehicleIdentifierInScope(ctx context.Context, protocolValue string, sourceCode string, identifierType string, value string) (string, string, string, sourceMetadata, error) { scope := strings.TrimSpace(sourceCode) cacheKey := "vehicle_identifier\x00" + protocolValue + "\x00" + scope + "\x00" + identifierType + "\x00" + value if snapshot, ok := r.snapshotIdentifier(protocolValue, scope, identifierType, value); ok { metadata := snapshot.sourceMetadata() return snapshot.VIN, vehicleIdentifierSource(identifierType, firstNonEmpty(scope, metadata.SourceCode)), "snapshot", metadata, nil } if r.snapshotOnlyLookups { return "", "", "", sourceMetadata{}, sql.ErrNoRows } now := time.Now() var stale lookupCacheEntry hasStale := false r.lookupMu.Lock() cached, ok := r.lookupCache[cacheKey] if ok && now.Before(cached.expiresAt) { r.lookupMu.Unlock() if cached.notFound { return "", "", "", sourceMetadata{}, sql.ErrNoRows } return cached.vin, vehicleIdentifierSource(identifierType, firstNonEmpty(scope, cached.sourceCode)), "", cached.sourceMetadata(), nil } if ok && cached.canServeStale(now) { stale = cached hasStale = true } r.lookupMu.Unlock() query := `SELECT DISTINCT vin, source_code, COALESCE(NULLIF(TRIM(oem), ''), source_code) AS platform_name FROM vehicle_identifier WHERE protocol = ? AND identifier_type = ? AND identifier_value = ? AND enabled = 1 AND vin IS NOT NULL AND vin <> ''` args := []any{protocolValue, identifierType, value} if scope != "" { query += ` AND source_code = ?` args = append(args, scope) } query += ` ORDER BY vin, source_code LIMIT 2` match, err := querySingleVehicleIdentifier(ctx, r.db, query, args...) if err != nil { if hasStale && isTransientLookupError(err) { return stale.vin, vehicleIdentifierSource(identifierType, firstNonEmpty(scope, stale.sourceCode)), "stale", stale.sourceMetadata(), nil } if isTransientLookupError(err) { return "", "", "", sourceMetadata{}, err } r.cacheLookup(cacheKey, r.newLookupCacheEntry(vehicleIdentifierMatch{}, true, now), now) return "", "", "", sourceMetadata{}, err } r.cacheLookup(cacheKey, r.newLookupCacheEntry(match, false, now), now) metadata := match.sourceMetadata() return match.VIN, vehicleIdentifierSource(identifierType, firstNonEmpty(scope, metadata.SourceCode)), "", metadata, nil } func querySingleVehicleIdentifier(ctx context.Context, db *sql.DB, query string, args ...any) (vehicleIdentifierMatch, error) { rows, err := db.QueryContext(ctx, query, args...) if err != nil { return vehicleIdentifierMatch{}, err } defer rows.Close() vins := map[string]struct{}{} sourceCodes := map[string]struct{}{} platformNames := map[string]struct{}{} for rows.Next() { var vin, sourceCode, platformName sql.NullString if err := rows.Scan(&vin, &sourceCode, &platformName); err != nil { return vehicleIdentifierMatch{}, err } vinValue := strings.TrimSpace(vin.String) if vinValue == "" { continue } vins[vinValue] = struct{}{} sourceCodeValue := strings.TrimSpace(sourceCode.String) if sourceCodeValue != "" { sourceCodes[sourceCodeValue] = struct{}{} } platformNameValue := strings.TrimSpace(platformName.String) if platformNameValue != "" { platformNames[platformNameValue] = struct{}{} } } if err := rows.Err(); err != nil { return vehicleIdentifierMatch{}, err } if len(vins) == 1 { match := vehicleIdentifierMatch{} for vin := range vins { match.VIN = vin } if len(sourceCodes) == 1 { for sourceCode := range sourceCodes { match.SourceCode = sourceCode } if len(platformNames) == 1 { for platformName := range platformNames { match.PlatformName = platformName } } } return match, nil } if len(vins) > 1 { return vehicleIdentifierMatch{}, errAmbiguousIdentifier } return vehicleIdentifierMatch{}, sql.ErrNoRows } func vehicleIdentifierSource(identifierType string, sourceCode string) string { source := "vehicle_identifier." + identifierType if strings.TrimSpace(sourceCode) != "" { source += "." + strings.TrimSpace(sourceCode) } return source } func (r *MySQLResolver) lookup(ctx context.Context, column string, value string) (string, string, error) { cacheKey := column + "\x00" + value if vin, ok := r.snapshotBinding(column, value); ok { return vin, "snapshot", nil } if r.snapshotOnlyLookups { return "", "", sql.ErrNoRows } now := time.Now() var stale lookupCacheEntry hasStale := false r.lookupMu.Lock() cached, ok := r.lookupCache[cacheKey] if ok && now.Before(cached.expiresAt) { r.lookupMu.Unlock() if cached.notFound { return "", "", sql.ErrNoRows } return cached.vin, "", nil } if ok && cached.canServeStale(now) { stale = cached hasStale = true } r.lookupMu.Unlock() query := "SELECT vin FROM " + r.table + " WHERE " + column + " = ? AND vin IS NOT NULL AND vin <> ''" var vin string err := r.db.QueryRowContext(ctx, query, value).Scan(&vin) if err != nil && !errors.Is(err, sql.ErrNoRows) { if hasStale { return stale.vin, "stale", nil } return "", "", err } r.cacheLookup(cacheKey, r.newLookupCacheEntry(vehicleIdentifierMatch{VIN: strings.TrimSpace(vin)}, errors.Is(err, sql.ErrNoRows), now), now) return vin, "", err } func (r *MySQLResolver) cacheLookup(cacheKey string, entry lookupCacheEntry, now time.Time) { r.lookupMu.Lock() if entry.staleAt.IsZero() { entry.staleAt = r.staleUntil(entry.expiresAt, entry.notFound) } r.lookupCache[cacheKey] = entry r.cleanupLookupCachesLocked(now, false) r.lookupMu.Unlock() } func (r *MySQLResolver) cacheRegistration(phone string, entry registrationCacheEntry, now time.Time) { r.lookupMu.Lock() if entry.staleAt.IsZero() { entry.staleAt = r.staleUntil(entry.expiresAt, entry.notFound) } r.registrationCache[phone] = entry r.cleanupLookupCachesLocked(now, false) r.lookupMu.Unlock() } func (r *MySQLResolver) newLookupCacheEntry(match vehicleIdentifierMatch, notFound bool, now time.Time) lookupCacheEntry { expiresAt := now.Add(r.lookupCacheTTL) metadata := match.sourceMetadata() return lookupCacheEntry{ vin: strings.TrimSpace(match.VIN), sourceCode: strings.TrimSpace(metadata.SourceCode), platformName: strings.TrimSpace(metadata.PlatformName), sourceKind: strings.TrimSpace(metadata.SourceKind), notFound: notFound, expiresAt: expiresAt, staleAt: r.staleUntil(expiresAt, notFound), } } func (match vehicleIdentifierMatch) sourceMetadata() sourceMetadata { sourceCode := strings.TrimSpace(match.SourceCode) if sourceCode == "" { return sourceMetadata{} } return sourceMetadata{ SourceCode: sourceCode, PlatformName: strings.TrimSpace(firstNonEmpty(match.PlatformName, sourceCode)), SourceKind: "PLATFORM", } } func (e lookupCacheEntry) sourceMetadata() sourceMetadata { return sourceMetadata{ SourceCode: strings.TrimSpace(e.sourceCode), PlatformName: strings.TrimSpace(e.platformName), SourceKind: strings.TrimSpace(e.sourceKind), } } func (r *MySQLResolver) staleUntil(expiresAt time.Time, notFound bool) time.Time { if notFound || r.staleLookupTTL <= 0 { return expiresAt } return expiresAt.Add(r.staleLookupTTL) } func (e lookupCacheEntry) canServeStale(now time.Time) bool { return !e.notFound && strings.TrimSpace(e.vin) != "" && now.Before(e.staleAt) } func (e registrationCacheEntry) canServeStale(now time.Time) bool { return !e.notFound && (strings.TrimSpace(e.vin) != "" || strings.TrimSpace(e.deviceID) != "" || strings.TrimSpace(e.plate) != "") && now.Before(e.staleAt) } func (e sourceCodeCacheEntry) canServeStale(now time.Time) bool { return !e.notFound && strings.TrimSpace(e.sourceCode) != "" && now.Before(e.staleAt) } func isTransientLookupError(err error) bool { return err != nil && !errors.Is(err, sql.ErrNoRows) && !errors.Is(err, errAmbiguousIdentifier) } func (r *MySQLResolver) cleanupLookupCachesLocked(now time.Time, force bool) { if !force && r.maxCacheEntries > 0 && len(r.lookupCache) <= r.maxCacheEntries && len(r.registrationCache) <= r.maxCacheEntries && len(r.sourceCodeCache) <= r.maxCacheEntries && !r.nextLookupCleanup.IsZero() && now.Before(r.nextLookupCleanup) { return } r.nextLookupCleanup = now.Add(r.cacheCleanupInterval) for key, entry := range r.lookupCache { if !now.Before(entry.staleAt) { delete(r.lookupCache, key) } } for key, entry := range r.registrationCache { if !now.Before(entry.staleAt) { delete(r.registrationCache, key) } } for key, entry := range r.sourceCodeCache { if !now.Before(entry.staleAt) { delete(r.sourceCodeCache, key) } } r.trimLookupCacheLocked() r.trimRegistrationCacheLocked() r.trimSourceCodeCacheLocked() } func (r *MySQLResolver) trimLookupCacheLocked() { if r.maxCacheEntries <= 0 { return } for len(r.lookupCache) > r.maxCacheEntries { victim := "" var victimExpiresAt time.Time for key, entry := range r.lookupCache { if victim == "" || entry.expiresAt.Before(victimExpiresAt) { victim = key victimExpiresAt = entry.expiresAt } } if victim == "" { return } delete(r.lookupCache, victim) } } func (r *MySQLResolver) trimRegistrationCacheLocked() { if r.maxCacheEntries <= 0 { return } for len(r.registrationCache) > r.maxCacheEntries { victim := "" var victimExpiresAt time.Time for key, entry := range r.registrationCache { if victim == "" || entry.expiresAt.Before(victimExpiresAt) { victim = key victimExpiresAt = entry.expiresAt } } if victim == "" { return } delete(r.registrationCache, victim) } } func (r *MySQLResolver) trimSourceCodeCacheLocked() { if r.maxCacheEntries <= 0 { return } for len(r.sourceCodeCache) > r.maxCacheEntries { victim := "" var victimExpiresAt time.Time for key, entry := range r.sourceCodeCache { if victim == "" || entry.expiresAt.Before(victimExpiresAt) { victim = key victimExpiresAt = entry.expiresAt } } if victim == "" { return } delete(r.sourceCodeCache, victim) } } func candidateIdentifierType(column string) string { switch strings.TrimSpace(strings.ToLower(column)) { case "phone": return "JT808_PHONE" case "plate": return "PLATE" default: return "" } } func normalizeIdentifierValue(identifierType string, value string) string { value = strings.TrimSpace(value) switch strings.ToUpper(strings.TrimSpace(identifierType)) { case "JT808_PHONE": return normalizePhone(value) default: return value } } func (r *MySQLResolver) trackJT808(ctx context.Context, env envelope.FrameEnvelope) error { if env.Protocol != envelope.ProtocolJT808 || strings.TrimSpace(env.Phone) == "" || strings.TrimSpace(env.MessageID) == "" { return nil } registration := mapValue(env.Parsed, "registration") authentication := mapValue(env.Parsed, "authentication") deviceID := firstNonEmpty(env.DeviceID, textValue(registration, "device_id")) plate := firstNonEmpty(env.Plate, textValue(registration, "plate")) vin := strings.TrimSpace(env.VIN) if vin == "" { vin = "unknown" } if r.disableRegistrationWrites { // The gateway still updates its local phone session immediately. Durable // registration/authentication facts are projected from Kafka raw by the // identity writer, keeping MySQL latency out of the ingress process. r.cacheJT808Registration(env, deviceID, plate, vin) if env.MessageID == JT808RegisterMessageID || env.MessageID == JT808AuthMessageID { r.observeRegistrationWrite("delegated", "ok", nil) } return nil } locationTouchPhone := "" locationTouchAt := time.Time{} if env.MessageID != JT808RegisterMessageID && env.MessageID != JT808AuthMessageID { var ok bool locationTouchPhone, locationTouchAt, ok = r.reserveJT808LocationTouch(env) if !ok { return nil } } firstRegisteredAt := nilTime() latestRegisteredAt := nilTime() if env.MessageID == JT808RegisterMessageID { firstRegisteredAt = "CURRENT_TIMESTAMP" latestRegisteredAt = "CURRENT_TIMESTAMP" } latestAuthenticatedAt := nilTime() if env.MessageID == JT808AuthMessageID { latestAuthenticatedAt = "CURRENT_TIMESTAMP" } query := `INSERT INTO jt808_registration (phone, device_id, plate, vin, province, city, manufacturer, device_type, plate_color, auth_token, auth_imei, auth_software_version, source_endpoint, source_ip, first_registered_at, latest_registered_at, latest_authenticated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ` + firstRegisteredAt + `, ` + latestRegisteredAt + `, ` + latestAuthenticatedAt + `) ON DUPLICATE KEY UPDATE device_id = IF(VALUES(device_id) <> '', VALUES(device_id), device_id), plate = IF(VALUES(plate) <> '', VALUES(plate), plate), vin = IF(VALUES(vin) <> 'unknown', VALUES(vin), vin), province = IF(VALUES(province) <> '', VALUES(province), province), city = IF(VALUES(city) <> '', VALUES(city), city), manufacturer = IF(VALUES(manufacturer) <> '', VALUES(manufacturer), manufacturer), device_type = IF(VALUES(device_type) <> '', VALUES(device_type), device_type), plate_color = IF(VALUES(plate_color) <> '', VALUES(plate_color), plate_color), auth_token = IF(VALUES(auth_token) <> '', VALUES(auth_token), auth_token), auth_imei = IF(VALUES(auth_imei) <> '', VALUES(auth_imei), auth_imei), auth_software_version = IF(VALUES(auth_software_version) <> '', VALUES(auth_software_version), auth_software_version), source_endpoint = IF(VALUES(source_endpoint) <> '', VALUES(source_endpoint), source_endpoint), source_ip = IF(VALUES(source_ip) <> '', VALUES(source_ip), source_ip), latest_seen_at = CURRENT_TIMESTAMP, first_registered_at = COALESCE(first_registered_at, VALUES(first_registered_at)), latest_registered_at = COALESCE(VALUES(latest_registered_at), latest_registered_at), latest_authenticated_at = COALESCE(VALUES(latest_authenticated_at), latest_authenticated_at)` args := []any{ env.Phone, deviceID, plate, vin, textValue(registration, "province"), textValue(registration, "city"), textValue(registration, "manufacturer"), textValue(registration, "device_type"), textValue(registration, "plate_color"), textValue(authentication, "token"), textValue(authentication, "imei"), textValue(authentication, "software_version"), env.SourceEndpoint, normalizeEndpointIP(env.SourceEndpoint), } if r.registrationWriteQueue != nil { if err := r.enqueueJT808RegistrationWrite(ctx, jt808RegistrationWriteJob{ query: query, args: args, locationTouchPhone: locationTouchPhone, locationTouchAt: locationTouchAt, }); err != nil { r.observeRegistrationWrite("async_enqueue", "error", err) if locationTouchPhone != "" { r.rollbackJT808LocationTouch(locationTouchPhone, locationTouchAt) r.markJT808LocationTouchFailure(locationTouchPhone) } return err } r.observeRegistrationWrite("async_enqueue", "ok", nil) r.cacheJT808Registration(env, deviceID, plate, vin) return nil } err := r.execJT808Registration(ctx, query, args...) if err != nil { r.observeRegistrationWrite("sync", "error", err) if locationTouchPhone != "" { r.rollbackJT808LocationTouch(locationTouchPhone, locationTouchAt) r.markJT808LocationTouchFailure(locationTouchPhone) } return err } r.observeRegistrationWrite("sync", "ok", nil) if locationTouchPhone != "" { r.clearJT808LocationTouchFailure(locationTouchPhone) } r.cacheJT808Registration(env, deviceID, plate, vin) return nil } func (r *MySQLResolver) enqueueJT808RegistrationWrite(ctx context.Context, job jt808RegistrationWriteJob) error { if r.registrationWriteQueue == nil { return r.execJT808Registration(ctx, job.query, job.args...) } select { case <-r.registrationWriteClosed: return ErrRegistrationWriteQueueClosed default: } var timeoutC <-chan time.Time var timer *time.Timer if r.registrationEnqueueTimeout > 0 { timer = time.NewTimer(r.registrationEnqueueTimeout) timeoutC = timer.C defer timer.Stop() } select { case r.registrationWriteQueue <- job: return nil case <-r.registrationWriteClosed: return ErrRegistrationWriteQueueClosed case <-ctx.Done(): return ctx.Err() case <-timeoutC: return ErrRegistrationWriteQueueTimeout } } func (r *MySQLResolver) registrationWriteWorker() { defer r.registrationWriteWG.Done() for { select { case job := <-r.registrationWriteQueue: r.runJT808RegistrationWriteJob(job) case <-r.registrationWriteClosed: for { select { case job := <-r.registrationWriteQueue: r.runJT808RegistrationWriteJob(job) default: return } } } } } func (r *MySQLResolver) runJT808RegistrationWriteJob(job jt808RegistrationWriteJob) { ctx := context.Background() var cancel context.CancelFunc if r.registrationWriteTimeout > 0 { ctx, cancel = context.WithTimeout(ctx, r.registrationWriteTimeout) } else { ctx, cancel = context.WithCancel(ctx) } err := r.execJT808Registration(ctx, job.query, job.args...) cancel() if err != nil { r.observeRegistrationWrite("async_background", "error", err) if job.locationTouchPhone != "" { r.rollbackJT808LocationTouch(job.locationTouchPhone, job.locationTouchAt) r.markJT808LocationTouchFailure(job.locationTouchPhone) } return } r.observeRegistrationWrite("async_background", "ok", nil) if job.locationTouchPhone != "" { r.clearJT808LocationTouchFailure(job.locationTouchPhone) } } func (r *MySQLResolver) observeRegistrationWrite(mode string, status string, err error) { if r == nil { return } mode = strings.TrimSpace(mode) if mode == "" { mode = "unknown" } status = strings.TrimSpace(status) if status == "" { status = "unknown" } if r.onRegistrationWriteResult != nil { r.onRegistrationWriteResult(RegistrationWriteResult{ Mode: mode, Status: status, Err: err, }) } if err != nil && r.onRegistrationWriteError != nil { r.onRegistrationWriteError(err) } } func (r *MySQLResolver) execJT808Registration(ctx context.Context, query string, args ...any) error { attempts := r.registrationWriteAttempts if attempts <= 0 { attempts = 1 } var err error for attempt := 1; attempt <= attempts; attempt++ { _, err = r.db.ExecContext(ctx, query, args...) if err == nil || !isTransientMySQLIdentityWriteError(err) || attempt == attempts { return err } if r.registrationWriteRetryDelay <= 0 { continue } timer := time.NewTimer(r.registrationWriteRetryDelay) select { case <-ctx.Done(): timer.Stop() return ctx.Err() case <-timer.C: } } return err } func isTransientMySQLIdentityWriteError(err error) bool { if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } text := strings.ToLower(strings.TrimSpace(err.Error())) return strings.Contains(text, "deadlock") || strings.Contains(text, "error 1213") || strings.Contains(text, "40001") || strings.Contains(text, "lock wait timeout") || strings.Contains(text, "error 1205") || strings.Contains(text, "timeout") || strings.Contains(text, "temporary") || strings.Contains(text, "temporarily") || strings.Contains(text, "connection refused") || strings.Contains(text, "connection reset") || strings.Contains(text, "connection closed") || strings.Contains(text, "broken pipe") || strings.Contains(text, "bad connection") || strings.Contains(text, "invalid connection") || strings.Contains(text, "i/o timeout") || text == "eof" || strings.Contains(text, "unexpected eof") || strings.Contains(text, "server is down") || strings.Contains(text, "network is unreachable") || strings.Contains(text, "no route to host") } func (r *MySQLResolver) cacheJT808Registration(env envelope.FrameEnvelope, deviceID string, plate string, vin string) { phone := normalizePhone(env.Phone) if phone == "" { return } vin = strings.TrimSpace(vin) if vin == "" { vin = "unknown" } now := time.Now() r.lookupMu.Lock() defer r.lookupMu.Unlock() existing := r.registrationCache[phone] r.registrationCache[phone] = registrationCacheEntry{ vin: preferKnownVIN(vin, existing.vin), deviceID: firstNonEmpty(deviceID, existing.deviceID), plate: firstNonEmpty(plate, existing.plate), notFound: false, expiresAt: now.Add(r.lookupCacheTTL), } entry := r.registrationCache[phone] entry.staleAt = r.staleUntil(entry.expiresAt, entry.notFound) r.registrationCache[phone] = entry r.cleanupLookupCachesLocked(now, false) } func preferKnownVIN(candidate string, existing string) string { candidate = strings.TrimSpace(candidate) existing = strings.TrimSpace(existing) if candidate != "" && !strings.EqualFold(candidate, "unknown") { return candidate } if existing != "" { return existing } if candidate != "" { return candidate } return "unknown" } func (r *MySQLResolver) reserveJT808LocationTouch(env envelope.FrameEnvelope) (string, time.Time, bool) { if env.MessageID != "0x0200" { return "", time.Time{}, false } phone := normalizePhone(env.Phone) if phone == "" { return "", time.Time{}, false } now := time.Now() r.touchMu.Lock() defer r.touchMu.Unlock() if retryAt, ok := r.locationTouchFailures[phone]; ok { if now.Before(retryAt) { return "", time.Time{}, false } delete(r.locationTouchFailures, phone) } last, ok := r.locationTouches[phone] if ok && now.Sub(last) < r.locationTouchInterval { return "", time.Time{}, false } r.locationTouches[phone] = now r.cleanupLocationTouchesLocked(now, false) return phone, now, true } func (r *MySQLResolver) rollbackJT808LocationTouch(phone string, reservedAt time.Time) { phone = normalizePhone(phone) if phone == "" || reservedAt.IsZero() { return } r.touchMu.Lock() defer r.touchMu.Unlock() if current, ok := r.locationTouches[phone]; ok && current.Equal(reservedAt) { delete(r.locationTouches, phone) } } func (r *MySQLResolver) markJT808LocationTouchFailure(phone string) { phone = normalizePhone(phone) if phone == "" || r.locationTouchRetryInterval <= 0 { return } r.touchMu.Lock() defer r.touchMu.Unlock() if r.locationTouchFailures == nil { r.locationTouchFailures = map[string]time.Time{} } now := time.Now() r.locationTouchFailures[phone] = now.Add(r.locationTouchRetryInterval) r.cleanupLocationTouchesLocked(now, false) } func (r *MySQLResolver) clearJT808LocationTouchFailure(phone string) { phone = normalizePhone(phone) if phone == "" { return } r.touchMu.Lock() defer r.touchMu.Unlock() delete(r.locationTouchFailures, phone) } func (r *MySQLResolver) cleanupLocationTouchesLocked(now time.Time, force bool) { if !force && r.maxCacheEntries > 0 && len(r.locationTouches) <= r.maxCacheEntries && len(r.locationTouchFailures) <= r.maxCacheEntries && !r.nextTouchCleanup.IsZero() && now.Before(r.nextTouchCleanup) { return } r.nextTouchCleanup = now.Add(r.cacheCleanupInterval) cutoff := now.Add(-r.locationTouchInterval) for phone, last := range r.locationTouches { if last.Before(cutoff) || last.Equal(cutoff) { delete(r.locationTouches, phone) } } for phone, retryAt := range r.locationTouchFailures { if !now.Before(retryAt) { delete(r.locationTouchFailures, phone) } } if r.maxCacheEntries <= 0 { return } for len(r.locationTouches) > r.maxCacheEntries { victim := "" var oldest time.Time for phone, last := range r.locationTouches { if victim == "" || last.Before(oldest) { victim = phone oldest = last } } if victim == "" { return } delete(r.locationTouches, victim) } for len(r.locationTouchFailures) > r.maxCacheEntries { victim := "" var earliest time.Time for phone, retryAt := range r.locationTouchFailures { if victim == "" || retryAt.Before(earliest) { victim = phone earliest = retryAt } } if victim == "" { return } delete(r.locationTouchFailures, victim) } } type CandidateKey struct { Column string Value string } func CandidateKeys(env envelope.FrameEnvelope) []CandidateKey { var out []CandidateKey add := func(column string, value string) { value = strings.TrimSpace(value) if value == "" || strings.EqualFold(value, "unknown") { return } for _, existing := range out { if existing.Column == column && existing.Value == value { return } } out = append(out, CandidateKey{Column: column, Value: value}) } add("phone", normalizePhone(env.Phone)) add("plate", env.Plate) add("vin", env.VehicleKeyHint) return out } func normalizePhone(phone string) string { trimmed := strings.TrimLeft(strings.TrimSpace(phone), "0") if trimmed == "" { return strings.TrimSpace(phone) } return trimmed } func mapValue(values map[string]any, key string) map[string]any { if values == nil { return nil } nested, _ := values[key].(map[string]any) return nested } func textValue(values map[string]any, key string) string { if values == nil { return "" } value, ok := values[key] if !ok || value == nil { return "" } switch typed := value.(type) { case string: return strings.TrimSpace(typed) default: return strings.TrimSpace(fmt.Sprint(typed)) } } func firstNonEmpty(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" } func nilTime() string { return "NULL" }