package identity import ( "context" "database/sql" "fmt" "strings" "sync" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) const ( JT808RegisterMessageID = "0x0100" JT808AuthMessageID = "0x0102" JT808LocationMessageID = "0x0200" ) // JT808RegistrationFact is the durable identity projection carried by a raw // JT808 envelope. SeenAt uses gateway receive time so replay cannot move a // terminal to a future date because its device clock was wrong. type JT808RegistrationFact struct { Phone string DeviceID string Plate string VIN string Province string City string Manufacturer string DeviceType string PlateColor string AuthToken string AuthIMEI string AuthSoftwareVersion string SourceEndpoint string SourceIP string FirstRegisteredAt *time.Time LatestRegisteredAt *time.Time LatestAuthenticated *time.Time SeenAt time.Time } // JT808RegistrationProjector throttles ordinary location touches in memory. // Registration and authentication frames are never throttled. type JT808RegistrationProjector struct { location *time.Location touchInterval time.Duration retention time.Duration mu sync.Mutex lastTouches map[string]time.Time nextCleanup time.Time } func NewJT808RegistrationProjector(location *time.Location, touchInterval time.Duration) *JT808RegistrationProjector { if location == nil { location = time.Local } if touchInterval <= 0 { touchInterval = 10 * time.Minute } return &JT808RegistrationProjector{ location: location, touchInterval: touchInterval, retention: 24 * time.Hour, lastTouches: map[string]time.Time{}, } } // ProjectBatch returns at most one merged fact per phone. Call MarkPersisted // only after the database transaction succeeds; otherwise replay must remain // eligible immediately. func (p *JT808RegistrationProjector) ProjectBatch(envelopes []envelope.FrameEnvelope) []JT808RegistrationFact { if p == nil || len(envelopes) == 0 { return nil } p.mu.Lock() defer p.mu.Unlock() byPhone := make(map[string]JT808RegistrationFact) order := make([]string, 0, len(envelopes)) for _, env := range envelopes { fact, ok := p.projectLocked(env) if !ok { continue } if current, exists := byPhone[fact.Phone]; exists { byPhone[fact.Phone] = mergeJT808RegistrationFact(current, fact) continue } byPhone[fact.Phone] = fact order = append(order, fact.Phone) } result := make([]JT808RegistrationFact, 0, len(order)) for _, phone := range order { result = append(result, byPhone[phone]) } return result } func (p *JT808RegistrationProjector) MarkPersisted(facts []JT808RegistrationFact) { if p == nil || len(facts) == 0 { return } p.mu.Lock() defer p.mu.Unlock() for _, fact := range facts { phone := normalizePhone(fact.Phone) if phone == "" || fact.SeenAt.IsZero() { continue } if current := p.lastTouches[phone]; fact.SeenAt.After(current) { p.lastTouches[phone] = fact.SeenAt } } p.cleanupLocked(time.Now()) } func (p *JT808RegistrationProjector) projectLocked(env envelope.FrameEnvelope) (JT808RegistrationFact, bool) { if env.Protocol != envelope.ProtocolJT808 || env.ParseStatus == envelope.ParseBadFrame { return JT808RegistrationFact{}, false } messageID := strings.TrimSpace(env.MessageID) if messageID != JT808RegisterMessageID && messageID != JT808AuthMessageID && messageID != JT808LocationMessageID { return JT808RegistrationFact{}, false } phone := normalizePhone(env.Phone) if phone == "" { return JT808RegistrationFact{}, false } seenAt := p.receivedAt(env) if seenAt.IsZero() { return JT808RegistrationFact{}, false } if messageID == JT808LocationMessageID { if last := p.lastTouches[phone]; !last.IsZero() && seenAt.Before(last.Add(p.touchInterval)) { return JT808RegistrationFact{}, false } } registration := mapValue(env.Parsed, "registration") authentication := mapValue(env.Parsed, "authentication") authenticationAccepted := messageID != JT808AuthMessageID || !env.AuthenticationEnforced || env.AuthenticationStatus == "accepted" vin := strings.TrimSpace(env.VIN) if vin == "" { vin = "unknown" } fact := JT808RegistrationFact{ Phone: phone, DeviceID: firstNonEmpty(env.DeviceID, textValue(registration, "device_id"), parsedFieldText(env.ParsedFields, "jt808.registration.device_id")), Plate: firstNonEmpty(env.Plate, textValue(registration, "plate"), parsedFieldText(env.ParsedFields, "jt808.registration.plate")), VIN: vin, Province: firstNonEmpty(textValue(registration, "province"), parsedFieldText(env.ParsedFields, "jt808.registration.province")), City: firstNonEmpty(textValue(registration, "city"), parsedFieldText(env.ParsedFields, "jt808.registration.city")), Manufacturer: firstNonEmpty(textValue(registration, "manufacturer"), parsedFieldText(env.ParsedFields, "jt808.registration.manufacturer")), DeviceType: firstNonEmpty(textValue(registration, "device_type"), parsedFieldText(env.ParsedFields, "jt808.registration.device_type")), PlateColor: firstNonEmpty(textValue(registration, "plate_color"), parsedFieldText(env.ParsedFields, "jt808.registration.plate_color")), AuthToken: firstNonEmpty(textValue(authentication, "token"), parsedFieldText(env.ParsedFields, "jt808.authentication.token")), AuthIMEI: firstNonEmpty(textValue(authentication, "imei"), parsedFieldText(env.ParsedFields, "jt808.authentication.imei")), AuthSoftwareVersion: firstNonEmpty(textValue(authentication, "software_version"), parsedFieldText(env.ParsedFields, "jt808.authentication.software_version")), SourceEndpoint: strings.TrimSpace(env.SourceEndpoint), SourceIP: normalizeEndpointIP(env.SourceEndpoint), SeenAt: seenAt, } if !authenticationAccepted { fact.AuthToken = "" fact.AuthIMEI = "" fact.AuthSoftwareVersion = "" } if messageID == JT808RegisterMessageID { fact.FirstRegisteredAt = timePointer(seenAt) fact.LatestRegisteredAt = timePointer(seenAt) } if messageID == JT808AuthMessageID && authenticationAccepted { fact.LatestAuthenticated = timePointer(seenAt) } return fact, true } func parsedFieldText(fields map[string]any, key string) string { value, ok := fields[key] if !ok || value == nil { return "" } text := strings.TrimSpace(fmt.Sprint(value)) if text == "" { return "" } return text } func (p *JT808RegistrationProjector) receivedAt(env envelope.FrameEnvelope) time.Time { milliseconds := env.ReceivedAtMS if milliseconds <= 0 { milliseconds = env.EventTimeMS } if milliseconds <= 0 { return time.Time{} } return time.UnixMilli(milliseconds).In(p.location).Truncate(time.Second) } func (p *JT808RegistrationProjector) cleanupLocked(now time.Time) { if !p.nextCleanup.IsZero() && now.Before(p.nextCleanup) { return } p.nextCleanup = now.Add(time.Hour) cutoff := now.Add(-p.retention) for phone, touchedAt := range p.lastTouches { if touchedAt.Before(cutoff) { delete(p.lastTouches, phone) } } } func mergeJT808RegistrationFact(current JT808RegistrationFact, candidate JT808RegistrationFact) JT808RegistrationFact { if candidate.SeenAt.After(current.SeenAt) || candidate.SeenAt.Equal(current.SeenAt) { current.DeviceID = firstNonEmpty(candidate.DeviceID, current.DeviceID) current.Plate = firstNonEmpty(candidate.Plate, current.Plate) current.VIN = preferKnownVIN(candidate.VIN, current.VIN) current.Province = firstNonEmpty(candidate.Province, current.Province) current.City = firstNonEmpty(candidate.City, current.City) current.Manufacturer = firstNonEmpty(candidate.Manufacturer, current.Manufacturer) current.DeviceType = firstNonEmpty(candidate.DeviceType, current.DeviceType) current.PlateColor = firstNonEmpty(candidate.PlateColor, current.PlateColor) current.AuthToken = firstNonEmpty(candidate.AuthToken, current.AuthToken) current.AuthIMEI = firstNonEmpty(candidate.AuthIMEI, current.AuthIMEI) current.AuthSoftwareVersion = firstNonEmpty(candidate.AuthSoftwareVersion, current.AuthSoftwareVersion) current.SourceEndpoint = firstNonEmpty(candidate.SourceEndpoint, current.SourceEndpoint) current.SourceIP = firstNonEmpty(candidate.SourceIP, current.SourceIP) current.SeenAt = candidate.SeenAt } else { current.DeviceID = firstNonEmpty(current.DeviceID, candidate.DeviceID) current.Plate = firstNonEmpty(current.Plate, candidate.Plate) current.VIN = preferKnownVIN(current.VIN, candidate.VIN) current.Province = firstNonEmpty(current.Province, candidate.Province) current.City = firstNonEmpty(current.City, candidate.City) current.Manufacturer = firstNonEmpty(current.Manufacturer, candidate.Manufacturer) current.DeviceType = firstNonEmpty(current.DeviceType, candidate.DeviceType) current.PlateColor = firstNonEmpty(current.PlateColor, candidate.PlateColor) current.AuthToken = firstNonEmpty(current.AuthToken, candidate.AuthToken) current.AuthIMEI = firstNonEmpty(current.AuthIMEI, candidate.AuthIMEI) current.AuthSoftwareVersion = firstNonEmpty(current.AuthSoftwareVersion, candidate.AuthSoftwareVersion) } current.FirstRegisteredAt = earlierTimePointer(current.FirstRegisteredAt, candidate.FirstRegisteredAt) current.LatestRegisteredAt = laterTimePointer(current.LatestRegisteredAt, candidate.LatestRegisteredAt) current.LatestAuthenticated = laterTimePointer(current.LatestAuthenticated, candidate.LatestAuthenticated) return current } func timePointer(value time.Time) *time.Time { copy := value return © } func earlierTimePointer(left *time.Time, right *time.Time) *time.Time { if left == nil { return right } if right == nil || left.Before(*right) || left.Equal(*right) { return left } return right } func laterTimePointer(left *time.Time, right *time.Time) *time.Time { if left == nil { return right } if right == nil || left.After(*right) || left.Equal(*right) { return left } return right } type JT808RegistrationStore struct { db *sql.DB } func NewJT808RegistrationStore(db *sql.DB) *JT808RegistrationStore { if db == nil { panic("jt808 registration db must not be nil") } return &JT808RegistrationStore{db: db} } func EnsureJT808RegistrationSchema(ctx context.Context, db *sql.DB) error { if db == nil { return fmt.Errorf("jt808 registration db is nil") } if _, err := db.ExecContext(ctx, jt808RegistrationTableSQL); err != nil { return err } for _, statement := range jt808RegistrationAlterSQL { if _, err := db.ExecContext(ctx, statement); err != nil && !isIgnoredJT808RegistrationAlterError(err) { return err } } _, err := db.ExecContext(ctx, jt808RegistrationSourceIPBackfillSQL) return err } func (s *JT808RegistrationStore) UpsertBatch(ctx context.Context, facts []JT808RegistrationFact) error { if s == nil || s.db == nil || len(facts) == 0 { return nil } const columns = `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, latest_seen_at` values := make([]string, 0, len(facts)) args := make([]any, 0, len(facts)*18) for _, fact := range facts { phone := normalizePhone(fact.Phone) if phone == "" || fact.SeenAt.IsZero() { continue } vin := strings.TrimSpace(fact.VIN) if vin == "" { vin = "unknown" } values = append(values, "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") args = append(args, phone, strings.TrimSpace(fact.DeviceID), strings.TrimSpace(fact.Plate), vin, strings.TrimSpace(fact.Province), strings.TrimSpace(fact.City), strings.TrimSpace(fact.Manufacturer), strings.TrimSpace(fact.DeviceType), strings.TrimSpace(fact.PlateColor), strings.TrimSpace(fact.AuthToken), strings.TrimSpace(fact.AuthIMEI), strings.TrimSpace(fact.AuthSoftwareVersion), strings.TrimSpace(fact.SourceEndpoint), strings.TrimSpace(fact.SourceIP), fact.FirstRegisteredAt, fact.LatestRegisteredAt, fact.LatestAuthenticated, fact.SeenAt, ) } if len(values) == 0 { return nil } query := `INSERT INTO jt808_registration (` + columns + `) VALUES ` + strings.Join(values, ",") + ` ON DUPLICATE KEY UPDATE device_id = IF(VALUES(device_id) <> '' AND (device_id IS NULL OR device_id = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(device_id), device_id), plate = IF(VALUES(plate) <> '' AND (plate IS NULL OR plate = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(plate), plate), vin = IF(VALUES(vin) <> '' AND VALUES(vin) <> 'unknown' AND (vin IS NULL OR vin = '' OR vin = 'unknown' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(vin), vin), province = IF(VALUES(province) <> '' AND (province IS NULL OR province = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(province), province), city = IF(VALUES(city) <> '' AND (city IS NULL OR city = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(city), city), manufacturer = IF(VALUES(manufacturer) <> '' AND (manufacturer IS NULL OR manufacturer = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(manufacturer), manufacturer), device_type = IF(VALUES(device_type) <> '' AND (device_type IS NULL OR device_type = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(device_type), device_type), plate_color = IF(VALUES(plate_color) <> '' AND (plate_color IS NULL OR plate_color = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(plate_color), plate_color), auth_token = IF(VALUES(auth_token) <> '' AND (auth_token IS NULL OR auth_token = '' OR latest_authenticated_at IS NULL OR VALUES(latest_authenticated_at) >= latest_authenticated_at), VALUES(auth_token), auth_token), auth_imei = IF(VALUES(auth_imei) <> '' AND (auth_imei IS NULL OR auth_imei = '' OR latest_authenticated_at IS NULL OR VALUES(latest_authenticated_at) >= latest_authenticated_at), VALUES(auth_imei), auth_imei), auth_software_version = IF(VALUES(auth_software_version) <> '' AND (auth_software_version IS NULL OR auth_software_version = '' OR latest_authenticated_at IS NULL OR VALUES(latest_authenticated_at) >= latest_authenticated_at), VALUES(auth_software_version), auth_software_version), source_endpoint = IF(VALUES(source_endpoint) <> '' AND (source_endpoint IS NULL OR source_endpoint = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(source_endpoint), source_endpoint), source_ip = IF(VALUES(source_ip) <> '' AND (source_ip IS NULL OR source_ip = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(source_ip), source_ip), first_registered_at = CASE WHEN VALUES(first_registered_at) IS NULL THEN first_registered_at WHEN first_registered_at IS NULL THEN VALUES(first_registered_at) ELSE LEAST(first_registered_at, VALUES(first_registered_at)) END, latest_registered_at = CASE WHEN VALUES(latest_registered_at) IS NULL THEN latest_registered_at WHEN latest_registered_at IS NULL THEN VALUES(latest_registered_at) ELSE GREATEST(latest_registered_at, VALUES(latest_registered_at)) END, latest_authenticated_at = CASE WHEN VALUES(latest_authenticated_at) IS NULL THEN latest_authenticated_at WHEN latest_authenticated_at IS NULL THEN VALUES(latest_authenticated_at) ELSE GREATEST(latest_authenticated_at, VALUES(latest_authenticated_at)) END, latest_seen_at = GREATEST(latest_seen_at, VALUES(latest_seen_at))` _, err := s.db.ExecContext(ctx, query, args...) return err }