40 KiB
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_keyand never mix sources. - Final statistics use
vin + stat_date + protocoland 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.
- Owns source endpoint normalization,
- 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.
- Owns
- 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.Appendthrough source metadata, candidate upsert, and election projection.
- Keep protocol mileage extraction, route
- 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:
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:
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:
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:
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:
go test ./internal/stats -run 'TestNormalizeSourceIP|TestNewSourceIdentity|TestUpsertDataSource' -count=1
Expected: PASS.
- Step 6: Commit source metadata foundation
Run:
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:
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:
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:
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:
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:
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:
go test ./internal/stats -run 'TestSourceKey|TestUpsertSourceMileage' -count=1
Expected: PASS.
- Step 7: Commit candidate writer
Run:
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:
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:
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:
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:
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:
go test ./internal/stats -run TestProjectDailyMileageSelectsCandidateAndMarksSource -count=1
Expected: PASS.
- Step 5: Commit projector
Run:
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.Appendwrites 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:
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:
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:
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:
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:
go test ./internal/stats -count=1
Expected: PASS.
- Step 6: Commit realtime writer rewire
Run:
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:
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:
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:
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:
sourceKey := normalizedSourceKey(string(protocol), phone, "", sourceEndpoint)
- Step 4: Change backfill write path to candidates
Replace final-only writeAggregates with candidate write and projection:
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:
key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey
When a current source has no previous source, create a candidate with:
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:
go test ./cmd/stats-backfill ./internal/stats -count=1
Expected: PASS.
- Step 7: Commit backfill candidate support
Run:
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-metricsresponse 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:
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:
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:
TrustedSourceKey *string `json:"trusted_source_key,omitempty"`
TrustedPhone *string `json:"trusted_phone,omitempty"`
TrustedSourceEndpoint *string `json:"trusted_source_endpoint,omitempty"`
Modify selected columns:
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:
go test ./internal/stats -run 'TestMetricQuery|TestMetricHandler' -count=1
Expected: PASS.
- Step 5: Commit query compatibility
Run:
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.envand/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:
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:
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:
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:
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:
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:
{
"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:
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:
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_lagis0for GB32960, JT808, and Yutong MQTT fields topics. -
vehicle_stat_writes_total{status="ok"}is increasing. -
Step 8: Commit deployment-ready changes
Run:
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
SourceIdentitytoSourceMileageSample; final projection always consumes candidate rows and updatesvehicle_daily_mileage.