# Multi-Source Mileage Statistics Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Store every mileage source candidate, maintain source platform metadata, and project one elected daily mileage result for default BI/API queries. **Architecture:** Add a source metadata table and a per-source daily mileage candidate table in MySQL. Real-time stat writer and historical backfill write candidates first, then run an election projector that updates the existing `vehicle_daily_mileage` final table. Default reads stay on `vehicle_daily_mileage`; source audit reads can be added after the write path is stable. **Tech Stack:** Go 1.26, MySQL via `github.com/go-sql-driver/mysql`, TDengine via `github.com/taosdata/driver-go/v3/taosWS`, Kafka via `github.com/segmentio/kafka-go`, existing `internal/stats` package. ## Global Constraints - Runtime flow must not add non-protocol synthetic fields to `vehicle.fields.go.*`. - Source identity uses `protocol + source_ip`; source endpoint port is latest connection metadata only. - Platform name, trust priority, enabled flag, and remark are operator-maintained and must not be overwritten by runtime upserts. - Candidate statistics use `vin + stat_date + protocol + source_key` and never mix sources. - Final statistics use `vin + stat_date + protocol` and are projected from one selected candidate. - The projector must not calculate `B today - A yesterday`. - A source with no same-source previous-day baseline remains a candidate and is not selected by default. - Default API queries continue to read `vehicle_daily_mileage`. --- ## File Structure - Modify `go/vehicle-gateway/internal/stats/schema.go` - Owns MySQL schema strings for source metadata, source candidates, final table, and compatibility alters. - Create `go/vehicle-gateway/internal/stats/source.go` - Owns source endpoint normalization, `SourceIdentity`, and source metadata upsert. - Create `go/vehicle-gateway/internal/stats/source_test.go` - Tests source IP extraction and runtime upsert preserving manual fields. - Create `go/vehicle-gateway/internal/stats/source_mileage.go` - Owns `SourceMileageSample`, candidate upsert, final election projection, and shared constants. - Create `go/vehicle-gateway/internal/stats/source_mileage_test.go` - Tests candidate writes and election rules. - Modify `go/vehicle-gateway/internal/stats/daily_metric.go` - Keep protocol mileage extraction, route `Writer.Append` through source metadata, candidate upsert, and election projection. - Modify `go/vehicle-gateway/internal/stats/daily_metric_test.go` - Update expectations for source candidate writes and final projection. - Modify `go/vehicle-gateway/internal/stats/query.go` - Keep default response compatible; optionally include selected source columns in existing rows if already present. - Modify `go/vehicle-gateway/cmd/stats-backfill/main.go` - Rebuild source candidates by day/source, then project final rows. - Modify `go/vehicle-gateway/cmd/stats-backfill/main_test.go` - Update trusted-source tests to assert source candidates and final selection. - Deploy binaries: - `/opt/lingniu-go-native/current/stat-writer` - `/opt/lingniu-go-native/current/stats-backfill` --- ### Task 1: Source Metadata Schema And Identity Helpers **Files:** - Modify: `go/vehicle-gateway/internal/stats/schema.go` - Create: `go/vehicle-gateway/internal/stats/source.go` - Create: `go/vehicle-gateway/internal/stats/source_test.go` **Interfaces:** - Produces: `type SourceIdentity struct { Protocol envelope.Protocol; SourceIP string; SourceEndpoint string }` - Produces: `func NewSourceIdentity(protocol envelope.Protocol, endpoint string) (SourceIdentity, bool)` - Produces: `func NormalizeSourceIP(endpoint string) string` - Produces: `func UpsertDataSource(ctx context.Context, exec Execer, identity SourceIdentity, now time.Time) error` - Consumes: `stats.Execer` - [ ] **Step 1: Write failing source identity tests** Add to `go/vehicle-gateway/internal/stats/source_test.go`: ```go package stats import ( "context" "strings" "testing" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) 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", "": "", } for input, want := range tests { if got := NormalizeSourceIP(input); got != want { t.Fatalf("NormalizeSourceIP(%q) = %q, want %q", input, got, want) } } } func TestNewSourceIdentityRequiresSourceIP(t *testing.T) { identity, ok := NewSourceIdentity(envelope.ProtocolJT808, "115.231.168.135:20215") if !ok { t.Fatal("NewSourceIdentity() ok = false") } if identity.Protocol != envelope.ProtocolJT808 { t.Fatalf("protocol = %q", identity.Protocol) } if identity.SourceIP != "115.231.168.135" { t.Fatalf("source ip = %q", identity.SourceIP) } if identity.SourceEndpoint != "115.231.168.135:20215" { t.Fatalf("endpoint = %q", identity.SourceEndpoint) } if _, ok := NewSourceIdentity(envelope.ProtocolJT808, ""); ok { t.Fatal("empty endpoint should not produce identity") } } func TestUpsertDataSourcePreservesManualFields(t *testing.T) { exec := &recordingExec{} identity := SourceIdentity{ Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceEndpoint: "115.231.168.135:20215", } if err := UpsertDataSource(context.Background(), exec, identity, time.Date(2026, 7, 8, 13, 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)) } sql := exec.calls[0].query for _, want := range []string{ "INSERT INTO vehicle_data_source", "latest_source_endpoint = VALUES(latest_source_endpoint)", "latest_seen_at = VALUES(latest_seen_at)", } { if !strings.Contains(sql, want) { t.Fatalf("source upsert missing %q: %s", want, sql) } } for _, forbidden := range []string{ "platform_name = VALUES(platform_name)", "trust_priority = VALUES(trust_priority)", "enabled = VALUES(enabled)", "remark = VALUES(remark)", } { if strings.Contains(sql, forbidden) { t.Fatalf("source upsert should preserve manual field %q: %s", forbidden, sql) } } } ``` - [ ] **Step 2: Run source tests and verify failure** Run: ```bash cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway go test ./internal/stats -run 'TestNormalizeSourceIP|TestNewSourceIdentity|TestUpsertDataSource' -count=1 ``` Expected: FAIL because `NormalizeSourceIP`, `NewSourceIdentity`, and `UpsertDataSource` are undefined. - [ ] **Step 3: Add source schema** Add to `go/vehicle-gateway/internal/stats/schema.go`: ```go const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source ( id BIGINT NOT NULL AUTO_INCREMENT, protocol VARCHAR(32) NOT NULL, source_ip VARCHAR(64) NOT NULL, latest_source_endpoint VARCHAR(128) NULL, platform_name VARCHAR(128) NULL, trust_priority INT NOT NULL DEFAULT 100, enabled TINYINT(1) NOT NULL DEFAULT 1, first_seen_at DATETIME NULL, latest_seen_at DATETIME NULL, remark VARCHAR(512) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 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_enabled_priority (protocol, enabled, trust_priority) )` ``` Update `Writer.EnsureSchema` in Task 3 to execute this statement. - [ ] **Step 4: Implement source helpers** Create `go/vehicle-gateway/internal/stats/source.go`: ```go package stats import ( "context" "strings" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) type SourceIdentity struct { Protocol envelope.Protocol SourceIP string SourceEndpoint string } func NewSourceIdentity(protocol envelope.Protocol, endpoint string) (SourceIdentity, bool) { sourceIP := NormalizeSourceIP(endpoint) if sourceIP == "" { return SourceIdentity{}, false } return SourceIdentity{ Protocol: protocol, SourceIP: sourceIP, SourceEndpoint: strings.TrimSpace(endpoint), }, true } 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 } func UpsertDataSource(ctx context.Context, exec Execer, identity SourceIdentity, now time.Time) error { if exec == nil { panic("stats execer must not be nil") } if identity.SourceIP == "" { return nil } if now.IsZero() { now = time.Now() } _, err := exec.ExecContext(ctx, upsertDataSourceSQL, string(identity.Protocol), identity.SourceIP, identity.SourceEndpoint, now, now, ) return err } const upsertDataSourceSQL = ` INSERT INTO vehicle_data_source (protocol, source_ip, latest_source_endpoint, 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), updated_at = CURRENT_TIMESTAMP ` ``` - [ ] **Step 5: Run source tests and verify pass** Run: ```bash go test ./internal/stats -run 'TestNormalizeSourceIP|TestNewSourceIdentity|TestUpsertDataSource' -count=1 ``` Expected: PASS. - [ ] **Step 6: Commit source metadata foundation** Run: ```bash git add go/vehicle-gateway/internal/stats/schema.go go/vehicle-gateway/internal/stats/source.go go/vehicle-gateway/internal/stats/source_test.go git commit -m "feat(stats): add vehicle data source metadata" ``` --- ### Task 2: Candidate Mileage Schema And Writer **Files:** - Modify: `go/vehicle-gateway/internal/stats/schema.go` - Create: `go/vehicle-gateway/internal/stats/source_mileage.go` - Create: `go/vehicle-gateway/internal/stats/source_mileage_test.go` **Interfaces:** - Consumes: `MetricSample` - Produces: `type SourceMileageSample struct` - Produces: `func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity) SourceMileageSample` - Produces: `func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageSample) error` - Produces: `func SourceKey(protocol envelope.Protocol, phone string, deviceID string, sourceIP string) string` - [ ] **Step 1: Write failing candidate tests** Add to `go/vehicle-gateway/internal/stats/source_mileage_test.go`: ```go package stats import ( "context" "strings" "testing" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) func TestSourceKeyUsesProtocolDeviceAndSourceIP(t *testing.T) { got := SourceKey(envelope.ProtocolJT808, "13307765812", "", "115.231.168.135") if got != "JT808:13307765812@115.231.168.135" { t.Fatalf("source key = %q", got) } got = SourceKey(envelope.ProtocolGB32960, "", "iccid-1", "202.98.117.132") if got != "GB32960:iccid-1@202.98.117.132" { t.Fatalf("source key = %q", got) } } func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) { exec := &recordingExec{} sample := SourceMileageSample{ VIN: "LA9GG64L7PBAF4001", StatDate: "2026-07-08", Protocol: envelope.ProtocolJT808, SourceKey: "JT808:13307765812@115.231.168.135", SourceIP: "115.231.168.135", SourceEndpoint: "115.231.168.135:20215", Phone: "13307765812", FirstTotalKM: 4100.8, LatestTotalKM: 4123.9, DailyKM: 23.1, SampleCount: 2, FirstEventTime: time.Date(2026, 7, 8, 0, 1, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), LatestEventTime: time.Date(2026, 7, 8, 23, 59, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), QualityStatus: QualityOK, QualityReason: "same_source_continuous", } 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", len(exec.calls)) } sql := exec.calls[0].query for _, want := range []string{ "INSERT INTO vehicle_daily_mileage_source", "ON DUPLICATE KEY UPDATE", "daily_mileage_km = VALUES(daily_mileage_km)", "quality_status = VALUES(quality_status)", } { if !strings.Contains(sql, want) { t.Fatalf("candidate upsert missing %q: %s", want, sql) } } if got := exec.calls[0].args[0]; got != "LA9GG64L7PBAF4001" { t.Fatalf("first arg = %#v", got) } } ``` - [ ] **Step 2: Run candidate tests and verify failure** Run: ```bash go test ./internal/stats -run 'TestSourceKey|TestUpsertSourceMileage' -count=1 ``` Expected: FAIL because candidate interfaces are undefined. - [ ] **Step 3: Add candidate schema** Add to `go/vehicle-gateway/internal/stats/schema.go`: ```go const DailyMileageSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_mileage_source ( vin VARCHAR(32) NOT NULL, stat_date DATE NOT NULL, protocol VARCHAR(32) NOT NULL, source_key VARCHAR(256) NOT NULL, source_ip VARCHAR(64) NOT NULL, source_endpoint VARCHAR(128) NULL, phone VARCHAR(32) NULL, device_id VARCHAR(64) NULL, platform_name VARCHAR(128) NULL, first_total_mileage_km DECIMAL(18,3) NULL, latest_total_mileage_km DECIMAL(18,3) NULL, daily_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0, sample_count BIGINT NOT NULL DEFAULT 0, first_event_time DATETIME NULL, latest_event_time DATETIME NULL, quality_status VARCHAR(32) NOT NULL DEFAULT 'OK', quality_reason VARCHAR(255) NULL, is_selected TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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) )` ``` - [ ] **Step 4: Implement candidate writer** Create `go/vehicle-gateway/internal/stats/source_mileage.go`: ```go package stats import ( "context" "strings" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) const ( QualityOK = "OK" QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE" QualityInvalidDelta = "INVALID_DELTA" ) type SourceMileageSample struct { VIN string StatDate string Protocol envelope.Protocol SourceKey string SourceIP string SourceEndpoint string Phone string DeviceID string PlatformName string FirstTotalKM float64 LatestTotalKM float64 DailyKM float64 SampleCount int64 FirstEventTime time.Time LatestEventTime time.Time QualityStatus string QualityReason string } func SourceKey(protocol envelope.Protocol, phone string, deviceID string, sourceIP string) string { identity := strings.TrimSpace(phone) if identity == "" { identity = strings.TrimSpace(deviceID) } if identity == "" { identity = "unknown" } return string(protocol) + ":" + identity + "@" + strings.TrimSpace(sourceIP) } func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity) SourceMileageSample { eventTime := sample.EventTime return SourceMileageSample{ VIN: sample.VIN, StatDate: sample.StatDate, Protocol: sample.Protocol, SourceKey: SourceKey(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP), SourceIP: identity.SourceIP, SourceEndpoint: identity.SourceEndpoint, Phone: sample.Phone, DeviceID: sample.DeviceID, FirstTotalKM: sample.TotalMileageKM, LatestTotalKM: sample.TotalMileageKM, DailyKM: 0, SampleCount: 1, FirstEventTime: eventTime, LatestEventTime: eventTime, QualityStatus: QualityOK, QualityReason: "realtime_sample", } } func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageSample) error { if sample.VIN == "" || sample.SourceKey == "" { return nil } if sample.QualityStatus == "" { sample.QualityStatus = QualityOK } _, err := exec.ExecContext(ctx, upsertSourceMileageSQL, sample.VIN, sample.StatDate, string(sample.Protocol), sample.SourceKey, sample.SourceIP, sample.SourceEndpoint, sample.Phone, sample.DeviceID, sample.PlatformName, sample.FirstTotalKM, sample.LatestTotalKM, sample.DailyKM, sample.SampleCount, sample.FirstEventTime, sample.LatestEventTime, sample.QualityStatus, sample.QualityReason, ) return err } const upsertSourceMileageSQL = ` 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE source_ip = VALUES(source_ip), source_endpoint = VALUES(source_endpoint), phone = VALUES(phone), device_id = VALUES(device_id), platform_name = COALESCE(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, 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 = VALUES(quality_status), quality_reason = VALUES(quality_reason), updated_at = CURRENT_TIMESTAMP ` ``` - [ ] **Step 5: Extend MetricSample with event and device fields** Modify `MetricSample` in `go/vehicle-gateway/internal/stats/daily_metric.go`: ```go type MetricSample struct { VIN string Protocol envelope.Protocol StatDate string TotalMileageKM float64 EventTime time.Time SourceKey string Phone string DeviceID string SourceEndpoint string } ``` Populate `EventTime` and `DeviceID` in `SamplesFromEnvelope`. - [ ] **Step 6: Run candidate tests and verify pass** Run: ```bash go test ./internal/stats -run 'TestSourceKey|TestUpsertSourceMileage' -count=1 ``` Expected: PASS. - [ ] **Step 7: Commit candidate writer** Run: ```bash git add go/vehicle-gateway/internal/stats/schema.go go/vehicle-gateway/internal/stats/source_mileage.go go/vehicle-gateway/internal/stats/source_mileage_test.go go/vehicle-gateway/internal/stats/daily_metric.go git commit -m "feat(stats): store source mileage candidates" ``` --- ### Task 3: Final Mileage Election Projector **Files:** - Modify: `go/vehicle-gateway/internal/stats/source_mileage.go` - Modify: `go/vehicle-gateway/internal/stats/source_mileage_test.go` - Modify: `go/vehicle-gateway/internal/stats/schema.go` **Interfaces:** - Produces: `func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error` - Produces: `const maxSelectedDailyMileageKM = 1000` - Consumes: `vehicle_daily_mileage_source` - Produces: selected row in `vehicle_daily_mileage` - [ ] **Step 1: Write failing projector SQL tests** Add to `go/vehicle-gateway/internal/stats/source_mileage_test.go`: ```go 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) != 2 { t.Fatalf("exec calls = %d, want 2", len(exec.calls)) } updateSources := exec.calls[0].query projectFinal := exec.calls[1].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) } for _, want := range []string{ "INSERT INTO vehicle_daily_mileage", "FROM vehicle_daily_mileage_source s", "LEFT JOIN vehicle_data_source ds", "ORDER BY COALESCE(ds.trust_priority, 100)", "s.quality_status = 'OK'", "s.daily_mileage_km BETWEEN 0 AND", } { if !strings.Contains(projectFinal, want) { t.Fatalf("project query missing %q: %s", want, projectFinal) } } } ``` - [ ] **Step 2: Run projector test and verify failure** Run: ```bash go test ./internal/stats -run TestProjectDailyMileageSelectsCandidateAndMarksSource -count=1 ``` Expected: FAIL because `ProjectDailyMileage` is undefined. - [ ] **Step 3: Implement projector** Add to `go/vehicle-gateway/internal/stats/source_mileage.go`: ```go const maxSelectedDailyMileageKM = 1000 func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error { if strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" { return nil } if _, err := exec.ExecContext(ctx, clearSelectedSourceSQL, vin, statDate, string(protocol)); err != nil { return err } _, err := exec.ExecContext(ctx, projectDailyMileageSQL, vin, statDate, string(protocol), maxSelectedDailyMileageKM, vin, statDate, string(protocol), ) return err } const clearSelectedSourceSQL = ` UPDATE vehicle_daily_mileage_source SET is_selected = 0 WHERE vin = ? AND stat_date = ? AND protocol = ? ` const projectDailyMileageSQL = ` INSERT INTO vehicle_daily_mileage (vin, stat_date, protocol, daily_mileage_km, first_total_mileage_km, latest_total_mileage_km, trusted_source_key, trusted_phone, trusted_source_endpoint, sample_count) SELECT s.vin, s.stat_date, s.protocol, s.daily_mileage_km, s.first_total_mileage_km, s.latest_total_mileage_km, s.source_key, s.phone, s.source_endpoint, s.sample_count FROM vehicle_daily_mileage_source s 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 = 'OK' AND s.daily_mileage_km BETWEEN 0 AND ? AND COALESCE(ds.enabled, 1) = 1 ORDER BY COALESCE(ds.trust_priority, 100), s.sample_count DESC, s.latest_event_time DESC, s.source_key ASC LIMIT 1 ON DUPLICATE KEY UPDATE daily_mileage_km = VALUES(daily_mileage_km), first_total_mileage_km = VALUES(first_total_mileage_km), latest_total_mileage_km = VALUES(latest_total_mileage_km), trusted_source_key = VALUES(trusted_source_key), trusted_phone = VALUES(trusted_phone), trusted_source_endpoint = VALUES(trusted_source_endpoint), sample_count = VALUES(sample_count), updated_at = CURRENT_TIMESTAMP ` ``` Then add a third exec in `ProjectDailyMileage` to mark the selected candidate: ```go const markSelectedSourceSQL = ` UPDATE vehicle_daily_mileage_source s JOIN vehicle_daily_mileage m ON m.vin = s.vin AND m.stat_date = s.stat_date AND m.protocol = s.protocol AND m.trusted_source_key = s.source_key SET s.is_selected = 1 WHERE s.vin = ? AND s.stat_date = ? AND s.protocol = ? ` ``` Update the test to expect three calls and assert `markSelectedSourceSQL` contains `SET s.is_selected = 1`. - [ ] **Step 4: Run projector tests and verify pass** Run: ```bash go test ./internal/stats -run TestProjectDailyMileageSelectsCandidateAndMarksSource -count=1 ``` Expected: PASS. - [ ] **Step 5: Commit projector** Run: ```bash git add go/vehicle-gateway/internal/stats/source_mileage.go go/vehicle-gateway/internal/stats/source_mileage_test.go git commit -m "feat(stats): project elected mileage source" ``` --- ### Task 4: Rewire Real-Time Stat Writer To Candidates **Files:** - Modify: `go/vehicle-gateway/internal/stats/daily_metric.go` - Modify: `go/vehicle-gateway/internal/stats/daily_metric_test.go` **Interfaces:** - Consumes: `UpsertDataSource` - Consumes: `UpsertSourceMileage` - Consumes: `ProjectDailyMileage` - Produces: `Writer.Append` writes source metadata, candidate row, and final projection. - [ ] **Step 1: Write failing Writer.Append flow test** Add to `go/vehicle-gateway/internal/stats/daily_metric_test.go`: ```go func TestWriterAppendWritesSourceCandidateAndProjection(t *testing.T) { exec := &recordingExec{} writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) event := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LA9GG64L7PBAF4001", Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", EventTimeMS: time.Date(2026, 7, 8, 13, 20, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(), Fields: map[string]any{ "jt808.location.total_mileage_km": 4123.9, }, } if err := writer.Append(context.Background(), event); err != nil { t.Fatalf("Append() error = %v", err) } if len(exec.calls) != 5 { t.Fatalf("exec calls = %d, want source + candidate + clear + project + mark", len(exec.calls)) } if !strings.Contains(exec.calls[0].query, "INSERT INTO vehicle_data_source") { t.Fatalf("first call should upsert source: %s", exec.calls[0].query) } if !strings.Contains(exec.calls[1].query, "INSERT INTO vehicle_daily_mileage_source") { t.Fatalf("second call should upsert candidate: %s", exec.calls[1].query) } if !strings.Contains(exec.calls[3].query, "INSERT INTO vehicle_daily_mileage") { t.Fatalf("fourth call should project final: %s", exec.calls[3].query) } } ``` - [ ] **Step 2: Run flow test and verify failure** Run: ```bash go test ./internal/stats -run TestWriterAppendWritesSourceCandidateAndProjection -count=1 ``` Expected: FAIL because `Writer.Append` still calls the old direct final upsert. - [ ] **Step 3: Rewrite Writer.Append** Modify `Writer.Append` in `go/vehicle-gateway/internal/stats/daily_metric.go`: ```go func (w *Writer) Append(ctx context.Context, env envelope.FrameEnvelope) error { samples, err := SamplesFromEnvelope(env, w.loc) if err != nil { return err } identity, hasSource := NewSourceIdentity(env.Protocol, env.SourceEndpoint) for _, sample := range samples { if w.seenSameMileage(sample) { continue } if hasSource { if err := UpsertDataSource(ctx, w.exec, identity, sample.EventTime); err != nil { return err } candidate := SourceMileageSampleFromMetric(sample, identity) if err := UpsertSourceMileage(ctx, w.exec, candidate); err != nil { return err } } if err := ProjectDailyMileage(ctx, w.exec, sample.VIN, sample.StatDate, sample.Protocol); err != nil { return err } w.markMileageWritten(sample) } return nil } ``` Keep `upsertDailyMileageSQL` only if older tests still reference it; remove it after tests prove no code path uses it. - [ ] **Step 4: Update schema bootstrap** Modify `EnsureSchema` in `go/vehicle-gateway/internal/stats/daily_metric.go`: ```go func (w *Writer) EnsureSchema(ctx context.Context) error { for _, statement := range []string{ DataSourceTableSQL, DailyMileageSourceTableSQL, DailyMileageTableSQL, } { if _, err := w.exec.ExecContext(ctx, statement); err != nil { return err } } for _, statement := range DailyMileageAlterSQL { if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isDuplicateColumnError(err) { return err } } return nil } ``` - [ ] **Step 5: Run stats tests** Run: ```bash go test ./internal/stats -count=1 ``` Expected: PASS. - [ ] **Step 6: Commit realtime writer rewire** Run: ```bash git add go/vehicle-gateway/internal/stats/daily_metric.go go/vehicle-gateway/internal/stats/daily_metric_test.go git commit -m "feat(stats): write realtime mileage candidates" ``` --- ### Task 5: Backfill Candidate Rows And Final Projection **Files:** - Modify: `go/vehicle-gateway/cmd/stats-backfill/main.go` - Modify: `go/vehicle-gateway/cmd/stats-backfill/main_test.go` **Interfaces:** - Consumes: `UpsertDataSource` - Consumes: `UpsertSourceMileage` - Consumes: `ProjectDailyMileage` - Produces: backfill can rebuild source candidates and final result for a date/protocol range. - [ ] **Step 1: Write failing backfill election test** Modify `go/vehicle-gateway/cmd/stats-backfill/main_test.go` to keep the existing `TestChooseTrustedSourceKeepsContinuingSourceAndRejectsNewJump` and add: ```go func TestDailySourceLastBuildsCandidateKeysBySourceIP(t *testing.T) { sourceA := dailySourceLast{ VIN: "LA9GG64L7PBAF4001", SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215"), Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", TotalKM: 4123.9, } sourceB := dailySourceLast{ VIN: "LA9GG64L7PBAF4001", SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:42630"), Phone: "13307765812", SourceEndpoint: "115.231.168.135:42630", TotalKM: 4100.8, } if sourceA.SourceKey != sourceB.SourceKey { t.Fatalf("same source IP should produce same source key: %q vs %q", sourceA.SourceKey, sourceB.SourceKey) } } ``` - [ ] **Step 2: Run backfill tests and verify failure** Run: ```bash go test ./cmd/stats-backfill -run 'TestChooseTrustedSource|TestDailySourceLastBuildsCandidateKeysBySourceIP' -count=1 ``` Expected: FAIL until `normalizedSourceKey` accepts protocol and device fields. - [ ] **Step 3: Update source key normalization in backfill** Change `normalizedSourceKey` in `go/vehicle-gateway/cmd/stats-backfill/main.go`: ```go func normalizedSourceKey(protocol string, phone string, deviceID string, endpoint string) string { sourceIP := stats.NormalizeSourceIP(endpoint) return stats.SourceKey(envelope.Protocol(protocol), phone, deviceID, sourceIP) } ``` Update all calls: ```go sourceKey := normalizedSourceKey(string(protocol), phone, "", sourceEndpoint) ``` - [ ] **Step 4: Change backfill write path to candidates** Replace final-only `writeAggregates` with candidate write and projection: ```go func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg, batchSize int) (int64, error) { var written int64 for _, agg := range aggregates { identity := stats.SourceIdentity{ Protocol: agg.Protocol, SourceIP: stats.NormalizeSourceIP(agg.SourceEndpoint), SourceEndpoint: agg.SourceEndpoint, } if err := stats.UpsertDataSource(ctx, db, identity, agg.LatestEventTime); err != nil { return written, err } candidate := stats.SourceMileageSample{ VIN: agg.VIN, StatDate: agg.Date, Protocol: agg.Protocol, SourceKey: agg.SourceKey, SourceIP: identity.SourceIP, SourceEndpoint: agg.SourceEndpoint, Phone: agg.Phone, DeviceID: agg.DeviceID, FirstTotalKM: agg.FirstKM, LatestTotalKM: agg.LatestKM, DailyKM: agg.LatestKM - agg.FirstKM, SampleCount: agg.Count, FirstEventTime: agg.FirstEventTime, LatestEventTime: agg.LatestEventTime, QualityStatus: stats.QualityOK, QualityReason: "same_source_previous_day", } if candidate.DailyKM < 0 || candidate.DailyKM > 1000 { candidate.QualityStatus = stats.QualityInvalidDelta candidate.QualityReason = "outside_daily_range" } if err := stats.UpsertSourceMileage(ctx, db, candidate); err != nil { return written, err } if err := stats.ProjectDailyMileage(ctx, db, agg.VIN, agg.Date, agg.Protocol); err != nil { return written, err } written++ } return written, nil } ``` Extend `metricAgg` with `DeviceID`, `FirstEventTime`, and `LatestEventTime`. - [ ] **Step 5: Preserve every source candidate** Update `buildLastDiffAggregates` so it writes one aggregate per source candidate when same-source previous baseline exists, not just one chosen source. Use this key: ```go key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey ``` When a current source has no previous source, create a candidate with: ```go QualityStatus: stats.QualityNoPreviousBaseline QualityReason: "missing_previous_source" FirstKM: currentRow.TotalKM LatestKM: currentRow.TotalKM DailyKM: 0 ``` Do not project invalid candidates; projection SQL already filters `quality_status = 'OK'`. - [ ] **Step 6: Run backfill tests** Run: ```bash go test ./cmd/stats-backfill ./internal/stats -count=1 ``` Expected: PASS. - [ ] **Step 7: Commit backfill candidate support** Run: ```bash git add go/vehicle-gateway/cmd/stats-backfill/main.go go/vehicle-gateway/cmd/stats-backfill/main_test.go git commit -m "feat(stats): backfill mileage source candidates" ``` --- ### Task 6: API Query Compatibility And Candidate Audit Hook **Files:** - Modify: `go/vehicle-gateway/internal/stats/query.go` - Modify: `go/vehicle-gateway/internal/stats/query_test.go` **Interfaces:** - Produces: existing `/api/stats/daily-metrics` response remains backward-compatible. - Produces: selected source metadata is visible in final rows when present. - [ ] **Step 1: Write failing query test for selected source fields** Add to `go/vehicle-gateway/internal/stats/query_test.go`: ```go func TestMetricQueryReturnsSelectedSourceFields(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() rows := sqlmock.NewRows([]string{ "vin", "stat_date", "protocol", "daily_mileage_km", "first_total_mileage_km", "latest_total_mileage_km", "trusted_source_key", "trusted_phone", "trusted_source_endpoint", "sample_count", "updated_at", }).AddRow( "LA9GG64L7PBAF4001", "2026-07-08", "JT808", 23.1, 4100.8, 4123.9, "JT808:13307765812@115.231.168.135", "13307765812", "115.231.168.135:20215", 1, "2026-07-08 13:30:57", ) mock.ExpectQuery("SELECT vin, stat_date, protocol").WillReturnRows(rows) repo := NewMetricRepository(db) got, err := repo.Query(context.Background(), MetricQuery{ VIN: "LA9GG64L7PBAF4001", Protocol: "JT808", Limit: 1, }) if err != nil { t.Fatalf("Query() error = %v", err) } if got[0].TrustedSourceKey == nil || *got[0].TrustedSourceKey != "JT808:13307765812@115.231.168.135" { t.Fatalf("trusted source = %#v", got[0].TrustedSourceKey) } } ``` - [ ] **Step 2: Run query test and verify failure** Run: ```bash go test ./internal/stats -run TestMetricQueryReturnsSelectedSourceFields -count=1 ``` Expected: FAIL because `MetricRow` does not expose trusted source fields. - [ ] **Step 3: Extend MetricRow and SQL** Modify `MetricRow` in `go/vehicle-gateway/internal/stats/query.go`: ```go TrustedSourceKey *string `json:"trusted_source_key,omitempty"` TrustedPhone *string `json:"trusted_phone,omitempty"` TrustedSourceEndpoint *string `json:"trusted_source_endpoint,omitempty"` ``` Modify selected columns: ```go SELECT vin, stat_date, protocol, daily_mileage_km, first_total_mileage_km, latest_total_mileage_km, trusted_source_key, trusted_phone, trusted_source_endpoint, sample_count, updated_at FROM vehicle_daily_mileage ``` Scan into `sql.NullString` values and set pointers when valid. - [ ] **Step 4: Run query tests** Run: ```bash go test ./internal/stats -run 'TestMetricQuery|TestMetricHandler' -count=1 ``` Expected: PASS. - [ ] **Step 5: Commit query compatibility** Run: ```bash git add go/vehicle-gateway/internal/stats/query.go go/vehicle-gateway/internal/stats/query_test.go git commit -m "feat(stats): expose selected mileage source" ``` --- ### Task 7: Deploy, Rebuild, And Verify Production Data **Files:** - Uses built binaries from `go/vehicle-gateway/cmd/stat-writer` - Uses built binaries from `go/vehicle-gateway/cmd/stats-backfill` **Interfaces:** - Consumes: ECS env files at `/opt/lingniu-go-native/env/stat-writer.env` and `/opt/lingniu-go-native/env/history-writer.env` - Produces: source tables and final table populated on ECS. - [ ] **Step 1: Run full test suite locally** Run: ```bash cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway go test ./... -count=1 ``` Expected: PASS for all packages. - [ ] **Step 2: Build Linux binaries** Run: ```bash mkdir -p ../../outputs/multi_source_stats_20260708/bin GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o ../../outputs/multi_source_stats_20260708/bin/stat-writer ./cmd/stat-writer GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o ../../outputs/multi_source_stats_20260708/bin/stats-backfill ./cmd/stats-backfill ``` Expected: both binaries exist in `../../outputs/multi_source_stats_20260708/bin`. - [ ] **Step 3: Upload and restart stat writer** Run: ```bash scp -q ../../outputs/multi_source_stats_20260708/bin/stat-writer root@115.29.187.205:/tmp/stat-writer.new scp -q ../../outputs/multi_source_stats_20260708/bin/stats-backfill root@115.29.187.205:/tmp/stats-backfill.new ssh root@115.29.187.205 'set -e stamp=$(date +%Y%m%d%H%M%S) cp /opt/lingniu-go-native/current/stat-writer /opt/lingniu-go-native/current/stat-writer.bak.$stamp cp /opt/lingniu-go-native/current/stats-backfill /opt/lingniu-go-native/current/stats-backfill.bak.$stamp || true install -m 755 /tmp/stat-writer.new /opt/lingniu-go-native/current/stat-writer install -m 755 /tmp/stats-backfill.new /opt/lingniu-go-native/current/stats-backfill rm -f /tmp/stat-writer.new /tmp/stats-backfill.new systemctl restart lingniu-go-stat-writer.service systemctl is-active lingniu-go-stat-writer.service journalctl -u lingniu-go-stat-writer.service -n 20 --no-pager' ``` Expected: service status is `active`. - [ ] **Step 4: Rebuild 2026-07-08 JT808** Run: ```bash ssh root@115.29.187.205 'BACKFILL_ENV_FILES=/opt/lingniu-go-native/env/stat-writer.env,/opt/lingniu-go-native/env/history-writer.env BACKFILL_METHOD=last_diff BACKFILL_DATE_FROM=2026-07-08 BACKFILL_DATE_TO=2026-07-08 BACKFILL_PROTOCOLS=JT808 BACKFILL_DRY_RUN=false BACKFILL_RESET=true /opt/lingniu-go-native/current/stats-backfill' ``` Expected output includes `stats backfill complete` and non-zero candidate/final counts. - [ ] **Step 5: Verify anomaly VIN** Run: ```bash curl -fsS 'http://115.29.187.205:20200/api/stats/daily-metrics?vin=LA9GG64L7PBAF4001&protocol=JT808&dateFrom=2026-07-08&dateTo=2026-07-08&limit=10&includeTotal=true' ``` Expected JSON contains: ```json { "vin": "LA9GG64L7PBAF4001", "daily_mileage_km": 23.1, "first_total_mileage_km": 4100.8, "latest_total_mileage_km": 4123.9 } ``` - [ ] **Step 6: Verify candidates exist** Run a temporary MySQL query binary or existing inspection helper to execute: ```sql SELECT vin, stat_date, protocol, source_key, source_ip, daily_mileage_km, quality_status, is_selected FROM vehicle_daily_mileage_source WHERE vin = 'LA9GG64L7PBAF4001' AND stat_date = '2026-07-08' AND protocol = 'JT808' ORDER BY source_key; ``` Expected: - At least one selected source row with `is_selected = 1`. - At least one non-selected row when alternate 424xx source data exists. - Non-selected invalid rows remain available for audit. - [ ] **Step 7: Verify stat writer health** Run: ```bash ssh root@115.29.187.205 "curl -fsS http://127.0.0.1:20213/metrics | grep -E 'vehicle_stat_(kafka_lag|writes)' | tail -40" ``` Expected: - `vehicle_stat_kafka_lag` is `0` for GB32960, JT808, and Yutong MQTT fields topics. - `vehicle_stat_writes_total{status="ok"}` is increasing. - [ ] **Step 8: Commit deployment-ready changes** Run: ```bash git status --short git add go/vehicle-gateway/internal/stats go/vehicle-gateway/cmd/stats-backfill git commit -m "feat(stats): persist multi-source mileage candidates" git push origin go ``` Expected: branch `go` is pushed successfully. --- ## Self-Review - Spec coverage: source metadata, source candidates, final projection, source election, API compatibility, backfill, and production verification are each covered by at least one task. - Placeholder scan: this plan contains no placeholder markers or open-ended implementation instructions. - Type consistency: source identity flows from `SourceIdentity` to `SourceMileageSample`; final projection always consumes candidate rows and updates `vehicle_daily_mileage`.