feat: expand vehicle data platform capabilities

This commit is contained in:
lingniu
2026-07-27 16:46:15 +08:00
parent e3a1f80f86
commit 3c4bece72c
650 changed files with 62155 additions and 2552 deletions

View File

@@ -0,0 +1,743 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"flag"
"fmt"
"math"
"os"
"regexp"
"sort"
"strings"
"time"
mysql "github.com/go-sql-driver/mysql"
)
const (
protocol = "GB32960"
legacySourceIP = "legacy-mysql.lingniu-prod"
legacySourceKey = "GB32960:LEGACY_MILEAGE@legacy-mysql.lingniu-prod"
legacySourceCode = "legacy_lingniu_prod"
legacyPlatformName = "历史生产库里程"
legacyQuality = "legacy_mysql_backfill_before_2026-07-21"
maxDailyMileageKM = 2500.0
)
var vinPattern = regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`)
type sourceRecord struct {
ID int64
Identifier string
StatDate time.Time
DailyKM sql.NullFloat64
TotalKM sql.NullFloat64
Now sql.NullTime
MappingOrigin string
VIN string
}
type mappingIndex struct {
Unique map[string]string
Ambiguous map[string]struct{}
}
type scanReport struct {
SourceRows int `json:"source_rows"`
SourceDistinctIdentifiers int `json:"source_distinct_identifiers"`
DedupedSourceRows int `json:"deduped_source_rows"`
DuplicateRows int `json:"duplicate_rows"`
ConflictingDuplicateGroups int `json:"conflicting_duplicate_groups"`
MappedByCurrentVehicle int `json:"mapped_by_current_vehicle"`
MappedByLegacyVehicle int `json:"mapped_by_legacy_vehicle"`
CurrentLegacyVINConflicts int `json:"current_legacy_vin_conflicts"`
UnresolvedRows int `json:"unresolved_rows"`
UnresolvedIdentifiers int `json:"unresolved_identifiers"`
UnresolvedEligibleRows int `json:"unresolved_eligible_rows"`
UnresolvedIdentifierValues []string `json:"unresolved_identifier_values,omitempty"`
NullMileageRows int `json:"null_mileage_rows"`
ZeroMileageRows int `json:"zero_mileage_rows"`
NegativeMileageRows int `json:"negative_mileage_rows"`
OverLimitMileageRows int `json:"over_limit_mileage_rows"`
EligibleRows int `json:"eligible_rows"`
VINDateCollisions int `json:"vin_date_collisions"`
ConflictingVINDateCollisions int `json:"conflicting_vin_date_collisions"`
}
type targetReport struct {
StageRows int64 `json:"stage_rows"`
MissingRows int64 `json:"missing_rows"`
ZeroRows int64 `json:"zero_rows"`
NonzeroProtectedRows int64 `json:"nonzero_protected_rows"`
ExistingPositiveCandidateRows int64 `json:"existing_positive_candidate_rows"`
ProtectedSameMileageRows int64 `json:"protected_same_mileage_rows"`
ProtectedMileageSumKM float64 `json:"protected_mileage_sum_km"`
ProtectedMileageFingerprint uint64 `json:"protected_mileage_fingerprint"`
LegacyCandidateRows int64 `json:"legacy_candidate_rows"`
LegacyCandidateTotalMatchedRows int64 `json:"legacy_candidate_total_matched_rows"`
LegacyProjectedRows int64 `json:"legacy_projected_rows"`
LegacyProjectedTotalMatchedRows int64 `json:"legacy_projected_total_matched_rows"`
LegacySelectedCandidateRows int64 `json:"legacy_selected_candidate_rows"`
PreexistingNonLegacyRows int64 `json:"preexisting_non_legacy_rows"`
PreexistingNonLegacyMileageSumKM float64 `json:"preexisting_non_legacy_mileage_sum_km"`
PreexistingNonLegacyFingerprint uint64 `json:"preexisting_non_legacy_fingerprint"`
RemainingMissingOrZeroRows int64 `json:"remaining_missing_or_zero_rows"`
PostProtectedRows int64 `json:"post_protected_rows"`
PostProtectedMileageSumKM float64 `json:"post_protected_mileage_sum_km"`
PostProtectedMileageFingerprint uint64 `json:"post_protected_mileage_fingerprint"`
ProtectedRowsUnchanged int64 `json:"protected_rows_unchanged"`
ProtectedMileageFingerprintStable bool `json:"protected_mileage_fingerprint_stable"`
}
type output struct {
Mode string `json:"mode"`
CutoffExclusive string `json:"cutoff_exclusive"`
Protocol string `json:"protocol"`
SourceTable string `json:"source_table"`
TotalMileageDivisor float64 `json:"total_mileage_divisor_m_to_km"`
Scan scanReport `json:"scan"`
Target targetReport `json:"target"`
Duration string `json:"duration"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
var cutoffRaw, dsn string
var apply bool
var timeout time.Duration
flag.StringVar(&cutoffRaw, "before", "2026-07-21", "exclusive upper date boundary (YYYY-MM-DD)")
flag.StringVar(&dsn, "mysql-dsn", strings.TrimSpace(os.Getenv("MYSQL_DSN")), "target MySQL DSN")
flag.BoolVar(&apply, "apply", false, "apply the migration; default is dry-run")
flag.DurationVar(&timeout, "timeout", 10*time.Minute, "overall timeout")
flag.Parse()
started := time.Now()
cutoff, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(cutoffRaw), time.FixedZone("Asia/Shanghai", 8*3600))
if err != nil {
return fmt.Errorf("invalid -before date: %w", err)
}
if strings.TrimSpace(dsn) == "" {
return errors.New("MYSQL_DSN or -mysql-dsn is required")
}
dsn, err = normalizedDSN(dsn)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
db, err := sql.Open("mysql", dsn)
if err != nil {
return err
}
defer db.Close()
db.SetMaxOpenConns(2)
if err := db.PingContext(ctx); err != nil {
return err
}
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
currentMappings, err := loadMappingIndex(ctx, conn, `
SELECT CONVERT(plate USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM vehicle
WHERE plate IS NOT NULL AND TRIM(plate) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM vehicle_identifier
WHERE enabled = 1 AND plate IS NOT NULL AND TRIM(plate) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''`)
if err != nil {
return fmt.Errorf("load current vehicle mappings: %w", err)
}
legacyMappings, err := loadMappingIndex(ctx, conn, `
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.tab_truck
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.truck_info
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.v_vehicle_daily_stats
WHERE plate IS NOT NULL AND TRIM(plate) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.tab_truck_remote_sync_realtime_info
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.view_truck_comprehensive_info
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin_code USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.tab_hecri_hydrogen_order
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin_code IS NOT NULL AND TRIM(vin_code) <> ''
UNION ALL
SELECT CONVERT(plate USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.bi_ele_charge_record
WHERE plate IS NOT NULL AND TRIM(plate) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.v_truck_task_basic
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.view_vehicle_annual_inspection
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''`)
if err != nil {
return fmt.Errorf("load legacy vehicle mappings: %w", err)
}
records, report, err := loadAndResolveSource(ctx, conn, cutoff, currentMappings, legacyMappings)
if err != nil {
return err
}
if err := createAndLoadStage(ctx, conn, records); err != nil {
return err
}
target, err := inspectTarget(ctx, conn)
if err != nil {
return err
}
mode := "dry_run"
if apply {
mode = "apply"
if err := createProtectedSnapshot(ctx, conn); err != nil {
return err
}
if err := applyMigration(ctx, conn); err != nil {
return err
}
post, err := inspectTarget(ctx, conn)
if err != nil {
return err
}
post.PostProtectedRows, post.PostProtectedMileageSumKM, post.PostProtectedMileageFingerprint, post.ProtectedRowsUnchanged, err = inspectProtectedSnapshot(ctx, conn)
if err != nil {
return err
}
post.ProtectedMileageFingerprintStable =
target.PreexistingNonLegacyRows == post.PostProtectedRows &&
math.Abs(target.PreexistingNonLegacyMileageSumKM-post.PostProtectedMileageSumKM) < 0.0005 &&
target.PreexistingNonLegacyFingerprint == post.PostProtectedMileageFingerprint &&
post.ProtectedRowsUnchanged == target.PreexistingNonLegacyRows
post.NonzeroProtectedRows = target.NonzeroProtectedRows
post.ProtectedSameMileageRows = target.ProtectedSameMileageRows
post.ProtectedMileageSumKM = target.ProtectedMileageSumKM
post.ProtectedMileageFingerprint = target.ProtectedMileageFingerprint
target = post
}
result := output{
Mode: mode, CutoffExclusive: cutoff.Format("2006-01-02"), Protocol: protocol,
SourceTable: "lingniu_prod.ln_vehicle_day_mileage", TotalMileageDivisor: 1000, Scan: report, Target: target,
Duration: time.Since(started).Round(time.Millisecond).String(),
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
func createProtectedSnapshot(ctx context.Context, conn *sql.Conn) error {
_, err := conn.ExecContext(ctx, `
CREATE TEMPORARY TABLE tmp_legacy_mileage_protected AS
SELECT m.vin, m.stat_date, m.protocol, m.source_id, m.daily_mileage_km,
m.latest_total_mileage_km, m.updated_at
FROM tmp_legacy_mileage_stage st
JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
LEFT JOIN vehicle_data_source ds ON ds.id = m.source_id
WHERE m.daily_mileage_km > 0 AND COALESCE(ds.source_ip, '') <> ?`, legacySourceIP)
if err != nil {
return fmt.Errorf("snapshot protected mileage rows: %w", err)
}
return nil
}
func inspectProtectedSnapshot(ctx context.Context, queryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}) (count int64, sum float64, fingerprint uint64, unchanged int64, err error) {
var nullableSum sql.NullFloat64
err = queryer.QueryRowContext(ctx, `
SELECT
COUNT(m.vin),
SUM(CASE WHEN m.vin IS NOT NULL THEN m.daily_mileage_km ELSE 0 END),
COALESCE(BIT_XOR(CASE WHEN m.vin IS NOT NULL THEN CRC32(CONCAT_WS('|', m.vin, m.stat_date, m.protocol, m.daily_mileage_km, COALESCE(m.latest_total_mileage_km, 'NULL'))) ELSE 0 END), 0),
COALESCE(SUM(
m.vin IS NOT NULL
AND (m.source_id <=> p.source_id)
AND (m.daily_mileage_km <=> p.daily_mileage_km)
AND (m.latest_total_mileage_km <=> p.latest_total_mileage_km)
AND (m.updated_at <=> p.updated_at)
), 0)
FROM tmp_legacy_mileage_protected p
LEFT JOIN vehicle_daily_mileage m
ON m.vin = p.vin AND m.stat_date = p.stat_date AND m.protocol = p.protocol`).Scan(
&count, &nullableSum, &fingerprint, &unchanged,
)
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("verify protected mileage snapshot: %w", err)
}
if nullableSum.Valid {
sum = nullableSum.Float64
}
return count, sum, fingerprint, unchanged, nil
}
func normalizedDSN(raw string) (string, error) {
cfg, err := mysql.ParseDSN(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("parse mysql dsn: %w", err)
}
cfg.ParseTime = true
cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600)
cfg.Params = cloneMap(cfg.Params)
cfg.Params["charset"] = "utf8mb4"
return cfg.FormatDSN(), nil
}
func cloneMap(source map[string]string) map[string]string {
result := make(map[string]string, len(source)+1)
for key, value := range source {
result[key] = value
}
return result
}
func loadMappingIndex(ctx context.Context, queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, query string) (mappingIndex, error) {
rows, err := queryer.QueryContext(ctx, query)
if err != nil {
return mappingIndex{}, err
}
defer rows.Close()
values := map[string]map[string]struct{}{}
for rows.Next() {
var plate, vin sql.NullString
if err := rows.Scan(&plate, &vin); err != nil {
return mappingIndex{}, err
}
plateValue := normalizeIdentifier(plate.String)
vinValue := normalizeVIN(vin.String)
if plateValue == "" || !vinPattern.MatchString(vinValue) {
continue
}
if values[plateValue] == nil {
values[plateValue] = map[string]struct{}{}
}
values[plateValue][vinValue] = struct{}{}
}
if err := rows.Err(); err != nil {
return mappingIndex{}, err
}
result := mappingIndex{Unique: map[string]string{}, Ambiguous: map[string]struct{}{}}
for plate, vins := range values {
if len(vins) != 1 {
result.Ambiguous[plate] = struct{}{}
continue
}
for vin := range vins {
result.Unique[plate] = vin
}
}
return result, nil
}
func loadAndResolveSource(ctx context.Context, queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, cutoff time.Time, current, legacy mappingIndex) ([]sourceRecord, scanReport, error) {
rows, err := queryer.QueryContext(ctx, `
SELECT id, plateNumber, dates, dayMileage, totalMileage, nowDatetime
FROM lingniu_prod.ln_vehicle_day_mileage
WHERE dates < ?
ORDER BY plateNumber, dates, (nowDatetime IS NULL), nowDatetime DESC, id DESC`, cutoff)
if err != nil {
return nil, scanReport{}, fmt.Errorf("query source mileage: %w", err)
}
defer rows.Close()
var report scanReport
distinctIdentifiers := map[string]struct{}{}
unresolvedIdentifiers := map[string]struct{}{}
byIdentifierDate := map[string]sourceRecord{}
duplicateValues := map[string]float64{}
conflictingDuplicateKeys := map[string]struct{}{}
for rows.Next() {
var item sourceRecord
var rawIdentifier sql.NullString
if err := rows.Scan(&item.ID, &rawIdentifier, &item.StatDate, &item.DailyKM, &item.TotalKM, &item.Now); err != nil {
return nil, report, err
}
report.SourceRows++
item.Identifier = normalizeIdentifier(rawIdentifier.String)
distinctIdentifiers[item.Identifier] = struct{}{}
key := item.Identifier + "\x00" + item.StatDate.Format("2006-01-02")
if existing, ok := byIdentifierDate[key]; ok {
report.DuplicateRows++
if item.DailyKM.Valid && existing.DailyKM.Valid && math.Abs(item.DailyKM.Float64-existing.DailyKM.Float64) > 0.0005 {
conflictingDuplicateKeys[key] = struct{}{}
}
continue
}
byIdentifierDate[key] = item
if item.DailyKM.Valid {
duplicateValues[key] = item.DailyKM.Float64
}
}
if err := rows.Err(); err != nil {
return nil, report, err
}
_ = duplicateValues
report.SourceDistinctIdentifiers = len(distinctIdentifiers)
report.DedupedSourceRows = len(byIdentifierDate)
report.ConflictingDuplicateGroups = len(conflictingDuplicateKeys)
keys := make([]string, 0, len(byIdentifierDate))
for key := range byIdentifierDate {
keys = append(keys, key)
}
sort.Strings(keys)
stageByVINDate := map[string]sourceRecord{}
var stageOrder []string
for _, key := range keys {
item := byIdentifierDate[key]
vin, origin, conflict := resolveVIN(item.Identifier, current, legacy)
if conflict {
report.CurrentLegacyVINConflicts++
}
if vin == "" {
report.UnresolvedRows++
unresolvedIdentifiers[item.Identifier] = struct{}{}
if item.DailyKM.Valid && item.DailyKM.Float64 > 0 && item.DailyKM.Float64 <= maxDailyMileageKM {
report.UnresolvedEligibleRows++
}
continue
}
item.VIN = vin
item.MappingOrigin = origin
if origin == "current_vehicle" {
report.MappedByCurrentVehicle++
} else {
report.MappedByLegacyVehicle++
}
if !item.DailyKM.Valid {
report.NullMileageRows++
continue
}
switch {
case item.DailyKM.Float64 == 0:
report.ZeroMileageRows++
continue
case item.DailyKM.Float64 < 0:
report.NegativeMileageRows++
continue
case item.DailyKM.Float64 > maxDailyMileageKM:
report.OverLimitMileageRows++
continue
}
report.EligibleRows++
stageKey := item.VIN + "\x00" + item.StatDate.Format("2006-01-02")
if existing, ok := stageByVINDate[stageKey]; ok {
report.VINDateCollisions++
if math.Abs(existing.DailyKM.Float64-item.DailyKM.Float64) > 0.0005 {
report.ConflictingVINDateCollisions++
}
if item.ID <= existing.ID {
continue
}
} else {
stageOrder = append(stageOrder, stageKey)
}
stageByVINDate[stageKey] = item
}
report.UnresolvedIdentifiers = len(unresolvedIdentifiers)
for identifier := range unresolvedIdentifiers {
report.UnresolvedIdentifierValues = append(report.UnresolvedIdentifierValues, identifier)
}
sort.Strings(report.UnresolvedIdentifierValues)
sort.Strings(stageOrder)
stage := make([]sourceRecord, 0, len(stageOrder))
for _, key := range stageOrder {
stage = append(stage, stageByVINDate[key])
}
return stage, report, nil
}
func resolveVIN(identifier string, current, legacy mappingIndex) (vin, origin string, conflict bool) {
if vinPattern.MatchString(identifier) {
return identifier, "source_vin", false
}
currentVIN, currentOK := current.Unique[identifier]
legacyVIN, legacyOK := legacy.Unique[identifier]
if currentOK && legacyOK && currentVIN != legacyVIN {
return "", "", true
}
if currentOK {
return currentVIN, "current_vehicle", false
}
if legacyOK {
return legacyVIN, "legacy_vehicle", false
}
return "", "", false
}
func normalizeIdentifier(value string) string {
return strings.ToUpper(strings.Join(strings.Fields(strings.TrimSpace(value)), ""))
}
func normalizeVIN(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func createAndLoadStage(ctx context.Context, conn *sql.Conn, records []sourceRecord) error {
if _, err := conn.ExecContext(ctx, `
CREATE TEMPORARY TABLE tmp_legacy_mileage_stage (
vin VARCHAR(32) NOT NULL,
stat_date DATE NOT NULL,
daily_mileage_km DECIMAL(18,3) NOT NULL,
total_mileage_km DECIMAL(18,3) NULL,
source_record_id BIGINT NOT NULL,
mapping_origin VARCHAR(32) NOT NULL,
PRIMARY KEY (vin, stat_date)
) ENGINE=InnoDB`); err != nil {
return fmt.Errorf("create temporary stage: %w", err)
}
const batchSize = 500
for start := 0; start < len(records); start += batchSize {
end := min(start+batchSize, len(records))
var query strings.Builder
query.WriteString("INSERT INTO tmp_legacy_mileage_stage (vin, stat_date, daily_mileage_km, total_mileage_km, source_record_id, mapping_origin) VALUES ")
args := make([]any, 0, (end-start)*6)
for index, item := range records[start:end] {
if index > 0 {
query.WriteByte(',')
}
query.WriteString("(?,?,?,?,?,?)")
args = append(args, item.VIN, item.StatDate, item.DailyKM.Float64, nullableTotalMileageKM(item.TotalKM), item.ID, item.MappingOrigin)
}
if _, err := conn.ExecContext(ctx, query.String(), args...); err != nil {
return fmt.Errorf("load temporary stage batch %d: %w", start/batchSize+1, err)
}
}
return nil
}
func nullableTotalMileageKM(value sql.NullFloat64) any {
if !value.Valid {
return nil
}
return value.Float64 / 1000
}
func inspectTarget(ctx context.Context, queryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}) (targetReport, error) {
var report targetReport
var protectedSum sql.NullFloat64
err := queryer.QueryRowContext(ctx, `
SELECT
COUNT(*),
COALESCE(SUM(m.vin IS NULL), 0),
COALESCE(SUM(m.vin IS NOT NULL AND m.daily_mileage_km = 0), 0),
COALESCE(SUM(m.daily_mileage_km > 0), 0),
COALESCE(SUM(m.daily_mileage_km > 0 AND ABS(m.daily_mileage_km - st.daily_mileage_km) <= 0.011), 0),
SUM(CASE WHEN m.daily_mileage_km > 0 THEN m.daily_mileage_km ELSE 0 END),
COALESCE(BIT_XOR(CASE WHEN m.daily_mileage_km > 0 THEN CRC32(CONCAT_WS('|', m.vin, m.stat_date, m.protocol, m.daily_mileage_km, COALESCE(m.latest_total_mileage_km, 'NULL'))) ELSE 0 END), 0),
COALESCE(SUM(candidate.vin IS NOT NULL AND (m.vin IS NULL OR m.daily_mileage_km = 0)), 0)
FROM tmp_legacy_mileage_stage st
LEFT JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
LEFT JOIN (
SELECT vin, stat_date
FROM vehicle_daily_mileage_source
WHERE protocol = 'GB32960' AND quality_status = 'OK' AND daily_mileage_km > 0
GROUP BY vin, stat_date
) candidate ON candidate.vin = st.vin AND candidate.stat_date = st.stat_date`).Scan(
&report.StageRows, &report.MissingRows, &report.ZeroRows, &report.NonzeroProtectedRows,
&report.ProtectedSameMileageRows, &protectedSum, &report.ProtectedMileageFingerprint,
&report.ExistingPositiveCandidateRows,
)
if err != nil {
return report, fmt.Errorf("inspect migration target: %w", err)
}
if protectedSum.Valid {
report.ProtectedMileageSumKM = protectedSum.Float64
}
if err := queryer.QueryRowContext(ctx, `
SELECT COUNT(*), COALESCE(SUM(s.latest_total_mileage_km <=> st.total_mileage_km), 0)
FROM vehicle_daily_mileage_source s
JOIN tmp_legacy_mileage_stage st ON st.vin = s.vin AND st.stat_date = s.stat_date
WHERE s.protocol = 'GB32960' AND s.source_key = ?`, legacySourceKey).Scan(
&report.LegacyCandidateRows, &report.LegacyCandidateTotalMatchedRows,
); err != nil {
return report, err
}
if err := queryer.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM vehicle_daily_mileage_source s
JOIN tmp_legacy_mileage_stage st ON st.vin = s.vin AND st.stat_date = s.stat_date
WHERE s.protocol = 'GB32960' AND s.source_key = ? AND s.is_selected = 1`, legacySourceKey).Scan(&report.LegacySelectedCandidateRows); err != nil {
return report, err
}
if err := queryer.QueryRowContext(ctx, `
SELECT COUNT(*), COALESCE(SUM(m.latest_total_mileage_km <=> st.total_mileage_km), 0)
FROM vehicle_daily_mileage m
JOIN tmp_legacy_mileage_stage st ON st.vin = m.vin AND st.stat_date = m.stat_date
JOIN vehicle_data_source ds ON ds.id = m.source_id
WHERE m.protocol = 'GB32960' AND ds.protocol = 'GB32960' AND ds.source_ip = ?
AND ABS(m.daily_mileage_km - st.daily_mileage_km) <= 0.0005`, legacySourceIP).Scan(
&report.LegacyProjectedRows, &report.LegacyProjectedTotalMatchedRows,
); err != nil {
return report, err
}
if err := queryer.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM tmp_legacy_mileage_stage st
LEFT JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
WHERE m.vin IS NULL OR m.daily_mileage_km = 0`).Scan(&report.RemainingMissingOrZeroRows); err != nil {
return report, err
}
var nonLegacySum sql.NullFloat64
if err := queryer.QueryRowContext(ctx, `
SELECT
COUNT(*),
SUM(m.daily_mileage_km),
COALESCE(BIT_XOR(CRC32(CONCAT_WS('|', m.vin, m.stat_date, m.protocol, m.daily_mileage_km, COALESCE(m.latest_total_mileage_km, 'NULL')))), 0)
FROM vehicle_daily_mileage m
JOIN tmp_legacy_mileage_stage st ON st.vin = m.vin AND st.stat_date = m.stat_date
LEFT JOIN vehicle_data_source ds ON ds.id = m.source_id
WHERE m.protocol = 'GB32960' AND m.daily_mileage_km > 0
AND COALESCE(ds.source_ip, '') <> ?`, legacySourceIP).Scan(
&report.PreexistingNonLegacyRows, &nonLegacySum, &report.PreexistingNonLegacyFingerprint,
); err != nil {
return report, err
}
if nonLegacySum.Valid {
report.PreexistingNonLegacyMileageSumKM = nonLegacySum.Float64
}
return report, nil
}
func applyMigration(ctx context.Context, conn *sql.Conn) error {
tx, err := conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_data_source
(protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind,
trust_priority, enabled, first_seen_at, latest_seen_at, remark)
VALUES ('GB32960', ?, 'lingniu_prod.ln_vehicle_day_mileage', ?, ?, 'UNKNOWN', 1000, 1,
'2024-10-17 00:00:00', '2026-07-20 23:59:59',
'仅用于迁移 2026-07-21 之前缺失或为 0 的 GB32960 日里程')
ON DUPLICATE KEY UPDATE
latest_source_endpoint = VALUES(latest_source_endpoint),
platform_name = VALUES(platform_name),
source_code = VALUES(source_code),
source_kind = VALUES(source_kind),
trust_priority = VALUES(trust_priority),
enabled = 1,
remark = VALUES(remark)`, legacySourceIP, legacyPlatformName, legacySourceCode); err != nil {
return fmt.Errorf("upsert legacy data source: %w", err)
}
var sourceID int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM vehicle_data_source WHERE protocol = 'GB32960' AND source_ip = ?`, legacySourceIP).Scan(&sourceID); err != nil {
return fmt.Errorf("lookup legacy data source: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, 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, is_selected)
SELECT
st.vin, st.stat_date, 'GB32960', ?, ?, 'lingniu_prod.ln_vehicle_day_mileage', ?,
CASE WHEN st.total_mileage_km IS NOT NULL AND st.total_mileage_km >= st.daily_mileage_km
THEN st.total_mileage_km - st.daily_mileage_km ELSE NULL END,
st.total_mileage_km, st.daily_mileage_km, 1,
TIMESTAMP(st.stat_date, '00:00:00'), TIMESTAMP(st.stat_date, '23:59:59'),
'OK', ?, 0
FROM tmp_legacy_mileage_stage st
LEFT JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
WHERE m.vin IS NULL OR m.daily_mileage_km = 0 OR m.source_id = ?
ON DUPLICATE KEY UPDATE
updated_at = IF(
NOT (vehicle_daily_mileage_source.first_total_mileage_km <=> VALUES(first_total_mileage_km))
OR NOT (vehicle_daily_mileage_source.latest_total_mileage_km <=> VALUES(latest_total_mileage_km))
OR NOT (vehicle_daily_mileage_source.daily_mileage_km <=> VALUES(daily_mileage_km))
OR NOT (vehicle_daily_mileage_source.quality_status <=> VALUES(quality_status))
OR NOT (vehicle_daily_mileage_source.quality_reason <=> VALUES(quality_reason)),
CURRENT_TIMESTAMP,
vehicle_daily_mileage_source.updated_at
),
source_ip = VALUES(source_ip),
source_endpoint = VALUES(source_endpoint),
platform_name = VALUES(platform_name),
first_total_mileage_km = VALUES(first_total_mileage_km),
latest_total_mileage_km = VALUES(latest_total_mileage_km),
daily_mileage_km = VALUES(daily_mileage_km),
sample_count = VALUES(sample_count),
first_event_time = VALUES(first_event_time),
latest_event_time = VALUES(latest_event_time),
quality_status = VALUES(quality_status),
quality_reason = VALUES(quality_reason)`, legacySourceKey, legacySourceIP, legacyPlatformName, legacyQuality, sourceID); err != nil {
return fmt.Errorf("upsert legacy mileage candidates: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_daily_mileage
(vin, stat_date, protocol, source_id, daily_mileage_km, latest_total_mileage_km)
SELECT st.vin, st.stat_date, 'GB32960', ?, st.daily_mileage_km, st.total_mileage_km
FROM tmp_legacy_mileage_stage st
LEFT JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
WHERE m.vin IS NULL OR m.daily_mileage_km = 0 OR m.source_id = ?
ON DUPLICATE KEY UPDATE
updated_at = IF(
vehicle_daily_mileage.daily_mileage_km = 0
OR (
(vehicle_daily_mileage.source_id <=> VALUES(source_id))
AND (
NOT (vehicle_daily_mileage.daily_mileage_km <=> VALUES(daily_mileage_km))
OR NOT (vehicle_daily_mileage.latest_total_mileage_km <=> VALUES(latest_total_mileage_km))
)
),
CURRENT_TIMESTAMP,
vehicle_daily_mileage.updated_at
),
source_id = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(source_id), vehicle_daily_mileage.source_id),
latest_total_mileage_km = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(latest_total_mileage_km), vehicle_daily_mileage.latest_total_mileage_km),
daily_mileage_km = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(daily_mileage_km), vehicle_daily_mileage.daily_mileage_km)`, sourceID, sourceID); err != nil {
return fmt.Errorf("upsert final daily mileage: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE vehicle_daily_mileage_source s
JOIN tmp_legacy_mileage_stage st
ON st.vin = s.vin AND st.stat_date = s.stat_date
JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
SET s.is_selected = CASE WHEN s.source_key = ? THEN 1 ELSE 0 END
WHERE s.protocol = 'GB32960'
AND m.source_id = ?
AND ABS(m.daily_mileage_km - st.daily_mileage_km) <= 0.0005`, legacySourceKey, sourceID); err != nil {
return fmt.Errorf("mark legacy mileage selections: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration: %w", err)
}
return nil
}

View File

@@ -0,0 +1,56 @@
package main
import (
"database/sql"
"testing"
)
func TestResolveVINPrefersConsistentCurrentMapping(t *testing.T) {
current := mappingIndex{Unique: map[string]string{"沪A12345": "LA9GG64L0NBAF4175"}}
legacy := mappingIndex{Unique: map[string]string{"沪A12345": "LA9GG64L0NBAF4175"}}
vin, origin, conflict := resolveVIN("沪A12345", current, legacy)
if conflict || vin != "LA9GG64L0NBAF4175" || origin != "current_vehicle" {
t.Fatalf("unexpected resolution: vin=%q origin=%q conflict=%v", vin, origin, conflict)
}
}
func TestResolveVINRejectsCurrentLegacyConflict(t *testing.T) {
current := mappingIndex{Unique: map[string]string{"沪A12345": "LA9GG64L0NBAF4175"}}
legacy := mappingIndex{Unique: map[string]string{"沪A12345": "LA9GG64L0NBAF4176"}}
vin, _, conflict := resolveVIN("沪A12345", current, legacy)
if !conflict || vin != "" {
t.Fatalf("expected conflict rejection, got vin=%q conflict=%v", vin, conflict)
}
}
func TestResolveVINUsesLegacyFallback(t *testing.T) {
current := mappingIndex{Unique: map[string]string{}}
legacy := mappingIndex{Unique: map[string]string{"沪A12345": "LA9GG64L0NBAF4175"}}
vin, origin, conflict := resolveVIN("沪A12345", current, legacy)
if conflict || vin != "LA9GG64L0NBAF4175" || origin != "legacy_vehicle" {
t.Fatalf("unexpected resolution: vin=%q origin=%q conflict=%v", vin, origin, conflict)
}
}
func TestResolveVINAcceptsSourceVIN(t *testing.T) {
vin, origin, conflict := resolveVIN("LA9GG64L0NBAF4175", mappingIndex{}, mappingIndex{})
if conflict || vin != "LA9GG64L0NBAF4175" || origin != "source_vin" {
t.Fatalf("unexpected resolution: vin=%q origin=%q conflict=%v", vin, origin, conflict)
}
}
func TestNormalizeIdentifierRemovesWhitespace(t *testing.T) {
if got := normalizeIdentifier(" 沪 a 12345 \n"); got != "沪A12345" {
t.Fatalf("normalizeIdentifier() = %q", got)
}
}
func TestNullableTotalMileageConvertsMetersToKM(t *testing.T) {
got := nullableTotalMileageKM(sql.NullFloat64{Float64: 59198000, Valid: true})
if got != 59198.0 {
t.Fatalf("nullableTotalMileageKM() = %#v", got)
}
if got := nullableTotalMileageKM(sql.NullFloat64{}); got != nil {
t.Fatalf("invalid mileage should remain nil, got %#v", got)
}
}

View File

@@ -75,6 +75,13 @@ func main() {
os.Exit(1)
}
}
if cfg.HydrogenCapacitySyncEnabled {
syncHydrogenCapacities(ctx, logger, registry, db, writer, cfg)
} else if loaded, err := writer.ReloadHydrogenTankCapacities(ctx); err != nil {
logger.Warn("hydrogen tank capacity cache load failed", "error", err)
} else {
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
}
if cfg.NormalizePlatformSourcesOnStart {
statDate := time.Now().In(cfg.Location).Format("2006-01-02")
normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout)
@@ -108,6 +115,13 @@ func main() {
logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "project_interval_seconds", cfg.ProjectInterval.Seconds(), "source_touch_interval_seconds", cfg.SourceTouchInterval.Seconds(), "cache_retention_seconds", cfg.CacheRetention.Seconds(), "cache_cleanup_interval_seconds", cfg.CacheCleanupInterval.Seconds(), "baseline_miss_ttl_seconds", cfg.BaselineMissTTL.Seconds(), "baseline_hit_ttl_seconds", cfg.BaselineHitTTL.Seconds(), "cache_max_entries", cfg.CacheMaxEntries, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait, "retry_attempts", cfg.RetryAttempts, "retry_delay_ms", cfg.RetryDelay.Milliseconds(), "quarantine_dir", cfg.QuarantineDir)
var background sync.WaitGroup
if cfg.HydrogenCapacitySyncEnabled && cfg.HydrogenCapacitySyncInterval > 0 {
background.Add(1)
go func() {
defer background.Done()
runHydrogenCapacitySync(ctx, logger, registry, db, writer, cfg)
}()
}
if cfg.ProjectInterval > 0 {
background.Add(1)
go func() {
@@ -132,6 +146,46 @@ func main() {
}
}
func runHydrogenCapacitySync(ctx context.Context, logger interface {
Info(string, ...any)
Warn(string, ...any)
}, registry *metrics.Registry, db *sql.DB, writer *stats.Writer, cfg config) {
ticker := time.NewTicker(cfg.HydrogenCapacitySyncInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
syncHydrogenCapacities(ctx, logger, registry, db, writer, cfg)
}
}
}
func syncHydrogenCapacities(ctx context.Context, logger interface {
Info(string, ...any)
Warn(string, ...any)
}, registry *metrics.Registry, db *sql.DB, writer *stats.Writer, cfg config) {
syncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.HydrogenCapacitySyncTimeout)
defer cancel()
result, err := stats.SyncHydrogenTankCapacities(syncCtx, db, cfg.HydrogenCapacitySourceSchema)
if err != nil {
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "error"})
logger.Warn("hydrogen tank capacity sync failed; retaining current cache", "source_schema", cfg.HydrogenCapacitySourceSchema, "error", err)
return
}
loaded, err := writer.ReloadHydrogenTankCapacities(syncCtx)
if err != nil {
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "cache_error"})
logger.Warn("hydrogen tank capacity cache reload failed", "error", err)
return
}
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "ok"})
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
metrics.RecordLastActivity(registry, "vehicle_stat_hydrogen_capacity_last_sync_unix_seconds", nil)
logger.Info("hydrogen tank capacities synchronized", "source_schema", cfg.HydrogenCapacitySourceSchema, "read", result.Read, "written", result.Written, "deactivated", result.Deactivated, "cache_entries", loaded)
}
type pendingProjectionFlusher interface {
FlushPendingProjections(context.Context, time.Time) (stats.ProjectionFlushResult, error)
}
@@ -790,6 +844,23 @@ func recordStatSampleMetrics(registry *metrics.Registry, message kafka.Message,
addStatSampleMetric(registry, message, protocol, "skipped_same_mileage", result.SamplesSkippedSameMileage)
addStatSampleMetric(registry, message, protocol, "skipped_missing_source", result.SamplesSkippedMissingSource)
addStatSampleMetric(registry, message, protocol, "event_time_future_adjusted", result.SamplesAdjustedFutureEventTime)
addHydrogenStreamMetric(registry, message, "found", result.HydrogenSamplesFound)
addHydrogenStreamMetric(registry, message, "written", result.HydrogenSamplesWritten)
addHydrogenStreamMetric(registry, message, "duplicate_or_late", result.HydrogenSamplesDuplicate)
addHydrogenStreamMetric(registry, message, "invalid", result.HydrogenSamplesInvalid)
addHydrogenStreamMetric(registry, message, "not_current_date", result.HydrogenSamplesNotCurrent)
}
func addHydrogenStreamMetric(registry *metrics.Registry, message kafka.Message, status string, count int) {
if registry == nil || count == 0 {
return
}
registry.AddCounter("vehicle_stat_hydrogen_stream_total", metrics.Labels{
"topic": message.Topic, "status": status,
}, float64(count))
if status == "written" {
metrics.RecordLastActivity(registry, "vehicle_stat_last_hydrogen_stream_write_unix_seconds", metrics.Labels{"status": status})
}
}
func addStatSampleMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
@@ -950,6 +1021,10 @@ type config struct {
ReconcileProjectionsOnStart bool
ReconcileProjectionsTimeout time.Duration
QuarantineDir string
HydrogenCapacitySyncEnabled bool
HydrogenCapacitySourceSchema string
HydrogenCapacitySyncInterval time.Duration
HydrogenCapacitySyncTimeout time.Duration
}
func (c config) Validate() error {
@@ -996,6 +1071,10 @@ func loadConfig() config {
ReconcileProjectionsOnStart: env("STATS_RECONCILE_PROJECTIONS_ON_START", "true") != "false",
ReconcileProjectionsTimeout: time.Duration(envInt("STATS_RECONCILE_PROJECTIONS_TIMEOUT_SECONDS", 30)) * time.Second,
QuarantineDir: env("STATS_QUARANTINE_DIR", "/var/lib/lingniu-go-native/stat-writer-quarantine"),
HydrogenCapacitySyncEnabled: env("HYDROGEN_CAPACITY_SYNC_ENABLED", "true") != "false",
HydrogenCapacitySourceSchema: env("HYDROGEN_CAPACITY_SOURCE_SCHEMA", "ln_asset_management"),
HydrogenCapacitySyncInterval: time.Duration(envInt("HYDROGEN_CAPACITY_SYNC_INTERVAL_SECONDS", 21600)) * time.Second,
HydrogenCapacitySyncTimeout: time.Duration(envInt("HYDROGEN_CAPACITY_SYNC_TIMEOUT_SECONDS", 60)) * time.Second,
}
}

View File

@@ -57,23 +57,27 @@ type rawFrameRow struct {
}
type metricAgg struct {
VIN string
Date string
Protocol envelope.Protocol
FirstKM float64
LatestKM float64
Count int64
SourceKey string
Phone string
DeviceID string
SourceEndpoint string
SourceCode string
PlatformName string
SourceKind string
FirstEventTime time.Time
LatestEventTime time.Time
QualityStatus string
QualityReason string
VIN string
Date string
Protocol envelope.Protocol
FirstKM float64
LatestKM float64
PureHydrogenMileageKM float64
Count int64
PureHydrogenSampleCount int64
SourceKey string
Phone string
DeviceID string
SourceEndpoint string
SourceCode string
PlatformName string
SourceKind string
FirstEventTime time.Time
LatestEventTime time.Time
LatestPureHydrogenActive bool
LatestPureHydrogenModeKnown bool
QualityStatus string
QualityReason string
}
type dailySourceLast struct {
@@ -266,21 +270,24 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
agg, ok := aggregates[key]
if !ok {
aggregates[key] = &metricAgg{
VIN: sample.VIN,
Date: sample.StatDate,
Protocol: sample.Protocol,
FirstKM: sample.TotalMileageKM,
LatestKM: sample.TotalMileageKM,
Count: 1,
SourceKey: sample.SourceKey,
Phone: sample.Phone,
DeviceID: sample.DeviceID,
SourceEndpoint: sample.SourceEndpoint,
PlatformName: sample.PlatformName,
FirstEventTime: sample.EventTime,
LatestEventTime: sample.EventTime,
QualityStatus: stats.QualityOK,
QualityReason: "current_day_first_sample",
VIN: sample.VIN,
Date: sample.StatDate,
Protocol: sample.Protocol,
FirstKM: sample.TotalMileageKM,
LatestKM: sample.TotalMileageKM,
Count: 1,
PureHydrogenSampleCount: activeModeSampleCount(sample),
SourceKey: sample.SourceKey,
Phone: sample.Phone,
DeviceID: sample.DeviceID,
SourceEndpoint: sample.SourceEndpoint,
PlatformName: sample.PlatformName,
FirstEventTime: sample.EventTime,
LatestEventTime: sample.EventTime,
LatestPureHydrogenActive: sample.PureHydrogenActive,
LatestPureHydrogenModeKnown: sample.PureHydrogenModeKnown,
QualityStatus: stats.QualityOK,
QualityReason: "current_day_first_sample",
}
continue
}
@@ -289,8 +296,18 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
agg.FirstEventTime = sample.EventTime
}
if sample.EventTime.After(agg.LatestEventTime) {
agg.PureHydrogenMileageKM += stats.PureHydrogenMileageDelta(
agg.LatestKM,
sample.TotalMileageKM,
agg.LatestPureHydrogenActive,
agg.LatestPureHydrogenModeKnown,
sample.PureHydrogenActive,
sample.PureHydrogenModeKnown,
)
agg.LatestKM = sample.TotalMileageKM
agg.LatestEventTime = sample.EventTime
agg.LatestPureHydrogenActive = sample.PureHydrogenActive
agg.LatestPureHydrogenModeKnown = sample.PureHydrogenModeKnown
agg.SourceEndpoint = sample.SourceEndpoint
if strings.TrimSpace(sample.Phone) != "" {
agg.Phone = sample.Phone
@@ -303,9 +320,17 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
}
}
agg.Count++
agg.PureHydrogenSampleCount += activeModeSampleCount(sample)
}
}
func activeModeSampleCount(sample stats.MetricSample) int64 {
if sample.PureHydrogenModeKnown && sample.PureHydrogenActive {
return 1
}
return 0
}
func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg, batchSize int) (int64, error) {
if len(aggregates) == 0 {
return 0, nil
@@ -338,23 +363,27 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
}
dailyKM := stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM)
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,
PlatformName: agg.PlatformName,
FirstTotalKM: agg.FirstKM,
LatestTotalKM: agg.LatestKM,
DailyKM: dailyKM,
SampleCount: agg.Count,
FirstEventTime: agg.FirstEventTime,
LatestEventTime: agg.LatestEventTime,
QualityStatus: agg.QualityStatus,
QualityReason: agg.QualityReason,
VIN: agg.VIN,
StatDate: agg.Date,
Protocol: agg.Protocol,
SourceKey: agg.SourceKey,
SourceIP: identity.SourceIP,
SourceEndpoint: agg.SourceEndpoint,
Phone: agg.Phone,
DeviceID: agg.DeviceID,
PlatformName: agg.PlatformName,
FirstTotalKM: agg.FirstKM,
LatestTotalKM: agg.LatestKM,
DailyKM: dailyKM,
PureHydrogenMileageKM: agg.PureHydrogenMileageKM,
SampleCount: agg.Count,
PureHydrogenSampleCount: agg.PureHydrogenSampleCount,
FirstEventTime: agg.FirstEventTime,
LatestEventTime: agg.LatestEventTime,
LatestPureHydrogenActive: agg.LatestPureHydrogenActive,
LatestPureHydrogenModeKnown: agg.LatestPureHydrogenModeKnown,
QualityStatus: agg.QualityStatus,
QualityReason: agg.QualityReason,
}
if candidate.QualityStatus == "" {
candidate.QualityStatus = stats.QualityOK
@@ -1266,9 +1295,49 @@ func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[str
}
fields[key] = value
}
promoteFuelCellWorkModeForStats(protocol, fields, text)
return fields
}
func promoteFuelCellWorkModeForStats(protocol envelope.Protocol, fields map[string]any, text string) {
if fields == nil {
return
}
if _, exists := fields[envelope.FieldFuelCellWorkMode]; exists {
return
}
var candidates []string
switch protocol {
case envelope.ProtocolGB32960:
candidates = []string{
"gb32960.gd_fc_stack.engine_work_state",
"engine_work_state",
}
case envelope.ProtocolYutongMQTT:
candidates = []string{
"yutong_mqtt.data.triangle_state",
"yutong_mqtt.root.data.triangle_state",
"TRIANGLE_STATE",
"triangle_state",
"triangleState",
}
default:
return
}
for _, key := range candidates {
if value, exists := fields[key]; exists {
fields[envelope.FieldFuelCellWorkMode] = value
return
}
}
for _, key := range candidates {
if value, ok := extractJSONStringField(text, key); ok {
fields[envelope.FieldFuelCellWorkMode] = value
return
}
}
}
func mileageKeys(protocol envelope.Protocol) []string {
switch protocol {
case envelope.ProtocolGB32960:

View File

@@ -763,7 +763,8 @@ func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
fields := fieldsForStats(envelope.ProtocolYutongMQTT, "LMRKH9AC6R1004108", `{
"data": {
"TOTAL_MILEAGE": 65423000,
"METER_SPEED": 12.3
"METER_SPEED": 12.3,
"TRIANGLE_STATE": 11
},
"root": {
"device": "LMRKH9AC6R1004108"
@@ -773,6 +774,9 @@ func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
if got := fields["yutong_mqtt.data.total_mileage"]; got == nil {
t.Fatalf("fields missing raw yutong total mileage: %#v", fields)
}
if got := fields[envelope.FieldFuelCellWorkMode]; got == nil {
t.Fatalf("fields missing raw yutong fuel-cell work mode: %#v", fields)
}
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
@@ -791,6 +795,32 @@ func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
if samples[0].TotalMileageKM != 65423 {
t.Fatalf("total mileage km = %v, want 65423", samples[0].TotalMileageKM)
}
if !samples[0].PureHydrogenModeKnown || !samples[0].PureHydrogenActive {
t.Fatalf("pure-hydrogen mode = known:%v active:%v, want known active", samples[0].PureHydrogenModeKnown, samples[0].PureHydrogenActive)
}
}
func TestFieldsForStatsPromotesGB32960FuelCellEngineWorkState(t *testing.T) {
fields := fieldsForStats(envelope.ProtocolGB32960, "LTEST000000000001", `{
"data_units": [{
"type": "0x30",
"name": "gd_fc_stack",
"value": {
"summaries": [{"engine_work_state": 2}]
}
}]
}`)
if got := fields[envelope.FieldFuelCellWorkMode]; got == nil {
t.Fatalf("fields missing GB32960 fuel-cell work mode: %#v", fields)
}
active, known := stats.PureHydrogenModeFromEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
Fields: fields,
})
if !known || !active {
t.Fatalf("pure-hydrogen mode = known:%v active:%v, want known active", known, active)
}
}
func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) {
@@ -863,9 +893,13 @@ func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T)
float64(4100),
float64(4101),
float64(1),
float64(0),
int64(1),
int64(0),
projNow,
projNow,
false,
false,
stats.QualityInvalidDelta,
"outside_daily_range",
).
@@ -994,6 +1028,51 @@ func TestAddSamplesUsesCurrentDayFirstSampleBaseline(t *testing.T) {
}
}
func TestAddSamplesBackfillsOnlyContinuousPureHydrogenIntervals(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
base := stats.MetricSample{
VIN: "LTEST000000000001",
Protocol: envelope.ProtocolGB32960,
StatDate: "2026-07-08",
SourceKey: "GB32960|127.0.0.1",
PureHydrogenModeKnown: true,
}
samples := []stats.MetricSample{
base,
base,
base,
base,
}
samples[0].TotalMileageKM = 100
samples[0].EventTime = time.Date(2026, 7, 8, 8, 0, 0, 0, loc)
samples[1].TotalMileageKM = 102
samples[1].EventTime = time.Date(2026, 7, 8, 9, 0, 0, 0, loc)
samples[1].PureHydrogenActive = true
samples[2].TotalMileageKM = 107
samples[2].EventTime = time.Date(2026, 7, 8, 10, 0, 0, 0, loc)
samples[2].PureHydrogenActive = true
samples[3].TotalMileageKM = 110
samples[3].EventTime = time.Date(2026, 7, 8, 11, 0, 0, 0, loc)
aggregates := map[string]*metricAgg{}
addSamples(aggregates, samples)
if len(aggregates) != 1 {
t.Fatalf("aggregate count = %d, want 1", len(aggregates))
}
for _, agg := range aggregates {
if agg.PureHydrogenMileageKM != 5 {
t.Fatalf("pure-hydrogen mileage = %v, want 5", agg.PureHydrogenMileageKM)
}
if agg.PureHydrogenSampleCount != 2 {
t.Fatalf("pure-hydrogen sample count = %d, want 2", agg.PureHydrogenSampleCount)
}
if !agg.LatestPureHydrogenModeKnown || agg.LatestPureHydrogenActive {
t.Fatalf("latest mode = known:%v active:%v, want known inactive", agg.LatestPureHydrogenModeKnown, agg.LatestPureHydrogenActive)
}
}
}
func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) {
t.Setenv("BACKFILL_METHOD", "")
t.Setenv("BACKFILL_DAYS_BACK", "")