489 lines
14 KiB
Go
489 lines
14 KiB
Go
package identity
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
)
|
|
|
|
type Resolver interface {
|
|
Resolve(context.Context, envelope.FrameEnvelope) (envelope.FrameEnvelope, error)
|
|
}
|
|
|
|
type NoopResolver struct{}
|
|
|
|
func (NoopResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
|
|
return env, nil
|
|
}
|
|
|
|
type MySQLResolver struct {
|
|
db *sql.DB
|
|
table string
|
|
locationTouchInterval time.Duration
|
|
lookupCacheTTL time.Duration
|
|
lookupMu sync.Mutex
|
|
lookupCache map[string]lookupCacheEntry
|
|
registrationCache map[string]registrationCacheEntry
|
|
touchMu sync.Mutex
|
|
locationTouches map[string]time.Time
|
|
}
|
|
|
|
type MySQLResolverOptions struct {
|
|
LocationTouchInterval time.Duration
|
|
LookupCacheTTL time.Duration
|
|
}
|
|
|
|
type lookupCacheEntry struct {
|
|
vin string
|
|
notFound bool
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type registrationCacheEntry struct {
|
|
vin string
|
|
deviceID string
|
|
plate string
|
|
notFound bool
|
|
expiresAt time.Time
|
|
}
|
|
|
|
func NewMySQLResolver(db *sql.DB, table string) *MySQLResolver {
|
|
return NewMySQLResolverWithOptions(db, table, MySQLResolverOptions{})
|
|
}
|
|
|
|
func NewMySQLResolverWithOptions(db *sql.DB, table string, opts MySQLResolverOptions) *MySQLResolver {
|
|
if db == nil {
|
|
panic("identity db must not be nil")
|
|
}
|
|
table = strings.TrimSpace(table)
|
|
if table == "" || !safeIdentifier(table) {
|
|
table = "vehicle_identity_binding"
|
|
}
|
|
interval := opts.LocationTouchInterval
|
|
if interval <= 0 {
|
|
interval = 10 * time.Minute
|
|
}
|
|
lookupTTL := opts.LookupCacheTTL
|
|
if lookupTTL <= 0 {
|
|
lookupTTL = 10 * time.Minute
|
|
}
|
|
return &MySQLResolver{
|
|
db: db,
|
|
table: table,
|
|
locationTouchInterval: interval,
|
|
lookupCacheTTL: lookupTTL,
|
|
lookupCache: map[string]lookupCacheEntry{},
|
|
registrationCache: map[string]registrationCacheEntry{},
|
|
locationTouches: map[string]time.Time{},
|
|
}
|
|
}
|
|
|
|
func (r *MySQLResolver) EnsureSchema(ctx context.Context) error {
|
|
if _, err := r.db.ExecContext(ctx, identityBindingTableSQL(r.table)); err != nil {
|
|
return err
|
|
}
|
|
_, err := r.db.ExecContext(ctx, jt808RegistrationTableSQL)
|
|
return err
|
|
}
|
|
|
|
func identityBindingTableSQL(table string) string {
|
|
if table == "" || !safeIdentifier(table) {
|
|
table = "vehicle_identity_binding"
|
|
}
|
|
return `CREATE TABLE IF NOT EXISTS ` + table + ` (
|
|
vin VARCHAR(32) PRIMARY KEY,
|
|
plate VARCHAR(32) NULL,
|
|
phone VARCHAR(32) NULL,
|
|
device_id 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),
|
|
UNIQUE KEY uk_identity_device (device_id)
|
|
)`
|
|
}
|
|
|
|
const jt808RegistrationTableSQL = `CREATE TABLE IF NOT EXISTS jt808_registration (
|
|
phone VARCHAR(32) PRIMARY KEY,
|
|
device_id VARCHAR(64) NULL,
|
|
plate VARCHAR(32) NULL,
|
|
vin VARCHAR(32) NULL DEFAULT 'unknown',
|
|
province VARCHAR(16) NULL,
|
|
city VARCHAR(16) NULL,
|
|
manufacturer VARCHAR(64) NULL,
|
|
device_type VARCHAR(64) NULL,
|
|
plate_color VARCHAR(16) NULL,
|
|
auth_token VARCHAR(128) NULL,
|
|
auth_imei VARCHAR(64) NULL,
|
|
auth_software_version VARCHAR(64) NULL,
|
|
source_endpoint VARCHAR(64) NULL,
|
|
first_registered_at DATETIME NULL,
|
|
latest_registered_at DATETIME NULL,
|
|
latest_authenticated_at DATETIME NULL,
|
|
latest_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
KEY idx_jt808_registration_device (device_id),
|
|
KEY idx_jt808_registration_plate (plate),
|
|
KEY idx_jt808_registration_vin (vin),
|
|
KEY idx_jt808_registration_seen (latest_seen_at)
|
|
)`
|
|
|
|
func safeIdentifier(value string) bool {
|
|
if value == "" {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (r *MySQLResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
|
|
env.Phone = normalizePhone(env.Phone)
|
|
if strings.TrimSpace(env.VIN) != "" {
|
|
if err := r.trackJT808(ctx, env); err != nil {
|
|
return env, err
|
|
}
|
|
return env, nil
|
|
}
|
|
candidates := CandidateKeys(env)
|
|
for _, candidate := range candidates {
|
|
vin, err := r.lookup(ctx, candidate.Column, candidate.Value)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
continue
|
|
}
|
|
return env, err
|
|
}
|
|
if strings.TrimSpace(vin) != "" {
|
|
env.VIN = strings.TrimSpace(vin)
|
|
if env.Parsed == nil {
|
|
env.Parsed = map[string]any{}
|
|
}
|
|
env.Parsed["identity"] = map[string]any{
|
|
"resolved": true,
|
|
"source": candidate.Column,
|
|
"value": candidate.Value,
|
|
}
|
|
env.EventID = env.StableEventID()
|
|
if err := r.trackJT808(ctx, env); err != nil {
|
|
return env, err
|
|
}
|
|
return env, nil
|
|
}
|
|
}
|
|
resolved, ok, err := r.resolveFromJT808Registration(ctx, env)
|
|
if err != nil {
|
|
return env, err
|
|
}
|
|
if ok {
|
|
env = resolved
|
|
if err := r.trackJT808(ctx, env); err != nil {
|
|
return env, err
|
|
}
|
|
return env, nil
|
|
}
|
|
if err := r.trackJT808(ctx, env); err != nil {
|
|
return env, err
|
|
}
|
|
return env, nil
|
|
}
|
|
|
|
func (r *MySQLResolver) resolveFromJT808Registration(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, bool, error) {
|
|
if env.Protocol != envelope.ProtocolJT808 || env.MessageID != "0x0200" || strings.TrimSpace(env.Phone) == "" {
|
|
return env, false, nil
|
|
}
|
|
vin, deviceID, plate, err := r.lookupJT808Registration(ctx, env.Phone)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return env, false, nil
|
|
}
|
|
return env, false, err
|
|
}
|
|
env.DeviceID = firstNonEmpty(env.DeviceID, deviceID)
|
|
env.Plate = firstNonEmpty(env.Plate, plate)
|
|
if strings.TrimSpace(vin) != "" && !strings.EqualFold(strings.TrimSpace(vin), "unknown") {
|
|
env.VIN = strings.TrimSpace(vin)
|
|
env.EventID = env.StableEventID()
|
|
addIdentityMetadata(env.Parsed, "jt808_registration.vin", env.Phone)
|
|
return env, true, nil
|
|
}
|
|
for _, candidate := range []CandidateKey{
|
|
{Column: "device_id", Value: env.DeviceID},
|
|
{Column: "plate", Value: env.Plate},
|
|
} {
|
|
value := strings.TrimSpace(candidate.Value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
vin, err := r.lookup(ctx, candidate.Column, value)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
continue
|
|
}
|
|
return env, false, err
|
|
}
|
|
if strings.TrimSpace(vin) == "" {
|
|
continue
|
|
}
|
|
env.VIN = strings.TrimSpace(vin)
|
|
env.EventID = env.StableEventID()
|
|
addIdentityMetadata(env.Parsed, "jt808_registration."+candidate.Column, value)
|
|
return env, true, nil
|
|
}
|
|
return env, false, nil
|
|
}
|
|
|
|
func (r *MySQLResolver) lookupJT808Registration(ctx context.Context, phone string) (vin string, deviceID string, plate string, err error) {
|
|
phone = normalizePhone(phone)
|
|
now := time.Now()
|
|
r.lookupMu.Lock()
|
|
cached, ok := r.registrationCache[phone]
|
|
if ok && now.Before(cached.expiresAt) {
|
|
r.lookupMu.Unlock()
|
|
if cached.notFound {
|
|
return "", "", "", sql.ErrNoRows
|
|
}
|
|
return cached.vin, cached.deviceID, cached.plate, nil
|
|
}
|
|
r.lookupMu.Unlock()
|
|
|
|
var vinValue, deviceValue, plateValue sql.NullString
|
|
err = r.db.QueryRowContext(ctx, "SELECT vin, device_id, plate FROM jt808_registration WHERE phone = ?", phone).
|
|
Scan(&vinValue, &deviceValue, &plateValue)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return "", "", "", err
|
|
}
|
|
entry := registrationCacheEntry{
|
|
vin: strings.TrimSpace(vinValue.String),
|
|
deviceID: strings.TrimSpace(deviceValue.String),
|
|
plate: strings.TrimSpace(plateValue.String),
|
|
notFound: errors.Is(err, sql.ErrNoRows),
|
|
expiresAt: now.Add(r.lookupCacheTTL),
|
|
}
|
|
r.lookupMu.Lock()
|
|
r.registrationCache[phone] = entry
|
|
r.lookupMu.Unlock()
|
|
if entry.notFound {
|
|
return "", "", "", sql.ErrNoRows
|
|
}
|
|
return entry.vin, entry.deviceID, entry.plate, nil
|
|
}
|
|
|
|
func addIdentityMetadata(parsed map[string]any, source string, value string) {
|
|
if parsed == nil {
|
|
return
|
|
}
|
|
parsed["identity"] = map[string]any{
|
|
"resolved": true,
|
|
"source": source,
|
|
"value": value,
|
|
}
|
|
}
|
|
|
|
func (r *MySQLResolver) lookup(ctx context.Context, column string, value string) (string, error) {
|
|
cacheKey := column + "\x00" + value
|
|
now := time.Now()
|
|
r.lookupMu.Lock()
|
|
cached, ok := r.lookupCache[cacheKey]
|
|
if ok && now.Before(cached.expiresAt) {
|
|
r.lookupMu.Unlock()
|
|
if cached.notFound {
|
|
return "", sql.ErrNoRows
|
|
}
|
|
return cached.vin, nil
|
|
}
|
|
r.lookupMu.Unlock()
|
|
|
|
query := "SELECT vin FROM " + r.table + " WHERE " + column + " = ? AND vin IS NOT NULL AND vin <> ''"
|
|
var vin string
|
|
err := r.db.QueryRowContext(ctx, query, value).Scan(&vin)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return "", err
|
|
}
|
|
r.lookupMu.Lock()
|
|
r.lookupCache[cacheKey] = lookupCacheEntry{
|
|
vin: strings.TrimSpace(vin),
|
|
notFound: errors.Is(err, sql.ErrNoRows),
|
|
expiresAt: now.Add(r.lookupCacheTTL),
|
|
}
|
|
r.lookupMu.Unlock()
|
|
return vin, err
|
|
}
|
|
|
|
func (r *MySQLResolver) trackJT808(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
if env.Protocol != envelope.ProtocolJT808 || strings.TrimSpace(env.Phone) == "" || strings.TrimSpace(env.MessageID) == "" {
|
|
return nil
|
|
}
|
|
if env.MessageID != "0x0100" && env.MessageID != "0x0102" && !r.shouldTouchJT808Location(env) {
|
|
return nil
|
|
}
|
|
registration := mapValue(env.Parsed, "registration")
|
|
authentication := mapValue(env.Parsed, "authentication")
|
|
deviceID := firstNonEmpty(env.DeviceID, textValue(registration, "device_id"))
|
|
plate := firstNonEmpty(env.Plate, textValue(registration, "plate"))
|
|
vin := strings.TrimSpace(env.VIN)
|
|
if vin == "" {
|
|
vin = "unknown"
|
|
}
|
|
firstRegisteredAt := nilTime()
|
|
latestRegisteredAt := nilTime()
|
|
if env.MessageID == "0x0100" {
|
|
firstRegisteredAt = "CURRENT_TIMESTAMP"
|
|
latestRegisteredAt = "CURRENT_TIMESTAMP"
|
|
}
|
|
latestAuthenticatedAt := nilTime()
|
|
if env.MessageID == "0x0102" {
|
|
latestAuthenticatedAt = "CURRENT_TIMESTAMP"
|
|
}
|
|
|
|
query := `INSERT INTO jt808_registration
|
|
(phone, device_id, plate, vin, province, city, manufacturer, device_type, plate_color,
|
|
auth_token, auth_imei, auth_software_version, source_endpoint,
|
|
first_registered_at, latest_registered_at, latest_authenticated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ` + firstRegisteredAt + `, ` + latestRegisteredAt + `, ` + latestAuthenticatedAt + `)
|
|
ON DUPLICATE KEY UPDATE
|
|
device_id = IF(VALUES(device_id) <> '', VALUES(device_id), device_id),
|
|
plate = IF(VALUES(plate) <> '', VALUES(plate), plate),
|
|
vin = IF(VALUES(vin) <> 'unknown', VALUES(vin), vin),
|
|
province = IF(VALUES(province) <> '', VALUES(province), province),
|
|
city = IF(VALUES(city) <> '', VALUES(city), city),
|
|
manufacturer = IF(VALUES(manufacturer) <> '', VALUES(manufacturer), manufacturer),
|
|
device_type = IF(VALUES(device_type) <> '', VALUES(device_type), device_type),
|
|
plate_color = IF(VALUES(plate_color) <> '', VALUES(plate_color), plate_color),
|
|
auth_token = IF(VALUES(auth_token) <> '', VALUES(auth_token), auth_token),
|
|
auth_imei = IF(VALUES(auth_imei) <> '', VALUES(auth_imei), auth_imei),
|
|
auth_software_version = IF(VALUES(auth_software_version) <> '', VALUES(auth_software_version), auth_software_version),
|
|
source_endpoint = IF(VALUES(source_endpoint) <> '', VALUES(source_endpoint), source_endpoint),
|
|
latest_seen_at = CURRENT_TIMESTAMP,
|
|
first_registered_at = COALESCE(first_registered_at, VALUES(first_registered_at)),
|
|
latest_registered_at = COALESCE(VALUES(latest_registered_at), latest_registered_at),
|
|
latest_authenticated_at = COALESCE(VALUES(latest_authenticated_at), latest_authenticated_at)`
|
|
_, err := r.db.ExecContext(ctx, query,
|
|
env.Phone,
|
|
deviceID,
|
|
plate,
|
|
vin,
|
|
textValue(registration, "province"),
|
|
textValue(registration, "city"),
|
|
textValue(registration, "manufacturer"),
|
|
textValue(registration, "device_type"),
|
|
textValue(registration, "plate_color"),
|
|
textValue(authentication, "token"),
|
|
textValue(authentication, "imei"),
|
|
textValue(authentication, "software_version"),
|
|
env.SourceEndpoint,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if vin == "unknown" {
|
|
return nil
|
|
}
|
|
_, err = r.db.ExecContext(ctx, `UPDATE IGNORE `+r.table+`
|
|
SET
|
|
phone = CASE WHEN (phone IS NULL OR phone = '') AND ? <> '' THEN ? ELSE phone END,
|
|
device_id = CASE WHEN (device_id IS NULL OR device_id = '') AND ? <> '' THEN ? ELSE device_id END,
|
|
plate = CASE WHEN (plate IS NULL OR plate = '') AND ? <> '' THEN ? ELSE plate END
|
|
WHERE vin = ?`, env.Phone, env.Phone, deviceID, deviceID, plate, plate, vin)
|
|
return err
|
|
}
|
|
|
|
func (r *MySQLResolver) shouldTouchJT808Location(env envelope.FrameEnvelope) bool {
|
|
if env.MessageID != "0x0200" {
|
|
return false
|
|
}
|
|
phone := normalizePhone(env.Phone)
|
|
if phone == "" {
|
|
return false
|
|
}
|
|
now := time.Now()
|
|
r.touchMu.Lock()
|
|
defer r.touchMu.Unlock()
|
|
last, ok := r.locationTouches[phone]
|
|
if ok && now.Sub(last) < r.locationTouchInterval {
|
|
return false
|
|
}
|
|
r.locationTouches[phone] = now
|
|
return true
|
|
}
|
|
|
|
type CandidateKey struct {
|
|
Column string
|
|
Value string
|
|
}
|
|
|
|
func CandidateKeys(env envelope.FrameEnvelope) []CandidateKey {
|
|
var out []CandidateKey
|
|
add := func(column string, value string) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || strings.EqualFold(value, "unknown") {
|
|
return
|
|
}
|
|
for _, existing := range out {
|
|
if existing.Column == column && existing.Value == value {
|
|
return
|
|
}
|
|
}
|
|
out = append(out, CandidateKey{Column: column, Value: value})
|
|
}
|
|
add("phone", normalizePhone(env.Phone))
|
|
add("device_id", env.DeviceID)
|
|
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"
|
|
}
|