feat(platform): harden telemetry pipeline and unify Semi UI workspaces

This commit is contained in:
lingniu
2026-07-18 00:26:36 +08:00
parent 65b4e4f055
commit 159c80b0ae
136 changed files with 21616 additions and 1785 deletions

View File

@@ -253,6 +253,8 @@ func requiredRole(r *http.Request) string {
path := r.URL.Path
if r.Method == http.MethodPost {
switch path {
case "/api/v2/auth/logout":
return "viewer"
case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports":
return "viewer"
case "/api/v2/alerts/notifications/read":

View File

@@ -147,6 +147,15 @@ func TestAPIAuthSessionAndDisabledMode(t *testing.T) {
}
}
func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
if role := requiredRole(httptest.NewRequest(http.MethodPost, "/api/v2/auth/logout", nil)); role != "viewer" {
t.Fatalf("logout should allow authenticated customers, required role=%s", role)
}
if role := requiredRole(httptest.NewRequest(http.MethodPut, "/api/v2/auth/password", nil)); role != "viewer" {
t.Fatalf("password change should allow authenticated customers, required role=%s", role)
}
}
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
cases := []config.Config{
{AuthMode: "enforce"},

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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())
}
}

View File

@@ -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,

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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)