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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user