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

@@ -3,7 +3,6 @@ package stats
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
@@ -11,6 +10,15 @@ import (
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
const (
maxNegativeMileageJitterKM = 1.0
defaultCacheRetention = 72 * time.Hour
defaultCacheCleanupInterval = 10 * time.Minute
defaultBaselineMissTTL = time.Minute
defaultMaxCacheEntries = 1000000
)
type Execer interface {
@@ -18,14 +26,26 @@ type Execer interface {
}
type Writer struct {
exec Execer
query Queryer
loc *time.Location
sourceTouchInterval time.Duration
mu sync.Mutex
lastTotalMileage map[string]float64
lastSourceSeen map[string]time.Time
baselineCache map[string]sourceBaselineCacheEntry
exec Execer
query Queryer
loc *time.Location
sourceTouchInterval time.Duration
projectionInterval time.Duration
cacheRetention time.Duration
cacheCleanupInterval time.Duration
baselineMissTTL time.Duration
maxCacheEntries int
lastCacheCleanup time.Time
lastCacheCleanupStats cacheCleanupStats
cacheEvictions cacheCleanupStats
mu sync.Mutex
lastTotalMileage map[string]float64
lastSourceSeen map[string]time.Time
lastProjection map[string]projectionCacheEntry
baselineCache map[string]sourceBaselineCacheEntry
mileageKeysByPrefix map[string]map[string]struct{}
projectionKeysByPrefix map[string]map[string]struct{}
baselineKeysByPrefix map[string]map[string]struct{}
}
type MetricSample struct {
@@ -38,6 +58,53 @@ type MetricSample struct {
Phone string
DeviceID string
SourceEndpoint string
PlatformName string
}
type AppendResult struct {
SamplesFound int
SamplesWritten int
SamplesSkippedMissingFields int
SamplesSkippedMissingVIN int
SamplesSkippedMissingMileage int
SamplesSkippedNonMileageFrame int
SamplesSkippedNonPositiveMileage int
SamplesSkippedMissingTime int
SamplesSkippedSameMileage int
SamplesSkippedMissingSource int
SamplesAdjustedFutureEventTime int
SourceTouchesAttempted int
SourceTouchesWritten int
SourceTouchesSkippedThrottled int
SourceTouchesSkippedMissing int
SourceTouchesSkippedUnmanaged int
ProjectionsAttempted int
ProjectionsWritten int
ProjectionsSkippedThrottled int
}
type CacheStats struct {
LastTotalMileageEntries int
LastSourceSeenEntries int
LastProjectionEntries int
BaselineEntries int
MaxEntries int
LastCleanupAt time.Time
LastCleanupTotalMileage int
LastCleanupSourceSeen int
LastCleanupProjection int
LastCleanupBaseline int
TotalMileageEvictions int
SourceSeenEvictions int
ProjectionEvictions int
BaselineEvictions int
}
type cacheCleanupStats struct {
totalMileage int
sourceSeen int
projection int
baseline int
}
func NewWriter(exec Execer, loc *time.Location) *Writer {
@@ -48,12 +115,21 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
writer := &Writer{
exec: exec,
loc: loc,
sourceTouchInterval: time.Minute,
lastTotalMileage: map[string]float64{},
lastSourceSeen: map[string]time.Time{},
baselineCache: map[string]sourceBaselineCacheEntry{},
exec: exec,
loc: loc,
sourceTouchInterval: time.Minute,
projectionInterval: 15 * time.Second,
cacheRetention: defaultCacheRetention,
cacheCleanupInterval: defaultCacheCleanupInterval,
baselineMissTTL: defaultBaselineMissTTL,
maxCacheEntries: defaultMaxCacheEntries,
lastTotalMileage: map[string]float64{},
lastSourceSeen: map[string]time.Time{},
lastProjection: map[string]projectionCacheEntry{},
baselineCache: map[string]sourceBaselineCacheEntry{},
mileageKeysByPrefix: map[string]map[string]struct{}{},
projectionKeysByPrefix: map[string]map[string]struct{}{},
baselineKeysByPrefix: map[string]map[string]struct{}{},
}
if query, ok := exec.(Queryer); ok {
writer.query = query
@@ -61,6 +137,82 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
return writer
}
func (w *Writer) SetSourceTouchInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if interval < 0 {
interval = 0
}
w.sourceTouchInterval = interval
}
func (w *Writer) SetProjectionInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if interval < 0 {
interval = 0
}
w.projectionInterval = interval
}
func (w *Writer) SetCacheRetention(retention time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if retention < 0 {
retention = 0
}
w.cacheRetention = retention
}
func (w *Writer) SetCacheCleanupInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if interval < 0 {
interval = 0
}
w.cacheCleanupInterval = interval
}
func (w *Writer) SetBaselineMissTTL(ttl time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if ttl < 0 {
ttl = 0
}
w.baselineMissTTL = ttl
}
func (w *Writer) SetMaxCacheEntries(maxEntries int) {
w.mu.Lock()
defer w.mu.Unlock()
if maxEntries < 0 {
maxEntries = 0
}
w.maxCacheEntries = maxEntries
w.enforceCacheLimitsLocked()
}
func (w *Writer) CacheStats() CacheStats {
w.mu.Lock()
defer w.mu.Unlock()
return CacheStats{
LastTotalMileageEntries: len(w.lastTotalMileage),
LastSourceSeenEntries: len(w.lastSourceSeen),
LastProjectionEntries: len(w.lastProjection),
BaselineEntries: len(w.baselineCache),
MaxEntries: w.maxCacheEntries,
LastCleanupAt: w.lastCacheCleanup,
LastCleanupTotalMileage: w.lastCacheCleanupStats.totalMileage,
LastCleanupSourceSeen: w.lastCacheCleanupStats.sourceSeen,
LastCleanupProjection: w.lastCacheCleanupStats.projection,
LastCleanupBaseline: w.lastCacheCleanupStats.baseline,
TotalMileageEvictions: w.cacheEvictions.totalMileage,
SourceSeenEvictions: w.cacheEvictions.sourceSeen,
ProjectionEvictions: w.cacheEvictions.projection,
BaselineEvictions: w.cacheEvictions.baseline,
}
}
func (w *Writer) EnsureSchema(ctx context.Context) error {
for _, statement := range []string{
DataSourceTableSQL,
@@ -80,48 +232,121 @@ func (w *Writer) EnsureSchema(ctx context.Context) error {
}
func (w *Writer) Append(ctx context.Context, env envelope.FrameEnvelope) error {
identity, hasSource := NewSourceIdentity(env.Protocol, env.SourceEndpoint)
if hasSource {
seenAt := w.sourceSeenAt(env)
_, err := w.AppendWithResult(ctx, env)
return err
}
func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelope) (AppendResult, error) {
var result AppendResult
if len(env.Fields) == 0 {
result.SamplesSkippedMissingFields = 1
return result, nil
}
seenAt := w.sourceSeenAt(env)
w.maybeCleanupCaches(seenAt)
identity, hasSource := NewSourceIdentityFromEnvelope(env)
var sourceResult AppendResult
if hasSource && ShouldManageDataSource(identity) {
if w.shouldTouchSource(identity, seenAt) {
sourceResult.SourceTouchesAttempted = 1
if err := UpsertDataSource(ctx, w.exec, identity, seenAt); err != nil {
return err
return sourceResult, err
}
w.markSourceTouched(identity, seenAt)
sourceResult.SourceTouchesWritten = 1
} else {
sourceResult.SourceTouchesSkippedThrottled = 1
}
} else if hasSource {
sourceResult.SourceTouchesSkippedUnmanaged = 1
}
samples, err := SamplesFromEnvelope(env, w.loc)
samples, extractionResult, err := samplesFromEnvelopeWithResult(env, w.loc)
result = extractionResult
result.SourceTouchesAttempted += sourceResult.SourceTouchesAttempted
result.SourceTouchesWritten += sourceResult.SourceTouchesWritten
result.SourceTouchesSkippedThrottled += sourceResult.SourceTouchesSkippedThrottled
result.SourceTouchesSkippedMissing += sourceResult.SourceTouchesSkippedMissing
result.SourceTouchesSkippedUnmanaged += sourceResult.SourceTouchesSkippedUnmanaged
if err != nil {
return err
return result, err
}
for _, sample := range samples {
if w.seenSameMileage(sample) {
if !hasSource {
result.SamplesSkippedMissingSource++
result.SourceTouchesSkippedMissing++
continue
}
if !hasSource {
if w.seenSameMileage(sample) {
result.SamplesSkippedSameMileage++
continue
}
candidate := SourceMileageSampleFromMetric(sample, identity)
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
return err
return result, err
}
if err := UpsertSourceMileage(ctx, w.exec, candidate); err != nil {
return err
projectDaily := w.shouldProjectDailyMileage(sample)
if projectDaily {
result.ProjectionsAttempted++
} else {
result.ProjectionsSkippedThrottled++
}
if err := ProjectDailyMileage(ctx, w.exec, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
return err
if err := w.writeMileageSample(ctx, sample, candidate, projectDaily); err != nil {
return result, err
}
w.markBaselineWritten(sample, candidate)
if projectDaily {
w.markProjected(sample)
result.ProjectionsWritten++
}
w.markMileageWritten(sample)
result.SamplesWritten++
}
return result, nil
}
func (w *Writer) writeMileageSample(ctx context.Context, sample MetricSample, candidate SourceMileageSample, projectDaily bool) error {
if projectDaily {
if beginner, ok := w.exec.(txBeginner); ok {
tx, err := beginner.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := UpsertSourceMileage(ctx, tx, candidate); err != nil {
_ = tx.Rollback()
return err
}
if ShouldNormalizePlatformSourceMileage(candidate) {
if err := NormalizePlatformSourceMileage(ctx, tx, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
_ = tx.Rollback()
return err
}
}
if err := projectDailyMileageWithExec(ctx, tx, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
}
if err := UpsertSourceMileage(ctx, w.exec, candidate); err != nil {
return err
}
if projectDaily {
if ShouldNormalizePlatformSourceMileage(candidate) {
if err := NormalizePlatformSourceMileage(ctx, w.exec, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
return err
}
}
return ProjectDailyMileage(ctx, w.exec, sample.VIN, sample.StatDate, sample.Protocol)
}
return nil
}
func (w *Writer) sourceSeenAt(env envelope.FrameEnvelope) time.Time {
eventMS := env.EventTimeMS
if eventMS <= 0 {
eventMS = env.ReceivedAtMS
if env.ReceivedAtMS > 0 {
return time.UnixMilli(env.ReceivedAtMS).In(w.loc)
}
if eventMS > 0 {
if eventMS, ok := statEventTimeMS(env); ok {
return time.UnixMilli(eventMS).In(w.loc)
}
return time.Now().In(w.loc)
@@ -131,6 +356,9 @@ func (w *Writer) shouldTouchSource(identity SourceIdentity, seenAt time.Time) bo
key := string(identity.Protocol) + "|" + identity.SourceIP
w.mu.Lock()
defer w.mu.Unlock()
if w.sourceTouchInterval == 0 {
return true
}
last, ok := w.lastSourceSeen[key]
if ok && !seenAt.After(last.Add(w.sourceTouchInterval)) {
return false
@@ -142,9 +370,45 @@ func (w *Writer) markSourceTouched(identity SourceIdentity, seenAt time.Time) {
key := string(identity.Protocol) + "|" + identity.SourceIP
w.mu.Lock()
w.lastSourceSeen[key] = seenAt
w.enforceCacheLimitsLocked()
w.mu.Unlock()
}
func (w *Writer) shouldProjectDailyMileage(sample MetricSample) bool {
key := projectionCacheKey(sample)
w.mu.Lock()
defer w.mu.Unlock()
interval := w.projectionInterval
if interval == 0 {
return true
}
entry, ok := w.lastProjection[key]
if !ok {
return true
}
if _, ok := entry.sourceKeys[sample.SourceKey]; !ok {
return true
}
return sample.EventTime.After(entry.projectedAt.Add(interval))
}
func (w *Writer) markProjected(sample MetricSample) {
key := projectionCacheKey(sample)
prefix := projectionCachePrefix(sample)
w.mu.Lock()
defer w.mu.Unlock()
w.deleteProjectionPrefixExceptLocked(prefix, key)
entry := w.lastProjection[key]
if entry.sourceKeys == nil {
entry.sourceKeys = map[string]struct{}{}
}
entry.sourceKeys[sample.SourceKey] = struct{}{}
entry.projectedAt = sample.EventTime
w.lastProjection[key] = entry
w.addCacheKeyLocked(w.projectionKeysByPrefix, prefix, key)
w.enforceCacheLimitsLocked()
}
func (w *Writer) seenSameMileage(sample MetricSample) bool {
key := mileageCacheKey(sample)
w.mu.Lock()
@@ -160,17 +424,59 @@ func (w *Writer) markMileageWritten(sample MetricSample) {
key := mileageCacheKey(sample)
w.mu.Lock()
defer w.mu.Unlock()
for existing := range w.lastTotalMileage {
if strings.HasPrefix(existing, prefix) && existing != key {
delete(w.lastTotalMileage, existing)
}
}
for existing := range w.baselineCache {
if strings.HasPrefix(existing, prefix) && existing != key {
delete(w.baselineCache, existing)
}
}
w.deleteMileagePrefixExceptLocked(prefix, key)
w.deleteBaselinePrefixExceptLocked(prefix, key)
w.lastTotalMileage[key] = sample.TotalMileageKM
w.addCacheKeyLocked(w.mileageKeysByPrefix, prefix, key)
w.enforceCacheLimitsLocked()
}
func (w *Writer) maybeCleanupCaches(now time.Time) {
if now.IsZero() {
now = time.Now().In(w.loc)
}
w.mu.Lock()
defer w.mu.Unlock()
retention := w.cacheRetention
if retention <= 0 {
w.enforceCacheLimitsLocked()
return
}
interval := w.cacheCleanupInterval
if interval > 0 && !w.lastCacheCleanup.IsZero() && !now.After(w.lastCacheCleanup.Add(interval)) {
w.enforceCacheLimitsLocked()
return
}
w.lastCacheCleanup = now
cleanupStats := cacheCleanupStats{}
cutoff := now.Add(-retention)
cutoffDate := cutoff.In(w.loc).Format("2006-01-02")
for key, seenAt := range w.lastSourceSeen {
if !seenAt.IsZero() && seenAt.Before(cutoff) {
delete(w.lastSourceSeen, key)
cleanupStats.sourceSeen++
}
}
for key, entry := range w.lastProjection {
if entry.projectedAt.IsZero() || entry.projectedAt.Before(cutoff) || cacheKeyDateBefore(key, cutoffDate) {
w.deleteProjectionKeyLocked(key)
cleanupStats.projection++
}
}
for key := range w.lastTotalMileage {
if cacheKeyDateBefore(key, cutoffDate) {
w.deleteMileageKeyLocked(key)
cleanupStats.totalMileage++
}
}
for key := range w.baselineCache {
if cacheKeyDateBefore(key, cutoffDate) {
w.deleteBaselineKeyLocked(key)
cleanupStats.baseline++
}
}
w.lastCacheCleanupStats = cleanupStats
w.enforceCacheLimitsLocked()
}
func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMileageSample) error {
@@ -182,22 +488,22 @@ func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMil
return err
}
if !found {
// If no earlier odometer exists at all, use the first current-day sample.
// Later samples retain that boundary through the in-memory baseline cache.
candidate.DailyKM = 0
candidate.QualityStatus = QualityOK
candidate.QualityReason = "current_day_first_sample"
candidate.QualityReason = QualityReasonCurrentDayFirst
return nil
}
candidate.FirstTotalKM = baseline.LatestTotalKM
candidate.FirstEventTime = baseline.LatestEventTime
candidate.DailyKM = candidate.LatestTotalKM - baseline.LatestTotalKM
candidate.DailyKM = DailyMileageFromDayBoundary(baseline.LatestTotalKM, candidate.LatestTotalKM)
candidate.QualityStatus = QualityOK
candidate.QualityReason = baseline.QualityReason
if candidate.QualityReason == "" {
candidate.QualityReason = "historical_source_baseline"
}
if candidate.DailyKM < 0 || candidate.DailyKM > maxSelectedDailyMileageKM {
candidate.QualityStatus = QualityInvalidDelta
candidate.QualityReason = "outside_daily_range"
candidate.QualityReason = QualityReasonHistorical
}
ApplyMileageQualityRules(candidate)
return nil
}
@@ -208,10 +514,15 @@ func (w *Writer) previousBaseline(ctx context.Context, candidate SourceMileageSa
StatDate: candidate.StatDate,
SourceKey: candidate.SourceKey,
})
now := time.Now()
w.mu.Lock()
if cached, ok := w.baselineCache[cacheKey]; ok {
w.mu.Unlock()
return cached.baseline, cached.found, nil
missExpired := !cached.found && (w.baselineMissTTL == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= w.baselineMissTTL)
if !missExpired {
w.mu.Unlock()
return cached.baseline, cached.found, nil
}
w.deleteBaselineKeyLocked(cacheKey)
}
w.mu.Unlock()
@@ -219,21 +530,165 @@ func (w *Writer) previousBaseline(ctx context.Context, candidate SourceMileageSa
if err != nil {
return sourceBaseline{}, false, err
}
if !found {
baseline, found, err = lookupCurrentSourceBaseline(ctx, w.query, candidate.VIN, candidate.StatDate, candidate.Protocol, candidate.SourceKey)
if err != nil {
return sourceBaseline{}, false, err
}
}
if found {
w.mu.Lock()
w.baselineCache[cacheKey] = sourceBaselineCacheEntry{baseline: baseline, found: true}
w.mu.Unlock()
w.cacheBaseline(candidate, cacheKey, sourceBaselineCacheEntry{baseline: baseline, found: true})
} else {
w.cacheBaseline(candidate, cacheKey, sourceBaselineCacheEntry{found: false})
}
return baseline, found, nil
}
func (w *Writer) markBaselineWritten(sample MetricSample, candidate SourceMileageSample) {
if candidate.QualityStatus != QualityOK {
return
}
baselineTotal := candidate.FirstTotalKM
if baselineTotal <= 0 {
baselineTotal = candidate.LatestTotalKM
}
baselineTime := candidate.FirstEventTime
if baselineTime.IsZero() {
baselineTime = candidate.LatestEventTime
}
if baselineTotal <= 0 || baselineTime.IsZero() {
return
}
cacheKey := mileageCacheKey(sample)
w.cacheBaseline(candidate, cacheKey, sourceBaselineCacheEntry{
baseline: sourceBaseline{
LatestTotalKM: baselineTotal,
LatestEventTime: baselineTime,
QualityReason: candidate.QualityReason,
},
found: true,
})
}
func (w *Writer) cacheBaseline(candidate SourceMileageSample, cacheKey string, entry sourceBaselineCacheEntry) {
if cacheKey == "" {
return
}
w.mu.Lock()
if entry.cachedAt.IsZero() {
entry.cachedAt = time.Now()
}
w.baselineCache[cacheKey] = entry
w.addCacheKeyLocked(w.baselineKeysByPrefix, mileageCachePrefix(MetricSample{
VIN: candidate.VIN,
Protocol: candidate.Protocol,
SourceKey: candidate.SourceKey,
}), cacheKey)
w.enforceCacheLimitsLocked()
w.mu.Unlock()
}
func (w *Writer) addCacheKeyLocked(index map[string]map[string]struct{}, prefix string, key string) {
if prefix == "" || key == "" {
return
}
keys := index[prefix]
if keys == nil {
keys = map[string]struct{}{}
index[prefix] = keys
}
keys[key] = struct{}{}
}
func (w *Writer) deleteMileagePrefixExceptLocked(prefix string, keep string) {
for key := range w.mileageKeysByPrefix[prefix] {
if key == keep {
continue
}
w.deleteMileageKeyLocked(key)
}
}
func (w *Writer) deleteBaselinePrefixExceptLocked(prefix string, keep string) {
for key := range w.baselineKeysByPrefix[prefix] {
if key == keep {
continue
}
w.deleteBaselineKeyLocked(key)
}
}
func (w *Writer) deleteProjectionPrefixExceptLocked(prefix string, keep string) {
for key := range w.projectionKeysByPrefix[prefix] {
if key == keep {
continue
}
w.deleteProjectionKeyLocked(key)
}
}
func (w *Writer) deleteMileageKeyLocked(key string) {
delete(w.lastTotalMileage, key)
w.deleteIndexedCacheKeyLocked(w.mileageKeysByPrefix, cacheKeyPrefix(key), key)
}
func (w *Writer) deleteBaselineKeyLocked(key string) {
delete(w.baselineCache, key)
w.deleteIndexedCacheKeyLocked(w.baselineKeysByPrefix, cacheKeyPrefix(key), key)
}
func (w *Writer) deleteProjectionKeyLocked(key string) {
delete(w.lastProjection, key)
w.deleteIndexedCacheKeyLocked(w.projectionKeysByPrefix, cacheKeyPrefix(key), key)
}
func (w *Writer) deleteIndexedCacheKeyLocked(index map[string]map[string]struct{}, prefix string, key string) {
if prefix == "" || key == "" {
return
}
keys := index[prefix]
if len(keys) == 0 {
return
}
delete(keys, key)
if len(keys) == 0 {
delete(index, prefix)
}
}
func (w *Writer) enforceCacheLimitsLocked() {
if w.maxCacheEntries <= 0 {
return
}
for len(w.lastTotalMileage) > w.maxCacheEntries {
victim := oldestCacheKeyByDate(w.lastTotalMileage)
if victim == "" {
break
}
w.deleteMileageKeyLocked(victim)
w.cacheEvictions.totalMileage++
}
for len(w.baselineCache) > w.maxCacheEntries {
victim := oldestCacheKeyByDate(w.baselineCache)
if victim == "" {
break
}
w.deleteBaselineKeyLocked(victim)
w.cacheEvictions.baseline++
}
for len(w.lastProjection) > w.maxCacheEntries {
victim := oldestProjectionCacheKey(w.lastProjection)
if victim == "" {
break
}
w.deleteProjectionKeyLocked(victim)
w.cacheEvictions.projection++
}
for len(w.lastSourceSeen) > w.maxCacheEntries {
victim := oldestSourceSeenKey(w.lastSourceSeen)
if victim == "" {
break
}
delete(w.lastSourceSeen, victim)
w.cacheEvictions.sourceSeen++
}
}
func mileageCachePrefix(sample MetricSample) string {
return fmt.Sprintf("%s|%s|%s|", sample.VIN, sample.Protocol, sample.SourceKey)
}
@@ -242,34 +697,138 @@ func mileageCacheKey(sample MetricSample) string {
return mileageCachePrefix(sample) + sample.StatDate
}
func projectionCacheKey(sample MetricSample) string {
return projectionCachePrefix(sample) + sample.StatDate
}
func projectionCachePrefix(sample MetricSample) string {
return fmt.Sprintf("%s|%s|", sample.VIN, sample.Protocol)
}
func cacheKeyDateBefore(key string, cutoffDate string) bool {
date := cacheKeyDate(key)
if len(date) != len("2006-01-02") {
return false
}
return date < cutoffDate
}
func cacheKeyPrefix(key string) string {
index := strings.LastIndex(key, "|")
if index < 0 {
return ""
}
return key[:index+1]
}
func cacheKeyDate(key string) string {
index := strings.LastIndex(key, "|")
if index < 0 || index == len(key)-1 {
return ""
}
return key[index+1:]
}
func oldestCacheKeyByDate[T any](items map[string]T) string {
victim := ""
victimDate := ""
for key := range items {
date := cacheKeyDate(key)
if date == "" {
date = "9999-99-99"
}
if victim == "" || date < victimDate || (date == victimDate && key < victim) {
victim = key
victimDate = date
}
}
return victim
}
func oldestProjectionCacheKey(items map[string]projectionCacheEntry) string {
victim := ""
var victimTime time.Time
for key, entry := range items {
if victim == "" ||
(!entry.projectedAt.IsZero() && (victimTime.IsZero() || entry.projectedAt.Before(victimTime))) ||
(entry.projectedAt.Equal(victimTime) && key < victim) {
victim = key
victimTime = entry.projectedAt
}
}
if victim != "" {
return victim
}
return oldestCacheKeyByDate(items)
}
func oldestSourceSeenKey(items map[string]time.Time) string {
victim := ""
var victimTime time.Time
for key, seenAt := range items {
if victim == "" ||
(!seenAt.IsZero() && (victimTime.IsZero() || seenAt.Before(victimTime))) ||
(seenAt.Equal(victimTime) && key < victim) {
victim = key
victimTime = seenAt
}
}
return victim
}
type projectionCacheEntry struct {
projectedAt time.Time
sourceKeys map[string]struct{}
}
type sourceBaselineCacheEntry struct {
baseline sourceBaseline
found bool
cachedAt time.Time
}
func SamplesFromEnvelope(env envelope.FrameEnvelope, loc *time.Location) ([]MetricSample, error) {
samples, _, err := samplesFromEnvelopeWithResult(env, loc)
return samples, err
}
func samplesFromEnvelopeWithResult(env envelope.FrameEnvelope, loc *time.Location) ([]MetricSample, AppendResult, error) {
var result AppendResult
if len(env.Fields) == 0 {
result.SamplesSkippedMissingFields = 1
return nil, result, nil
}
vin := strings.TrimSpace(env.VIN)
if vin == "" {
return nil, nil
result.SamplesSkippedMissingVIN = 1
return nil, result, nil
}
totalMileage, ok := totalMileageKMFromEnvelope(env)
if !ok {
return nil, nil
if isMileageCandidateEnvelope(env) {
result.SamplesSkippedMissingMileage = 1
} else {
result.SamplesSkippedNonMileageFrame = 1
}
return nil, result, nil
}
if totalMileage <= 0 {
return nil, nil
result.SamplesSkippedNonPositiveMileage = 1
return nil, result, nil
}
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
eventMS := env.EventTimeMS
if eventMS <= 0 {
eventMS = env.ReceivedAtMS
eventMS, reason, ok := envelope.NormalizedEventTimeMSWithReason(env)
if !ok {
result.SamplesSkippedMissingTime = 1
return nil, result, nil
}
if eventMS <= 0 {
return nil, errors.New("event or received time is required")
if reason == envelope.EventTimeReasonReceivedFutureEvent {
result.SamplesAdjustedFutureEventTime = 1
}
statDate := time.UnixMilli(eventMS).In(loc).Format("2006-01-02")
result.SamplesFound = 1
return []MetricSample{{
VIN: vin,
Protocol: env.Protocol,
@@ -280,11 +839,16 @@ func SamplesFromEnvelope(env envelope.FrameEnvelope, loc *time.Location) ([]Metr
Phone: strings.TrimSpace(env.Phone),
DeviceID: strings.TrimSpace(env.DeviceID),
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
}}, nil
PlatformName: strings.TrimSpace(env.PlatformName),
}}, result, nil
}
func statEventTimeMS(env envelope.FrameEnvelope) (int64, bool) {
return envelope.NormalizedEventTimeMS(env)
}
func sourceKey(env envelope.FrameEnvelope) string {
return SourceKey(env.Protocol, env.Phone, env.DeviceID, NormalizeSourceIP(env.SourceEndpoint))
return SourceKeyForSource(env.Protocol, env.Phone, env.DeviceID, NormalizeSourceIP(env.SourceEndpoint), env.SourceKind, env.SourceCode)
}
func isIgnorableSchemaChangeError(err error) bool {
@@ -300,41 +864,59 @@ func isIgnorableSchemaChangeError(err error) bool {
strings.Contains(text, "1091")
}
type mileageFieldMapping struct {
key string
scale float64
}
type mileageFieldMapping = telemetry.MileageFieldMapping
func totalMileageKMFromEnvelope(env envelope.FrameEnvelope) (float64, bool) {
if value, ok := floatField(env, envelope.FieldTotalMileageKM); ok {
return value, true
}
for _, mapping := range mileageMappingsByProtocol(env.Protocol) {
value, ok := floatField(env, mapping.key)
if !ok {
continue
}
return value * mapping.scale, true
}
return 0, false
return telemetry.TotalMileageKM(env.Protocol, env.Fields)
}
func mileageMappingsByProtocol(protocol envelope.Protocol) []mileageFieldMapping {
switch protocol {
case envelope.ProtocolGB32960:
return []mileageFieldMapping{{key: "gb32960.vehicle.total_mileage_km", scale: 1}}
return telemetry.MileageFieldMappings(protocol)
}
func isMileageCandidateEnvelope(env envelope.FrameEnvelope) bool {
switch env.Protocol {
case envelope.ProtocolJT808:
return []mileageFieldMapping{{key: "jt808.location.total_mileage_km", scale: 1}}
messageID := strings.TrimSpace(env.MessageID)
return strings.EqualFold(messageID, "0x0200") ||
hasFieldPrefix(env.Fields, "jt808.location.") ||
hasAnyField(env.Fields, envelope.FieldLatitude, envelope.FieldLongitude, envelope.FieldSpeedKMH)
case envelope.ProtocolGB32960:
return hasFieldPrefix(env.Fields, "gb32960.vehicle.") ||
hasAnyField(env.Fields, envelope.FieldLatitude, envelope.FieldLongitude, envelope.FieldSpeedKMH, envelope.FieldSOCPercent)
case envelope.ProtocolYutongMQTT:
return []mileageFieldMapping{
{key: "yutong_mqtt.data.total_mileage", scale: 0.001},
{key: "yutong_mqtt.root.data.total_mileage", scale: 0.001},
}
return hasFieldPrefix(env.Fields, "yutong_mqtt.data.") ||
hasFieldPrefix(env.Fields, "yutong_mqtt.root.data.") ||
hasAnyField(env.Fields, envelope.FieldLatitude, envelope.FieldLongitude, envelope.FieldSpeedKMH, envelope.FieldSOCPercent)
default:
return nil
return true
}
}
func hasFieldPrefix(fields map[string]any, prefix string) bool {
if len(fields) == 0 || prefix == "" {
return false
}
for key := range fields {
if strings.HasPrefix(key, prefix) {
return true
}
}
return false
}
func hasAnyField(fields map[string]any, keys ...string) bool {
if len(fields) == 0 {
return false
}
for _, key := range keys {
if _, ok := fields[key]; ok {
return true
}
}
return false
}
func floatField(env envelope.FrameEnvelope, key string) (float64, bool) {
if env.Fields == nil {
return 0, false

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,700 @@
package stats
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
const dataSourceSelectPattern = "SELECT id, protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind, trust_priority, enabled, first_seen_at, latest_seen_at, remark, updated_at FROM vehicle_data_source"
func TestDataSourceRepositoryQueriesWithOperationalFilters(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dataSourceSelectPattern).
WithArgs("JT808", "115.231.168.135", "G7S", "PLATFORM", 1, 20, 10).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind",
"trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at",
}).AddRow(
3, "JT808", "115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM",
10, 1,
time.Date(2026, 7, 8, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
time.Date(2026, 7, 12, 1, 16, 4, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
"trusted source",
time.Date(2026, 7, 12, 1, 16, 5, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
))
enabled := true
rows, err := NewDataSourceRepository(db).Query(context.Background(), DataSourceQuery{
Protocol: "jt808",
SourceIP: "115.231.168.135",
SourceCode: "G7S",
SourceKind: "platform",
Enabled: &enabled,
Limit: 20,
Offset: 10,
})
if err != nil {
t.Fatalf("Query() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.ID != 3 || row.Protocol != "JT808" || row.PlatformName != "G7 平台" || row.SourceCode != "G7S" || row.SourceKind != "PLATFORM" || !row.Enabled {
t.Fatalf("unexpected source row: %#v", row)
}
if row.LatestSeenAt != "2026-07-12 01:16:04" {
t.Fatalf("latest seen = %q", row.LatestSeenAt)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceRepositoryFiltersMissingSourceCode(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dataSourceSelectPattern).
WithArgs("JT808", 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind",
"trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at",
}))
missing := true
_, err = NewDataSourceRepository(db).Query(context.Background(), DataSourceQuery{
Protocol: "JT808",
SourceCodeMissing: &missing,
})
if err != nil {
t.Fatalf("Query() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceRepositoryDiagnosesJT808SourceMapping(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("JT808", "117.132.194.31", 1, 10, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}).AddRow(
242190, "JT808", "117.132.194.31", "117.132.194.31:20471", nil, nil,
"UNKNOWN", "2026-07-11 23:00:00", "2026-07-12 01:36:26", 9386, 120,
5, 4, 2,
3, 1, 0.75, 0, nil, 2, "g7s", "G7s", "g7s,xinda", "G7s,信达", "13307795425,14400000000",
))
missing := true
rows, err := NewDataSourceRepository(db).QueryDiagnostics(context.Background(), DataSourceDiagnosticsQuery{
Protocol: "jt808",
SourceIP: "117.132.194.31",
SourceCodeMissing: &missing,
Limit: 10,
})
if err != nil {
t.Fatalf("QueryDiagnostics() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.Reason != "ambiguous_source_code" || row.IdentifierMatchedPhones != 3 || len(row.MatchedSourceCodes) != 2 || len(row.SamplePhones) != 2 {
t.Fatalf("unexpected diagnostic row: %#v", row)
}
if row.SuggestedSourceKind != "UNKNOWN" || row.SuggestionConfidence != "HIGH" {
t.Fatalf("unexpected kind suggestion: %#v", row)
}
sqlText, _ := buildDataSourceDiagnosticsSQL(DataSourceDiagnosticsQuery{Protocol: "JT808", Limit: 10})
if !strings.Contains(sqlText, "ON ds.protocol = 'JT808'") || !strings.Contains(sqlText, "AND r.source_ip = ds.source_ip") {
t.Fatalf("diagnostics should join by indexed source_ip:\n%s", sqlText)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceDiagnosticsMappingIssueFilterUsesHaving(t *testing.T) {
missing := false
query := normalizeDataSourceDiagnosticsQuery(DataSourceDiagnosticsQuery{
Protocol: "JT808",
SourceCodeMissing: &missing,
MappingIssueOnly: true,
Limit: 10,
})
sqlText, args := buildDataSourceDiagnosticsSQL(query)
if !strings.Contains(sqlText, "HAVING") || !strings.Contains(sqlText, "identifier_match_ratio < 0.8") || !strings.Contains(sqlText, "configured_source_code_platform_name") {
t.Fatalf("mapping issue query should include aggregate HAVING:\n%s", sqlText)
}
if strings.Contains(sqlText, "ds.platform_name IS NOT NULL") {
t.Fatalf("mapping issue HAVING should use aggregate aliases, not table-qualified platform_name:\n%s", sqlText)
}
if len(args) != 4 {
t.Fatalf("mapping issue query args = %#v, want protocol/enabled/limit/offset", args)
}
countSQL, _ := buildDataSourceDiagnosticsCountSQL(query)
if !strings.Contains(countSQL, "FROM (") || !strings.Contains(countSQL, "HAVING") {
t.Fatalf("mapping issue count should wrap grouped diagnostics:\n%s", countSQL)
}
if strings.Contains(countSQL, "ds.platform_name IS NOT NULL") {
t.Fatalf("mapping issue count HAVING should use aggregate aliases, not table-qualified platform_name:\n%s", countSQL)
}
}
func TestDiagnoseDataSourceHighlightsConfiguredMappingIssues(t *testing.T) {
lowCoverage := DataSourceDiagnosticRow{
Protocol: "JT808",
PlatformName: "东方北斗",
SourceCode: "dongfang_beidou",
RegistrationRows: 388,
PhoneCount: 388,
IdentifierMatchedPhones: 8,
UnmappedPhoneCount: 380,
IdentifierMatchRatio: 0.0206,
ConfiguredSourceCodeMatchedPhones: 8,
ConfiguredSourceCodePlatformName: "东方北斗",
MatchedSourceCodeCount: 1,
MatchedSourceCodes: []string{"dongfang_beidou"},
}
lowCoverage.ConfiguredSourceCodeConflict = configuredSourceCodeConflict(lowCoverage)
lowCoverage.SourcePlatformNameMismatch = sourcePlatformNameMismatch(lowCoverage)
reason, _ := diagnoseDataSource(lowCoverage)
if reason != "low_identifier_coverage" {
t.Fatalf("reason = %q, want low_identifier_coverage", reason)
}
lowCoverageMismatch := lowCoverage
lowCoverageMismatch.PlatformName = "G7易流"
lowCoverageMismatch.SourcePlatformNameMismatch = sourcePlatformNameMismatch(lowCoverageMismatch)
reason, _ = diagnoseDataSource(lowCoverageMismatch)
if reason != "low_identifier_coverage" {
t.Fatalf("reason = %q, want low_identifier_coverage when coverage is weak even if platform name differs", reason)
}
nameMismatch := DataSourceDiagnosticRow{
Protocol: "JT808",
PlatformName: "G7易流",
SourceCode: "dongfang_beidou",
RegistrationRows: 388,
PhoneCount: 388,
IdentifierMatchedPhones: 8,
ConfiguredSourceCodePlatformName: "东方北斗",
MatchedSourceCodeCount: 1,
MatchedSourceCodes: []string{"dongfang_beidou"},
}
nameMismatch.SourcePlatformNameMismatch = sourcePlatformNameMismatch(nameMismatch)
reason, _ = diagnoseDataSource(nameMismatch)
if reason != "source_platform_name_mismatch" {
t.Fatalf("reason = %q, want source_platform_name_mismatch", reason)
}
conflict := DataSourceDiagnosticRow{
Protocol: "JT808",
SourceCode: "g7s",
RegistrationRows: 20,
PhoneCount: 20,
IdentifierMatchedPhones: 20,
IdentifierMatchRatio: 1,
ConfiguredSourceCodeMatchedPhones: 0,
MatchedSourceCodeCount: 1,
MatchedSourceCodes: []string{"dongfang_beidou"},
}
conflict.ConfiguredSourceCodeConflict = configuredSourceCodeConflict(conflict)
reason, _ = diagnoseDataSource(conflict)
if reason != "source_code_conflict" {
t.Fatalf("reason = %q, want source_code_conflict", reason)
}
}
func TestDataSourceHandlerReturnsSourcePage(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_data_source").
WithArgs("GB32960", 1).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(3))
mock.ExpectQuery(dataSourceSelectPattern).
WithArgs("GB32960", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind",
"trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at",
}).AddRow(
7, "GB32960", "8.134.95.166", "8.134.95.166:56432", "现代 HTWO", "HYUNDAI", "PLATFORM",
5, 1, "2026-07-11 18:27:10", "2026-07-12 01:15:52", "", "2026-07-12 01:15:52",
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources?protocol=gb32960&enabled=true&includeTotal=true", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"total":3`, `"source_ip":"8.134.95.166"`, `"platform_name":"现代 HTWO"`, `"source_code":"HYUNDAI"`, `"source_kind":"PLATFORM"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsDiagnosticsPage(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_data_source ds").
WithArgs("JT808", 1).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("JT808", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}).AddRow(
242190, "JT808", "117.132.194.31", "117.132.194.31:20471", nil, nil,
"UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800,
5, 4, 2,
0, 4, 0.0, 0, nil, 0, nil, nil, nil, nil, "13307795425",
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=jt808&includeTotal=true", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"total":1`, `"reason":"no_identifier_match"`, `"sample_phones":["13307795425"]`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsJT808IdentityGaps(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM jt808_registration r").
WithArgs("117.132.194.31", "guangan_beidou", 3600).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery("FROM jt808_registration r").
WithArgs("117.132.194.31", "guangan_beidou", 3600, 20, 0).
WillReturnRows(sqlmock.NewRows([]string{
"phone", "device_id", "plate", "vin", "source_ip", "source_endpoint",
"source_code", "platform_name", "source_kind",
"first_registered_at", "latest_registered_at", "latest_authenticated_at", "latest_seen_at", "latest_seen_age_seconds",
}).AddRow(
"13307795425", "", "沪A63305F", "unknown", "117.132.194.31", "117.132.194.31:20471",
"guangan_beidou", "广安北斗", "PLATFORM",
nil, nil, nil, "2026-07-12 22:24:53", 32,
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/jt808-identity-gaps?sourceIP=117.132.194.31&sourceCode=guangan_beidou&recentSeconds=3600&includeTotal=true&limit=20", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
`"total":1`,
`"phone":"13307795425"`,
`"reason":"missing_phone_and_plate_binding"`,
`"raw_frame_query_path":"/api/history/raw-frames?`,
`phone=13307795425`,
`dateFrom=2026-07-12+00%3A00%3A00`,
`"data_source_query_path":"/api/stats/data-sources?`,
`sourceIP=117.132.194.31`,
} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsJT808MappingGaps(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM jt808_registration r").
WithArgs("115.159.85.149", "dongfang_beidou", 3600).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery("FROM jt808_registration r").
WithArgs("115.159.85.149", "dongfang_beidou", 3600, 20, 0).
WillReturnRows(sqlmock.NewRows([]string{
"phone", "device_id", "plate", "vin", "source_ip", "source_endpoint",
"source_code", "platform_name", "source_kind", "identifier_vin", "identifier_plate",
"matched_source_codes", "matched_platform_names", "latest_seen_at", "latest_seen_age_seconds",
}).AddRow(
"64646848246", "", "粤AG18312", "LKLG7C4E8NA774778", "115.159.85.149", "115.159.85.149:16885",
"dongfang_beidou", "G7易流", "PLATFORM", nil, nil,
"g7s", "G7s", "2026-07-12 23:38:22", 32,
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/jt808-mapping-gaps?sourceIP=115.159.85.149&sourceCode=dongfang_beidou&recentSeconds=3600&includeTotal=true&limit=20", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
`"total":1`,
`"phone":"64646848246"`,
`"suggested_source_code":"dongfang_beidou"`,
`"suggested_identifier_type":"JT808_PHONE"`,
`"suggested_vin":"LKLG7C4E8NA774778"`,
`"reason":"missing_source_phone_identifier"`,
`"matched_source_codes":["g7s"]`,
`"raw_frame_query_path":"/api/history/raw-frames?`,
`phone=64646848246`,
`"data_source_query_path":"/api/stats/data-sources?`,
`sourceIP=115.159.85.149`,
`"vehicle_identifier_example":"protocol=JT808, source_code=dongfang_beidou, identifier_type=JT808_PHONE, identifier_value=64646848246, vin=LKLG7C4E8NA774778, plate=粤AG18312, oem=G7易流"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestJT808MappingGapSQLUsesConfiguredSourceIdentifier(t *testing.T) {
sqlText, args := buildJT808MappingGapSQL(normalizeJT808MappingGapQuery(JT808MappingGapQuery{
SourceIP: "115.159.85.149",
SourceCode: "dongfang_beidou",
RecentSeconds: 3600,
Limit: 20,
}))
for _, want := range []string{
"JOIN vehicle_data_source ds",
"source_vi.source_code = ds.source_code",
"source_vi.identifier_type = 'JT808_PHONE'",
"source_vi.identifier_value = r.phone",
"source_vi.identifier_value IS NULL OR source_vi.vin IS NULL",
} {
if !strings.Contains(sqlText, want) {
t.Fatalf("mapping gap SQL missing %q:\n%s", want, sqlText)
}
}
if len(args) != 5 {
t.Fatalf("args = %#v, want sourceIP/sourceCode/recent/limit/offset", args)
}
}
func TestDataSourceDiagnosticsCanQueryRetiredSources(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("JT808", 0, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=jt808&enabled=false", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
sqlText, args := buildDataSourceDiagnosticsSQL(normalizeDataSourceDiagnosticsQuery(DataSourceDiagnosticsQuery{Protocol: "JT808"}))
if !strings.Contains(sqlText, "ds.enabled = ?") {
t.Fatalf("diagnostics should default to enabled sources:\n%s", sqlText)
}
if len(args) < 2 || args[1] != 1 {
t.Fatalf("diagnostics enabled default args = %#v, want enabled=1 before limit", args)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsGenericDiagnosticsForNonJT808(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("GB32960", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}).AddRow(
7, "GB32960", "8.134.95.166", "8.134.95.166:32960", "现代 HTWO", nil,
"UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800,
0, 0, 0,
0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil,
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=GB32960", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"protocol":"GB32960"`, `"reason":"source_code_missing"`, `"suggested_source_kind":"PLATFORM"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsKindSuggestionsPage(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_data_source ds").
WithArgs("JT808", "UNKNOWN", 1).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("JT808", "UNKNOWN", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}).AddRow(
242190, "JT808", "117.132.194.31", "117.132.194.31:20471", "广安北斗", "guangan_beidou",
"UNKNOWN", "2026-07-12 00:00:00", "2026-07-12 00:05:00", 300, 7200,
0, 0, 0,
0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil,
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/kind-suggestions?protocol=jt808&includeTotal=true", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"total":1`, `"suggested_source_kind":"UNKNOWN"`, `"suggestion_confidence":"MEDIUM"`, `"suggestion_reason":"manual_source_without_registration_evidence"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsKindSuggestionsForNonJT808(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("GB32960", "UNKNOWN", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}).AddRow(
7, "GB32960", "8.134.95.166", "8.134.95.166:32960", "现代 HTWO", "HYUNDAI",
"UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800,
0, 0, 0,
0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil,
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/kind-suggestions?protocol=GB32960", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"reason":"source_configured"`, `"suggested_source_kind":"PLATFORM"`, `"suggestion_reason":"non_jt808_configured_source"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestSuggestSourceKindMarksStaleUnclassifiedSourceAsDirect(t *testing.T) {
kind, confidence, reason := suggestSourceKind(DataSourceDiagnosticRow{
SourceKind: "UNKNOWN",
Reason: "no_registration",
LatestSeenAgeSeconds: 25 * 3600,
})
if kind != "DIRECT" || confidence != "LOW" || reason != "stale_unclassified_source_without_registration" {
t.Fatalf("unexpected suggestion: kind=%s confidence=%s reason=%s", kind, confidence, reason)
}
}
func TestDataSourceHandlerPatchesOnlyManualFields(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT 1 FROM vehicle_data_source WHERE id = \\? LIMIT 1").
WithArgs(int64(3)).
WillReturnRows(sqlmock.NewRows([]string{"one"}).AddRow(1))
mock.ExpectExec("UPDATE vehicle_data_source SET platform_name = \\?, source_code = \\?, source_kind = \\?, trust_priority = \\?, enabled = \\?, remark = \\?, updated_at = CURRENT_TIMESTAMP WHERE id = \\?").
WithArgs("G7 平台", "G7S", "PLATFORM", 10, 0, "可信 808 来源", int64(3)).
WillReturnResult(sqlmock.NewResult(0, 0))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
body := strings.NewReader(`{"platform_name":"G7 平台","source_code":"G7S","source_kind":"platform","trust_priority":10,"enabled":false,"remark":"可信 808 来源","latest_seen_at":"2099-01-01 00:00:00"}`)
request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/3", body)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), `"updated":true`) {
t.Fatalf("patch response should confirm update: %s", response.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerRejectsConflictingSourceCodeFilters(t *testing.T) {
handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources?sourceCode=g7s&sourceCodeMissing=true", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "sourceCode") {
t.Fatalf("response should mention sourceCode: %s", response.Body.String())
}
}
func TestDataSourceHandlerRejectsInvalidSourceCode(t *testing.T) {
handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{"source_code":"G7 中文"}`))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "source_code") {
t.Fatalf("response should mention source_code: %s", response.Body.String())
}
}
func TestDataSourceHandlerRejectsInvalidSourceKind(t *testing.T) {
handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{"source_kind":"temporary"}`))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "source_kind") {
t.Fatalf("response should mention source_kind: %s", response.Body.String())
}
}
func TestDataSourceHandlerRejectsEmptyPatch(t *testing.T) {
handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{}`))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "no mutable fields") {
t.Fatalf("response should mention no mutable fields: %s", response.Body.String())
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,10 @@ const DailyMileageTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_mileage (
)`
var DailyMileageAlterSQL = []string{
"ALTER TABLE vehicle_data_source ADD COLUMN source_code VARCHAR(64) NULL AFTER platform_name",
"ALTER TABLE vehicle_data_source ADD COLUMN source_kind VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN' AFTER source_code",
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_code (protocol, source_code)",
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_kind_seen (protocol, source_kind, latest_seen_at)",
"ALTER TABLE vehicle_daily_mileage ADD COLUMN source_id BIGINT NULL AFTER protocol",
"ALTER TABLE vehicle_daily_mileage ADD KEY idx_source_id (source_id)",
"ALTER TABLE vehicle_daily_mileage DROP COLUMN first_total_mileage_km",
@@ -22,6 +26,10 @@ var DailyMileageAlterSQL = []string{
"ALTER TABLE vehicle_daily_mileage DROP COLUMN trusted_phone",
"ALTER TABLE vehicle_daily_mileage DROP COLUMN trusted_source_endpoint",
"ALTER TABLE vehicle_daily_mileage DROP COLUMN sample_count",
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_protocol_date_selected (protocol, stat_date, is_selected, vin)",
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_protocol_date_quality (protocol, stat_date, quality_status, vin)",
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_protocol_date_quality_reason (protocol, stat_date, quality_status, quality_reason, vin)",
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_source_ip_date (protocol, source_ip, stat_date)",
}
const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
@@ -30,6 +38,8 @@ const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
source_ip VARCHAR(64) NOT NULL,
latest_source_endpoint VARCHAR(128) NULL,
platform_name VARCHAR(128) NULL,
source_code VARCHAR(64) NULL,
source_kind VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN',
trust_priority INT NOT NULL DEFAULT 100,
enabled TINYINT(1) NOT NULL DEFAULT 1,
first_seen_at DATETIME NULL,
@@ -39,6 +49,8 @@ const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_protocol_source_ip (protocol, source_ip),
KEY idx_protocol_source_code (protocol, source_code),
KEY idx_protocol_source_kind_seen (protocol, source_kind, latest_seen_at),
KEY idx_protocol_enabled_priority (protocol, enabled, trust_priority)
)`
@@ -66,5 +78,9 @@ const DailyMileageSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_mil
PRIMARY KEY (vin, stat_date, protocol, source_key),
KEY idx_protocol_date (protocol, stat_date),
KEY idx_source_ip (protocol, source_ip),
KEY idx_selected (stat_date, protocol, is_selected)
KEY idx_selected (stat_date, protocol, is_selected),
KEY idx_protocol_date_selected (protocol, stat_date, is_selected, vin),
KEY idx_protocol_date_quality (protocol, stat_date, quality_status, vin),
KEY idx_protocol_date_quality_reason (protocol, stat_date, quality_status, quality_reason, vin),
KEY idx_source_ip_date (protocol, source_ip, stat_date)
)`

View File

@@ -12,6 +12,9 @@ type SourceIdentity struct {
Protocol envelope.Protocol
SourceIP string
SourceEndpoint string
SourceCode string
PlatformName string
SourceKind string
}
func NewSourceIdentity(protocol envelope.Protocol, endpoint string) (SourceIdentity, bool) {
@@ -26,15 +29,29 @@ func NewSourceIdentity(protocol envelope.Protocol, endpoint string) (SourceIdent
}, true
}
func NewSourceIdentityFromEnvelope(env envelope.FrameEnvelope) (SourceIdentity, bool) {
identity, ok := NewSourceIdentity(env.Protocol, env.SourceEndpoint)
if !ok {
return SourceIdentity{}, false
}
identity.SourceCode = strings.TrimSpace(env.SourceCode)
identity.PlatformName = strings.TrimSpace(env.PlatformName)
identity.SourceKind = normalizeSourceKindForRead(env.SourceKind)
return identity, true
}
func ShouldManageDataSource(identity SourceIdentity) bool {
if strings.TrimSpace(identity.SourceIP) == "" {
return false
}
if strings.TrimSpace(identity.SourceCode) != "" || strings.TrimSpace(identity.PlatformName) != "" {
return true
}
return normalizeSourceKindForRead(identity.SourceKind) == "PLATFORM"
}
func NormalizeSourceIP(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return ""
}
if host, _, ok := strings.Cut(endpoint, ":"); ok {
return strings.TrimSpace(host)
}
return endpoint
return envelope.NormalizeSourceEndpointKey(endpoint)
}
func UpsertDataSource(ctx context.Context, exec Execer, identity SourceIdentity, now time.Time) error {
@@ -51,18 +68,69 @@ func UpsertDataSource(ctx context.Context, exec Execer, identity SourceIdentity,
string(identity.Protocol),
identity.SourceIP,
identity.SourceEndpoint,
nullableTrimmedString(identity.PlatformName),
nullableTrimmedString(identity.SourceCode),
sourceKindForDataSourceWrite(identity),
now,
now,
)
return err
}
func sourceKindForDataSourceWrite(identity SourceIdentity) string {
kind := normalizeSourceKindForWrite(identity.SourceKind)
if kind != "UNKNOWN" {
return kind
}
if strings.TrimSpace(identity.SourceCode) != "" || strings.TrimSpace(identity.PlatformName) != "" {
return "PLATFORM"
}
return "UNKNOWN"
}
const upsertDataSourceSQL = `
INSERT INTO vehicle_data_source
(protocol, source_ip, latest_source_endpoint, first_seen_at, latest_seen_at)
VALUES (?, ?, ?, ?, ?)
(protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind, first_seen_at, latest_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
latest_source_endpoint = VALUES(latest_source_endpoint),
latest_seen_at = VALUES(latest_seen_at),
platform_name = CASE
WHEN vehicle_data_source.platform_name IS NULL OR TRIM(vehicle_data_source.platform_name) = ''
THEN VALUES(platform_name)
ELSE vehicle_data_source.platform_name
END,
source_code = CASE
WHEN vehicle_data_source.source_code IS NULL OR TRIM(vehicle_data_source.source_code) = ''
THEN VALUES(source_code)
ELSE vehicle_data_source.source_code
END,
source_kind = CASE
WHEN vehicle_data_source.source_kind IS NULL OR TRIM(vehicle_data_source.source_kind) = '' OR vehicle_data_source.source_kind = 'UNKNOWN'
THEN VALUES(source_kind)
ELSE vehicle_data_source.source_kind
END,
enabled = CASE
WHEN vehicle_data_source.enabled = 0
AND (vehicle_data_source.remark LIKE 'auto-retired:%' OR vehicle_data_source.remark = 'auto-reenabled: source evidence restored')
AND (
(VALUES(platform_name) IS NOT NULL AND TRIM(VALUES(platform_name)) <> '')
OR (VALUES(source_code) IS NOT NULL AND TRIM(VALUES(source_code)) <> '')
OR VALUES(source_kind) <> 'UNKNOWN'
)
THEN 1
ELSE vehicle_data_source.enabled
END,
remark = CASE
WHEN vehicle_data_source.enabled = 0
AND (vehicle_data_source.remark LIKE 'auto-retired:%' OR vehicle_data_source.remark = 'auto-reenabled: source evidence restored')
AND (
(VALUES(platform_name) IS NOT NULL AND TRIM(VALUES(platform_name)) <> '')
OR (VALUES(source_code) IS NOT NULL AND TRIM(VALUES(source_code)) <> '')
OR VALUES(source_kind) <> 'UNKNOWN'
)
THEN 'auto-reenabled: source evidence restored'
ELSE vehicle_data_source.remark
END,
latest_seen_at = GREATEST(COALESCE(latest_seen_at, VALUES(latest_seen_at)), VALUES(latest_seen_at)),
updated_at = CURRENT_TIMESTAMP
`

View File

@@ -10,10 +10,16 @@ import (
)
const (
QualityOK = "OK"
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
QualityInvalidDelta = "INVALID_DELTA"
maxSelectedDailyMileageKM = 1000
QualityOK = "OK"
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
QualityInvalidDelta = "INVALID_DELTA"
QualityReasonHistorical = "historical_source_baseline"
QualityReasonCurrentDayFirst = "current_day_first_baseline"
maxSelectedDailyMileageKM = 2500
maxSelectedDailyMileageKMSQL = "2500"
maxNegativeMileageJitterKMSQL = "1"
directSourceKeySuffix = "@DIRECT"
platformSourceKeyPrefix = "@PLATFORM:"
)
type SourceMileageSample struct {
@@ -37,14 +43,37 @@ type SourceMileageSample struct {
}
func SourceKey(protocol envelope.Protocol, phone string, deviceID string, sourceIP string) string {
identity, _ := sourceKeyIdentity(phone, deviceID)
return string(protocol) + ":" + identity + "@" + strings.TrimSpace(sourceIP)
}
func sourceKeyIdentity(phone string, deviceID string) (string, bool) {
identity := strings.TrimSpace(phone)
if identity == "" {
identity = strings.TrimSpace(deviceID)
}
if identity == "" {
identity = "unknown"
return "unknown", false
}
return string(protocol) + ":" + identity + "@" + strings.TrimSpace(sourceIP)
return identity, true
}
func SourceKeyForKind(protocol envelope.Protocol, phone string, deviceID string, sourceIP string, sourceKind string) string {
return SourceKeyForSource(protocol, phone, deviceID, sourceIP, sourceKind, "")
}
func SourceKeyForSource(protocol envelope.Protocol, phone string, deviceID string, sourceIP string, sourceKind string, sourceCode string) string {
identity, hasIdentity := sourceKeyIdentity(phone, deviceID)
if strings.EqualFold(strings.TrimSpace(sourceKind), "DIRECT") && hasIdentity {
return string(protocol) + ":" + identity + directSourceKeySuffix
}
if strings.EqualFold(strings.TrimSpace(sourceKind), "PLATFORM") && hasIdentity {
sourceCode = strings.TrimSpace(sourceCode)
if sourceCode != "" {
return string(protocol) + ":" + identity + platformSourceKeyPrefix + sourceCode
}
}
return SourceKey(protocol, phone, deviceID, sourceIP)
}
func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity) SourceMileageSample {
@@ -53,11 +82,12 @@ func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity)
VIN: sample.VIN,
StatDate: sample.StatDate,
Protocol: sample.Protocol,
SourceKey: SourceKey(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP),
SourceKey: SourceKeyForSource(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode),
SourceIP: identity.SourceIP,
SourceEndpoint: identity.SourceEndpoint,
Phone: sample.Phone,
DeviceID: sample.DeviceID,
PlatformName: firstNonEmpty(sample.PlatformName, identity.PlatformName),
FirstTotalKM: sample.TotalMileageKM,
LatestTotalKM: sample.TotalMileageKM,
DailyKM: 0,
@@ -69,6 +99,81 @@ func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity)
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func NormalizeDailyMileageDelta(deltaKM float64) (float64, bool, string) {
return NormalizeDailyMileageDeltaForWindow(deltaKM, time.Time{}, time.Time{})
}
// DailyMileageFromDayBoundary keeps the business formula explicit: the
// current day's latest cumulative odometer minus the nearest earlier
// cumulative odometer from the same source.
func DailyMileageFromDayBoundary(previousBaselineKM float64, currentDayLatestKM float64) float64 {
return currentDayLatestKM - previousBaselineKM
}
func NormalizeDailyMileageDeltaForWindow(deltaKM float64, firstEventTime time.Time, latestEventTime time.Time) (float64, bool, string) {
if deltaKM < 0 && deltaKM >= -maxNegativeMileageJitterKM {
return 0, true, "negative_jitter_clamped"
}
maxMileage := float64(MileageQualityLimitKM(firstEventTime, latestEventTime))
if deltaKM < 0 || deltaKM > maxMileage {
return deltaKM, false, "outside_daily_range"
}
return deltaKM, true, ""
}
func MileageQualityWindowDays(firstEventTime time.Time, latestEventTime time.Time) int {
if firstEventTime.IsZero() || latestEventTime.IsZero() || !latestEventTime.After(firstEventTime) {
return 1
}
location := latestEventTime.Location()
firstYear, firstMonth, firstDay := firstEventTime.In(location).Date()
latestYear, latestMonth, latestDay := latestEventTime.Date()
firstDate := time.Date(firstYear, firstMonth, firstDay, 0, 0, 0, 0, time.UTC)
latestDate := time.Date(latestYear, latestMonth, latestDay, 0, 0, 0, 0, time.UTC)
days := int(latestDate.Sub(firstDate) / (24 * time.Hour))
if days < 1 {
return 1
}
return days
}
func MileageQualityLimitKM(firstEventTime time.Time, latestEventTime time.Time) int {
// When the previous natural day is missing, the business baseline walks
// farther back. Scale the plausibility guard by that calendar-day gap while
// preserving the exact cumulative-odometer difference in the current row.
return maxSelectedDailyMileageKM * MileageQualityWindowDays(firstEventTime, latestEventTime)
}
func ApplyMileageQualityRules(sample *SourceMileageSample) {
if sample == nil {
return
}
if sample.QualityStatus == "" {
sample.QualityStatus = QualityOK
}
if sample.QualityStatus != QualityOK {
return
}
normalized, ok, reason := NormalizeDailyMileageDeltaForWindow(sample.DailyKM, sample.FirstEventTime, sample.LatestEventTime)
sample.DailyKM = normalized
if reason != "" {
sample.QualityReason = reason
}
if !ok {
sample.QualityStatus = QualityInvalidDelta
}
}
func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageSample) error {
if exec == nil {
panic("stats execer must not be nil")
@@ -79,6 +184,11 @@ func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageS
if sample.QualityStatus == "" {
sample.QualityStatus = QualityOK
}
// MySQL DATETIME(0) may round 23:59:59.xxx into the next natural day.
// Truncate protocol event timestamps before persistence so day boundaries
// remain stable and match the TDengine event_time predicate.
sample.FirstEventTime = truncateMileageEventTime(sample.FirstEventTime)
sample.LatestEventTime = truncateMileageEventTime(sample.LatestEventTime)
_, err := exec.ExecContext(ctx, upsertSourceMileageSQL,
sample.VIN,
sample.StatDate,
@@ -101,6 +211,75 @@ func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageS
return err
}
func truncateMileageEventTime(value time.Time) time.Time {
if value.IsZero() {
return value
}
return value.Truncate(time.Second)
}
func NormalizePlatformSourceMileage(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error {
if exec == nil {
panic("stats execer must not be nil")
}
if strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" {
return nil
}
_, err := exec.ExecContext(ctx, normalizePlatformSourceMileageInsertSQL, vin, statDate, string(protocol))
if err != nil {
return err
}
_, err = exec.ExecContext(ctx, normalizePlatformSourceMileageDeleteSQL, vin, statDate, string(protocol))
return err
}
func NormalizePlatformSourceMileageForDate(ctx context.Context, db interface {
Execer
Queryer
}, statDate string, protocol envelope.Protocol) (int, error) {
if db == nil {
panic("stats db must not be nil")
}
if strings.TrimSpace(statDate) == "" {
return 0, nil
}
rows, err := db.QueryContext(ctx, selectPlatformSourceMileageLegacyVINSQL, statDate, string(protocol))
if err != nil {
return 0, err
}
defer rows.Close()
var vins []string
for rows.Next() {
var vin string
if err := rows.Scan(&vin); err != nil {
return 0, err
}
if strings.TrimSpace(vin) != "" {
vins = append(vins, vin)
}
}
if err := rows.Err(); err != nil {
return 0, err
}
normalized := 0
for _, vin := range vins {
if err := NormalizePlatformSourceMileage(ctx, db, vin, statDate, protocol); err != nil {
return normalized, err
}
if err := ProjectDailyMileage(ctx, db, vin, statDate, protocol); err != nil {
return normalized, err
}
normalized++
}
return normalized, nil
}
func ShouldNormalizePlatformSourceMileage(sample SourceMileageSample) bool {
return strings.Contains(sample.SourceKey, platformSourceKeyPrefix)
}
func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error {
if exec == nil {
panic("stats execer must not be nil")
@@ -108,9 +287,25 @@ func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate
if strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" {
return nil
}
if _, err := exec.ExecContext(ctx, clearSelectedSourceSQL, vin, statDate, string(protocol)); err != nil {
return err
if beginner, ok := exec.(txBeginner); ok {
tx, err := beginner.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := projectDailyMileageWithExec(ctx, tx, vin, statDate, protocol); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
return projectDailyMileageWithExec(ctx, exec, vin, statDate, protocol)
}
type txBeginner interface {
BeginTx(context.Context, *sql.TxOptions) (*sql.Tx, error)
}
func projectDailyMileageWithExec(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error {
if _, err := exec.ExecContext(ctx, projectDailyMileageSQL,
vin,
statDate,
@@ -142,6 +337,70 @@ func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate
return err
}
const upsertSourceMergedFirstTotalSQL = `CASE
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
THEN VALUES(first_total_mileage_km)
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time
THEN VALUES(first_total_mileage_km)
ELSE first_total_mileage_km
END`
const upsertSourceMergedLatestTotalSQL = `CASE
WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0
THEN VALUES(latest_total_mileage_km)
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time
THEN VALUES(latest_total_mileage_km)
ELSE latest_total_mileage_km
END`
const upsertSourceMergedFirstEventSQL = `CASE
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time
THEN VALUES(first_event_time)
ELSE first_event_time
END`
const upsertSourceMergedLatestEventSQL = `CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time
THEN VALUES(latest_event_time)
ELSE latest_event_time
END`
const upsertSourceMergedDeltaSQL = `(` + upsertSourceMergedLatestTotalSQL + ` - ` + upsertSourceMergedFirstTotalSQL + `)`
const upsertSourceMergedDailySQL = `CASE
WHEN ` + upsertSourceMergedDeltaSQL + ` < 0
AND ` + upsertSourceMergedDeltaSQL + ` >= -` + maxNegativeMileageJitterKMSQL + `
THEN 0
ELSE ` + upsertSourceMergedDeltaSQL + `
END`
const upsertSourceMergedOutsideDailyRangeSQL = `(` + upsertSourceMergedDailySQL + ` < 0 OR ` + upsertSourceMergedDailySQL + ` > ` + upsertSourceMergedMaxMileageSQL + `)`
const upsertSourceMergedMissingPreviousBaselineSQL = `(` + upsertSourceMergedFirstEventSQL + ` >= CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME))`
const upsertSourceMergedQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(` + upsertSourceMergedLatestEventSQL + `), DATE(` + upsertSourceMergedFirstEventSQL + `)))`
const upsertSourceMergedMaxMileageSQL = `(` + upsertSourceMergedQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
const normalizePlatformFirstTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_total_mileage_km ORDER BY s.first_event_time ASC, s.updated_at ASC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
const normalizePlatformLatestTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_total_mileage_km ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
const normalizePlatformDeltaSQL = `(` + normalizePlatformLatestTotalSQL + ` - ` + normalizePlatformFirstTotalSQL + `)`
const normalizePlatformDailySQL = `CASE
WHEN ` + normalizePlatformDeltaSQL + ` < 0
AND ` + normalizePlatformDeltaSQL + ` >= -` + maxNegativeMileageJitterKMSQL + `
THEN 0
ELSE ` + normalizePlatformDeltaSQL + `
END`
const normalizePlatformQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(MAX(s.latest_event_time)), DATE(MIN(s.first_event_time))))`
const normalizePlatformMaxMileageSQL = `(` + normalizePlatformQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
const normalizePlatformOutsideDailyRangeSQL = `(` + normalizePlatformDailySQL + ` < 0 OR ` + normalizePlatformDailySQL + ` > ` + normalizePlatformMaxMileageSQL + `)`
const upsertSourceMileageSQL = `
INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name,
@@ -154,48 +413,186 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
phone = VALUES(phone),
device_id = VALUES(device_id),
platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name),
first_total_mileage_km = CASE
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
THEN VALUES(first_total_mileage_km)
ELSE LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))
END,
latest_total_mileage_km = CASE
WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0
THEN VALUES(latest_total_mileage_km)
ELSE GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))
END,
daily_mileage_km = GREATEST(
CASE
WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0
THEN VALUES(latest_total_mileage_km)
ELSE latest_total_mileage_km
END,
VALUES(latest_total_mileage_km)
) - CASE
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
THEN VALUES(first_total_mileage_km)
ELSE LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))
END,
first_total_mileage_km = ` + upsertSourceMergedFirstTotalSQL + `,
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
sample_count = sample_count + VALUES(sample_count),
first_event_time = CASE
WHEN first_event_time IS NULL OR VALUES(first_event_time) < first_event_time
THEN VALUES(first_event_time)
ELSE first_event_time
first_event_time = ` + upsertSourceMergedFirstEventSQL + `,
latest_event_time = ` + upsertSourceMergedLatestEventSQL + `,
quality_status = CASE
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
ELSE '` + QualityOK + `'
END,
latest_event_time = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) > latest_event_time
THEN VALUES(latest_event_time)
ELSE latest_event_time
quality_reason = CASE
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
WHEN ` + upsertSourceMergedDailySQL + ` = 0 AND ` + upsertSourceMergedDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
WHEN ` + upsertSourceMergedMissingPreviousBaselineSQL + ` THEN '` + QualityReasonCurrentDayFirst + `'
ELSE '` + QualityReasonHistorical + `'
END,
quality_status = VALUES(quality_status),
quality_reason = VALUES(quality_reason),
updated_at = CURRENT_TIMESTAMP
`
const clearSelectedSourceSQL = `
UPDATE vehicle_daily_mileage_source
SET is_selected = 0
WHERE vin = ? AND stat_date = ? AND protocol = ?
const normalizePlatformSourceMileageInsertSQL = `
INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name,
first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count,
first_event_time, latest_event_time, quality_status, quality_reason)
SELECT
s.vin,
s.stat_date,
s.protocol,
CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code)) AS stable_source_key,
SUBSTRING_INDEX(GROUP_CONCAT(s.source_ip ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS source_ip,
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.source_endpoint, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS source_endpoint,
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.phone, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS phone,
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.device_id, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS device_id,
COALESCE(NULLIF(TRIM(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(TRIM(s.platform_name), ''), ds.platform_name, vi.platform_name, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1)), ''), MAX(TRIM(COALESCE(ds.platform_name, vi.platform_name)))) AS platform_name,
` + normalizePlatformFirstTotalSQL + ` AS first_total_mileage_km,
` + normalizePlatformLatestTotalSQL + ` AS latest_total_mileage_km,
` + normalizePlatformDailySQL + ` AS daily_mileage_km,
SUM(s.sample_count) AS sample_count,
MIN(s.first_event_time) AS first_event_time,
MAX(s.latest_event_time) AS latest_event_time,
CASE
WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
ELSE '` + QualityOK + `'
END AS quality_status,
CASE
WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
WHEN ` + normalizePlatformDailySQL + ` = 0 AND ` + normalizePlatformDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)
THEN '` + QualityReasonHistorical + `'
ELSE '` + QualityReasonCurrentDayFirst + `'
END AS quality_reason
FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
AND ds.enabled = 1
AND ds.source_kind = 'PLATFORM'
AND ds.source_code IS NOT NULL
AND TRIM(ds.source_code) <> ''
LEFT JOIN (
SELECT
identifier_value AS phone,
MIN(TRIM(source_code)) AS source_code,
MIN(COALESCE(NULLIF(TRIM(oem), ''), NULLIF(TRIM(source_code), ''))) AS platform_name
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND identifier_type = 'JT808_PHONE'
AND enabled = 1
AND source_code IS NOT NULL
AND TRIM(source_code) <> ''
GROUP BY identifier_value
HAVING COUNT(DISTINCT TRIM(source_code)) = 1
) vi
ON s.protocol = 'JT808' AND vi.phone = TRIM(s.phone)
WHERE s.vin = ?
AND s.stat_date = ?
AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
GROUP BY s.vin, s.stat_date, s.protocol, stable_source_key
ON DUPLICATE KEY UPDATE
source_ip = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(source_ip)
ELSE source_ip
END,
source_endpoint = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(source_endpoint)
ELSE source_endpoint
END,
phone = COALESCE(NULLIF(TRIM(VALUES(phone)), ''), phone),
device_id = COALESCE(NULLIF(TRIM(VALUES(device_id)), ''), device_id),
platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name),
first_total_mileage_km = ` + upsertSourceMergedFirstTotalSQL + `,
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
sample_count = sample_count + VALUES(sample_count),
first_event_time = CASE
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time THEN VALUES(first_event_time)
ELSE first_event_time
END,
latest_event_time = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(latest_event_time)
ELSE latest_event_time
END,
quality_status = CASE
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
ELSE '` + QualityOK + `'
END,
quality_reason = CASE
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
WHEN ` + upsertSourceMergedDailySQL + ` = 0 AND ` + upsertSourceMergedDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
WHEN ` + upsertSourceMergedMissingPreviousBaselineSQL + ` THEN '` + QualityReasonCurrentDayFirst + `'
ELSE '` + QualityReasonHistorical + `'
END,
updated_at = CURRENT_TIMESTAMP
`
const normalizePlatformSourceMileageDeleteSQL = `
DELETE s
FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
AND ds.enabled = 1
AND ds.source_kind = 'PLATFORM'
AND ds.source_code IS NOT NULL
AND TRIM(ds.source_code) <> ''
LEFT JOIN (
SELECT
identifier_value AS phone,
MIN(TRIM(source_code)) AS source_code
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND identifier_type = 'JT808_PHONE'
AND enabled = 1
AND source_code IS NOT NULL
AND TRIM(source_code) <> ''
GROUP BY identifier_value
HAVING COUNT(DISTINCT TRIM(source_code)) = 1
) vi
ON s.protocol = 'JT808' AND vi.phone = TRIM(s.phone)
WHERE s.vin = ?
AND s.stat_date = ?
AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
`
const selectPlatformSourceMileageLegacyVINSQL = `
SELECT DISTINCT s.vin
FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
AND ds.enabled = 1
AND ds.source_kind = 'PLATFORM'
AND ds.source_code IS NOT NULL
AND TRIM(ds.source_code) <> ''
LEFT JOIN (
SELECT
identifier_value AS phone,
MIN(TRIM(source_code)) AS source_code
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND identifier_type = 'JT808_PHONE'
AND enabled = 1
AND source_code IS NOT NULL
AND TRIM(source_code) <> ''
GROUP BY identifier_value
HAVING COUNT(DISTINCT TRIM(source_code)) = 1
) vi
ON s.protocol = 'JT808' AND vi.phone = TRIM(s.phone)
WHERE s.stat_date = ?
AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
ORDER BY s.vin
`
const projectDailyMileageSQL = `
@@ -209,15 +606,25 @@ SELECT
s.daily_mileage_km,
s.latest_total_mileage_km
FROM vehicle_daily_mileage_source s
JOIN vehicle_data_source ds
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
WHERE s.vin = ?
AND s.stat_date = ?
AND s.protocol = ?
AND s.quality_status = '` + QualityOK + `'
AND s.daily_mileage_km BETWEEN 0 AND ?
AND ds.enabled = 1
ORDER BY ds.trust_priority,
AND s.daily_mileage_km BETWEEN 0 AND (? * GREATEST(1, DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time))))
AND (ds.id IS NULL OR ds.enabled = 1 OR (
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
))
ORDER BY CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN s.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END)
WHEN 'PLATFORM' THEN 0
WHEN 'DIRECT' THEN 1
WHEN 'UNKNOWN' THEN 2
ELSE 3
END,
COALESCE(ds.trust_priority, 100000),
s.sample_count DESC,
s.latest_event_time DESC,
s.source_key ASC
@@ -231,22 +638,32 @@ ON DUPLICATE KEY UPDATE
const markSelectedSourceSQL = `
UPDATE vehicle_daily_mileage_source s
JOIN (
LEFT JOIN (
SELECT
s2.source_key,
s2.vin,
s2.stat_date,
s2.protocol
FROM vehicle_daily_mileage_source s2
JOIN vehicle_data_source ds
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s2.protocol AND ds.source_ip = s2.source_ip
WHERE s2.vin = ?
AND s2.stat_date = ?
AND s2.protocol = ?
AND s2.quality_status = '` + QualityOK + `'
AND s2.daily_mileage_km BETWEEN 0 AND ?
AND ds.enabled = 1
ORDER BY ds.trust_priority,
AND s2.daily_mileage_km BETWEEN 0 AND (? * GREATEST(1, DATEDIFF(DATE(s2.latest_event_time), DATE(s2.first_event_time))))
AND (ds.id IS NULL OR ds.enabled = 1 OR (
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
))
ORDER BY CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END)
WHEN 'PLATFORM' THEN 0
WHEN 'DIRECT' THEN 1
WHEN 'UNKNOWN' THEN 2
ELSE 3
END,
COALESCE(ds.trust_priority, 100000),
s2.sample_count DESC,
s2.latest_event_time DESC,
s2.source_key ASC
@@ -256,7 +673,7 @@ JOIN (
AND selected_source.vin = s.vin
AND selected_source.stat_date = s.stat_date
AND selected_source.protocol = s.protocol
SET s.is_selected = 1
SET s.is_selected = CASE WHEN selected_source.source_key IS NULL THEN 0 ELSE 1 END
WHERE s.vin = ? AND s.stat_date = ? AND s.protocol = ?
`
@@ -302,60 +719,30 @@ func lookupPreviousSourceBaseline(ctx context.Context, query Queryer, vin string
return sourceBaseline{
LatestTotalKM: latestTotal.Float64,
LatestEventTime: latestEvent.Time,
QualityReason: "historical_source_baseline",
QualityReason: QualityReasonHistorical,
}, latestTotal.Valid, nil
}
func lookupCurrentSourceBaseline(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol, sourceKey string) (sourceBaseline, bool, error) {
if query == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" || strings.TrimSpace(sourceKey) == "" {
return sourceBaseline{}, false, nil
}
rows, err := query.QueryContext(ctx, currentSourceBaselineSQL, vin, statDate, string(protocol), sourceKey)
if err != nil {
return sourceBaseline{}, false, err
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return sourceBaseline{}, false, err
}
return sourceBaseline{}, false, nil
}
var firstTotal sql.NullFloat64
var firstEvent sql.NullTime
if err := rows.Scan(&firstTotal, &firstEvent); err != nil {
return sourceBaseline{}, false, err
}
if err := rows.Err(); err != nil {
return sourceBaseline{}, false, err
}
return sourceBaseline{
LatestTotalKM: firstTotal.Float64,
LatestEventTime: firstEvent.Time,
QualityReason: "current_day_first_sample",
}, firstTotal.Valid, nil
// LookupLatestSourceBaselineBefore returns the nearest durable odometer for the
// same VIN, protocol and source before statDate. Missing calendar days are
// skipped automatically.
func LookupLatestSourceBaselineBefore(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol, sourceKey string) (float64, time.Time, bool, error) {
baseline, found, err := lookupPreviousSourceBaseline(ctx, query, vin, statDate, protocol, sourceKey)
return baseline.LatestTotalKM, baseline.LatestEventTime, found, err
}
const previousSourceBaselineSQL = `
SELECT latest_total_mileage_km, latest_event_time
FROM vehicle_daily_mileage_source
WHERE vin = ?
AND stat_date < ?
AND stat_date < ?
AND protocol = ?
AND source_key = ?
AND quality_status = '` + QualityOK + `'
AND latest_total_mileage_km IS NOT NULL
AND latest_total_mileage_km > 0
AND latest_event_time IS NOT NULL
AND latest_event_time >= CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME)
AND latest_event_time < DATE_ADD(CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME), INTERVAL 1 DAY)
ORDER BY stat_date DESC, latest_event_time DESC
LIMIT 1
`
const currentSourceBaselineSQL = `
SELECT first_total_mileage_km, first_event_time
FROM vehicle_daily_mileage_source
WHERE vin = ?
AND stat_date = ?
AND protocol = ?
AND source_key = ?
AND quality_status = '` + QualityOK + `'
ORDER BY latest_event_time DESC
LIMIT 1
`

View File

@@ -2,10 +2,13 @@ package stats
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
@@ -21,6 +24,85 @@ func TestSourceKeyUsesProtocolDeviceAndSourceIP(t *testing.T) {
}
}
func TestSourceKeyForDirectUsesStableDeviceIdentity(t *testing.T) {
first := SourceKeyForKind(envelope.ProtocolJT808, "13307765812", "", "115.231.168.135", "DIRECT")
second := SourceKeyForKind(envelope.ProtocolJT808, "13307765812", "", "39.144.3.22", "DIRECT")
if first != "JT808:13307765812@DIRECT" || second != first {
t.Fatalf("direct source keys = %q/%q, want stable phone key", first, second)
}
fallback := SourceKeyForKind(envelope.ProtocolJT808, "", "", "39.144.3.22", "DIRECT")
if fallback != "JT808:unknown@39.144.3.22" {
t.Fatalf("direct fallback source key = %q", fallback)
}
}
func TestSourceKeyForPlatformUsesStableSourceCode(t *testing.T) {
first := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.194.167", "PLATFORM", "guangan_beidou")
second := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.198.90", "PLATFORM", "guangan_beidou")
if first != "JT808:41456413943@PLATFORM:guangan_beidou" || second != first {
t.Fatalf("platform source keys = %q/%q, want stable source_code key", first, second)
}
withoutSourceCode := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.198.90", "PLATFORM", "")
if withoutSourceCode != "JT808:41456413943@117.132.198.90" {
t.Fatalf("platform fallback source key = %q", withoutSourceCode)
}
withoutDeviceIdentity := SourceKeyForSource(envelope.ProtocolJT808, "", "", "117.132.198.90", "PLATFORM", "guangan_beidou")
if withoutDeviceIdentity != "JT808:unknown@117.132.198.90" {
t.Fatalf("unknown platform source key = %q", withoutDeviceIdentity)
}
}
func TestSourceMileageSampleFromMetricUsesDirectSourceKey(t *testing.T) {
sample := MetricSample{
VIN: "LA9GG64L7PBAF4001",
StatDate: "2026-07-12",
Protocol: envelope.ProtocolJT808,
Phone: "13307765812",
TotalMileageKM: 4123.9,
EventTime: time.Date(2026, 7, 12, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
}
candidate := SourceMileageSampleFromMetric(sample, SourceIdentity{
Protocol: envelope.ProtocolJT808,
SourceIP: "115.231.168.135",
SourceEndpoint: "115.231.168.135:43625",
SourceKind: "DIRECT",
})
if candidate.SourceKey != "JT808:13307765812@DIRECT" {
t.Fatalf("source key = %q", candidate.SourceKey)
}
if candidate.SourceIP != "115.231.168.135" {
t.Fatalf("source ip = %q", candidate.SourceIP)
}
}
func TestSourceMileageSampleFromMetricUsesPlatformSourceCode(t *testing.T) {
sample := MetricSample{
VIN: "LNXNEGRR0SR321372",
StatDate: "2026-07-12",
Protocol: envelope.ProtocolJT808,
Phone: "41456413943",
TotalMileageKM: 46484.2,
EventTime: time.Date(2026, 7, 12, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
}
candidate := SourceMileageSampleFromMetric(sample, SourceIdentity{
Protocol: envelope.ProtocolJT808,
SourceIP: "117.132.194.167",
SourceEndpoint: "117.132.194.167:9806",
SourceCode: "guangan_beidou",
PlatformName: "广安北斗",
SourceKind: "PLATFORM",
})
if candidate.SourceKey != "JT808:41456413943@PLATFORM:guangan_beidou" {
t.Fatalf("source key = %q", candidate.SourceKey)
}
if candidate.PlatformName != "广安北斗" {
t.Fatalf("platform name = %q", candidate.PlatformName)
}
}
func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) {
exec := &recordingExec{}
sample := SourceMileageSample{
@@ -50,8 +132,13 @@ func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) {
for _, want := range []string{
"INSERT INTO vehicle_daily_mileage_source",
"ON DUPLICATE KEY UPDATE",
"daily_mileage_km = GREATEST(",
"quality_status = VALUES(quality_status)",
"daily_mileage_km = CASE",
"VALUES(latest_event_time) >= latest_event_time",
"VALUES(first_event_time) <= first_event_time",
"quality_status = CASE",
"THEN '" + QualityInvalidDelta + "'",
"quality_reason = CASE",
"THEN 'outside_daily_range'",
"platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name)",
} {
if !strings.Contains(sql, want) {
@@ -63,6 +150,114 @@ func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) {
}
}
func TestUpsertSourceMileageTruncatesSubsecondEventTimesAtDayBoundary(t *testing.T) {
exec := &recordingExec{}
loc := time.FixedZone("Asia/Shanghai", 8*3600)
previous := time.Date(2026, 7, 12, 23, 59, 59, 999_000_000, loc)
current := time.Date(2026, 7, 13, 0, 0, 1, 999_000_000, loc)
sample := SourceMileageSample{
VIN: "LMRKH9AC7R1004098",
StatDate: "2026-07-13",
Protocol: envelope.ProtocolYutongMQTT,
SourceKey: "YUTONG_MQTT:LMRKH9AC7R1004098@PLATFORM:yutong",
SourceIP: "mqtt",
FirstTotalKM: 41249,
LatestTotalKM: 41250,
DailyKM: 1,
SampleCount: 1,
FirstEventTime: previous,
LatestEventTime: current,
QualityStatus: QualityOK,
QualityReason: "historical_source_baseline",
}
if err := UpsertSourceMileage(context.Background(), exec, sample); err != nil {
t.Fatalf("UpsertSourceMileage() error = %v", err)
}
if len(exec.calls) != 1 {
t.Fatalf("exec calls = %d, want 1", len(exec.calls))
}
if got := exec.calls[0].args[13]; got != previous.Truncate(time.Second) {
t.Fatalf("first event arg = %v, want %v", got, previous.Truncate(time.Second))
}
if got := exec.calls[0].args[14]; got != current.Truncate(time.Second) {
t.Fatalf("latest event arg = %v, want %v", got, current.Truncate(time.Second))
}
}
func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) {
for _, want := range []string{
"first_total_mileage_km = CASE",
"VALUES(first_event_time) <= first_event_time",
"latest_total_mileage_km = CASE",
"VALUES(latest_event_time) >= latest_event_time",
"daily_mileage_km = CASE",
"VALUES(latest_total_mileage_km)",
"VALUES(first_total_mileage_km)",
} {
if !strings.Contains(upsertSourceMileageSQL, want) {
t.Fatalf("event-time boundary upsert SQL missing %q:\n%s", want, upsertSourceMileageSQL)
}
}
for _, forbidden := range []string{
"GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))",
"LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))",
"current_day_fallback_after_invalid_baseline",
} {
if strings.Contains(upsertSourceMileageSQL, forbidden) {
t.Fatalf("event-time boundary upsert must not contain %q:\n%s", forbidden, upsertSourceMileageSQL)
}
}
}
func TestUpsertSourceMileageRechecksMergedDailyRange(t *testing.T) {
if maxSelectedDailyMileageKMSQL != "2500" || maxNegativeMileageJitterKMSQL != "1" {
t.Fatalf("SQL mileage limits drifted from Go quality constants: selected=%s jitter=%s", maxSelectedDailyMileageKMSQL, maxNegativeMileageJitterKMSQL)
}
for _, want := range []string{
"quality_status = CASE",
">= -" + maxNegativeMileageJitterKMSQL,
"DATEDIFF(DATE(",
"* " + maxSelectedDailyMileageKMSQL,
"THEN '" + QualityInvalidDelta + "'",
"quality_reason = CASE",
"THEN 'outside_daily_range'",
"THEN '" + QualityReasonCurrentDayFirst + "'",
"ELSE '" + QualityReasonHistorical + "'",
} {
if !strings.Contains(upsertSourceMileageSQL, want) {
t.Fatalf("merged range guard missing %q:\n%s", want, upsertSourceMileageSQL)
}
}
}
func TestPreviousSourceBaselineUsesLatestEarlierCalendarDay(t *testing.T) {
if !strings.Contains(previousSourceBaselineSQL, "stat_date < ?") {
t.Fatalf("previous baseline should search earlier calendar days:\n%s", previousSourceBaselineSQL)
}
if !strings.Contains(previousSourceBaselineSQL, "ORDER BY stat_date DESC, latest_event_time DESC") {
t.Fatalf("previous baseline should prefer the nearest earlier sample:\n%s", previousSourceBaselineSQL)
}
if strings.Contains(previousSourceBaselineSQL, "quality_status =") {
t.Fatalf("previous day's last odometer must remain usable even when that day's delta has no baseline:\n%s", previousSourceBaselineSQL)
}
for _, want := range []string{
"latest_total_mileage_km > 0",
"latest_event_time >= CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME)",
"latest_event_time < DATE_ADD(CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME), INTERVAL 1 DAY)",
} {
if !strings.Contains(previousSourceBaselineSQL, want) {
t.Fatalf("previous baseline SQL missing %q:\n%s", want, previousSourceBaselineSQL)
}
}
}
func TestDailyMileageFromDayBoundaryUsesCurrentMinusPrevious(t *testing.T) {
got := DailyMileageFromDayBoundary(14989.0, 15369.0)
if got != 380.0 {
t.Fatalf("daily mileage = %v, want current latest minus previous last = 380", got)
}
}
func TestUpsertSourceMileageSkipsBlankSourceIP(t *testing.T) {
exec := &recordingExec{}
sample := SourceMileageSample{
@@ -82,30 +277,227 @@ func TestUpsertSourceMileageSkipsBlankSourceIP(t *testing.T) {
}
}
func TestNormalizePlatformSourceMileageMergesLegacyIPKeys(t *testing.T) {
exec := &recordingExec{}
err := NormalizePlatformSourceMileage(context.Background(), exec, "LNXNEGRR0SR321372", "2026-07-12", envelope.ProtocolJT808)
if err != nil {
t.Fatalf("NormalizePlatformSourceMileage() error = %v", err)
}
if len(exec.calls) != 2 {
t.Fatalf("exec calls = %d, want insert merge + delete legacy", len(exec.calls))
}
insertSQL := exec.calls[0].query
for _, want := range []string{
"INSERT INTO vehicle_daily_mileage_source",
"CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '@PLATFORM:', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))",
"LEFT JOIN vehicle_data_source ds",
"LEFT JOIN (",
"FROM vehicle_identifier",
"identifier_type = 'JT808_PHONE'",
"HAVING COUNT(DISTINCT TRIM(source_code)) = 1",
"ds.source_kind = 'PLATFORM'",
"MAX(TRIM(COALESCE(ds.platform_name, vi.platform_name)))",
"s.source_key <> CONCAT",
"GROUP BY s.vin, s.stat_date, s.protocol, stable_source_key",
"ON DUPLICATE KEY UPDATE",
"sample_count = sample_count + VALUES(sample_count)",
"s.quality_status IN ('" + QualityOK + "', '" + QualityNoPreviousBaseline + "')",
"WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)",
"THEN '" + QualityInvalidDelta + "'",
"ELSE '" + QualityOK + "'",
"THEN 'negative_jitter_clamped'",
"THEN '" + QualityReasonHistorical + "'",
"ELSE '" + QualityReasonCurrentDayFirst + "'",
upsertSourceMergedMissingPreviousBaselineSQL,
} {
if !strings.Contains(insertSQL, want) {
t.Fatalf("normalize insert SQL missing %q:\n%s", want, insertSQL)
}
}
deleteSQL := exec.calls[1].query
for _, want := range []string{
"DELETE s",
"FROM vehicle_daily_mileage_source s",
"LEFT JOIN vehicle_data_source ds",
"FROM vehicle_identifier",
"ds.source_kind = 'PLATFORM'",
"s.source_key <> CONCAT",
} {
if !strings.Contains(deleteSQL, want) {
t.Fatalf("normalize delete SQL missing %q:\n%s", want, deleteSQL)
}
}
for i, call := range exec.calls {
if len(call.args) != 3 || call.args[0] != "LNXNEGRR0SR321372" || call.args[1] != "2026-07-12" || call.args[2] != "JT808" {
t.Fatalf("call %d args = %#v", i, call.args)
}
}
}
func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
statDate := "2026-07-12"
protocol := envelope.ProtocolJT808
rows := sqlmock.NewRows([]string{"vin"}).
AddRow("LA9HE60A0PBAF4002").
AddRow("LA9HE60A1PBAF4008")
mock.ExpectQuery(selectPlatformSourceMileageLegacyVINSQL).
WithArgs(statDate, string(protocol)).
WillReturnRows(rows)
for _, vin := range []string{"LA9HE60A0PBAF4002", "LA9HE60A1PBAF4008"} {
mock.ExpectExec(normalizePlatformSourceMileageInsertSQL).
WithArgs(vin, statDate, string(protocol)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(normalizePlatformSourceMileageDeleteSQL).
WithArgs(vin, statDate, string(protocol)).
WillReturnResult(sqlmock.NewResult(0, 1))
expectProjectDailyMileageSQL(mock, vin, statDate, protocol)
mock.ExpectCommit()
}
normalized, err := NormalizePlatformSourceMileageForDate(context.Background(), db, statDate, protocol)
if err != nil {
t.Fatalf("NormalizePlatformSourceMileageForDate() error = %v", err)
}
if normalized != 2 {
t.Fatalf("normalized = %d, want 2", normalized)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations were not met: %v", err)
}
}
func TestShouldNormalizePlatformSourceMileage(t *testing.T) {
if !ShouldNormalizePlatformSourceMileage(SourceMileageSample{SourceKey: "JT808:41456413943@PLATFORM:guangan_beidou"}) {
t.Fatal("platform source key should trigger legacy key normalization")
}
for _, sourceKey := range []string{"JT808:41456413943@DIRECT", "JT808:41456413943@117.132.194.167", ""} {
if ShouldNormalizePlatformSourceMileage(SourceMileageSample{SourceKey: sourceKey}) {
t.Fatalf("source key %q should not trigger platform normalization", sourceKey)
}
}
}
func TestApplyMileageQualityRulesClampsSmallNegativeJitter(t *testing.T) {
sample := SourceMileageSample{
DailyKM: -0.1,
QualityStatus: QualityOK,
QualityReason: "historical_source_baseline",
}
ApplyMileageQualityRules(&sample)
if sample.DailyKM != 0 {
t.Fatalf("daily km = %v, want 0", sample.DailyKM)
}
if sample.QualityStatus != QualityOK || sample.QualityReason != "negative_jitter_clamped" {
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
}
}
func TestApplyMileageQualityRulesRejectsLargeNegativeDelta(t *testing.T) {
sample := SourceMileageSample{
DailyKM: -55,
QualityStatus: QualityOK,
QualityReason: "historical_source_baseline",
}
ApplyMileageQualityRules(&sample)
if sample.DailyKM != -55 {
t.Fatalf("daily km = %v, want original invalid delta", sample.DailyKM)
}
if sample.QualityStatus != QualityInvalidDelta || sample.QualityReason != "outside_daily_range" {
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
}
}
func TestApplyMileageQualityRulesAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
sample := SourceMileageSample{
DailyKM: 7833.5,
FirstEventTime: time.Date(2026, 7, 4, 11, 7, 58, 0, loc),
LatestEventTime: time.Date(2026, 7, 12, 2, 52, 47, 0, loc),
QualityStatus: QualityOK,
QualityReason: "historical_source_baseline",
}
ApplyMileageQualityRules(&sample)
if sample.QualityStatus != QualityOK || sample.QualityReason != "historical_source_baseline" {
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
}
if sample.DailyKM != 7833.5 {
t.Fatalf("daily km = %v", sample.DailyKM)
}
if got := MileageQualityWindowDays(sample.FirstEventTime, sample.LatestEventTime); got != 8 {
t.Fatalf("window days = %d, want 8", got)
}
if got := MileageQualityLimitKM(sample.FirstEventTime, sample.LatestEventTime); got != 8*maxSelectedDailyMileageKM {
t.Fatalf("quality limit = %d, want 8-day limit %d", got, 8*maxSelectedDailyMileageKM)
}
}
func TestApplyMileageQualityRulesAcceptsContinuousHighUtilizationDay(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
sample := SourceMileageSample{
DailyKM: 1895.7,
FirstEventTime: time.Date(2026, 7, 12, 23, 59, 37, 0, loc),
LatestEventTime: time.Date(2026, 7, 13, 20, 32, 42, 0, loc),
QualityStatus: QualityOK,
QualityReason: QualityReasonHistorical,
}
ApplyMileageQualityRules(&sample)
if sample.QualityStatus != QualityOK || sample.QualityReason != QualityReasonHistorical {
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
}
if got := MileageQualityLimitKM(sample.FirstEventTime, sample.LatestEventTime); got != 2500 {
t.Fatalf("quality limit = %d, want 2500", got)
}
}
func TestApplyMileageQualityRulesRejectsImplausibleSingleDayJump(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
sample := SourceMileageSample{
DailyKM: 4000,
FirstEventTime: time.Date(2026, 7, 12, 23, 59, 37, 0, loc),
LatestEventTime: time.Date(2026, 7, 13, 20, 32, 42, 0, loc),
QualityStatus: QualityOK,
QualityReason: QualityReasonHistorical,
}
ApplyMileageQualityRules(&sample)
if sample.QualityStatus != QualityInvalidDelta || sample.QualityReason != "outside_daily_range" {
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
}
}
func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
exec := &recordingExec{}
err := ProjectDailyMileage(context.Background(), exec, "LA9GG64L7PBAF4001", "2026-07-08", envelope.ProtocolJT808)
if err != nil {
t.Fatalf("ProjectDailyMileage() error = %v", err)
}
if len(exec.calls) != 4 {
t.Fatalf("exec calls = %d, want 4", len(exec.calls))
}
updateSources := exec.calls[0].query
projectFinal := exec.calls[1].query
markSelected := exec.calls[2].query
cleanupFinal := exec.calls[3].query
if !strings.Contains(updateSources, "UPDATE vehicle_daily_mileage_source") || !strings.Contains(updateSources, "is_selected = 0") {
t.Fatalf("first query should clear selected candidates: %s", updateSources)
if len(exec.calls) != 3 {
t.Fatalf("exec calls = %d, want 3", len(exec.calls))
}
projectFinal := exec.calls[0].query
markSelected := exec.calls[1].query
cleanupFinal := exec.calls[2].query
for _, want := range []string{
"INSERT INTO vehicle_daily_mileage",
"FROM vehicle_daily_mileage_source s",
"JOIN vehicle_data_source ds",
"LEFT JOIN vehicle_data_source ds",
"ds.id",
"ORDER BY ds.trust_priority",
"CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)",
"WHEN 'PLATFORM' THEN 0",
"WHEN 'DIRECT' THEN 1",
"WHEN 'UNKNOWN' THEN 2",
"ds.trust_priority",
"s.quality_status = '" + QualityOK + "'",
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
"s.daily_mileage_km BETWEEN 0 AND",
"DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time))",
} {
if !strings.Contains(projectFinal, want) {
t.Fatalf("project query missing %q: %s", want, projectFinal)
@@ -123,8 +515,10 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
t.Fatalf("project query should not use final-table field %q: %s", forbidden, projectFinal)
}
}
if !strings.Contains(markSelected, "UPDATE vehicle_daily_mileage_source s") || !strings.Contains(markSelected, "SET s.is_selected = 1") {
t.Fatalf("mark query should flag the elected source: %s", markSelected)
if !strings.Contains(markSelected, "UPDATE vehicle_daily_mileage_source s") ||
!strings.Contains(markSelected, "LEFT JOIN (") ||
!strings.Contains(markSelected, "SET s.is_selected = CASE WHEN selected_source.source_key IS NULL THEN 0 ELSE 1 END") {
t.Fatalf("mark query should reconcile the elected source: %s", markSelected)
}
if strings.Contains(markSelected, "JOIN vehicle_daily_mileage m") {
t.Fatalf("mark query should not rejoin final mileage for candidate selection: %s", markSelected)
@@ -135,18 +529,33 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
"s2.source_key",
"s2.quality_status = '" + QualityOK + "'",
"s2.daily_mileage_km BETWEEN 0 AND",
"ds.enabled = 1",
"ORDER BY ds.trust_priority",
"DATEDIFF(DATE(s2.latest_event_time), DATE(s2.first_event_time))",
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
"CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)",
"WHEN 'PLATFORM' THEN 0",
"WHEN 'DIRECT' THEN 1",
"WHEN 'UNKNOWN' THEN 2",
"ds.trust_priority",
"LIMIT 1",
} {
if !strings.Contains(markSelected, want) {
t.Fatalf("mark query missing %q: %s", want, markSelected)
}
}
if len(exec.calls[2].args) != 7 {
t.Fatalf("mark query args = %d, want 7", len(exec.calls[2].args))
if len(exec.calls[1].args) != 7 {
t.Fatalf("mark query args = %d, want 7", len(exec.calls[1].args))
}
if got := exec.calls[2].args[3]; got != maxSelectedDailyMileageKM {
for _, forbidden := range []string{
"WHEN 'UNKNOWN' THEN 1",
"WHEN 'DIRECT' THEN 2",
} {
if strings.Contains(projectFinal, forbidden) || strings.Contains(markSelected, forbidden) {
t.Fatalf("source selection should prefer classified DIRECT before UNKNOWN, found %q", forbidden)
}
}
if got := exec.calls[1].args[3]; got != maxSelectedDailyMileageKM {
t.Fatalf("mark query max mileage arg = %#v, want %d", got, maxSelectedDailyMileageKM)
}
for _, want := range []string{
@@ -159,7 +568,70 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
t.Fatalf("cleanup query missing %q: %s", want, cleanupFinal)
}
}
if len(exec.calls[3].args) != 6 {
t.Fatalf("cleanup query args = %d, want 6", len(exec.calls[3].args))
if len(exec.calls[2].args) != 6 {
t.Fatalf("cleanup query args = %d, want 6", len(exec.calls[2].args))
}
}
func TestProjectDailyMileageCommitsTransactionForSQLDB(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
vin := "LA9GG64L7PBAF4001"
statDate := "2026-07-08"
protocol := envelope.ProtocolJT808
expectProjectDailyMileageSQL(mock, vin, statDate, protocol)
mock.ExpectCommit()
if err := ProjectDailyMileage(context.Background(), db, vin, statDate, protocol); err != nil {
t.Fatalf("ProjectDailyMileage() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations were not met: %v", err)
}
}
func TestProjectDailyMileageRollsBackTransactionOnFailure(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
vin := "LA9GG64L7PBAF4001"
statDate := "2026-07-08"
protocol := envelope.ProtocolJT808
wantErr := errors.New("mark selected failed")
mock.ExpectBegin()
mock.ExpectExec(projectDailyMileageSQL).
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(markSelectedSourceSQL).
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM, vin, statDate, string(protocol)).
WillReturnError(wantErr)
mock.ExpectRollback()
err = ProjectDailyMileage(context.Background(), db, vin, statDate, protocol)
if !errors.Is(err, wantErr) {
t.Fatalf("ProjectDailyMileage() error = %v, want %v", err, wantErr)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations were not met: %v", err)
}
}
func expectProjectDailyMileageSQL(mock sqlmock.Sqlmock, vin string, statDate string, protocol envelope.Protocol) {
mock.ExpectBegin()
mock.ExpectExec(projectDailyMileageSQL).
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(markSelectedSourceSQL).
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM, vin, statDate, string(protocol)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(cleanupProjectedDailyMileageSQL).
WithArgs(vin, statDate, string(protocol), vin, statDate, string(protocol)).
WillReturnResult(sqlmock.NewResult(0, 1))
}

View File

@@ -11,10 +11,12 @@ import (
func TestNormalizeSourceIPDropsPort(t *testing.T) {
tests := map[string]string{
"115.231.168.135:20215": "115.231.168.135",
"115.231.168.135": "115.231.168.135",
" 115.159.85.149:28316 ": "115.159.85.149",
"": "",
"115.231.168.135:20215": "115.231.168.135",
"115.231.168.135": "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 := NormalizeSourceIP(input); got != want {
@@ -43,6 +45,60 @@ func TestNewSourceIdentityRequiresSourceIP(t *testing.T) {
}
}
func TestShouldManageDataSourceRequiresPlatformEvidence(t *testing.T) {
if ShouldManageDataSource(SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135"}) {
t.Fatal("unclassified source should not be managed")
}
if !ShouldManageDataSource(SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceCode: "g7s"}) {
t.Fatal("source_code should make source manageable")
}
if ShouldManageDataSource(SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceKind: "DIRECT"}) {
t.Fatal("direct source without platform evidence should not be auto-managed by source IP")
}
if !ShouldManageDataSource(SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceKind: "PLATFORM"}) {
t.Fatal("explicit platform source_kind should make source manageable")
}
if ShouldManageDataSource(SourceIdentity{Protocol: envelope.ProtocolJT808, SourceCode: "g7s"}) {
t.Fatal("empty source ip should not be managed")
}
}
func TestSourceKindForDataSourceWriteInfersPlatformEvidence(t *testing.T) {
tests := []struct {
name string
identity SourceIdentity
want string
}{
{
name: "empty evidence",
identity: SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135"},
want: "UNKNOWN",
},
{
name: "source code",
identity: SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceCode: "g7s"},
want: "PLATFORM",
},
{
name: "platform name",
identity: SourceIdentity{Protocol: envelope.ProtocolGB32960, SourceIP: "8.134.95.166", PlatformName: "Hyundai"},
want: "PLATFORM",
},
{
name: "explicit direct wins",
identity: SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceCode: "direct-import", SourceKind: "DIRECT"},
want: "DIRECT",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sourceKindForDataSourceWrite(tt.identity); got != tt.want {
t.Fatalf("sourceKindForDataSourceWrite() = %q, want %q", got, tt.want)
}
})
}
}
func TestUpsertDataSourcePreservesManualFields(t *testing.T) {
exec := &recordingExec{}
identity := SourceIdentity{
@@ -60,7 +116,7 @@ func TestUpsertDataSourcePreservesManualFields(t *testing.T) {
for _, want := range []string{
"INSERT INTO vehicle_data_source",
"latest_source_endpoint = VALUES(latest_source_endpoint)",
"latest_seen_at = VALUES(latest_seen_at)",
"latest_seen_at = GREATEST",
} {
if !strings.Contains(sql, want) {
t.Fatalf("source upsert missing %q: %s", want, sql)
@@ -77,3 +133,103 @@ func TestUpsertDataSourcePreservesManualFields(t *testing.T) {
}
}
}
func TestUpsertDataSourceWritesPlatformKindWhenEvidenceExists(t *testing.T) {
exec := &recordingExec{}
identity := SourceIdentity{
Protocol: envelope.ProtocolGB32960,
SourceIP: "8.134.95.166",
SourceEndpoint: "8.134.95.166:32960",
SourceCode: "Hyundai",
PlatformName: "现代 HTWO",
}
if err := UpsertDataSource(context.Background(), exec, identity, time.Date(2026, 7, 12, 18, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("UpsertDataSource() error = %v", err)
}
if len(exec.calls) != 1 {
t.Fatalf("exec calls = %d", len(exec.calls))
}
if got, want := exec.calls[0].args[5], "PLATFORM"; got != want {
t.Fatalf("source_kind arg = %#v, want %q", got, want)
}
}
func TestUpsertDataSourceCanReenableAutoRetiredSourceWithEvidence(t *testing.T) {
exec := &recordingExec{}
identity := SourceIdentity{
Protocol: envelope.ProtocolJT808,
SourceIP: "115.231.168.135",
SourceEndpoint: "115.231.168.135:20215",
SourceCode: "g7s",
PlatformName: "G7s",
}
if err := UpsertDataSource(context.Background(), exec, identity, time.Date(2026, 7, 12, 18, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("UpsertDataSource() error = %v", err)
}
sql := exec.calls[0].query
for _, want := range []string{
"vehicle_data_source.enabled = 0",
"vehicle_data_source.remark LIKE 'auto-retired:%'",
"vehicle_data_source.remark = 'auto-reenabled: source evidence restored'",
"THEN 'auto-reenabled: source evidence restored'",
"THEN 1",
} {
if !strings.Contains(sql, want) {
t.Fatalf("source upsert should re-enable auto-retired source with evidence; missing %q:\n%s", want, sql)
}
}
if strings.Contains(sql, "enabled = VALUES(enabled)") {
t.Fatalf("source upsert should not blindly copy enabled from values:\n%s", sql)
}
if strings.Index(sql, "enabled = CASE") < 0 || strings.Index(sql, "remark = CASE") < 0 || strings.Index(sql, "enabled = CASE") > strings.Index(sql, "remark = CASE") {
t.Fatalf("source upsert must restore enabled before updating remark because MySQL evaluates assignments in order:\n%s", sql)
}
}
func TestDataSourceSchemaIncludesStableSourceCode(t *testing.T) {
for _, want := range []string{
"source_code VARCHAR(64) NULL",
"source_kind VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN'",
"KEY idx_protocol_source_code (protocol, source_code)",
"KEY idx_protocol_source_kind_seen (protocol, source_kind, latest_seen_at)",
} {
if !strings.Contains(DataSourceTableSQL, want) {
t.Fatalf("data source schema missing %q:\n%s", want, DataSourceTableSQL)
}
}
for _, want := range []string{
"ALTER TABLE vehicle_data_source ADD COLUMN source_code",
"ALTER TABLE vehicle_data_source ADD COLUMN source_kind",
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_code",
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_kind_seen",
} {
if !containsStatement(DailyMileageAlterSQL, want) {
t.Fatalf("data source alter SQL missing %q: %#v", want, DailyMileageAlterSQL)
}
}
}
func TestDailyMileageSourceSchemaIncludesQueryIndexes(t *testing.T) {
for _, want := range []string{
"KEY idx_protocol_date_selected (protocol, stat_date, is_selected, vin)",
"KEY idx_protocol_date_quality (protocol, stat_date, quality_status, vin)",
"KEY idx_protocol_date_quality_reason (protocol, stat_date, quality_status, quality_reason, vin)",
"KEY idx_source_ip_date (protocol, source_ip, stat_date)",
} {
if !strings.Contains(DailyMileageSourceTableSQL, want) {
t.Fatalf("daily mileage source schema missing %q:\n%s", want, DailyMileageSourceTableSQL)
}
if !containsStatement(DailyMileageAlterSQL, "ALTER TABLE vehicle_daily_mileage_source ADD "+want) {
t.Fatalf("daily mileage source alter SQL missing %q: %#v", want, DailyMileageAlterSQL)
}
}
}
func containsStatement(statements []string, fragment string) bool {
for _, statement := range statements {
if strings.Contains(statement, fragment) {
return true
}
}
return false
}