feat: build vehicle data platform and production pipeline
This commit is contained in:
252
vehicle-data-platform/apps/api/cmd/alert-benchmark/main.go
Normal file
252
vehicle-data-platform/apps/api/cmd/alert-benchmark/main.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
vehicle-data-platform/apps/api/cmd/alert-evaluator/main.go
Normal file
52
vehicle-data-platform/apps/api/cmd/alert-evaluator/main.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
129
vehicle-data-platform/apps/api/cmd/platform-migrate/main.go
Normal file
129
vehicle-data-platform/apps/api/cmd/platform-migrate/main.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
176
vehicle-data-platform/apps/api/internal/app/auth.go
Normal file
176
vehicle-data-platform/apps/api/internal/app/auth.go
Normal 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
|
||||
}
|
||||
135
vehicle-data-platform/apps/api/internal/app/auth_test.go
Normal file
135
vehicle-data-platform/apps/api/internal/app/auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
480
vehicle-data-platform/apps/api/internal/platform/access.go
Normal file
480
vehicle-data-platform/apps/api/internal/platform/access.go
Normal 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
|
||||
}
|
||||
255
vehicle-data-platform/apps/api/internal/platform/access_store.go
Normal file
255
vehicle-data-platform/apps/api/internal/platform/access_store.go
Normal 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(¤tVersion); 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)
|
||||
}
|
||||
164
vehicle-data-platform/apps/api/internal/platform/access_test.go
Normal file
164
vehicle-data-platform/apps/api/internal/platform/access_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
314
vehicle-data-platform/apps/api/internal/platform/alert.go
Normal file
314
vehicle-data-platform/apps/api/internal/platform/alert.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
306
vehicle-data-platform/apps/api/internal/platform/alert_mock.go
Normal file
306
vehicle-data-platform/apps/api/internal/platform/alert_mock.go
Normal 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
|
||||
}
|
||||
457
vehicle-data-platform/apps/api/internal/platform/alert_store.go
Normal file
457
vehicle-data-platform/apps/api/internal/platform/alert_store.go
Normal 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(¤t)
|
||||
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
|
||||
}
|
||||
271
vehicle-data-platform/apps/api/internal/platform/alert_stream.go
Normal file
271
vehicle-data-platform/apps/api/internal/platform/alert_stream.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
499
vehicle-data-platform/apps/api/internal/platform/alert_test.go
Normal file
499
vehicle-data-platform/apps/api/internal/platform/alert_test.go
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
309
vehicle-data-platform/apps/api/internal/platform/profile.go
Normal file
309
vehicle-data-platform/apps/api/internal/platform/profile.go
Normal 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
|
||||
}
|
||||
@@ -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(¤t)
|
||||
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
|
||||
}
|
||||
265
vehicle-data-platform/apps/api/internal/platform/profile_test.go
Normal file
265
vehicle-data-platform/apps/api/internal/platform/profile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
52
vehicle-data-platform/apps/web/pnpm-lock.yaml
generated
52
vehicle-data-platform/apps/web/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
7819
vehicle-data-platform/apps/web/src/PrototypeApp.tsx
Normal file
7819
vehicle-data-platform/apps/web/src/PrototypeApp.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 () => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
});
|
||||
@@ -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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
32
vehicle-data-platform/apps/web/src/integrations/amap.test.ts
Normal file
32
vehicle-data-platform/apps/web/src/integrations/amap.test.ts
Normal 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);
|
||||
});
|
||||
267
vehicle-data-platform/apps/web/src/integrations/amap.ts
Normal file
267
vehicle-data-platform/apps/web/src/integrations/amap.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
564
vehicle-data-platform/apps/web/src/prototype/mockData.ts
Normal file
564
vehicle-data-platform/apps/web/src/prototype/mockData.ts
Normal 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);
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
167
vehicle-data-platform/apps/web/src/prototype/rawEvidence.ts
Normal file
167
vehicle-data-platform/apps/web/src/prototype/rawEvidence.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
50
vehicle-data-platform/apps/web/src/prototype/rawFieldMap.ts
Normal file
50
vehicle-data-platform/apps/web/src/prototype/rawFieldMap.ts
Normal 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));
|
||||
}
|
||||
126
vehicle-data-platform/apps/web/src/prototype/realData.test.ts
Normal file
126
vehicle-data-platform/apps/web/src/prototype/realData.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
1556
vehicle-data-platform/apps/web/src/prototype/realData.ts
Normal file
1556
vehicle-data-platform/apps/web/src/prototype/realData.ts
Normal file
File diff suppressed because it is too large
Load Diff
101
vehicle-data-platform/apps/web/src/prototype/types.ts
Normal file
101
vehicle-data-platform/apps/web/src/prototype/types.ts
Normal 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';
|
||||
};
|
||||
450
vehicle-data-platform/apps/web/src/prototype/viewModel.test.ts
Normal file
450
vehicle-data-platform/apps/web/src/prototype/viewModel.test.ts
Normal 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']
|
||||
});
|
||||
});
|
||||
});
|
||||
1316
vehicle-data-platform/apps/web/src/prototype/viewModel.ts
Normal file
1316
vehicle-data-platform/apps/web/src/prototype/viewModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
8380
vehicle-data-platform/apps/web/src/styles/prototype.css
Normal file
8380
vehicle-data-platform/apps/web/src/styles/prototype.css
Normal file
File diff suppressed because it is too large
Load Diff
47
vehicle-data-platform/apps/web/src/v2/AppV2.tsx
Normal file
47
vehicle-data-platform/apps/web/src/v2/AppV2.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx
Normal file
50
vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx
Normal 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>;
|
||||
}
|
||||
22
vehicle-data-platform/apps/web/src/v2/auth/session.test.ts
Normal file
22
vehicle-data-platform/apps/web/src/v2/auth/session.test.ts
Normal 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);
|
||||
});
|
||||
31
vehicle-data-platform/apps/web/src/v2/auth/session.ts
Normal file
31
vehicle-data-platform/apps/web/src/v2/auth/session.ts
Normal 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';
|
||||
}
|
||||
25
vehicle-data-platform/apps/web/src/v2/domain/access.test.ts
Normal file
25
vehicle-data-platform/apps/web/src/v2/domain/access.test.ts
Normal 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('"示范企业"');
|
||||
});
|
||||
});
|
||||
45
vehicle-data-platform/apps/web/src/v2/domain/access.ts
Normal file
45
vehicle-data-platform/apps/web/src/v2/domain/access.ts
Normal 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')}`;
|
||||
}
|
||||
26
vehicle-data-platform/apps/web/src/v2/domain/alert.test.ts
Normal file
26
vehicle-data-platform/apps/web/src/v2/domain/alert.test.ts
Normal 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('区间内 20–80 %,持续 30 秒');
|
||||
expect(ruleCondition({ valueType: 'boolean', metric: 'alarm_active', operator: 'changed', durationSec: 0 } as never)).toBe('协议告警位 状态变化');
|
||||
});
|
||||
});
|
||||
36
vehicle-data-platform/apps/web/src/v2/domain/alert.ts
Normal file
36
vehicle-data-platform/apps/web/src/v2/domain/alert.ts
Normal 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';
|
||||
}
|
||||
44
vehicle-data-platform/apps/web/src/v2/domain/history.test.ts
Normal file
44
vehicle-data-platform/apps/web/src/v2/domain/history.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
87
vehicle-data-platform/apps/web/src/v2/domain/history.ts
Normal file
87
vehicle-data-platform/apps/web/src/v2/domain/history.ts
Normal 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`;
|
||||
}
|
||||
40
vehicle-data-platform/apps/web/src/v2/domain/monitor.test.ts
Normal file
40
vehicle-data-platform/apps/web/src/v2/domain/monitor.test.ts
Normal 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('行驶');
|
||||
});
|
||||
});
|
||||
35
vehicle-data-platform/apps/web/src/v2/domain/monitor.ts
Normal file
35
vehicle-data-platform/apps/web/src/v2/domain/monitor.ts
Normal 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);
|
||||
}
|
||||
@@ -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(/引号结束/);
|
||||
});
|
||||
});
|
||||
70
vehicle-data-platform/apps/web/src/v2/domain/profileSync.ts
Normal file
70
vehicle-data-platform/apps/web/src/v2/domain/profileSync.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
19
vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts
Normal file
19
vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts
Normal 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;
|
||||
}
|
||||
21
vehicle-data-platform/apps/web/src/v2/domain/track.test.ts
Normal file
21
vehicle-data-platform/apps/web/src/v2/domain/track.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
42
vehicle-data-platform/apps/web/src/v2/domain/track.ts
Normal file
42
vehicle-data-platform/apps/web/src/v2/domain/track.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
104
vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts
Normal file
104
vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts
Normal 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 };
|
||||
}
|
||||
88
vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx
Normal file
88
vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
351
vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx
Normal file
351
vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx
Normal 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();
|
||||
});
|
||||
442
vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx
Normal file
442
vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx
Normal 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) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
})[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>
|
||||
);
|
||||
}
|
||||
108
vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx
Normal file
108
vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx
Normal 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>;
|
||||
}
|
||||
117
vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx
Normal file
117
vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx
Normal 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>;
|
||||
}
|
||||
126
vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx
Normal file
126
vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx
Normal 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>;
|
||||
}
|
||||
132
vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx
Normal file
132
vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
207
vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
Normal file
207
vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
191
vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
Normal file
191
vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
Normal 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>;
|
||||
}
|
||||
205
vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx
Normal file
205
vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx
Normal 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(); }} />;
|
||||
}
|
||||
19
vehicle-data-platform/apps/web/src/v2/shared/AsyncState.tsx
Normal file
19
vehicle-data-platform/apps/web/src/v2/shared/AsyncState.tsx
Normal 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>;
|
||||
}
|
||||
980
vehicle-data-platform/apps/web/src/v2/styles/v2.css
Normal file
980
vehicle-data-platform/apps/web/src/v2/styles/v2.css
Normal 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; }
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user