feat: expand vehicle data platform capabilities
This commit is contained in:
743
go/vehicle-gateway/cmd/legacy-mileage-migrate/main.go
Normal file
743
go/vehicle-gateway/cmd/legacy-mileage-migrate/main.go
Normal 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
|
||||
}
|
||||
56
go/vehicle-gateway/cmd/legacy-mileage-migrate/main_test.go
Normal file
56
go/vehicle-gateway/cmd/legacy-mileage-migrate/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user