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