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
@@ -0,0 +1,133 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
_ "github.com/go-sql-driver/mysql"
_ "github.com/taosdata/driver-go/v3/taosWS"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/dailygeo"
"log"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
if e := run(); e != nil {
log.Fatal(e)
}
}
func run() error {
mode := flag.String("mode", "status", "status, backfill, resolve, or live")
from := flag.String("from", "", "first historical date, defaults to first hydrogen day")
to := flag.String("to", "", "last historical date, defaults to yesterday")
concurrency := flag.Int("concurrency", 3, "maximum concurrent geocoding requests (1-8)")
flag.Parse()
cfg := config.Load()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
db, e := sql.Open("mysql", cfg.MySQLDSN)
if e != nil {
return e
}
defer db.Close()
db.SetMaxOpenConns(12)
if *from == "" {
if e = db.QueryRowContext(ctx, `SELECT COALESCE(DATE_FORMAT(MIN(stat_date),'%Y-%m-%d'),'') FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN'`).Scan(from); e != nil {
return e
}
}
if *to == "" {
*to = time.Now().In(dailygeo.Shanghai).AddDate(0, 0, -1).Format("2006-01-02")
}
first, e := time.ParseInLocation("2006-01-02", *from, dailygeo.Shanghai)
if e != nil {
return e
}
last, e := time.ParseInLocation("2006-01-02", *to, dailygeo.Shanghai)
if e != nil {
return e
}
if first.After(last) {
return fmt.Errorf("invalid date range")
}
if *mode == "status" {
return printStatus(ctx, db, *from, *to)
}
if cfg.AMapAPIKey == "" {
return fmt.Errorf("AMAP_API_KEY required")
}
td, e := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
if e != nil {
return e
}
defer td.Close()
td.SetMaxOpenConns(2)
w := dailygeo.New(db, td, cfg.TDengineDatabase, &dailygeo.AMap{Key: cfg.AMapAPIKey})
w.Concurrency = *concurrency
if *mode == "live" {
w.Run(ctx)
return ctx.Err()
}
if *mode != "backfill" && *mode != "resolve" {
return fmt.Errorf("invalid mode")
}
return w.WithLock(ctx, "vehicle_daily_geography_backfill", func() error {
if *mode == "backfill" {
for d := first; !d.After(last); d = d.AddDate(0, 0, 1) {
rctx, c := context.WithTimeout(ctx, 90*time.Second)
n, e := w.RefreshDay(rctx, d.Format("2006-01-02"))
c()
if e != nil {
return e
}
log.Printf("daily_geography_seed date=%s points=%d", d.Format("2006-01-02"), n)
}
}
for {
n, failed, e := w.ResolvePending(ctx, *from, *to, 1000)
log.Printf("daily_geography_batch resolved=%d failed=%d error=%v", n, failed, e)
if ctx.Err() != nil {
return ctx.Err()
}
if e != nil && failed == 0 {
return e
}
if n+failed == 0 {
break
}
}
return printStatus(ctx, db, *from, *to)
})
}
func printStatus(ctx context.Context, db *sql.DB, from, to string) error {
rows, e := db.QueryContext(ctx, `SELECT status,COUNT(*) FROM vehicle_daily_geography WHERE stat_date BETWEEN ? AND ? GROUP BY status`, from, to)
if e != nil {
return e
}
defer rows.Close()
counts := map[string]int{}
for rows.Next() {
var s string
var n int
if e = rows.Scan(&s, &n); e != nil {
return e
}
counts[s] = n
}
if e = rows.Err(); e != nil {
return e
}
rows.Close()
var missing int
e = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_daily_energy h LEFT JOIN vehicle_daily_geography g ON g.vin=h.vin COLLATE utf8mb4_unicode_ci AND g.stat_date=h.stat_date WHERE h.energy_type='HYDROGEN' AND h.stat_date BETWEEN ? AND ? AND g.vin IS NULL`, from, to).Scan(&missing)
if e != nil {
return e
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"from": from, "to": to, "counts": counts, "hydrogenDaysMissing": missing})
}
@@ -0,0 +1,93 @@
// Read-only production verification of the same repository used by HTTP APIs.
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
_ "github.com/go-sql-driver/mysql"
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
"math"
"os"
"reflect"
"strings"
"time"
)
func main() {
start := flag.String("start", "2026-09-11", "first date")
end := flag.String("end", "2026-09-15", "last date")
vin := flag.String("vins", "", "VINs, empty = all current vehicles")
flag.Parse()
db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
must(err)
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
vins := strings.Split(*vin, ",")
if *vin == "" {
vins = nil
rows, e := db.QueryContext(ctx, "SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin<>''")
must(e)
for rows.Next() {
var v string
must(rows.Scan(&v))
vins = append(vins, v)
}
must(rows.Err())
rows.Close()
}
r := openplatform.NewMySQLRepository(db)
began := time.Now()
priorities := []string{"GB32960", "YUTONG_MQTT"}
all, err := r.ReconciledMileageRange(ctx, vins, *start, *end, priorities)
must(err)
first, e := time.Parse("2006-01-02", *start)
must(e)
last, e := time.Parse("2006-01-02", *end)
must(e)
mismatches := 0
differences := 0
normal := 0
qualities := map[string]int{}
samples := map[string]openplatform.DailyMileage{}
for date := first; !date.After(last); date = date.AddDate(0, 0, 1) {
d := date.Format("2006-01-02")
single, e := r.ReconciledMileageRange(ctx, vins, d, d, priorities)
must(e)
for _, v := range vins {
key := v + "\x00" + d
value, ok := all[key]
one, exists := single[key]
if ok != exists || !reflect.DeepEqual(value, one) {
differences++
}
if !ok {
continue
}
qualities[value.DataQuality]++
prev, hasPrev := all[v+"\x00"+date.AddDate(0, 0, -1).Format("2006-01-02")]
if value.DataQuality == "" || value.DataQuality == "CARRIED_FORWARD" {
normal++
if hasPrev && math.Abs(value.TotalMileageKm-prev.TotalMileageKm-value.MileageKm) > 0.001 {
mismatches++
}
}
if len(vins) < 10 {
samples[key] = value
}
}
}
must(json.NewEncoder(os.Stdout).Encode(map[string]any{"vehicles": len(vins), "rows": len(all), "normal": normal, "qualities": qualities, "reconciliationMismatches": mismatches, "singleRangeDifferences": differences, "samples": samples, "elapsed": time.Since(began).String()}))
if mismatches > 0 || differences > 0 {
os.Exit(2)
}
}
func must(e error) {
if e != nil {
fmt.Fprintln(os.Stderr, e)
os.Exit(1)
}
}
@@ -0,0 +1,174 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"time"
_ "github.com/go-sql-driver/mysql"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func main() {
apply := flag.Bool("apply", false, "apply corrections after writing a durable backup")
backup := flag.String("backup", "", "new backup JSON path, required with --apply")
flag.Parse()
if *apply && *backup == "" {
panic("--backup is required with --apply")
}
db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
if err != nil {
panic("invalid database configuration")
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err = run(ctx, db, *apply, *backup); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func rows(ctx context.Context, tx *sql.Tx, query string) ([]map[string]any, error) {
r, err := tx.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer r.Close()
columns, err := r.Columns()
if err != nil {
return nil, err
}
result := []map[string]any{}
for r.Next() {
values := make([]any, len(columns))
targets := make([]any, len(columns))
for i := range values {
targets[i] = &values[i]
}
if err = r.Scan(targets...); err != nil {
return nil, err
}
item := map[string]any{}
for i, k := range columns {
if b, ok := values[i].([]byte); ok {
item[k] = string(b)
} else {
item[k] = values[i]
}
}
result = append(result, item)
}
return result, r.Err()
}
type correction struct {
id, title, status string
remove bool
}
func run(ctx context.Context, db *sql.DB, apply bool, backup string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
archive := map[string]any{"createdAt": time.Now().UTC().Format(time.RFC3339), "reason": "decode GB32960 table 18; remove reserved-only false alarms"}
data := map[string][]map[string]any{}
// Consumer must be stopped for --apply. Lock event rows as well so a concurrent
// operator's action is serialized with the correction and fully backed up.
for _, q := range []struct{ key, sql string }{
{"events", `SELECT * FROM vehicle_alert_event WHERE rule_id='native-gb32960-alarm' ORDER BY id FOR UPDATE`},
{"evidence", `SELECT n.* FROM vehicle_native_alarm_evidence n JOIN vehicle_alert_event e ON e.id=n.event_id WHERE e.rule_id='native-gb32960-alarm'`},
{"actions", `SELECT a.* FROM vehicle_alert_event_action a JOIN vehicle_alert_event e ON e.id=a.event_id WHERE e.rule_id='native-gb32960-alarm'`},
{"state", `SELECT s.* FROM vehicle_native_alarm_state s JOIN vehicle_alert_event e ON e.id=s.active_event_id WHERE e.rule_id='native-gb32960-alarm'`},
{"notifications", `SELECT n.id,n.event_id FROM vehicle_alert_notification n JOIN vehicle_alert_event e ON e.id=n.event_id WHERE e.rule_id='native-gb32960-alarm'`},
} {
items, e := rows(ctx, tx, q.sql)
if e != nil {
return e
}
data[q.key] = items
archive[q.key] = items
}
evidence := map[string]string{}
for _, row := range data["evidence"] {
evidence[fmt.Sprint(row["event_id"])] = fmt.Sprint(row["fields_json"])
}
notified := map[string]bool{}
for _, row := range data["notifications"] {
notified[fmt.Sprint(row["event_id"])] = true
}
changes := []correction{}
renamed, removed := 0, 0
names := map[string]int{}
for _, row := range data["events"] {
id := fmt.Sprint(row["id"])
var fields map[string]json.RawMessage
if err = json.Unmarshal([]byte(evidence[id]), &fields); err != nil {
return fmt.Errorf("event %s has no valid evidence; no changes applied", id)
}
description, valid := platform.DescribeNativeAlarm(fields)
if !valid {
return fmt.Errorf("event %s has unsupported evidence; no changes applied", id)
}
names[description.Title]++
if !description.Active {
if notified[id] {
return fmt.Errorf("event %s has notifications; refusing automatic deletion", id)
}
changes = append(changes, correction{id: id, remove: true})
removed++
} else if fmt.Sprint(row["rule_name"]) != description.Title {
changes = append(changes, correction{id: id, title: description.Title, status: fmt.Sprint(row["status"])})
renamed++
}
}
if apply && len(changes) > 0 {
file, e := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if e != nil {
return e
}
e = json.NewEncoder(file).Encode(archive)
if e == nil {
e = file.Sync()
}
closeErr := file.Close()
if e != nil {
return e
}
if closeErr != nil {
return closeErr
}
for _, change := range changes {
if change.remove {
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_native_alarm_state SET active_event_id='' WHERE active_event_id=?`, change.id); err != nil {
return err
}
for _, table := range []string{"vehicle_alert_event_action", "vehicle_native_alarm_evidence"} {
if _, err = tx.ExecContext(ctx, "DELETE FROM "+table+" WHERE event_id=?", change.id); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_event WHERE id=? AND rule_id='native-gb32960-alarm'`, change.id); err != nil {
return err
}
} else {
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET rule_name=?,version=version+1 WHERE id=? AND rule_id='native-gb32960-alarm'`, change.title, change.id); 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(?,'repair',?,?,'native-alarm-repair','按GB/T 32960.3-2016表18修正具体告警名称,保留原始证据')`, change.id, change.status, change.status); err != nil {
return err
}
}
}
if err = tx.Commit(); err != nil {
return err
}
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"applied": apply, "scanned": len(data["events"]), "renamed": renamed, "removed": removed, "descriptions": names})
}
@@ -0,0 +1,108 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func expectSnapshot(mock sqlmock.Sqlmock, notified bool) {
mock.ExpectBegin()
mock.ExpectQuery(`SELECT \* FROM vehicle_alert_event`).WillReturnRows(sqlmock.NewRows([]string{"id", "rule_name", "status"}).AddRow("false-event", "车辆原生告警", "unprocessed").AddRow("real-event", "车辆原生告警", "processing"))
payload := func(flag string) string {
data := map[string]any{"gb32960.alarm.max_alarm_level": "0", "gb32960.alarm.general_alarm_flag": flag}
for _, key := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
data["gb32960.alarm."+key] = []string{}
}
b, _ := json.Marshal(data)
return string(b)
}
mock.ExpectQuery(`SELECT n.\* FROM vehicle_native_alarm_evidence`).WillReturnRows(sqlmock.NewRows([]string{"event_id", "fields_json"}).AddRow("false-event", payload("0x00300000")).AddRow("real-event", payload("0x00380800")))
mock.ExpectQuery(`SELECT a.\* FROM vehicle_alert_event_action`).WillReturnRows(sqlmock.NewRows([]string{"event_id", "action"}).AddRow("false-event", "trigger"))
mock.ExpectQuery(`SELECT s.\* FROM vehicle_native_alarm_state`).WillReturnRows(sqlmock.NewRows([]string{"vin", "active_event_id"}).AddRow("VIN1", "false-event"))
notifications := sqlmock.NewRows([]string{"id", "event_id"})
if notified {
notifications.AddRow(1, "false-event")
}
mock.ExpectQuery(`SELECT n.id,n.event_id`).WillReturnRows(notifications)
}
func TestRepairDryRunNeverWrites(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
expectSnapshot(mock, false)
mock.ExpectRollback()
if err := run(t.Context(), db, false, ""); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRepairBacksUpAndCorrectsOnlyNativeEvents(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
expectSnapshot(mock, false)
mock.ExpectExec(`UPDATE vehicle_native_alarm_state`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM vehicle_alert_event_action`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM vehicle_native_alarm_evidence`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM vehicle_alert_event WHERE id=\? AND rule_id='native-gb32960-alarm'`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE vehicle_alert_event SET rule_name=\?,version=version\+1 WHERE id=\? AND rule_id='native-gb32960-alarm'`).WithArgs("绝缘报警", "real-event").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WithArgs("real-event", "processing", "processing").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
path := filepath.Join(t.TempDir(), "backup.json")
if err := run(t.Context(), db, true, path); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var archive map[string]json.RawMessage
if err = json.Unmarshal(data, &archive); err != nil {
t.Fatal(err)
}
for _, key := range []string{"events", "evidence", "actions", "state"} {
if len(archive[key]) == 0 {
t.Fatalf("missing backup %s", key)
}
}
info, _ := os.Stat(path)
if info.Mode().Perm() != 0600 {
t.Fatal("backup permissions")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRepairRefusesToOverwriteBackup(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
expectSnapshot(mock, false)
mock.ExpectRollback()
path := filepath.Join(t.TempDir(), "backup.json")
if err := os.WriteFile(path, []byte("previous backup"), 0600); err != nil {
t.Fatal(err)
}
if err := run(t.Context(), db, true, path); err == nil {
t.Fatal("overwrote backup")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRepairRefusesToOrphanNotifications(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
expectSnapshot(mock, true)
mock.ExpectRollback()
if err := run(t.Context(), db, true, filepath.Join(t.TempDir(), "backup.json")); err == nil {
t.Fatal("deleted notified event")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
@@ -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)
@@ -0,0 +1,681 @@
package main
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
)
const (
baseURL = "http://115.29.187.205:20200"
hydrogenKWh = 16.0
)
type candidate struct {
VIN string `json:"vin"`
StatDate string `json:"stat_date"`
PlatformName string `json:"platform_name"`
DailyMileageKm float64 `json:"daily_mileage_km"`
RawTotal int `json:"raw_total"`
Plate string `json:"plate,omitempty"`
SampleNo int `json:"sample_no,omitempty"`
MileageBin int `json:"mileage_bin,omitempty"`
Model string `json:"model,omitempty"`
TankCapacityL float64 `json:"tank_capacity_l,omitempty"`
BatteryKWh float64 `json:"battery_capacity_kwh,omitempty"`
}
type capacityRecord struct {
VIN, Plate, Model string
TankCapacityL float64
Active int
}
type rawFrame struct {
TS string `json:"ts"`
FrameID string `json:"frame_id"`
EventID string `json:"event_id"`
MessageID int `json:"message_id"`
MessageIDHex string `json:"message_id_hex"`
EventTime string `json:"event_time"`
ReceivedAt string `json:"received_at"`
RawSizeBytes int `json:"raw_size_bytes"`
RawHex string `json:"raw_hex"`
ParsedFields map[string]any `json:"parsed_fields"`
ParseStatus string `json:"parse_status"`
SourceEndpoint string `json:"source_endpoint"`
Protocol string `json:"protocol"`
VIN string `json:"vin"`
}
type rawResponse struct {
Items []rawFrame `json:"items"`
Total int `json:"total"`
}
type dayOutput struct {
Candidate candidate `json:"candidate"`
APIRawFrameCount int `json:"apiRawFrameCount"`
UniqueFrameCount int `json:"uniqueFrameCount"`
DuplicateFrameCount int `json:"duplicateFrameCount"`
AlgorithmSamples int `json:"algorithmSamples"`
CriticalFieldRows int `json:"criticalFieldRows"`
EarliestEventTime string `json:"earliestEventTime"`
LatestEventTime string `json:"latestEventTime"`
RawArchiveFile string `json:"rawArchiveFile"`
RawArchiveSHA256 string `json:"rawArchiveSha256"`
RawArchiveBytes int64 `json:"rawArchiveBytes"`
Stat openplatform.HydrogenDailyStat `json:"stat"`
}
func main() {
if len(os.Args) != 5 && len(os.Args) != 6 {
panic("usage: audit <vehicle-metadata.json> <start-date> <end-date> <output-dir> [source-archive-dir]")
}
metadataPath, startDate, endDate, outputDir := os.Args[1], os.Args[2], os.Args[3], os.Args[4]
sourceArchiveDir := ""
if len(os.Args) == 6 {
sourceArchiveDir = os.Args[5]
}
start, err := time.Parse("2006-01-02", startDate)
if err != nil {
panic(err)
}
end, err := time.Parse("2006-01-02", endDate)
if err != nil || end.Before(start) {
panic("invalid date range")
}
var metadata []struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Model string `json:"model"`
TankCapacityL float64 `json:"tank_capacity_l"`
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
}
mustReadJSON(metadataPath, &metadata)
if len(metadata) == 0 {
panic("vehicle metadata is empty")
}
selected := make([]candidate, 0, len(metadata)*int(end.Sub(start).Hours()/24+1))
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
statDate := day.Format("2006-01-02")
for _, item := range metadata {
if len(strings.TrimSpace(item.VIN)) != 17 || item.TankCapacityL <= 0 || item.BatteryCapacityKWh <= 0 {
continue
}
selected = append(selected, candidate{
VIN: strings.ToUpper(strings.TrimSpace(item.VIN)), StatDate: statDate,
Plate: item.Plate, Model: item.Model, TankCapacityL: item.TankCapacityL,
BatteryKWh: item.BatteryCapacityKWh,
})
}
}
sort.Slice(selected, func(i, j int) bool {
if selected[i].StatDate != selected[j].StatDate {
return selected[i].StatDate < selected[j].StatDate
}
if selected[i].Plate != selected[j].Plate {
return selected[i].Plate < selected[j].Plate
}
return selected[i].VIN < selected[j].VIN
})
for index := range selected {
selected[index].SampleNo = index + 1
}
if sourceArchiveDir == "" {
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
if err := os.MkdirAll(filepath.Join(outputDir, "raw", day.Format("2006-01-02")), 0o755); err != nil {
panic(err)
}
}
}
writeJSON(filepath.Join(outputDir, "selected_vehicles.json"), selected)
jobs := make(chan candidate)
results := make(chan dayOutput)
errs := make(chan error, len(selected))
var wg sync.WaitGroup
for i := 0; i < 12; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for c := range jobs {
out, err := processDay(c, outputDir, sourceArchiveDir)
if err != nil {
errs <- err
continue
}
results <- out
}
}()
}
go func() {
for _, c := range selected {
jobs <- c
}
close(jobs)
wg.Wait()
close(results)
close(errs)
}()
outputs := make([]dayOutput, 0, len(selected))
for out := range results {
if out.APIRawFrameCount == 0 {
continue
}
outputs = append(outputs, out)
fmt.Fprintf(os.Stderr, "completed %03d %s %s raw=%d samples=%d quality=%s\n", out.Candidate.SampleNo, out.Candidate.Plate, out.Candidate.VIN, out.APIRawFrameCount, out.AlgorithmSamples, out.Stat.QualityStatus)
}
var allErrs []string
for err := range errs {
allErrs = append(allErrs, err.Error())
}
if len(allErrs) > 0 {
panic(strings.Join(allErrs, "\n"))
}
sort.Slice(outputs, func(i, j int) bool { return outputs[i].Candidate.SampleNo < outputs[j].Candidate.SampleNo })
writeJSON(filepath.Join(outputDir, "daily_results.json"), outputs)
writeManifest(filepath.Join(outputDir, "raw_manifest.csv"), outputs)
fmt.Printf("processed=%d raw_frames=%d algorithm_samples=%d intervals=%d\n", len(outputs), sumRaw(outputs), sumSamples(outputs), sumIntervals(outputs))
}
func stratifiedSelect(all []candidate, plateByVIN map[string]string, capacityByVIN map[string]capacityRecord) []candidate {
general, cold := make([]candidate, 0), make([]candidate, 0)
for _, c := range all {
cap, ok := capacityByVIN[strings.ToUpper(c.VIN)]
if !ok || cap.Active != 1 || c.DailyMileageKm < 10 || c.DailyMileageKm > 600 || c.RawTotal < 300 {
continue
}
if cap.Model != "4.5吨货车" && cap.Model != "帕力安牌4.5吨冷链车" {
continue
}
c.Plate = cap.Plate
if c.Plate == "" {
c.Plate = plateByVIN[strings.ToUpper(c.VIN)]
}
c.Model = cap.Model
c.TankCapacityL = cap.TankCapacityL
if cap.Model == "4.5吨货车" {
general = append(general, c)
} else {
cold = append(cold, c)
}
}
if len(general) < 41 || len(cold) < 59 {
panic(fmt.Sprintf("insufficient pools general=%d cold=%d", len(general), len(cold)))
}
selected := append(selectEvenly(general, 41), selectEvenly(cold, 59)...)
sort.Slice(selected, func(i, j int) bool {
if selected[i].DailyMileageKm != selected[j].DailyMileageKm {
return selected[i].DailyMileageKm < selected[j].DailyMileageKm
}
return selected[i].VIN < selected[j].VIN
})
for i := range selected {
selected[i].SampleNo = i + 1
selected[i].MileageBin = i/10 + 1
}
return selected
}
func selectEvenly(values []candidate, count int) []candidate {
sort.Slice(values, func(i, j int) bool {
if values[i].DailyMileageKm != values[j].DailyMileageKm {
return values[i].DailyMileageKm < values[j].DailyMileageKm
}
return values[i].VIN < values[j].VIN
})
out := make([]candidate, 0, count)
for i := 0; i < count; i++ {
idx := 0
if count > 1 {
idx = int(math.Round(float64(i) * float64(len(values)-1) / float64(count-1)))
}
out = append(out, values[idx])
}
return out
}
func processDay(c candidate, outputDir, sourceArchiveDir string) (dayOutput, error) {
archiveName := rawArchiveName(c)
archivePath := filepath.Join(outputDir, "raw", c.StatDate, archiveName)
readArchivePath := archivePath
if sourceArchiveDir != "" {
readArchivePath = filepath.Join(sourceArchiveDir, "raw", c.StatDate, archiveName)
if _, statErr := os.Stat(readArchivePath); os.IsNotExist(statErr) {
// Sample numbers depend on the selected vehicle/date set. Reuse an
// existing archive by its stable plate/VIN/date suffix when rerunning
// only a small subset for validation.
pattern := filepath.Join(sourceArchiveDir, "raw", c.StatDate, "*_"+c.Plate+"_"+c.VIN+"_"+c.StatDate+".csv.gz")
if matches, _ := filepath.Glob(pattern); len(matches) == 1 {
readArchivePath = matches[0]
}
}
}
frames := []rawFrame(nil)
total := 0
var err error
if _, statErr := os.Stat(readArchivePath); statErr == nil {
frames, err = readRawArchive(readArchivePath)
total = len(frames)
} else if sourceArchiveDir != "" && os.IsNotExist(statErr) {
return dayOutput{Candidate: c}, nil
} else {
for attempt := 1; attempt <= 3; attempt++ {
frames, total, err = fetchFrames(c.VIN, c.StatDate)
if err == nil {
break
}
if attempt < 3 {
time.Sleep(time.Duration(attempt) * time.Second)
}
}
}
if err != nil {
return dayOutput{}, fmt.Errorf("%s %s: %w", c.StatDate, c.VIN, err)
}
if total == 0 {
return dayOutput{Candidate: c}, nil
}
sort.SliceStable(frames, func(i, j int) bool {
if frames[i].EventTime != frames[j].EventTime {
return frames[i].EventTime < frames[j].EventTime
}
if frames[i].SourceEndpoint != frames[j].SourceEndpoint {
return frames[i].SourceEndpoint < frames[j].SourceEndpoint
}
return frames[i].TS < frames[j].TS
})
seen := map[string]bool{}
duplicateFrames := 0
observations := make([]openplatform.HydrogenObservation, 0, len(frames))
criticalRows := 0
for _, f := range frames {
if f.FrameID != "" {
if seen[f.FrameID] {
duplicateFrames++
}
seen[f.FrameID] = true
}
obs, ok, critical := observationFromFrame(f, c.TankCapacityL, c.StatDate)
if critical {
criticalRows++
}
if ok {
observations = append(observations, obs)
}
}
params := map[string]openplatform.HydrogenCalculationParameters{c.VIN: {BatteryCapacityKWh: c.BatteryKWh, HydrogenEnergyKWhKg: hydrogenKWh}}
stats := openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, c.StatDate, 0.05, 20, params)
var stat openplatform.HydrogenDailyStat
if len(stats) == 1 {
stat = stats[0]
} else {
stat = openplatform.HydrogenDailyStat{VIN: c.VIN, Date: c.StatDate, SampleCount: len(observations), QualityStatus: "NO_DATA", QualityReason: "无有效压力温度样本"}
}
roles := map[string]string{}
for _, interval := range stat.Intervals {
appendRole(roles, interval.StartEventID, fmt.Sprintf("运行区间%d(%s)起点", interval.Index, interval.Type))
appendRole(roles, interval.EndEventID, fmt.Sprintf("运行区间%d(%s)终点", interval.Index, interval.Type))
}
for _, interval := range stat.HydrogenIntervals {
appendRole(roles, interval.StartEventID, fmt.Sprintf("氢量分段%d起点", interval.Index))
appendRole(roles, interval.EndEventID, fmt.Sprintf("氢量分段%d终点", interval.Index))
}
// Even when the original frames are reused, rewrite the audit CSV so its
// “计算角色/排除原因” column matches the current algorithm version and
// interval boundaries. The original HEX and parsed fields remain unchanged.
if sourceArchiveDir == "" {
if err := writeRawCSV(archivePath, c, frames, roles); err != nil {
return dayOutput{}, err
}
}
checksum, size, err := fileSHA256(readArchivePath)
if err != nil {
return dayOutput{}, err
}
out := dayOutput{Candidate: c, APIRawFrameCount: total, UniqueFrameCount: len(seen), DuplicateFrameCount: duplicateFrames, AlgorithmSamples: len(observations), CriticalFieldRows: criticalRows, RawArchiveFile: filepath.ToSlash(filepath.Join("raw", c.StatDate, archiveName)), RawArchiveSHA256: checksum, RawArchiveBytes: size, Stat: stat}
if len(frames) > 0 {
out.EarliestEventTime = frames[0].EventTime
out.LatestEventTime = frames[len(frames)-1].EventTime
}
return out, nil
}
func rawArchiveName(c candidate) string {
return fmt.Sprintf("%04d_%s_%s_%s.csv.gz", c.SampleNo, safeName(c.Plate), c.VIN, c.StatDate)
}
func appendRole(roles map[string]string, eventID, role string) {
if eventID == "" {
return
}
if roles[eventID] == "" {
roles[eventID] = role
return
}
roles[eventID] += "" + role
}
func fileSHA256(path string) (string, int64, error) {
file, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer file.Close()
hash := sha256.New()
size, err := io.Copy(hash, file)
if err != nil {
return "", 0, err
}
return fmt.Sprintf("%x", hash.Sum(nil)), size, nil
}
func readRawArchive(path string) ([]rawFrame, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
gz, err := gzip.NewReader(file)
if err != nil {
return nil, err
}
defer gz.Close()
reader := csv.NewReader(gz)
rows, err := reader.ReadAll()
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("empty raw archive %s", path)
}
columns := map[string]int{}
for index, name := range rows[0] {
columns[name] = index
}
cell := func(row []string, name string) string {
index, ok := columns[name]
if !ok || index >= len(row) {
return ""
}
return row[index]
}
frames := make([]rawFrame, 0, len(rows)-1)
for _, row := range rows[1:] {
fields := map[string]any{}
if rawFields := cell(row, "完整解析字段JSON"); rawFields != "" {
_ = json.Unmarshal([]byte(rawFields), &fields)
}
messageID, _ := strconv.Atoi(cell(row, "消息ID"))
rawSize, _ := strconv.Atoi(cell(row, "原始字节数"))
frames = append(frames, rawFrame{
FrameID: cell(row, "帧ID"), EventID: cell(row, "事件ID"), MessageID: messageID,
MessageIDHex: cell(row, "消息ID_HEX"), EventTime: cell(row, "事件时间"), ReceivedAt: cell(row, "接收时间"),
RawSizeBytes: rawSize, RawHex: cell(row, "原始报文HEX"), ParsedFields: fields,
ParseStatus: cell(row, "解析状态"), SourceEndpoint: cell(row, "源端点"), Protocol: "GB32960", VIN: cell(row, "VIN"),
})
}
return frames, nil
}
func fetchFrames(vin, statDate string) ([]rawFrame, int, error) {
const limit = 500
first, err := fetchPage(vin, statDate, 0, limit, true)
if err != nil {
return nil, 0, err
}
frames := append([]rawFrame(nil), first.Items...)
for offset := limit; offset < first.Total; offset += limit {
page, err := fetchPage(vin, statDate, offset, limit, false)
if err != nil {
return nil, 0, err
}
frames = append(frames, page.Items...)
}
if len(frames) != first.Total {
return nil, first.Total, fmt.Errorf("API total=%d fetched=%d", first.Total, len(frames))
}
return frames, first.Total, nil
}
func fetchPage(vin, statDate string, offset, limit int, includeTotal bool) (rawResponse, error) {
q := url.Values{"protocol": {"GB32960"}, "vin": {vin}, "dateFrom": {statDate + " 00:00:00"}, "dateTo": {statDate + " 23:59:59"}, "orderBy": {"eventTime"}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}, "includeFields": {"true"}, "includePayload": {"true"}, "includeTotal": {strconv.FormatBool(includeTotal)}}
req, _ := http.NewRequest(http.MethodGet, baseURL+"/api/history/raw-frames?"+q.Encode(), nil)
req.Header.Set("Accept-Encoding", "gzip")
client := &http.Client{Timeout: 90 * time.Second}
res, err := client.Do(req)
if err != nil {
return rawResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
if res.StatusCode == http.StatusInternalServerError && bytes.Contains(body, []byte("Table does not exist")) {
return rawResponse{}, nil
}
return rawResponse{}, fmt.Errorf("HTTP %d: %s", res.StatusCode, body)
}
var reader io.Reader = res.Body
if res.Header.Get("Content-Encoding") == "gzip" {
gz, err := gzip.NewReader(res.Body)
if err != nil {
return rawResponse{}, err
}
defer gz.Close()
reader = gz
}
var out rawResponse
if err := json.NewDecoder(reader).Decode(&out); err != nil {
return rawResponse{}, err
}
return out, nil
}
func observationFromFrame(f rawFrame, tankCapacity float64, statDate string) (openplatform.HydrogenObservation, bool, bool) {
if f.MessageID != 2 || f.ParseStatus != "OK" {
return openplatform.HydrogenObservation{}, false, false
}
pressure, pok := num(f.ParsedFields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
temp, tok := num(f.ParsedFields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
critical := pok && tok
if !pok || !tok || pressure <= 0 || pressure > 70 || temp <= -40 || temp > 726.85 {
return openplatform.HydrogenObservation{}, false, critical
}
mass, ok := openplatform.PressureHydrogenMassKg(pressure, temp, tankCapacity)
if !ok {
return openplatform.HydrogenObservation{}, false, critical
}
step, _ := openplatform.PressureHydrogenMassKg(math.Max(0, pressure-0.2), temp, tankCapacity)
parsed, _ := json.Marshal(f.ParsedFields)
_, _, active, known, _ := openplatform.ExtractHydrogenTelemetry(string(parsed))
t, err := parseEventTime(f.EventTime)
if err != nil || t.Format("2006-01-02") != statDate {
return openplatform.HydrogenObservation{}, false, critical
}
o := openplatform.HydrogenObservation{VIN: f.VIN, Source: f.SourceEndpoint, EventID: f.EventID, ObservedAt: t, MassKg: mass, TankCapacityLiter: tankCapacity, PressureMPa: pressure, TemperatureC: temp, NoiseKg: math.Min(1, math.Max(0.05, mass-step)), RefuelThresholdKg: math.Max(1, mass*0.05), FuelCellActive: active, FuelCellStateKnown: known}
if voltage, vok := num(f.ParsedFields["gb32960.fuel_cell.fuel_cell_voltage_v"]); vok && voltage > 0 && voltage <= 1000 {
if current, cok := num(f.ParsedFields["gb32960.fuel_cell.fuel_cell_current_a"]); cok && current >= 0 && current <= 2000 {
o.FuelCellVoltageV = voltage
o.FuelCellCurrentA = current
o.FuelCellPowerKnown = true
}
}
if v, ok := num(f.ParsedFields["gb32960.vehicle.soc_percent"]); ok && v >= 0 && v <= 100 {
o.SOCPercent = v
o.SOCKnown = true
}
if v, ok := num(f.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && v >= 0 {
o.MileageKm = v
o.MileageKnown = true
}
if v, ok := num(f.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
o.VehicleState = int(v)
o.VehicleStateKnown = v >= 0 && v <= 255
}
if v, ok := num(f.ParsedFields["gb32960.vehicle.charge_status"]); ok {
o.ChargeState = int(v)
o.ChargeStateKnown = v >= 0 && v <= 255
}
if v, ok := num(f.ParsedFields["gb32960.vehicle.running_mode"]); ok {
o.RunningMode = int(v)
o.RunningModeKnown = v >= 0 && v <= 255
}
return o, true, critical
}
var rawHeaders = []string{"序号", "车牌", "VIN", "统计日期", "车型", "储氢总容积(L)", "额定电量(kWh)", "事件时间", "接收时间", "帧ID", "事件ID", "消息ID", "消息ID_HEX", "源端点", "解析状态", "原始字节数", "原始报文HEX", "完整解析字段JSON", "最高氢压(MPa)", "最高氢温(℃)", "电池SOC(%)", "仪表总里程(km)", "车辆状态", "充电状态", "运行模式", "燃料电池工作状态", "燃料电池电流(A)", "车端氢气质量(kg)", "系统压力换算剩余氢量(kg)", "噪声阈值(kg)", "是否算法有效样本", "计算角色/排除原因"}
func writeRawCSV(path string, c candidate, frames []rawFrame, roles map[string]string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
gz := gzip.NewWriter(f)
defer gz.Close()
w := csv.NewWriter(gz)
defer w.Flush()
if err := w.Write(rawHeaders); err != nil {
return err
}
for _, rf := range frames {
o, valid, _ := observationFromFrame(rf, c.TankCapacityL, c.StatDate)
role := roles[rf.EventID]
if !valid {
if t, err := parseEventTime(rf.EventTime); err == nil && t.Format("2006-01-02") != c.StatDate {
role = "事件时间不在统计日,不进入计算"
} else if rf.MessageID != 2 {
role = "非实时信息上报帧,不进入计算"
} else if rf.ParseStatus != "OK" {
role = "解析状态非OK,不进入计算"
} else {
role = "压力/温度无效或缺失,不进入计算"
}
} else if role == "" {
role = "有效候选帧(由分段规则判定)"
}
parsedJSON, _ := json.Marshal(rf.ParsedFields)
row := []string{fmt.Sprintf("%04d", c.SampleNo), c.Plate, c.VIN, c.StatDate, c.Model, strconv.FormatFloat(c.TankCapacityL, 'f', 2, 64), strconv.FormatFloat(c.BatteryKWh, 'f', 2, 64), rf.EventTime, rf.ReceivedAt, rf.FrameID, rf.EventID, strconv.Itoa(rf.MessageID), rf.MessageIDHex, rf.SourceEndpoint, rf.ParseStatus, strconv.Itoa(rf.RawSizeBytes), rf.RawHex, string(parsedJSON), val(rf.ParsedFields, "gb32960.fuel_cell.max_hydrogen_pressure_mpa"), val(rf.ParsedFields, "gb32960.fuel_cell.max_hydrogen_temperature_c"), val(rf.ParsedFields, "gb32960.vehicle.soc_percent"), val(rf.ParsedFields, "gb32960.vehicle.total_mileage_km"), val(rf.ParsedFields, "gb32960.vehicle.vehicle_status"), val(rf.ParsedFields, "gb32960.vehicle.charge_status"), val(rf.ParsedFields, "gb32960.vehicle.running_mode"), val(rf.ParsedFields, "gb32960.gd_fc_stack.engine_work_state"), val(rf.ParsedFields, "gb32960.fuel_cell.fuel_cell_current_a"), val(rf.ParsedFields, "gb32960.gd_fc_vehicle_info.hydrogen_mass_kg"), blankFloat(valid, o.MassKg), blankFloat(valid, o.NoiseKg), yesNo(valid), role}
if err := w.Write(row); err != nil {
return err
}
}
return w.Error()
}
func writeManifest(path string, outputs []dayOutput) {
f, err := os.Create(path)
if err != nil {
panic(err)
}
defer f.Close()
w := csv.NewWriter(f)
defer w.Flush()
_ = w.Write([]string{"序号", "车牌", "VIN", "日期", "车型", "储氢总容积(L)", "额定电量(kWh)", "API原始帧数", "唯一帧数", "重复帧数", "算法样本数", "关键字段帧数", "最早事件时间", "最晚事件时间", "原始文件", "压缩包SHA256", "压缩字节数", "质量状态", "质量原因"})
for _, o := range outputs {
_ = w.Write([]string{fmt.Sprintf("%04d", o.Candidate.SampleNo), o.Candidate.Plate, o.Candidate.VIN, o.Candidate.StatDate, o.Candidate.Model, strconv.FormatFloat(o.Candidate.TankCapacityL, 'f', 2, 64), strconv.FormatFloat(o.Candidate.BatteryKWh, 'f', 2, 64), strconv.Itoa(o.APIRawFrameCount), strconv.Itoa(o.UniqueFrameCount), strconv.Itoa(o.DuplicateFrameCount), strconv.Itoa(o.AlgorithmSamples), strconv.Itoa(o.CriticalFieldRows), o.EarliestEventTime, o.LatestEventTime, o.RawArchiveFile, o.RawArchiveSHA256, strconv.FormatInt(o.RawArchiveBytes, 10), o.Stat.QualityStatus, o.Stat.QualityReason})
}
}
func num(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case string:
n, e := strconv.ParseFloat(strings.TrimSpace(x), 64)
return n, e == nil
case json.Number:
n, e := x.Float64()
return n, e == nil
default:
return 0, false
}
}
func val(m map[string]any, k string) string {
if v, ok := m[k]; ok {
return fmt.Sprint(v)
}
return ""
}
func blankFloat(ok bool, v float64) string {
if !ok {
return ""
}
return strconv.FormatFloat(v, 'f', 6, 64)
}
func yesNo(v bool) string {
if v {
return "是"
}
return "否"
}
func safeName(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return "无车牌"
}
return strings.NewReplacer("/", "_", "\\", "_", " ", "_").Replace(v)
}
func parseEventTime(value string) (time.Time, error) {
loc := time.FixedZone("CST", 8*3600)
if t, e := time.ParseInLocation("2006-01-02 15:04:05.000", value, loc); e == nil {
return t, nil
}
return time.ParseInLocation("2006-01-02 15:04:05", value, loc)
}
func mustReadJSON(path string, v any) {
b, e := os.ReadFile(path)
if e != nil {
panic(e)
}
if e = json.Unmarshal(b, v); e != nil {
panic(e)
}
}
func writeJSON(path string, v any) {
b, e := json.MarshalIndent(v, "", " ")
if e != nil {
panic(e)
}
if e = os.WriteFile(path, b, 0o644); e != nil {
panic(e)
}
}
func sumRaw(v []dayOutput) int {
n := 0
for _, x := range v {
n += x.APIRawFrameCount
}
return n
}
func sumSamples(v []dayOutput) int {
n := 0
for _, x := range v {
n += x.AlgorithmSamples
}
return n
}
func sumIntervals(v []dayOutput) int {
n := 0
for _, x := range v {
n += len(x.Stat.Intervals)
}
return n
}
@@ -0,0 +1,38 @@
//go:build ignore
package main
import (
"database/sql"
"encoding/json"
"fmt"
"net"
"net/url"
"os"
"sort"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
func main() {
if len(os.Args)!=3 { panic("usage: query_capacity <selected.json> <output.json>") }
var selected []struct{ VIN string `json:"vin"` }
b,err:=os.ReadFile(os.Args[1]);if err!=nil{panic(err)};if err=json.Unmarshal(b,&selected);err!=nil{panic(err)}
prefix := "HYDROGEN_DB_"
host := strings.TrimSpace(os.Getenv(prefix + "HOST"))
port := strings.TrimSpace(os.Getenv(prefix + "PORT")); if port == "" { port = "3306" }
name := strings.TrimSpace(os.Getenv(prefix + "NAME"))
user := os.Getenv(prefix + "USER"); pass := os.Getenv(prefix + "PASSWORD")
dsn := user+":"+pass+"@tcp("+net.JoinHostPort(host,port)+")/"+name+"?parseTime=true&loc="+url.QueryEscape("Asia/Shanghai")+"&timeout=5s&readTimeout=15s"
db,err:=sql.Open("mysql",dsn); if err!=nil{panic(err)}; defer db.Close(); db.SetConnMaxLifetime(time.Minute); if err=db.Ping();err!=nil{panic(err)}
vins:=make([]string,0,len(selected));args:=make([]any,0,len(selected));for _,s:=range selected{v:=strings.ToUpper(strings.TrimSpace(s.VIN));vins=append(vins,v);args=append(args,v)}
placeholders:=strings.TrimRight(strings.Repeat("?,",len(vins)),",")
rows,err:=db.Query(`SELECT UPPER(TRIM(vin)),plate_number,brand_name,model_name,tank_capacity_l,active,source_model_id,source_updated_at,synced_at FROM lingniu_vehicle_data.vehicle_hydrogen_tank_capacity WHERE UPPER(TRIM(vin)) IN (`+placeholders+`) ORDER BY vin`,args...);if err!=nil{panic(err)};defer rows.Close()
type row struct{VIN,Plate,Brand,Model string;TankCapacityL float64;Active int;SourceModelID *int64;SourceUpdatedAt, SyncedAt *time.Time}
out:=make([]row,0,len(vins));for rows.Next(){var r row;if err=rows.Scan(&r.VIN,&r.Plate,&r.Brand,&r.Model,&r.TankCapacityL,&r.Active,&r.SourceModelID,&r.SourceUpdatedAt,&r.SyncedAt);err!=nil{panic(err)};out=append(out,r)};if err=rows.Err();err!=nil{panic(err)}
sort.Slice(out,func(i,j int)bool{return out[i].VIN<out[j].VIN});data,err:=json.MarshalIndent(out,""," ");if err!=nil{panic(err)};if err=os.WriteFile(os.Args[2],data,0o644);err!=nil{panic(err)}
counts:=map[float64]int{};models:=map[string]int{};for _,r:=range out{counts[r.TankCapacityL]++;models[r.Model]++}
fmt.Printf("requested=%d found=%d capacities=%v models=%v\n",len(vins),len(out),counts,models)
}
@@ -0,0 +1,97 @@
//go:build ignore
package main
import (
"database/sql"
"encoding/json"
"os"
"sort"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
type metadataRecord struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Brand string `json:"brand"`
Model string `json:"model"`
VehicleModelID int64 `json:"vehicle_model_id"`
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
TankCapacityL float64 `json:"tank_capacity_l"`
SourceUpdatedAt *time.Time `json:"source_updated_at,omitempty"`
}
func main() {
if len(os.Args) != 2 {
panic("usage: query_metadata <output.json>")
}
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
if dsn == "" {
panic("MYSQL_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
panic(err)
}
defer db.Close()
rows, err := db.Query(`SELECT UPPER(TRIM(vi.vin)),COALESCE(vi.plate_number,''),
COALESCE(vm.brand,''),COALESCE(vm.model,''),vm.id,vm.battery_capacity,vm.tank_capacity,
CASE
WHEN vi.update_time IS NULL THEN vm.update_time
WHEN vm.update_time IS NULL THEN vi.update_time
ELSE GREATEST(vi.update_time,vm.update_time)
END
FROM ln_asset_management.vehicle_info vi
JOIN ln_asset_management.vehicle_model vm ON vm.id=vi.vehicle_model_id
WHERE COALESCE(vi.del_flag,'0')='0' AND COALESCE(vm.del_flag,'0')='0'
AND LOWER(TRIM(vm.brand))='hyundai'
AND LENGTH(TRIM(vi.vin))=17
AND vm.tank_capacity>0
AND vm.battery_capacity>0
ORDER BY vi.vin,vi.update_time DESC,vi.id DESC`)
if err != nil {
panic(err)
}
defer rows.Close()
byVIN := map[string]metadataRecord{}
for rows.Next() {
var row metadataRecord
var updated sql.NullTime
if err := rows.Scan(&row.VIN, &row.Plate, &row.Brand, &row.Model, &row.VehicleModelID, &row.BatteryCapacityKWh, &row.TankCapacityL, &updated); err != nil {
panic(err)
}
if _, exists := byVIN[row.VIN]; exists {
continue
}
if updated.Valid {
value := updated.Time
row.SourceUpdatedAt = &value
}
byVIN[row.VIN] = row
}
if err := rows.Err(); err != nil {
panic(err)
}
vins := make([]string, 0, len(byVIN))
for vin := range byVIN {
vins = append(vins, vin)
}
sort.Strings(vins)
out := make([]metadataRecord, 0, len(vins))
for _, vin := range vins {
out = append(out, byVIN[vin])
}
output, err := os.Create(os.Args[1])
if err != nil {
panic(err)
}
defer output.Close()
encoder := json.NewEncoder(output)
encoder.SetIndent("", " ")
if err := encoder.Encode(out); err != nil {
panic(err)
}
}
@@ -0,0 +1,660 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
)
const (
baseURL = "http://115.29.187.205:20200"
hydrogenEnergyKWh = 16.0
maxIntegrationGap = 30 * time.Second
)
type vehicleMetadata struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Model string `json:"model"`
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
TankCapacityL float64 `json:"tank_capacity_l"`
}
type rawFrame struct {
TS string `json:"ts"`
FrameID string `json:"frame_id"`
EventID string `json:"event_id"`
MessageID int `json:"message_id"`
EventTime string `json:"event_time"`
ParseStatus string `json:"parse_status"`
SourceEndpoint string `json:"source_endpoint"`
VIN string `json:"vin"`
ParsedFields map[string]any `json:"parsed_fields"`
}
type rawResponse struct {
Items []rawFrame `json:"items"`
Total int `json:"total"`
}
type energySample struct {
At time.Time
VehicleStatus int
ChargeStatus int
SOC float64
Mileage float64
BatteryVoltageV float64
BatteryCurrentA float64
FuelCellVoltageV float64
FuelCellCurrentA float64
HasSOC bool
HasMileage bool
HasBatteryPower bool
HasFuelCellPower bool
HasVehicleStatus bool
HasChargeStatus bool
}
type energyResult struct {
FirstTime string `json:"firstTime"`
LastTime string `json:"lastTime"`
StartSOC float64 `json:"startSoc"`
EndSOC float64 `json:"endSoc"`
SOCChangePctEndMinusStart float64 `json:"socChangePctEndMinusStart"`
StoredEnergyChangeKWhSOC float64 `json:"storedEnergyChangeKWhSoc"`
BatteryNetOutputKWhSOC float64 `json:"batteryNetOutputKWhSoc"`
BatteryNetOutputKWhIntegrated float64 `json:"batteryNetOutputKWhIntegrated"`
FuelCellOutputKWhIntegrated float64 `json:"fuelCellOutputKWhIntegrated"`
BatteryCoverageSeconds float64 `json:"batteryCoverageSeconds"`
FuelCellCoverageSeconds float64 `json:"fuelCellCoverageSeconds"`
OperatingSpanSeconds float64 `json:"operatingSpanSeconds"`
BatteryCoverageRatio float64 `json:"batteryCoverageRatio"`
FuelCellCoverageRatio float64 `json:"fuelCellCoverageRatio"`
ExternalChargeFrameCount int `json:"externalChargeFrameCount"`
EnergySampleCount int `json:"energySampleCount"`
}
type row struct {
Date string `json:"date"`
Plate string `json:"plate"`
VIN string `json:"vin"`
Model string `json:"model"`
RawFrameCount int `json:"rawFrameCount"`
CurrentQuality string `json:"currentQuality"`
CurrentReason string `json:"currentReason"`
RefuelCount int `json:"refuelCount"`
ChargeCount int `json:"chargeCount"`
TotalMileageKm float64 `json:"totalMileageKm"`
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
CurrentMixedMileageKm float64 `json:"currentMixedMileageKm"`
PhysicalHydrogenKg float64 `json:"physicalHydrogenKg"`
CurrentSOCBalancedKg float64 `json:"currentSocBalancedKg"`
CurrentRatePerMixedKm float64 `json:"currentRatePerMixedKm"`
PhysicalRatePerTotalKm float64 `json:"physicalRatePerTotalKm"`
FullDayStartSOC float64 `json:"fullDayStartSoc"`
FullDayEndSOC float64 `json:"fullDayEndSoc"`
FullDaySOCChangePct float64 `json:"fullDaySocChangePct"`
StoredEnergyChangeKWhSOC float64 `json:"storedEnergyChangeKWhSoc"`
BatteryEquivalentKgFixed16 float64 `json:"batteryEquivalentKgFixed16"`
StandardLikeBalancedKg float64 `json:"standardLikeBalancedKg"`
StandardLikeRatePerTotalKm float64 `json:"standardLikeRatePerTotalKm"`
StandardLikeApplicable bool `json:"standardLikeApplicable"`
StandardLikeReason string `json:"standardLikeReason"`
BatteryNetOutputKWhIntegrated float64 `json:"batteryNetOutputKWhIntegrated"`
FuelCellOutputKWhIntegrated float64 `json:"fuelCellOutputKWhIntegrated"`
BatteryEnergyShare float64 `json:"batteryEnergyShare"`
FuelCellEnergyShare float64 `json:"fuelCellEnergyShare"`
BatteryContributionKm float64 `json:"batteryContributionKm"`
FuelCellContributionKm float64 `json:"fuelCellContributionKm"`
HydrogenRatePerFCContributionKm float64 `json:"hydrogenRatePerFcContributionKm"`
BatteryCoverageRatio float64 `json:"batteryCoverageRatio"`
FuelCellCoverageRatio float64 `json:"fuelCellCoverageRatio"`
ExternalChargeFrameCount int `json:"externalChargeFrameCount"`
PowerIntegrationUsable bool `json:"powerIntegrationUsable"`
}
type job struct {
Vehicle vehicleMetadata
Date string
}
func main() {
if len(os.Args) != 5 {
panic("usage: standard-model-trial <metadata.json> <start-date> <end-date> <output-dir>")
}
metadataPath, startDate, endDate, outputDir := os.Args[1], os.Args[2], os.Args[3], os.Args[4]
start := mustDate(startDate)
end := mustDate(endDate)
if end.Before(start) {
panic("end date is before start date")
}
var vehicles []vehicleMetadata
mustReadJSON(metadataPath, &vehicles)
if len(vehicles) == 0 {
panic("metadata is empty")
}
if err := os.MkdirAll(outputDir, 0o755); err != nil {
panic(err)
}
jobs := make(chan job)
results := make(chan row)
errs := make(chan error, 128)
var wg sync.WaitGroup
for worker := 0; worker < 16; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range jobs {
result, err := process(item)
if err != nil {
errs <- fmt.Errorf("%s %s %s: %w", item.Date, item.Vehicle.Plate, item.Vehicle.VIN, err)
continue
}
if result.RawFrameCount > 0 {
results <- result
}
}
}()
}
go func() {
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
for _, vehicle := range vehicles {
if len(strings.TrimSpace(vehicle.VIN)) != 17 || vehicle.TankCapacityL <= 0 || vehicle.BatteryCapacityKWh <= 0 {
continue
}
jobs <- job{Vehicle: vehicle, Date: day.Format("2006-01-02")}
}
}
close(jobs)
wg.Wait()
close(results)
close(errs)
}()
var rows []row
completed := 0
for result := range results {
rows = append(rows, result)
completed++
if completed%25 == 0 {
fmt.Fprintf(os.Stderr, "completed=%d latest=%s %s quality=%s raw=%d\n", completed, result.Date, result.Plate, result.CurrentQuality, result.RawFrameCount)
}
}
var errorMessages []string
for err := range errs {
errorMessages = append(errorMessages, err.Error())
}
if len(errorMessages) > 0 {
panic(strings.Join(errorMessages, "\n"))
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Date != rows[j].Date {
return rows[i].Date < rows[j].Date
}
if rows[i].Plate != rows[j].Plate {
return rows[i].Plate < rows[j].Plate
}
return rows[i].VIN < rows[j].VIN
})
writeJSON(filepath.Join(outputDir, "results.json"), rows)
writeCSV(filepath.Join(outputDir, "results.csv"), rows)
writeJSON(filepath.Join(outputDir, "summary.json"), summarize(rows))
fmt.Printf("vehicle_days=%d output=%s\n", len(rows), outputDir)
}
func process(item job) (row, error) {
frames, total, err := fetchFrames(item.Vehicle.VIN, item.Date)
if err != nil || total == 0 {
return row{Date: item.Date, Plate: item.Vehicle.Plate, VIN: item.Vehicle.VIN, Model: item.Vehicle.Model, RawFrameCount: total}, err
}
sort.SliceStable(frames, func(i, j int) bool {
if frames[i].EventTime != frames[j].EventTime {
return frames[i].EventTime < frames[j].EventTime
}
return frames[i].EventID < frames[j].EventID
})
observations := make([]openplatform.HydrogenObservation, 0, len(frames))
energySamples := make([]energySample, 0, len(frames))
for _, frame := range frames {
if observation, ok := observationFromFrame(frame, item.Vehicle.TankCapacityL, item.Date); ok {
observations = append(observations, observation)
}
if sample, ok := energySampleFromFrame(frame, item.Date); ok {
energySamples = append(energySamples, sample)
}
}
params := map[string]openplatform.HydrogenCalculationParameters{
item.Vehicle.VIN: {BatteryCapacityKWh: item.Vehicle.BatteryCapacityKWh, HydrogenEnergyKWhKg: hydrogenEnergyKWh},
}
stats := openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, item.Date, 0.05, 20, params)
stat := openplatform.HydrogenDailyStat{VIN: item.Vehicle.VIN, Date: item.Date, QualityStatus: "NO_DATA", QualityReason: "无有效压力温度样本"}
if len(stats) == 1 {
stat = stats[0]
}
energy := calculateEnergy(energySamples, item.Vehicle.BatteryCapacityKWh)
totalMileage := stat.MixedMileageKm + stat.PureElectricMileageKm
result := row{
Date: item.Date, Plate: item.Vehicle.Plate, VIN: item.Vehicle.VIN, Model: item.Vehicle.Model,
RawFrameCount: total, CurrentQuality: stat.QualityStatus, CurrentReason: stat.QualityReason,
RefuelCount: stat.RefuelCount, ChargeCount: stat.ChargeCount,
TotalMileageKm: totalMileage, PureElectricMileageKm: stat.PureElectricMileageKm,
CurrentMixedMileageKm: stat.MixedMileageKm, PhysicalHydrogenKg: stat.ConsumptionKg,
FullDayStartSOC: energy.StartSOC, FullDayEndSOC: energy.EndSOC,
FullDaySOCChangePct: energy.SOCChangePctEndMinusStart,
StoredEnergyChangeKWhSOC: energy.StoredEnergyChangeKWhSOC,
BatteryNetOutputKWhIntegrated: energy.BatteryNetOutputKWhIntegrated,
FuelCellOutputKWhIntegrated: energy.FuelCellOutputKWhIntegrated,
BatteryCoverageRatio: energy.BatteryCoverageRatio,
FuelCellCoverageRatio: energy.FuelCellCoverageRatio,
ExternalChargeFrameCount: energy.ExternalChargeFrameCount,
}
if stat.SOCBalancedConsumptionKg != nil {
result.CurrentSOCBalancedKg = *stat.SOCBalancedConsumptionKg
}
if stat.SOCBalancedKgPer100Km != nil {
result.CurrentRatePerMixedKm = *stat.SOCBalancedKgPer100Km
}
if totalMileage > 0 {
result.PhysicalRatePerTotalKm = round(stat.ConsumptionKg * 100 / totalMileage)
result.BatteryEquivalentKgFixed16 = round(-energy.StoredEnergyChangeKWhSOC / hydrogenEnergyKWh)
result.StandardLikeBalancedKg = round(stat.ConsumptionKg + result.BatteryEquivalentKgFixed16)
result.StandardLikeRatePerTotalKm = round(result.StandardLikeBalancedKg * 100 / totalMileage)
switch {
case energy.ExternalChargeFrameCount > 0:
result.StandardLikeReason = "检测到外部充电,整日SOC平衡修正不适用,应按CD/CS分段"
case result.StandardLikeBalancedKg < 0:
result.StandardLikeReason = "SOC平衡氢量为负,端点、SOC或换算系数需复核"
default:
result.StandardLikeApplicable = true
}
} else {
result.StandardLikeReason = "无有效总里程"
}
// The GB/T 43252 contribution trial uses positive battery net output and
// fuel-cell output measured over non-external-charge vehicle-on intervals.
// Coverage gates prevent sparse power fields from being treated as precise.
if totalMileage >= 10 && stat.ConsumptionKg > 0 && energy.ExternalChargeFrameCount == 0 && energy.BatteryCoverageRatio >= 0.98 && energy.FuelCellCoverageRatio >= 0.98 && energy.FuelCellOutputKWhIntegrated > 0 {
batteryOutput := math.Max(0, energy.BatteryNetOutputKWhIntegrated)
totalOutput := batteryOutput + energy.FuelCellOutputKWhIntegrated
if totalOutput > 0 {
result.PowerIntegrationUsable = true
result.BatteryEnergyShare = round(batteryOutput / totalOutput)
result.FuelCellEnergyShare = round(energy.FuelCellOutputKWhIntegrated / totalOutput)
result.BatteryContributionKm = round(totalMileage * result.BatteryEnergyShare)
result.FuelCellContributionKm = round(totalMileage * result.FuelCellEnergyShare)
if result.FuelCellContributionKm > 0 {
result.HydrogenRatePerFCContributionKm = round(stat.ConsumptionKg * 100 / result.FuelCellContributionKm)
}
}
}
return result, nil
}
func calculateEnergy(samples []energySample, batteryCapacityKWh float64) energyResult {
result := energyResult{EnergySampleCount: len(samples)}
if len(samples) == 0 {
return result
}
for _, sample := range samples {
if sample.HasChargeStatus && sample.ChargeStatus == 1 {
result.ExternalChargeFrameCount++
}
}
operating := make([]energySample, 0, len(samples))
for _, sample := range samples {
if sample.HasVehicleStatus && sample.VehicleStatus == 1 && (!sample.HasChargeStatus || sample.ChargeStatus != 1) {
operating = append(operating, sample)
}
}
if len(operating) == 0 {
return result
}
result.FirstTime = operating[0].At.Format(time.RFC3339)
result.LastTime = operating[len(operating)-1].At.Format(time.RFC3339)
result.OperatingSpanSeconds = operating[len(operating)-1].At.Sub(operating[0].At).Seconds()
for _, sample := range operating {
if sample.HasSOC {
result.StartSOC = sample.SOC
break
}
}
for index := len(operating) - 1; index >= 0; index-- {
if operating[index].HasSOC {
result.EndSOC = operating[index].SOC
break
}
}
result.SOCChangePctEndMinusStart = round(result.EndSOC - result.StartSOC)
result.StoredEnergyChangeKWhSOC = round(batteryCapacityKWh * result.SOCChangePctEndMinusStart / 100)
result.BatteryNetOutputKWhSOC = round(-result.StoredEnergyChangeKWhSOC)
for index := 1; index < len(operating); index++ {
previous, current := operating[index-1], operating[index]
delta := current.At.Sub(previous.At)
if delta <= 0 || delta > maxIntegrationGap {
continue
}
hours := delta.Hours()
if previous.HasBatteryPower && current.HasBatteryPower {
power0 := previous.BatteryVoltageV * previous.BatteryCurrentA / 1000
power1 := current.BatteryVoltageV * current.BatteryCurrentA / 1000
result.BatteryNetOutputKWhIntegrated += (power0 + power1) * 0.5 * hours
result.BatteryCoverageSeconds += delta.Seconds()
}
if previous.HasFuelCellPower && current.HasFuelCellPower {
power0 := previous.FuelCellVoltageV * previous.FuelCellCurrentA / 1000
power1 := current.FuelCellVoltageV * current.FuelCellCurrentA / 1000
result.FuelCellOutputKWhIntegrated += (power0 + power1) * 0.5 * hours
result.FuelCellCoverageSeconds += delta.Seconds()
}
}
result.BatteryNetOutputKWhIntegrated = round(result.BatteryNetOutputKWhIntegrated)
result.FuelCellOutputKWhIntegrated = round(result.FuelCellOutputKWhIntegrated)
if result.OperatingSpanSeconds > 0 {
result.BatteryCoverageRatio = round(result.BatteryCoverageSeconds / result.OperatingSpanSeconds)
result.FuelCellCoverageRatio = round(result.FuelCellCoverageSeconds / result.OperatingSpanSeconds)
}
return result
}
func observationFromFrame(frame rawFrame, tankCapacity float64, statDate string) (openplatform.HydrogenObservation, bool) {
if frame.MessageID != 2 || !strings.EqualFold(frame.ParseStatus, "OK") {
return openplatform.HydrogenObservation{}, false
}
pressure, pressureOK := number(frame.ParsedFields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
temperature, temperatureOK := number(frame.ParsedFields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
if !pressureOK || !temperatureOK || pressure <= 0 || pressure > 70 || temperature <= -40 || temperature > 726.85 {
return openplatform.HydrogenObservation{}, false
}
mass, ok := openplatform.PressureHydrogenMassKg(pressure, temperature, tankCapacity)
if !ok {
return openplatform.HydrogenObservation{}, false
}
step, _ := openplatform.PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, tankCapacity)
at, err := parseEventTime(frame.EventTime)
if err != nil || at.Format("2006-01-02") != statDate {
return openplatform.HydrogenObservation{}, false
}
parsed, _ := json.Marshal(frame.ParsedFields)
_, _, active, known, _ := openplatform.ExtractHydrogenTelemetry(string(parsed))
observation := openplatform.HydrogenObservation{
VIN: frame.VIN, Source: frame.SourceEndpoint, EventID: frame.EventID, ObservedAt: at,
MassKg: mass, TankCapacityLiter: tankCapacity, PressureMPa: pressure, TemperatureC: temperature,
NoiseKg: math.Min(1, math.Max(0.05, mass-step)), RefuelThresholdKg: math.Max(1, mass*0.05),
FuelCellActive: active, FuelCellStateKnown: known,
}
if value, ok := number(frame.ParsedFields["gb32960.vehicle.soc_percent"]); ok && value >= 0 && value <= 100 {
observation.SOCPercent, observation.SOCKnown = value, true
}
if value, ok := number(frame.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && value >= 0 {
observation.MileageKm, observation.MileageKnown = value, true
}
if value, ok := number(frame.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
observation.VehicleState, observation.VehicleStateKnown = int(value), value >= 0 && value <= 255
}
if value, ok := number(frame.ParsedFields["gb32960.vehicle.charge_status"]); ok {
observation.ChargeState, observation.ChargeStateKnown = int(value), value >= 0 && value <= 255
}
if value, ok := number(frame.ParsedFields["gb32960.vehicle.running_mode"]); ok {
observation.RunningMode, observation.RunningModeKnown = int(value), value >= 0 && value <= 255
}
return observation, true
}
func energySampleFromFrame(frame rawFrame, statDate string) (energySample, bool) {
if frame.MessageID != 2 || !strings.EqualFold(frame.ParseStatus, "OK") {
return energySample{}, false
}
at, err := parseEventTime(frame.EventTime)
if err != nil || at.Format("2006-01-02") != statDate {
return energySample{}, false
}
sample := energySample{At: at}
if value, ok := number(frame.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
sample.VehicleStatus, sample.HasVehicleStatus = int(value), true
}
if value, ok := number(frame.ParsedFields["gb32960.vehicle.charge_status"]); ok {
sample.ChargeStatus, sample.HasChargeStatus = int(value), true
}
if value, ok := number(frame.ParsedFields["gb32960.vehicle.soc_percent"]); ok && value >= 0 && value <= 100 {
sample.SOC, sample.HasSOC = value, true
}
if value, ok := number(frame.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && value >= 0 {
sample.Mileage, sample.HasMileage = value, true
}
batteryVoltage, batteryVoltageOK := number(frame.ParsedFields["gb32960.vehicle.total_voltage_v"])
batteryCurrent, batteryCurrentOK := number(frame.ParsedFields["gb32960.vehicle.total_current_a"])
if batteryVoltageOK && batteryCurrentOK && batteryVoltage > 0 && batteryVoltage <= 1000 && batteryCurrent >= -1000 && batteryCurrent <= 1000 {
sample.BatteryVoltageV, sample.BatteryCurrentA, sample.HasBatteryPower = batteryVoltage, batteryCurrent, true
}
fuelCellVoltage, fuelCellVoltageOK := number(frame.ParsedFields["gb32960.fuel_cell.fuel_cell_voltage_v"])
fuelCellCurrent, fuelCellCurrentOK := number(frame.ParsedFields["gb32960.fuel_cell.fuel_cell_current_a"])
if fuelCellVoltageOK && fuelCellCurrentOK && fuelCellVoltage >= 0 && fuelCellVoltage <= 2000 && fuelCellCurrent >= 0 && fuelCellCurrent <= 2000 {
sample.FuelCellVoltageV, sample.FuelCellCurrentA, sample.HasFuelCellPower = fuelCellVoltage, fuelCellCurrent, true
}
return sample, sample.HasVehicleStatus || sample.HasSOC || sample.HasBatteryPower || sample.HasFuelCellPower
}
func fetchFrames(vin, date string) ([]rawFrame, int, error) {
const limit = 500
first, err := fetchPage(vin, date, 0, limit, true)
if err != nil {
return nil, 0, err
}
frames := append([]rawFrame(nil), first.Items...)
for offset := limit; offset < first.Total; offset += limit {
page, err := fetchPage(vin, date, offset, limit, false)
if err != nil {
return nil, first.Total, err
}
frames = append(frames, page.Items...)
}
if len(frames) != first.Total {
return nil, first.Total, fmt.Errorf("API total=%d fetched=%d", first.Total, len(frames))
}
return frames, first.Total, nil
}
func fetchPage(vin, date string, offset, limit int, includeTotal bool) (rawResponse, error) {
query := url.Values{
"protocol": {"GB32960"}, "vin": {vin},
"dateFrom": {date + " 00:00:00"}, "dateTo": {date + " 23:59:59"},
"orderBy": {"eventTime"}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)},
"includeFields": {"true"}, "includePayload": {"false"}, "includeTotal": {strconv.FormatBool(includeTotal)},
}
var response *http.Response
var err error
for attempt := 1; attempt <= 4; attempt++ {
request, _ := http.NewRequest(http.MethodGet, baseURL+"/api/history/raw-frames?"+query.Encode(), nil)
request.Header.Set("Accept-Encoding", "gzip")
response, err = (&http.Client{Timeout: 90 * time.Second}).Do(request)
if err == nil {
break
}
if attempt < 4 {
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
}
if err != nil {
return rawResponse{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
if response.StatusCode == http.StatusInternalServerError && bytes.Contains(body, []byte("Table does not exist")) {
return rawResponse{}, nil
}
return rawResponse{}, fmt.Errorf("HTTP %d: %s", response.StatusCode, body)
}
var reader io.Reader = response.Body
if response.Header.Get("Content-Encoding") == "gzip" {
gzipReader, err := gzip.NewReader(response.Body)
if err != nil {
return rawResponse{}, err
}
defer gzipReader.Close()
reader = gzipReader
}
var result rawResponse
if err := json.NewDecoder(reader).Decode(&result); err != nil {
return rawResponse{}, err
}
return result, nil
}
func summarize(rows []row) map[string]any {
byDate := map[string]map[string]any{}
for _, date := range uniqueDates(rows) {
var dateRows []row
for _, value := range rows {
if value.Date == date {
dateRows = append(dateRows, value)
}
}
quality := map[string]int{}
valid := 0
standardApplicable := 0
powerUsable := 0
physicalTotal, balancedTotal, mileageTotal, balancedMileageTotal := 0.0, 0.0, 0.0, 0.0
for _, value := range dateRows {
quality[value.CurrentQuality]++
if value.CurrentQuality != "NO_DATA" && value.TotalMileageKm > 0 {
valid++
physicalTotal += value.PhysicalHydrogenKg
mileageTotal += value.TotalMileageKm
}
if value.StandardLikeApplicable {
standardApplicable++
balancedTotal += value.StandardLikeBalancedKg
balancedMileageTotal += value.TotalMileageKm
}
if value.PowerIntegrationUsable {
powerUsable++
}
}
physicalRate, balancedRate := 0.0, 0.0
if mileageTotal > 0 {
physicalRate = round(physicalTotal * 100 / mileageTotal)
}
if balancedMileageTotal > 0 {
balancedRate = round(balancedTotal * 100 / balancedMileageTotal)
}
byDate[date] = map[string]any{
"vehicleDaysWithFrames": len(dateRows), "validVehicleDays": valid, "standardLikeApplicableVehicleDays": standardApplicable, "qualityCounts": quality,
"powerIntegrationUsableVehicleDays": powerUsable, "totalMileageKm": round(mileageTotal),
"physicalHydrogenKg": round(physicalTotal), "standardLikeBalancedHydrogenKg": round(balancedTotal),
"standardLikeMileageKm": round(balancedMileageTotal), "fleetPhysicalRateKgPer100Km": physicalRate, "fleetStandardLikeRateKgPer100Km": balancedRate,
}
}
return map[string]any{"vehicleDays": len(rows), "byDate": byDate}
}
func writeCSV(path string, rows []row) {
file, err := os.Create(path)
if err != nil {
panic(err)
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
headers := []string{"日期", "车牌", "VIN", "车型", "原始帧数", "当前质量状态", "当前质量原因", "加氢次数", "充电次数", "总里程km", "当前纯电里程km", "当前混动里程km", "物理耗氢kg", "当前SOC平衡氢量kg", "当前修正氢耗_按混动里程", "物理氢耗_按总里程", "全日起始SOC", "全日终止SOC", "全日SOC变化_末减初", "全日电池储能变化kWh", "电池等效氢量_16kWhkg", "标准式平衡氢量kg", "标准式平衡氢耗_按总里程", "标准式修正可用", "标准式修正说明", "电流积分电池净输出kWh", "燃料电池输出积分kWh", "电池能量贡献占比", "燃料电池能量贡献占比", "电池贡献里程km", "燃料电池贡献里程km", "按燃料电池贡献里程氢耗", "电池功率覆盖率", "燃料电池功率覆盖率", "外充状态帧数", "功率积分可用"}
_ = writer.Write(headers)
for _, value := range rows {
_ = writer.Write([]string{
value.Date, value.Plate, value.VIN, value.Model, integer(value.RawFrameCount), value.CurrentQuality, value.CurrentReason,
integer(value.RefuelCount), integer(value.ChargeCount), decimal(value.TotalMileageKm), decimal(value.PureElectricMileageKm),
decimal(value.CurrentMixedMileageKm), decimal(value.PhysicalHydrogenKg), decimal(value.CurrentSOCBalancedKg),
decimal(value.CurrentRatePerMixedKm), decimal(value.PhysicalRatePerTotalKm), decimal(value.FullDayStartSOC),
decimal(value.FullDayEndSOC), decimal(value.FullDaySOCChangePct), decimal(value.StoredEnergyChangeKWhSOC),
decimal(value.BatteryEquivalentKgFixed16), decimal(value.StandardLikeBalancedKg), decimal(value.StandardLikeRatePerTotalKm),
strconv.FormatBool(value.StandardLikeApplicable), value.StandardLikeReason,
decimal(value.BatteryNetOutputKWhIntegrated), decimal(value.FuelCellOutputKWhIntegrated), decimal(value.BatteryEnergyShare),
decimal(value.FuelCellEnergyShare), decimal(value.BatteryContributionKm), decimal(value.FuelCellContributionKm),
decimal(value.HydrogenRatePerFCContributionKm), decimal(value.BatteryCoverageRatio), decimal(value.FuelCellCoverageRatio),
integer(value.ExternalChargeFrameCount), strconv.FormatBool(value.PowerIntegrationUsable),
})
}
}
func uniqueDates(rows []row) []string {
seen := map[string]bool{}
for _, value := range rows {
seen[value.Date] = true
}
dates := make([]string, 0, len(seen))
for date := range seen {
dates = append(dates, date)
}
sort.Strings(dates)
return dates
}
func number(value any) (float64, bool) {
switch typed := value.(type) {
case float64:
return typed, true
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
default:
return 0, false
}
}
func parseEventTime(value string) (time.Time, error) {
location := time.FixedZone("CST", 8*3600)
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05.000", value, location); err == nil {
return parsed, nil
}
return time.ParseInLocation("2006-01-02 15:04:05", value, location)
}
func mustDate(value string) time.Time {
parsed, err := time.Parse("2006-01-02", value)
if err != nil {
panic(err)
}
return parsed
}
func mustReadJSON(path string, target any) {
data, err := os.ReadFile(path)
if err != nil {
panic(err)
}
if err := json.Unmarshal(data, target); err != nil {
panic(err)
}
}
func writeJSON(path string, value any) {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
panic(err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
panic(err)
}
}
func round(value float64) float64 { return math.Round(value*1000) / 1000 }
func decimal(value float64) string { return strconv.FormatFloat(value, 'f', 3, 64) }
func integer(value int) string { return strconv.Itoa(value) }
@@ -477,7 +477,7 @@ export const api = {
}),
vehicleRealtime: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRealtimeRow>>(`/api/realtime/vehicles?${params.toString()}`, signal ? { signal } : undefined),
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`),
historyLocations: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`, withSignal(undefined, signal)),
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
rawFramesQuery: (query: RawFrameQuery) => request<Page<RawFrameRow>>('/api/history/raw-frames/query', {
method: 'POST',
@@ -381,6 +381,8 @@ export interface AlertQuery { keyword?: string; severity?: string; status?: stri
export interface AlertSummary { active: number; unprocessed: number; processing: number; recovered: number; closed: number; ignored: number; unreadNotifications: number; asOf: string; }
export interface AlertAction { id: number; action: string; fromStatus: string; toStatus: string; actor: string; note: string; createdAt: string; }
export interface AlertEvent {
nativeAlarmFields?: Record<string, unknown>;
nativeAlarmNames?: string[]; nativeAlarmReservedBits?: number[];
id: string; eventType?: string; eventCategory?: string; executionState?: 'pending' | 'processing' | 'recovered' | 'completed' | 'ignored'; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; triggerType?: AlertTriggerType; status: AlertStatus;
vin: string; plate: string; protocol: string; metric: string; operator: string; triggerValue: number; threshold: number; thresholdHigh: number;
unit: string; durationSec: number; location: string; longitude?: number; latitude?: number; sourceEventId: string;
@@ -901,6 +903,12 @@ export interface LatestTelemetryResponse {
}
export interface DailyMileageRow {
province?: string;
city?: string;
region?: string;
locationTime?: string;
locationStatus?: string;
hydrogenPhysicalConsumptionKg?: number | null;
vin: string;
plate: string;
date: string;
@@ -941,7 +949,7 @@ export interface HydrogenDailyEvidence {
vin: string; plate: string; date: string; source: string; rawConsumptionKg: number;
batterySocDeltaPct?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
socBalancedConsumptionKg?: number; mixedMileageKm: number; pureElectricMileageKm: number;
consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
physicalConsumptionKgPer100Km?: number; consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
refuelCount: number; refuelAmountKg?: number; chargeCount: number; chargeEnergyKWh?: number; validSegmentCount: number; invalidSegmentCount: number;
qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string;
parameters: {
@@ -14,7 +14,7 @@ const LEGACY_LOCAL_SQL_TIMESTAMP = /\.\d{6}Z$/;
export const severityLabels: Record<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
export const statusLabels: Record<AlertStatus, string> = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
export const actionLabels: Record<string, string> = { trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
export const actionLabels: Record<string, string> = { repair: '修正告警信息', trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
export const metricLabels: Record<string, string> = { speed_kmh: '速度', soc_percent: 'SOC', alarm_active: '协议告警位', freshness_sec: '离线时长', data_delay_sec: '数据延迟' };
export const operatorLabels: Record<string, string> = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠', between: '区间内', outside: '区间外', changed: '状态变化' };
export const triggerTypeLabels: Record<string, string> = { metric: '数值触发', geofence: '电子围栏', stationary: '长时间静止', offline: '长时间离线' };
@@ -145,3 +145,25 @@ export function automationSource(rule: Pick<AlertRule, 'scopeProtocols'>) {
if (protocols.length === 1) return protocolEventSources.find((item) => item.protocol === protocols[0])?.label ?? protocols[0];
return `${protocols.length} 类协议`;
}
export function isNativeAlarm(event: Pick<AlertEvent, 'ruleId'>) {
return event.ruleId === 'native-gb32960-alarm';
}
export function nativeAlarmDetails(event: AlertEvent) {
const fields = event.nativeAlarmFields;
if (!fields) return [];
return [
['max_alarm_level', '最高报警等级'],
['general_alarm_flag', '通用报警标志'],
['battery_faults', '可充电储能装置故障码'],
['motor_faults', '驱动电机故障码'],
['engine_faults', '发动机故障码'],
['other_faults', '其他故障码'],
].map(([name, label]) => {
const value = fields[`gb32960.alarm.${name}`];
let text = value == null ? '未上报' : Array.isArray(value) ? (value.length ? value.join('、') : '无') : String(value);
if (name === 'max_alarm_level' && value != null) text = ({ '0': '0 · 无故障', '1': '1 · 一级故障', '2': '2 · 二级故障', '3': '3 · 三级故障', '254': '254 · 异常', '255': '255 · 无效' } as Record<string, string>)[String(value)] ?? text;
return { key: label, value: text };
});
}
@@ -14,7 +14,7 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
{ vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' }
],
mileageRows: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, pureElectricMileageKm: 32.5, mixedMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenSocBalancedKg: 3.126, batterySocDeltaPct: -2, chargeCount: 1, chargeEnergyKWh: 12.5, refuelCount: 1, refuelAmountKg: 8.976, hydrogenQualityStatus: 'SUSPECT', hydrogenQualityReason: '疑似管路泄压', source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, pureElectricMileageKm: 32.5, mixedMileageKm: 56.2, hydrogenConsumptionKg: 3.126, hydrogenPhysicalConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenSocBalancedKg: 3.126, batterySocDeltaPct: -2, chargeCount: 1, chargeEnergyKWh: 12.5, refuelCount: 1, refuelAmountKg: 8.976, hydrogenQualityStatus: 'SUSPECT', hydrogenQualityReason: '疑似管路泄压', source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, source: 'GB32960' }
],
sources: [
@@ -43,12 +43,12 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
expect(hydrogenSheet.getRow(1).values).toEqual([
undefined, '日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)', '纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)', '修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
undefined, '日期', '车牌', '当天所在省', '当天所在市', '大区', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)', '纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)', '修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源', '定位时间(当日最后有效点)', '位置状态'
]);
expect(hydrogenSheet.getRow(2).values).toEqual([
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', '现代', 'XCIENT', 5.5, 'SUSPECT', 88.7, 32.5, 56.2, -2, 1, 12.5, 1, 8.976, 3.126, '疑似管路泄压', 3.1, 'GB32960'
undefined, '2026-07-13', '粤A12345', '广东省', '广州市', '华南', 'LTEST000000000001', '现代', 'XCIENT', 5.5, 'SUSPECT', 88.7, 32.5, 56.2, -2, 1, 12.5, 1, 8.976, 3.126, '疑似管路泄压', 3.1, 'GB32960', '2026-07-13 23:00:00', '已解析'
]);
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 19 } });
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 24 } });
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
});
@@ -93,18 +93,20 @@ test('terminates the workbook worker when an active export is cancelled', async
dateTo: '2026-07-14',
dates: ['2026-07-13', '2026-07-14'],
vehicles: [],
mileageRows: [{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' }],
mileageRows: [{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' }],
sources: []
}, controller.signal);
expect(postMessage).toHaveBeenCalledTimes(3);
expect(postMessage.mock.calls.map(([message]) => message.type)).toEqual(['start', 'rows', 'finish']);
expect(postMessage.mock.calls[1][0].rows).toEqual([{
province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析',
vin: 'LTEST000000000001',
date: '2026-07-13',
dailyMileageKm: 88.7,
pureHydrogenMileageKm: undefined,
hydrogenConsumptionKg: undefined,
hydrogenPhysicalConsumptionKg: undefined,
hydrogenConsumptionKgPer100Km: undefined,
hydrogenSocBalancedKg: undefined,
pureElectricMileageKm: undefined,
@@ -143,3 +145,10 @@ test('releases a streaming export before finish when the route aborts', async ()
expect(terminate).toHaveBeenCalledTimes(1);
await expect(stream.finish([{ vin: 'VIN001', plate: '粤A00001' }])).rejects.toMatchObject({ name: 'AbortError' });
});
test('hydrogen export opens the hydrogen sheet with geography in the first five columns', async () => {
const workbook = await createMileageWorkbook({ metricView: 'hydrogen', dateFrom: '2026-09-01', dateTo: '2026-09-01', dates: ['2026-09-01'], vehicles: [], mileageRows: [], sources: [] });
expect(workbook.worksheets[0].name).toBe('氢能明细');
expect(Array.from(workbook.worksheets[0].getRow(1).values as unknown[]).slice(1, 6)).toEqual(['日期', '车牌', '当天所在省', '当天所在市', '大区']);
});
@@ -6,6 +6,7 @@ export type MileageExportSource = { protocol: string; label: string; mileageType
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
export type MileageExportInput = {
metricView?: 'mileage' | 'hydrogen';
dateFrom: string;
dateTo: string;
dates: string[];
@@ -68,7 +69,7 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
}
const buffer = event.data.buffer;
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
downloadBlob(blob, `车辆里程查询_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
downloadBlob(blob, `车辆${input.metricView === 'hydrogen' ? '氢耗明细' : '里程查询'}_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
resolveFinish?.();
dispose();
};
@@ -84,11 +85,13 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
post({
type: 'rows',
rows: rows.map((row) => ({
province: row.province, city: row.city, region: row.region, locationTime: row.locationTime, locationStatus: row.locationStatus,
vin: row.vin,
date: row.date,
dailyMileageKm: row.dailyMileageKm,
pureHydrogenMileageKm: row.pureHydrogenMileageKm,
hydrogenConsumptionKg: row.hydrogenConsumptionKg,
hydrogenPhysicalConsumptionKg: row.hydrogenPhysicalConsumptionKg,
hydrogenConsumptionKgPer100Km: row.hydrogenConsumptionKgPer100Km,
hydrogenSocBalancedKg: row.hydrogenSocBalancedKg,
pureElectricMileageKm: row.pureElectricMileageKm,
@@ -126,6 +129,7 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
export async function downloadMileageWorkbook(input: MileageExportInput, signal?: AbortSignal) {
const stream = createMileageExportStream({
metricView: input.metricView,
dateFrom: input.dateFrom,
dateTo: input.dateTo,
dates: input.dates,
@@ -26,6 +26,8 @@ export async function createMileageWorkbook(input: MileageExportInput) {
workbook.modified = input.exportedAt ?? new Date();
workbook.calcProperties.fullCalcOnLoad = true;
// Create the selected view first so Excel/WPS opens the requested export.
const primaryHydrogenSheet = input.metricView === 'hydrogen' ? workbook.addWorksheet('氢能明细') : undefined;
const sheet = workbook.addWorksheet('里程查询', {
views: [{ state: 'frozen', xSplit: 4, ySplit: 6, activeCell: 'E7', showGridLines: false }],
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } },
@@ -133,14 +135,13 @@ export async function createMileageWorkbook(input: MileageExportInput) {
}
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
const hydrogenSheet = workbook.addWorksheet('氢能明细', {
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
properties: { defaultRowHeight: 21 }
});
const hydrogenSheet = primaryHydrogenSheet ?? workbook.addWorksheet('氢能明细');
hydrogenSheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 1, activeCell: 'C2', showGridLines: false }];
hydrogenSheet.properties.defaultRowHeight = 21;
const hydrogenHeaders = [
'日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
'日期', '车牌', '当天所在省', '当天所在市', '大区', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
'纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)',
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源', '定位时间(当日最后有效点)', '位置状态'
];
hydrogenSheet.addRow(hydrogenHeaders);
const hydrogenHeader = hydrogenSheet.getRow(1);
@@ -158,6 +159,7 @@ export async function createMileageWorkbook(input: MileageExportInput) {
const row = hydrogenSheet.addRow([
value.date,
vehicle?.plate || value.plate || '未绑定',
value.province || '未知', value.city || '未知', value.region || '未知',
value.vin,
vehicle?.brandName || '待维护',
vehicle?.modelName || '待维护',
@@ -173,20 +175,21 @@ export async function createMileageWorkbook(input: MileageExportInput) {
value.refuelAmountKg ?? null,
value.hydrogenSocBalancedKg ?? null,
value.hydrogenQualityReason || '',
value.hydrogenConsumptionKg ?? null,
value.source ?? ''
value.hydrogenPhysicalConsumptionKg ?? null,
value.source ?? '',
value.locationTime || '', value.locationStatus || '未查询'
]);
row.height = 24;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } };
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 6 && columnNumber <= 16 ? 'right' : 'left' };
cell.font = { name: columnNumber === 6 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 6 ? 9 : 10, color: { argb: 'FF34445A' } };
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 9 && columnNumber <= 19 ? 'right' : 'left' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if ([6, 8, 9, 10, 11, 13, 15, 16, 18].includes(columnNumber)) cell.numFmt = '#,##0.000';
if ([12, 14].includes(columnNumber)) cell.numFmt = '0';
if ([9, 11, 12, 13, 14, 16, 18, 19, 21].includes(columnNumber)) cell.numFmt = '#,##0.000';
if ([15, 17].includes(columnNumber)) cell.numFmt = '0';
});
});
[13, 15, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 16].forEach((width, index) => {
[13, 15, 18, 18, 12, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 16, 30, 24].forEach((width, index) => {
hydrogenSheet.getColumn(index + 1).width = width;
});
if (energyRows.length) {
@@ -1359,3 +1359,42 @@ test('opens a recent automation run in a focused event inbox', async () => {
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('eventId=');
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('runFromAutomationId=');
});
test.each([false, true])('displays native GB32960 alarms without automation (mobile=%s)', async (mobile) => {
layout.mobile = mobile;
const event: AlertEvent = {
...alertEvent('native-event', 'NATIVEVIN', '粤A原生01'),
ruleId: 'native-gb32960-alarm', ruleName: '绝缘报警、驱动电机温度报警', ruleVersion: 0,
protocol: 'GB32960', metric: 'alarm_active',
nativeAlarmNames: ['绝缘报警', '驱动电机温度报警'], nativeAlarmReservedBits: [20, 21],
nativeAlarmFields: {
'gb32960.alarm.max_alarm_level': 2,
'gb32960.alarm.general_alarm_flag': '0x00000004',
'gb32960.alarm.battery_faults': ['0x00000001'],
'gb32960.alarm.motor_faults': [],
'gb32960.alarm.engine_faults': ['0x00000002'],
'gb32960.alarm.other_faults': [],
},
};
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [event], total: 1, limit: 20, offset: 0 });
mocks.alertEventV2.mockResolvedValue(event);
mocks.alertRulesV2.mockResolvedValue([]);
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?eventId=native-event']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const fields = await screen.findByRole('region', { name: '32960 原生告警字段' });
await waitFor(() => expect(fields).toHaveTextContent('2 · 二级故障'));
expect(fields).toHaveTextContent('0x00000004');
expect(fields).toHaveTextContent('可充电储能装置故障码0x00000001');
expect(fields).toHaveTextContent('驱动电机故障码无');
expect(fields).toHaveTextContent('发动机故障码0x00000002');
expect(fields).toHaveTextContent('其他故障码无');
expect(screen.queryByLabelText('事件证据')).not.toBeInTheDocument();
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('绝缘报警');
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('驱动电机温度报警');
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('bit20、bit21');
expect(screen.getByLabelText('事件执行轨迹')).toHaveTextContent('车辆原生告警入库');
expect(screen.getByLabelText('事件执行轨迹')).not.toHaveTextContent('匹配自动化');
});
@@ -6,7 +6,7 @@ import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AlertEvent, AlertNotification, AlertNotificationChannelCapability, AlertNotificationConfig, AlertNotificationRetryResult, AlertNotificationTarget, AlertQuery, AlertRule, AlertRuleInput, AlertRulePage, AlertRuleRevision, AlertStatus, AlertTriggerType, MetricDefinition, Page } from '../../api/types';
import { actionLabels, alertDeltaText, alertValue, canAct, formatAlertDuration, formatAlertTime, operatorLabels, ruleCondition, thresholdText, triggerTypeLabels } from '../domain/alert';
import { automationEventType, automationSource, eventContract, eventEvidenceHistoryPath, protocolEventSources, toVehicleEvent } from '../domain/event';
import { automationEventType, automationSource, eventContract, eventEvidenceHistoryPath, isNativeAlarm, nativeAlarmDetails, protocolEventSources, toVehicleEvent } from '../domain/event';
import { InlineError, PanelEmpty, PanelError, PanelLoading } from '../shared/AsyncState';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { ProtocolTag } from '../shared/ProtocolTag';
@@ -341,7 +341,7 @@ const AlertEventTable = memo(function AlertEventTable({ rows, selectedID, compac
},
{ title: '协议', dataIndex: 'protocol', className: 'is-protocol', width: 98, render: (value: string) => <ProtocolTag className="v2-alert-protocol-tag" protocol={value} compact /> },
{ title: '发生时间', dataIndex: 'triggeredAt', className: 'is-triggered-at', width: 124, render: (value: string) => <AlertTime value={value} /> },
{ title: '匹配自动化', dataIndex: 'ruleName', className: 'is-rule', width: 154, render: (value: string) => <span className="v2-alert-event-rule" title={value}>{value}</span> },
{ title: '事件来源 / 自动化', dataIndex: 'ruleName', className: 'is-rule', width: 154, render: (value: string, event: AlertEvent) => <span className="v2-alert-event-rule" title={value}>{isNativeAlarm(event) ? '车辆原生告警' : value}</span> },
{ title: '执行状态', dataIndex: 'status', className: 'is-status', width: 92, render: (_: AlertEvent['status'], event: AlertEvent) => <EventExecutionTag event={event} /> },
];
return compact
@@ -390,14 +390,22 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
<div className="v2-alert-focus-facts" aria-label="事件上下文">
<span><small></small><strong><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact unknownLabel="未知协议" /></strong></span>
<span><small></small><strong><AlertTime value={normalized.occurredAt} /></strong></span>
<span><small></small><strong>v{event.ruleVersion}</strong></span>
<span><small>{isNativeAlarm(event) ? '事件来源' : '匹配自动化'}</small><strong>{isNativeAlarm(event) ? '车辆原生告警' : `v${event.ruleVersion}`}</strong></span>
</div>
<div className="v2-alert-evidence" aria-label="事件证据">
{isNativeAlarm(event) ? <section aria-label="32960 原生告警字段">
<div className="v2-native-alarm-meaning" aria-label="具体告警信息">
<strong></strong>
{event.nativeAlarmNames?.length ? <ul>{event.nativeAlarmNames.map((name) => <li key={name}>{name}</li>)}</ul> : <p>{event.ruleName}</p>}
{event.nativeAlarmReservedBits?.length ? <p> {event.nativeAlarmReservedBits.map((bit) => `bit${bit}`).join('、')} </p> : null}
</div>
<p></p>
{event.nativeAlarmFields ? <Descriptions className="v2-alert-descriptions" align="left" size="small" data={nativeAlarmDetails(event)} /> : <p>{loading ? '正在读取告警字段…' : '告警字段暂不可用'}</p>}
</section> : <div className="v2-alert-evidence" aria-label="事件证据">
<Card className="v2-alert-evidence-value is-trigger"><small></small><strong>{alertValue(event)}</strong></Card>
<Card className="v2-alert-evidence-value"><small></small><strong>{thresholdText(event)}</strong></Card>
<Card className="v2-alert-evidence-value is-delta"><small></small><strong>{alertDeltaText(event)}</strong></Card>
</div>
<div className="v2-alert-focus-rule"><Tag color="blue" type="light" size="small"></Tag><span>{event.ruleName}</span></div>
</div>}
<div className="v2-alert-focus-rule"><Tag color="blue" type="light" size="small">{isNativeAlarm(event) ? '车辆原生告警' : '自动化'}</Tag><span>{event.ruleName}</span></div>
</Card>
{normalized.execution.requiresAttention ? <Card className="v2-alert-detail-card v2-alert-disposition-card" title={<span className="v2-alert-detail-title"><strong></strong><Tag color={editable ? 'orange' : 'grey'} type="light" size="small">{editable ? '选填' : '只读'}</Tag></span>} headerLine aria-label="事件处置操作">
{editable ? <><TextArea maxCount={200} autosize={{ minRows: 2, maxRows: 4 }} placeholder="补充处理说明(选填)" value={note} onChange={onNote} />{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}</> : <p className="v2-role-notice"></p>}
@@ -406,8 +414,8 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
<Card className="v2-alert-detail-card v2-alert-progress-card" title={<span className="v2-alert-detail-title"><strong>线</strong><Tag color="blue" type="light" size="small">{normalized.execution.label}</Tag></span>} headerLine>
<Timeline className="v2-alert-timeline v2-event-execution-trace" aria-label="事件执行轨迹">
<Timeline.Item time={<AlertTime value={event.receivedAt} />}><strong></strong><p>{event.protocol} </p></Timeline.Item>
<Timeline.Item time={<AlertTime value={event.triggeredAt} />}><strong></strong><p>{event.ruleName} · v{event.ruleVersion}</p></Timeline.Item>
<Timeline.Item type={normalized.execution.requiresAttention ? 'ongoing' : 'default'} time={<AlertTime value={event.triggeredAt} />}><strong>{normalized.execution.requiresAttention ? '创建待办并通知' : '执行动作并记录'}</strong><p>{normalized.execution.requiresAttention ? '等待人工确认' : normalized.execution.label}</p></Timeline.Item>
<Timeline.Item time={<AlertTime value={event.triggeredAt} />}><strong>{isNativeAlarm(event) ? '车辆原生告警入库' : '匹配自动化'}</strong><p>{event.ruleName}{isNativeAlarm(event) ? '' : ` · v${event.ruleVersion}`}</p></Timeline.Item>
<Timeline.Item type={normalized.execution.requiresAttention ? 'ongoing' : 'default'} time={<AlertTime value={event.triggeredAt} />}><strong>{isNativeAlarm(event) ? '记录告警事件' : normalized.execution.requiresAttention ? '创建待办并通知' : '执行动作并记录'}</strong><p>{normalized.execution.requiresAttention ? '等待人工确认' : normalized.execution.label}</p></Timeline.Item>
{event.actions?.filter((item) => item.action !== 'trigger').map((item) => <Timeline.Item key={item.id} type="default" time={<span>{item.actor} · <AlertTime value={item.createdAt} /></span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}
</Timeline>
</Card>
@@ -420,7 +428,7 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
...eventContract(event).map((item) => ({ key: item.key, value: <code>{item.value || '—'}</code> })),
{ key: 'event.id', value: <code>{event.id}</code> },
{ key: 'source.event_id', value: <code>{event.sourceEventId || '—'}</code> },
{ key: 'automation', value: <code>{event.ruleId} / v{event.ruleVersion}</code> }
{ key: isNativeAlarm(event) ? 'source.kind' : 'automation', value: <code>{isNativeAlarm(event) ? 'native-gb32960' : `${event.ruleId} / v${event.ruleVersion}`}</code> }
]} />
</Collapse.Panel>
</Collapse>
@@ -567,7 +575,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, editable,
aria-label={mobileLayout ? '事件列表,可上下滚动' : undefined}
>
{mobileLayout
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 事件详情`} onClick={() => selectEvent(event.id)}><span className="v2-alert-mobile-card-content"><header><strong>{event.ruleName}</strong><EventExecutionTag event={event} /></header><p><strong>{event.plate || '未绑定车牌'}</strong><span>{event.vin}</span></p><div className="v2-alert-mobile-facts"><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact /><span><AlertTime value={event.triggeredAt} /></span><span className="v2-alert-mobile-condition"><i></i>{thresholdText(event)}</span></div><footer aria-hidden="true"><IconChevronRight /></footer></span></Button></Card>)}</div>
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 事件详情`} onClick={() => selectEvent(event.id)}><span className="v2-alert-mobile-card-content"><header><strong>{event.ruleName}</strong><EventExecutionTag event={event} /></header><p><strong>{event.plate || '未绑定车牌'}</strong><span>{event.vin}</span></p><div className="v2-alert-mobile-facts"><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact /><span><AlertTime value={event.triggeredAt} /></span><span className="v2-alert-mobile-condition"><i>{isNativeAlarm(event) ? '事件来源' : '匹配条件'}</i>{isNativeAlarm(event) ? '车辆原生告警' : thresholdText(event)}</span></div><footer aria-hidden="true"><IconChevronRight /></footer></span></Button></Card>)}</div>
: <AlertEventTable rows={rows} selectedID={selectedID} compact={Boolean(selectedID)} onSelect={selectEvent} />}
{events.isFetching ? <PanelLoading className="v2-alert-loading" title="正在更新事件…" description="告警列表返回后会自动更新。" compact={Boolean(rows.length)} /> : null}
{!events.isFetching && !events.isError && !rows.length ? <PanelEmpty
@@ -7,7 +7,7 @@ import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext';
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), hydrogenDailyEvidence: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
const mocks = vi.hoisted(() => ({ historyLocations: vi.fn(), mileageStatistics: vi.fn(), dailyMileage: vi.fn(), hydrogenDailyEvidence: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
const exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
const layout = vi.hoisted(() => ({ mobile: false }));
const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' }));
@@ -24,6 +24,7 @@ function renderPage(initialEntry = '/statistics') {
}
function prepareData() {
mocks.historyLocations.mockResolvedValue({ items: [], total: 0 });
exportMocks.finish.mockResolvedValue(undefined);
exportMocks.createMileageExportStream.mockReturnValue({ appendRows: exportMocks.appendRows, finish: exportMocks.finish, dispose: exportMocks.dispose });
mocks.mileageStatistics.mockResolvedValue({
@@ -35,8 +36,8 @@ function prepareData() {
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
});
mocks.dailyMileage.mockResolvedValue({ items: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' }
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 2.9, hydrogenSocBalancedKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.0, hydrogenSocBalancedKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' }
], total: 2, limit: 10000, offset: 0 });
mocks.hydrogenDailyEvidence.mockResolvedValue({
vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', source: 'factory-a',
@@ -147,14 +148,14 @@ test('renders only the desktop matrix with dates as columns and a period total',
const hydrogenGuide = screen.getByRole('note', { name: '氢耗矩阵读表说明' });
expect(hydrogenGuide).toHaveTextContent('日期子列里程固定展示');
expect(screen.getByRole('group', { name: '日期子列' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '用氢量' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '修正用氢量' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '纯电里程' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: '纯氢里程' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: '百公里氢耗' })).toHaveAttribute('aria-pressed', 'true');
expect(hydrogenGuide).toHaveTextContent('里程 当日末累计里程-同源日基线');
expect(hydrogenGuide).toHaveTextContent('纯电里程 总里程-纯氢里程');
expect(hydrogenGuide).toHaveTextContent('纯氢里程 运行模式 0x02 的相邻里程差累计');
expect(hydrogenGuide).toHaveTextContent('百公里氢耗 有效用氢量÷纯氢里程×100');
expect(hydrogenGuide).toHaveTextContent('百公里氢耗 修正用氢量÷纯氢里程×100');
expect(screen.getByRole('region', { name: '车辆每日氢耗矩阵,可横向滚动查看日期' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('氢耗匹配纯氢里程128.4 km纯氢里程=总里程-纯电模式里程');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('区间氢耗7.3 kg5.7 kg/100km · 2 个有效车辆日');
@@ -655,3 +656,16 @@ test('aborts an active export when the mileage route unmounts', async () => {
expect(exportSignal?.aborted).toBe(true);
expect(exportMocks.createMileageExportStream).not.toHaveBeenCalled();
});
test('exports stored daily geography without querying history or geocoding', async () => {
prepareData();
const row = { vin: 'LTEST000000000001', date: '2026-07-13', dailyMileageKm: 100, province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13T23:00:00+08:00', locationStatus: '已解析' };
mocks.dailyMileage.mockResolvedValue({ items: [row], total: 1, limit: 10000, offset: 0 });
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-13');
fireEvent.click(await screen.findByRole('button', { name: '氢耗' }));
fireEvent.click(screen.getByRole('button', { name: '导出 Excel' }));
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
expect(exportMocks.appendRows).toHaveBeenCalledWith([row]);
expect(mocks.historyLocations).not.toHaveBeenCalled();
});
@@ -47,7 +47,7 @@ type HydrogenMatrixMetric = 'consumption' | 'pureElectric' | 'pureHydrogen' | 'r
type MatrixMetric = 'mileage' | HydrogenMatrixMetric;
const HYDROGEN_MATRIX_METRICS: Array<{ key: HydrogenMatrixMetric; label: string }> = [
{ key: 'consumption', label: '用氢量' },
{ key: 'consumption', label: '修正用氢量' },
{ key: 'pureElectric', label: '纯电里程' },
{ key: 'pureHydrogen', label: '纯氢里程' },
{ key: 'rate', label: '百公里氢耗' }
@@ -55,7 +55,7 @@ const HYDROGEN_MATRIX_METRICS: Array<{ key: HydrogenMatrixMetric; label: string
const DEFAULT_HYDROGEN_MATRIX_METRICS: HydrogenMatrixMetric[] = ['consumption', 'rate'];
const MATRIX_METRIC_META: Record<MatrixMetric, { label: string; unit: string; width: number }> = {
mileage: { label: '里程', unit: 'km', width: 92 },
consumption: { label: '用氢量', unit: 'kg', width: 88 },
consumption: { label: '修正用氢量', unit: 'kg', width: 88 },
pureElectric: { label: '纯电里程', unit: 'km', width: 96 },
pureHydrogen: { label: '纯氢里程', unit: 'km', width: 96 },
rate: { label: '百公里氢耗', unit: 'kg/100km', width: 112 }
@@ -633,7 +633,8 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
}
type HydrogenDayMetric = {
consumptionKg: number;
consumptionKg?: number;
matchedMileageKm: number;
rateKgPer100Km?: number;
socBalancedKg?: number;
socBalancedRateKgPer100Km?: number;
@@ -690,10 +691,10 @@ function MileageMatrixGuide({
</div>
<div className="v2-mileage-metric-logic" aria-label="指标取值逻辑">
<span><strong></strong> 线</span>
<span><strong></strong> </span>
<span><strong></strong> SOC变化折算用氢量</span>
<span><strong></strong> </span>
<span><strong></strong> 0x02 </span>
<span><strong></strong> ÷×100</span>
<span><strong></strong> ÷×100</span>
<span><strong></strong> </span>
</div>
</aside>;
@@ -872,7 +873,8 @@ function HydrogenEvidenceSheet({ target, onClose }: { target?: HydrogenEvidenceT
badgeColor={data?.qualityStatus === 'OK' ? 'green' : data?.qualityStatus === 'SUSPECT' ? 'orange' : 'grey'}
width="min(760px, 94vw)"
summaryItems={data ? [
{ label: '物理耗氢', value: `${evidenceNumber(data.rawConsumptionKg)} kg`, detail: 'NIST压力—质量差分段累计', tone: 'primary' },
{ label: '百公里氢耗', value: data.consumptionKgPer100Km == null ? '未计算' : `${evidenceNumber(data.consumptionKgPer100Km)} kg/100km`, detail: '修正用氢量÷匹配里程×100', tone: 'primary' },
{ label: '物理耗氢', value: `${evidenceNumber(data.rawConsumptionKg)} kg`, detail: '原始计算量,供核对', tone: 'neutral' },
{ label: 'SOC平衡氢耗', value: data.socBalancedConsumptionKg == null ? '未计算' : `${evidenceNumber(data.socBalancedConsumptionKg)} kg`, detail: data.parameters.batteryCapacityKWh > 0 ? `电池 ${evidenceNumber(data.parameters.batteryCapacityKWh, 2)} kWh` : '缺少经确认的电池容量', tone: 'warning' },
{ label: '混动里程', value: `${evidenceNumber(data.mixedMileageKm)} km`, detail: `${data.validSegmentCount} 个有效区间`, tone: 'success' }
] : []}
@@ -1044,9 +1046,10 @@ export default function StatisticsPage() {
const pureHydrogenMileage = Math.max(0, Math.min(row.dailyMileageKm, row.pureHydrogenMileageKm ?? 0));
entry.pureHydrogenDays.set(row.date, pureHydrogenMileage);
entry.pureElectricDays.set(row.date, Math.max(0, row.dailyMileageKm - pureHydrogenMileage));
if (row.hydrogenConsumptionKg != null) {
if (row.hydrogenConsumptionKg != null || row.hydrogenSocBalancedKg != null) {
entry.hydrogenDays.set(row.date, {
consumptionKg: row.hydrogenConsumptionKg,
consumptionKg: row.hydrogenSocBalancedKg ?? undefined,
matchedMileageKm: row.mixedMileageKm != null && row.mixedMileageKm > 0 ? row.mixedMileageKm : row.pureHydrogenMileageKm ?? 0,
rateKgPer100Km: row.hydrogenConsumptionKgPer100Km ?? undefined,
socBalancedKg: row.hydrogenSocBalancedKg ?? undefined,
socBalancedRateKgPer100Km: row.hydrogenSocBalancedKgPer100Km ?? undefined,
@@ -1070,17 +1073,21 @@ export default function StatisticsPage() {
const sources = daily?.sources ?? new Map<string, string>();
let dailyTotal = 0;
let hydrogenTotal = 0;
let hydrogenRatedTotal = 0;
let correctedDays = 0;
let hydrogenRatedMileage = 0;
let pureElectricTotal = 0;
let pureHydrogenTotal = 0;
for (const value of days.values()) dailyTotal += value;
for (const value of pureElectricDays.values()) pureElectricTotal += value;
for (const value of pureHydrogenDays.values()) pureHydrogenTotal += value;
for (const [date, value] of hydrogenDays) {
const matchedMileage = pureHydrogenDays.get(date) ?? 0;
if (matchedMileage <= 0) continue;
for (const value of hydrogenDays.values()) {
if (value.consumptionKg == null) continue;
hydrogenTotal += value.consumptionKg;
hydrogenRatedMileage += matchedMileage;
correctedDays++;
if (value.matchedMileageKm <= 0 || value.qualityStatus !== 'OK' || value.rateKgPer100Km == null) continue;
hydrogenRatedTotal += value.consumptionKg;
hydrogenRatedMileage += value.matchedMileageKm;
}
return {
...vehicle,
@@ -1093,8 +1100,8 @@ export default function StatisticsPage() {
totalMileageKm: days.size ? ranking?.mileageKm ?? dailyTotal : undefined,
totalPureElectricMileageKm: pureElectricDays.size ? pureElectricTotal : undefined,
totalPureHydrogenMileageKm: pureHydrogenDays.size ? pureHydrogenTotal : undefined,
totalHydrogenConsumptionKg: hydrogenDays.size ? hydrogenTotal : undefined,
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenTotal * 100 / hydrogenRatedMileage : undefined
totalHydrogenConsumptionKg: correctedDays ? hydrogenTotal : undefined,
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenRatedTotal * 100 / hydrogenRatedMileage : undefined
};
}), [displayVehicles, mileageByVin, rankingByVin]);
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
@@ -1204,6 +1211,7 @@ export default function StatisticsPage() {
}
const plateByVin = new Map(vehicles.filter((vehicle) => vehicle.plate).map((vehicle) => [vehicle.vin, vehicle.plate]));
exportStream = createMileageExportStream({
metricView,
dateFrom: criteria.dateFrom,
dateTo: criteria.dateTo,
dates,