Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity/resolver.go

1691 lines
52 KiB
Go

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"
}