feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
@@ -65,7 +65,7 @@ func (s *ProductionStore) AccessEvidence(ctx context.Context) ([]AccessEvidenceR
|
||||
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(NULLIF(p.brand_name, ''), NULLIF(b.oem, ''), '') AS oem,
|
||||
COALESCE(p.model_name, '') AS model_name,
|
||||
COALESCE(p.company_name, '') AS company_name,
|
||||
COALESCE(s.protocol, '') AS protocol,
|
||||
@@ -145,7 +145,7 @@ LIMIT ?`, accessEvidenceLimit+1)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error {
|
||||
s.accessSchemaOnce.Do(func() {
|
||||
return s.accessSchema.ensure(func() error {
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS vehicle_access_threshold_config (
|
||||
id TINYINT NOT NULL PRIMARY KEY,
|
||||
@@ -170,17 +170,16 @@ func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error {
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := s.db.ExecContext(ctx, statement); err != nil {
|
||||
s.accessSchemaErr = err
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
defaults := defaultAccessThresholds(time.Now())
|
||||
protocols, _ := json.Marshal(defaults.Protocols)
|
||||
_, s.accessSchemaErr = s.db.ExecContext(ctx, `INSERT IGNORE INTO vehicle_access_threshold_config
|
||||
_, err := 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 err
|
||||
})
|
||||
return s.accessSchemaErr
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AccessThresholds(ctx context.Context) (AccessThresholdConfig, error) {
|
||||
|
||||
@@ -439,7 +439,7 @@ func loadActiveAlertEvents(ctx context.Context, tx *sql.Tx) (map[string][]alertA
|
||||
}
|
||||
|
||||
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)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT l.vin,COALESCE(NULLIF(l.plate,''),NULLIF(b.plate,''),''),l.protocol,COALESCE(NULLIF(p.brand_name,''),NULLIF(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
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
)
|
||||
|
||||
func (s *ProductionStore) ensureAlertSchema(ctx context.Context) error {
|
||||
s.alertSchemaOnce.Do(func() {
|
||||
return s.alertSchema.ensure(func() error {
|
||||
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)
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_rule`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("alert center schema unavailable; apply deploy/migrations/002_alert_center.sql: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return s.alertSchemaErr
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertSummary(ctx context.Context, query AlertQuery) (AlertSummary, error) {
|
||||
|
||||
@@ -367,7 +367,7 @@ func loadAlertStreamVehicleMetadata(ctx context.Context, tx *sql.Tx, records []A
|
||||
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`
|
||||
query := `SELECT v.vin,COALESCE(MAX(NULLIF(b.plate,'')),''),COALESCE(MAX(NULLIF(p.brand_name,'')),MAX(NULLIF(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
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
@@ -15,6 +16,33 @@ import (
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestAlertSchemaProbeRetriesAfterCanceledRequestAndCachesSuccess(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := store.ensureAlertSchema(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected canceled schema probe, got %v", err)
|
||||
}
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
if err := store.ensureAlertSchema(t.Context()); err != nil {
|
||||
t.Fatalf("expected retry to succeed, got %v", err)
|
||||
}
|
||||
if err := store.ensureAlertSchema(t.Context()); err != nil {
|
||||
t.Fatalf("expected successful probe to stay cached, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type configuredMetricStore struct {
|
||||
*MockStore
|
||||
definitions []MetricDefinition
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// gb32960FieldReference is the platform copy of the authoritative Chinese
|
||||
// terminology supplied for GB/T 32960 and the Guangdong fuel-cell extension.
|
||||
// It enriches protocol values at read time without changing the stored RAW
|
||||
// evidence or replacing the parser's stable source-field keys.
|
||||
type gb32960FieldReference struct {
|
||||
Label string
|
||||
Unit string
|
||||
Description string
|
||||
ValueMappings []MetricValueMapping
|
||||
}
|
||||
|
||||
func enumMappings(values ...string) []MetricValueMapping {
|
||||
result := make([]MetricValueMapping, 0, len(values)/3)
|
||||
for index := 0; index+2 < len(values); index += 3 {
|
||||
result = append(result, MetricValueMapping{Value: values[index], Label: values[index+1], Description: values[index+2]})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var gb32960PositionMappings = func() []MetricValueMapping {
|
||||
result := make([]MetricValueMapping, 0, 8)
|
||||
for value := 0; value < 8; value++ {
|
||||
parts := []string{"有效定位", "北纬", "东经"}
|
||||
if value&1 != 0 {
|
||||
parts[0] = "无效定位"
|
||||
}
|
||||
if value&2 != 0 {
|
||||
parts[1] = "南纬"
|
||||
}
|
||||
if value&4 != 0 {
|
||||
parts[2] = "西经"
|
||||
}
|
||||
result = append(result, MetricValueMapping{
|
||||
Value: strconv.Itoa(value),
|
||||
Label: strings.Join(parts, " · "),
|
||||
Description: "bit0 表示定位有效性,bit1 表示纬度方向,bit2 表示经度方向。",
|
||||
})
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
var gb32960FieldReferences = map[string]gb32960FieldReference{
|
||||
"gb32960.vehicle.vehicle_status": {Label: "车辆状态", Description: "车辆运行、停止等状态编码。", ValueMappings: enumMappings("1", "启动", "车辆处于可行驶启动状态。", "2", "熄火", "车辆处于熄火状态。", "3", "其他", "车辆处于其他状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.vehicle.charge_status": {Label: "充电状态", Description: "车辆充电状态编码。", ValueMappings: enumMappings("1", "停车充电", "车辆停车充电。", "2", "行驶充电", "车辆行驶充电。", "3", "未充电", "车辆未充电。", "4", "充电完成", "车辆充电完成。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃料电池等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃料电池", "燃料电池驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.vehicle.speed_kmh": {Label: "车速", Unit: "km/h", Description: "车辆当前速度。"},
|
||||
"gb32960.vehicle.total_mileage_km": {Label: "累计里程", Unit: "km", Description: "车辆累计行驶里程。"},
|
||||
"gb32960.vehicle.total_voltage_v": {Label: "总电压", Unit: "V", Description: "动力系统总电压。"},
|
||||
"gb32960.vehicle.total_current_a": {Label: "总电流", Unit: "A", Description: "动力系统总电流。"},
|
||||
"gb32960.vehicle.soc_percent": {Label: "SOC", Unit: "%", Description: "动力电池荷电状态。"},
|
||||
"gb32960.vehicle.dc_dc_status": {Label: "DC/DC 状态", Description: "DC/DC 工作状态编码。", ValueMappings: enumMappings("1", "工作", "DC/DC 处于工作状态。", "2", "断开", "DC/DC 处于断开状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.vehicle.gear": {Label: "挡位原始值", Description: "挡位、驱动力和制动力组合原始编码。"},
|
||||
"gb32960.vehicle.insulation_kohm": {Label: "绝缘电阻", Unit: "kΩ", Description: "高压系统绝缘电阻。"},
|
||||
"gb32960.vehicle.accelerator_pct": {Label: "加速踏板行程", Unit: "%", Description: "2016 版字段,加速踏板开度。"},
|
||||
"gb32960.vehicle.brake_pct": {Label: "制动踏板状态", Unit: "%", Description: "2016 版字段,制动踏板状态或开度。"},
|
||||
|
||||
"gb32960.drive_motor.serial_no": {Label: "驱动电机序号", Description: "电机编号。"},
|
||||
"gb32960.drive_motor.state": {Label: "驱动电机状态", Description: "电机工作状态编码。", ValueMappings: enumMappings("1", "耗电", "驱动电机耗电。", "2", "发电", "驱动电机发电。", "3", "关闭", "驱动电机关闭。", "4", "准备", "驱动电机准备。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.drive_motor.controller_temperature_c": {Label: "控制器温度", Unit: "℃", Description: "驱动电机控制器温度。"},
|
||||
"gb32960.drive_motor.speed_rpm": {Label: "转速", Unit: "rpm", Description: "驱动电机转速。"},
|
||||
"gb32960.drive_motor.torque_nm": {Label: "转矩", Unit: "N·m", Description: "驱动电机输出转矩。"},
|
||||
"gb32960.drive_motor.motor_temperature_c": {Label: "电机温度", Unit: "℃", Description: "驱动电机温度。"},
|
||||
"gb32960.drive_motor.controller_voltage_v": {Label: "控制器输入电压", Unit: "V", Description: "电机控制器直流母线输入电压。"},
|
||||
"gb32960.drive_motor.controller_current_a": {Label: "控制器直流母线电流", Unit: "A", Description: "电机控制器直流母线电流。"},
|
||||
|
||||
"gb32960.fuel_cell.fuel_cell_voltage_v": {Label: "燃料电池电压", Unit: "V", Description: "燃料电池系统输出电压。"},
|
||||
"gb32960.fuel_cell.fuel_cell_current_a": {Label: "燃料电池电流", Unit: "A", Description: "燃料电池系统输出电流。"},
|
||||
"gb32960.fuel_cell.hydrogen_consumption_kg_per_100km": {Label: "氢耗", Unit: "kg/100km", Description: "燃料电池氢气消耗率。"},
|
||||
"gb32960.fuel_cell.temperature_probe_values_c": {Label: "探针温度", Unit: "℃", Description: "燃料电池温度探针列表。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_temperature_c": {Label: "氢系统最高温度", Unit: "℃", Description: "氢系统最高温度。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_concentration_percent": {Label: "最高氢浓度", Unit: "%", Description: "氢气浓度最高值。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_pressure_mpa": {Label: "最高氢压力", Unit: "MPa", Description: "氢系统最高压力。"},
|
||||
"gb32960.fuel_cell.dc_dc_status": {Label: "高压 DC/DC 状态", Description: "燃料电池高压 DC/DC 状态。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_temperature_probe_id": {Label: "最高氢温探针编号", Description: "检测到氢系统最高温度的探针编号。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_concentration_probe_id": {Label: "最高氢浓度探针编号", Description: "检测到最高氢浓度的探针编号。"},
|
||||
"gb32960.fuel_cell.max_hydrogen_pressure_probe_id": {Label: "最高氢压探针编号", Description: "检测到最高氢压力的探针编号。"},
|
||||
|
||||
"gb32960.position.position_status": {Label: "定位状态标志", Description: "定位有效性及经纬度方向标志。", ValueMappings: gb32960PositionMappings},
|
||||
"gb32960.position.coordinate_system": {Label: "坐标系", Description: "2025 版新增坐标系编码。", ValueMappings: enumMappings("1", "WGS84", "WGS84 坐标系。", "2", "GCJ-02", "GCJ-02 坐标系。", "3", "其他", "其他坐标系。")},
|
||||
"gb32960.position.longitude": {Label: "经度", Unit: "°", Description: "车辆经度。"},
|
||||
"gb32960.position.latitude": {Label: "纬度", Unit: "°", Description: "车辆纬度。"},
|
||||
|
||||
"gb32960.alarm.max_alarm_level": {Label: "最高报警等级", Description: "当前最高报警等级。", ValueMappings: enumMappings("0", "无故障", "当前无报警故障。", "1", "一级故障", "一级报警故障。", "2", "二级故障", "二级报警故障。", "3", "三级故障", "三级报警故障。")},
|
||||
"gb32960.alarm.general_alarm_flag": {Label: "通用报警标志", Description: "通用报警位图。"},
|
||||
"gb32960.alarm.battery_faults": {Label: "可充电储能装置故障码", Description: "动力电池相关故障码列表。"},
|
||||
"gb32960.alarm.motor_faults": {Label: "驱动电机故障码", Description: "驱动电机相关故障码列表。"},
|
||||
"gb32960.alarm.engine_faults": {Label: "发动机故障码", Description: "发动机相关故障码列表。"},
|
||||
"gb32960.alarm.other_faults": {Label: "其他故障码", Description: "其他故障码列表。"},
|
||||
|
||||
"gb32960.gd_fc_stack.stack_count": {Label: "电堆数量", Description: "本包包含的电堆数量。"},
|
||||
"gb32960.gd_fc_stack.engine_work_state": {Label: "发动机工作状态", Description: "燃料电池发动机工作状态。"},
|
||||
"gb32960.gd_fc_stack.stack_water_outlet_temp_c": {Label: "电堆出水温度", Unit: "℃", Description: "电堆冷却水出口温度。"},
|
||||
"gb32960.gd_fc_stack.hydrogen_inlet_pressure_kpa": {Label: "氢气入口压力", Unit: "kPa", Description: "电堆氢气入口压力。"},
|
||||
"gb32960.gd_fc_stack.air_inlet_pressure_kpa": {Label: "空气入口压力", Unit: "kPa", Description: "电堆空气入口压力。"},
|
||||
"gb32960.gd_fc_stack.air_inlet_temp_c": {Label: "空气入口温度", Unit: "℃", Description: "电堆空气入口温度。"},
|
||||
"gb32960.gd_fc_stack.max_cell_voltage_v": {Label: "单体最高电压", Unit: "V", Description: "本电堆单体最高电压。"},
|
||||
"gb32960.gd_fc_stack.min_cell_voltage_v": {Label: "单体最低电压", Unit: "V", Description: "本电堆单体最低电压。"},
|
||||
"gb32960.gd_fc_stack.avg_cell_voltage_v": {Label: "单体平均电压", Unit: "V", Description: "本电堆单体平均电压。"},
|
||||
"gb32960.gd_fc_stack.cell_count": {Label: "单体总数", Description: "电堆单体总数量。"},
|
||||
"gb32960.gd_fc_stack.frame_cell_start": {Label: "本帧单体起始序号", Description: "当前分段电压起始单体序号。"},
|
||||
"gb32960.gd_fc_stack.frame_cell_count": {Label: "本帧单体数量", Description: "当前分段包含的单体数量。"},
|
||||
"gb32960.gd_fc_stack.frame_cell_voltages_v": {Label: "本帧单体电压列表", Unit: "V", Description: "当前分段的单体电压数组;未跨帧合并时不代表完整电堆快照。"},
|
||||
|
||||
"gb32960.gd_fc_dcdc.input_voltage_v": {Label: "输入电压", Unit: "V", Description: "DC/DC 输入电压。"},
|
||||
"gb32960.gd_fc_dcdc.input_current_a": {Label: "输入电流", Unit: "A", Description: "DC/DC 输入电流。"},
|
||||
"gb32960.gd_fc_dcdc.output_voltage_v": {Label: "输出电压", Unit: "V", Description: "DC/DC 输出电压。"},
|
||||
"gb32960.gd_fc_dcdc.output_current_a": {Label: "输出电流", Unit: "A", Description: "DC/DC 输出电流。"},
|
||||
"gb32960.gd_fc_dcdc.controller_temp_c": {Label: "控制器温度", Unit: "℃", Description: "DC/DC 控制器温度。"},
|
||||
|
||||
"gb32960.gd_fc_air_conditioner.status": {Label: "空调状态", Description: "空调工作状态。", ValueMappings: enumMappings("0", "关闭", "空调关闭。", "1", "启动", "空调启动。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.gd_fc_air_conditioner.power_kw": {Label: "空调功率", Unit: "kW", Description: "空调消耗功率。"},
|
||||
"gb32960.gd_fc_air_conditioner.compressor_input_voltage_v": {Label: "压缩机输入电压", Unit: "V", Description: "空调压缩机输入电压。"},
|
||||
|
||||
"gb32960.gd_fc_vehicle_info.collision_alarm": {Label: "碰撞报警", Description: "碰撞报警状态。", ValueMappings: enumMappings("0", "无碰撞报警", "当前无碰撞报警。", "1", "有碰撞报警", "当前有碰撞报警。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
|
||||
"gb32960.gd_fc_vehicle_info.ambient_temp_c": {Label: "环境温度", Unit: "℃", Description: "车辆环境温度。"},
|
||||
"gb32960.gd_fc_vehicle_info.ambient_pressure_kpa": {Label: "环境压力", Unit: "kPa", Description: "车辆环境压力。"},
|
||||
"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg": {Label: "车载氢量", Unit: "kg", Description: "当前车载氢气质量。"},
|
||||
}
|
||||
|
||||
func canonicalGB32960ReferenceKey(sourceField string) string {
|
||||
sourceField = strings.ToLower(strings.TrimSpace(sourceField))
|
||||
if !strings.HasPrefix(sourceField, "gb32960.") {
|
||||
return sourceField
|
||||
}
|
||||
if strings.HasPrefix(sourceField, "gb32960.drive_motor.") {
|
||||
return "gb32960.drive_motor." + lastSourcePart(sourceField)
|
||||
}
|
||||
if strings.HasPrefix(sourceField, "gb32960.gd_fc_stack.") {
|
||||
return "gb32960.gd_fc_stack." + lastSourcePart(sourceField)
|
||||
}
|
||||
return sourceField
|
||||
}
|
||||
|
||||
func lastSourcePart(sourceField string) string {
|
||||
if index := strings.LastIndexByte(sourceField, '.'); index >= 0 && index+1 < len(sourceField) {
|
||||
return sourceField[index+1:]
|
||||
}
|
||||
return sourceField
|
||||
}
|
||||
|
||||
func gb32960ReferenceForSource(sourceField string) (gb32960FieldReference, bool) {
|
||||
reference, ok := gb32960FieldReferences[canonicalGB32960ReferenceKey(sourceField)]
|
||||
if !ok {
|
||||
return gb32960FieldReference{}, false
|
||||
}
|
||||
reference.ValueMappings = append([]MetricValueMapping(nil), reference.ValueMappings...)
|
||||
return reference, true
|
||||
}
|
||||
|
||||
func protocolDisplayValue(sourceField string, value any) string {
|
||||
reference, ok := gb32960ReferenceForSource(sourceField)
|
||||
if !ok || len(reference.ValueMappings) == 0 || value == nil {
|
||||
return ""
|
||||
}
|
||||
raw := metricMappingValue(value)
|
||||
for _, mapping := range reference.ValueMappings {
|
||||
if mapping.Value == raw {
|
||||
return fmt.Sprintf("%s(%s)", mapping.Label, raw)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("未知状态(%s)", raw)
|
||||
}
|
||||
|
||||
func metricMappingValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
if typed == float64(int64(typed)) {
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
}
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int8:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
case int16:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
case uint:
|
||||
return strconv.FormatUint(uint64(typed), 10)
|
||||
case uint8:
|
||||
return strconv.FormatUint(uint64(typed), 10)
|
||||
case uint16:
|
||||
return strconv.FormatUint(uint64(typed), 10)
|
||||
case uint32:
|
||||
return strconv.FormatUint(uint64(typed), 10)
|
||||
case uint64:
|
||||
return strconv.FormatUint(typed, 10)
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
|
||||
func enrichMetricDefinitions(definitions []MetricDefinition) []MetricDefinition {
|
||||
result := make([]MetricDefinition, len(definitions))
|
||||
copy(result, definitions)
|
||||
for index := range result {
|
||||
definition := &result[index]
|
||||
sourceField := definition.SourceFields["GB32960"]
|
||||
reference, ok := gb32960ReferenceForSource(sourceField)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(definition.Protocols) == 1 && strings.EqualFold(definition.Protocols[0], "GB32960") {
|
||||
definition.Label = reference.Label
|
||||
definition.Description = reference.Description
|
||||
definition.Unit = reference.Unit
|
||||
}
|
||||
definition.ValueMappings = reference.ValueMappings
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -84,13 +84,13 @@ func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRejectsInvalidMonitorBounds(t *testing.T) {
|
||||
func TestHandlerIgnoresInvalidOptionalMonitorBounds(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())
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"mode"`) || strings.Contains(response.Body.String(), "MONITOR_BOUNDS_INVALID") {
|
||||
t.Fatalf("optional invalid bounds must degrade to an unbounded map response: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func NewMockStore() *MockStore {
|
||||
sourceProviders: map[string]string{},
|
||||
sourcePolicyRemarks: map[string]string{},
|
||||
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"},
|
||||
"LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", BrandName: "飞驰", 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},
|
||||
@@ -350,7 +350,7 @@ func (m *MockStore) SaveVehicleProfile(_ context.Context, vin string, input Vehi
|
||||
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)}
|
||||
profile := VehicleProfile{VIN: vin, BrandName: input.BrandName, 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
|
||||
}
|
||||
@@ -394,7 +394,7 @@ func (m *MockStore) SyncVehicleProfiles(_ context.Context, request VehicleProfil
|
||||
continue
|
||||
}
|
||||
m.profiles[item.VIN] = VehicleProfile{
|
||||
VIN: item.VIN, ModelName: item.ModelName, VehicleType: item.VehicleType,
|
||||
VIN: item.VIN, BrandName: item.BrandName, ModelName: item.ModelName, VehicleType: item.VehicleType,
|
||||
CompanyName: item.CompanyName, OperationStatus: item.OperationStatus,
|
||||
AccessProvider: item.AccessProvider, FirstAccessAt: item.FirstAccessAt,
|
||||
RuntimeSeconds: item.RuntimeSeconds, SourceSystem: request.SourceSystem,
|
||||
|
||||
@@ -206,17 +206,24 @@ type MetricCatalog struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
ValueMappings []MetricValueMapping `json:"valueMappings,omitempty"`
|
||||
Searchable bool `json:"searchable"`
|
||||
Chartable bool `json:"chartable"`
|
||||
Alertable bool `json:"alertable"`
|
||||
}
|
||||
|
||||
type MetricValueMapping struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type HistoryDataCategory struct {
|
||||
@@ -225,12 +232,14 @@ type HistoryDataCategory struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Unit string `json:"unit"`
|
||||
Category string `json:"category"`
|
||||
ValueType string `json:"valueType"`
|
||||
ValueMappings []MetricValueMapping `json:"valueMappings,omitempty"`
|
||||
DefaultVisible bool `json:"defaultVisible"`
|
||||
}
|
||||
|
||||
type HistoryDataResponse struct {
|
||||
@@ -940,6 +949,7 @@ type VehicleDetail struct {
|
||||
|
||||
type VehicleProfile struct {
|
||||
VIN string `json:"vin"`
|
||||
BrandName string `json:"brandName"`
|
||||
ModelName string `json:"modelName"`
|
||||
VehicleType string `json:"vehicleType"`
|
||||
CompanyName string `json:"companyName"`
|
||||
@@ -958,6 +968,7 @@ type VehicleProfile struct {
|
||||
}
|
||||
|
||||
type VehicleProfileInput struct {
|
||||
BrandName string `json:"brandName"`
|
||||
ModelName string `json:"modelName"`
|
||||
VehicleType string `json:"vehicleType"`
|
||||
CompanyName string `json:"companyName"`
|
||||
@@ -971,6 +982,7 @@ type VehicleProfileInput struct {
|
||||
|
||||
type VehicleProfileSyncItem struct {
|
||||
VIN string `json:"vin"`
|
||||
BrandName string `json:"brandName"`
|
||||
ModelName string `json:"modelName"`
|
||||
VehicleType string `json:"vehicleType"`
|
||||
CompanyName string `json:"companyName"`
|
||||
@@ -1337,6 +1349,7 @@ type LatestTelemetryValue struct {
|
||||
Category string `json:"category"`
|
||||
ValueType string `json:"valueType"`
|
||||
Value any `json:"value"`
|
||||
DisplayValue string `json:"displayValue,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
SourceEndpoint string `json:"sourceEndpoint,omitempty"`
|
||||
FrameID string `json:"frameId"`
|
||||
|
||||
@@ -7,26 +7,21 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProductionStore struct {
|
||||
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
|
||||
reconciliationSchemaOnce sync.Once
|
||||
reconciliationSchemaErr error
|
||||
db *sql.DB
|
||||
tdengine *sql.DB
|
||||
tdDatabase string
|
||||
redisOnline redisOnlineKeyCounter
|
||||
capacityCheck capacityChecker
|
||||
alertStreamGroup string
|
||||
alertStreamMode string
|
||||
accessSchema schemaReadyGate
|
||||
alertSchema schemaReadyGate
|
||||
profileSchema schemaReadyGate
|
||||
reconciliationSchema schemaReadyGate
|
||||
}
|
||||
|
||||
type redisOnlineKeyCounter interface {
|
||||
|
||||
@@ -100,6 +100,7 @@ func normalizeVehicleProfileSyncRequest(request VehicleProfileSyncRequest) Vehic
|
||||
item := &request.Items[index]
|
||||
item.VIN = strings.ToUpper(strings.TrimSpace(item.VIN))
|
||||
normalized := normalizeVehicleProfileInput(vehicleProfileSyncInput(*item, request.Actor))
|
||||
item.BrandName = normalized.BrandName
|
||||
item.ModelName = normalized.ModelName
|
||||
item.VehicleType = normalized.VehicleType
|
||||
item.CompanyName = normalized.CompanyName
|
||||
@@ -149,7 +150,7 @@ func validateVehicleProfileSyncRequest(request VehicleProfileSyncRequest) error
|
||||
|
||||
func vehicleProfileSyncInput(item VehicleProfileSyncItem, actor string) VehicleProfileInput {
|
||||
return VehicleProfileInput{
|
||||
ModelName: item.ModelName, VehicleType: item.VehicleType, CompanyName: item.CompanyName,
|
||||
BrandName: item.BrandName, ModelName: item.ModelName, VehicleType: item.VehicleType, CompanyName: item.CompanyName,
|
||||
OperationStatus: item.OperationStatus, AccessProvider: item.AccessProvider,
|
||||
FirstAccessAt: item.FirstAccessAt, RuntimeSeconds: item.RuntimeSeconds, Actor: actor,
|
||||
}
|
||||
@@ -172,7 +173,7 @@ func vehicleProfileSyncDecision(current VehicleProfile, exists bool, request Veh
|
||||
}
|
||||
|
||||
func vehicleProfileSyncFieldsEqual(current VehicleProfile, item VehicleProfileSyncItem) bool {
|
||||
return current.ModelName == item.ModelName && current.VehicleType == item.VehicleType &&
|
||||
return current.BrandName == item.BrandName && 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)
|
||||
@@ -221,6 +222,7 @@ func validateProfileVIN(vin string) error {
|
||||
}
|
||||
|
||||
func normalizeVehicleProfileInput(input VehicleProfileInput) VehicleProfileInput {
|
||||
input.BrandName = strings.TrimSpace(input.BrandName)
|
||||
input.ModelName = strings.TrimSpace(input.ModelName)
|
||||
input.VehicleType = strings.TrimSpace(input.VehicleType)
|
||||
input.CompanyName = strings.TrimSpace(input.CompanyName)
|
||||
@@ -240,7 +242,7 @@ func validateVehicleProfileInput(input VehicleProfileInput) error {
|
||||
max int
|
||||
name string
|
||||
}{
|
||||
{input.ModelName, 128, "车型"}, {input.VehicleType, 64, "车辆类型"},
|
||||
{input.BrandName, 128, "品牌"}, {input.ModelName, 128, "车型"}, {input.VehicleType, 64, "车辆类型"},
|
||||
{input.CompanyName, 128, "所属企业"}, {input.AccessProvider, 128, "接入服务商"},
|
||||
}
|
||||
for _, field := range lengths {
|
||||
@@ -278,7 +280,10 @@ func parseVehicleProfileTime(value string) (time.Time, error) {
|
||||
}
|
||||
|
||||
func decorateVehicleProfile(profile VehicleProfile) VehicleProfile {
|
||||
missing := make([]string, 0, 7)
|
||||
missing := make([]string, 0, 8)
|
||||
if profile.BrandName == "" {
|
||||
missing = append(missing, "brandName")
|
||||
}
|
||||
if profile.ModelName == "" {
|
||||
missing = append(missing, "modelName")
|
||||
}
|
||||
@@ -301,7 +306,7 @@ func decorateVehicleProfile(profile VehicleProfile) VehicleProfile {
|
||||
missing = append(missing, "runtimeSeconds")
|
||||
}
|
||||
profile.MissingFields = missing
|
||||
profile.Completeness = (7 - len(missing)) * 100 / 7
|
||||
profile.Completeness = (8 - len(missing)) * 100 / 8
|
||||
if profile.OperationStatus == "" {
|
||||
profile.OperationStatus = "unknown"
|
||||
}
|
||||
|
||||
@@ -8,17 +8,19 @@ import (
|
||||
"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 `
|
||||
const vehicleProfileSelect = `SELECT vin,brand_name,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() {
|
||||
return s.profileSchema.ensure(func() error {
|
||||
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
|
||||
if err := 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); err != nil {
|
||||
return err
|
||||
}
|
||||
if present != 2 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return s.profileSchemaErr
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleProfile(ctx context.Context, vin string) (VehicleProfile, bool, error) {
|
||||
@@ -48,7 +50,7 @@ func (s *ProductionStore) SaveVehicleProfile(ctx context.Context, vin string, in
|
||||
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)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,brand_name,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.BrandName, input.ModelName, input.VehicleType, input.CompanyName, input.OperationStatus, input.AccessProvider, firstAccess, input.RuntimeSeconds, input.Actor)
|
||||
current = 0
|
||||
} else if err != nil {
|
||||
return VehicleProfile{}, err
|
||||
@@ -56,7 +58,7 @@ func (s *ProductionStore) SaveVehicleProfile(ctx context.Context, vin string, in
|
||||
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)
|
||||
result, updateErr := tx.ExecContext(ctx, `UPDATE vehicle_profile SET brand_name=?,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.BrandName, 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()
|
||||
@@ -154,10 +156,10 @@ func (s *ProductionStore) SyncVehicleProfiles(ctx context.Context, request Vehic
|
||||
|
||||
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)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO vehicle_profile(vin,brand_name,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.BrandName, 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)
|
||||
updateResult, err = tx.ExecContext(ctx, `UPDATE vehicle_profile SET brand_name=?,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.BrandName, 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 {
|
||||
@@ -207,7 +209,7 @@ func scanVehicleProfile(scanner vehicleProfileScanner) (VehicleProfile, error) {
|
||||
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)
|
||||
err := scanner.Scan(&profile.VIN, &profile.BrandName, &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
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestVehicleProfileReturnsCompletenessAndMissingFields(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if empty.Version != 0 || empty.Completeness != 0 || len(empty.MissingFields) != 7 {
|
||||
if empty.Version != 0 || empty.Completeness != 0 || len(empty.MissingFields) != 8 {
|
||||
t.Fatalf("expected explicit empty profile, got %+v", empty)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestVehicleProfileReturnsCompletenessAndMissingFields(t *testing.T) {
|
||||
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"}
|
||||
input := VehicleProfileInput{BrandName: "现代", 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)
|
||||
@@ -174,7 +174,7 @@ func TestVehicleProfileHandlersReadAndUpdate(t *testing.T) {
|
||||
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}`)
|
||||
body := bytes.NewBufferString(`{"brandName":"现代","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())
|
||||
@@ -216,12 +216,12 @@ func TestProductionVehicleProfileCreateIsTransactionalAndAudited(t *testing.T) {
|
||||
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(vin,brand_name,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"})
|
||||
columns := []string{"vin", "brand_name", "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{BrandName: "现代", ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: now.Format(time.RFC3339), RuntimeSeconds: &runtime, Actor: "admin-a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -243,13 +243,13 @@ func TestProductionVehicleProfileSyncCreatesAndAuditsInOneTransaction(t *testing
|
||||
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}},
|
||||
Items: []VehicleProfileSyncItem{{VIN: "VIN001", BrandName: "现代", 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.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin", "brand_name", "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,brand_name,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)
|
||||
|
||||
@@ -42,18 +42,17 @@ type reconciliationExisting struct {
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ensureReconciliationSchema(ctx context.Context) error {
|
||||
s.reconciliationSchemaOnce.Do(func() {
|
||||
return s.reconciliationSchema.ensure(func() error {
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name IN ('vehicle_reconciliation_run','vehicle_reconciliation_issue','vehicle_reconciliation_action')`).Scan(&count); err != nil {
|
||||
s.reconciliationSchemaErr = err
|
||||
return
|
||||
return err
|
||||
}
|
||||
if count != 3 {
|
||||
s.reconciliationSchemaErr = fmt.Errorf("reconciliation schema unavailable; apply deploy/migrations/017_reconciliation_center.sql")
|
||||
return fmt.Errorf("reconciliation schema unavailable; apply deploy/migrations/017_reconciliation_center.sql")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return s.reconciliationSchemaErr
|
||||
}
|
||||
|
||||
func (s *ProductionStore) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package platform
|
||||
|
||||
import "sync"
|
||||
|
||||
// schemaReadyGate serializes schema probes and caches only a successful result.
|
||||
// Request cancellation and transient database failures must remain retryable.
|
||||
type schemaReadyGate struct {
|
||||
mu sync.Mutex
|
||||
ready bool
|
||||
}
|
||||
|
||||
func (gate *schemaReadyGate) ensure(check func() error) error {
|
||||
gate.mu.Lock()
|
||||
defer gate.mu.Unlock()
|
||||
if gate.ready {
|
||||
return nil
|
||||
}
|
||||
if err := check(); err != nil {
|
||||
return err
|
||||
}
|
||||
gate.ready = true
|
||||
return nil
|
||||
}
|
||||
@@ -608,9 +608,13 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
|
||||
zoom = 20
|
||||
}
|
||||
provinceMode := zoom <= monitorProvinceMaxZoom
|
||||
bounds, hasBounds, err := parseMonitorBounds(query.Get("bounds"))
|
||||
if err != nil {
|
||||
return MonitorMapResponse{}, err
|
||||
bounds, hasBounds, boundsErr := parseMonitorBounds(query.Get("bounds"))
|
||||
if boundsErr != nil {
|
||||
// The viewport is an optional performance hint. Wide, wrapped or
|
||||
// intermediate map frames can briefly exceed WGS-84 bounds; falling
|
||||
// back to the bounded unfiltered response keeps the map usable.
|
||||
bounds = monitorBounds{}
|
||||
hasBounds = false
|
||||
}
|
||||
result := MonitorMapResponse{
|
||||
Mode: "clusters",
|
||||
@@ -1602,6 +1606,63 @@ func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[
|
||||
return s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
type trackSourceCandidate struct {
|
||||
Protocol string
|
||||
Page Page[HistoryLocationRow]
|
||||
Raw []HistoryLocationRow
|
||||
Clean []HistoryLocationRow
|
||||
Quality TrackQuality
|
||||
}
|
||||
|
||||
var trackCandidateProtocols = []string{"GB32960", "JT808", "YUTONG_MQTT"}
|
||||
|
||||
func cloneURLValues(values url.Values) url.Values {
|
||||
next := make(url.Values, len(values))
|
||||
for key, items := range values {
|
||||
next[key] = append([]string(nil), items...)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func sortedHistoryLocations(items []HistoryLocationRow) []HistoryLocationRow {
|
||||
points := append([]HistoryLocationRow(nil), items...)
|
||||
sort.SliceStable(points, func(i, j int) bool {
|
||||
left, leftOK := parseVehicleServiceTime(points[i].DeviceTime)
|
||||
right, rightOK := parseVehicleServiceTime(points[j].DeviceTime)
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.Before(right)
|
||||
}
|
||||
return points[i].DeviceTime < points[j].DeviceTime
|
||||
})
|
||||
return points
|
||||
}
|
||||
|
||||
func (s *Service) trackSourceCandidates(ctx context.Context, query url.Values, requestedProtocol string) ([]trackSourceCandidate, error) {
|
||||
protocols := trackCandidateProtocols
|
||||
if requestedProtocol != "" {
|
||||
protocols = []string{requestedProtocol}
|
||||
}
|
||||
candidates := make([]trackSourceCandidate, 0, len(protocols))
|
||||
for _, protocol := range protocols {
|
||||
sourceQuery := cloneURLValues(query)
|
||||
sourceQuery.Set("protocol", protocol)
|
||||
page, err := s.store.HistoryLocationsFromTDengine(ctx, sourceQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := sortedHistoryLocations(page.Items)
|
||||
clean, quality := analyzeTrackPoints(raw)
|
||||
candidates = append(candidates, trackSourceCandidate{
|
||||
Protocol: protocol,
|
||||
Page: page,
|
||||
Raw: raw,
|
||||
Clean: clean,
|
||||
Quality: quality,
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPlaybackResponse, error) {
|
||||
keyword := strings.TrimSpace(firstNonEmpty(query.Get("keyword"), query.Get("vin")))
|
||||
if keyword == "" {
|
||||
@@ -1627,37 +1688,41 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla
|
||||
}
|
||||
resolvedQuery.Set("limit", "5000")
|
||||
resolvedQuery.Set("offset", "0")
|
||||
page, err := s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||||
requestedProtocol := strings.ToUpper(strings.TrimSpace(query.Get("protocol")))
|
||||
candidates, err := s.trackSourceCandidates(ctx, resolvedQuery, requestedProtocol)
|
||||
if err != nil {
|
||||
return TrackPlaybackResponse{}, err
|
||||
}
|
||||
rawPoints := append([]HistoryLocationRow(nil), page.Items...)
|
||||
sort.SliceStable(rawPoints, func(i, j int) bool {
|
||||
left, leftOK := parseVehicleServiceTime(rawPoints[i].DeviceTime)
|
||||
right, rightOK := parseVehicleServiceTime(rawPoints[j].DeviceTime)
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.Before(right)
|
||||
allCleanPoints := make([]HistoryLocationRow, 0)
|
||||
for _, candidate := range candidates {
|
||||
allCleanPoints = append(allCleanPoints, candidate.Clean...)
|
||||
}
|
||||
points, selectedProtocol, alternateSourcePoints := selectTrackPrimarySource(allCleanPoints, requestedProtocol)
|
||||
selected := trackSourceCandidate{Protocol: selectedProtocol, Clean: []HistoryLocationRow{}}
|
||||
for _, candidate := range candidates {
|
||||
if strings.EqualFold(candidate.Protocol, selectedProtocol) {
|
||||
selected = candidate
|
||||
break
|
||||
}
|
||||
return rawPoints[i].DeviceTime < rawPoints[j].DeviceTime
|
||||
})
|
||||
cleanPoints, quality := analyzeTrackPoints(rawPoints)
|
||||
points, selectedProtocol, alternateSourcePoints := selectTrackPrimarySource(cleanPoints, query.Get("protocol"))
|
||||
}
|
||||
quality := selected.Quality
|
||||
quality.SelectedProtocol = selectedProtocol
|
||||
quality.AlternateSourcePoints = alternateSourcePoints
|
||||
quality.ValidPoints = len(points)
|
||||
applyTrackSequenceQuality(points, &quality)
|
||||
truncated := selected.Page.Total > len(selected.Raw)
|
||||
result := TrackPlaybackResponse{
|
||||
Points: []HistoryLocationRow{},
|
||||
Events: []TrackPlaybackEvent{},
|
||||
Sources: summarizeTrackSources(cleanPoints),
|
||||
Sources: summarizeTrackSources(allCleanPoints),
|
||||
Segments: []TrackSegment{},
|
||||
Stops: []TrackStop{},
|
||||
Total: page.Total,
|
||||
Truncated: page.Total > len(rawPoints),
|
||||
Total: selected.Page.Total,
|
||||
Truncated: truncated,
|
||||
Quality: quality,
|
||||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), 0, result.Truncated, false)
|
||||
result.Coverage = trackCoverage(resolvedQuery, selected.Page.Total, len(selected.Raw), len(points), 0, result.Truncated, false)
|
||||
applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints)
|
||||
if len(points) == 0 {
|
||||
return result, nil
|
||||
@@ -1696,7 +1761,7 @@ func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPla
|
||||
for index := range result.Stops {
|
||||
result.Stops[index].SampledIndex = nearestSampledIndex((result.Stops[index].startIndex+result.Stops[index].endIndex)/2, sampledIndexes)
|
||||
}
|
||||
result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), len(result.Points), result.Truncated, result.Sampled)
|
||||
result.Coverage = trackCoverage(resolvedQuery, selected.Page.Total, len(selected.Raw), len(points), len(result.Points), result.Truncated, result.Sampled)
|
||||
applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints)
|
||||
result.Coverage.ActualStart = result.Summary.StartTime
|
||||
result.Coverage.ActualEnd = result.Summary.EndTime
|
||||
@@ -1755,9 +1820,13 @@ type metricCatalogStore interface {
|
||||
|
||||
func (s *Service) metricDefinitions(ctx context.Context) ([]MetricDefinition, error) {
|
||||
if store, ok := s.store.(metricCatalogStore); ok {
|
||||
return store.MetricDefinitions(ctx)
|
||||
definitions, err := store.MetricDefinitions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return enrichMetricDefinitions(definitions), nil
|
||||
}
|
||||
return metricDefinitions(), nil
|
||||
return enrichMetricDefinitions(metricDefinitions()), nil
|
||||
}
|
||||
|
||||
func (s *Service) MetricCatalog(ctx context.Context) (MetricCatalog, error) {
|
||||
@@ -1865,7 +1934,8 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
if unified {
|
||||
key = definition.Key
|
||||
}
|
||||
if previous, exists := candidates[key]; exists && (!observedOK || (previous.timeValid && !observed.After(previous.sortTime))) {
|
||||
candidateKey := normalizedProtocol + "\x00" + key
|
||||
if previous, exists := candidates[candidateKey]; exists && (!observedOK || (previous.timeValid && !observed.After(previous.sortTime))) {
|
||||
continue
|
||||
}
|
||||
label, unit := latestTelemetryRawMetadata(sourceField)
|
||||
@@ -1873,6 +1943,11 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
valueType := "dynamic"
|
||||
description := ""
|
||||
value := rawValue
|
||||
if reference, ok := gb32960ReferenceForSource(sourceField); ok {
|
||||
label = reference.Label
|
||||
unit = reference.Unit
|
||||
description = reference.Description
|
||||
}
|
||||
if unified {
|
||||
label = definition.Label
|
||||
unit = definition.Unit
|
||||
@@ -1880,11 +1955,18 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
valueType = definition.ValueType
|
||||
description = definition.Description
|
||||
value = normalizeTelemetryValue(rawValue, definition.ValueType)
|
||||
if reference, ok := gb32960ReferenceForSource(sourceField); ok {
|
||||
label = reference.Label
|
||||
unit = reference.Unit
|
||||
description = reference.Description
|
||||
}
|
||||
label, description = protocolTelemetryMetadata(normalizedProtocol, key, label, description)
|
||||
}
|
||||
candidates[key] = latestTelemetryCandidate{
|
||||
displayValue := protocolDisplayValue(sourceField, value)
|
||||
candidates[candidateKey] = latestTelemetryCandidate{
|
||||
value: LatestTelemetryValue{
|
||||
Key: key, SourceField: sourceField, Label: label, Description: description, Unit: unit,
|
||||
Category: category, ValueType: valueType, Value: value, Protocol: normalizedProtocol,
|
||||
Category: category, ValueType: valueType, Value: value, DisplayValue: displayValue, Protocol: normalizedProtocol,
|
||||
SourceEndpoint: frame.SourceEndpoint, FrameID: frame.ID, DeviceTime: frame.DeviceTime, ServerTime: frame.ServerTime,
|
||||
Quality: quality, QualityReason: qualityReason, FreshnessSeconds: freshness, DataDelaySeconds: delay,
|
||||
},
|
||||
@@ -1896,6 +1978,9 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
response.Values = append(response.Values, candidate.value)
|
||||
}
|
||||
sort.Slice(response.Values, func(i, j int) bool {
|
||||
if left, right := telemetryProtocolRank(response.Values[i].Protocol), telemetryProtocolRank(response.Values[j].Protocol); left != right {
|
||||
return left < right
|
||||
}
|
||||
left, right := telemetryCategoryRank(response.Values[i].Category), telemetryCategoryRank(response.Values[j].Category)
|
||||
if left != right {
|
||||
return left < right
|
||||
@@ -1915,10 +2000,24 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin
|
||||
}
|
||||
response.Categories[index].Count++
|
||||
}
|
||||
response.Evidence = fmt.Sprintf("scanned %d newest RAW frames; selected %d newest scalar values by unified metric/source field", len(frames), len(response.Values))
|
||||
response.Evidence = fmt.Sprintf("scanned %d newest RAW frames; retained %d newest scalar values independently by protocol and metric/source field", len(frames), len(response.Values))
|
||||
return response
|
||||
}
|
||||
|
||||
func protocolTelemetryMetadata(protocol, key, label, description string) (string, string) {
|
||||
if key != "total_mileage_km" {
|
||||
return label, description
|
||||
}
|
||||
switch protocol {
|
||||
case "JT808":
|
||||
return "GPS 总里程", "JT/T 808 定位终端累计的 GPS 里程,不等同于车辆仪表盘里程"
|
||||
case "GB32960", "YUTONG_MQTT":
|
||||
return "仪表盘总里程", "车辆总线或车厂平台上报的仪表盘累计里程"
|
||||
default:
|
||||
return label, description
|
||||
}
|
||||
}
|
||||
|
||||
func telemetrySourceLookupKey(protocol, sourceField string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(protocol)) + "\x00" + strings.TrimSpace(sourceField)
|
||||
}
|
||||
@@ -2036,6 +2135,10 @@ func normalizeTelemetryCategory(category string) string {
|
||||
return "location"
|
||||
case "quality":
|
||||
return "quality"
|
||||
case "fuel-cell", "fuel_cell", "fuelcell", "hydrogen":
|
||||
return "fuel-cell"
|
||||
case "motor", "engine":
|
||||
return "motor"
|
||||
default:
|
||||
return "extension"
|
||||
}
|
||||
@@ -2046,7 +2149,7 @@ func telemetrySourceCategory(sourceField string) string {
|
||||
switch {
|
||||
case strings.Contains(normalized, "alarm") || strings.Contains(normalized, "fault") || strings.Contains(normalized, "warning"):
|
||||
return "alarm"
|
||||
case strings.Contains(normalized, "fuel_cell") || strings.Contains(normalized, "fuelcell") || strings.Contains(normalized, "fc_stack"):
|
||||
case strings.Contains(normalized, "fuel_cell") || strings.Contains(normalized, "fuelcell") || strings.Contains(normalized, "fc_stack") || strings.Contains(normalized, "hydrogen"):
|
||||
return "fuel-cell"
|
||||
case strings.Contains(normalized, "motor") || strings.Contains(normalized, "engine") || strings.Contains(normalized, "rpm"):
|
||||
return "motor"
|
||||
@@ -2070,6 +2173,15 @@ func telemetryCategoryRank(category string) int {
|
||||
return 99
|
||||
}
|
||||
|
||||
func telemetryProtocolRank(protocol string) int {
|
||||
for index, candidate := range canonicalVehicleProtocols {
|
||||
if protocol == candidate {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return len(canonicalVehicleProtocols)
|
||||
}
|
||||
|
||||
func telemetryCategoryLabel(category string) string {
|
||||
labels := map[string]string{"vehicle": "整车数据", "motor": "驱动电机", "fuel-cell": "燃料电池", "battery": "电池", "location": "定位", "alarm": "报警", "quality": "数据质量", "extension": "扩展字段"}
|
||||
if label := labels[category]; label != "" {
|
||||
@@ -2086,9 +2198,28 @@ func metricDefinitions() []MetricDefinition {
|
||||
{Key: "alarm_active", Label: "协议告警位", Description: "协议报文是否携带活动告警", Unit: "", Category: "safety", ValueType: "boolean", Protocols: []string{"GB32960", "JT808"}, SourceFields: map[string]string{"GB32960": "gb32960.alarm.general_alarm_flag", "JT808": "jt808.location.alarm_flag"}, Searchable: true, Chartable: false, Alertable: true},
|
||||
{Key: "freshness_sec", Label: "离线时长", Description: "最新数据距当前时间的秒数", Unit: "s", Category: "quality", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.updated_at"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "data_delay_sec", Label: "数据延迟", Description: "服务接收时间与设备事件时间的差值", Unit: "s", Category: "quality", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "received_at-event_time"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程", Unit: "km", Category: "driving", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km", "YUTONG_MQTT": "yutong.vehicle.total_mileage_km"}, Searchable: true, Chartable: true, Alertable: false},
|
||||
{Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程;JT808 为 GPS 里程,其余为仪表盘里程", Unit: "km", Category: "driving", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km", "YUTONG_MQTT": "yutong_mqtt.data.total_mileage_km"}, Searchable: true, Chartable: true, Alertable: false},
|
||||
{Key: "longitude", Label: "经度", Description: "WGS84/协议归一化经度", Unit: "°", Category: "location", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.longitude"}, Searchable: true, Chartable: false, Alertable: false},
|
||||
{Key: "latitude", Label: "纬度", Description: "WGS84/协议归一化纬度", Unit: "°", Category: "location", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.latitude"}, Searchable: true, Chartable: false, Alertable: false},
|
||||
{Key: "vehicle_status", Label: "车辆状态", Description: "GB/T 32960 整车状态码", Unit: "", Category: "vehicle", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.vehicle_status"}, Searchable: true, Chartable: false, Alertable: true},
|
||||
{Key: "charge_status", Label: "充电状态", Description: "GB/T 32960 充电状态码", Unit: "", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.charge_status"}, Searchable: true, Chartable: false, Alertable: true},
|
||||
{Key: "total_voltage_v", Label: "总电压", Description: "动力电池总电压", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "total_current_a", Label: "总电流", Description: "动力电池总电流", Unit: "A", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_current_a"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "insulation_kohm", Label: "绝缘电阻", Description: "整车绝缘电阻", Unit: "kΩ", Category: "safety", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.insulation_kohm"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "fuel_cell_voltage_v", Label: "燃料电池电压", Description: "燃料电池系统输出电压", Unit: "V", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.fuel_cell_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "fuel_cell_current_a", Label: "燃料电池电流", Description: "燃料电池系统输出电流", Unit: "A", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.fuel_cell_current_a"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_consumption_kg_per_100km", Label: "百公里氢耗", Description: "燃料电池系统百公里氢气消耗量", Unit: "kg/100km", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.hydrogen_consumption_kg_per_100km"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_concentration_percent", Label: "最高氢浓度", Description: "氢气浓度最高值", Unit: "%", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_pressure_mpa", Label: "最高氢气压力", Description: "燃料电池系统氢气压力探针的最高读数", Unit: "MPa", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_pressure_mpa"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "hydrogen_temperature_c", Label: "最高氢气温度", Description: "燃料电池系统氢气温度探针的最高读数", Unit: "℃", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_temperature_c"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "engine_speed_rpm", Label: "发动机转速", Description: "发动机曲轴转速", Unit: "rpm", Category: "vehicle", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.engine.crank_speed_rpm"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "max_cell_voltage_v", Label: "单体最高电压", Description: "动力电池单体电压最高值", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.max_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "min_cell_voltage_v", Label: "单体最低电压", Description: "动力电池单体电压最低值", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.min_voltage_v"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "max_battery_temperature_c", Label: "电池最高温度", Description: "动力电池温度探针最高值", Unit: "℃", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.max_temp_c"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "min_battery_temperature_c", Label: "电池最低温度", Description: "动力电池温度探针最低值", Unit: "℃", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.min_temp_c"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "gnss_satellite_count", Label: "GNSS 卫星数", Description: "JT/T 808 定位终端参与定位的卫星数量", Unit: "颗", Category: "location", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.gnss_satellite_count"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "network_signal_strength", Label: "通信信号强度", Description: "JT/T 808 终端无线通信信号强度", Unit: "", Category: "quality", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.network_signal_strength"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
{Key: "fuel_l", Label: "油量", Description: "JT/T 808 终端上报的车辆油量", Unit: "L", Category: "vehicle", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.fuel_l"}, Searchable: true, Chartable: true, Alertable: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2593,19 +2724,51 @@ func mergeDiscoveredRawMetrics(base []HistoryMetricDefinition, rows []HistoryDat
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
label, unit := rawMetricMetadata(key)
|
||||
result = append(result, HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: len(result) < 7})
|
||||
metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: len(result) < 7}
|
||||
if reference, ok := gb32960ReferenceForSource(key); ok {
|
||||
metric.Label = reference.Label + " · GB32960"
|
||||
metric.Description = reference.Description
|
||||
metric.Unit = reference.Unit
|
||||
metric.ValueMappings = reference.ValueMappings
|
||||
}
|
||||
result = append(result, metric)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rawMetricMetadata(key string) (string, string) {
|
||||
if reference, ok := gb32960ReferenceForSource(key); ok {
|
||||
return reference.Label + " · GB32960", reference.Unit
|
||||
}
|
||||
parts := strings.Split(key, ".")
|
||||
tail := parts[len(parts)-1]
|
||||
protocol := strings.ToUpper(parts[0])
|
||||
labels := map[string]string{"speed_kmh": "速度", "total_mileage_km": "总里程", "avg_voltage_v": "平均电压", "soc_percent": "SOC", "longitude": "经度", "latitude": "纬度", "direction": "方向", "current_a": "电流", "temperature_c": "温度"}
|
||||
labels := map[string]string{
|
||||
"speed_kmh": "速度", "recorder_speed_kmh": "行驶记录仪速度", "total_mileage_km": "总里程", "avg_voltage_v": "平均电压",
|
||||
"soc_percent": "SOC", "longitude": "经度", "latitude": "纬度", "direction": "方向", "direction_deg": "方向角", "altitude_m": "海拔",
|
||||
"current_a": "电流", "temperature_c": "温度", "vehicle_status": "车辆状态", "charge_status": "充电状态", "running_mode": "运行模式",
|
||||
"total_voltage_v": "总电压", "total_current_a": "总电流", "dc_dc_status": "DC/DC 状态", "gear": "挡位", "insulation_kohm": "绝缘电阻",
|
||||
"accelerator_pct": "加速踏板开度", "brake_pct": "制动踏板状态", "state": "工作状态", "controller_temperature_c": "控制器温度",
|
||||
"speed_rpm": "转速", "torque_nm": "转矩", "motor_temperature_c": "驱动电机温度", "controller_voltage_v": "控制器输入电压",
|
||||
"controller_current_a": "控制器母线电流", "fuel_cell_voltage_v": "燃料电池电压", "fuel_cell_current_a": "燃料电池电流",
|
||||
"hydrogen_consumption_kg_per_100km": "百公里氢耗", "temperature_probe_count": "温度探针数量", "max_hydrogen_temperature_c": "最高氢气温度",
|
||||
"max_hydrogen_temperature_probe_id": "最高氢温探针编号", "max_hydrogen_concentration_fraction": "最高氢气浓度占比",
|
||||
"max_hydrogen_concentration_percent": "最高氢浓度", "max_hydrogen_concentration_ppm": "最高氢浓度原始值", "max_hydrogen_concentration_probe_id": "最高氢浓度探针编号",
|
||||
"max_hydrogen_pressure_mpa": "最高氢气压力", "max_hydrogen_pressure_probe_id": "最高氢压探针编号",
|
||||
"engine_status": "发动机状态", "crank_speed_rpm": "发动机转速", "fuel_rate": "燃料消耗率",
|
||||
"max_voltage_subsystem_no": "最高电压子系统编号", "max_voltage_cell_no": "最高电压单体编号", "max_voltage_v": "单体最高电压",
|
||||
"min_voltage_subsystem_no": "最低电压子系统编号", "min_voltage_cell_no": "最低电压单体编号", "min_voltage_v": "单体最低电压",
|
||||
"max_temp_subsystem_no": "最高温度子系统编号", "max_temp_probe_no": "最高温度探针编号", "max_temp_c": "电池最高温度",
|
||||
"min_temp_subsystem_no": "最低温度子系统编号", "min_temp_probe_no": "最低温度探针编号", "min_temp_c": "电池最低温度",
|
||||
"max_alarm_level": "最高告警等级", "general_alarm_flag": "通用告警标志", "alarm_flag": "告警标志", "status_flag": "状态标志",
|
||||
"fuel_l": "油量", "manual_alarm_event_id": "人工确认告警事件编号", "carriage_temperature_c": "车厢温度",
|
||||
"network_signal_strength": "通信信号强度", "gnss_satellite_count": "GNSS 卫星数", "device_time": "设备时间",
|
||||
"hydrogen_mass_kg": "储氢质量", "gd_fc_vehicle_hydrogen_mass_kg": "储氢质量", "stack_temp_c": "电堆温度",
|
||||
"gd_fc_demo_stack_temp_c": "电堆温度", "controller_temp_c": "控制器温度", "gd_fc_dcdc_controller_temp_c": "燃料电池 DC/DC 控制器温度",
|
||||
}
|
||||
label := labels[tail]
|
||||
if label == "" {
|
||||
label = strings.ReplaceAll(tail, "_", " ")
|
||||
label = humanizeRawMetricTail(tail)
|
||||
}
|
||||
if protocol != "" {
|
||||
label += " · " + protocol
|
||||
@@ -2614,6 +2777,8 @@ func rawMetricMetadata(key string) (string, string) {
|
||||
switch {
|
||||
case strings.HasSuffix(tail, "_kmh"):
|
||||
unit = "km/h"
|
||||
case strings.HasSuffix(tail, "_kg_per_100km"):
|
||||
unit = "kg/100km"
|
||||
case strings.HasSuffix(tail, "_km"):
|
||||
unit = "km"
|
||||
case strings.HasSuffix(tail, "_percent"):
|
||||
@@ -2624,10 +2789,63 @@ func rawMetricMetadata(key string) (string, string) {
|
||||
unit = "A"
|
||||
case strings.HasSuffix(tail, "_temperature_c") || strings.HasSuffix(tail, "_c"):
|
||||
unit = "℃"
|
||||
case strings.HasSuffix(tail, "_rpm"):
|
||||
unit = "rpm"
|
||||
case strings.HasSuffix(tail, "_nm"):
|
||||
unit = "N·m"
|
||||
case strings.HasSuffix(tail, "_mpa"):
|
||||
unit = "MPa"
|
||||
case strings.HasSuffix(tail, "_kohm"):
|
||||
unit = "kΩ"
|
||||
case strings.HasSuffix(tail, "_ppm"):
|
||||
unit = "ppm"
|
||||
case strings.HasSuffix(tail, "_kg"):
|
||||
unit = "kg"
|
||||
case strings.HasSuffix(tail, "_deg"):
|
||||
unit = "°"
|
||||
case strings.HasSuffix(tail, "_m"):
|
||||
unit = "m"
|
||||
case strings.HasSuffix(tail, "_l"):
|
||||
unit = "L"
|
||||
}
|
||||
return label, unit
|
||||
}
|
||||
|
||||
func humanizeRawMetricTail(tail string) string {
|
||||
tokenLabels := map[string]string{
|
||||
"max": "最高", "min": "最低", "avg": "平均", "total": "总", "vehicle": "车辆", "battery": "电池",
|
||||
"fuel": "燃料", "cell": "电池", "hydrogen": "氢气", "motor": "电机", "engine": "发动机", "controller": "控制器",
|
||||
"stack": "电堆", "inlet": "入口", "outlet": "出口", "pressure": "压力", "temperature": "温度", "temp": "温度",
|
||||
"voltage": "电压", "current": "电流", "speed": "速度", "mileage": "里程", "status": "状态", "count": "数量",
|
||||
"probe": "探针", "serial": "序号", "subsystem": "子系统", "signal": "信号", "strength": "强度", "alarm": "告警",
|
||||
"level": "等级", "rate": "速率", "mass": "质量", "consumption": "消耗量", "capacity": "容量", "soc": "SOC",
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, token := range strings.Split(tail, "_") {
|
||||
if label := tokenLabels[token]; label != "" {
|
||||
builder.WriteString(label)
|
||||
} else if token != "" && !isRawMetricUnitToken(token) {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteByte(' ')
|
||||
}
|
||||
builder.WriteString(strings.ToUpper(token))
|
||||
}
|
||||
}
|
||||
if builder.Len() == 0 {
|
||||
return strings.ReplaceAll(tail, "_", " ")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func isRawMetricUnitToken(token string) bool {
|
||||
switch token {
|
||||
case "kmh", "km", "v", "a", "c", "rpm", "nm", "mpa", "kohm", "ppm", "kg", "pct", "percent", "deg", "m", "l":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExportRequest) (HistoryExportJob, error) {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
@@ -3170,7 +3388,12 @@ func historyExportColumns(category string, requested []string) []HistoryMetricDe
|
||||
continue
|
||||
}
|
||||
label, unit := rawMetricMetadata(key)
|
||||
selected = append(selected, HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: true})
|
||||
metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: true}
|
||||
if reference, ok := gb32960ReferenceForSource(key); ok {
|
||||
metric.Description = reference.Description
|
||||
metric.ValueMappings = reference.ValueMappings
|
||||
}
|
||||
selected = append(selected, metric)
|
||||
known[key] = true
|
||||
}
|
||||
return selected
|
||||
@@ -3200,7 +3423,11 @@ func historyExportRecord(row HistoryDataRow, columns []HistoryMetricDefinition)
|
||||
record = append(record, "")
|
||||
continue
|
||||
}
|
||||
record = append(record, fmt.Sprint(value))
|
||||
if display := protocolDisplayValue(column.Key, value); display != "" {
|
||||
record = append(record, display)
|
||||
} else {
|
||||
record = append(record, fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
@@ -3368,27 +3595,37 @@ func analyzeTrackPoints(raw []HistoryLocationRow) ([]HistoryLocationRow, TrackQu
|
||||
|
||||
func selectTrackPrimarySource(points []HistoryLocationRow, requestedProtocol string) ([]HistoryLocationRow, string, int) {
|
||||
requestedProtocol = strings.ToUpper(strings.TrimSpace(requestedProtocol))
|
||||
counts := map[string]int{}
|
||||
latest := map[string]time.Time{}
|
||||
grouped := map[string][]HistoryLocationRow{}
|
||||
for _, point := range points {
|
||||
protocol := strings.ToUpper(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN"))
|
||||
counts[protocol]++
|
||||
if observedAt, ok := parseVehicleServiceTime(point.DeviceTime); ok && observedAt.After(latest[protocol]) {
|
||||
latest[protocol] = observedAt
|
||||
}
|
||||
grouped[protocol] = append(grouped[protocol], point)
|
||||
}
|
||||
selected := requestedProtocol
|
||||
if selected == "" {
|
||||
protocols := make([]string, 0, len(counts))
|
||||
for protocol := range counts {
|
||||
protocols := make([]string, 0, len(grouped))
|
||||
for protocol := range grouped {
|
||||
protocols = append(protocols, protocol)
|
||||
}
|
||||
sort.Slice(protocols, func(i, j int) bool {
|
||||
if counts[protocols[i]] != counts[protocols[j]] {
|
||||
return counts[protocols[i]] > counts[protocols[j]]
|
||||
left := trackSourceSelectionScore(grouped[protocols[i]])
|
||||
right := trackSourceSelectionScore(grouped[protocols[j]])
|
||||
if left.HasMovement != right.HasMovement {
|
||||
return left.HasMovement
|
||||
}
|
||||
if !latest[protocols[i]].Equal(latest[protocols[j]]) {
|
||||
return latest[protocols[i]].After(latest[protocols[j]])
|
||||
if left.DurationSeconds != right.DurationSeconds {
|
||||
return left.DurationSeconds > right.DurationSeconds
|
||||
}
|
||||
if left.CoordinateChanges != right.CoordinateChanges {
|
||||
return left.CoordinateChanges > right.CoordinateChanges
|
||||
}
|
||||
if math.Abs(left.DistanceKm-right.DistanceKm) > 0.001 {
|
||||
return left.DistanceKm > right.DistanceKm
|
||||
}
|
||||
if left.MovingPoints != right.MovingPoints {
|
||||
return left.MovingPoints > right.MovingPoints
|
||||
}
|
||||
if len(grouped[protocols[i]]) != len(grouped[protocols[j]]) {
|
||||
return len(grouped[protocols[i]]) > len(grouped[protocols[j]])
|
||||
}
|
||||
return protocols[i] < protocols[j]
|
||||
})
|
||||
@@ -3396,13 +3633,48 @@ func selectTrackPrimarySource(points []HistoryLocationRow, requestedProtocol str
|
||||
selected = protocols[0]
|
||||
}
|
||||
}
|
||||
result := make([]HistoryLocationRow, 0, counts[selected])
|
||||
for _, point := range points {
|
||||
if strings.EqualFold(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN"), selected) {
|
||||
result = append(result, point)
|
||||
result := grouped[selected]
|
||||
return result, selected, len(points) - len(result)
|
||||
}
|
||||
|
||||
type trackSourceScore struct {
|
||||
HasMovement bool
|
||||
DurationSeconds int64
|
||||
CoordinateChanges int
|
||||
MovingPoints int
|
||||
DistanceKm float64
|
||||
}
|
||||
|
||||
func trackSourceSelectionScore(points []HistoryLocationRow) trackSourceScore {
|
||||
score := trackSourceScore{}
|
||||
if len(points) == 0 {
|
||||
return score
|
||||
}
|
||||
if start, startOK := parseVehicleServiceTime(points[0].DeviceTime); startOK {
|
||||
if end, endOK := parseVehicleServiceTime(points[len(points)-1].DeviceTime); endOK && end.After(start) {
|
||||
score.DurationSeconds = int64(end.Sub(start).Seconds())
|
||||
}
|
||||
}
|
||||
return result, selected, len(points) - len(result)
|
||||
for index, point := range points {
|
||||
if point.SpeedKmh > trackStopSpeedKmh {
|
||||
score.MovingPoints++
|
||||
}
|
||||
if index == 0 || !validTrackCoordinate(points[index-1]) || !validTrackCoordinate(point) {
|
||||
continue
|
||||
}
|
||||
previousTime, previousOK := parseVehicleServiceTime(points[index-1].DeviceTime)
|
||||
currentTime, currentOK := parseVehicleServiceTime(point.DeviceTime)
|
||||
if !previousOK || !currentOK || !currentTime.After(previousTime) || currentTime.Sub(previousTime) > time.Duration(trackGapThresholdSeconds)*time.Second {
|
||||
continue
|
||||
}
|
||||
distance := haversineKm(points[index-1].Latitude, points[index-1].Longitude, point.Latitude, point.Longitude)
|
||||
score.DistanceKm += distance
|
||||
if distance >= 0.03 {
|
||||
score.CoordinateChanges++
|
||||
}
|
||||
}
|
||||
score.HasMovement = score.CoordinateChanges >= 2 || score.DistanceKm >= 0.5 || score.MovingPoints >= 3
|
||||
return score
|
||||
}
|
||||
|
||||
func applyTrackSequenceQuality(points []HistoryLocationRow, quality *TrackQuality) {
|
||||
|
||||
@@ -445,6 +445,31 @@ func TestTrackPrimarySourcePreventsParallelProtocolsFromBecomingOneRoute(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackPrimarySourcePrefersUsefulMovementOverHighFrequencyStaticHeartbeat(t *testing.T) {
|
||||
points := make([]HistoryLocationRow, 0, 120)
|
||||
for index := 0; index < 100; index++ {
|
||||
points = append(points, HistoryLocationRow{
|
||||
DeviceTime: time.Date(2026, 7, 16, 8, 0, index, 0, time.UTC).Format(time.RFC3339),
|
||||
Protocol: "YUTONG_MQTT",
|
||||
Longitude: 121.400000,
|
||||
Latitude: 31.200000,
|
||||
})
|
||||
}
|
||||
for index := 0; index < 20; index++ {
|
||||
points = append(points, HistoryLocationRow{
|
||||
DeviceTime: time.Date(2026, 7, 16, 8, index, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
Protocol: "JT808",
|
||||
Longitude: 121.400000 + float64(index)*0.002,
|
||||
Latitude: 31.200000 + float64(index)*0.001,
|
||||
SpeedKmh: 36,
|
||||
})
|
||||
}
|
||||
selected, protocol, alternate := selectTrackPrimarySource(points, "")
|
||||
if protocol != "JT808" || len(selected) != 20 || alternate != 100 {
|
||||
t.Fatalf("moving source must beat static heartbeat volume: protocol=%s selected=%d alternate=%d", protocol, len(selected), alternate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackSegmentsExposeStopsAndDataGapsWithoutClaimingIgnition(t *testing.T) {
|
||||
points := []HistoryLocationRow{
|
||||
{DeviceTime: "2026-07-03 10:00:00", SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1},
|
||||
@@ -487,6 +512,23 @@ func TestMonitorBoundsAreValidatedAndApplied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorMapIgnoresInvalidOptionalBounds(t *testing.T) {
|
||||
vehicles := Page[VehicleRealtimeRow]{
|
||||
Items: []VehicleRealtimeRow{{
|
||||
VIN: "LTEST000000000001", Plate: "粤A00001", Online: true, LocationAvailable: true,
|
||||
Longitude: 113.2, Latitude: 23.1, LastSeen: "2026-07-17T15:41:00+08:00",
|
||||
}},
|
||||
Total: 1,
|
||||
}
|
||||
result, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"12"}, "bounds": {"105,31,103,29"}})
|
||||
if err != nil {
|
||||
t.Fatalf("optional invalid bounds must not fail the map: %v", err)
|
||||
}
|
||||
if result.Total != 1 || len(result.Points) != 1 {
|
||||
t.Fatalf("invalid bounds should fall back to the bounded unfiltered map: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorQueryKeepsServerOwnedKeywordAndMotionStatus(t *testing.T) {
|
||||
query := normalizeMonitorQuery(url.Values{"keyword": {"沪A"}, "status": {"driving"}})
|
||||
if query.Get("vin") != "沪A" || query.Get("limit") != "10000" {
|
||||
@@ -921,13 +963,14 @@ func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testi
|
||||
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"}},
|
||||
{Key: "hydrogen_concentration_percent", Label: "最高氢浓度", Unit: "%", Category: "fuel-cell", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"}},
|
||||
}
|
||||
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: "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), "gb32960.fuel_cell.max_hydrogen_concentration_percent": 0.032, "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 {
|
||||
if response.VIN != "VIN001" || response.ScannedFrames != 2 || len(response.Values) != 4 || len(response.Categories) != 4 {
|
||||
t.Fatalf("unexpected response: %+v", response)
|
||||
}
|
||||
byKey := map[string]LatestTelemetryValue{}
|
||||
@@ -940,11 +983,58 @@ func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testi
|
||||
if alarm := byKey["alarm_active"]; alarm.Value != true || alarm.Category != "alarm" {
|
||||
t.Fatalf("catalog boolean should be normalized: %+v", alarm)
|
||||
}
|
||||
if hydrogen := byKey["hydrogen_concentration_percent"]; hydrogen.Value != 0.032 || hydrogen.Category != "fuel-cell" || hydrogen.Unit != "%" {
|
||||
t.Fatalf("fuel-cell catalog metric should retain its business category: %+v", hydrogen)
|
||||
}
|
||||
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 TestGB32960ReferenceFormatsStatusAndHistoryMetadata(t *testing.T) {
|
||||
if display := protocolDisplayValue("gb32960.vehicle.vehicle_status", float64(1)); display != "启动(1)" {
|
||||
t.Fatalf("vehicle status display = %q", display)
|
||||
}
|
||||
if display := protocolDisplayValue("gb32960.position.position_status", 6); display != "有效定位 · 南纬 · 西经(6)" {
|
||||
t.Fatalf("position status display = %q", display)
|
||||
}
|
||||
columns := mergeDiscoveredRawMetrics(historyRawMetrics(), []HistoryDataRow{{Values: map[string]any{
|
||||
"gb32960.drive_motor.motors.motor_1.state": 2,
|
||||
}}})
|
||||
for _, column := range columns {
|
||||
if column.Key == "gb32960.drive_motor.motors.motor_1.state" {
|
||||
if column.Label != "驱动电机状态 · GB32960" || len(column.ValueMappings) == 0 || column.ValueMappings[1].Label != "发电" {
|
||||
t.Fatalf("unexpected protocol reference column: %+v", column)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("GB32960 reference column missing: %+v", columns)
|
||||
}
|
||||
|
||||
func TestBuildLatestTelemetryResponseRetainsSameMetricAcrossProtocols(t *testing.T) {
|
||||
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
definitions := []MetricDefinition{{
|
||||
Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程", Unit: "km", Category: "driving", ValueType: "numeric",
|
||||
SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km"},
|
||||
}}
|
||||
frames := []RawFrameRow{
|
||||
{ID: "gb", VIN: "VIN001", Protocol: "GB32960", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: map[string]any{"gb32960.vehicle.total_mileage_km": 1000.0}},
|
||||
{ID: "jt", VIN: "VIN001", Protocol: "JT808", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: map[string]any{"jt808.location.total_mileage_km": 980.0}},
|
||||
}
|
||||
response := buildLatestTelemetryResponse("VIN001", frames, definitions, now)
|
||||
if len(response.Values) != 2 {
|
||||
t.Fatalf("same unified metric from two protocols must be retained independently: %+v", response.Values)
|
||||
}
|
||||
byProtocol := map[string]LatestTelemetryValue{}
|
||||
for _, value := range response.Values {
|
||||
byProtocol[value.Protocol] = value
|
||||
}
|
||||
if byProtocol["GB32960"].Label != "仪表盘总里程" || byProtocol["JT808"].Label != "GPS 总里程" {
|
||||
t.Fatalf("mileage semantics were not preserved: %+v", byProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user