Files
lingniu-vehicle-ingest/docs/superpowers/plans/2026-07-08-multi-source-mileage-stats-implementation.md

1313 lines
40 KiB
Markdown

# 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`.