feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,773 @@
package identity
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
)
const (
IdentifierTypeJT808Phone = "JT808_PHONE"
IdentifierTypePlate = "PLATE"
)
type MappingRecord struct {
File string `json:"file"`
Sheet string `json:"sheet"`
Row int `json:"row"`
SourceCode string `json:"source_code"`
SourceName string `json:"source_name"`
Protocol string `json:"protocol"`
IdentifierType string `json:"identifier_type"`
IdentifierValue string `json:"identifier_value"`
RawValue string `json:"raw_value,omitempty"`
Plate string `json:"plate,omitempty"`
OEM string `json:"oem,omitempty"`
}
type MappingScanReport struct {
Files int `json:"files"`
Sheets int `json:"sheets"`
Rows int `json:"rows"`
Records int `json:"records"`
Skipped int `json:"skipped"`
UnsupportedFiles int `json:"unsupported_files,omitempty"`
Sources []MappingSourceScanReport `json:"sources,omitempty"`
UnsupportedItems []MappingUnsupportedFileReport `json:"unsupported_items,omitempty"`
Errors []string `json:"errors,omitempty"`
}
type MappingSourceScanReport struct {
SourceCode string `json:"source_code"`
SourceName string `json:"source_name"`
Files int `json:"files"`
Sheets int `json:"sheets"`
Rows int `json:"rows"`
Records int `json:"records"`
PhoneRecords int `json:"phone_records"`
PlateRecords int `json:"plate_records"`
Skipped int `json:"skipped"`
}
type MappingUnsupportedFileReport struct {
File string `json:"file"`
Ext string `json:"ext"`
Reason string `json:"reason"`
}
type MappingImportOptions struct {
Apply bool
LegacyTable string
ReportItemLimit int
}
type MappingImportReport struct {
Scan MappingScanReport `json:"scan"`
Records int `json:"records"`
Deduplicated int `json:"deduplicated"`
Resolved int `json:"resolved"`
Unresolved int `json:"unresolved"`
Conflicts int `json:"conflicts"`
WouldInsert int `json:"would_insert,omitempty"`
WouldUpdate int `json:"would_update,omitempty"`
Inserted int `json:"inserted,omitempty"`
Updated int `json:"updated,omitempty"`
SourceResults []MappingSourceImportStat `json:"source_results,omitempty"`
UnresolvedItems []MappingRecord `json:"unresolved_items,omitempty"`
ConflictItems []MappingConflict `json:"conflict_items,omitempty"`
}
type MappingSourceImportStat struct {
SourceCode string `json:"source_code"`
SourceName string `json:"source_name"`
Records int `json:"records"`
Deduplicated int `json:"deduplicated"`
Resolved int `json:"resolved"`
Unresolved int `json:"unresolved"`
Conflicts int `json:"conflicts"`
WouldInsert int `json:"would_insert,omitempty"`
WouldUpdate int `json:"would_update,omitempty"`
Inserted int `json:"inserted,omitempty"`
Updated int `json:"updated,omitempty"`
}
type MappingConflict struct {
Record MappingRecord `json:"record"`
ExistingVIN string `json:"existing_vin,omitempty"`
NewVIN string `json:"new_vin,omitempty"`
Reason string `json:"reason"`
}
type resolvedMappingRecord struct {
MappingRecord
VIN string
}
type mappingStore interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
var digitPattern = regexp.MustCompile(`\D+`)
func EnsureVehicleIdentifierSchema(ctx context.Context, db *sql.DB) error {
if db == nil {
return errors.New("identity db must not be nil")
}
if _, err := db.ExecContext(ctx, vehicleTableSQL); err != nil {
return err
}
_, err := db.ExecContext(ctx, vehicleIdentifierTableSQL)
return err
}
func ReadMappingDirectory(root string) ([]MappingRecord, MappingScanReport, error) {
root = strings.TrimSpace(root)
if root == "" {
return nil, MappingScanReport{}, errors.New("mapping input directory is empty")
}
var records []MappingRecord
report := MappingScanReport{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
report.Errors = append(report.Errors, err.Error())
return nil
}
if entry == nil || entry.IsDir() {
return nil
}
name := entry.Name()
if strings.HasPrefix(name, "~$") || strings.HasPrefix(name, "._") {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if !isSupportedMappingWorkbookExt(ext) {
if isUnsupportedMappingWorkbookExt(ext) {
report.UnsupportedFiles++
report.UnsupportedItems = append(report.UnsupportedItems, MappingUnsupportedFileReport{
File: path,
Ext: ext,
Reason: "convert legacy workbook to .xlsx before import",
})
}
return nil
}
fileRecords, fileReport, err := readMappingWorkbook(root, path)
report.Files++
report.Sheets += fileReport.Sheets
report.Rows += fileReport.Rows
report.Records += fileReport.Records
report.Skipped += fileReport.Skipped
report.Errors = append(report.Errors, fileReport.Errors...)
for _, source := range fileReport.Sources {
mergeMappingSourceScan(&report, source)
}
if err != nil {
report.Errors = append(report.Errors, fmt.Sprintf("%s: %v", path, err))
return nil
}
records = append(records, fileRecords...)
return nil
})
if err != nil {
return records, report, err
}
sortMappingSourceScans(report.Sources)
return records, report, nil
}
func readMappingWorkbook(root string, path string) ([]MappingRecord, MappingScanReport, error) {
workbook, err := excelize.OpenFile(path)
if err != nil {
return nil, MappingScanReport{}, err
}
defer func() { _ = workbook.Close() }()
sourceCode, sourceName := mappingSource(root, path)
sourceReport := MappingSourceScanReport{
SourceCode: sourceCode,
SourceName: sourceName,
Files: 1,
}
var records []MappingRecord
report := MappingScanReport{}
for _, sheet := range workbook.GetSheetList() {
rows, err := workbook.GetRows(sheet)
if err != nil {
report.Errors = append(report.Errors, fmt.Sprintf("%s/%s: %v", path, sheet, err))
continue
}
report.Sheets++
sourceReport.Sheets++
report.Rows += len(rows)
sourceReport.Rows += len(rows)
if len(rows) == 0 {
continue
}
header, dataStart := mappingHeader(rows)
for rowIndex := dataStart; rowIndex < len(rows); rowIndex++ {
row := rows[rowIndex]
plate := normalizePlate(cellByHeader(row, header, "plate"))
rawPhone := cellByHeader(row, header, "phone")
phone := normalizeMappingPhone(rawPhone)
if len(header) == 0 {
plate = normalizePlate(cell(row, 0))
rawPhone = cell(row, 1)
phone = normalizeMappingPhone(rawPhone)
}
if plate == "" && phone == "" {
report.Skipped++
sourceReport.Skipped++
continue
}
if phone != "" {
records = append(records, MappingRecord{
File: path,
Sheet: sheet,
Row: rowIndex + 1,
SourceCode: sourceCode,
SourceName: sourceName,
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: phone,
RawValue: strings.TrimSpace(rawPhone),
Plate: plate,
OEM: sourceName,
})
sourceReport.PhoneRecords++
sourceReport.Records++
}
if plate != "" {
records = append(records, MappingRecord{
File: path,
Sheet: sheet,
Row: rowIndex + 1,
SourceCode: sourceCode,
SourceName: sourceName,
Protocol: "JT808",
IdentifierType: IdentifierTypePlate,
IdentifierValue: plate,
RawValue: plate,
Plate: plate,
OEM: sourceName,
})
sourceReport.PlateRecords++
sourceReport.Records++
}
}
}
report.Records = len(records)
report.Sources = []MappingSourceScanReport{sourceReport}
return records, report, nil
}
func mergeMappingSourceScan(report *MappingScanReport, source MappingSourceScanReport) {
if report == nil || strings.TrimSpace(source.SourceCode) == "" {
return
}
for index := range report.Sources {
if report.Sources[index].SourceCode != source.SourceCode {
continue
}
report.Sources[index].Files += source.Files
report.Sources[index].Sheets += source.Sheets
report.Sources[index].Rows += source.Rows
report.Sources[index].Records += source.Records
report.Sources[index].PhoneRecords += source.PhoneRecords
report.Sources[index].PlateRecords += source.PlateRecords
report.Sources[index].Skipped += source.Skipped
if report.Sources[index].SourceName == "" {
report.Sources[index].SourceName = source.SourceName
}
return
}
report.Sources = append(report.Sources, source)
}
func sortMappingSourceScans(sources []MappingSourceScanReport) {
sort.SliceStable(sources, func(i, j int) bool {
return sources[i].SourceCode < sources[j].SourceCode
})
}
func isSupportedMappingWorkbookExt(ext string) bool {
switch strings.ToLower(strings.TrimSpace(ext)) {
case ".xlsx", ".xlsm":
return true
default:
return false
}
}
func isUnsupportedMappingWorkbookExt(ext string) bool {
switch strings.ToLower(strings.TrimSpace(ext)) {
case ".xls", ".xlsb":
return true
default:
return false
}
}
func ImportMappingRecords(ctx context.Context, db *sql.DB, records []MappingRecord, scan MappingScanReport, opts MappingImportOptions) (MappingImportReport, error) {
if db == nil {
return MappingImportReport{}, errors.New("identity db must not be nil")
}
legacyTable := strings.TrimSpace(opts.LegacyTable)
if legacyTable == "" || !safeIdentifier(legacyTable) {
legacyTable = "vehicle_identity_binding"
}
report := MappingImportReport{
Scan: scan,
Records: len(records),
}
sourceStats := map[string]*MappingSourceImportStat{}
for _, record := range records {
sourceImportStat(sourceStats, record).Records++
}
reportLimit := opts.ReportItemLimit
if reportLimit == 0 {
reportLimit = 50
}
deduped, conflicts := dedupeMappingRecords(records)
report.Deduplicated = len(deduped)
report.Conflicts += len(conflicts)
for _, conflict := range conflicts {
sourceImportStat(sourceStats, conflict.Record).Conflicts++
appendConflictItem(&report, conflict, reportLimit)
}
store := mappingStore(db)
var tx *sql.Tx
if opts.Apply {
var err error
tx, err = db.BeginTx(ctx, nil)
if err != nil {
return report, err
}
store = tx
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
}
for _, record := range deduped {
sourceStat := sourceImportStat(sourceStats, record)
sourceStat.Deduplicated++
vin, err := resolveMappingVIN(ctx, store, legacyTable, record)
if err != nil {
return report, err
}
if vin == "" {
report.Unresolved++
sourceStat.Unresolved++
appendUnresolvedItem(&report, record, reportLimit)
continue
}
resolved := resolvedMappingRecord{MappingRecord: record, VIN: vin}
existingVIN, exists, err := existingIdentifierVIN(ctx, store, resolved)
if err != nil {
return report, err
}
if exists && !strings.EqualFold(existingVIN, vin) {
report.Conflicts++
sourceStat.Conflicts++
appendConflictItem(&report, MappingConflict{
Record: record,
ExistingVIN: existingVIN,
NewVIN: vin,
Reason: "identifier already points to another vin",
}, reportLimit)
continue
}
report.Resolved++
sourceStat.Resolved++
if exists {
if opts.Apply {
if err := updateVehicleIdentifier(ctx, store, resolved); err != nil {
return report, err
}
report.Updated++
sourceStat.Updated++
} else {
report.WouldUpdate++
sourceStat.WouldUpdate++
}
continue
}
if opts.Apply {
if err := upsertVehicle(ctx, store, resolved); err != nil {
return report, err
}
if err := insertVehicleIdentifier(ctx, store, resolved); err != nil {
return report, err
}
report.Inserted++
sourceStat.Inserted++
} else {
report.WouldInsert++
sourceStat.WouldInsert++
}
}
if tx != nil {
if err := tx.Commit(); err != nil {
return report, err
}
tx = nil
}
report.SourceResults = sortedMappingSourceImportStats(sourceStats)
return report, nil
}
func sourceImportStat(stats map[string]*MappingSourceImportStat, record MappingRecord) *MappingSourceImportStat {
sourceCode := strings.TrimSpace(record.SourceCode)
if sourceCode == "" {
sourceCode = "unknown"
}
stat := stats[sourceCode]
if stat != nil {
if stat.SourceName == "" {
stat.SourceName = strings.TrimSpace(record.SourceName)
}
return stat
}
stat = &MappingSourceImportStat{
SourceCode: sourceCode,
SourceName: strings.TrimSpace(record.SourceName),
}
stats[sourceCode] = stat
return stat
}
func sortedMappingSourceImportStats(stats map[string]*MappingSourceImportStat) []MappingSourceImportStat {
if len(stats) == 0 {
return nil
}
keys := make([]string, 0, len(stats))
for key := range stats {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]MappingSourceImportStat, 0, len(keys))
for _, key := range keys {
out = append(out, *stats[key])
}
return out
}
func appendUnresolvedItem(report *MappingImportReport, record MappingRecord, limit int) {
if report == nil {
return
}
if limit >= 0 && len(report.UnresolvedItems) >= limit {
return
}
report.UnresolvedItems = append(report.UnresolvedItems, record)
}
func appendConflictItem(report *MappingImportReport, conflict MappingConflict, limit int) {
if report == nil {
return
}
if limit >= 0 && len(report.ConflictItems) >= limit {
return
}
report.ConflictItems = append(report.ConflictItems, conflict)
}
func dedupeMappingRecords(records []MappingRecord) ([]MappingRecord, []MappingConflict) {
seen := map[string]MappingRecord{}
indexByKey := map[string]int{}
var out []MappingRecord
var conflicts []MappingConflict
for _, record := range records {
record.IdentifierValue = normalizeIdentifierValue(record.IdentifierType, record.IdentifierValue)
record.Plate = normalizePlate(record.Plate)
if record.Protocol == "" {
record.Protocol = "JT808"
}
if record.IdentifierValue == "" || record.IdentifierType == "" {
continue
}
key := strings.Join([]string{record.Protocol, record.SourceCode, record.IdentifierType, record.IdentifierValue}, "\x00")
existing, ok := seen[key]
if !ok {
seen[key] = record
indexByKey[key] = len(out)
out = append(out, record)
continue
}
if existing.Plate != "" && record.Plate != "" && existing.Plate != record.Plate {
conflicts = append(conflicts, MappingConflict{
Record: record,
Reason: fmt.Sprintf("same source identifier maps to multiple plates: %s/%s", existing.Plate, record.Plate),
})
continue
}
merged := mergeMappingRecord(existing, record)
seen[key] = merged
if index, ok := indexByKey[key]; ok && index >= 0 && index < len(out) {
out[index] = merged
}
}
return out, conflicts
}
func mergeMappingRecord(existing MappingRecord, incoming MappingRecord) MappingRecord {
merged := existing
if merged.File == "" {
merged.File = incoming.File
}
if merged.Sheet == "" {
merged.Sheet = incoming.Sheet
}
if merged.Row == 0 {
merged.Row = incoming.Row
}
if merged.SourceName == "" {
merged.SourceName = incoming.SourceName
}
if merged.Protocol == "" {
merged.Protocol = incoming.Protocol
}
if merged.IdentifierType == "" {
merged.IdentifierType = incoming.IdentifierType
}
if merged.IdentifierValue == "" {
merged.IdentifierValue = incoming.IdentifierValue
}
if merged.RawValue == "" {
merged.RawValue = incoming.RawValue
}
if merged.Plate == "" {
merged.Plate = incoming.Plate
}
if merged.OEM == "" {
merged.OEM = incoming.OEM
}
return merged
}
func resolveMappingVIN(ctx context.Context, db mappingStore, legacyTable string, record MappingRecord) (string, error) {
if record.Plate != "" {
vin, err := lookupLegacyVIN(ctx, db, legacyTable, "plate", record.Plate)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
if vin != "" {
return vin, nil
}
}
if record.IdentifierType == IdentifierTypeJT808Phone && record.IdentifierValue != "" {
vin, err := lookupLegacyVIN(ctx, db, legacyTable, "phone", record.IdentifierValue)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
return vin, nil
}
return "", nil
}
func lookupLegacyVIN(ctx context.Context, db mappingStore, table string, column string, value string) (string, error) {
if !safeIdentifier(table) || !safeIdentifier(column) {
return "", sql.ErrNoRows
}
query := "SELECT vin FROM " + table + " WHERE " + column + " = ? AND vin IS NOT NULL AND vin <> '' LIMIT 1"
var vin string
err := db.QueryRowContext(ctx, query, value).Scan(&vin)
if err != nil {
return "", err
}
return strings.TrimSpace(vin), nil
}
func existingIdentifierVIN(ctx context.Context, db mappingStore, record resolvedMappingRecord) (string, bool, error) {
var vin string
err := db.QueryRowContext(ctx, `SELECT vin FROM vehicle_identifier
WHERE protocol = ? AND source_code = ? AND identifier_type = ? AND identifier_value = ?`,
record.Protocol, record.SourceCode, record.IdentifierType, record.IdentifierValue).Scan(&vin)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return strings.TrimSpace(vin), true, nil
}
func upsertVehicle(ctx context.Context, db mappingStore, record resolvedMappingRecord) error {
_, err := db.ExecContext(ctx, `INSERT INTO vehicle (vin, plate, oem, enabled)
VALUES (?, ?, ?, 1)
ON DUPLICATE KEY UPDATE
plate = IF(VALUES(plate) <> '', VALUES(plate), plate),
oem = IF(VALUES(oem) <> '', VALUES(oem), oem),
enabled = 1`,
record.VIN, record.Plate, record.OEM)
return err
}
func insertVehicleIdentifier(ctx context.Context, db mappingStore, record resolvedMappingRecord) error {
_, err := db.ExecContext(ctx, `INSERT INTO vehicle_identifier
(protocol, source_code, identifier_type, identifier_value, vin, plate, oem, raw_value, enabled, latest_import_file)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
record.Protocol,
record.SourceCode,
record.IdentifierType,
record.IdentifierValue,
record.VIN,
record.Plate,
record.OEM,
record.RawValue,
record.File,
)
return err
}
func updateVehicleIdentifier(ctx context.Context, db mappingStore, record resolvedMappingRecord) error {
_, err := db.ExecContext(ctx, `UPDATE vehicle_identifier
SET plate = IF(? <> '', ?, plate),
oem = IF(? <> '', ?, oem),
raw_value = IF(? <> '', ?, raw_value),
latest_import_file = ?,
enabled = 1
WHERE protocol = ? AND source_code = ? AND identifier_type = ? AND identifier_value = ?`,
record.Plate, record.Plate,
record.OEM, record.OEM,
record.RawValue, record.RawValue,
record.File,
record.Protocol,
record.SourceCode,
record.IdentifierType,
record.IdentifierValue,
)
return err
}
func mappingSource(root string, path string) (string, string) {
rel, err := filepath.Rel(root, path)
if err != nil {
rel = filepath.Base(path)
}
parts := strings.Split(filepath.ToSlash(rel), "/")
name := strings.TrimSpace(parts[0])
if name == "" || strings.EqualFold(name, ".") {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
code := sourceCode(name)
return code, name
}
func sourceCode(name string) string {
switch strings.TrimSpace(strings.ToLower(name)) {
case "g7s":
return "g7s"
case "信达":
return "xinda"
case "广安北斗", "广安车联":
return "guangan_beidou"
case "东方北斗":
return "dongfang_beidou"
case "赛格":
return "saige"
default:
return normalizeASCIIKey(name)
}
}
func mappingHeader(rows [][]string) (map[string]int, int) {
for index, row := range rows {
header := map[string]int{}
for columnIndex, value := range row {
key := normalizeHeader(value)
switch key {
case "车牌", "车牌号", "车牌号码":
header["plate"] = columnIndex
case "sim", "sim卡号", "手机号", "终端手机号", "终端id", "终端标识":
header["phone"] = columnIndex
}
}
if len(header) > 0 {
return header, index + 1
}
if index >= 5 {
break
}
}
return nil, 0
}
func cellByHeader(row []string, header map[string]int, key string) string {
if len(header) == 0 {
return ""
}
index, ok := header[key]
if !ok {
return ""
}
return cell(row, index)
}
func cell(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return strings.TrimSpace(row[index])
}
func normalizeHeader(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "\t", "")
value = strings.ReplaceAll(value, "", "(")
value = strings.ReplaceAll(value, "", ")")
return value
}
func normalizePlate(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "\t", "")
return value
}
func normalizeMappingPhone(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if strings.ContainsAny(value, ".eE") {
if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
return normalizePhone(strconv.FormatFloat(parsed, 'f', 0, 64))
}
}
digits := digitPattern.ReplaceAllString(value, "")
return normalizePhone(digits)
}
func normalizeASCIIKey(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
var b strings.Builder
lastUnderscore := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
b.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(b.String(), "_")
}

View File

@@ -0,0 +1,345 @@
package identity
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/xuri/excelize/v2"
)
func TestReadMappingDirectoryExtractsPhoneAndPlate(t *testing.T) {
dir := t.TempDir()
writeWorkbook(t, filepath.Join(dir, "G7s", "宇速全量.xlsx"), [][]string{
{"车牌号", "sim卡号", "设备号"},
{"粤AG18312", "013307795425", "DEV001"},
})
writeWorkbook(t, filepath.Join(dir, "东方北斗", "无标题0703.xlsx"), [][]string{
{"沪A01559F", "64341233712"},
})
if err := os.MkdirAll(filepath.Join(dir, "信达"), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "信达", "旧格式.xls"), []byte("legacy xls"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "信达", "说明.txt"), []byte("ignored"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
records, report, err := ReadMappingDirectory(dir)
if err != nil {
t.Fatalf("ReadMappingDirectory() error = %v", err)
}
if report.Files != 2 {
t.Fatalf("files = %d, report=%#v", report.Files, report)
}
if report.UnsupportedFiles != 1 || len(report.UnsupportedItems) != 1 {
t.Fatalf("unsupported files = %d, items=%#v", report.UnsupportedFiles, report.UnsupportedItems)
}
if got := report.UnsupportedItems[0]; got.Ext != ".xls" || got.Reason == "" {
t.Fatalf("unsupported item = %#v", got)
}
sources := map[string]MappingSourceScanReport{}
for _, source := range report.Sources {
sources[source.SourceCode] = source
}
if got := sources["g7s"]; got.Files != 1 || got.Sheets != 1 || got.PhoneRecords != 1 || got.PlateRecords != 1 || got.Records != 2 {
t.Fatalf("g7s source report = %#v", got)
}
if got := sources["dongfang_beidou"]; got.Files != 1 || got.Sheets != 1 || got.PhoneRecords != 1 || got.PlateRecords != 1 || got.Records != 2 {
t.Fatalf("dongfang source report = %#v", got)
}
var phoneSeen bool
var plateSeen bool
var headerlessSeen bool
for _, record := range records {
if record.SourceCode == "g7s" && record.IdentifierType == IdentifierTypeJT808Phone && record.IdentifierValue == "13307795425" && record.Plate == "粤AG18312" {
phoneSeen = true
}
if record.SourceCode == "g7s" && record.IdentifierType == IdentifierTypePlate && record.IdentifierValue == "粤AG18312" {
plateSeen = true
}
if record.SourceCode == "dongfang_beidou" && record.IdentifierType == IdentifierTypeJT808Phone && record.IdentifierValue == "64341233712" && record.Plate == "沪A01559F" {
headerlessSeen = true
}
}
if !phoneSeen || !plateSeen || !headerlessSeen {
t.Fatalf("records missing expected mappings: %#v", records)
}
}
func TestImportMappingRecordsDryRunResolvesVINFromLegacyPlate(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "013307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{LegacyTable: "vehicle_identity_binding"})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Resolved != 1 || report.WouldInsert != 1 || report.Unresolved != 0 {
t.Fatalf("report = %#v", report)
}
if len(report.SourceResults) != 1 {
t.Fatalf("source results = %#v, want one source", report.SourceResults)
}
if got := report.SourceResults[0]; got.SourceCode != "g7s" || got.Records != 1 || got.Deduplicated != 1 || got.Resolved != 1 || got.WouldInsert != 1 {
t.Fatalf("source result = %#v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsReportsSourceResults(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤B00000").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("14400000000").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
Plate: "粤B99999",
OEM: "G7s",
},
{
SourceCode: "xinda",
SourceName: "信达",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "14400000000",
Plate: "粤B00000",
OEM: "信达",
},
}, MappingScanReport{}, MappingImportOptions{LegacyTable: "vehicle_identity_binding"})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Records != 3 || report.Deduplicated != 2 || report.Resolved != 1 || report.Unresolved != 1 || report.Conflicts != 1 || report.WouldInsert != 1 {
t.Fatalf("report = %#v", report)
}
got := map[string]MappingSourceImportStat{}
for _, source := range report.SourceResults {
got[source.SourceCode] = source
}
if source := got["g7s"]; source.Records != 2 || source.Deduplicated != 1 || source.Resolved != 1 || source.Conflicts != 1 || source.WouldInsert != 1 {
t.Fatalf("g7s source result = %#v", source)
}
if source := got["xinda"]; source.Records != 1 || source.Deduplicated != 1 || source.Unresolved != 1 {
t.Fatalf("xinda source result = %#v", source)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsMergesDuplicateIdentifierDetails(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
File: "G7s/no-plate.xlsx",
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
OEM: "G7s",
},
{
File: "G7s/with-plate.xlsx",
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{LegacyTable: "vehicle_identity_binding"})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Records != 2 || report.Deduplicated != 1 || report.Resolved != 1 || report.WouldInsert != 1 || report.Unresolved != 0 {
t.Fatalf("report = %#v", report)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsApplyCommitsSingleTransaction(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectBegin()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectExec("INSERT INTO vehicle").
WithArgs("LB9A32A22P0LS1230", "粤AG18312", "G7s").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425", "LB9A32A22P0LS1230", "粤AG18312", "G7s", "13307795425", "G7s/example.xlsx").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
File: "G7s/example.xlsx",
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
RawValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{
Apply: true,
LegacyTable: "vehicle_identity_binding",
})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Inserted != 1 || report.Resolved != 1 || report.WouldInsert != 0 {
t.Fatalf("report = %#v", report)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsApplyRollsBackOnWriteError(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
errWrite := errors.New("insert vehicle failed")
mock.ExpectBegin()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectExec("INSERT INTO vehicle").
WithArgs("LB9A32A22P0LS1230", "粤AG18312", "G7s").
WillReturnError(errWrite)
mock.ExpectRollback()
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
RawValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{
Apply: true,
LegacyTable: "vehicle_identity_binding",
})
if !errors.Is(err, errWrite) {
t.Fatalf("ImportMappingRecords() error = %v, want %v", err, errWrite)
}
if report.Inserted != 0 || report.Resolved != 1 {
t.Fatalf("report = %#v", report)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestNormalizeMappingPhoneHandlesExcelNumericFormats(t *testing.T) {
tests := map[string]string{
"013307795425": "13307795425",
"13307795425.0": "13307795425",
"1.3307795425E10": "13307795425",
}
for input, want := range tests {
if got := normalizeMappingPhone(input); got != want {
t.Fatalf("normalizeMappingPhone(%q) = %q, want %q", input, got, want)
}
}
}
func writeWorkbook(t *testing.T, path string, rows [][]string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
workbook := excelize.NewFile()
sheet := "Sheet1"
for rowIndex, row := range rows {
for columnIndex, value := range row {
cellName, err := excelize.CoordinatesToCellName(columnIndex+1, rowIndex+1)
if err != nil {
t.Fatalf("CoordinatesToCellName() error = %v", err)
}
if err := workbook.SetCellValue(sheet, cellName, value); err != nil {
t.Fatalf("SetCellValue() error = %v", err)
}
}
}
if err := workbook.SaveAs(path); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
if err := workbook.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}

View File

@@ -0,0 +1,357 @@
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 &copy
}
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
}

View File

@@ -0,0 +1,221 @@
package identity
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestJT808RegistrationProjectorProjectsRegistrationUsingReceiveTime(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*60*60)
projector := NewJT808RegistrationProjector(loc, 10*time.Minute)
receivedAt := time.Date(2026, 7, 13, 17, 20, 30, 987000000, loc)
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808RegisterMessageID,
Phone: "0013307795425",
VIN: "LTESTVIN000000001",
DeviceID: "DEV-1",
Plate: "粤A00001",
SourceEndpoint: "115.231.168.135:43625",
ReceivedAtMS: receivedAt.UnixMilli(),
EventTimeMS: receivedAt.Add(24 * time.Hour).UnixMilli(),
ParseStatus: envelope.ParseOK,
ParsedFields: map[string]any{
"jt808.registration.province": "44",
"jt808.registration.city": "1",
"jt808.registration.manufacturer": "YUTNG",
"jt808.registration.device_type": "TBOX-1",
"jt808.registration.plate_color": "2",
},
}
facts := projector.ProjectBatch([]envelope.FrameEnvelope{env})
if len(facts) != 1 {
t.Fatalf("facts = %d, want 1", len(facts))
}
fact := facts[0]
if fact.Phone != "13307795425" || fact.VIN != env.VIN || fact.Plate != env.Plate {
t.Fatalf("identity fact = %+v", fact)
}
wantTime := receivedAt.Truncate(time.Second)
if !fact.SeenAt.Equal(wantTime) || fact.FirstRegisteredAt == nil || !fact.FirstRegisteredAt.Equal(wantTime) {
t.Fatalf("fact times = seen %s first %#v, want %s", fact.SeenAt, fact.FirstRegisteredAt, wantTime)
}
if fact.SourceIP != "115.231.168.135" || fact.Manufacturer != "YUTNG" || fact.PlateColor != "2" {
t.Fatalf("registration details = %+v", fact)
}
}
func TestJT808RegistrationProjectorThrottlesLocationOnlyAfterPersist(t *testing.T) {
loc := time.UTC
projector := NewJT808RegistrationProjector(loc, 10*time.Minute)
base := time.Date(2026, 7, 13, 8, 0, 0, 0, loc)
location := func(at time.Time) envelope.FrameEnvelope {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808LocationMessageID,
Phone: "013307795425",
VIN: "LTESTVIN000000001",
ReceivedAtMS: at.UnixMilli(),
ParseStatus: envelope.ParseOK,
}
}
first := projector.ProjectBatch([]envelope.FrameEnvelope{location(base)})
if len(first) != 1 {
t.Fatalf("first facts = %d, want 1", len(first))
}
// A failed database attempt must remain immediately replayable.
if replay := projector.ProjectBatch([]envelope.FrameEnvelope{location(base)}); len(replay) != 1 {
t.Fatalf("uncommitted replay facts = %d, want 1", len(replay))
}
projector.MarkPersisted(first)
if throttled := projector.ProjectBatch([]envelope.FrameEnvelope{location(base.Add(9 * time.Minute))}); len(throttled) != 0 {
t.Fatalf("throttled facts = %d, want 0", len(throttled))
}
if due := projector.ProjectBatch([]envelope.FrameEnvelope{location(base.Add(10 * time.Minute))}); len(due) != 1 {
t.Fatalf("due facts = %d, want 1", len(due))
}
}
func TestJT808RegistrationProjectorMergesRegisterAndAuthForPhone(t *testing.T) {
projector := NewJT808RegistrationProjector(time.UTC, 10*time.Minute)
base := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
register := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808RegisterMessageID,
Phone: "013307795425",
VIN: "LTESTVIN000000001",
Plate: "粤A00001",
ReceivedAtMS: base.UnixMilli(),
ParseStatus: envelope.ParseOK,
}
auth := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808AuthMessageID,
Phone: "13307795425",
ReceivedAtMS: base.Add(time.Second).UnixMilli(),
ParseStatus: envelope.ParseOK,
ParsedFields: map[string]any{
"jt808.authentication.token": "g7gps",
"jt808.authentication.imei": "123456789012345",
},
}
facts := projector.ProjectBatch([]envelope.FrameEnvelope{register, auth})
if len(facts) != 1 {
t.Fatalf("facts = %d, want 1", len(facts))
}
fact := facts[0]
if fact.VIN != register.VIN || fact.Plate != register.Plate || fact.AuthToken != "g7gps" {
t.Fatalf("merged fact = %+v", fact)
}
if fact.FirstRegisteredAt == nil || fact.LatestRegisteredAt == nil || fact.LatestAuthenticated == nil {
t.Fatalf("merged timestamps missing: %+v", fact)
}
}
func TestJT808RegistrationProjectorDoesNotTrustEnforcedRejectedAuth(t *testing.T) {
projector := NewJT808RegistrationProjector(time.UTC, 10*time.Minute)
seenAt := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
facts := projector.ProjectBatch([]envelope.FrameEnvelope{{
Protocol: envelope.ProtocolJT808,
MessageID: JT808AuthMessageID,
Phone: "13307795425",
ReceivedAtMS: seenAt.UnixMilli(),
ParseStatus: envelope.ParseOK,
AuthenticationEnforced: true,
AuthenticationStatus: "rejected",
Parsed: map[string]any{
"authentication": map[string]any{
"token": "untrusted-token",
"imei": "untrusted-imei",
"software_version": "untrusted-version",
},
},
}})
if len(facts) != 1 {
t.Fatalf("facts = %d, want 1 audit touch", len(facts))
}
fact := facts[0]
if fact.AuthToken != "" || fact.AuthIMEI != "" || fact.AuthSoftwareVersion != "" || fact.LatestAuthenticated != nil {
t.Fatalf("rejected credential leaked into registration fact: %+v", fact)
}
}
func TestJT808RegistrationStoreUsesIdempotentEventTimeUpsert(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewJT808RegistrationStore(db)
seenAt := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO jt808_registration")).
WithArgs(
"13307795425", "DEV-1", "粤A00001", "LTESTVIN000000001", "", "", "YUTNG", "", "",
"g7gps", "", "", "115.231.168.135:43625", "115.231.168.135",
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), seenAt,
).
WillReturnResult(sqlmock.NewResult(1, 1))
err = store.UpsertBatch(context.Background(), []JT808RegistrationFact{{
Phone: "013307795425",
DeviceID: "DEV-1",
Plate: "粤A00001",
VIN: "LTESTVIN000000001",
Manufacturer: "YUTNG",
AuthToken: "g7gps",
SourceEndpoint: "115.231.168.135:43625",
SourceIP: "115.231.168.135",
FirstRegisteredAt: timePointer(seenAt),
LatestRegisteredAt: timePointer(seenAt),
LatestAuthenticated: timePointer(seenAt),
SeenAt: seenAt,
}})
if err != nil {
t.Fatalf("UpsertBatch() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRegistrationUpsertProtectsLatestValuesFromOldReplay(t *testing.T) {
var query string
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(_ string, actual string) error {
query = actual
return nil
})))
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewJT808RegistrationStore(db)
mock.ExpectExec("INSERT INTO jt808_registration").WillReturnResult(sqlmock.NewResult(1, 1))
if err := store.UpsertBatch(context.Background(), []JT808RegistrationFact{{
Phone: "13307795425",
VIN: "unknown",
SeenAt: time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC),
}}); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
for _, required := range []string{
"VALUES(latest_seen_at) >= latest_seen_at",
"LEAST(first_registered_at, VALUES(first_registered_at))",
"GREATEST(latest_authenticated_at, VALUES(latest_authenticated_at))",
"latest_seen_at = GREATEST(latest_seen_at, VALUES(latest_seen_at))",
} {
if !regexp.MustCompile(regexp.QuoteMeta(required)).MatchString(query) {
t.Fatalf("upsert SQL missing %q", required)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"strings"
"sync"
"testing"
"time"
@@ -57,9 +58,7 @@ func TestCandidateKeysNormalizesPhone(t *testing.T) {
func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNBVIN00000000001"))
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
@@ -74,7 +73,7 @@ func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
t.Fatalf("vin = %q", env.VIN)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "phone" {
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
@@ -82,9 +81,254 @@ func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
}
}
func TestMySQLResolverUsesDataSourceCodeForJT808IdentifierLookup(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeHit(mock, "115.231.168.135", "xinda")
expectVehicleIdentifierHitForSource(mock, "xinda", "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNBVIN00000000001" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "xinda" || env.PlatformName != "G7s" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.xinda" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "xinda" || identity["platform_name"] != "G7s" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverVehicleIdentifierSourceOverridesDataSourceCode(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", "115.159.85.149").
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow("dongfang_beidou", "G7易流", "PLATFORM"))
expectVehicleIdentifierMissForSource(mock, "dongfang_beidou", "JT808_PHONE", "64341232682")
expectVehicleIdentifierHitWithSource(mock, "JT808_PHONE", "64341232682", "LB9A32A23R0LS1045", "g7s", "G7s")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "064341232682",
SourceEndpoint: "115.159.85.149:42823",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LB9A32A23R0LS1045" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "g7s" || env.PlatformName != "G7s" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.g7s" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "g7s" || identity["platform_name"] != "G7s" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverUsesStaleIdentifierCacheWhenLookupFails(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Nanosecond,
StaleLookupTTL: time.Hour,
})
first, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("first Resolve() error = %v", err)
}
if first.VIN != "LNBVIN00000000001" {
t.Fatalf("first vin = %q", first.VIN)
}
time.Sleep(time.Millisecond)
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", "JT808_PHONE", "13307795425").
WillReturnError(errors.New("mysql temporarily unavailable"))
second, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("second Resolve() error = %v", err)
}
if second.VIN != "LNBVIN00000000001" {
t.Fatalf("second vin = %q, want stale cached vin", second.VIN)
}
identity, ok := second.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", second.Parsed["identity"])
}
if identity["cache_status"] != "stale" {
t.Fatalf("identity cache_status = %#v, want stale", identity["cache_status"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestNormalizeEndpointIPUsesSharedSourceEndpointKey(t *testing.T) {
tests := map[string]string{
"115.231.168.135:43625": "115.231.168.135",
" 115.159.85.149:28316 ": "115.159.85.149",
"mqtt://yutong/ytforward/shln/3": "mqtt",
"MQTT://YUTONG/topic": "mqtt",
}
for input, want := range tests {
if got := normalizeEndpointIP(input); got != want {
t.Fatalf("normalizeEndpointIP(%q) = %q, want %q", input, got, want)
}
}
}
func TestMySQLResolverKeepsDirectSourceKindWithoutSourceCode(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", "39.144.3.22").
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow("", "", "DIRECT"))
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307765812", "LA9GG64L7PBAF4001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307765812",
SourceEndpoint: "39.144.3.22:60177",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LA9GG64L7PBAF4001" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "" || env.SourceKind != "DIRECT" {
t.Fatalf("source metadata = code:%q kind:%q", env.SourceCode, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source_kind"] != "DIRECT" {
t.Fatalf("identity source metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverFallsBackToGlobalIdentifierWhenSourceCodeMisses(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeHit(mock, "115.231.168.135", "xinda")
expectVehicleIdentifierMissForSource(mock, "xinda", "JT808_PHONE", "13307795425")
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000002")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNBVIN00000000002" {
t.Fatalf("vin = %q", env.VIN)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverUsesSingleGlobalIdentifierSourceCodeForJT808SourceMetadata(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeMiss(mock, "117.132.196.119")
expectVehicleIdentifierHitWithSource(mock, "JT808_PHONE", "41456413943", "LNXNEGRR0SR321372", "xinda", "信达")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "41456413943",
SourceEndpoint: "117.132.196.119:3275",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNXNEGRR0SR321372" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "xinda" || env.PlatformName != "信达" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.xinda" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "xinda" || identity["platform_name"] != "信达" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverLooksUpVINByUniqueKeyWithoutSort(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\? AND vin IS NOT NULL AND vin <> ''$").
WithArgs("13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNBVIN00000000001"))
@@ -109,6 +353,7 @@ func TestMySQLResolverLooksUpVINByUniqueKeyWithoutSort(t *testing.T) {
func TestMySQLResolverDoesNotLookupBindingByDeviceID(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
@@ -133,9 +378,11 @@ func TestMySQLResolverDoesNotLookupBindingByDeviceID(t *testing.T) {
func TestMySQLResolverTracksJT808RegistrationWithoutWritingBinding(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13079963379")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13079963379").
WillReturnError(sql.ErrNoRows)
expectVehicleIdentifierMiss(mock, "PLATE", "TEST123")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("TEST123").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LKLG7C4E3NA774736"))
@@ -176,6 +423,7 @@ func TestMySQLResolverTracksJT808RegistrationWithoutWritingBinding(t *testing.T)
func TestMySQLResolverTracksFirstJT808LocationThenThrottles(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
@@ -206,12 +454,14 @@ func TestMySQLResolverTracksFirstJT808LocationThenThrottles(t *testing.T) {
func TestMySQLResolverUsesJT808RegistrationPlateForLocationVIN(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "40692934322")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
WithArgs("40692934322").
WillReturnRows(sqlmock.NewRows([]string{"vin", "device_id", "plate"}).AddRow("unknown", "18285", "粤AG18285"))
expectVehicleIdentifierMiss(mock, "PLATE", "粤AG18285")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18285").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNXNEGRR7SR318212"))
@@ -248,9 +498,352 @@ func TestMySQLResolverUsesJT808RegistrationPlateForLocationVIN(t *testing.T) {
}
}
func TestMySQLResolverRefreshesRegistrationCacheAfterRegisterFrame(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "40692934322")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
expectVehicleIdentifierMiss(mock, "PLATE", "粤AG18285")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18285").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNXNEGRR7SR318212"))
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
LookupCacheTTL: time.Hour,
})
firstLocation, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("first location Resolve() error = %v", err)
}
if firstLocation.VIN != "" {
t.Fatalf("first location vin = %q, want unresolved", firstLocation.VIN)
}
registered, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0100",
Phone: "040692934322",
DeviceID: "18285",
Plate: "粤AG18285",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{
"registration": map[string]any{
"device_id": "18285",
"plate": "粤AG18285",
},
},
})
if err != nil {
t.Fatalf("registration Resolve() error = %v", err)
}
if registered.VIN != "LNXNEGRR7SR318212" {
t.Fatalf("registered vin = %q", registered.VIN)
}
secondLocation, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("second location Resolve() error = %v", err)
}
if secondLocation.VIN != "LNXNEGRR7SR318212" {
t.Fatalf("second location vin = %q, want cache-refreshed registration vin", secondLocation.VIN)
}
if secondLocation.DeviceID != "18285" || secondLocation.Plate != "粤AG18285" {
t.Fatalf("second location identity not copied: device=%q plate=%q", secondLocation.DeviceID, secondLocation.Plate)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverDelegatesRegistrationPersistenceButKeepsLocalSession(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
var results []RegistrationWriteResult
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
DisableRegistrationWrites: true,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results = append(results, result)
},
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808RegisterMessageID,
Phone: "013307795425",
VIN: "LTESTVIN000000001",
DeviceID: "DEV-1",
Plate: "粤A00001",
SourceEndpoint: "115.231.168.135:43625",
ParseStatus: envelope.ParseOK,
}
resolved, err := resolver.Resolve(context.Background(), env)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.VIN != env.VIN {
t.Fatalf("resolved vin = %q, want %q", resolved.VIN, env.VIN)
}
entry, ok := resolver.registrationCache["13307795425"]
if !ok || entry.vin != env.VIN || entry.plate != env.Plate || entry.deviceID != env.DeviceID {
t.Fatalf("local session = %+v exists=%v", entry, ok)
}
if len(results) != 1 || results[0].Mode != "delegated" || results[0].Status != "ok" {
t.Fatalf("registration write results = %#v", results)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unexpected mysql access: %v", err)
}
}
func TestMySQLResolverRetriesRegistrationUpsertTransientFailure(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection: read tcp: connection reset by peer"))
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
RegistrationWriteAttempts: 2,
RegistrationWriteRetryDelay: -1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v, want internal retry to recover", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverAsyncRegistrationWritesDrainOnClose(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
results := make(chan RegistrationWriteResult, 2)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
AsyncRegistrationWrites: true,
RegistrationWriteQueueSize: 8,
RegistrationWriteWorkers: 1,
RegistrationWriteTimeout: time.Second,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results <- result
},
LocationTouchInterval: time.Hour,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if err := resolver.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
close(results)
got := map[string]int{}
for result := range results {
got[result.Mode+":"+result.Status]++
}
if got["async_enqueue:ok"] != 1 || got["async_background:ok"] != 1 {
t.Fatalf("registration write results = %#v, want enqueue/background ok", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverAsyncRegistrationWriteFailureMarksLocationRetry(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection"))
errs := make(chan error, 1)
results := make(chan RegistrationWriteResult, 2)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
AsyncRegistrationWrites: true,
RegistrationWriteQueueSize: 8,
RegistrationWriteWorkers: 1,
RegistrationWriteTimeout: time.Second,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results <- result
},
OnRegistrationWriteError: func(err error) {
errs <- err
},
LocationTouchInterval: time.Hour,
LocationTouchRetryInterval: time.Hour,
RegistrationWriteAttempts: 1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v, async write failure should be reported out-of-band", err)
}
if err := resolver.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
select {
case err := <-errs:
if err == nil {
t.Fatal("async error callback received nil")
}
default:
t.Fatal("async write failure was not reported")
}
stats := resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries = %d, want 1", stats.LocationTouchFailureEntries)
}
close(results)
got := map[string]int{}
for result := range results {
got[result.Mode+":"+result.Status]++
}
if got["async_enqueue:ok"] != 1 || got["async_background:error"] != 1 {
t.Fatalf("registration write results = %#v, want enqueue ok/background error", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverBacksOffLocationTouchAfterExhaustedUpsert(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection"))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
LocationTouchRetryInterval: time.Hour,
RegistrationWriteAttempts: 1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err == nil {
t.Fatal("first Resolve() error = nil, want exhausted transient registration upsert failure")
}
stats := resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries = %d, want 1", stats.LocationTouchFailureEntries)
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("second Resolve() error = %v, want short backoff to skip immediate retry", err)
}
stats = resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries after backoff = %d, want 1", stats.LocationTouchFailureEntries)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestIsTransientMySQLIdentityWriteError(t *testing.T) {
for _, err := range []error{
errors.New("dial tcp 127.0.0.1:3306: connection refused"),
errors.New("read tcp: connection reset by peer"),
errors.New("write tcp: broken pipe"),
errors.New("driver: bad connection"),
errors.New("invalid connection"),
errors.New("i/o timeout"),
errors.New("EOF"),
errors.New("server is down"),
errors.New("network is unreachable"),
} {
if !isTransientMySQLIdentityWriteError(err) {
t.Fatalf("isTransientMySQLIdentityWriteError(%q) = false, want true", err.Error())
}
}
for _, err := range []error{
context.Canceled,
context.DeadlineExceeded,
errors.New("duplicate key conflict"),
nil,
} {
if isTransientMySQLIdentityWriteError(err) {
t.Fatalf("isTransientMySQLIdentityWriteError(%v) = true, want false", err)
}
}
}
func TestTimeoutResolverAppliesDeadlineToDelegate(t *testing.T) {
delegate := &deadlineCheckingResolver{}
resolver := TimeoutResolver{Delegate: delegate, Timeout: 50 * time.Millisecond}
if _, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if delegate.deadline.IsZero() {
t.Fatal("delegate did not receive a deadline")
}
if remaining := time.Until(delegate.deadline); remaining <= 0 || remaining > time.Second {
t.Fatalf("deadline remaining = %s", remaining)
}
}
func TestMySQLResolverCachesIdentityMissesForHighFrequencyFrames(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
@@ -274,6 +867,95 @@ func TestMySQLResolverCachesIdentityMissesForHighFrequencyFrames(t *testing.T) {
}
}
func TestMySQLResolverBoundsIdentityLookupCaches(t *testing.T) {
db, _ := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Hour,
CacheCleanupInterval: time.Hour,
MaxCacheEntries: 2,
LocationTouchInterval: time.Hour,
})
now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
for i, key := range []string{"lookup-1", "lookup-2", "lookup-3"} {
entryNow := now.Add(time.Duration(i) * time.Second)
resolver.cacheLookup(key, lookupCacheEntry{
vin: key,
expiresAt: entryNow.Add(time.Hour),
}, entryNow)
resolver.cacheRegistration("phone-"+key, registrationCacheEntry{
vin: key,
expiresAt: entryNow.Add(time.Hour),
}, entryNow)
resolver.cacheSourceMetadata("source-"+key, sourceMetadata{SourceCode: key}, false, entryNow)
}
stats := resolver.CacheStats()
if stats.LookupEntries != 2 || stats.RegistrationEntries != 2 || stats.SourceCodeEntries != 2 || stats.MaxEntries != 2 {
t.Fatalf("cache stats = %+v, want all identity caches capped at 2", stats)
}
resolver.lookupMu.Lock()
_, hasOldLookup := resolver.lookupCache["lookup-1"]
_, hasOldRegistration := resolver.registrationCache["phone-lookup-1"]
_, hasOldSource := resolver.sourceCodeCache["source-lookup-1"]
resolver.lookupMu.Unlock()
if hasOldLookup || hasOldRegistration || hasOldSource {
t.Fatalf("oldest cache entries should be evicted")
}
}
func TestMySQLResolverBoundsLocationTouchCache(t *testing.T) {
db, _ := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Hour,
CacheCleanupInterval: time.Hour,
MaxCacheEntries: 2,
LocationTouchInterval: time.Hour,
})
now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
resolver.touchMu.Lock()
resolver.locationTouches["old-expired"] = now.Add(-2 * time.Hour)
resolver.locationTouches["phone-1"] = now.Add(-2 * time.Minute)
resolver.locationTouches["phone-2"] = now.Add(-time.Minute)
resolver.locationTouches["phone-3"] = now
resolver.locationTouchFailures["old-failure"] = now.Add(-time.Minute)
resolver.locationTouchFailures["phone-2"] = now.Add(time.Minute)
resolver.locationTouchFailures["phone-3"] = now.Add(2 * time.Minute)
resolver.locationTouchFailures["phone-4"] = now.Add(3 * time.Minute)
resolver.cleanupLocationTouchesLocked(now, false)
resolver.touchMu.Unlock()
stats := resolver.CacheStats()
if stats.LocationTouchEntries != 2 || stats.LocationTouchFailureEntries != 2 || stats.MaxEntries != 2 {
t.Fatalf("cache stats = %+v, want location touch caches capped at 2", stats)
}
resolver.touchMu.Lock()
_, hasExpired := resolver.locationTouches["old-expired"]
_, hasOldest := resolver.locationTouches["phone-1"]
_, hasExpiredFailure := resolver.locationTouchFailures["old-failure"]
_, hasOldestFailure := resolver.locationTouchFailures["phone-2"]
resolver.touchMu.Unlock()
if hasExpired || hasOldest || hasExpiredFailure || hasOldestFailure {
t.Fatalf("expired and oldest location touch entries should be evicted")
}
}
type deadlineCheckingResolver struct {
mu sync.Mutex
deadline time.Time
}
func (r *deadlineCheckingResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
deadline, _ := ctx.Deadline()
r.mu.Lock()
r.deadline = deadline
r.mu.Unlock()
return env, nil
}
func TestMySQLResolverEnsuresMinimalIdentitySchema(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
@@ -285,8 +967,18 @@ func TestMySQLResolverEnsuresMinimalIdentitySchema(t *testing.T) {
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP COLUMN device_id").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle \\(").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identifier").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD COLUMN source_ip").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
if err := resolver.EnsureSchema(context.Background()); err != nil {
@@ -308,8 +1000,18 @@ func TestMySQLResolverIgnoresExistingOEMColumn(t *testing.T) {
WillReturnError(errors.New("Error 1091 (42000): Can't DROP 'uk_identity_device'; check that column/key exists"))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP COLUMN device_id").
WillReturnError(errors.New("Error 1091 (42000): Can't DROP 'device_id'; check that column/key exists"))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle \\(").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identifier").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD COLUMN source_ip").
WillReturnError(errors.New("Error 1060 (42S21): Duplicate column name 'source_ip'"))
mock.ExpectExec("ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip").
WillReturnError(errors.New("Error 1061 (42000): Duplicate key name 'idx_jt808_registration_source_ip'"))
mock.ExpectExec("UPDATE jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
if err := resolver.EnsureSchema(context.Background()); err != nil {
@@ -339,6 +1041,15 @@ func TestIdentitySchemaUsesBusinessKeysOnly(t *testing.T) {
if !strings.Contains(registration, "phone VARCHAR(32) PRIMARY KEY") {
t.Fatalf("registration table should key by phone:\n%s", registration)
}
if !strings.Contains(registration, "source_ip VARCHAR(64)") || !strings.Contains(registration, "idx_jt808_registration_source_ip") {
t.Fatalf("registration table should keep indexed source_ip:\n%s", registration)
}
if !strings.Contains(vehicleIdentifierTableSQL, "PRIMARY KEY (protocol, source_code, identifier_type, identifier_value)") {
t.Fatalf("vehicle identifier should use protocol/source/type/value as key:\n%s", vehicleIdentifierTableSQL)
}
if strings.Contains(vehicleIdentifierTableSQL, "AUTO_INCREMENT") {
t.Fatalf("vehicle identifier should not use surrogate auto increment id:\n%s", vehicleIdentifierTableSQL)
}
}
func newMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
@@ -349,3 +1060,43 @@ func newMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
}
return db, mock
}
func expectVehicleIdentifierMiss(mock sqlmock.Sqlmock, identifierType string, value string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}))
}
func expectVehicleIdentifierHit(mock sqlmock.Sqlmock, identifierType string, value string, vin string) {
expectVehicleIdentifierHitWithSource(mock, identifierType, value, vin, "", "")
}
func expectVehicleIdentifierHitWithSource(mock sqlmock.Sqlmock, identifierType string, value string, vin string, sourceCode string, platformName string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}).AddRow(vin, sourceCode, platformName))
}
func expectSourceCodeHit(mock sqlmock.Sqlmock, sourceIP string, sourceCode string) {
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", sourceIP).
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow(sourceCode, "G7s", "PLATFORM"))
}
func expectSourceCodeMiss(mock sqlmock.Sqlmock, sourceIP string) {
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", sourceIP).
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}))
}
func expectVehicleIdentifierMissForSource(mock sqlmock.Sqlmock, sourceCode string, identifierType string, value string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value, sourceCode).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}))
}
func expectVehicleIdentifierHitForSource(mock sqlmock.Sqlmock, sourceCode string, identifierType string, value string, vin string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value, sourceCode).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}).AddRow(vin, sourceCode, "G7s"))
}

View File

@@ -0,0 +1,325 @@
package identity
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
type SnapshotRefreshResult struct {
BindingEntries int
IdentifierEntries int
RegistrationEntries int
SourceEntries int
RefreshedAt time.Time
}
// identitySnapshot is immutable after atomic publication, so frame handling
// performs only local map lookups and never waits for MySQL or a refresh lock.
type identitySnapshot struct {
bindings map[string]string
identifiers map[string]vehicleIdentifierMatch
registrations map[string]registrationCacheEntry
sources map[string]sourceMetadata
refreshedAt time.Time
}
func (r *MySQLResolver) RefreshSnapshot(ctx context.Context) (SnapshotRefreshResult, error) {
if r == nil || r.db == nil {
return SnapshotRefreshResult{}, fmt.Errorf("identity snapshot database is not configured")
}
r.snapshotRefreshMu.Lock()
defer r.snapshotRefreshMu.Unlock()
next := &identitySnapshot{
bindings: map[string]string{},
identifiers: map[string]vehicleIdentifierMatch{},
registrations: map[string]registrationCacheEntry{},
sources: map[string]sourceMetadata{},
}
if err := r.loadBindingSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
if err := r.loadIdentifierSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
if err := r.loadRegistrationSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
if err := r.loadSourceSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
next.refreshedAt = time.Now()
r.snapshot.Store(next)
return SnapshotRefreshResult{
BindingEntries: len(next.bindings),
IdentifierEntries: len(next.identifiers),
RegistrationEntries: len(next.registrations),
SourceEntries: len(next.sources),
RefreshedAt: next.refreshedAt,
}, nil
}
func (r *MySQLResolver) loadBindingSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, "SELECT vin, plate, phone FROM "+r.table+" WHERE vin IS NOT NULL AND TRIM(vin) <> ''")
if err != nil {
return fmt.Errorf("load identity binding snapshot: %w", err)
}
defer rows.Close()
ambiguous := map[string]struct{}{}
for rows.Next() {
var vin, plate, phone sql.NullString
if err := rows.Scan(&vin, &plate, &phone); err != nil {
return fmt.Errorf("scan identity binding snapshot: %w", err)
}
vinValue := strings.TrimSpace(vin.String)
if vinValue == "" {
continue
}
addSnapshotBinding(target.bindings, ambiguous, bindingSnapshotKey("vin", vinValue), vinValue)
if value := strings.TrimSpace(plate.String); value != "" {
addSnapshotBinding(target.bindings, ambiguous, bindingSnapshotKey("plate", value), vinValue)
}
if value := normalizePhone(phone.String); value != "" {
addSnapshotBinding(target.bindings, ambiguous, bindingSnapshotKey("phone", value), vinValue)
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate identity binding snapshot: %w", err)
}
return nil
}
func (r *MySQLResolver) loadIdentifierSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, `SELECT protocol, source_code, identifier_type, identifier_value,
vin, COALESCE(NULLIF(TRIM(oem), ''), source_code) AS platform_name
FROM vehicle_identifier
WHERE enabled = 1 AND vin IS NOT NULL AND TRIM(vin) <> ''
AND identifier_value IS NOT NULL AND TRIM(identifier_value) <> ''`)
if err != nil {
return fmt.Errorf("load vehicle identifier snapshot: %w", err)
}
defer rows.Close()
ambiguous := map[string]struct{}{}
for rows.Next() {
var protocol, sourceCode, identifierType, identifierValue, vin, platformName sql.NullString
if err := rows.Scan(&protocol, &sourceCode, &identifierType, &identifierValue, &vin, &platformName); err != nil {
return fmt.Errorf("scan vehicle identifier snapshot: %w", err)
}
protocolValue := strings.TrimSpace(protocol.String)
typeValue := strings.ToUpper(strings.TrimSpace(identifierType.String))
value := normalizeIdentifierValue(typeValue, identifierValue.String)
vinValue := strings.TrimSpace(vin.String)
if protocolValue == "" || typeValue == "" || value == "" || vinValue == "" {
continue
}
match := vehicleIdentifierMatch{
VIN: vinValue,
SourceCode: strings.TrimSpace(sourceCode.String),
PlatformName: strings.TrimSpace(platformName.String),
}
scopedKey := vehicleIdentifierSnapshotKey(protocolValue, match.SourceCode, typeValue, value)
addSnapshotIdentifier(target.identifiers, ambiguous, scopedKey, match)
globalKey := vehicleIdentifierSnapshotKey(protocolValue, "", typeValue, value)
addSnapshotIdentifier(target.identifiers, ambiguous, globalKey, match)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate vehicle identifier snapshot: %w", err)
}
return nil
}
func (r *MySQLResolver) loadRegistrationSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, `SELECT phone, vin, device_id, plate, auth_token
FROM jt808_registration
WHERE phone IS NOT NULL AND TRIM(phone) <> ''`)
if err != nil {
return fmt.Errorf("load jt808 registration snapshot: %w", err)
}
defer rows.Close()
for rows.Next() {
var phone, vin, deviceID, plate, authToken sql.NullString
if err := rows.Scan(&phone, &vin, &deviceID, &plate, &authToken); err != nil {
return fmt.Errorf("scan jt808 registration snapshot: %w", err)
}
phoneValue := normalizePhone(phone.String)
if phoneValue == "" {
continue
}
target.registrations[phoneValue] = registrationCacheEntry{
vin: strings.TrimSpace(vin.String),
deviceID: strings.TrimSpace(deviceID.String),
plate: strings.TrimSpace(plate.String),
authToken: strings.TrimSpace(authToken.String),
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate jt808 registration snapshot: %w", err)
}
return nil
}
func (r *MySQLResolver) loadSourceSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, `SELECT protocol, source_ip, source_code, platform_name, source_kind
FROM vehicle_data_source
WHERE enabled = 1 AND source_ip IS NOT NULL AND TRIM(source_ip) <> ''
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) <> '')
)`)
if err != nil {
if isOptionalSourceCodeLookupError(err) {
return nil
}
return fmt.Errorf("load vehicle data source snapshot: %w", err)
}
defer rows.Close()
for rows.Next() {
var protocol, sourceIP, sourceCode, platformName, sourceKind sql.NullString
if err := rows.Scan(&protocol, &sourceIP, &sourceCode, &platformName, &sourceKind); err != nil {
return fmt.Errorf("scan vehicle data source snapshot: %w", err)
}
key := sourceSnapshotKey(protocol.String, sourceIP.String)
if key == "" {
continue
}
target.sources[key] = sourceMetadata{
SourceCode: strings.TrimSpace(sourceCode.String),
PlatformName: strings.TrimSpace(platformName.String),
SourceKind: strings.TrimSpace(sourceKind.String),
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate vehicle data source snapshot: %w", err)
}
return nil
}
func addSnapshotBinding(values map[string]string, ambiguous map[string]struct{}, key string, vin string) {
if key == "" {
return
}
if _, exists := ambiguous[key]; exists {
return
}
if current, exists := values[key]; exists && !strings.EqualFold(current, vin) {
delete(values, key)
ambiguous[key] = struct{}{}
return
}
values[key] = vin
}
func addSnapshotIdentifier(values map[string]vehicleIdentifierMatch, ambiguous map[string]struct{}, key string, match vehicleIdentifierMatch) {
if key == "" {
return
}
if _, exists := ambiguous[key]; exists {
return
}
current, exists := values[key]
if !exists {
values[key] = match
return
}
if !strings.EqualFold(current.VIN, match.VIN) {
delete(values, key)
ambiguous[key] = struct{}{}
return
}
if !strings.EqualFold(current.SourceCode, match.SourceCode) {
current.SourceCode = ""
current.PlatformName = ""
} else if current.PlatformName != match.PlatformName {
current.PlatformName = ""
}
values[key] = current
}
func (r *MySQLResolver) snapshotBinding(column string, value string) (string, bool) {
key := bindingSnapshotKey(column, value)
if key == "" {
return "", false
}
snapshot := r.snapshot.Load()
if snapshot == nil {
return "", false
}
vin, ok := snapshot.bindings[key]
return vin, ok
}
func (r *MySQLResolver) snapshotIdentifier(protocolValue string, sourceCode string, identifierType string, value string) (vehicleIdentifierMatch, bool) {
key := vehicleIdentifierSnapshotKey(protocolValue, sourceCode, identifierType, value)
snapshot := r.snapshot.Load()
if snapshot == nil {
return vehicleIdentifierMatch{}, false
}
match, ok := snapshot.identifiers[key]
return match, ok
}
func (r *MySQLResolver) snapshotRegistration(phone string) (registrationCacheEntry, bool) {
phone = normalizePhone(phone)
snapshot := r.snapshot.Load()
if snapshot == nil {
return registrationCacheEntry{}, false
}
entry, ok := snapshot.registrations[phone]
return entry, ok
}
func (r *MySQLResolver) snapshotSource(protocolValue string, sourceIP string) (sourceMetadata, bool) {
key := sourceSnapshotKey(protocolValue, sourceIP)
snapshot := r.snapshot.Load()
if snapshot == nil {
return sourceMetadata{}, false
}
metadata, ok := snapshot.sources[key]
return metadata, ok
}
// JT808AuthToken serves authentication from the same immutable snapshot used
// by identity resolution. It deliberately never falls back to a per-frame SQL
// query because authentication is on the protocol response hot path.
func (r *MySQLResolver) JT808AuthToken(phone string) (string, bool) {
entry, ok := r.snapshotRegistration(phone)
token := strings.TrimSpace(entry.authToken)
return token, ok && token != ""
}
func bindingSnapshotKey(column string, value string) string {
column = strings.ToLower(strings.TrimSpace(column))
value = strings.TrimSpace(value)
if column == "phone" {
value = normalizePhone(value)
}
if value == "" || (column != "vin" && column != "plate" && column != "phone") {
return ""
}
return column + "\x00" + value
}
func vehicleIdentifierSnapshotKey(protocolValue string, sourceCode string, identifierType string, value string) string {
protocolValue = strings.TrimSpace(protocolValue)
sourceCode = strings.TrimSpace(sourceCode)
identifierType = strings.ToUpper(strings.TrimSpace(identifierType))
value = normalizeIdentifierValue(identifierType, value)
if protocolValue == "" || identifierType == "" || value == "" {
return ""
}
return "vehicle_identifier\x00" + protocolValue + "\x00" + sourceCode + "\x00" + identifierType + "\x00" + value
}
func sourceSnapshotKey(protocolValue string, sourceIP string) string {
protocolValue = strings.TrimSpace(protocolValue)
sourceIP = normalizeEndpointIP(sourceIP)
if protocolValue == "" || sourceIP == "" {
return ""
}
return protocolValue + "\x00" + sourceIP
}

View File

@@ -0,0 +1,166 @@
package identity
import (
"context"
"errors"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestRefreshSnapshotResolvesKnownJT808WithoutPerFrameQueries(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
result, err := resolver.RefreshSnapshot(context.Background())
if err != nil {
t.Fatalf("RefreshSnapshot() error = %v", err)
}
if result.BindingEntries != 3 || result.IdentifierEntries != 2 || result.RegistrationEntries != 1 || result.SourceEntries != 1 {
t.Fatalf("snapshot result = %+v", result)
}
resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.VIN != "LNBVIN00000000001" {
t.Fatalf("vin = %q", resolved.VIN)
}
if resolved.SourceCode != "g7s" || resolved.PlatformName != "G7s" || resolved.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", resolved.SourceCode, resolved.PlatformName, resolved.SourceKind)
}
identityMetadata, _ := resolved.Parsed["identity"].(map[string]any)
if identityMetadata["cache_status"] != "snapshot" {
t.Fatalf("identity metadata = %#v, want snapshot cache status", identityMetadata)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestSnapshotOnlyResolverMissDoesNotQueryMySQL(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307700000",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.VIN != "" {
t.Fatalf("vin = %q, want unresolved", resolved.VIN)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("snapshot-only miss should not query mysql: %v", err)
}
}
func TestRefreshSnapshotFailureKeepsLastKnownGoodSnapshot(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
first, err := resolver.RefreshSnapshot(context.Background())
if err != nil {
t.Fatalf("first RefreshSnapshot() error = %v", err)
}
mock.ExpectQuery("SELECT vin, plate, phone").
WillReturnError(errors.New("mysql unavailable"))
if _, err := resolver.RefreshSnapshot(context.Background()); err == nil {
t.Fatal("second RefreshSnapshot() error = nil, want failure")
}
resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() after failed refresh error = %v", err)
}
if resolved.VIN != "LNBVIN00000000001" {
t.Fatalf("vin after failed refresh = %q", resolved.VIN)
}
stats := resolver.CacheStats()
if !stats.SnapshotReady || stats.SnapshotRefreshedAt.IsZero() || !stats.SnapshotRefreshedAt.Equal(first.RefreshedAt) {
t.Fatalf("snapshot stats after failed refresh = %+v, first = %+v", stats, first)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func expectIdentitySnapshot(mock sqlmock.Sqlmock) {
mock.ExpectQuery("SELECT vin, plate, phone").
WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "phone"}).
AddRow("LNBVIN00000000001", "粤A00001", "13307795425"))
mock.ExpectQuery("SELECT protocol, source_code, identifier_type, identifier_value").
WillReturnRows(sqlmock.NewRows([]string{"protocol", "source_code", "identifier_type", "identifier_value", "vin", "platform_name"}).
AddRow("JT808", "g7s", "JT808_PHONE", "13307795425", "LNBVIN00000000001", "G7s"))
mock.ExpectQuery("SELECT phone, vin, device_id, plate, auth_token").
WillReturnRows(sqlmock.NewRows([]string{"phone", "vin", "device_id", "plate", "auth_token"}).
AddRow("13307795425", "LNBVIN00000000001", "DEVICE-1", "粤A00001", "device-code"))
mock.ExpectQuery("SELECT protocol, source_ip, source_code, platform_name, source_kind").
WillReturnRows(sqlmock.NewRows([]string{"protocol", "source_ip", "source_code", "platform_name", "source_kind"}).
AddRow("JT808", "115.231.168.135", "g7s", "G7s", "PLATFORM"))
}
func TestSnapshotServesJT808AuthenticationTokenByNormalizedPhone(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
if _, err := resolver.RefreshSnapshot(context.Background()); err != nil {
t.Fatalf("RefreshSnapshot() error = %v", err)
}
token, ok := resolver.JT808AuthToken("0013307795425")
if !ok || token != "device-code" {
t.Fatalf("JT808AuthToken() = %q, %v", token, ok)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestSnapshotResultRefreshedAtUsesCurrentTime(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
before := time.Now()
result, err := resolver.RefreshSnapshot(context.Background())
if err != nil {
t.Fatalf("RefreshSnapshot() error = %v", err)
}
if result.RefreshedAt.Before(before) || result.RefreshedAt.After(time.Now()) {
t.Fatalf("refreshed_at = %v, want current time", result.RefreshedAt)
}
}