feat: build vehicle data platform and production pipeline

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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