chore: snapshot production code before Apple Design UI refinement

This commit is contained in:
lingniu
2026-09-17 18:05:57 +08:00
parent 9f0a87f49e
commit f7e7176f88
90 changed files with 8990 additions and 328 deletions
@@ -19,6 +19,7 @@ import (
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/dailygeo"
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
@@ -69,6 +70,11 @@ func NewServer(cfg config.Config) http.Handler {
log.Printf("production capacity-check probe enabled")
}
productionStore.WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup)
if cfg.DailyGeographyEnabled && tdengine != nil && cfg.AMapAPIKey != "" {
geographyWorker := dailygeo.New(db, tdengine, cfg.TDengineDatabase, &dailygeo.AMap{Key: cfg.AMapAPIKey})
geographyWorker.RequestInterval = 100 * time.Millisecond
go geographyWorker.Run(context.Background())
}
store = productionStore
storeErr = nil
log.Printf("production mysql store enabled")
@@ -8,6 +8,7 @@ import (
)
type Config struct {
DailyGeographyEnabled bool
HTTPAddr string
StaticDir string
MySQLDSN string
@@ -97,6 +98,7 @@ func Load() Config {
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
AMapAPIKey: os.Getenv("AMAP_API_KEY"),
DailyGeographyEnabled: envBool("DAILY_GEOGRAPHY_ENABLED", false),
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
@@ -0,0 +1,35 @@
package dailygeo
import "math"
func WGS84ToGCJ02(longitude float64, latitude float64) (float64, float64) {
if longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271 {
return longitude, latitude
}
const semiMajorAxis = 6378245.0
const eccentricitySquared = 0.006693421622965943
longitudeOffset := transformGCJLongitude(longitude-105, latitude-35)
latitudeOffset := transformGCJLatitude(longitude-105, latitude-35)
radianLatitude := latitude / 180 * math.Pi
magic := 1 - eccentricitySquared*math.Pow(math.Sin(radianLatitude), 2)
squareRootMagic := math.Sqrt(magic)
convertedLatitude := latitude + latitudeOffset*180/((semiMajorAxis*(1-eccentricitySquared))/(magic*squareRootMagic)*math.Pi)
convertedLongitude := longitude + longitudeOffset*180/(semiMajorAxis/squareRootMagic*math.Cos(radianLatitude)*math.Pi)
return convertedLongitude, convertedLatitude
}
func transformGCJLatitude(longitude float64, latitude float64) float64 {
value := -100 + 2*longitude + 3*latitude + 0.2*latitude*latitude + 0.1*longitude*latitude + 0.2*math.Sqrt(math.Abs(longitude))
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
value += (20*math.Sin(latitude*math.Pi) + 40*math.Sin(latitude/3*math.Pi)) * 2 / 3
value += (160*math.Sin(latitude/12*math.Pi) + 320*math.Sin(latitude*math.Pi/30)) * 2 / 3
return value
}
func transformGCJLongitude(longitude float64, latitude float64) float64 {
value := 300 + longitude + 2*latitude + 0.1*longitude*longitude + 0.1*longitude*latitude + 0.1*math.Sqrt(math.Abs(longitude))
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
value += (20*math.Sin(longitude*math.Pi) + 40*math.Sin(longitude/3*math.Pi)) * 2 / 3
value += (150*math.Sin(longitude/12*math.Pi) + 300*math.Sin(longitude/30*math.Pi)) * 2 / 3
return value
}
@@ -0,0 +1,123 @@
package dailygeo
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Address struct{ Province, City, Region, Adcode string }
type Resolver interface {
Resolve(context.Context, float64, float64) (Address, error)
}
type AMap struct {
Key, BaseURL string
Client *http.Client
}
func (a *AMap) Resolve(ctx context.Context, lng, lat float64) (Address, error) {
if a.Key == "" {
return Address{}, fmt.Errorf("AMAP_API_KEY is not configured")
}
base := a.BaseURL
if base == "" {
base = "https://restapi.amap.com"
}
u, e := url.Parse(base + "/v3/geocode/regeo")
if e != nil {
return Address{}, fmt.Errorf("invalid geocoder URL")
}
x, y := WGS84ToGCJ02(lng, lat)
q := u.Query()
q.Set("key", a.Key)
q.Set("location", fmt.Sprintf("%.6f,%.6f", x, y))
q.Set("extensions", "base")
q.Set("radius", "1000")
u.RawQuery = q.Encode()
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if e != nil {
return Address{}, fmt.Errorf("invalid geocoder request")
}
client := a.Client
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
resp, e := client.Do(req)
if e != nil {
return Address{}, fmt.Errorf("geocoder request failed")
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return Address{}, fmt.Errorf("geocoder HTTP %d", resp.StatusCode)
}
var body struct {
Status string
Infocode string
Regeocode struct {
AddressComponent struct{ Province, City, District, Adcode json.RawMessage } `json:"addressComponent"`
}
}
if e = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); e != nil {
return Address{}, fmt.Errorf("invalid geocoder response")
}
if body.Status != "1" {
return Address{}, fmt.Errorf("geocoder rejected: %s", body.Infocode)
}
p, c := textValue(body.Regeocode.AddressComponent.Province), textValue(body.Regeocode.AddressComponent.City)
if c == "" {
switch p {
case "北京市", "上海市", "天津市", "重庆市", "香港特别行政区", "澳门特别行政区":
c = p
}
}
// Province-administered county-level cities (for example 潜江市) are
// returned in district, with city=[], by AMap.
if c == "" && p != "" {
district := textValue(body.Regeocode.AddressComponent.District)
if strings.HasSuffix(district, "市") {
c = district
}
}
if p == "" || c == "" {
return Address{}, fmt.Errorf("geocoder returned incomplete province/city")
}
return Address{p, c, Region(p), textValue(body.Regeocode.AddressComponent.Adcode)}, nil
}
func textValue(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
var ss []string
if json.Unmarshal(raw, &ss) == nil {
return strings.Join(ss, "")
}
return ""
}
func Region(province string) string {
groups := []struct {
name string
provinces []string
}{
{"华东", []string{"上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾"}},
{"华北", []string{"北京", "天津", "河北", "山西", "内蒙古"}},
{"华中", []string{"河南", "湖北", "湖南"}},
{"华南", []string{"广东", "广西", "海南", "香港", "澳门"}},
{"东北", []string{"辽宁", "吉林", "黑龙江"}},
{"西南", []string{"重庆", "四川", "贵州", "云南", "西藏"}},
{"西北", []string{"陕西", "甘肃", "青海", "宁夏", "新疆"}},
}
for _, g := range groups {
for _, p := range g.provinces {
if strings.HasPrefix(province, p) {
return g.name
}
}
}
return ""
}
@@ -0,0 +1,297 @@
package dailygeo
import (
"context"
"database/sql"
"fmt"
"log"
"math"
"regexp"
"sync"
"time"
)
var Shanghai = time.FixedZone("Asia/Shanghai", 8*3600)
var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type Point struct {
VIN, Protocol string
Time time.Time
Longitude, Latitude float64
}
type Worker struct {
RequestInterval time.Duration
DB, TD *sql.DB
Database string
Resolver Resolver
Concurrency int
requestMu sync.Mutex
nextRequest time.Time
}
func New(db, td *sql.DB, database string, r Resolver) *Worker {
return &Worker{DB: db, TD: td, Database: database, Resolver: r, Concurrency: 3}
}
func validPoint(p Point) bool {
return p.VIN != "" && !(p.Longitude == -0.999999 && p.Latitude == -0.999999) && !p.Time.IsZero() && p.Longitude != 0 && p.Latitude != 0 && !math.IsNaN(p.Longitude) && !math.IsNaN(p.Latitude) && math.Abs(p.Longitude) <= 180 && math.Abs(p.Latitude) <= 90
}
func newer(a, b Point) bool {
return a.Time.After(b.Time) || (a.Time.Equal(b.Time) && a.Protocol < b.Protocol)
}
// Last() is applied only to non-null valid coordinates, partitioned by VIN and
// protocol. All selected columns therefore refer to the same final source row.
func (w *Worker) RefreshDay(ctx context.Context, date string) (int, error) {
day, e := time.ParseInLocation("2006-01-02", date, Shanghai)
if e != nil {
return 0, e
}
if w.TD == nil || !identifier.MatchString(w.Database) {
return 0, fmt.Errorf("historical location store unavailable")
}
q := fmt.Sprintf(`SELECT vin,protocol,LAST(ts),LAST(longitude),LAST(latitude) FROM %s.vehicle_locations WHERE ts >= '%s' AND ts < '%s' AND NOT (longitude = -0.999999 AND latitude = -0.999999) AND longitude <> 0 AND longitude BETWEEN -180 AND 180 AND latitude <> 0 AND latitude BETWEEN -90 AND 90 PARTITION BY vin,protocol`, w.Database, day.Format(time.RFC3339), day.AddDate(0, 0, 1).Format(time.RFC3339))
rows, e := w.TD.QueryContext(ctx, q)
if e != nil {
return 0, e
}
points := map[string]Point{}
for rows.Next() {
var p Point
if e = rows.Scan(&p.VIN, &p.Protocol, &p.Time, &p.Longitude, &p.Latitude); e != nil {
rows.Close()
return 0, e
}
if !validPoint(p) || p.Time.In(Shanghai).Format("2006-01-02") != date {
continue
}
if old, ok := points[p.VIN]; !ok || newer(p, old) {
points[p.VIN] = p
}
}
e = rows.Err()
rows.Close()
if e != nil {
return 0, e
}
// Repair the device's invalid-location sentinel before applying real points.
// Clearing its timestamp allows an earlier valid point from this day to win.
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET longitude=NULL,latitude=NULL,location_time=NULL,province='',city='',region='',adcode='',status='NO_LOCATION',attempts=0,last_error='',next_attempt_at=NULL,resolved_at=NULL WHERE stat_date=? AND longitude=-0.999999 AND latitude=-0.999999`, date)
if e != nil {
return 0, e
}
for _, p := range points {
if e = w.SavePoint(ctx, p); e != nil {
return 0, e
}
}
// A successful historical lookup with no point is distinct from an unavailable
// history service. Never record NO_LOCATION when the lookup itself failed.
_, e = w.DB.ExecContext(ctx, `INSERT IGNORE INTO vehicle_daily_geography(vin,stat_date,status) SELECT vin,stat_date,'NO_LOCATION' FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN' AND stat_date=?`, date)
return len(points), e
}
const savePointSQL = `INSERT INTO vehicle_daily_geography(vin,stat_date,longitude,latitude,location_time,source_protocol,status) VALUES(?,?,?,?,?,?,'PENDING') ON DUPLICATE KEY UPDATE
province=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',province),
city=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',city),
region=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',region),
adcode=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',adcode),
status=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'PENDING',status),
attempts=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),0,attempts),
next_attempt_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,next_attempt_at),
last_error=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',last_error),
resolved_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,resolved_at),
longitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(longitude),longitude),
latitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(latitude),latitude),
source_protocol=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(source_protocol),source_protocol),
location_time=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(location_time),location_time)`
func (w *Worker) SavePoint(ctx context.Context, p Point) error {
if !validPoint(p) {
return fmt.Errorf("invalid historical location")
}
_, e := w.DB.ExecContext(ctx, savePointSQL, p.VIN, p.Time.In(Shanghai).Format("2006-01-02"), math.Round(p.Longitude*1e6)/1e6, math.Round(p.Latitude*1e6)/1e6, p.Time.In(Shanghai), p.Protocol)
return e
}
type pending struct {
VIN, Date string
Longitude, Latitude float64
}
func (w *Worker) ResolvePending(ctx context.Context, from, to string, limit int) (int, int, error) {
if limit <= 0 || limit > 5000 {
limit = 500
}
rows, e := w.DB.QueryContext(ctx, `SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),longitude,latitude FROM vehicle_daily_geography WHERE stat_date BETWEEN ? AND ? AND status IN ('PENDING','ERROR') AND (next_attempt_at IS NULL OR next_attempt_at<=NOW(3)) ORDER BY stat_date DESC,vin LIMIT ?`, from, to, limit)
if e != nil {
return 0, 0, e
}
items := []pending{}
for rows.Next() {
var p pending
if e = rows.Scan(&p.VIN, &p.Date, &p.Longitude, &p.Latitude); e != nil {
rows.Close()
return 0, 0, e
}
items = append(items, p)
}
e = rows.Err()
rows.Close()
if e != nil {
return 0, 0, e
}
jobs := make(chan pending, len(items))
for _, p := range items {
jobs <- p
}
close(jobs)
count := w.Concurrency
if count < 1 {
count = 1
}
if count > 8 {
count = 8
}
var mu sync.Mutex
var wg sync.WaitGroup
ok, failed := 0, 0
var first error
for i := 0; i < count; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for p := range jobs {
if ctx.Err() != nil {
return
}
e := w.resolveOne(ctx, p)
mu.Lock()
if e == nil {
ok++
} else {
failed++
if first == nil {
first = e
}
}
mu.Unlock()
}
}()
}
wg.Wait()
if ctx.Err() != nil {
return ok, failed, ctx.Err()
}
return ok, failed, first
}
func (w *Worker) resolveOne(ctx context.Context, p pending) error {
var a Address
e := w.DB.QueryRowContext(ctx, `SELECT province,city,region,adcode FROM vehicle_geography_cache WHERE longitude=? AND latitude=? AND resolved_at>=DATE_SUB(NOW(),INTERVAL 180 DAY)`, p.Longitude, p.Latitude).Scan(&a.Province, &a.City, &a.Region, &a.Adcode)
if e != nil && e != sql.ErrNoRows {
return e
}
if e == sql.ErrNoRows {
// At most three external calls in flight by default; quota/network failures
// are persisted and retried with backoff, never exported as a guessed city.
w.requestMu.Lock()
delay := time.Until(w.nextRequest)
if delay > 0 {
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
w.requestMu.Unlock()
return ctx.Err()
case <-timer.C:
}
}
interval := w.RequestInterval
if interval <= 0 {
interval = 20 * time.Millisecond
}
w.nextRequest = time.Now().Add(interval)
w.requestMu.Unlock()
rctx, cancel := context.WithTimeout(ctx, 12*time.Second)
a, e = w.Resolver.Resolve(rctx, p.Longitude, p.Latitude)
cancel()
if e != nil {
_, saveErr := w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET status='ERROR',attempts=attempts+1,last_error=?,next_attempt_at=DATE_ADD(NOW(3),INTERVAL LEAST(3600,60*POW(2,LEAST(attempts,6))) SECOND) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, e.Error(), p.VIN, p.Date, p.Longitude, p.Latitude)
if saveErr != nil {
return saveErr
}
return e
}
_, e = w.DB.ExecContext(ctx, `INSERT INTO vehicle_geography_cache(longitude,latitude,province,city,region,adcode) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE province=VALUES(province),city=VALUES(city),region=VALUES(region),adcode=VALUES(adcode),resolved_at=NOW(3)`, p.Longitude, p.Latitude, a.Province, a.City, a.Region, a.Adcode)
if e != nil {
return e
}
}
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET province=?,city=?,region=?,adcode=?,status='RESOLVED',last_error='',attempts=0,next_attempt_at=NULL,resolved_at=NOW(3) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, a.Province, a.City, a.Region, a.Adcode, p.VIN, p.Date, p.Longitude, p.Latitude)
return e
}
// An advisory lock is tied to this dedicated connection and released even on
// cancellation/crash, preventing multiple API replicas from running the loop.
func (w *Worker) WithLock(ctx context.Context, name string, fn func() error) error {
conn, e := w.DB.Conn(ctx)
if e != nil {
return e
}
defer conn.Close()
var acquired int
if e = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,0)`, name).Scan(&acquired); e != nil {
return e
}
if acquired != 1 {
return fmt.Errorf("daily geography worker already running")
}
defer conn.ExecContext(context.Background(), `SELECT RELEASE_LOCK(?)`, name)
return fn()
}
func (w *Worker) Run(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
round := 0
for {
e := w.WithLock(ctx, "vehicle_daily_geography_live", func() error {
today := time.Now().In(Shanghai)
date := today.Format("2006-01-02")
rctx, c := context.WithTimeout(ctx, 50*time.Second)
defer c()
if _, e := w.RefreshDay(rctx, date); e != nil {
return e
}
if round%60 == 0 {
for d := 1; d <= 2; d++ {
if _, e := w.RefreshDay(rctx, today.AddDate(0, 0, -d).Format("2006-01-02")); e != nil {
return e
}
}
}
n, failed, e := w.ResolvePending(rctx, today.AddDate(0, 0, -2).Format("2006-01-02"), date, 500)
log.Printf("daily_geography resolved=%d failed=%d", n, failed)
if e != nil {
return e
}
// Historical retry is independent of the live queue. The same lock used
// by the CLI prevents duplicate external calls during a bulk backfill.
_ = w.WithLock(rctx, "vehicle_daily_geography_backfill", func() error {
n, failed, e := w.ResolvePending(rctx, "2000-01-01", today.AddDate(0, 0, -3).Format("2006-01-02"), 200)
if n+failed > 0 {
log.Printf("daily_geography_history_retry resolved=%d failed=%d error=%v", n, failed, e)
}
return e
})
return nil
})
if e != nil {
log.Printf("daily_geography: %v", e)
}
round++
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
@@ -0,0 +1,138 @@
package dailygeo
import (
"context"
"database/sql"
"errors"
"github.com/DATA-DOG/go-sqlmock"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
)
type resolverFunc func(context.Context, float64, float64) (Address, error)
func (f resolverFunc) Resolve(c context.Context, x, y float64) (Address, error) { return f(c, x, y) }
func TestAMapMunicipalityAndRegion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("key") != "test-key" {
t.Error("missing credential")
}
w.Write([]byte(`{"status":"1","regeocode":{"addressComponent":{"province":"上海市","city":[],"adcode":"310101"}}}`))
}))
defer server.Close()
a, e := (&AMap{Key: "test-key", BaseURL: server.URL}).Resolve(context.Background(), 121.47, 31.23)
if e != nil || a.Province != "上海市" || a.City != "上海市" || a.Region != "华东" {
t.Fatalf("address=%+v error=%v", a, e)
}
for p, want := range map[string]string{"广东省": "华南", "四川省": "西南", "陕西省": "西北", "山西省": "华北", "湖北省": "华中", "吉林省": "东北", "": ""} {
if Region(p) != want {
t.Fatal(p)
}
}
}
func TestAMapQuotaFailureDoesNotLeakKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"status":"0","infocode":"10021"}`)) }))
defer server.Close()
_, e := (&AMap{Key: "secret-value", BaseURL: server.URL}).Resolve(context.Background(), 121, 31)
if e == nil || !strings.Contains(e.Error(), "10021") || strings.Contains(e.Error(), "secret-value") {
t.Fatal(e)
}
}
func TestCachedCoordinateNeedsNoGeocoding(t *testing.T) {
db, m, e := sqlmock.New()
if e != nil {
t.Fatal(e)
}
defer db.Close()
m.ExpectQuery("SELECT province,city,region,adcode").WithArgs(121.0, 31.0).WillReturnRows(sqlmock.NewRows([]string{"province", "city", "region", "adcode"}).AddRow("上海市", "上海市", "华东", "310000"))
m.ExpectExec("UPDATE vehicle_daily_geography SET province=.*WHERE vin=\\? AND stat_date=\\? AND longitude=\\? AND latitude=\\?").WithArgs("上海市", "上海市", "华东", "310000", "VIN1", "2026-08-31", 121.0, 31.0).WillReturnResult(sqlmock.NewResult(0, 1))
w := New(db, nil, "", resolverFunc(func(context.Context, float64, float64) (Address, error) {
t.Fatal("cache hit must not call external API")
return Address{}, nil
}))
if e = w.resolveOne(context.Background(), pending{"VIN1", "2026-08-31", 121, 31}); e != nil {
t.Fatal(e)
}
if e = m.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
}
func TestResolverFailurePersistsRetryInsteadOfFalseUnknown(t *testing.T) {
db, m, _ := sqlmock.New()
defer db.Close()
m.ExpectQuery("SELECT province,city,region,adcode").WillReturnError(sql.ErrNoRows)
m.ExpectExec("UPDATE vehicle_daily_geography SET status='ERROR'.*next_attempt_at=.*WHERE vin=\\? AND stat_date=\\? AND longitude=\\? AND latitude=\\?").WithArgs("quota", "VIN1", "2026-08-31", 121.0, 31.0).WillReturnResult(sqlmock.NewResult(0, 1))
w := New(db, nil, "", resolverFunc(func(context.Context, float64, float64) (Address, error) { return Address{}, errors.New("quota") }))
if e := w.resolveOne(context.Background(), pending{"VIN1", "2026-08-31", 121, 31}); e == nil {
t.Fatal("expected failure")
}
if e := m.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
}
func TestHistoricalFailureDoesNotMarkMissingLocation(t *testing.T) {
db, m, _ := sqlmock.New()
defer db.Close()
td, tm, _ := sqlmock.New()
defer td.Close()
tm.ExpectQuery("SELECT vin,protocol,LAST").WillReturnError(errors.New("historical store unavailable"))
w := New(db, td, "test_ts", nil)
if _, e := w.RefreshDay(context.Background(), "2026-08-31"); e == nil {
t.Fatal("expected history error")
}
if e := m.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
if e := tm.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
}
func TestHistoryUsesLatestSourceInsideBusinessDate(t *testing.T) {
db, m, _ := sqlmock.New()
defer db.Close()
td, tm, _ := sqlmock.New()
defer td.Close()
early := time.Date(2026, 8, 31, 2, 0, 0, 0, Shanghai)
late := early.Add(20 * time.Hour)
tm.ExpectQuery("SELECT vin,protocol,LAST.*2026-08-31T00:00:00\\+08:00.*2026-09-01T00:00:00\\+08:00.*PARTITION BY vin,protocol").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "ts", "longitude", "latitude"}).AddRow("VIN1", "GB32960", late, 121.0, 31.0).AddRow("VIN1", "JT808", early, 113.0, 23.0).AddRow("VIN2", "JT808", late.Add(5*time.Hour), 110.0, 20.0))
m.ExpectExec("UPDATE vehicle_daily_geography SET longitude=NULL").WithArgs("2026-08-31").WillReturnResult(sqlmock.NewResult(0, 1))
m.ExpectExec(regexp.QuoteMeta(savePointSQL)).WithArgs("VIN1", "2026-08-31", 121.0, 31.0, late, "GB32960").WillReturnResult(sqlmock.NewResult(0, 1))
m.ExpectExec("INSERT IGNORE INTO vehicle_daily_geography").WithArgs("2026-08-31").WillReturnResult(sqlmock.NewResult(0, 0))
w := New(db, td, "test_ts", nil)
n, e := w.RefreshDay(context.Background(), "2026-08-31")
if e != nil || n != 1 {
t.Fatalf("n=%d error=%v", n, e)
}
if e = m.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
if e = tm.ExpectationsWereMet(); e != nil {
t.Fatal(e)
}
}
func TestProvinceAdministeredCity(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"1","regeocode":{"addressComponent":{"province":"湖北省","city":[],"district":"潜江市","adcode":"429005"}}}`))
}))
defer server.Close()
a, e := (&AMap{Key: "test", BaseURL: server.URL}).Resolve(context.Background(), 112.807848, 30.381828)
if e != nil || a.City != "潜江市" || a.Region != "华中" {
t.Fatalf("address=%+v error=%v", a, e)
}
}
func TestInvalidLocationSentinel(t *testing.T) {
p := Point{VIN: "VIN1", Time: time.Now(), Longitude: -0.999999, Latitude: -0.999999}
if validPoint(p) {
t.Fatal("invalid device sentinel accepted")
}
p.Longitude, p.Latitude = 114.3, 30.5
if !validPoint(p) {
t.Fatal("valid location rejected")
}
}
@@ -81,7 +81,7 @@
<section class="section" id="daily-hydrogen"><div class="section-head"><div><h2>车辆单日用氢量</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/hydrogen-consumption/query</div><p>查询车辆单日用氢量,单位 kg。NORMAL + OK + PRELIMINARY 仅供初步监控;正式报表要求 FINAL 并核对证据与版本。两个日统计接口没有共同快照,区间缺失或不同不能直接计算百公里氢耗。</p></div><a class="anchor" href="#daily-hydrogen">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required"></td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional"></td><td>车牌数组;省略或 [] 时查询全部授权车辆</td></tr></table></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"plateNumber"</span>: <span class="string">"浙F06618F"</span>,<br> <span class="key">"hydrogenConsumptionKg"</span>: <span class="number">12.315</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期或车牌格式不正确</li><li>401appKey 无效、停用或过期</li><li>403:指定车辆未授权</li></ul></div></div></section>
<section class="section" id="daily-mileage"><div class="section-head"><div><h2>车辆单日里程</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/mileage/query</div><p>返回当日行驶里程、当日累计总里程和实际选用的数据协议,单位 km。</p></div><a class="anchor" href="#daily-mileage">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required"></td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional"></td><td>省略时查询全部授权车辆</td></tr><tr><td>protocolPriority</td><td class="optional"></td><td>协议选源顺序,例如 ["GB32960","MQTT","JT808"]</td></tr></table><div class="note"><strong>缺数规则:</strong>若当日没有有效里程,日里程 0;累计总里程沿用上一个有效统计周期,计算时间也显示该周期时间</div></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"dailyMileageKm"</span>: <span class="number">182.437</span>,<br> <span class="key">"totalMileageKm"</span>: <span class="number">12345.679</span>,<br> <span class="key">"sourceProtocol"</span>: <span class="string">"GB32960"</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期、车牌或协议参数错误</li><li>403:授权期未覆盖查询日</li><li>无统计:单车以 NO_DATA 返回</li></ul></div></div></section>
<section class="section" id="daily-mileage"><div class="section-head"><div><h2>车辆单日里程</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/mileage/query</div><p>返回当日行驶里程、当日累计总里程和实际选用的数据协议,单位 km。</p></div><a class="anchor" href="#daily-mileage">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required"></td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional"></td><td>省略时查询全部授权车辆</td></tr><tr><td>protocolPriority</td><td class="optional"></td><td>协议选源顺序,例如 ["GB32960","MQTT","JT808"]</td></tr></table><div class="note"><strong>缺数规则:</strong>日里程按同来源相邻日累计差计算;缺报日沿用累计值、日里程 0CARRIED_FORWARD),跨缺报期增量计入恢复日。首次基线、来源切换或累计回退返回 DATA_ANOMALY,日里程为 null。GPS 估算不参与本接口</div></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"dailyMileageKm"</span>: <span class="number">182.437</span>,<br> <span class="key">"totalMileageKm"</span>: <span class="number">12345.679</span>,<br> <span class="key">"sourceProtocol"</span>: <span class="string">"GB32960"</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期、车牌或协议参数错误</li><li>403:授权期未覆盖查询日</li><li>无统计:单车以 NO_DATA 返回</li></ul></div></div></section>
<section class="section" id="mileage-range"><div class="section-head"><div><h2>车辆区间日里程</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/mileage/range/query</div><p>按车辆、日期分页返回区间日里程,最长查询区间为 366 天。</p></div><a class="anchor" href="#mileage-range">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>startDate / endDate</td><td class="required"></td><td>日期区间,yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional"></td><td>车牌数组,最多 5000 辆</td></tr><tr><td>protocolPriority</td><td class="optional"></td><td>协议选源顺序</td></tr><tr><td>pageSize / cursor</td><td class="optional"></td><td>分页大小及下一页游标</td></tr></table></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"date"</span>: <span class="string">"2026-08-06"</span>,<br> <span class="key">"dailyMileageKm"</span>: <span class="number">182.437</span>,<br> <span class="key">"totalMileageKm"</span>: <span class="number">12345.679</span><br> }],<br> <span class="key">"nextCursor"</span>: <span class="string">null</span><br>}</pre><ul class="errors"><li>400:区间超限或游标与原参数不一致</li><li>403:授权未完整覆盖查询区间</li></ul></div></div></section>
@@ -102,9 +102,9 @@ paths:
summary: 查询车辆单日里程
description: |
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程可由 GPS 轨迹估算;累计总里程读取每日统计中的 day_end_total_mileage_km,始终优先采用同协议终端上报的累计里程,不会使用 GPS 日里程估算值冒充累计里程
当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间
protocolPriority 传入时,逐车按数组顺序选择截至查询日已有有效累计读数的第一个协议;高优先级协议缺报时沿用其历史累计值,不切换累计基准,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程按同来源相邻自然日累计读数之差计算,缺报期间增量计入恢复上报日;GPS 估算不参与本接口。首次基线、来源切换、累计回退返回 DATA_ANOMALY、dailyMileageKm=null 和 dataQuality
当日缺报时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用同协议历史读数,dataQuality=CARRIED_FORWARD
operationId: queryDailyMileage
security:
- AppKeyAuth: []
@@ -141,7 +141,7 @@ paths:
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK,绝不沿用历史值伪装为正常数据。日里程与日末累计总里程独立统计:GPS 轨迹估算仅用于日里程,累计总里程读取每日统计字段 day_end_total_mileage_km
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK。日里程与累计值使用同一终端来源;日里程为相邻自然日累计差,GPS 估算不参与本接口。单日、区间和分页共用相同计算规则
operationId: queryDailyMileageRange
security:
- AppKeyAuth: []
@@ -784,7 +784,7 @@ components:
type: number
format: double
nullable: true
description: 单日里程,km;当日无记录但存在历史累计里程时为 0
description: 相邻自然日同来源累计值之差,km;跨缺报期增量计入恢复日;缺报日为 0;首次基线、来源变化和累计异常时为 null
totalMileageKm:
type: number
format: double
@@ -794,7 +794,7 @@ components:
type: string
format: date-time
nullable: true
description: 当日统计所选来源的最早 first_event_time,跨日基线可早于当日零点,不是自然日起始;历史结转补零或无数据时为 null
description: 日里程起点累计读数的时间;可跨越多个缺报日;历史结转补零或无数据时为 null
statisticsEndTime:
type: string
format: date-time
@@ -818,8 +818,8 @@ components:
dataQuality:
type: string
nullable: true
enum: [TOTAL_MILEAGE_ROLLBACK]
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
enum: [TOTAL_MILEAGE_ROLLBACK, ODOMETER_SOURCE_CHANGED, NO_PREVIOUS_BASELINE, PREVIOUS_ODOMETER_ANOMALY, CARRIED_FORWARD, outside_daily_range, INVALID_DELTA]
description: CARRIED_FORWARD 表示缺报结转;其他值表示无法连续对账的原因,此时 dailyMileageKm 为 null,保留累计读数及来源证据
status:
$ref: '#/components/schemas/DataStatus'
MileageRangeResult:
@@ -862,8 +862,8 @@ components:
dataQuality:
type: string
nullable: true
enum: [TOTAL_MILEAGE_ROLLBACK]
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
enum: [TOTAL_MILEAGE_ROLLBACK, ODOMETER_SOURCE_CHANGED, NO_PREVIOUS_BASELINE, PREVIOUS_ODOMETER_ANOMALY, CARRIED_FORWARD, outside_daily_range, INVALID_DELTA]
description: CARRIED_FORWARD 表示缺报结转;其他值表示无法连续对账的原因,此时 dailyMileageKm 为 null,保留累计读数及来源证据
status:
$ref: '#/components/schemas/DataStatus'
DataStatus:
@@ -0,0 +1,171 @@
package openplatform
import (
"context"
"fmt"
"math"
"sort"
"strings"
"time"
)
// ReconciledMileageRange uses terminal odometers, including one preceding
// observation per protocol. The same function serves single days and every
// range page: neither the requested start date nor page size changes a result.
func (r *MySQLRepository) ReconciledMileageRange(ctx context.Context, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
if len(vins) == 0 {
return map[string]DailyMileage{}, nil
}
if len(protocols) == 0 {
protocols = []string{"GB32960", "YUTONG_MQTT", "JT808"}
}
placeholders := func(n int) string { return strings.TrimRight(strings.Repeat("?,", n), ",") }
// Unknown-protocol legacy imports and GPS distance are not terminal odometers.
eligible := func(alias string) string {
return fmt.Sprintf(`%[1]s.quality_status IN ('OK','INVALID_DELTA')
AND %[1]s.latest_total_mileage_km > 0
AND COALESCE(%[1]s.quality_reason,'') <> 'gps_coordinate_accumulation'
AND %[1]s.source_ip NOT IN ('legacy-mysql.lingniu-prod','manual-lingniu-prod-day-mileage')
AND %[1]s.latest_event_time IS NOT NULL
AND %[1]s.latest_event_time < TIMESTAMP(%[1]s.stat_date)+INTERVAL 1 DAY`, alias)
}
filter := `vin IN (` + placeholders(len(vins)) + `) AND protocol IN (` + placeholders(len(protocols)) + `)`
query := `WITH wanted AS (
SELECT DISTINCT vin,protocol,stat_date FROM vehicle_daily_mileage_source p
WHERE ` + filter + ` AND stat_date BETWEEN ? AND ? AND ` + eligible("p") + `
UNION ALL
SELECT vin,protocol,MAX(stat_date) AS stat_date FROM vehicle_daily_mileage_source p
WHERE ` + filter + ` AND stat_date<? AND ` + eligible("p") + ` GROUP BY vin,protocol
)
SELECT s.vin,DATE_FORMAT(s.stat_date,'%Y-%m-%d'),s.protocol,s.source_key,
s.daily_mileage_km,s.latest_total_mileage_km,
DATE_FORMAT(s.latest_event_time,'%Y-%m-%dT%H:%i:%s+08:00'),
DATE_FORMAT(s.updated_at,'%Y-%m-%dT%H:%i:%s+08:00'),
COALESCE(DATE_FORMAT(s.first_event_time,'%Y-%m-%dT%H:%i:%s+08:00'),''),
CASE WHEN s.quality_status='INVALID_DELTA' THEN COALESCE(s.quality_reason,'INVALID_DELTA') ELSE '' END
FROM wanted w JOIN vehicle_daily_mileage_source s
ON s.vin=w.vin AND s.protocol=w.protocol AND s.stat_date=w.stat_date
WHERE ` + eligible("s") + `
ORDER BY s.stat_date,s.vin,s.protocol,s.is_selected DESC,
CASE WHEN s.quality_status='OK' THEN 0 ELSE 1 END,s.latest_event_time DESC,s.sample_count DESC,s.source_key`
args := make([]any, 0, 2*(len(vins)+len(protocols))+3)
for _, v := range vins {
args = append(args, v)
}
for _, p := range protocols {
args = append(args, p)
}
args = append(args, start, end)
for _, v := range vins {
args = append(args, v)
}
for _, p := range protocols {
args = append(args, p)
}
args = append(args, start)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var points []DailyMileage
seen := map[string]bool{}
for rows.Next() {
var v DailyMileage
if err := rows.Scan(&v.VIN, &v.Date, &v.Protocol, &v.SourceKey, &v.MileageKm, &v.TotalMileageKm, &v.DataTime, &v.UpdatedAt, &v.StatisticsStartTime, &v.DataQuality); err != nil {
return nil, err
}
key := v.VIN + "|" + v.Date + "|" + v.Protocol
if !seen[key] {
points = append(points, v)
seen[key] = true
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return reconcileMileage(points, vins, start, end, protocols)
}
func reconcileMileage(points []DailyMileage, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
first, err := time.ParseInLocation("2006-01-02", start, loc)
if err != nil {
return nil, err
}
last, err := time.ParseInLocation("2006-01-02", end, loc)
if err != nil {
return nil, err
}
if last.Before(first) {
return nil, fmt.Errorf("invalid mileage interval")
}
if len(protocols) == 0 {
protocols = []string{"GB32960", "YUTONG_MQTT", "JT808"}
}
sort.SliceStable(points, func(i, j int) bool { return points[i].Date < points[j].Date })
state := map[string]map[string]DailyMileage{}
pick := func(vin string) (DailyMileage, bool) {
for _, p := range protocols {
if v, ok := state[vin][p]; ok {
return v, true
}
}
return DailyMileage{}, false
}
put := func(v DailyMileage) {
if state[v.VIN] == nil {
state[v.VIN] = map[string]DailyMileage{}
}
state[v.VIN][v.Protocol] = v
}
i := 0
for i < len(points) && points[i].Date < start {
put(points[i])
i++
}
out := map[string]DailyMileage{}
for day := first; !day.After(last); day = day.AddDate(0, 0, 1) {
date := day.Format("2006-01-02")
before := map[string]DailyMileage{}
for _, vin := range vins {
if p, ok := pick(vin); ok {
before[vin] = p
}
}
for i < len(points) && points[i].Date == date {
put(points[i])
i++
}
for _, vin := range vins {
current, ok := pick(vin)
if !ok {
continue
}
result := current
previous, hasPrevious := before[vin]
if current.Date < date {
result.MileageKm = 0
result.StatisticsStartTime = ""
if result.DataQuality == "" {
result.DataQuality = "CARRIED_FORWARD"
}
} else if current.DataQuality != "" {
result.MileageKm = 0
} else if !hasPrevious {
result.DataQuality = "NO_PREVIOUS_BASELINE"
} else if current.Protocol != previous.Protocol || current.SourceKey != previous.SourceKey {
result.DataQuality = "ODOMETER_SOURCE_CHANGED"
} else if current.TotalMileageKm < previous.TotalMileageKm {
result.DataQuality = mileageTotalRollbackQuality
} else if previous.DataQuality != "" {
result.DataQuality = "PREVIOUS_ODOMETER_ANOMALY"
} else {
result.MileageKm = math.Round((current.TotalMileageKm-previous.TotalMileageKm)*1000) / 1000
result.StatisticsStartTime = previous.DataTime
}
out[dailyMileageKey(vin, date)] = result
}
}
return out, nil
}
@@ -0,0 +1,83 @@
package openplatform
import (
"reflect"
"testing"
)
func TestReconciliationRecoveryAndPagination(t *testing.T) {
points := []DailyMileage{
{VIN: "v", Date: "2026-09-13", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 14366, DataTime: "2026-09-13T07:17:27+08:00"},
{VIN: "v", Date: "2026-09-15", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 14467.4, DataTime: "2026-09-15T20:57:04+08:00"},
}
all, err := reconcileMileage(points, []string{"v"}, "2026-09-14", "2026-09-15", nil)
if err != nil {
t.Fatal(err)
}
if all["v\x002026-09-15"].MileageKm != 101.4 {
t.Fatalf("recovery=%+v", all)
}
for _, date := range []string{"2026-09-14", "2026-09-15"} {
one, _ := reconcileMileage(points, []string{"v"}, date, date, nil)
if !reflect.DeepEqual(one[dailyMileageKey("v", date)], all[dailyMileageKey("v", date)]) {
t.Fatal("page changes result")
}
}
}
func TestReconciliationPinsProtocolThroughMissingDays(t *testing.T) {
points := []DailyMileage{{VIN: "v", Date: "2026-09-11", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 100}, {VIN: "v", Date: "2026-09-12", Protocol: "YUTONG_MQTT", SourceKey: "b", TotalMileageKm: 9000}, {VIN: "v", Date: "2026-09-14", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 120}}
got, _ := reconcileMileage(points, []string{"v"}, "2026-09-12", "2026-09-14", nil)
if got[dailyMileageKey("v", "2026-09-12")].TotalMileageKm != 100 || got[dailyMileageKey("v", "2026-09-14")].MileageKm != 20 {
t.Fatalf("%+v", got)
}
}
func TestReconciliationMarksDiscontinuities(t *testing.T) {
for _, tc := range []struct {
name, source string
total float64
want string
}{{"rollback", "a", 90, mileageTotalRollbackQuality}, {"replacement", "b", 120, "ODOMETER_SOURCE_CHANGED"}} {
t.Run(tc.name, func(t *testing.T) {
got, _ := reconcileMileage([]DailyMileage{{VIN: "v", Date: "2026-09-12", Protocol: "JT808", SourceKey: "a", TotalMileageKm: 100}, {VIN: "v", Date: "2026-09-13", Protocol: "JT808", SourceKey: tc.source, TotalMileageKm: tc.total}}, []string{"v"}, "2026-09-13", "2026-09-13", []string{"JT808"})
v := got[dailyMileageKey("v", "2026-09-13")]
var response MileageResult
fillMileageResult(&response, v, v.MileageKm)
if response.DailyMileageKm != nil || response.Status != StatusDataAnomaly || v.DataQuality != tc.want {
t.Fatalf("%+v %+v", v, response)
}
})
}
}
func TestReconciliationFirstObservationHasNoInventedDistance(t *testing.T) {
got, _ := reconcileMileage([]DailyMileage{{VIN: "v", Date: "2026-09-12", Protocol: "JT808", SourceKey: "a", TotalMileageKm: 100, DataTime: "2026-09-12T10:00:00+08:00"}}, []string{"v"}, "2026-09-12", "2026-09-12", nil)
v := got[dailyMileageKey("v", "2026-09-12")]
var result MileageResult
fillMileageResult(&result, v, v.MileageKm)
if result.DailyMileageKm != nil || result.TotalMileageKm == nil || *result.DataQuality != "NO_PREVIOUS_BASELINE" {
t.Fatalf("%+v", result)
}
}
// A source replacement is flagged only on its boundary; starting a new page
// must not hide that boundary or change the following day's odometer delta.
func TestReconciliationSourceReplacementAcrossPageBoundary(t *testing.T) {
points := []DailyMileage{
{VIN: "v", Date: "2026-09-11", Protocol: "JT808", SourceKey: "old", TotalMileageKm: 9000},
{VIN: "v", Date: "2026-09-12", Protocol: "JT808", SourceKey: "new", TotalMileageKm: 100},
{VIN: "v", Date: "2026-09-14", Protocol: "JT808", SourceKey: "new", TotalMileageKm: 140},
}
all, _ := reconcileMileage(points, []string{"v"}, "2026-09-12", "2026-09-14", nil)
if all[dailyMileageKey("v", "2026-09-12")].DataQuality != "ODOMETER_SOURCE_CHANGED" {
t.Fatal(all)
}
if all[dailyMileageKey("v", "2026-09-14")].MileageKm != 40 {
t.Fatal(all)
}
for _, day := range []string{"2026-09-12", "2026-09-13", "2026-09-14"} {
one, _ := reconcileMileage(points, []string{"v"}, day, day, nil)
if !reflect.DeepEqual(one[dailyMileageKey("v", day)], all[dailyMileageKey("v", day)]) {
t.Fatal(day)
}
}
}
@@ -314,6 +314,8 @@ type DailyHydrogen struct {
}
type DailyMileage struct {
SourceKey string
DataQuality string
StatisticsStartTime string
VIN string
Date string
@@ -29,6 +29,7 @@ var (
)
type Repository interface {
ReconciledMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error)
AuthorizedVehicles(context.Context, uint64, []string, time.Time, time.Time) (map[string]AuthorizedVehicle, error)
DailyHydrogen(context.Context, []string, string) (map[string]DailyHydrogen, error)
@@ -435,35 +436,17 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
plates = vehiclePlates(vehicles)
}
vins := vehicleVINs(vehicles)
values, err := s.repository.DailyMileage(ctx, vins, date, protocols)
values, err := s.repository.ReconciledMileageRange(ctx, vins, date, date, protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
return nil, err
}
rollbacks, err := s.repository.MileageRollbacks(ctx, vins, date, date, protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
return nil, err
}
missingVINs := missingMileageVINs(vins, values, rollbacks, date)
carried := map[string]DailyMileage{}
if len(missingVINs) > 0 {
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
return nil, err
}
}
results := make([]MileageResult, 0, len(plates))
for _, plate := range plates {
vehicle := vehicles[plate]
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
if value, ok := values[dailyMileageKey(vehicle.VIN, date)]; ok {
fillMileageResult(&item, value, value.MileageKm)
} else if rollbacks[dailyMileageKey(vehicle.VIN, date)] {
fillMileageAnomaly(&item)
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageResult(&item, value, 0)
}
results = append(results, item)
}
@@ -556,35 +539,17 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
vinSet[vehicle.VIN] = struct{}{}
}
values := map[string]DailyMileage{}
carried := map[string]DailyMileage{}
rollbacks := map[string]bool{}
if len(positions) > 0 {
vins := make([]string, 0, len(vinSet))
for vin := range vinSet {
vins = append(vins, vin)
}
sort.Strings(vins)
values, err = s.repository.DailyMileageRange(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
values, err = s.repository.ReconciledMileageRange(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
return MileageRangeResponse{}, err
}
rollbacks, err = s.repository.MileageRollbacks(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
return MileageRangeResponse{}, err
}
missingVINs := missingMileageRangeInitialVINs(positions, values, rollbacks)
if len(missingVINs) > 0 {
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
return MileageRangeResponse{}, err
}
if carried == nil {
carried = map[string]DailyMileage{}
}
}
}
results := make([]MileageRangeResult, 0, len(positions))
for _, position := range positions {
@@ -594,14 +559,8 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
Date: position.date,
Status: StatusNoData,
}
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok {
fillMileageRangeResult(&item, value, value.MileageKm)
carried[position.vehicle.VIN] = value
} else if rollbacks[dailyMileageKey(position.vehicle.VIN, position.date)] {
fillMileageRangeAnomaly(&item)
delete(carried, position.vehicle.VIN)
} else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageRangeResult(&item, value, 0)
}
results = append(results, item)
}
@@ -1001,6 +960,10 @@ func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map
}
func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) {
if value.DataQuality != "" && value.DataTime == "" {
fillMileageAnomaly(item)
return
}
if value.Date == item.Date {
item.StatisticsStartTime, item.StatisticsEndTime = validatedStatisticsInterval(value.StatisticsStartTime, value.DataTime)
}
@@ -1014,9 +977,20 @@ func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage flo
sourceProtocol := externalMileageProtocol(value.Protocol)
item.SourceProtocol = &sourceProtocol
item.Status = StatusNormal
if value.DataQuality != "" {
item.DataQuality = stringPointer(value.DataQuality)
if value.DataQuality != "CARRIED_FORWARD" {
item.Status = StatusDataAnomaly
item.DailyMileageKm = nil
}
}
}
func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyMileage float64) {
if value.DataQuality != "" && value.DataTime == "" {
fillMileageRangeAnomaly(item)
return
}
item.DailyMileageKm = &dailyMileage
totalMileage := value.TotalMileageKm
item.TotalMileageKm = &totalMileage
@@ -1027,6 +1001,13 @@ func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyM
sourceProtocol := externalMileageProtocol(value.Protocol)
item.SourceProtocol = &sourceProtocol
item.Status = StatusNormal
if value.DataQuality != "" {
item.DataQuality = stringPointer(value.DataQuality)
if value.DataQuality != "CARRIED_FORWARD" {
item.Status = StatusDataAnomaly
item.DailyMileageKm = nil
}
}
}
const mileageTotalRollbackQuality = "TOTAL_MILEAGE_ROLLBACK"
@@ -47,6 +47,56 @@ func (f *fakeRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates
f.requestedPlates = append([]string(nil), plates...)
return f.vehicles, nil
}
// Repository stub retains fixture values; reconciliation itself is tested with
// actual dated observations in mileage_reconciliation_test.go.
func (f *fakeRepository) ReconciledMileageRange(ctx context.Context, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
f.dailyVINs = append([]string(nil), vins...)
f.rangeVINs = append([]string(nil), vins...)
f.dailyProtocols = protocols
out := map[string]DailyMileage{}
carried := map[string]DailyMileage{}
var missing []string
for _, vin := range vins {
v, ok := f.mileage[dailyMileageKey(vin, start)]
if !ok {
v, ok = f.mileage[vin]
}
if (!ok || !validDailyMileage(v)) && !f.rollbacks[dailyMileageKey(vin, start)] {
missing = append(missing, vin)
}
}
if len(missing) > 0 {
prior, _ := f.LatestMileageBefore(ctx, missing, start, protocols)
for vin, v := range prior {
carried[vin] = v
}
}
first, _ := time.Parse("2006-01-02", start)
last, _ := time.Parse("2006-01-02", end)
for day := first; !day.After(last); day = day.AddDate(0, 0, 1) {
date := day.Format("2006-01-02")
for _, vin := range vins {
key := dailyMileageKey(vin, date)
v, ok := f.mileage[key]
if !ok {
v, ok = f.mileage[vin]
}
if ok && validDailyMileage(v) {
out[key] = v
carried[vin] = v
} else if f.rollbacks[key] {
out[key] = DailyMileage{DataQuality: mileageTotalRollbackQuality}
delete(carried, vin)
} else if v, ok = carried[vin]; ok && validDailyMileage(v) {
v.MileageKm = 0
out[key] = v
}
}
}
return out, nil
}
func (f *fakeRepository) DailyHydrogen(_ context.Context, vins []string, _ string) (map[string]DailyHydrogen, error) {
f.dailyVINs = append([]string(nil), vins...)
return f.hydrogen, nil
@@ -413,6 +413,9 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction
func normalizeVehicleEvent(event AlertEvent) AlertEvent {
event.EventCategory, event.EventType = canonicalVehicleEvent(event.TriggerType, event.Metric, event.Operator)
if event.RuleID == nativeAlarmRuleID {
event.EventCategory, event.EventType = "safety", "vehicle.safety.gb32960_alarm"
}
switch strings.ToLower(strings.TrimSpace(event.Status)) {
case "processing":
event.ExecutionState = "processing"
@@ -169,6 +169,20 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent
if err != nil {
return AlertEvent{}, err
}
if event.RuleID == nativeAlarmRuleID {
var payload []byte
if err := s.db.QueryRowContext(ctx, `SELECT fields_json FROM vehicle_native_alarm_evidence WHERE event_id=?`, id).Scan(&payload); err != nil {
return AlertEvent{}, err
}
if err := json.Unmarshal(payload, &event.NativeAlarmFields); err != nil {
return AlertEvent{}, err
}
if description, valid := DescribeNativeAlarm(event.NativeAlarmFields); valid {
event.RuleName = description.Title
event.NativeAlarmNames = description.Names
event.NativeAlarmReservedBits = description.ReservedBits
}
}
rows, err := s.db.QueryContext(ctx, alertActionSelect, id)
if err != nil {
return AlertEvent{}, err
@@ -22,6 +22,9 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
if err != nil {
return result, err
}
if err := recordNativeAlarmsTx(ctx, tx, records, &result); err != nil {
return result, err
}
if len(rules) == 0 {
return result, nil
}
@@ -0,0 +1,51 @@
package platform
import (
"context"
"net/url"
"testing"
)
func TestCorrectedDailyHydrogenRate(t *testing.T) {
physical, corrected, mixed, zero := 10.0, 12.0, 200.0, 0.0
row := DailyMileageRow{HydrogenConsumptionKg: &physical, HydrogenSOCBalancedKg: &corrected, PureHydrogenMileageKm: 100, MixedMileageKm: &mixed}
if got := correctedDailyHydrogenRate(row); got == nil || *got != 6 {
t.Fatalf("rate must use corrected mass and matching mileage: %v", got)
}
row.MixedMileageKm = nil
if got := correctedDailyHydrogenRate(row); got == nil || *got != 12 {
t.Fatalf("fallback mileage: %v", got)
}
row.HydrogenSOCBalancedKg = nil
if got := correctedDailyHydrogenRate(row); got != nil {
t.Fatalf("missing correction must not use physical mass: %v", *got)
}
row.HydrogenSOCBalancedKg = &zero
if got := correctedDailyHydrogenRate(row); got == nil || *got != 0 {
t.Fatalf("real zero must survive: %v", got)
}
row.PureHydrogenMileageKm = 0
if got := correctedDailyHydrogenRate(row); got != nil {
t.Fatalf("zero mileage must have no rate: %v", *got)
}
}
type distinctHydrogenStore struct{ *MockStore }
func (s *distinctHydrogenStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
physical, corrected, distance := 12.813, 8.714, 77.0
return Page[DailyMileageRow]{Items: []DailyMileageRow{{VIN: "LA9GG64L1NBAF4167", Date: "2026-09-01", HydrogenConsumptionKg: &physical, HydrogenSOCBalancedKg: &corrected, MixedMileageKm: &distance}}}, nil
}
func TestDailyMileageHistoricalPublicFieldsUseCorrection(t *testing.T) {
result, err := NewService(&distinctHydrogenStore{NewMockStore()}).DailyMileage(context.Background(), url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-01"}})
if err != nil {
t.Fatal(err)
}
row := result.Items[0]
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 8.714 || row.HydrogenPhysicalConsumptionKg == nil || *row.HydrogenPhysicalConsumptionKg != 12.813 {
t.Fatalf("historical primary amount must be corrected, with physical amount separately available: %+v", row)
}
if row.HydrogenConsumptionKgPer100Km == nil || *row.HydrogenConsumptionKgPer100Km != 8.714*100/77 {
t.Fatalf("historical rate mismatch: %+v", row)
}
}
@@ -34,6 +34,9 @@ func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEve
if result.Parameters.BatteryCapacityKWh != 21.04 || result.RefuelAmountKg == nil || *result.RefuelAmountKg != 8.976 || result.ChargeEnergyKWh == nil || *result.ChargeEnergyKWh != 12.5 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" {
t.Fatalf("result=%#v", result)
}
if result.ConsumptionKgPer100Km == nil || *result.ConsumptionKgPer100Km != 5.562 || result.PhysicalConsumptionKgPer100Km == nil || *result.PhysicalConsumptionKgPer100Km != 5.516 {
t.Fatalf("evidence must expose corrected rate separately from physical rate: %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
@@ -1,6 +1,9 @@
package platform
import "time"
import (
"encoding/json"
"time"
)
type Page[T any] struct {
Items []T `json:"items"`
@@ -1000,37 +1003,40 @@ type AlertRuleLifecycleRequest struct {
}
type AlertEvent struct {
ID string `json:"id"`
EventType string `json:"eventType"`
EventCategory string `json:"eventCategory"`
ExecutionState string `json:"executionState"`
RuleID string `json:"ruleId"`
RuleName string `json:"ruleName"`
RuleVersion int `json:"ruleVersion"`
Severity string `json:"severity"`
TriggerType string `json:"triggerType"`
Status string `json:"status"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Protocol string `json:"protocol"`
Metric string `json:"metric"`
Operator string `json:"operator"`
TriggerValue float64 `json:"triggerValue"`
Threshold float64 `json:"threshold"`
ThresholdHigh float64 `json:"thresholdHigh"`
Unit string `json:"unit"`
DurationSec int `json:"durationSec"`
Location string `json:"location"`
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
SourceEventID string `json:"sourceEventId"`
EventAt string `json:"eventAt"`
ReceivedAt string `json:"receivedAt"`
TriggeredAt string `json:"triggeredAt"`
RecoveredAt string `json:"recoveredAt"`
Handler string `json:"handler"`
Version int `json:"version"`
Actions []AlertAction `json:"actions,omitempty"`
NativeAlarmNames []string `json:"nativeAlarmNames,omitempty"`
NativeAlarmReservedBits []int `json:"nativeAlarmReservedBits,omitempty"`
NativeAlarmFields map[string]json.RawMessage `json:"nativeAlarmFields,omitempty"`
ID string `json:"id"`
EventType string `json:"eventType"`
EventCategory string `json:"eventCategory"`
ExecutionState string `json:"executionState"`
RuleID string `json:"ruleId"`
RuleName string `json:"ruleName"`
RuleVersion int `json:"ruleVersion"`
Severity string `json:"severity"`
TriggerType string `json:"triggerType"`
Status string `json:"status"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Protocol string `json:"protocol"`
Metric string `json:"metric"`
Operator string `json:"operator"`
TriggerValue float64 `json:"triggerValue"`
Threshold float64 `json:"threshold"`
ThresholdHigh float64 `json:"thresholdHigh"`
Unit string `json:"unit"`
DurationSec int `json:"durationSec"`
Location string `json:"location"`
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
SourceEventID string `json:"sourceEventId"`
EventAt string `json:"eventAt"`
ReceivedAt string `json:"receivedAt"`
TriggeredAt string `json:"triggeredAt"`
RecoveredAt string `json:"recoveredAt"`
Handler string `json:"handler"`
Version int `json:"version"`
Actions []AlertAction `json:"actions,omitempty"`
}
type AlertAction struct {
@@ -1874,6 +1880,12 @@ type LatestTelemetryResponse struct {
}
type DailyMileageRow struct {
Province string `json:"province,omitempty"`
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
LocationTime string `json:"locationTime,omitempty"`
LocationStatus string `json:"locationStatus"`
HydrogenPhysicalConsumptionKg *float64 `json:"hydrogenPhysicalConsumptionKg,omitempty"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Date string `json:"date"`
@@ -1942,32 +1954,33 @@ type HydrogenIntervalEvidenceRow struct {
}
type HydrogenDailyEvidence struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Date string `json:"date"`
Source string `json:"source"`
RawConsumptionKg float64 `json:"rawConsumptionKg"`
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
MixedMileageKm float64 `json:"mixedMileageKm"`
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"`
RefuelCount int `json:"refuelCount"`
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
ChargeCount int `json:"chargeCount"`
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
ValidSegmentCount int `json:"validSegmentCount"`
InvalidSegmentCount int `json:"invalidSegmentCount"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
AlgorithmVersion string `json:"algorithmVersion"`
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
CalculatedAt string `json:"calculatedAt"`
PhysicalConsumptionKgPer100Km *float64 `json:"physicalConsumptionKgPer100Km,omitempty"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Date string `json:"date"`
Source string `json:"source"`
RawConsumptionKg float64 `json:"rawConsumptionKg"`
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
MixedMileageKm float64 `json:"mixedMileageKm"`
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"`
RefuelCount int `json:"refuelCount"`
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
ChargeCount int `json:"chargeCount"`
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
ValidSegmentCount int `json:"validSegmentCount"`
InvalidSegmentCount int `json:"invalidSegmentCount"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
AlgorithmVersion string `json:"algorithmVersion"`
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
CalculatedAt string `json:"calculatedAt"`
}
// MileageQuery is the POST contract used by mileage statistics and daily
@@ -565,7 +565,8 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
LEFT JOIN vehicle_open_daily_energy h
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
AND h.stat_date = m.stat_date
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')`
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')
LEFT JOIN vehicle_daily_geography g ON g.vin = m.vin COLLATE utf8mb4_unicode_ci AND g.stat_date = m.stat_date`
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
@@ -594,7 +595,7 @@ LEFT JOIN vehicle_open_daily_energy h
`h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, ` +
`CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END AS hydrogen_evidence_available, ` +
`COALESCE(h.quality_status, '') AS hydrogen_quality_status, COALESCE(h.quality_reason, '') AS hydrogen_quality_reason, ` +
`COALESCE(h.algorithm_version, '') AS hydrogen_algorithm_version, m.protocol ` +
`COALESCE(h.algorithm_version, '') AS hydrogen_algorithm_version, m.protocol, ` + dailyGeographyProjectionSQL + ` ` +
`FROM (` + pageSQL + `) m` + enrichmentSQL + ` ORDER BY m.stat_date DESC, m.vin ASC`,
Args: args,
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
@@ -613,7 +614,7 @@ LEFT JOIN vehicle_open_daily_energy h
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` +
`h.soc_balanced_kg_per_100km, h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, ` +
`h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol ` +
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol, ` + dailyGeographyProjectionSQL + ` ` +
`FROM (SELECT m.* ` + filterFromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?) m` + enrichmentSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC`,
Args: args,
CountText: `SELECT COUNT(*) ` + filterFromSQL,
@@ -710,7 +711,7 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
dailyMileageExpression + ` AS daily_mileage_km, ` +
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
`CASE WHEN MAX(COALESCE(h.mixed_mileage_km,0))>0 THEN MAX(h.mixed_mileage_km) ELSE ` + pureHydrogenMileageExpression + ` END AS hydrogen_matched_mileage_km, ` +
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_consumption_kg, ` +
latestMileageExpression + ` AS latest_mileage_km ` +
`FROM vehicle_daily_mileage m
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
@@ -917,3 +918,5 @@ func mustInt(value string) int {
n, _ := strconv.Atoi(value)
return n
}
const dailyGeographyProjectionSQL = `COALESCE(g.province,''),COALESCE(g.city,''),COALESCE(g.region,''),COALESCE(DATE_FORMAT(g.location_time,'%Y-%m-%dT%H:%i:%s.%f+08:00'),''),CASE COALESCE(g.status,'PENDING') WHEN 'RESOLVED' THEN '已解析' WHEN 'NO_LOCATION' THEN '当天无有效定位' WHEN 'ERROR' THEN '解析失败,后台重试中' ELSE '后台待解析' END`
@@ -0,0 +1,142 @@
package platform
import (
"context"
"database/sql"
"encoding/json"
"math"
"time"
)
const nativeAlarmRuleID = "native-gb32960-alarm"
// Require the complete 0x07 unit: absent/invalid data must never clear an alarm.
func nativeAlarmFields(record AlertStreamRecord) (map[string]json.RawMessage, int, bool, bool) {
if record.Protocol != "GB32960" {
return nil, 0, false, false
}
fields := make(map[string]json.RawMessage, 6)
level, levelOK := alertStreamRawNumber(record.Fields["gb32960.alarm.max_alarm_level"])
flag, flagOK := alertStreamRawNumber(record.Fields["gb32960.alarm.general_alarm_flag"])
if !levelOK || !flagOK || level < 0 || level > 3 || math.Trunc(level) != level || flag < 0 || flag > math.MaxUint32 || math.Trunc(flag) != flag {
return nil, 0, false, false
}
mask := nativeAlarm2016Mask
if nativeAlarmVersion(record.Fields) == "V2025" {
mask = math.MaxUint32
}
active := level > 0 || uint32(flag)&mask != 0
if version := record.Fields["gb32960.header.version"]; len(version) > 0 {
fields["gb32960.header.version"] = version
}
for _, name := range []string{"max_alarm_level", "general_alarm_flag", "battery_faults", "motor_faults", "engine_faults", "other_faults"} {
key := "gb32960.alarm." + name
raw := record.Fields[key]
if name != "max_alarm_level" && name != "general_alarm_flag" {
// The gateway's FIELDS contract serializes complex KV values as JSON strings.
var encoded string
if json.Unmarshal(raw, &encoded) == nil {
raw = json.RawMessage(encoded)
}
var codes []string
if len(raw) == 0 || string(raw) == "null" || json.Unmarshal(raw, &codes) != nil || codes == nil {
return nil, 0, false, false
}
active = active || len(codes) > 0
}
fields[key] = raw
}
return fields, int(level), active, true
}
// Runs in the same transaction as the Kafka checkpoint, independently of rules.
func recordNativeAlarmsTx(ctx context.Context, tx *sql.Tx, records []AlertStreamRecord, result *AlertEvaluationResult) error {
eligible := make([]AlertStreamRecord, 0)
for _, record := range records {
if _, _, _, ok := nativeAlarmFields(record); ok && !record.Late {
eligible = append(eligible, record)
}
}
if len(eligible) == 0 {
return nil
}
metadata, err := loadAlertStreamVehicleMetadata(ctx, tx, eligible)
if err != nil {
return err
}
for _, record := range eligible {
fields, level, active, _ := nativeAlarmFields(record)
// Serialize concurrent consumers for this VIN, including the first observation.
if _, err = tx.ExecContext(ctx, `INSERT IGNORE INTO vehicle_native_alarm_state(vin,last_event_at) VALUES(?,'1970-01-01 00:00:00')`, record.VIN); err != nil {
return err
}
var lastAt time.Time
var eventID string
if err = tx.QueryRowContext(ctx, `SELECT last_event_at,active_event_id FROM vehicle_native_alarm_state WHERE vin=? FOR UPDATE`, record.VIN).Scan(&lastAt, &eventID); err != nil {
return err
}
if !record.EventAt.After(lastAt) {
result.LateObservations++
continue
}
if active && eventID == "" {
eventID, err = newAlertID("alert")
if err != nil {
return err
}
severity := "minor"
if level == 2 {
severity = "major"
}
if level == 3 {
severity = "critical"
}
description, _ := DescribeNativeAlarm(fields)
title := description.Title
rule := AlertRule{ID: nativeAlarmRuleID, Name: title, Severity: severity, TriggerType: "metric", Metric: "alarm_active", Operator: "eq", Threshold: 1}
item := alertStreamEvidence(record, metadata[record.VIN], nil)
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
item.EventAt = record.EventAt.In(shanghai).Format("2006-01-02 15:04:05.999")
item.ReceivedAt = record.ReceivedAt.In(shanghai).Format("2006-01-02 15:04:05.999")
query, args := buildAlertEventInsert(eventID, nativeAlarmRuleID+"|"+record.VIN+"|GB32960", rule, item, 1)
// Missing coordinates must not appear as a real position at (0,0).
if !item.HasLocation {
args[17], args[18], args[19] = "", nil, nil
}
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
return err
}
payload, marshalErr := json.Marshal(fields)
if marshalErr != nil {
return marshalErr
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_native_alarm_evidence(event_id,fields_json) VALUES(?,?)`, eventID, string(payload)); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'trigger','','unprocessed','native-gb32960','车辆上报原生告警')`, eventID); err != nil {
return err
}
result.Opened++
} else if !active && eventID != "" {
update, updateErr := tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET status='recovered',recovered_at=?,version=version+1 WHERE id=? AND status IN ('unprocessed','processing')`, record.EventAt, eventID)
if updateErr != nil {
return updateErr
}
count, countErr := update.RowsAffected()
if countErr != nil {
return countErr
}
if count > 0 {
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'recover','','recovered','native-gb32960','车辆上报原生告警解除')`, eventID); err != nil {
return err
}
result.Recovered++
}
eventID = ""
}
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_native_alarm_state SET last_event_at=?,active_event_id=? WHERE vin=?`, record.EventAt, eventID, record.VIN); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,84 @@
package platform
import (
"encoding/json"
"fmt"
"strings"
)
// GB/T 32960.3-2016, table 18. Bits 1931 are reserved, not faults.
var nativeAlarmBitNames = [...]string{
"温度差异报警", "电池高温报警", "车载储能装置类型过压报警", "车载储能装置类型欠压报警",
"SOC低报警", "单体电池过压报警", "单体电池欠压报警", "SOC过高报警", "SOC跳变报警",
"可充电储能系统不匹配报警", "电池单体一致性差报警", "绝缘报警", "DC-DC温度报警", "制动系统报警",
"DC-DC状态报警", "驱动电机控制器温度报警", "高压互锁状态报警", "驱动电机温度报警", "车载储能装置类型过充报警",
}
const nativeAlarm2016Mask uint32 = (1 << 19) - 1
// NativeAlarmDescription is also used by the repair command so historical and
// newly ingested events use the same interpretation of their saved evidence.
type NativeAlarmDescription struct {
Title string `json:"title"`
Names []string `json:"names"`
ReservedBits []int `json:"reservedBits"`
Active bool `json:"active"`
}
func nativeAlarmVersion(fields map[string]json.RawMessage) string {
var version string
_ = json.Unmarshal(fields["gb32960.header.version"], &version)
return version
}
func DescribeNativeAlarm(fields map[string]json.RawMessage) (NativeAlarmDescription, bool) {
fields, level, active, valid := nativeAlarmFields(AlertStreamRecord{Protocol: "GB32960", Fields: fields})
result := NativeAlarmDescription{Names: []string{}, ReservedBits: []int{}, Active: active}
if !valid {
return result, false
}
value, _ := alertStreamRawNumber(fields["gb32960.alarm.general_alarm_flag"])
flag := uint32(value)
for bit, name := range nativeAlarmBitNames {
if flag&(uint32(1)<<bit) != 0 {
result.Names = append(result.Names, name)
}
}
for bit := 19; bit < 32; bit++ {
if flag&(uint32(1)<<bit) == 0 {
continue
}
if nativeAlarmVersion(fields) == "V2025" {
result.Names = append(result.Names, fmt.Sprintf("扩展报警位 bit%d(待匹配2025版定义)", bit))
} else {
result.ReservedBits = append(result.ReservedBits, bit)
}
}
for _, group := range []struct{ key, label string }{
{"battery_faults", "可充电储能装置"}, {"motor_faults", "驱动电机"}, {"engine_faults", "发动机"}, {"other_faults", "其他"},
} {
var codes []string
_ = json.Unmarshal(fields["gb32960.alarm."+group.key], &codes)
for _, code := range codes {
result.Names = append(result.Names, group.label+"故障码 "+code+"(厂商定义)")
}
}
if len(result.Names) == 0 && level > 0 {
result.Names = append(result.Names, fmt.Sprintf("%d级故障(车辆未上报具体故障项)", level))
}
if len(result.Names) == 0 {
result.Title = "无标准告警"
return result, true
}
result.Title = strings.Join(result.Names, "、")
// Persist a useful, bounded title in the existing VARCHAR(80) column; detail
// keeps every name and code, including concurrent faults.
if len([]rune(result.Title)) > 80 {
first := []rune(result.Names[0])
if len(first) > 55 {
first = first[:55]
}
result.Title = fmt.Sprintf("%s 等%d项告警", string(first), len(result.Names))
}
return result, true
}
@@ -0,0 +1,203 @@
package platform
import (
"encoding/json"
"fmt"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func nativeAlarmRecord(level, flag string) AlertStreamRecord {
fields := map[string]json.RawMessage{"gb32960.alarm.max_alarm_level": json.RawMessage(level), "gb32960.alarm.general_alarm_flag": json.RawMessage(flag)}
for _, name := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
fields["gb32960.alarm."+name] = json.RawMessage(`[]`)
}
return AlertStreamRecord{Protocol: "GB32960", VIN: "VIN1", SourceEventID: "native-source", EventAt: time.Date(2026, 9, 17, 1, 0, 0, 0, time.UTC), ReceivedAt: time.Date(2026, 9, 17, 1, 0, 1, 0, time.UTC), Fields: fields, Valid: true}
}
func TestNativeAlarmFields(t *testing.T) {
for _, tc := range []struct {
name, level, flag string
active, valid bool
}{
{"clear", "0", `"0x00000000"`, false, true},
{"level", "3", `0`, true, true},
{"flag", "0", `"0x00000004"`, true, true},
{"reserved only", "0", `"0x00300000"`, false, true},
{"reserved and insulation", "0", `"0x00380800"`, true, true},
{"abnormal", "254", `0`, false, false},
{"invalid", "255", `0`, false, false},
{"fraction", "1.5", `0`, false, false},
{"bad bitmap", "0", `"garbage"`, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, active, valid := nativeAlarmFields(nativeAlarmRecord(tc.level, tc.flag))
if active != tc.active || valid != tc.valid {
t.Fatalf("active=%v valid=%v", active, valid)
}
})
}
encodedRecord := nativeAlarmRecord(`"2"`, `"0x00000004"`)
for _, name := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
encodedRecord.Fields["gb32960.alarm."+name] = json.RawMessage(`"[]"`)
}
encodedRecord.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`"[\"0x00000001\"]"`)
fields, _, active, valid := nativeAlarmFields(encodedRecord)
if !valid || !active || string(fields["gb32960.alarm.motor_faults"]) != `["0x00000001"]` {
t.Fatalf("gateway string-encoded fault arrays lost: %v", fields)
}
record := nativeAlarmRecord("0", "0")
record.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`["0x00000001"]`)
if _, _, active, valid := nativeAlarmFields(record); !active || !valid {
t.Fatal("fault code alone must activate alarm")
}
delete(record.Fields, "gb32960.alarm.battery_faults")
if _, _, _, valid := nativeAlarmFields(record); valid {
t.Fatal("partial unit must not activate or recover")
}
}
func TestNativeAlarmLifecycleWithoutRules(t *testing.T) {
for _, tc := range []struct {
name, existing string
clear, stale, closed bool
}{
{name: "open without rules"},
{name: "continuous alarm deduplicated", existing: "existing"},
{name: "recover", existing: "existing", clear: true},
{name: "manual closure preserved", existing: "existing", clear: true, closed: true},
{name: "out of order ignored", existing: "existing", clear: true, stale: true},
} {
t.Run(tc.name, func(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
record := nativeAlarmRecord("2", `"0x00000004"`)
if tc.clear {
record = nativeAlarmRecord("0", "0")
}
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectQuery(`SELECT v.vin,`).WithArgs("VIN1").WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "oem", "model", "company"}).AddRow("VIN1", "粤A12345", "", "", ""))
mock.ExpectExec(`INSERT IGNORE INTO vehicle_native_alarm_state`).WithArgs("VIN1").WillReturnResult(sqlmock.NewResult(1, 1))
last := record.EventAt.Add(-time.Second)
if tc.stale {
last = record.EventAt.Add(time.Second)
}
mock.ExpectQuery(`SELECT last_event_at,active_event_id`).WithArgs("VIN1").WillReturnRows(sqlmock.NewRows([]string{"last_event_at", "active_event_id"}).AddRow(last, tc.existing))
if !tc.stale {
if tc.existing == "" {
mock.ExpectExec(regexp.QuoteMeta(alertEventInsertSQL)).WithArgs(sqlmock.AnyArg(), nativeAlarmRuleID+"|VIN1|GB32960", nativeAlarmRuleID, "车载储能装置类型过压报警", 0, "major", "metric", "VIN1", "粤A12345", "GB32960", "alarm_active", "eq", float64(1), float64(1), float64(0), "", 0, "", nil, nil, "native-source", sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
payload, _ := json.Marshal(record.Fields)
mock.ExpectExec(`INSERT INTO vehicle_native_alarm_evidence`).WithArgs(sqlmock.AnyArg(), string(payload)).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WillReturnResult(sqlmock.NewResult(1, 1))
} else if tc.clear {
count := int64(1)
if tc.closed {
count = 0
}
mock.ExpectExec(`UPDATE vehicle_alert_event SET status='recovered'`).WithArgs(record.EventAt, tc.existing).WillReturnResult(sqlmock.NewResult(0, count))
if !tc.closed {
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WillReturnResult(sqlmock.NewResult(1, 1))
}
}
mock.ExpectExec(`UPDATE vehicle_native_alarm_state`).WithArgs(record.EventAt, sqlmock.AnyArg(), "VIN1").WillReturnResult(sqlmock.NewResult(0, 1))
}
mock.ExpectCommit()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
result, err := evaluateAlertStreamRecordsTx(t.Context(), tx, []AlertStreamRecord{record}, record.ReceivedAt)
if err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if result.RulesEvaluated != 0 {
t.Fatal("native alarms require no automation")
}
if tc.existing == "" && result.Opened != 1 {
t.Fatalf("not opened: %+v", result)
}
if tc.existing != "" && result.Opened != 0 {
t.Fatal("duplicate event")
}
if tc.clear && !tc.closed && !tc.stale && result.Recovered != 1 {
t.Fatal("not recovered")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
func TestNativeAlarmLateAndMissingUnitsDoNotWrite(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
late := nativeAlarmRecord("3", "1")
late.Late = true
missing := nativeAlarmRecord("0", "0")
delete(missing.Fields, "gb32960.alarm.general_alarm_flag")
mock.ExpectBegin()
mock.ExpectCommit()
tx, _ := db.Begin()
if err := recordNativeAlarmsTx(t.Context(), tx, []AlertStreamRecord{late, missing}, &AlertEvaluationResult{}); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestNativeAlarmDescriptionDecodesSpecificFaultsAndReservedBits(t *testing.T) {
record := nativeAlarmRecord("0", `"0x00380800"`)
d, ok := DescribeNativeAlarm(record.Fields)
if !ok || !d.Active || d.Title != "绝缘报警" || len(d.Names) != 1 || len(d.ReservedBits) != 3 || d.ReservedBits[0] != 19 {
t.Fatalf("wrong description: %+v", d)
}
record = nativeAlarmRecord("0", `"0x00380000"`)
d, ok = DescribeNativeAlarm(record.Fields)
if !ok || d.Active || len(d.Names) != 0 {
t.Fatalf("reserved bits became faults: %+v", d)
}
record = nativeAlarmRecord("2", `"0x00020810"`)
record.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`"[\"0x12345678\"]"`)
d, ok = DescribeNativeAlarm(record.Fields)
if !ok || len(d.Names) != 4 || d.Names[0] != "SOC低报警" || d.Names[1] != "绝缘报警" || d.Names[2] != "驱动电机温度报警" || d.Names[3] != "驱动电机故障码 0x12345678(厂商定义)" {
t.Fatalf("lost concurrent faults: %+v", d)
}
record = nativeAlarmRecord("3", `"0x00000000"`)
d, ok = DescribeNativeAlarm(record.Fields)
if !ok || !d.Active || d.Title != "3级故障(车辆未上报具体故障项)" {
t.Fatalf("invented fault meaning: %+v", d)
}
for bit, name := range nativeAlarmBitNames {
record = nativeAlarmRecord("0", fmt.Sprintf("%d", uint32(1)<<bit))
d, ok = DescribeNativeAlarm(record.Fields)
if !ok || !d.Active || d.Title != name {
t.Fatalf("bit %d decoded as %+v", bit, d)
}
}
record = nativeAlarmRecord("0", `"0x00300000"`)
record.Fields["gb32960.header.version"] = json.RawMessage(`"V2025"`)
d, ok = DescribeNativeAlarm(record.Fields)
if !ok || !d.Active || len(d.Names) != 2 || len(d.ReservedBits) != 0 {
t.Fatalf("2025 extension must not be silently discarded: %+v", d)
}
record = nativeAlarmRecord("2", `"0x0007FFFF"`)
d, _ = DescribeNativeAlarm(record.Fields)
if len(d.Names) != 19 || len([]rune(d.Title)) > 80 {
t.Fatalf("title overflow or lost detail: %+v", d)
}
}
@@ -1158,7 +1158,7 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
var evidenceAvailable int
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm,
&hydrogen, &physicalRate, &balanced, &balancedRate, &pureElectric, &mixed, &socDelta, &chargeCount, &chargeEnergy, &refuelCount, &refuelAmount,
&evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &row.HydrogenAlgorithmVersion, &row.Source); err != nil {
&evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &row.HydrogenAlgorithmVersion, &row.Source, &row.Province, &row.City, &row.Region, &row.LocationTime, &row.LocationStatus); err != nil {
return Page[DailyMileageRow]{}, err
}
if hydrogen.Valid {
@@ -1237,7 +1237,8 @@ LIMIT 1`, vin, date)
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
result.PhysicalConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
result.ConsumptionKgPer100Km = nullableFloatPointer(balancedRate)
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
result.RefuelAmountKg = nullableFloatPointer(refuelAmount)
result.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy)
@@ -1267,7 +1268,7 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
result := MileageStatistics{
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的纯氢里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的纯氢里程计算)/ vehicle_open_daily_energy(质量通过的日修正用氢量)/ vehicle_realtime_location(最新里程表)",
}
summary := buildMileageStatisticsSummarySQL(query)
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
@@ -24,14 +24,14 @@ func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testi
"hydrogen_consumption_kg", "hydrogen_consumption_kg_per_100km", "hydrogen_soc_balanced_kg", "hydrogen_soc_balanced_kg_per_100km",
"pure_electric_mileage_km", "mixed_mileage_km", "battery_soc_delta_pct", "charge_count", "charge_energy_kwh",
"refuel_count", "refuel_amount_kg", "hydrogen_evidence_available", "hydrogen_quality_status", "hydrogen_quality_reason",
"hydrogen_algorithm_version", "protocol",
"hydrogen_algorithm_version", "protocol", "province", "city", "region", "location_time", "location_status",
}
mock.ExpectQuery("SELECT m.vin").
WithArgs(20, 0).
WillReturnRows(sqlmock.NewRows(columns).AddRow(
"LB9A32A29R0LS1423", "粤AGR9816", "2026-08-26", 23119.8, 23333.1, 213.3, 213.3,
4.491, 2.106, 4.544, 2.130, 0.0, 213.3, -4.0, 1, 12.5,
1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960",
1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960", "广东省", "广州市", "华南", "2026-08-26T23:59:00+08:00", "已解析",
))
page, err := (&ProductionStore{db: db}).DailyMileage(context.Background(), url.Values{"skipCount": {"1"}})
@@ -42,6 +42,9 @@ func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testi
t.Fatalf("items=%#v", page.Items)
}
row := page.Items[0]
if row.Province != "广东省" || row.City != "广州市" || row.Region != "华南" || row.LocationStatus != "已解析" {
t.Fatalf("missing persisted geography: %+v", row)
}
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 4.491 || row.HydrogenQualityStatus != "SUSPECT" || row.HydrogenQualityReason == "" ||
row.ChargeCount == nil || *row.ChargeCount != 1 || row.ChargeEnergyKWh == nil || *row.ChargeEnergyKWh != 12.5 ||
row.RefuelCount == nil || *row.RefuelCount != 1 || row.RefuelAmountKg == nil || *row.RefuelAmountKg != 8.976 {
@@ -693,7 +693,7 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
"GROUP BY m.vin, m.stat_date",
"MAX(COALESCE(m.daily_mileage_km",
"MAX(COALESCE(m.pure_hydrogen_mileage_km",
"MAX(h.consumption_kg)",
"MAX(h.soc_balanced_consumption_kg)",
"COUNT(DISTINCT d.vin)",
"SUM(d.daily_mileage_km)",
"SUM(d.pure_hydrogen_mileage_km)",
@@ -5591,10 +5591,18 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
if err != nil {
return Page[DailyMileageRow]{}, err
}
for index := range result.Items {
row := &result.Items[index]
row.HydrogenPhysicalConsumptionKg = row.HydrogenConsumptionKg
row.HydrogenConsumptionKg = row.HydrogenSOCBalancedKg
row.HydrogenConsumptionKgPer100Km = correctedDailyHydrogenRate(*row)
}
if !hydrogenConsumptionAllowed(ctx) {
for index := range result.Items {
result.Items[index].PureHydrogenMileageKm = 0
result.Items[index].HydrogenConsumptionKg = nil
result.Items[index].HydrogenPhysicalConsumptionKg = nil
result.Items[index].HydrogenConsumptionKgPer100Km = nil
result.Items[index].HydrogenSOCBalancedKg = nil
result.Items[index].HydrogenSOCBalancedKgPer100Km = nil
@@ -6702,3 +6710,16 @@ func boolToInt(value bool) int {
}
return 0
}
// correctedDailyHydrogenRate keeps physical consumption available for audit,
// but never uses it as a fallback for the displayed consumption rate.
func correctedDailyHydrogenRate(row DailyMileageRow) *float64 {
if row.HydrogenSOCBalancedKg == nil {
return nil
}
mileage := row.PureHydrogenMileageKm
if row.MixedMileageKm != nil && *row.MixedMileageKm > 0 {
mileage = *row.MixedMileageKm
}
return hydrogenRatePer100Km(*row.HydrogenSOCBalancedKg, mileage, 1)
}
@@ -629,7 +629,7 @@ func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T)
t.Fatal(err)
}
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil ||
daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable {
daily.Items[0].HydrogenPhysicalConsumptionKg != nil || daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable {
t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
}
summary, err := service.MileageStatistics(customer, query)