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,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)
}