feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,252 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
const (
temporaryMode = "temporary"
durableMode = "durable"
temporaryTable = "vehicle_alert_candidate_benchmark"
durableTable = "vehicle_alert_candidate_benchmark_durable"
durableLock = "lingniu.vehicle-alert-candidate-benchmark"
)
type benchmarkConfig struct {
Rows int
BatchSize int
Mode string
ConfirmDurable bool
}
type benchmarkResult struct {
Mode string `json:"mode"`
Rows int `json:"rows"`
BatchSize int `json:"batchSize"`
Batches int `json:"batches"`
Transactions int `json:"transactions"`
DurationMS int64 `json:"durationMs"`
RowsPerSec float64 `json:"rowsPerSec"`
VerifiedRows int `json:"verifiedRows"`
Temporary bool `json:"temporaryTable"`
DurableWrite bool `json:"durableWrite"`
CleanupMS int64 `json:"cleanupMs"`
CleanupVerified bool `json:"cleanupVerified"`
GlobalStatusDelta map[string]uint64 `json:"globalStatusDelta,omitempty"`
GlobalStatusAvailable bool `json:"globalStatusAvailable"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "alert benchmark failed:", err)
os.Exit(1)
}
}
func run() error {
config := benchmarkConfig{}
flag.IntVar(&config.Rows, "rows", 10_000, "candidate rows to write")
flag.IntVar(&config.BatchSize, "batch-size", 500, "rows per insert")
flag.StringVar(&config.Mode, "mode", temporaryMode, "storage mode: temporary or durable")
flag.BoolVar(&config.ConfirmDurable, "confirm-durable-write", false, "required acknowledgement for durable writes and DDL")
flag.Parse()
if err := config.validate(); err != nil {
return err
}
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
if dsn == "" {
return fmt.Errorf("MYSQL_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return err
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
result, err := executeBenchmark(ctx, conn, config)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(result)
}
func (config benchmarkConfig) validate() error {
config.Mode = strings.ToLower(strings.TrimSpace(config.Mode))
if config.Rows < 1 || config.Rows > 100_000 || config.BatchSize < 1 || config.BatchSize > 1_000 {
return fmt.Errorf("rows must be 1..100000 and batch-size 1..1000")
}
if config.Mode != temporaryMode && config.Mode != durableMode {
return fmt.Errorf("mode must be temporary or durable")
}
if config.Mode == durableMode && !config.ConfirmDurable {
return fmt.Errorf("durable mode creates, commits to, and drops a dedicated physical table; pass --confirm-durable-write")
}
return nil
}
func executeBenchmark(ctx context.Context, conn *sql.Conn, config benchmarkConfig) (result benchmarkResult, err error) {
mode := strings.ToLower(strings.TrimSpace(config.Mode))
table := temporaryTable
createPrefix := "CREATE TEMPORARY TABLE"
dropPrefix := "DROP TEMPORARY TABLE IF EXISTS"
if mode == durableMode {
table = durableTable
createPrefix = "CREATE TABLE"
dropPrefix = "DROP TABLE IF EXISTS"
locked, lockErr := acquireBenchmarkLock(ctx, conn)
if lockErr != nil {
return result, fmt.Errorf("acquire durable benchmark lock: %w", lockErr)
}
if !locked {
return result, fmt.Errorf("another durable benchmark is already running")
}
defer releaseBenchmarkLock(conn)
if _, err = conn.ExecContext(ctx, dropPrefix+" "+table); err != nil {
return result, fmt.Errorf("remove stale durable benchmark table: %w", err)
}
}
if _, err = conn.ExecContext(ctx, createPrefix+" "+table+" LIKE vehicle_alert_candidate"); err != nil {
return result, fmt.Errorf("create %s benchmark table: %w", mode, err)
}
cleanupStarted := time.Time{}
cleanupComplete := false
defer func() {
cleanupStarted = time.Now()
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, cleanupErr := conn.ExecContext(cleanupCtx, dropPrefix+" "+table)
if cleanupErr == nil {
cleanupComplete = true
if mode == durableMode {
var remaining int
cleanupErr = conn.QueryRowContext(cleanupCtx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?`, table).Scan(&remaining)
cleanupComplete = cleanupErr == nil && remaining == 0
}
}
result.CleanupMS = time.Since(cleanupStarted).Milliseconds()
result.CleanupVerified = cleanupComplete
if err == nil && cleanupErr != nil {
err = fmt.Errorf("cleanup benchmark table: %w", cleanupErr)
} else if err == nil && !cleanupComplete {
err = fmt.Errorf("benchmark table cleanup could not be verified")
}
}()
statusBefore, statusBeforeErr := readGlobalStatus(ctx, conn)
started := time.Now()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return result, err
}
now := time.Now().UTC()
batches := 0
for start := 0; start < config.Rows; start += config.BatchSize {
end := min(start+config.BatchSize, config.Rows)
query, args := buildCandidateInsert(table, start, end, now)
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
tx.Rollback()
return result, fmt.Errorf("insert batch %d: %w", batches+1, err)
}
batches++
}
if err = tx.Commit(); err != nil {
return result, err
}
duration := time.Since(started)
var verified int
if err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table).Scan(&verified); err != nil {
return result, err
}
if verified != config.Rows {
return result, fmt.Errorf("row verification failed: wrote %d, found %d", config.Rows, verified)
}
statusAfter, statusAfterErr := readGlobalStatus(ctx, conn)
statusDelta := map[string]uint64{}
statusAvailable := statusBeforeErr == nil && statusAfterErr == nil
if statusAvailable {
for key, after := range statusAfter {
if before, ok := statusBefore[key]; ok && after >= before {
statusDelta[key] = after - before
}
}
}
result = benchmarkResult{
Mode: mode,
Rows: config.Rows,
BatchSize: config.BatchSize,
Batches: batches,
Transactions: 1,
DurationMS: duration.Milliseconds(),
RowsPerSec: float64(config.Rows) / duration.Seconds(),
VerifiedRows: verified,
Temporary: mode == temporaryMode,
DurableWrite: mode == durableMode,
GlobalStatusDelta: statusDelta,
GlobalStatusAvailable: statusAvailable,
}
return result, nil
}
func acquireBenchmarkLock(ctx context.Context, conn *sql.Conn) (bool, error) {
var acquired sql.NullInt64
if err := conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,0)`, durableLock).Scan(&acquired); err != nil {
return false, err
}
return acquired.Valid && acquired.Int64 == 1, nil
}
func releaseBenchmarkLock(conn *sql.Conn) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = conn.ExecContext(ctx, `SELECT RELEASE_LOCK(?)`, durableLock)
}
func readGlobalStatus(ctx context.Context, conn *sql.Conn) (map[string]uint64, error) {
rows, err := conn.QueryContext(ctx, `SHOW GLOBAL STATUS WHERE Variable_name IN ('Innodb_os_log_written','Binlog_cache_use','Binlog_cache_disk_use','Com_commit')`)
if err != nil {
return nil, err
}
defer rows.Close()
values := map[string]uint64{}
for rows.Next() {
var key, raw string
if err := rows.Scan(&key, &raw); err != nil {
return nil, err
}
value, parseErr := strconv.ParseUint(raw, 10, 64)
if parseErr != nil {
return nil, parseErr
}
values[key] = value
}
return values, rows.Err()
}
func buildCandidateInsert(table string, start, end int, now time.Time) (string, []any) {
if table != temporaryTable && table != durableTable {
panic("unsupported benchmark table")
}
values := make([]string, 0, end-start)
args := make([]any, 0, (end-start)*7)
for index := start; index < end; index++ {
values = append(values, "(?,?,?,?,?,?,?)")
args = append(args, "benchmark-rule", fmt.Sprintf("BENCH%017d", index), "JT808", now, now, float64(index%120), fmt.Sprintf("benchmark-event-%d", index))
}
return `INSERT INTO ` + table + `(rule_id,vin,protocol,first_matched_at,last_matched_at,latest_value,source_event_id) VALUES ` + strings.Join(values, ","), args
}

View File

@@ -0,0 +1,37 @@
package main
import (
"strings"
"testing"
"time"
)
func TestBuildCandidateInsertMatchesBatchArguments(t *testing.T) {
query, args := buildCandidateInsert(temporaryTable, 10, 13, time.Now())
if strings.Count(query, "(?,?,?,?,?,?,?)") != 3 || strings.Count(query, "?") != len(args) || len(args) != 21 {
t.Fatalf("query/args mismatch: placeholders=%d args=%d query=%s", strings.Count(query, "?"), len(args), query)
}
}
func TestBenchmarkConfigRequiresExplicitDurableConfirmation(t *testing.T) {
valid := benchmarkConfig{Rows: 100_000, BatchSize: 500, Mode: durableMode, ConfirmDurable: true}
if err := valid.validate(); err != nil {
t.Fatalf("valid durable benchmark rejected: %v", err)
}
valid.ConfirmDurable = false
if err := valid.validate(); err == nil || !strings.Contains(err.Error(), "--confirm-durable-write") {
t.Fatalf("durable mode must require confirmation, got %v", err)
}
}
func TestBenchmarkConfigBoundsAndModes(t *testing.T) {
for _, config := range []benchmarkConfig{
{Rows: 0, BatchSize: 500, Mode: temporaryMode},
{Rows: 1, BatchSize: 1001, Mode: temporaryMode},
{Rows: 1, BatchSize: 1, Mode: "business-table"},
} {
if err := config.validate(); err == nil {
t.Fatalf("invalid config accepted: %+v", config)
}
}
}

View File

@@ -0,0 +1,52 @@
package main
import (
"context"
"log"
"os/signal"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func main() {
cfg := config.Load()
if cfg.MySQLDSN == "" {
log.Fatal("MYSQL_DSN is required")
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := platform.OpenSQL(ctx, "mysql", cfg.MySQLDSN)
if err != nil {
log.Fatal(err)
}
defer db.Close()
store := platform.NewProductionStore(db, nil, "").WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup)
service := platform.NewService(store)
interval := cfg.AlertEvaluationInterval
if interval < time.Second {
interval = time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
log.Printf("alert evaluator started interval=%s stream_mode=%s", interval, cfg.AlertStreamMode)
for {
started := time.Now()
result, err := service.EvaluateAlerts(ctx)
if err != nil {
log.Printf("alert evaluation failed duration=%s error=%v", time.Since(started), err)
} else {
log.Printf("alert evaluation completed rules=%d vehicles=%d candidates_advanced=%d duplicate_observations=%d late_observations=%d stale_evidence_skipped=%d opened=%d recovered=%d duration=%s", result.RulesEvaluated, result.VehiclesScanned, result.CandidatesAdvanced, result.DuplicateObservations, result.LateObservations, result.StaleEvidenceSkipped, result.Opened, result.Recovered, time.Since(started))
}
select {
case <-ctx.Done():
log.Printf("alert evaluator stopped")
return
case <-ticker.C:
}
}
}

View File

@@ -0,0 +1,178 @@
package main
import (
"context"
"fmt"
"log"
"os/signal"
"sort"
"strings"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/segmentio/kafka-go"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
const alertStreamOperationTimeout = 30 * time.Second
type alertStreamStore interface {
RecordAlertStreamBatch(context.Context, string, []platform.AlertStreamRecord) (platform.AlertStreamBatchResult, error)
}
type kafkaMessageFetcher interface {
FetchMessage(context.Context) (kafka.Message, error)
}
type kafkaMessageCommitter interface {
CommitMessages(context.Context, ...kafka.Message) error
}
func main() {
cfg := config.Load()
if err := validateAlertStreamConfig(cfg); err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := platform.OpenSQL(ctx, "mysql", cfg.MySQLDSN)
if err != nil {
log.Fatal(err)
}
defer db.Close()
store := platform.NewProductionStore(db, nil, "").WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup)
reader := kafka.NewReader(alertStreamReaderConfig(cfg))
defer reader.Close()
log.Printf("alert stream evaluator started mode=%s group=%s topics=%s batch_size=%d batch_wait=%s lateness=%s", cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup, strings.Join(cfg.AlertStreamKafkaTopics, ","), cfg.AlertStreamBatchSize, cfg.AlertStreamBatchWait, cfg.AlertStreamLateness)
for {
message, err := reader.FetchMessage(ctx)
if err != nil {
if ctx.Err() != nil {
log.Printf("alert stream evaluator stopped")
return
}
log.Printf("alert stream fetch failed error=%v", err)
continue
}
messages := collectAlertStreamBatch(ctx, reader, message, cfg.AlertStreamBatchSize, cfg.AlertStreamBatchWait)
started := time.Now()
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), alertStreamOperationTimeout)
records := make([]platform.AlertStreamRecord, 0, len(messages))
for _, item := range messages {
records = append(records, platform.DecodeAlertStreamRecord(item.Topic, item.Partition, item.Offset, item.HighWaterMark, item.Value, cfg.AlertStreamLateness))
}
invalidCodes := alertStreamInvalidCodes(records)
result, recordErr := store.RecordAlertStreamBatch(operationCtx, cfg.AlertStreamKafkaGroup, records)
if recordErr == nil {
recordErr = reader.CommitMessages(operationCtx, messages...)
}
cancel()
if recordErr != nil {
log.Printf("alert stream batch failed fetched=%d duration=%s error=%v", len(messages), time.Since(started), recordErr)
time.Sleep(time.Second)
continue
}
log.Printf("alert stream batch completed mode=%s fetched=%d processed=%d valid=%d invalid=%d invalid_codes=%s late=%d replay_skipped=%d partitions=%d rules=%d candidates_advanced=%d duplicate_observations=%d late_observations=%d opened=%d recovered=%d duration=%s", cfg.AlertStreamMode, result.Fetched, result.Processed, result.Valid, result.Invalid, invalidCodes, result.Late, result.ReplaySkipped, result.Partitions, result.RulesEvaluated, result.CandidatesAdvanced, result.DuplicateObservations, result.LateObservations, result.Opened, result.Recovered, time.Since(started))
}
}
func alertStreamInvalidCodes(records []platform.AlertStreamRecord) string {
counts := map[string]int{}
for _, record := range records {
if !record.Valid {
counts[record.ErrorCode]++
}
}
if len(counts) == 0 {
return "none"
}
keys := make([]string, 0, len(counts))
for key := range counts {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s:%d", key, counts[key]))
}
return strings.Join(parts, ",")
}
func alertStreamReaderConfig(cfg config.Config) kafka.ReaderConfig {
return kafka.ReaderConfig{
Brokers: cfg.AlertStreamKafkaBrokers,
GroupID: cfg.AlertStreamKafkaGroup,
GroupTopics: cfg.AlertStreamKafkaTopics,
MinBytes: 1,
MaxBytes: 10e6,
StartOffset: kafka.LastOffset,
}
}
func validateAlertStreamConfig(cfg config.Config) error {
if cfg.AlertStreamMode != "shadow" && cfg.AlertStreamMode != "active" {
return fmt.Errorf("ALERT_STREAM_MODE must be shadow or active")
}
if cfg.MySQLDSN == "" {
return fmt.Errorf("MYSQL_DSN is required")
}
if len(cfg.AlertStreamKafkaBrokers) == 0 || len(cfg.AlertStreamKafkaTopics) == 0 {
return fmt.Errorf("ALERT_STREAM_KAFKA_BROKERS and ALERT_STREAM_KAFKA_TOPICS are required")
}
if strings.TrimSpace(cfg.AlertStreamKafkaGroup) == "" {
return fmt.Errorf("ALERT_STREAM_KAFKA_GROUP is required")
}
if cfg.AlertStreamBatchSize < 1 || cfg.AlertStreamBatchSize > 1000 {
return fmt.Errorf("ALERT_STREAM_BATCH_SIZE must be between 1 and 1000")
}
if cfg.AlertStreamBatchWait < time.Millisecond || cfg.AlertStreamBatchWait > 5*time.Second {
return fmt.Errorf("ALERT_STREAM_BATCH_WAIT_MS must be between 1 and 5000")
}
if cfg.AlertStreamLateness < 0 || cfg.AlertStreamLateness > 24*time.Hour {
return fmt.Errorf("ALERT_STREAM_LATENESS_SEC must be between 0 and 86400")
}
for _, topic := range cfg.AlertStreamKafkaTopics {
if _, known := alertStreamTopicProtocol(topic); !known {
return fmt.Errorf("unsupported alert stream topic %q", topic)
}
}
return nil
}
func alertStreamTopicProtocol(topic string) (string, bool) {
switch strings.TrimSpace(topic) {
case "vehicle.fields.go.gb32960.v1":
return "GB32960", true
case "vehicle.fields.go.jt808.v1":
return "JT808", true
case "vehicle.fields.go.yutong-mqtt.v1":
return "YUTONG_MQTT", true
default:
return "", false
}
}
func collectAlertStreamBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message {
if maxSize <= 1 {
return []kafka.Message{first}
}
messages := []kafka.Message{first}
deadline := time.Now().Add(maxWait)
for len(messages) < maxSize {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
fetchCtx, cancel := context.WithTimeout(ctx, remaining)
message, err := fetcher.FetchMessage(fetchCtx)
cancel()
if err != nil {
break
}
messages = append(messages, message)
}
return messages
}

View File

@@ -0,0 +1,62 @@
package main
import (
"testing"
"time"
"github.com/segmentio/kafka-go"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func validAlertStreamConfig() config.Config {
return config.Config{
MySQLDSN: "user:pass@tcp(localhost:3306)/db?parseTime=true",
AlertStreamMode: "shadow",
AlertStreamKafkaBrokers: []string{"kafka-a:9092"},
AlertStreamKafkaTopics: []string{"vehicle.fields.go.jt808.v1"},
AlertStreamKafkaGroup: "vehicle-alert-stream-v1",
AlertStreamBatchSize: 200,
AlertStreamBatchWait: 100 * time.Millisecond,
AlertStreamLateness: 2 * time.Minute,
}
}
func TestAlertStreamConfigAcceptsReleasedModesAndRefusesUnknownTopics(t *testing.T) {
cfg := validAlertStreamConfig()
if err := validateAlertStreamConfig(cfg); err != nil {
t.Fatalf("valid shadow config rejected: %v", err)
}
cfg.AlertStreamMode = "active"
if err := validateAlertStreamConfig(cfg); err != nil {
t.Fatalf("released active mode rejected: %v", err)
}
cfg.AlertStreamMode = "unsafe"
if err := validateAlertStreamConfig(cfg); err == nil {
t.Fatal("unknown mode must fail closed")
}
cfg = validAlertStreamConfig()
cfg.AlertStreamKafkaTopics = []string{"vehicle.raw.go.jt808.v1"}
if err := validateAlertStreamConfig(cfg); err == nil {
t.Fatal("raw topic must not be accepted as a fields alert stream")
}
}
func TestNewAlertStreamGroupStartsAtLatestAndThenUsesCommittedOffsets(t *testing.T) {
cfg := validAlertStreamConfig()
reader := alertStreamReaderConfig(cfg)
if reader.StartOffset != kafka.LastOffset {
t.Fatalf("new production group would replay the full retained topic: start=%d", reader.StartOffset)
}
if reader.GroupID != cfg.AlertStreamKafkaGroup || len(reader.GroupTopics) != 1 || reader.GroupTopics[0] != cfg.AlertStreamKafkaTopics[0] {
t.Fatalf("reader group contract drifted: %+v", reader)
}
}
func TestAlertStreamInvalidCodesAreAggregatedWithoutPayloads(t *testing.T) {
records := []platform.AlertStreamRecord{{ErrorCode: "missing_vin_jt808"}, {Valid: true}, {ErrorCode: "invalid_field_name"}, {ErrorCode: "missing_vin_jt808"}}
if got := alertStreamInvalidCodes(records); got != "invalid_field_name:1,missing_vin_jt808:2" {
t.Fatalf("invalid code summary=%q", got)
}
}

View File

@@ -0,0 +1,129 @@
package main
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-sql-driver/mysql"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "platform migration failed:", err)
os.Exit(1)
}
}
func run() error {
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
if dsn == "" {
return fmt.Errorf("MYSQL_DSN is required")
}
if len(os.Args) < 2 {
return fmt.Errorf("usage: platform-migrate migration.sql [migration.sql ...]")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return err
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("connect mysql: %w", err)
}
if len(os.Args) == 2 && os.Args[1] == "--server-version" {
var version string
if err := db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&version); err != nil {
return err
}
fmt.Println(version)
return nil
}
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS vehicle_platform_schema_migration (
version VARCHAR(255) NOT NULL PRIMARY KEY,
checksum CHAR(64) NOT NULL,
applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
)`); err != nil {
return fmt.Errorf("ensure migration journal: %w", err)
}
for _, path := range os.Args[1:] {
contents, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
version := filepath.Base(path)
sum := sha256.Sum256(contents)
checksum := hex.EncodeToString(sum[:])
var recorded string
err = db.QueryRowContext(ctx, `SELECT checksum FROM vehicle_platform_schema_migration WHERE version=?`, version).Scan(&recorded)
if err == nil {
if recorded != checksum {
return fmt.Errorf("migration %s was changed after being applied", version)
}
fmt.Printf("skipped %s (already applied)\n", path)
continue
}
if err != sql.ErrNoRows {
return fmt.Errorf("check migration %s: %w", version, err)
}
statements := splitSQL(string(contents))
for index, statement := range statements {
if _, err := db.ExecContext(ctx, statement); err != nil {
if isResumableMigrationDDL(err, statement) {
fmt.Printf("skipped %s statement %d (schema object already exists)\n", path, index+1)
continue
}
return fmt.Errorf("apply %s statement %d: %w", path, index+1, err)
}
}
if _, err := db.ExecContext(ctx, `INSERT INTO vehicle_platform_schema_migration(version,checksum) VALUES(?,?)`, version, checksum); err != nil {
return fmt.Errorf("record migration %s: %w", version, err)
}
fmt.Printf("applied %s (%d statements)\n", path, len(statements))
}
return nil
}
func isResumableMigrationDDL(err error, statement string) bool {
var mysqlErr *mysql.MySQLError
if !errors.As(err, &mysqlErr) {
return false
}
upper := strings.ToUpper(strings.TrimSpace(statement))
duplicateColumn := mysqlErr.Number == 1060 && strings.Contains(upper, "ALTER TABLE") && strings.Contains(upper, "ADD COLUMN")
duplicateIndex := mysqlErr.Number == 1061 && strings.HasPrefix(upper, "CREATE INDEX")
return duplicateColumn || duplicateIndex
}
// splitSQL intentionally supports the platform's forward-only DDL files. Those
// files contain no procedures or quoted semicolons; rejecting empty fragments
// keeps deployment output deterministic without enabling multiStatements in DSN.
func splitSQL(contents string) []string {
lines := strings.Split(contents, "\n")
withoutComments := make([]string, 0, len(lines))
for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "--") {
continue
}
withoutComments = append(withoutComments, line)
}
contents = strings.Join(withoutComments, "\n")
fragments := strings.Split(contents, ";")
statements := make([]string, 0, len(fragments))
for _, fragment := range fragments {
statement := strings.TrimSpace(fragment)
if statement != "" {
statements = append(statements, statement)
}
}
return statements
}

View File

@@ -0,0 +1,36 @@
package main
import (
"errors"
"testing"
"github.com/go-sql-driver/mysql"
)
func TestSplitSQLKeepsForwardMigrationStatements(t *testing.T) {
statements := splitSQL("-- migration\nCREATE TABLE x (id INT);\n\nINSERT INTO x VALUES (1);\n")
if len(statements) != 2 || statements[0] != "CREATE TABLE x (id INT)" || statements[1] != "INSERT INTO x VALUES (1)" {
t.Fatalf("unexpected statements: %#v", statements)
}
}
func TestSplitSQLIgnoresSemicolonsInFullLineComments(t *testing.T) {
statements := splitSQL("-- first clause; second clause\nCREATE TABLE x (id INT);\n-- trailing; note\n")
if len(statements) != 1 || statements[0] != "CREATE TABLE x (id INT)" {
t.Fatalf("comment punctuation became executable SQL: %#v", statements)
}
}
func TestOnlyDuplicateForwardDDLIsIgnoredForMigrationResume(t *testing.T) {
duplicate := &mysql.MySQLError{Number: 1060, Message: "Duplicate column"}
if !isResumableMigrationDDL(duplicate, "ALTER TABLE x ADD COLUMN y INT") {
t.Fatal("duplicate ADD COLUMN should be resumable")
}
if isResumableMigrationDDL(duplicate, "CREATE TABLE x (y INT)") || isResumableMigrationDDL(errors.New("duplicate"), "ALTER TABLE x ADD COLUMN y INT") {
t.Fatal("unrelated migration errors must not be ignored")
}
duplicateIndex := &mysql.MySQLError{Number: 1061, Message: "Duplicate key name"}
if !isResumableMigrationDDL(duplicateIndex, "CREATE INDEX idx_x ON x(y)") || isResumableMigrationDDL(duplicateIndex, "DROP INDEX idx_x ON x") {
t.Fatal("only duplicate CREATE INDEX should be resumable")
}
}

View File

@@ -9,9 +9,13 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/segmentio/kafka-go v0.4.49 // indirect
)

View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -12,12 +14,19 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=

View File

@@ -0,0 +1,176 @@
package app
import (
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
type configuredPrincipal struct {
Token string `json:"token"`
Name string `json:"name"`
Role string `json:"role"`
}
type tokenPrincipal struct {
hash [sha256.Size]byte
principal platform.Principal
}
type apiAuthenticator struct {
mode string
tokens []tokenPrincipal
}
func withAPIAuth(next http.Handler, cfg config.Config) http.Handler {
authenticator, err := newAPIAuthenticator(cfg)
if err != nil {
log.Printf("platform API authentication misconfigured: %v", err)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusServiceUnavailable, "AUTH_CONFIG_INVALID", "平台鉴权配置无效", err.Error(), requestTraceID(r))
})
}
return authenticator.middleware(next)
}
func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) {
mode := strings.ToLower(strings.TrimSpace(cfg.AuthMode))
if mode == "" {
mode = "disabled"
}
if mode != "disabled" && mode != "enforce" {
return nil, fmt.Errorf("AUTH_MODE must be disabled or enforce")
}
authenticator := &apiAuthenticator{mode: mode}
configured := []configuredPrincipal{}
if strings.TrimSpace(cfg.AuthTokensJSON) != "" {
if err := json.Unmarshal([]byte(cfg.AuthTokensJSON), &configured); err != nil {
return nil, fmt.Errorf("decode AUTH_TOKENS_JSON: %w", err)
}
}
if token := strings.TrimSpace(cfg.AuthToken); token != "" {
configured = append(configured, configuredPrincipal{Token: token, Name: "platform-admin", Role: "admin"})
}
seen := map[[sha256.Size]byte]bool{}
for _, item := range configured {
item.Token = strings.TrimSpace(item.Token)
item.Name = strings.TrimSpace(item.Name)
item.Role = strings.ToLower(strings.TrimSpace(item.Role))
if len(item.Token) < 16 {
return nil, fmt.Errorf("token for %q must contain at least 16 characters", item.Name)
}
if item.Name == "" || roleRank(item.Role) == 0 {
return nil, fmt.Errorf("token principal requires name and viewer/operator/admin role")
}
hash := sha256.Sum256([]byte(item.Token))
if seen[hash] {
return nil, fmt.Errorf("duplicate authentication token")
}
seen[hash] = true
authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: platform.Principal{Name: item.Name, Role: item.Role}})
}
if mode == "enforce" && len(authenticator.tokens) == 0 {
return nil, fmt.Errorf("enforce mode requires AUTH_TOKEN or AUTH_TOKENS_JSON")
}
return authenticator, nil
}
func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := a.authenticate(r)
if !ok {
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-platform"`)
httpx.WriteError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "需要有效的访问令牌", "", requestTraceID(r))
return
}
required := requiredRole(r)
if roleRank(principal.Role) < roleRank(required) {
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
return
}
ctx := platform.WithPrincipal(r.Context(), principal)
r = r.WithContext(ctx)
if r.URL.Path == "/api/v2/session" {
httpx.WriteOK(w, requestTraceID(r), struct {
Name string `json:"name"`
Role string `json:"role"`
AuthMode string `json:"authMode"`
}{principal.Name, principal.Role, a.mode})
return
}
next.ServeHTTP(w, r)
})
}
func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bool) {
if a.mode == "disabled" {
return platform.Principal{Name: "local-developer", Role: "admin"}, true
}
header := strings.TrimSpace(r.Header.Get("Authorization"))
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
return platform.Principal{}, false
}
token := strings.TrimSpace(header[7:])
if token == "" {
return platform.Principal{}, false
}
hash := sha256.Sum256([]byte(token))
var match platform.Principal
matched := 0
for _, item := range a.tokens {
equal := subtle.ConstantTimeCompare(hash[:], item.hash[:])
matched |= equal
if equal == 1 {
match = item.principal
}
}
return match, matched == 1
}
func requiredRole(r *http.Request) string {
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") {
return "operator"
}
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return "viewer"
}
path := r.URL.Path
if r.Method == http.MethodPost {
switch path {
case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events":
return "viewer"
case "/api/v2/exports", "/api/v2/alerts/notifications/read":
return "operator"
}
if strings.HasPrefix(path, "/api/v2/alerts/events/") && strings.HasSuffix(path, "/actions") {
return "operator"
}
if path == "/api/v2/alerts/rules" {
return "admin"
}
}
if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) {
return "admin"
}
return "admin"
}
func roleRank(role string) int {
switch strings.ToLower(role) {
case "viewer":
return 1
case "operator":
return 2
case "admin":
return 3
}
return 0
}

View File

@@ -0,0 +1,135 @@
package app
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
const (
viewerToken = "viewer-token-at-least-16"
operatorToken = "operator-token-at-least-16"
adminToken = "admin-token-at-least-16"
)
func testAuthConfig() config.Config {
return config.Config{
AuthMode: "enforce",
AuthTokensJSON: `[
{"token":"` + viewerToken + `","name":"viewer-a","role":"viewer"},
{"token":"` + operatorToken + `","name":"operator-a","role":"operator"},
{"token":"` + adminToken + `","name":"admin-a","role":"admin"}
]`,
}
}
func authRequest(t *testing.T, cfg config.Config, method, path, token string) *httptest.ResponseRecorder {
t.Helper()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := platform.PrincipalFromContext(r.Context())
if !ok {
t.Fatal("authenticated request reached handler without principal")
}
w.Header().Set("X-Principal", principal.Name+":"+principal.Role)
w.WriteHeader(http.StatusNoContent)
})
req := httptest.NewRequest(method, path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
withAPIAuth(next, cfg).ServeHTTP(rec, req)
return rec
}
func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
cfg := testAuthConfig()
missing := authRequest(t, cfg, http.MethodGet, "/api/v2/alerts/rules", "")
if missing.Code != http.StatusUnauthorized || !strings.HasPrefix(missing.Header().Get("WWW-Authenticate"), "Bearer") {
t.Fatalf("missing token status=%d headers=%v body=%s", missing.Code, missing.Header(), missing.Body.String())
}
invalid := authRequest(t, cfg, http.MethodGet, "/api/v2/alerts/rules", "wrong-token-at-least-16")
if invalid.Code != http.StatusUnauthorized {
t.Fatalf("invalid token status=%d body=%s", invalid.Code, invalid.Body.String())
}
viewerQuery := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/events", viewerToken)
if viewerQuery.Code != http.StatusNoContent || viewerQuery.Header().Get("X-Principal") != "viewer-a:viewer" {
t.Fatalf("viewer query status=%d principal=%s", viewerQuery.Code, viewerQuery.Header().Get("X-Principal"))
}
viewerAction := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/events/a/actions", viewerToken)
if viewerAction.Code != http.StatusForbidden {
t.Fatalf("viewer mutation should be forbidden, status=%d", viewerAction.Code)
}
viewerExports := authRequest(t, cfg, http.MethodGet, "/api/v2/exports", viewerToken)
if viewerExports.Code != http.StatusForbidden {
t.Fatalf("viewer export listing should be forbidden, status=%d", viewerExports.Code)
}
operatorExports := authRequest(t, cfg, http.MethodGet, "/api/v2/exports/exp_1/download", operatorToken)
if operatorExports.Code != http.StatusNoContent {
t.Fatalf("operator export download status=%d", operatorExports.Code)
}
operatorAction := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/events/a/actions", operatorToken)
if operatorAction.Code != http.StatusNoContent {
t.Fatalf("operator action status=%d body=%s", operatorAction.Code, operatorAction.Body.String())
}
operatorRule := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/rules", operatorToken)
if operatorRule.Code != http.StatusForbidden {
t.Fatalf("operator rule mutation should be forbidden, status=%d", operatorRule.Code)
}
operatorProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", operatorToken)
if operatorProfile.Code != http.StatusForbidden {
t.Fatalf("operator profile mutation should be forbidden, status=%d", operatorProfile.Code)
}
operatorProfileSync := authRequest(t, cfg, http.MethodPost, "/api/v2/vehicle-profiles/sync", operatorToken)
if operatorProfileSync.Code != http.StatusForbidden {
t.Fatalf("operator profile sync should be forbidden, status=%d", operatorProfileSync.Code)
}
adminProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", adminToken)
if adminProfile.Code != http.StatusNoContent || adminProfile.Header().Get("X-Principal") != "admin-a:admin" {
t.Fatalf("admin profile mutation status=%d principal=%s", adminProfile.Code, adminProfile.Header().Get("X-Principal"))
}
adminProfileSync := authRequest(t, cfg, http.MethodPost, "/api/v2/vehicle-profiles/sync", adminToken)
if adminProfileSync.Code != http.StatusNoContent || adminProfileSync.Header().Get("X-Principal") != "admin-a:admin" {
t.Fatalf("admin profile sync status=%d principal=%s", adminProfileSync.Code, adminProfileSync.Header().Get("X-Principal"))
}
adminThreshold := authRequest(t, cfg, http.MethodPut, "/api/v2/access/thresholds", adminToken)
if adminThreshold.Code != http.StatusNoContent {
t.Fatalf("admin threshold status=%d body=%s", adminThreshold.Code, adminThreshold.Body.String())
}
}
func TestAPIAuthSessionAndDisabledMode(t *testing.T) {
rec := authRequest(t, config.Config{AuthMode: "disabled"}, http.MethodGet, "/api/v2/alerts/rules", "")
if rec.Code != http.StatusNoContent || rec.Header().Get("X-Principal") != "local-developer:admin" {
t.Fatalf("disabled mode status=%d principal=%s", rec.Code, rec.Header().Get("X-Principal"))
}
req := httptest.NewRequest(http.MethodGet, "/api/v2/session", nil)
req.Header.Set("Authorization", "Bearer "+operatorToken)
session := httptest.NewRecorder()
withAPIAuth(http.NotFoundHandler(), testAuthConfig()).ServeHTTP(session, req)
if session.Code != http.StatusOK || !strings.Contains(session.Body.String(), `"name":"operator-a"`) || !strings.Contains(session.Body.String(), `"role":"operator"`) {
t.Fatalf("session status=%d body=%s", session.Code, session.Body.String())
}
}
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
cases := []config.Config{
{AuthMode: "enforce"},
{AuthMode: "unknown"},
{AuthMode: "enforce", AuthTokensJSON: `not-json`},
{AuthMode: "enforce", AuthToken: "short"},
}
for _, cfg := range cases {
rec := authRequest(t, cfg, http.MethodGet, "/api/v2/alerts/rules", "")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("misconfigured auth should fail closed: cfg=%+v status=%d", cfg, rec.Code)
}
}
}

View File

@@ -23,13 +23,27 @@ import (
)
func NewServer(cfg config.Config) http.Handler {
dataMode := strings.ToLower(strings.TrimSpace(cfg.DataMode))
if dataMode == "" {
dataMode = "mock"
}
var store platform.Store = platform.NewMockStore()
if cfg.MySQLDSN != "" {
var storeErr error
if dataMode != "mock" && dataMode != "production" {
storeErr = fmt.Errorf("DATA_MODE must be mock or production")
}
if dataMode == "production" && strings.TrimSpace(cfg.MySQLDSN) == "" {
storeErr = fmt.Errorf("production data mode requires MYSQL_DSN")
}
if cfg.MySQLDSN != "" && dataMode == "production" {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
db, err := platform.OpenSQL(ctx, "mysql", cfg.MySQLDSN)
if err != nil {
log.Printf("production mysql store disabled: %v", err)
if dataMode == "production" {
storeErr = fmt.Errorf("connect production mysql: %w", err)
}
} else {
var tdengine *sql.DB
if cfg.TDengineDSN != "" {
@@ -49,11 +63,15 @@ func NewServer(cfg config.Config) http.Handler {
productionStore.WithCapacityChecker(platform.NewCapacityCheckCommand(cfg.CapacityCheckBin))
log.Printf("production capacity-check probe enabled")
}
productionStore.WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup)
store = productionStore
storeErr = nil
log.Printf("production mysql store enabled")
}
}
api := platform.NewHandler(platform.NewServiceWithRuntime(store, platform.RuntimeInfo{
var api http.Handler = platform.NewHandler(platform.NewServiceWithRuntime(store, platform.RuntimeInfo{
DataMode: dataMode,
ExportDir: strings.TrimSpace(cfg.ExportDir),
RequestTimeoutMs: int(cfg.RequestTimeout / time.Millisecond),
AMapWebJSConfigured: strings.TrimSpace(cfg.AMapWebJSKey) != "",
AMapAPIConfigured: strings.TrimSpace(cfg.AMapAPIKey) != "",
@@ -61,8 +79,16 @@ func NewServer(cfg config.Config) http.Handler {
AMapSecurityCodeExposed: exposedAMapSecurityCode(cfg) != "",
AMapSecurityServiceHost: strings.TrimSpace(cfg.AMapServiceHost),
PlatformRelease: strings.TrimSpace(cfg.PlatformRelease),
AlertStreamMode: strings.TrimSpace(cfg.AlertStreamMode),
AlertStreamConsumerGroup: strings.TrimSpace(cfg.AlertStreamKafkaGroup),
}))
handler := static.Handler(cfg.StaticDir, api)
if storeErr != nil {
log.Printf("platform data store unavailable: %v", storeErr)
api = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusServiceUnavailable, "DATA_STORE_UNAVAILABLE", "生产数据源不可用", storeErr.Error(), requestTraceID(r))
})
}
handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg))
handler = withAMapReverseGeocodeAPI(handler, cfg, "https://restapi.amap.com", http.DefaultClient)
handler = withAppConfig(handler, cfg)
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
@@ -244,7 +270,8 @@ func withAMapReverseGeocodeAPI(next http.Handler, cfg config.Config, upstream st
}
query := target.Query()
query.Set("key", apiKey)
query.Set("location", fmt.Sprintf("%.6f,%.6f", longitude, latitude))
mapLongitude, mapLatitude := wgs84ToGCJ02(longitude, latitude)
query.Set("location", fmt.Sprintf("%.6f,%.6f", mapLongitude, mapLatitude))
query.Set("extensions", "base")
query.Set("radius", "1000")
query.Set("output", "JSON")
@@ -321,6 +348,38 @@ func parseReverseGeocodeCoordinate(query url.Values) (float64, float64, error) {
return longitude, latitude, nil
}
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
}
func isCoordinate(value float64, min float64, max float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= min && value <= max
}

View File

@@ -2,12 +2,14 @@ package app
import (
"encoding/json"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
func TestWithRequestTimeoutAddsContextDeadline(t *testing.T) {
@@ -31,6 +33,15 @@ func TestWithRequestTimeoutAddsContextDeadline(t *testing.T) {
}
}
func TestProductionDataModeFailsClosedWithoutMySQL(t *testing.T) {
handler := NewServer(config.Config{DataMode: "production", RequestTimeout: time.Second})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/ops/health", nil))
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "DATA_STORE_UNAVAILABLE") {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestWithRequestTimeoutReturnsEnvelopeWithTraceID(t *testing.T) {
handler := withRequestTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
@@ -192,7 +203,8 @@ func TestAMapReverseGeocodeAPIUsesServerSideKey(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if gotKey != "server-api-key" || gotLocation != "113.123457,23.765432" {
mapLongitude, mapLatitude := wgs84ToGCJ02(113.1234567, 23.7654321)
if gotKey != "server-api-key" || gotLocation != fmt.Sprintf("%.6f,%.6f", mapLongitude, mapLatitude) {
t.Fatalf("key=%q location=%q", gotKey, gotLocation)
}
var body struct {

View File

@@ -3,53 +3,121 @@ package config
import (
"os"
"strconv"
"strings"
"time"
)
type Config struct {
HTTPAddr string
StaticDir string
MySQLDSN string
RedisAddr string
RedisUsername string
RedisPassword string
RedisDB int
TDengineDriver string
TDengineDSN string
TDengineDatabase string
CapacityCheckBin string
AuthToken string
RequestTimeout time.Duration
AMapWebJSKey string
AMapAPIKey string
AMapSecurityCode string
AMapServiceHost string
PlatformRelease string
HTTPAddr string
StaticDir string
MySQLDSN string
RedisAddr string
RedisUsername string
RedisPassword string
RedisDB int
TDengineDriver string
TDengineDSN string
TDengineDatabase string
CapacityCheckBin string
AuthToken string
AuthMode string
AuthTokensJSON string
DataMode string
ExportDir string
RequestTimeout time.Duration
AMapWebJSKey string
AMapAPIKey string
AMapSecurityCode string
AMapServiceHost string
PlatformRelease string
AlertEvaluationInterval time.Duration
AlertStreamMode string
AlertStreamKafkaBrokers []string
AlertStreamKafkaTopics []string
AlertStreamKafkaGroup string
AlertStreamBatchSize int
AlertStreamBatchWait time.Duration
AlertStreamLateness time.Duration
}
func Load() Config {
return Config{
HTTPAddr: env("HTTP_ADDR", ":20300"),
StaticDir: env("STATIC_DIR", ""),
MySQLDSN: os.Getenv("MYSQL_DSN"),
RedisAddr: os.Getenv("REDIS_ADDR"),
RedisUsername: os.Getenv("REDIS_USERNAME"),
RedisPassword: os.Getenv("REDIS_PASSWORD"),
RedisDB: envInt("REDIS_DB", 50),
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
TDengineDSN: os.Getenv("TDENGINE_DSN"),
TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"),
CapacityCheckBin: os.Getenv("CAPACITY_CHECK_BIN"),
AuthToken: os.Getenv("AUTH_TOKEN"),
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
AMapAPIKey: os.Getenv("AMAP_API_KEY"),
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
HTTPAddr: env("HTTP_ADDR", ":20300"),
StaticDir: env("STATIC_DIR", ""),
MySQLDSN: os.Getenv("MYSQL_DSN"),
RedisAddr: os.Getenv("REDIS_ADDR"),
RedisUsername: os.Getenv("REDIS_USERNAME"),
RedisPassword: os.Getenv("REDIS_PASSWORD"),
RedisDB: envInt("REDIS_DB", 50),
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
TDengineDSN: os.Getenv("TDENGINE_DSN"),
TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"),
CapacityCheckBin: os.Getenv("CAPACITY_CHECK_BIN"),
AuthToken: os.Getenv("AUTH_TOKEN"),
AuthMode: authMode(),
AuthTokensJSON: os.Getenv("AUTH_TOKENS_JSON"),
DataMode: dataMode(),
ExportDir: os.Getenv("EXPORT_DIR"),
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
AMapAPIKey: os.Getenv("AMAP_API_KEY"),
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
AlertEvaluationInterval: time.Duration(envInt("ALERT_EVALUATION_INTERVAL_SEC", 10)) * time.Second,
AlertStreamMode: strings.ToLower(env("ALERT_STREAM_MODE", "disabled")),
AlertStreamKafkaBrokers: splitCSV(firstEnv("ALERT_STREAM_KAFKA_BROKERS", "KAFKA_BROKERS")),
AlertStreamKafkaTopics: splitCSV(env("ALERT_STREAM_KAFKA_TOPICS", "vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1")),
AlertStreamKafkaGroup: env("ALERT_STREAM_KAFKA_GROUP", "vehicle-alert-stream-v1"),
AlertStreamBatchSize: envInt("ALERT_STREAM_BATCH_SIZE", 200),
AlertStreamBatchWait: time.Duration(envInt("ALERT_STREAM_BATCH_WAIT_MS", 100)) * time.Millisecond,
AlertStreamLateness: time.Duration(envInt("ALERT_STREAM_LATENESS_SEC", 120)) * time.Second,
}
}
func firstEnv(keys ...string) string {
for _, key := range keys {
if value := os.Getenv(key); value != "" {
return value
}
}
return ""
}
func splitCSV(raw string) []string {
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" && !seen[part] {
seen[part] = true
values = append(values, part)
}
}
return values
}
func dataMode() string {
if value := os.Getenv("DATA_MODE"); value != "" {
return value
}
if os.Getenv("MYSQL_DSN") != "" {
return "production"
}
return "mock"
}
func authMode() string {
if value := os.Getenv("AUTH_MODE"); value != "" {
return value
}
if os.Getenv("AUTH_TOKEN") != "" || os.Getenv("AUTH_TOKENS_JSON") != "" {
return "enforce"
}
return "disabled"
}
func env(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value

View File

@@ -34,3 +34,19 @@ func TestLoadReadsAMapServerAPIKey(t *testing.T) {
t.Fatalf("AMapAPIKey = %q", cfg.AMapAPIKey)
}
}
func TestLoadSelectsProductionDataModeWhenMySQLIsConfigured(t *testing.T) {
t.Setenv("DATA_MODE", "")
t.Setenv("MYSQL_DSN", "user:password@tcp(db:3306)/platform")
if cfg := Load(); cfg.DataMode != "production" {
t.Fatalf("DataMode = %q, want production", cfg.DataMode)
}
}
func TestLoadKeepsExplicitMockDataMode(t *testing.T) {
t.Setenv("DATA_MODE", "mock")
t.Setenv("MYSQL_DSN", "user:password@tcp(db:3306)/platform")
if cfg := Load(); cfg.DataMode != "mock" {
t.Fatalf("DataMode = %q, want mock", cfg.DataMode)
}
}

View File

@@ -0,0 +1,480 @@
package platform
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
type accessEvidenceStore interface {
AccessEvidence(context.Context) ([]AccessEvidenceRow, error)
}
type accessThresholdStore interface {
AccessThresholds(context.Context) (AccessThresholdConfig, error)
SaveAccessThresholds(context.Context, AccessThresholdUpdate) (AccessThresholdConfig, error)
}
type accessUnresolvedIdentityStore interface {
AccessUnresolvedIdentities(context.Context, AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error)
}
func defaultAccessThresholds(now time.Time) AccessThresholdConfig {
return AccessThresholdConfig{
Version: 1,
DefaultThresholdSec: 300,
DelayThresholdSec: 30,
LongOfflineSec: 1800,
Protocols: []AccessProtocolThreshold{
{Protocol: "GB32960", ThresholdSec: 300},
{Protocol: "JT808", ThresholdSec: 300},
{Protocol: "YUTONG_MQTT", ThresholdSec: 600},
},
UpdatedBy: "system",
UpdatedAt: now.Format(time.RFC3339),
}
}
func (s *Service) AccessThresholds(ctx context.Context) (AccessThresholdConfig, error) {
store, ok := s.store.(accessThresholdStore)
if !ok {
return defaultAccessThresholds(time.Now()), nil
}
config, err := store.AccessThresholds(ctx)
if err != nil {
return AccessThresholdConfig{}, err
}
return normalizeAccessThresholdConfig(config, time.Now()), nil
}
func (s *Service) UpdateAccessThresholds(ctx context.Context, update AccessThresholdUpdate) (AccessThresholdConfig, error) {
if err := validateAccessThresholdUpdate(update); err != nil {
return AccessThresholdConfig{}, err
}
store, ok := s.store.(accessThresholdStore)
if !ok {
return AccessThresholdConfig{}, clientError{Code: "ACCESS_THRESHOLD_READ_ONLY", Message: "当前存储不支持更新接入阈值"}
}
update.Actor = strings.TrimSpace(update.Actor)
if update.Actor == "" {
update.Actor = "platform-admin"
}
return store.SaveAccessThresholds(ctx, update)
}
func (s *Service) AccessVehicles(ctx context.Context, query AccessQuery) (Page[AccessVehicleRow], error) {
rows, config, err := s.accessRows(ctx, query)
if err != nil {
return Page[AccessVehicleRow]{}, err
}
_ = config
limit := query.Limit
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
offset := query.Offset
if offset < 0 {
offset = 0
}
total := len(rows)
if offset >= total {
return Page[AccessVehicleRow]{Items: []AccessVehicleRow{}, Total: total, Limit: limit, Offset: offset}, nil
}
end := offset + limit
if end > total {
end = total
}
return Page[AccessVehicleRow]{Items: append([]AccessVehicleRow(nil), rows[offset:end]...), Total: total, Limit: limit, Offset: offset}, nil
}
func (s *Service) AccessUnresolvedIdentities(ctx context.Context, query AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error) {
store, ok := s.store.(accessUnresolvedIdentityStore)
if !ok {
return Page[AccessUnresolvedIdentity]{}, fmt.Errorf("store does not provide unresolved identity evidence")
}
query.Keyword = strings.TrimSpace(query.Keyword)
query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
if query.Protocol != "" && query.Protocol != "JT808" {
return Page[AccessUnresolvedIdentity]{Items: []AccessUnresolvedIdentity{}, Limit: 50}, nil
}
if query.Limit <= 0 {
query.Limit = 50
}
if query.Limit > 200 {
query.Limit = 200
}
if query.Offset < 0 {
query.Offset = 0
}
return store.AccessUnresolvedIdentities(ctx, query)
}
func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessSummary, error) {
rows, config, err := s.accessRows(ctx, query)
if err != nil {
return AccessSummary{}, err
}
now := time.Now()
result := AccessSummary{TotalVehicles: len(rows), AsOf: now.Format(time.RFC3339), ThresholdVersion: config.Version}
protocols := map[string]*AccessDistribution{}
oems := map[string]*AccessDistribution{}
for _, row := range rows {
switch row.OnlineState {
case "online":
result.OnlineVehicles++
case "offline":
result.OfflineVehicles++
if row.FreshnessSec != nil && *row.FreshnessSec >= config.LongOfflineSec {
result.LongOfflineVehicles++
}
case "never_reported":
result.NeverReported++
default:
result.UnknownVehicles++
}
if row.DelayAbnormal {
result.DelayAbnormal++
}
if reportedOnDay(row.LatestReceivedAt, now) {
result.ReportedToday++
}
addAccessDistribution(protocols, firstNonEmpty(row.Protocol, "未识别"), row.OnlineState == "online")
addAccessDistribution(oems, firstNonEmpty(row.OEM, "未维护"), row.OnlineState == "online")
}
if result.TotalVehicles > 0 {
result.OnlineRate = float64(result.OnlineVehicles) / float64(result.TotalVehicles) * 100
}
result.Protocols = sortedAccessDistributions(protocols)
result.OEMs = sortedAccessDistributions(oems)
return result, nil
}
func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVehicleRow, AccessThresholdConfig, error) {
if err := validateAccessQuery(query); err != nil {
return nil, AccessThresholdConfig{}, err
}
store, ok := s.store.(accessEvidenceStore)
if !ok {
return nil, AccessThresholdConfig{}, fmt.Errorf("store does not provide access evidence")
}
config, err := s.AccessThresholds(ctx)
if err != nil {
return nil, AccessThresholdConfig{}, err
}
evidence, err := store.AccessEvidence(ctx)
if err != nil {
return nil, AccessThresholdConfig{}, err
}
now := time.Now()
rows := make([]AccessVehicleRow, 0, len(evidence))
for _, item := range evidence {
row := buildAccessVehicleRow(item, config, now)
if keepAccessRow(row, query) {
rows = append(rows, row)
}
}
sort.SliceStable(rows, func(i, j int) bool {
left, right := accessStateRank(rows[i].OnlineState), accessStateRank(rows[j].OnlineState)
if left != right {
return left < right
}
if rows[i].LatestReceivedAt != rows[j].LatestReceivedAt {
return rows[i].LatestReceivedAt > rows[j].LatestReceivedAt
}
return rows[i].VIN < rows[j].VIN
})
return rows, config, nil
}
func buildAccessVehicleRow(item AccessEvidenceRow, config AccessThresholdConfig, now time.Time) AccessVehicleRow {
threshold := config.DefaultThresholdSec
for _, override := range config.Protocols {
if strings.EqualFold(strings.TrimSpace(override.Protocol), strings.TrimSpace(item.Protocol)) {
threshold = override.ThresholdSec
break
}
}
row := AccessVehicleRow{
VIN: strings.TrimSpace(item.VIN),
Plate: strings.TrimSpace(item.Plate),
OEM: strings.TrimSpace(item.OEM),
Model: strings.TrimSpace(item.Model),
Company: strings.TrimSpace(item.Company),
Protocol: strings.TrimSpace(item.Protocol),
Provider: strings.TrimSpace(item.Provider),
Source: strings.TrimSpace(item.Source),
FirstSeenAt: normalizeAccessTime(item.FirstSeenAt),
LatestEventAt: normalizeAccessTime(item.LatestEventAt),
LatestReceivedAt: normalizeAccessTime(item.LatestReceivedAt),
ReportIntervalSec: item.ReportIntervalSec,
ThresholdSec: threshold,
LatestMessageType: firstNonEmpty(strings.TrimSpace(item.LatestMessageType), accessMessageType(item.Protocol)),
LatestEventID: strings.TrimSpace(item.LatestEventID),
LatestError: strings.TrimSpace(item.LatestError),
FirstSeenEvidence: strings.TrimSpace(item.FirstSeenEvidence),
FirstSeenSource: strings.TrimSpace(item.FirstSeenSource),
ReportIntervalProof: strings.TrimSpace(item.ReportIntervalProof),
ReportSampleCount: item.ReportSampleCount,
}
if row.FirstSeenEvidence == "" {
row.FirstSeenEvidence = "现有实时快照不保存首次接入时间"
}
if row.ReportIntervalProof == "" {
row.ReportIntervalProof = "需要连续上报样本后才能计算"
}
eventAt, eventOK := parseAccessTime(item.LatestEventAt)
receivedAt, receivedOK := parseAccessTime(item.LatestReceivedAt)
if eventOK && receivedOK {
delay := int(receivedAt.Sub(eventAt).Seconds())
row.DataDelaySec = &delay
row.DelayAbnormal = delay < 0 || delay > config.DelayThresholdSec
if delay < 0 && row.LatestError == "" {
row.LatestError = "接收时间早于事件时间"
}
}
latest, latestOK := receivedAt, receivedOK
if !latestOK {
latest, latestOK = parseAccessTime(item.LatestUpdatedAt)
}
switch {
case strings.TrimSpace(item.Protocol) == "" && !latestOK && !eventOK:
row.OnlineState = "never_reported"
case !latestOK:
row.OnlineState = "unknown"
if row.LatestError == "" {
row.LatestError = "缺少可解析的接收时间"
}
default:
freshness := int(now.Sub(latest).Seconds())
if freshness < 0 {
freshness = 0
}
row.FreshnessSec = &freshness
if freshness <= threshold {
row.OnlineState = "online"
} else {
row.OnlineState = "offline"
}
}
return row
}
func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool {
keyword := strings.ToLower(strings.TrimSpace(query.Keyword))
if keyword != "" && !strings.Contains(strings.ToLower(row.VIN), keyword) && !strings.Contains(strings.ToLower(row.Plate), keyword) {
return false
}
if value := strings.TrimSpace(query.Protocol); value != "" && !strings.EqualFold(value, row.Protocol) {
return false
}
if value := strings.TrimSpace(query.OEM); value != "" && !strings.EqualFold(value, row.OEM) {
return false
}
if value := strings.ToLower(strings.TrimSpace(query.Model)); value != "" && !strings.Contains(strings.ToLower(row.Model), value) {
return false
}
if value := strings.ToLower(strings.TrimSpace(query.Provider)); value != "" && !strings.Contains(strings.ToLower(row.Provider), value) {
return false
}
if !accessTimeMatches(row.FirstSeenAt, query.FirstSeenFrom, query.FirstSeenTo) || !accessTimeMatches(row.LatestReceivedAt, query.LatestSeenFrom, query.LatestSeenTo) {
return false
}
if value := strings.TrimSpace(query.OnlineState); value != "" && value != "all" && value != row.OnlineState {
return false
}
switch strings.TrimSpace(query.DelayState) {
case "abnormal":
return row.DelayAbnormal
case "normal":
return row.DataDelaySec != nil && !row.DelayAbnormal
}
return true
}
func validateAccessQuery(query AccessQuery) error {
for _, item := range []struct{ name, from, to string }{{"首次接入", query.FirstSeenFrom, query.FirstSeenTo}, {"最新上报", query.LatestSeenFrom, query.LatestSeenTo}} {
start, startOK := parseAccessFilterTime(item.from)
end, endOK := parseAccessFilterTime(item.to)
if strings.TrimSpace(item.from) != "" && !startOK || strings.TrimSpace(item.to) != "" && !endOK {
return clientError{Code: "ACCESS_TIME_INVALID", Message: item.name + "时间格式无效"}
}
if startOK && endOK && start.After(end) {
return clientError{Code: "ACCESS_TIME_RANGE_INVALID", Message: item.name + "开始时间不能晚于结束时间"}
}
}
return nil
}
func parseAccessFilterTime(value string) (time.Time, bool) {
if strings.TrimSpace(value) == "" {
return time.Time{}, false
}
if parsed, ok := parseTrackRequestTime(value); ok {
return parsed, true
}
return parseAccessTime(value)
}
func accessTimeMatches(value, from, to string) bool {
if strings.TrimSpace(from) == "" && strings.TrimSpace(to) == "" {
return true
}
actual, ok := parseAccessTime(value)
if !ok {
return false
}
if start, ok := parseAccessFilterTime(from); ok && actual.Before(start) {
return false
}
if end, ok := parseAccessFilterTime(to); ok && actual.After(end) {
return false
}
return true
}
func validateAccessThresholdUpdate(update AccessThresholdUpdate) error {
if update.Version <= 0 {
return clientError{Code: "ACCESS_THRESHOLD_VERSION_REQUIRED", Message: "阈值版本不能为空"}
}
if update.DefaultThresholdSec < 30 || update.DefaultThresholdSec > 86400 {
return clientError{Code: "ACCESS_THRESHOLD_INVALID", Message: "全局在线阈值必须在 30 秒到 24 小时之间"}
}
if update.DelayThresholdSec < 1 || update.DelayThresholdSec > 3600 {
return clientError{Code: "ACCESS_DELAY_THRESHOLD_INVALID", Message: "延迟阈值必须在 1 秒到 1 小时之间"}
}
if update.LongOfflineSec < update.DefaultThresholdSec || update.LongOfflineSec > 604800 {
return clientError{Code: "ACCESS_LONG_OFFLINE_INVALID", Message: "长离线阈值必须不小于在线阈值且不超过 7 天"}
}
seen := map[string]struct{}{}
for _, item := range update.Protocols {
protocol := strings.ToUpper(strings.TrimSpace(item.Protocol))
if protocol == "" || item.ThresholdSec < 30 || item.ThresholdSec > 86400 {
return clientError{Code: "ACCESS_PROTOCOL_THRESHOLD_INVALID", Message: "协议阈值必须包含协议名且位于 30 秒到 24 小时之间"}
}
if _, exists := seen[protocol]; exists {
return clientError{Code: "ACCESS_PROTOCOL_THRESHOLD_DUPLICATED", Message: "协议阈值不能重复"}
}
seen[protocol] = struct{}{}
}
return nil
}
func normalizeAccessThresholdConfig(config AccessThresholdConfig, now time.Time) AccessThresholdConfig {
defaults := defaultAccessThresholds(now)
if config.Version <= 0 {
return defaults
}
if config.DefaultThresholdSec <= 0 {
config.DefaultThresholdSec = defaults.DefaultThresholdSec
}
if config.DelayThresholdSec <= 0 {
config.DelayThresholdSec = defaults.DelayThresholdSec
}
if config.LongOfflineSec <= 0 {
config.LongOfflineSec = defaults.LongOfflineSec
}
if config.Protocols == nil {
config.Protocols = []AccessProtocolThreshold{}
}
if config.Audit == nil {
config.Audit = []AccessThresholdAudit{}
}
return config
}
func parseAccessTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
for _, layout := range []string{"2006-01-02 15:04:05.000", "2006-01-02 15:04:05"} {
if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func normalizeAccessTime(value string) string {
parsed, ok := parseAccessTime(value)
if !ok {
return ""
}
return parsed.Format(time.RFC3339)
}
func accessMessageType(protocol string) string {
switch strings.ToUpper(strings.TrimSpace(protocol)) {
case "GB32960":
return "实时信息上报"
case "JT808":
return "位置信息汇报"
case "YUTONG_MQTT":
return "实时遥测"
default:
return ""
}
}
func accessStateRank(state string) int {
switch state {
case "online":
return 0
case "offline":
return 1
case "never_reported":
return 2
default:
return 3
}
}
func reportedOnDay(value string, day time.Time) bool {
parsed, ok := parseAccessTime(value)
if !ok {
return false
}
year, month, date := parsed.In(day.Location()).Date()
wantYear, wantMonth, wantDate := day.Date()
return year == wantYear && month == wantMonth && date == wantDate
}
func addAccessDistribution(items map[string]*AccessDistribution, name string, online bool) {
item := items[name]
if item == nil {
item = &AccessDistribution{Name: name}
items[name] = item
}
item.Total++
if online {
item.Online++
}
}
func sortedAccessDistributions(items map[string]*AccessDistribution) []AccessDistribution {
result := make([]AccessDistribution, 0, len(items))
for _, item := range items {
value := *item
if value.Total > 0 {
value.OnlineRate = float64(value.Online) / float64(value.Total) * 100
}
result = append(result, value)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Total != result[j].Total {
return result[i].Total > result[j].Total
}
return result[i].Name < result[j].Name
})
return result
}

View File

@@ -0,0 +1,255 @@
package platform
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
const accessEvidenceLimit = 50000
func buildAccessUnresolvedIdentityWhere(query AccessUnresolvedIdentityQuery) (string, []any) {
where := []string{
`COALESCE(NULLIF(TRIM(b.vin),''),'')=''`,
`(r.vin IS NULL OR TRIM(r.vin)='' OR LOWER(TRIM(r.vin))='unknown')`,
}
args := []any{}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
where = append(where, `(r.phone LIKE ? OR r.plate LIKE ? OR r.manufacturer LIKE ? OR r.source_endpoint LIKE ?)`)
args = append(args, like, like, like, like)
}
return strings.Join(where, " AND "), args
}
func (s *ProductionStore) AccessUnresolvedIdentities(ctx context.Context, query AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error) {
where, args := buildAccessUnresolvedIdentityWhere(query)
from := ` FROM jt808_registration r LEFT JOIN vehicle_identity_binding b ON b.phone=r.phone WHERE ` + where
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*)`+from, args...).Scan(&total); err != nil {
return Page[AccessUnresolvedIdentity]{}, err
}
listArgs := append(append([]any(nil), args...), query.Limit, query.Offset)
rows, err := s.db.QueryContext(ctx, `SELECT
SHA2(CONCAT('JT808:',r.phone),256),
'JT808',
CASE WHEN CHAR_LENGTH(r.phone)>=7 THEN CONCAT(LEFT(r.phone,3),'****',RIGHT(r.phone,4)) ELSE '***' END,
COALESCE(r.plate,''),COALESCE(r.manufacturer,''),COALESCE(r.source_endpoint,''),
COALESCE(DATE_FORMAT(r.first_registered_at,'%Y-%m-%d %H:%i:%s'),''),
COALESCE(DATE_FORMAT(r.latest_registered_at,'%Y-%m-%d %H:%i:%s'),''),
COALESCE(DATE_FORMAT(r.latest_authenticated_at,'%Y-%m-%d %H:%i:%s'),''),
COALESCE(DATE_FORMAT(r.latest_seen_at,'%Y-%m-%d %H:%i:%s'),''),
GREATEST(0,TIMESTAMPDIFF(SECOND,r.latest_seen_at,NOW())),
'missing_vin_jt808',
'核对终端手机号、车牌和厂家后维护 phone→VIN 权威绑定;禁止猜测 VIN'
`+from+` ORDER BY r.latest_seen_at DESC,r.phone ASC LIMIT ? OFFSET ?`, listArgs...)
if err != nil {
return Page[AccessUnresolvedIdentity]{}, err
}
defer rows.Close()
items := make([]AccessUnresolvedIdentity, 0, query.Limit)
for rows.Next() {
var item AccessUnresolvedIdentity
if err := rows.Scan(&item.ID, &item.Protocol, &item.IdentifierMasked, &item.Plate, &item.Manufacturer, &item.SourceEndpoint, &item.FirstRegisteredAt, &item.LatestRegisteredAt, &item.LatestAuthenticatedAt, &item.LatestSeenAt, &item.FreshnessSec, &item.IssueCode, &item.RecommendedAction); err != nil {
return Page[AccessUnresolvedIdentity]{}, err
}
items = append(items, item)
}
return Page[AccessUnresolvedIdentity]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
}
func (s *ProductionStore) AccessEvidence(ctx context.Context) ([]AccessEvidenceRow, error) {
rows, err := s.db.QueryContext(ctx, `SELECT
v.vin,
COALESCE(NULLIF(s.plate, ''), NULLIF(b.plate, ''), '') AS plate,
COALESCE(NULLIF(b.oem, ''), '') AS oem,
COALESCE(p.model_name, '') AS model_name,
COALESCE(p.company_name, '') AS company_name,
COALESCE(s.protocol, '') AS protocol,
COALESCE(s.platform_name, '') AS provider,
COALESCE(DATE_FORMAT(s.access_first_seen_at, '%Y-%m-%d %H:%i:%s.%f'), '') AS first_seen_at,
COALESCE(s.access_first_seen_source, '') AS first_seen_source,
COALESCE(DATE_FORMAT(s.event_time, '%Y-%m-%d %H:%i:%s.%f'), '') AS event_time,
COALESCE(DATE_FORMAT(s.access_latest_received_at, '%Y-%m-%d %H:%i:%s.%f'), '') AS received_at,
COALESCE(DATE_FORMAT(s.access_latest_received_at, '%Y-%m-%d %H:%i:%s.%f'), '') AS updated_at,
COALESCE(s.access_report_interval_ms, -1) AS report_interval_ms,
COALESCE(s.access_sample_count, 0) AS report_sample_count,
COALESCE(s.event_id, '') AS event_id
FROM (
SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''
UNION
SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> ''
) v
LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin
LEFT JOIN vehicle_profile p ON p.vin = v.vin
LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin
AND NOT EXISTS (
SELECT 1 FROM vehicle_realtime_snapshot newer
WHERE newer.vin = s.vin
AND (newer.access_latest_received_at > s.access_latest_received_at OR (newer.access_latest_received_at = s.access_latest_received_at AND newer.protocol < s.protocol))
)
ORDER BY s.access_latest_received_at DESC, v.vin ASC
LIMIT ?`, accessEvidenceLimit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]AccessEvidenceRow, 0, 1024)
for rows.Next() {
var row AccessEvidenceRow
var reportIntervalMS int64
if err := rows.Scan(&row.VIN, &row.Plate, &row.OEM, &row.Model, &row.Company, &row.Protocol, &row.Provider, &row.FirstSeenAt, &row.FirstSeenSource, &row.LatestEventAt, &row.LatestReceivedAt, &row.LatestUpdatedAt, &reportIntervalMS, &row.ReportSampleCount, &row.LatestEventID); err != nil {
return nil, err
}
if reportIntervalMS >= 0 {
seconds := int((reportIntervalMS + 500) / 1000)
row.ReportIntervalSec = &seconds
}
if row.Protocol == "" {
row.Source = "vehicle_identity_binding"
row.LatestError = "车辆已建档但从未形成实时快照"
} else {
row.Source = "vehicle_realtime_snapshot"
switch row.FirstSeenSource {
case "live_writer":
row.FirstSeenEvidence = "网关实时写入首次观测"
case "snapshot_backfill":
row.FirstSeenEvidence = "上线基线:由实时快照回填(非历史首次接入)"
}
if row.ReportSampleCount >= 2 && row.ReportIntervalSec != nil {
row.ReportIntervalProof = fmt.Sprintf("网关连续接收时间差(持久样本 %d 条)", row.ReportSampleCount)
} else if row.ReportSampleCount == 1 {
row.ReportIntervalProof = "仅有 1 个持久接收样本,等待下一次上报"
}
}
items = append(items, row)
if len(items) > accessEvidenceLimit {
return nil, fmt.Errorf("access evidence exceeds safety limit %d", accessEvidenceLimit)
}
}
return items, rows.Err()
}
func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error {
s.accessSchemaOnce.Do(func() {
statements := []string{
`CREATE TABLE IF NOT EXISTS vehicle_access_threshold_config (
id TINYINT NOT NULL PRIMARY KEY,
version INT NOT NULL,
default_threshold_sec INT NOT NULL,
delay_threshold_sec INT NOT NULL,
long_offline_sec INT NOT NULL,
protocol_overrides_json LONGTEXT NOT NULL,
updated_by VARCHAR(128) NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS vehicle_access_threshold_audit (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL,
actor VARCHAR(128) NOT NULL,
summary VARCHAR(255) NOT NULL,
config_json LONGTEXT NOT NULL,
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_access_threshold_audit_version (version),
KEY idx_access_threshold_audit_changed (changed_at)
)`,
}
for _, statement := range statements {
if _, err := s.db.ExecContext(ctx, statement); err != nil {
s.accessSchemaErr = err
return
}
}
defaults := defaultAccessThresholds(time.Now())
protocols, _ := json.Marshal(defaults.Protocols)
_, s.accessSchemaErr = s.db.ExecContext(ctx, `INSERT IGNORE INTO vehicle_access_threshold_config
(id, version, default_threshold_sec, delay_threshold_sec, long_offline_sec, protocol_overrides_json, updated_by)
VALUES (1, ?, ?, ?, ?, ?, ?)`, defaults.Version, defaults.DefaultThresholdSec, defaults.DelayThresholdSec, defaults.LongOfflineSec, string(protocols), defaults.UpdatedBy)
})
return s.accessSchemaErr
}
func (s *ProductionStore) AccessThresholds(ctx context.Context) (AccessThresholdConfig, error) {
if err := s.ensureAccessSchema(ctx); err != nil {
return AccessThresholdConfig{}, err
}
var config AccessThresholdConfig
var protocolsJSON string
err := s.db.QueryRowContext(ctx, `SELECT version, default_threshold_sec, delay_threshold_sec, long_offline_sec,
protocol_overrides_json, updated_by, DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s')
FROM vehicle_access_threshold_config WHERE id = 1`).Scan(
&config.Version, &config.DefaultThresholdSec, &config.DelayThresholdSec, &config.LongOfflineSec,
&protocolsJSON, &config.UpdatedBy, &config.UpdatedAt,
)
if err != nil {
return AccessThresholdConfig{}, err
}
if err := json.Unmarshal([]byte(protocolsJSON), &config.Protocols); err != nil {
return AccessThresholdConfig{}, fmt.Errorf("decode access protocol thresholds: %w", err)
}
config.UpdatedAt = normalizeAccessTime(config.UpdatedAt)
auditRows, err := s.db.QueryContext(ctx, `SELECT version, actor, DATE_FORMAT(changed_at, '%Y-%m-%d %H:%i:%s'), summary
FROM vehicle_access_threshold_audit ORDER BY changed_at DESC, id DESC LIMIT 10`)
if err != nil {
return AccessThresholdConfig{}, err
}
defer auditRows.Close()
for auditRows.Next() {
var item AccessThresholdAudit
if err := auditRows.Scan(&item.Version, &item.Actor, &item.ChangedAt, &item.Summary); err != nil {
return AccessThresholdConfig{}, err
}
item.ChangedAt = normalizeAccessTime(item.ChangedAt)
config.Audit = append(config.Audit, item)
}
return config, auditRows.Err()
}
func (s *ProductionStore) SaveAccessThresholds(ctx context.Context, update AccessThresholdUpdate) (AccessThresholdConfig, error) {
if err := s.ensureAccessSchema(ctx); err != nil {
return AccessThresholdConfig{}, err
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return AccessThresholdConfig{}, err
}
defer tx.Rollback()
var currentVersion int
if err := tx.QueryRowContext(ctx, `SELECT version FROM vehicle_access_threshold_config WHERE id = 1 FOR UPDATE`).Scan(&currentVersion); err != nil {
return AccessThresholdConfig{}, err
}
if currentVersion != update.Version {
return AccessThresholdConfig{}, clientError{Code: "ACCESS_THRESHOLD_VERSION_CONFLICT", Message: "阈值配置已被其他用户更新,请刷新后重试"}
}
protocols, err := json.Marshal(update.Protocols)
if err != nil {
return AccessThresholdConfig{}, err
}
nextVersion := currentVersion + 1
result, err := tx.ExecContext(ctx, `UPDATE vehicle_access_threshold_config SET
version = ?, default_threshold_sec = ?, delay_threshold_sec = ?, long_offline_sec = ?,
protocol_overrides_json = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1 AND version = ?`, nextVersion, update.DefaultThresholdSec, update.DelayThresholdSec, update.LongOfflineSec, string(protocols), update.Actor, currentVersion)
if err != nil {
return AccessThresholdConfig{}, err
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
return AccessThresholdConfig{}, clientError{Code: "ACCESS_THRESHOLD_VERSION_CONFLICT", Message: "阈值配置更新冲突,请刷新后重试"}
}
snapshot, _ := json.Marshal(map[string]any{
"version": nextVersion, "defaultThresholdSec": update.DefaultThresholdSec,
"delayThresholdSec": update.DelayThresholdSec, "longOfflineSec": update.LongOfflineSec,
"protocols": update.Protocols,
})
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_access_threshold_audit
(version, actor, summary, config_json) VALUES (?, ?, ?, ?)`, nextVersion, update.Actor, "更新在线、延迟和长离线阈值", string(snapshot)); err != nil {
return AccessThresholdConfig{}, err
}
if err := tx.Commit(); err != nil {
return AccessThresholdConfig{}, err
}
return s.AccessThresholds(ctx)
}

View File

@@ -0,0 +1,164 @@
package platform
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestAccessSummaryUsesDynamicFreshnessAndDelay(t *testing.T) {
service := NewService(NewMockStore())
summary, err := service.AccessSummary(context.Background(), AccessQuery{})
if err != nil {
t.Fatalf("AccessSummary returned error: %v", err)
}
if summary.TotalVehicles != 6 || summary.OnlineVehicles != 2 || summary.OfflineVehicles != 2 {
t.Fatalf("unexpected access states: %+v", summary)
}
if summary.NeverReported != 1 || summary.UnknownVehicles != 1 || summary.LongOfflineVehicles != 2 {
t.Fatalf("summary must distinguish never/offline/unknown: %+v", summary)
}
if summary.DelayAbnormal != 1 || summary.ThresholdVersion != 1 {
t.Fatalf("summary must expose dynamic delay and threshold version: %+v", summary)
}
}
func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessVehicles(context.Background(), AccessQuery{OnlineState: "never_reported", Limit: 10})
if err != nil {
t.Fatalf("AccessVehicles returned error: %v", err)
}
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].OnlineState != "never_reported" {
t.Fatalf("unexpected never-reported page: %+v", page)
}
if page.Items[0].FirstSeenAt != "" || page.Items[0].FirstSeenEvidence == "" || page.Items[0].ReportIntervalProof == "" {
t.Fatalf("missing source evidence must stay explicit: %+v", page.Items[0])
}
delayed, err := service.AccessVehicles(context.Background(), AccessQuery{DelayState: "abnormal", Limit: 10})
if err != nil || delayed.Total != 1 || delayed.Items[0].DataDelaySec == nil || *delayed.Items[0].DataDelaySec != 45 {
t.Fatalf("delay filter should use event/receive difference: page=%+v err=%v", delayed, err)
}
}
func TestAccessVehiclesSupportsModelProviderAndTimeFilters(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessVehicles(t.Context(), AccessQuery{Model: "氢燃料", Provider: "车厂", LatestSeenFrom: time.Now().Add(-time.Minute).Format("2006-01-02T15:04"), Limit: 10})
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].VIN != "LNXNEGRR7SR318212" {
t.Fatalf("advanced access filters failed: page=%+v err=%v", page, err)
}
_, err = service.AccessVehicles(t.Context(), AccessQuery{FirstSeenFrom: "not-a-time"})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ACCESS_TIME_INVALID" {
t.Fatalf("invalid access time must be rejected, got %v", err)
}
_, err = service.AccessSummary(t.Context(), AccessQuery{LatestSeenFrom: "2026-07-14T09:00", LatestSeenTo: "2026-07-14T08:00"})
clientErr, ok = asClientError(err)
if !ok || clientErr.Code != "ACCESS_TIME_RANGE_INVALID" {
t.Fatalf("reversed access time range must be rejected, got %v", err)
}
}
func TestAccessThresholdUpdateUsesOptimisticVersion(t *testing.T) {
store := NewMockStore()
service := NewService(store)
updated, err := service.UpdateAccessThresholds(context.Background(), AccessThresholdUpdate{
Version: 1, DefaultThresholdSec: 600, DelayThresholdSec: 60, LongOfflineSec: 3600,
Protocols: []AccessProtocolThreshold{{Protocol: "JT808", ThresholdSec: 300}}, Actor: "tester",
})
if err != nil {
t.Fatalf("UpdateAccessThresholds returned error: %v", err)
}
if updated.Version != 2 || updated.UpdatedBy != "tester" || len(updated.Audit) != 1 {
t.Fatalf("threshold update should version and audit: %+v", updated)
}
_, err = service.UpdateAccessThresholds(context.Background(), AccessThresholdUpdate{
Version: 1, DefaultThresholdSec: 600, DelayThresholdSec: 60, LongOfflineSec: 3600,
})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ACCESS_THRESHOLD_VERSION_CONFLICT" {
t.Fatalf("stale update should conflict, got %v", err)
}
}
func TestAccessVehicleCarriesDurableProjectionEvidenceAndMasterData(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessVehicles(context.Background(), AccessQuery{Keyword: "LB9A32A24R0LS1426", Limit: 10})
if err != nil || len(page.Items) != 1 {
t.Fatalf("access vehicle query failed: page=%+v err=%v", page, err)
}
row := page.Items[0]
if row.Company != "岭牛示范车队" || row.FirstSeenSource != "live_writer" || row.ReportSampleCount != 120 || row.ReportIntervalSec == nil {
t.Fatalf("durable access evidence/master data missing: %+v", row)
}
}
func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
mock.ExpectQuery("(?s)SELECT.*access_first_seen_at.*access_first_seen_source.*access_report_interval_ms.*vehicle_profile.*access_latest_received_at").
WithArgs(accessEvidenceLimit + 1).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "plate", "oem", "model_name", "company_name", "protocol", "provider", "first_seen_at", "first_seen_source",
"event_time", "received_at", "updated_at", "report_interval_ms", "report_sample_count", "event_id",
}).AddRow("VIN001", "粤A1", "示范厂家", "纯电客车", "示范公交", "GB32960", "车厂平台", "2026-07-14 05:00:00.000", "snapshot_backfill", "2026-07-14 05:01:00.000", "2026-07-14 05:01:30.500", "2026-07-14 05:01:30.500", int64(30500), int64(3), "evt-3"))
rows, err := store.AccessEvidence(context.Background())
if err != nil || len(rows) != 1 {
t.Fatalf("AccessEvidence rows=%+v err=%v", rows, err)
}
row := rows[0]
if row.ReportIntervalSec == nil || *row.ReportIntervalSec != 31 || row.ReportSampleCount != 3 || row.Model != "纯电客车" || row.Company != "示范公交" {
t.Fatalf("unexpected durable projection row: %+v", row)
}
if row.FirstSeenEvidence != "上线基线:由实时快照回填(非历史首次接入)" || row.ReportIntervalProof == "" {
t.Fatalf("projection evidence boundary missing: %+v", row)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAccessUnresolvedIdentitiesRemainMaskedAndProtocolScoped(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Protocol: "jt808", Limit: 20})
if err != nil || page.Total != 1 || len(page.Items) != 1 {
t.Fatalf("unresolved identity queue failed: page=%+v err=%v", page, err)
}
if page.Items[0].IdentifierMasked != "138****0001" || page.Items[0].IssueCode != "missing_vin_jt808" || page.Items[0].RecommendedAction == "" {
t.Fatalf("unresolved evidence is not actionable and masked: %+v", page.Items[0])
}
empty, err := service.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Protocol: "GB32960"})
if err != nil || empty.Total != 0 || len(empty.Items) != 0 {
t.Fatalf("non-JT808 unresolved query must be empty: page=%+v err=%v", empty, err)
}
}
func TestProductionUnresolvedIdentityQueueExcludesBoundPhonesAndNeverReturnsRawIdentifier(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\).*jt808_registration.*COALESCE.*b\.vin`).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(`(?s)SELECT.*SHA2\(CONCAT\('JT808:',r\.phone\),256\).*CHAR_LENGTH\(r\.phone\).*missing_vin_jt808.*ORDER BY r\.latest_seen_at`).WithArgs(20, 0).WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "identifier_masked", "plate", "manufacturer", "source_endpoint", "first_registered_at", "latest_registered_at", "latest_authenticated_at", "latest_seen_at", "freshness_sec", "issue_code", "recommended_action",
}).AddRow("hash-id", "JT808", "410****3543", "", "示范终端", "gateway-a", "", "", "", "2026-07-14 07:52:09", 20, "missing_vin_jt808", "核对后绑定"))
page, err := store.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Limit: 20})
if err != nil || page.Total != 1 || len(page.Items) != 1 {
t.Fatalf("production unresolved query failed: page=%+v err=%v", page, err)
}
if page.Items[0].ID != "hash-id" || page.Items[0].IdentifierMasked != "410****3543" || page.Items[0].FreshnessSec != 20 {
t.Fatalf("production unresolved evidence lost: %+v", page.Items[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,314 @@
package platform
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
)
type alertStore interface {
AlertSummary(context.Context, AlertQuery) (AlertSummary, error)
AlertEvents(context.Context, AlertQuery) (Page[AlertEvent], error)
AlertEvent(context.Context, string) (AlertEvent, error)
AlertRules(context.Context) ([]AlertRule, error)
SaveAlertRule(context.Context, AlertRuleInput) (AlertRule, error)
SetAlertRuleEnabled(context.Context, string, AlertRuleEnabledUpdate) (AlertRule, error)
ActOnAlert(context.Context, string, AlertActionRequest) (AlertEvent, error)
AlertNotifications(context.Context, AlertNotificationQuery) (Page[AlertNotification], error)
MarkAlertNotificationsRead(context.Context, AlertNotificationReadRequest) (int, error)
}
type alertEvaluatorStore interface {
EvaluateAlerts(context.Context) (AlertEvaluationResult, error)
}
func (s *Service) alertStore() (alertStore, error) {
store, ok := s.store.(alertStore)
if !ok {
return nil, fmt.Errorf("store does not provide durable alert center")
}
return store, nil
}
func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSummary, error) {
store, err := s.alertStore()
if err != nil {
return AlertSummary{}, err
}
return store.AlertSummary(ctx, normalizeAlertQuery(query))
}
func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[AlertEvent], error) {
store, err := s.alertStore()
if err != nil {
return Page[AlertEvent]{}, err
}
return store.AlertEvents(ctx, normalizeAlertQuery(query))
}
func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error) {
if strings.TrimSpace(id) == "" {
return AlertEvent{}, clientError{Code: "ALERT_EVENT_ID_REQUIRED", Message: "告警事件 ID 不能为空"}
}
store, err := s.alertStore()
if err != nil {
return AlertEvent{}, err
}
return store.AlertEvent(ctx, strings.TrimSpace(id))
}
func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) {
store, err := s.alertStore()
if err != nil {
return nil, err
}
return store.AlertRules(ctx)
}
func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) {
definitions, err := s.metricDefinitions(ctx)
if err != nil {
return AlertRule{}, err
}
if err := validateAlertRule(input, definitions...); err != nil {
return AlertRule{}, err
}
input.ID = strings.TrimSpace(input.ID)
if input.ID == "" {
id, err := newAlertID("rule")
if err != nil {
return AlertRule{}, err
}
input.ID = id
}
input.Actor = firstNonEmpty(strings.TrimSpace(input.Actor), "platform-admin")
input.ScopeProtocols = cleanUniqueStrings(input.ScopeProtocols)
input.ScopeVINs = cleanUniqueStrings(input.ScopeVINs)
input.ScopeOEMs = cleanUniqueStrings(input.ScopeOEMs)
input.ScopeModels = cleanUniqueStrings(input.ScopeModels)
input.ScopeCompanies = cleanUniqueStrings(input.ScopeCompanies)
if err := validateAlertRuleScopes(input); err != nil {
return AlertRule{}, err
}
input.NotificationChannels = normalizeAlertChannels(input.NotificationChannels)
if strings.EqualFold(input.Operator, "changed") {
input.DurationSec = 0
}
if strings.EqualFold(input.ValueType, "boolean") && input.BooleanThreshold != nil {
if *input.BooleanThreshold {
input.Threshold = 1
} else {
input.Threshold = 0
}
}
store, err := s.alertStore()
if err != nil {
return AlertRule{}, err
}
return store.SaveAlertRule(ctx, input)
}
func validateAlertRuleScopes(input AlertRuleInput) error {
for _, scope := range []struct {
name string
values []string
}{
{"协议", input.ScopeProtocols}, {"VIN", input.ScopeVINs}, {"厂家", input.ScopeOEMs},
{"车型", input.ScopeModels}, {"企业", input.ScopeCompanies},
} {
if len(scope.values) > 500 {
return clientError{Code: "ALERT_RULE_SCOPE_TOO_LARGE", Message: scope.name + "范围最多允许 500 项"}
}
for _, value := range scope.values {
if len([]rune(value)) > 128 {
return clientError{Code: "ALERT_RULE_SCOPE_VALUE_INVALID", Message: scope.name + "范围值不能超过 128 个字符"}
}
}
}
return nil
}
func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
if strings.TrimSpace(id) == "" || update.Version <= 0 {
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则 ID 和版本不能为空"}
}
update.Actor = firstNonEmpty(strings.TrimSpace(update.Actor), "platform-admin")
store, err := s.alertStore()
if err != nil {
return AlertRule{}, err
}
return store.SetAlertRuleEnabled(ctx, strings.TrimSpace(id), update)
}
func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
if strings.TrimSpace(id) == "" || request.Version <= 0 {
return AlertEvent{}, clientError{Code: "ALERT_EVENT_VERSION_REQUIRED", Message: "事件 ID 和版本不能为空"}
}
request.Action = strings.ToLower(strings.TrimSpace(request.Action))
if request.Action != "acknowledge" && request.Action != "close" && request.Action != "ignore" {
return AlertEvent{}, clientError{Code: "ALERT_ACTION_INVALID", Message: "仅支持确认、关闭或忽略告警"}
}
if len([]rune(request.Note)) > 200 {
return AlertEvent{}, clientError{Code: "ALERT_NOTE_TOO_LONG", Message: "处置备注不能超过 200 字"}
}
request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
store, err := s.alertStore()
if err != nil {
return AlertEvent{}, err
}
return store.ActOnAlert(ctx, strings.TrimSpace(id), request)
}
func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificationQuery) (Page[AlertNotification], error) {
if query.Limit <= 0 {
query.Limit = 20
}
if query.Limit > 100 {
query.Limit = 100
}
if query.Offset < 0 {
query.Offset = 0
}
store, err := s.alertStore()
if err != nil {
return Page[AlertNotification]{}, err
}
return store.AlertNotifications(ctx, query)
}
func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertNotificationReadRequest) (int, error) {
if len(request.IDs) == 0 || len(request.IDs) > 100 {
return 0, clientError{Code: "ALERT_NOTIFICATION_IDS_INVALID", Message: "请选择 1 到 100 条通知"}
}
request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
store, err := s.alertStore()
if err != nil {
return 0, err
}
return store.MarkAlertNotificationsRead(ctx, request)
}
func (s *Service) EvaluateAlerts(ctx context.Context) (AlertEvaluationResult, error) {
store, ok := s.store.(alertEvaluatorStore)
if !ok {
return AlertEvaluationResult{}, fmt.Errorf("store does not provide alert evaluation")
}
return store.EvaluateAlerts(ctx)
}
func normalizeAlertQuery(query AlertQuery) AlertQuery {
query.Keyword = strings.TrimSpace(query.Keyword)
query.Severity = strings.ToLower(strings.TrimSpace(query.Severity))
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.RuleID = strings.TrimSpace(query.RuleID)
query.Protocol = strings.TrimSpace(query.Protocol)
if query.Limit <= 0 {
query.Limit = 20
}
if query.Limit > 200 {
query.Limit = 200
}
if query.Offset < 0 {
query.Offset = 0
}
return query
}
func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error {
if strings.TrimSpace(input.Name) == "" || len([]rune(input.Name)) > 80 {
return clientError{Code: "ALERT_RULE_NAME_INVALID", Message: "规则名称不能为空且不能超过 80 字"}
}
severity := strings.ToLower(strings.TrimSpace(input.Severity))
if severity != "critical" && severity != "major" && severity != "minor" {
return clientError{Code: "ALERT_RULE_SEVERITY_INVALID", Message: "告警级别仅支持 critical、major、minor"}
}
valueType := strings.ToLower(strings.TrimSpace(input.ValueType))
if valueType != "numeric" && valueType != "boolean" {
return clientError{Code: "ALERT_RULE_VALUE_TYPE_INVALID", Message: "规则值类型仅支持 numeric 或 boolean"}
}
if strings.TrimSpace(input.Metric) == "" {
return clientError{Code: "ALERT_RULE_METRIC_REQUIRED", Message: "规则指标不能为空"}
}
var metric *MetricDefinition
definitions := catalog
if len(definitions) == 0 {
definitions = metricDefinitions()
}
for index := range definitions {
candidate := definitions[index]
if candidate.Key == strings.TrimSpace(input.Metric) {
metric = &candidate
break
}
}
if metric == nil || !metric.Alertable {
return clientError{Code: "ALERT_RULE_METRIC_INVALID", Message: "规则指标不在可告警指标目录中"}
}
if _, supported := alertMetricValue(input.Metric, alertEvaluationEvidence{}); !supported {
return clientError{Code: "ALERT_RULE_METRIC_UNSUPPORTED", Message: "规则指标尚未接入告警评估器"}
}
if metric.ValueType != valueType {
return clientError{Code: "ALERT_RULE_METRIC_TYPE_MISMATCH", Message: "规则值类型与指标目录不一致"}
}
operator := strings.ToLower(strings.TrimSpace(input.Operator))
allowed := map[string]bool{"gt": true, "gte": true, "lt": true, "lte": true, "eq": true, "neq": true, "between": true, "outside": true, "changed": true}
if !allowed[operator] {
return clientError{Code: "ALERT_RULE_OPERATOR_INVALID", Message: "规则比较符无效"}
}
if (operator == "between" || operator == "outside") && (valueType != "numeric" || input.ThresholdHigh <= input.Threshold) {
return clientError{Code: "ALERT_RULE_RANGE_INVALID", Message: "区间规则必须是数值型且上限大于下限"}
}
if operator == "changed" && valueType != "boolean" {
return clientError{Code: "ALERT_RULE_CHANGE_INVALID", Message: "状态变化规则必须使用布尔值类型"}
}
if valueType == "boolean" && operator != "changed" && input.BooleanThreshold == nil {
return clientError{Code: "ALERT_RULE_BOOLEAN_REQUIRED", Message: "布尔规则必须配置目标值"}
}
if input.DurationSec < 0 || input.DurationSec > 86400 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "持续时间或重复间隔超出允许范围"}
}
if input.Version < 0 {
return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"}
}
return nil
}
func normalizeAlertChannels(values []string) []string {
allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true}
out := make([]string, 0, len(values)+1)
seen := map[string]bool{}
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if allowed[value] && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
if !seen["in_app"] {
out = append([]string{"in_app"}, out...)
}
return out
}
func cleanUniqueStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
return out
}
func newAlertID(prefix string) (string, error) {
buf := make([]byte, 10)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return prefix + "-" + hex.EncodeToString(buf), nil
}

View File

@@ -0,0 +1,553 @@
package platform
import (
"context"
"database/sql"
"fmt"
"math"
"strings"
"time"
)
const alertEvaluationVehicleLimit = 50000
const alertCandidateBatchSize = 500
const alertCandidateContinuityWindow = time.Minute
const alertDynamicEvidenceMaxAge = 5 * time.Minute
type alertEvaluationEvidence struct {
VIN, Plate, Protocol, OEM, Model, Company, SourceEventID, EventAt, ReceivedAt, Location string
SpeedKmh, SOCPercent, Longitude, Latitude float64
AlarmFlag int64
FreshnessSec, DataDelaySec int
}
type alertCandidateState struct {
FirstMatchedAt, LastMatchedAt time.Time
SourceEventID string
}
type alertActiveEvent struct{ ID, Status string }
type alertCandidateUpsert struct {
RuleID, VIN, Protocol, SourceEventID string
FirstMatchedAt, LastMatchedAt time.Time
LatestValue float64
}
type alertRuleState struct {
LastValue float64
LastObservedAt time.Time
}
type alertRuleStateUpsert struct {
RuleID, VIN, Protocol string
LastValue float64
ObservedAt time.Time
}
type alertObservationDecision uint8
const (
alertObservationAdvanced alertObservationDecision = iota
alertObservationDuplicate
alertObservationLate
)
const alertEventInsertSQL = `INSERT INTO vehicle_alert_event(id,fingerprint,rule_id,rule_name,rule_version,severity,status,vin,plate,protocol,metric,operator,trigger_value,threshold_value,threshold_high,unit,duration_sec,location_text,longitude,latitude,source_event_id,event_at,received_at,triggered_at) VALUES(?,?,?,?,?,?,'unprocessed',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))`
func buildAlertEventInsert(id, fingerprint string, rule AlertRule, item alertEvaluationEvidence, value float64) (string, []any) {
return alertEventInsertSQL, []any{
id, fingerprint, rule.ID, rule.Name, rule.Version, rule.Severity,
item.VIN, item.Plate, item.Protocol, rule.Metric, rule.Operator, value,
rule.Threshold, rule.ThresholdHigh, alertMetricUnit(rule.Metric), rule.DurationSec,
item.Location, item.Longitude, item.Latitude, item.SourceEventID,
nullableAlertTime(item.EventAt), nullableAlertTime(item.ReceivedAt),
}
}
func (s *ProductionStore) EvaluateAlerts(ctx context.Context) (AlertEvaluationResult, error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return AlertEvaluationResult{}, err
}
rules, err := s.AlertRules(ctx)
if err != nil {
return AlertEvaluationResult{}, err
}
rules = snapshotAlertRules(rules, s.alertStreamMode)
if len(rules) == 0 {
return AlertEvaluationResult{AsOf: time.Now().Format(time.RFC3339)}, nil
}
evidence, err := s.alertEvaluationEvidence(ctx)
if err != nil {
return AlertEvaluationResult{}, err
}
result := AlertEvaluationResult{VehiclesScanned: len(evidence), AsOf: time.Now().Format(time.RFC3339)}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return result, err
}
defer tx.Rollback()
candidates, err := loadAlertCandidates(ctx, tx)
if err != nil {
return result, err
}
activeEvents, err := loadActiveAlertEvents(ctx, tx)
if err != nil {
return result, err
}
ruleStates, err := loadAlertRuleStates(ctx, tx)
if err != nil {
return result, err
}
lastTriggered, err := loadAlertLastTriggered(ctx, tx)
if err != nil {
return result, err
}
now := time.Now()
for _, rule := range rules {
if !rule.Enabled {
continue
}
enabled, lockErr := lockAlertRuleForEvaluation(ctx, tx, rule.ID)
if lockErr != nil {
return result, lockErr
}
if !enabled {
continue
}
result.RulesEvaluated++
candidateUpserts := make([]alertCandidateUpsert, 0, len(evidence))
stateUpserts := make([]alertRuleStateUpsert, 0, len(evidence))
for _, item := range evidence {
if !alertRuleInScope(rule, item) {
continue
}
value, supported := alertMetricValue(rule.Metric, item)
if !supported {
continue
}
fingerprint := rule.ID + "|" + item.VIN + "|" + item.Protocol
if rule.Metric != "freshness_sec" && time.Duration(item.FreshnessSec)*time.Second > alertDynamicEvidenceMaxAge {
result.StaleEvidenceSkipped++
if _, exists := candidates[fingerprint]; exists {
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_candidate WHERE rule_id=? AND vin=? AND protocol=?`, rule.ID, item.VIN, item.Protocol); err != nil {
return result, err
}
delete(candidates, fingerprint)
}
continue
}
observedAt, clockDriven, observationOK := alertObservationTime(rule.Metric, item, now)
if !observationOK {
result.LateObservations++
continue
}
matched := false
if strings.EqualFold(rule.Operator, "changed") {
normalized := 0.0
if value != 0 {
normalized = 1
}
previous, exists := ruleStates[fingerprint]
if exists && !observedAt.After(previous.LastObservedAt) {
result.LateObservations++
continue
}
matched = exists && previous.LastValue != normalized
stateUpserts = append(stateUpserts, alertRuleStateUpsert{RuleID: rule.ID, VIN: item.VIN, Protocol: item.Protocol, LastValue: normalized, ObservedAt: observedAt})
ruleStates[fingerprint] = alertRuleState{LastValue: normalized, LastObservedAt: observedAt}
} else {
matched = compareAlertRuleValue(value, rule)
}
if matched {
if len(activeEvents[fingerprint]) > 0 {
continue
}
candidate, exists := candidates[fingerprint]
candidate, decision := advanceAlertCandidate(candidate, exists, observedAt, item.SourceEventID, clockDriven)
switch decision {
case alertObservationDuplicate:
result.DuplicateObservations++
continue
case alertObservationLate:
result.LateObservations++
continue
}
candidates[fingerprint] = candidate
result.CandidatesAdvanced++
duration := int(candidate.LastMatchedAt.Sub(candidate.FirstMatchedAt).Seconds())
if duration < rule.DurationSec {
candidateUpserts = append(candidateUpserts, alertCandidateUpsert{RuleID: rule.ID, VIN: item.VIN, Protocol: item.Protocol, SourceEventID: candidate.SourceEventID, FirstMatchedAt: candidate.FirstMatchedAt, LastMatchedAt: candidate.LastMatchedAt, LatestValue: value})
continue
}
if !alertRepeatAllowed(now, lastTriggered[fingerprint], rule.RepeatIntervalSec) {
candidateUpserts = append(candidateUpserts, alertCandidateUpsert{RuleID: rule.ID, VIN: item.VIN, Protocol: item.Protocol, SourceEventID: candidate.SourceEventID, FirstMatchedAt: candidate.FirstMatchedAt, LastMatchedAt: candidate.LastMatchedAt, LatestValue: value})
continue
}
id, e := newAlertID("alert")
if e != nil {
return result, e
}
unit := alertMetricUnit(rule.Metric)
insertSQL, insertArgs := buildAlertEventInsert(id, fingerprint, rule, item, value)
_, err = tx.ExecContext(ctx, insertSQL, insertArgs...)
if err != nil {
return result, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'trigger','','unprocessed','alert-evaluator','规则命中并达到持续时间')`, id); err != nil {
return result, err
}
for _, channel := range rule.NotificationChannels {
delivery := "reserved"
if channel == "in_app" {
delivery = "created"
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,delivery_status) VALUES(?,?,?,?,?,?)`, id, rule.Name, fmt.Sprintf("%s / %s 触发%s%.2f %s", item.Plate, item.VIN, rule.Name, value, unit), rule.Severity, channel, delivery); err != nil {
return result, err
}
}
activeEvents[fingerprint] = []alertActiveEvent{{ID: id, Status: "unprocessed"}}
lastTriggered[fingerprint] = now
if exists {
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_candidate WHERE rule_id=? AND vin=? AND protocol=?`, rule.ID, item.VIN, item.Protocol); err != nil {
return result, err
}
delete(candidates, fingerprint)
}
result.Opened++
} else {
if _, exists := candidates[fingerprint]; exists {
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_candidate WHERE rule_id=? AND vin=? AND protocol=?`, rule.ID, item.VIN, item.Protocol); err != nil {
return result, err
}
delete(candidates, fingerprint)
}
recovered := true
if strings.TrimSpace(rule.RecoveryOperator) != "" {
recovered = compareAlertValue(value, rule.RecoveryOperator, rule.RecoveryThreshold)
}
if !recovered {
continue
}
for _, event := range activeEvents[fingerprint] {
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET status='recovered',recovered_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=? AND status=?`, event.ID, event.Status); err != nil {
return result, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'recover',?,'recovered','alert-evaluator','恢复条件满足')`, event.ID, event.Status); err != nil {
return result, err
}
result.Recovered++
}
delete(activeEvents, fingerprint)
}
}
for start := 0; start < len(candidateUpserts); start += alertCandidateBatchSize {
end := min(start+alertCandidateBatchSize, len(candidateUpserts))
query, args := buildAlertCandidateUpsert(candidateUpserts[start:end])
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
return result, err
}
}
for start := 0; start < len(stateUpserts); start += alertCandidateBatchSize {
end := min(start+alertCandidateBatchSize, len(stateUpserts))
query, args := buildAlertRuleStateUpsert(stateUpserts[start:end])
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
return result, err
}
}
}
if err = tx.Commit(); err != nil {
return result, err
}
return result, nil
}
func snapshotAlertRules(rules []AlertRule, streamMode string) []AlertRule {
if !strings.EqualFold(strings.TrimSpace(streamMode), "active") {
return rules
}
filtered := make([]AlertRule, 0, len(rules))
for _, rule := range rules {
if strings.EqualFold(strings.TrimSpace(rule.Metric), "freshness_sec") {
filtered = append(filtered, rule)
}
}
return filtered
}
func lockAlertRuleForEvaluation(ctx context.Context, tx *sql.Tx, ruleID string) (bool, error) {
var enabled bool
err := tx.QueryRowContext(ctx, `SELECT enabled FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, ruleID).Scan(&enabled)
if err == sql.ErrNoRows {
return false, nil
}
return enabled, err
}
func loadAlertCandidates(ctx context.Context, tx *sql.Tx) (map[string]alertCandidateState, error) {
rows, err := tx.QueryContext(ctx, `SELECT c.rule_id,c.vin,c.protocol,c.first_matched_at,c.last_matched_at,c.source_event_id FROM vehicle_alert_candidate c JOIN vehicle_alert_rule r ON r.id=c.rule_id AND r.enabled=1`)
if err != nil {
return nil, err
}
defer rows.Close()
items := map[string]alertCandidateState{}
for rows.Next() {
var ruleID, vin, protocol string
var state alertCandidateState
if err := rows.Scan(&ruleID, &vin, &protocol, &state.FirstMatchedAt, &state.LastMatchedAt, &state.SourceEventID); err != nil {
return nil, err
}
items[ruleID+"|"+vin+"|"+protocol] = state
}
return items, rows.Err()
}
func buildAlertCandidateUpsert(items []alertCandidateUpsert) (string, []any) {
values := make([]string, 0, len(items))
args := make([]any, 0, len(items)*7)
for _, item := range items {
values = append(values, "(?,?,?,?,?,?,?)")
args = append(args, item.RuleID, item.VIN, item.Protocol, item.FirstMatchedAt, item.LastMatchedAt, item.LatestValue, item.SourceEventID)
}
return `INSERT INTO vehicle_alert_candidate(rule_id,vin,protocol,first_matched_at,last_matched_at,latest_value,source_event_id) VALUES ` + strings.Join(values, ",") + ` ON DUPLICATE KEY UPDATE first_matched_at=VALUES(first_matched_at),last_matched_at=VALUES(last_matched_at),latest_value=VALUES(latest_value),source_event_id=VALUES(source_event_id)`, args
}
func loadAlertRuleStates(ctx context.Context, tx *sql.Tx) (map[string]alertRuleState, error) {
rows, err := tx.QueryContext(ctx, `SELECT s.rule_id,s.vin,s.protocol,s.observed_value,s.last_observed_at FROM vehicle_alert_rule_state s JOIN vehicle_alert_rule r ON r.id=s.rule_id AND r.enabled=1`)
if err != nil {
return nil, err
}
defer rows.Close()
items := map[string]alertRuleState{}
for rows.Next() {
var ruleID, vin, protocol string
var state alertRuleState
if err := rows.Scan(&ruleID, &vin, &protocol, &state.LastValue, &state.LastObservedAt); err != nil {
return nil, err
}
items[ruleID+"|"+vin+"|"+protocol] = state
}
return items, rows.Err()
}
func loadAlertLastTriggered(ctx context.Context, tx *sql.Tx) (map[string]time.Time, error) {
rows, err := tx.QueryContext(ctx, `SELECT e.fingerprint,MAX(e.triggered_at) FROM vehicle_alert_event e JOIN vehicle_alert_rule r ON r.id=e.rule_id AND r.enabled=1 GROUP BY e.fingerprint`)
if err != nil {
return nil, err
}
defer rows.Close()
items := map[string]time.Time{}
for rows.Next() {
var fingerprint string
var triggeredAt time.Time
if err := rows.Scan(&fingerprint, &triggeredAt); err != nil {
return nil, err
}
items[fingerprint] = triggeredAt
}
return items, rows.Err()
}
func alertRepeatAllowed(now, last time.Time, intervalSec int) bool {
return intervalSec <= 0 || last.IsZero() || now.Sub(last) >= time.Duration(intervalSec)*time.Second
}
func alertObservationTime(metric string, item alertEvaluationEvidence, now time.Time) (time.Time, bool, bool) {
if strings.EqualFold(strings.TrimSpace(metric), "freshness_sec") {
return now, true, true
}
for _, value := range []string{item.EventAt, item.ReceivedAt} {
if parsed, ok := parseAlertEvidenceTime(value); ok {
return parsed, false, true
}
}
return time.Time{}, false, false
}
func parseAlertEvidenceTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
for _, layout := range []string{
time.RFC3339Nano,
"2006-01-02 15:04:05.999999",
"2006-01-02 15:04:05",
} {
parsed, err := time.ParseInLocation(layout, value, time.Local)
if err == nil {
return parsed, true
}
}
return time.Time{}, false
}
// advanceAlertCandidate makes event-time, not evaluator wall time, the proof of
// duration. Re-reading the same snapshot cannot advance a telemetry candidate.
// freshness_sec is deliberately clock-driven because staleness itself changes
// while the latest source event remains the same.
func advanceAlertCandidate(current alertCandidateState, exists bool, observedAt time.Time, sourceEventID string, clockDriven bool) (alertCandidateState, alertObservationDecision) {
sourceEventID = strings.TrimSpace(sourceEventID)
if !exists {
return alertCandidateState{FirstMatchedAt: observedAt, LastMatchedAt: observedAt, SourceEventID: sourceEventID}, alertObservationAdvanced
}
if !clockDriven && sourceEventID != "" && sourceEventID == current.SourceEventID {
return current, alertObservationDuplicate
}
if observedAt.Before(current.LastMatchedAt) {
return current, alertObservationLate
}
if observedAt.Equal(current.LastMatchedAt) {
if clockDriven || sourceEventID == "" || sourceEventID == current.SourceEventID {
return current, alertObservationDuplicate
}
// A distinct event can share millisecond precision. Remember its ID so
// subsequent evaluator passes are idempotent, without inventing duration.
current.SourceEventID = sourceEventID
return current, alertObservationAdvanced
}
if observedAt.Sub(current.LastMatchedAt) > alertCandidateContinuityWindow {
return alertCandidateState{FirstMatchedAt: observedAt, LastMatchedAt: observedAt, SourceEventID: sourceEventID}, alertObservationAdvanced
}
current.LastMatchedAt = observedAt
current.SourceEventID = sourceEventID
return current, alertObservationAdvanced
}
func buildAlertRuleStateUpsert(items []alertRuleStateUpsert) (string, []any) {
values := make([]string, 0, len(items))
args := make([]any, 0, len(items)*5)
for _, item := range items {
values = append(values, "(?,?,?,?,?)")
args = append(args, item.RuleID, item.VIN, item.Protocol, item.LastValue, item.ObservedAt)
}
return `INSERT INTO vehicle_alert_rule_state(rule_id,vin,protocol,observed_value,last_observed_at) VALUES ` + strings.Join(values, ",") + ` ON DUPLICATE KEY UPDATE observed_value=VALUES(observed_value),last_observed_at=VALUES(last_observed_at)`, args
}
func loadActiveAlertEvents(ctx context.Context, tx *sql.Tx) (map[string][]alertActiveEvent, error) {
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,id,status FROM vehicle_alert_event WHERE status IN ('unprocessed','processing') FOR UPDATE`)
if err != nil {
return nil, err
}
defer rows.Close()
items := map[string][]alertActiveEvent{}
for rows.Next() {
var fingerprint string
var event alertActiveEvent
if err := rows.Scan(&fingerprint, &event.ID, &event.Status); err != nil {
return nil, err
}
items[fingerprint] = append(items[fingerprint], event)
}
return items, rows.Err()
}
func (s *ProductionStore) alertEvaluationEvidence(ctx context.Context) ([]alertEvaluationEvidence, error) {
rows, err := s.db.QueryContext(ctx, `SELECT l.vin,COALESCE(NULLIF(l.plate,''),NULLIF(b.plate,''),''),l.protocol,COALESCE(b.oem,''),COALESCE(p.model_name,''),COALESCE(p.company_name,''),COALESCE(l.speed_kmh,0),COALESCE(l.soc_percent,0),COALESCE(l.alarm_flag,0),COALESCE(l.longitude,0),COALESCE(l.latitude,0),COALESCE(l.event_id,''),COALESCE(DATE_FORMAT(l.event_time,'%Y-%m-%d %H:%i:%s.%f'),''),COALESCE(DATE_FORMAT(l.received_at,'%Y-%m-%d %H:%i:%s.%f'),''),GREATEST(0,TIMESTAMPDIFF(SECOND,l.updated_at,NOW())),TIMESTAMPDIFF(SECOND,l.event_time,l.received_at),COALESCE(CONCAT(l.longitude,',',l.latitude),'') FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin=l.vin LEFT JOIN vehicle_profile p ON p.vin=l.vin WHERE l.vin<>'' ORDER BY l.updated_at DESC LIMIT ?`, alertEvaluationVehicleLimit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]alertEvaluationEvidence, 0, 1024)
for rows.Next() {
var item alertEvaluationEvidence
var delay sql.NullInt64
if err = rows.Scan(&item.VIN, &item.Plate, &item.Protocol, &item.OEM, &item.Model, &item.Company, &item.SpeedKmh, &item.SOCPercent, &item.AlarmFlag, &item.Longitude, &item.Latitude, &item.SourceEventID, &item.EventAt, &item.ReceivedAt, &item.FreshnessSec, &delay, &item.Location); err != nil {
return nil, err
}
if delay.Valid {
item.DataDelaySec = int(delay.Int64)
}
items = append(items, item)
if len(items) > alertEvaluationVehicleLimit {
return nil, fmt.Errorf("alert evaluation evidence exceeds safety limit %d", alertEvaluationVehicleLimit)
}
}
return items, rows.Err()
}
func alertRuleInScope(rule AlertRule, item alertEvaluationEvidence) bool {
if len(rule.ScopeProtocols) > 0 && !containsFold(rule.ScopeProtocols, item.Protocol) {
return false
}
if len(rule.ScopeVINs) > 0 && !containsFold(rule.ScopeVINs, item.VIN) {
return false
}
if len(rule.ScopeOEMs) > 0 && !containsFold(rule.ScopeOEMs, item.OEM) {
return false
}
if len(rule.ScopeModels) > 0 && !containsFold(rule.ScopeModels, item.Model) {
return false
}
if len(rule.ScopeCompanies) > 0 && !containsFold(rule.ScopeCompanies, item.Company) {
return false
}
return true
}
func containsFold(values []string, target string) bool {
for _, value := range values {
if strings.EqualFold(value, target) {
return true
}
}
return false
}
func alertMetricValue(metric string, item alertEvaluationEvidence) (float64, bool) {
switch strings.ToLower(strings.TrimSpace(metric)) {
case "speed_kmh":
return item.SpeedKmh, true
case "soc_percent":
return item.SOCPercent, true
case "alarm_active":
if item.AlarmFlag != 0 {
return 1, true
}
return 0, true
case "freshness_sec":
return float64(item.FreshnessSec), true
case "data_delay_sec":
return float64(item.DataDelaySec), true
}
return 0, false
}
func compareAlertValue(value float64, operator string, threshold float64) bool {
switch strings.ToLower(operator) {
case "gt":
return value > threshold
case "gte":
return value >= threshold
case "lt":
return value < threshold
case "lte":
return value <= threshold
case "eq":
return math.Abs(value-threshold) < 1e-9
case "neq":
return math.Abs(value-threshold) >= 1e-9
}
return false
}
func compareAlertRuleValue(value float64, rule AlertRule) bool {
switch strings.ToLower(rule.Operator) {
case "between":
return value >= rule.Threshold && value <= rule.ThresholdHigh
case "outside":
return value < rule.Threshold || value > rule.ThresholdHigh
default:
return compareAlertValue(value, rule.Operator, rule.Threshold)
}
}
func alertMetricUnit(metric string) string {
switch metric {
case "speed_kmh":
return "km/h"
case "soc_percent":
return "%"
case "freshness_sec", "data_delay_sec":
return "秒"
}
return ""
}
func nullableAlertTime(value string) any {
if strings.TrimSpace(value) == "" {
return nil
}
return value
}

View File

@@ -0,0 +1,306 @@
package platform
import (
"context"
"sort"
"strings"
"time"
)
func (m *MockStore) seedAlertCenter() {
now := time.Now()
boolTrue := true
m.alertRules = []AlertRule{
{ID: "rule-speeding", Name: "持续超速告警", Description: "速度持续高于阈值", Severity: "critical", ValueType: "numeric", Metric: "speed_kmh", Operator: "gt", Threshold: 80, DurationSec: 60, RecoveryOperator: "lte", RecoveryThreshold: 75, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808", "GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-48 * time.Hour).Format(time.RFC3339)},
{ID: "rule-offline", Name: "离线超时", Description: "车辆超过阈值未上报", Severity: "major", ValueType: "numeric", Metric: "freshness_sec", Operator: "gt", Threshold: 3600, DurationSec: 0, RecoveryOperator: "lte", RecoveryThreshold: 300, RepeatIntervalSec: 3600, ScopeProtocols: []string{"JT808", "GB32960", "YUTONG_MQTT"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339), UpdatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339)},
{ID: "rule-alarm", Name: "协议告警位", Description: "原始协议告警位非零", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &boolTrue, DurationSec: 0, RecoveryOperator: "eq", RecoveryThreshold: 0, RepeatIntervalSec: 300, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: false, Version: 1, CreatedBy: "platform-admin", UpdatedBy: "platform-admin", CreatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339), UpdatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339)},
}
locations := []string{"广东省深圳市南山区科技南路", "广东省广州市白云区机场高速", "广东省东莞市南城街道", "广东省佛山市顺德区伦教街道", "上海市临港新片区", "四川省成都市高新区"}
statuses := []string{"unprocessed", "unprocessed", "processing", "recovered", "closed", "ignored"}
severities := []string{"critical", "critical", "major", "major", "minor", "minor"}
plates := []string{"粤AG18312", "粤B7C526", "粤C9D872", "粤E6P987", "豫A88888", "川AHTWO1"}
vins := []string{"LB9A32A24R0LS1426", "LFP23A98V2P012345", "LS5A3A5E8N0123456", "LJ12BA3R1N0456789", "LMRKH9AC2R1004087", "LNXNEGRR7SR318212"}
for i := range statuses {
triggered := now.Add(-time.Duration(8+i*11) * time.Minute)
event := AlertEvent{ID: "alert-demo-" + string(rune('1'+i)), RuleID: "rule-speeding", RuleName: "持续超速告警", RuleVersion: 2, Severity: severities[i], Status: statuses[i], VIN: vins[i], Plate: plates[i], Protocol: []string{"JT808", "JT808", "JT808", "GB32960", "YUTONG_MQTT", "GB32960"}[i], Metric: "speed_kmh", Operator: "gt", TriggerValue: 96 - float64(i*3), Threshold: 80, Unit: "km/h", DurationSec: 60, Location: locations[i], SourceEventID: "source-event-00" + string(rune('1'+i)), EventAt: triggered.Add(-time.Second).Format(time.RFC3339), ReceivedAt: triggered.Format(time.RFC3339), TriggeredAt: triggered.Format(time.RFC3339), Version: 1}
if statuses[i] == "processing" {
event.Handler = "张三"
}
if statuses[i] == "recovered" || statuses[i] == "closed" || statuses[i] == "ignored" {
event.RecoveredAt = triggered.Add(5 * time.Minute).Format(time.RFC3339)
}
event.Actions = []AlertAction{{ID: int64(i + 1), Action: "trigger", ToStatus: "unprocessed", Actor: "alert-evaluator", Note: "规则命中并达到持续时间", CreatedAt: triggered.Format(time.RFC3339)}}
if statuses[i] != "unprocessed" {
event.Actions = append(event.Actions, AlertAction{ID: int64(20 + i), Action: statuses[i], FromStatus: "unprocessed", ToStatus: statuses[i], Actor: firstNonEmpty(event.Handler, "platform-admin"), CreatedAt: triggered.Add(time.Minute).Format(time.RFC3339)})
}
m.alertEvents = append(m.alertEvents, event)
}
m.nextAlertActionID = 100
for i := 0; i < 9; i++ {
m.alertNotifications = append(m.alertNotifications, AlertNotification{ID: int64(i + 1), EventID: m.alertEvents[i%len(m.alertEvents)].ID, Title: m.alertEvents[i%len(m.alertEvents)].RuleName, Content: plates[i%len(plates)] + " 触发告警,请及时处理", Severity: severities[i%len(severities)], Channel: "in_app", Read: i >= 7, CreatedAt: now.Add(-time.Duration(i+1) * time.Minute).Format(time.RFC3339)})
}
m.nextNotificationID = 10
}
func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSummary, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
result := AlertSummary{AsOf: time.Now().Format(time.RFC3339)}
for _, event := range m.alertEvents {
if !keepMockAlertEvent(event, query) {
continue
}
switch event.Status {
case "unprocessed":
result.Unprocessed++
result.Active++
case "processing":
result.Processing++
result.Active++
case "recovered":
result.Recovered++
case "closed":
result.Closed++
case "ignored":
result.Ignored++
}
}
for _, item := range m.alertNotifications {
if !item.Read {
result.UnreadNotifications++
}
}
return result, nil
}
func (m *MockStore) ActiveAlertVINs(_ context.Context, protocol string) ([]string, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
seen := map[string]struct{}{}
vins := make([]string, 0)
for _, event := range m.alertEvents {
if event.Status != "unprocessed" && event.Status != "processing" {
continue
}
if protocol != "" && event.Protocol != protocol {
continue
}
if _, exists := seen[event.VIN]; exists {
continue
}
seen[event.VIN] = struct{}{}
vins = append(vins, event.VIN)
}
return vins, nil
}
func (m *MockStore) AlertEvents(_ context.Context, query AlertQuery) (Page[AlertEvent], error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
items := make([]AlertEvent, 0, len(m.alertEvents))
for _, event := range m.alertEvents {
if keepMockAlertEvent(event, query) {
event.Actions = nil
items = append(items, event)
}
}
sort.SliceStable(items, func(i, j int) bool { return items[i].TriggeredAt > items[j].TriggeredAt })
total := len(items)
start := query.Offset
if start > total {
start = total
}
end := start + query.Limit
if end > total {
end = total
}
return Page[AlertEvent]{Items: append([]AlertEvent(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset}, nil
}
func keepMockAlertEvent(event AlertEvent, query AlertQuery) bool {
keyword := strings.ToLower(query.Keyword)
if keyword != "" && !strings.Contains(strings.ToLower(event.VIN), keyword) && !strings.Contains(strings.ToLower(event.Plate), keyword) && !strings.Contains(strings.ToLower(event.RuleName), keyword) {
return false
}
if query.Severity != "" && query.Severity != "all" && event.Severity != query.Severity {
return false
}
if query.Status == "active" && event.Status != "unprocessed" && event.Status != "processing" {
return false
}
if query.Status != "" && query.Status != "all" && query.Status != "active" && event.Status != query.Status {
return false
}
if query.RuleID != "" && event.RuleID != query.RuleID {
return false
}
if query.Protocol != "" && !strings.EqualFold(event.Protocol, query.Protocol) {
return false
}
if query.DateFrom != "" && event.TriggeredAt < query.DateFrom {
return false
}
if query.DateTo != "" && event.TriggeredAt > query.DateTo {
return false
}
return true
}
func (m *MockStore) AlertEvent(_ context.Context, id string) (AlertEvent, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
for _, event := range m.alertEvents {
if event.ID == id {
event.Actions = append([]AlertAction(nil), event.Actions...)
return event, nil
}
}
return AlertEvent{}, clientError{Code: "ALERT_EVENT_NOT_FOUND", Message: "告警事件不存在"}
}
func (m *MockStore) AlertRules(context.Context) ([]AlertRule, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
return append([]AlertRule(nil), m.alertRules...), nil
}
func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (AlertRule, error) {
m.alertMu.Lock()
defer m.alertMu.Unlock()
now := time.Now().Format(time.RFC3339)
for i, current := range m.alertRules {
if current.ID != input.ID {
continue
}
if input.Version != current.Version {
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
}
next := ruleFromInput(input)
next.Version = current.Version + 1
next.CreatedAt = current.CreatedAt
next.CreatedBy = current.CreatedBy
next.UpdatedAt = now
next.UpdatedBy = input.Actor
m.alertRules[i] = next
return next, nil
}
if input.Version != 0 {
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "待更新规则不存在"}
}
next := ruleFromInput(input)
next.Version = 1
next.CreatedAt = now
next.UpdatedAt = now
next.CreatedBy = input.Actor
next.UpdatedBy = input.Actor
m.alertRules = append(m.alertRules, next)
return next, nil
}
func ruleFromInput(input AlertRuleInput) AlertRule {
return AlertRule{ID: input.ID, Name: input.Name, Description: input.Description, Severity: strings.ToLower(input.Severity), ValueType: strings.ToLower(input.ValueType), Metric: input.Metric, Operator: strings.ToLower(input.Operator), Threshold: input.Threshold, ThresholdHigh: input.ThresholdHigh, BooleanThreshold: input.BooleanThreshold, DurationSec: input.DurationSec, RecoveryOperator: strings.ToLower(input.RecoveryOperator), RecoveryThreshold: input.RecoveryThreshold, RepeatIntervalSec: input.RepeatIntervalSec, ScopeProtocols: append([]string(nil), input.ScopeProtocols...), ScopeVINs: append([]string(nil), input.ScopeVINs...), ScopeOEMs: append([]string(nil), input.ScopeOEMs...), ScopeModels: append([]string(nil), input.ScopeModels...), ScopeCompanies: append([]string(nil), input.ScopeCompanies...), NotificationChannels: append([]string(nil), input.NotificationChannels...), Enabled: input.Enabled}
}
func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
m.alertMu.Lock()
defer m.alertMu.Unlock()
for i := range m.alertRules {
if m.alertRules[i].ID == id {
if m.alertRules[i].Version != update.Version {
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
}
m.alertRules[i].Enabled = update.Enabled
m.alertRules[i].Version++
m.alertRules[i].UpdatedBy = update.Actor
m.alertRules[i].UpdatedAt = time.Now().Format(time.RFC3339)
return m.alertRules[i], nil
}
}
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "规则不存在"}
}
func (m *MockStore) ActOnAlert(_ context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
m.alertMu.Lock()
defer m.alertMu.Unlock()
for i := range m.alertEvents {
event := &m.alertEvents[i]
if event.ID != id {
continue
}
if event.Version != request.Version {
return AlertEvent{}, clientError{Code: "ALERT_EVENT_VERSION_CONFLICT", Message: "事件已被其他用户更新,请刷新后重试"}
}
from := event.Status
to := from
switch request.Action {
case "acknowledge":
if from != "unprocessed" {
return AlertEvent{}, clientError{Code: "ALERT_ACTION_NOT_ALLOWED", Message: "只有未处理告警可以确认"}
}
to = "processing"
event.Handler = request.Actor
case "close":
if from != "unprocessed" && from != "processing" && from != "recovered" {
return AlertEvent{}, clientError{Code: "ALERT_ACTION_NOT_ALLOWED", Message: "当前状态不能关闭"}
}
to = "closed"
event.Handler = request.Actor
case "ignore":
if from != "unprocessed" && from != "processing" {
return AlertEvent{}, clientError{Code: "ALERT_ACTION_NOT_ALLOWED", Message: "当前状态不能忽略"}
}
to = "ignored"
event.Handler = request.Actor
}
m.nextAlertActionID++
event.Actions = append(event.Actions, AlertAction{ID: m.nextAlertActionID, Action: request.Action, FromStatus: from, ToStatus: to, Actor: request.Actor, Note: request.Note, CreatedAt: time.Now().Format(time.RFC3339)})
event.Status = to
event.Version++
return *event, nil
}
return AlertEvent{}, clientError{Code: "ALERT_EVENT_NOT_FOUND", Message: "告警事件不存在"}
}
func (m *MockStore) AlertNotifications(_ context.Context, query AlertNotificationQuery) (Page[AlertNotification], error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
items := make([]AlertNotification, 0, len(m.alertNotifications))
for _, item := range m.alertNotifications {
if !query.UnreadOnly || !item.Read {
items = append(items, item)
}
}
total := len(items)
start := query.Offset
if start > total {
start = total
}
end := start + query.Limit
if end > total {
end = total
}
return Page[AlertNotification]{Items: append([]AlertNotification(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset}, nil
}
func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertNotificationReadRequest) (int, error) {
m.alertMu.Lock()
defer m.alertMu.Unlock()
ids := map[int64]bool{}
for _, id := range request.IDs {
ids[id] = true
}
count := 0
now := time.Now().Format(time.RFC3339)
for i := range m.alertNotifications {
if ids[m.alertNotifications[i].ID] && !m.alertNotifications[i].Read {
m.alertNotifications[i].Read = true
m.alertNotifications[i].ReadAt = now
count++
}
}
return count, nil
}
func (m *MockStore) EvaluateAlerts(context.Context) (AlertEvaluationResult, error) {
return AlertEvaluationResult{RulesEvaluated: len(m.alertRules), VehiclesScanned: len(m.vehicles), AsOf: time.Now().Format(time.RFC3339)}, nil
}

View File

@@ -0,0 +1,457 @@
package platform
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
func (s *ProductionStore) ensureAlertSchema(ctx context.Context) error {
s.alertSchemaOnce.Do(func() {
var count int
s.alertSchemaErr = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_rule`).Scan(&count)
if s.alertSchemaErr != nil {
s.alertSchemaErr = fmt.Errorf("alert center schema unavailable; apply deploy/migrations/002_alert_center.sql: %w", s.alertSchemaErr)
}
})
return s.alertSchemaErr
}
func (s *ProductionStore) AlertSummary(ctx context.Context, query AlertQuery) (AlertSummary, error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return AlertSummary{}, err
}
where, args := buildAlertWhere(query)
var result AlertSummary
err := s.db.QueryRowContext(ctx, `SELECT
COALESCE(SUM(CASE WHEN status IN ('unprocessed','processing') THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN status='unprocessed' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN status='processing' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN status='recovered' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN status='closed' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN status='ignored' THEN 1 ELSE 0 END),0)
FROM vehicle_alert_event e WHERE `+where, args...).Scan(&result.Active, &result.Unprocessed, &result.Processing, &result.Recovered, &result.Closed, &result.Ignored)
if err != nil {
return AlertSummary{}, err
}
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification WHERE channel='in_app' AND is_read=0`).Scan(&result.UnreadNotifications); err != nil {
return AlertSummary{}, err
}
result.AsOf = time.Now().Format(time.RFC3339)
return result, nil
}
func (s *ProductionStore) ActiveAlertVINs(ctx context.Context, protocol string) ([]string, error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return nil, err
}
query := `SELECT DISTINCT vin FROM vehicle_alert_event WHERE status IN ('unprocessed','processing') AND vin<>''`
args := []any{}
if protocol = strings.TrimSpace(protocol); protocol != "" {
query += ` AND protocol=?`
args = append(args, protocol)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
vins := make([]string, 0)
for rows.Next() {
var vin string
if err := rows.Scan(&vin); err != nil {
return nil, err
}
vins = append(vins, vin)
}
return vins, rows.Err()
}
func buildAlertWhere(query AlertQuery) (string, []any) {
where := []string{"1=1"}
args := []any{}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
where = append(where, "(e.vin LIKE ? OR e.plate LIKE ? OR e.rule_name LIKE ?)")
args = append(args, like, like, like)
}
if query.Severity != "" && query.Severity != "all" {
where = append(where, "e.severity=?")
args = append(args, query.Severity)
}
if query.Status == "active" {
where = append(where, "e.status IN ('unprocessed','processing')")
} else if query.Status != "" && query.Status != "all" {
where = append(where, "e.status=?")
args = append(args, query.Status)
}
if query.RuleID != "" {
where = append(where, "e.rule_id=?")
args = append(args, query.RuleID)
}
if query.Protocol != "" {
where = append(where, "e.protocol=?")
args = append(args, query.Protocol)
}
if query.DateFrom != "" {
where = append(where, "e.triggered_at>=?")
args = append(args, query.DateFrom)
}
if query.DateTo != "" {
where = append(where, "e.triggered_at<=?")
args = append(args, query.DateTo)
}
return strings.Join(where, " AND "), args
}
const alertEventSelect = `SELECT e.id,e.rule_id,e.rule_name,e.rule_version,e.severity,e.status,e.vin,e.plate,e.protocol,
e.metric,e.operator,e.trigger_value,e.threshold_value,e.threshold_high,e.unit,e.duration_sec,e.location_text,e.longitude,e.latitude,
e.source_event_id,COALESCE(DATE_FORMAT(e.event_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),COALESCE(DATE_FORMAT(e.received_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),
DATE_FORMAT(e.triggered_at,'%Y-%m-%dT%H:%i:%s.%fZ'),COALESCE(DATE_FORMAT(e.recovered_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),e.handler,e.version
FROM vehicle_alert_event e `
func scanAlertEvent(scanner interface{ Scan(...any) error }) (AlertEvent, error) {
var event AlertEvent
var longitude, latitude sql.NullFloat64
err := scanner.Scan(&event.ID, &event.RuleID, &event.RuleName, &event.RuleVersion, &event.Severity, &event.Status, &event.VIN, &event.Plate, &event.Protocol,
&event.Metric, &event.Operator, &event.TriggerValue, &event.Threshold, &event.ThresholdHigh, &event.Unit, &event.DurationSec, &event.Location, &longitude, &latitude,
&event.SourceEventID, &event.EventAt, &event.ReceivedAt, &event.TriggeredAt, &event.RecoveredAt, &event.Handler, &event.Version)
if longitude.Valid {
event.Longitude = &longitude.Float64
}
if latitude.Valid {
event.Latitude = &latitude.Float64
}
return event, err
}
func (s *ProductionStore) AlertEvents(ctx context.Context, query AlertQuery) (Page[AlertEvent], error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return Page[AlertEvent]{}, err
}
where, args := buildAlertWhere(query)
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_event e WHERE `+where, args...).Scan(&total); err != nil {
return Page[AlertEvent]{}, err
}
listArgs := append(append([]any(nil), args...), query.Limit, query.Offset)
rows, err := s.db.QueryContext(ctx, alertEventSelect+`WHERE `+where+` ORDER BY e.triggered_at DESC,e.id DESC LIMIT ? OFFSET ?`, listArgs...)
if err != nil {
return Page[AlertEvent]{}, err
}
defer rows.Close()
items := make([]AlertEvent, 0, query.Limit)
for rows.Next() {
event, err := scanAlertEvent(rows)
if err != nil {
return Page[AlertEvent]{}, err
}
items = append(items, event)
}
return Page[AlertEvent]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
}
func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent, error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return AlertEvent{}, err
}
event, err := scanAlertEvent(s.db.QueryRowContext(ctx, alertEventSelect+`WHERE e.id=?`, id))
if err == sql.ErrNoRows {
return AlertEvent{}, clientError{Code: "ALERT_EVENT_NOT_FOUND", Message: "告警事件不存在"}
}
if err != nil {
return AlertEvent{}, err
}
rows, err := s.db.QueryContext(ctx, `SELECT id,action,from_status,to_status,actor,note,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ') FROM vehicle_alert_event_action WHERE event_id=? ORDER BY created_at,id`, id)
if err != nil {
return AlertEvent{}, err
}
defer rows.Close()
for rows.Next() {
var item AlertAction
if err := rows.Scan(&item.ID, &item.Action, &item.FromStatus, &item.ToStatus, &item.Actor, &item.Note, &item.CreatedAt); err != nil {
return AlertEvent{}, err
}
event.Actions = append(event.Actions, item)
}
return event, rows.Err()
}
const alertRuleSelect = `SELECT id,name,description,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,
recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,enabled,version,
created_by,updated_by,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ'),DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%s.%fZ') FROM vehicle_alert_rule `
func scanAlertRule(scanner interface{ Scan(...any) error }) (AlertRule, error) {
var rule AlertRule
var boolean sql.NullBool
var protocols, vins, oems, models, companies, channels string
err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt)
if boolean.Valid {
rule.BooleanThreshold = &boolean.Bool
}
if err == nil {
if e := json.Unmarshal([]byte(protocols), &rule.ScopeProtocols); e != nil {
return AlertRule{}, e
}
if e := json.Unmarshal([]byte(vins), &rule.ScopeVINs); e != nil {
return AlertRule{}, e
}
if e := json.Unmarshal([]byte(oems), &rule.ScopeOEMs); e != nil {
return AlertRule{}, e
}
if e := json.Unmarshal([]byte(models), &rule.ScopeModels); e != nil {
return AlertRule{}, e
}
if e := json.Unmarshal([]byte(companies), &rule.ScopeCompanies); e != nil {
return AlertRule{}, e
}
if e := json.Unmarshal([]byte(channels), &rule.NotificationChannels); e != nil {
return AlertRule{}, e
}
}
return rule, err
}
func (s *ProductionStore) AlertRules(ctx context.Context) ([]AlertRule, error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, alertRuleSelect+`ORDER BY enabled DESC,severity,name`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []AlertRule{}
for rows.Next() {
item, err := scanAlertRule(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func marshalAlertRuleLists(input AlertRuleInput) (string, string, string, string, string, string, error) {
p, e := json.Marshal(input.ScopeProtocols)
if e != nil {
return "", "", "", "", "", "", e
}
v, e := json.Marshal(input.ScopeVINs)
if e != nil {
return "", "", "", "", "", "", e
}
o, e := json.Marshal(input.ScopeOEMs)
if e != nil {
return "", "", "", "", "", "", e
}
m, e := json.Marshal(input.ScopeModels)
if e != nil {
return "", "", "", "", "", "", e
}
co, e := json.Marshal(input.ScopeCompanies)
if e != nil {
return "", "", "", "", "", "", e
}
c, e := json.Marshal(input.NotificationChannels)
return string(p), string(v), string(o), string(m), string(co), string(c), e
}
const alertRuleInsertSQL = `INSERT INTO vehicle_alert_rule(id,name,description,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,scope_oems_json,scope_models_json,scope_companies_json,notification_channels_json,enabled,version,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`
const alertRuleUpdateSQL = `UPDATE vehicle_alert_rule SET name=?,description=?,severity=?,value_type=?,metric=?,operator=?,threshold_value=?,threshold_high=?,boolean_threshold=?,duration_sec=?,recovery_operator=?,recovery_threshold=?,repeat_interval_sec=?,scope_protocols_json=?,scope_vins_json=?,scope_oems_json=?,scope_models_json=?,scope_companies_json=?,notification_channels_json=?,enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`
func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return AlertRule{}, err
}
p, v, o, m, co, c, err := marshalAlertRuleLists(input)
if err != nil {
return AlertRule{}, err
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return AlertRule{}, err
}
defer tx.Rollback()
var current int
err = tx.QueryRowContext(ctx, `SELECT version FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(&current)
if err == sql.ErrNoRows {
if input.Version != 0 {
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "待更新规则不存在"}
}
_, err = tx.ExecContext(ctx, alertRuleInsertSQL, input.ID, input.Name, input.Description, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, input.Enabled, input.Actor, input.Actor)
current = 0
} else if err != nil {
return AlertRule{}, err
} else {
if current != input.Version {
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
}
res, e := tx.ExecContext(ctx, alertRuleUpdateSQL, input.Name, input.Description, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, input.Enabled, input.Actor, input.ID, current)
err = e
if err == nil {
n, _ := res.RowsAffected()
if n != 1 {
err = clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则更新冲突,请刷新后重试"}
}
}
}
if err != nil {
return AlertRule{}, err
}
snapshot, _ := json.Marshal(input)
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, input.ID, current+1, input.Actor, firstNonEmpty(map[bool]string{true: "create", false: "update"}[current == 0], "update"), string(snapshot)); err != nil {
return AlertRule{}, err
}
if err = tx.Commit(); err != nil {
return AlertRule{}, err
}
return scanAlertRule(s.db.QueryRowContext(ctx, alertRuleSelect+`WHERE id=?`, input.ID))
}
func (s *ProductionStore) SetAlertRuleEnabled(ctx context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return AlertRule{}, err
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return AlertRule{}, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`, update.Enabled, update.Actor, id, update.Version)
if err != nil {
return AlertRule{}, err
}
n, _ := res.RowsAffected()
if n != 1 {
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则不存在或已被其他用户更新"}
}
rule, err := scanAlertRule(tx.QueryRowContext(ctx, alertRuleSelect+`WHERE id=?`, id))
if err != nil {
return AlertRule{}, err
}
if !update.Enabled {
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_candidate WHERE rule_id=?`, id); err != nil {
return AlertRule{}, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`, id); err != nil {
return AlertRule{}, err
}
}
snapshot, _ := json.Marshal(rule)
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, id, rule.Version, update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled], string(snapshot)); err != nil {
return AlertRule{}, err
}
if err = tx.Commit(); err != nil {
return AlertRule{}, err
}
return rule, nil
}
func alertActionTarget(status, action string) (string, error) {
switch action {
case "acknowledge":
if status == "unprocessed" {
return "processing", nil
}
case "close":
if status == "unprocessed" || status == "processing" || status == "recovered" {
return "closed", nil
}
case "ignore":
if status == "unprocessed" || status == "processing" {
return "ignored", nil
}
}
return "", clientError{Code: "ALERT_ACTION_NOT_ALLOWED", Message: "当前状态不允许该处置动作"}
}
func (s *ProductionStore) ActOnAlert(ctx context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return AlertEvent{}, err
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return AlertEvent{}, err
}
defer tx.Rollback()
var status string
var version int
if err = tx.QueryRowContext(ctx, `SELECT status,version FROM vehicle_alert_event WHERE id=? FOR UPDATE`, id).Scan(&status, &version); err == sql.ErrNoRows {
return AlertEvent{}, clientError{Code: "ALERT_EVENT_NOT_FOUND", Message: "告警事件不存在"}
} else if err != nil {
return AlertEvent{}, err
}
if version != request.Version {
return AlertEvent{}, clientError{Code: "ALERT_EVENT_VERSION_CONFLICT", Message: "事件已被其他用户更新,请刷新后重试"}
}
target, err := alertActionTarget(status, request.Action)
if err != nil {
return AlertEvent{}, err
}
res, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET status=?,handler=?,version=version+1 WHERE id=? AND version=?`, target, request.Actor, id, version)
if err != nil {
return AlertEvent{}, err
}
n, _ := res.RowsAffected()
if n != 1 {
return AlertEvent{}, clientError{Code: "ALERT_EVENT_VERSION_CONFLICT", Message: "事件更新冲突,请刷新后重试"}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,?,?,?,?,?)`, id, request.Action, status, target, request.Actor, request.Note); err != nil {
return AlertEvent{}, err
}
if err = tx.Commit(); err != nil {
return AlertEvent{}, err
}
return s.AlertEvent(ctx, id)
}
func (s *ProductionStore) AlertNotifications(ctx context.Context, query AlertNotificationQuery) (Page[AlertNotification], error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return Page[AlertNotification]{}, err
}
where := "channel='in_app'"
if query.UnreadOnly {
where += " AND is_read=0"
}
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification WHERE `+where).Scan(&total); err != nil {
return Page[AlertNotification]{}, err
}
rows, err := s.db.QueryContext(ctx, `SELECT id,event_id,title,content,severity,channel,is_read,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ'),COALESCE(DATE_FORMAT(read_at,'%Y-%m-%dT%H:%i:%s.%fZ'),'') FROM vehicle_alert_notification WHERE `+where+` ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?`, query.Limit, query.Offset)
if err != nil {
return Page[AlertNotification]{}, err
}
defer rows.Close()
items := []AlertNotification{}
for rows.Next() {
var item AlertNotification
if err := rows.Scan(&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Read, &item.CreatedAt, &item.ReadAt); err != nil {
return Page[AlertNotification]{}, err
}
items = append(items, item)
}
return Page[AlertNotification]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
}
func (s *ProductionStore) MarkAlertNotificationsRead(ctx context.Context, request AlertNotificationReadRequest) (int, error) {
if err := s.ensureAlertSchema(ctx); err != nil {
return 0, err
}
placeholders := make([]string, len(request.IDs))
args := make([]any, 0, len(request.IDs)+1)
args = append(args, request.Actor)
for i, id := range request.IDs {
placeholders[i] = "?"
args = append(args, id)
}
result, err := s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET is_read=1,read_by=?,read_at=CURRENT_TIMESTAMP(3) WHERE channel='in_app' AND is_read=0 AND id IN (`+strings.Join(placeholders, ",")+`)`, args...)
if err != nil {
return 0, err
}
n, err := result.RowsAffected()
return int(n), err
}

View File

@@ -0,0 +1,271 @@
package platform
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
const alertStreamMaxFutureSkew = 10 * time.Minute
type AlertStreamRecord struct {
Topic string
Partition int
Offset int64
HighWatermark int64
Protocol string
VIN string
Plate string
SourceEventID string
EventAt time.Time
ReceivedAt time.Time
Fields map[string]json.RawMessage
Valid bool
Late bool
ErrorCode string
}
type AlertStreamBatchResult struct {
Fetched int
Processed int
Valid int
Invalid int
Late int
ReplaySkipped int
Partitions int
RulesEvaluated int
CandidatesAdvanced int
DuplicateObservations int
LateObservations int
Opened int
Recovered int
}
type alertStreamEnvelope struct {
EventID string `json:"event_id"`
EventKind string `json:"event_kind"`
SourceEventID string `json:"source_event_id"`
FieldMapping string `json:"field_mapping"`
Protocol string `json:"protocol"`
VIN string `json:"vin"`
Plate string `json:"plate"`
EventTimeMS int64 `json:"event_time_ms"`
ReceivedAtMS int64 `json:"received_at_ms"`
Fields map[string]json.RawMessage `json:"fields"`
}
func DecodeAlertStreamRecord(topic string, partition int, offset, highWatermark int64, payload []byte, lateness time.Duration) AlertStreamRecord {
record := AlertStreamRecord{Topic: strings.TrimSpace(topic), Partition: partition, Offset: offset, HighWatermark: highWatermark}
var envelope alertStreamEnvelope
if err := json.Unmarshal(payload, &envelope); err != nil {
record.ErrorCode = "invalid_json"
return record
}
record.Protocol = strings.TrimSpace(envelope.Protocol)
record.VIN = strings.TrimSpace(envelope.VIN)
record.Plate = strings.TrimSpace(envelope.Plate)
record.SourceEventID = firstNonEmpty(strings.TrimSpace(envelope.SourceEventID), strings.TrimSpace(envelope.EventID))
record.Fields = envelope.Fields
if expected, known := alertStreamTopicProtocol(record.Topic); known && !strings.EqualFold(expected, record.Protocol) {
record.ErrorCode = "protocol_topic_mismatch"
return record
}
if !strings.EqualFold(strings.TrimSpace(envelope.EventKind), "FIELDS") {
record.ErrorCode = "event_kind_mismatch"
return record
}
if strings.TrimSpace(envelope.FieldMapping) == "" {
record.ErrorCode = "missing_field_mapping"
return record
}
if record.VIN == "" {
record.ErrorCode = "missing_vin_" + strings.ToLower(record.Protocol)
return record
}
if record.SourceEventID == "" {
record.ErrorCode = "missing_source_event_id"
return record
}
if len(envelope.Fields) == 0 {
record.ErrorCode = "missing_fields"
return record
}
prefix := map[string]string{"GB32960": "gb32960.", "JT808": "jt808.", "YUTONG_MQTT": "yutong_mqtt."}[strings.ToUpper(record.Protocol)]
for field := range envelope.Fields {
if prefix != "" && (field != strings.TrimSpace(field) || !strings.HasPrefix(field, prefix) || len(field) <= len(prefix)) {
record.ErrorCode = "invalid_field_name"
return record
}
}
if envelope.ReceivedAtMS <= 0 {
record.ErrorCode = "missing_received_time"
return record
}
receivedAt := time.UnixMilli(envelope.ReceivedAtMS)
eventMS := envelope.EventTimeMS
if eventMS <= 0 || eventMS > envelope.ReceivedAtMS+alertStreamMaxFutureSkew.Milliseconds() {
eventMS = envelope.ReceivedAtMS
}
record.EventAt = time.UnixMilli(eventMS)
record.ReceivedAt = receivedAt
if lateness > 0 && receivedAt.Sub(record.EventAt) > lateness {
record.Late = true
}
record.Valid = true
return record
}
func alertStreamTopicProtocol(topic string) (string, bool) {
switch strings.TrimSpace(topic) {
case "vehicle.fields.go.gb32960.v1":
return "GB32960", true
case "vehicle.fields.go.jt808.v1":
return "JT808", true
case "vehicle.fields.go.yutong-mqtt.v1":
return "YUTONG_MQTT", true
default:
return "", false
}
}
type alertStreamPartition struct {
topic string
partition int
}
type alertStreamCheckpointUpdate struct {
key alertStreamPartition
nextOffset, highWatermark int64
processed, valid, invalid, late, replay int64
lastEventAt, lastReceivedAt any
lastSourceEventID string
lastInvalidCode string
}
func (s *ProductionStore) RecordAlertStreamBatch(ctx context.Context, consumerGroup string, records []AlertStreamRecord) (AlertStreamBatchResult, error) {
result := AlertStreamBatchResult{Fetched: len(records)}
consumerGroup = strings.TrimSpace(consumerGroup)
if consumerGroup == "" || len(consumerGroup) > 128 {
return result, fmt.Errorf("alert stream consumer group is empty or too long")
}
if len(records) == 0 {
return result, nil
}
grouped := map[alertStreamPartition][]AlertStreamRecord{}
for _, record := range records {
if record.Topic == "" || len(record.Topic) > 255 || record.Partition < 0 || record.Offset < 0 {
return result, fmt.Errorf("invalid alert stream message position topic=%q partition=%d offset=%d", record.Topic, record.Partition, record.Offset)
}
key := alertStreamPartition{topic: record.Topic, partition: record.Partition}
grouped[key] = append(grouped[key], record)
}
keys := make([]alertStreamPartition, 0, len(grouped))
for key := range grouped {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].topic == keys[j].topic {
return keys[i].partition < keys[j].partition
}
return keys[i].topic < keys[j].topic
})
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return result, err
}
defer tx.Rollback()
updates := make([]alertStreamCheckpointUpdate, 0, len(keys))
accepted := make([]AlertStreamRecord, 0, len(records))
for _, key := range keys {
partitionRecords := grouped[key]
sort.Slice(partitionRecords, func(i, j int) bool { return partitionRecords[i].Offset < partitionRecords[j].Offset })
var nextOffset int64
hasCheckpoint := true
if err = tx.QueryRowContext(ctx, `SELECT next_offset FROM vehicle_alert_stream_checkpoint WHERE consumer_group=? AND topic=? AND partition_id=? FOR UPDATE`, consumerGroup, key.topic, key.partition).Scan(&nextOffset); err == sql.ErrNoRows {
hasCheckpoint = false
nextOffset = -1
} else if err != nil {
return result, err
}
update := alertStreamCheckpointUpdate{key: key}
maxOffset := nextOffset - 1
var lastEventTime time.Time
var lastPosition int64
hasPosition := false
for _, record := range partitionRecords {
if record.HighWatermark > update.highWatermark {
update.highWatermark = record.HighWatermark
}
if hasCheckpoint && record.Offset < nextOffset {
update.replay++
continue
}
if hasPosition && record.Offset == lastPosition {
update.replay++
continue
}
lastPosition = record.Offset
hasPosition = true
update.processed++
if record.Valid {
update.valid++
if record.Late {
update.late++
}
if lastEventTime.IsZero() || record.EventAt.After(lastEventTime) {
lastEventTime = record.EventAt
update.lastEventAt = record.EventAt
update.lastReceivedAt = record.ReceivedAt
update.lastSourceEventID = record.SourceEventID
}
accepted = append(accepted, record)
} else {
update.invalid++
update.lastInvalidCode = record.ErrorCode
}
if record.Offset > maxOffset {
maxOffset = record.Offset
}
}
update.nextOffset = nextOffset
if maxOffset >= 0 && maxOffset+1 > update.nextOffset {
update.nextOffset = maxOffset + 1
}
if update.nextOffset < 0 {
update.nextOffset = 0
}
updates = append(updates, update)
result.Processed += int(update.processed)
result.Valid += int(update.valid)
result.Invalid += int(update.invalid)
result.Late += int(update.late)
result.ReplaySkipped += int(update.replay)
}
if strings.EqualFold(s.alertStreamMode, "active") && len(accepted) > 0 {
evaluation, evaluationErr := evaluateAlertStreamRecordsTx(ctx, tx, accepted, time.Now())
if evaluationErr != nil {
return result, evaluationErr
}
result.RulesEvaluated = evaluation.RulesEvaluated
result.CandidatesAdvanced = evaluation.CandidatesAdvanced
result.DuplicateObservations = evaluation.DuplicateObservations
result.LateObservations = evaluation.LateObservations
result.Opened = evaluation.Opened
result.Recovered = evaluation.Recovered
}
for _, update := range updates {
_, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_stream_checkpoint(consumer_group,topic,partition_id,next_offset,high_watermark,processed_count,valid_count,invalid_count,late_count,replay_skipped_count,last_event_at,last_received_at,last_source_event_id,last_invalid_code,last_invalid_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,IF(?='',NULL,CURRENT_TIMESTAMP(3))) ON DUPLICATE KEY UPDATE next_offset=GREATEST(next_offset,VALUES(next_offset)),high_watermark=GREATEST(high_watermark,VALUES(high_watermark)),processed_count=processed_count+VALUES(processed_count),valid_count=valid_count+VALUES(valid_count),invalid_count=invalid_count+VALUES(invalid_count),late_count=late_count+VALUES(late_count),replay_skipped_count=replay_skipped_count+VALUES(replay_skipped_count),last_event_at=COALESCE(VALUES(last_event_at),last_event_at),last_received_at=COALESCE(VALUES(last_received_at),last_received_at),last_source_event_id=IF(VALUES(last_source_event_id)='',last_source_event_id,VALUES(last_source_event_id)),last_invalid_code=IF(VALUES(last_invalid_code)='',last_invalid_code,VALUES(last_invalid_code)),last_invalid_at=COALESCE(VALUES(last_invalid_at),last_invalid_at)`, consumerGroup, update.key.topic, update.key.partition, update.nextOffset, update.highWatermark, update.processed, update.valid, update.invalid, update.late, update.replay, update.lastEventAt, update.lastReceivedAt, update.lastSourceEventID, update.lastInvalidCode, update.lastInvalidCode)
if err != nil {
return result, err
}
}
result.Partitions = len(keys)
if err = tx.Commit(); err != nil {
return result, err
}
return result, nil
}

View File

@@ -0,0 +1,471 @@
package platform
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
type alertStreamVehicleMetadata struct {
Plate, OEM, Model, Company string
}
type alertStreamMetricMapping map[string]map[string]string
func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []AlertStreamRecord, now time.Time) (AlertEvaluationResult, error) {
result := AlertEvaluationResult{VehiclesScanned: len(records), AsOf: now.Format(time.RFC3339)}
rules, err := loadActiveAlertStreamRules(ctx, tx)
if err != nil {
return result, err
}
if len(rules) == 0 {
return result, nil
}
mappings, err := loadAlertStreamMetricMappings(ctx, tx)
if err != nil {
return result, err
}
metadata, err := loadAlertStreamVehicleMetadata(ctx, tx, records)
if err != nil {
return result, err
}
candidates, err := loadAlertStreamCandidates(ctx, tx, rules, records)
if err != nil {
return result, err
}
activeEvents, err := loadActiveAlertStreamEvents(ctx, tx, rules, records)
if err != nil {
return result, err
}
ruleStates, err := loadAlertStreamRuleStates(ctx, tx, rules, records)
if err != nil {
return result, err
}
lastTriggered, err := loadAlertStreamLastTriggered(ctx, tx, rules, records)
if err != nil {
return result, err
}
candidateUpserts := map[string]alertCandidateUpsert{}
candidateDeletes := map[string]alertCandidateUpsert{}
stateUpserts := map[string]alertRuleStateUpsert{}
for _, rule := range rules {
result.RulesEvaluated++
for _, record := range records {
item := alertStreamEvidence(record, metadata[record.VIN], mappings)
if !alertRuleInScope(rule, item) {
continue
}
value, supported := alertStreamMetricValue(rule.Metric, record, mappings)
if !supported {
continue
}
// Delayed device observations remain useful evidence for data-delay
// rules, but must not open or recover telemetry-value alerts using
// evidence outside the configured ingestion lateness window.
if record.Late && !strings.EqualFold(rule.Metric, "data_delay_sec") {
result.LateObservations++
continue
}
observedAt := record.EventAt
if strings.EqualFold(rule.Metric, "data_delay_sec") {
observedAt = record.ReceivedAt
}
fingerprint := rule.ID + "|" + record.VIN + "|" + record.Protocol
matched := false
if strings.EqualFold(rule.Operator, "changed") {
normalized := 0.0
if value != 0 {
normalized = 1
}
previous, exists := ruleStates[fingerprint]
if exists && !observedAt.After(previous.LastObservedAt) {
result.LateObservations++
continue
}
matched = exists && previous.LastValue != normalized
ruleStates[fingerprint] = alertRuleState{LastValue: normalized, LastObservedAt: observedAt}
stateUpserts[fingerprint] = alertRuleStateUpsert{RuleID: rule.ID, VIN: record.VIN, Protocol: record.Protocol, LastValue: normalized, ObservedAt: observedAt}
} else {
matched = compareAlertRuleValue(value, rule)
}
if matched {
if len(activeEvents[fingerprint]) > 0 {
continue
}
candidate, exists := candidates[fingerprint]
candidate, decision := advanceAlertCandidate(candidate, exists, observedAt, record.SourceEventID, false)
switch decision {
case alertObservationDuplicate:
result.DuplicateObservations++
continue
case alertObservationLate:
result.LateObservations++
continue
}
candidates[fingerprint] = candidate
result.CandidatesAdvanced++
candidateUpserts[fingerprint] = alertCandidateUpsert{RuleID: rule.ID, VIN: record.VIN, Protocol: record.Protocol, SourceEventID: candidate.SourceEventID, FirstMatchedAt: candidate.FirstMatchedAt, LastMatchedAt: candidate.LastMatchedAt, LatestValue: value}
delete(candidateDeletes, fingerprint)
if int(candidate.LastMatchedAt.Sub(candidate.FirstMatchedAt).Seconds()) < rule.DurationSec || !alertRepeatAllowed(now, lastTriggered[fingerprint], rule.RepeatIntervalSec) {
continue
}
id, idErr := newAlertID("alert")
if idErr != nil {
return result, idErr
}
insertSQL, insertArgs := buildAlertEventInsert(id, fingerprint, rule, item, value)
if _, err = tx.ExecContext(ctx, insertSQL, insertArgs...); err != nil {
return result, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'trigger','','unprocessed','alert-stream-evaluator','Kafka 事件时间规则命中并达到持续时间')`, id); err != nil {
return result, err
}
unit := alertMetricUnit(rule.Metric)
for _, channel := range rule.NotificationChannels {
delivery := "reserved"
if channel == "in_app" {
delivery = "created"
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,delivery_status) VALUES(?,?,?,?,?,?)`, id, rule.Name, fmt.Sprintf("%s / %s 触发%s%.2f %s", item.Plate, item.VIN, rule.Name, value, unit), rule.Severity, channel, delivery); err != nil {
return result, err
}
}
activeEvents[fingerprint] = []alertActiveEvent{{ID: id, Status: "unprocessed"}}
lastTriggered[fingerprint] = now
delete(candidates, fingerprint)
delete(candidateUpserts, fingerprint)
candidateDeletes[fingerprint] = alertCandidateUpsert{RuleID: rule.ID, VIN: record.VIN, Protocol: record.Protocol}
result.Opened++
continue
}
if _, exists := candidates[fingerprint]; exists {
delete(candidates, fingerprint)
delete(candidateUpserts, fingerprint)
candidateDeletes[fingerprint] = alertCandidateUpsert{RuleID: rule.ID, VIN: record.VIN, Protocol: record.Protocol}
}
recovered := true
if strings.TrimSpace(rule.RecoveryOperator) != "" {
recovered = compareAlertValue(value, rule.RecoveryOperator, rule.RecoveryThreshold)
}
if !recovered {
continue
}
for _, event := range activeEvents[fingerprint] {
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET status='recovered',recovered_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=? AND status=?`, event.ID, event.Status); err != nil {
return result, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'recover',?,'recovered','alert-stream-evaluator','Kafka 事件时间恢复条件满足')`, event.ID, event.Status); err != nil {
return result, err
}
result.Recovered++
}
delete(activeEvents, fingerprint)
}
}
for _, item := range candidateDeletes {
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_candidate WHERE rule_id=? AND vin=? AND protocol=?`, item.RuleID, item.VIN, item.Protocol); err != nil {
return result, err
}
}
candidateItems := make([]alertCandidateUpsert, 0, len(candidateUpserts))
for _, item := range candidateUpserts {
candidateItems = append(candidateItems, item)
}
for start := 0; start < len(candidateItems); start += alertCandidateBatchSize {
end := min(start+alertCandidateBatchSize, len(candidateItems))
query, args := buildAlertCandidateUpsert(candidateItems[start:end])
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
return result, err
}
}
stateItems := make([]alertRuleStateUpsert, 0, len(stateUpserts))
for _, item := range stateUpserts {
stateItems = append(stateItems, item)
}
for start := 0; start < len(stateItems); start += alertCandidateBatchSize {
end := min(start+alertCandidateBatchSize, len(stateItems))
query, args := buildAlertRuleStateUpsert(stateItems[start:end])
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
return result, err
}
}
return result, nil
}
func alertStreamScope(rules []AlertRule, records []AlertStreamRecord) ([]string, []string, []string) {
ruleIDs := make([]string, 0, len(rules))
vins := make([]string, 0, len(records))
protocols := make([]string, 0, 3)
seenRules, seenVINs, seenProtocols := map[string]bool{}, map[string]bool{}, map[string]bool{}
for _, rule := range rules {
if !seenRules[rule.ID] {
seenRules[rule.ID] = true
ruleIDs = append(ruleIDs, rule.ID)
}
}
for _, record := range records {
if !seenVINs[record.VIN] {
seenVINs[record.VIN] = true
vins = append(vins, record.VIN)
}
if !seenProtocols[record.Protocol] {
seenProtocols[record.Protocol] = true
protocols = append(protocols, record.Protocol)
}
}
return ruleIDs, vins, protocols
}
func alertStreamScopeWhere(rules []AlertRule, records []AlertStreamRecord) (string, []any) {
ruleIDs, vins, protocols := alertStreamScope(rules, records)
placeholders := func(count int) string { return strings.TrimSuffix(strings.Repeat("?,", count), ",") }
args := make([]any, 0, len(ruleIDs)+len(vins)+len(protocols))
for _, value := range ruleIDs {
args = append(args, value)
}
for _, value := range vins {
args = append(args, value)
}
for _, value := range protocols {
args = append(args, value)
}
return `rule_id IN (` + placeholders(len(ruleIDs)) + `) AND vin IN (` + placeholders(len(vins)) + `) AND protocol IN (` + placeholders(len(protocols)) + `)`, args
}
func loadAlertStreamCandidates(ctx context.Context, tx *sql.Tx, rules []AlertRule, records []AlertStreamRecord) (map[string]alertCandidateState, error) {
where, args := alertStreamScopeWhere(rules, records)
rows, err := tx.QueryContext(ctx, `SELECT rule_id,vin,protocol,first_matched_at,last_matched_at,source_event_id FROM vehicle_alert_candidate WHERE `+where, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := map[string]alertCandidateState{}
for rows.Next() {
var ruleID, vin, protocol string
var state alertCandidateState
if err := rows.Scan(&ruleID, &vin, &protocol, &state.FirstMatchedAt, &state.LastMatchedAt, &state.SourceEventID); err != nil {
return nil, err
}
items[ruleID+"|"+vin+"|"+protocol] = state
}
return items, rows.Err()
}
func loadActiveAlertStreamEvents(ctx context.Context, tx *sql.Tx, rules []AlertRule, records []AlertStreamRecord) (map[string][]alertActiveEvent, error) {
where, args := alertStreamScopeWhere(rules, records)
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,id,status FROM vehicle_alert_event WHERE status IN ('unprocessed','processing') AND `+where+` FOR UPDATE`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := map[string][]alertActiveEvent{}
for rows.Next() {
var fingerprint string
var event alertActiveEvent
if err := rows.Scan(&fingerprint, &event.ID, &event.Status); err != nil {
return nil, err
}
items[fingerprint] = append(items[fingerprint], event)
}
return items, rows.Err()
}
func loadAlertStreamRuleStates(ctx context.Context, tx *sql.Tx, rules []AlertRule, records []AlertStreamRecord) (map[string]alertRuleState, error) {
where, args := alertStreamScopeWhere(rules, records)
rows, err := tx.QueryContext(ctx, `SELECT rule_id,vin,protocol,observed_value,last_observed_at FROM vehicle_alert_rule_state WHERE `+where, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := map[string]alertRuleState{}
for rows.Next() {
var ruleID, vin, protocol string
var state alertRuleState
if err := rows.Scan(&ruleID, &vin, &protocol, &state.LastValue, &state.LastObservedAt); err != nil {
return nil, err
}
items[ruleID+"|"+vin+"|"+protocol] = state
}
return items, rows.Err()
}
func loadAlertStreamLastTriggered(ctx context.Context, tx *sql.Tx, rules []AlertRule, records []AlertStreamRecord) (map[string]time.Time, error) {
where, args := alertStreamScopeWhere(rules, records)
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,MAX(triggered_at) FROM vehicle_alert_event WHERE `+where+` GROUP BY fingerprint`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := map[string]time.Time{}
for rows.Next() {
var fingerprint string
var triggeredAt time.Time
if err := rows.Scan(&fingerprint, &triggeredAt); err != nil {
return nil, err
}
items[fingerprint] = triggeredAt
}
return items, rows.Err()
}
func loadActiveAlertStreamRules(ctx context.Context, tx *sql.Tx) ([]AlertRule, error) {
rows, err := tx.QueryContext(ctx, alertRuleSelect+`WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)
if err != nil {
return nil, err
}
defer rows.Close()
rules := []AlertRule{}
for rows.Next() {
rule, scanErr := scanAlertRule(rows)
if scanErr != nil {
return nil, scanErr
}
rules = append(rules, rule)
}
return rules, rows.Err()
}
func loadAlertStreamMetricMappings(ctx context.Context, tx *sql.Tx) (alertStreamMetricMapping, error) {
rows, err := tx.QueryContext(ctx, `SELECT m.metric_key,m.protocol,m.source_field FROM vehicle_metric_protocol_mapping m JOIN vehicle_metric_definition d ON d.metric_key=m.metric_key WHERE d.enabled=1 AND (d.alertable=1 OR d.metric_key IN ('longitude','latitude'))`)
if err != nil {
return nil, err
}
defer rows.Close()
result := alertStreamMetricMapping{}
for rows.Next() {
var metric, protocol, source string
if err := rows.Scan(&metric, &protocol, &source); err != nil {
return nil, err
}
if result[metric] == nil {
result[metric] = map[string]string{}
}
result[metric][strings.ToUpper(protocol)] = source
}
return result, rows.Err()
}
func loadAlertStreamVehicleMetadata(ctx context.Context, tx *sql.Tx, records []AlertStreamRecord) (map[string]alertStreamVehicleMetadata, error) {
vins := make([]string, 0, len(records))
seen := map[string]bool{}
for _, record := range records {
if !seen[record.VIN] {
seen[record.VIN] = true
vins = append(vins, record.VIN)
}
}
if len(vins) == 0 {
return map[string]alertStreamVehicleMetadata{}, nil
}
selects := make([]string, len(vins))
args := make([]any, len(vins))
for i, vin := range vins {
selects[i] = "SELECT ? AS vin"
args[i] = vin
}
query := `SELECT v.vin,COALESCE(MAX(NULLIF(b.plate,'')),''),COALESCE(MAX(b.oem),''),COALESCE(MAX(p.model_name),''),COALESCE(MAX(p.company_name),'') FROM (` + strings.Join(selects, " UNION ALL ") + `) v LEFT JOIN vehicle_identity_binding b ON b.vin=v.vin LEFT JOIN vehicle_profile p ON p.vin=v.vin GROUP BY v.vin`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := map[string]alertStreamVehicleMetadata{}
for rows.Next() {
var vin string
var item alertStreamVehicleMetadata
if err := rows.Scan(&vin, &item.Plate, &item.OEM, &item.Model, &item.Company); err != nil {
return nil, err
}
result[vin] = item
}
return result, rows.Err()
}
func alertStreamEvidence(record AlertStreamRecord, metadata alertStreamVehicleMetadata, mappings alertStreamMetricMapping) alertEvaluationEvidence {
longitude, _ := alertStreamLocationValue("longitude", record, mappings)
latitude, _ := alertStreamLocationValue("latitude", record, mappings)
plate := firstNonEmpty(record.Plate, metadata.Plate)
return alertEvaluationEvidence{
VIN: record.VIN, Plate: plate, Protocol: record.Protocol, OEM: metadata.OEM, Model: metadata.Model, Company: metadata.Company,
SourceEventID: record.SourceEventID,
EventAt: record.EventAt.Format("2006-01-02 15:04:05.999"),
ReceivedAt: record.ReceivedAt.Format("2006-01-02 15:04:05.999"),
Longitude: longitude, Latitude: latitude,
Location: fmt.Sprintf("%.6f,%.6f", longitude, latitude),
}
}
func alertStreamLocationValue(metric string, record AlertStreamRecord, mappings alertStreamMetricMapping) (float64, bool) {
if source := mappings[metric][strings.ToUpper(record.Protocol)]; source != "" && !strings.EqualFold(source, "vehicle_realtime_location."+metric) {
return alertStreamRawNumber(record.Fields[source])
}
aliases := map[string]map[string][]string{
"longitude": {
"GB32960": {"gb32960.position.longitude"}, "JT808": {"jt808.location.longitude"}, "YUTONG_MQTT": {"yutong_mqtt.data.longitude", "yutong_mqtt.root.data.longitude"},
},
"latitude": {
"GB32960": {"gb32960.position.latitude"}, "JT808": {"jt808.location.latitude"}, "YUTONG_MQTT": {"yutong_mqtt.data.latitude", "yutong_mqtt.root.data.latitude"},
},
}
for _, source := range aliases[metric][strings.ToUpper(record.Protocol)] {
if value, ok := alertStreamRawNumber(record.Fields[source]); ok {
return value, true
}
}
return 0, false
}
func alertStreamMetricValue(metric string, record AlertStreamRecord, mappings alertStreamMetricMapping) (float64, bool) {
metric = strings.ToLower(strings.TrimSpace(metric))
if metric == "data_delay_sec" {
return record.ReceivedAt.Sub(record.EventAt).Seconds(), true
}
source := mappings[metric][strings.ToUpper(record.Protocol)]
if source == "" || strings.EqualFold(source, "received_at-event_time") || strings.HasPrefix(source, "vehicle_realtime_location.") {
return 0, false
}
value, ok := alertStreamRawNumber(record.Fields[source])
if ok && metric == "alarm_active" {
if value != 0 {
return 1, true
}
return 0, true
}
return value, ok
}
func alertStreamRawNumber(raw json.RawMessage) (float64, bool) {
if len(raw) == 0 || string(raw) == "null" {
return 0, false
}
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return 0, false
}
switch typed := value.(type) {
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
case string:
text := strings.TrimSpace(typed)
if strings.HasPrefix(strings.ToLower(text), "0x") {
parsed, err := strconv.ParseUint(text[2:], 16, 64)
return float64(parsed), err == nil
}
parsed, err := strconv.ParseFloat(text, 64)
return parsed, err == nil
case bool:
if typed {
return 1, true
}
return 0, true
default:
return 0, false
}
}

View File

@@ -0,0 +1,210 @@
package platform
import (
"encoding/json"
"errors"
"reflect"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestDecodeAlertStreamRecordValidatesContractAndLateness(t *testing.T) {
payload := []byte(`{"event_id":"evt:fields","event_kind":"FIELDS","source_event_id":"evt","field_mapping":"2026-07-03.v1","protocol":"JT808","vin":"VIN1","plate":"川A10001","event_time_ms":1783980000000,"received_at_ms":1783980181000,"fields":{"jt808.location.speed_kmh":88}}`)
record := DecodeAlertStreamRecord("vehicle.fields.go.jt808.v1", 2, 10, 20, payload, 2*time.Minute)
if !record.Valid || !record.Late || record.ErrorCode != "" || record.SourceEventID != "evt" || record.Protocol != "JT808" {
t.Fatalf("unexpected valid record: %+v", record)
}
if delay := record.ReceivedAt.Sub(record.EventAt); delay != 181*time.Second {
t.Fatalf("event/receipt delay=%s", delay)
}
if value, ok := alertStreamRawNumber(record.Fields["jt808.location.speed_kmh"]); !ok || value != 88 {
t.Fatalf("decoded canonical fields were not retained: value=%v ok=%v", value, ok)
}
badTopic := DecodeAlertStreamRecord("vehicle.fields.go.gb32960.v1", 0, 1, 2, payload, time.Minute)
if badTopic.Valid || badTopic.ErrorCode != "protocol_topic_mismatch" {
t.Fatalf("topic/protocol mismatch accepted: %+v", badTopic)
}
badField := []byte(`{"event_id":"evt:fields","event_kind":"FIELDS","source_event_id":"evt","field_mapping":"v1","protocol":"JT808","vin":"VIN1","event_time_ms":1783980000000,"received_at_ms":1783980001000,"fields":{"speed_kmh":88}}`)
record = DecodeAlertStreamRecord("vehicle.fields.go.jt808.v1", 0, 1, 2, badField, time.Minute)
if record.Valid || record.ErrorCode != "invalid_field_name" {
t.Fatalf("unscoped field accepted: %+v", record)
}
}
func TestAlertStreamMetricValueUsesCatalogMappingAndStrictMissingSemantics(t *testing.T) {
record := AlertStreamRecord{
Protocol: "GB32960", EventAt: time.Unix(100, 0), ReceivedAt: time.Unix(130, 0),
Fields: map[string]json.RawMessage{
"gb32960.vehicle.speed_kmh": json.RawMessage(`"82.5"`),
"gb32960.alarm.general_alarm_flag": json.RawMessage(`"0x00000004"`),
},
}
mappings := alertStreamMetricMapping{
"speed_kmh": {"GB32960": "gb32960.vehicle.speed_kmh"},
"soc_percent": {"GB32960": "gb32960.vehicle.soc_percent"},
"alarm_active": {"GB32960": "gb32960.alarm.general_alarm_flag"},
}
if value, ok := alertStreamMetricValue("speed_kmh", record, mappings); !ok || value != 82.5 {
t.Fatalf("mapped numeric field = %v,%v", value, ok)
}
if value, ok := alertStreamMetricValue("alarm_active", record, mappings); !ok || value != 1 {
t.Fatalf("hex alarm field = %v,%v", value, ok)
}
if _, ok := alertStreamMetricValue("soc_percent", record, mappings); ok {
t.Fatal("missing SOC must not be coerced to zero")
}
if value, ok := alertStreamMetricValue("data_delay_sec", record, mappings); !ok || value != 30 {
t.Fatalf("derived delay = %v,%v", value, ok)
}
}
func TestAlertStreamStateQueriesAreScopedToBatchRulesVehiclesAndProtocols(t *testing.T) {
rules := []AlertRule{{ID: "r2"}, {ID: "r1"}, {ID: "r2"}}
records := []AlertStreamRecord{{VIN: "VIN1", Protocol: "JT808"}, {VIN: "VIN2", Protocol: "GB32960"}, {VIN: "VIN1", Protocol: "JT808"}}
where, args := alertStreamScopeWhere(rules, records)
if where != "rule_id IN (?,?) AND vin IN (?,?) AND protocol IN (?,?)" {
t.Fatalf("unexpected scoped predicate: %s", where)
}
want := []any{"r2", "r1", "VIN1", "VIN2", "JT808", "GB32960"}
if !reflect.DeepEqual(args, want) {
t.Fatalf("scope args=%#v want=%#v", args, want)
}
}
func TestActiveAlertStreamCommitsRuleEffectsBeforeCheckpointInSameTransaction(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "").WithAlertStreamConfig("active", "alert-active")
record := AlertStreamRecord{Topic: "vehicle.fields.go.jt808.v1", Partition: 0, Offset: 7, HighWatermark: 8, Protocol: "JT808", VIN: "VIN1", SourceEventID: "evt-7", EventAt: time.Now(), ReceivedAt: time.Now(), Fields: map[string]json.RawMessage{"jt808.location.speed_kmh": json.RawMessage(`10`)}, Valid: true}
checkpointQuery := `SELECT next_offset FROM vehicle_alert_stream_checkpoint WHERE consumer_group=? AND topic=? AND partition_id=? FOR UPDATE`
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(checkpointQuery)).WithArgs("alert-active", record.Topic, 0).WillReturnRows(sqlmock.NewRows([]string{"next_offset"}))
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec(`INSERT INTO vehicle_alert_stream_checkpoint`).WithArgs("alert-active", record.Topic, 0, int64(8), int64(8), int64(1), int64(1), int64(0), int64(0), int64(0), record.EventAt, record.ReceivedAt, "evt-7", "", "").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
result, err := store.RecordAlertStreamBatch(t.Context(), "alert-active", []AlertStreamRecord{record})
if err != nil || result.Processed != 1 {
t.Fatalf("active batch failed: result=%+v err=%v", result, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestActiveAlertStreamRollsBackCheckpointWhenRuleEvaluationFails(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "").WithAlertStreamConfig("active", "alert-active")
record := AlertStreamRecord{Topic: "vehicle.fields.go.jt808.v1", Partition: 0, Offset: 9, HighWatermark: 10, Protocol: "JT808", VIN: "VIN1", SourceEventID: "evt-9", EventAt: time.Now(), ReceivedAt: time.Now(), Fields: map[string]json.RawMessage{"jt808.location.speed_kmh": json.RawMessage(`10`)}, Valid: true}
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT next_offset FROM vehicle_alert_stream_checkpoint WHERE consumer_group=? AND topic=? AND partition_id=? FOR UPDATE`)).WithArgs("alert-active", record.Topic, 0).WillReturnRows(sqlmock.NewRows([]string{"next_offset"}))
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnError(errors.New("rule read failed"))
mock.ExpectRollback()
if _, err := store.RecordAlertStreamBatch(t.Context(), "alert-active", []AlertStreamRecord{record}); err == nil {
t.Fatal("rule evaluation failure must abort checkpoint transaction")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDecodeAlertStreamRecordNormalizesMissingAndFutureEventTime(t *testing.T) {
for _, eventMS := range []int64{0, 1783981000001} {
payload := []byte(`{"event_id":"evt:fields","event_kind":"FIELDS","source_event_id":"evt","field_mapping":"v1","protocol":"JT808","vin":"VIN1","event_time_ms":` + itoa64(eventMS) + `,"received_at_ms":1783980000000,"fields":{"jt808.location.speed_kmh":1}}`)
record := DecodeAlertStreamRecord("vehicle.fields.go.jt808.v1", 0, 1, 2, payload, time.Minute)
if !record.Valid || !record.EventAt.Equal(record.ReceivedAt) || record.Late {
t.Fatalf("event time was not normalized: %+v", record)
}
}
}
func TestAlertStreamCheckpointMakesDatabaseOffsetAuthoritative(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
eventAt := time.Date(2026, 7, 14, 7, 0, 0, 123000000, time.Local)
receivedAt := eventAt.Add(3 * time.Minute)
records := []AlertStreamRecord{
{Topic: "vehicle.fields.go.jt808.v1", Partition: 0, Offset: 10, HighWatermark: 20, Valid: true, Late: true, EventAt: eventAt, ReceivedAt: receivedAt, SourceEventID: "evt-10"},
{Topic: "vehicle.fields.go.jt808.v1", Partition: 0, Offset: 11, HighWatermark: 20, Valid: false, ErrorCode: "invalid_json"},
}
checkpointQuery := `SELECT next_offset FROM vehicle_alert_stream_checkpoint WHERE consumer_group=? AND topic=? AND partition_id=? FOR UPDATE`
checkpointInsert := `INSERT INTO vehicle_alert_stream_checkpoint`
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(checkpointQuery)).WithArgs("alert-shadow", "vehicle.fields.go.jt808.v1", 0).WillReturnRows(sqlmock.NewRows([]string{"next_offset"}))
mock.ExpectExec(checkpointInsert).WithArgs("alert-shadow", "vehicle.fields.go.jt808.v1", 0, int64(12), int64(20), int64(2), int64(1), int64(1), int64(1), int64(0), eventAt, receivedAt, "evt-10", "invalid_json", "invalid_json").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
result, err := store.RecordAlertStreamBatch(t.Context(), "alert-shadow", records)
if err != nil {
t.Fatal(err)
}
if result.Processed != 2 || result.Valid != 1 || result.Invalid != 1 || result.Late != 1 || result.ReplaySkipped != 0 || result.Partitions != 1 {
t.Fatalf("unexpected first checkpoint result: %+v", result)
}
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(checkpointQuery)).WithArgs("alert-shadow", "vehicle.fields.go.jt808.v1", 0).WillReturnRows(sqlmock.NewRows([]string{"next_offset"}).AddRow(12))
mock.ExpectExec(checkpointInsert).WithArgs("alert-shadow", "vehicle.fields.go.jt808.v1", 0, int64(12), int64(20), int64(0), int64(0), int64(0), int64(0), int64(2), nil, nil, "", "", "").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
replayed, err := store.RecordAlertStreamBatch(t.Context(), "alert-shadow", records)
if err != nil {
t.Fatal(err)
}
if replayed.Processed != 0 || replayed.ReplaySkipped != 2 {
t.Fatalf("database checkpoint did not suppress replay: %+v", replayed)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAlertStreamCheckpointHealthExposesLagAndQuality(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "").WithAlertStreamConfig("shadow", "alert-shadow")
query := `SELECT COUNT(*),COALESCE(SUM(GREATEST(high_watermark-next_offset,0)),0),COALESCE(SUM(processed_count),0),COALESCE(SUM(valid_count),0),COALESCE(SUM(invalid_count),0),COALESCE(SUM(late_count),0),COALESCE(SUM(replay_skipped_count),0),MAX(updated_at),COALESCE((SELECT c2.last_invalid_code FROM vehicle_alert_stream_checkpoint c2 WHERE c2.consumer_group=? AND c2.last_invalid_at IS NOT NULL ORDER BY c2.last_invalid_at DESC LIMIT 1),''),MAX(last_invalid_at) FROM vehicle_alert_stream_checkpoint WHERE consumer_group=?`
now := time.Now()
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs("alert-shadow", "alert-shadow").WillReturnRows(sqlmock.NewRows([]string{"partitions", "lag", "processed", "valid", "invalid", "late", "replay", "updated_at", "last_invalid_code", "last_invalid_at"}).AddRow(9, 3, 1000, 999, 1, 4, 2, now, "missing_vin_jt808", now))
health, status := store.alertStreamCheckpointHealth(t.Context())
if health.Status != "warning" || status.Mode != "shadow" || status.Partitions != 9 || status.Lag != 3 || status.Processed != 1000 || status.Invalid != 1 || status.Late != 4 || status.ReplaySkipped != 2 || status.UpdatedAt == "" || status.LastInvalidCode != "missing_vin_jt808" || status.LastInvalidAt == "" {
t.Fatalf("checkpoint health lost evidence: health=%+v status=%+v", health, status)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func itoa64(value int64) string {
if value == 0 {
return "0"
}
negative := value < 0
if negative {
value = -value
}
digits := make([]byte, 0, 20)
for value > 0 {
digits = append([]byte{byte('0' + value%10)}, digits...)
value /= 10
}
if negative {
digits = append([]byte{'-'}, digits...)
}
return string(digits)
}

View File

@@ -0,0 +1,499 @@
package platform
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
type configuredMetricStore struct {
*MockStore
definitions []MetricDefinition
}
func (s *configuredMetricStore) MetricDefinitions(context.Context) ([]MetricDefinition, error) {
return append([]MetricDefinition(nil), s.definitions...), nil
}
func BenchmarkAlertEvaluationPlan10000x20(b *testing.B) {
evidence := make([]alertEvaluationEvidence, 10_000)
for index := range evidence {
evidence[index] = alertEvaluationEvidence{VIN: "VIN" + strconv.Itoa(index), Protocol: "JT808", SpeedKmh: float64(index % 120), FreshnessSec: 5}
}
rules := make([]AlertRule, 20)
for index := range rules {
rules[index] = AlertRule{ID: "rule" + strconv.Itoa(index), Enabled: true, Metric: "speed_kmh", Operator: "gt", Threshold: float64(60 + index)}
}
now := time.Now()
b.ResetTimer()
for iteration := 0; iteration < b.N; iteration++ {
upserts := make([]alertCandidateUpsert, 0, len(evidence))
for _, rule := range rules {
for _, item := range evidence {
value, ok := alertMetricValue(rule.Metric, item)
if ok && alertRuleInScope(rule, item) && compareAlertValue(value, rule.Operator, rule.Threshold) {
upserts = append(upserts, alertCandidateUpsert{RuleID: rule.ID, VIN: item.VIN, Protocol: item.Protocol, FirstMatchedAt: now, LastMatchedAt: now, LatestValue: value})
}
}
}
for start := 0; start < len(upserts); start += alertCandidateBatchSize {
end := min(start+alertCandidateBatchSize, len(upserts))
_, args := buildAlertCandidateUpsert(upserts[start:end])
if len(args) == 0 {
b.Fatal("expected batched candidates")
}
}
}
}
func TestAlertEventWorkflowIsVersionedAndAudited(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
list := httptest.NewRecorder()
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/alerts/events", bytes.NewBufferString(`{"status":"unprocessed","limit":20}`)))
if list.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", list.Code, list.Body.String())
}
var body struct {
Data Page[AlertEvent] `json:"data"`
}
if err := json.Unmarshal(list.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Data.Total < 2 {
t.Fatalf("expected seeded unprocessed events, got %+v", body.Data)
}
event := body.Data.Items[0]
action := func(version int) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v2/alerts/events/"+event.ID+"/actions", bytes.NewBufferString(`{"version":`+itoa(version)+`,"action":"acknowledge","note":"已联系司机","actor":"spoofed-body-user"}`))
req.Header.Set("X-User-Name", "spoofed-header-user")
req = req.WithContext(WithPrincipal(req.Context(), Principal{Name: "operator-a", Role: "operator"}))
handler.ServeHTTP(rec, req)
return rec
}
rec := action(event.Version)
if rec.Code != http.StatusOK {
t.Fatalf("action status=%d body=%s", rec.Code, rec.Body.String())
}
var updated struct {
Data AlertEvent `json:"data"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &updated)
if updated.Data.Status != "processing" || updated.Data.Handler != "operator-a" || len(updated.Data.Actions) < 2 {
t.Fatalf("workflow not persisted: %+v", updated.Data)
}
stale := action(event.Version)
if stale.Code != http.StatusConflict {
t.Fatalf("stale update should conflict, status=%d body=%s", stale.Code, stale.Body.String())
}
}
func TestAlertEventsActiveStatusIncludesOnlyOpenWorkflowStates(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AlertEvents(t.Context(), AlertQuery{Status: "active", Limit: 200})
if err != nil {
t.Fatal(err)
}
if page.Total == 0 {
t.Fatal("expected seeded active alerts")
}
for _, event := range page.Items {
if event.Status != "unprocessed" && event.Status != "processing" {
t.Fatalf("active query returned terminal state: %+v", event)
}
}
if where, args := buildAlertWhere(AlertQuery{Status: "active"}); !strings.Contains(where, "status IN ('unprocessed','processing')") || len(args) != 0 {
t.Fatalf("active SQL filter is not authoritative: where=%s args=%v", where, args)
}
}
func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
service := NewService(NewMockStore())
truth := true
rule, err := service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "主电源异常", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &truth, ScopeModels: []string{"纯电客车", "纯电客车"}, ScopeCompanies: []string{"示范公交"}, NotificationChannels: []string{"sms", "sms"}, Enabled: true})
if err != nil {
t.Fatal(err)
}
if rule.Threshold != 1 {
t.Fatalf("boolean threshold should normalize to numeric evaluator value: %+v", rule)
}
if len(rule.NotificationChannels) != 2 || rule.NotificationChannels[0] != "in_app" {
t.Fatalf("channels should include unique in-app truth: %+v", rule.NotificationChannels)
}
if len(rule.ScopeModels) != 1 || rule.ScopeModels[0] != "纯电客车" || len(rule.ScopeCompanies) != 1 {
t.Fatalf("master-data scopes should normalize and round-trip: %+v", rule)
}
_, err = service.SaveAlertRule(t.Context(), AlertRuleInput{ID: rule.ID, Version: 99, Name: rule.Name, Severity: rule.Severity, ValueType: rule.ValueType, Metric: rule.Metric, Operator: rule.Operator, BooleanThreshold: &truth})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ALERT_RULE_VERSION_CONFLICT" {
t.Fatalf("expected optimistic conflict, got %v", err)
}
}
func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
service := NewService(NewMockStore())
before, err := service.AlertSummary(t.Context(), AlertQuery{})
if err != nil {
t.Fatal(err)
}
page, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{UnreadOnly: true, Limit: 20})
if err != nil {
t.Fatal(err)
}
if page.Total != before.UnreadNotifications || page.Total == 0 {
t.Fatalf("unread truth differs: summary=%+v page=%+v", before, page)
}
count, err := service.MarkAlertNotificationsRead(t.Context(), AlertNotificationReadRequest{IDs: []int64{page.Items[0].ID}, Actor: "operator-a"})
if err != nil || count != 1 {
t.Fatalf("mark read count=%d err=%v", count, err)
}
after, _ := service.AlertSummary(t.Context(), AlertQuery{})
if after.UnreadNotifications != before.UnreadNotifications-1 {
t.Fatalf("unread should recalculate: before=%d after=%d", before.UnreadNotifications, after.UnreadNotifications)
}
}
func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
item := alertEvaluationEvidence{VIN: "VIN1", Protocol: "JT808", OEM: "宇通", Model: "纯电客车", Company: "示范公交", SpeedKmh: 96, AlarmFlag: 1}
rule := AlertRule{Metric: "speed_kmh", Operator: "gt", Threshold: 80, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{"VIN1"}}
value, ok := alertMetricValue(rule.Metric, item)
if !ok || !compareAlertValue(value, rule.Operator, rule.Threshold) || !alertRuleInScope(rule, item) {
t.Fatalf("numeric rule should match current evidence")
}
if compareAlertValue(75, "gt", 80) || !compareAlertValue(75, "lte", 75) {
t.Fatal("comparison boundary incorrect")
}
rule.ScopeVINs = []string{"OTHER"}
if alertRuleInScope(rule, item) {
t.Fatal("VIN scope must prevent cross-vehicle evaluation")
}
rule.ScopeVINs = nil
rule.ScopeOEMs = []string{"其他厂家"}
if alertRuleInScope(rule, item) {
t.Fatal("OEM scope must prevent cross-manufacturer evaluation")
}
rule.ScopeOEMs = nil
rule.ScopeModels = []string{"氢燃料重卡"}
if alertRuleInScope(rule, item) {
t.Fatal("model scope must use authoritative vehicle profile")
}
rule.ScopeModels = []string{"纯电客车"}
rule.ScopeCompanies = []string{"其他企业"}
if alertRuleInScope(rule, item) {
t.Fatal("company scope must use authoritative vehicle profile")
}
if !compareAlertRuleValue(80, AlertRule{Operator: "between", Threshold: 70, ThresholdHigh: 90}) || compareAlertRuleValue(95, AlertRule{Operator: "between", Threshold: 70, ThresholdHigh: 90}) {
t.Fatal("between comparison boundary incorrect")
}
if !compareAlertRuleValue(95, AlertRule{Operator: "outside", Threshold: 70, ThresholdHigh: 90}) || compareAlertRuleValue(80, AlertRule{Operator: "outside", Threshold: 70, ThresholdHigh: 90}) {
t.Fatal("outside comparison boundary incorrect")
}
}
func TestAlertRuleSQLMatchesMasterDataScopeArguments(t *testing.T) {
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 23 {
t.Fatalf("alert insert placeholders=%d query=%s", placeholders, alertRuleInsertSQL)
}
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 23 {
t.Fatalf("alert update placeholders=%d query=%s", placeholders, alertRuleUpdateSQL)
}
}
func TestAlertRuleScopeBoundsProtectEvaluator(t *testing.T) {
values := make([]string, 501)
for index := range values {
values[index] = "company-" + strconv.Itoa(index)
}
err := validateAlertRuleScopes(AlertRuleInput{ScopeCompanies: values})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ALERT_RULE_SCOPE_TOO_LARGE" {
t.Fatalf("large scope should be rejected, got %v", err)
}
}
func TestAlertCandidateUpsertBatchesRows(t *testing.T) {
now := time.Now()
query, args := buildAlertCandidateUpsert([]alertCandidateUpsert{
{RuleID: "r1", VIN: "v1", Protocol: "JT808", FirstMatchedAt: now, LastMatchedAt: now, LatestValue: 91, SourceEventID: "e1"},
{RuleID: "r1", VIN: "v2", Protocol: "GB32960", FirstMatchedAt: now, LastMatchedAt: now, LatestValue: 92, SourceEventID: "e2"},
})
if strings.Count(query, "(?,?,?,?,?,?,?)") != 2 || len(args) != 14 || !strings.Contains(query, "ON DUPLICATE KEY UPDATE") {
t.Fatalf("unexpected batch query=%s args=%#v", query, args)
}
}
func TestAlertStateLoadersPreserveMySQLDatetimeMilliseconds(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectBegin()
tx, err := db.BeginTx(t.Context(), nil)
if err != nil {
t.Fatal(err)
}
first := time.Date(2026, 7, 14, 10, 0, 0, 123000000, time.Local)
last := first.Add(20*time.Second + 333*time.Millisecond)
candidateSQL := `SELECT c.rule_id,c.vin,c.protocol,c.first_matched_at,c.last_matched_at,c.source_event_id FROM vehicle_alert_candidate c JOIN vehicle_alert_rule r ON r.id=c.rule_id AND r.enabled=1`
mock.ExpectQuery(regexp.QuoteMeta(candidateSQL)).WillReturnRows(sqlmock.NewRows([]string{"rule_id", "vin", "protocol", "first_matched_at", "last_matched_at", "source_event_id"}).AddRow("r1", "v1", "JT808", first, last, "event-2"))
candidates, err := loadAlertCandidates(t.Context(), tx)
if err != nil {
t.Fatal(err)
}
candidate := candidates["r1|v1|JT808"]
if !candidate.FirstMatchedAt.Equal(first) || !candidate.LastMatchedAt.Equal(last) || candidate.SourceEventID != "event-2" {
t.Fatalf("candidate datetime precision lost: %+v", candidate)
}
stateSQL := `SELECT s.rule_id,s.vin,s.protocol,s.observed_value,s.last_observed_at FROM vehicle_alert_rule_state s JOIN vehicle_alert_rule r ON r.id=s.rule_id AND r.enabled=1`
mock.ExpectQuery(regexp.QuoteMeta(stateSQL)).WillReturnRows(sqlmock.NewRows([]string{"rule_id", "vin", "protocol", "observed_value", "last_observed_at"}).AddRow("r2", "v2", "GB32960", 1.0, last))
states, err := loadAlertRuleStates(t.Context(), tx)
if err != nil {
t.Fatal(err)
}
state := states["r2|v2|GB32960"]
if state.LastValue != 1 || !state.LastObservedAt.Equal(last) {
t.Fatalf("rule state datetime precision lost: %+v", state)
}
lastTriggeredSQL := `SELECT e.fingerprint,MAX(e.triggered_at) FROM vehicle_alert_event e JOIN vehicle_alert_rule r ON r.id=e.rule_id AND r.enabled=1 GROUP BY e.fingerprint`
mock.ExpectQuery(regexp.QuoteMeta(lastTriggeredSQL)).WillReturnRows(sqlmock.NewRows([]string{"fingerprint", "triggered_at"}).AddRow("r1|v1|JT808", last))
lastTriggered, err := loadAlertLastTriggered(t.Context(), tx)
if err != nil {
t.Fatal(err)
}
if !lastTriggered["r1|v1|JT808"].Equal(last) {
t.Fatalf("repeat timestamp precision lost: %s", lastTriggered["r1|v1|JT808"])
}
mock.ExpectRollback()
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAlertRuleDisableIsAtomicAndClearsEvaluatorState(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
columns := []string{"id", "name", "description", "severity", "value_type", "metric", "operator", "threshold_value", "threshold_high", "boolean_threshold", "duration_sec", "recovery_operator", "recovery_threshold", "repeat_interval_sec", "scope_protocols_json", "scope_vins_json", "scope_oems_json", "scope_models_json", "scope_companies_json", "notification_channels_json", "enabled", "version", "created_by", "updated_by", "created_at", "updated_at"}
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE id=?`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows(columns).AddRow("rule-1", "超速", "", "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_candidate WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
rule, err := store.SetAlertRuleEnabled(t.Context(), "rule-1", AlertRuleEnabledUpdate{Version: 1, Enabled: false, Actor: "admin-a"})
if err != nil {
t.Fatal(err)
}
if rule.Enabled || rule.Version != 2 {
t.Fatalf("unexpected disabled rule: %+v", rule)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAlertEvaluationLocksRuleAgainstConcurrentDisable(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectBegin()
tx, err := db.BeginTx(t.Context(), nil)
if err != nil {
t.Fatal(err)
}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT enabled FROM vehicle_alert_rule WHERE id=? FOR UPDATE`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows([]string{"enabled"}).AddRow(true))
enabled, err := lockAlertRuleForEvaluation(t.Context(), tx, "rule-1")
if err != nil || !enabled {
t.Fatalf("rule lock enabled=%t err=%v", enabled, err)
}
mock.ExpectRollback()
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAlertRuleStateUpsertBatchesChangedValues(t *testing.T) {
now := time.Now()
query, args := buildAlertRuleStateUpsert([]alertRuleStateUpsert{{RuleID: "r1", VIN: "v1", Protocol: "JT808", LastValue: 1, ObservedAt: now}, {RuleID: "r1", VIN: "v2", Protocol: "GB32960", LastValue: 0, ObservedAt: now}})
if strings.Count(query, "(?,?,?,?,?)") != 2 || len(args) != 10 || !strings.Contains(query, "ON DUPLICATE KEY UPDATE") {
t.Fatalf("unexpected state batch query=%s args=%#v", query, args)
}
}
func TestAlertEventInsertContractMatchesArguments(t *testing.T) {
rule := AlertRule{ID: "rule-1", Name: "超速", Version: 3, Severity: "major", Metric: "speed_kmh", Operator: "between", Threshold: 60, ThresholdHigh: 90, DurationSec: 30}
item := alertEvaluationEvidence{VIN: "VIN1", Plate: "川A10001", Protocol: "JT808", SourceEventID: "event-1", EventAt: "2026-07-14 10:00:00.000", ReceivedAt: "2026-07-14 10:00:01.000", Location: "104.1,30.6", Longitude: 104.1, Latitude: 30.6}
query, args := buildAlertEventInsert("alert-1", "rule-1|VIN1|JT808", rule, item, 75)
if placeholders := strings.Count(query, "?"); placeholders != len(args) {
t.Fatalf("event insert placeholders=%d args=%d query=%s", placeholders, len(args), query)
}
if len(args) != 22 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
t.Fatalf("unexpected event insert contract args=%#v query=%s", args, query)
}
}
func TestAlertRuleValidationCoversRangesAndStateChange(t *testing.T) {
base := AlertRuleInput{Name: "区间", Severity: "major", ValueType: "numeric", Metric: "soc_percent", Operator: "between", Threshold: 20, ThresholdHigh: 80}
if err := validateAlertRule(base); err != nil {
t.Fatalf("valid range rejected: %v", err)
}
base.ThresholdHigh = 10
if err := validateAlertRule(base); err == nil {
t.Fatal("inverted range should be rejected")
}
changed := AlertRuleInput{Name: "状态变化", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "changed"}
if err := validateAlertRule(changed); err != nil {
t.Fatalf("boolean state change rejected: %v", err)
}
unknown := AlertRuleInput{Name: "未知指标", Severity: "major", ValueType: "numeric", Metric: "client_supplied_sql", Operator: "gt"}
if err := validateAlertRule(unknown); err == nil {
t.Fatal("metric outside the server catalog should be rejected")
}
mismatched := AlertRuleInput{Name: "类型错误", Severity: "major", ValueType: "boolean", Metric: "speed_kmh", Operator: "eq", BooleanThreshold: new(bool)}
if err := validateAlertRule(mismatched); err == nil {
t.Fatal("metric catalog type mismatch should be rejected")
}
}
func TestMetricCatalogAndAlertValidationShareStoreConfiguration(t *testing.T) {
definitions := metricDefinitions()
for index := range definitions {
if definitions[index].Key == "speed_kmh" {
definitions[index].Label = "数据库配置速度"
definitions[index].Alertable = false
}
}
store := &configuredMetricStore{MockStore: NewMockStore(), definitions: definitions}
service := NewService(store)
catalog, err := service.MetricCatalog(t.Context())
if err != nil {
t.Fatal(err)
}
if catalog.Metrics[0].Label != "数据库配置速度" {
t.Fatalf("catalog did not use store definitions: %+v", catalog.Metrics[0])
}
_, err = service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "应被禁用", Severity: "major", ValueType: "numeric", Metric: "speed_kmh", Operator: "gt"})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ALERT_RULE_METRIC_INVALID" {
t.Fatalf("rule validation must share configured catalog, got %v", err)
}
}
func TestAlertRepeatIntervalBoundary(t *testing.T) {
now := time.Now()
if alertRepeatAllowed(now, now.Add(-59*time.Second), 60) {
t.Fatal("repeat should remain suppressed inside interval")
}
if !alertRepeatAllowed(now, now.Add(-60*time.Second), 60) || !alertRepeatAllowed(now, time.Time{}, 60) {
t.Fatal("repeat should open at boundary or without history")
}
}
func TestAlertCandidateDurationOnlyAdvancesWithNewEventTime(t *testing.T) {
first := time.Date(2026, 7, 14, 10, 0, 0, 0, time.Local)
candidate, decision := advanceAlertCandidate(alertCandidateState{}, false, first, "event-1", false)
if decision != alertObservationAdvanced || !candidate.FirstMatchedAt.Equal(first) || !candidate.LastMatchedAt.Equal(first) {
t.Fatalf("first observation did not create candidate: decision=%v candidate=%+v", decision, candidate)
}
unchanged, decision := advanceAlertCandidate(candidate, true, first.Add(30*time.Second), "event-1", false)
if decision != alertObservationDuplicate || !unchanged.LastMatchedAt.Equal(first) {
t.Fatalf("same source event must not invent duration: decision=%v candidate=%+v", decision, unchanged)
}
advanced, decision := advanceAlertCandidate(candidate, true, first.Add(30*time.Second), "event-2", false)
if decision != alertObservationAdvanced || advanced.LastMatchedAt.Sub(advanced.FirstMatchedAt) != 30*time.Second {
t.Fatalf("newer event should advance event-time duration: decision=%v candidate=%+v", decision, advanced)
}
late, decision := advanceAlertCandidate(advanced, true, first.Add(20*time.Second), "event-late", false)
if decision != alertObservationLate || late.SourceEventID != "event-2" {
t.Fatalf("late event should not regress candidate: decision=%v candidate=%+v", decision, late)
}
}
func TestAlertCandidateContinuityGapResetsEventTimeWindow(t *testing.T) {
first := time.Date(2026, 7, 14, 10, 0, 0, 0, time.Local)
current := alertCandidateState{FirstMatchedAt: first, LastMatchedAt: first.Add(30 * time.Second), SourceEventID: "event-2"}
nextAt := current.LastMatchedAt.Add(alertCandidateContinuityWindow + time.Second)
next, decision := advanceAlertCandidate(current, true, nextAt, "event-3", false)
if decision != alertObservationAdvanced || !next.FirstMatchedAt.Equal(nextAt) || !next.LastMatchedAt.Equal(nextAt) {
t.Fatalf("continuity gap should start a new window: decision=%v candidate=%+v", decision, next)
}
}
func TestFreshnessCandidateIsExplicitlyClockDriven(t *testing.T) {
first := time.Date(2026, 7, 14, 10, 0, 0, 0, time.Local)
item := alertEvaluationEvidence{SourceEventID: "same-event", EventAt: "2026-07-14 09:55:00.000000"}
observedAt, clockDriven, ok := alertObservationTime("freshness_sec", item, first)
if !ok || !clockDriven || !observedAt.Equal(first) {
t.Fatalf("freshness observation must use evaluator clock: at=%s clock=%t ok=%t", observedAt, clockDriven, ok)
}
candidate := alertCandidateState{FirstMatchedAt: first, LastMatchedAt: first, SourceEventID: item.SourceEventID}
next, decision := advanceAlertCandidate(candidate, true, first.Add(10*time.Second), item.SourceEventID, true)
if decision != alertObservationAdvanced || next.LastMatchedAt.Sub(next.FirstMatchedAt) != 10*time.Second {
t.Fatalf("freshness should advance while source event is unchanged: decision=%v candidate=%+v", decision, next)
}
}
func TestAlertObservationTimePrefersEventTimeAndFallsBackToReceipt(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.Local)
eventItem := alertEvaluationEvidence{EventAt: "2026-07-14 10:00:30.123000", ReceivedAt: "2026-07-14 10:00:31.000000"}
observedAt, clockDriven, ok := alertObservationTime("speed_kmh", eventItem, now)
if !ok || clockDriven || observedAt.Second() != 30 || observedAt.Nanosecond() != 123000000 {
t.Fatalf("event time not selected: at=%s clock=%t ok=%t", observedAt, clockDriven, ok)
}
receiptItem := alertEvaluationEvidence{ReceivedAt: "2026-07-14 10:00:31.000000"}
observedAt, _, ok = alertObservationTime("speed_kmh", receiptItem, now)
if !ok || observedAt.Second() != 31 {
t.Fatalf("receipt fallback not selected: at=%s ok=%t", observedAt, ok)
}
}
func TestSnapshotEvaluatorKeepsOnlyFreshnessRulesWhenStreamIsActive(t *testing.T) {
rules := []AlertRule{{ID: "speed", Metric: "speed_kmh"}, {ID: "fresh", Metric: "freshness_sec"}, {ID: "delay", Metric: "data_delay_sec"}}
if got := snapshotAlertRules(rules, "shadow"); len(got) != 3 {
t.Fatalf("shadow mode filtered rules: %+v", got)
}
got := snapshotAlertRules(rules, "active")
if len(got) != 1 || got[0].ID != "fresh" {
t.Fatalf("active snapshot ownership is not freshness-only: %+v", got)
}
}
func itoa(value int) string {
if value == 0 {
return "0"
}
digits := []byte{}
for value > 0 {
digits = append([]byte{byte('0' + value%10)}, digits...)
value /= 10
}
return string(digits)
}

View File

@@ -52,6 +52,258 @@ func (h *Handler) routes() {
h.mux.HandleFunc("GET /api/alert-events/notification-plan", h.handleQualityNotificationPlan)
h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth)
h.mux.HandleFunc("GET /api/ops/source-readiness", h.handleSourceReadiness)
h.mux.HandleFunc("GET /api/v2/monitor/summary", h.handleMonitorSummary)
h.mux.HandleFunc("GET /api/v2/monitor/map", h.handleMonitorMap)
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/profile", h.handleVehicleProfile)
h.mux.HandleFunc("PUT /api/v2/vehicles/{vin}/profile", h.handleSaveVehicleProfile)
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/telemetry/latest", h.handleLatestTelemetry)
h.mux.HandleFunc("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles)
h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback)
h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog)
h.mux.HandleFunc("GET /api/v2/history/metrics", h.handleHistoryMetricCatalog)
h.mux.HandleFunc("GET /api/v2/history/query", h.handleHistoryData)
h.mux.HandleFunc("GET /api/v2/history/series", h.handleHistorySeries)
h.mux.HandleFunc("POST /api/v2/exports", h.handleCreateHistoryExport)
h.mux.HandleFunc("GET /api/v2/exports", h.handleListHistoryExports)
h.mux.HandleFunc("GET /api/v2/exports/{id}/download", h.handleDownloadHistoryExport)
h.mux.HandleFunc("POST /api/v2/access/summary", h.handleAccessSummary)
h.mux.HandleFunc("POST /api/v2/access/vehicles", h.handleAccessVehicles)
h.mux.HandleFunc("POST /api/v2/access/unresolved-identities", h.handleAccessUnresolvedIdentities)
h.mux.HandleFunc("GET /api/v2/access/thresholds", h.handleAccessThresholds)
h.mux.HandleFunc("PUT /api/v2/access/thresholds", h.handleUpdateAccessThresholds)
h.mux.HandleFunc("POST /api/v2/alerts/summary", h.handleAlertSummary)
h.mux.HandleFunc("POST /api/v2/alerts/events", h.handleAlertEvents)
h.mux.HandleFunc("GET /api/v2/alerts/events/{id}", h.handleAlertEvent)
h.mux.HandleFunc("POST /api/v2/alerts/events/{id}/actions", h.handleAlertAction)
h.mux.HandleFunc("GET /api/v2/alerts/rules", h.handleAlertRules)
h.mux.HandleFunc("POST /api/v2/alerts/rules", h.handleSaveAlertRule)
h.mux.HandleFunc("PUT /api/v2/alerts/rules/{id}", h.handleSaveAlertRule)
h.mux.HandleFunc("PUT /api/v2/alerts/rules/{id}/enabled", h.handleAlertRuleEnabled)
h.mux.HandleFunc("GET /api/v2/alerts/notifications", h.handleAlertNotifications)
h.mux.HandleFunc("POST /api/v2/alerts/notifications/read", h.handleAlertNotificationsRead)
}
func (h *Handler) handleAccessUnresolvedIdentities(w http.ResponseWriter, r *http.Request) {
var query AccessUnresolvedIdentityQuery
if !decodeJSONBody(w, r, &query) {
return
}
data, err := h.service.AccessUnresolvedIdentities(r.Context(), query)
h.write(w, r, data, err)
}
func (h *Handler) handleVehicleProfile(w http.ResponseWriter, r *http.Request) {
data, err := h.service.VehicleProfile(r.Context(), r.PathValue("vin"))
h.write(w, r, data, err)
}
func (h *Handler) handleLatestTelemetry(w http.ResponseWriter, r *http.Request) {
data, err := h.service.LatestTelemetry(r.Context(), r.PathValue("vin"))
h.write(w, r, data, err)
}
func (h *Handler) handleSaveVehicleProfile(w http.ResponseWriter, r *http.Request) {
var input VehicleProfileInput
if !decodeJSONBody(w, r, &input) {
return
}
input.Actor = ActorFromContext(r.Context())
data, err := h.service.SaveVehicleProfile(r.Context(), r.PathValue("vin"), input)
h.write(w, r, data, err)
}
func (h *Handler) handleSyncVehicleProfiles(w http.ResponseWriter, r *http.Request) {
var request VehicleProfileSyncRequest
if !decodeJSONBody(w, r, &request) {
return
}
request.Actor = ActorFromContext(r.Context())
data, err := h.service.SyncVehicleProfiles(r.Context(), request)
h.write(w, r, data, err)
}
func (h *Handler) handleMetricCatalog(w http.ResponseWriter, r *http.Request) {
data, err := h.service.MetricCatalog(r.Context())
h.write(w, r, data, err)
}
func (h *Handler) handleAlertSummary(w http.ResponseWriter, r *http.Request) {
var query AlertQuery
if !decodeJSONBody(w, r, &query) {
return
}
data, err := h.service.AlertSummary(r.Context(), query)
h.write(w, r, data, err)
}
func (h *Handler) handleAlertEvents(w http.ResponseWriter, r *http.Request) {
var query AlertQuery
if !decodeJSONBody(w, r, &query) {
return
}
data, err := h.service.AlertEvents(r.Context(), query)
h.write(w, r, data, err)
}
func (h *Handler) handleAlertEvent(w http.ResponseWriter, r *http.Request) {
data, err := h.service.AlertEvent(r.Context(), r.PathValue("id"))
h.write(w, r, data, err)
}
func (h *Handler) handleAlertAction(w http.ResponseWriter, r *http.Request) {
var request AlertActionRequest
if !decodeJSONBody(w, r, &request) {
return
}
request.Actor = ActorFromContext(r.Context())
data, err := h.service.ActOnAlert(r.Context(), r.PathValue("id"), request)
h.write(w, r, data, err)
}
func (h *Handler) handleAlertRules(w http.ResponseWriter, r *http.Request) {
data, err := h.service.AlertRules(r.Context())
h.write(w, r, data, err)
}
func (h *Handler) handleSaveAlertRule(w http.ResponseWriter, r *http.Request) {
var input AlertRuleInput
if !decodeJSONBody(w, r, &input) {
return
}
if pathID := r.PathValue("id"); pathID != "" {
input.ID = pathID
}
input.Actor = ActorFromContext(r.Context())
data, err := h.service.SaveAlertRule(r.Context(), input)
h.write(w, r, data, err)
}
func (h *Handler) handleAlertRuleEnabled(w http.ResponseWriter, r *http.Request) {
var update AlertRuleEnabledUpdate
if !decodeJSONBody(w, r, &update) {
return
}
update.Actor = ActorFromContext(r.Context())
data, err := h.service.SetAlertRuleEnabled(r.Context(), r.PathValue("id"), update)
h.write(w, r, data, err)
}
func (h *Handler) handleAlertNotifications(w http.ResponseWriter, r *http.Request) {
limit := parsePositive(r.URL.Query().Get("limit"), 20)
offset := parsePositive(r.URL.Query().Get("offset"), 0)
unread := strings.EqualFold(r.URL.Query().Get("unreadOnly"), "true") || r.URL.Query().Get("unreadOnly") == "1"
data, err := h.service.AlertNotifications(r.Context(), AlertNotificationQuery{UnreadOnly: unread, Limit: limit, Offset: offset})
h.write(w, r, data, err)
}
func (h *Handler) handleAlertNotificationsRead(w http.ResponseWriter, r *http.Request) {
var request AlertNotificationReadRequest
if !decodeJSONBody(w, r, &request) {
return
}
request.Actor = ActorFromContext(r.Context())
count, err := h.service.MarkAlertNotificationsRead(r.Context(), request)
h.write(w, r, map[string]int{"updated": count}, err)
}
func (h *Handler) handleAccessSummary(w http.ResponseWriter, r *http.Request) {
var query AccessQuery
if !decodeJSONBody(w, r, &query) {
return
}
data, err := h.service.AccessSummary(r.Context(), query)
h.write(w, r, data, err)
}
func (h *Handler) handleAccessVehicles(w http.ResponseWriter, r *http.Request) {
var query AccessQuery
if !decodeJSONBody(w, r, &query) {
return
}
data, err := h.service.AccessVehicles(r.Context(), query)
h.write(w, r, data, err)
}
func (h *Handler) handleAccessThresholds(w http.ResponseWriter, r *http.Request) {
data, err := h.service.AccessThresholds(r.Context())
h.write(w, r, data, err)
}
func (h *Handler) handleUpdateAccessThresholds(w http.ResponseWriter, r *http.Request) {
var update AccessThresholdUpdate
if !decodeJSONBody(w, r, &update) {
return
}
update.Actor = ActorFromContext(r.Context())
data, err := h.service.UpdateAccessThresholds(r.Context(), update)
h.write(w, r, data, err)
}
func decodeJSONBody(w http.ResponseWriter, r *http.Request, target any) bool {
defer r.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", err.Error(), traceID(r))
return false
}
return true
}
func (h *Handler) handleCreateHistoryExport(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var request HistoryExportRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", err.Error(), traceID(r))
return
}
data, err := h.service.CreateHistoryExport(request)
h.write(w, r, data, err)
}
func (h *Handler) handleListHistoryExports(w http.ResponseWriter, r *http.Request) {
h.write(w, r, h.service.ListHistoryExports(), nil)
}
func (h *Handler) handleDownloadHistoryExport(w http.ResponseWriter, r *http.Request) {
path, name, err := h.service.HistoryExportFile(r.PathValue("id"))
if err != nil {
h.write(w, r, nil, err)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="history-export.csv"`)
w.Header().Set("X-Export-Name", name)
http.ServeFile(w, r, path)
}
func (h *Handler) handleHistoryMetricCatalog(w http.ResponseWriter, r *http.Request) {
h.write(w, r, h.service.HistoryMetricCatalog(), nil)
}
func (h *Handler) handleHistoryData(w http.ResponseWriter, r *http.Request) {
data, err := h.service.HistoryData(r.Context(), r.URL.Query())
h.write(w, r, data, err)
}
func (h *Handler) handleHistorySeries(w http.ResponseWriter, r *http.Request) {
data, err := h.service.HistorySeries(r.Context(), r.URL.Query())
h.write(w, r, data, err)
}
func (h *Handler) handleTrackPlayback(w http.ResponseWriter, r *http.Request) {
data, err := h.service.TrackPlayback(r.Context(), r.URL.Query())
h.write(w, r, data, err)
}
func (h *Handler) handleMonitorSummary(w http.ResponseWriter, r *http.Request) {
data, err := h.service.MonitorSummary(r.Context(), r.URL.Query())
h.write(w, r, data, err)
}
func (h *Handler) handleMonitorMap(w http.ResponseWriter, r *http.Request) {
data, err := h.service.MonitorMap(r.Context(), r.URL.Query())
h.write(w, r, data, err)
}
func (h *Handler) handleDashboardSummary(w http.ResponseWriter, r *http.Request) {
@@ -214,7 +466,14 @@ func (h *Handler) handleSourceReadiness(w http.ResponseWriter, r *http.Request)
func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err error) {
if err != nil {
if clientErr, ok := asClientError(err); ok {
httpx.WriteError(w, http.StatusBadRequest, clientErr.Code, clientErr.Message, "", traceID(r))
status := http.StatusBadRequest
switch {
case strings.HasSuffix(clientErr.Code, "_NOT_FOUND"):
status = http.StatusNotFound
case strings.HasSuffix(clientErr.Code, "_CONFLICT"), clientErr.Code == "ALERT_ACTION_NOT_ALLOWED":
status = http.StatusConflict
}
httpx.WriteError(w, status, clientErr.Code, clientErr.Message, "", traceID(r))
return
}
httpx.WriteError(w, http.StatusInternalServerError, "INTERNAL", "服务处理失败", err.Error(), traceID(r))

View File

@@ -8,6 +8,7 @@ import (
"net/url"
"strings"
"testing"
"time"
)
func TestHandlerDashboardSummary(t *testing.T) {
@@ -26,6 +27,140 @@ func TestHandlerDashboardSummary(t *testing.T) {
}
}
func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct {
path string
want string
}{
{path: "/api/v2/monitor/summary", want: "drivingVehicles"},
{path: "/api/v2/monitor/map?zoom=5", want: "clusters"},
{path: "/api/v2/monitor/map?zoom=11", want: "points"},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, test.path, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d body=%s", test.path, rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), test.want) {
t.Fatalf("%s missing %q: %s", test.path, test.want, rec.Body.String())
}
if test.path == "/api/v2/monitor/summary" && (!strings.Contains(rec.Body.String(), `"alertDataAvailable":true`) || !strings.Contains(rec.Body.String(), `"alertVehicles":1`)) {
t.Fatalf("monitor summary must expose filtered active alert vehicles: %s", rec.Body.String())
}
}
}
func TestHandlerRejectsInvalidMonitorBounds(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
request := httptest.NewRequest(http.MethodGet, "/api/v2/monitor/map?zoom=12&bounds=105,31,103,29", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "MONITOR_BOUNDS_INVALID") {
t.Fatalf("unexpected invalid bounds response: status=%d body=%s", response.Code, response.Body.String())
}
}
func TestHandlerV2AccessManagement(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct {
method string
path string
body string
want string
}{
{method: http.MethodPost, path: "/api/v2/access/summary", body: `{}`, want: `"neverReported":1`},
{method: http.MethodPost, path: "/api/v2/access/vehicles", body: `{"onlineState":"offline","limit":10}`, want: `"onlineState":"offline"`},
{method: http.MethodGet, path: "/api/v2/access/thresholds", want: `"defaultThresholdSec":300`},
{method: http.MethodPut, path: "/api/v2/access/thresholds", body: `{"version":1,"defaultThresholdSec":600,"delayThresholdSec":60,"longOfflineSec":3600,"protocols":[{"protocol":"JT808","thresholdSec":300}],"actor":"handler-test"}`, want: `"version":2`},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s %s status=%d body=%s", test.method, test.path, rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), test.want) {
t.Fatalf("%s %s missing %s body=%s", test.method, test.path, test.want, rec.Body.String())
}
}
}
func TestHandlerV2TrackPlayback(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v2/tracks?keyword=%E5%B7%9DAHTWO1&maxPoints=8", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data TrackPlaybackResponse `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
}
if body.Data.VIN != "LNXNEGRR7SR318212" || body.Data.Summary.PointCount < 10 {
t.Fatalf("track should resolve vehicle and summarize full point set: %+v", body.Data)
}
if len(body.Data.Points) != 8 || !body.Data.Sampled {
t.Fatalf("track should server-sample map payload to requested bound: %+v", body.Data)
}
if len(body.Data.Events) < 2 || len(body.Data.Sources) != 1 {
t.Fatalf("track should expose events and source evidence: %+v", body.Data)
}
}
func TestHandlerV2HistoryCatalogAndMultiVehicleQuery(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct{ path, want string }{
{path: "/api/v2/metrics", want: "sourceFields"},
{path: "/api/v2/vehicles/%E5%B7%9DAHTWO1/telemetry/latest", want: "sourceField"},
{path: "/api/v2/history/metrics", want: "dailyMileageKm"},
{path: "/api/v2/history/query?keywords=%E5%B7%9DAHTWO1,%E7%B2%A4AG18312&category=location&limit=10", want: "queryDurationMs"},
{path: "/api/v2/history/query?keyword=%E5%B7%9DAHTWO1&category=raw&limit=10", want: "gb32960.vehicle.speed_kmh"},
{path: "/api/v2/history/series?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-07-03T18%3A00&dateTo=2026-07-03T21%3A00&targetPoints=120", want: "grainSeconds"},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, test.path, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", test.path, rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), test.want) {
t.Fatalf("%s missing %q body=%s", test.path, test.want, rec.Body.String())
}
}
}
func TestHistorySeriesGrainAndBounds(t *testing.T) {
if got := historySeriesGrain(24*time.Hour, 240); got != 900 {
t.Fatalf("24h / 240 should select the next safe nice bucket, got %d", got)
}
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct{ path, code string }{
{path: "/api/v2/history/series?category=raw&keyword=%E5%B7%9DAHTWO1", code: "HISTORY_SERIES_CATEGORY_UNSUPPORTED"},
{path: "/api/v2/history/series?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-01-01T00%3A00&dateTo=2026-03-01T00%3A00", code: "HISTORY_TIME_RANGE_TOO_LARGE"},
} {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, test.path, nil))
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), test.code) {
t.Fatalf("%s expected %s, status=%d body=%s", test.path, test.code, rec.Code, rec.Body.String())
}
}
}
func TestHandlerV2HistoryRequiresBoundedVehicleScope(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v2/history/query?category=location", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "VEHICLE_KEY_REQUIRED") {
t.Fatalf("unbounded history query should be rejected: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandlerVehicles(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()

View File

@@ -0,0 +1,54 @@
package platform
import (
"context"
"encoding/json"
"fmt"
)
func (s *ProductionStore) MetricDefinitions(ctx context.Context) ([]MetricDefinition, error) {
rows, err := s.db.QueryContext(ctx, `SELECT metric_key,label,description,unit,category,value_type,protocols_json,searchable,chartable,alertable
FROM vehicle_metric_definition WHERE enabled=1 ORDER BY sort_order,metric_key`)
if err != nil {
return nil, fmt.Errorf("metric catalog unavailable; apply deploy/migrations/005_metric_catalog.sql: %w", err)
}
defer rows.Close()
definitions := make([]MetricDefinition, 0, 16)
byKey := map[string]int{}
for rows.Next() {
var definition MetricDefinition
var protocols string
if err := rows.Scan(&definition.Key, &definition.Label, &definition.Description, &definition.Unit, &definition.Category, &definition.ValueType, &protocols, &definition.Searchable, &definition.Chartable, &definition.Alertable); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(protocols), &definition.Protocols); err != nil {
return nil, fmt.Errorf("decode metric %s protocols: %w", definition.Key, err)
}
definition.SourceFields = map[string]string{}
definitions = append(definitions, definition)
byKey[definition.Key] = len(definitions) - 1
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(definitions) == 0 {
return nil, fmt.Errorf("metric catalog has no enabled definitions")
}
mappings, err := s.db.QueryContext(ctx, `SELECT m.metric_key,m.protocol,m.source_field
FROM vehicle_metric_protocol_mapping m JOIN vehicle_metric_definition d ON d.metric_key=m.metric_key AND d.enabled=1
ORDER BY m.metric_key,m.protocol`)
if err != nil {
return nil, err
}
defer mappings.Close()
for mappings.Next() {
var key, protocol, sourceField string
if err := mappings.Scan(&key, &protocol, &sourceField); err != nil {
return nil, err
}
if index, ok := byKey[key]; ok {
definitions[index].SourceFields[protocol] = sourceField
}
}
return definitions, mappings.Err()
}

View File

@@ -6,11 +6,23 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
)
type MockStore struct {
vehicles []VehicleRow
locations []RealtimeLocationRow
vehicles []VehicleRow
locations []RealtimeLocationRow
accessMu sync.RWMutex
accessThresholds AccessThresholdConfig
profileMu sync.RWMutex
profiles map[string]VehicleProfile
alertMu sync.RWMutex
alertRules []AlertRule
alertEvents []AlertEvent
alertNotifications []AlertNotification
nextAlertActionID int64
nextNotificationID int64
}
func NewMockStore() *MockStore {
@@ -21,14 +33,157 @@ func NewMockStore() *MockStore {
{VIN: "LMRKH9AC2R1004087", Plate: "豫A88888", Phone: "", OEM: "宇通", Protocol: "YUTONG_MQTT", Online: true, LastSeen: "2026-07-03 20:11:59", LocationText: "上海市临港", BindingScore: 92},
{VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Phone: "13307795426", OEM: "广安车联", Protocol: "JT808", Online: false, LastSeen: "2026-07-03 19:58:00", LocationText: "广东省佛山市", BindingScore: 88},
}
return &MockStore{
vehicles: vehicles,
store := &MockStore{
vehicles: vehicles,
accessThresholds: defaultAccessThresholds(time.Now()),
profiles: map[string]VehicleProfile{
"LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"},
},
locations: []RealtimeLocationRow{
{VIN: vehicles[0].VIN, Plate: vehicles[0].Plate, Protocol: vehicles[0].Protocol, Longitude: 113.2644, Latitude: 23.1291, SpeedKmh: 42.5, SOCPercent: 76.2, TotalMileageKm: 48798.9, LastSeen: vehicles[0].LastSeen},
{VIN: vehicles[1].VIN, Plate: vehicles[1].Plate, Protocol: vehicles[1].Protocol, Longitude: 104.0668, Latitude: 30.5728, SpeedKmh: 18.3, SOCPercent: 64.8, TotalMileageKm: 119925, LastSeen: vehicles[1].LastSeen},
{VIN: vehicles[2].VIN, Plate: vehicles[2].Plate, Protocol: vehicles[2].Protocol, Longitude: 121.075044, Latitude: 30.590921, SpeedKmh: 27, SOCPercent: 78.4, TotalMileageKm: 119925, LastSeen: vehicles[2].LastSeen},
},
}
store.seedAlertCenter()
return store
}
func int64Pointer(value int64) *int64 { return &value }
func (m *MockStore) VehicleProfile(_ context.Context, vin string) (VehicleProfile, bool, error) {
m.profileMu.RLock()
defer m.profileMu.RUnlock()
profile, ok := m.profiles[vin]
return profile, ok, nil
}
func (m *MockStore) SaveVehicleProfile(_ context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) {
m.profileMu.Lock()
defer m.profileMu.Unlock()
current, exists := m.profiles[vin]
if !exists && input.Version != 0 {
return VehicleProfile{}, clientError{Code: "VEHICLE_PROFILE_NOT_FOUND", Message: "待更新车辆档案不存在"}
}
if exists && input.Version != current.Version {
return VehicleProfile{}, clientError{Code: "VEHICLE_PROFILE_VERSION_CONFLICT", Message: "车辆档案已被其他用户更新,请刷新后重试"}
}
version := 1
if exists {
version = current.Version + 1
}
profile := VehicleProfile{VIN: vin, ModelName: input.ModelName, VehicleType: input.VehicleType, CompanyName: input.CompanyName, OperationStatus: input.OperationStatus, AccessProvider: input.AccessProvider, FirstAccessAt: input.FirstAccessAt, RuntimeSeconds: input.RuntimeSeconds, SourceSystem: "manual", Version: version, UpdatedBy: input.Actor, UpdatedAt: time.Now().Format(time.RFC3339)}
m.profiles[vin] = profile
return profile, nil
}
func (m *MockStore) SyncVehicleProfiles(_ context.Context, request VehicleProfileSyncRequest) (VehicleProfileSyncResult, error) {
m.profileMu.Lock()
defer m.profileMu.Unlock()
result := VehicleProfileSyncResult{
SourceSystem: request.SourceSystem, SourceVersion: request.SourceVersion,
DryRun: request.DryRun, Received: len(request.Items),
Items: make([]VehicleProfileSyncItemResult, 0, len(request.Items)),
}
known := make(map[string]bool, len(m.vehicles))
for _, vehicle := range m.vehicles {
known[vehicle.VIN] = true
}
now := time.Now().Format(time.RFC3339)
for _, item := range request.Items {
itemResult := VehicleProfileSyncItemResult{VIN: item.VIN}
if !known[item.VIN] {
itemResult.Status = profileSyncMissingVehicle
countVehicleProfileSyncResult(&result, itemResult.Status)
result.Items = append(result.Items, itemResult)
continue
}
current, exists := m.profiles[item.VIN]
if exists {
itemResult.PreviousSource = current.SourceSystem
itemResult.PreviousVersion = current.SourceVersion
}
itemResult.Status = vehicleProfileSyncDecision(current, exists, request, item)
itemResult.ProfileVersion = current.Version
if itemResult.Status == profileSyncCreated {
itemResult.ProfileVersion = 1
} else if itemResult.Status == profileSyncUpdated {
itemResult.ProfileVersion = current.Version + 1
}
countVehicleProfileSyncResult(&result, itemResult.Status)
result.Items = append(result.Items, itemResult)
if request.DryRun || (itemResult.Status != profileSyncCreated && itemResult.Status != profileSyncUpdated) {
continue
}
m.profiles[item.VIN] = VehicleProfile{
VIN: item.VIN, ModelName: item.ModelName, VehicleType: item.VehicleType,
CompanyName: item.CompanyName, OperationStatus: item.OperationStatus,
AccessProvider: item.AccessProvider, FirstAccessAt: item.FirstAccessAt,
RuntimeSeconds: item.RuntimeSeconds, SourceSystem: request.SourceSystem,
SourceVersion: request.SourceVersion, SyncedAt: now,
Version: itemResult.ProfileVersion, UpdatedBy: request.Actor, UpdatedAt: now,
}
}
return result, nil
}
func (m *MockStore) AccessEvidence(context.Context) ([]AccessEvidenceRow, error) {
now := time.Now()
seconds := func(value int) string { return now.Add(-time.Duration(value) * time.Second).Format(time.RFC3339) }
interval10, interval30, interval60 := 10, 30, 60
return []AccessEvidenceRow{
{VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", OEM: "比亚迪", Model: "G7s", Company: "岭牛示范车队", Protocol: "JT808", Provider: "G7", Source: "vehicle_realtime_snapshot", FirstSeenAt: now.AddDate(0, -4, 0).Format(time.RFC3339), LatestEventAt: seconds(13), LatestReceivedAt: seconds(12), LatestUpdatedAt: seconds(12), ReportIntervalSec: &interval10, ReportSampleCount: 120, LatestMessageType: "位置信息汇报", LatestEventID: "evt-jt808-001", FirstSeenEvidence: "网关实时写入首次观测", FirstSeenSource: "live_writer", ReportIntervalProof: "网关连续接收时间差(持久样本 120 条)"},
{VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", OEM: "现代", Model: "氢燃料车型", Company: "示范物流", Protocol: "GB32960", Provider: "车厂平台", Source: "vehicle_realtime_snapshot", FirstSeenAt: now.AddDate(0, -2, 0).Format(time.RFC3339), LatestEventAt: seconds(57), LatestReceivedAt: seconds(12), LatestUpdatedAt: seconds(12), ReportIntervalSec: &interval30, ReportSampleCount: 48, LatestMessageType: "实时信息上报", LatestEventID: "evt-gb32960-002", LatestError: "上游事件时间延迟", FirstSeenEvidence: "网关实时写入首次观测", FirstSeenSource: "live_writer", ReportIntervalProof: "网关连续接收时间差(持久样本 48 条)"},
{VIN: "LMRKH9AC2R1004087", Plate: "豫A88888", OEM: "宇通", Model: "纯电客车", Protocol: "YUTONG_MQTT", Provider: "宇通云", Source: "vehicle_realtime_snapshot", FirstSeenAt: now.AddDate(0, -8, 0).Format(time.RFC3339), LatestEventAt: seconds(2400), LatestReceivedAt: seconds(2398), LatestUpdatedAt: seconds(2398), ReportIntervalSec: &interval60, LatestMessageType: "实时遥测", LatestEventID: "evt-mqtt-003", FirstSeenEvidence: "测试接入台账", ReportIntervalProof: "最近两次接收时间差"},
{VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", OEM: "广安车联", Model: "运营车辆", Protocol: "JT808", Provider: "广安车联", Source: "vehicle_realtime_snapshot", FirstSeenAt: now.AddDate(-1, 0, 0).Format(time.RFC3339), LatestEventAt: seconds(9100), LatestReceivedAt: seconds(9098), LatestUpdatedAt: seconds(9098), ReportIntervalSec: &interval30, LatestMessageType: "位置信息汇报", LatestEventID: "evt-jt808-004", LatestError: "来源链路长时间无更新", FirstSeenEvidence: "测试接入台账", ReportIntervalProof: "最近两次接收时间差"},
{VIN: "LUNKNOWNACCESS01", Plate: "粤A待核1", OEM: "待维护", Protocol: "UNKNOWN", Provider: "未知平台", Source: "vehicle_realtime_snapshot", LatestEventAt: "invalid-time", LatestMessageType: "协议未识别", LatestError: "协议或时间字段无法识别"},
{VIN: "LNEVERREPORT0001", Plate: "粤A未报1", OEM: "档案车辆", Model: "待接入", Source: "vehicle_identity_binding", LatestError: "车辆已建档但从未形成实时快照"},
}, nil
}
func (m *MockStore) AccessUnresolvedIdentities(_ context.Context, query AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error) {
items := []AccessUnresolvedIdentity{{
ID: "mock-unresolved-jt808", Protocol: "JT808", IdentifierMasked: "138****0001", Plate: "待维护",
Manufacturer: "示范终端", SourceEndpoint: "gateway-a", LatestSeenAt: time.Now().Add(-30 * time.Second).Format(time.RFC3339),
FreshnessSec: 30, IssueCode: "missing_vin_jt808", RecommendedAction: "核对终端手机号、车牌和厂家后维护 phone→VIN 权威绑定;禁止猜测 VIN",
}}
if query.Offset >= len(items) {
return Page[AccessUnresolvedIdentity]{Items: []AccessUnresolvedIdentity{}, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
}
return Page[AccessUnresolvedIdentity]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
}
func (m *MockStore) AccessThresholds(context.Context) (AccessThresholdConfig, error) {
m.accessMu.RLock()
defer m.accessMu.RUnlock()
config := m.accessThresholds
config.Protocols = append([]AccessProtocolThreshold(nil), config.Protocols...)
config.Audit = append([]AccessThresholdAudit(nil), config.Audit...)
return config, nil
}
func (m *MockStore) SaveAccessThresholds(_ context.Context, update AccessThresholdUpdate) (AccessThresholdConfig, error) {
m.accessMu.Lock()
defer m.accessMu.Unlock()
if update.Version != m.accessThresholds.Version {
return AccessThresholdConfig{}, clientError{Code: "ACCESS_THRESHOLD_VERSION_CONFLICT", Message: "阈值配置已被其他用户更新,请刷新后重试"}
}
now := time.Now().Format(time.RFC3339)
next := AccessThresholdConfig{
Version: update.Version + 1,
DefaultThresholdSec: update.DefaultThresholdSec,
DelayThresholdSec: update.DelayThresholdSec,
LongOfflineSec: update.LongOfflineSec,
Protocols: append([]AccessProtocolThreshold(nil), update.Protocols...),
UpdatedBy: update.Actor,
UpdatedAt: now,
Audit: append([]AccessThresholdAudit{{Version: update.Version + 1, Actor: update.Actor, ChangedAt: now, Summary: "更新在线、延迟和长离线阈值"}}, m.accessThresholds.Audit...),
}
if len(next.Audit) > 10 {
next.Audit = next.Audit[:10]
}
m.accessThresholds = next
return next, nil
}
func (m *MockStore) DashboardSummary(context.Context) (DashboardSummary, error) {
@@ -396,6 +551,9 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
continue
}
}
if status := strings.TrimSpace(query.Get("status")); status != "" && !matchesMonitorStatus(*row, status) {
continue
}
if !keepServiceStatus(row.ServiceStatus, query.Get("serviceStatus")) {
continue
}
@@ -521,14 +679,105 @@ func (m *MockStore) HistoryLocations(ctx context.Context, query url.Values) (Pag
func (m *MockStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
realtime, _ := m.RealtimeLocations(ctx, query)
rows := make([]HistoryLocationRow, 0, len(realtime.Items))
rows := make([]HistoryLocationRow, 0, len(realtime.Items)*18)
speeds := []float64{0, 8, 18, 32, 61, 44, 12, 0, 16, 43, 72, 38, 26, 54, 80, 41, 14, 0}
for _, row := range realtime.Items {
rows = append(rows, HistoryLocationRow{
VIN: row.VIN, Plate: row.Plate, Protocol: row.Protocol, Longitude: row.Longitude, Latitude: row.Latitude,
SpeedKmh: row.SpeedKmh, TotalMileageKm: row.TotalMileageKm, DeviceTime: row.LastSeen, ServerTime: row.LastSeen,
})
endTime, ok := parseVehicleServiceTime(row.LastSeen)
if !ok {
endTime = time.Date(2026, 7, 3, 20, 12, 0, 0, time.UTC)
}
for index, speed := range speeds {
remaining := len(speeds) - 1 - index
observedAt := endTime.Add(-time.Duration(remaining*5) * time.Minute).Format("2006-01-02 15:04:05")
rows = append(rows, HistoryLocationRow{
VIN: row.VIN, Plate: row.Plate, Protocol: row.Protocol,
Longitude: row.Longitude - float64(remaining)*0.0062,
Latitude: row.Latitude - float64(remaining)*0.0021 + float64(index%3)*0.0008,
SpeedKmh: speed, TotalMileageKm: row.TotalMileageKm - float64(remaining)*1.35,
DeviceTime: observedAt, ServerTime: observedAt,
})
}
}
return page(rows, query), nil
}
func (m *MockStore) HistoryLocationSeries(ctx context.Context, query HistoryLocationSeriesQuery) ([]HistoryLocationSeriesBucket, error) {
page, err := m.HistoryLocationsFromTDengine(ctx, url.Values{"vin": {query.VIN}, "protocol": {query.Protocol}, "limit": {"1000"}})
if err != nil {
return nil, err
}
buckets := make([]HistoryLocationSeriesBucket, 0, len(page.Items))
for _, row := range page.Items {
speed, mileage := row.SpeedKmh, row.TotalMileageKm
buckets = append(buckets, HistoryLocationSeriesBucket{Time: row.DeviceTime, Protocol: row.Protocol, Count: 1, SpeedAverage: &speed, SpeedMinimum: &speed, SpeedMaximum: &speed, SpeedLast: &speed, MileageAverage: &mileage, MileageMinimum: &mileage, MileageMaximum: &mileage, MileageLast: &mileage})
}
sort.SliceStable(buckets, func(i, j int) bool { return buckets[i].Time < buckets[j].Time })
return buckets, nil
}
func (m *MockStore) HistoryExportCount(ctx context.Context, query HistoryExportStoreQuery) (int64, error) {
rows, err := m.mockHistoryExportRows(ctx, query)
return int64(len(rows)), err
}
func (m *MockStore) HistoryExportBatch(ctx context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) {
rows, err := m.mockHistoryExportRows(ctx, query)
if err != nil {
return nil, cursor, err
}
if limit <= 0 {
limit = 5000
}
start := cursor.Offset
if start > len(rows) {
start = len(rows)
}
end := start + limit
if end > len(rows) {
end = len(rows)
}
result := append([]HistoryDataRow(nil), rows[start:end]...)
cursor.Offset = end
return result, cursor, nil
}
func (m *MockStore) mockHistoryExportRows(ctx context.Context, query HistoryExportStoreQuery) ([]HistoryDataRow, error) {
switch query.Category {
case "raw":
page, err := m.RawFrames(ctx, RawFrameQuery{VIN: query.VIN, Protocol: query.Protocol, IncludeFields: true, Fields: query.Metrics, Limit: 1000})
if err != nil {
return nil, err
}
rows := make([]HistoryDataRow, 0, len(page.Items))
for _, item := range page.Items {
values := map[string]any{"frameType": item.FrameType, "rawSizeBytes": item.RawSizeBytes}
for key, value := range item.ParsedFields {
values[key] = value
}
rows = append(rows, HistoryDataRow{ID: item.ID, VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: "normal", EvidenceID: item.ID, Values: values})
}
return rows, nil
case "mileage":
page, err := m.DailyMileage(ctx, url.Values{"vin": {query.VIN}, "protocol": {query.Protocol}, "limit": {"1000"}})
if err != nil {
return nil, err
}
rows := make([]HistoryDataRow, 0, len(page.Items))
for index, item := range page.Items {
rows = append(rows, HistoryDataRow{ID: "mileage-" + item.VIN + "-" + strconv.Itoa(index), VIN: item.VIN, Plate: item.Plate, Protocol: item.Source, DeviceTime: item.Date, ServerTime: item.Date, Quality: "normal", Values: map[string]any{"startMileageKm": item.StartMileageKm, "endMileageKm": item.EndMileageKm, "dailyMileageKm": item.DailyMileageKm}})
}
return rows, nil
default:
page, err := m.HistoryLocationsFromTDengine(ctx, url.Values{"vin": {query.VIN}, "protocol": {query.Protocol}, "limit": {"1000"}})
if err != nil {
return nil, err
}
rows := make([]HistoryDataRow, 0, len(page.Items))
for index, item := range page.Items {
rows = append(rows, HistoryDataRow{ID: "location-" + item.VIN + "-" + strconv.Itoa(index), VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: "normal", Values: map[string]any{"speedKmh": item.SpeedKmh, "totalMileageKm": item.TotalMileageKm, "longitude": item.Longitude, "latitude": item.Latitude}})
}
return rows, nil
}
return Page[HistoryLocationRow]{Items: rows, Total: len(rows), Limit: realtime.Limit, Offset: realtime.Offset}, nil
}
func (m *MockStore) RawFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
@@ -551,8 +800,8 @@ func (m *MockStore) RawFrames(_ context.Context, query RawFrameQuery) (Page[RawF
fields = nil
}
rows := []RawFrameRow{
{ID: "raw-20260703-001", VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "GB32960", FrameType: "realtime", DeviceTime: "2026-07-03 20:12:06", ServerTime: "2026-07-03 20:12:06", RawSizeBytes: 430, ParsedFields: fields},
{ID: "raw-20260703-002", VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", FrameType: "realtime", DeviceTime: "2026-07-03 20:12:06", ServerTime: "2026-07-03 20:12:06", RawSizeBytes: 430, ParsedFields: fields},
{ID: "raw-20260703-001", VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "GB32960", FrameType: "realtime", DeviceTime: "2026-07-03 20:12:06", ServerTime: "2026-07-03 20:12:06", RawSizeBytes: 430, ParseStatus: "ok", SourceEndpoint: "gb32960-gateway", ParsedFields: fields},
{ID: "raw-20260703-002", VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", FrameType: "realtime", DeviceTime: "2026-07-03 20:12:06", ServerTime: "2026-07-03 20:12:06", RawSizeBytes: 430, ParseStatus: "ok", SourceEndpoint: "gb32960-gateway", ParsedFields: fields},
}
if vin := strings.TrimSpace(query.VIN); vin != "" {
rows = keep(rows, func(row RawFrameRow) bool { return row.VIN == vin })

View File

@@ -7,6 +7,661 @@ type Page[T any] struct {
Offset int `json:"offset"`
}
type MonitorSummary struct {
TotalVehicles int `json:"totalVehicles"`
OnlineVehicles int `json:"onlineVehicles"`
OfflineVehicles int `json:"offlineVehicles"`
DrivingVehicles int `json:"drivingVehicles"`
IdleVehicles int `json:"idleVehicles"`
AlertVehicles int `json:"alertVehicles"`
UnknownVehicles int `json:"unknownVehicles"`
ActiveToday int `json:"activeToday"`
FrameToday int `json:"frameToday"`
AlertDataAvailable bool `json:"alertDataAvailable"`
Truncated bool `json:"truncated"`
AsOf string `json:"asOf"`
}
type MonitorMapResponse struct {
Mode string `json:"mode"`
Zoom int `json:"zoom"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
Points []MonitorMapPoint `json:"points"`
Clusters []MonitorMapCluster `json:"clusters"`
AsOf string `json:"asOf"`
}
type MonitorMapPoint struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Protocol string `json:"protocol"`
Protocols []string `json:"protocols"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
SpeedKmh float64 `json:"speedKmh"`
SOCPercent float64 `json:"socPercent"`
TotalMileageKm float64 `json:"totalMileageKm"`
LastSeen string `json:"lastSeen"`
Status string `json:"status"`
}
type MonitorMapCluster struct {
ID string `json:"id"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
Count int `json:"count"`
Online int `json:"online"`
Offline int `json:"offline"`
Driving int `json:"driving"`
Idle int `json:"idle"`
Unknown int `json:"unknown"`
}
type TrackPlaybackResponse struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Points []HistoryLocationRow `json:"points"`
Events []TrackPlaybackEvent `json:"events"`
Sources []TrackPlaybackSource `json:"sources"`
Segments []TrackSegment `json:"segments"`
Stops []TrackStop `json:"stops"`
Summary TrackPlaybackSummary `json:"summary"`
Coverage TrackCoverage `json:"coverage"`
Quality TrackQuality `json:"quality"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
Sampled bool `json:"sampled"`
AsOf string `json:"asOf"`
}
type TrackPlaybackSummary struct {
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
DistanceKm float64 `json:"distanceKm"`
DurationSeconds int64 `json:"durationSeconds"`
AverageSpeedKmh float64 `json:"averageSpeedKmh"`
MaximumSpeedKmh float64 `json:"maximumSpeedKmh"`
PointCount int `json:"pointCount"`
MovingSeconds int64 `json:"movingSeconds"`
StoppedSeconds int64 `json:"stoppedSeconds"`
StopCount int `json:"stopCount"`
SegmentCount int `json:"segmentCount"`
}
type TrackPlaybackEvent struct {
Index int `json:"index"`
SampledIndex int `json:"sampledIndex"`
Type string `json:"type"`
Title string `json:"title"`
Time string `json:"time"`
SpeedKmh float64 `json:"speedKmh"`
SOCPercent float64 `json:"socPercent"`
SOCAvailable bool `json:"socAvailable"`
DirectionDeg *int64 `json:"directionDeg,omitempty"`
AlarmFlag *int64 `json:"alarmFlag,omitempty"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
}
type TrackPlaybackSource struct {
Protocol string `json:"protocol"`
PointCount int `json:"pointCount"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
}
// TrackCoverage makes a bounded replay query explicit. Complete=false means
// summaries describe only the fetched slice, never the whole requested range.
type TrackCoverage struct {
RequestedStart string `json:"requestedStart"`
RequestedEnd string `json:"requestedEnd"`
ActualStart string `json:"actualStart"`
ActualEnd string `json:"actualEnd"`
TotalPoints int `json:"totalPoints"`
FetchedPoints int `json:"fetchedPoints"`
ProcessedPoints int `json:"processedPoints"`
ReturnedPoints int `json:"returnedPoints"`
Complete bool `json:"complete"`
LimitReasons []string `json:"limitReasons"`
Evidence string `json:"evidence"`
}
// TrackSegment is inferred from GPS movement and gaps. It deliberately does
// not claim ignition state because vehicle_locations does not contain it.
type TrackSegment struct {
Index int `json:"index"`
Type string `json:"type"`
Title string `json:"title"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
DurationSeconds int64 `json:"durationSeconds"`
DistanceKm float64 `json:"distanceKm"`
PointCount int `json:"pointCount"`
StartIndex int `json:"startIndex"`
EndIndex int `json:"endIndex"`
SampledStartIndex int `json:"sampledStartIndex"`
SampledEndIndex int `json:"sampledEndIndex"`
}
type TrackStop struct {
Index int `json:"index"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
DurationSeconds int64 `json:"durationSeconds"`
PointCount int `json:"pointCount"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
SampledIndex int `json:"sampledIndex"`
Evidence string `json:"evidence"`
startIndex int
endIndex int
}
type TrackQuality struct {
Status string `json:"status"`
SelectedProtocol string `json:"selectedProtocol"`
RawPoints int `json:"rawPoints"`
ValidPoints int `json:"validPoints"`
AlternateSourcePoints int `json:"alternateSourcePoints"`
InvalidCoordinatePoints int `json:"invalidCoordinatePoints"`
DuplicatePoints int `json:"duplicatePoints"`
DriftPoints int `json:"driftPoints"`
SourceSwitches int `json:"sourceSwitches"`
LargeGapCount int `json:"largeGapCount"`
MaximumGapSeconds int64 `json:"maximumGapSeconds"`
Evidence string `json:"evidence"`
}
type HistoryMetricCatalog struct {
Categories []HistoryDataCategory `json:"categories"`
Metrics []HistoryMetricDefinition `json:"metrics"`
}
type MetricCatalog struct {
Metrics []MetricDefinition `json:"metrics"`
AsOf string `json:"asOf"`
}
type MetricDefinition struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
Unit string `json:"unit"`
Category string `json:"category"`
ValueType string `json:"valueType"`
Protocols []string `json:"protocols"`
SourceFields map[string]string `json:"sourceFields"`
Searchable bool `json:"searchable"`
Chartable bool `json:"chartable"`
Alertable bool `json:"alertable"`
}
type HistoryDataCategory struct {
Key string `json:"key"`
Label string `json:"label"`
}
type HistoryMetricDefinition struct {
Key string `json:"key"`
Label string `json:"label"`
Unit string `json:"unit"`
Category string `json:"category"`
ValueType string `json:"valueType"`
DefaultVisible bool `json:"defaultVisible"`
}
type HistoryDataResponse struct {
Category string `json:"category"`
Columns []HistoryMetricDefinition `json:"columns"`
Rows []HistoryDataRow `json:"rows"`
Summary HistoryDataSummary `json:"summary"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
AsOf string `json:"asOf"`
}
type HistoryDataSummary struct {
ResultRows int `json:"resultRows"`
VehicleCount int `json:"vehicleCount"`
Sources []string `json:"sources"`
QueryDuration int64 `json:"queryDurationMs"`
}
type HistoryDataRow struct {
ID string `json:"id"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Protocol string `json:"protocol"`
DeviceTime string `json:"deviceTime"`
ServerTime string `json:"serverTime"`
Quality string `json:"quality"`
EvidenceID string `json:"evidenceId,omitempty"`
Values map[string]any `json:"values"`
}
type HistorySeriesResponse struct {
Metrics []HistoryMetricDefinition `json:"metrics"`
Series []HistorySeries `json:"series"`
Summary HistorySeriesSummary `json:"summary"`
DateFrom string `json:"dateFrom"`
DateTo string `json:"dateTo"`
AsOf string `json:"asOf"`
}
type HistorySeries struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Protocol string `json:"protocol"`
Metric string `json:"metric"`
Label string `json:"label"`
Unit string `json:"unit"`
Aggregation string `json:"aggregation"`
Points []HistorySeriesPoint `json:"points"`
}
type HistorySeriesPoint struct {
Time string `json:"time"`
Value *float64 `json:"value"`
Min *float64 `json:"min"`
Max *float64 `json:"max"`
Count int64 `json:"count"`
}
type HistorySeriesSummary struct {
RawPointCount int64 `json:"rawPointCount"`
BucketCount int `json:"bucketCount"`
ReturnedPointCount int `json:"returnedPointCount"`
SeriesCount int `json:"seriesCount"`
GrainSeconds int `json:"grainSeconds"`
TargetPoints int `json:"targetPoints"`
ExpectedBucketCount int `json:"expectedBucketCount"`
MissingBucketCount int `json:"missingBucketCount"`
QueryDuration int64 `json:"queryDurationMs"`
Complete bool `json:"complete"`
Evidence string `json:"evidence"`
}
type HistoryLocationSeriesQuery struct {
VIN string
Protocol string
DateFrom string
DateTo string
GrainSeconds int
}
type HistoryLocationSeriesBucket struct {
Time string
Protocol string
Count int64
SpeedAverage *float64
SpeedMinimum *float64
SpeedMaximum *float64
SpeedLast *float64
MileageAverage *float64
MileageMinimum *float64
MileageMaximum *float64
MileageLast *float64
}
type HistoryExportRequest struct {
Keywords []string `json:"keywords"`
Category string `json:"category"`
Protocol string `json:"protocol"`
DateFrom string `json:"dateFrom"`
DateTo string `json:"dateTo"`
Metrics []string `json:"metrics"`
Format string `json:"format"`
}
type HistoryExportJob struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Progress int `json:"progress"`
Format string `json:"format"`
Category string `json:"category"`
Keywords []string `json:"keywords"`
RowCount int `json:"rowCount"`
TotalRows int64 `json:"totalRows"`
ProcessedRows int64 `json:"processedRows"`
FileSizeBytes int64 `json:"fileSizeBytes"`
Error string `json:"error,omitempty"`
DownloadURL string `json:"downloadUrl,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
CompletedAt string `json:"completedAt,omitempty"`
Evidence string `json:"evidence"`
filePath string
}
type HistoryExportStoreQuery struct {
Category string
VIN string
Protocol string
DateFrom string
DateTo string
Metrics []string
}
type HistoryExportCursor struct {
Time string
Protocol string
ID string
Offset int
}
type AccessQuery struct {
Keyword string `json:"keyword"`
Protocol string `json:"protocol"`
OEM string `json:"oem"`
Model string `json:"model"`
Provider string `json:"provider"`
FirstSeenFrom string `json:"firstSeenFrom"`
FirstSeenTo string `json:"firstSeenTo"`
LatestSeenFrom string `json:"latestSeenFrom"`
LatestSeenTo string `json:"latestSeenTo"`
OnlineState string `json:"onlineState"`
DelayState string `json:"delayState"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type AccessEvidenceRow struct {
VIN string
Plate string
OEM string
Model string
Company string
Protocol string
Provider string
Source string
FirstSeenAt string
LatestEventAt string
LatestReceivedAt string
LatestUpdatedAt string
ReportIntervalSec *int
LatestMessageType string
LatestEventID string
LatestError string
FirstSeenEvidence string
FirstSeenSource string
ReportIntervalProof string
ReportSampleCount int64
}
type AccessVehicleRow struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
OEM string `json:"oem"`
Model string `json:"model"`
Company string `json:"company"`
Protocol string `json:"protocol"`
Provider string `json:"provider"`
Source string `json:"source"`
FirstSeenAt string `json:"firstSeenAt"`
LatestEventAt string `json:"latestEventAt"`
LatestReceivedAt string `json:"latestReceivedAt"`
ReportIntervalSec *int `json:"reportIntervalSec"`
DataDelaySec *int `json:"dataDelaySec"`
FreshnessSec *int `json:"freshnessSec"`
OnlineState string `json:"onlineState"`
ThresholdSec int `json:"thresholdSec"`
LatestMessageType string `json:"latestMessageType"`
LatestEventID string `json:"latestEventId"`
LatestError string `json:"latestError"`
DelayAbnormal bool `json:"delayAbnormal"`
FirstSeenEvidence string `json:"firstSeenEvidence"`
FirstSeenSource string `json:"firstSeenSource"`
ReportIntervalProof string `json:"reportIntervalEvidence"`
ReportSampleCount int64 `json:"reportSampleCount"`
}
type AccessUnresolvedIdentityQuery struct {
Keyword string `json:"keyword"`
Protocol string `json:"protocol"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type AccessUnresolvedIdentity struct {
ID string `json:"id"`
Protocol string `json:"protocol"`
IdentifierMasked string `json:"identifierMasked"`
Plate string `json:"plate"`
Manufacturer string `json:"manufacturer"`
SourceEndpoint string `json:"sourceEndpoint"`
FirstRegisteredAt string `json:"firstRegisteredAt"`
LatestRegisteredAt string `json:"latestRegisteredAt"`
LatestAuthenticatedAt string `json:"latestAuthenticatedAt"`
LatestSeenAt string `json:"latestSeenAt"`
FreshnessSec int `json:"freshnessSec"`
IssueCode string `json:"issueCode"`
RecommendedAction string `json:"recommendedAction"`
}
type AccessDistribution struct {
Name string `json:"name"`
Total int `json:"total"`
Online int `json:"online"`
OnlineRate float64 `json:"onlineRate"`
}
type AccessSummary struct {
TotalVehicles int `json:"totalVehicles"`
OnlineVehicles int `json:"onlineVehicles"`
OfflineVehicles int `json:"offlineVehicles"`
LongOfflineVehicles int `json:"longOfflineVehicles"`
NeverReported int `json:"neverReported"`
UnknownVehicles int `json:"unknownVehicles"`
DelayAbnormal int `json:"delayAbnormal"`
ReportedToday int `json:"reportedToday"`
OnlineRate float64 `json:"onlineRate"`
Protocols []AccessDistribution `json:"protocols"`
OEMs []AccessDistribution `json:"oems"`
AsOf string `json:"asOf"`
ThresholdVersion int `json:"thresholdVersion"`
}
type AccessProtocolThreshold struct {
Protocol string `json:"protocol"`
ThresholdSec int `json:"thresholdSec"`
}
type AccessThresholdAudit struct {
Version int `json:"version"`
Actor string `json:"actor"`
ChangedAt string `json:"changedAt"`
Summary string `json:"summary"`
}
type AccessThresholdConfig struct {
Version int `json:"version"`
DefaultThresholdSec int `json:"defaultThresholdSec"`
DelayThresholdSec int `json:"delayThresholdSec"`
LongOfflineSec int `json:"longOfflineSec"`
Protocols []AccessProtocolThreshold `json:"protocols"`
UpdatedBy string `json:"updatedBy"`
UpdatedAt string `json:"updatedAt"`
Audit []AccessThresholdAudit `json:"audit"`
}
type AccessThresholdUpdate struct {
Version int `json:"version"`
DefaultThresholdSec int `json:"defaultThresholdSec"`
DelayThresholdSec int `json:"delayThresholdSec"`
LongOfflineSec int `json:"longOfflineSec"`
Protocols []AccessProtocolThreshold `json:"protocols"`
Actor string `json:"actor"`
}
type AlertQuery struct {
Keyword string `json:"keyword"`
Severity string `json:"severity"`
Status string `json:"status"`
RuleID string `json:"ruleId"`
Protocol string `json:"protocol"`
DateFrom string `json:"dateFrom"`
DateTo string `json:"dateTo"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type AlertSummary struct {
Active int `json:"active"`
Unprocessed int `json:"unprocessed"`
Processing int `json:"processing"`
Recovered int `json:"recovered"`
Closed int `json:"closed"`
Ignored int `json:"ignored"`
UnreadNotifications int `json:"unreadNotifications"`
AsOf string `json:"asOf"`
}
type AlertRule struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Severity string `json:"severity"`
ValueType string `json:"valueType"`
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold float64 `json:"threshold"`
ThresholdHigh float64 `json:"thresholdHigh"`
BooleanThreshold *bool `json:"booleanThreshold,omitempty"`
DurationSec int `json:"durationSec"`
RecoveryOperator string `json:"recoveryOperator"`
RecoveryThreshold float64 `json:"recoveryThreshold"`
RepeatIntervalSec int `json:"repeatIntervalSec"`
ScopeProtocols []string `json:"scopeProtocols"`
ScopeVINs []string `json:"scopeVins"`
ScopeOEMs []string `json:"scopeOems"`
ScopeModels []string `json:"scopeModels"`
ScopeCompanies []string `json:"scopeCompanies"`
NotificationChannels []string `json:"notificationChannels"`
Enabled bool `json:"enabled"`
Version int `json:"version"`
CreatedBy string `json:"createdBy"`
UpdatedBy string `json:"updatedBy"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type AlertRuleInput struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Severity string `json:"severity"`
ValueType string `json:"valueType"`
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold float64 `json:"threshold"`
ThresholdHigh float64 `json:"thresholdHigh"`
BooleanThreshold *bool `json:"booleanThreshold,omitempty"`
DurationSec int `json:"durationSec"`
RecoveryOperator string `json:"recoveryOperator"`
RecoveryThreshold float64 `json:"recoveryThreshold"`
RepeatIntervalSec int `json:"repeatIntervalSec"`
ScopeProtocols []string `json:"scopeProtocols"`
ScopeVINs []string `json:"scopeVins"`
ScopeOEMs []string `json:"scopeOems"`
ScopeModels []string `json:"scopeModels"`
ScopeCompanies []string `json:"scopeCompanies"`
NotificationChannels []string `json:"notificationChannels"`
Enabled bool `json:"enabled"`
Version int `json:"version"`
Actor string `json:"actor"`
}
type AlertRuleEnabledUpdate struct {
Version int `json:"version"`
Enabled bool `json:"enabled"`
Actor string `json:"actor"`
}
type AlertEvent struct {
ID string `json:"id"`
RuleID string `json:"ruleId"`
RuleName string `json:"ruleName"`
RuleVersion int `json:"ruleVersion"`
Severity string `json:"severity"`
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 {
ID int64 `json:"id"`
Action string `json:"action"`
FromStatus string `json:"fromStatus"`
ToStatus string `json:"toStatus"`
Actor string `json:"actor"`
Note string `json:"note"`
CreatedAt string `json:"createdAt"`
}
type AlertActionRequest struct {
Version int `json:"version"`
Action string `json:"action"`
Actor string `json:"actor"`
Note string `json:"note"`
}
type AlertNotification struct {
ID int64 `json:"id"`
EventID string `json:"eventId"`
Title string `json:"title"`
Content string `json:"content"`
Severity string `json:"severity"`
Channel string `json:"channel"`
Read bool `json:"read"`
CreatedAt string `json:"createdAt"`
ReadAt string `json:"readAt"`
}
type AlertNotificationQuery struct {
UnreadOnly bool `json:"unreadOnly"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type AlertNotificationReadRequest struct {
IDs []int64 `json:"ids"`
Actor string `json:"actor"`
}
type AlertEvaluationResult struct {
RulesEvaluated int `json:"rulesEvaluated"`
VehiclesScanned int `json:"vehiclesScanned"`
CandidatesAdvanced int `json:"candidatesAdvanced"`
DuplicateObservations int `json:"duplicateObservations"`
LateObservations int `json:"lateObservations"`
StaleEvidenceSkipped int `json:"staleEvidenceSkipped"`
Opened int `json:"opened"`
Recovered int `json:"recovered"`
AsOf string `json:"asOf"`
}
type DashboardSummary struct {
OnlineVehicles int `json:"onlineVehicles"`
ActiveToday int `json:"activeToday"`
@@ -97,6 +752,7 @@ type VehicleDetail struct {
LookupResolved bool `json:"lookupResolved"`
Resolution *VehicleIdentityResolution `json:"resolution,omitempty"`
Identity *VehicleRow `json:"identity,omitempty"`
Profile *VehicleProfile `json:"profile,omitempty"`
RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"`
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
ServiceOverview *VehicleServiceOverview `json:"serviceOverview,omitempty"`
@@ -110,6 +766,78 @@ type VehicleDetail struct {
Quality Page[QualityIssueRow] `json:"quality"`
}
type VehicleProfile struct {
VIN string `json:"vin"`
ModelName string `json:"modelName"`
VehicleType string `json:"vehicleType"`
CompanyName string `json:"companyName"`
OperationStatus string `json:"operationStatus"`
AccessProvider string `json:"accessProvider"`
FirstAccessAt string `json:"firstAccessAt"`
RuntimeSeconds *int64 `json:"runtimeSeconds"`
SourceSystem string `json:"sourceSystem"`
SourceVersion string `json:"sourceVersion"`
SyncedAt string `json:"syncedAt"`
Version int `json:"version"`
UpdatedBy string `json:"updatedBy"`
UpdatedAt string `json:"updatedAt"`
Completeness int `json:"completeness"`
MissingFields []string `json:"missingFields"`
}
type VehicleProfileInput struct {
ModelName string `json:"modelName"`
VehicleType string `json:"vehicleType"`
CompanyName string `json:"companyName"`
OperationStatus string `json:"operationStatus"`
AccessProvider string `json:"accessProvider"`
FirstAccessAt string `json:"firstAccessAt"`
RuntimeSeconds *int64 `json:"runtimeSeconds"`
Version int `json:"version"`
Actor string `json:"actor"`
}
type VehicleProfileSyncItem struct {
VIN string `json:"vin"`
ModelName string `json:"modelName"`
VehicleType string `json:"vehicleType"`
CompanyName string `json:"companyName"`
OperationStatus string `json:"operationStatus"`
AccessProvider string `json:"accessProvider"`
FirstAccessAt string `json:"firstAccessAt"`
RuntimeSeconds *int64 `json:"runtimeSeconds"`
}
type VehicleProfileSyncRequest struct {
SourceSystem string `json:"sourceSystem"`
SourceVersion string `json:"sourceVersion"`
ConflictPolicy string `json:"conflictPolicy"`
DryRun bool `json:"dryRun"`
Items []VehicleProfileSyncItem `json:"items"`
Actor string `json:"actor"`
}
type VehicleProfileSyncItemResult struct {
VIN string `json:"vin"`
Status string `json:"status"`
PreviousSource string `json:"previousSource,omitempty"`
PreviousVersion string `json:"previousVersion,omitempty"`
ProfileVersion int `json:"profileVersion,omitempty"`
}
type VehicleProfileSyncResult struct {
SourceSystem string `json:"sourceSystem"`
SourceVersion string `json:"sourceVersion"`
DryRun bool `json:"dryRun"`
Received int `json:"received"`
Created int `json:"created"`
Updated int `json:"updated"`
Unchanged int `json:"unchanged"`
Conflicted int `json:"conflicted"`
Missing int `json:"missing"`
Items []VehicleProfileSyncItemResult `json:"items"`
}
type VehicleSourceConsistency struct {
SourceCount int `json:"sourceCount"`
OnlineSourceCount int `json:"onlineSourceCount"`
@@ -257,21 +985,64 @@ type HistoryLocationRow struct {
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
SpeedKmh float64 `json:"speedKmh"`
SOCPercent float64 `json:"socPercent"`
SOCAvailable bool `json:"socAvailable"`
DirectionDeg *int64 `json:"directionDeg,omitempty"`
AlarmFlag *int64 `json:"alarmFlag,omitempty"`
TotalMileageKm float64 `json:"totalMileageKm"`
DeviceTime string `json:"deviceTime"`
ServerTime string `json:"serverTime"`
}
type RawFrameRow struct {
ID string `json:"id"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Protocol string `json:"protocol"`
FrameType string `json:"frameType"`
DeviceTime string `json:"deviceTime"`
ServerTime string `json:"serverTime"`
RawSizeBytes int `json:"rawSizeBytes"`
ParsedFields map[string]any `json:"parsedFields"`
ID string `json:"id"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Protocol string `json:"protocol"`
FrameType string `json:"frameType"`
DeviceTime string `json:"deviceTime"`
ServerTime string `json:"serverTime"`
RawSizeBytes int `json:"rawSizeBytes"`
ParseStatus string `json:"parseStatus,omitempty"`
ParseError string `json:"parseError,omitempty"`
SourceEndpoint string `json:"sourceEndpoint,omitempty"`
ParsedFields map[string]any `json:"parsedFields"`
}
type LatestTelemetryCategory struct {
Key string `json:"key"`
Label string `json:"label"`
Count int `json:"count"`
}
type LatestTelemetryValue struct {
Key string `json:"key"`
SourceField string `json:"sourceField"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Unit string `json:"unit"`
Category string `json:"category"`
ValueType string `json:"valueType"`
Value any `json:"value"`
Protocol string `json:"protocol"`
SourceEndpoint string `json:"sourceEndpoint,omitempty"`
FrameID string `json:"frameId"`
DeviceTime string `json:"deviceTime"`
ServerTime string `json:"serverTime"`
Quality string `json:"quality"`
QualityReason string `json:"qualityReason"`
FreshnessSeconds int64 `json:"freshnessSeconds"`
DataDelaySeconds *int64 `json:"dataDelaySeconds,omitempty"`
}
type LatestTelemetryResponse struct {
VIN string `json:"vin"`
Categories []LatestTelemetryCategory `json:"categories"`
Values []LatestTelemetryValue `json:"values"`
AsOf string `json:"asOf"`
StaleAfterSeconds int64 `json:"staleAfterSeconds"`
ScannedFrames int `json:"scannedFrames"`
Evidence string `json:"evidence"`
}
type DailyMileageRow struct {
@@ -387,15 +1158,31 @@ type QualityPriorityIssue struct {
}
type OpsHealth struct {
LinkHealth []LinkHealth `json:"linkHealth"`
KafkaLag *int `json:"kafkaLag"`
ActiveConnections *int `json:"activeConnections"`
CapacityMetrics CapacityMetrics `json:"capacityMetrics"`
CapacityFindings []string `json:"capacityFindings"`
RedisOnlineKeys *int `json:"redisOnlineKeys"`
TDengineWritable bool `json:"tdengineWritable"`
MySQLWritable bool `json:"mysqlWritable"`
Runtime RuntimeInfo `json:"runtime"`
LinkHealth []LinkHealth `json:"linkHealth"`
KafkaLag *int `json:"kafkaLag"`
ActiveConnections *int `json:"activeConnections"`
CapacityMetrics CapacityMetrics `json:"capacityMetrics"`
CapacityFindings []string `json:"capacityFindings"`
RedisOnlineKeys *int `json:"redisOnlineKeys"`
TDengineWritable bool `json:"tdengineWritable"`
MySQLWritable bool `json:"mysqlWritable"`
AlertStream AlertStreamHealth `json:"alertStream"`
Runtime RuntimeInfo `json:"runtime"`
}
type AlertStreamHealth struct {
Mode string `json:"mode"`
ConsumerGroup string `json:"consumerGroup"`
Partitions int `json:"partitions"`
Lag int64 `json:"lag"`
Processed int64 `json:"processed"`
Valid int64 `json:"valid"`
Invalid int64 `json:"invalid"`
Late int64 `json:"late"`
ReplaySkipped int64 `json:"replaySkipped"`
UpdatedAt string `json:"updatedAt"`
LastInvalidCode string `json:"lastInvalidCode"`
LastInvalidAt string `json:"lastInvalidAt"`
}
type CapacityMetrics struct {
@@ -412,6 +1199,8 @@ type CapacityMetrics struct {
}
type RuntimeInfo struct {
DataMode string `json:"dataMode"`
ExportDir string `json:"-"`
RequestTimeoutMs int `json:"requestTimeoutMs"`
AMapWebJSConfigured bool `json:"amapWebJsConfigured"`
AMapAPIConfigured bool `json:"amapApiConfigured"`
@@ -419,4 +1208,6 @@ type RuntimeInfo struct {
AMapSecurityCodeExposed bool `json:"amapSecurityCodeExposed"`
AMapSecurityServiceHost string `json:"amapSecurityServiceHost"`
PlatformRelease string `json:"platformRelease"`
AlertStreamMode string `json:"alertStreamMode"`
AlertStreamConsumerGroup string `json:"alertStreamConsumerGroup"`
}

View File

@@ -337,6 +337,19 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
case "offline":
having = append(having, "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0")
}
primarySpeed := "CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY l.updated_at DESC, l.protocol ASC), ',', 1) AS DECIMAL(18,6))"
switch strings.TrimSpace(query.Get("status")) {
case "driving":
having = append(having,
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
primarySpeed+" > 3",
)
case "idle":
having = append(having,
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
primarySpeed+" <= 3",
)
}
switch strings.TrimSpace(query.Get("serviceStatus")) {
case "healthy":
having = append(having,
@@ -424,7 +437,8 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
return SQLQuery{
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
`m.first_total_mileage_km, m.latest_total_mileage_km, m.daily_mileage_km, m.protocol ` +
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, m.protocol ` +
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
Args: args,
CountText: `SELECT COUNT(*) ` + fromSQL,

View File

@@ -0,0 +1,26 @@
package platform
import "context"
type Principal struct {
Name string `json:"name"`
Role string `json:"role"`
}
type principalContextKey struct{}
func WithPrincipal(ctx context.Context, principal Principal) context.Context {
return context.WithValue(ctx, principalContextKey{}, principal)
}
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
principal, ok := ctx.Value(principalContextKey{}).(Principal)
return principal, ok
}
func ActorFromContext(ctx context.Context) string {
if principal, ok := PrincipalFromContext(ctx); ok && principal.Name != "" {
return principal.Name
}
return "system"
}

View File

@@ -7,15 +7,24 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
)
type ProductionStore struct {
db *sql.DB
tdengine *sql.DB
tdDatabase string
redisOnline redisOnlineKeyCounter
capacityCheck capacityChecker
db *sql.DB
tdengine *sql.DB
tdDatabase string
redisOnline redisOnlineKeyCounter
capacityCheck capacityChecker
alertStreamGroup string
alertStreamMode string
accessSchemaOnce sync.Once
accessSchemaErr error
alertSchemaOnce sync.Once
alertSchemaErr error
profileSchemaOnce sync.Once
profileSchemaErr error
}
type redisOnlineKeyCounter interface {
@@ -43,6 +52,12 @@ func (s *ProductionStore) WithCapacityChecker(checker capacityChecker) *Producti
return s
}
func (s *ProductionStore) WithAlertStreamConfig(mode, consumerGroup string) *ProductionStore {
s.alertStreamMode = strings.TrimSpace(mode)
s.alertStreamGroup = strings.TrimSpace(consumerGroup)
return s
}
func OpenSQL(ctx context.Context, driver, dsn string) (*sql.DB, error) {
db, err := sql.Open(driver, dsn)
if err != nil {
@@ -575,7 +590,7 @@ func (s *ProductionStore) HistoryLocations(ctx context.Context, query url.Values
for _, row := range realtime.Items {
items = append(items, HistoryLocationRow{
VIN: row.VIN, Plate: row.Plate, Protocol: row.Protocol, Longitude: row.Longitude, Latitude: row.Latitude,
SpeedKmh: row.SpeedKmh, TotalMileageKm: row.TotalMileageKm, DeviceTime: row.LastSeen, ServerTime: row.LastSeen,
SpeedKmh: row.SpeedKmh, SOCPercent: row.SOCPercent, SOCAvailable: row.SOCPercent > 0, TotalMileageKm: row.TotalMileageKm, DeviceTime: row.LastSeen, ServerTime: row.LastSeen,
})
}
return Page[HistoryLocationRow]{Items: items, Total: realtime.Total, Limit: realtime.Limit, Offset: realtime.Offset}, nil
@@ -619,17 +634,26 @@ func (s *ProductionStore) HistoryLocationsFromTDengine(ctx context.Context, quer
items := make([]HistoryLocationRow, 0)
for rows.Next() {
var row HistoryLocationRow
var ts, receivedAt string
var longitude, latitude, speed, mileage sql.NullFloat64
if err := rows.Scan(&ts, &row.VIN, &row.Protocol, &longitude, &latitude, &speed, &mileage, &receivedAt); err != nil {
var ts int64
var receivedAt sql.NullInt64
var longitude, latitude, speed, soc, mileage sql.NullFloat64
var direction, alarm sql.NullInt64
if err := rows.Scan(&ts, &row.VIN, &row.Protocol, &longitude, &latitude, &speed, &soc, &direction, &alarm, &mileage, &receivedAt); err != nil {
return Page[HistoryLocationRow]{}, err
}
row.Longitude = nullFloat64(longitude)
row.Latitude = nullFloat64(latitude)
row.SpeedKmh = nullFloat64(speed)
row.SOCPercent = nullFloat64(soc)
row.SOCAvailable = soc.Valid
row.DirectionDeg = nullInt64Pointer(direction)
row.AlarmFlag = nullInt64Pointer(alarm)
row.TotalMileageKm = nullFloat64(mileage)
row.DeviceTime = ts
row.ServerTime = firstNonEmpty(receivedAt, ts)
row.DeviceTime = time.UnixMilli(ts).UTC().Format(time.RFC3339Nano)
row.ServerTime = row.DeviceTime
if receivedAt.Valid {
row.ServerTime = time.UnixMilli(receivedAt.Int64).UTC().Format(time.RFC3339Nano)
}
items = append(items, row)
}
if err := rows.Err(); err != nil {
@@ -644,6 +668,172 @@ func (s *ProductionStore) HistoryLocationsFromTDengine(ctx context.Context, quer
return Page[HistoryLocationRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
func (s *ProductionStore) HistoryLocationSeries(ctx context.Context, query HistoryLocationSeriesQuery) ([]HistoryLocationSeriesBucket, error) {
if s.tdengine == nil {
return []HistoryLocationSeriesBucket{}, nil
}
built := buildHistoryLocationSeriesSQL(s.tdDatabase, query)
rows, err := s.tdengine.QueryContext(ctx, built.Text, built.Args...)
if err != nil {
if isTDengineTableNotExist(err) {
return []HistoryLocationSeriesBucket{}, nil
}
return nil, err
}
defer rows.Close()
buckets := make([]HistoryLocationSeriesBucket, 0)
for rows.Next() {
var bucket HistoryLocationSeriesBucket
var speedAverage, speedMinimum, speedMaximum, speedLast sql.NullFloat64
var mileageAverage, mileageMinimum, mileageMaximum, mileageLast sql.NullFloat64
if err := rows.Scan(&bucket.Time, &bucket.Protocol, &bucket.Count, &speedAverage, &speedMinimum, &speedMaximum, &speedLast, &mileageAverage, &mileageMinimum, &mileageMaximum, &mileageLast); err != nil {
return nil, err
}
if parsed, ok := parseVehicleServiceTime(bucket.Time); ok {
bucket.Time = parsed.UTC().Format(time.RFC3339)
}
bucket.SpeedAverage = nullFloat64Pointer(speedAverage)
bucket.SpeedMinimum = nullFloat64Pointer(speedMinimum)
bucket.SpeedMaximum = nullFloat64Pointer(speedMaximum)
bucket.SpeedLast = nullFloat64Pointer(speedLast)
bucket.MileageAverage = nullFloat64Pointer(mileageAverage)
bucket.MileageMinimum = nullFloat64Pointer(mileageMinimum)
bucket.MileageMaximum = nullFloat64Pointer(mileageMaximum)
bucket.MileageLast = nullFloat64Pointer(mileageLast)
buckets = append(buckets, bucket)
}
return buckets, rows.Err()
}
func (s *ProductionStore) HistoryExportCount(ctx context.Context, query HistoryExportStoreQuery) (int64, error) {
if query.Category == "mileage" {
page, err := s.DailyMileage(ctx, historyExportMileageQuery(query, 1, 0))
return int64(page.Total), err
}
if s.tdengine == nil {
return 0, nil
}
built := buildHistoryExportBatchSQL(s.tdDatabase, query, HistoryExportCursor{}, 1)
var total int64
if err := s.tdengine.QueryRowContext(ctx, built.CountText, built.CountArgs...).Scan(&total); err != nil {
if isTDengineTableNotExist(err) {
return 0, nil
}
return 0, err
}
return total, nil
}
func (s *ProductionStore) HistoryExportBatch(ctx context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) {
if query.Category == "mileage" {
page, err := s.DailyMileage(ctx, historyExportMileageQuery(query, limit, cursor.Offset))
if err != nil {
return nil, cursor, err
}
result := make([]HistoryDataRow, 0, len(page.Items))
for index, item := range page.Items {
result = append(result, HistoryDataRow{ID: "mileage-" + item.VIN + "-" + strconv.Itoa(cursor.Offset+index), VIN: item.VIN, Plate: item.Plate, Protocol: item.Source, DeviceTime: item.Date, ServerTime: item.Date, Quality: "normal", Values: map[string]any{"startMileageKm": item.StartMileageKm, "endMileageKm": item.EndMileageKm, "dailyMileageKm": item.DailyMileageKm}})
}
cursor.Offset += len(result)
return result, cursor, nil
}
if s.tdengine == nil {
return []HistoryDataRow{}, cursor, nil
}
built := buildHistoryExportBatchSQL(s.tdDatabase, query, cursor, limit)
rows, err := s.tdengine.QueryContext(ctx, built.Text, built.Args...)
if err != nil {
if isTDengineTableNotExist(err) {
return []HistoryDataRow{}, cursor, nil
}
return nil, cursor, err
}
defer rows.Close()
plates, err := s.platesByVIN(ctx, []string{query.VIN})
if err != nil {
return nil, cursor, err
}
if query.Category == "raw" {
return scanRawHistoryExportRows(rows, query, cursor, plates[query.VIN])
}
return scanLocationHistoryExportRows(rows, cursor, plates[query.VIN])
}
func scanLocationHistoryExportRows(rows *sql.Rows, cursor HistoryExportCursor, plate string) ([]HistoryDataRow, HistoryExportCursor, error) {
items := make([]HistoryDataRow, 0)
for rows.Next() {
var vin, protocol, ts, receivedAt string
var longitude, latitude, speed, mileage sql.NullFloat64
if err := rows.Scan(&ts, &vin, &protocol, &longitude, &latitude, &speed, &mileage, &receivedAt); err != nil {
return nil, cursor, err
}
quality := "normal"
location := HistoryLocationRow{Longitude: nullFloat64(longitude), Latitude: nullFloat64(latitude)}
if !validTrackCoordinate(location) {
quality = "warning"
}
items = append(items, HistoryDataRow{ID: "location-" + vin + "-" + protocol + "-" + ts, VIN: vin, Plate: plate, Protocol: protocol, DeviceTime: ts, ServerTime: firstNonEmpty(receivedAt, ts), Quality: quality, Values: map[string]any{"speedKmh": nullFloat64(speed), "totalMileageKm": nullFloat64(mileage), "longitude": nullFloat64(longitude), "latitude": nullFloat64(latitude)}})
cursor = HistoryExportCursor{Time: ts, Protocol: protocol}
}
return items, cursor, rows.Err()
}
func scanRawHistoryExportRows(rows *sql.Rows, query HistoryExportStoreQuery, cursor HistoryExportCursor, plate string) ([]HistoryDataRow, HistoryExportCursor, error) {
items := make([]HistoryDataRow, 0)
for rows.Next() {
var ts, frameID, eventTime, receivedAt, parsedFields, parseStatus, parseError, sourceEndpoint, protocol, vehicleKey, vin, phone string
var rawSizeBytes int
if err := rows.Scan(&ts, &frameID, &eventTime, &receivedAt, &rawSizeBytes, &parsedFields, &parseStatus, &parseError, &sourceEndpoint, &protocol, &vehicleKey, &vin, &phone); err != nil {
return nil, cursor, err
}
values := map[string]any{"frameType": vehicleKey, "rawSizeBytes": rawSizeBytes}
fields := parsedFieldsFromString(parsedFields)
if len(query.Metrics) > 0 {
fields = filterParsedFieldsMap(fields, query.Metrics)
}
for key, value := range fields {
values[key] = value
}
quality := "normal"
if parseStatus != "" && !strings.EqualFold(parseStatus, "ok") {
quality = "warning"
}
items = append(items, HistoryDataRow{ID: frameID, VIN: vin, Plate: plate, Protocol: protocol, DeviceTime: firstNonEmpty(eventTime, ts), ServerTime: firstNonEmpty(receivedAt, ts), Quality: quality, EvidenceID: frameID, Values: values})
cursor = HistoryExportCursor{Time: ts, Protocol: protocol, ID: frameID}
}
return items, cursor, rows.Err()
}
func historyExportMileageQuery(query HistoryExportStoreQuery, limit, offset int) url.Values {
values := url.Values{"vin": {query.VIN}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}}
if query.Protocol != "" {
values.Set("protocol", query.Protocol)
}
if query.DateFrom != "" {
values.Set("dateFrom", strings.Split(query.DateFrom, "T")[0])
}
if query.DateTo != "" {
values.Set("dateTo", strings.Split(query.DateTo, "T")[0])
}
return values
}
func nullFloat64Pointer(value sql.NullFloat64) *float64 {
if !value.Valid {
return nil
}
result := value.Float64
return &result
}
func nullInt64Pointer(value sql.NullInt64) *int64 {
if !value.Valid {
return nil
}
result := value.Int64
return &result
}
func (s *ProductionStore) enrichHistoryLocationPlates(ctx context.Context, items []HistoryLocationRow) error {
vins := make([]string, 0)
seen := map[string]struct{}{}
@@ -758,6 +948,9 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P
row.DeviceTime = firstNonEmpty(eventTime, ts)
row.ServerTime = firstNonEmpty(receivedAt, ts)
row.RawSizeBytes = rawSizeBytes
row.ParseStatus = parseStatus
row.ParseError = parseError
row.SourceEndpoint = sourceEndpoint
row.ParsedFields = parsedFieldsFromString(parsedFields)
if len(query.Fields) > 0 {
row.ParsedFields = filterParsedFieldsMap(row.ParsedFields, query.Fields)
@@ -930,6 +1123,7 @@ func (s *ProductionStore) OpsHealth(ctx context.Context) (OpsHealth, error) {
tdengineHealth := s.tdengineRawFrameHealth(ctx)
redisHealth, redisOnlineKeys := s.redisOnlineKeyHealth(ctx)
capacityHealth, kafkaLag, activeConnections, capacityMetrics, capacityFindings := s.capacityCheckHealth(ctx)
alertStreamHealth, alertStream := s.alertStreamCheckpointHealth(ctx)
mysqlWritable := mysqlStatus == "ok" && snapshotHealth.Status == "ok" && locationHealth.Status == "ok"
return OpsHealth{
LinkHealth: []LinkHealth{
@@ -939,6 +1133,7 @@ func (s *ProductionStore) OpsHealth(ctx context.Context) (OpsHealth, error) {
tdengineHealth,
capacityHealth,
redisHealth,
alertStreamHealth,
},
KafkaLag: kafkaLag,
ActiveConnections: activeConnections,
@@ -947,9 +1142,49 @@ func (s *ProductionStore) OpsHealth(ctx context.Context) (OpsHealth, error) {
RedisOnlineKeys: redisOnlineKeys,
TDengineWritable: tdengineHealth.Status == "ok",
MySQLWritable: mysqlWritable,
AlertStream: alertStream,
}, nil
}
func (s *ProductionStore) alertStreamCheckpointHealth(ctx context.Context) (LinkHealth, AlertStreamHealth) {
result := AlertStreamHealth{Mode: firstNonEmpty(s.alertStreamMode, "disabled"), ConsumerGroup: s.alertStreamGroup}
health := LinkHealth{Name: "Alert Kafka stream", Status: "warning", Detail: "Kafka event-time consumer 未配置"}
if s.alertStreamGroup == "" {
return health, result
}
var updatedAt, lastInvalidAt sql.NullTime
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*),COALESCE(SUM(GREATEST(high_watermark-next_offset,0)),0),COALESCE(SUM(processed_count),0),COALESCE(SUM(valid_count),0),COALESCE(SUM(invalid_count),0),COALESCE(SUM(late_count),0),COALESCE(SUM(replay_skipped_count),0),MAX(updated_at),COALESCE((SELECT c2.last_invalid_code FROM vehicle_alert_stream_checkpoint c2 WHERE c2.consumer_group=? AND c2.last_invalid_at IS NOT NULL ORDER BY c2.last_invalid_at DESC LIMIT 1),''),MAX(last_invalid_at) FROM vehicle_alert_stream_checkpoint WHERE consumer_group=?`, s.alertStreamGroup, s.alertStreamGroup).Scan(&result.Partitions, &result.Lag, &result.Processed, &result.Valid, &result.Invalid, &result.Late, &result.ReplaySkipped, &updatedAt, &result.LastInvalidCode, &lastInvalidAt)
if err != nil {
health.Status = "error"
health.Detail = "读取告警流 checkpoint 失败:" + err.Error()
return health, result
}
if updatedAt.Valid {
result.UpdatedAt = updatedAt.Time.Format(time.RFC3339)
}
if lastInvalidAt.Valid {
result.LastInvalidAt = lastInvalidAt.Time.Format(time.RFC3339)
}
if result.Partitions == 0 || !updatedAt.Valid {
health.Detail = "告警流尚未建立 Kafka 分区 checkpoint"
return health, result
}
age := time.Since(updatedAt.Time)
health.Status = "ok"
health.Detail = result.Mode + " checkpoint 正常"
if age > 2*time.Minute {
health.Status = "error"
health.Detail = "告警流 checkpoint 超过 2 分钟未推进"
} else if result.Lag > 1000 || (lastInvalidAt.Valid && time.Since(lastInvalidAt.Time) < 5*time.Minute) {
health.Status = "warning"
health.Detail = "告警流存在积压或最近非法消息"
if result.LastInvalidCode != "" {
health.Detail += "" + result.LastInvalidCode
}
}
return health, result
}
func (s *ProductionStore) capacityCheckHealth(ctx context.Context) (LinkHealth, *int, *int, CapacityMetrics, []string) {
health := LinkHealth{Name: "Capacity check", Status: "warning", Detail: "平台暂未接入 capacity-check"}
if s.capacityCheck == nil {

View File

@@ -0,0 +1,309 @@
package platform
import (
"context"
"fmt"
"net/url"
"regexp"
"strings"
"time"
)
var vehicleOperationStatuses = map[string]bool{
"unknown": true, "active": true, "inactive": true, "maintenance": true, "retired": true,
}
const (
vehicleProfileSyncLimit = 500
profileSyncCreated = "created"
profileSyncUpdated = "updated"
profileSyncUnchanged = "unchanged"
profileSyncConflictSource = "conflict_source"
profileSyncConflictSourceVersion = "conflict_source_version"
profileSyncMissingVehicle = "missing_vehicle"
profileSyncConflictPolicyPreserve = "preserve"
profileSyncConflictPolicyOverwrite = "overwrite"
)
var vehicleProfileSourceSystemPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._:-]*$`)
func (s *Service) VehicleProfile(ctx context.Context, vin string) (VehicleProfile, error) {
vin = strings.TrimSpace(vin)
if err := validateProfileVIN(vin); err != nil {
return VehicleProfile{}, err
}
store, ok := s.store.(VehicleProfileStore)
if !ok {
return decorateVehicleProfile(VehicleProfile{VIN: vin, OperationStatus: "unknown", SourceSystem: "unconfigured", MissingFields: []string{}}), nil
}
profile, exists, err := store.VehicleProfile(ctx, vin)
if err != nil {
return VehicleProfile{}, err
}
if !exists {
profile = VehicleProfile{VIN: vin, OperationStatus: "unknown", SourceSystem: "unconfigured"}
}
return decorateVehicleProfile(profile), nil
}
func (s *Service) SaveVehicleProfile(ctx context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) {
vin = strings.TrimSpace(vin)
if err := validateProfileVIN(vin); err != nil {
return VehicleProfile{}, err
}
store, ok := s.store.(VehicleProfileStore)
if !ok {
return VehicleProfile{}, fmt.Errorf("store does not provide durable vehicle profiles")
}
exists, err := s.vehicleExists(ctx, vin)
if err != nil {
return VehicleProfile{}, err
}
if !exists {
return VehicleProfile{}, clientError{Code: "VEHICLE_NOT_FOUND", Message: "车辆身份不存在,不能创建主档"}
}
input = normalizeVehicleProfileInput(input)
if err := validateVehicleProfileInput(input); err != nil {
return VehicleProfile{}, err
}
profile, err := store.SaveVehicleProfile(ctx, vin, input)
if err != nil {
return VehicleProfile{}, err
}
return decorateVehicleProfile(profile), nil
}
func (s *Service) SyncVehicleProfiles(ctx context.Context, request VehicleProfileSyncRequest) (VehicleProfileSyncResult, error) {
store, ok := s.store.(VehicleProfileStore)
if !ok {
return VehicleProfileSyncResult{}, fmt.Errorf("store does not provide durable vehicle profiles")
}
request = normalizeVehicleProfileSyncRequest(request)
if err := validateVehicleProfileSyncRequest(request); err != nil {
return VehicleProfileSyncResult{}, err
}
return store.SyncVehicleProfiles(ctx, request)
}
func normalizeVehicleProfileSyncRequest(request VehicleProfileSyncRequest) VehicleProfileSyncRequest {
request.SourceSystem = strings.ToLower(strings.TrimSpace(request.SourceSystem))
request.SourceVersion = strings.TrimSpace(request.SourceVersion)
request.ConflictPolicy = strings.ToLower(strings.TrimSpace(request.ConflictPolicy))
request.Actor = strings.TrimSpace(request.Actor)
if request.ConflictPolicy == "" {
request.ConflictPolicy = profileSyncConflictPolicyPreserve
}
for index := range request.Items {
item := &request.Items[index]
item.VIN = strings.ToUpper(strings.TrimSpace(item.VIN))
normalized := normalizeVehicleProfileInput(vehicleProfileSyncInput(*item, request.Actor))
item.ModelName = normalized.ModelName
item.VehicleType = normalized.VehicleType
item.CompanyName = normalized.CompanyName
item.OperationStatus = normalized.OperationStatus
item.AccessProvider = normalized.AccessProvider
item.FirstAccessAt = normalized.FirstAccessAt
if item.FirstAccessAt != "" {
if parsed, err := parseVehicleProfileTime(item.FirstAccessAt); err == nil {
item.FirstAccessAt = parsed.Format(time.RFC3339)
}
}
}
return request
}
func validateVehicleProfileSyncRequest(request VehicleProfileSyncRequest) error {
if request.SourceSystem == "" || len(request.SourceSystem) > 64 || !vehicleProfileSourceSystemPattern.MatchString(request.SourceSystem) || request.SourceSystem == "manual" || request.SourceSystem == "unconfigured" {
return clientError{Code: "VEHICLE_PROFILE_SYNC_SOURCE_INVALID", Message: "sourceSystem 必须是 64 字符内的外部系统标识"}
}
if request.SourceVersion == "" || len([]rune(request.SourceVersion)) > 128 {
return clientError{Code: "VEHICLE_PROFILE_SYNC_VERSION_INVALID", Message: "sourceVersion 不能为空且不能超过 128 个字符"}
}
if request.ConflictPolicy != profileSyncConflictPolicyPreserve && request.ConflictPolicy != profileSyncConflictPolicyOverwrite {
return clientError{Code: "VEHICLE_PROFILE_SYNC_POLICY_INVALID", Message: "conflictPolicy 仅支持 preserve 或 overwrite"}
}
if len(request.Items) == 0 || len(request.Items) > vehicleProfileSyncLimit {
return clientError{Code: "VEHICLE_PROFILE_SYNC_SIZE_INVALID", Message: "单批车辆主档必须为 1 至 500 条"}
}
if request.Actor == "" {
return clientError{Code: "VEHICLE_PROFILE_ACTOR_REQUIRED", Message: "缺少档案同步操作人"}
}
seen := make(map[string]struct{}, len(request.Items))
for _, item := range request.Items {
if err := validateProfileVIN(item.VIN); err != nil {
return err
}
if _, exists := seen[item.VIN]; exists {
return clientError{Code: "VEHICLE_PROFILE_SYNC_DUPLICATE_VIN", Message: "同一批次不能包含重复 VIN"}
}
seen[item.VIN] = struct{}{}
if err := validateVehicleProfileInput(vehicleProfileSyncInput(item, request.Actor)); err != nil {
return err
}
}
return nil
}
func vehicleProfileSyncInput(item VehicleProfileSyncItem, actor string) VehicleProfileInput {
return VehicleProfileInput{
ModelName: item.ModelName, VehicleType: item.VehicleType, CompanyName: item.CompanyName,
OperationStatus: item.OperationStatus, AccessProvider: item.AccessProvider,
FirstAccessAt: item.FirstAccessAt, RuntimeSeconds: item.RuntimeSeconds, Actor: actor,
}
}
func vehicleProfileSyncDecision(current VehicleProfile, exists bool, request VehicleProfileSyncRequest, item VehicleProfileSyncItem) string {
if !exists {
return profileSyncCreated
}
if current.SourceSystem == request.SourceSystem && current.SourceVersion == request.SourceVersion {
if vehicleProfileSyncFieldsEqual(current, item) {
return profileSyncUnchanged
}
return profileSyncConflictSourceVersion
}
if request.ConflictPolicy == profileSyncConflictPolicyPreserve && current.SourceSystem != request.SourceSystem {
return profileSyncConflictSource
}
return profileSyncUpdated
}
func vehicleProfileSyncFieldsEqual(current VehicleProfile, item VehicleProfileSyncItem) bool {
return current.ModelName == item.ModelName && current.VehicleType == item.VehicleType &&
current.CompanyName == item.CompanyName && current.OperationStatus == item.OperationStatus &&
current.AccessProvider == item.AccessProvider && current.FirstAccessAt == item.FirstAccessAt &&
equalOptionalInt64(current.RuntimeSeconds, item.RuntimeSeconds)
}
func equalOptionalInt64(left, right *int64) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
func countVehicleProfileSyncResult(result *VehicleProfileSyncResult, status string) {
switch status {
case profileSyncCreated:
result.Created++
case profileSyncUpdated:
result.Updated++
case profileSyncUnchanged:
result.Unchanged++
case profileSyncMissingVehicle:
result.Missing++
default:
result.Conflicted++
}
}
func (s *Service) vehicleExists(ctx context.Context, vin string) (bool, error) {
page, err := s.store.Vehicles(ctx, url.Values{"keyword": {vin}, "limit": {"20"}})
if err != nil {
return false, err
}
for _, item := range page.Items {
if strings.EqualFold(strings.TrimSpace(item.VIN), vin) {
return true, nil
}
}
return false, nil
}
func validateProfileVIN(vin string) error {
if vin == "" || len(vin) > 32 {
return clientError{Code: "VEHICLE_PROFILE_VIN_INVALID", Message: "VIN 不能为空且不能超过 32 个字符"}
}
return nil
}
func normalizeVehicleProfileInput(input VehicleProfileInput) VehicleProfileInput {
input.ModelName = strings.TrimSpace(input.ModelName)
input.VehicleType = strings.TrimSpace(input.VehicleType)
input.CompanyName = strings.TrimSpace(input.CompanyName)
input.OperationStatus = strings.ToLower(strings.TrimSpace(input.OperationStatus))
input.AccessProvider = strings.TrimSpace(input.AccessProvider)
input.FirstAccessAt = strings.TrimSpace(input.FirstAccessAt)
input.Actor = strings.TrimSpace(input.Actor)
if input.OperationStatus == "" {
input.OperationStatus = "unknown"
}
return input
}
func validateVehicleProfileInput(input VehicleProfileInput) error {
lengths := []struct {
value string
max int
name string
}{
{input.ModelName, 128, "车型"}, {input.VehicleType, 64, "车辆类型"},
{input.CompanyName, 128, "所属企业"}, {input.AccessProvider, 128, "接入服务商"},
}
for _, field := range lengths {
if len([]rune(field.value)) > field.max {
return clientError{Code: "VEHICLE_PROFILE_FIELD_INVALID", Message: field.name + "长度超出限制"}
}
}
if !vehicleOperationStatuses[input.OperationStatus] {
return clientError{Code: "VEHICLE_PROFILE_STATUS_INVALID", Message: "运营状态不在允许范围内"}
}
if input.RuntimeSeconds != nil && *input.RuntimeSeconds < 0 {
return clientError{Code: "VEHICLE_PROFILE_RUNTIME_INVALID", Message: "累计运行时长不能为负数"}
}
if input.FirstAccessAt != "" {
if _, err := parseVehicleProfileTime(input.FirstAccessAt); err != nil {
return clientError{Code: "VEHICLE_PROFILE_TIME_INVALID", Message: "首次接入时间格式无效"}
}
}
if input.Version < 0 {
return clientError{Code: "VEHICLE_PROFILE_VERSION_INVALID", Message: "档案版本无效"}
}
if input.Actor == "" {
return clientError{Code: "VEHICLE_PROFILE_ACTOR_REQUIRED", Message: "缺少档案维护人"}
}
return nil
}
func parseVehicleProfileTime(value string) (time.Time, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02 15:04:05"} {
if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("unsupported vehicle profile time")
}
func decorateVehicleProfile(profile VehicleProfile) VehicleProfile {
missing := make([]string, 0, 7)
if profile.ModelName == "" {
missing = append(missing, "modelName")
}
if profile.VehicleType == "" {
missing = append(missing, "vehicleType")
}
if profile.CompanyName == "" {
missing = append(missing, "companyName")
}
if profile.OperationStatus == "" || profile.OperationStatus == "unknown" {
missing = append(missing, "operationStatus")
}
if profile.AccessProvider == "" {
missing = append(missing, "accessProvider")
}
if profile.FirstAccessAt == "" {
missing = append(missing, "firstAccessAt")
}
if profile.RuntimeSeconds == nil {
missing = append(missing, "runtimeSeconds")
}
profile.MissingFields = missing
profile.Completeness = (7 - len(missing)) * 100 / 7
if profile.OperationStatus == "" {
profile.OperationStatus = "unknown"
}
if profile.SourceSystem == "" {
profile.SourceSystem = "unconfigured"
}
return profile
}

View File

@@ -0,0 +1,226 @@
package platform
import (
"context"
"database/sql"
"encoding/json"
"strings"
"time"
)
const vehicleProfileSelect = `SELECT vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by,updated_at FROM vehicle_profile `
func (s *ProductionStore) ensureProfileSchema(ctx context.Context) error {
s.profileSchemaOnce.Do(func() {
var present int
s.profileSchemaErr = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`).Scan(&present)
if s.profileSchemaErr == nil && present != 2 {
s.profileSchemaErr = sql.ErrNoRows
}
})
return s.profileSchemaErr
}
func (s *ProductionStore) VehicleProfile(ctx context.Context, vin string) (VehicleProfile, bool, error) {
if err := s.ensureProfileSchema(ctx); err != nil {
return VehicleProfile{}, false, err
}
profile, err := scanVehicleProfile(s.db.QueryRowContext(ctx, vehicleProfileSelect+`WHERE vin=?`, vin))
if err == sql.ErrNoRows {
return VehicleProfile{}, false, nil
}
return profile, err == nil, err
}
func (s *ProductionStore) SaveVehicleProfile(ctx context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) {
if err := s.ensureProfileSchema(ctx); err != nil {
return VehicleProfile{}, err
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return VehicleProfile{}, err
}
defer tx.Rollback()
var current int
err = tx.QueryRowContext(ctx, `SELECT version FROM vehicle_profile WHERE vin=? FOR UPDATE`, vin).Scan(&current)
firstAccess := nullableVehicleProfileTime(input.FirstAccessAt)
if err == sql.ErrNoRows {
if input.Version != 0 {
return VehicleProfile{}, clientError{Code: "VEHICLE_PROFILE_NOT_FOUND", Message: "待更新车辆档案不存在"}
}
_, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`, vin, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor)
current = 0
} else if err != nil {
return VehicleProfile{}, err
} else {
if input.Version != current {
return VehicleProfile{}, clientError{Code: "VEHICLE_PROFILE_VERSION_CONFLICT", Message: "车辆档案已被其他用户更新,请刷新后重试"}
}
result, updateErr := tx.ExecContext(ctx, `UPDATE vehicle_profile SET model_name=?,vehicle_type=?,company_name=?,operation_status=?,access_provider=?,first_access_at=?,runtime_seconds=?,source_system='manual',source_version='',synced_at=NULL,version=version+1,updated_by=? WHERE vin=? AND version=?`, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor, vin, current)
err = updateErr
if err == nil {
rows, _ := result.RowsAffected()
if rows != 1 {
err = clientError{Code: "VEHICLE_PROFILE_VERSION_CONFLICT", Message: "车辆档案更新冲突,请刷新后重试"}
}
}
}
if err != nil {
return VehicleProfile{}, err
}
snapshot, _ := json.Marshal(input)
action := "update"
if current == 0 {
action = "create"
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile_audit(vin,profile_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, vin, current+1, input.Actor, action, string(snapshot)); err != nil {
return VehicleProfile{}, err
}
if err = tx.Commit(); err != nil {
return VehicleProfile{}, err
}
return scanVehicleProfile(s.db.QueryRowContext(ctx, vehicleProfileSelect+`WHERE vin=?`, vin))
}
func (s *ProductionStore) SyncVehicleProfiles(ctx context.Context, request VehicleProfileSyncRequest) (VehicleProfileSyncResult, error) {
result := VehicleProfileSyncResult{
SourceSystem: request.SourceSystem, SourceVersion: request.SourceVersion,
DryRun: request.DryRun, Received: len(request.Items),
Items: make([]VehicleProfileSyncItemResult, 0, len(request.Items)),
}
if err := s.ensureProfileSchema(ctx); err != nil {
return VehicleProfileSyncResult{}, err
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return VehicleProfileSyncResult{}, err
}
defer tx.Rollback()
identityArgs := make([]any, 0, len(request.Items))
for _, item := range request.Items {
identityArgs = append(identityArgs, item.VIN)
}
rows, err := tx.QueryContext(ctx, `SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin IN (`+repeatPlaceholders(len(identityArgs))+`)`, identityArgs...)
if err != nil {
return VehicleProfileSyncResult{}, err
}
knownVINs := make(map[string]bool, len(request.Items))
for rows.Next() {
var vin string
if err := rows.Scan(&vin); err != nil {
rows.Close()
return VehicleProfileSyncResult{}, err
}
knownVINs[vin] = true
}
if err := rows.Close(); err != nil {
return VehicleProfileSyncResult{}, err
}
if err := rows.Err(); err != nil {
return VehicleProfileSyncResult{}, err
}
syncedAt := time.Now()
for _, item := range request.Items {
itemResult := VehicleProfileSyncItemResult{VIN: item.VIN}
if !knownVINs[item.VIN] {
itemResult.Status = profileSyncMissingVehicle
countVehicleProfileSyncResult(&result, itemResult.Status)
result.Items = append(result.Items, itemResult)
continue
}
current, scanErr := scanVehicleProfile(tx.QueryRowContext(ctx, vehicleProfileSelect+`WHERE vin=? FOR UPDATE`, item.VIN))
exists := scanErr == nil
if scanErr != nil && scanErr != sql.ErrNoRows {
return VehicleProfileSyncResult{}, scanErr
}
if exists {
itemResult.PreviousSource = current.SourceSystem
itemResult.PreviousVersion = current.SourceVersion
}
itemResult.Status = vehicleProfileSyncDecision(current, exists, request, item)
itemResult.ProfileVersion = current.Version
if itemResult.Status == profileSyncCreated {
itemResult.ProfileVersion = 1
} else if itemResult.Status == profileSyncUpdated {
itemResult.ProfileVersion = current.Version + 1
}
countVehicleProfileSyncResult(&result, itemResult.Status)
result.Items = append(result.Items, itemResult)
if request.DryRun || (itemResult.Status != profileSyncCreated && itemResult.Status != profileSyncUpdated) {
continue
}
firstAccess := nullableVehicleProfileTime(item.FirstAccessAt)
if itemResult.Status == profileSyncCreated {
_, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?)`, item.VIN, item.ModelName, item.VehicleType, item.CompanyName, item.OperationStatus, item.AccessProvider, firstAccess, item.RuntimeSeconds, request.SourceSystem, request.SourceVersion, syncedAt, request.Actor)
} else {
var updateResult sql.Result
updateResult, err = tx.ExecContext(ctx, `UPDATE vehicle_profile SET model_name=?,vehicle_type=?,company_name=?,operation_status=?,access_provider=?,first_access_at=?,runtime_seconds=?,source_system=?,source_version=?,synced_at=?,version=version+1,updated_by=? WHERE vin=? AND version=?`, item.ModelName, item.VehicleType, item.CompanyName, item.OperationStatus, item.AccessProvider, firstAccess, item.RuntimeSeconds, request.SourceSystem, request.SourceVersion, syncedAt, request.Actor, item.VIN, current.Version)
if err == nil {
rowsAffected, rowsErr := updateResult.RowsAffected()
if rowsErr != nil {
err = rowsErr
} else if rowsAffected != 1 {
err = clientError{Code: "VEHICLE_PROFILE_VERSION_CONFLICT", Message: "车辆档案同步冲突,请重新预演"}
}
}
}
if err != nil {
return VehicleProfileSyncResult{}, err
}
snapshot, _ := json.Marshal(struct {
SourceSystem string `json:"sourceSystem"`
SourceVersion string `json:"sourceVersion"`
Profile VehicleProfileSyncItem `json:"profile"`
}{request.SourceSystem, request.SourceVersion, item})
action := "sync_" + itemResult.Status
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile_audit(vin,profile_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, item.VIN, itemResult.ProfileVersion, request.Actor, action, string(snapshot)); err != nil {
return VehicleProfileSyncResult{}, err
}
}
if request.DryRun {
return result, nil
}
if err := tx.Commit(); err != nil {
return VehicleProfileSyncResult{}, err
}
return result, nil
}
func nullableVehicleProfileTime(value string) any {
if strings.TrimSpace(value) == "" {
return nil
}
parsed, err := parseVehicleProfileTime(value)
if err != nil {
return nil
}
return parsed
}
type vehicleProfileScanner interface{ Scan(...any) error }
func scanVehicleProfile(scanner vehicleProfileScanner) (VehicleProfile, error) {
var profile VehicleProfile
var firstAccess, syncedAt sql.NullTime
var runtime sql.NullInt64
var updatedAt time.Time
err := scanner.Scan(&profile.VIN, &profile.ModelName, &profile.VehicleType, &profile.CompanyName, &profile.OperationStatus, &profile.AccessProvider, &firstAccess, &runtime, &profile.SourceSystem, &profile.SourceVersion, &syncedAt, &profile.Version, &profile.UpdatedBy, &updatedAt)
if err != nil {
return VehicleProfile{}, err
}
if firstAccess.Valid {
profile.FirstAccessAt = firstAccess.Time.Format(time.RFC3339)
}
if runtime.Valid {
value := runtime.Int64
profile.RuntimeSeconds = &value
}
if syncedAt.Valid {
profile.SyncedAt = syncedAt.Time.Format(time.RFC3339)
}
profile.UpdatedAt = updatedAt.Format(time.RFC3339)
return profile, nil
}

View File

@@ -0,0 +1,265 @@
package platform
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestVehicleProfileReturnsCompletenessAndMissingFields(t *testing.T) {
service := NewService(NewMockStore())
profile, err := service.VehicleProfile(context.Background(), "LB9A32A24R0LS1426")
if err != nil {
t.Fatal(err)
}
if profile.Completeness != 100 || len(profile.MissingFields) != 0 || profile.RuntimeSeconds == nil {
t.Fatalf("expected complete seeded profile, got %+v", profile)
}
empty, err := service.VehicleProfile(context.Background(), "LNXNEGRR7SR318212")
if err != nil {
t.Fatal(err)
}
if empty.Version != 0 || empty.Completeness != 0 || len(empty.MissingFields) != 7 {
t.Fatalf("expected explicit empty profile, got %+v", empty)
}
}
func TestSaveVehicleProfileCreatesAndUsesOptimisticVersion(t *testing.T) {
service := NewService(NewMockStore())
runtime := int64(3600)
input := VehicleProfileInput{ModelName: "氢燃料重卡", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime, Actor: "admin-a"}
profile, err := service.SaveVehicleProfile(context.Background(), "LNXNEGRR7SR318212", input)
if err != nil {
t.Fatal(err)
}
if profile.Version != 1 || profile.Completeness != 100 || profile.UpdatedBy != "admin-a" {
t.Fatalf("unexpected created profile: %+v", profile)
}
input.Version = 0
_, err = service.SaveVehicleProfile(context.Background(), "LNXNEGRR7SR318212", input)
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "VEHICLE_PROFILE_VERSION_CONFLICT" {
t.Fatalf("stale write should conflict, got %v", err)
}
}
func TestVehicleProfileValidationRejectsInventedVINAndInvalidValues(t *testing.T) {
service := NewService(NewMockStore())
negative := int64(-1)
_, err := service.SaveVehicleProfile(context.Background(), "LNOTPRESENT000000", VehicleProfileInput{OperationStatus: "active", Actor: "admin"})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "VEHICLE_NOT_FOUND" {
t.Fatalf("invented VIN should be rejected, got %v", err)
}
_, err = service.SaveVehicleProfile(context.Background(), "LNXNEGRR7SR318212", VehicleProfileInput{OperationStatus: "flying", RuntimeSeconds: &negative, Actor: "admin"})
clientErr, ok = asClientError(err)
if !ok || clientErr.Code != "VEHICLE_PROFILE_STATUS_INVALID" {
t.Fatalf("invalid status should be rejected first, got %v", err)
}
}
func TestVehicleProfileSyncIsDryRunnableIdempotentAndVersionSafe(t *testing.T) {
service := NewService(NewMockStore())
runtime := int64(7200)
request := VehicleProfileSyncRequest{
SourceSystem: " OEM-TSP ", SourceVersion: "snapshot-20260714-01", DryRun: true, Actor: "sync-admin",
Items: []VehicleProfileSyncItem{{VIN: " lnxnegrr7sr318212 ", ModelName: "氢燃料重卡", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "ACTIVE", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime}},
}
dryRun, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if !dryRun.DryRun || dryRun.Created != 1 || dryRun.Items[0].VIN != "LNXNEGRR7SR318212" {
t.Fatalf("unexpected dry-run result: %+v", dryRun)
}
profile, err := service.VehicleProfile(context.Background(), "LNXNEGRR7SR318212")
if err != nil || profile.Version != 0 {
t.Fatalf("dry-run must not persist profile: profile=%+v err=%v", profile, err)
}
request.DryRun = false
created, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil || created.Created != 1 {
t.Fatalf("expected created sync: result=%+v err=%v", created, err)
}
profile, err = service.VehicleProfile(context.Background(), "LNXNEGRR7SR318212")
if err != nil || profile.SourceSystem != "oem-tsp" || profile.SourceVersion != request.SourceVersion || profile.Version != 1 {
t.Fatalf("unexpected synced profile: %+v err=%v", profile, err)
}
repeated, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil || repeated.Unchanged != 1 || repeated.Items[0].Status != profileSyncUnchanged {
t.Fatalf("same source version should be idempotent: result=%+v err=%v", repeated, err)
}
request.Items[0].CompanyName = "同版本篡改"
conflict, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil || conflict.Conflicted != 1 || conflict.Items[0].Status != profileSyncConflictSourceVersion {
t.Fatalf("changed payload under same source version should conflict: result=%+v err=%v", conflict, err)
}
request.SourceVersion = "snapshot-20260714-02"
updated, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil || updated.Updated != 1 || updated.Items[0].ProfileVersion != 2 {
t.Fatalf("new source version should update: result=%+v err=%v", updated, err)
}
}
func TestVehicleProfileSyncProtectsManualOwnershipAndReportsMissingVIN(t *testing.T) {
service := NewService(NewMockStore())
request := VehicleProfileSyncRequest{
SourceSystem: "fleet-gps", SourceVersion: "42", Actor: "sync-admin",
Items: []VehicleProfileSyncItem{
{VIN: "LB9A32A24R0LS1426", ModelName: "外部车型", OperationStatus: "active"},
{VIN: "LNOTPRESENT000000", ModelName: "未知车辆", OperationStatus: "active"},
},
}
result, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if result.Conflicted != 1 || result.Missing != 1 || result.Items[0].Status != profileSyncConflictSource || result.Items[1].Status != profileSyncMissingVehicle {
t.Fatalf("default policy must preserve manual profile and report missing VIN: %+v", result)
}
request.ConflictPolicy = profileSyncConflictPolicyOverwrite
request.Items = request.Items[:1]
overwritten, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil || overwritten.Updated != 1 {
t.Fatalf("explicit overwrite should take ownership: result=%+v err=%v", overwritten, err)
}
profile, _ := service.VehicleProfile(context.Background(), "LB9A32A24R0LS1426")
if profile.SourceSystem != "fleet-gps" || profile.ModelName != "外部车型" {
t.Fatalf("overwrite did not update source ownership: %+v", profile)
}
}
func TestVehicleProfileSyncValidatesBatchBoundsAndDuplicateVIN(t *testing.T) {
service := NewService(NewMockStore())
base := VehicleProfileSyncRequest{SourceSystem: "oem-tsp", SourceVersion: "1", Actor: "admin"}
base.Items = []VehicleProfileSyncItem{{VIN: "LNXNEGRR7SR318212"}, {VIN: "lnxnegrr7sr318212"}}
_, err := service.SyncVehicleProfiles(context.Background(), base)
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "VEHICLE_PROFILE_SYNC_DUPLICATE_VIN" {
t.Fatalf("duplicate VIN should fail validation, got %v", err)
}
base.Items = make([]VehicleProfileSyncItem, vehicleProfileSyncLimit+1)
_, err = service.SyncVehicleProfiles(context.Background(), base)
clientErr, ok = asClientError(err)
if !ok || clientErr.Code != "VEHICLE_PROFILE_SYNC_SIZE_INVALID" {
t.Fatalf("oversized batch should fail validation, got %v", err)
}
}
func TestVehicleDetailIncludesProfile(t *testing.T) {
service := NewService(NewMockStore())
detail, err := service.VehicleDetail(context.Background(), "LB9A32A24R0LS1426", "")
if err != nil {
t.Fatal(err)
}
if detail.Profile == nil || detail.Profile.CompanyName != "岭牛示范车队" || detail.Profile.Completeness != 100 {
t.Fatalf("vehicle detail missing master profile: %+v", detail.Profile)
}
}
func TestVehicleProfileHandlersReadAndUpdate(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
read := httptest.NewRecorder()
handler.ServeHTTP(read, httptest.NewRequest(http.MethodGet, "/api/v2/vehicles/LNXNEGRR7SR318212/profile", nil))
if read.Code != http.StatusOK || !bytes.Contains(read.Body.Bytes(), []byte(`"completeness":0`)) {
t.Fatalf("empty profile read status=%d body=%s", read.Code, read.Body.String())
}
update := httptest.NewRecorder()
body := bytes.NewBufferString(`{"modelName":"氢燃料重卡","vehicleType":"重卡","companyName":"示范物流","operationStatus":"active","accessProvider":"车厂平台","firstAccessAt":"2026-07-01T08:30","runtimeSeconds":3600,"version":0}`)
handler.ServeHTTP(update, httptest.NewRequest(http.MethodPut, "/api/v2/vehicles/LNXNEGRR7SR318212/profile", body))
if update.Code != http.StatusOK {
t.Fatalf("profile update status=%d body=%s", update.Code, update.Body.String())
}
var response struct {
Data VehicleProfile `json:"data"`
}
if err := json.Unmarshal(update.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Data.Version != 1 || response.Data.Completeness != 100 || response.Data.UpdatedBy != "system" {
t.Fatalf("unexpected handler profile: %+v", response.Data)
}
}
func TestVehicleProfileSyncHandlerUsesAuthenticatedActor(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
recorder := httptest.NewRecorder()
body := bytes.NewBufferString(`{"sourceSystem":"oem-tsp","sourceVersion":"v1","items":[{"vin":"LNXNEGRR7SR318212","modelName":"氢燃料重卡","operationStatus":"active"}]}`)
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/v2/vehicle-profiles/sync", body))
if recorder.Code != http.StatusOK || !bytes.Contains(recorder.Body.Bytes(), []byte(`"created":1`)) {
t.Fatalf("profile sync status=%d body=%s", recorder.Code, recorder.Body.String())
}
profile, _ := handler.service.VehicleProfile(context.Background(), "LNXNEGRR7SR318212")
if profile.UpdatedBy != "system" {
t.Fatalf("handler must override body actor with authenticated actor, got %+v", profile)
}
}
func TestProductionVehicleProfileCreateIsTransactionalAndAudited(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
now := time.Now()
runtime := int64(7200)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM vehicle_profile WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"version"}))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`)).WithArgs("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "admin-a").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile_audit(vin,profile_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("VIN001", 1, "admin-a", "create", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
columns := []string{"vin", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"}
mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=?`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows(columns).AddRow("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", now, runtime, "manual", "", nil, 1, "admin-a", now))
profile, err := store.SaveVehicleProfile(context.Background(), "VIN001", VehicleProfileInput{ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: now.Format(time.RFC3339), RuntimeSeconds: &runtime, Actor: "admin-a"})
if err != nil {
t.Fatal(err)
}
if profile.Version != 1 || profile.RuntimeSeconds == nil || *profile.RuntimeSeconds != runtime {
t.Fatalf("unexpected stored profile: %+v", profile)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestProductionVehicleProfileSyncCreatesAndAuditsInOneTransaction(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
runtime := int64(3600)
request := VehicleProfileSyncRequest{
SourceSystem: "oem-tsp", SourceVersion: "snapshot-1", Actor: "sync-admin",
Items: []VehicleProfileSyncItem{{VIN: "VIN001", ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime}},
}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin IN (?)`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"}))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?)`)).WithArgs("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "oem-tsp", "snapshot-1", sqlmock.AnyArg(), "sync-admin").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile_audit(vin,profile_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("VIN001", 1, "sync-admin", "sync_created", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
result, err := store.SyncVehicleProfiles(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if result.Created != 1 || result.Items[0].ProfileVersion != 1 {
t.Fatalf("unexpected sync result: %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@@ -232,6 +232,15 @@ func TestBuildVehicleRealtimeSQLFiltersServiceStatus(t *testing.T) {
}
}
func TestBuildVehicleRealtimeSQLFiltersMotionStatusBeforePagination(t *testing.T) {
built := buildVehicleRealtimeSQL(url.Values{"status": {"driving"}, "limit": {"200"}})
for _, want := range []string{"HAVING", "INTERVAL 1 MINUTE", "GROUP_CONCAT(CAST(l.speed_kmh AS CHAR)", "> 3"} {
if !strings.Contains(built.Text, want) || !strings.Contains(built.CountText, want) {
t.Fatalf("motion filter must constrain rows and count before LIMIT, missing %q: %s / %s", want, built.Text, built.CountText)
}
}
}
func TestBuildDailyMileageSQL(t *testing.T) {
query := url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
built := buildDailyMileageSQL(query)
@@ -241,6 +250,9 @@ func TestBuildDailyMileageSQL(t *testing.T) {
if !strings.Contains(built.Text, "ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC") {
t.Fatalf("SQL should keep stable pagination order: %s", built.Text)
}
if strings.Contains(built.Text, "m.first_total_mileage_km") || !strings.Contains(built.Text, "m.latest_total_mileage_km - m.daily_mileage_km") {
t.Fatalf("daily mileage must follow the current projection schema and derive its start value: %s", built.Text)
}
if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
t.Fatalf("count SQL = %s", built.CountText)
}
@@ -285,6 +297,13 @@ func TestBuildRawFrameSQL(t *testing.T) {
}
}
func TestBuildRawFrameSQLCanSkipUnneededCount(t *testing.T) {
built := buildRawFrameSQL("lingniu_vehicle_ts", RawFrameQuery{VIN: "VIN001", IncludeFields: true, Limit: 100, SkipCount: true})
if built.CountText != "" || !strings.Contains(built.Text, "LIMIT 100") {
t.Fatalf("bounded latest query should skip count: %+v", built)
}
}
func TestBuildHistoryLocationSQL(t *testing.T) {
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
"protocol": "JT808",
@@ -295,6 +314,14 @@ func TestBuildHistoryLocationSQL(t *testing.T) {
if !strings.Contains(built.Text, "lingniu_vehicle_ts.loc_jt808_") || !strings.Contains(built.Text, "vin = 'VIN001'") {
t.Fatalf("SQL = %s", built.Text)
}
for _, field := range []string{"soc_percent", "direction_deg", "alarm_flag"} {
if !strings.Contains(built.Text, field) {
t.Fatalf("track evidence field %s missing from SQL: %s", field, built.Text)
}
}
if !strings.Contains(built.Text, "CAST(ts AS BIGINT)") || !strings.Contains(built.Text, "CAST(received_at AS BIGINT)") {
t.Fatalf("history location time must use epoch scans: %s", built.Text)
}
if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
t.Fatalf("count SQL = %s", built.CountText)
}
@@ -303,10 +330,61 @@ func TestBuildHistoryLocationSQL(t *testing.T) {
}
}
func TestBuildHistoryLocationSQLNormalizesDatetimeLocalMinutePrecision(t *testing.T) {
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
"vin": "VIN001", "dateFrom": "2026-07-14T00:00", "dateTo": "2026-07-14T05:56", "limit": "10",
})
for _, want := range []string{"ts >= '2026-07-14T00:00:00+08:00'", "ts <= '2026-07-14T05:56:00+08:00'"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("datetime-local minute precision must be normalized for TDengine, missing %q in %s", want, built.Text)
}
}
}
func TestTDengineTimeLiteralsAlwaysCarryAnExplicitOffset(t *testing.T) {
for _, value := range []string{"2026-07-14T08:24", "2026-07-14 08:24:00", "2026-07-14T00:24:00Z"} {
normalized := normalizeTDengineTime(value)
if !strings.HasSuffix(normalized, "+08:00") && !strings.HasSuffix(normalized, "Z") {
t.Fatalf("TDengine time literal lost timezone: input=%s normalized=%s", value, normalized)
}
}
}
func TestBuildHistoryLocationSeriesSQLUsesWhitelistedAggregationWindow(t *testing.T) {
built := buildHistoryLocationSeriesSQL("lingniu_vehicle_ts", HistoryLocationSeriesQuery{VIN: "VIN'001", Protocol: "jt808", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T05:56", GrainSeconds: 60})
for _, want := range []string{"SELECT _wstart, protocol, COUNT(*)", "AVG(speed_kmh)", "LAST(total_mileage_km)", "vin = 'VIN''001'", "protocol = 'JT808'", "ts >= '2026-07-14T00:00:00+08:00'", "PARTITION BY protocol INTERVAL(60s)", "ORDER BY _wstart ASC, protocol ASC"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("series SQL missing %q: %s", want, built.Text)
}
}
if strings.Contains(built.Text, "FILL") || strings.Contains(built.Text, "GROUP BY") {
t.Fatalf("series SQL must preserve missing windows and use TDengine window syntax: %s", built.Text)
}
}
func TestBuildHistoryExportBatchSQLUsesStableForwardCursor(t *testing.T) {
built := buildHistoryExportBatchSQL("lingniu_vehicle_ts", HistoryExportStoreQuery{Category: "location", VIN: "VIN001", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00"}, HistoryExportCursor{Time: "2026-07-14T01:00:00+08:00", Protocol: "GB32960"}, 5000)
for _, want := range []string{"ts >= '2026-07-14T00:00:00+08:00'", "ts <= '2026-07-14T06:00:00+08:00'", "ts > '2026-07-14T01:00:00+08:00'", "protocol > 'GB32960'", "ORDER BY ts ASC, protocol ASC LIMIT 5000"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("location export SQL missing %q: %s", want, built.Text)
}
}
if strings.Contains(built.Text, "OFFSET") {
t.Fatalf("million-row export must not use OFFSET: %s", built.Text)
}
raw := buildHistoryExportBatchSQL("lingniu_vehicle_ts", HistoryExportStoreQuery{Category: "raw", VIN: "VIN001"}, HistoryExportCursor{Time: "2026-07-14T01:00:00+08:00", Protocol: "JT808", ID: "frame-9"}, 5000)
for _, want := range []string{"frame_id > 'frame-9'", "ORDER BY ts ASC, protocol ASC, frame_id ASC", "parsed_json"} {
if !strings.Contains(raw.Text, want) {
t.Fatalf("raw export SQL missing %q: %s", want, raw.Text)
}
}
}
func TestBuildTodayRawFrameCountSQL(t *testing.T) {
now := time.Date(2026, 7, 3, 22, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
built := buildTodayRawFrameCountSQL("lingniu_vehicle_ts", now)
for _, want := range []string{"SELECT COUNT(*)", "lingniu_vehicle_ts.raw_frames", "ts >= '2026-07-02 16:00:00'"} {
for _, want := range []string{"SELECT COUNT(*)", "lingniu_vehicle_ts.raw_frames", "ts >= '2026-07-03T00:00:00+08:00'"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("SQL missing %q: %s", want, built.Text)
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,50 @@ package platform
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
)
func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
dir := t.TempDir()
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
completedAt := time.Now().UTC().Format(time.RFC3339)
filePath := filepath.Join(dir, "exp_completed.csv")
if err := os.WriteFile(filePath, []byte("vin\nVIN001\n"), 0o640); err != nil {
t.Fatal(err)
}
first.exportsMu.Lock()
first.exports["exp_completed"] = &HistoryExportJob{ID: "exp_completed", Name: "完成任务", Status: "completed", Progress: 100, Format: "csv", Category: "location", CreatedAt: completedAt, UpdatedAt: completedAt, DownloadURL: "/api/v2/exports/exp_completed/download", filePath: filePath}
first.exports["exp_running"] = &HistoryExportJob{ID: "exp_running", Name: "中断任务", Status: "running", Progress: 50, Format: "csv", Category: "location", CreatedAt: completedAt, UpdatedAt: completedAt}
if err := first.persistHistoryExportsLocked(); err != nil {
first.exportsMu.Unlock()
t.Fatal(err)
}
first.exportsMu.Unlock()
restarted := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
jobs := restarted.ListHistoryExports()
if len(jobs) != 2 {
t.Fatalf("jobs=%+v", jobs)
}
path, _, err := restarted.HistoryExportFile("exp_completed")
if err != nil || path != filePath {
t.Fatalf("completed export not restored: path=%q err=%v", path, err)
}
for _, job := range jobs {
if job.ID == "exp_running" && (job.Status != "failed" || !strings.Contains(job.Error, "服务重启")) {
t.Fatalf("interrupted export should be failed: %+v", job)
}
}
}
type countingStore struct {
*MockStore
vehiclesCalls int
@@ -102,3 +142,467 @@ func TestCompleteProtocolStatsIncludesCanonicalSlots(t *testing.T) {
t.Fatalf("missing canonical protocol should be exposed as zero slot, got %+v", byProtocol["GB32960"])
}
}
func TestSummarizeTrackUsesMileageAndChronology(t *testing.T) {
points := []HistoryLocationRow{
{DeviceTime: "2026-07-03 10:00:00", TotalMileageKm: 100, SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1},
{DeviceTime: "2026-07-03 10:30:00", TotalMileageKm: 112.5, SpeedKmh: 50, Longitude: 113.2, Latitude: 23.2},
}
summary := summarizeTrack(points)
if summary.DistanceKm != 12.5 || summary.DurationSeconds != 1800 || summary.MaximumSpeedKmh != 50 {
t.Fatalf("unexpected track summary: %+v", summary)
}
}
func TestTrackEventKeepsSynchronizedTelemetryEvidence(t *testing.T) {
direction := int64(88)
alarm := int64(2)
event := trackEvent(7, "alarm", "报警点", HistoryLocationRow{DeviceTime: "2026-07-03 10:00:00", SpeedKmh: 36, SOCPercent: 78.5, SOCAvailable: true, DirectionDeg: &direction, AlarmFlag: &alarm, Longitude: 113.2, Latitude: 23.1})
if event.Index != 7 || !event.SOCAvailable || event.SOCPercent != 78.5 || event.DirectionDeg == nil || *event.DirectionDeg != 88 || event.AlarmFlag == nil || *event.AlarmFlag != 2 {
t.Fatalf("track event lost synchronized telemetry evidence: %+v", event)
}
}
func TestSampleTrackPointsPreservesEndpoints(t *testing.T) {
points := make([]HistoryLocationRow, 10)
for index := range points {
points[index].DeviceTime = strconv.Itoa(index)
}
sampled := sampleTrackPoints(points, 4)
if len(sampled) != 4 || sampled[0].DeviceTime != "0" || sampled[len(sampled)-1].DeviceTime != "9" {
t.Fatalf("sampling should preserve endpoints: %+v", sampled)
}
}
func TestTrackAnalysisFiltersInvalidDuplicateAndDriftPoints(t *testing.T) {
points := []HistoryLocationRow{
{DeviceTime: "2026-07-03 10:00:00", Protocol: "JT808", Longitude: 113.1, Latitude: 23.1},
{DeviceTime: "2026-07-03 10:00:00", Protocol: "JT808", Longitude: 113.1, Latitude: 23.1},
{DeviceTime: "2026-07-03 10:01:00", Protocol: "JT808", Longitude: 114.1, Latitude: 24.1},
{DeviceTime: "2026-07-03 10:02:00", Protocol: "JT808", Longitude: 0, Latitude: 0},
{DeviceTime: "2026-07-03 10:03:00", Protocol: "JT808", Longitude: 113.101, Latitude: 23.101},
}
clean, quality := analyzeTrackPoints(points)
if len(clean) != 2 || quality.ValidPoints != 2 || quality.DuplicatePoints != 1 || quality.DriftPoints != 1 || quality.InvalidCoordinatePoints != 1 {
t.Fatalf("unexpected track quality projection: clean=%+v quality=%+v", clean, quality)
}
if quality.Status != "warning" {
t.Fatalf("filtered evidence must surface as warning: %+v", quality)
}
}
func TestTrackPrimarySourcePreventsParallelProtocolsFromBecomingOneRoute(t *testing.T) {
points := []HistoryLocationRow{
{DeviceTime: "2026-07-03 10:00:00", Protocol: "GB32960", Longitude: 113.1, Latitude: 23.1},
{DeviceTime: "2026-07-03 10:00:00", Protocol: "JT808", Longitude: 103.1, Latitude: 30.1},
{DeviceTime: "2026-07-03 10:01:00", Protocol: "GB32960", Longitude: 113.101, Latitude: 23.101},
{DeviceTime: "2026-07-03 10:01:00", Protocol: "JT808", Longitude: 103.101, Latitude: 30.101},
{DeviceTime: "2026-07-03 10:02:00", Protocol: "JT808", Longitude: 103.102, Latitude: 30.102},
}
clean, quality := analyzeTrackPoints(points)
if len(clean) != 5 || quality.DriftPoints != 0 {
t.Fatalf("parallel protocols must be checked within source: clean=%+v quality=%+v", clean, quality)
}
selected, protocol, alternate := selectTrackPrimarySource(clean, "")
if protocol != "JT808" || len(selected) != 3 || alternate != 2 {
t.Fatalf("unexpected primary source projection: protocol=%s selected=%+v alternate=%d", protocol, selected, alternate)
}
explicit, protocol, alternate := selectTrackPrimarySource(clean, "GB32960")
if protocol != "GB32960" || len(explicit) != 2 || alternate != 3 {
t.Fatalf("explicit source must win: protocol=%s selected=%+v alternate=%d", protocol, explicit, alternate)
}
}
func TestTrackSegmentsExposeStopsAndDataGapsWithoutClaimingIgnition(t *testing.T) {
points := []HistoryLocationRow{
{DeviceTime: "2026-07-03 10:00:00", SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1},
{DeviceTime: "2026-07-03 10:02:00", SpeedKmh: 0, Longitude: 113.10001, Latitude: 23.10001},
{DeviceTime: "2026-07-03 10:04:00", SpeedKmh: 0, Longitude: 113.10002, Latitude: 23.10002},
{DeviceTime: "2026-07-03 10:05:00", SpeedKmh: 30, Longitude: 113.11, Latitude: 23.11},
{DeviceTime: "2026-07-03 10:20:00", SpeedKmh: 30, Longitude: 113.12, Latitude: 23.12},
}
segments := buildTrackSegments(points)
if len(segments) != 3 || segments[0].Type != "stopped" || segments[1].Type != "moving" || segments[2].Type != "gap" {
t.Fatalf("unexpected inferred segments: %+v", segments)
}
stops := buildTrackStops(points, segments)
if len(stops) != 1 || stops[0].DurationSeconds != 240 || !strings.Contains(stops[0].Evidence, "不代表点火状态") {
t.Fatalf("unexpected inferred stops: %+v", stops)
}
}
func TestTrackSegmentsDoNotTreatSubsecondSamplesAsDataGaps(t *testing.T) {
points := []HistoryLocationRow{
{DeviceTime: "2026-07-03T10:00:00.100+08:00", SpeedKmh: 30, Longitude: 113.1, Latitude: 23.1},
{DeviceTime: "2026-07-03T10:00:00.900+08:00", SpeedKmh: 31, Longitude: 113.1001, Latitude: 23.1001},
{DeviceTime: "2026-07-03T10:00:01.700+08:00", SpeedKmh: 32, Longitude: 113.1002, Latitude: 23.1002},
}
segments := buildTrackSegments(points)
if len(segments) != 1 || segments[0].Type != "moving" || segments[0].PointCount != 3 {
t.Fatalf("subsecond telemetry must remain a continuous moving segment: %+v", segments)
}
}
func TestMonitorBoundsAreValidatedAndApplied(t *testing.T) {
bounds, ok, err := parseMonitorBounds("103,29,105,31")
if err != nil || !ok || !bounds.contains(104, 30) || bounds.contains(106, 30) {
t.Fatalf("unexpected monitor bounds: bounds=%+v ok=%t err=%v", bounds, ok, err)
}
for _, invalid := range []string{"103,29,105", "east,29,105,31", "105,31,103,29", "-181,0,1,1"} {
if _, _, invalidErr := parseMonitorBounds(invalid); invalidErr == nil {
t.Fatalf("invalid monitor bounds must be rejected: %q", invalid)
}
}
}
func TestMonitorQueryKeepsServerOwnedKeywordAndMotionStatus(t *testing.T) {
query := normalizeMonitorQuery(url.Values{"keyword": {"沪A"}, "status": {"driving"}})
if query.Get("vin") != "沪A" || query.Get("limit") != "10000" {
t.Fatalf("monitor query did not normalize keyword and bound: %v", query)
}
if !matchesMonitorStatus(VehicleRealtimeRow{Online: true, SpeedKmh: 20, LastSeen: "2026-07-14T08:00:00+08:00"}, "driving") {
t.Fatal("driving row must match driving filter")
}
if matchesMonitorStatus(VehicleRealtimeRow{Online: true, SpeedKmh: 0, LastSeen: "2026-07-14T08:00:00+08:00"}, "driving") {
t.Fatal("idle row must not match driving filter")
}
}
func TestMonitorMapTenThousandVehiclesNeverReturnsTenThousandPoints(t *testing.T) {
vehicles := syntheticMonitorVehicles(10_000)
result, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"13"}})
if err != nil {
t.Fatal(err)
}
if (result.Mode != "clusters" && result.Mode != "mixed") || len(result.Clusters) == 0 || len(result.Points)+len(result.Clusters) > monitorPointLimit {
t.Fatalf("10k unbounded map must stay aggregated: mode=%s points=%d clusters=%d", result.Mode, len(result.Points), len(result.Clusters))
}
for _, cluster := range result.Clusters {
if cluster.Count < 2 {
t.Fatalf("singleton must be released as a point instead of a fake cluster: %+v", cluster)
}
}
viewport, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"11"}, "bounds": {"113,22,114,24"}})
if err != nil {
t.Fatal(err)
}
if viewport.Mode != "points" || len(viewport.Points) >= monitorPointLimit {
t.Fatalf("bounded high zoom should return a controlled point set: mode=%s points=%d", viewport.Mode, len(viewport.Points))
}
}
func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) {
vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{
{VIN: "NEAR-1", Plate: "粤A00001", Online: true, Longitude: 113.260, Latitude: 23.130},
{VIN: "NEAR-2", Plate: "粤A00002", Online: true, Longitude: 113.265, Latitude: 23.135},
{VIN: "SINGLE", Plate: "粤A00003", Online: true, Longitude: 113.600, Latitude: 23.500},
}, Total: 3, Limit: 3}
mixed, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"10"}})
if err != nil {
t.Fatal(err)
}
if mixed.Mode != "mixed" || len(mixed.Clusters) != 1 || mixed.Clusters[0].Count != 2 || len(mixed.Points) != 1 || mixed.Points[0].VIN != "SINGLE" {
t.Fatalf("zoom below the detail threshold must keep only a meaningful cluster and release its singleton: %+v", mixed)
}
detail, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"11"}})
if err != nil {
t.Fatal(err)
}
if detail.Mode != "points" || len(detail.Points) != 3 || len(detail.Clusters) != 0 {
t.Fatalf("zoom 11 must release controlled data as exact points: %+v", detail)
}
}
func BenchmarkMonitorMapTenThousandVehicles(b *testing.B) {
vehicles := syntheticMonitorVehicles(10_000)
query := url.Values{"zoom": {"5"}}
b.ReportAllocs()
b.ResetTimer()
for index := 0; index < b.N; index++ {
if _, err := buildMonitorMapResponse(vehicles, query); err != nil {
b.Fatal(err)
}
}
}
func syntheticMonitorVehicles(count int) Page[VehicleRealtimeRow] {
items := make([]VehicleRealtimeRow, count)
for index := range items {
items[index] = VehicleRealtimeRow{
VIN: "SYNTH" + strconv.Itoa(index), Online: true, SpeedKmh: float64(index % 90),
Longitude: 73 + float64((index/100)%200)*0.3, Latitude: 18 + float64(index%100)*0.3,
LastSeen: "2026-07-14T08:00:00+08:00",
}
}
return Page[VehicleRealtimeRow]{Items: items, Total: count, Limit: count}
}
func TestKeyPointSamplingPreservesEventsAndMapsToReturnedIndexes(t *testing.T) {
points := make([]HistoryLocationRow, 20)
for index := range points {
points[index].DeviceTime = strconv.Itoa(index)
}
sampled, indexes := sampleTrackPointsWithKeys(points, 6, []int{5, 11})
if len(sampled) != 6 || !containsInt(indexes, 0) || !containsInt(indexes, 5) || !containsInt(indexes, 11) || !containsInt(indexes, 19) {
t.Fatalf("key-point sampling lost evidence: indexes=%+v", indexes)
}
mapped := nearestSampledIndex(11, indexes)
if mapped < 0 || mapped >= len(indexes) || indexes[mapped] != 11 {
t.Fatalf("event should map to its preserved returned point: mapped=%d indexes=%+v", mapped, indexes)
}
}
func TestTrackCoverageDoesNotPresentLatestSliceAsCompleteWindow(t *testing.T) {
coverage := trackCoverage(url.Values{"dateFrom": {"2026-07-01T00:00:00"}}, 71000, 5000, 4990, 1200, true, true)
if coverage.Complete || coverage.TotalPoints != 71000 || coverage.FetchedPoints != 5000 || len(coverage.LimitReasons) != 3 {
t.Fatalf("unexpected bounded coverage: %+v", coverage)
}
if !strings.Contains(coverage.Evidence, "不代表完整时间窗") {
t.Fatalf("coverage boundary must be explicit: %+v", coverage)
}
}
func TestTrackWindowRequiresBoundedOrderedPair(t *testing.T) {
if err := validateTrackWindow("2026-07-01T00:00", ""); err == nil {
t.Fatal("one-sided track window must be rejected")
}
if err := validateTrackWindow("2026-07-08T00:00", "2026-07-01T00:00"); err == nil {
t.Fatal("reversed track window must be rejected")
}
if err := validateTrackWindow("2026-07-01T00:00", "2026-07-09T00:00"); err == nil {
t.Fatal("track window over seven days must be rejected")
}
if err := validateTrackWindow("2026-07-01T00:00", "2026-07-08T00:00"); err != nil {
t.Fatalf("seven-day track window should be accepted: %v", err)
}
if err := validateTrackWindow("", ""); err != nil {
t.Fatalf("unbounded latest-slice compatibility mode should remain available: %v", err)
}
}
func containsInt(values []int, wanted int) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func TestHistoryExportRunsAsControlledAsyncJob(t *testing.T) {
service := NewService(NewMockStore())
job, err := service.CreateHistoryExport(HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"})
if err != nil {
t.Fatalf("create export: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
jobs := service.ListHistoryExports()
if len(jobs) > 0 && jobs[0].ID == job.ID && jobs[0].Status == "completed" {
path, _, fileErr := service.HistoryExportFile(job.ID)
if fileErr != nil {
t.Fatalf("export file: %v", fileErr)
}
body, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("read export: %v", readErr)
}
if len(body) < 3 || string(body[:3]) != "\xEF\xBB\xBF" {
t.Fatalf("CSV should include UTF-8 BOM")
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("export job did not complete: %+v", service.ListHistoryExports())
}
type largeHistoryExportStore struct {
*MockStore
total int64
}
func (s *largeHistoryExportStore) HistoryExportCount(context.Context, HistoryExportStoreQuery) (int64, error) {
return s.total, nil
}
func (s *largeHistoryExportStore) HistoryExportBatch(_ context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) {
remaining := int(s.total) - cursor.Offset
if remaining <= 0 {
return []HistoryDataRow{}, cursor, nil
}
if remaining < limit {
limit = remaining
}
rows := make([]HistoryDataRow, limit)
for index := range rows {
number := cursor.Offset + index
rows[index] = HistoryDataRow{ID: "row-" + strconv.Itoa(number), VIN: query.VIN, Plate: "粤A测试", Protocol: "JT808", DeviceTime: "2026-07-14T00:00:00+08:00", ServerTime: "2026-07-14T00:00:01+08:00", Quality: "normal", Values: map[string]any{"speedKmh": float64(number % 120)}}
}
cursor.Offset += len(rows)
return rows, cursor, nil
}
func TestHistoryExportStreamsBeyondLegacyLimit(t *testing.T) {
assertLargeHistoryExport(t, 12_345, 10*time.Second)
}
func TestHistoryExportMillionRows(t *testing.T) {
if os.Getenv("EXPORT_MILLION_TEST") != "1" {
t.Skip("set EXPORT_MILLION_TEST=1 for the release capacity gate")
}
assertLargeHistoryExport(t, 1_000_000, 2*time.Minute)
}
func assertLargeHistoryExport(t *testing.T, total int64, timeout time.Duration) {
t.Helper()
dir := t.TempDir()
store := &largeHistoryExportStore{MockStore: NewMockStore(), total: total}
service := NewServiceWithRuntime(store, RuntimeInfo{ExportDir: dir})
job, err := service.CreateHistoryExport(HistoryExportRequest{Keywords: []string{"LNXNEGRR7SR318212"}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv"})
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
current := service.ListHistoryExports()[0]
if current.Status == "failed" {
t.Fatalf("export failed: %+v", current)
}
if current.Status == "completed" {
if current.RowCount != int(total) || current.ProcessedRows != total || current.TotalRows != total || current.FileSizeBytes == 0 || current.CompletedAt == "" {
t.Fatalf("incomplete evidence: %+v", current)
}
path, _, fileErr := service.HistoryExportFile(job.ID)
if fileErr != nil {
t.Fatal(fileErr)
}
if _, statErr := os.Stat(path + ".part"); !os.IsNotExist(statErr) {
t.Fatalf("part file should be atomically removed: %v", statErr)
}
body, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
if !strings.Contains(string(body[:min(len(body), 512)]), "查询开始") || !strings.Contains(string(body[:min(len(body), 512)]), "速度(km/h)") {
t.Fatalf("CSV metadata missing: %q", body[:min(len(body), 512)])
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("export timed out: %+v", service.ListHistoryExports())
}
func TestRawMetricMetadataKeepsProtocolAndUnit(t *testing.T) {
label, unit := rawMetricMetadata("jt808.location.additional.total_mileage_km")
if label != "总里程 · JT808" || unit != "km" {
t.Fatalf("unexpected RAW metric metadata: %q %q", label, unit)
}
}
func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testing.T) {
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
definitions := []MetricDefinition{
{Key: "speed_kmh", Label: "速度", Description: "车辆最新行驶速度", Unit: "km/h", Category: "driving", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.vehicle.speed_kmh"}},
{Key: "alarm_active", Label: "协议告警位", Unit: "", Category: "safety", ValueType: "boolean", SourceFields: map[string]string{"GB32960": "gb32960.alarm.general_alarm_flag"}},
}
frames := []RawFrameRow{
{ID: "new", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", SourceEndpoint: "gateway-a", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 42.5, "gb32960.alarm.general_alarm_flag": float64(1), "vendor.custom_temperature_c": 31.2}},
{ID: "old", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:20:00", ServerTime: "2026-07-14 09:20:01", ParseStatus: "ok", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 10.0}},
}
response := buildLatestTelemetryResponse("VIN001", frames, definitions, now)
if response.VIN != "VIN001" || response.ScannedFrames != 2 || len(response.Values) != 3 || len(response.Categories) != 3 {
t.Fatalf("unexpected response: %+v", response)
}
byKey := map[string]LatestTelemetryValue{}
for _, value := range response.Values {
byKey[value.Key] = value
}
if speed := byKey["speed_kmh"]; speed.Value != 42.5 || speed.SourceField != "gb32960.vehicle.speed_kmh" || speed.FrameID != "new" || speed.Quality != "good" || speed.FreshnessSeconds != 1 {
t.Fatalf("unified speed should use newest frame and evidence: %+v", speed)
}
if alarm := byKey["alarm_active"]; alarm.Value != true || alarm.Category != "alarm" {
t.Fatalf("catalog boolean should be normalized: %+v", alarm)
}
if extension := byKey["vendor.custom_temperature_c"]; extension.Category != "extension" || extension.Unit != "℃" || extension.Protocol != "GB32960" {
t.Fatalf("manufacturer extension should retain source semantics: %+v", extension)
}
}
func TestLatestTelemetryQualityDistinguishesStaleAndWarnings(t *testing.T) {
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
stale, reason, freshness, delay := latestTelemetryQuality(RawFrameRow{DeviceTime: "2026-07-14 09:19:59", ServerTime: "2026-07-14 09:20:00", ParseStatus: "ok"}, now)
if stale != "stale" || freshness != 600 || delay == nil || *delay != 1 || !strings.Contains(reason, "超过 5 分钟") {
t.Fatalf("unexpected stale evidence: %s %s %d %v", stale, reason, freshness, delay)
}
warning, reason, _, _ := latestTelemetryQuality(RawFrameRow{ServerTime: "2026-07-14 09:29:59", ParseStatus: "failed", ParseError: "checksum"}, now)
if warning != "warning" || !strings.Contains(reason, "checksum") {
t.Fatalf("parse warning should retain reason: %s %s", warning, reason)
}
}
type latestTelemetryQueryStore struct {
*MockStore
mu sync.Mutex
queries []RawFrameQuery
}
func (s *latestTelemetryQueryStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
s.mu.Lock()
s.queries = append(s.queries, query)
s.mu.Unlock()
return s.MockStore.RawFrames(ctx, query)
}
func TestLatestTelemetryBoundsProtocolReads(t *testing.T) {
store := &latestTelemetryQueryStore{MockStore: NewMockStore()}
response, err := NewService(store).LatestTelemetry(t.Context(), "LNXNEGRR7SR318212")
if err != nil {
t.Fatal(err)
}
if len(response.Values) == 0 || response.ScannedFrames != 1 {
t.Fatalf("expected one mock GB frame: %+v", response)
}
store.mu.Lock()
defer store.mu.Unlock()
if len(store.queries) != len(canonicalVehicleProtocols) {
t.Fatalf("queries = %+v", store.queries)
}
protocols := map[string]bool{}
for _, query := range store.queries {
protocols[query.Protocol] = true
if query.VIN != "LNXNEGRR7SR318212" || query.Limit != 5 || !query.IncludeFields || !query.SkipCount {
t.Fatalf("unbounded latest query: %+v", query)
}
}
for _, protocol := range canonicalVehicleProtocols {
if !protocols[protocol] {
t.Fatalf("missing protocol %s: %+v", protocol, store.queries)
}
}
}
func BenchmarkLatestTelemetryHundredFrames(b *testing.B) {
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
definitions := metricDefinitions()
frames := make([]RawFrameRow, 100)
for frameIndex := range frames {
fields := make(map[string]any, 50)
for fieldIndex := 0; fieldIndex < 50; fieldIndex++ {
fields[fmt.Sprintf("vendor.section_%02d.metric_%02d_voltage_v", fieldIndex/10, fieldIndex)] = float64(frameIndex + fieldIndex)
}
fields["gb32960.vehicle.speed_kmh"] = float64(frameIndex)
frames[frameIndex] = RawFrameRow{ID: fmt.Sprintf("frame-%03d", frameIndex), VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: fields}
}
b.ReportAllocs()
b.ResetTimer()
for range b.N {
response := buildLatestTelemetryResponse("VIN001", frames, definitions, now)
if len(response.Values) != 51 {
b.Fatalf("values = %d", len(response.Values))
}
}
}

View File

@@ -33,6 +33,9 @@ func buildRawFrameSQL(database string, query RawFrameQuery) SQLQuery {
if len(where) > 0 {
countText += ` WHERE ` + strings.Join(where, " AND ")
}
if query.SkipCount {
countText = ""
}
text += ` ORDER BY ts DESC, frame_id ASC LIMIT ` + strconv.Itoa(limit) + ` OFFSET ` + strconv.Itoa(offset)
return SQLQuery{Text: text, Args: args, CountText: countText}
}
@@ -71,7 +74,7 @@ func buildHistoryLocationSQL(database string, query map[string]string) SQLQuery
where = append(where, "ts <= '"+quoteTDengine(normalizeTDengineTime(dateTo))+"'")
}
limit, offset := parseLimitOffset(query["limit"], query["offset"])
text := `SELECT ts, vin, protocol, longitude, latitude, speed_kmh, total_mileage_km, received_at FROM ` + table
text := `SELECT CAST(ts AS BIGINT), vin, protocol, longitude, latitude, speed_kmh, soc_percent, direction_deg, alarm_flag, total_mileage_km, CAST(received_at AS BIGINT) FROM ` + table
if len(where) > 0 {
text += ` WHERE ` + strings.Join(where, " AND ")
}
@@ -83,6 +86,90 @@ func buildHistoryLocationSQL(database string, query map[string]string) SQLQuery
return SQLQuery{Text: text, Args: args, CountText: countText}
}
func buildHistoryLocationSeriesSQL(database string, query HistoryLocationSeriesQuery) SQLQuery {
tableQuery := map[string]string{"vin": query.VIN, "protocol": query.Protocol}
where := []string{"vin = '" + quoteTDengine(strings.TrimSpace(query.VIN)) + "'"}
if protocol := strings.TrimSpace(query.Protocol); protocol != "" {
where = append(where, "protocol = '"+quoteTDengine(strings.ToUpper(protocol))+"'")
}
where = append(where,
"ts >= '"+quoteTDengine(normalizeTDengineSeriesTime(query.DateFrom))+"'",
"ts <= '"+quoteTDengine(normalizeTDengineSeriesTime(query.DateTo))+"'",
)
grain := query.GrainSeconds
if grain <= 0 {
grain = 60
}
text := `SELECT _wstart, protocol, COUNT(*), ` +
`AVG(speed_kmh), MIN(speed_kmh), MAX(speed_kmh), LAST(speed_kmh), ` +
`AVG(total_mileage_km), MIN(total_mileage_km), MAX(total_mileage_km), LAST(total_mileage_km) ` +
`FROM ` + locationTable(database, tableQuery) + ` WHERE ` + strings.Join(where, " AND ") +
` PARTITION BY protocol INTERVAL(` + strconv.Itoa(grain) + `s) ORDER BY _wstart ASC, protocol ASC`
return SQLQuery{Text: text}
}
func buildHistoryExportBatchSQL(database string, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) SQLQuery {
if limit <= 0 || limit > 5000 {
limit = 5000
}
switch query.Category {
case "raw":
return buildRawExportBatchSQL(database, query, cursor, limit)
default:
return buildLocationExportBatchSQL(database, query, cursor, limit)
}
}
func buildLocationExportBatchSQL(database string, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) SQLQuery {
tableQuery := map[string]string{"vin": query.VIN, "protocol": query.Protocol}
where := historyExportTDengineWhere(query)
if cursor.Time != "" {
cursorTime := quoteTDengine(cursor.Time)
cursorProtocol := quoteTDengine(cursor.Protocol)
where = append(where, "(ts > '"+cursorTime+"' OR (ts = '"+cursorTime+"' AND protocol > '"+cursorProtocol+"'))")
}
table := locationTable(database, tableQuery)
return SQLQuery{
Text: `SELECT ts, vin, protocol, longitude, latitude, speed_kmh, total_mileage_km, received_at FROM ` + table + ` WHERE ` + strings.Join(where, " AND ") + ` ORDER BY ts ASC, protocol ASC LIMIT ` + strconv.Itoa(limit),
CountText: `SELECT COUNT(*) FROM ` + table + ` WHERE ` + strings.Join(historyExportTDengineWhere(query), " AND "),
}
}
func buildRawExportBatchSQL(database string, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) SQLQuery {
rawQuery := RawFrameQuery{VIN: query.VIN, Protocol: query.Protocol}
where := historyExportTDengineWhere(query)
if cursor.Time != "" {
timeValue := quoteTDengine(cursor.Time)
protocol := quoteTDengine(cursor.Protocol)
identifier := quoteTDengine(cursor.ID)
where = append(where, "(ts > '"+timeValue+"' OR (ts = '"+timeValue+"' AND (protocol > '"+protocol+"' OR (protocol = '"+protocol+"' AND frame_id > '"+identifier+"'))))")
}
table := rawFrameTable(database, rawQuery)
selectText := `SELECT ts, frame_id, event_time, received_at, raw_size_bytes, parsed_json, parse_status, parse_error, source_endpoint, protocol, vehicle_key, vin, phone FROM ` + table + ` WHERE ` + strings.Join(where, " AND ")
return SQLQuery{Text: selectText + ` ORDER BY ts ASC, protocol ASC, frame_id ASC LIMIT ` + strconv.Itoa(limit), CountText: `SELECT COUNT(*) FROM ` + table + ` WHERE ` + strings.Join(historyExportTDengineWhere(query), " AND ")}
}
func historyExportTDengineWhere(query HistoryExportStoreQuery) []string {
where := []string{"vin = '" + quoteTDengine(strings.TrimSpace(query.VIN)) + "'"}
if protocol := strings.TrimSpace(query.Protocol); protocol != "" {
where = append(where, "protocol = '"+quoteTDengine(strings.ToUpper(protocol))+"'")
}
if query.DateFrom != "" {
where = append(where, "ts >= '"+quoteTDengine(normalizeTDengineSeriesTime(query.DateFrom))+"'")
}
if query.DateTo != "" {
where = append(where, "ts <= '"+quoteTDengine(normalizeTDengineSeriesTime(query.DateTo))+"'")
}
return where
}
func normalizeTDengineSeriesTime(value string) string {
if parsed, ok := parseTrackRequestTime(value); ok {
return parsed.Format(time.RFC3339)
}
return strings.TrimSpace(value)
}
func buildTodayRawFrameCountSQL(database string, now time.Time) SQLQuery {
start := shanghaiDayStartUTC(now)
return SQLQuery{
@@ -133,13 +220,13 @@ func normalizeTDengineTime(value string) string {
return ""
}
shanghai := time.FixedZone("Asia/Shanghai", 8*3600)
for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02"} {
for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02"} {
if parsed, err := time.ParseInLocation(layout, value, shanghai); err == nil {
return parsed.UTC().Format("2006-01-02 15:04:05")
return parsed.Format(time.RFC3339)
}
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed.UTC().Format("2006-01-02 15:04:05")
return parsed.Format(time.RFC3339)
}
return value
}
@@ -148,5 +235,5 @@ func shanghaiDayStartUTC(now time.Time) string {
shanghai := time.FixedZone("Asia/Shanghai", 8*3600)
local := now.In(shanghai)
start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghai)
return start.UTC().Format("2006-01-02 15:04:05")
return start.Format(time.RFC3339)
}

View File

@@ -10,10 +10,12 @@
"@douyinfe/semi-icons": "^2.71.0",
"@douyinfe/semi-theme-default": "^2.71.0",
"@douyinfe/semi-ui": "^2.71.0",
"@tanstack/react-query": "5.101.2",
"@vitejs/plugin-react": "^4.3.4",
"echarts": "^5.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "6.30.1",
"vite": "^6.0.0"
},
"devDependencies": {

View File

@@ -17,6 +17,9 @@ importers:
'@douyinfe/semi-ui':
specifier: ^2.71.0
version: 2.101.0(@floating-ui/dom@1.7.6)(@tiptap/suggestion@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-query':
specifier: 5.101.2
version: 5.101.2(react@18.3.1)
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0(vite@6.4.3(sass@1.101.0))
@@ -29,6 +32,9 @@ importers:
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-router-dom:
specifier: 6.30.1
version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
vite:
specifier: ^6.0.0
version: 6.4.3(sass@1.101.0)
@@ -647,6 +653,10 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@remix-run/router@1.23.0':
resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==}
engines: {node: '>=14.0.0'}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -788,6 +798,14 @@ packages:
cpu: [x64]
os: [win32]
'@tanstack/query-core@5.101.2':
resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==}
'@tanstack/react-query@5.101.2':
resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==}
peerDependencies:
react: ^18 || ^19
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -1842,6 +1860,19 @@ packages:
react: '>= 16.3'
react-dom: '>= 16.3'
react-router-dom@6.30.1:
resolution: {integrity: sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.30.1:
resolution: {integrity: sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-window@1.8.11:
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
engines: {node: '>8.0.0'}
@@ -2751,6 +2782,8 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6
optional: true
'@remix-run/router@1.23.0': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.62.2':
@@ -2828,6 +2861,13 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true
'@tanstack/query-core@5.101.2': {}
'@tanstack/react-query@5.101.2(react@18.3.1)':
dependencies:
'@tanstack/query-core': 5.101.2
react: 18.3.1
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
@@ -4267,6 +4307,18 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
react-draggable: 4.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom@6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-router: 6.30.1(react@18.3.1)
react-router@6.30.1(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.0
react: 18.3.1
react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.29.7

View File

@@ -0,0 +1,8 @@
window.__LINGNIU_APP_CONFIG__ = {
amapWebJsKey: '',
// Production should prefer amapSecurityServiceHost and keep amapSecurityJsCode on the API service.
amapSecurityJsCode: '',
amapSecurityServiceHost: '',
apiBaseUrl: '',
prototypeUseRealData: true
};

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,85 @@ import { api } from './client';
afterEach(() => {
vi.restoreAllMocks();
window.sessionStorage.clear();
});
test('authenticated requests use the session-only bearer token', async () => {
window.sessionStorage.setItem('vehicle-platform.access-token', 'operator-secret-token');
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { name: 'operator-a', role: 'operator', authMode: 'enforce' } }) } as Response);
await api.session();
const [, init] = fetchMock.mock.calls[0];
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer operator-secret-token');
expect(window.localStorage.getItem('vehicle-platform.access-token')).toBeNull();
});
test('durable alert APIs keep versioned actions, rules and notification reads explicit', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-alert', timestamp: 1 }) } as Response);
await api.alertEventsV2({ status: 'unprocessed', limit: 20, offset: 0 });
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'unprocessed', limit: 20, offset: 0 }) });
await api.actOnAlertV2('alert 1', { version: 2, action: 'acknowledge', note: '已确认' });
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/events/alert%201/actions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 2, action: 'acknowledge', note: '已确认' }) });
await api.readAlertNotificationsV2([7, 8]);
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/alerts/notifications/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [7, 8] }) });
});
test('access APIs post one shared filter contract and version threshold updates', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ data: { items: [], total: 0, limit: 50, offset: 0 }, traceId: 'trace-access', timestamp: 1783094400000 })
} as Response);
const query = { protocol: 'JT808', onlineState: 'offline', limit: 50, offset: 0 };
await api.accessVehicles(query);
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/vehicles', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
});
const unresolved = { protocol: 'JT808', limit: 20, offset: 0 };
await api.accessUnresolvedIdentities(unresolved);
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/unresolved-identities', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(unresolved)
});
await api.updateAccessThresholds({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] });
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/thresholds', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] })
});
});
test('vehicle profile API uses encoded VIN and optimistic version updates', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-profile', timestamp: 1 }) } as Response);
const input = { modelName: '氢燃料重卡', vehicleType: '重卡', companyName: '示范物流', operationStatus: 'active' as const, accessProvider: '车厂平台', firstAccessAt: '2026-07-01T08:30', runtimeSeconds: 3600, version: 2 };
await api.updateVehicleProfile('VIN 001', input);
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/VIN%20001/profile', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
});
});
test('latest telemetry uses the encoded vehicle identity path', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { categories: [], values: [] }, traceId: 'trace-telemetry', timestamp: 1 }) } as Response);
await api.latestTelemetry('粤A 001');
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicles/%E7%B2%A4A%20001/telemetry/latest', undefined);
});
test('vehicle profile sync posts an explicit dry-run and conflict policy contract', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-profile-sync', timestamp: 1 }) } as Response);
const input = {
sourceSystem: 'oem-tsp', sourceVersion: 'snapshot-1', conflictPolicy: 'preserve' as const, dryRun: true,
items: [{ vin: 'VIN001', modelName: '车型一', vehicleType: '重卡', companyName: '示范物流', operationStatus: 'active' as const, accessProvider: '车厂平台', firstAccessAt: '', runtimeSeconds: null }]
};
await api.syncVehicleProfiles(input);
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicle-profiles/sync', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
});
});
test('trackPlayback preserves the bounded V2 query contract', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ data: { vin: 'VIN001', points: [], events: [], sources: [], summary: { pointCount: 0 } }, traceId: 'trace-test', timestamp: 1783094400000 })
} as Response);
await api.trackPlayback(new URLSearchParams({ keyword: '粤AG18312', maxPoints: '1200' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v2/tracks?keyword=%E7%B2%A4AG18312&maxPoints=1200', undefined);
});
test('rawFramesQuery posts structured JSON instead of URL query strings', async () => {

View File

@@ -1,10 +1,33 @@
import type {
ApiEnvelope,
AccessQuery,
AccessSummary,
AccessThresholdConfig,
AccessThresholdUpdate,
AccessUnresolvedIdentity,
AccessUnresolvedIdentityQuery,
AccessVehicleRow,
AlertAction,
AlertEvent,
AlertNotification,
AlertQuery,
AlertRule,
AlertRuleInput,
AlertSummary,
DailyMileageRow,
DashboardSummary,
HistoryLocationRow,
HistoryDataResponse,
HistorySeriesResponse,
HistoryExportJob,
HistoryExportRequest,
HistoryMetricCatalog,
MetricCatalog,
LatestTelemetryResponse,
MileageSummary,
MapReverseGeocode,
MonitorMapResponse,
MonitorSummary,
OnlineStatisticsSummary,
OnlineVehicleStatusRow,
OpsHealth,
@@ -15,15 +38,22 @@ import type {
RawFrameRow,
RealtimeLocationRow,
SourceReadinessPlan,
SessionInfo,
TrackPlaybackResponse,
VehicleRealtimeRow,
VehicleCoverageRow,
VehicleCoverageSummary,
VehicleDetail,
VehicleProfile,
VehicleProfileInput,
VehicleProfileSyncRequest,
VehicleProfileSyncResult,
VehicleIdentityResolution,
VehicleServiceOverview,
VehicleServiceSummary,
VehicleRow
} from './types';
import { getAccessToken } from '../v2/auth/session';
export type RawFrameQuery = {
keyword?: string;
@@ -54,7 +84,9 @@ type ApiErrorEnvelope = {
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, init);
const token = getAccessToken();
const requestInit = token ? { ...init, headers: withAuthorization(init?.headers, token) } : init;
const response = await fetch(path, requestInit);
if (!response.ok) {
throw new Error(await responseErrorMessage(response));
}
@@ -62,6 +94,12 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return envelope.data;
}
function withAuthorization(headers: HeadersInit | undefined, token: string) {
const authorized = new Headers(headers);
authorized.set('Authorization', `Bearer ${token}`);
return authorized;
}
async function responseErrorMessage(response: Response) {
try {
const envelope = (await response.json()) as ApiErrorEnvelope;
@@ -85,12 +123,66 @@ function withTraceID(message: string, traceID?: string) {
}
export const api = {
session: () => request<SessionInfo>('/api/v2/session'),
monitorSummary: (params = new URLSearchParams()) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`),
monitorMap: (params = new URLSearchParams()) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`),
trackPlayback: (params = new URLSearchParams()) => request<TrackPlaybackResponse>(`/api/v2/tracks?${params.toString()}`),
metricCatalog: () => request<MetricCatalog>('/api/v2/metrics'),
historyMetricCatalog: () => request<HistoryMetricCatalog>('/api/v2/history/metrics'),
historyData: (params = new URLSearchParams()) => request<HistoryDataResponse>(`/api/v2/history/query?${params.toString()}`),
historySeries: (params = new URLSearchParams()) => request<HistorySeriesResponse>(`/api/v2/history/series?${params.toString()}`),
historyExports: () => request<HistoryExportJob[]>('/api/v2/exports'),
createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}),
accessSummary: (query: AccessQuery) => request<AccessSummary>('/api/v2/access/summary', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}),
accessVehicles: (query: AccessQuery) => request<Page<AccessVehicleRow>>('/api/v2/access/vehicles', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}),
accessUnresolvedIdentities: (query: AccessUnresolvedIdentityQuery) => request<Page<AccessUnresolvedIdentity>>('/api/v2/access/unresolved-identities', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}),
accessThresholds: () => request<AccessThresholdConfig>('/api/v2/access/thresholds'),
updateAccessThresholds: (update: AccessThresholdUpdate) => request<AccessThresholdConfig>('/api/v2/access/thresholds', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update)
}),
alertSummaryV2: (query: AlertQuery) => request<AlertSummary>('/api/v2/alerts/summary', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}),
alertEventsV2: (query: AlertQuery) => request<Page<AlertEvent>>('/api/v2/alerts/events', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}),
alertEventV2: (id: string) => request<AlertEvent>(`/api/v2/alerts/events/${encodeURIComponent(id)}`),
actOnAlertV2: (id: string, action: Pick<AlertAction, 'action' | 'note'> & { version: number; actor?: string }) => request<AlertEvent>(`/api/v2/alerts/events/${encodeURIComponent(id)}/actions`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(action)
}),
alertRulesV2: () => request<AlertRule[]>('/api/v2/alerts/rules'),
saveAlertRuleV2: (input: AlertRuleInput) => request<AlertRule>(input.version > 0 ? `/api/v2/alerts/rules/${encodeURIComponent(input.id)}` : '/api/v2/alerts/rules', {
method: input.version > 0 ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
}),
setAlertRuleEnabledV2: (id: string, update: { version: number; enabled: boolean; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/enabled`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update)
}),
alertNotificationsV2: (params = new URLSearchParams()) => request<Page<AlertNotification>>(`/api/v2/alerts/notifications?${params.toString()}`),
readAlertNotificationsV2: (ids: number[]) => request<{ updated: number }>('/api/v2/alerts/notifications/read', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids })
}),
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
vehicleCoverage: (params = new URLSearchParams()) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`),
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
vehicleProfile: (vin: string) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`),
latestTelemetry: (vin: string) => request<LatestTelemetryResponse>(`/api/v2/vehicles/${encodeURIComponent(vin)}/telemetry/latest`),
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
}),
syncVehicleProfiles: (input: VehicleProfileSyncRequest) => request<VehicleProfileSyncResult>('/api/v2/vehicle-profiles/sync', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
}),
vehicleServiceSummary: () => request<VehicleServiceSummary>('/api/vehicle-service/summary'),
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
vehicleServiceOverviews: (query: VehicleOverviewBatchQuery) => request<Page<VehicleServiceOverview>>('/api/vehicle-service/overviews', {

View File

@@ -4,6 +4,247 @@ export interface ApiEnvelope<T> {
timestamp: number;
}
export interface SessionInfo {
name: string;
role: 'viewer' | 'operator' | 'admin';
authMode: 'disabled' | 'enforce';
}
export interface MonitorSummary {
totalVehicles: number;
onlineVehicles: number;
offlineVehicles: number;
drivingVehicles: number;
idleVehicles: number;
alertVehicles: number;
unknownVehicles: number;
activeToday: number;
frameToday: number;
alertDataAvailable: boolean;
truncated: boolean;
asOf: string;
}
export interface MonitorMapPoint {
vin: string;
plate: string;
protocol: string;
protocols: string[];
longitude: number;
latitude: number;
speedKmh: number;
socPercent: number;
totalMileageKm: number;
lastSeen: string;
status: 'driving' | 'idle' | 'offline' | 'unknown';
}
export interface MonitorMapCluster {
id: string;
longitude: number;
latitude: number;
count: number;
online: number;
offline: number;
driving: number;
idle: number;
unknown: number;
}
export interface MonitorMapResponse {
mode: 'clusters' | 'mixed' | 'points';
zoom: number;
total: number;
truncated: boolean;
points: MonitorMapPoint[];
clusters: MonitorMapCluster[];
asOf: string;
}
export interface TrackPlaybackResponse {
vin: string;
plate: string;
points: HistoryLocationRow[];
events: TrackPlaybackEvent[];
sources: TrackPlaybackSource[];
segments: TrackSegment[];
stops: TrackStop[];
summary: TrackPlaybackSummary;
coverage: TrackCoverage;
quality: TrackQuality;
total: number;
truncated: boolean;
sampled: boolean;
asOf: string;
}
export interface TrackPlaybackSummary {
startTime: string;
endTime: string;
distanceKm: number;
durationSeconds: number;
averageSpeedKmh: number;
maximumSpeedKmh: number;
pointCount: number;
movingSeconds: number;
stoppedSeconds: number;
stopCount: number;
segmentCount: number;
}
export interface TrackPlaybackEvent {
index: number;
sampledIndex: number;
type: 'start' | 'end' | 'stop' | 'acceleration' | 'braking' | 'source_switch' | string;
title: string;
time: string;
speedKmh: number;
socPercent: number;
socAvailable: boolean;
directionDeg?: number;
alarmFlag?: number;
longitude: number;
latitude: number;
}
export interface TrackPlaybackSource {
protocol: string;
pointCount: number;
startTime: string;
endTime: string;
}
export interface TrackCoverage {
requestedStart: string;
requestedEnd: string;
actualStart: string;
actualEnd: string;
totalPoints: number;
fetchedPoints: number;
processedPoints: number;
returnedPoints: number;
complete: boolean;
limitReasons: string[];
evidence: string;
}
export interface TrackSegment {
index: number;
type: 'moving' | 'stopped' | 'gap' | 'point' | string;
title: string;
startTime: string;
endTime: string;
durationSeconds: number;
distanceKm: number;
pointCount: number;
startIndex: number;
endIndex: number;
sampledStartIndex: number;
sampledEndIndex: number;
}
export interface TrackStop {
index: number;
startTime: string;
endTime: string;
durationSeconds: number;
pointCount: number;
longitude: number;
latitude: number;
sampledIndex: number;
evidence: string;
}
export interface TrackQuality {
status: 'good' | 'warning' | string;
selectedProtocol: string;
rawPoints: number;
validPoints: number;
alternateSourcePoints: number;
invalidCoordinatePoints: number;
duplicatePoints: number;
driftPoints: number;
sourceSwitches: number;
largeGapCount: number;
maximumGapSeconds: number;
evidence: string;
}
export interface HistoryMetricCatalog {
categories: HistoryDataCategory[];
metrics: HistoryMetricDefinition[];
}
export interface MetricCatalog { metrics: MetricDefinition[]; asOf: string; }
export interface MetricDefinition {
key: string; label: string; description: string; unit: string; category: string; valueType: 'numeric' | 'boolean';
protocols: string[]; sourceFields: Record<string, string>; searchable: boolean; chartable: boolean; alertable: boolean;
}
export interface HistoryDataCategory { key: 'location' | 'raw' | 'mileage' | string; label: string; }
export interface HistoryMetricDefinition { key: string; label: string; unit: string; category: string; valueType: string; defaultVisible: boolean; }
export interface HistoryDataRow { id: string; vin: string; plate: string; protocol: string; deviceTime: string; serverTime: string; quality: string; evidenceId?: string; values: Record<string, unknown>; }
export interface HistoryDataSummary { resultRows: number; vehicleCount: number; sources: string[]; queryDurationMs: number; }
export interface HistoryDataResponse { category: string; columns: HistoryMetricDefinition[]; rows: HistoryDataRow[]; summary: HistoryDataSummary; total: number; limit: number; offset: number; asOf: string; }
export interface HistorySeriesPoint { time: string; value: number | null; min: number | null; max: number | null; count: number; }
export interface HistorySeries { vin: string; plate: string; protocol: string; metric: string; label: string; unit: string; aggregation: 'avg' | 'last' | string; points: HistorySeriesPoint[]; }
export interface HistorySeriesSummary { rawPointCount: number; bucketCount: number; returnedPointCount: number; seriesCount: number; grainSeconds: number; targetPoints: number; expectedBucketCount: number; missingBucketCount: number; queryDurationMs: number; complete: boolean; evidence: string; }
export interface HistorySeriesResponse { metrics: HistoryMetricDefinition[]; series: HistorySeries[]; summary: HistorySeriesSummary; dateFrom: string; dateTo: string; asOf: string; }
export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; }
export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; keywords: string[]; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; }
export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; limit?: number; offset?: number; }
export interface AccessUnresolvedIdentityQuery { keyword?: string; protocol?: string; limit?: number; offset?: number; }
export interface AccessUnresolvedIdentity {
id: string; protocol: string; identifierMasked: string; plate: string; manufacturer: string; sourceEndpoint: string;
firstRegisteredAt: string; latestRegisteredAt: string; latestAuthenticatedAt: string; latestSeenAt: string;
freshnessSec: number; issueCode: string; recommendedAction: string;
}
export interface AccessVehicleRow {
vin: string; plate: string; oem: string; model: string; company: string; protocol: string; provider: string; source: string;
firstSeenAt: string; latestEventAt: string; latestReceivedAt: string; reportIntervalSec: number | null;
dataDelaySec: number | null; freshnessSec: number | null; onlineState: 'online' | 'offline' | 'never_reported' | 'unknown';
thresholdSec: number; latestMessageType: string; latestEventId: string; latestError: string; delayAbnormal: boolean;
firstSeenEvidence: string; firstSeenSource: string; reportIntervalEvidence: string; reportSampleCount: number;
}
export interface AccessDistribution { name: string; total: number; online: number; onlineRate: number; }
export interface AccessSummary {
totalVehicles: number; onlineVehicles: number; offlineVehicles: number; longOfflineVehicles: number;
neverReported: number; unknownVehicles: number; delayAbnormal: number; reportedToday: number; onlineRate: number;
protocols: AccessDistribution[]; oems: AccessDistribution[]; asOf: string; thresholdVersion: number;
}
export interface AccessProtocolThreshold { protocol: string; thresholdSec: number; }
export interface AccessThresholdAudit { version: number; actor: string; changedAt: string; summary: string; }
export interface AccessThresholdConfig {
version: number; defaultThresholdSec: number; delayThresholdSec: number; longOfflineSec: number;
protocols: AccessProtocolThreshold[]; updatedBy: string; updatedAt: string; audit: AccessThresholdAudit[];
}
export interface AccessThresholdUpdate {
version: number; defaultThresholdSec: number; delayThresholdSec: number; longOfflineSec: number;
protocols: AccessProtocolThreshold[]; actor?: string;
}
export type AlertSeverity = 'critical' | 'major' | 'minor';
export type AlertStatus = 'unprocessed' | 'processing' | 'recovered' | 'closed' | 'ignored';
export interface AlertQuery { keyword?: string; severity?: string; status?: string; ruleId?: string; protocol?: string; dateFrom?: string; dateTo?: string; limit?: number; offset?: number; }
export interface AlertSummary { active: number; unprocessed: number; processing: number; recovered: number; closed: number; ignored: number; unreadNotifications: number; asOf: string; }
export interface AlertAction { id: number; action: string; fromStatus: string; toStatus: string; actor: string; note: string; createdAt: string; }
export interface AlertEvent {
id: string; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; status: AlertStatus;
vin: string; plate: string; protocol: string; metric: string; operator: string; triggerValue: number; threshold: number; thresholdHigh: number;
unit: string; durationSec: number; location: string; longitude?: number; latitude?: number; sourceEventId: string;
eventAt: string; receivedAt: string; triggeredAt: string; recoveredAt: string; handler: string; version: number; actions?: AlertAction[];
}
export interface AlertRule {
id: string; name: string; description: string; severity: AlertSeverity; valueType: 'numeric' | 'boolean'; metric: string;
operator: string; threshold: number; thresholdHigh: number; booleanThreshold?: boolean; durationSec: number; recoveryOperator: string;
recoveryThreshold: number; repeatIntervalSec: number; scopeProtocols: string[]; scopeVins: string[]; scopeOems: string[]; scopeModels: string[]; scopeCompanies: string[];
notificationChannels: string[]; enabled: boolean; version: number; createdBy: string; updatedBy: string; createdAt: string; updatedAt: string;
}
export interface AlertRuleInput extends Omit<AlertRule, 'createdBy' | 'updatedBy' | 'createdAt' | 'updatedAt'> { actor?: string; }
export interface AlertNotification { id: number; eventId: string; title: string; content: string; severity: AlertSeverity; channel: string; read: boolean; createdAt: string; readAt: string; }
export interface ProtocolStat {
protocol: string;
online: number;
@@ -94,6 +335,7 @@ export interface VehicleDetail {
lookupResolved: boolean;
resolution?: VehicleIdentityResolution;
identity?: VehicleRow;
profile?: VehicleProfile;
realtimeSummary?: VehicleRealtimeRow;
serviceStatus?: VehicleServiceStatus;
serviceOverview?: VehicleServiceOverview;
@@ -107,6 +349,76 @@ export interface VehicleDetail {
quality: Page<QualityIssueRow>;
}
export interface VehicleProfile {
vin: string;
modelName: string;
vehicleType: string;
companyName: string;
operationStatus: 'unknown' | 'active' | 'inactive' | 'maintenance' | 'retired';
accessProvider: string;
firstAccessAt: string;
runtimeSeconds: number | null;
sourceSystem: string;
sourceVersion: string;
syncedAt: string;
version: number;
updatedBy: string;
updatedAt: string;
completeness: number;
missingFields: string[];
}
export interface VehicleProfileInput {
modelName: string;
vehicleType: string;
companyName: string;
operationStatus: VehicleProfile['operationStatus'];
accessProvider: string;
firstAccessAt: string;
runtimeSeconds: number | null;
version: number;
}
export interface VehicleProfileSyncItem {
vin: string;
modelName: string;
vehicleType: string;
companyName: string;
operationStatus: VehicleProfile['operationStatus'];
accessProvider: string;
firstAccessAt: string;
runtimeSeconds: number | null;
}
export interface VehicleProfileSyncRequest {
sourceSystem: string;
sourceVersion: string;
conflictPolicy: 'preserve' | 'overwrite';
dryRun: boolean;
items: VehicleProfileSyncItem[];
}
export interface VehicleProfileSyncItemResult {
vin: string;
status: 'created' | 'updated' | 'unchanged' | 'conflict_source' | 'conflict_source_version' | 'missing_vehicle';
previousSource?: string;
previousVersion?: string;
profileVersion?: number;
}
export interface VehicleProfileSyncResult {
sourceSystem: string;
sourceVersion: string;
dryRun: boolean;
received: number;
created: number;
updated: number;
unchanged: number;
conflicted: number;
missing: number;
items: VehicleProfileSyncItemResult[];
}
export interface VehicleSourceConsistency {
sourceCount: number;
onlineSourceCount: number;
@@ -220,6 +532,9 @@ export interface VehicleRealtimeRow {
}
export interface HistoryLocationRow extends RealtimeLocationRow {
socAvailable: boolean;
directionDeg?: number;
alarmFlag?: number;
deviceTime: string;
serverTime: string;
}
@@ -233,9 +548,24 @@ export interface RawFrameRow {
deviceTime: string;
serverTime: string;
rawSizeBytes: number;
parseStatus?: string;
parseError?: string;
sourceEndpoint?: string;
parsedFields?: Record<string, unknown>;
}
export interface LatestTelemetryCategory { key: string; label: string; count: number; }
export interface LatestTelemetryValue {
key: string; sourceField: string; label: string; description?: string; unit: string; category: string;
valueType: string; value: unknown; protocol: string; sourceEndpoint?: string; frameId: string;
deviceTime: string; serverTime: string; quality: 'good' | 'stale' | 'warning'; qualityReason: string;
freshnessSeconds: number; dataDelaySeconds?: number;
}
export interface LatestTelemetryResponse {
vin: string; categories: LatestTelemetryCategory[]; values: LatestTelemetryValue[]; asOf: string;
staleAfterSeconds: number; scannedFrames: number; evidence: string;
}
export interface DailyMileageRow {
vin: string;
plate: string;
@@ -401,6 +731,7 @@ export interface CapacityMetrics {
}
export interface RuntimeInfo {
dataMode?: 'mock' | 'production' | string;
requestTimeoutMs: number;
amapWebJsConfigured?: boolean;
amapApiConfigured?: boolean;

View File

@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { VehicleMap } from './VehicleMap';
class TestMap {
add = vi.fn();
addControl = vi.fn();
setFitView = vi.fn();
destroy = vi.fn();
constructor(
public container: HTMLDivElement,
public options: Record<string, unknown>
) {}
}
class TestOverlay {
setMap = vi.fn();
on = vi.fn();
}
class TestScale {}
afterEach(() => {
delete window.__LINGNIU_APP_CONFIG__;
delete window.AMapLoader;
vi.restoreAllMocks();
});
test('loads AMap base map even when realtime vehicle points are empty', async () => {
const mapLoad = vi.fn().mockResolvedValue({
Map: TestMap,
Marker: TestOverlay,
Polyline: TestOverlay,
Scale: TestScale
});
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: mapLoad };
render(<VehicleMap points={[]} fallbackLabel="等待真实车辆坐标" />);
expect(await screen.findByText('高德地图已加载')).toBeInTheDocument();
expect(screen.queryByText('等待真实车辆坐标')).not.toBeInTheDocument();
expect(mapLoad).toHaveBeenCalledWith({
key: 'amap-web-key',
version: '2.0',
plugins: ['AMap.Scale']
});
});

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Tag } from '@douyinfe/semi-ui';
import { getAMapConfig, isAMapConfigured } from '../config/appConfig';
import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapMap, type AMapOverlay } from '../integrations/amap';
export type VehicleMapPoint = {
id: string;
@@ -11,72 +12,8 @@ export type VehicleMapPoint = {
title?: string;
};
type AMapLike = {
Map: new (container: HTMLDivElement, options: Record<string, unknown>) => AMapMap;
Marker: new (options: Record<string, unknown>) => AMapOverlay;
Polyline: new (options: Record<string, unknown>) => AMapOverlay;
Scale: new () => unknown;
};
type AMapMap = {
add: (overlay: AMapOverlay | AMapOverlay[]) => void;
addControl: (control: unknown) => void;
setFitView: (overlays?: AMapOverlay[] | null, immediate?: boolean, padding?: number[]) => void;
destroy: () => void;
};
type AMapOverlay = {
setMap?: (map: AMapMap | null) => void;
on?: (eventName: string, handler: () => void) => void;
};
let amapLoaderPromise: Promise<AMapLike> | null = null;
function validPoint(point: VehicleMapPoint) {
return Number.isFinite(point.longitude) && Number.isFinite(point.latitude) && point.longitude !== 0 && point.latitude !== 0;
}
function loadScript(src: string) {
return new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error('高德地图 Loader 加载失败')), { once: true });
if (window.AMapLoader) resolve();
return;
}
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('高德地图 Loader 加载失败'));
document.head.appendChild(script);
});
}
function loadAMap() {
const config = getAMapConfig();
if (!isAMapConfigured(config)) {
return Promise.reject(new Error('高德地图未配置'));
}
if (!amapLoaderPromise) {
if (config.securityServiceHost) {
window._AMapSecurityConfig = { serviceHost: config.securityServiceHost };
} else if (config.securityJsCode) {
window._AMapSecurityConfig = { securityJsCode: config.securityJsCode };
}
amapLoaderPromise = loadScript('https://webapi.amap.com/loader.js').then(() => {
if (!window.AMapLoader) {
throw new Error('高德地图 Loader 不可用');
}
return window.AMapLoader.load({
key: config.webJsKey,
version: '2.0',
plugins: ['AMap.Scale']
}) as Promise<AMapLike>;
});
}
return amapLoaderPromise;
return isValidAMapCoordinate(point.longitude, point.latitude);
}
function pointToPixelStyle(point: VehicleMapPoint, index: number) {
@@ -88,6 +25,38 @@ function pointToPixelStyle(point: VehicleMapPoint, index: number) {
return { left: `${left}%`, top: `${top}%` };
}
function escapeHTML(value: string) {
return value.replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char] ?? char));
}
function markerContent(point: VehicleMapPoint, index: number, selected: boolean, mode: 'realtime' | 'track') {
const statusClass = point.online === false ? 'vp-amap-marker-offline' : 'vp-amap-marker-online';
const selectedClass = selected ? 'vp-amap-marker-selected' : '';
const markerText = mode === 'track' ? String(index + 1) : '';
const label = selected ? `<span class="vp-amap-marker-label">${escapeHTML(point.label)}</span>` : '';
return `<button type="button" class="vp-amap-marker-shell ${selected ? 'vp-amap-marker-shell-selected' : ''}" data-map-vehicle-id="${escapeHTML(point.id)}" aria-label="选择地图车辆 ${escapeHTML(point.label)}"><span class="vp-amap-marker ${statusClass} ${selectedClass}">${markerText}</span>${label}</button>`;
}
function initialMapView(points: VehicleMapPoint[], mode: 'realtime' | 'track') {
const firstPoint = points[0];
if (firstPoint) {
return {
center: wgs84ToGcj02(firstPoint.longitude, firstPoint.latitude),
zoom: mode === 'track' ? 13 : 11
};
}
return {
center: wgs84ToGcj02(104.1954, 35.8617),
zoom: 4
};
}
export function VehicleMap({
points,
mode = 'realtime',
@@ -116,7 +85,7 @@ export function VehicleMap({
useEffect(() => {
let cancelled = false;
if (!isAMapConfigured(config) || validPoints.length === 0 || !containerRef.current) {
if (!isAMapConfigured(config) || !containerRef.current) {
setStatus('fallback');
return;
}
@@ -124,10 +93,11 @@ export function VehicleMap({
loadAMap()
.then((AMap) => {
if (cancelled || !containerRef.current) return;
const mapView = initialMapView(validPoints, mode);
if (!mapRef.current) {
mapRef.current = new AMap.Map(containerRef.current, {
zoom: 11,
center: [validPoints[0].longitude, validPoints[0].latitude],
zoom: mapView.zoom,
center: mapView.center,
viewMode: '2D',
mapStyle: 'amap://styles/normal'
});
@@ -135,11 +105,12 @@ export function VehicleMap({
}
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
const markers = validPoints.slice(0, 500).map((point, index) => {
const selected = point.id === selectedId;
const marker = new AMap.Marker({
position: [point.longitude, point.latitude],
position: wgs84ToGcj02(point.longitude, point.latitude),
title: point.title || point.label,
zIndex: point.id === selectedId ? 300 : mode === 'track' ? 100 + index : 100,
content: `<div class="vp-amap-marker ${point.online === false ? 'vp-amap-marker-offline' : 'vp-amap-marker-online'} ${point.id === selectedId ? 'vp-amap-marker-selected' : ''}">${mode === 'track' ? index + 1 : ''}</div>`
zIndex: selected ? 300 : mode === 'track' ? 100 + index : 100,
content: markerContent(point, index, selected, mode)
});
marker.on?.('click', () => onPointSelect?.(point));
return marker;
@@ -147,7 +118,7 @@ export function VehicleMap({
const nextOverlays: AMapOverlay[] = [...markers];
if (mode === 'track' && validPoints.length > 1) {
nextOverlays.push(new AMap.Polyline({
path: validPoints.map((point) => [point.longitude, point.latitude]),
path: validPoints.map((point) => wgs84ToGcj02(point.longitude, point.latitude)),
strokeColor: '#1664ff',
strokeOpacity: 0.85,
strokeWeight: 5,
@@ -155,8 +126,10 @@ export function VehicleMap({
}));
}
overlaysRef.current = nextOverlays;
mapRef.current.add(nextOverlays);
mapRef.current.setFitView(nextOverlays, false, [56, 56, 56, 56]);
if (nextOverlays.length > 0) {
mapRef.current.add(nextOverlays);
mapRef.current.setFitView(nextOverlays, false, [56, 56, 56, 56]);
}
setStatus('ready');
})
.catch(() => {
@@ -196,7 +169,11 @@ export function VehicleMap({
{status === 'loading' ? '地图加载中' : fallbackLabel || '高德地图未配置,显示坐标预览'}
</Tag>
</div>
) : null}
) : (
<Tag className="vp-map-provider-status" color="green">
</Tag>
)}
{children}
</div>
);

View File

@@ -4,15 +4,55 @@ export type AMapRuntimeConfig = {
securityServiceHost: string;
};
export type AppRuntimeConfig = AMapRuntimeConfig & {
apiBaseUrl: string;
prototypeUseRealData: boolean;
};
function runtimeConfig() {
return window.__LINGNIU_APP_CONFIG__ ?? {};
}
function boolFromConfig(value: unknown, defaultValue: boolean) {
if (value == null || value === '') {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
return !['0', 'false', 'off', 'no'].includes(String(value).trim().toLowerCase());
}
export function getAMapConfig(): AMapRuntimeConfig {
const runtime = window.__LINGNIU_APP_CONFIG__ ?? {};
const runtime = runtimeConfig();
return {
webJsKey: String(runtime.amapWebJsKey || import.meta.env.VITE_AMAP_WEB_JS_KEY || '').trim(),
securityJsCode: String(runtime.amapSecurityJsCode || '').trim(),
securityServiceHost: String(runtime.amapSecurityServiceHost || '').trim()
securityJsCode: String(runtime.amapSecurityJsCode || import.meta.env.VITE_AMAP_SECURITY_JS_CODE || '').trim(),
securityServiceHost: String(runtime.amapSecurityServiceHost || import.meta.env.VITE_AMAP_SECURITY_SERVICE_HOST || '').trim()
};
}
export function isAMapConfigured(config = getAMapConfig()) {
return Boolean(config.webJsKey);
}
export function getAppConfig(): AppRuntimeConfig {
const runtime = runtimeConfig();
const amap = getAMapConfig();
return {
...amap,
apiBaseUrl: String(runtime.apiBaseUrl || import.meta.env.VITE_API_BASE_URL || '').trim().replace(/\/$/, ''),
prototypeUseRealData: boolFromConfig(
runtime.prototypeUseRealData ?? import.meta.env.VITE_PROTOTYPE_REAL_DATA,
true
)
};
}
export function getApiBaseUrl(config = getAppConfig()) {
return config.apiBaseUrl;
}
export function shouldUsePrototypeRealData(config = getAppConfig()) {
return config.prototypeUseRealData;
}

View File

@@ -0,0 +1,32 @@
import { expect, test } from 'vitest';
import { buildAMapMarkerURL, gcj02ToWgs84, isValidAMapCoordinate, wgs84ToGcj02 } from './amap';
test('accepts coordinates inside the China vehicle service range', () => {
expect(isValidAMapCoordinate(116.397128, 39.916527)).toBe(true);
expect(isValidAMapCoordinate(87.6379, 43.98146)).toBe(true);
});
test('rejects placeholder and out-of-range coordinates', () => {
expect(isValidAMapCoordinate(0, 0)).toBe(false);
expect(isValidAMapCoordinate(-0.999999, -0.999999)).toBe(false);
expect(isValidAMapCoordinate(139.6917, 35.6895)).toBe(false);
expect(isValidAMapCoordinate(121.4737, 10)).toBe(false);
});
test('converts WGS-84 coordinates to GCJ-02 and supports a precise viewport round trip', () => {
const wgs: [number, number] = [116.397128, 39.916527];
const gcj = wgs84ToGcj02(...wgs);
expect(gcj[0]).toBeCloseTo(116.40337, 5);
expect(gcj[1]).toBeCloseTo(39.91793, 5);
const roundTrip = gcj02ToWgs84(...gcj);
expect(roundTrip[0]).toBeCloseTo(wgs[0], 6);
expect(roundTrip[1]).toBeCloseTo(wgs[1], 6);
});
test('keeps coordinates outside China unchanged and converts AMap marker links at the rendering boundary', () => {
expect(wgs84ToGcj02(2.3522, 48.8566)).toEqual([2.3522, 48.8566]);
const url = new URL(buildAMapMarkerURL({ longitude: 116.397128, latitude: 39.916527, name: '测试车辆' }));
const position = url.searchParams.get('position')?.split(',').map(Number) ?? [];
expect(position[0]).toBeCloseTo(116.40337, 5);
expect(position[1]).toBeCloseTo(39.91793, 5);
});

View File

@@ -0,0 +1,267 @@
import { getAMapConfig, isAMapConfigured } from '../config/appConfig';
export type AMapPlugin = 'AMap.Scale' | 'AMap.Geocoder' | 'AMap.ToolBar';
export type AMapMap = {
add: (overlay: AMapOverlay | AMapOverlay[]) => void;
addControl: (control: unknown) => void;
setFitView: (overlays?: AMapOverlay[] | null, immediate?: boolean, padding?: number[]) => void;
destroy: () => void;
on?: (eventName: string, handler: (event: unknown) => void) => void;
getZoom?: () => number;
getBounds?: () => {
getSouthWest?: () => { getLng?: () => number; getLat?: () => number };
getNorthEast?: () => { getLng?: () => number; getLat?: () => number };
};
setCenter?: (center: [number, number]) => void;
setZoomAndCenter?: (zoom: number, center: [number, number]) => void;
panTo?: (center: [number, number], duration?: number) => void;
resize?: () => void;
};
export type AMapOverlay = {
setMap?: (map: AMapMap | null) => void;
on?: (eventName: string, handler: () => void) => void;
setPosition?: (position: [number, number]) => void;
};
export type AMapMassMarks = {
setMap: (map: AMapMap | null) => void;
setData: (data: AMapMassPoint[]) => void;
setStyle?: (style: Array<{ url: string; anchor: unknown; size: unknown }>) => void;
on: (eventName: string, handler: (event: { data: AMapMassPoint }) => void) => void;
};
export type AMapLabelsLayer = {
setMap: (map: AMapMap | null) => void;
add: (markers: AMapOverlay | AMapOverlay[]) => void;
clear: () => void;
};
export type AMapMassPoint = {
lnglat: [number, number];
style: number;
id: string;
label: string;
};
type AMapAddressComponent = {
province?: string;
city?: string | string[];
district?: string;
township?: string;
adcode?: string;
};
type AMapGeocoderResult = {
info?: string;
regeocode?: {
formattedAddress?: string;
addressComponent?: AMapAddressComponent;
};
};
export type AMapAddress = {
provider: 'AMap Web JS';
formattedAddress: string;
province?: string;
city?: string;
district?: string;
township?: string;
adcode?: string;
};
export type AMapLike = {
Map: new (container: HTMLDivElement, options: Record<string, unknown>) => AMapMap;
Marker: new (options: Record<string, unknown>) => AMapOverlay;
Polyline: new (options: Record<string, unknown>) => AMapOverlay;
Scale: new () => unknown;
ToolBar?: new (options?: Record<string, unknown>) => unknown;
Size: new (width: number, height: number) => unknown;
Pixel: new (x: number, y: number) => unknown;
MassMarks: new (data: AMapMassPoint[], options: Record<string, unknown>) => AMapMassMarks;
LabelsLayer?: new (options?: Record<string, unknown>) => AMapLabelsLayer;
LabelMarker?: new (options: Record<string, unknown>) => AMapOverlay;
Geocoder?: new (options?: Record<string, unknown>) => {
getAddress: (
lnglat: [number, number],
callback: (status: string, result: AMapGeocoderResult | string) => void
) => void;
};
};
const amapLoaderPromises = new Map<string, Promise<AMapLike>>();
function loadScript(src: string) {
return new Promise<void>((resolve, reject) => {
if (window.AMapLoader) {
resolve();
return;
}
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error('高德地图 Loader 加载失败')), { once: true });
if (window.AMapLoader) resolve();
return;
}
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('高德地图 Loader 加载失败'));
document.head.appendChild(script);
});
}
function pluginKey(plugins: AMapPlugin[]) {
return plugins.slice().sort().join('|') || 'base';
}
function normalizeCity(value: AMapAddressComponent['city']) {
if (Array.isArray(value)) {
return value.filter(Boolean).join('/');
}
return String(value ?? '').trim() || undefined;
}
const GCJ_A = 6378245;
const GCJ_EE = 0.006693421622965943;
function outsideGcjChina(longitude: number, latitude: number) {
return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271;
}
function transformLatitude(longitude: number, latitude: number) {
let 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;
}
function transformLongitude(longitude: number, latitude: number) {
let 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;
}
export function wgs84ToGcj02(longitude: number, latitude: number): [number, number] {
if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || outsideGcjChina(longitude, latitude)) {
return [longitude, latitude];
}
const latitudeOffset = transformLatitude(longitude - 105, latitude - 35);
const longitudeOffset = transformLongitude(longitude - 105, latitude - 35);
const radianLatitude = latitude / 180 * Math.PI;
const magic = 1 - GCJ_EE * Math.sin(radianLatitude) ** 2;
const sqrtMagic = Math.sqrt(magic);
const convertedLatitude = latitude + latitudeOffset * 180 / ((GCJ_A * (1 - GCJ_EE)) / (magic * sqrtMagic) * Math.PI);
const convertedLongitude = longitude + longitudeOffset * 180 / (GCJ_A / sqrtMagic * Math.cos(radianLatitude) * Math.PI);
return [convertedLongitude, convertedLatitude];
}
export function gcj02ToWgs84(longitude: number, latitude: number): [number, number] {
if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || outsideGcjChina(longitude, latitude)) {
return [longitude, latitude];
}
let wgsLongitude = longitude;
let wgsLatitude = latitude;
for (let iteration = 0; iteration < 3; iteration += 1) {
const [convertedLongitude, convertedLatitude] = wgs84ToGcj02(wgsLongitude, wgsLatitude);
wgsLongitude -= convertedLongitude - longitude;
wgsLatitude -= convertedLatitude - latitude;
}
return [wgsLongitude, wgsLatitude];
}
export function isValidAMapCoordinate(longitude: number, latitude: number) {
return (
Number.isFinite(longitude) &&
Number.isFinite(latitude) &&
longitude >= 73 &&
longitude <= 135 &&
latitude >= 18 &&
latitude <= 54
);
}
export function buildAMapMarkerURL({
longitude,
latitude,
name
}: {
longitude: number;
latitude: number;
name: string;
}) {
const [mapLongitude, mapLatitude] = wgs84ToGcj02(longitude, latitude);
return `https://uri.amap.com/marker?position=${mapLongitude},${mapLatitude}&name=${encodeURIComponent(name)}&src=lingniu-vehicle-platform`;
}
export function loadAMap(plugins: AMapPlugin[] = ['AMap.Scale']) {
const config = getAMapConfig();
if (!isAMapConfigured(config)) {
return Promise.reject(new Error('高德地图未配置'));
}
const key = pluginKey(plugins);
const cached = amapLoaderPromises.get(key);
if (cached) {
return cached;
}
if (config.securityServiceHost) {
window._AMapSecurityConfig = { serviceHost: config.securityServiceHost };
} else if (config.securityJsCode) {
window._AMapSecurityConfig = { securityJsCode: config.securityJsCode };
}
const promise = loadScript('https://webapi.amap.com/loader.js').then(() => {
if (!window.AMapLoader) {
throw new Error('高德地图 Loader 不可用');
}
return window.AMapLoader.load({
key: config.webJsKey,
version: '2.0',
plugins
}) as Promise<AMapLike>;
});
amapLoaderPromises.set(key, promise);
return promise;
}
export async function reverseGeocodeWithAMap(longitude: number, latitude: number): Promise<AMapAddress> {
if (!isValidAMapCoordinate(longitude, latitude)) {
throw new Error('坐标不可用');
}
const [mapLongitude, mapLatitude] = wgs84ToGcj02(longitude, latitude);
const AMap = await loadAMap(['AMap.Geocoder']);
if (!AMap.Geocoder) {
throw new Error('高德 Geocoder 插件不可用');
}
const geocoder = new AMap.Geocoder({ city: '全国' });
return new Promise((resolve, reject) => {
geocoder.getAddress([mapLongitude, mapLatitude], (status, result) => {
if (status !== 'complete' || typeof result === 'string') {
reject(new Error(typeof result === 'string' ? result : result?.info || '高德地址解析失败'));
return;
}
const formattedAddress = String(result.regeocode?.formattedAddress ?? '').trim();
if (!formattedAddress) {
reject(new Error('高德地址解析返回空地址'));
return;
}
const component = result.regeocode?.addressComponent ?? {};
resolve({
provider: 'AMap Web JS',
formattedAddress,
province: String(component.province ?? '').trim() || undefined,
city: normalizeCity(component.city),
district: String(component.district ?? '').trim() || undefined,
township: String(component.township ?? '').trim() || undefined,
adcode: String(component.adcode ?? '').trim() || undefined
});
});
});
}

View File

@@ -1,10 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AppV2 } from './v2/AppV2';
import './styles/global.css';
import './v2/styles/v2.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
<AppV2 />
</React.StrictMode>
);

View File

@@ -0,0 +1,564 @@
import type {
AlertEvent,
DataSourceRecord,
FieldOption,
HistoryRecord,
Protocol,
TrackPoint,
VehicleRecord,
VehicleSourceSnapshot,
VehicleStatus
} from './types';
export const protocolMeta: Record<Protocol, { label: string; short: string; color: 'blue' | 'green' | 'orange' }> = {
GB32960: { label: 'GB/T 32960', short: '32960', color: 'blue' },
JT808: { label: 'JT/T 808', short: '808', color: 'green' },
YUTONG_MQTT: { label: '宇通 MQTT', short: 'MQTT', color: 'orange' }
};
export const statusLabel: Record<VehicleStatus, string> = {
online: '在线',
warning: '关注',
offline: '离线'
};
export const sourceHealthLabel = {
healthy: '健康',
warning: '波动',
down: '中断'
} as const;
export const dataSources: DataSourceRecord[] = [
{
id: 'src-hyundai',
name: '现代 HTWO 平台',
protocol: 'GB32960',
endpoint: '47.99.166.38:32960',
platformAccount: 'Hyundai',
health: 'warning',
vehicleCount: 184,
onlineCount: 41,
latencyP95Ms: 312,
lastFrameAt: '2026-07-08 12:05:33',
owner: '数智中心'
},
{
id: 'src-yuejin',
name: '跃进 32960 平台',
protocol: 'GB32960',
endpoint: '115.29.187.205:32960',
platformAccount: 'YueJin',
health: 'healthy',
vehicleCount: 96,
onlineCount: 83,
latencyP95Ms: 188,
lastFrameAt: '2026-07-08 12:06:08',
owner: '接入组'
},
{
id: 'src-g7s',
name: 'G7s 转发',
protocol: 'JT808',
endpoint: '115.29.187.205:808',
platformAccount: 'g7gps',
health: 'healthy',
vehicleCount: 312,
onlineCount: 229,
latencyP95Ms: 246,
lastFrameAt: '2026-07-08 12:06:11',
owner: '接入组'
},
{
id: 'src-ga',
name: '广安车联',
protocol: 'JT808',
endpoint: '115.231.168.135:43625',
platformAccount: 'phone-binding',
health: 'healthy',
vehicleCount: 128,
onlineCount: 107,
latencyP95Ms: 271,
lastFrameAt: '2026-07-08 12:05:59',
owner: '运营'
},
{
id: 'src-yutong',
name: '宇通 MQTT',
protocol: 'YUTONG_MQTT',
endpoint: '/ytforward/shln/3',
platformAccount: 'yutong-forward',
health: 'warning',
vehicleCount: 58,
onlineCount: 50,
latencyP95Ms: 438,
lastFrameAt: '2026-07-08 12:04:48',
owner: '接入组'
}
];
function source(
id: string,
status: VehicleStatus,
latencyMs: number,
lastSeen: string,
fieldCount: number,
overrides: Partial<VehicleSourceSnapshot> = {}
): VehicleSourceSnapshot {
const sourceRecord = dataSources.find((item) => item.id === id);
if (!sourceRecord) {
throw new Error(`Unknown source ${id}`);
}
return {
id,
protocol: sourceRecord.protocol,
sourceName: sourceRecord.name,
endpoint: sourceRecord.endpoint,
platformAccount: sourceRecord.platformAccount,
status,
latencyMs,
lastSeen,
fieldCount,
...overrides
};
}
export const vehicles: VehicleRecord[] = [
{
id: 'veh-001',
plate: '粤AG18312',
vin: 'LB9A32A22P0LS1230',
phone: '13307795425',
oem: 'G7s',
status: 'online',
city: '上海',
address: '上海市奉贤区海湾镇海兴路',
longitude: 121.075044,
latitude: 30.590921,
mapX: 58,
mapY: 44,
heading: 126,
soc: 78.4,
hydrogenKg: 12.23,
primarySourceId: 'src-hyundai',
sources: [
source('src-hyundai', 'online', 226, '12 秒前', 86, {
totalMileageKm: 119925,
todayMileageKm: 86.4,
speedKph: 42
}),
source('src-g7s', 'online', 241, '19 秒前', 34, {
totalMileageKm: 119924.7,
todayMileageKm: 86.1,
speedKph: 41
})
],
flatFields: {
GB32960: {
'gb32960.vehicle.speed_kph': 42,
'gb32960.vehicle.total_mileage_km': 119925,
'gb32960.vehicle.soc_pct': 78.4,
'gb32960.vehicle.dc_voltage_v': 556.1,
'gb32960.vendor.fc_stack.stack_1.avg_cell_voltage_v': 0.79,
'gb32960.vendor.fc_stack.stack_1.hydrogen_inlet_pressure_kpa': 130,
'gb32960.vendor.fc_auxiliary.stack_1.air_compressor_power_kw': 0.8
},
JT808: {
'jt808.location.speed_kph': 41,
'jt808.location.total_mileage_km': 119924.7,
'jt808.location.latitude': 30.590901,
'jt808.location.longitude': 121.075011,
'jt808.location.alarm_flag': 0
},
YUTONG_MQTT: {}
}
},
{
id: 'veh-002',
plate: '粤AFF7936',
vin: 'LB9A32A22P0LS1261',
phone: '13426091261',
oem: '广安车联',
status: 'online',
city: '杭州',
address: '杭州市萧山区机场高速',
longitude: 120.435311,
latitude: 30.227898,
mapX: 43,
mapY: 53,
heading: 84,
hydrogenKg: 9.4,
primarySourceId: 'src-ga',
sources: [
source('src-ga', 'online', 209, '21 秒前', 31, {
totalMileageKm: 48798.9,
todayMileageKm: 54.1,
speedKph: 27
})
],
flatFields: {
GB32960: {},
JT808: {
'jt808.location.speed_kph': 27,
'jt808.location.total_mileage_km': 48798.9,
'jt808.location.direction_deg': 84,
'jt808.additional.total_mileage_km': 48798.9,
'jt808.additional.fuel_level_pct': 61
},
YUTONG_MQTT: {}
}
},
{
id: 'veh-003',
plate: '粤AG15568',
vin: 'LB9A32A23P0LS1222',
oem: '宇通',
status: 'warning',
city: '郑州',
address: '郑州市管城回族区经开第八大街',
longitude: 113.746221,
latitude: 34.716884,
mapX: 69,
mapY: 34,
heading: 0,
soc: 65.1,
hydrogenKg: 8.7,
primarySourceId: 'src-yutong',
sources: [
source('src-yutong', 'warning', 524, '4 分钟前', 24, {
totalMileageKm: 88310,
todayMileageKm: 19.8,
speedKph: 0
})
],
flatFields: {
GB32960: {},
JT808: {},
YUTONG_MQTT: {
'mqtt.vehicle.total_mileage_m': 88310000,
'mqtt.vehicle.speed_kph': 0,
'mqtt.vehicle.soc_pct': 65.1,
'mqtt.vehicle.left_hydrogen_kg': 8.7,
'mqtt.fuel_cell.voltage_v': 318,
'mqtt.fuel_cell.current_a': 54.9,
'mqtt.fuel_cell.air_in_pressure_kpa': 18
}
}
},
{
id: 'veh-004',
plate: '粤AG18285',
vin: 'LB9A32A23P0LS1236',
phone: '13426091236',
oem: '现代四川',
status: 'offline',
city: '成都',
address: '成都市龙泉驿区车城大道',
longitude: 104.269552,
latitude: 30.574117,
mapX: 31,
mapY: 37,
heading: 0,
soc: 41.2,
hydrogenKg: 5.1,
primarySourceId: 'src-hyundai',
sources: [
source('src-hyundai', 'offline', 0, '38 分钟前', 77, {
totalMileageKm: 72104,
todayMileageKm: 0,
speedKph: 0
})
],
flatFields: {
GB32960: {
'gb32960.vehicle.total_mileage_km': 72104,
'gb32960.vehicle.soc_pct': 41.2,
'gb32960.vehicle.status': 'offline'
},
JT808: {},
YUTONG_MQTT: {}
}
},
{
id: 'veh-005',
plate: '粤AFN0065',
vin: 'LB9A32A23P0LS1270',
phone: '13307791270',
oem: '广安车联',
status: 'online',
city: '广安',
address: '广安市前锋区物流大道',
longitude: 106.884219,
latitude: 30.504891,
mapX: 51,
mapY: 68,
heading: 213,
hydrogenKg: 11.9,
primarySourceId: 'src-ga',
sources: [
source('src-ga', 'online', 218, '31 秒前', 33, {
totalMileageKm: 65177.2,
todayMileageKm: 102.6,
speedKph: 63
})
],
flatFields: {
GB32960: {},
JT808: {
'jt808.location.speed_kph': 63,
'jt808.location.total_mileage_km': 65177.2,
'jt808.location.direction_deg': 213,
'jt808.additional.total_mileage_km': 65177.2
},
YUTONG_MQTT: {}
}
},
{
id: 'veh-006',
plate: '粤AG39007',
vin: 'LB9A32A25P0LS1223',
oem: '跃进',
status: 'online',
city: '上海',
address: '上海市浦东新区申江路',
longitude: 121.6431,
latitude: 31.2249,
mapX: 73,
mapY: 57,
heading: 36,
soc: 82,
hydrogenKg: 13.6,
primarySourceId: 'src-yuejin',
sources: [
source('src-yuejin', 'online', 164, '8 秒前', 92, {
totalMileageKm: 90441.5,
todayMileageKm: 73.2,
speedKph: 51
})
],
flatFields: {
GB32960: {
'gb32960.vehicle.speed_kph': 51,
'gb32960.vehicle.total_mileage_km': 90441.5,
'gb32960.vehicle.soc_pct': 82,
'gb32960.vendor.fc_stack.stack_1.stack_water_outlet_temp_c': 63
},
JT808: {},
YUTONG_MQTT: {}
}
},
{
id: 'veh-007',
plate: '粤AFQ8013',
vin: 'LB9A32A25P0LS1254',
oem: 'G7s',
phone: '13307791254',
status: 'warning',
city: '苏州',
address: '苏州市吴江区同津大道',
longitude: 120.6415,
latitude: 31.1608,
mapX: 63,
mapY: 61,
heading: 175,
hydrogenKg: 7.3,
primarySourceId: 'src-g7s',
sources: [
source('src-g7s', 'warning', 612, '6 分钟前', 34, {
totalMileageKm: 40031.8,
todayMileageKm: 12.4,
speedKph: 18
})
],
flatFields: {
GB32960: {},
JT808: {
'jt808.location.speed_kph': 18,
'jt808.location.total_mileage_km': 40031.8,
'jt808.location.direction_deg': 175,
'jt808.quality.delay_ms': 612
},
YUTONG_MQTT: {}
}
},
{
id: 'veh-008',
plate: '粤AG37790',
vin: 'LB9A32A26P0LS1232',
oem: '宇通',
status: 'online',
city: '上海',
address: '上海市金山区亭卫公路',
longitude: 121.3309,
latitude: 30.7478,
mapX: 47,
mapY: 72,
heading: 302,
soc: 74.6,
hydrogenKg: 10.5,
primarySourceId: 'src-yutong',
sources: [
source('src-yutong', 'online', 352, '44 秒前', 22, {
totalMileageKm: 119925,
todayMileageKm: 41.6,
speedKph: 29
}),
source('src-yuejin', 'online', 191, '37 秒前', 84, {
totalMileageKm: 119924.9,
todayMileageKm: 41.4,
speedKph: 30
})
],
flatFields: {
GB32960: {
'gb32960.vehicle.speed_kph': 30,
'gb32960.vehicle.total_mileage_km': 119924.9,
'gb32960.vehicle.soc_pct': 74.6
},
JT808: {},
YUTONG_MQTT: {
'mqtt.vehicle.total_mileage_m': 119925000,
'mqtt.vehicle.speed_kph': 29,
'mqtt.vehicle.soc_pct': 74.6,
'mqtt.vehicle.left_hydrogen_kg': 10.5
}
}
}
];
export const trackPoints: TrackPoint[] = [
{ id: 'trk-1', vehicleId: 'veh-001', time: '08:12', state: 'running', address: '奉贤区海湾镇', longitude: 121.0192, latitude: 30.5803, speedKph: 36, mileageKm: 119842.1, note: '早高峰开始运行' },
{ id: 'trk-2', vehicleId: 'veh-001', time: '09:48', state: 'running', address: '金山区亭卫公路', longitude: 121.2451, latitude: 30.7359, speedKph: 58, mileageKm: 119887.4, note: '高速路段速度稳定' },
{ id: 'trk-3', vehicleId: 'veh-001', time: '10:26', state: 'alert', address: '浦东新区申江路', longitude: 121.6145, latitude: 31.2012, speedKph: 0, mileageKm: 119901.2, note: '出现 92 秒数据断链' },
{ id: 'trk-4', vehicleId: 'veh-001', time: '11:36', state: 'stop', address: '奉贤区海兴路', longitude: 121.075044, latitude: 30.590921, speedKph: 0, mileageKm: 119925, note: '车辆停靠,今日里程闭合' },
{ id: 'trk-5', vehicleId: 'veh-005', time: '07:52', state: 'running', address: '前锋区物流大道', longitude: 106.8951, latitude: 30.5002, speedKph: 44, mileageKm: 65074.6, note: 'JT808 位置上报正常' },
{ id: 'trk-6', vehicleId: 'veh-005', time: '10:18', state: 'running', address: '沪蓉高速广安段', longitude: 106.7289, latitude: 30.4567, speedKph: 63, mileageKm: 65177.2, note: '总里程来自 0x01 附加信息' }
];
export const alertEvents: AlertEvent[] = [
{
id: 'alt-1',
vehicleId: 'veh-001',
level: 'P1',
type: '断链',
title: 'GB32960 心跳延迟',
detail: '现代 HTWO 平台 3 台车超过 90 秒未上报,已触发断链预警。',
sourceId: 'src-hyundai',
time: '2026-07-08 11:58:21',
status: 'open'
},
{
id: 'alt-2',
vehicleId: 'veh-007',
level: 'P2',
type: '里程跳变',
title: 'JT808 总里程回退',
detail: 'G7s 来源出现 1 条总里程小幅回退,统计服务已跳过异常区间。',
sourceId: 'src-g7s',
time: '2026-07-08 11:41:06',
status: 'processing'
},
{
id: 'alt-3',
vehicleId: 'veh-003',
level: 'P2',
type: '字段缺失',
title: 'MQTT 字段缺失',
detail: '宇通 MQTT 本次只上报经纬度,实时快照使用上一帧补全业务字段。',
sourceId: 'src-yutong',
time: '2026-07-08 10:36:44',
status: 'open'
},
{
id: 'alt-4',
vehicleId: 'veh-004',
level: 'P1',
type: '定位异常',
title: '车辆长时间离线',
detail: '现代四川来源 38 分钟无实时数据,当前位置仅作为最后有效位置展示。',
sourceId: 'src-hyundai',
time: '2026-07-08 10:14:09',
status: 'open'
}
];
export const historyRecords: HistoryRecord[] = [
{
id: 'raw-gb-20260708-000923',
vehicleId: 'veh-001',
protocol: 'GB32960',
frameType: '实时信息上报 0x02',
receiveTime: '2026-07-08 12:05:33.226',
deviceTime: '2026-07-08 12:05:32',
sourceName: '现代 HTWO 平台',
latencyMs: 226,
fieldCount: 86,
exportable: true
},
{
id: 'raw-808-20260708-002841',
vehicleId: 'veh-001',
protocol: 'JT808',
frameType: '位置信息汇报 0x0200',
receiveTime: '2026-07-08 12:05:34.118',
deviceTime: '2026-07-08 12:05:33',
sourceName: 'G7s 转发',
latencyMs: 241,
fieldCount: 34,
exportable: true
},
{
id: 'raw-mqtt-20260708-000114',
vehicleId: 'veh-003',
protocol: 'YUTONG_MQTT',
frameType: '遥测转发 code=0F80',
receiveTime: '2026-07-08 12:04:48.524',
deviceTime: '2026-07-08 12:04:47',
sourceName: '宇通 MQTT',
latencyMs: 524,
fieldCount: 24,
exportable: true
},
{
id: 'raw-gb-20260708-000927',
vehicleId: 'veh-006',
protocol: 'GB32960',
frameType: '补发信息上报 0x03',
receiveTime: '2026-07-08 12:06:08.164',
deviceTime: '2026-07-08 12:05:58',
sourceName: '跃进 32960 平台',
latencyMs: 164,
fieldCount: 92,
exportable: true
},
{
id: 'raw-808-20260708-002846',
vehicleId: 'veh-005',
protocol: 'JT808',
frameType: '位置信息汇报 0x0200',
receiveTime: '2026-07-08 12:05:59.218',
deviceTime: '2026-07-08 12:05:58',
sourceName: '广安车联',
latencyMs: 218,
fieldCount: 33,
exportable: true
}
];
export const fieldOptions: FieldOption[] = [
{ key: 'speed_kph', label: '车速', protocol: 'ALL' },
{ key: 'total_mileage_km', label: '总里程', protocol: 'ALL' },
{ key: 'soc_pct', label: 'SOC', protocol: 'GB32960' },
{ key: 'left_hydrogen_kg', label: '剩余氢量', protocol: 'YUTONG_MQTT' },
{ key: 'direction_deg', label: '方向角', protocol: 'JT808' },
{ key: 'lat_lon', label: '经纬度', protocol: 'ALL' },
{ key: 'fc_stack', label: '燃料电池堆', protocol: 'GB32960' },
{ key: 'quality_delay', label: '链路延迟', protocol: 'ALL' }
];
export function getVehiclePrimarySource(vehicle: VehicleRecord) {
return vehicle.sources.find((item) => item.id === vehicle.primarySourceId) ?? vehicle.sources[0];
}
export function getVehicleProtocols(vehicle: VehicleRecord) {
return vehicle.sources.map((item) => item.protocol);
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, test } from 'vitest';
import { rawCoordinateAddressEvidence, rawCoordinateEvidence } from './rawEvidence';
describe('rawCoordinateEvidence', () => {
test('converts Yutong MQTT total_mileage meters to kilometers for RAW map evidence', () => {
const evidence = rawCoordinateEvidence('YUTONG_MQTT', {
'yutong_mqtt.data.longitude': '121.075044',
'yutong_mqtt.data.latitude': '30.590921',
'yutong_mqtt.data.meter_speed': '27',
'yutong_mqtt.data.total_mileage': '119925000'
});
expect(evidence).toMatchObject({
longitude: 121.075044,
latitude: 30.590921,
speedKmh: 27,
totalMileageKm: 119925,
totalMileageKey: 'yutong_mqtt.data.total_mileage',
totalMileageUnitEvidence: 'm->km'
});
});
test('keeps GB32960 kilometer mileage unchanged', () => {
const evidence = rawCoordinateEvidence('GB32960', {
'gb32960.position.longitude': '114.12552',
'gb32960.position.latitude': '30.452935',
'gb32960.vehicle.speed_kmh': '91.5',
'gb32960.vehicle.total_mileage_km': '35370.2'
});
expect(evidence).toMatchObject({
longitude: 114.12552,
latitude: 30.452935,
speedKmh: 91.5,
totalMileageKm: 35370.2,
totalMileageKey: 'gb32960.vehicle.total_mileage_km',
totalMileageUnitEvidence: 'km'
});
});
test('rejects out-of-business-range coordinates', () => {
expect(rawCoordinateEvidence('JT808', {
'jt808.location.longitude': '0',
'jt808.location.latitude': '0',
'jt808.location.total_mileage_km': '10'
})).toBeUndefined();
});
test('summarizes reverse geocode state for RAW coordinate evidence', () => {
const coordinate = rawCoordinateEvidence('JT808', {
'jt808.location.longitude': '121.4737',
'jt808.location.latitude': '31.2304',
'jt808.location.speed_kmh': '32'
});
expect(rawCoordinateAddressEvidence(coordinate, {
status: 'ready',
provider: 'AMap Web JS',
address: '上海市黄浦区人民大道',
region: '上海市/黄浦区'
})).toEqual({
status: 'ready',
label: 'AMap Web JS 已解析',
detail: '上海市黄浦区人民大道',
evidence: '上海市/黄浦区',
tone: 'ready'
});
expect(rawCoordinateAddressEvidence(undefined, { status: 'disabled' })).toMatchObject({
status: 'unavailable',
label: '无坐标',
tone: 'waiting'
});
});
});

View File

@@ -0,0 +1,167 @@
import { isValidAMapCoordinate } from '../integrations/amap';
import type { Protocol } from './types';
export type RawCoordinateEvidence = {
latitude: number;
longitude: number;
latitudeKey: string;
longitudeKey: string;
speedKmh?: number;
totalMileageKm?: number;
totalMileageKey?: string;
totalMileageUnitEvidence?: 'km' | 'm->km';
directionDeg?: number;
altitudeM?: number;
};
export type RawCoordinateAddressInput = {
status: 'disabled' | 'loading' | 'ready' | 'unavailable' | 'error';
provider?: string;
address?: string;
region?: string;
error?: string;
};
export type RawCoordinateAddressEvidence = {
status: 'ready' | 'loading' | 'unavailable' | 'error';
label: string;
detail: string;
evidence: string;
tone: 'ready' | 'warning' | 'waiting' | 'error';
};
function rawNumberValue(value: unknown) {
if (value == null || value === '') {
return undefined;
}
const number = Number(String(value).trim());
return Number.isFinite(number) ? number : undefined;
}
function rawFieldScore(key: string) {
const normalized = key.toLowerCase();
if (normalized.includes('.location.')) return 0;
if (normalized.includes('.data.') && !normalized.includes('.root.data.')) return 1;
if (normalized.includes('.root.data.')) return 2;
return 3;
}
function pickRawNumberField(fields: Record<string, unknown>, matcher: (key: string) => boolean) {
return Object.entries(fields)
.map(([key, value]) => ({ key, value: rawNumberValue(value) }))
.filter((item): item is { key: string; value: number } => item.value != null && matcher(item.key.toLowerCase()))
.sort((left, right) => rawFieldScore(left.key) - rawFieldScore(right.key))[0];
}
function rawMileageProjection(protocol: Protocol, mileage?: { key: string; value: number }) {
if (!mileage) {
return undefined;
}
const normalizedKey = mileage.key.toLowerCase();
if (normalizedKey.endsWith('.total_mileage_m')) {
return { value: mileage.value / 1000, unitEvidence: 'm->km' as const };
}
if (
protocol === 'YUTONG_MQTT' &&
normalizedKey.endsWith('.total_mileage') &&
!normalizedKey.endsWith('.total_mileage_km')
) {
return { value: mileage.value / 1000, unitEvidence: 'm->km' as const };
}
return { value: mileage.value, unitEvidence: 'km' as const };
}
export function rawCoordinateEvidence(
protocol: Protocol,
fields: Record<string, unknown>
): RawCoordinateEvidence | undefined {
const latitude = pickRawNumberField(fields, (key) =>
key === 'latitude' ||
key.endsWith('.latitude') ||
key.endsWith('.lat')
);
const longitude = pickRawNumberField(fields, (key) =>
key === 'longitude' ||
key.endsWith('.longitude') ||
key.endsWith('.lng') ||
key.endsWith('.lon')
);
if (!latitude || !longitude || !isValidAMapCoordinate(longitude.value, latitude.value)) {
return undefined;
}
const speed = pickRawNumberField(fields, (key) =>
key.endsWith('.speed_kmh') ||
key.endsWith('.speed_kph') ||
key.endsWith('.meter_speed')
);
const mileage = pickRawNumberField(fields, (key) =>
key.endsWith('.total_mileage_km') ||
key.endsWith('.total_mileage_m') ||
key.endsWith('.total_mileage')
);
const mileageProjection = rawMileageProjection(protocol, mileage);
const direction = pickRawNumberField(fields, (key) => key.endsWith('.direction_deg'));
const altitude = pickRawNumberField(fields, (key) => key.endsWith('.altitude_m'));
return {
latitude: latitude.value,
longitude: longitude.value,
latitudeKey: latitude.key,
longitudeKey: longitude.key,
speedKmh: speed?.value,
totalMileageKm: mileageProjection?.value,
totalMileageKey: mileage?.key,
totalMileageUnitEvidence: mileageProjection?.unitEvidence,
directionDeg: direction?.value,
altitudeM: altitude?.value
};
}
export function rawCoordinateAddressEvidence(
coordinate: RawCoordinateEvidence | undefined,
address: RawCoordinateAddressInput
): RawCoordinateAddressEvidence {
if (!coordinate) {
return {
status: 'unavailable',
label: '无坐标',
detail: '当前 RAW 没有可用于高德解析的有效经纬度。',
evidence: 'raw parsed_fields',
tone: 'waiting'
};
}
if (address.status === 'ready') {
const provider = address.provider || 'AMap';
return {
status: 'ready',
label: `${provider} 已解析`,
detail: address.address || '已返回地址',
evidence: address.region || `${coordinate.latitude.toFixed(6)}, ${coordinate.longitude.toFixed(6)}`,
tone: address.error ? 'warning' : 'ready'
};
}
if (address.status === 'loading') {
return {
status: 'loading',
label: '解析中',
detail: '正在根据 RAW 经纬度请求逆地理解析。',
evidence: `${coordinate.latitude.toFixed(6)}, ${coordinate.longitude.toFixed(6)}`,
tone: 'waiting'
};
}
if (address.status === 'error') {
return {
status: 'error',
label: '解析失败',
detail: address.error || '地址解析接口或 AMap Web JS 不可用。',
evidence: `${coordinate.latitude.toFixed(6)}, ${coordinate.longitude.toFixed(6)}`,
tone: 'error'
};
}
return {
status: 'unavailable',
label: '待解析',
detail: address.error || '地址解析能力未启用,仍可打开高德坐标。',
evidence: `${coordinate.latitude.toFixed(6)}, ${coordinate.longitude.toFixed(6)}`,
tone: 'waiting'
};
}

View File

@@ -0,0 +1,50 @@
const rawFrameFieldAliases: Record<string, string[]> = {
speed_kph: [
'gb32960.vehicle.speed_kmh',
'jt808.location.speed_kmh',
'yutong_mqtt.data.meter_speed'
],
total_mileage_km: [
'gb32960.vehicle.total_mileage_km',
'jt808.location.total_mileage_km',
'yutong_mqtt.data.total_mileage'
],
soc_pct: [
'gb32960.vehicle.soc_percent',
'yutong_mqtt.data.battery_capacity_soc'
],
left_hydrogen_kg: [
'gb32960.gd_fc_vehicle_info.hydrogen_mass_kg',
'yutong_mqtt.data.left_hydrogen'
],
direction_deg: [
'jt808.location.direction_deg'
],
lat_lon: [
'gb32960.position.latitude',
'gb32960.position.longitude',
'jt808.location.latitude',
'jt808.location.longitude',
'yutong_mqtt.data.latitude',
'yutong_mqtt.data.longitude'
],
fc_stack: [
'gb32960.gd_fc_stack.stack_count',
'gb32960.gd_fc_stack.engine_work_state',
'gb32960.gd_fc_stack.avg_cell_voltage_v',
'gb32960.gd_fc_stack.stack_water_outlet_temp_c',
'gb32960.gd_fc_stack.hydrogen_inlet_pressure_kpa',
'gb32960.fuel_cell.fuel_cell_voltage_v',
'gb32960.fuel_cell.fuel_cell_current_a'
],
quality_delay: [
'gb32960.device_time.device_time',
'jt808.location.device_time',
'yutong_mqtt.root.time'
]
};
export function expandRawFrameFields(fields: string[]) {
const expanded = fields.flatMap((field) => rawFrameFieldAliases[field] ?? [field]);
return Array.from(new Set(expanded));
}

View File

@@ -0,0 +1,126 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { httpErrorMessage, loadPrototypeRealtimeVehicles } from './realData';
afterEach(() => {
delete window.__LINGNIU_APP_CONFIG__;
window.sessionStorage.clear();
vi.restoreAllMocks();
});
describe('httpErrorMessage', () => {
test('preserves HTTP status and compact response body', async () => {
const response = new Response(' database connection failed ', { status: 500 });
await expect(httpErrorMessage(response)).resolves.toBe('HTTP 500: database connection failed');
});
test('marks an empty HTTP error body explicitly', async () => {
const response = new Response('', { status: 500 });
await expect(httpErrorMessage(response)).resolves.toBe('HTTP 500: empty response body');
});
});
describe('loadPrototypeRealtimeVehicles', () => {
test('merges realtime snapshot parsed_json fields into the same vehicle as realtime locations', async () => {
window.__LINGNIU_APP_CONFIG__ = { prototypeUseRealData: true };
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
const url = String(input);
if (url.includes('/api/realtime/locations')) {
return Promise.resolve(new Response(JSON.stringify({
items: [{
protocol: 'GB32960',
vin: 'LB9A32A22R0LS1456',
plate: '粤AGP6637',
event_time: '2026-07-09 10:38:21.000',
latitude: 23.093449,
longitude: 113.159489,
speed_kmh: 26,
total_mileage_km: 22734.4,
soc_percent: 68,
received_at: '2026-07-09 10:38:24.185',
updated_at: '2026-07-09 10:38:25.000'
}],
total: 1
})));
}
if (url.includes('/api/realtime/snapshots')) {
return Promise.resolve(new Response(JSON.stringify({
items: [{
protocol: 'GB32960',
vin: 'LB9A32A22R0LS1456',
plate: '粤AGP6637',
platform_name: 'Hyundai',
peer: '8.134.95.166:35062',
parsed_json: JSON.stringify({
'gb32960.vehicle.speed_kmh': '32',
'gb32960.vehicle.total_mileage_km': '22734.5',
'gb32960.gd_fc_stack.stack_water_outlet_temp_c': '65',
'gb32960.gd_fc_auxiliary.subsystems.subsystem_1.water_pump_voltage_v': '340'
}),
event_time: '2026-07-09 10:38:31.000',
received_at: '2026-07-09 10:38:34.000',
updated_at: '2026-07-09 10:38:35.000'
}],
total: 1
})));
}
return Promise.reject(new Error(`unexpected fetch ${url}`));
});
const state = await loadPrototypeRealtimeVehicles(10);
expect(fetchMock).toHaveBeenCalledWith('/api/realtime/locations?limit=10&offset=0', expect.any(Object));
expect(fetchMock).toHaveBeenCalledWith('/api/realtime/snapshots?limit=10&offset=0', expect.any(Object));
expect(state.status).toBe('ready');
expect(state.vehicles).toHaveLength(1);
const vehicle = state.vehicles[0];
expect(vehicle.flatFields.GB32960['gb32960.gd_fc_stack.stack_water_outlet_temp_c']).toBe('65');
expect(vehicle.flatFields.GB32960['gb32960.gd_fc_auxiliary.subsystems.subsystem_1.water_pump_voltage_v']).toBe('340');
expect(vehicle.sources[0].endpoint).toBe('8.134.95.166:35062');
expect(vehicle.sources[0].fieldCount).toBeGreaterThan(10);
});
test('converts Yutong MQTT total_mileage meters to source totalMileageKm while preserving raw flat field', async () => {
window.__LINGNIU_APP_CONFIG__ = { prototypeUseRealData: true };
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
const url = String(input);
if (url.includes('/api/realtime/locations')) {
return Promise.resolve(new Response(JSON.stringify({ items: [], total: 0 })));
}
if (url.includes('/api/realtime/snapshots')) {
return Promise.resolve(new Response(JSON.stringify({
items: [{
protocol: 'YUTONG_MQTT',
vin: 'LMRKH9AC2R1004087',
plate: '沪A65516F',
peer: 'mqtt://yutong/ytforward/shln/3',
parsed_json: JSON.stringify({
'yutong_mqtt.data.longitude': '121.075044',
'yutong_mqtt.data.latitude': '30.590921',
'yutong_mqtt.data.meter_speed': '27',
'yutong_mqtt.data.total_mileage': '119925000',
'yutong_mqtt.data.battery_capacity_soc': '78.4'
}),
event_time: '2026-07-09 10:50:37.945',
received_at: '2026-07-09 10:50:37.967'
}],
total: 1
})));
}
return Promise.reject(new Error(`unexpected fetch ${url}`));
});
const state = await loadPrototypeRealtimeVehicles(10);
expect(state.status).toBe('ready');
expect(state.vehicles).toHaveLength(1);
const vehicle = state.vehicles[0];
expect(vehicle.longitude).toBe(121.075044);
expect(vehicle.latitude).toBe(30.590921);
expect(vehicle.soc).toBe(78.4);
expect(vehicle.sources[0].speedKph).toBe(27);
expect(vehicle.sources[0].totalMileageKm).toBe(119925);
expect(vehicle.flatFields.YUTONG_MQTT['yutong_mqtt.data.total_mileage']).toBe('119925000');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
export type Protocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
export type VehicleStatus = 'online' | 'warning' | 'offline';
export type SourceHealth = 'healthy' | 'warning' | 'down';
export type VehicleSourceSnapshot = {
id: string;
protocol: Protocol;
sourceName: string;
endpoint: string;
platformAccount: string;
status: VehicleStatus;
latencyMs: number;
lastSeen: string;
lastSeenAt?: string;
totalMileageKm?: number;
todayMileageKm?: number;
speedKph?: number;
fieldCount: number;
};
export type VehicleRecord = {
id: string;
plate: string;
vin: string;
phone?: string;
oem: string;
status: VehicleStatus;
city: string;
address: string;
longitude: number;
latitude: number;
mapX: number;
mapY: number;
heading: number;
soc?: number;
hydrogenKg?: number;
primarySourceId: string;
sources: VehicleSourceSnapshot[];
flatFields: Record<Protocol, Record<string, string | number | boolean | null>>;
};
export type DataSourceRecord = {
id: string;
name: string;
protocol: Protocol;
endpoint: string;
platformAccount: string;
health: SourceHealth;
vehicleCount: number;
onlineCount: number;
latencyP95Ms: number;
lastFrameAt: string;
owner: string;
};
export type TrackPoint = {
id: string;
vehicleId: string;
time: string;
timestampMs?: number;
state: 'running' | 'stop' | 'alert';
address: string;
longitude?: number;
latitude?: number;
speedKph: number;
mileageKm: number;
note: string;
};
export type AlertEvent = {
id: string;
vehicleId: string;
level: 'P0' | 'P1' | 'P2';
type: '断链' | '里程跳变' | '字段缺失' | '定位异常';
title: string;
detail: string;
sourceId: string;
time: string;
status: 'open' | 'processing' | 'closed';
};
export type HistoryRecord = {
id: string;
vehicleId: string;
protocol: Protocol;
frameType: string;
receiveTime: string;
deviceTime: string;
sourceName: string;
latencyMs: number;
fieldCount: number;
exportable: boolean;
};
export type FieldOption = {
key: string;
label: string;
protocol: Protocol | 'ALL';
};

View File

@@ -0,0 +1,450 @@
import { describe, expect, test } from 'vitest';
import {
buildDataCapabilityRadar,
buildReplayRawLookupWindow,
getCommandPriorityItems,
getMileageSourceDecisions,
getSampleBoundaryCopy,
getStatsInterpretationItems,
getVehicleDiagnosticSteps,
getVehicleDetailDigest,
getVehicleSourceResponsibilityRows
} from './viewModel';
import type { VehicleRecord } from './types';
describe('buildReplayRawLookupWindow', () => {
test('builds a focused China-time RAW lookup window around a replay node', () => {
const timestamp = Date.parse('2026-07-09T09:53:20+08:00');
expect(buildReplayRawLookupWindow(timestamp)).toEqual({
dateFrom: '2026-07-09T09:51:20+08:00',
dateTo: '2026-07-09T09:55:20+08:00'
});
});
});
describe('getMileageSourceDecisions', () => {
test('selects one trusted mileage source without summing multiple protocols', () => {
const vehicle = testVehicle({ id: '004', protocol: 'GB32960', totalMileageKm: 1000 });
vehicle.sources[0].todayMileageKm = 12;
vehicle.sources[0].lastSeen = '40 秒前';
vehicle.sources.push({
id: '004-JT808',
protocol: 'JT808',
sourceName: 'JT808 来源',
endpoint: '/api/realtime/locations',
platformAccount: '测试平台',
status: 'online',
latencyMs: 60,
lastSeen: '5 秒前',
totalMileageKm: 1000.8,
todayMileageKm: 14,
fieldCount: 15
});
const [decision] = getMileageSourceDecisions([vehicle]);
expect(decision).toMatchObject({
selectedProtocol: 'JT808',
selectedDailyMileageKm: 14,
selectedTotalMileageKm: 1000.8,
candidateCount: 2,
status: 'ready'
});
expect(decision.sources.map((source) => source.todayMileageKm)).toEqual([12, 14]);
expect(decision.selectedDailyMileageKm).not.toBe(26);
});
test('marks vehicles without mileage as missing instead of inventing values', () => {
const vehicle = testVehicle({ id: '005', protocol: 'YUTONG_MQTT' });
vehicle.sources[0].totalMileageKm = undefined;
vehicle.sources[0].todayMileageKm = undefined;
const [decision] = getMileageSourceDecisions([vehicle]);
expect(decision).toMatchObject({
selectedProtocol: undefined,
selectedDailyMileageKm: 0,
selectedTotalMileageKm: 0,
candidateCount: 0,
status: 'missing'
});
expect(decision.detail).toContain('不展示里程');
});
});
describe('getSampleBoundaryCopy', () => {
test('keeps fallback samples clearly separated from production facts', () => {
expect(getSampleBoundaryCopy('history-raw', false)).toMatchObject({
title: 'Raw 样例记录已收起',
actionLabel: '展开样例记录'
});
expect(getSampleBoundaryCopy('alert-events', true)).toMatchObject({
title: '正在展示样例事件',
actionLabel: '收起样例事件'
});
expect(getSampleBoundaryCopy('source-monitor', false)).toMatchObject({
title: '样例来源已收起',
actionLabel: '展开样例来源'
});
const allCopy = [
getSampleBoundaryCopy('history-raw', true),
getSampleBoundaryCopy('alert-events', true),
getSampleBoundaryCopy('source-monitor', true)
];
allCopy.forEach((copy) => {
expect(copy.detail).toContain('不代表');
expect(copy.detail).not.toContain('生产已接入');
});
});
});
function testVehicle({
id,
protocol,
longitude = 121.4,
latitude = 31.2,
totalMileageKm
}: {
id: string;
protocol: VehicleRecord['sources'][number]['protocol'];
longitude?: number;
latitude?: number;
totalMileageKm?: number;
}): VehicleRecord {
return {
id,
plate: `沪A${id}`,
vin: `VIN-${id}`,
oem: '测试厂商',
status: 'online',
city: '上海',
address: '测试地址',
longitude,
latitude,
mapX: 50,
mapY: 50,
heading: 0,
primarySourceId: `${id}-${protocol}`,
sources: [{
id: `${id}-${protocol}`,
protocol,
sourceName: `${protocol} 来源`,
endpoint: '/api/realtime/locations',
platformAccount: '测试平台',
status: 'online',
latencyMs: 80,
lastSeen: '12 秒前',
totalMileageKm,
fieldCount: 18
}],
flatFields: {
GB32960: {},
JT808: {},
YUTONG_MQTT: {}
}
};
}
describe('buildDataCapabilityRadar', () => {
test('summarizes only source-backed capabilities for the realtime command surface', () => {
const radar = buildDataCapabilityRadar({
vehicles: [
testVehicle({ id: '001', protocol: 'GB32960', totalMileageKm: 1024.5 }),
testVehicle({ id: '002', protocol: 'JT808' })
],
realtimeStatus: 'ready',
realtimeTotal: 2,
amapStatus: 'ready',
rawQueryEnabled: true,
trackQueryEnabled: true
});
expect(radar.map((item) => item.key)).toEqual([
'realtime-location',
'protocol-coverage',
'amap-map',
'raw-evidence',
'mileage-stat'
]);
expect(radar.find((item) => item.key === 'realtime-location')).toMatchObject({
state: 'ready',
metric: '2/2 台'
});
expect(radar.find((item) => item.key === 'protocol-coverage')).toMatchObject({
state: 'partial',
metric: '32960 1 / 808 1 / MQTT 0'
});
expect(radar.find((item) => item.key === 'mileage-stat')).toMatchObject({
state: 'partial',
metric: '1/2 来源'
});
});
test('does not promote mock or unavailable infrastructure as production capability', () => {
const radar = buildDataCapabilityRadar({
vehicles: [],
realtimeStatus: 'error',
realtimeTotal: 0,
amapStatus: 'error',
rawQueryEnabled: false,
trackQueryEnabled: false
});
expect(radar.every((item) => item.state !== 'ready')).toBe(true);
expect(radar.find((item) => item.key === 'raw-evidence')).toMatchObject({
state: 'unavailable',
metric: '未启用'
});
expect(radar.map((item) => item.title).join(' ')).not.toContain('通知');
});
});
describe('getVehicleDetailDigest', () => {
test('condenses a selected vehicle into realtime, trust, and traceability cards', () => {
const vehicle = testVehicle({ id: '003', protocol: 'JT808', totalMileageKm: 2048.6 });
vehicle.sources.push({
id: '003-GB32960',
protocol: 'GB32960',
sourceName: 'GB32960 来源',
endpoint: '/api/realtime/locations',
platformAccount: '测试平台',
status: 'warning',
latencyMs: 230,
lastSeen: '4 分钟前',
totalMileageKm: 2049.1,
fieldCount: 22
});
const digest = getVehicleDetailDigest(vehicle, vehicle.sources[0]);
expect(digest.map((item) => item.title)).toEqual(['实时状态', '来源可信度', '可追溯证据']);
expect(digest[0]).toMatchObject({
tone: 'ok',
value: '0 km/h'
});
expect(digest[1]).toMatchObject({
tone: 'warning',
metric: '1/2 在线'
});
expect(digest[2]).toMatchObject({
tone: 'ready',
value: 'JT808 RAW'
});
expect(digest.map((item) => item.source).join(' ')).toContain('Redis');
expect(digest.map((item) => item.source).join(' ')).toContain('TDengine');
});
});
describe('getVehicleDiagnosticSteps', () => {
test('builds an ordered production troubleshooting path for the selected vehicle', () => {
const vehicle = testVehicle({ id: '006', protocol: 'JT808', totalMileageKm: 2048.6 });
vehicle.sources[0].fieldCount = 0;
vehicle.sources.push({
id: '006-GB32960',
protocol: 'GB32960',
sourceName: 'GB32960 来源',
endpoint: '/api/realtime/snapshots',
platformAccount: '测试平台',
status: 'online',
latencyMs: 180,
lastSeen: '10 秒前',
totalMileageKm: 2102.6,
fieldCount: 22
});
const steps = getVehicleDiagnosticSteps(vehicle, vehicle.sources[0]);
expect(steps.map((item) => item.key)).toEqual(['realtime-state', 'source-consistency', 'evidence-loop']);
expect(steps[0]).toMatchObject({
title: '确认当前态',
status: 'warning',
action: 'overview'
});
expect(steps[0].detail).toContain('字段');
expect(steps[1]).toMatchObject({
title: '核对来源一致性',
status: 'warning',
action: 'sources'
});
expect(steps[1].detail).toContain('里程');
expect(steps[2]).toMatchObject({
title: '回查证据闭环',
status: 'ready',
action: 'history'
});
expect(steps[2].detail).toContain('TDengine');
});
});
describe('getVehicleSourceResponsibilityRows', () => {
test('turns protocol sources into an operations responsibility table', () => {
const vehicle = testVehicle({ id: '007', protocol: 'JT808', totalMileageKm: 2048.6 });
vehicle.sources[0].fieldCount = 0;
vehicle.sources.push({
id: '007-GB32960',
protocol: 'GB32960',
sourceName: 'GB32960 来源',
endpoint: '/api/realtime/snapshots',
platformAccount: '测试平台',
status: 'online',
latencyMs: 180,
lastSeen: '10 秒前',
totalMileageKm: 2102.6,
fieldCount: 22
}, {
id: '007-YUTONG',
protocol: 'YUTONG_MQTT',
sourceName: '宇通 MQTT 来源',
endpoint: 'mqtt://yutong/topic',
platformAccount: '测试平台',
status: 'offline',
latencyMs: 900,
lastSeen: '12 分钟前',
fieldCount: 8
});
const rows = getVehicleSourceResponsibilityRows(vehicle, vehicle.sources[0]);
expect(rows.map((row) => row.protocol)).toEqual(['JT808', 'GB32960', 'YUTONG_MQTT']);
expect(rows[0]).toMatchObject({
selected: true,
status: 'warning',
issueCount: 1,
issueSummary: '字段缺失',
mileageDeltaKm: 0
});
expect(rows[1]).toMatchObject({
status: 'warning',
issueSummary: '里程差异',
mileageDeltaKm: 54
});
expect(rows[2]).toMatchObject({
status: 'error',
issueCount: 2,
issueSummary: '来源离线 / 里程缺失'
});
});
});
describe('getStatsInterpretationItems', () => {
test('summarizes trusted mileage, daily API, source consistency, and protocol coverage', () => {
const ready = testVehicle({ id: '020', protocol: 'GB32960', totalMileageKm: 1000 });
ready.sources[0].todayMileageKm = 12;
const conflict = testVehicle({ id: '021', protocol: 'GB32960', totalMileageKm: 2000 });
conflict.sources[0].todayMileageKm = 20;
conflict.sources.push({
id: '021-JT808',
protocol: 'JT808',
sourceName: 'JT808 来源',
endpoint: '/api/realtime/locations',
platformAccount: '测试平台',
status: 'online',
latencyMs: 60,
lastSeen: '4 秒前',
totalMileageKm: 2028,
todayMileageKm: 22,
fieldCount: 16
});
const missing = testVehicle({ id: '022', protocol: 'YUTONG_MQTT' });
missing.sources[0].totalMileageKm = undefined;
missing.sources[0].todayMileageKm = undefined;
const items = getStatsInterpretationItems([ready, conflict, missing], 'ALL', false);
expect(items.map((item) => item.key)).toEqual([
'trusted-mileage',
'daily-mileage-api',
'source-consistency',
'protocol-coverage'
]);
expect(items[0]).toMatchObject({
metric: '1/3',
tone: 'error'
});
expect(items[1]).toMatchObject({
metric: '待接入',
tone: 'waiting'
});
expect(items[2]).toMatchObject({
metric: '1/1 需关注',
tone: 'warning'
});
expect(items[3]).toMatchObject({
metric: '32960 2 / 808 1 / MQTT 1',
tone: 'ready'
});
});
});
describe('getCommandPriorityItems', () => {
test('prioritizes source health, coordinate, mileage, and field coverage issues from realtime vehicles', () => {
const healthy = testVehicle({ id: '010', protocol: 'GB32960', totalMileageKm: 1024 });
healthy.sources[0].fieldCount = 20;
healthy.flatFields.GB32960 = {
'gb32960.vehicle.speed_kmh': 20,
'gb32960.vehicle.total_mileage_km': 1024
};
const offline = testVehicle({ id: '011', protocol: 'JT808', totalMileageKm: 2048 });
offline.status = 'offline';
offline.sources[0].status = 'offline';
offline.sources[0].fieldCount = 0;
const missingCoordinate = testVehicle({
id: '012',
protocol: 'YUTONG_MQTT',
longitude: 0,
latitude: 0,
totalMileageKm: 4096
});
missingCoordinate.sources[0].fieldCount = 12;
missingCoordinate.flatFields.YUTONG_MQTT = {
'yutong_mqtt.data.longitude': 0,
'yutong_mqtt.data.latitude': 0
};
const missingMileage = testVehicle({ id: '013', protocol: 'GB32960' });
missingMileage.sources[0].totalMileageKm = undefined;
missingMileage.sources[0].todayMileageKm = undefined;
missingMileage.sources[0].fieldCount = 0;
const priorities = getCommandPriorityItems([
healthy,
offline,
missingCoordinate,
missingMileage
]);
expect(priorities.map((item) => item.key)).toEqual([
'source-health',
'coordinate',
'mileage',
'field-coverage'
]);
expect(priorities[0]).toMatchObject({
value: '1',
tone: 'error',
vehicleIds: ['011']
});
expect(priorities[1]).toMatchObject({
value: '1',
tone: 'warning',
vehicleIds: ['012']
});
expect(priorities[2]).toMatchObject({
value: '1',
tone: 'warning',
vehicleIds: ['013']
});
expect(priorities[3]).toMatchObject({
value: '2',
tone: 'warning',
vehicleIds: ['011', '013']
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -7001,6 +7001,26 @@ button.vp-realtime-command-item:focus-visible {
box-shadow: var(--vp-shadow-sm);
}
.vp-map-provider-status {
position: absolute;
left: 12px;
top: 12px;
z-index: 10;
box-shadow: var(--vp-shadow-sm);
}
.vp-amap-marker-shell {
padding: 0;
border: 0;
background: transparent;
display: inline-flex;
align-items: center;
gap: 6px;
color: inherit;
cursor: pointer;
font: inherit;
}
.vp-amap-marker {
min-width: 18px;
height: 18px;
@@ -7030,6 +7050,22 @@ button.vp-realtime-command-item:focus-visible {
0 10px 22px rgba(16, 24, 40, 0.24);
}
.vp-amap-marker-label {
max-width: 116px;
padding: 5px 7px;
border: 1px solid #b8cdfd;
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
color: #164194;
box-shadow: 0 10px 22px rgba(16, 24, 40, 0.16);
font-size: 12px;
font-weight: 700;
line-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vp-monitor-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { lazy, Suspense } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AppShell } from './layout/AppShell';
import { AuthGate } from './auth/AuthGate';
import { PageLoading } from './shared/AsyncState';
const MonitorPage = lazy(() => import('./pages/MonitorPage'));
const VehiclePage = lazy(() => import('./pages/VehiclePage'));
const TrackPage = lazy(() => import('./pages/TrackPage'));
const HistoryPage = lazy(() => import('./pages/HistoryPage'));
const AccessPage = lazy(() => import('./pages/AccessPage'));
const AlertsPage = lazy(() => import('./pages/AlertsPage'));
const OperationsPage = lazy(() => import('./pages/OperationsPage'));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 15_000,
gcTime: 5 * 60_000,
retry: 1,
refetchOnWindowFocus: false
}
}
});
export function AppV2() {
return (
<QueryClientProvider client={queryClient}>
<AuthGate><BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Routes>
<Route element={<AppShell />}>
<Route index element={<Navigate to="/monitor" replace />} />
<Route path="/monitor" element={<Suspense fallback={<PageLoading />}><MonitorPage /></Suspense>} />
<Route path="/vehicles/:vin?" element={<Suspense fallback={<PageLoading />}><VehiclePage /></Suspense>} />
<Route path="/tracks" element={<Suspense fallback={<PageLoading />}><TrackPage /></Suspense>} />
<Route path="/history" element={<Suspense fallback={<PageLoading />}><HistoryPage /></Suspense>} />
<Route path="/access" element={<Suspense fallback={<PageLoading />}><AccessPage /></Suspense>} />
<Route path="/alerts/*" element={<Suspense fallback={<PageLoading />}><AlertsPage /></Suspense>} />
<Route path="/operations" element={<Suspense fallback={<PageLoading />}><OperationsPage /></Suspense>} />
<Route path="*" element={<Navigate to="/monitor" replace />} />
</Route>
</Routes>
</BrowserRouter></AuthGate>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,50 @@
import { useQuery } from '@tanstack/react-query';
import { createContext, FormEvent, ReactNode, useContext, useState } from 'react';
import { api } from '../../api/client';
import { clearAccessToken, getAccessToken, PlatformSession, setAccessToken } from './session';
type AuthContextValue = {
session: PlatformSession;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function usePlatformSession() {
const value = useContext(AuthContext);
if (!value) throw new Error('usePlatformSession must be used inside AuthGate');
return value;
}
export function AuthGate({ children }: { children: ReactNode }) {
const [tokenVersion, setTokenVersion] = useState(0);
const [draftToken, setDraftToken] = useState('');
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
const session = useQuery({
queryKey: ['platform-session', tokenVersion],
queryFn: api.session,
retry: false,
staleTime: Infinity
});
const login = (event: FormEvent) => {
event.preventDefault();
setAccessToken(draftToken);
setAttempted(true);
setTokenVersion((value) => value + 1);
};
const logout = () => {
clearAccessToken();
setDraftToken('');
setAttempted(false);
setTokenVersion((value) => value + 1);
};
if (session.isPending) {
return <div className="v2-auth-screen"><div className="v2-auth-card"><i className="v2-auth-spinner" /><strong>访</strong></div></div>;
}
if (!session.data) {
return <div className="v2-auth-screen"><form className="v2-auth-card" onSubmit={login}><div className="v2-auth-mark"></div><h1></h1><p>访</p><label><span>访</span><input autoFocus required type="password" autoComplete="current-password" value={draftToken} onChange={(event) => setDraftToken(event.target.value)} placeholder="Bearer token" /></label>{attempted && session.error ? <em>{session.error.message}</em> : null}<button type="submit" disabled={!draftToken.trim()}></button></form></div>;
}
return <AuthContext.Provider value={{ session: session.data, logout }}>{children}</AuthContext.Provider>;
}

View File

@@ -0,0 +1,22 @@
import { afterEach, expect, test } from 'vitest';
import { canAdminister, canOperate, clearAccessToken, getAccessToken, setAccessToken } from './session';
afterEach(() => {
window.sessionStorage.clear();
window.localStorage.clear();
});
test('access token is scoped to the browser session and can be cleared', () => {
setAccessToken(' secret-token ');
expect(getAccessToken()).toBe('secret-token');
expect(window.localStorage.length).toBe(0);
clearAccessToken();
expect(getAccessToken()).toBe('');
});
test('role helpers follow the server permission hierarchy', () => {
expect(canOperate({ name: 'v', role: 'viewer', authMode: 'enforce' })).toBe(false);
expect(canOperate({ name: 'o', role: 'operator', authMode: 'enforce' })).toBe(true);
expect(canAdminister({ name: 'o', role: 'operator', authMode: 'enforce' })).toBe(false);
expect(canAdminister({ name: 'a', role: 'admin', authMode: 'enforce' })).toBe(true);
});

View File

@@ -0,0 +1,31 @@
const TOKEN_KEY = 'vehicle-platform.access-token';
export type PlatformRole = 'viewer' | 'operator' | 'admin';
export interface PlatformSession {
name: string;
role: PlatformRole;
authMode: 'disabled' | 'enforce';
}
export function getAccessToken() {
return window.sessionStorage.getItem(TOKEN_KEY) ?? '';
}
export function setAccessToken(token: string) {
const normalized = token.trim();
if (normalized) window.sessionStorage.setItem(TOKEN_KEY, normalized);
else window.sessionStorage.removeItem(TOKEN_KEY);
}
export function clearAccessToken() {
window.sessionStorage.removeItem(TOKEN_KEY);
}
export function canOperate(session: PlatformSession) {
return session.role === 'operator' || session.role === 'admin';
}
export function canAdminister(session: PlatformSession) {
return session.role === 'admin';
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { accessRowsToCSV, formatSeconds, thresholdForProtocol, updateProtocolThreshold } from './access';
describe('access domain helpers', () => {
it('formats duration without hiding sign or long offline windows', () => {
expect(formatSeconds(45)).toBe('45 秒');
expect(formatSeconds(3720)).toBe('1 小时 2 分');
expect(formatSeconds(-3)).toBe('-3 秒');
expect(formatSeconds(null)).toBe('—');
});
it('uses protocol override and updates without duplicates', () => {
const config = { version: 1, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, updatedBy: '', updatedAt: '', audit: [], protocols: [{ protocol: 'JT808', thresholdSec: 60 }] };
expect(thresholdForProtocol(config, 'JT808')).toBe(60);
expect(thresholdForProtocol(config, 'GB32960')).toBe(300);
expect(updateProtocolThreshold(config.protocols, 'JT808', 120)).toEqual([{ protocol: 'JT808', thresholdSec: 120 }]);
});
it('exports explicit state and evidence fields', () => {
const csv = accessRowsToCSV([{ vin: 'VIN1', plate: '粤A1', oem: '', model: '', company: '示范企业', protocol: 'JT808', provider: '', source: '', firstSeenAt: '', latestEventAt: '', latestReceivedAt: '', reportIntervalSec: null, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, latestMessageType: '位置,数据', latestEventId: '', latestError: '', delayAbnormal: false, firstSeenEvidence: '', firstSeenSource: '', reportIntervalEvidence: '', reportSampleCount: 2 }]);
expect(csv).toContain('在线');
expect(csv).toContain('"位置,数据"');
expect(csv).toContain('"示范企业"');
});
});

View File

@@ -0,0 +1,45 @@
import type { AccessProtocolThreshold, AccessThresholdConfig, AccessVehicleRow } from '../../api/types';
export const accessStateLabels: Record<AccessVehicleRow['onlineState'], string> = {
online: '在线',
offline: '离线',
never_reported: '从未上报',
unknown: '未知'
};
export function formatSeconds(value: number | null | undefined) {
if (value === null || value === undefined || !Number.isFinite(value)) return '—';
const sign = value < 0 ? '-' : '';
const seconds = Math.abs(Math.round(value));
if (seconds < 60) return `${sign}${seconds}`;
if (seconds < 3600) return `${sign}${Math.floor(seconds / 60)}${seconds % 60}`;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${sign}${hours} 小时${minutes ? ` ${minutes}` : ''}`;
}
export function formatAccessTime(value: string) {
if (!value) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(parsed).replace(/\//g, '-');
}
export function thresholdForProtocol(config: AccessThresholdConfig, protocol: string) {
return config.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? config.defaultThresholdSec;
}
export function updateProtocolThreshold(items: AccessProtocolThreshold[], protocol: string, thresholdSec: number) {
const next = items.filter((item) => item.protocol !== protocol);
next.push({ protocol, thresholdSec });
return next.sort((a, b) => a.protocol.localeCompare(b.protocol));
}
export function accessRowsToCSV(rows: AccessVehicleRow[]) {
const columns = ['在线状态', '车牌', 'VIN', '厂家', '车型', '企业', '协议', '接入厂家', '首次接入', '首次接入证据', '最新事件时间', '最新接收时间', '上报间隔(秒)', '持久样本数', '上报间隔证据', '数据延迟(秒)', '动态阈值(秒)', '最新消息类型', '最近错误'];
const quote = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = rows.map((row) => [accessStateLabels[row.onlineState], row.plate, row.vin, row.oem, row.model, row.company, row.protocol, row.provider, row.firstSeenAt, row.firstSeenEvidence, row.latestEventAt, row.latestReceivedAt, row.reportIntervalSec, row.reportSampleCount, row.reportIntervalEvidence, row.dataDelaySec, row.thresholdSec, row.latestMessageType, row.latestError].map(quote).join(','));
return `\uFEFF${columns.map(quote).join(',')}\n${lines.join('\n')}`;
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { alertValue, canAct, ruleCondition, thresholdText } from './alert';
describe('alert domain helpers', () => {
it('keeps trigger evidence and duration explicit', () => {
const event = { triggerValue: 96, threshold: 80, thresholdHigh: 0, operator: 'gt', unit: 'km/h', durationSec: 60 };
expect(alertValue(event)).toBe('96 km/h');
expect(thresholdText(event)).toBe('> 80 km/h持续 60 秒');
});
it('enforces valid workflow transitions', () => {
expect(canAct('unprocessed', 'acknowledge')).toBe(true);
expect(canAct('processing', 'acknowledge')).toBe(false);
expect(canAct('recovered', 'close')).toBe(true);
expect(canAct('closed', 'ignore')).toBe(false);
});
it('renders boolean rules without numeric fiction', () => {
expect(ruleCondition({ valueType: 'boolean', booleanThreshold: true, metric: 'alarm_active', durationSec: 0 } as never)).toBe('协议告警位 是');
});
it('renders range and state-change semantics', () => {
expect(thresholdText({ threshold: 20, thresholdHigh: 80, operator: 'between', unit: '%', durationSec: 30 })).toBe('区间内 2080 %,持续 30 秒');
expect(ruleCondition({ valueType: 'boolean', metric: 'alarm_active', operator: 'changed', durationSec: 0 } as never)).toBe('协议告警位 状态变化');
});
});

View File

@@ -0,0 +1,36 @@
import type { AlertEvent, AlertRule, AlertSeverity, AlertStatus } from '../../api/types';
export const severityLabels: Record<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
export const statusLabels: Record<AlertStatus, string> = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
export const actionLabels: Record<string, string> = { trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
export const metricLabels: Record<string, string> = { speed_kmh: '速度', soc_percent: 'SOC', alarm_active: '协议告警位', freshness_sec: '离线时长', data_delay_sec: '数据延迟' };
export const operatorLabels: Record<string, string> = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠', between: '区间内', outside: '区间外', changed: '状态变化' };
export function formatAlertTime(value: string) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value.replace('T', ' ').slice(0, 19);
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date).replace(/\//g, '-');
}
export function alertValue(event: Pick<AlertEvent, 'triggerValue' | 'unit'>) {
return `${Number(event.triggerValue.toFixed(2)).toLocaleString('zh-CN')} ${event.unit}`.trim();
}
export function thresholdText(event: Pick<AlertEvent, 'operator' | 'threshold' | 'thresholdHigh' | 'unit' | 'durationSec'>) {
const duration = event.durationSec > 0 ? `,持续 ${event.durationSec}` : '';
if (event.operator === 'between' || event.operator === 'outside') return `${operatorLabels[event.operator]} ${event.threshold}${event.thresholdHigh} ${event.unit}${duration}`.trim();
if (event.operator === 'changed') return `状态发生变化${duration}`;
return `${operatorLabels[event.operator] ?? event.operator} ${Number(event.threshold.toFixed(2)).toLocaleString('zh-CN')} ${event.unit}${duration}`.trim();
}
export function ruleCondition(rule: AlertRule, labels: Record<string, string> = metricLabels) {
const threshold = rule.operator === 'changed' ? '状态变化' : rule.operator === 'between' || rule.operator === 'outside' ? `${operatorLabels[rule.operator]} ${rule.threshold}${rule.thresholdHigh}` : rule.valueType === 'boolean' ? (rule.booleanThreshold ? '是' : '否') : `${operatorLabels[rule.operator] ?? rule.operator} ${rule.threshold}`;
return `${labels[rule.metric] ?? rule.metric} ${threshold}${rule.durationSec ? ` · ${rule.durationSec}` : ''}`;
}
export function canAct(status: AlertStatus, action: 'acknowledge' | 'close' | 'ignore') {
if (action === 'acknowledge') return status === 'unprocessed';
if (action === 'close') return status === 'unprocessed' || status === 'processing' || status === 'recovered';
return status === 'unprocessed' || status === 'processing';
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { buildHistoryChartSeries, buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, parseHistoryKeywords } from './history';
describe('history domain', () => {
it('parses and bounds multi-vehicle input', () => {
expect(parseHistoryKeywords('粤A1, 粤A1;VIN2\nVIN3')).toEqual(['粤A1', 'VIN2', 'VIN3']);
expect(parseHistoryKeywords('1,2,3,4,5,6')).toHaveLength(5);
});
it('formats units without fabricating missing values', () => {
expect(formatHistoryValue(undefined)).toBe('—');
expect(formatHistoryValue(42.5, { unit: 'km/h' } as never)).toBe('42.5 km/h');
});
it('builds only numeric series with at least two evidence points', () => {
const rows = [
{ values: { speedKmh: 20 }, deviceTime: '2' },
{ values: { speedKmh: 10 }, deviceTime: '1' }
] as never;
const series = buildHistoryChartSeries(rows, [{ key: 'speedKmh', label: '速度', unit: 'km/h' }] as never);
expect(series).toHaveLength(1);
expect(series[0].path).toContain('M');
});
it('builds unit-separated server aggregate panels and breaks lines across missing buckets', () => {
const response = {
dateFrom: '2026-07-13T16:00:00Z', dateTo: '2026-07-13T17:00:00Z',
summary: { grainSeconds: 60 },
series: [
{ vin: 'VIN1', plate: '粤A1', protocol: 'GB32960', metric: 'speedKmh', label: '速度', unit: 'km/h', points: [{ time: '2026-07-13T16:00:00Z', value: 10 }, { time: '2026-07-13T16:01:00Z', value: 20 }, { time: '2026-07-13T16:10:00Z', value: 30 }] },
{ vin: 'VIN1', plate: '粤A1', protocol: 'GB32960', metric: 'totalMileageKm', label: '总里程', unit: 'km', points: [{ time: '2026-07-13T16:00:00Z', value: 100 }, { time: '2026-07-13T16:01:00Z', value: 101 }] }
]
} as never;
const panels = buildHistorySeriesPanels(response);
expect(panels.map((panel) => panel.unit)).toEqual(['km/h', 'km']);
expect(panels[0].lines[0].paths).toHaveLength(2);
});
it('formats persisted export file sizes compactly', () => {
expect(formatExportFileSize(0)).toBe('—');
expect(formatExportFileSize(1536)).toBe('1.5 KB');
expect(formatExportFileSize(5 * 1024 * 1024)).toBe('5.0 MB');
});
});

View File

@@ -0,0 +1,87 @@
import type { HistoryDataRow, HistoryMetricDefinition, HistorySeries, HistorySeriesResponse } from '../../api/types';
export function parseHistoryKeywords(value: string) {
const seen = new Set<string>();
return value.split(/[,;\n]/).map((item) => item.trim()).filter((item) => {
const key = item.toLowerCase();
if (!item || seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, 5);
}
export function formatHistoryValue(value: unknown, metric?: HistoryMetricDefinition) {
if (value == null || value === '') return '—';
const formatted = typeof value === 'number' ? new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 6 }).format(value) : String(value);
return metric?.unit ? `${formatted} ${metric.unit}` : formatted;
}
export type ChartSeries = { key: string; label: string; color: string; path: string; points: number };
const chartColors = ['#1268f3', '#12a46f', '#8b5cf6', '#f59e0b'];
export function buildHistoryChartSeries(rows: HistoryDataRow[], metrics: HistoryMetricDefinition[], width = 800, height = 150): ChartSeries[] {
const ordered = [...rows].reverse();
return metrics.slice(0, 4).flatMap((metric, seriesIndex) => {
const values = ordered.map((row, rowIndex) => ({ rowIndex, value: row.values[metric.key] })).filter((item): item is { rowIndex: number; value: number } => typeof item.value === 'number' && Number.isFinite(item.value));
if (values.length < 2) return [];
let min = values[0].value;
let max = values[0].value;
for (const item of values) { if (item.value < min) min = item.value; if (item.value > max) max = item.value; }
const range = max - min || 1;
const usableWidth = width - 24;
const usableHeight = height - 24;
const path = values.map((item, pointIndex) => {
const x = 12 + (ordered.length <= 1 ? 0 : item.rowIndex / (ordered.length - 1)) * usableWidth;
const y = 12 + (1 - (item.value - min) / range) * usableHeight;
return `${pointIndex ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return [{ key: metric.key, label: metric.unit ? `${metric.label} (${metric.unit})` : metric.label, color: chartColors[seriesIndex], path, points: values.length }];
});
}
export type HistorySeriesLine = { key: string; label: string; color: string; paths: string[]; points: number };
export type HistorySeriesPanel = { key: string; label: string; unit: string; minimum: number; maximum: number; start: string; end: string; lines: HistorySeriesLine[] };
export function buildHistorySeriesPanels(response?: HistorySeriesResponse, width = 800, height = 116): HistorySeriesPanel[] {
if (!response) return [];
const byMetric = new Map<string, HistorySeries[]>();
response.series.forEach((series) => byMetric.set(series.metric, [...(byMetric.get(series.metric) ?? []), series]));
return [...byMetric.entries()].flatMap(([metric, seriesList], panelIndex) => {
const values = seriesList.flatMap((series) => series.points.map((point) => point.value).filter((value): value is number => typeof value === 'number' && Number.isFinite(value)));
if (!values.length) return [];
let minimum = Math.min(...values); let maximum = Math.max(...values);
if (minimum === maximum) { const padding = Math.max(Math.abs(minimum) * 0.05, 1); minimum -= padding; maximum += padding; }
const startMs = new Date(response.dateFrom).getTime(); const endMs = new Date(response.dateTo).getTime();
const timeRange = Math.max(1, endMs - startMs); const valueRange = maximum - minimum;
const lines = seriesList.map((series, seriesIndex) => {
const paths: string[] = []; let current = ''; let previousMs: number | undefined;
series.points.forEach((point) => {
if (typeof point.value !== 'number' || !Number.isFinite(point.value)) { if (current) paths.push(current); current = ''; previousMs = undefined; return; }
const time = new Date(point.time.replace(' ', 'T')).getTime();
if (!Number.isFinite(time)) return;
if (previousMs != null && time - previousMs > response.summary.grainSeconds * 1500) { if (current) paths.push(current); current = ''; }
const x = 54 + Math.max(0, Math.min(1, (time - startMs) / timeRange)) * (width - 68);
const y = 10 + (1 - (point.value - minimum) / valueRange) * (height - 30);
current += `${current ? ' L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`; previousMs = time;
});
if (current) paths.push(current);
return { key: `${series.vin}-${series.protocol}-${metric}`, label: `${series.plate || series.vin} · ${series.protocol}`, color: chartColors[(panelIndex * 2 + seriesIndex) % chartColors.length], paths, points: series.points.length };
});
const first = seriesList[0];
return [{ key: metric, label: first.label, unit: first.unit, minimum, maximum, start: response.dateFrom, end: response.dateTo, lines }];
});
}
export function formatSeriesGrain(seconds: number) {
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${seconds / 60} 分钟`;
if (seconds < 86400) return `${seconds / 3600} 小时`;
return `${seconds / 86400}`;
}
export function formatExportFileSize(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,40 @@
import type { VehicleRealtimeRow } from '../../api/types';
import { describe, expect, it } from 'vitest';
import { formatNumber, statusLabel, vehicleStatus } from './monitor';
function vehicle(overrides: Partial<VehicleRealtimeRow> = {}): VehicleRealtimeRow {
return {
vin: 'LTEST000000000001',
plate: '粤A00001',
phone: '',
oem: '',
protocols: ['JT808'],
sourceStatus: [],
sourceCount: 1,
onlineSourceCount: 1,
online: true,
bindingStatus: 'bound',
primaryProtocol: 'JT808',
longitude: 113.2,
latitude: 23.1,
speedKmh: 0,
socPercent: 80,
totalMileageKm: 10,
lastSeen: '2026-07-14 01:00:00',
...overrides
};
}
describe('monitor domain', () => {
it('keeps online, motion and unknown status semantics separate', () => {
expect(vehicleStatus(vehicle({ online: false }))).toBe('offline');
expect(vehicleStatus(vehicle({ speedKmh: 32 }))).toBe('driving');
expect(vehicleStatus(vehicle({ speedKmh: 0 }))).toBe('idle');
expect(vehicleStatus(vehicle({ lastSeen: '' }))).toBe('unknown');
});
it('formats dense monitor values consistently', () => {
expect(formatNumber(12560)).toBe('12,560');
expect(statusLabel('driving')).toBe('行驶');
});
});

View File

@@ -0,0 +1,35 @@
import type { VehicleRealtimeRow } from '../../api/types';
export type FleetStatus = 'online' | 'offline' | 'driving' | 'idle' | 'alert' | 'unknown';
export function vehicleStatus(vehicle: VehicleRealtimeRow): FleetStatus {
if (!vehicle.lastSeen) return 'unknown';
if (!vehicle.online) return 'offline';
if (vehicle.speedKmh > 3) return 'driving';
return 'idle';
}
export function statusLabel(status: FleetStatus) {
return {
online: '在线',
offline: '离线',
driving: '行驶',
idle: '静止',
alert: '告警',
unknown: '未知'
}[status];
}
export function relativeFreshness(value: string) {
const time = Date.parse(value);
if (!Number.isFinite(time)) return '时间未知';
const seconds = Math.max(0, Math.round((Date.now() - time) / 1000));
if (seconds < 60) return `${seconds} 秒前`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`;
return `${Math.floor(seconds / 86400)} 天前`;
}
export function formatNumber(value: number, maximumFractionDigits = 0) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value);
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from './profileSync';
describe('parseVehicleProfileSyncCSV', () => {
it('parses quoted values, BOM, CRLF and normalizes VIN/status', () => {
const rows = parseVehicleProfileSyncCSV(`\uFEFF${vehicleProfileSyncCSVHeader}\r\nvin001,"车型,一",重卡,示范物流,ACTIVE,车厂平台,2026-07-01T08:30:00+08:00,3600\r\n`);
expect(rows).toEqual([expect.objectContaining({ vin: 'VIN001', modelName: '车型,一', operationStatus: 'active', runtimeSeconds: 3600 })]);
});
it('rejects duplicate VINs and malformed source rows before upload', () => {
const duplicate = `${vehicleProfileSyncCSVHeader}\nVIN001,,,,unknown,,,\nvin001,,,,unknown,,,`;
expect(() => parseVehicleProfileSyncCSV(duplicate)).toThrow(/VIN 重复/);
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,,,,active,,,1.5`)).toThrow(/累计运行秒数/);
expect(() => parseVehicleProfileSyncCSV('vin,modelName\nVIN001,车型')).toThrow(/表头/);
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,"车型"x,,,active,,,`)).toThrow(/引号结束/);
});
});

View File

@@ -0,0 +1,70 @@
import type { VehicleProfileSyncItem } from '../../api/types';
const headers = ['vin', 'modelName', 'vehicleType', 'companyName', 'operationStatus', 'accessProvider', 'firstAccessAt', 'runtimeSeconds'] as const;
const allowedStatuses = new Set(['', 'unknown', 'active', 'inactive', 'maintenance', 'retired']);
export const vehicleProfileSyncCSVHeader = headers.join(',');
export function parseVehicleProfileSyncCSV(text: string): VehicleProfileSyncItem[] {
const rows = parseCSVRows(text.replace(/^\uFEFF/, ''));
if (rows.length < 2) throw new Error('CSV 至少需要表头和一行车辆数据');
const actualHeaders = rows[0].map((value) => value.trim());
if (actualHeaders.length !== headers.length || actualHeaders.some((value, index) => value !== headers[index])) {
throw new Error(`CSV 表头必须为:${vehicleProfileSyncCSVHeader}`);
}
const dataRows = rows.slice(1).filter((row) => row.some((value) => value.trim() !== ''));
if (dataRows.length === 0 || dataRows.length > 500) throw new Error('单个 CSV 必须包含 1 至 500 辆车');
const seen = new Set<string>();
return dataRows.map((row, index) => {
const line = index + 2;
if (row.length !== headers.length) throw new Error(`CSV 第 ${line} 行列数不正确`);
const [rawVIN, modelName, vehicleType, companyName, rawStatus, accessProvider, firstAccessAt, rawRuntime] = row.map((value) => value.trim());
const vin = rawVIN.toUpperCase();
if (!vin || vin.length > 32) throw new Error(`CSV 第 ${line} 行 VIN 无效`);
if (seen.has(vin)) throw new Error(`CSV 第 ${line} 行 VIN 重复:${vin}`);
seen.add(vin);
const operationStatus = rawStatus.toLowerCase();
if (!allowedStatuses.has(operationStatus)) throw new Error(`CSV 第 ${line} 行运营状态无效`);
const runtimeSeconds = rawRuntime === '' ? null : Number(rawRuntime);
if (runtimeSeconds !== null && (!Number.isSafeInteger(runtimeSeconds) || runtimeSeconds < 0)) throw new Error(`CSV 第 ${line} 行累计运行秒数无效`);
return {
vin, modelName, vehicleType, companyName,
operationStatus: (operationStatus || 'unknown') as VehicleProfileSyncItem['operationStatus'],
accessProvider, firstAccessAt, runtimeSeconds
};
});
}
function parseCSVRows(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let quoted = false;
let closedQuote = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (char === '"') {
if (text[index + 1] === '"') { field += '"'; index += 1; } else { quoted = false; closedQuote = true; }
} else {
field += char;
}
continue;
}
if (closedQuote && char !== ',' && char !== '\n' && char !== '\r') throw new Error('CSV 引号结束后存在无效字符');
if (char === '"') {
if (field !== '') throw new Error('CSV 引号格式无效');
quoted = true;
} else if (char === ',') {
row.push(field); field = ''; closedQuote = false;
} else if (char === '\n' || char === '\r') {
if (char === '\r' && text[index + 1] === '\n') index += 1;
row.push(field); rows.push(row); row = []; field = ''; closedQuote = false;
} else {
field += char;
}
}
if (quoted) throw new Error('CSV 存在未闭合的引号');
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
return rows;
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from './telemetry';
describe('latest telemetry presentation', () => {
it('formats server-authoritative scalar values without inferring metadata', () => {
expect(formatTelemetryValue(42.567)).toBe('42.57');
expect(formatTelemetryValue(true)).toBe('是');
expect(formatTelemetryValue(null)).toBe('—');
});
it('translates server quality states', () => {
expect(telemetryQualityLabel('good')).toBe('正常');
expect(telemetryQualityLabel('stale')).toBe('陈旧');
expect(telemetryQualityLabel('warning')).toBe('异常');
});
it('keeps telemetry timestamps compact for local and RFC3339 values', () => {
expect(formatTelemetryTime('2026-07-14T09:24:34+08:00')).toBe('09:24:34');
expect(formatTelemetryTime('2026-07-14 09:24:34')).toBe('09:24:34');
});
});

View File

@@ -0,0 +1,19 @@
export function formatTelemetryValue(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(value);
}
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'string') return value || '—';
return value == null ? '—' : String(value);
}
export function telemetryQualityLabel(quality: string) {
if (quality === 'good') return '正常';
if (quality === 'stale') return '陈旧';
return '异常';
}
export function formatTelemetryTime(value?: string) {
if (!value) return '—';
return value.match(/[T ](\d{2}:\d{2}:\d{2})/)?.[1] ?? value;
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import type { TrackPlaybackResponse } from '../../api/types';
import { formatDuration, sampledEventIndex, trackCsv } from './track';
describe('track domain', () => {
it('formats durations and maps original event indices to sampled points', () => {
expect(formatDuration(3671)).toBe('01:01:11');
expect(sampledEventIndex({ index: 50 } as never, 11, 101)).toBe(5);
expect(sampledEventIndex({ index: 50, sampledIndex: 7 } as never, 11, 101)).toBe(7);
});
it('exports the current result with UTF-8 BOM and escaped values', () => {
const track = {
plate: '粤A,001', vin: 'VIN', summary: { startTime: '2026-07-03 10:00:00' },
points: [{ vin: 'VIN', plate: '粤A,001', protocol: 'JT808', deviceTime: '2026-07-03 10:00:00', serverTime: '2026-07-03 10:00:01', longitude: 113.1, latitude: 23.1, speedKmh: 10, totalMileageKm: 100 }]
} as TrackPlaybackResponse;
const csv = trackCsv(track);
expect(csv.startsWith('\uFEFFVIN,')).toBe(true);
expect(csv).toContain('"粤A,001"');
});
});

View File

@@ -0,0 +1,42 @@
import type { HistoryLocationRow, TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types';
export function formatDuration(seconds: number) {
const safe = Math.max(0, Math.floor(seconds || 0));
const hours = Math.floor(safe / 3600);
const minutes = Math.floor((safe % 3600) / 60);
const remainder = safe % 60;
return [hours, minutes, remainder].map((value) => String(value).padStart(2, '0')).join(':');
}
export function sampledEventIndex(event: TrackPlaybackEvent, sampledCount: number, originalCount: number) {
if (sampledCount <= 1 || originalCount <= 1) return 0;
if (Number.isInteger(event.sampledIndex) && event.sampledIndex >= 0) {
return Math.min(sampledCount - 1, event.sampledIndex);
}
return Math.max(0, Math.min(sampledCount - 1, Math.round(event.index * (sampledCount - 1) / (originalCount - 1))));
}
function csvCell(value: unknown) {
const text = String(value ?? '');
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
export function trackCsv(track: TrackPlaybackResponse) {
const headers = ['VIN', '车牌', '协议', '设备时间', '服务时间', '经度', '纬度', '速度(km/h)', '总里程(km)'];
const rows = track.points.map((point) => [point.vin, point.plate, point.protocol, point.deviceTime, point.serverTime, point.longitude, point.latitude, point.speedKmh, point.totalMileageKm]);
return `\uFEFF${[headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\n')}`;
}
export function downloadTrackCsv(track: TrackPlaybackResponse) {
const blob = new Blob([trackCsv(track)], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `track-${track.plate || track.vin}-${track.summary.startTime.slice(0, 10) || 'latest'}.csv`;
link.click();
URL.revokeObjectURL(url);
}
export function validTrackPoints(points: HistoryLocationRow[]) {
return points.filter((point) => Number.isFinite(point.longitude) && Number.isFinite(point.latitude) && point.longitude >= 73 && point.longitude <= 135 && point.latitude >= 18 && point.latitude <= 54);
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { MONITOR_REFRESH, monitorMapQueryParams, monitorQueryParams } from './useMonitorData';
describe('monitor query params', () => {
it('keeps server-owned status filters and bounded list size', () => {
const params = monitorQueryParams({ keyword: ' 沪A ', protocol: 'JT808', status: 'driving' }, 200);
expect(params.get('keyword')).toBe('沪A');
expect(params.get('protocol')).toBe('JT808');
expect(params.get('status')).toBe('driving');
expect(params.get('online')).toBeNull();
expect(params.get('limit')).toBe('200');
});
it('retains compatibility online filter for online and offline states', () => {
expect(monitorQueryParams({ keyword: '', protocol: '', status: 'offline' }, 200).get('online')).toBe('offline');
});
it('drops stale viewport bounds for a direct vehicle search', () => {
const viewport = { zoom: 13, bounds: '103,29,105,31' };
expect(monitorMapQueryParams({ keyword: '粤A1', protocol: '', status: '' }, viewport).has('bounds')).toBe(false);
expect(monitorMapQueryParams({ keyword: '', protocol: '', status: '' }, viewport).get('bounds')).toBe(viewport.bounds);
});
});
describe('monitor refresh cadence', () => {
it('prioritizes one selected vehicle without polling the whole fleet too aggressively', () => {
expect(MONITOR_REFRESH).toEqual({ summary: 30_000, fleet: 15_000, selected: 10_000, alerts: 15_000 });
expect(MONITOR_REFRESH.selected).toBeLessThan(MONITOR_REFRESH.fleet);
expect(MONITOR_REFRESH.fleet).toBeLessThan(MONITOR_REFRESH.summary);
});
});

View File

@@ -0,0 +1,104 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { VehicleRealtimeRow } from '../../api/types';
export type MonitorFilters = {
keyword: string;
protocol: string;
status: string;
};
export type MonitorViewport = {
zoom: number;
bounds: string;
};
export const MONITOR_REFRESH = {
summary: 30_000,
fleet: 15_000,
selected: 10_000,
alerts: 15_000
} as const;
export function monitorQueryParams(filters: MonitorFilters, limit: number) {
const params = new URLSearchParams({ limit: String(limit) });
if (filters.keyword.trim()) params.set('keyword', filters.keyword.trim());
if (filters.protocol) params.set('protocol', filters.protocol);
if (filters.status) params.set('status', filters.status);
if (filters.status === 'online' || filters.status === 'offline') params.set('online', filters.status);
return params;
}
export function monitorMapQueryParams(filters: MonitorFilters, viewport: MonitorViewport) {
const params = monitorQueryParams(filters, 10_000);
params.set('zoom', String(viewport.zoom));
if (viewport.bounds && !filters.keyword.trim()) params.set('bounds', viewport.bounds);
return params;
}
export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewport, selectedVin: string) {
const params = monitorQueryParams(filters, 200);
const mapParams = monitorMapQueryParams(filters, viewport);
const summary = useQuery({
queryKey: ['monitor', 'summary', params.toString()],
queryFn: () => api.monitorSummary(params),
refetchInterval: MONITOR_REFRESH.summary
});
const vehicles = useQuery({
queryKey: ['monitor', 'vehicles', params.toString()],
queryFn: () => api.vehicleRealtime(params),
refetchInterval: MONITOR_REFRESH.fleet
});
const map = useQuery({
queryKey: ['monitor', 'map', mapParams.toString()],
queryFn: () => api.monitorMap(mapParams),
placeholderData: (previous) => previous,
staleTime: 5_000,
refetchInterval: MONITOR_REFRESH.fleet
});
const selectedVehicle = useQuery({
queryKey: ['monitor', 'selected-vehicle', selectedVin],
queryFn: () => api.vehicleRealtime(new URLSearchParams({ keyword: selectedVin, limit: '1', offset: '0' })),
enabled: Boolean(selectedVin),
staleTime: 5_000,
refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false
});
return { summary, vehicles, map, selectedVehicle };
}
export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow, activelyTracked = false) {
const enabled = Boolean(vin);
const longitude = vehicle?.longitude;
const latitude = vehicle?.latitude;
const hasCoordinate = Number.isFinite(longitude) && Number.isFinite(latitude)
&& Math.abs(longitude ?? 0) <= 180 && Math.abs(latitude ?? 0) <= 90;
const detail = useQuery({
queryKey: ['monitor', 'vehicle-card', 'detail', vin],
queryFn: () => api.vehicleDetail(new URLSearchParams({ keyword: vin })),
enabled,
staleTime: 30_000
});
const activeAlerts = useQuery({
queryKey: ['monitor', 'vehicle-card', 'active-alerts', vin],
queryFn: () => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }),
enabled,
staleTime: 10_000,
refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false
});
const address = useQuery({
queryKey: ['monitor', 'vehicle-card', 'address', longitude, latitude],
queryFn: () => api.reverseGeocode(new URLSearchParams({
longitude: longitude!.toFixed(6),
latitude: latitude!.toFixed(6)
})),
enabled: enabled && hasCoordinate,
staleTime: 60 * 60_000
});
return { detail, activeAlerts, address };
}

View File

@@ -0,0 +1,88 @@
import {
IconAlarm,
IconBarChartHStroked,
IconBox,
IconChevronLeft,
IconHelpCircle,
IconHome,
IconMapPin,
IconSearch,
IconSetting,
IconUser,
IconExit
} from '@douyinfe/semi-icons';
import { useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { usePlatformSession } from '../auth/AuthGate';
const navigation = [
{ to: '/monitor', label: '全局监控', icon: IconHome },
{ to: '/vehicles', label: '车辆查询', icon: IconSearch },
{ to: '/tracks', label: '轨迹回放', icon: IconMapPin },
{ to: '/history', label: '历史数据', icon: IconBarChartHStroked },
{ to: '/alerts', label: '告警中心', icon: IconAlarm },
{ to: '/access', label: '接入管理', icon: IconBox }
];
const pageNames: Record<string, string> = {
monitor: '全局监控',
vehicles: '车辆查询',
tracks: '轨迹回放',
history: '历史数据',
alerts: '告警中心',
access: '接入管理',
operations: '运维质量'
};
export function AppShell() {
const location = useLocation();
const section = location.pathname.split('/')[1] || 'monitor';
const { session, logout } = usePlatformSession();
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员' }[session.role];
return (
<div className="v2-shell">
<Sidebar />
<div className="v2-main">
<header className="v2-topbar">
<h1>{pageNames[section] ?? '车辆数据中台'}</h1>
<div className="v2-topbar-actions">
<button type="button" aria-label="帮助"><IconHelpCircle /></button>
<span className="v2-current-user"><IconUser /><b>{session.name}</b><small>{roleLabel}</small></span>
<button type="button" aria-label="退出登录" title="退出登录" onClick={logout}><IconExit /></button>
</div>
</header>
<main className="v2-content"><Outlet /></main>
</div>
</div>
);
}
function Sidebar() {
const [collapsed, setCollapsed] = useState(false);
return (
<aside className={`v2-sidebar${collapsed ? ' is-collapsed' : ''}`}>
<div className="v2-brand">
<span className="v2-brand-mark"><IconBox size="large" /></span>
<strong></strong>
</div>
<nav className="v2-navigation" aria-label="主导航">
{navigation.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => `v2-nav-item ${isActive ? 'is-active' : ''}`}>
<Icon size="large" />
<span className="v2-nav-label">{label}</span>
</NavLink>
))}
</nav>
<NavLink to="/operations" className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}>
<IconSetting size="large" />
<span className="v2-nav-label"></span>
</NavLink>
<button className="v2-collapse" type="button" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
<IconChevronLeft />
<span></span>
</button>
</aside>
);
}

View File

@@ -0,0 +1,351 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import type { MonitorMapResponse } from '../../api/types';
import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap';
import { FleetMap } from './FleetMap';
const setData = vi.fn<(data: AMapMassPoint[]) => void>();
const setStyle = vi.fn();
const addLabels = vi.fn();
const clearLabels = vi.fn();
const setLabelsMap = vi.fn();
const markerSetMap = vi.fn();
const markerSetPosition = vi.fn();
const setZoomAndCenter = vi.fn();
const panTo = vi.fn();
const getZoom = vi.fn(() => 5);
const getBounds = vi.fn((): ReturnType<NonNullable<AMapMap['getBounds']>> => ({}));
const mapHandlers = new Map<string, (event: unknown) => void>();
const markerOptions: Record<string, unknown>[] = [];
const mapOptions: Record<string, unknown>[] = [];
const labelLayerOptions: Record<string, unknown>[] = [];
class TestMap {
constructor(_container: HTMLDivElement, options: Record<string, unknown>) {
mapOptions.push(options);
}
add = vi.fn();
addControl = vi.fn();
destroy = vi.fn();
on = vi.fn((event: string, handler: (value: unknown) => void) => mapHandlers.set(event, handler));
getZoom = getZoom;
getBounds = getBounds;
setZoomAndCenter = setZoomAndCenter;
panTo = panTo;
}
class TestMassMarks {
on = vi.fn();
setMap = vi.fn();
setData = setData;
setStyle = setStyle;
}
class TestScale {}
class TestToolBar {}
class TestSize {}
class TestPixel {}
class TestLabelsLayer {
constructor(options: Record<string, unknown> = {}) {
labelLayerOptions.push(options);
}
add = addLabels;
clear = clearLabels;
setMap = setLabelsMap;
}
class TestLabelMarker {
constructor(public options: Record<string, unknown>) {}
}
class TestMarker {
setMap = markerSetMap;
setPosition = markerSetPosition;
constructor(options: Record<string, unknown>) {
markerOptions.push(options);
}
}
const monitorMap: MonitorMapResponse = {
mode: 'clusters',
zoom: 5,
total: 12,
truncated: false,
points: [],
clusters: [{
id: 'cluster-1',
longitude: 121.1,
latitude: 30.6,
count: 12,
online: 8,
offline: 4,
driving: 3,
idle: 5,
unknown: 0
}],
asOf: '2026-07-14T01:00:00Z'
};
const pointMap: MonitorMapResponse = {
...monitorMap,
mode: 'points',
zoom: 13,
total: 2,
clusters: [],
points: [{
vin: 'LTEST000000000001',
plate: '粤A12345',
protocol: 'JT808',
protocols: ['JT808'],
longitude: 113.26,
latitude: 23.13,
speedKmh: 42,
socPercent: 80,
totalMileageKm: 1234,
lastSeen: '2026-07-14T01:00:00Z',
status: 'driving'
}, {
vin: 'LTEST000000000002',
plate: '粤B67890',
protocol: 'JT808',
protocols: ['JT808'],
longitude: 113.28,
latitude: 23.15,
speedKmh: 0,
socPercent: 72,
totalMileageKm: 2234,
lastSeen: '2026-07-14T01:00:00Z',
status: 'idle'
}]
};
function amapMock(): AMapLike {
return {
Map: TestMap as unknown as AMapLike['Map'],
Marker: TestMarker as unknown as AMapLike['Marker'],
Polyline: class {} as unknown as AMapLike['Polyline'],
Scale: TestScale,
ToolBar: TestToolBar,
Size: TestSize as unknown as AMapLike['Size'],
Pixel: TestPixel as unknown as AMapLike['Pixel'],
MassMarks: TestMassMarks as unknown as AMapLike['MassMarks'],
LabelsLayer: TestLabelsLayer as unknown as NonNullable<AMapLike['LabelsLayer']>,
LabelMarker: TestLabelMarker as unknown as NonNullable<AMapLike['LabelMarker']>
};
}
afterEach(() => {
cleanup();
delete window.__LINGNIU_APP_CONFIG__;
delete window.AMapLoader;
setData.mockReset();
setStyle.mockReset();
addLabels.mockReset();
clearLabels.mockReset();
setLabelsMap.mockReset();
markerSetMap.mockReset();
markerSetPosition.mockReset();
setZoomAndCenter.mockReset();
panTo.mockReset();
getZoom.mockReset();
getZoom.mockReturnValue(5);
getBounds.mockReset();
getBounds.mockReturnValue({});
mapHandlers.clear();
markerOptions.length = 0;
mapOptions.length = 0;
labelLayerOptions.length = 0;
vi.restoreAllMocks();
});
test('renders data that arrives before the delayed AMap SDK is ready', async () => {
let resolveAMap!: (value: AMapLike) => void;
const delayedAMap = new Promise<AMapLike>((resolve) => {
resolveAMap = resolve;
});
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(() => delayedAMap) };
render(
<FleetMap
vehicles={[]}
monitorMap={monitorMap}
onSelect={() => undefined}
/>
);
expect(setData).not.toHaveBeenCalled();
await act(async () => {
resolveAMap(amapMock());
await delayedAMap;
});
await waitFor(() => expect(setData).toHaveBeenCalledWith([
expect.objectContaining({ id: 'cluster-1', lnglat: wgs84ToGcj02(121.1, 30.6), label: '12 辆' })
]));
const clusterStyles = setStyle.mock.calls[setStyle.mock.calls.length - 1]?.[0] as Array<{ url: string }>;
expect(decodeURIComponent(clusterStyles[5].url)).toContain('>12</text>');
expect(decodeURIComponent(clusterStyles[5].url)).not.toContain('10+');
});
test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data', async () => {
const [west, south] = wgs84ToGcj02(113, 22);
const [east, north] = wgs84ToGcj02(114, 24);
getZoom.mockReturnValue(13);
getBounds.mockReturnValue({
getSouthWest: () => ({ getLng: () => west, getLat: () => south }),
getNorthEast: () => ({ getLng: () => east, getLat: () => north })
});
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const onViewportChange = vi.fn();
render(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} onViewportChange={onViewportChange} />);
await waitFor(() => expect(onViewportChange).toHaveBeenCalled());
const viewport = onViewportChange.mock.calls[onViewportChange.mock.calls.length - 1]?.[0] as { zoom: number; bounds: string };
expect(viewport.zoom).toBe(13);
const bounds = viewport.bounds.split(',').map(Number);
expect(Math.abs(bounds[0] - 113)).toBeLessThan(0.0005);
expect(Math.abs(bounds[1] - 22)).toBeLessThan(0.0005);
expect(Math.abs(bounds[2] - 114)).toBeLessThan(0.0005);
expect(Math.abs(bounds[3] - 24)).toBeLessThan(0.0005);
});
test('renders one selected plate and smoothly follows it until the map is dragged', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const view = render(
<FleetMap
vehicles={[]}
monitorMap={pointMap}
selectedVin="LTEST000000000001"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(addLabels).toHaveBeenCalled());
const renderedLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
const selectedPlate = renderedLabels.find((marker) => (marker.options.text as { content: string }).content === '粤A12345');
const floatingPlate = renderedLabels.find((marker) => (marker.options.text as { content: string }).content === '粤B67890');
expect(selectedPlate).toBeDefined();
expect(floatingPlate).toBeDefined();
expect((selectedPlate!.options.text as { style: unknown }).style).toEqual((floatingPlate!.options.text as { style: unknown }).style);
expect(selectedPlate!.options).toEqual(expect.objectContaining({ rank: 100, zIndex: 10 }));
expect(floatingPlate!.options).toEqual(expect.objectContaining({ rank: 1, zIndex: 1 }));
expect((floatingPlate!.options.text as { style: Record<string, unknown> }).style).toEqual(expect.objectContaining({
fillColor: '#174d9f',
backgroundColor: '#eef5ff',
borderColor: '#7fb0fa',
borderWidth: 1,
borderRadius: 6,
padding: [5, 9],
fontSize: 11,
shadowColor: 'rgba(18, 104, 243, 0.18)',
shadowBlur: 14,
shadowOffsetY: 5
}));
expect(mapOptions).toContainEqual(expect.objectContaining({ mapStyle: 'amap://styles/whitesmoke' }));
await waitFor(() => expect(markerOptions).toContainEqual(expect.objectContaining({
content: expect.stringContaining('粤A12345')
})));
expect(markerOptions[markerOptions.length - 1]?.content).not.toContain('<span>');
expect(markerSetMap).toHaveBeenCalled();
expect(renderedLabels).toHaveLength(2);
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
expect(setZoomAndCenter).toHaveBeenLastCalledWith(15, wgs84ToGcj02(113.26, 23.13));
view.rerender(
<FleetMap
vehicles={[]}
monitorMap={{
...pointMap,
asOf: '2026-07-14T01:00:15Z',
points: [{ ...pointMap.points[0], longitude: 113.27, latitude: 23.14 }]
}}
selectedVin="LTEST000000000001"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(markerSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14)));
expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14), 650);
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
const follow = screen.getByRole('button', { name: '跟随车辆' });
expect(follow).toHaveAttribute('aria-pressed', 'true');
act(() => mapHandlers.get('dragstart')?.({}));
expect(follow).toHaveAttribute('aria-pressed', 'false');
const panCountAfterDrag = panTo.mock.calls.length;
view.rerender(
<FleetMap
vehicles={[]}
monitorMap={{
...pointMap,
asOf: '2026-07-14T01:00:30Z',
points: [{ ...pointMap.points[0], longitude: 113.29, latitude: 23.16 }, pointMap.points[1]]
}}
selectedVin="LTEST000000000001"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(markerSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.29, 23.16)));
expect(panTo).toHaveBeenCalledTimes(panCountAfterDrag);
fireEvent.click(follow);
await waitFor(() => expect(follow).toHaveAttribute('aria-pressed', 'true'));
expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.29, 23.16), 650);
getZoom.mockReturnValue(20);
view.rerender(
<FleetMap
vehicles={[]}
monitorMap={{ ...pointMap, asOf: '2026-07-14T01:00:45Z' }}
selectedVin="LTEST000000000002"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.28, 23.15), 650));
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
const toggle = screen.getByRole('button', { name: '悬浮车牌' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(toggle);
await waitFor(() => expect(toggle).toHaveAttribute('aria-pressed', 'false'));
expect(clearLabels).toHaveBeenCalled();
const selectedOnlyLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(selectedOnlyLabels).toHaveLength(1);
expect((selectedOnlyLabels[0].options.text as { content: string }).content).toBe('粤B67890');
});
test('renders every nearby plate with staggered positions at maximum zoom', async () => {
getZoom.mockReturnValue(20);
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const crowdedMap: MonitorMapResponse = {
...pointMap,
zoom: 20,
total: 3,
points: [
pointMap.points[0],
{ ...pointMap.points[1], longitude: 113.26001, latitude: 23.13001 },
{ ...pointMap.points[1], vin: 'LTEST000000000003', plate: '粤C24680', longitude: 113.26002, latitude: 23.13002 }
]
};
const view = render(<FleetMap vehicles={[]} monitorMap={crowdedMap} onSelect={() => undefined} />);
await waitFor(() => expect(addLabels).toHaveBeenCalled());
expect(labelLayerOptions).toContainEqual(expect.objectContaining({ zooms: [19, 20], zIndex: 110, collision: false, allowCollision: true }));
const denseMarkers = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(denseMarkers).toHaveLength(3);
const textOffsets = denseMarkers.map((marker) => JSON.stringify((marker.options.text as { offset: [number, number] }).offset));
expect(new Set(textOffsets).size).toBe(3);
expect(denseMarkers.every((marker) => marker.options.icon == null)).toBe(true);
const labelRenderCount = addLabels.mock.calls.length;
view.rerender(<FleetMap vehicles={[]} monitorMap={crowdedMap} selectedVin="LTEST000000000002" onSelect={() => undefined} />);
await waitFor(() => expect(addLabels.mock.calls.length).toBeGreaterThan(labelRenderCount));
const selectedDenseMarkers = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(selectedDenseMarkers.every((marker) => marker.options.icon == null)).toBe(true);
expect(selectedDenseMarkers.find((marker) => marker.options.rank === 100)).toBeDefined();
});

View File

@@ -0,0 +1,442 @@
import { IconEyeClosed, IconEyeOpened, IconMapPin } from '@douyinfe/semi-icons';
import { useEffect, useMemo, useRef, useState } from 'react';
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
import {
gcj02ToWgs84,
isValidAMapCoordinate,
loadAMap,
wgs84ToGcj02,
type AMapMap,
type AMapLabelsLayer,
type AMapLike,
type AMapMassMarks,
type AMapMassPoint,
type AMapOverlay
} from '../../integrations/amap';
import type { MonitorMapResponse, VehicleRealtimeRow } from '../../api/types';
import { vehicleStatus } from '../domain/monitor';
import type { MonitorViewport } from '../hooks/useMonitorData';
const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444'];
function dotDataUrl(color: string) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="54" height="54" viewBox="0 0 18 18"><circle cx="9" cy="9" r="6.5" fill="${color}" stroke="white" stroke-width="2.5"/></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
function clusterVisual(count: number) {
const label = count.toLocaleString('en-US');
const diameter = Math.min(54, 34 + Math.max(0, label.length - 1) * 4);
const center = diameter / 2;
const fontSize = label.length >= 6 ? 9 : label.length >= 4 ? 10 : 11;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${diameter * 3}" height="${diameter * 3}" viewBox="0 0 ${diameter} ${diameter}"><circle cx="${center}" cy="${center}" r="${center - 2}" fill="#1268f3" stroke="white" stroke-width="2.5"/><circle cx="${center}" cy="${center}" r="${center - 5}" fill="none" stroke="rgba(255,255,255,.22)" stroke-width="1"/><text x="${center}" y="${center + fontSize * 0.34}" text-anchor="middle" font-family="Inter,Arial,sans-serif" font-size="${fontSize}" font-weight="800" fill="white">${label}</text></svg>`;
return { diameter, url: `data:image/svg+xml,${encodeURIComponent(svg)}` };
}
function viewportFromMap(map: AMapMap): MonitorViewport | null {
const zoom = Math.round(map.getZoom?.() ?? 5);
const bounds = map.getBounds?.();
const southWest = bounds?.getSouthWest?.();
const northEast = bounds?.getNorthEast?.();
const values = [southWest?.getLng?.(), southWest?.getLat?.(), northEast?.getLng?.(), northEast?.getLat?.()];
if (values.some((value) => !Number.isFinite(value))) return { zoom, bounds: '' };
const west = Number(values[0]);
const south = Number(values[1]);
const east = Number(values[2]);
const north = Number(values[3]);
const wgsCorners = [
gcj02ToWgs84(west, south),
gcj02ToWgs84(west, north),
gcj02ToWgs84(east, south),
gcj02ToWgs84(east, north)
];
const longitudes = wgsCorners.map(([longitude]) => longitude);
const latitudes = wgsCorners.map(([, latitude]) => latitude);
return {
zoom,
bounds: [Math.min(...longitudes), Math.min(...latitudes), Math.max(...longitudes), Math.max(...latitudes)]
.map((value) => value.toFixed(6)).join(',')
};
}
function styleIndex(vehicle: VehicleRealtimeRow) {
const status = vehicleStatus(vehicle);
return statusStyleIndex(status);
}
function statusStyleIndex(status: string) {
if (status === 'driving') return 2;
if (status === 'idle') return 0;
if (status === 'offline') return 1;
if (status === 'alert') return 4;
return 3;
}
function escapeHtml(value: string) {
return value.replace(/[&<>'"]/g, (character) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
})[character] ?? character);
}
type PlateLabelPoint = {
vin: string;
plate: string;
longitude: number;
latitude: number;
};
function densePlatePlacements(points: PlateLabelPoint[]) {
const buckets = new Map<string, PlateLabelPoint[]>();
for (const point of points) {
const key = `${Math.round(point.longitude / 0.00018)}:${Math.round(point.latitude / 0.00008)}`;
const bucket = buckets.get(key);
if (bucket) bucket.push(point);
else buckets.set(key, [point]);
}
const placements = new Map<string, {
direction: 'left' | 'right';
textOffset: [number, number];
}>();
for (const bucket of buckets.values()) {
bucket.sort((left, right) => left.vin.localeCompare(right.vin));
bucket.forEach((point, index) => {
const column = Math.floor(index / 7);
const row = index % 7;
const rowsInColumn = Math.min(7, bucket.length - column * 7);
const direction = column % 2 === 0 ? 'right' : 'left';
placements.set(point.vin, {
direction,
textOffset: [8 + Math.floor(column / 2) * 78, (row - (rowsInColumn - 1) / 2) * 23]
});
});
}
return placements;
}
export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelectVin, onViewportChange }: {
vehicles: VehicleRealtimeRow[];
selectedVin?: string;
onSelect: (vehicle: VehicleRealtimeRow) => void;
monitorMap?: MonitorMapResponse;
onSelectVin?: (vin: string) => void;
onViewportChange?: (viewport: MonitorViewport) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<AMapMap | null>(null);
const amapRef = useRef<AMapLike | null>(null);
const massRef = useRef<AMapMassMarks | null>(null);
const labelsRef = useRef<AMapLabelsLayer | null>(null);
const denseLabelsRef = useRef<AMapLabelsLayer | null>(null);
const selectionRef = useRef<AMapOverlay | null>(null);
const onSelectRef = useRef(onSelect);
const onSelectVinRef = useRef(onSelectVin);
const onViewportChangeRef = useRef(onViewportChange);
const vehiclesRef = useRef(new Map<string, VehicleRealtimeRow>());
const clustersRef = useRef(new Map<string, { longitude: number; latitude: number }>());
const viewportTimerRef = useRef<number | undefined>(undefined);
const selectionKeyRef = useRef('');
const selectionPositionRef = useRef('');
const centeredVinRef = useRef('');
const followSelectedRef = useRef(true);
const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
const [showLabels, setShowLabels] = useState(true);
const [followSelected, setFollowSelected] = useState(true);
const [mapZoom, setMapZoom] = useState(5);
const points = useMemo(() => vehicles.filter((vehicle) => isValidAMapCoordinate(vehicle.longitude, vehicle.latitude)), [vehicles]);
const selectedTarget = useMemo(() => selectedVin
? monitorMap?.points.find((item) => item.vin === selectedVin) ?? points.find((item) => item.vin === selectedVin)
: undefined, [monitorMap, points, selectedVin]);
const renderedPointCount = monitorMap ? monitorMap.points.length : points.length;
const renderedClusterCount = monitorMap?.clusters.length ?? 0;
const mapComposition = monitorMap && renderedClusterCount > 0
? `${renderedClusterCount} 个聚合 · ${renderedPointCount} 个车辆点 · ${monitorMap.total}`
: `${renderedPointCount} 个有效点位`;
const initialSelectionRef = useRef(points.find((vehicle) => vehicle.vin === selectedVin));
useEffect(() => {
vehiclesRef.current = new Map(points.map((vehicle) => [vehicle.vin, vehicle]));
}, [points, state]);
useEffect(() => {
onSelectRef.current = onSelect;
onSelectVinRef.current = onSelectVin;
onViewportChangeRef.current = onViewportChange;
}, [onSelect, onSelectVin, onViewportChange]);
useEffect(() => {
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) {
setState('fallback');
return;
}
let cancelled = false;
let resizeTimer: number | undefined;
let resizeObserver: ResizeObserver | undefined;
setState('loading');
loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => {
if (cancelled || !containerRef.current) return;
const initialSelection = initialSelectionRef.current;
const initialCenter = initialSelection
? wgs84ToGcj02(initialSelection.longitude, initialSelection.latitude)
: wgs84ToGcj02(105.4, 35.9);
const map = new AMap.Map(containerRef.current, {
zoom: initialSelection ? 13 : 5,
center: initialCenter,
viewMode: '2D',
mapStyle: 'amap://styles/whitesmoke',
showLabel: true,
resizeEnable: true
});
map.addControl(new AMap.Scale());
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '76px' } }));
const styles = [
...COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) }))
];
const mass = new AMap.MassMarks([], { opacity: 0.96, zIndex: 120, cursor: 'pointer', style: styles, zooms: [3, 20] });
mass.on('click', (event) => {
const cluster = clustersRef.current.get(event.data.id);
if (cluster) {
map.setZoomAndCenter?.(Math.min(20, (map.getZoom?.() ?? 5) + 2), wgs84ToGcj02(cluster.longitude, cluster.latitude));
return;
}
if (onSelectVinRef.current) {
onSelectVinRef.current(event.data.id);
return;
}
const vehicle = vehiclesRef.current.get(event.data.id);
if (vehicle) onSelectRef.current(vehicle);
});
mass.setMap(map);
const labels = AMap.LabelsLayer ? new AMap.LabelsLayer({ zooms: [11, 18.99], zIndex: 110, collision: true, allowCollision: false }) : null;
const denseLabels = AMap.LabelsLayer ? new AMap.LabelsLayer({ zooms: [19, 20], zIndex: 110, collision: false, allowCollision: true }) : null;
labels?.setMap(map);
const notifyViewport = () => {
setMapZoom(map.getZoom?.() ?? 5);
window.clearTimeout(viewportTimerRef.current);
viewportTimerRef.current = window.setTimeout(() => {
const viewport = viewportFromMap(map);
if (viewport) onViewportChangeRef.current?.(viewport);
}, 300);
};
map.on?.('moveend', notifyViewport);
map.on?.('zoomend', notifyViewport);
map.on?.('dragstart', () => {
if (!centeredVinRef.current) return;
followSelectedRef.current = false;
setFollowSelected(false);
});
mapRef.current = map;
amapRef.current = AMap;
massRef.current = mass;
labelsRef.current = labels;
denseLabelsRef.current = denseLabels;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => map.resize?.(), 80);
});
resizeObserver.observe(containerRef.current);
}
setState('ready');
notifyViewport();
}).catch(() => {
if (!cancelled) setState('error');
});
return () => {
cancelled = true;
resizeObserver?.disconnect();
window.clearTimeout(resizeTimer);
window.clearTimeout(viewportTimerRef.current);
massRef.current?.setMap(null);
labelsRef.current?.setMap(null);
denseLabelsRef.current?.setMap(null);
selectionRef.current?.setMap?.(null);
mapRef.current?.destroy();
massRef.current = null;
labelsRef.current = null;
denseLabelsRef.current = null;
selectionRef.current = null;
amapRef.current = null;
mapRef.current = null;
};
}, []);
useEffect(() => {
const mass = massRef.current;
const AMap = amapRef.current;
if (!mass || !AMap) return;
clustersRef.current = new Map((monitorMap?.clusters ?? []).map((cluster) => [cluster.id, cluster]));
const baseStyles = COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) }));
const clusterCounts = [...new Set((monitorMap?.clusters ?? []).map((cluster) => cluster.count))].sort((a, b) => a - b);
const clusterStyles = clusterCounts.map((count) => {
const visual = clusterVisual(count);
return { url: visual.url, anchor: new AMap.Pixel(visual.diameter / 2, visual.diameter / 2), size: new AMap.Size(visual.diameter, visual.diameter) };
});
mass.setStyle?.([...baseStyles, ...clusterStyles]);
const clusterStyleIndexes = new Map(clusterCounts.map((count, index) => [count, COLORS.length + index]));
const data: AMapMassPoint[] = monitorMap ? [
...monitorMap.clusters.map((cluster) => ({ lnglat: wgs84ToGcj02(cluster.longitude, cluster.latitude), style: clusterStyleIndexes.get(cluster.count) ?? COLORS.length, id: cluster.id, label: `${cluster.count}` })),
...monitorMap.points.map((point) => ({ lnglat: wgs84ToGcj02(point.longitude, point.latitude), style: point.status === 'driving' ? 2 : point.status === 'idle' ? 0 : point.status === 'offline' ? 1 : 3, id: point.vin, label: point.plate || point.vin }))
] : points.map((vehicle) => ({
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin
}));
mass.setData(data);
}, [monitorMap, points, state]);
useEffect(() => {
const labels = labelsRef.current;
const denseLabels = denseLabelsRef.current;
const AMap = amapRef.current;
const map = mapRef.current;
if (!labels || !denseLabels || !AMap?.LabelMarker || !map) return;
labels.clear();
denseLabels.clear();
if (!showLabels && !selectedVin) {
labels.setMap(null);
denseLabels.setMap(null);
return;
}
const mapLabelPoints = (monitorMap
? monitorMap.points
: points.map((vehicle) => ({ ...vehicle, status: vehicleStatus(vehicle) })));
const allLabelPoints = selectedTarget && !mapLabelPoints.some((point) => point.vin === selectedTarget.vin)
? [...mapLabelPoints, selectedTarget]
: mapLabelPoints;
const labelPoints = showLabels ? allLabelPoints : allLabelPoints.filter((point) => point.vin === selectedVin);
const showEveryPlate = mapZoom >= 19;
const activeLabels = showEveryPlate ? denseLabels : labels;
labels.setMap(showEveryPlate ? null : map);
denseLabels.setMap(showEveryPlate ? map : null);
const densePlacements = showEveryPlate ? densePlatePlacements(labelPoints) : null;
const markers = labelPoints.map((point) => {
const placement = densePlacements?.get(point.vin);
return new AMap.LabelMarker!({
name: point.plate || point.vin,
position: wgs84ToGcj02(point.longitude, point.latitude),
rank: point.vin === selectedVin ? 100 : 1,
zIndex: point.vin === selectedVin ? 10 : 1,
text: {
content: point.plate || point.vin,
direction: placement?.direction ?? 'right',
offset: placement?.textOffset ?? [8, 0],
style: {
fontSize: 11,
fontWeight: 700,
fillColor: '#174d9f',
strokeColor: 'transparent',
strokeWidth: 0,
padding: [5, 9],
backgroundColor: '#eef5ff',
borderColor: '#7fb0fa',
borderWidth: 1,
borderRadius: 6,
shadowColor: 'rgba(18, 104, 243, 0.18)',
shadowBlur: 14,
shadowOffsetY: 5
}
}
});
});
if (markers.length) activeLabels.add(markers);
}, [mapZoom, monitorMap, points, selectedTarget, selectedVin, showLabels, state]);
useEffect(() => {
if (!selectedVin || !mapRef.current || !amapRef.current) {
selectionRef.current?.setMap?.(null);
selectionRef.current = null;
selectionKeyRef.current = '';
selectionPositionRef.current = '';
centeredVinRef.current = '';
followSelectedRef.current = false;
setFollowSelected(false);
return;
}
const target = selectedTarget;
if (!target) return;
const label = escapeHtml(target.plate || target.vin);
const mapPosition = wgs84ToGcj02(target.longitude, target.latitude);
const selectionKey = `${selectedVin}|${label}`;
const positionKey = `${target.longitude.toFixed(6)},${target.latitude.toFixed(6)}`;
if (centeredVinRef.current !== selectedVin) {
followSelectedRef.current = true;
setFollowSelected(true);
const currentZoom = mapRef.current.getZoom?.() ?? 15;
if (currentZoom < 15) mapRef.current.setZoomAndCenter?.(15, mapPosition);
else mapRef.current.panTo?.(mapPosition, 650);
centeredVinRef.current = selectedVin;
} else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current) {
mapRef.current.panTo?.(mapPosition, 650);
}
selectionPositionRef.current = positionKey;
if (selectionKeyRef.current === selectionKey && selectionRef.current) {
selectionRef.current.setPosition?.(mapPosition);
return;
}
selectionRef.current?.setMap?.(null);
selectionRef.current = null;
const marker = new amapRef.current.Marker({
position: mapPosition,
offset: new amapRef.current.Pixel(-24, -24),
zIndex: 300,
content: `<div class="v2-map-selection-marker" aria-label="已选车辆 ${label}"><i></i><i></i><b></b></div>`
});
marker.setMap?.(mapRef.current);
selectionRef.current = marker;
selectionKeyRef.current = selectionKey;
}, [selectedTarget, selectedVin, state]);
const toggleFollow = () => {
const next = !followSelected;
followSelectedRef.current = next;
setFollowSelected(next);
if (next && selectedTarget && mapRef.current) {
mapRef.current.panTo?.(wgs84ToGcj02(selectedTarget.longitude, selectedTarget.latitude), 650);
}
};
return (
<div className="v2-fleet-map">
<div ref={containerRef} className="v2-fleet-map-canvas" aria-label="车辆全局监控地图" />
<div className="v2-map-controls">
{selectedVin ? (
<button
type="button"
className={`v2-map-follow-control${followSelected ? ' is-active' : ''}`}
aria-label="跟随车辆"
aria-pressed={followSelected}
title={followSelected ? '车辆移动时保持居中;拖动地图可暂停' : '恢复车辆居中跟随'}
onClick={toggleFollow}
>
<IconMapPin />
<span><strong></strong><small>{followSelected ? '实时居中' : '已暂停'}</small></span>
</button>
) : null}
<button
type="button"
className="v2-map-layer-control"
aria-label="悬浮车牌"
aria-pressed={showLabels}
title={monitorMap?.mode === 'clusters' ? '放大地图后显示车辆车牌' : '显示或隐藏车辆悬浮车牌'}
onClick={() => setShowLabels((current) => !current)}
>
{showLabels ? <IconEyeOpened /> : <IconEyeClosed />}
<span><strong></strong><small>{monitorMap?.mode === 'clusters' ? '放大后显示' : '仅明细点'}</small></span>
<i className={showLabels ? 'is-on' : ''} />
</button>
</div>
{state !== 'ready' ? (
<div className={`v2-map-state is-${state}`}>
{state === 'loading' ? <><span className="v2-spinner" /></> : null}
{state === 'fallback' ? `地图未配置,当前已载入 ${renderedPointCount} 个有效坐标` : null}
{state === 'error' ? '地图加载失败,请检查高德 Key、域名白名单和网络' : null}
</div>
) : null}
<div className="v2-map-legend" aria-label="车辆状态图例">
<span><i className="is-driving" /></span>
<span><i className="is-idle" /></span>
<span><i className="is-offline" />线</span>
<span><i className="is-alert" /></span>
<b>{mapComposition}</b>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { HistoryLocationRow, TrackPlaybackEvent } from '../../api/types';
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapLike, type AMapMap, type AMapOverlay } from '../../integrations/amap';
function markerContent(kind: string, label?: string) {
if (kind === 'current') return '<div class="v2-track-current-marker"><span></span></div>';
return `<div class="v2-track-marker is-${kind}">${label ?? ''}</div>`;
}
export function TrackMap({ points, events, activeIndex, onSelectIndex }: {
points: HistoryLocationRow[];
events: TrackPlaybackEvent[];
activeIndex: number;
onSelectIndex: (index: number) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<AMapMap | null>(null);
const amapRef = useRef<AMapLike | null>(null);
const overlaysRef = useRef<AMapOverlay[]>([]);
const currentMarkerRef = useRef<AMapOverlay | null>(null);
const selectRef = useRef(onSelectIndex);
const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
const valid = useMemo(() => points.map((point, index) => ({ point, index })).filter(({ point }) => isValidAMapCoordinate(point.longitude, point.latitude)), [points]);
useEffect(() => { selectRef.current = onSelectIndex; }, [onSelectIndex]);
useEffect(() => {
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { setState('fallback'); return; }
let cancelled = false;
loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => {
if (cancelled || !containerRef.current) return;
const first = valid[0]?.point;
const map = new AMap.Map(containerRef.current, {
zoom: first ? 13 : 5,
center: first ? wgs84ToGcj02(first.longitude, first.latitude) : wgs84ToGcj02(105.4, 35.9),
viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', showLabel: true, resizeEnable: true
});
map.addControl(new AMap.Scale());
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '22px' } }));
mapRef.current = map;
amapRef.current = AMap;
setState('ready');
}).catch(() => { if (!cancelled) setState('error'); });
return () => {
cancelled = true;
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
currentMarkerRef.current?.setMap?.(null);
mapRef.current?.destroy();
overlaysRef.current = [];
currentMarkerRef.current = null;
mapRef.current = null;
amapRef.current = null;
};
}, []);
useEffect(() => {
const AMap = amapRef.current;
if (state !== 'ready' || !mapRef.current || !valid.length || !AMap) return;
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
currentMarkerRef.current?.setMap?.(null);
const path = valid.map(({ point }) => wgs84ToGcj02(point.longitude, point.latitude));
const polyline = new AMap.Polyline({ path, strokeColor: '#1268f3', strokeWeight: 5, strokeOpacity: 0.92, lineJoin: 'round', lineCap: 'round', showDir: true, zIndex: 80 });
const first = valid[0];
const last = valid[valid.length - 1];
const overlays: AMapOverlay[] = [polyline];
const start = new AMap.Marker({ position: wgs84ToGcj02(first.point.longitude, first.point.latitude), anchor: 'center', content: markerContent('start', '始'), zIndex: 110 });
const end = new AMap.Marker({ position: wgs84ToGcj02(last.point.longitude, last.point.latitude), anchor: 'center', content: markerContent('end', '终'), zIndex: 110 });
start.on?.('click', () => selectRef.current(first.index));
end.on?.('click', () => selectRef.current(last.index));
overlays.push(start, end);
events.slice(1, -1).forEach((event, eventIndex) => {
if (!isValidAMapCoordinate(event.longitude, event.latitude)) return;
const exactIndex = points.findIndex((point) => point.deviceTime === event.time);
const targetIndex = exactIndex >= 0 ? exactIndex : points.reduce((closest, point, index) => {
const best = points[closest];
const distance = (point.longitude - event.longitude) ** 2 + (point.latitude - event.latitude) ** 2;
const bestDistance = (best.longitude - event.longitude) ** 2 + (best.latitude - event.latitude) ** 2;
return distance < bestDistance ? index : closest;
}, 0);
const marker = new AMap.Marker({ position: wgs84ToGcj02(event.longitude, event.latitude), anchor: 'center', content: markerContent('event', String(eventIndex + 1)), zIndex: 105 });
marker.on?.('click', () => selectRef.current(targetIndex));
overlays.push(marker);
});
const active = valid.find(({ index }) => index === activeIndex) ?? first;
const current = new AMap.Marker({ position: wgs84ToGcj02(active.point.longitude, active.point.latitude), anchor: 'center', content: markerContent('current'), zIndex: 130 });
currentMarkerRef.current = current;
overlaysRef.current = overlays;
mapRef.current.add([...overlays, current]);
mapRef.current.setFitView(overlays, false, [52, 52, 52, 52]);
}, [events, points, state, valid]);
useEffect(() => {
const point = points[activeIndex];
if (!point || !isValidAMapCoordinate(point.longitude, point.latitude)) return;
currentMarkerRef.current?.setPosition?.(wgs84ToGcj02(point.longitude, point.latitude));
}, [activeIndex, points]);
return <div className="v2-track-map">
<div ref={containerRef} className="v2-track-map-canvas" aria-label="历史轨迹地图" />
{state !== 'ready' ? <div className={`v2-map-state is-${state}`}>
{state === 'loading' ? <><span className="v2-spinner" /></> : null}
{state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null}
{state === 'error' ? '地图加载失败,请检查高德地图配置' : null}
</div> : null}
<div className="v2-track-map-legend"><span><i className="is-start" /></span><span><i className="is-current" /></span><span><i className="is-end" /></span><b>{valid.length} </b></div>
</div>;
}

View File

@@ -0,0 +1,117 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconDownload, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow } from '../../api/types';
import { accessRowsToCSV, accessStateLabels, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
import { InlineError } from '../shared/AsyncState';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', onlineState: '', delayState: '' };
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'];
const protocolColors = ['#1685c5', '#6f2da8', '#15a46d', '#7c8fd6', '#9aa4b2'];
type Filters = typeof EMPTY_FILTERS;
function StatusLabel({ state }: { state: AccessVehicleRow['onlineState'] }) {
return <span className={`v2-access-status is-${state}`}><i />{accessStateLabels[state]}</span>;
}
function ProtocolDistribution({ summary }: { summary?: AccessSummary }) {
const rows = summary?.protocols ?? [];
const total = Math.max(1, rows.reduce((sum, item) => sum + item.total, 0));
return <section className="v2-access-protocols"><header><strong></strong><span> · 线</span></header>
<div className="v2-access-segments">{rows.map((item, index) => <i key={item.name} style={{ width: `${item.total / total * 100}%`, background: protocolColors[index % protocolColors.length] }} title={`${item.name} ${item.total}`} />)}</div>
<div className="v2-access-legends">{rows.map((item, index) => <span key={item.name}><i style={{ background: protocolColors[index % protocolColors.length] }} /><b>{item.name}</b>{item.total.toLocaleString('zh-CN')} <em>{item.onlineRate.toFixed(1)}% 线</em></span>)}{!rows.length ? <span></span> : null}</div>
</section>;
}
function IdentityQueue({ items, total, loading }: { items: AccessUnresolvedIdentity[]; total: number; loading: boolean }) {
const [copied, setCopied] = useState('');
if (!loading && total === 0) return null;
const copyEvidence = async (item: AccessUnresolvedIdentity) => {
const text = [`身份待绑定:${item.identifierMasked}`, `协议:${item.protocol}`, `车牌:${item.plate || '待核对'}`, `厂家:${item.manufacturer || '待核对'}`, `来源:${item.sourceEndpoint || '未知'}`, `最近上报:${formatAccessTime(item.latestSeenAt)}`, `问题:${item.issueCode}`, `建议动作:${item.recommendedAction}`].join('\n');
await navigator.clipboard?.writeText(text);
setCopied(item.id);
};
return <details id="access-identity-queue" className="v2-access-identity-queue" open={total > 0}><summary><span><b></b><strong>{loading ? '…' : total.toLocaleString('zh-CN')}</strong></span><em> · VIN</em></summary>
<div>{items.slice(0, 5).map((item) => <article key={item.id}><span className="v2-access-identity-code">{item.identifierMasked}</span><dl><div><dt></dt><dd>{[item.plate, item.manufacturer, item.sourceEndpoint].filter(Boolean).join(' · ') || '仅有终端上报'}</dd></div><div><dt></dt><dd>{formatAccessTime(item.latestSeenAt)} · {formatSeconds(item.freshnessSec)}</dd></div></dl><button type="button" onClick={() => void copyEvidence(item)}>{copied === item.id ? '已复制' : '复制处置证据'}</button></article>)}</div>
</details>;
}
function AccessInspector({ row }: { row?: AccessVehicleRow }) {
if (!row) return <section className="v2-access-inspector"><header><strong></strong></header><div className="v2-access-side-empty"></div></section>;
return <section className="v2-access-inspector"><header><strong></strong><StatusLabel state={row.onlineState} /></header>
<dl className="v2-access-identity"><div><dt></dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt> / </dt><dd>{[row.model, row.company].filter(Boolean).join(' / ') || '—'}</dd></div><div><dt></dt><dd>{row.protocol || '—'}</dd></div><div><dt> / </dt><dd>{[row.oem, row.provider].filter(Boolean).join(' / ') || '—'}</dd></div></dl>
<Link className="v2-access-vehicle-link" to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link>
<section><h3></h3><dl><div><dt></dt><dd>{formatAccessTime(row.latestEventAt)}</dd></div><div><dt></dt><dd>{formatAccessTime(row.latestReceivedAt)}</dd></div><div><dt></dt><dd className={row.delayAbnormal ? 'is-danger' : 'is-good'}>{formatSeconds(row.dataDelaySec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.reportIntervalSec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.freshnessSec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.thresholdSec)}</dd></div></dl></section>
<section><h3></h3><dl><div><dt></dt><dd>{row.latestMessageType || '—'}</dd></div><div><dt> ID</dt><dd>{row.latestEventId || '—'}</dd></div><div><dt></dt><dd className={row.latestError ? 'is-danger' : ''}>{row.latestError || '无已知错误'}</dd></div><div><dt></dt><dd>{row.source || '—'}</dd></div></dl></section>
<section className="v2-access-proof"><h3></h3><p><b></b>{row.firstSeenAt ? `${formatAccessTime(row.firstSeenAt)} · ${row.firstSeenEvidence}` : row.firstSeenEvidence}</p><p><b></b>{row.reportIntervalEvidence || (row.reportIntervalSec !== null ? `${row.reportSampleCount} 个持久样本计算` : '等待连续样本')}</p></section>
</section>;
}
function ThresholdPanel({ config, draft, saving, error, editable, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; saving: boolean; error?: string; editable: boolean; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
return <section className="v2-access-threshold"><header><strong>线</strong><span>v{config?.version ?? '—'}</span></header>
{draft ? <fieldset className="v2-threshold-form" disabled={!editable}><label><span></span><select value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })}><option value="60">1 </option><option value="300">5 </option><option value="600">10 </option><option value="1800">30 </option></select></label><label><span></span><input type="number" min="1" max="3600" value={draft.delayThresholdSec} onChange={(event) => onChange({ ...draft, delayThresholdSec: Number(event.target.value) })} /><em></em></label><label><span>线</span><select value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })}><option value="1800">30 </option><option value="3600">1 </option><option value="21600">6 </option><option value="86400">24 </option></select></label>
{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" min="30" max="86400" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /><em></em></label>)}
{error ? <p className="v2-threshold-error">{error}</p> : null}{editable ? <button type="button" disabled={saving} onClick={onSave}><IconSave />{saving ? '保存中' : '保存并重算'}</button> : <p className="v2-role-notice"></p>}</fieldset> : <div className="v2-access-side-empty"></div>}
{config?.audit[0] ? <footer><span></span><b>{config.audit[0].actor} · {formatAccessTime(config.audit[0].changedAt)}</b></footer> : <footer><span></span><b>MySQL </b></footer>}
</section>;
}
function downloadRows(rows: AccessVehicleRow[]) {
const blob = new Blob([accessRowsToCSV(rows)], { type: 'text/csv;charset=utf-8' });
const href = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = href; anchor.download = `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click();
URL.revokeObjectURL(href);
}
export default function AccessPage() {
const { session } = usePlatformSession(); const thresholdEditable = canAdminister(session);
const [searchParams, setSearchParams] = useSearchParams();
const initial: Filters = Object.fromEntries(Object.keys(EMPTY_FILTERS).map((key) => [key, searchParams.get(key) ?? ''])) as Filters;
const [draft, setDraft] = useState(initial);
const [criteria, setCriteria] = useState(initial);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(50);
const [selectedVIN, setSelectedVIN] = useState('');
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>();
const queryClient = useQueryClient();
const baseQuery: AccessQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
const summaryQuery = useQuery({ queryKey: ['access-summary', baseQuery], queryFn: () => api.accessSummary(baseQuery), staleTime: 10_000 });
const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: () => api.accessVehicles({ ...baseQuery, limit, offset }), placeholderData: (previous) => previous });
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: () => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }), staleTime: 10_000 });
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: api.accessThresholds, staleTime: 60_000 });
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { queryClient.setQueryData(['access-thresholds'], config); setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
const rows = vehiclesQuery.data?.items ?? [];
const selected = rows.find((row) => row.vin === selectedVIN) ?? rows[0];
useEffect(() => { if (rows.length && !rows.some((row) => row.vin === selectedVIN)) setSelectedVIN(rows[0].vin); }, [rows, selectedVIN]);
useEffect(() => { const config = thresholdQuery.data; if (config && !thresholdDraft) setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); }, [thresholdDraft, thresholdQuery.data]);
const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); };
const submit = (event: FormEvent) => { event.preventDefault(); setCriteria(draft); setOffset(0); syncURL(draft); };
const reset = () => { setDraft(EMPTY_FILTERS); setCriteria(EMPTY_FILTERS); setOffset(0); setSearchParams({}, { replace: true }); };
const applyState = (onlineState: string, delayState = '') => { const next = { ...criteria, onlineState, delayState }; setDraft(next); setCriteria(next); setOffset(0); syncURL(next); };
const showIdentityQueue = () => document.getElementById('access-identity-queue')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const page = Math.floor(offset / limit) + 1;
const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit));
const summary = summaryQuery.data;
return <div className="v2-access-page">
<form className="v2-access-filter" onSubmit={submit}><label><span></span><div><IconSearch /><input value={draft.keyword} onChange={(event) => setDraft((current) => ({ ...current, keyword: event.target.value }))} placeholder="车牌 / VIN" /></div></label><label><span></span><select value={draft.protocol} onChange={(event) => setDraft((current) => ({ ...current, protocol: event.target.value }))}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><select value={draft.oem} onChange={(event) => setDraft((current) => ({ ...current, oem: event.target.value }))}><option value=""></option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><label><span>线</span><select value={draft.onlineState} onChange={(event) => setDraft((current) => ({ ...current, onlineState: event.target.value }))}><option value=""></option><option value="online">线</option><option value="offline">线</option><option value="never_reported"></option><option value="unknown"></option></select></label><label><span></span><select value={draft.delayState} onChange={(event) => setDraft((current) => ({ ...current, delayState: event.target.value }))}><option value=""></option><option value="normal"></option><option value="abnormal"></option></select></label><button className="v2-primary-button" type="submit"></button><button className="v2-secondary-button" type="button" onClick={reset}></button><details className="v2-access-advanced"><summary> · / / </summary><div><label><span></span><input value={draft.model} onChange={(event) => setDraft((current) => ({ ...current, model: event.target.value }))} placeholder="输入车型关键词" /></label><label><span></span><input value={draft.provider} onChange={(event) => setDraft((current) => ({ ...current, provider: event.target.value }))} placeholder="输入平台名称" /></label><label><span></span><input type="datetime-local" value={draft.firstSeenFrom} onChange={(event) => setDraft((current) => ({ ...current, firstSeenFrom: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.firstSeenTo} onChange={(event) => setDraft((current) => ({ ...current, firstSeenTo: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.latestSeenFrom} onChange={(event) => setDraft((current) => ({ ...current, latestSeenFrom: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.latestSeenTo} onChange={(event) => setDraft((current) => ({ ...current, latestSeenTo: event.target.value }))} /></label></div></details></form>
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
<section className="v2-access-kpis">{[
['接入车辆', summary?.totalVehicles ?? 0, '', () => applyState('')], ['在线', summary?.onlineVehicles ?? 0, 'online', () => applyState('online')], ['长离线', summary?.longOfflineVehicles ?? 0, 'offline', () => applyState('offline')], ['从未上报', summary?.neverReported ?? 0, 'never', () => applyState('never_reported')], ['延迟异常', summary?.delayAbnormal ?? 0, 'delay', () => applyState('', 'abnormal')], ['身份待绑定', unresolvedQuery.data?.total ?? 0, 'identity', showIdentityQueue], ['今日上报', summary?.reportedToday ?? 0, 'today', () => applyState('')]
].map(([label, value, tone, action]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={action as () => void}><small>{label as string}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '在线' ? <em>{(summary?.onlineRate ?? 0).toFixed(1)}%</em> : null}</button>)}</section>
<ProtocolDistribution summary={summary} />
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '身份待绑定队列读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} loading={unresolvedQuery.isLoading} />
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
<div className="v2-access-workspace"><section className="v2-access-table-card"><header><strong></strong><div><span> v{summary?.thresholdVersion ?? '—'}</span><button type="button" onClick={() => vehiclesQuery.refetch()}><IconRefresh /></button><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload /></button><button type="button"><IconSetting /></button></div></header><div className="v2-access-table-scroll"><table><thead><tr><th /><th>线</th><th></th><th>VIN</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''}><td><input type="radio" name="access-row" checked={selected?.vin === row.vin} onChange={() => setSelectedVIN(row.vin)} aria-label={`选择 ${row.plate || row.vin}`} /></td><td><StatusLabel state={row.onlineState} /></td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.oem || '—'}</td><td>{row.protocol || '—'}</td><td title={row.firstSeenEvidence}>{formatAccessTime(row.firstSeenAt)}</td><td>{formatAccessTime(row.latestEventAt)}</td><td>{formatAccessTime(row.latestReceivedAt)}</td><td title={row.reportIntervalEvidence}>{formatSeconds(row.reportIntervalSec)}</td><td className={row.delayAbnormal ? 'is-danger' : 'is-good'}>{formatSeconds(row.dataDelaySec)}</td><td>{formatSeconds(row.thresholdSec)}</td><td>{row.latestMessageType || '—'}</td><td className={row.latestError ? 'is-danger' : ''} title={row.latestError}>{row.latestError || '—'}</td><td><button type="button" onClick={() => setSelectedVIN(row.vin)}></button></td></tr>)}</tbody></table>{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i /></div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty"></div> : null}</div><footer><span> {page} / {totalPages} {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>
<aside className="v2-access-side"><AccessInspector row={selected} /><ThresholdPanel config={thresholdQuery.data} draft={thresholdDraft} saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} editable={thresholdEditable} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} /></aside></div>
</div>;
}

View File

@@ -0,0 +1,126 @@
import { IconAlarm, IconBell, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, memo, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AlertEvent, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition } from '../../api/types';
import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert';
import { InlineError } from '../shared/AsyncState';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister, canOperate } from '../auth/session';
type Tab = 'events' | 'rules' | 'notifications';
type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string };
const EMPTY_FILTERS: Filters = { keyword: '', severity: '', status: '', ruleId: '', protocol: '', dateFrom: '', dateTo: '' };
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'];
const NUMERIC_OPERATORS = ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'between', 'outside'];
const BOOLEAN_OPERATORS = ['eq', 'neq', 'changed'];
function SeverityTag({ severity }: Pick<AlertEvent, 'severity'>) { return <span className={`v2-alert-severity is-${severity}`}><i />{severityLabels[severity]}</span>; }
function StatusTag({ status }: Pick<AlertEvent, 'status'>) { return <span className={`v2-alert-status is-${status}`}>{statusLabels[status]}</span>; }
const AlertRows = memo(function AlertRows({ rows, selectedID, onSelect }: { rows: AlertEvent[]; selectedID: string; onSelect: (id: string) => void }) {
return <>{rows.map((event) => <tr key={event.id} className={selectedID === event.id ? 'is-selected' : ''} onClick={() => onSelect(event.id)}>
<td><input type="radio" name="alert-event" checked={selectedID === event.id} onChange={() => onSelect(event.id)} aria-label={`选择 ${event.ruleName}`} /></td>
<td><SeverityTag severity={event.severity} /></td><td><strong>{event.plate || '—'}</strong><small>{event.vin}</small></td><td>{event.ruleName}</td><td>{event.protocol || '—'}</td>
<td>{formatAlertTime(event.triggeredAt)}</td><td>{formatAlertTime(event.recoveredAt)}</td><td><StatusTag status={event.status} /></td><td>{alertValue(event)}</td><td>{thresholdText(event)}</td><td title={event.location}>{event.location || '—'}</td><td>{event.handler || '—'}</td>
</tr>)}</>;
});
function EventInspector({ event, note, acting, actionError, editable, onNote, onAction }: { event?: AlertEvent; note: string; acting: boolean; actionError?: string; editable: boolean; onNote: (value: string) => void; onAction: (action: 'acknowledge' | 'close' | 'ignore') => void }) {
if (!event) return <aside className="v2-alert-inspector"><div className="v2-alert-side-empty"><IconAlarm /><strong></strong><span>线</span></div></aside>;
return <aside className="v2-alert-inspector"><header><div><strong>{event.ruleName}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></div></header>
<section><h3></h3><dl><div><dt> ID</dt><dd>{event.id}</dd></div><div><dt> / </dt><dd>{event.ruleId} / v{event.ruleVersion}</dd></div><div><dt> / VIN</dt><dd>{event.plate || '—'} / {event.vin}</dd></div><div><dt></dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt></dt><dd>{formatAlertTime(event.recoveredAt)}</dd></div></dl></section>
<section><h3></h3><div className="v2-alert-evidence"><div><small></small><strong>{alertValue(event)}</strong></div><b>VS</b><div><small></small><strong>{thresholdText(event)}</strong></div></div><dl><div><dt> ID</dt><dd>{event.sourceEventId || '—'}</dd></div><div><dt></dt><dd>{event.protocol || '—'}</dd></div><div><dt> / </dt><dd>{formatAlertTime(event.eventAt)} / {formatAlertTime(event.receivedAt)}</dd></div></dl></section>
<section><h3></h3><div className="v2-alert-timeline">{event.actions?.map((item) => <article key={item.id}><i /><div><strong>{actionLabels[item.action] ?? item.action}</strong><span>{item.actor} · {formatAlertTime(item.createdAt)}</span>{item.note ? <p>{item.note}</p> : null}</div></article>)}</div></section>
<section><h3></h3>{editable ? <><textarea maxLength={200} placeholder="请输入处置说明(选填)" value={note} onChange={(e) => onNote(e.target.value)} /><small className="v2-alert-note-count">{note.length}/200</small>{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}<div className="v2-alert-actions"><button className="is-primary" disabled={acting || !canAct(event.status, 'acknowledge')} onClick={() => onAction('acknowledge')}></button><button disabled={acting || !canAct(event.status, 'close')} onClick={() => onAction('close')}></button><button disabled={acting || !canAct(event.status, 'ignore')} onClick={() => onAction('ignore')}></button></div></> : <p className="v2-role-notice"></p>}</section>
<nav className="v2-alert-links"><Link to={`/vehicles/${encodeURIComponent(event.vin)}`}></Link><Link to={`/tracks?vin=${encodeURIComponent(event.vin)}`}></Link><Link to={`/history?vin=${encodeURIComponent(event.vin)}`}></Link></nav>
</aside>;
}
function EventWorkspace({ filters, setFilters, rules, unread, editable, onTab }: { filters: Filters; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) {
const [draft, setDraft] = useState(filters); const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selectedID, setSelectedID] = useState(''); const [note, setNote] = useState(''); const queryClient = useQueryClient();
const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]);
const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]);
const summary = useQuery({ queryKey: ['alert-summary-v2', baseQuery], queryFn: () => api.alertSummaryV2(baseQuery), staleTime: 8_000 });
const events = useQuery({ queryKey: ['alert-events-v2', query], queryFn: () => api.alertEventsV2(query), placeholderData: (previous) => previous, staleTime: 5_000 });
const rows = events.data?.items ?? [];
useEffect(() => { if (rows.length && !rows.some((item) => item.id === selectedID)) setSelectedID(rows[0].id); }, [rows, selectedID]);
const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: () => api.alertEventV2(selectedID), enabled: Boolean(selectedID), staleTime: 3_000 });
const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } });
const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); };
const quickStatus = (status: string) => { const next = { ...filters, status }; setDraft(next); setFilters(next); setOffset(0); };
const totalPages = Math.max(1, Math.ceil((events.data?.total ?? 0) / limit)); const page = Math.floor(offset / limit) + 1; const sums = summary.data;
return <><form className="v2-alert-filter" onSubmit={submit}><label><span></span><div><IconSearch /><input value={draft.keyword} onChange={(e) => setDraft({ ...draft, keyword: e.target.value })} placeholder="车牌 / VIN / 规则名称" /></div></label><label><span></span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value })}><option value=""></option><option value="critical"></option><option value="major"></option><option value="minor"></option></select></label><label><span></span><select value={draft.status} onChange={(e) => setDraft({ ...draft, status: e.target.value })}><option value=""></option>{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label><span></span><select value={draft.ruleId} onChange={(e) => setDraft({ ...draft, ruleId: e.target.value })}><option value=""></option>{rules.map((rule) => <option key={rule.id} value={rule.id}>{rule.name}</option>)}</select></label><label><span></span><select value={draft.protocol} onChange={(e) => setDraft({ ...draft, protocol: e.target.value })}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(e) => setDraft({ ...draft, dateFrom: e.target.value })} /></label><label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(e) => setDraft({ ...draft, dateTo: e.target.value })} /></label><button className="v2-primary-button"></button><button className="v2-secondary-button" type="button" onClick={() => { setDraft(EMPTY_FILTERS); setFilters(EMPTY_FILTERS); setOffset(0); }}></button></form>
{summary.isError ? <InlineError message={summary.error instanceof Error ? summary.error.message : '告警汇总读取失败'} onRetry={() => summary.refetch()} /> : null}
<section className="v2-alert-kpis">{[['活跃告警', sums?.active, '', ''], ['未处理', sums?.unprocessed, 'unprocessed', 'unprocessed'], ['处理中', sums?.processing, 'processing', 'processing'], ['已恢复', sums?.recovered, 'recovered', 'recovered'], ['已关闭', sums?.closed, 'closed', 'closed'], ['已忽略', sums?.ignored, 'ignored', 'ignored'], ['未读通知', unread, 'notice', 'notice']].map(([label, value, tone, status]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={() => status === 'notice' ? onTab('notifications') : quickStatus(String(status))}><small>{label as string}</small><strong>{Number(value ?? 0).toLocaleString('zh-CN')}</strong></button>)}</section>
{events.isError ? <InlineError message={events.error instanceof Error ? events.error.message : '告警事件读取失败'} onRetry={() => events.refetch()} /> : null}
<div className="v2-alert-workspace"><section className="v2-alert-table-card"><header><strong></strong><div><span> {(events.data?.total ?? 0).toLocaleString('zh-CN')} </span><button onClick={() => Promise.all([events.refetch(), summary.refetch(), detail.refetch()])}><IconRefresh /></button></div></header><div className="v2-alert-table-scroll"><table><thead><tr><th /><th></th><th> / VIN</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody><AlertRows rows={rows} selectedID={selectedID} onSelect={setSelectedID} /></tbody></table>{events.isFetching ? <div className="v2-alert-loading"><i /></div> : null}{!events.isFetching && !rows.length ? <div className="v2-alert-empty"></div> : null}</div><footer><span> {page} / {totalPages} </span><div><button disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(e) => { setLimit(Number(e.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option></select></div></footer></section><EventInspector event={detail.data ?? rows.find((row) => row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} /></div></>;
}
function emptyRule(): AlertRuleInput { return { id: '', name: '', description: '', severity: 'major', valueType: 'numeric', metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 100, durationSec: 60, recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600, scopeProtocols: [], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [], notificationChannels: ['in_app'], enabled: true, version: 0 }; }
function ruleDraft(rule: AlertRule): AlertRuleInput {
return {
id: rule.id, name: rule.name, description: rule.description, severity: rule.severity, valueType: rule.valueType,
metric: rule.metric, operator: rule.operator, threshold: rule.threshold, thresholdHigh: rule.thresholdHigh, booleanThreshold: rule.booleanThreshold,
durationSec: rule.durationSec, recoveryOperator: rule.recoveryOperator, recoveryThreshold: rule.recoveryThreshold,
repeatIntervalSec: rule.repeatIntervalSec, scopeProtocols: [...(rule.scopeProtocols ?? [])], scopeVins: [...(rule.scopeVins ?? [])], scopeOems: [...(rule.scopeOems ?? [])], scopeModels: [...(rule.scopeModels ?? [])], scopeCompanies: [...(rule.scopeCompanies ?? [])],
notificationChannels: [...(rule.notificationChannels ?? ['in_app'])], enabled: rule.enabled, version: rule.version
};
}
function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: MetricDefinition[] }) {
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState('');
const [draft, setDraft] = useState<AlertRuleInput>(emptyRule());
useEffect(() => { if (!selectedID && rules[0]) { setSelectedID(rules[0].id); setDraft(ruleDraft(rules[0])); } }, [rules, selectedID]);
const save = useMutation({ mutationFn: api.saveAlertRuleV2, onSuccess: async (rule) => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
const toggle = useMutation({ mutationFn: (rule: AlertRule) => api.setAlertRuleEnabledV2(rule.id, { version: rule.version, enabled: !rule.enabled }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
const operators = draft.valueType === 'boolean' ? BOOLEAN_OPERATORS : NUMERIC_OPERATORS;
const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType);
const catalogLabels = Object.fromEntries(metrics.map((metric) => [metric.key, metric.label]));
const setList = (key: 'scopeProtocols' | 'scopeVins' | 'scopeOems' | 'scopeModels' | 'scopeCompanies', value: string) => setDraft({ ...draft, [key]: value.split(',').map((item) => item.trim()).filter(Boolean) });
return <div className="v2-alert-rules">
<section className="v2-alert-rule-list"><header><strong></strong><button onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}>+ </button></header>{rules.map((rule) => <button className={selectedID === rule.id ? 'is-selected' : ''} key={rule.id} onClick={() => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); }}><i className={`is-${rule.severity}`} /><span><strong>{rule.name}</strong><small>{ruleCondition(rule, catalogLabels)} · v{rule.version}</small></span><em className={rule.enabled ? 'is-enabled' : ''}>{rule.enabled ? '已启用' : '已停用'}</em></button>)}</section>
<form className="v2-alert-rule-editor" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
<header><div><strong>{draft.version ? '编辑规则' : '新建规则'}</strong><span></span></div>{draft.version ? <button type="button" onClick={() => { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{draft.enabled ? '停用规则' : '启用规则'}</button> : null}</header>
<div className="v2-rule-form-grid">
<label><span></span><input required maxLength={80} value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} /></label>
<label><span></span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value as AlertRuleInput['severity'] })}><option value="critical"></option><option value="major"></option><option value="minor"></option></select></label>
<label><span></span><select value={draft.valueType} onChange={(e) => { const valueType = e.target.value as AlertRuleInput['valueType']; const metric = metrics.find((item) => item.alertable && item.valueType === valueType)?.key ?? ''; setDraft({ ...draft, valueType, operator: valueType === 'boolean' ? 'eq' : 'gt', metric }); }}><option value="numeric"></option><option value="boolean"></option></select></label>
<label><span></span><select required disabled={!availableMetrics.length} value={draft.metric} onChange={(e) => setDraft({ ...draft, metric: e.target.value })}>{availableMetrics.map((metric) => <option key={metric.key} value={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</option>)}</select></label>
<label><span></span><select value={draft.operator} onChange={(e) => setDraft({ ...draft, operator: e.target.value, durationSec: e.target.value === 'changed' ? 0 : draft.durationSec })}>{operators.map((value) => <option key={value} value={value}>{operatorLabels[value]}</option>)}</select></label>
{draft.operator === 'changed' ? <label><span></span><input value="false ↔ true" disabled /></label> : draft.valueType === 'boolean' ? <label><span></span><select value={draft.booleanThreshold ? 'true' : 'false'} onChange={(e) => setDraft({ ...draft, booleanThreshold: e.target.value === 'true' })}><option value="true"></option><option value="false"></option></select></label> : <label><span>{draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'}</span><input type="number" step="0.1" value={draft.threshold} onChange={(e) => setDraft({ ...draft, threshold: Number(e.target.value) })} /></label>}
{draft.operator === 'between' || draft.operator === 'outside' ? <label><span></span><input type="number" step="0.1" value={draft.thresholdHigh} onChange={(e) => setDraft({ ...draft, thresholdHigh: Number(e.target.value) })} /></label> : null}
<label><span></span><input type="number" min="0" max="86400" disabled={draft.operator === 'changed'} value={draft.durationSec} onChange={(e) => setDraft({ ...draft, durationSec: Number(e.target.value) })} /></label>
<label><span></span><select value={draft.recoveryOperator} onChange={(e) => setDraft({ ...draft, recoveryOperator: e.target.value })}><option value=""></option>{['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => <option key={value} value={value}>{operatorLabels[value]}</option>)}</select></label>
<label><span></span><input type="number" step="0.1" value={draft.recoveryThreshold} onChange={(e) => setDraft({ ...draft, recoveryThreshold: Number(e.target.value) })} /></label>
<label><span></span><input type="number" min="0" max="604800" value={draft.repeatIntervalSec} onChange={(e) => setDraft({ ...draft, repeatIntervalSec: Number(e.target.value) })} /></label>
<label className="is-wide"><span></span><input value={draft.scopeProtocols.join(',')} onChange={(e) => setList('scopeProtocols', e.target.value)} /></label>
<label className="is-wide"><span> VIN </span><input value={draft.scopeVins.join(',')} onChange={(e) => setList('scopeVins', e.target.value)} /></label>
<label className="is-wide"><span></span><input value={draft.scopeOems.join(',')} onChange={(e) => setList('scopeOems', e.target.value)} /></label>
<label className="is-wide"><span></span><input value={draft.scopeModels.join(',')} onChange={(e) => setList('scopeModels', e.target.value)} /></label>
<label className="is-wide"><span></span><input value={draft.scopeCompanies.join(',')} onChange={(e) => setList('scopeCompanies', e.target.value)} /></label>
<label className="is-wide"><span></span><textarea maxLength={500} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} /></label>
</div>
<footer><div><b></b><span> / </span></div>{save.error ? <em>{save.error.message}</em> : null}<button className="v2-primary-button" disabled={save.isPending}>{save.isPending ? '保存中' : '保存规则'}</button></footer>
</form>
</div>;
}
function NotificationsWorkspace({ editable }: { editable: boolean }) {
const queryClient = useQueryClient(); const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: () => api.alertNotificationsV2(new URLSearchParams({ limit: '100' })), staleTime: 5_000 });
const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } });
return <div className="v2-alert-notifications"><header><div><strong></strong><span></span></div>{editable ? <button disabled={!notifications.data?.items.some((item) => !item.read)} onClick={() => read.mutate(notifications.data?.items.filter((item) => !item.read).map((item) => item.id) ?? [])}></button> : <span className="v2-role-badge"></span>}</header>{notifications.isError ? <InlineError message={notifications.error.message} onRetry={() => notifications.refetch()} /> : null}<div>{notifications.data?.items.map((item) => <article className={item.read ? 'is-read' : ''} key={item.id}><i className={`is-${item.severity}`} /><div><strong>{item.title}</strong><p>{item.content}</p><span>{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}</span></div>{editable && !item.read ? <button onClick={() => read.mutate([item.id])}></button> : null}</article>)}</div><footer><b></b><span>SMS / </span><span>Email / </span><span>WeCom / </span></footer></div>;
}
export default function AlertsPage() {
const { session } = usePlatformSession(); const operator = canOperate(session); const admin = canAdminister(session);
const [params, setParams] = useSearchParams(); const initialTab = (params.get('tab') as Tab) || 'events'; const [tab, setTabState] = useState<Tab>(['events', 'rules', 'notifications'].includes(initialTab) ? initialTab : 'events');
const initialFilters: Filters = { ...EMPTY_FILTERS, keyword: params.get('vin') ?? params.get('keyword') ?? '', severity: params.get('severity') ?? '', status: params.get('status') ?? '', ruleId: params.get('ruleId') ?? '', protocol: params.get('protocol') ?? '' };
const [filters, setFilterState] = useState(initialFilters); const rules = useQuery({ queryKey: ['alert-rules-v2'], queryFn: api.alertRulesV2, staleTime: 30_000 }); const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: api.metricCatalog, staleTime: 300_000, enabled: admin }); const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: () => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '100' })), staleTime: 5_000 });
const setTab = (next: Tab) => { setTabState(next); const copy = new URLSearchParams(params); copy.set('tab', next); setParams(copy, { replace: true }); };
const setFilters = (next: Filters) => { setFilterState(next); const copy = new URLSearchParams(); if (tab !== 'events') copy.set('tab', tab); Object.entries(next).forEach(([key, value]) => { if (value) copy.set(key, value); }); setParams(copy, { replace: true }); };
const activeTab = tab === 'rules' && !admin ? 'events' : tab;
return <div className="v2-alert-page"><header className="v2-alert-heading"><div><h2></h2><p></p></div></header><nav className="v2-alert-tabs"><button className={activeTab === 'events' ? 'is-active' : ''} onClick={() => setTab('events')}><IconAlarm /></button>{admin ? <button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setTab('rules')}></button> : null}<button className={activeTab === 'notifications' ? 'is-active' : ''} onClick={() => setTab('notifications')}><IconBell />{(notices.data?.total ?? 0) > 0 ? <b>{notices.data?.total}</b> : null}</button></nav>{rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} setFilters={setFilters} rules={rules.data ?? []} unread={notices.data?.total ?? 0} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} />}</div>;
}

View File

@@ -0,0 +1,132 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history';
import { InlineError } from '../shared/AsyncState';
function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) {
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
if (category !== 'location') return <section className="v2-history-trend"><header><strong></strong></header><div className="v2-history-chart-empty">{category === 'raw' ? '原始报文是离散证据,不生成可能误导的连续趋势;请使用明细与导出。' : '日里程按自然日展示,当前请使用明细表核对起止里程。'}</div></section>;
const summary = response?.summary;
const coverage = summary?.expectedBucketCount ? Math.max(0, (summary.expectedBucketCount - summary.missingBucketCount) / summary.expectedBucketCount * 100) : 0;
return <section className="v2-history-trend"><header><strong></strong><div>{summary ? <><span>{formatSeriesGrain(summary.grainSeconds)}</span><span> {coverage.toFixed(1)}%</span><span>{summary.rawPointCount.toLocaleString('zh-CN')} </span></> : null}</div></header>
{error ? <div className="v2-history-chart-empty">{error}</div> : loading && !response ? <div className="v2-history-chart-empty"></div> : panels.length ? <div className="v2-history-trend-panels">{panels.map((panel) => <article key={panel.key}><header><strong>{panel.label}</strong><span>{panel.unit || '数值'} · {panel.lines.reduce((sum, line) => sum + line.points, 0)} </span></header><svg viewBox="0 0 800 116" role="img" aria-label={`${panel.label}按时间变化趋势`}>
<g className="v2-chart-grid"><line x1="54" y1="10" x2="786" y2="10" /><line x1="54" y1="53" x2="786" y2="53" /><line x1="54" y1="96" x2="786" y2="96" /></g>
<g className="v2-chart-axis"><text x="49" y="14">{panel.maximum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="49" y="100">{panel.minimum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="54" y="112">{formatAxisTime(panel.start)}</text><text x="786" y="112" textAnchor="end">{formatAxisTime(panel.end)}</text></g>
{panel.lines.flatMap((line) => line.paths.map((path, index) => <path key={`${line.key}-${index}`} d={path} fill="none" stroke={line.color} strokeWidth="2" vectorEffect="non-scaling-stroke"><title>{line.label}</title></path>))}
</svg><footer>{panel.lines.map((line) => <span key={line.key}><i style={{ background: line.color }} />{line.label}</span>)}</footer></article>)}</div> : <div className="v2-history-chart-empty"></div>}
{summary ? <small className="v2-history-trend-evidence">{summary.evidence} · {summary.missingBucketCount.toLocaleString('zh-CN')} / {summary.expectedBucketCount.toLocaleString('zh-CN')} · {summary.queryDurationMs} ms</small> : null}
</section>;
}
export function formatAxisTime(value: string) {
const parsed = new Date(value);
if (!Number.isFinite(parsed.getTime())) return value.replace('T', ' ').slice(5, 16);
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(parsed).replace('/', '-');
}
function currentHistoryWindow() {
const now = new Date(); const pad = (value: number) => String(value).padStart(2, '0');
const day = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
return { dateFrom: `${day}T00:00`, dateTo: `${day}T${pad(now.getHours())}:${pad(now.getMinutes())}` };
}
function CreateExportButton({ request, disabled }: { request: HistoryExportRequest; disabled: boolean }) {
const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: api.createHistoryExport, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history-exports'] }) });
const label = mutation.isPending ? '任务排队中' : mutation.isError ? '导出失败,重试' : '创建导出';
return <button className="v2-secondary-button" type="button" disabled={disabled || mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '最多 100 万行;任务按创建顺序单并发流式执行'} onClick={() => mutation.mutate(request)}><IconDownload />{label}</button>;
}
function ExportJobsPanel() {
const query = useQuery({ queryKey: ['history-exports'], queryFn: api.historyExports, refetchInterval: (current) => current.state.data?.some((job) => job.status === 'queued' || job.status === 'running') ? 1000 : false });
const jobs = query.data ?? [];
return <section className="v2-export-jobs"><header><strong></strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}</small></div>{job.downloadUrl ? <a href={job.downloadUrl}><IconDownload /></a> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty"></div> : !jobs.length ? <div className="v2-history-side-empty"></div> : null}</div></section>;
}
function EvidencePanel({ row, metrics, onClose }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void }) {
return <section className="v2-history-evidence"><header><strong></strong>{row ? <button onClick={onClose} type="button" aria-label="关闭行证据"><IconClose /></button> : null}</header>
{row ? <><dl><div><dt></dt><dd>{row.deviceTime}</dd></div><div><dt></dt><dd>{row.serverTime}</dd></div><div><dt></dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt></dt><dd>{row.protocol}</dd></div><div><dt></dt><dd><i className={`is-${row.quality}`} />{row.quality === 'normal' ? '正常' : row.quality}</dd></div></dl><div className="v2-evidence-values"><strong></strong>{metrics.slice(0, 12).map((metric) => <div key={metric.key}><span>{metric.label}<small>{metric.key}</small></span><b>{formatHistoryValue(row.values[metric.key], metric)}</b></div>)}</div><footer><span>RAW </span><b>{row.evidenceId || '该数据类型没有独立 RAW 帧 ID'}</b></footer></> : <div className="v2-history-side-empty"></div>}
</section>;
}
export default function HistoryPage() {
const [searchParams, setSearchParams] = useSearchParams();
const today = useMemo(currentHistoryWindow, []);
const initial = { keywords: searchParams.get('vin') || searchParams.get('keywords') || '', dateFrom: searchParams.get('dateFrom') || today.dateFrom, dateTo: searchParams.get('dateTo') || today.dateTo, category: searchParams.get('category') || 'location', protocol: searchParams.get('protocol') || '' };
const [draft, setDraft] = useState(initial);
const [criteria, setCriteria] = useState(initial);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(50);
const [visibleByCategory, setVisibleByCategory] = useState<Record<string, string[]>>({});
const [selectedRow, setSelectedRow] = useState<HistoryDataRow>();
const [density, setDensity] = useState<'compact' | 'comfortable'>('compact');
const keywords = useMemo(() => parseHistoryKeywords(criteria.keywords), [criteria.keywords]);
const params = useMemo(() => {
const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, limit: String(limit), offset: String(offset) });
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria, keywords, limit, offset]);
const seriesParams = useMemo(() => {
const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, metrics: 'speedKmh,totalMileageKm', targetPoints: '240' });
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria, keywords]);
const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: api.historyMetricCatalog, staleTime: 30 * 60_000 });
const dataQuery = useQuery({ queryKey: ['history-data', params.toString()], enabled: keywords.length > 0, queryFn: () => api.historyData(params), placeholderData: (previous) => previous });
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: () => api.historySeries(seriesParams), placeholderData: (previous) => previous });
const result = dataQuery.data;
const allMetrics = result?.columns ?? catalogQuery.data?.metrics.filter((metric) => metric.category === criteria.category) ?? [];
const visibleKeys = visibleByCategory[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);
const visibleMetrics = allMetrics.filter((metric) => visibleKeys.includes(metric.key));
useEffect(() => { setSelectedRow(result?.rows[0]); }, [result?.asOf]);
const submit = (event: FormEvent) => {
event.preventDefault();
const parsed = parseHistoryKeywords(draft.keywords);
if (!parsed.length) return;
const next = { ...draft, keywords: parsed.join(',') };
setCriteria(next); setOffset(0);
const url = new URLSearchParams({ keywords: next.keywords, category: next.category });
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
if (next.dateTo) url.set('dateTo', next.dateTo);
if (next.protocol) url.set('protocol', next.protocol);
setSearchParams(url, { replace: true });
};
const reset = () => { const next = { keywords: '', ...currentHistoryWindow(), category: 'location', protocol: '' }; setDraft(next); setCriteria(next); setOffset(0); setSearchParams({}, { replace: true }); };
const toggleMetric = (key: string) => setVisibleByCategory((current) => {
const baseline = current[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);
const next = baseline.includes(key) ? baseline.filter((item) => item !== key) : [...baseline, key];
return { ...current, [criteria.category]: next };
});
const totalPages = Math.max(1, Math.ceil((result?.total ?? 0) / limit));
const page = Math.floor(offset / limit) + 1;
return <div className="v2-history-page">
<form className="v2-history-toolbar" onSubmit={submit}>
<label className="v2-history-vehicles"><span> 5 </span><div><IconSearch /><input value={draft.keywords} onChange={(event) => setDraft((value) => ({ ...value, keywords: event.target.value }))} placeholder="车牌 / VIN多台用逗号分隔" /></div></label>
<label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></label>
<label><span></span><select value={draft.category} onChange={(event) => setDraft((value) => ({ ...value, category: event.target.value }))}>{(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></label>
<label><span></span><select value={draft.protocol} onChange={(event) => setDraft((value) => ({ ...value, protocol: event.target.value }))}><option value=""></option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
<button className="v2-primary-button" type="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></button><button className="v2-secondary-button" type="button" onClick={reset}></button><CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} />
</form>
<div className="v2-history-metrics"><strong></strong>{allMetrics.map((metric) => <button type="button" className={visibleKeys.includes(metric.key) ? 'is-active' : ''} onClick={() => toggleMetric(metric.key)} key={metric.key}><i />{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</button>)}{!allMetrics.length ? <span></span> : null}</div>
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
<div className="v2-history-workspace">
<div className="v2-history-main">
<div className="v2-history-summary"><div><small></small><strong>{result?.total.toLocaleString('zh-CN') ?? 0}</strong></div><div><small></small><strong>{result?.summary.vehicleCount ?? 0}</strong></div><div><small></small><strong>{result?.summary.sources.join('、') || '—'}</strong></div><div><small></small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></div>
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} />
<section className={`v2-history-table-card is-${density}`}><header><strong></strong><div><button type="button"><IconSetting /></button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact"></option><option value="comfortable"></option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header><div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th></th><th></th><th></th><th>VIN</th><th></th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th></th><th></th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRow(selectedRow?.id === row.id ? undefined : row)} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></td><td><button type="button" onClick={() => setSelectedRow(row)}></button></td></tr>)}</tbody></table>{!result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span> {page} / {totalPages} {result?.total ?? 0} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>
</div>
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={allMetrics} onClose={() => setSelectedRow(undefined)} /><ExportJobsPanel /></aside>
</div>
</div>;
}

View File

@@ -0,0 +1,80 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { VehicleRealtimeRow } from '../../api/types';
import MonitorPage from './MonitorPage';
const vehicles = [{
vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['JT808'], primaryProtocol: 'JT808',
longitude: 113.26, latitude: 23.13, speedKmh: 42, socPercent: 80, totalMileageKm: 1234,
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
}, {
vin: 'LTEST000000000002', plate: '粤B67890', protocols: ['JT808'], primaryProtocol: 'JT808',
longitude: 113.28, latitude: 23.15, speedKmh: 0, socPercent: 72, totalMileageKm: 2234,
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
}] as VehicleRealtimeRow[];
const monitorMap = { clusters: [], points: [], total: 2 };
const fleetMapRenderSpy = vi.hoisted(() => vi.fn());
vi.mock('../map/FleetMap', () => ({
FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => {
fleetMapRenderSpy(selectedVin);
return <div data-testid="fleet-map" data-selected-vin={selectedVin ?? ''}>
<button type="button" onClick={() => onSelectVin?.('LTEST000000000002')}></button>
</div>;
}
}));
vi.mock('../hooks/useMonitorData', () => ({
MONITOR_REFRESH: { selected: 10_000, fleet: 15_000, summary: 30_000 },
useMonitorData: () => ({
summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
vehicles: { data: { items: vehicles, total: 2 }, isError: false, isLoading: false, isFetching: false },
map: { data: monitorMap },
selectedVehicle: { data: { items: [] } }
}),
useMonitorVehicleCard: () => ({ detail: {}, activeAlerts: {}, address: {} })
}));
afterEach(cleanup);
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
const view = render(<MemoryRouter><MonitorPage /></MemoryRouter>);
const workspace = view.container.querySelector('.v2-monitor-workspace')!;
const firstVehicle = screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ });
const secondVehicle = screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ });
expect(workspace).not.toHaveClass('is-detail-open');
expect(workspace).not.toHaveClass('is-detail-collapsed');
expect(firstVehicle).not.toHaveClass('is-selected');
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
fireEvent.click(firstVehicle);
expect(workspace).toHaveClass('is-detail-open');
expect(firstVehicle).toHaveClass('is-selected');
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
const mapRendersAfterSelection = fleetMapRenderSpy.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '收起车辆详情' }));
expect(workspace).toHaveClass('is-detail-collapsed');
expect(firstVehicle).toHaveClass('is-selected');
expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument();
expect(fleetMapRenderSpy).toHaveBeenCalledTimes(mapRendersAfterSelection);
fireEvent.click(secondVehicle);
expect(workspace).toHaveClass('is-detail-open');
expect(secondVehicle).toHaveClass('is-selected');
expect(screen.queryByRole('button', { name: '展开车辆详情' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '取消选择车辆' }));
expect(workspace).not.toHaveClass('is-detail-open');
expect(workspace).not.toHaveClass('is-detail-collapsed');
expect(secondVehicle).not.toHaveClass('is-selected');
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
fireEvent.click(screen.getByRole('button', { name: '选择地图车辆' }));
expect(workspace).toHaveClass('is-detail-open');
expect(secondVehicle).toHaveClass('is-selected');
});

View File

@@ -0,0 +1,207 @@
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { memo, useCallback, useDeferredValue, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import type { AlertEvent, MapReverseGeocode, VehicleDetail as VehicleDetailData, VehicleRealtimeRow } from '../../api/types';
import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError } from '../shared/AsyncState';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { MONITOR_REFRESH, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
const status = vehicleStatus(vehicle);
return (
<button type="button" className={`v2-vehicle-row ${selected ? 'is-selected' : ''}`} onClick={() => onSelect(vehicle.vin)}>
<i className={`v2-status-dot is-${status}`} />
<span className="v2-vehicle-identity"><strong>{vehicle.plate || '未绑定车牌'}</strong><small>{vehicle.vin}</small></span>
<span className="v2-vehicle-motion"><strong>{formatNumber(vehicle.speedKmh, 1)} <small>km/h</small></strong><small>{statusLabel(status)}</small></span>
</button>
);
});
const MemoFleetMap = memo(FleetMap);
function VehicleDetailCard({
vehicle,
detail,
activeAlerts,
address,
onCollapse,
onClear
}: {
vehicle: VehicleRealtimeRow;
detail?: VehicleDetailData;
activeAlerts?: { items: AlertEvent[]; total: number };
address?: MapReverseGeocode;
onCollapse: () => void;
onClear: () => void;
}) {
const status = vehicleStatus(vehicle);
const dailyMileage = detail?.mileage.items[0]?.dailyMileageKm;
const latestAlert = activeAlerts?.items[0];
return (
<aside className="v2-vehicle-detail">
<div className="v2-detail-controls">
<button type="button" aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse}><IconChevronRight /></button>
<button type="button" aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear}><IconClose /></button>
</div>
<div className="v2-detail-title">
<div><strong>{vehicle.plate || '未绑定车牌'}</strong><span className={`v2-status-text is-${status}`}>{statusLabel(status)}</span></div>
<small>{vehicle.vin}</small>
</div>
<div className="v2-detail-actions">
<Link to={`/vehicles/${encodeURIComponent(vehicle.vin)}`}></Link>
<Link to={`/tracks?vin=${encodeURIComponent(vehicle.vin)}`}></Link>
<Link to={`/history?vin=${encodeURIComponent(vehicle.vin)}`}></Link>
</div>
<section>
<h3></h3>
<dl className="v2-detail-list">
<div><dt>VIN</dt><dd>{vehicle.vin}</dd></div>
<div><dt></dt><dd>{vehicle.oem || '待补充'}</dd></div>
<div><dt></dt><dd>{vehicle.primaryProtocol || vehicle.protocols.join('、') || '未知'}</dd></div>
<div><dt></dt><dd>{detail?.sources.join('、') || vehicle.protocols.join('、') || '未知'}</dd></div>
<div><dt></dt><dd>{detail?.profile?.accessProvider || '待补充'}</dd></div>
<div><dt></dt><dd>{vehicle.onlineSourceCount}/{vehicle.sourceCount}</dd></div>
</dl>
</section>
<section>
<h3></h3>
<div className="v2-metric-grid">
<div><small></small><strong>{formatNumber(vehicle.speedKmh, 1)}<em>km/h</em></strong></div>
<div><small>SOC</small><strong>{formatNumber(vehicle.socPercent, 1)}<em>%</em></strong></div>
<div><small></small><strong>{formatNumber(vehicle.totalMileageKm, 1)}<em>km</em></strong></div>
<div><small></small><strong>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></strong></div>
<div><small></small><strong>{statusLabel(status)}</strong></div>
<div><small></small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em></em></strong></div>
</div>
</section>
<section>
<h3></h3>
<dl className="v2-detail-list">
<div><dt></dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
<div><dt></dt><dd>{relativeFreshness(vehicle.lastSeen)}</dd></div>
<div><dt></dt><dd>{vehicle.longitude.toFixed(6)}, {vehicle.latitude.toFixed(6)}</dd></div>
<div><dt></dt><dd>{address?.formattedAddress || '位置解析中'}</dd></div>
<div><dt></dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
</dl>
</section>
</aside>
);
}
export default function MonitorPage() {
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword);
const [protocol, setProtocol] = useState('');
const [status, setStatus] = useState('');
const [selectedVin, setSelectedVin] = useState('');
const [detailOpen, setDetailOpen] = useState(false);
const [viewport, setViewport] = useState<MonitorViewport>({ zoom: 5, bounds: '' });
const updateViewport = useCallback((next: MonitorViewport) => {
setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next);
}, []);
const { summary, vehicles, map, selectedVehicle } = useMonitorData({ keyword: deferredKeyword, protocol, status }, viewport, selectedVin);
const rows = useMemo(() => {
const data = vehicles.data?.items ?? [];
if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status);
return data;
}, [status, vehicles.data?.items]);
const selected = selectedVin
? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0]
: undefined;
const selectVehicle = useCallback((vin: string) => {
setSelectedVin(vin);
setDetailOpen(true);
}, []);
const clearSelection = useCallback(() => {
setSelectedVin('');
setDetailOpen(false);
}, []);
const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]);
const collapseDetail = useCallback(() => setDetailOpen(false), []);
const expandDetail = useCallback(() => setDetailOpen(true), []);
const card = useMonitorVehicleCard(selected?.vin ?? '', selected, Boolean(selectedVin));
const driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length;
const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length;
const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length;
return (
<div className="v2-monitor-page">
<section className="v2-filterbar" aria-label="车辆筛选">
<label className="v2-search-field"><IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌 / VIN / 厂家" /></label>
<select value={protocol} onChange={(event) => setProtocol(event.target.value)} aria-label="协议">
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
</select>
<select value={status} onChange={(event) => setStatus(event.target.value)} aria-label="在线状态">
{statuses.map((item) => <option key={item} value={item}>{item ? statusLabel(item as never) : '全部状态'}</option>)}
</select>
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); }}><IconRefresh /></button>
<button type="button" className="v2-primary-button"><IconFilter /></button>
</section>
<section className="v2-kpis" aria-label="车辆整体统计">
{[
['接入车辆', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
['静止车辆', formatNumber(summary.data?.idleVehicles ?? idle), 'idle'],
['告警车辆', summary.data?.alertDataAvailable ? formatNumber(summary.data.alertVehicles) : '—', 'alert'],
['今日上报', formatNumber(summary.data?.frameToday ?? 0), 'today']
].map(([label, value, tone]) => <div key={label} className={`v2-kpi is-${tone}`}><small>{label}</small><strong>{value}</strong></div>)}
</section>
{vehicles.isError ? <InlineError message={vehicles.error instanceof Error ? vehicles.error.message : '车辆数据加载失败'} onRetry={() => vehicles.refetch()} /> : null}
<section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
<div className="v2-vehicle-rail">
<header><strong></strong><span>{formatNumber(vehicles.data?.total ?? rows.length)} </span></header>
<div className="v2-rail-search"><IconSearch /><span>{deferredKeyword ? `正在筛选“${deferredKeyword}` : '按最新上报排序'}</span></div>
<div className="v2-vehicle-scroll">
{vehicles.isLoading ? <div className="v2-list-loading"><span className="v2-spinner" /></div> : null}
{!vehicles.isLoading && rows.length === 0 ? <EmptyState /> : null}
{rows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)}
</div>
<footer> {rows.length} / {vehicles.data?.total ?? rows.length} </footer>
</div>
<MemoFleetMap
vehicles={rows}
monitorMap={map.data}
selectedVin={selectedVin || undefined}
onSelect={selectMapVehicle}
onSelectVin={selectVehicle}
onViewportChange={updateViewport}
/>
{selected && detailOpen ? (
<VehicleDetailCard
vehicle={selected}
detail={card.detail.data}
activeAlerts={card.activeAlerts.data}
address={card.address.data}
onCollapse={collapseDetail}
onClear={clearSelection}
/>
) : null}
{selected && !detailOpen ? (
<aside className="v2-detail-peek" aria-label="已收起的车辆详情">
<button type="button" aria-label="展开车辆详情" title={`展开 ${selected.plate || selected.vin} 的车辆详情`} onClick={expandDetail}>
<IconChevronLeft />
<i className={`v2-status-dot is-${vehicleStatus(selected)}`} />
<span>{selected.plate || '未绑定车牌'}</span>
</button>
</aside>
) : null}
</section>
<section className="v2-event-strip">
<strong></strong>
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
<span> {rows.length} · {map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}</span>
<span className="v2-refresh-cadence"><b></b> {MONITOR_REFRESH.selected / 1000} · {MONITOR_REFRESH.fleet / 1000} · {MONITOR_REFRESH.summary / 1000} </span>
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
</section>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { IconRefresh } from '@douyinfe/semi-icons';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client';
import { InlineError } from '../shared/AsyncState';
function statusLabel(status: string) {
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
}
export default function OperationsPage() {
const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: api.opsHealth, refetchInterval: 15_000, staleTime: 8_000 });
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: api.sourceReadiness, refetchInterval: 30_000, staleTime: 15_000 });
const data = health.data; const sources = readiness.data;
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
return <div className="v2-ops-page">
<header className="v2-ops-heading"><div><h2></h2><p> ECS </p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh /></button></header>
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
<section className="v2-ops-kpis">
<article><small></small><strong>{data?.runtime.platformRelease || '未注入'}</strong><span className={data?.runtime.dataMode === 'production' ? 'is-ok' : 'is-error'}>{data?.runtime.dataMode || 'unknown'}</span></article>
<article><small></small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>capacity-check</span></article>
<article><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><span className={data?.kafkaLag === 0 ? 'is-ok' : 'is-warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</span></article>
<article><small>Redis 线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small> / 线</small><strong>{sources ? `${sources.onlineVehicles} / ${sources.totalVehicles}` : '—'}</strong><span></span></article>
</section>
<div className="v2-ops-grid"><section className="v2-ops-links"><header><strong></strong><span>15 </span></header><div>{data?.linkHealth.map((item) => <article key={item.name}><i className={`is-${item.status}`} /><div><strong>{item.name}</strong><p>{item.detail || '无补充信息'}</p></div><span className={`is-${item.status}`}>{statusLabel(item.status)}</span></article>)}</div></section>
<section className="v2-ops-runtime"><header><strong></strong></header><dl><div><dt></dt><dd>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</dd></div><div><dt>MySQL </dt><dd className={data?.mysqlWritable ? 'is-ok' : 'is-error'}>{data?.mysqlWritable ? '正常' : '异常'}</dd></div><div><dt>TDengine </dt><dd className={data?.tdengineWritable ? 'is-ok' : 'is-error'}>{data?.tdengineWritable ? '正常' : '异常'}</dd></div><div><dt></dt><dd>{data?.runtime.requestTimeoutMs ?? '—'} ms</dd></div><div><dt></dt><dd className={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'is-ok' : 'is-warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</dd></div></dl>{data?.capacityFindings?.length ? <div className="v2-ops-findings">{data.capacityFindings.map((item) => <p key={item}>{item}</p>)}</div> : <p className="v2-ops-clear"></p>}</section></div>
<section className="v2-ops-sources"><header><strong></strong><span></span></header><div>{sources?.sources.map((source) => <article key={source.protocol}><div><i className={`is-${source.severity}`} /><strong>{source.protocol}</strong><span>{source.role}</span></div><b>{source.online} / {source.total} 线</b><p>{source.evidence}</p><p>{source.action}</p><em>{source.acceptance}</em></article>)}</div></section>
</div>;
}

View File

@@ -0,0 +1,191 @@
import { useQuery } from '@tanstack/react-query';
import {
IconBox, IconChevronLeft, IconChevronRight, IconDownload, IconPause, IconPlay, IconSearch
} from '@douyinfe/semi-icons';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types';
import { downloadTrackCsv, formatDuration, sampledEventIndex } from '../domain/track';
import { TrackMap } from '../map/TrackMap';
import { InlineError } from '../shared/AsyncState';
const speedOptions = [1, 2, 4] as const;
const visibleSegmentLimit = 120;
function number(value: number, digits = 1) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number.isFinite(value) ? value : 0);
}
function direction(value?: number) {
if (value === undefined || !Number.isFinite(value)) return '方向 —';
const normalized = ((value % 360) + 360) % 360;
const names = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
return `${number(normalized, 0)}° ${names[Math.round(normalized / 45) % names.length]}`;
}
function alarm(value?: number) {
if (value === undefined) return '报警 —';
return value === 0 ? '无报警' : `报警 0x${Math.trunc(value).toString(16).toUpperCase().padStart(8, '0')}`;
}
function time(value?: string) {
if (!value) return '—';
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(parsed);
const clock = value.split(' ').pop();
return clock?.slice(0, 8) || '—';
}
function localDateTime(value: Date) {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function defaultTrackWindow() {
const now = new Date();
const start = new Date(now);
start.setHours(0, 0, 0, 0);
return { dateFrom: localDateTime(start), dateTo: localDateTime(now) };
}
function eventTone(type: string) {
if (type === 'start') return 'start';
if (type === 'end' || type === 'braking' || type === 'gap') return 'end';
if (type === 'acceleration' || type === 'stop') return 'warning';
return 'info';
}
function EmptyTrack({ queried }: { queried: boolean }) {
return <div className="v2-track-empty"><IconSearch size="extra-large" /><strong>{queried ? '当前条件没有轨迹点' : '选择车辆开始查询轨迹'}</strong><p>{queried ? '请扩大时间范围或切换数据来源。' : '支持车牌、VIN 或终端标识,结果不会回退到本地样例。'}</p></div>;
}
function CoverageStrip({ track }: { track: TrackPlaybackResponse }) {
return <div className={`v2-track-coverage ${track.coverage.complete ? 'is-complete' : 'is-limited'}`}>
<strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>
<span>{track.coverage.evidence}</span>
<em>{track.coverage.processedPoints} {track.coverage.returnedPoints} </em>
</div>;
}
function SegmentTimeline({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
const segments = track.segments.slice(0, visibleSegmentLimit);
return <div className="v2-track-timeline">
<header><strong></strong><span>{track.segments.length} · {track.stops.length} </span></header>
<div>{segments.map((segment) => <button
aria-label={`${segment.title} ${time(segment.startTime)}${time(segment.endTime)}`}
className={`is-${segment.type}`}
key={`${segment.index}-${segment.startTime}`}
onClick={() => onSelectIndex(segment.sampledStartIndex)}
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
type="button"
><i /><span>{segment.title}</span></button>)}</div>
{track.segments.length > visibleSegmentLimit ? <em> {visibleSegmentLimit} </em> : null}
</div>;
}
function TripInspector({ track, onEvent }: { track: TrackPlaybackResponse; onEvent: (event: TrackPlaybackEvent) => void }) {
return <aside className="v2-track-inspector">
<section><header><strong></strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><div className="v2-track-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>VIN {track.vin}</small></div></div></section>
<section><header><strong></strong></header><dl className="v2-track-summary">
<div><dt></dt><dd>{track.summary.startTime || '—'}</dd></div><div><dt></dt><dd>{track.summary.endTime || '—'}</dd></div><div><dt></dt><dd>{number(track.summary.distanceKm)} km</dd></div><div><dt></dt><dd>{formatDuration(track.summary.durationSeconds)}</dd></div><div><dt> / </dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div><div><dt> / </dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div><div><dt></dt><dd>{number(track.summary.averageSpeedKmh)} km/h</dd></div><div><dt></dt><dd>{number(track.summary.maximumSpeedKmh)} km/h</dd></div>
</dl></section>
<section className="v2-track-sources"><header><strong></strong><b>{track.coverage.totalPoints} </b></header><div>{track.sources.map((source) => <span key={source.protocol}><strong>{source.protocol}</strong><small>{source.pointCount} · {time(source.startTime)}{time(source.endTime)}</small></span>)}</div>{!track.coverage.complete ? <p>{track.coverage.evidence} 7 </p> : null}</section>
<section className={`v2-track-quality is-${track.quality.status}`}><header><strong></strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><dl className="v2-track-summary"><div><dt> / </dt><dd>{track.quality.selectedProtocol || 'UNKNOWN'} / {track.quality.alternateSourcePoints}</dd></div><div><dt> / </dt><dd>{track.quality.validPoints} / {track.quality.rawPoints}</dd></div><div><dt> / / </dt><dd>{track.quality.invalidCoordinatePoints} / {track.quality.duplicatePoints} / {track.quality.driftPoints}</dd></div><div><dt> / </dt><dd>{track.quality.largeGapCount} / {formatDuration(track.quality.maximumGapSeconds)}</dd></div></dl><p>{track.quality.evidence}</p></section>
<section className="v2-track-events"><header><strong></strong><span>{track.events.length} </span></header><div>{track.events.map((event, index) => <button key={`${event.type}-${event.index}-${event.time}`} onClick={() => onEvent(event)} type="button"><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{time(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>)}</div></section>
</aside>;
}
export default function TrackPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialKeyword = searchParams.get('vin') || searchParams.get('keyword') || '';
const [draft, setDraft] = useState(() => {
const fallback = defaultTrackWindow();
return { keyword: initialKeyword, dateFrom: searchParams.get('dateFrom') || fallback.dateFrom, dateTo: searchParams.get('dateTo') || fallback.dateTo, protocol: searchParams.get('protocol') || '' };
});
const [criteria, setCriteria] = useState(draft);
const [activeIndex, setActiveIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState<(typeof speedOptions)[number]>(1);
const params = useMemo(() => {
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1200' });
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria]);
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: () => api.trackPlayback(params) });
const track = query.data;
const points = track?.points ?? [];
const current = points[Math.min(activeIndex, Math.max(points.length - 1, 0))];
const [addressPoint, setAddressPoint] = useState<{ longitude: number; latitude: number }>();
useEffect(() => {
if (playing || !current) return;
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 350);
return () => window.clearTimeout(timer);
}, [current?.latitude, current?.longitude, playing]);
const addressQuery = useQuery({
queryKey: ['track-address', addressPoint?.longitude.toFixed(6), addressPoint?.latitude.toFixed(6)],
enabled: Boolean(addressPoint), staleTime: 60 * 60 * 1000,
queryFn: () => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }))
});
useEffect(() => { setActiveIndex(0); setPlaying(false); }, [track?.asOf]);
useEffect(() => {
if (!playing || points.length < 2) return;
const timer = window.setInterval(() => setActiveIndex((index) => {
if (index >= points.length - 1) { setPlaying(false); return points.length - 1; }
return index + 1;
}), Math.max(120, 800 / playbackSpeed));
return () => window.clearInterval(timer);
}, [playing, playbackSpeed, points.length]);
const submit = (event: FormEvent) => {
event.preventDefault();
const keyword = draft.keyword.trim();
if (!keyword) return;
const next = { ...draft, keyword };
setCriteria(next);
const url = new URLSearchParams({ vin: keyword });
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
if (next.dateTo) url.set('dateTo', next.dateTo);
if (next.protocol) url.set('protocol', next.protocol);
setSearchParams(url, { replace: true });
};
const selectEvent = (event: TrackPlaybackEvent) => {
if (!track) return;
setActiveIndex(sampledEventIndex(event, points.length, track.summary.pointCount));
setPlaying(false);
};
return <div className="v2-track-page">
<form className="v2-track-toolbar" onSubmit={submit}>
<label className="v2-track-vehicle-input"><span></span><div><IconSearch /><input value={draft.keyword} onChange={(event) => setDraft((value) => ({ ...value, keyword: event.target.value }))} placeholder="车牌 / VIN / 终端标识" /></div></label>
<label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></label>
<label><span></span><select value={draft.protocol} onChange={(event) => setDraft((value) => ({ ...value, protocol: event.target.value }))}><option value=""></option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
<button className="v2-primary-button" type="submit" disabled={!draft.keyword.trim()}></button>
<button className="v2-secondary-button" type="button" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload /></button>
</form>
{query.isError ? <InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /> : null}
<div className="v2-track-workspace">
<div className="v2-track-main">
{track && points.length ? <CoverageStrip track={track} /> : <div className="v2-track-coverage is-empty"><span> 7 </span></div>}
<div className="v2-track-canvas-wrap">
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" /></div> : null}
{points.length ? <TrackMap points={points} events={track?.events ?? []} activeIndex={activeIndex} onSelectIndex={setActiveIndex} /> : <EmptyTrack queried={Boolean(track)} />}
</div>
{track && points.length ? <SegmentTimeline track={track} onSelectIndex={(index) => { setPlaying(false); setActiveIndex(index); }} /> : <div className="v2-track-timeline is-empty"><span></span></div>}
<div className="v2-track-playback">
<div className="v2-play-controls"><small></small><p><button type="button" onClick={() => setPlaying((value) => !value)} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button><button type="button" onClick={() => { setPlaying(false); setActiveIndex((value) => Math.max(0, value - 1)); }} disabled={!activeIndex}><IconChevronLeft /></button><button type="button" onClick={() => { setPlaying(false); setActiveIndex((value) => Math.min(points.length - 1, value + 1)); }} disabled={!points.length || activeIndex >= points.length - 1}><IconChevronRight /></button><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as 1 | 2 | 4)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></p></div>
<div className="v2-play-progress"><header><strong>{time(current?.deviceTime)}</strong><span>{track?.summary.endTime ? time(track.summary.endTime) : '—'}</span></header><input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={Math.min(activeIndex, Math.max(0, points.length - 1))} onChange={(event) => { setPlaying(false); setActiveIndex(Number(event.target.value)); }} disabled={!points.length} /><footer> {points.length ? activeIndex + 1 : 0} / {points.length}</footer></div>
<dl className="v2-current-metrics"><div><dt> / </dt><dd>{number(current?.speedKmh ?? 0)}<em>km/h · {direction(current?.directionDeg)}</em></dd></div><div><dt>SOC / </dt><dd>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}<em> · {alarm(current?.alarmFlag)}</em></dd></div><div><dt> / </dt><dd>{number(current?.totalMileageKm ?? 0)}<em>km · {current?.protocol || '—'}</em></dd></div><div><dt></dt><dd title={addressQuery.data?.formattedAddress}>{playing ? '播放中暂停解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || (current ? `${current.longitude.toFixed(6)}, ${current.latitude.toFixed(6)}` : '—')}</dd></div></dl>
</div>
</div>
{track && points.length ? <TripInspector track={track} onEvent={selectEvent} /> : <aside className="v2-track-inspector is-empty"><strong></strong><p></p></aside>}
</div>
</div>;
}

View File

@@ -0,0 +1,205 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import {
IconAlarm, IconArrowRight, IconBox, IconCalendar, IconClock, IconCopy,
IconMapPin, IconSearch, IconTickCircle
} from '@douyinfe/semi-icons';
import { FormEvent, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry';
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState';
function fmt(value?: string) { return value?.trim() || '—'; }
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value) : fallback; }
function timeOnly(value?: string) { if (!value) return '—'; const parts = value.split(' '); return parts[parts.length - 1] || value; }
function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? 'error' : 'warning'; }
function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(seconds / 3600)} 小时`; }
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const;
function ProfileSyncPanel({ onClose }: { onClose: () => void }) {
const [sourceSystem, setSourceSystem] = useState('');
const [sourceVersion, setSourceVersion] = useState('');
const [conflictPolicy, setConflictPolicy] = useState<'preserve' | 'overwrite'>('preserve');
const [items, setItems] = useState<VehicleProfileSyncItem[]>([]);
const [fileName, setFileName] = useState('');
const [parseError, setParseError] = useState('');
const sync = useMutation<VehicleProfileSyncResult, Error, boolean>({
mutationFn: (dryRun) => api.syncVehicleProfiles({ sourceSystem: sourceSystem.trim(), sourceVersion: sourceVersion.trim(), conflictPolicy, dryRun, items })
});
const readFile = async (file?: File) => {
sync.reset(); setItems([]); setFileName(file?.name ?? ''); setParseError('');
if (!file) return;
try { setItems(parseVehicleProfileSyncCSV(await file.text())); } catch (error) { setParseError(error instanceof Error ? error.message : 'CSV 解析失败'); }
};
const ready = sourceSystem.trim() !== '' && sourceVersion.trim() !== '' && items.length > 0 && !sync.isPending;
const issues = sync.data?.items.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle').slice(0, 20) ?? [];
const applied = sync.data && !sync.data.dryRun;
return <section className="v2-profile-sync-panel" aria-label="车辆主档批量同步">
<header><div><strong></strong><p>CSV 500 </p></div><button type="button" onClick={onClose}></button></header>
<div className="v2-profile-sync-fields">
<label><span></span><input value={sourceSystem} onChange={(event) => { setSourceSystem(event.target.value); sync.reset(); }} placeholder="例如 oem-tsp" maxLength={64} /></label>
<label><span></span><input value={sourceVersion} onChange={(event) => { setSourceVersion(event.target.value); sync.reset(); }} placeholder="例如 snapshot-20260714-01" maxLength={128} /></label>
<label><span></span><select value={conflictPolicy} onChange={(event) => { setConflictPolicy(event.target.value as 'preserve' | 'overwrite'); sync.reset(); }}><option value="preserve"></option><option value="overwrite"></option></select></label>
<label className="is-file"><span>CSV </span><input type="file" accept=".csv,text/csv" onChange={(event) => { void readFile(event.target.files?.[0]); }} /></label>
</div>
<p className="v2-profile-sync-format"><code>{vehicleProfileSyncCSVHeader}</code></p>
{fileName ? <p className="v2-profile-sync-file">{fileName} · {items.length} </p> : null}
{parseError ? <p className="v2-profile-sync-error">{parseError}</p> : null}
{sync.isError ? <p className="v2-profile-sync-error">{sync.error.message}</p> : null}
{sync.data ? <div className="v2-profile-sync-result">
<div><span><strong>{sync.data.received}</strong></span><span><strong>{sync.data.created}</strong></span><span><strong>{sync.data.updated}</strong></span><span><strong>{sync.data.unchanged}</strong></span><span><strong>{sync.data.conflicted}</strong></span><span><strong>{sync.data.missing}</strong></span></div>
{issues.length ? <ul>{issues.map((item) => <li key={item.vin}><b>{item.vin}</b><span>{item.status === 'missing_vehicle' ? '网关身份不存在' : item.status === 'conflict_source_version' ? '同来源版本内容不一致' : `现有来源 ${item.previousSource || '未知'} 已保护`}</span></li>)}</ul> : <p></p>}
</div> : null}
{conflictPolicy === 'overwrite' ? <p className="v2-profile-sync-warning"></p> : null}
<footer><button type="button" onClick={() => sync.mutate(true)} disabled={!ready}>{sync.isPending ? '处理中…' : '预演同步'}</button><button className="is-primary" type="button" onClick={() => sync.mutate(false)} disabled={!ready || !sync.data?.dryRun}>{applied ? '已完成写入' : '确认写入'}</button></footer>
</section>;
}
function VehicleSearch() {
const navigate = useNavigate();
const { session } = usePlatformSession();
const [keyword, setKeyword] = useState('');
const [syncOpen, setSyncOpen] = useState(false);
const submit = (event: FormEvent) => {
event.preventDefault();
const value = keyword.trim();
if (value) navigate(`/vehicles/${encodeURIComponent(value)}`);
};
return <section className={`v2-vehicle-search-page ${syncOpen ? 'has-sync-panel' : ''}`}>
<div className="v2-vehicle-search-card">
<span className="v2-search-hero-icon"><IconBox size="extra-large" /></span>
<h2></h2>
<p>VIN </p>
<form onSubmit={submit}>
<IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="输入车牌 / VIN / 终端手机号" autoFocus />
<button type="submit"> <IconArrowRight /></button>
</form>
{canAdminister(session) ? <button className="v2-profile-sync-open" type="button" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起批量同步' : '批量同步主档'}</button> : null}
</div>
{syncOpen ? <ProfileSyncPanel onClose={() => setSyncOpen(false)} /> : null}
</section>;
}
function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; editable: boolean; onUpdated: () => void }) {
const profile = detail.profile;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState({ modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
const save = useMutation({
mutationFn: () => api.updateVehicleProfile(detail.vin, {
modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
operationStatus: draft.operationStatus as NonNullable<typeof profile>['operationStatus'], accessProvider: draft.accessProvider.trim(), firstAccessAt: draft.firstAccessAt,
runtimeSeconds: draft.runtimeHours.trim() === '' ? null : Math.round(Number(draft.runtimeHours) * 3600), version: profile?.version ?? 0
}),
onSuccess: () => { setEditing(false); onUpdated(); }
});
const startEditing = () => {
setDraft({ modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
save.reset(); setEditing(true);
};
const submit = (event: FormEvent) => { event.preventDefault(); if (draft.runtimeHours === '' || Number.isFinite(Number(draft.runtimeHours))) save.mutate(); };
return <section className="v2-record-card v2-archive-card">
<header><strong></strong><span className="v2-profile-heading"> {profile?.completeness ?? 0}%{editable && !editing ? <button type="button" onClick={startEditing}></button> : null}</span></header>
{editing ? <form className="v2-profile-form" onSubmit={submit}>
<label><span></span><input maxLength={128} value={draft.modelName} onChange={(event) => setDraft({ ...draft, modelName: event.target.value })} /></label>
<label><span></span><input maxLength={64} value={draft.vehicleType} onChange={(event) => setDraft({ ...draft, vehicleType: event.target.value })} /></label>
<label><span></span><input maxLength={128} value={draft.companyName} onChange={(event) => setDraft({ ...draft, companyName: event.target.value })} /></label>
<label><span></span><select value={draft.operationStatus} onChange={(event) => setDraft({ ...draft, operationStatus: event.target.value })}>{Object.entries(operationStatusLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label>
<label><span></span><input maxLength={128} value={draft.accessProvider} onChange={(event) => setDraft({ ...draft, accessProvider: event.target.value })} /></label>
<label><span></span><input type="datetime-local" value={draft.firstAccessAt} onChange={(event) => setDraft({ ...draft, firstAccessAt: event.target.value })} /></label>
<label><span></span><input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(event) => setDraft({ ...draft, runtimeHours: event.target.value })} /></label>
{save.isError ? <p>{save.error.message}</p> : null}<footer><button type="button" onClick={() => setEditing(false)}></button><button className="is-primary" type="submit" disabled={save.isPending}>{save.isPending ? '保存中' : '保存档案'}</button></footer>
</form> : <><dl className="v2-record-list">
<div><dt> / </dt><dd>{[profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—'}</dd></div>
<div><dt></dt><dd>{fmt(profile?.companyName)}</dd></div>
<div><dt></dt><dd>{operationStatusLabels[profile?.operationStatus ?? 'unknown']}</dd></div>
<div><dt></dt><dd>{fmt(profile?.accessProvider)}</dd></div>
<div><dt></dt><dd>{fmt(profile?.firstAccessAt)}</dd></div>
<div><dt></dt><dd>{durationHours(profile?.runtimeSeconds)}</dd></div>
</dl><p className="v2-record-note"> {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
</section>;
}
function Events({ detail }: { detail: VehicleDetail }) {
const events = [
...detail.quality.items.slice(0, 3).map((item) => ({ tone: issueTone(item), title: item.severity === 'error' ? '质量异常' : '质量提醒', detail: item.detail, time: item.lastSeen })),
...detail.sourceStatus.slice(0, 3).map((item) => ({ tone: item.online ? 'success' : 'muted', title: item.online ? '数据上报' : '来源离线', detail: `${item.protocol} · ${item.online ? '当前在线' : '暂无在线数据'}`, time: item.lastSeen }))
].slice(0, 5);
return <section className="v2-record-card v2-events-card">
<header><strong></strong><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}></Link></header>
<div className="v2-event-list">{events.length ? events.map((event, index) => <div className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time>{fmt(event.time)}</time>
</div>) : <div className="v2-empty-compact"></div>}</div>
</section>;
}
function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryResponse; pending: boolean; error?: string }) {
const [selectedCategory, setSelectedCategory] = useState('vehicle');
const indexed = useMemo(() => {
const valuesByCategory = new Map<string, LatestTelemetryResponse['values']>();
const sources = new Map<string, { protocol: string; endpoint?: string }>();
for (const value of data?.values ?? []) {
const values = valuesByCategory.get(value.category);
if (values) values.push(value); else valuesByCategory.set(value.category, [value]);
const sourceKey = `${value.protocol}\u0000${value.sourceEndpoint ?? ''}`;
if (!sources.has(sourceKey)) sources.set(sourceKey, { protocol: value.protocol, endpoint: value.sourceEndpoint });
}
return { valuesByCategory, sources: [...sources.values()] };
}, [data]);
const categories = data?.categories ?? [];
const activeCategory = indexed.valuesByCategory.has(selectedCategory) ? selectedCategory : categories[0]?.key ?? '';
const visibleMetrics = indexed.valuesByCategory.get(activeCategory) ?? [];
return <section className="v2-record-card v2-telemetry-card">
<nav>{categories.map((item) => <button className={activeCategory === item.key ? 'is-active' : ''} onClick={() => setSelectedCategory(item.key)} type="button" key={item.key}>{item.label}<span>{item.count}</span></button>)}</nav>
<div className="v2-telemetry-list">
{pending ? <div className="v2-empty-compact"></div> : error ? <div className="v2-empty-compact is-error">{error}</div> : visibleMetrics.length ? visibleMetrics.map((item) => <div key={item.key}>
<span>{item.label}<small title={item.sourceField}>{item.sourceField} · {item.protocol}{item.sourceEndpoint ? ` · ${item.sourceEndpoint}` : ''}</small></span>
<strong>{formatTelemetryValue(item.value)} <em>{item.unit}</em></strong>
<time title={`设备时间 ${item.deviceTime || '缺失'};接收时间 ${item.serverTime || '缺失'}${item.qualityReason};帧 ${item.frameId}`}><i className={`is-${item.quality}`}>{telemetryQualityLabel(item.quality)}</i>{formatTelemetryTime(item.deviceTime || item.serverTime)}</time>
</div>) : <div className="v2-empty-compact"> {data?.scannedFrames ?? 0} </div>}
</div>
<footer><b></b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{source.protocol}</span>)}<small> {data?.scannedFrames ?? 0} · {formatTelemetryTime(data?.asOf)}</small></footer>
</section>;
}
function VehicleRecord({ detail, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) {
const { session } = usePlatformSession();
const realtime = detail.realtimeSummary;
const identity = detail.identity;
const mapVehicles = realtime ? [realtime] : [];
const lastMileage = detail.mileage.items[0];
return <div className="v2-vehicle-record-page">
<section className="v2-identity-band">
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><span className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`}><i />{realtime?.online ? '在线' : '离线'}</span><small>VIN</small><b>{detail.vin}</b><button type="button" title="复制 VIN" onClick={() => navigator.clipboard?.writeText(detail.vin)}><IconCopy /></button></div>
<div className="v2-identity-meta"><div><small></small><p>{detail.sources.map((source) => <span key={source}>{source}</span>)}</p></div><div><small></small><strong>{fmt(realtime?.lastSeen || identity?.lastSeen)}</strong></div></div>
<div className="v2-identity-actions"><Link to={`/tracks?vin=${encodeURIComponent(detail.vin)}`}><IconMapPin /></Link><Link to={`/history?vin=${encodeURIComponent(detail.vin)}`}><IconCalendar /></Link><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><IconAlarm /></Link></div>
</section>
<div className="v2-record-grid">
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={detail.vin} onSelect={() => undefined} /><footer><span><IconMapPin />{realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</span><time>{fmt(realtime?.lastSeen)}</time></footer></section>
<Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} />
<section className="v2-record-card v2-live-card"><header><strong></strong><span><IconClock />{timeOnly(realtime?.lastSeen)}</span></header><div className="v2-live-grid">
<div><small></small><strong>{metric(realtime?.speedKmh)}<em>km/h</em></strong></div><div><small>SOC</small><strong>{metric(realtime?.socPercent)}<em>%</em></strong></div><div><small></small><strong>{metric(realtime?.totalMileageKm)}<em>km</em></strong></div><div><small></small><strong>{metric(lastMileage?.dailyMileageKm)}<em>km</em></strong></div><div><small>线</small><strong>{realtime?.onlineSourceCount ?? 0}<em></em></strong></div><div><small></small><strong>{detail.sourceStatus.length}<em></em></strong></div>
</div></section>
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
<Events detail={detail} />
</div>
</div>;
}
export default function VehiclePage() {
const { vin } = useParams();
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: () => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' })) });
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: () => api.latestTelemetry(vin!), staleTime: 10_000, refetchInterval: 20_000, refetchIntervalInBackground: false });
if (!vin) return <VehicleSearch />;
if (query.isPending) return <PageLoading />;
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
if (!query.data.lookupResolved) return <section className="v2-not-found"><IconSearch size="extra-large" /><h2></h2><p>{vin}VIN </p><Link to="/vehicles"></Link></section>;
return <VehicleRecord detail={query.data} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} onUpdated={() => { void query.refetch(); void telemetry.refetch(); }} />;
}

View File

@@ -0,0 +1,19 @@
import { IconAlertTriangle, IconRefresh } from '@douyinfe/semi-icons';
export function PageLoading({ label = '正在加载车辆数据' }: { label?: string }) {
return <div className="v2-page-state"><span className="v2-spinner" />{label}</div>;
}
export function InlineError({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="v2-inline-state is-error" role="alert">
<IconAlertTriangle />
<span>{message}</span>
{onRetry ? <button type="button" onClick={onRetry}><IconRefresh /></button> : null}
</div>
);
}
export function EmptyState({ title = '暂无符合条件的车辆' }: { title?: string }) {
return <div className="v2-inline-state"><span>{title}</span></div>;
}

View File

@@ -0,0 +1,980 @@
:root {
--v2-bg: #f4f7fb;
--v2-surface: #ffffff;
--v2-text: #152033;
--v2-muted: #718096;
--v2-border: #e4eaf2;
--v2-blue: #1268f3;
--v2-blue-soft: #edf4ff;
--v2-green: #12a46f;
--v2-orange: #f59e0b;
--v2-red: #ef4444;
--v2-shadow: 0 8px 30px rgba(21, 32, 51, 0.06);
--v2-radius: 10px;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--v2-bg); color: var(--v2-text); font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
button, input, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
.v2-auth-screen { display: grid; min-height: 100vh; place-items: center; background: radial-gradient(circle at 50% 10%, #edf4ff 0, #f4f7fb 42%, #eef2f7 100%); padding: 20px; }
.v2-auth-card { display: flex; width: min(390px, 100%); flex-direction: column; align-items: stretch; border: 1px solid var(--v2-border); border-radius: 16px; background: #fff; padding: 34px; box-shadow: 0 22px 70px rgba(21, 32, 51, .12); }
.v2-auth-card > strong { margin-top: 12px; text-align: center; }.v2-auth-mark { display: grid; width: 46px; height: 46px; margin: 0 auto 16px; place-items: center; border-radius: 13px; background: var(--v2-blue); color: #fff; font-size: 20px; font-weight: 800; box-shadow: 0 9px 20px rgba(18, 104, 243, .22); }
.v2-auth-card h1 { margin: 0; text-align: center; font-size: 22px; }.v2-auth-card p { margin: 10px 0 24px; color: var(--v2-muted); text-align: center; font-size: 12px; line-height: 1.7; }
.v2-auth-card label { display: flex; flex-direction: column; gap: 7px; color: #58667a; font-size: 12px; font-weight: 600; }.v2-auth-card input { height: 40px; border: 1px solid #d7e0ec; border-radius: 8px; padding: 0 11px; outline: 0; }.v2-auth-card input:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-auth-card em { margin-top: 9px; color: var(--v2-red); font-size: 11px; font-style: normal; }.v2-auth-card button { height: 40px; margin-top: 18px; border: 0; border-radius: 8px; background: var(--v2-blue); color: #fff; cursor: pointer; font-weight: 700; }.v2-auth-card button:disabled { opacity: .45; cursor: not-allowed; }
.v2-auth-spinner { width: 24px; height: 24px; margin: auto; border: 3px solid #d8e5fa; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
.v2-shell { height: 100vh; height: 100dvh; overflow: hidden; background: var(--v2-bg); }
.v2-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 50; contain: layout paint; display: flex; width: 188px; flex-direction: column; border-right: 1px solid var(--v2-border); background: #fff; }
.v2-brand { display: flex; height: 64px; align-items: center; gap: 11px; border-bottom: 1px solid var(--v2-border); padding: 0 16px; white-space: nowrap; overflow: hidden; }
.v2-brand strong { font-size: 16px; letter-spacing: -.02em; }
.v2-brand-mark { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 9px; background: var(--v2-blue); color: #fff; box-shadow: 0 6px 14px rgba(18, 104, 243, .22); }
.v2-navigation { display: flex; flex: 1; flex-direction: column; gap: 4px; padding: 14px 10px; }
.v2-nav-item { position: relative; display: flex; height: 44px; align-items: center; gap: 12px; border-radius: 8px; padding: 0 14px; color: #59677c; text-decoration: none; white-space: nowrap; overflow: hidden; font-size: 13px; font-weight: 600; transition: background .16s, color .16s; }
.v2-nav-item:hover { background: #f7f9fc; color: var(--v2-text); }
.v2-nav-item.is-active { background: var(--v2-blue-soft); color: var(--v2-blue); }
.v2-nav-item.is-active::before { position: absolute; left: 0; width: 3px; height: 24px; border-radius: 0 3px 3px 0; background: var(--v2-blue); content: ""; }
.v2-nav-operations { margin: 0 10px 8px; }
.v2-collapse { display: flex; height: 48px; align-items: center; gap: 10px; border: 0; border-top: 1px solid var(--v2-border); background: #fff; padding: 0 22px; color: #64748b; cursor: pointer; }
.v2-main { display: flex; height: 100vh; height: 100dvh; min-width: 0; flex-direction: column; margin-left: 188px; }
.v2-topbar { position: sticky; top: 0; z-index: 40; display: flex; height: 64px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); background: rgba(255,255,255,.94); padding: 0 24px; backdrop-filter: blur(16px); }
.v2-topbar h1 { margin: 0; font-size: 20px; letter-spacing: -.035em; }
.v2-topbar-actions { display: flex; align-items: center; gap: 4px; }
.v2-topbar-actions button { display: grid; width: 34px; height: 34px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: #64748b; cursor: pointer; }
.v2-topbar-actions button:hover { background: #f1f5f9; }
.v2-current-user { display: flex; align-items: center; gap: 6px; margin: 0 5px; color: #59677c; font-size: 11px; }.v2-current-user b { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-current-user small, .v2-role-badge { border-radius: 10px; background: var(--v2-blue-soft); padding: 2px 6px; color: var(--v2-blue); font-size: 9px; }
.v2-role-notice { margin: 6px 0; border-radius: 6px; background: #f6f8fb; padding: 8px; color: var(--v2-muted); font-size: 8px; line-height: 1.5; }
.v2-content { min-width: 0; min-height: 0; flex: 1; overflow: auto; }
.v2-sidebar.is-collapsed { width: 68px; }
.v2-sidebar.is-collapsed + .v2-main { margin-left: 68px; }
.v2-sidebar.is-collapsed .v2-brand strong, .v2-sidebar.is-collapsed .v2-nav-label, .v2-sidebar.is-collapsed .v2-collapse span { display: none; }
.v2-sidebar.is-collapsed .v2-brand { padding: 0 17px; }
.v2-sidebar.is-collapsed .v2-nav-item { justify-content: center; padding: 0; }
.v2-sidebar.is-collapsed .v2-collapse { justify-content: center; padding: 0; transform: rotate(180deg); }
.v2-monitor-page { display: flex; height: 100%; min-height: 0; overflow: hidden; flex-direction: column; gap: clamp(10px, 1vh, 14px); padding: clamp(10px, 1vw, 18px); }
.v2-filterbar { display: grid; grid-template-columns: minmax(240px, 1.4fr) minmax(130px, .55fr) minmax(130px, .55fr) auto auto; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 10px 12px; box-shadow: var(--v2-shadow); }
.v2-search-field { display: flex; height: 36px; align-items: center; gap: 8px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 10px; color: #8a98aa; }
.v2-search-field:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--v2-text); font-size: 12px; }
.v2-filterbar select { height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 30px 0 11px; color: #4d5c70; outline: 0; font-size: 12px; }
.v2-primary-button, .v2-secondary-button { display: flex; height: 36px; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; padding: 0 14px; cursor: pointer; font-size: 12px; font-weight: 700; }
.v2-primary-button { border: 1px solid var(--v2-blue); background: var(--v2-blue); color: #fff; box-shadow: 0 5px 12px rgba(18,104,243,.18); }
.v2-secondary-button { border: 1px solid #dce4ef; background: #fff; color: #59677c; }
.v2-kpis { display: grid; grid-template-columns: repeat(7, minmax(90px, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-kpi { position: relative; min-width: 0; padding: 10px 14px; }
.v2-kpi + .v2-kpi::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ""; }
.v2-kpi small { display: block; color: var(--v2-muted); font-size: 11px; }
.v2-kpi strong { display: block; margin-top: 5px; overflow: hidden; color: var(--v2-text); font-size: clamp(16px, 1.45vw, 22px); line-height: 1; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-kpi.is-online strong, .v2-kpi.is-idle strong { color: var(--v2-green); }
.v2-kpi.is-driving strong, .v2-kpi.is-today strong { color: var(--v2-blue); }
.v2-kpi.is-alert strong { color: var(--v2-red); }
.v2-kpi.is-offline strong { color: #7b8798; }
.v2-monitor-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr); grid-template-rows: minmax(0, 1fr); overflow: hidden; border: 1px solid #dce4ee; border-radius: 8px; background: #fff; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
.v2-monitor-workspace.is-detail-open { grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr) clamp(300px, 17vw, 360px); }
.v2-monitor-workspace.is-detail-collapsed { grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr) 44px; }
.v2-vehicle-rail { display: flex; min-width: 0; min-height: 0; flex-direction: column; border-right: 1px solid var(--v2-border); }
.v2-vehicle-rail > header { display: flex; height: 46px; align-items: center; justify-content: space-between; padding: 0 12px; }
.v2-vehicle-rail > header strong { font-size: 13px; }
.v2-vehicle-rail > header span, .v2-vehicle-rail > footer { color: var(--v2-muted); font-size: 10px; }
.v2-rail-search { display: flex; height: 34px; align-items: center; gap: 7px; margin: 0 9px 8px; border: 1px solid var(--v2-border); border-radius: 7px; padding: 0 9px; color: #8a98aa; font-size: 10px; }
.v2-vehicle-scroll { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; content-visibility: auto; }
.v2-vehicle-row { display: grid; width: 100%; min-height: 60px; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 0; border-top: 1px solid #eef2f7; background: #fff; padding: 8px 10px; text-align: left; cursor: pointer; }
.v2-vehicle-row:hover { background: #f8fbff; }
.v2-vehicle-row.is-selected { position: relative; z-index: 1; background: var(--v2-blue-soft); box-shadow: inset 3px 0 var(--v2-blue); }
.v2-status-dot { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }
.v2-status-dot.is-online, .v2-status-dot.is-idle { background: var(--v2-green); }
.v2-status-dot.is-driving { background: var(--v2-blue); }
.v2-status-dot.is-offline { background: #aab3c0; }
.v2-status-dot.is-alert { background: var(--v2-red); }
.v2-vehicle-identity, .v2-vehicle-motion { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.v2-vehicle-identity strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.v2-vehicle-identity small, .v2-vehicle-motion small { overflow: hidden; color: #8996a8; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.v2-vehicle-motion { align-items: flex-end; }
.v2-vehicle-motion strong { font-size: 11px; font-variant-numeric: tabular-nums; }
.v2-vehicle-motion strong small { font-size: 8px; font-weight: 500; }
.v2-vehicle-rail > footer { display: flex; height: 34px; align-items: center; justify-content: center; border-top: 1px solid var(--v2-border); }
.v2-list-loading { display: flex; min-height: 100px; align-items: center; justify-content: center; gap: 8px; color: var(--v2-muted); font-size: 11px; }
.v2-fleet-map { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #e8f0f6; }
.v2-fleet-map-canvas { position: absolute; inset: 0; }
.v2-map-controls { position: absolute; top: 12px; right: 12px; z-index: 5; display: flex; align-items: stretch; gap: 8px; }
.v2-map-layer-control { display: grid; height: 44px; grid-template-columns: auto auto auto; align-items: center; gap: 8px; border: 1px solid #d7e1ee; border-radius: 8px; background: #fff; padding: 0 10px; color: #435168; box-shadow: 0 5px 16px rgba(21,32,51,.1); cursor: pointer; }
.v2-map-layer-control > svg { color: var(--v2-blue); font-size: 15px; }
.v2-map-layer-control span { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
.v2-map-layer-control strong { font-size: 10px; line-height: 1.2; }
.v2-map-layer-control small { color: #7b8ba0; font-size: 8px; }
.v2-map-layer-control > i { position: relative; width: 24px; height: 14px; border-radius: 8px; background: #cbd5e1; transition: background .16s; }
.v2-map-layer-control > i::after { position: absolute; top: 2px; left: 2px; width: 10px; height: 10px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.25); content: ""; transition: transform .16s; }
.v2-map-layer-control > i.is-on { background: var(--v2-blue); }
.v2-map-layer-control > i.is-on::after { transform: translateX(10px); }
.v2-map-follow-control { display: grid; height: 44px; grid-template-columns: auto auto; align-items: center; gap: 8px; border: 1px solid #d7e1ee; border-radius: 8px; background: #fff; padding: 0 11px; color: #607086; box-shadow: 0 5px 16px rgba(21,32,51,.1); cursor: pointer; transition: border-color .16s, background .16s, color .16s; }
.v2-map-follow-control > svg { font-size: 16px; }
.v2-map-follow-control span { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
.v2-map-follow-control strong { font-size: 10px; line-height: 1.2; }
.v2-map-follow-control small { color: #8a98aa; font-size: 8px; }
.v2-map-follow-control.is-active { border-color: #9ec0f7; background: #eef5ff; color: #1268f3; }
.v2-map-follow-control.is-active small { color: #4e82cf; }
.v2-map-selection-marker { position: relative; width: 48px; height: 48px; pointer-events: none; }
.v2-map-selection-marker > i { position: absolute; inset: 4px; border: 2px solid rgba(18,104,243,.6); border-radius: 50%; animation: v2-map-ripple 2s ease-out infinite; }
.v2-map-selection-marker > i:nth-child(2) { animation-delay: 1s; }
.v2-map-selection-marker > b { position: absolute; top: 18px; left: 18px; width: 12px; height: 12px; border: 3px solid #fff; border-radius: 50%; background: var(--v2-blue); box-shadow: 0 2px 8px rgba(18,104,243,.45); }
.v2-map-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 9px; background: #eef3f7; color: #637083; font-size: 12px; }
.v2-map-state.is-loading { background: rgba(255,255,255,.82); backdrop-filter: blur(2px); }
.v2-map-state.is-error { color: #b42318; }
.v2-map-legend { position: absolute; bottom: 12px; left: 50%; display: flex; width: max-content; max-width: calc(100% - 28px); height: 36px; align-items: center; justify-content: center; gap: 18px; border: 1px solid #d8e2ed; border-radius: 8px; background: #fff; padding: 0 16px; color: #5f6e82; box-shadow: 0 6px 18px rgba(21,32,51,.1); font-size: 9px; transform: translateX(-50%); }
.v2-map-legend span { display: inline-flex; align-items: center; gap: 5px; }
.v2-map-legend i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }
.v2-map-legend .is-driving { background: var(--v2-blue); }
.v2-map-legend .is-idle { background: var(--v2-green); }
.v2-map-legend .is-alert { background: var(--v2-red); }
.v2-map-legend b { margin-left: 6px; color: #4d5c70; font-weight: 600; }
.v2-vehicle-detail { position: relative; min-width: 0; min-height: 0; contain: layout paint; overflow: auto; border-left: 1px solid var(--v2-border); background: #fff; padding: 14px; animation: v2-panel-enter .14s ease-out; }
.v2-detail-controls { position: absolute; top: 10px; right: 10px; z-index: 1; display: flex; gap: 4px; }
.v2-detail-controls button { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; color: #68768a; cursor: pointer; transition: border-color .16s, background .16s, color .16s; }
.v2-detail-controls button:hover { border-color: #a9c5f2; background: #f1f6ff; color: var(--v2-blue); }
.v2-detail-controls button:last-child:hover { border-color: #f0b9b9; background: #fff5f5; color: var(--v2-red); }
.v2-detail-title { padding: 2px 68px 12px 0; border-bottom: 1px solid var(--v2-border); }
.v2-detail-title > div { display: flex; align-items: center; gap: 8px; }
.v2-detail-title strong { font-size: 16px; }
.v2-detail-title small { display: block; margin-top: 4px; color: var(--v2-muted); font-size: 9px; }
.v2-status-text { color: var(--v2-muted); font-size: 10px; font-weight: 700; }
.v2-status-text.is-driving { color: var(--v2-blue); }
.v2-status-text.is-idle, .v2-status-text.is-online { color: var(--v2-green); }
.v2-status-text.is-alert { color: var(--v2-red); }
.v2-detail-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: 10px 0; }
.v2-detail-actions a { display: flex; height: 30px; align-items: center; justify-content: center; border: 1px solid #dce4ef; border-radius: 6px; color: #59677c; text-decoration: none; font-size: 9px; font-weight: 700; }
.v2-detail-actions a:first-child { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }
.v2-detail-peek { min-width: 0; min-height: 0; contain: layout paint; border-left: 1px solid var(--v2-border); background: #fff; animation: v2-panel-enter .12s ease-out; }
.v2-detail-peek button { display: flex; width: 100%; height: 100%; align-items: center; flex-direction: column; gap: 10px; border: 0; background: #fff; padding: 14px 8px; color: #65758a; cursor: pointer; transition: background .16s, color .16s; }
.v2-detail-peek button:hover { background: #f2f7ff; color: var(--v2-blue); }
.v2-detail-peek button > svg { flex: 0 0 auto; font-size: 15px; }
.v2-detail-peek button > span { overflow: hidden; writing-mode: vertical-rl; color: #435168; font-size: 10px; font-weight: 700; letter-spacing: 1px; text-overflow: ellipsis; }
.v2-vehicle-detail section { padding: 10px 0; border-top: 1px solid var(--v2-border); }
.v2-vehicle-detail h3 { margin: 0 0 9px; font-size: 11px; }
.v2-detail-list { margin: 0; }
.v2-detail-list > div { display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 8px; padding: 5px 0; font-size: 9px; }
.v2-detail-list dt { color: var(--v2-muted); }
.v2-detail-list dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #3d4a5e; }
.v2-metric-grid { display: grid; grid-template-columns: repeat(2, 1fr); border: 1px solid var(--v2-border); border-radius: 7px; }
.v2-metric-grid > div { min-width: 0; padding: 9px; }
.v2-metric-grid > div:nth-child(even) { border-left: 1px solid var(--v2-border); }
.v2-metric-grid > div:nth-child(n+3) { border-top: 1px solid var(--v2-border); }
.v2-metric-grid small { display: block; color: var(--v2-muted); font-size: 8px; }
.v2-metric-grid strong { display: block; margin-top: 4px; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
.v2-metric-grid em { margin-left: 2px; color: var(--v2-muted); font-size: 7px; font-style: normal; font-weight: 500; }
.v2-event-strip { display: flex; min-height: 48px; align-items: center; gap: 22px; border: 1px solid #dfe6ef; border-radius: 8px; background: #fff; padding: 0 16px; box-shadow: 0 3px 12px rgba(21,32,51,.035); color: #6d7a8d; font-size: 10px; }
.v2-event-strip strong { color: var(--v2-text); font-size: 11px; }
.v2-event-strip span { display: inline-flex; align-items: center; gap: 6px; }
.v2-event-strip i { width: 7px; height: 7px; border-radius: 50%; background: var(--v2-green); }
.v2-event-strip time { margin-left: auto; font-variant-numeric: tabular-nums; }
.v2-refresh-cadence { border-left: 1px solid var(--v2-border); padding-left: 18px; }
.v2-refresh-cadence b { color: var(--v2-blue); font-weight: 700; }
.v2-page-state, .v2-inline-state { display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--v2-muted); font-size: 12px; }
.v2-page-state { min-height: calc(100vh - 64px); }
.v2-inline-state { min-height: 76px; border: 1px dashed var(--v2-border); border-radius: 8px; padding: 12px; }
.v2-inline-state.is-error { justify-content: flex-start; min-height: 42px; border-style: solid; border-color: #fecaca; background: #fff5f5; color: #b42318; }
.v2-inline-state button { display: inline-flex; align-items: center; gap: 5px; margin-left: auto; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 11px; }
.v2-spinner { width: 14px; height: 14px; border: 2px solid #cfe0fb; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
.v2-module-stage { margin: 18px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 28px; box-shadow: var(--v2-shadow); }
.v2-module-stage h2 { margin: 0; font-size: 20px; }
.v2-module-stage p { margin: 10px 0 0; color: var(--v2-muted); font-size: 13px; }
.v2-ops-page { display: flex; min-height: 100%; flex-direction: column; gap: 10px; padding: 12px 16px 16px; }
.v2-ops-heading { display: flex; align-items: center; justify-content: space-between; }.v2-ops-heading h2 { margin: 0; font-size: 18px; }.v2-ops-heading p { margin: 4px 0 0; color: var(--v2-muted); font-size: 10px; }.v2-ops-heading button { display: flex; height: 32px; align-items: center; gap: 6px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 11px; color: #526176; cursor: pointer; font-size: 10px; }
.v2-ops-kpis { display: grid; grid-template-columns: repeat(5,1fr); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }.v2-ops-kpis article { position: relative; min-width: 0; padding: 13px 15px; }.v2-ops-kpis article + article::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ''; }.v2-ops-kpis small { display: block; color: var(--v2-muted); font-size: 9px; }.v2-ops-kpis strong { display: block; margin: 6px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 16px; }.v2-ops-kpis span { color: #8793a5; font-size: 8px; }
.v2-ops-grid { display: grid; min-height: 280px; grid-template-columns: minmax(460px,1.5fr) minmax(280px,.8fr); gap: 10px; }.v2-ops-links, .v2-ops-runtime, .v2-ops-sources { border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }.v2-ops-links > header, .v2-ops-runtime > header, .v2-ops-sources > header { display: flex; height: 42px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 13px; }.v2-ops-links header strong, .v2-ops-runtime header strong, .v2-ops-sources header strong { font-size: 11px; }.v2-ops-links header span, .v2-ops-sources header span { color: var(--v2-muted); font-size: 8px; }
.v2-ops-links article { display: grid; min-height: 46px; grid-template-columns: 8px minmax(0,1fr) auto; align-items: center; gap: 9px; border-bottom: 1px solid #eef2f7; padding: 7px 13px; }.v2-ops-links article > i, .v2-ops-sources article i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }.v2-ops-links article > i.is-ok, .v2-ops-sources article i.is-ok { background: var(--v2-green); }.v2-ops-links article > i.is-warning, .v2-ops-sources article i.is-warning { background: var(--v2-orange); }.v2-ops-links article > i.is-error, .v2-ops-sources article i.is-error { background: var(--v2-red); }.v2-ops-links article strong { font-size: 9px; }.v2-ops-links article p { margin: 3px 0 0; color: var(--v2-muted); font-size: 8px; }.v2-ops-links article > span { border-radius: 10px; background: #f1f5f9; padding: 3px 7px; color: #64748b; font-size: 8px; }
.v2-ops-runtime dl { margin: 0; padding: 7px 13px; }.v2-ops-runtime dl div { display: flex; min-height: 31px; align-items: center; justify-content: space-between; border-bottom: 1px solid #eef2f7; font-size: 9px; }.v2-ops-runtime dt { color: var(--v2-muted); }.v2-ops-runtime dd { margin: 0; }.v2-ops-clear, .v2-ops-findings { margin: 4px 13px 12px; border-radius: 6px; background: #f1fbf7; padding: 8px; color: #17815d; font-size: 8px; }.v2-ops-findings { background: #fff7ed; color: #b45309; }.v2-ops-findings p { margin: 3px 0; }
.v2-ops-sources > div { display: grid; grid-template-columns: repeat(3,1fr); }.v2-ops-sources article { min-width: 0; padding: 12px 14px; }.v2-ops-sources article + article { border-left: 1px solid var(--v2-border); }.v2-ops-sources article > div { display: flex; align-items: center; gap: 7px; }.v2-ops-sources article strong { font-size: 10px; }.v2-ops-sources article span { margin-left: auto; color: var(--v2-muted); font-size: 8px; }.v2-ops-sources article b { display: block; margin-top: 9px; font-size: 14px; }.v2-ops-sources article p { margin: 6px 0 0; color: #68768a; font-size: 8px; line-height: 1.45; }.v2-ops-sources article em { display: block; margin-top: 7px; color: var(--v2-blue); font-size: 8px; font-style: normal; }.is-ok { color: var(--v2-green) !important; }.is-warning { color: #b87900 !important; }.is-error { color: var(--v2-red) !important; }
.v2-vehicle-search-page, .v2-not-found { display: grid; min-height: 100%; place-items: center; padding: 28px; }
.v2-vehicle-search-card { width: min(660px, 100%); border: 1px solid var(--v2-border); border-radius: 16px; background: #fff; padding: 54px; text-align: center; box-shadow: var(--v2-shadow); }
.v2-search-hero-icon { display: grid; width: 54px; height: 54px; margin: 0 auto 18px; place-items: center; border-radius: 15px; background: var(--v2-blue-soft); color: var(--v2-blue); }
.v2-vehicle-search-card h2, .v2-not-found h2 { margin: 0; font-size: 22px; }
.v2-vehicle-search-card > p, .v2-not-found p { margin: 10px auto 26px; color: var(--v2-muted); font-size: 13px; line-height: 1.7; }
.v2-vehicle-search-card form { display: flex; height: 46px; align-items: center; gap: 10px; border: 1px solid #cfd9e7; border-radius: 9px; padding-left: 14px; color: #8a98aa; }
.v2-vehicle-search-card form:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 4px rgba(18,104,243,.08); }
.v2-vehicle-search-card input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--v2-text); }
.v2-vehicle-search-card button, .v2-not-found a { display: inline-flex; height: 46px; align-items: center; gap: 7px; border: 0; border-radius: 8px; background: var(--v2-blue); padding: 0 20px; color: #fff; text-decoration: none; cursor: pointer; font-weight: 700; }
.v2-vehicle-search-card .v2-profile-sync-open { height: 32px; margin-top: 16px; border: 1px solid #d5e3f7; background: #f6f9fe; color: var(--v2-blue); font-size: 10px; font-weight: 600; }
.v2-vehicle-search-page.has-sync-panel { align-content: center; gap: 14px; overflow: auto; }
.v2-profile-sync-panel { width: min(900px, 100%); border: 1px solid var(--v2-border); border-radius: 12px; background: #fff; padding: 16px; box-shadow: var(--v2-shadow); }
.v2-profile-sync-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.v2-profile-sync-panel > header strong { font-size: 14px; }.v2-profile-sync-panel > header p { margin: 5px 0 0; color: var(--v2-muted); font-size: 9px; }.v2-profile-sync-panel > header button { border: 0; background: transparent; color: var(--v2-muted); cursor: pointer; font-size: 9px; }
.v2-profile-sync-fields { display: grid; grid-template-columns: 1fr 1fr .8fr 1.2fr; gap: 10px; margin-top: 14px; }.v2-profile-sync-fields label { display: grid; min-width: 0; gap: 5px; color: var(--v2-muted); font-size: 9px; }.v2-profile-sync-fields input, .v2-profile-sync-fields select { min-width: 0; height: 34px; border: 1px solid var(--v2-border); border-radius: 6px; background: #fff; padding: 0 9px; color: var(--v2-text); font-size: 10px; }.v2-profile-sync-fields input[type="file"] { padding: 6px; }
.v2-profile-sync-format, .v2-profile-sync-file, .v2-profile-sync-error, .v2-profile-sync-warning { margin: 10px 0 0; color: var(--v2-muted); font-size: 9px; line-height: 1.5; }.v2-profile-sync-format code { overflow-wrap: anywhere; color: #53657c; }.v2-profile-sync-file { color: var(--v2-blue); }.v2-profile-sync-error { color: var(--v2-red); }.v2-profile-sync-warning { border-radius: 6px; background: #fff7ed; padding: 7px 9px; color: #b45309; }
.v2-profile-sync-result { margin-top: 12px; border: 1px solid #e4ebf4; border-radius: 8px; background: #fbfcfe; padding: 10px; }.v2-profile-sync-result > div { display: grid; grid-template-columns: repeat(6,1fr); }.v2-profile-sync-result > div span { color: var(--v2-muted); text-align: center; font-size: 8px; }.v2-profile-sync-result > div strong { display: block; margin-top: 4px; color: var(--v2-text); font-size: 14px; }.v2-profile-sync-result > p { margin: 8px 0 0; color: var(--v2-green); font-size: 9px; }.v2-profile-sync-result ul { max-height: 104px; margin: 9px 0 0; overflow: auto; border-top: 1px solid #e7edf5; padding: 5px 0 0; list-style: none; }.v2-profile-sync-result li { display: flex; justify-content: space-between; gap: 10px; padding: 4px 2px; font-size: 8px; }.v2-profile-sync-result li span { color: var(--v2-muted); }
.v2-profile-sync-panel > footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }.v2-profile-sync-panel > footer button { height: 32px; border: 1px solid var(--v2-border); border-radius: 6px; background: #fff; padding: 0 12px; color: var(--v2-text); cursor: pointer; font-size: 9px; }.v2-profile-sync-panel > footer button.is-primary { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }.v2-profile-sync-panel > footer button:disabled { cursor: not-allowed; opacity: .5; }
.v2-not-found { align-content: center; text-align: center; color: var(--v2-muted); }
.v2-not-found a { height: 38px; margin: 0 auto; }
.v2-page-error { padding: 18px; }
.v2-vehicle-record-page { display: flex; min-height: 100%; flex-direction: column; gap: 12px; padding: 12px 16px 16px; }
.v2-identity-band { display: grid; min-height: 96px; grid-template-columns: minmax(260px, 1fr) minmax(340px, 1.25fr) auto; align-items: center; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-identity-primary, .v2-identity-meta, .v2-identity-actions { min-width: 0; padding: 15px 18px; }
.v2-identity-primary { display: grid; grid-template-columns: auto auto 1fr; align-items: center; gap: 6px 14px; }
.v2-plate { display: inline-flex; min-height: 36px; align-items: center; gap: 8px; border: 1px solid #b9d2fb; border-radius: 7px; background: var(--v2-blue-soft); padding: 0 12px; color: var(--v2-blue); font-size: 16px; font-weight: 800; }
.v2-online-label { display: inline-flex; align-items: center; gap: 6px; color: #8793a5; font-size: 11px; font-weight: 700; }
.v2-online-label i { width: 7px; height: 7px; border-radius: 50%; background: #aab3c0; }
.v2-online-label.is-online { color: var(--v2-green); }
.v2-online-label.is-online i { background: var(--v2-green); }
.v2-identity-primary small { grid-row: 2; color: var(--v2-muted); font-size: 9px; }
.v2-identity-primary b { grid-row: 2; overflow: hidden; color: #445166; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.v2-identity-primary button { grid-row: 2; border: 0; background: transparent; color: #718096; cursor: pointer; }
.v2-identity-meta { display: grid; grid-template-columns: 1fr 1fr; align-self: stretch; align-items: center; border-right: 1px solid var(--v2-border); border-left: 1px solid var(--v2-border); }
.v2-identity-meta > div + div { border-left: 1px solid var(--v2-border); padding-left: 22px; }
.v2-identity-meta small { color: var(--v2-muted); font-size: 10px; }
.v2-identity-meta p { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0 0; }
.v2-identity-meta p span, .v2-telemetry-card footer span { border: 1px solid #dde5ef; border-radius: 5px; background: #f7f9fc; padding: 3px 7px; color: #526176; font-size: 9px; }
.v2-identity-meta strong { display: block; margin-top: 8px; color: #445166; font-size: 11px; font-variant-numeric: tabular-nums; }
.v2-identity-actions { display: flex; gap: 8px; }
.v2-identity-actions a { display: inline-flex; height: 38px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 14px; color: #536177; text-decoration: none; white-space: nowrap; font-size: 10px; font-weight: 700; }
.v2-identity-actions a:hover { border-color: #a8c7f9; color: var(--v2-blue); }
.v2-record-grid { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(520px, 1.75fr) minmax(300px, 1fr); grid-template-rows: minmax(300px, 1.05fr) auto minmax(270px, .95fr); gap: 12px; }
.v2-record-card, .v2-single-map-card { min-width: 0; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-record-card > header { display: flex; height: 40px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 14px; }
.v2-record-card > header strong { font-size: 12px; }
.v2-record-card > header span, .v2-record-card > header a { color: var(--v2-muted); text-decoration: none; font-size: 9px; }
.v2-single-map-card { position: relative; display: flex; min-height: 300px; flex-direction: column; }
.v2-single-map-card .v2-fleet-map { flex: 1; }
.v2-single-map-card .v2-map-legend { display: none; }
.v2-single-map-card footer { display: flex; min-height: 38px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 14px; color: #64748b; font-size: 9px; }
.v2-single-map-card footer span { display: inline-flex; min-width: 0; align-items: center; gap: 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-single-map-card footer time { white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-archive-card { grid-column: 2; grid-row: 1; }
.v2-record-list { margin: 0; padding: 8px 14px 4px; }
.v2-record-list > div { display: grid; grid-template-columns: 92px minmax(0, 1fr); padding: 6px 0; font-size: 10px; }
.v2-record-list dt { color: var(--v2-muted); }
.v2-record-list dd { margin: 0; overflow-wrap: anywhere; }
.v2-record-list dd i { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: #aab3c0; }
.v2-record-list dd i.is-online { background: var(--v2-green); }
.v2-record-note { margin: 3px 14px 12px; border-radius: 6px; background: #f7f9fc; padding: 8px 10px; color: #79869a; font-size: 8px; line-height: 1.5; }
.v2-profile-heading { display: flex; align-items: center; gap: 8px; }.v2-profile-heading button { border: 0; border-radius: 5px; background: var(--v2-blue-soft); padding: 4px 7px; color: var(--v2-blue); font-size: 9px; cursor: pointer; }.v2-profile-form { display: grid; grid-template-columns: 1fr 1fr; gap: 7px 10px; padding: 10px 14px 12px; }.v2-profile-form label { display: grid; gap: 3px; color: var(--v2-muted); font-size: 8px; }.v2-profile-form input, .v2-profile-form select { min-width: 0; height: 29px; border: 1px solid var(--v2-border); border-radius: 5px; background: #fff; padding: 0 7px; color: var(--v2-text); font-size: 9px; }.v2-profile-form > p { grid-column: 1 / -1; margin: 0; color: var(--v2-red); font-size: 8px; }.v2-profile-form footer { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 7px; }.v2-profile-form footer button { height: 28px; border: 1px solid var(--v2-border); border-radius: 5px; background: #fff; padding: 0 10px; font-size: 9px; cursor: pointer; }.v2-profile-form footer button.is-primary { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }
.v2-live-card { grid-column: 2; grid-row: 2; }
.v2-live-card > header span { display: inline-flex; align-items: center; gap: 5px; }
.v2-live-grid { display: grid; grid-template-columns: repeat(3, 1fr); }
.v2-live-grid > div { min-width: 0; padding: 12px 14px; }
.v2-live-grid > div + div { border-left: 1px solid var(--v2-border); }
.v2-live-grid > div:nth-child(4) { border-left: 0; }
.v2-live-grid > div:nth-child(n+4) { border-top: 1px solid var(--v2-border); }
.v2-live-grid small { display: block; color: var(--v2-muted); font-size: 9px; }
.v2-live-grid strong { display: block; margin-top: 5px; overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-live-grid em { margin-left: 3px; color: var(--v2-muted); font-size: 8px; font-style: normal; font-weight: 500; }
.v2-telemetry-card { grid-column: 1; grid-row: 2 / span 2; }
.v2-telemetry-card nav { display: flex; height: 42px; align-items: stretch; border-bottom: 1px solid var(--v2-border); padding: 0 8px; overflow-x: auto; }
.v2-telemetry-card nav button { position: relative; min-width: 78px; border: 0; background: transparent; color: #64748b; cursor: pointer; font-size: 10px; }
.v2-telemetry-card nav button.is-active { color: var(--v2-blue); font-weight: 700; }
.v2-telemetry-card nav button.is-active::after { position: absolute; right: 10px; bottom: 0; left: 10px; height: 2px; border-radius: 2px; background: var(--v2-blue); content: ''; }
.v2-telemetry-card nav button span { margin-left: 3px; color: #9aa6b7; font-size: 8px; }
.v2-telemetry-list { display: grid; grid-template-columns: 1fr 1fr; padding: 6px 14px; }
.v2-telemetry-list > div { display: grid; min-width: 0; grid-template-columns: minmax(130px, 1fr) auto 58px; align-items: center; gap: 10px; border-bottom: 1px solid #eef2f7; padding: 9px 8px; font-size: 10px; }
.v2-telemetry-list > div:nth-child(odd) { border-right: 1px solid var(--v2-border); }
.v2-telemetry-list > div > span { min-width: 0; color: #556276; }
.v2-telemetry-list > div > span small { display: block; margin-top: 3px; overflow: hidden; color: #a0aaba; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.v2-telemetry-list strong { font-size: 10px; font-variant-numeric: tabular-nums; }
.v2-telemetry-list strong em { color: #8a96a8; font-size: 8px; font-style: normal; font-weight: 500; }
.v2-telemetry-list time { display: grid; justify-items: end; gap: 3px; color: #8a96a8; font-size: 8px; text-align: right; }
.v2-telemetry-list time i { border-radius: 8px; background: #eef2f7; padding: 1px 5px; color: #778397; font-size: 7px; font-style: normal; }
.v2-telemetry-list time i.is-good { background: #e8f8f1; color: #16845b; }
.v2-telemetry-list time i.is-stale { background: #fff6df; color: #a66b00; }
.v2-telemetry-list time i.is-warning { background: #fff0f0; color: var(--v2-red); }
.v2-telemetry-list .v2-empty-compact { display: flex; min-height: 94px; grid-column: 1 / -1; justify-content: center; border: 0; }
.v2-telemetry-list .v2-empty-compact.is-error { color: var(--v2-red); }
.v2-telemetry-card footer { display: flex; align-items: center; gap: 6px; margin: 8px 10px 10px; border: 1px solid var(--v2-border); border-radius: 7px; padding: 8px 10px; color: #718096; font-size: 9px; }
.v2-telemetry-card footer b { color: #526176; font-size: 9px; }
.v2-telemetry-card footer small { margin-left: auto; color: #8a96a8; font-size: 8px; }
.v2-events-card { grid-column: 2; grid-row: 3; }
.v2-events-card > header a { color: var(--v2-blue); }
.v2-event-list { padding: 2px 14px 8px; }
.v2-event-row { position: relative; display: grid; min-height: 40px; grid-template-columns: 23px minmax(0, 1fr) auto; align-items: center; gap: 7px; }
.v2-event-row:not(:last-child)::after { position: absolute; top: 31px; bottom: -9px; left: 10px; width: 1px; background: var(--v2-border); content: ''; }
.v2-event-icon { z-index: 1; display: grid; width: 20px; height: 20px; place-items: center; border-radius: 50%; background: #eef3f8; color: #8492a6; }
.v2-event-row.is-success .v2-event-icon { background: #e8f8f1; color: var(--v2-green); }
.v2-event-row.is-error .v2-event-icon { background: #fff0f0; color: var(--v2-red); }
.v2-event-row.is-warning .v2-event-icon { background: #fff7e5; color: var(--v2-orange); }
.v2-event-row strong { font-size: 9px; }
.v2-event-row p { margin: 2px 0 0; overflow: hidden; color: #7f8b9d; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.v2-event-row time { color: #8d99aa; font-size: 8px; font-variant-numeric: tabular-nums; }
.v2-empty-compact { display: flex; min-height: 80px; align-items: center; color: var(--v2-muted); font-size: 10px; }
.v2-track-page { display: flex; height: 100%; min-height: 0; flex-direction: column; gap: 12px; overflow: hidden; padding: 12px 16px 16px; }
.v2-track-toolbar { display: grid; flex: 0 0 auto; grid-template-columns: minmax(210px, 1.15fr) minmax(170px, .85fr) minmax(170px, .85fr) minmax(140px, .65fr) auto auto; align-items: end; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 11px 13px; box-shadow: var(--v2-shadow); }
.v2-track-toolbar label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: var(--v2-muted); font-size: 9px; }
.v2-track-toolbar label > div { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 10px; color: #8996a8; }
.v2-track-toolbar input, .v2-track-toolbar select { min-width: 0; height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 9px; color: #435168; outline: 0; font-size: 10px; }
.v2-track-toolbar label > div input { height: auto; flex: 1; border: 0; padding: 0; }
.v2-track-toolbar input:focus, .v2-track-toolbar select:focus, .v2-track-toolbar label > div:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-track-toolbar button:disabled { opacity: .45; cursor: not-allowed; }
.v2-track-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(540px, 1fr) 330px; gap: 12px; }
.v2-track-main { display: grid; min-width: 0; min-height: 0; grid-template-rows: 34px minmax(280px, 1fr) 58px 108px; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-track-coverage { display: flex; min-width: 0; align-items: center; gap: 10px; border-bottom: 1px solid var(--v2-border); padding: 0 13px; background: #f8fbff; color: #617086; font-size: 8px; }
.v2-track-coverage::before { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--v2-green); content: ''; }
.v2-track-coverage.is-limited { background: #fff8ed; color: #8b5b19; }
.v2-track-coverage.is-limited::before { background: var(--v2-orange); }
.v2-track-coverage.is-empty::before { background: #9aa7b8; }
.v2-track-coverage strong { color: inherit; font-size: 9px; white-space: nowrap; }
.v2-track-coverage span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-coverage em { margin-left: auto; color: inherit; font-style: normal; white-space: nowrap; }
.v2-track-canvas-wrap { position: relative; min-height: 0; overflow: hidden; }
.v2-track-map, .v2-track-map-canvas { position: absolute; inset: 0; }
.v2-track-loading { position: absolute; z-index: 20; top: 12px; left: 50%; display: flex; height: 32px; align-items: center; gap: 7px; transform: translateX(-50%); border: 1px solid var(--v2-border); border-radius: 7px; background: rgba(255,255,255,.94); padding: 0 12px; color: #637083; box-shadow: var(--v2-shadow); font-size: 9px; }
.v2-track-map-legend { position: absolute; right: 14px; bottom: 12px; display: flex; height: 32px; align-items: center; gap: 12px; border: 1px solid rgba(220,228,239,.9); border-radius: 7px; background: rgba(255,255,255,.92); padding: 0 11px; color: #637083; box-shadow: 0 7px 18px rgba(21,32,51,.08); backdrop-filter: blur(8px); font-size: 8px; }
.v2-track-map-legend span { display: inline-flex; align-items: center; gap: 5px; }
.v2-track-map-legend i { width: 7px; height: 7px; border-radius: 50%; background: var(--v2-blue); }
.v2-track-map-legend i.is-start { background: var(--v2-green); }
.v2-track-map-legend i.is-end { background: var(--v2-red); }
.v2-track-map-legend b { margin-left: 3px; font-weight: 600; }
.v2-track-marker { display: grid; width: 24px; height: 24px; place-items: center; border: 3px solid #fff; border-radius: 50%; background: var(--v2-blue); color: #fff; box-shadow: 0 3px 10px rgba(21,32,51,.25); font-size: 8px; font-weight: 800; }
.v2-track-marker.is-start { background: var(--v2-green); }
.v2-track-marker.is-end { background: var(--v2-red); }
.v2-track-marker.is-event { width: 21px; height: 21px; border-width: 2px; }
.v2-track-current-marker { display: grid; width: 28px; height: 28px; place-items: center; border: 2px solid rgba(18,104,243,.22); border-radius: 50%; background: rgba(18,104,243,.15); box-shadow: 0 0 0 6px rgba(18,104,243,.08); }
.v2-track-current-marker span { width: 12px; height: 12px; border: 3px solid #fff; border-radius: 50%; background: var(--v2-blue); box-shadow: 0 2px 7px rgba(18,104,243,.45); }
.v2-track-empty { display: flex; height: 100%; align-items: center; justify-content: center; flex-direction: column; color: #8b98aa; text-align: center; }
.v2-track-empty strong { margin-top: 10px; color: #4e5b6f; font-size: 13px; }
.v2-track-empty p { margin: 6px 0 0; font-size: 9px; }
.v2-track-timeline { display: grid; min-width: 0; grid-template-columns: 142px minmax(0, 1fr); align-items: center; gap: 10px; border-top: 1px solid var(--v2-border); border-bottom: 1px solid var(--v2-border); padding: 7px 13px; color: #657286; font-size: 8px; }
.v2-track-timeline header { display: flex; flex-direction: column; gap: 3px; }
.v2-track-timeline header strong { color: #3f4e64; font-size: 9px; }
.v2-track-timeline header span { color: #8793a5; }
.v2-track-timeline > div { display: flex; min-width: 0; height: 24px; overflow: hidden; border-radius: 5px; background: #edf2f7; }
.v2-track-timeline button { display: flex; min-width: 8px; flex: 1; align-items: center; justify-content: center; gap: 4px; overflow: hidden; border: 0; border-right: 1px solid rgba(255,255,255,.8); background: #dff3e8; padding: 0 4px; color: #26734d; cursor: pointer; }
.v2-track-timeline button:hover { filter: brightness(.96); }
.v2-track-timeline button.is-stopped { background: #fff0cf; color: #92600d; }
.v2-track-timeline button.is-gap { background: #fde4e4; color: #a53b3b; }
.v2-track-timeline button i { width: 5px; height: 5px; flex: 0 0 5px; border-radius: 50%; background: currentColor; }
.v2-track-timeline button span { overflow: hidden; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-timeline > em { grid-column: 2; margin-top: -5px; color: #9a6b20; font-size: 7px; font-style: normal; }
.v2-track-timeline.is-empty { display: flex; justify-content: center; color: #8996a8; }
.v2-track-playback { display: grid; grid-template-columns: 220px minmax(230px, 1fr) minmax(320px, 1.3fr); align-items: center; min-width: 0; padding: 10px 13px; }
.v2-play-controls { min-width: 0; }
.v2-play-controls small { display: block; margin-bottom: 6px; color: #64748b; font-size: 9px; }
.v2-play-controls p { display: flex; gap: 6px; margin: 0; }
.v2-play-controls button, .v2-play-controls select { display: grid; width: 34px; height: 32px; place-items: center; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; color: #536177; cursor: pointer; }
.v2-play-controls button:first-child { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }
.v2-play-controls button:disabled { opacity: .35; cursor: not-allowed; }
.v2-play-controls select { display: block; width: 55px; padding: 0 7px; font-size: 9px; }
.v2-play-progress { min-width: 0; border-left: 1px solid var(--v2-border); padding: 0 18px; }
.v2-play-progress header { display: flex; justify-content: space-between; color: #7f8b9d; font-size: 8px; }
.v2-play-progress header strong { color: #48566b; font-size: 10px; }
.v2-play-progress input { width: 100%; height: 4px; margin: 10px 0 6px; accent-color: var(--v2-blue); cursor: pointer; }
.v2-play-progress footer { color: #738095; font-size: 8px; }
.v2-current-metrics { display: grid; min-width: 0; grid-template-columns: .7fr .9fr 1.6fr .8fr; margin: 0; border-left: 1px solid var(--v2-border); }
.v2-current-metrics > div { min-width: 0; padding: 4px 11px; }
.v2-current-metrics > div + div { border-left: 1px solid var(--v2-border); }
.v2-current-metrics dt { color: #8290a3; font-size: 8px; }
.v2-current-metrics dd { margin: 7px 0 0; overflow: hidden; font-size: 12px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-current-metrics em { margin-left: 3px; color: #8390a2; font-size: 7px; font-style: normal; font-weight: 500; }
.v2-track-inspector { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; }
.v2-track-inspector > section, .v2-track-inspector.is-empty { overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-track-inspector > section + section { margin-top: 10px; }
.v2-track-inspector section > header { display: flex; height: 38px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 13px; }
.v2-track-inspector section > header strong { font-size: 11px; }
.v2-track-inspector section > header span, .v2-track-inspector section > header b { color: #8491a3; font-size: 8px; font-weight: 500; }
.v2-track-vehicle { display: flex; align-items: center; gap: 10px; padding: 13px; }
.v2-track-vehicle > span { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 7px; background: var(--v2-blue-soft); color: var(--v2-blue); }
.v2-track-vehicle > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.v2-track-vehicle strong { font-size: 13px; }
.v2-track-vehicle small { overflow: hidden; color: #778498; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-summary { margin: 0; padding: 8px 13px; }
.v2-track-summary > div { display: grid; grid-template-columns: 88px minmax(0, 1fr); padding: 5px 0; font-size: 9px; }
.v2-track-summary dt { color: #8190a4; }
.v2-track-summary dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
.v2-track-sources > div { display: flex; flex-wrap: wrap; gap: 7px; padding: 10px 13px; }
.v2-track-sources > div span { display: flex; min-width: 128px; flex: 1; flex-direction: column; gap: 3px; border: 1px solid #e2e8f0; border-radius: 6px; background: #f8fafc; padding: 7px 8px; }
.v2-track-sources > div strong { font-size: 9px; }
.v2-track-sources > div small { color: #8390a2; font-size: 7px; }
.v2-track-sources > p { margin: 0 13px 10px; border-radius: 5px; background: #fff7ed; padding: 7px 8px; color: #a16207; font-size: 8px; line-height: 1.5; }
.v2-track-quality > p { margin: 0 13px 10px; border-radius: 5px; background: #f1f8f4; padding: 7px 8px; color: #3d7356; font-size: 8px; line-height: 1.5; }
.v2-track-quality.is-warning > p { background: #fff7ed; color: #9a620e; }
.v2-track-events > div { padding: 3px 11px 8px; }
.v2-track-events button { display: grid; width: 100%; min-height: 36px; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 0; background: #fff; padding: 3px 0; text-align: left; cursor: pointer; }
.v2-track-events button:hover { background: #f8fbff; }
.v2-track-events button > i { display: grid; width: 18px; height: 18px; place-items: center; border-radius: 50%; background: var(--v2-blue); color: #fff; font-size: 7px; font-style: normal; font-weight: 800; }
.v2-track-events button > i.is-start { background: var(--v2-green); }
.v2-track-events button > i.is-end { background: var(--v2-red); }
.v2-track-events button > i.is-warning { background: var(--v2-orange); }
.v2-track-events button > span { display: flex; min-width: 0; justify-content: space-between; gap: 8px; }
.v2-track-events button strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-events button small { color: #8491a3; font-size: 8px; }
.v2-track-events button em { color: #718096; font-size: 8px; font-style: normal; }
.v2-track-inspector.is-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; color: #78869a; text-align: center; }
.v2-track-inspector.is-empty strong { color: #4c596d; font-size: 12px; }
.v2-track-inspector.is-empty p { max-width: 220px; margin: 7px 0 0; font-size: 9px; line-height: 1.6; }
.v2-history-page { display: flex; height: 100%; min-height: 0; flex-direction: column; gap: 10px; overflow: hidden; padding: 12px 16px 16px; }
.v2-history-toolbar { display: grid; flex: 0 0 auto; grid-template-columns: minmax(230px, 1.2fr) minmax(160px, .8fr) minmax(160px, .8fr) 120px 130px auto auto auto; align-items: end; gap: 8px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 10px 12px; box-shadow: var(--v2-shadow); }
.v2-history-toolbar label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: var(--v2-muted); font-size: 8px; }
.v2-history-toolbar label > div { display: flex; height: 34px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 9px; color: #8996a8; }
.v2-history-toolbar input, .v2-history-toolbar select { min-width: 0; height: 34px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 8px; color: #435168; outline: 0; font-size: 9px; }
.v2-history-toolbar label > div input { height: auto; flex: 1; border: 0; padding: 0; }
.v2-history-toolbar input:focus, .v2-history-toolbar select:focus, .v2-history-toolbar label > div:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-history-toolbar button { height: 34px; padding: 0 11px; white-space: nowrap; }
.v2-history-toolbar button:disabled { opacity: .45; cursor: not-allowed; }
.v2-history-metrics { display: flex; min-height: 42px; flex: 0 0 auto; align-items: center; gap: 7px; overflow-x: auto; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 6px 11px; box-shadow: var(--v2-shadow); }
.v2-history-metrics > strong { margin-right: 4px; white-space: nowrap; font-size: 10px; }
.v2-history-metrics button { display: inline-flex; height: 27px; flex: 0 0 auto; align-items: center; gap: 6px; border: 1px solid #dfe6ef; border-radius: 6px; background: #fff; padding: 0 9px; color: #657286; cursor: pointer; font-size: 8px; }
.v2-history-metrics button i { width: 6px; height: 6px; border: 1px solid #9aa6b7; border-radius: 50%; }
.v2-history-metrics button.is-active { border-color: #b8d1fa; background: var(--v2-blue-soft); color: var(--v2-blue); }
.v2-history-metrics button.is-active i { border-color: var(--v2-blue); background: var(--v2-blue); }
.v2-history-metrics > span { color: var(--v2-muted); font-size: 8px; }
.v2-history-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(660px, 1fr) 310px; gap: 10px; }
.v2-history-main { display: grid; min-width: 0; min-height: 0; grid-template-rows: 58px 205px minmax(260px, 1fr); gap: 9px; }
.v2-history-summary { display: grid; grid-template-columns: repeat(4, 1fr); overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-history-summary > div { position: relative; display: flex; min-width: 0; justify-content: center; flex-direction: column; padding: 0 16px; }
.v2-history-summary > div + div::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ''; }
.v2-history-summary small { color: var(--v2-muted); font-size: 8px; }
.v2-history-summary strong { margin-top: 5px; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
.v2-history-trend { display: flex; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-history-trend > header { display: flex; min-height: 37px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 12px; }
.v2-history-trend > header strong { font-size: 10px; }
.v2-history-trend > header div { display: flex; gap: 14px; color: #657286; font-size: 8px; }
.v2-history-trend > header span { display: inline-flex; align-items: center; gap: 5px; }
.v2-history-trend > header i { width: 14px; height: 2px; border-radius: 2px; }
.v2-history-trend-panels { display: grid; min-height: 0; flex: 1; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; overflow: auto; padding: 8px 10px 5px; }
.v2-history-trend-panels article { min-width: 0; overflow: hidden; border: 1px solid #e6ebf2; border-radius: 6px; }
.v2-history-trend-panels article > header { display: flex; height: 25px; align-items: center; justify-content: space-between; padding: 0 8px; background: #f8fafc; }
.v2-history-trend-panels article > header strong { font-size: 9px; }
.v2-history-trend-panels article > header span { color: var(--v2-muted); font-size: 7px; }
.v2-history-trend-panels svg { display: block; width: 100%; height: 105px; }
.v2-history-trend-panels article > footer { display: flex; min-height: 22px; align-items: center; gap: 5px 10px; overflow-x: auto; padding: 0 8px; color: #657286; font-size: 7px; white-space: nowrap; }
.v2-history-trend-panels article > footer span { display: inline-flex; align-items: center; gap: 4px; }
.v2-history-trend-panels article > footer i { width: 10px; height: 2px; flex: 0 0 auto; }
.v2-chart-grid line { stroke: #e9eef5; stroke-width: 1; vector-effect: non-scaling-stroke; }
.v2-chart-axis text { fill: #7b8798; font-size: 8px; font-variant-numeric: tabular-nums; }
.v2-chart-axis text:first-child, .v2-chart-axis text:nth-child(2) { text-anchor: end; }
.v2-history-chart-empty { display: flex; flex: 1; align-items: center; justify-content: center; color: var(--v2-muted); font-size: 9px; }
.v2-history-trend-evidence { display: block; flex: 0 0 auto; overflow: hidden; border-top: 1px solid #eef2f7; padding: 4px 10px; color: var(--v2-muted); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.v2-history-table-card { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-history-table-card > header { display: flex; min-height: 38px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 10px 0 12px; }
.v2-history-table-card > header strong { font-size: 10px; }
.v2-history-table-card > header div { display: flex; gap: 6px; }
.v2-history-table-card > header button, .v2-history-table-card > header select { display: inline-flex; height: 27px; align-items: center; gap: 5px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 8px; color: #657286; cursor: pointer; font-size: 8px; }
.v2-history-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
.v2-history-table-scroll table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; color: #4e5b6f; font-size: 8px; }
.v2-history-table-scroll th { position: sticky; z-index: 3; top: 0; height: 31px; border-bottom: 1px solid var(--v2-border); background: #f8fafc; color: #607086; text-align: left; white-space: nowrap; font-weight: 700; }
.v2-history-table-scroll td, .v2-history-table-scroll th { max-width: 170px; border-right: 1px solid #eef2f7; padding: 0 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-history-table-scroll td { height: 30px; border-bottom: 1px solid #eef2f7; content-visibility: auto; font-variant-numeric: tabular-nums; }
.v2-history-table-card.is-comfortable .v2-history-table-scroll td { height: 40px; }
.v2-history-table-scroll tr:hover td, .v2-history-table-scroll tr.is-selected td { background: #f2f7ff; }
.v2-history-table-scroll td button { border: 0; background: transparent; color: var(--v2-blue); cursor: pointer; font-size: 8px; }
.v2-history-table-scroll input { width: 13px; height: 13px; accent-color: var(--v2-blue); }
.v2-quality { display: inline-flex; align-items: center; gap: 5px; }
.v2-quality i { width: 6px; height: 6px; border-radius: 50%; background: var(--v2-green); }
.v2-quality:not(.is-normal) i { background: var(--v2-orange); }
.v2-history-empty { position: sticky; left: 0; display: flex; min-height: 90px; align-items: center; justify-content: center; color: var(--v2-muted); font-size: 9px; }
.v2-history-table-card > footer { display: flex; min-height: 38px; align-items: center; justify-content: space-between; border-top: 1px solid var(--v2-border); padding: 0 10px; color: #657286; font-size: 8px; }
.v2-history-table-card > footer div { display: flex; gap: 5px; }
.v2-history-table-card > footer button, .v2-history-table-card > footer select { height: 26px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 8px; color: #5f6e83; font-size: 8px; }
.v2-history-table-card > footer button:disabled { opacity: .35; }
.v2-history-side { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; }
.v2-history-side > section { overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-history-side > section + section { margin-top: 9px; }
.v2-history-side section > header { display: flex; height: 38px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 12px; }
.v2-history-side section > header strong { font-size: 10px; }
.v2-history-side section > header span { color: var(--v2-muted); font-size: 8px; }
.v2-history-evidence > header button { border: 0; background: transparent; color: #718096; cursor: pointer; }
.v2-history-evidence > dl { margin: 0; padding: 8px 12px; border-bottom: 1px solid var(--v2-border); }
.v2-history-evidence > dl > div { display: grid; grid-template-columns: 78px minmax(0, 1fr); padding: 5px 0; font-size: 8px; }
.v2-history-evidence dt { color: #7f8c9f; }
.v2-history-evidence dd { margin: 0; overflow-wrap: anywhere; text-align: right; }
.v2-history-evidence dd i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: var(--v2-green); }
.v2-evidence-values { padding: 10px 12px; }
.v2-evidence-values > strong { display: block; margin-bottom: 5px; font-size: 9px; }
.v2-evidence-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef2f7; padding: 6px 0; }
.v2-evidence-values span { min-width: 0; color: #617087; font-size: 8px; }
.v2-evidence-values span small { display: block; margin-top: 2px; overflow: hidden; color: #a0aaba; font-size: 6px; text-overflow: ellipsis; white-space: nowrap; }
.v2-evidence-values b { font-size: 8px; font-weight: 600; }
.v2-history-evidence > footer { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--v2-border); padding: 9px 12px; color: #718096; font-size: 8px; }
.v2-history-evidence > footer b { overflow: hidden; color: var(--v2-blue); text-overflow: ellipsis; white-space: nowrap; }
.v2-history-side-empty { display: flex; min-height: 92px; align-items: center; justify-content: center; padding: 14px; color: var(--v2-muted); text-align: center; font-size: 8px; line-height: 1.6; }
.v2-export-jobs > div { padding: 4px 11px 8px; }
.v2-export-jobs article { display: grid; min-height: 42px; grid-template-columns: 7px minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef2f7; }
.v2-export-jobs article > i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }
.v2-export-jobs article > i.is-running { background: var(--v2-blue); }
.v2-export-jobs article > i.is-completed { background: var(--v2-green); }
.v2-export-jobs article > i.is-failed { background: var(--v2-red); }
.v2-export-jobs article > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.v2-export-jobs article strong { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.v2-export-jobs article small { overflow: hidden; color: #8290a3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.v2-export-jobs article a { display: inline-flex; align-items: center; gap: 4px; color: var(--v2-blue); text-decoration: none; font-size: 7px; }
.v2-export-jobs article em { color: #7f8c9f; font-size: 7px; font-style: normal; }
.v2-access-page { display: flex; height: 100%; min-height: 0; flex-direction: column; gap: 9px; overflow: hidden; padding: 12px 14px 14px; }
.v2-access-filter { display: grid; flex: 0 0 auto; grid-template-columns: minmax(210px, 1.15fr) repeat(4, minmax(120px, .7fr)) auto auto; align-items: end; gap: 8px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 9px 11px; box-shadow: var(--v2-shadow); }
.v2-access-filter label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: var(--v2-muted); font-size: 8px; }
.v2-access-filter label > div { display: flex; height: 34px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 6px; padding: 0 9px; color: #8996a8; }
.v2-access-filter input, .v2-access-filter select { min-width: 0; height: 34px; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; padding: 0 8px; color: #435168; outline: 0; font-size: 9px; }
.v2-access-filter label > div input { height: auto; flex: 1; border: 0; padding: 0; }
.v2-access-filter label > div:focus-within, .v2-access-filter select:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-access-filter button { height: 34px; white-space: nowrap; }
.v2-access-advanced { grid-column: 1 / -1; border-top: 1px solid var(--v2-border); padding-top: 6px; }
.v2-access-advanced summary { width: fit-content; color: var(--v2-blue); font-size: 9px; cursor: pointer; }
.v2-access-advanced > div { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); gap: 8px; padding-top: 8px; }
.v2-access-kpis { display: grid; min-height: 68px; flex: 0 0 auto; grid-template-columns: repeat(7, minmax(82px, 1fr)); overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-access-kpis button { position: relative; display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr) auto; align-content: center; border: 0; background: #fff; padding: 9px 15px; text-align: left; cursor: pointer; }
.v2-access-kpis button + button::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ''; }
.v2-access-kpis button:hover { background: #f8fbff; }
.v2-access-kpis small { grid-column: 1 / -1; color: var(--v2-muted); font-size: 9px; }
.v2-access-kpis strong { margin-top: 6px; overflow: hidden; font-size: 19px; line-height: 1; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-access-kpis em { align-self: end; color: #7f8c9f; font-size: 8px; font-style: normal; }
.v2-access-kpis .is-online strong, .v2-access-kpis .is-today strong { color: var(--v2-green); }
.v2-access-kpis .is-delay strong { color: var(--v2-orange); }
.v2-access-kpis .is-identity strong { color: #a65a12; }
.v2-access-kpis .is-never strong, .v2-access-kpis .is-offline strong { color: #64748b; }
.v2-access-protocols { flex: 0 0 auto; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 9px 12px 10px; box-shadow: var(--v2-shadow); }
.v2-access-protocols header { display: flex; align-items: center; justify-content: space-between; }
.v2-access-protocols header strong { font-size: 10px; }
.v2-access-protocols header span { color: var(--v2-muted); font-size: 7px; }
.v2-access-segments { display: flex; height: 6px; gap: 2px; margin-top: 8px; overflow: hidden; border-radius: 6px; background: #edf1f6; }
.v2-access-segments i { display: block; min-width: 2px; height: 100%; }
.v2-access-legends { display: flex; gap: 20px; margin-top: 8px; overflow-x: auto; color: #657286; font-size: 7px; white-space: nowrap; }
.v2-access-legends span { display: inline-flex; align-items: center; gap: 5px; }
.v2-access-legends span > i { width: 6px; height: 6px; border-radius: 50%; }
.v2-access-legends b { color: #455268; font-weight: 700; }
.v2-access-legends em { color: #8a97a9; font-style: normal; }
.v2-access-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(680px, 1fr) 300px; gap: 9px; }
.v2-access-identity-queue { flex: 0 0 auto; border: 1px solid #efd7b5; border-radius: var(--v2-radius); background: #fffaf3; color: var(--v2-text); }
.v2-access-identity-queue summary { display: flex; min-height: 35px; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 12px; cursor: pointer; list-style-position: inside; }
.v2-access-identity-queue summary span { display: inline-flex; align-items: center; gap: 8px; }
.v2-access-identity-queue summary strong { min-width: 22px; border-radius: 12px; background: #a65a12; padding: 2px 7px; color: #fff; font-size: 10px; text-align: center; }
.v2-access-identity-queue summary em { color: #8a6440; font-size: 10px; font-style: normal; }
.v2-access-identity-queue > div { display: grid; max-height: 112px; grid-template-columns: repeat(5, minmax(180px, 1fr)); gap: 7px; overflow: auto; border-top: 1px solid #efd7b5; padding: 7px; }
.v2-access-identity-queue article { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 8px; border: 1px solid #f0dfc7; border-radius: 7px; background: #fff; padding: 7px 8px; }
.v2-access-identity-code { overflow: hidden; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.v2-access-identity-queue article dl { display: grid; min-width: 0; grid-column: 1 / -1; gap: 2px; margin: 0; }
.v2-access-identity-queue article dl div { display: grid; min-width: 0; grid-template-columns: 45px minmax(0, 1fr); gap: 4px; }
.v2-access-identity-queue article dt, .v2-access-identity-queue article dd { overflow: hidden; margin: 0; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.v2-access-identity-queue article button { grid-column: 2; grid-row: 1; border: 0; background: transparent; color: var(--v2-blue); font-size: 9px; cursor: pointer; }
.v2-access-table-card { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-access-table-card > header { display: flex; min-height: 38px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 9px 0 12px; }
.v2-access-table-card > header strong { font-size: 10px; }
.v2-access-table-card > header div { display: flex; align-items: center; gap: 5px; }
.v2-access-table-card > header span { margin-right: 5px; color: var(--v2-muted); font-size: 7px; }
.v2-access-table-card > header button { display: inline-flex; height: 26px; align-items: center; gap: 4px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 7px; color: #657286; cursor: pointer; font-size: 7px; }
.v2-access-table-card > header button:disabled { opacity: .4; cursor: not-allowed; }
.v2-access-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
.v2-access-table-scroll table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; color: #4e5b6f; font-size: 7px; }
.v2-access-table-scroll th { position: sticky; z-index: 3; top: 0; height: 32px; border-bottom: 1px solid var(--v2-border); background: #f8fafc; color: #607086; text-align: left; font-weight: 700; }
.v2-access-table-scroll th, .v2-access-table-scroll td { max-width: 150px; border-right: 1px solid #eef2f7; padding: 0 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-access-table-scroll td { height: 31px; border-bottom: 1px solid #eef2f7; content-visibility: auto; font-variant-numeric: tabular-nums; }
.v2-access-table-scroll tr:hover td, .v2-access-table-scroll tr.is-selected td { background: #f2f7ff; }
.v2-access-table-scroll input { width: 12px; height: 12px; accent-color: var(--v2-blue); }
.v2-access-table-scroll td > button { border: 0; background: transparent; color: var(--v2-blue); cursor: pointer; font-size: 7px; }
.v2-access-table-scroll td.is-good { color: var(--v2-green); }
.v2-access-table-scroll td.is-danger { color: var(--v2-red); }
.v2-access-status { display: inline-flex; align-items: center; gap: 5px; color: #64748b; white-space: nowrap; }
.v2-access-status i { width: 6px; height: 6px; border-radius: 50%; background: #94a3b8; }
.v2-access-status.is-online { color: var(--v2-green); }
.v2-access-status.is-online i { background: var(--v2-green); }
.v2-access-status.is-offline i { background: #7b8798; }
.v2-access-status.is-never_reported i { background: #aab3c0; }
.v2-access-status.is-unknown i { background: var(--v2-orange); }
.v2-access-loading, .v2-access-empty { position: sticky; left: 0; display: flex; min-height: 72px; align-items: center; justify-content: center; gap: 7px; color: var(--v2-muted); font-size: 8px; }
.v2-access-loading { position: absolute; inset: 32px 0 auto; min-height: 34px; background: rgba(255,255,255,.88); backdrop-filter: blur(2px); }
.v2-access-loading i { width: 12px; height: 12px; border: 2px solid #cfe0fb; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
.v2-access-table-card > footer { display: flex; min-height: 38px; align-items: center; justify-content: space-between; border-top: 1px solid var(--v2-border); padding: 0 9px; color: #657286; font-size: 7px; }
.v2-access-table-card > footer div { display: flex; gap: 5px; }
.v2-access-table-card > footer button, .v2-access-table-card > footer select { height: 25px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 7px; color: #5f6e83; font-size: 7px; }
.v2-access-table-card > footer button:disabled { opacity: .35; }
.v2-access-side { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; }
.v2-access-side > section { overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-access-side > section + section { margin-top: 9px; }
.v2-access-side section > header { display: flex; height: 38px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 11px; }
.v2-access-side section > header strong { font-size: 10px; }
.v2-access-side section > header span { font-size: 7px; }
.v2-access-identity { margin: 0; padding: 7px 11px 3px; }
.v2-access-identity > div, .v2-access-inspector section dl > div { display: grid; grid-template-columns: 77px minmax(0, 1fr); gap: 8px; padding: 4px 0; font-size: 7px; }
.v2-access-inspector dt { color: #7e8b9e; }
.v2-access-inspector dd { margin: 0; overflow-wrap: anywhere; color: #46546a; text-align: right; }
.v2-access-inspector dd.is-good { color: var(--v2-green); }
.v2-access-inspector dd.is-danger { color: var(--v2-red); }
.v2-access-vehicle-link { display: block; margin: 3px 11px 9px; color: var(--v2-blue); text-decoration: none; font-size: 7px; }
.v2-access-inspector > section { border-top: 1px solid var(--v2-border); padding: 9px 11px; }
.v2-access-inspector h3 { margin: 0 0 5px; font-size: 8px; }
.v2-access-inspector section dl { margin: 0; }
.v2-access-proof p { display: grid; grid-template-columns: 64px 1fr; gap: 8px; margin: 0; padding: 4px 0; color: #778599; font-size: 7px; line-height: 1.5; }
.v2-access-proof p b { color: #4c596d; }
.v2-access-side-empty { display: flex; min-height: 100px; align-items: center; justify-content: center; padding: 16px; color: var(--v2-muted); text-align: center; font-size: 8px; line-height: 1.6; }
.v2-threshold-form { min-width: 0; margin: 0; border: 0; padding: 7px 11px 10px; }
.v2-threshold-form:disabled { opacity: .82; }
.v2-threshold-form label { position: relative; display: grid; min-height: 30px; grid-template-columns: 84px minmax(0, 1fr); align-items: center; gap: 7px; border-bottom: 1px solid #eef2f7; color: #657286; font-size: 7px; }
.v2-threshold-form input, .v2-threshold-form select { min-width: 0; height: 24px; border: 1px solid #dce4ef; border-radius: 5px; background: #fff; padding: 0 22px 0 7px; color: #46546a; outline: 0; font-size: 7px; }
.v2-threshold-form em { position: absolute; right: 7px; color: #96a1b1; font-size: 6px; font-style: normal; }
.v2-threshold-form > button { display: inline-flex; width: 100%; height: 29px; align-items: center; justify-content: center; gap: 5px; margin-top: 8px; border: 1px solid var(--v2-blue); border-radius: 6px; background: var(--v2-blue); color: #fff; cursor: pointer; font-size: 8px; font-weight: 700; }
.v2-threshold-form > button:disabled { opacity: .5; }
.v2-threshold-error { margin: 6px 0 0; color: var(--v2-red); font-size: 7px; line-height: 1.4; }
.v2-access-threshold > footer { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--v2-border); padding: 8px 11px; color: #7f8c9f; font-size: 6px; }
.v2-access-threshold > footer b { color: #536177; text-align: right; font-weight: 600; }
.v2-alert-page { display: flex; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; background: #f5f7fa; padding: 0 12px 12px; }
.v2-alert-heading { display: flex; min-height: 48px; flex: 0 0 auto; align-items: center; justify-content: space-between; background: #fff; margin: 0 -12px; padding: 0 16px; }
.v2-alert-heading h2 { margin: 0; color: #26354b; font-size: 17px; letter-spacing: -.02em; }
.v2-alert-heading p { display: inline; margin: 0 0 0 12px; color: var(--v2-muted); font-size: 8px; }
.v2-alert-heading > div { display: flex; align-items: baseline; }
.v2-alert-tabs { display: flex; min-height: 35px; flex: 0 0 auto; gap: 18px; border-bottom: 1px solid var(--v2-border); background: #fff; margin: 0 -12px 8px; padding: 0 16px; }
.v2-alert-tabs button { position: relative; display: inline-flex; align-items: center; gap: 5px; border: 0; background: transparent; padding: 0 4px; color: #68768a; cursor: pointer; font-size: 9px; font-weight: 600; }
.v2-alert-tabs button.is-active { color: var(--v2-blue); }
.v2-alert-tabs button.is-active::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; border-radius: 2px 2px 0 0; background: var(--v2-blue); content: ''; }
.v2-alert-tabs b { display: grid; min-width: 15px; height: 15px; place-items: center; border-radius: 8px; background: var(--v2-red); color: #fff; font-size: 7px; }
.v2-alert-filter { display: grid; flex: 0 0 auto; grid-template-columns: minmax(180px,1.15fr) repeat(4,minmax(92px,.65fr)) repeat(2,minmax(135px,.8fr)) auto auto; align-items: end; gap: 6px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 7px 9px; box-shadow: var(--v2-shadow); }
.v2-alert-filter label { display: flex; min-width: 0; flex-direction: column; gap: 4px; color: var(--v2-muted); font-size: 7px; }
.v2-alert-filter label > div { display: flex; height: 30px; align-items: center; gap: 5px; border: 1px solid #dce4ef; border-radius: 5px; padding: 0 7px; color: #8996a8; }
.v2-alert-filter input, .v2-alert-filter select { min-width: 0; height: 30px; border: 1px solid #dce4ef; border-radius: 5px; background: #fff; padding: 0 7px; color: #435168; outline: 0; font-size: 8px; }
.v2-alert-filter label > div input { height: auto; flex: 1; border: 0; padding: 0; }
.v2-alert-filter label > div:focus-within, .v2-alert-filter input:focus, .v2-alert-filter select:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-alert-filter button { height: 30px; padding: 0 10px; white-space: nowrap; font-size: 8px; }
.v2-alert-kpis { display: grid; min-height: 62px; flex: 0 0 auto; grid-template-columns: repeat(7,minmax(74px,1fr)); overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; margin-top: 8px; box-shadow: var(--v2-shadow); }
.v2-alert-kpis button { position: relative; display: flex; min-width: 0; align-items: center; justify-content: center; flex-direction: column; border: 0; background: #fff; cursor: pointer; }
.v2-alert-kpis button + button::before { position: absolute; inset: 11px auto 11px 0; width: 1px; background: var(--v2-border); content: ''; }
.v2-alert-kpis button:hover, .v2-alert-kpis .is-unprocessed { background: #fff9f2; }
.v2-alert-kpis small { color: var(--v2-muted); font-size: 8px; }
.v2-alert-kpis strong { margin-top: 5px; font-size: 18px; line-height: 1; font-variant-numeric: tabular-nums; }
.v2-alert-kpis .is-unprocessed strong, .v2-alert-kpis button:first-child strong { color: var(--v2-red); }
.v2-alert-kpis .is-processing strong { color: var(--v2-blue); }.v2-alert-kpis .is-recovered strong { color: var(--v2-green); }.v2-alert-kpis .is-notice strong { color: #7c3aed; }
.v2-alert-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(700px,1fr) 320px; gap: 8px; margin-top: 8px; }
.v2-alert-table-card { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-alert-table-card > header { display: flex; min-height: 36px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 9px 0 11px; }
.v2-alert-table-card > header strong { font-size: 9px; }.v2-alert-table-card > header div { display: flex; align-items: center; gap: 7px; }.v2-alert-table-card > header span { color: var(--v2-muted); font-size: 7px; }
.v2-alert-table-card > header button { display: inline-flex; height: 25px; align-items: center; gap: 4px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 7px; color: #657286; cursor: pointer; font-size: 7px; }
.v2-alert-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
.v2-alert-table-scroll table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; color: #4e5b6f; font-size: 7px; }
.v2-alert-table-scroll th { position: sticky; z-index: 3; top: 0; height: 31px; border-bottom: 1px solid var(--v2-border); background: #f8fafc; color: #607086; text-align: left; }
.v2-alert-table-scroll th, .v2-alert-table-scroll td { max-width: 130px; border-right: 1px solid #eef2f7; padding: 0 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-alert-table-scroll td { height: 34px; border-bottom: 1px solid #eef2f7; content-visibility: auto; font-variant-numeric: tabular-nums; cursor: pointer; }
.v2-alert-table-scroll td strong, .v2-alert-table-scroll td small { display: block; }.v2-alert-table-scroll td small { margin-top: 2px; color: #8b97a8; font-size: 6px; }
.v2-alert-table-scroll tr:hover td, .v2-alert-table-scroll tr.is-selected td { background: #f2f7ff; }.v2-alert-table-scroll tr.is-selected td:first-child { box-shadow: inset 2px 0 var(--v2-blue); }
.v2-alert-table-scroll input { width: 12px; height: 12px; accent-color: var(--v2-blue); }
.v2-alert-severity { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }.v2-alert-severity i { width: 6px; height: 6px; border-radius: 50%; background: #94a3b8; }.v2-alert-severity.is-critical { color: var(--v2-red); }.v2-alert-severity.is-critical i { background: var(--v2-red); }.v2-alert-severity.is-major { color: var(--v2-orange); }.v2-alert-severity.is-major i { background: var(--v2-orange); }
.v2-alert-status { display: inline-flex; border: 1px solid #dce4ef; border-radius: 4px; background: #f8fafc; padding: 2px 5px; color: #657286; white-space: nowrap; }.v2-alert-status.is-unprocessed { border-color: #fecaca; background: #fff1f2; color: #dc2626; }.v2-alert-status.is-processing { border-color: #bfdbfe; background: #eff6ff; color: var(--v2-blue); }.v2-alert-status.is-recovered { border-color: #bbf7d0; background: #f0fdf4; color: var(--v2-green); }
.v2-alert-loading, .v2-alert-empty { position: sticky; left: 0; display: flex; min-height: 70px; align-items: center; justify-content: center; gap: 6px; color: var(--v2-muted); font-size: 8px; }.v2-alert-loading { position: absolute; inset: 31px 0 auto; min-height: 32px; background: rgba(255,255,255,.88); }.v2-alert-loading i { width: 11px; height: 11px; border: 2px solid #cfe0fb; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
.v2-alert-table-card > footer { display: flex; min-height: 36px; align-items: center; justify-content: space-between; border-top: 1px solid var(--v2-border); padding: 0 9px; color: #657286; font-size: 7px; }.v2-alert-table-card > footer div { display: flex; gap: 5px; }.v2-alert-table-card > footer button, .v2-alert-table-card > footer select { height: 24px; border: 1px solid #dfe6ef; border-radius: 5px; background: #fff; padding: 0 7px; color: #5f6e83; font-size: 7px; }.v2-alert-table-card > footer button:disabled { opacity: .35; }
.v2-alert-inspector { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-alert-inspector > header { display: flex; min-height: 52px; align-items: center; border-bottom: 1px solid var(--v2-border); padding: 8px 11px; }.v2-alert-inspector > header > div { display: flex; min-width: 0; flex: 1; justify-content: space-between; flex-direction: column; gap: 6px; }.v2-alert-inspector > header strong { font-size: 12px; }.v2-alert-inspector > header span { display: flex; gap: 5px; font-size: 7px; }
.v2-alert-inspector > section { border-bottom: 1px solid var(--v2-border); padding: 9px 11px; }.v2-alert-inspector h3 { margin: 0 0 6px; border-left: 2px solid var(--v2-blue); padding-left: 6px; font-size: 8px; }.v2-alert-inspector dl { margin: 0; }.v2-alert-inspector dl > div { display: grid; grid-template-columns: 74px minmax(0,1fr); gap: 7px; padding: 3px 0; font-size: 7px; }.v2-alert-inspector dt { color: #7f8c9f; }.v2-alert-inspector dd { margin: 0; overflow-wrap: anywhere; text-align: right; }
.v2-alert-evidence { display: grid; grid-template-columns: 1fr 24px 1fr; align-items: center; gap: 4px; }.v2-alert-evidence > div { display: flex; min-height: 57px; align-items: center; justify-content: center; flex-direction: column; border: 1px solid #e0e7f0; border-radius: 6px; background: #fafcff; text-align: center; }.v2-alert-evidence small { color: #7e8b9e; font-size: 7px; }.v2-alert-evidence strong { margin-top: 6px; color: #39475c; font-size: 11px; }.v2-alert-evidence > div:first-child strong { color: var(--v2-red); }.v2-alert-evidence > b { color: #7e8b9e; text-align: center; font-size: 8px; }
.v2-alert-timeline { padding-left: 3px; }.v2-alert-timeline article { position: relative; display: grid; min-height: 35px; grid-template-columns: 12px 1fr; gap: 7px; }.v2-alert-timeline article:not(:last-child)::before { position: absolute; top: 10px; bottom: -3px; left: 3px; width: 1px; background: #cdd7e4; content: ''; }.v2-alert-timeline i { position: relative; z-index: 1; width: 7px; height: 7px; margin-top: 3px; border: 2px solid var(--v2-blue); border-radius: 50%; background: #fff; }.v2-alert-timeline article:first-child i { border-color: var(--v2-red); }.v2-alert-timeline strong { display: block; font-size: 7px; }.v2-alert-timeline span { display: block; margin-top: 3px; color: #8793a5; font-size: 6px; }.v2-alert-timeline p { margin: 3px 0 0; color: #68768a; font-size: 6px; }
.v2-alert-inspector textarea { width: 100%; height: 46px; resize: vertical; border: 1px solid #dce4ef; border-radius: 5px; padding: 6px; color: #46546a; outline: 0; font: inherit; font-size: 7px; }.v2-alert-note-count { display: block; margin-top: -13px; padding-right: 5px; color: #9aa5b4; text-align: right; font-size: 6px; }.v2-alert-action-error { margin: 6px 0 0; color: var(--v2-red); font-size: 7px; }
.v2-alert-actions { display: grid; grid-template-columns: 1.5fr 1fr 1fr; gap: 5px; margin-top: 8px; }.v2-alert-actions button { height: 28px; border: 1px solid #dce4ef; border-radius: 5px; background: #fff; color: #526176; cursor: pointer; font-size: 7px; }.v2-alert-actions button.is-primary { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }.v2-alert-actions button:disabled { opacity: .35; cursor: not-allowed; }
.v2-alert-links { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; padding: 8px 11px; }.v2-alert-links a { display: flex; height: 26px; align-items: center; justify-content: center; color: var(--v2-blue); text-decoration: none; font-size: 7px; }
.v2-alert-side-empty { display: flex; min-height: 220px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; padding: 20px; color: #8290a3; text-align: center; }.v2-alert-side-empty strong { color: #4b596d; font-size: 11px; }.v2-alert-side-empty span { font-size: 8px; }
.v2-alert-rules { display: grid; min-height: 0; flex: 1; grid-template-columns: 330px minmax(560px,1fr); gap: 8px; }.v2-alert-rule-list, .v2-alert-rule-editor, .v2-alert-notifications { min-height: 0; overflow: auto; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-alert-rule-list > header, .v2-alert-rule-editor > header, .v2-alert-notifications > header { display: flex; min-height: 45px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); padding: 0 12px; }.v2-alert-rule-list > header strong, .v2-alert-rule-editor > header strong, .v2-alert-notifications > header strong { font-size: 11px; }.v2-alert-rule-list > header button, .v2-alert-rule-editor > header button, .v2-alert-notifications > header button { height: 27px; border: 1px solid #dce4ef; border-radius: 5px; background: #fff; padding: 0 8px; color: var(--v2-blue); cursor: pointer; font-size: 8px; }
.v2-alert-rule-list > button { display: grid; width: 100%; min-height: 58px; grid-template-columns: 7px minmax(0,1fr) auto; align-items: center; gap: 9px; border: 0; border-bottom: 1px solid #eef2f7; background: #fff; padding: 8px 11px; text-align: left; cursor: pointer; }.v2-alert-rule-list > button.is-selected { background: #f2f7ff; box-shadow: inset 2px 0 var(--v2-blue); }.v2-alert-rule-list > button > i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }.v2-alert-rule-list > button > i.is-critical { background: var(--v2-red); }.v2-alert-rule-list > button > i.is-major { background: var(--v2-orange); }.v2-alert-rule-list > button span { min-width: 0; }.v2-alert-rule-list > button strong, .v2-alert-rule-list > button small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-alert-rule-list > button strong { font-size: 9px; }.v2-alert-rule-list > button small { margin-top: 5px; color: #8390a2; font-size: 7px; }.v2-alert-rule-list em { color: #94a3b8; font-size: 7px; font-style: normal; }.v2-alert-rule-list em.is-enabled { color: var(--v2-green); }
.v2-alert-rule-editor > header div, .v2-alert-notifications > header div { display: flex; flex-direction: column; gap: 3px; }.v2-alert-rule-editor > header span, .v2-alert-notifications > header span { color: var(--v2-muted); font-size: 7px; }.v2-rule-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; padding: 15px; }.v2-rule-form-grid label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: #68768a; font-size: 8px; }.v2-rule-form-grid label.is-wide { grid-column: 1/-1; }.v2-rule-form-grid input, .v2-rule-form-grid select, .v2-rule-form-grid textarea { min-width: 0; height: 34px; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; padding: 0 9px; color: #435168; outline: 0; font: inherit; font-size: 9px; }.v2-rule-form-grid textarea { height: 60px; padding: 8px; resize: vertical; }.v2-alert-rule-editor > footer { display: flex; align-items: center; gap: 12px; border-top: 1px solid var(--v2-border); padding: 10px 15px; }.v2-alert-rule-editor > footer > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }.v2-alert-rule-editor > footer b { font-size: 8px; }.v2-alert-rule-editor > footer span { color: var(--v2-muted); font-size: 7px; }.v2-alert-rule-editor > footer em { color: var(--v2-red); font-size: 7px; font-style: normal; }.v2-alert-rule-editor > footer button { height: 32px; }
.v2-alert-notifications { flex: 1; }.v2-alert-notifications > div { min-height: 0; max-height: calc(100% - 125px); overflow: auto; }.v2-alert-notifications article { display: grid; min-height: 64px; grid-template-columns: 8px minmax(0,1fr) auto; align-items: center; gap: 10px; border-bottom: 1px solid #eef2f7; padding: 9px 13px; }.v2-alert-notifications article.is-read { opacity: .58; }.v2-alert-notifications article > i { width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; }.v2-alert-notifications article > i.is-critical { background: var(--v2-red); }.v2-alert-notifications article > i.is-major { background: var(--v2-orange); }.v2-alert-notifications article strong { font-size: 9px; }.v2-alert-notifications article p { margin: 4px 0; color: #68768a; font-size: 8px; }.v2-alert-notifications article span { color: #94a3b8; font-size: 7px; }.v2-alert-notifications article button { border: 0; background: transparent; color: var(--v2-blue); cursor: pointer; font-size: 8px; }.v2-alert-notifications > footer { display: flex; gap: 16px; border-top: 1px solid var(--v2-border); padding: 12px 13px; color: #7f8c9f; font-size: 7px; }.v2-alert-notifications > footer b { color: #4f5d72; }
@keyframes v2-spin { to { transform: rotate(360deg); } }
@keyframes v2-map-ripple { 0% { opacity: .8; transform: scale(.35); } 80%, 100% { opacity: 0; transform: scale(1.25); } }
@keyframes v2-panel-enter { from { opacity: .72; transform: translate3d(8px, 0, 0); } to { opacity: 1; transform: translate3d(0, 0, 0); } }
@media (min-width: 1600px) and (min-height: 900px) {
.v2-filterbar { gap: 12px; padding: 11px 14px; }
.v2-search-field, .v2-filterbar select, .v2-primary-button, .v2-secondary-button { height: 40px; }
.v2-kpi { min-height: 70px; padding: 12px 17px; }
.v2-kpi small { font-size: 11px; }
.v2-kpi strong { margin-top: 7px; font-size: clamp(20px, 1.2vw, 27px); }
.v2-vehicle-rail > header { height: 50px; padding: 0 14px; }
.v2-rail-search { height: 36px; margin: 0 11px 9px; }
.v2-vehicle-row { min-height: 66px; padding: 9px 12px; }
.v2-vehicle-identity strong { font-size: 13px; }
.v2-vehicle-detail { padding: 16px; }
.v2-detail-title strong { font-size: 17px; }
.v2-detail-actions a { height: 32px; font-size: 9px; }
.v2-vehicle-detail h3 { font-size: 12px; }
.v2-detail-list > div { grid-template-columns: 84px minmax(0, 1fr); padding: 6px 0; font-size: 10px; }
.v2-metric-grid > div { padding: 11px; }
.v2-metric-grid small { font-size: 9px; }
.v2-metric-grid strong { font-size: 17px; }
.v2-event-strip { min-height: 52px; font-size: 10px; }
}
@media (min-width: 2200px) and (min-height: 1200px) {
.v2-monitor-page { gap: 14px; padding: 18px 22px 20px; }
.v2-filterbar { padding: 13px 16px; }
.v2-search-field, .v2-filterbar select, .v2-primary-button, .v2-secondary-button { height: 42px; font-size: 13px; }
.v2-kpi { min-height: 82px; padding: 15px 20px; }
.v2-kpi small { font-size: 12px; }
.v2-kpi strong { font-size: 28px; }
.v2-vehicle-rail > header { height: 54px; }
.v2-vehicle-rail > header strong { font-size: 14px; }
.v2-rail-search { height: 38px; font-size: 11px; }
.v2-vehicle-row { min-height: 72px; }
.v2-vehicle-identity strong { font-size: 14px; }
.v2-vehicle-motion strong { font-size: 12px; }
.v2-map-controls { top: 16px; right: 16px; }
.v2-map-layer-control, .v2-map-follow-control { height: 48px; padding: 0 12px; }
.v2-map-layer-control strong { font-size: 11px; }
.v2-map-layer-control small { font-size: 9px; }
.v2-map-follow-control strong { font-size: 11px; }
.v2-map-follow-control small { font-size: 9px; }
.v2-map-legend { bottom: 16px; height: 40px; gap: 22px; padding: 0 20px; font-size: 10px; }
.v2-vehicle-detail { padding: 19px; }
.v2-detail-title strong { font-size: 19px; }
.v2-detail-list > div { grid-template-columns: 92px minmax(0, 1fr); font-size: 11px; }
.v2-vehicle-detail h3 { font-size: 13px; }
.v2-metric-grid small { font-size: 10px; }
.v2-metric-grid strong { font-size: 19px; }
.v2-event-strip { min-height: 56px; padding: 0 20px; font-size: 11px; }
}
@media (max-width: 1180px) {
.v2-monitor-workspace { grid-template-columns: 220px minmax(420px, 1fr); }
.v2-monitor-workspace.is-detail-open { grid-template-columns: 220px minmax(420px, 1fr) 280px; }
.v2-monitor-workspace.is-detail-collapsed { grid-template-columns: 220px minmax(420px, 1fr) 42px; }
.v2-kpis { grid-template-columns: repeat(4, 1fr); }
.v2-kpi:nth-child(5)::before { display: none; }
.v2-identity-band { grid-template-columns: minmax(240px, 1fr) minmax(300px, 1fr); }
.v2-identity-actions { grid-column: 1 / -1; justify-content: flex-end; border-top: 1px solid var(--v2-border); }
.v2-record-grid { grid-template-columns: minmax(460px, 1.5fr) minmax(280px, 1fr); }
.v2-track-toolbar { grid-template-columns: minmax(210px, 1fr) repeat(3, minmax(140px, .7fr)); }
.v2-track-toolbar > button { min-width: 100px; }
.v2-track-workspace { grid-template-columns: minmax(500px, 1fr) 290px; }
.v2-track-playback { grid-template-columns: 180px minmax(210px, 1fr); }
.v2-current-metrics { grid-column: 1 / -1; border-top: 1px solid var(--v2-border); border-left: 0; padding-top: 6px; }
.v2-history-toolbar { grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(120px, .7fr)); }
.v2-history-toolbar button { min-width: 90px; }
.v2-history-workspace { grid-template-columns: minmax(600px, 1fr) 280px; }
.v2-access-filter { grid-template-columns: minmax(210px, 1fr) repeat(4, minmax(110px, .7fr)); }
.v2-access-filter button { min-width: 86px; }
.v2-access-advanced > div { grid-template-columns: repeat(3, minmax(150px, 1fr)); }
.v2-access-workspace { grid-template-columns: minmax(620px, 1fr) 280px; }
.v2-access-identity-queue > div { grid-template-columns: repeat(3, minmax(180px, 1fr)); }
.v2-alert-filter { grid-template-columns: minmax(180px,1fr) repeat(4,minmax(88px,.65fr)); }
.v2-alert-filter label:nth-of-type(6), .v2-alert-filter label:nth-of-type(7) { display: none; }
.v2-alert-workspace { grid-template-columns: minmax(620px,1fr) 290px; }
}
@media (max-width: 900px) {
.v2-sidebar { width: 68px; }
.v2-main { margin-left: 68px; }
.v2-brand strong, .v2-nav-label, .v2-collapse span { display: none; }
.v2-brand { padding: 0 17px; }
.v2-nav-item { justify-content: center; padding: 0; }
.v2-filterbar { grid-template-columns: 1fr 1fr; }
.v2-search-field { grid-column: 1 / -1; }
.v2-monitor-workspace, .v2-monitor-workspace.is-detail-open, .v2-monitor-workspace.is-detail-collapsed { position: relative; grid-template-columns: 220px minmax(0, 1fr); }
.v2-vehicle-detail { position: absolute; inset: 10px 10px 10px auto; z-index: 8; display: block; width: min(340px, calc(100% - 240px)); border: 1px solid #d8e2ed; border-radius: 10px; box-shadow: 0 12px 36px rgba(21,32,51,.16); }
.v2-detail-peek { position: absolute; right: 10px; bottom: 10px; z-index: 8; display: block; min-width: 0; min-height: 0; border: 1px solid #cfe0f8; border-radius: 22px; box-shadow: 0 7px 24px rgba(18,104,243,.16); }
.v2-detail-peek button { width: auto; height: 44px; flex-direction: row; gap: 7px; border-radius: 22px; padding: 0 14px; }
.v2-detail-peek button > span { max-width: 130px; writing-mode: horizontal-tb; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
.v2-identity-band { grid-template-columns: 1fr; }
.v2-identity-meta { border: 0; border-top: 1px solid var(--v2-border); border-bottom: 1px solid var(--v2-border); }
.v2-identity-actions { grid-column: auto; justify-content: stretch; border: 0; }
.v2-identity-actions a { flex: 1; justify-content: center; }
.v2-record-grid { display: flex; flex-direction: column; }
.v2-single-map-card { min-height: 360px; }
.v2-archive-card, .v2-live-card, .v2-telemetry-card, .v2-events-card { grid-area: auto; }
.v2-track-page { height: auto; min-height: 100%; overflow: auto; }
.v2-track-toolbar { grid-template-columns: 1fr 1fr; }
.v2-track-vehicle-input { grid-column: 1 / -1; }
.v2-track-workspace { display: flex; flex-direction: column; }
.v2-track-main { min-height: 700px; flex: none; grid-template-rows: 34px 420px 58px 145px; }
.v2-track-inspector { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; overflow: visible; }
.v2-track-inspector > section + section { margin-top: 0; }
.v2-track-events { grid-column: 1 / -1; }
.v2-history-page { height: auto; min-height: 100%; overflow: auto; }
.v2-history-toolbar { grid-template-columns: 1fr 1fr; }
.v2-history-vehicles { grid-column: 1 / -1; }
.v2-history-workspace { display: flex; flex-direction: column; }
.v2-history-main { height: 760px; min-height: 0; flex: none; grid-template-rows: 58px 220px minmax(0, 1fr); }
.v2-history-side { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; overflow: visible; }
.v2-history-side > section + section { margin-top: 0; }
.v2-access-page { height: auto; min-height: 100%; overflow: auto; }
.v2-access-filter { grid-template-columns: 1fr 1fr; }
.v2-access-filter label:first-child { grid-column: 1 / -1; }
.v2-access-advanced > div { grid-template-columns: 1fr 1fr; }
.v2-access-kpis { grid-template-columns: repeat(3, 1fr); }
.v2-access-kpis button:nth-child(4)::before { display: none; }
.v2-access-workspace { display: flex; flex-direction: column; }
.v2-access-identity-queue > div { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
.v2-access-table-card { height: 620px; flex: none; }
.v2-access-side { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; overflow: visible; }
.v2-access-side > section + section { margin-top: 0; }
.v2-alert-page { height: auto; min-height: 100%; overflow: auto; }
.v2-alert-filter { grid-template-columns: repeat(3,1fr); }
.v2-alert-filter label:first-child { grid-column: 1/-1; }
.v2-alert-kpis { grid-template-columns: repeat(4,1fr); }
.v2-alert-kpis button:nth-child(5)::before { display: none; }
.v2-alert-workspace { display: flex; flex-direction: column; }
.v2-alert-table-card { height: 610px; flex: none; }
.v2-alert-inspector { max-height: none; overflow: visible; }
.v2-alert-rules { display: flex; flex-direction: column; }
.v2-alert-rule-list { max-height: 300px; flex: none; }
.v2-ops-kpis { grid-template-columns: repeat(3,1fr); }.v2-ops-grid { grid-template-columns: 1fr; }.v2-ops-sources > div { grid-template-columns: 1fr; }.v2-ops-sources article + article { border-top: 1px solid var(--v2-border); border-left: 0; }
}
@media (max-width: 680px) {
html, body, #root { min-width: 320px; min-height: 100%; }
body { overscroll-behavior-y: none; }
.v2-auth-screen { min-height: 100dvh; padding: 16px; }
.v2-auth-card { padding: 26px 20px; }
.v2-auth-card input { height: 46px; font-size: 16px; }
.v2-auth-card button { height: 46px; }
.v2-sidebar { inset: auto 0 0 0; width: auto; height: calc(64px + env(safe-area-inset-bottom)); flex-direction: row; border: 0; border-top: 1px solid var(--v2-border); padding-bottom: env(safe-area-inset-bottom); box-shadow: 0 -8px 24px rgba(21,32,51,.07); }
.v2-brand, .v2-collapse, .v2-nav-operations { display: none; }
.v2-navigation { display: grid; width: 100%; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0; padding: 4px 3px; }
.v2-nav-item { height: 56px; justify-content: center; flex-direction: column; gap: 2px; padding: 3px 0 2px; border-radius: 8px; font-weight: 600; }
.v2-nav-item > svg { flex: 0 0 auto; font-size: 18px; }
.v2-nav-label, .v2-sidebar.is-collapsed .v2-nav-label { display: block; max-width: 100%; overflow: hidden; font-size: 9px; line-height: 1.2; text-overflow: ellipsis; }
.v2-nav-item.is-active::before { inset: auto auto 0; width: 20px; height: 2px; }
.v2-main, .v2-sidebar.is-collapsed + .v2-main { margin-left: 0; padding-bottom: calc(64px + env(safe-area-inset-bottom)); }
.v2-topbar { height: 52px; flex: 0 0 52px; padding: 0 10px 0 14px; }
.v2-topbar h1 { font-size: 17px; }
.v2-topbar-actions { gap: 0; }
.v2-topbar-actions button { width: 40px; height: 40px; }
.v2-current-user { display: none; }
.v2-content { overscroll-behavior: contain; }
.v2-monitor-page { height: auto; min-height: 100%; gap: 8px; overflow: visible; padding: 8px; }
.v2-filterbar { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; padding: 9px; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
.v2-search-field { grid-column: 1 / -1; height: 44px; }
.v2-search-field input, .v2-filterbar select { font-size: 16px; }
.v2-filterbar select, .v2-primary-button, .v2-secondary-button { height: 44px; }
.v2-primary-button, .v2-secondary-button { padding: 0 10px; font-size: 12px; }
.v2-kpis { display: flex; min-width: 0; overflow-x: auto; border-radius: 9px; scroll-snap-type: x proximity; scrollbar-width: none; }
.v2-kpis::-webkit-scrollbar { display: none; }
.v2-kpi { width: 108px; min-width: 108px; min-height: 70px; flex: 0 0 108px; padding: 11px 13px; scroll-snap-align: start; }
.v2-kpi + .v2-kpi::before, .v2-kpi:nth-child(odd)::before { display: block; }
.v2-kpi small { font-size: 10px; }
.v2-kpi strong { margin-top: 7px; font-size: 20px; }
.v2-monitor-workspace, .v2-monitor-workspace.is-detail-open, .v2-monitor-workspace.is-detail-collapsed { min-height: calc(52dvh + 360px); flex: none; grid-template-columns: 1fr; grid-template-rows: minmax(360px, 52dvh) 360px; overflow: hidden; }
.v2-vehicle-rail { grid-row: 2; border: 0; border-top: 1px solid var(--v2-border); }
.v2-fleet-map { grid-row: 1; }
.v2-vehicle-rail > header { height: 48px; }
.v2-rail-search { height: 40px; }
.v2-vehicle-row { min-height: 64px; padding: 9px 11px; }
.v2-vehicle-identity strong { font-size: 13px; }
.v2-map-controls { top: 8px; right: 8px; gap: 6px; }
.v2-map-layer-control, .v2-map-follow-control { width: 44px; height: 44px; grid-template-columns: 1fr; justify-items: center; padding: 0; }
.v2-map-layer-control span:not(.semi-icon), .v2-map-layer-control > i, .v2-map-follow-control span:not(.semi-icon) { display: none; }
.v2-map-layer-control .semi-icon, .v2-map-follow-control .semi-icon { display: inline-flex; font-size: 18px; }
.v2-map-legend { right: 8px; bottom: 8px; left: 8px; width: auto; max-width: none; height: 36px; justify-content: flex-start; gap: 13px; overflow-x: auto; padding: 0 12px; transform: none; white-space: nowrap; scrollbar-width: none; }
.v2-map-legend::-webkit-scrollbar { display: none; }
.v2-map-legend b { margin-left: auto; }
.v2-vehicle-detail { position: fixed; inset: auto 8px calc(72px + env(safe-area-inset-bottom)) 8px; z-index: 70; display: block; width: auto; height: min(68dvh, 620px); min-height: 300px; contain: layout paint; overscroll-behavior: contain; border: 1px solid #d8e2ed; border-radius: 14px; background: #fff; padding: 18px 15px 20px; box-shadow: 0 -10px 44px rgba(21,32,51,.2); animation: v2-mobile-sheet-enter .18s ease-out; }
.v2-vehicle-detail::before { display: block; width: 38px; height: 4px; margin: -8px auto 10px; border-radius: 2px; background: #d7e0eb; content: ""; }
.v2-detail-controls { top: 13px; right: 12px; }
.v2-detail-controls button { width: 36px; height: 36px; }
.v2-detail-title { padding: 0 82px 14px 0; }
.v2-detail-title strong { font-size: 18px; }
.v2-detail-actions { position: sticky; top: -18px; z-index: 2; background: #fff; padding: 10px 0; }
.v2-detail-actions a { min-height: 38px; font-size: 10px; }
.v2-detail-list > div { grid-template-columns: 82px minmax(0, 1fr); font-size: 11px; }
.v2-metric-grid small { font-size: 10px; }
.v2-metric-grid strong { font-size: 17px; }
.v2-detail-peek { position: fixed; right: 10px; bottom: calc(74px + env(safe-area-inset-bottom)); z-index: 65; display: block; min-width: 0; min-height: 0; border: 1px solid #cfe0f8; border-radius: 22px; box-shadow: 0 7px 24px rgba(18,104,243,.18); }
.v2-detail-peek button { width: auto; height: 44px; flex-direction: row; gap: 7px; border-radius: 22px; padding: 0 14px; background: #fff; }
.v2-detail-peek button > span { max-width: 120px; writing-mode: horizontal-tb; font-size: 11px; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
.v2-event-strip { flex-wrap: wrap; gap: 7px 12px; padding: 10px 12px; line-height: 1.4; }
.v2-event-strip > strong { width: 100%; }
.v2-refresh-cadence { border-left: 0; padding-left: 0; }
.v2-event-strip time { width: 100%; margin: 0; }
.v2-vehicle-record-page { overflow: auto; padding: 8px; }
.v2-vehicle-search-page { padding: 12px; }
.v2-vehicle-search-card { padding: 30px 18px; }
.v2-vehicle-search-card form { height: auto; flex-wrap: wrap; padding: 8px; }
.v2-vehicle-search-card form button { width: 100%; justify-content: center; }
.v2-vehicle-search-page.has-sync-panel { align-content: start; }
.v2-profile-sync-panel { padding: 13px; }
.v2-profile-sync-fields { grid-template-columns: 1fr; }
.v2-profile-sync-result > div { grid-template-columns: repeat(3,1fr); gap: 8px; }
.v2-profile-sync-panel > footer button { flex: 1; }
.v2-identity-actions { flex-wrap: wrap; }
.v2-identity-actions a { flex-basis: calc(50% - 4px); }
.v2-identity-meta { grid-template-columns: 1fr; }
.v2-identity-meta > div + div { margin-top: 12px; border: 0; border-top: 1px solid var(--v2-border); padding: 12px 0 0; }
.v2-single-map-card { min-height: 300px; }
.v2-single-map-card footer { align-items: flex-start; flex-direction: column; gap: 4px; padding: 8px 12px; }
.v2-telemetry-list { grid-template-columns: 1fr; }
.v2-telemetry-list > div:nth-child(odd) { border-right: 0; }
.v2-live-grid { grid-template-columns: repeat(2, 1fr); }
.v2-live-grid > div:nth-child(3), .v2-live-grid > div:nth-child(5) { border-left: 0; }
.v2-live-grid > div:nth-child(n+3) { border-top: 1px solid var(--v2-border); }
.v2-track-page { padding: 8px; }
.v2-track-toolbar { grid-template-columns: 1fr; }
.v2-track-vehicle-input { grid-column: auto; }
.v2-track-toolbar > button { width: 100%; }
.v2-track-main { min-height: 770px; grid-template-rows: auto 400px auto 205px; }
.v2-track-map-legend { right: 8px; bottom: 8px; left: 8px; justify-content: center; }
.v2-track-map-legend b { display: none; }
.v2-track-coverage { min-height: 52px; flex-wrap: wrap; gap: 4px 8px; padding: 8px 10px; }
.v2-track-coverage span { width: calc(100% - 20px); white-space: normal; }
.v2-track-coverage em { width: 100%; margin-left: 14px; }
.v2-track-timeline { min-height: 74px; grid-template-columns: 1fr; gap: 6px; padding: 8px 10px; }
.v2-track-timeline header { flex-direction: row; justify-content: space-between; }
.v2-track-timeline > em { grid-column: 1; }
.v2-track-playback { display: flex; align-items: stretch; flex-direction: column; gap: 12px; padding: 10px; }
.v2-play-controls { display: flex; align-items: center; justify-content: space-between; }
.v2-play-controls small { margin: 0; }
.v2-play-progress { border: 0; padding: 0; }
.v2-track-inspector { display: flex; flex-direction: column; }
.v2-history-page { padding: 8px; }
.v2-history-toolbar { grid-template-columns: 1fr; }
.v2-history-vehicles { grid-column: auto; }
.v2-history-toolbar button { width: 100%; }
.v2-history-summary { grid-template-columns: 1fr 1fr; min-height: 106px; }
.v2-history-summary > div:nth-child(3)::before { display: none; }
.v2-history-main { height: 1111px; grid-template-rows: 106px 385px minmax(0, 600px); }
.v2-history-trend > header { align-items: flex-start; flex-direction: column; gap: 6px; padding: 7px 10px; }
.v2-history-trend > header div { flex-wrap: wrap; gap: 5px 10px; }
.v2-history-trend-panels { grid-template-columns: 1fr; grid-auto-rows: 152px; }
.v2-history-side { display: flex; flex-direction: column; }
.v2-access-page { padding: 8px; }
.v2-access-filter { grid-template-columns: 1fr; }
.v2-access-filter label:first-child { grid-column: auto; }
.v2-access-filter button { width: 100%; }
.v2-access-advanced > div { grid-template-columns: 1fr; }
.v2-access-kpis { grid-template-columns: repeat(2, 1fr); }
.v2-access-identity-queue summary { align-items: flex-start; flex-direction: column; gap: 3px; }
.v2-access-identity-queue > div { grid-template-columns: 1fr; }
.v2-access-kpis button:nth-child(4)::before { display: block; }
.v2-access-kpis button:nth-child(odd)::before { display: none; }
.v2-access-protocols header { align-items: flex-start; flex-direction: column; gap: 4px; }
.v2-access-table-card { height: 600px; }
.v2-access-table-card > header { align-items: flex-start; height: auto; flex-direction: column; gap: 7px; padding: 8px 9px; }
.v2-access-table-card > header div { width: 100%; overflow-x: auto; }
.v2-access-table-card > footer { align-items: flex-start; height: auto; flex-direction: column; gap: 7px; padding: 8px 9px; }
.v2-access-side { display: flex; flex-direction: column; }
.v2-alert-page { padding: 0 8px 8px; }
.v2-alert-heading { margin: 0 -8px; padding: 0 10px; }
.v2-alert-heading > div { align-items: flex-start; flex-direction: column; gap: 3px; }
.v2-alert-heading p { margin: 0; }
.v2-alert-tabs { margin: 0 -8px 8px; padding: 0 9px; }
.v2-alert-filter { grid-template-columns: 1fr; }
.v2-alert-filter label:first-child { grid-column: auto; }
.v2-alert-filter label:nth-of-type(6), .v2-alert-filter label:nth-of-type(7) { display: flex; }
.v2-alert-filter button { width: 100%; }
.v2-alert-kpis { grid-template-columns: repeat(2,1fr); }
.v2-alert-kpis button:nth-child(5)::before { display: block; }
.v2-alert-kpis button:nth-child(odd)::before { display: none; }
.v2-alert-table-card { height: 590px; }
.v2-alert-table-card > header, .v2-alert-table-card > footer { align-items: flex-start; height: auto; flex-direction: column; gap: 6px; padding: 7px 9px; }
.v2-alert-inspector { max-height: none; }
.v2-rule-form-grid { grid-template-columns: 1fr; padding: 10px; }
.v2-rule-form-grid label.is-wide { grid-column: auto; }
.v2-alert-rule-editor > footer { align-items: stretch; flex-direction: column; }
.v2-alert-notifications > footer { flex-direction: column; gap: 5px; }
.v2-ops-page { padding: 8px; }.v2-ops-heading { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-ops-kpis { grid-template-columns: 1fr 1fr; }.v2-ops-kpis article + article::before { display: none; }.v2-ops-kpis article { border-bottom: 1px solid var(--v2-border); }
}
@keyframes v2-mobile-sheet-enter {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 374px) {
.v2-nav-label, .v2-sidebar.is-collapsed .v2-nav-label { font-size: 8px; }
.v2-monitor-workspace, .v2-monitor-workspace.is-detail-open, .v2-monitor-workspace.is-detail-collapsed { grid-template-rows: minmax(330px, 50dvh) 340px; min-height: calc(50dvh + 340px); }
.v2-map-legend span:nth-of-type(3), .v2-map-legend span:nth-of-type(4) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

View File

@@ -2,6 +2,10 @@
interface ImportMetaEnv {
readonly VITE_AMAP_WEB_JS_KEY?: string;
readonly VITE_AMAP_SECURITY_JS_CODE?: string;
readonly VITE_AMAP_SECURITY_SERVICE_HOST?: string;
readonly VITE_API_BASE_URL?: string;
readonly VITE_PROTOTYPE_REAL_DATA?: string;
}
interface ImportMeta {
@@ -13,6 +17,8 @@ interface Window {
amapWebJsKey?: string;
amapSecurityJsCode?: string;
amapSecurityServiceHost?: string;
apiBaseUrl?: string;
prototypeUseRealData?: boolean | string;
};
_AMapSecurityConfig?: {
securityJsCode?: string;

Some files were not shown because too many files have changed in this diff Show More