chore: snapshot production code before Apple Design UI refinement
This commit is contained in:
@@ -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>401:appKey 无效、停用或过期</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>日里程按同来源相邻日累计差计算;缺报日沿用累计值、日里程补 0(CARRIED_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 19–31 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) }
|
||||
Reference in New Issue
Block a user