feat: build vehicle data platform and production pipeline
This commit is contained in:
@@ -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
1733
go/vehicle-gateway/internal/stats/data_source_query.go
Normal file
1733
go/vehicle-gateway/internal/stats/data_source_query.go
Normal file
File diff suppressed because it is too large
Load Diff
700
go/vehicle-gateway/internal/stats/data_source_query_test.go
Normal file
700
go/vehicle-gateway/internal/stats/data_source_query_test.go
Normal 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
@@ -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)
|
||||
)`
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user