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
}