package identity import ( "context" "database/sql" "errors" "fmt" "strings" "sync" "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 MySQLResolver struct { db *sql.DB table string locationTouchInterval time.Duration lookupCacheTTL time.Duration lookupMu sync.Mutex lookupCache map[string]lookupCacheEntry registrationCache map[string]registrationCacheEntry touchMu sync.Mutex locationTouches map[string]time.Time } type MySQLResolverOptions struct { LocationTouchInterval time.Duration LookupCacheTTL time.Duration } type lookupCacheEntry struct { vin string notFound bool expiresAt time.Time } type registrationCacheEntry struct { vin string deviceID string plate string notFound bool expiresAt time.Time } 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 } lookupTTL := opts.LookupCacheTTL if lookupTTL <= 0 { lookupTTL = 10 * time.Minute } return &MySQLResolver{ db: db, table: table, locationTouchInterval: interval, lookupCacheTTL: lookupTTL, lookupCache: map[string]lookupCacheEntry{}, registrationCache: map[string]registrationCacheEntry{}, locationTouches: map[string]time.Time{}, } } 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 } } } _, err := r.db.ExecContext(ctx, jt808RegistrationTableSQL) return err } 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 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, 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_seen (latest_seen_at) )` 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 } candidates := CandidateKeys(env) for _, candidate := range candidates { vin, 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) if env.Parsed == nil { env.Parsed = map[string]any{} } env.Parsed["identity"] = map[string]any{ "resolved": true, "source": candidate.Column, "value": candidate.Value, } 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) 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) (envelope.FrameEnvelope, bool, error) { if env.Protocol != envelope.ProtocolJT808 || env.MessageID != "0x0200" || strings.TrimSpace(env.Phone) == "" { return env, false, nil } vin, deviceID, plate, 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() addIdentityMetadata(env.Parsed, "jt808_registration.vin", env.Phone) return env, true, nil } value := strings.TrimSpace(env.Plate) if value == "" { return env, false, nil } vin, 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() addIdentityMetadata(env.Parsed, "jt808_registration.plate", value) return env, true, nil } func (r *MySQLResolver) lookupJT808Registration(ctx context.Context, phone string) (vin string, deviceID string, plate string, err error) { phone = normalizePhone(phone) now := time.Now() 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 } r.lookupMu.Unlock() 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) { 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), } r.lookupMu.Lock() r.registrationCache[phone] = entry r.lookupMu.Unlock() 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 } parsed["identity"] = map[string]any{ "resolved": true, "source": source, "value": value, } } func (r *MySQLResolver) lookup(ctx context.Context, column string, value string) (string, error) { cacheKey := column + "\x00" + value now := time.Now() 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 } 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) { return "", err } r.lookupMu.Lock() r.lookupCache[cacheKey] = lookupCacheEntry{ vin: strings.TrimSpace(vin), notFound: errors.Is(err, sql.ErrNoRows), expiresAt: now.Add(r.lookupCacheTTL), } r.lookupMu.Unlock() return vin, err } 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 } if env.MessageID != "0x0100" && env.MessageID != "0x0102" && !r.shouldTouchJT808Location(env) { 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" } firstRegisteredAt := nilTime() latestRegisteredAt := nilTime() if env.MessageID == "0x0100" { firstRegisteredAt = "CURRENT_TIMESTAMP" latestRegisteredAt = "CURRENT_TIMESTAMP" } latestAuthenticatedAt := nilTime() if env.MessageID == "0x0102" { 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, 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), 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)` _, err := r.db.ExecContext(ctx, query, 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, ) if err != nil { return err } return nil } func (r *MySQLResolver) shouldTouchJT808Location(env envelope.FrameEnvelope) bool { if env.MessageID != "0x0200" { return false } phone := normalizePhone(env.Phone) if phone == "" { return false } now := time.Now() r.touchMu.Lock() defer r.touchMu.Unlock() last, ok := r.locationTouches[phone] if ok && now.Sub(last) < r.locationTouchInterval { return false } r.locationTouches[phone] = now return true } 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" }