1691 lines
52 KiB
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"
|
|
}
|