chore: snapshot production code before Apple Design UI refinement

This commit is contained in:
lingniu
2026-09-17 18:05:57 +08:00
parent 9f0a87f49e
commit f7e7176f88
90 changed files with 8990 additions and 328 deletions
@@ -413,6 +413,9 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction
func normalizeVehicleEvent(event AlertEvent) AlertEvent {
event.EventCategory, event.EventType = canonicalVehicleEvent(event.TriggerType, event.Metric, event.Operator)
if event.RuleID == nativeAlarmRuleID {
event.EventCategory, event.EventType = "safety", "vehicle.safety.gb32960_alarm"
}
switch strings.ToLower(strings.TrimSpace(event.Status)) {
case "processing":
event.ExecutionState = "processing"
@@ -169,6 +169,20 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent
if err != nil {
return AlertEvent{}, err
}
if event.RuleID == nativeAlarmRuleID {
var payload []byte
if err := s.db.QueryRowContext(ctx, `SELECT fields_json FROM vehicle_native_alarm_evidence WHERE event_id=?`, id).Scan(&payload); err != nil {
return AlertEvent{}, err
}
if err := json.Unmarshal(payload, &event.NativeAlarmFields); err != nil {
return AlertEvent{}, err
}
if description, valid := DescribeNativeAlarm(event.NativeAlarmFields); valid {
event.RuleName = description.Title
event.NativeAlarmNames = description.Names
event.NativeAlarmReservedBits = description.ReservedBits
}
}
rows, err := s.db.QueryContext(ctx, alertActionSelect, id)
if err != nil {
return AlertEvent{}, err
@@ -22,6 +22,9 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
if err != nil {
return result, err
}
if err := recordNativeAlarmsTx(ctx, tx, records, &result); err != nil {
return result, err
}
if len(rules) == 0 {
return result, nil
}
@@ -0,0 +1,51 @@
package platform
import (
"context"
"net/url"
"testing"
)
func TestCorrectedDailyHydrogenRate(t *testing.T) {
physical, corrected, mixed, zero := 10.0, 12.0, 200.0, 0.0
row := DailyMileageRow{HydrogenConsumptionKg: &physical, HydrogenSOCBalancedKg: &corrected, PureHydrogenMileageKm: 100, MixedMileageKm: &mixed}
if got := correctedDailyHydrogenRate(row); got == nil || *got != 6 {
t.Fatalf("rate must use corrected mass and matching mileage: %v", got)
}
row.MixedMileageKm = nil
if got := correctedDailyHydrogenRate(row); got == nil || *got != 12 {
t.Fatalf("fallback mileage: %v", got)
}
row.HydrogenSOCBalancedKg = nil
if got := correctedDailyHydrogenRate(row); got != nil {
t.Fatalf("missing correction must not use physical mass: %v", *got)
}
row.HydrogenSOCBalancedKg = &zero
if got := correctedDailyHydrogenRate(row); got == nil || *got != 0 {
t.Fatalf("real zero must survive: %v", got)
}
row.PureHydrogenMileageKm = 0
if got := correctedDailyHydrogenRate(row); got != nil {
t.Fatalf("zero mileage must have no rate: %v", *got)
}
}
type distinctHydrogenStore struct{ *MockStore }
func (s *distinctHydrogenStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
physical, corrected, distance := 12.813, 8.714, 77.0
return Page[DailyMileageRow]{Items: []DailyMileageRow{{VIN: "LA9GG64L1NBAF4167", Date: "2026-09-01", HydrogenConsumptionKg: &physical, HydrogenSOCBalancedKg: &corrected, MixedMileageKm: &distance}}}, nil
}
func TestDailyMileageHistoricalPublicFieldsUseCorrection(t *testing.T) {
result, err := NewService(&distinctHydrogenStore{NewMockStore()}).DailyMileage(context.Background(), url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-01"}})
if err != nil {
t.Fatal(err)
}
row := result.Items[0]
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 8.714 || row.HydrogenPhysicalConsumptionKg == nil || *row.HydrogenPhysicalConsumptionKg != 12.813 {
t.Fatalf("historical primary amount must be corrected, with physical amount separately available: %+v", row)
}
if row.HydrogenConsumptionKgPer100Km == nil || *row.HydrogenConsumptionKgPer100Km != 8.714*100/77 {
t.Fatalf("historical rate mismatch: %+v", row)
}
}
@@ -34,6 +34,9 @@ func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEve
if result.Parameters.BatteryCapacityKWh != 21.04 || result.RefuelAmountKg == nil || *result.RefuelAmountKg != 8.976 || result.ChargeEnergyKWh == nil || *result.ChargeEnergyKWh != 12.5 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" {
t.Fatalf("result=%#v", result)
}
if result.ConsumptionKgPer100Km == nil || *result.ConsumptionKgPer100Km != 5.562 || result.PhysicalConsumptionKgPer100Km == nil || *result.PhysicalConsumptionKgPer100Km != 5.516 {
t.Fatalf("evidence must expose corrected rate separately from physical rate: %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
@@ -1,6 +1,9 @@
package platform
import "time"
import (
"encoding/json"
"time"
)
type Page[T any] struct {
Items []T `json:"items"`
@@ -1000,37 +1003,40 @@ type AlertRuleLifecycleRequest struct {
}
type AlertEvent struct {
ID string `json:"id"`
EventType string `json:"eventType"`
EventCategory string `json:"eventCategory"`
ExecutionState string `json:"executionState"`
RuleID string `json:"ruleId"`
RuleName string `json:"ruleName"`
RuleVersion int `json:"ruleVersion"`
Severity string `json:"severity"`
TriggerType string `json:"triggerType"`
Status string `json:"status"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Protocol string `json:"protocol"`
Metric string `json:"metric"`
Operator string `json:"operator"`
TriggerValue float64 `json:"triggerValue"`
Threshold float64 `json:"threshold"`
ThresholdHigh float64 `json:"thresholdHigh"`
Unit string `json:"unit"`
DurationSec int `json:"durationSec"`
Location string `json:"location"`
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
SourceEventID string `json:"sourceEventId"`
EventAt string `json:"eventAt"`
ReceivedAt string `json:"receivedAt"`
TriggeredAt string `json:"triggeredAt"`
RecoveredAt string `json:"recoveredAt"`
Handler string `json:"handler"`
Version int `json:"version"`
Actions []AlertAction `json:"actions,omitempty"`
NativeAlarmNames []string `json:"nativeAlarmNames,omitempty"`
NativeAlarmReservedBits []int `json:"nativeAlarmReservedBits,omitempty"`
NativeAlarmFields map[string]json.RawMessage `json:"nativeAlarmFields,omitempty"`
ID string `json:"id"`
EventType string `json:"eventType"`
EventCategory string `json:"eventCategory"`
ExecutionState string `json:"executionState"`
RuleID string `json:"ruleId"`
RuleName string `json:"ruleName"`
RuleVersion int `json:"ruleVersion"`
Severity string `json:"severity"`
TriggerType string `json:"triggerType"`
Status string `json:"status"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Protocol string `json:"protocol"`
Metric string `json:"metric"`
Operator string `json:"operator"`
TriggerValue float64 `json:"triggerValue"`
Threshold float64 `json:"threshold"`
ThresholdHigh float64 `json:"thresholdHigh"`
Unit string `json:"unit"`
DurationSec int `json:"durationSec"`
Location string `json:"location"`
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
SourceEventID string `json:"sourceEventId"`
EventAt string `json:"eventAt"`
ReceivedAt string `json:"receivedAt"`
TriggeredAt string `json:"triggeredAt"`
RecoveredAt string `json:"recoveredAt"`
Handler string `json:"handler"`
Version int `json:"version"`
Actions []AlertAction `json:"actions,omitempty"`
}
type AlertAction struct {
@@ -1874,6 +1880,12 @@ type LatestTelemetryResponse struct {
}
type DailyMileageRow struct {
Province string `json:"province,omitempty"`
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
LocationTime string `json:"locationTime,omitempty"`
LocationStatus string `json:"locationStatus"`
HydrogenPhysicalConsumptionKg *float64 `json:"hydrogenPhysicalConsumptionKg,omitempty"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Date string `json:"date"`
@@ -1942,32 +1954,33 @@ type HydrogenIntervalEvidenceRow struct {
}
type HydrogenDailyEvidence struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Date string `json:"date"`
Source string `json:"source"`
RawConsumptionKg float64 `json:"rawConsumptionKg"`
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
MixedMileageKm float64 `json:"mixedMileageKm"`
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"`
RefuelCount int `json:"refuelCount"`
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
ChargeCount int `json:"chargeCount"`
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
ValidSegmentCount int `json:"validSegmentCount"`
InvalidSegmentCount int `json:"invalidSegmentCount"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
AlgorithmVersion string `json:"algorithmVersion"`
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
CalculatedAt string `json:"calculatedAt"`
PhysicalConsumptionKgPer100Km *float64 `json:"physicalConsumptionKgPer100Km,omitempty"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Date string `json:"date"`
Source string `json:"source"`
RawConsumptionKg float64 `json:"rawConsumptionKg"`
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
MixedMileageKm float64 `json:"mixedMileageKm"`
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"`
RefuelCount int `json:"refuelCount"`
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
ChargeCount int `json:"chargeCount"`
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
ValidSegmentCount int `json:"validSegmentCount"`
InvalidSegmentCount int `json:"invalidSegmentCount"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
AlgorithmVersion string `json:"algorithmVersion"`
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
CalculatedAt string `json:"calculatedAt"`
}
// MileageQuery is the POST contract used by mileage statistics and daily
@@ -565,7 +565,8 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
LEFT JOIN vehicle_open_daily_energy h
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
AND h.stat_date = m.stat_date
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')`
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')
LEFT JOIN vehicle_daily_geography g ON g.vin = m.vin COLLATE utf8mb4_unicode_ci AND g.stat_date = m.stat_date`
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
@@ -594,7 +595,7 @@ LEFT JOIN vehicle_open_daily_energy h
`h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, ` +
`CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END AS hydrogen_evidence_available, ` +
`COALESCE(h.quality_status, '') AS hydrogen_quality_status, COALESCE(h.quality_reason, '') AS hydrogen_quality_reason, ` +
`COALESCE(h.algorithm_version, '') AS hydrogen_algorithm_version, m.protocol ` +
`COALESCE(h.algorithm_version, '') AS hydrogen_algorithm_version, m.protocol, ` + dailyGeographyProjectionSQL + ` ` +
`FROM (` + pageSQL + `) m` + enrichmentSQL + ` ORDER BY m.stat_date DESC, m.vin ASC`,
Args: args,
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
@@ -613,7 +614,7 @@ LEFT JOIN vehicle_open_daily_energy h
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` +
`h.soc_balanced_kg_per_100km, h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, ` +
`h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol ` +
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol, ` + dailyGeographyProjectionSQL + ` ` +
`FROM (SELECT m.* ` + filterFromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?) m` + enrichmentSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC`,
Args: args,
CountText: `SELECT COUNT(*) ` + filterFromSQL,
@@ -710,7 +711,7 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
dailyMileageExpression + ` AS daily_mileage_km, ` +
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
`CASE WHEN MAX(COALESCE(h.mixed_mileage_km,0))>0 THEN MAX(h.mixed_mileage_km) ELSE ` + pureHydrogenMileageExpression + ` END AS hydrogen_matched_mileage_km, ` +
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_consumption_kg, ` +
latestMileageExpression + ` AS latest_mileage_km ` +
`FROM vehicle_daily_mileage m
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
@@ -917,3 +918,5 @@ func mustInt(value string) int {
n, _ := strconv.Atoi(value)
return n
}
const dailyGeographyProjectionSQL = `COALESCE(g.province,''),COALESCE(g.city,''),COALESCE(g.region,''),COALESCE(DATE_FORMAT(g.location_time,'%Y-%m-%dT%H:%i:%s.%f+08:00'),''),CASE COALESCE(g.status,'PENDING') WHEN 'RESOLVED' THEN '已解析' WHEN 'NO_LOCATION' THEN '当天无有效定位' WHEN 'ERROR' THEN '解析失败,后台重试中' ELSE '后台待解析' END`
@@ -0,0 +1,142 @@
package platform
import (
"context"
"database/sql"
"encoding/json"
"math"
"time"
)
const nativeAlarmRuleID = "native-gb32960-alarm"
// Require the complete 0x07 unit: absent/invalid data must never clear an alarm.
func nativeAlarmFields(record AlertStreamRecord) (map[string]json.RawMessage, int, bool, bool) {
if record.Protocol != "GB32960" {
return nil, 0, false, false
}
fields := make(map[string]json.RawMessage, 6)
level, levelOK := alertStreamRawNumber(record.Fields["gb32960.alarm.max_alarm_level"])
flag, flagOK := alertStreamRawNumber(record.Fields["gb32960.alarm.general_alarm_flag"])
if !levelOK || !flagOK || level < 0 || level > 3 || math.Trunc(level) != level || flag < 0 || flag > math.MaxUint32 || math.Trunc(flag) != flag {
return nil, 0, false, false
}
mask := nativeAlarm2016Mask
if nativeAlarmVersion(record.Fields) == "V2025" {
mask = math.MaxUint32
}
active := level > 0 || uint32(flag)&mask != 0
if version := record.Fields["gb32960.header.version"]; len(version) > 0 {
fields["gb32960.header.version"] = version
}
for _, name := range []string{"max_alarm_level", "general_alarm_flag", "battery_faults", "motor_faults", "engine_faults", "other_faults"} {
key := "gb32960.alarm." + name
raw := record.Fields[key]
if name != "max_alarm_level" && name != "general_alarm_flag" {
// The gateway's FIELDS contract serializes complex KV values as JSON strings.
var encoded string
if json.Unmarshal(raw, &encoded) == nil {
raw = json.RawMessage(encoded)
}
var codes []string
if len(raw) == 0 || string(raw) == "null" || json.Unmarshal(raw, &codes) != nil || codes == nil {
return nil, 0, false, false
}
active = active || len(codes) > 0
}
fields[key] = raw
}
return fields, int(level), active, true
}
// Runs in the same transaction as the Kafka checkpoint, independently of rules.
func recordNativeAlarmsTx(ctx context.Context, tx *sql.Tx, records []AlertStreamRecord, result *AlertEvaluationResult) error {
eligible := make([]AlertStreamRecord, 0)
for _, record := range records {
if _, _, _, ok := nativeAlarmFields(record); ok && !record.Late {
eligible = append(eligible, record)
}
}
if len(eligible) == 0 {
return nil
}
metadata, err := loadAlertStreamVehicleMetadata(ctx, tx, eligible)
if err != nil {
return err
}
for _, record := range eligible {
fields, level, active, _ := nativeAlarmFields(record)
// Serialize concurrent consumers for this VIN, including the first observation.
if _, err = tx.ExecContext(ctx, `INSERT IGNORE INTO vehicle_native_alarm_state(vin,last_event_at) VALUES(?,'1970-01-01 00:00:00')`, record.VIN); err != nil {
return err
}
var lastAt time.Time
var eventID string
if err = tx.QueryRowContext(ctx, `SELECT last_event_at,active_event_id FROM vehicle_native_alarm_state WHERE vin=? FOR UPDATE`, record.VIN).Scan(&lastAt, &eventID); err != nil {
return err
}
if !record.EventAt.After(lastAt) {
result.LateObservations++
continue
}
if active && eventID == "" {
eventID, err = newAlertID("alert")
if err != nil {
return err
}
severity := "minor"
if level == 2 {
severity = "major"
}
if level == 3 {
severity = "critical"
}
description, _ := DescribeNativeAlarm(fields)
title := description.Title
rule := AlertRule{ID: nativeAlarmRuleID, Name: title, Severity: severity, TriggerType: "metric", Metric: "alarm_active", Operator: "eq", Threshold: 1}
item := alertStreamEvidence(record, metadata[record.VIN], nil)
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
item.EventAt = record.EventAt.In(shanghai).Format("2006-01-02 15:04:05.999")
item.ReceivedAt = record.ReceivedAt.In(shanghai).Format("2006-01-02 15:04:05.999")
query, args := buildAlertEventInsert(eventID, nativeAlarmRuleID+"|"+record.VIN+"|GB32960", rule, item, 1)
// Missing coordinates must not appear as a real position at (0,0).
if !item.HasLocation {
args[17], args[18], args[19] = "", nil, nil
}
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
return err
}
payload, marshalErr := json.Marshal(fields)
if marshalErr != nil {
return marshalErr
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_native_alarm_evidence(event_id,fields_json) VALUES(?,?)`, eventID, string(payload)); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'trigger','','unprocessed','native-gb32960','车辆上报原生告警')`, eventID); err != nil {
return err
}
result.Opened++
} else if !active && eventID != "" {
update, updateErr := tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET status='recovered',recovered_at=?,version=version+1 WHERE id=? AND status IN ('unprocessed','processing')`, record.EventAt, eventID)
if updateErr != nil {
return updateErr
}
count, countErr := update.RowsAffected()
if countErr != nil {
return countErr
}
if count > 0 {
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'recover','','recovered','native-gb32960','车辆上报原生告警解除')`, eventID); err != nil {
return err
}
result.Recovered++
}
eventID = ""
}
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_native_alarm_state SET last_event_at=?,active_event_id=? WHERE vin=?`, record.EventAt, eventID, record.VIN); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,84 @@
package platform
import (
"encoding/json"
"fmt"
"strings"
)
// GB/T 32960.3-2016, table 18. Bits 1931 are reserved, not faults.
var nativeAlarmBitNames = [...]string{
"温度差异报警", "电池高温报警", "车载储能装置类型过压报警", "车载储能装置类型欠压报警",
"SOC低报警", "单体电池过压报警", "单体电池欠压报警", "SOC过高报警", "SOC跳变报警",
"可充电储能系统不匹配报警", "电池单体一致性差报警", "绝缘报警", "DC-DC温度报警", "制动系统报警",
"DC-DC状态报警", "驱动电机控制器温度报警", "高压互锁状态报警", "驱动电机温度报警", "车载储能装置类型过充报警",
}
const nativeAlarm2016Mask uint32 = (1 << 19) - 1
// NativeAlarmDescription is also used by the repair command so historical and
// newly ingested events use the same interpretation of their saved evidence.
type NativeAlarmDescription struct {
Title string `json:"title"`
Names []string `json:"names"`
ReservedBits []int `json:"reservedBits"`
Active bool `json:"active"`
}
func nativeAlarmVersion(fields map[string]json.RawMessage) string {
var version string
_ = json.Unmarshal(fields["gb32960.header.version"], &version)
return version
}
func DescribeNativeAlarm(fields map[string]json.RawMessage) (NativeAlarmDescription, bool) {
fields, level, active, valid := nativeAlarmFields(AlertStreamRecord{Protocol: "GB32960", Fields: fields})
result := NativeAlarmDescription{Names: []string{}, ReservedBits: []int{}, Active: active}
if !valid {
return result, false
}
value, _ := alertStreamRawNumber(fields["gb32960.alarm.general_alarm_flag"])
flag := uint32(value)
for bit, name := range nativeAlarmBitNames {
if flag&(uint32(1)<<bit) != 0 {
result.Names = append(result.Names, name)
}
}
for bit := 19; bit < 32; bit++ {
if flag&(uint32(1)<<bit) == 0 {
continue
}
if nativeAlarmVersion(fields) == "V2025" {
result.Names = append(result.Names, fmt.Sprintf("扩展报警位 bit%d(待匹配2025版定义)", bit))
} else {
result.ReservedBits = append(result.ReservedBits, bit)
}
}
for _, group := range []struct{ key, label string }{
{"battery_faults", "可充电储能装置"}, {"motor_faults", "驱动电机"}, {"engine_faults", "发动机"}, {"other_faults", "其他"},
} {
var codes []string
_ = json.Unmarshal(fields["gb32960.alarm."+group.key], &codes)
for _, code := range codes {
result.Names = append(result.Names, group.label+"故障码 "+code+"(厂商定义)")
}
}
if len(result.Names) == 0 && level > 0 {
result.Names = append(result.Names, fmt.Sprintf("%d级故障(车辆未上报具体故障项)", level))
}
if len(result.Names) == 0 {
result.Title = "无标准告警"
return result, true
}
result.Title = strings.Join(result.Names, "、")
// Persist a useful, bounded title in the existing VARCHAR(80) column; detail
// keeps every name and code, including concurrent faults.
if len([]rune(result.Title)) > 80 {
first := []rune(result.Names[0])
if len(first) > 55 {
first = first[:55]
}
result.Title = fmt.Sprintf("%s 等%d项告警", string(first), len(result.Names))
}
return result, true
}
@@ -0,0 +1,203 @@
package platform
import (
"encoding/json"
"fmt"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func nativeAlarmRecord(level, flag string) AlertStreamRecord {
fields := map[string]json.RawMessage{"gb32960.alarm.max_alarm_level": json.RawMessage(level), "gb32960.alarm.general_alarm_flag": json.RawMessage(flag)}
for _, name := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
fields["gb32960.alarm."+name] = json.RawMessage(`[]`)
}
return AlertStreamRecord{Protocol: "GB32960", VIN: "VIN1", SourceEventID: "native-source", EventAt: time.Date(2026, 9, 17, 1, 0, 0, 0, time.UTC), ReceivedAt: time.Date(2026, 9, 17, 1, 0, 1, 0, time.UTC), Fields: fields, Valid: true}
}
func TestNativeAlarmFields(t *testing.T) {
for _, tc := range []struct {
name, level, flag string
active, valid bool
}{
{"clear", "0", `"0x00000000"`, false, true},
{"level", "3", `0`, true, true},
{"flag", "0", `"0x00000004"`, true, true},
{"reserved only", "0", `"0x00300000"`, false, true},
{"reserved and insulation", "0", `"0x00380800"`, true, true},
{"abnormal", "254", `0`, false, false},
{"invalid", "255", `0`, false, false},
{"fraction", "1.5", `0`, false, false},
{"bad bitmap", "0", `"garbage"`, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, active, valid := nativeAlarmFields(nativeAlarmRecord(tc.level, tc.flag))
if active != tc.active || valid != tc.valid {
t.Fatalf("active=%v valid=%v", active, valid)
}
})
}
encodedRecord := nativeAlarmRecord(`"2"`, `"0x00000004"`)
for _, name := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
encodedRecord.Fields["gb32960.alarm."+name] = json.RawMessage(`"[]"`)
}
encodedRecord.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`"[\"0x00000001\"]"`)
fields, _, active, valid := nativeAlarmFields(encodedRecord)
if !valid || !active || string(fields["gb32960.alarm.motor_faults"]) != `["0x00000001"]` {
t.Fatalf("gateway string-encoded fault arrays lost: %v", fields)
}
record := nativeAlarmRecord("0", "0")
record.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`["0x00000001"]`)
if _, _, active, valid := nativeAlarmFields(record); !active || !valid {
t.Fatal("fault code alone must activate alarm")
}
delete(record.Fields, "gb32960.alarm.battery_faults")
if _, _, _, valid := nativeAlarmFields(record); valid {
t.Fatal("partial unit must not activate or recover")
}
}
func TestNativeAlarmLifecycleWithoutRules(t *testing.T) {
for _, tc := range []struct {
name, existing string
clear, stale, closed bool
}{
{name: "open without rules"},
{name: "continuous alarm deduplicated", existing: "existing"},
{name: "recover", existing: "existing", clear: true},
{name: "manual closure preserved", existing: "existing", clear: true, closed: true},
{name: "out of order ignored", existing: "existing", clear: true, stale: true},
} {
t.Run(tc.name, func(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
record := nativeAlarmRecord("2", `"0x00000004"`)
if tc.clear {
record = nativeAlarmRecord("0", "0")
}
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectQuery(`SELECT v.vin,`).WithArgs("VIN1").WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "oem", "model", "company"}).AddRow("VIN1", "粤A12345", "", "", ""))
mock.ExpectExec(`INSERT IGNORE INTO vehicle_native_alarm_state`).WithArgs("VIN1").WillReturnResult(sqlmock.NewResult(1, 1))
last := record.EventAt.Add(-time.Second)
if tc.stale {
last = record.EventAt.Add(time.Second)
}
mock.ExpectQuery(`SELECT last_event_at,active_event_id`).WithArgs("VIN1").WillReturnRows(sqlmock.NewRows([]string{"last_event_at", "active_event_id"}).AddRow(last, tc.existing))
if !tc.stale {
if tc.existing == "" {
mock.ExpectExec(regexp.QuoteMeta(alertEventInsertSQL)).WithArgs(sqlmock.AnyArg(), nativeAlarmRuleID+"|VIN1|GB32960", nativeAlarmRuleID, "车载储能装置类型过压报警", 0, "major", "metric", "VIN1", "粤A12345", "GB32960", "alarm_active", "eq", float64(1), float64(1), float64(0), "", 0, "", nil, nil, "native-source", sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
payload, _ := json.Marshal(record.Fields)
mock.ExpectExec(`INSERT INTO vehicle_native_alarm_evidence`).WithArgs(sqlmock.AnyArg(), string(payload)).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WillReturnResult(sqlmock.NewResult(1, 1))
} else if tc.clear {
count := int64(1)
if tc.closed {
count = 0
}
mock.ExpectExec(`UPDATE vehicle_alert_event SET status='recovered'`).WithArgs(record.EventAt, tc.existing).WillReturnResult(sqlmock.NewResult(0, count))
if !tc.closed {
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WillReturnResult(sqlmock.NewResult(1, 1))
}
}
mock.ExpectExec(`UPDATE vehicle_native_alarm_state`).WithArgs(record.EventAt, sqlmock.AnyArg(), "VIN1").WillReturnResult(sqlmock.NewResult(0, 1))
}
mock.ExpectCommit()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
result, err := evaluateAlertStreamRecordsTx(t.Context(), tx, []AlertStreamRecord{record}, record.ReceivedAt)
if err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if result.RulesEvaluated != 0 {
t.Fatal("native alarms require no automation")
}
if tc.existing == "" && result.Opened != 1 {
t.Fatalf("not opened: %+v", result)
}
if tc.existing != "" && result.Opened != 0 {
t.Fatal("duplicate event")
}
if tc.clear && !tc.closed && !tc.stale && result.Recovered != 1 {
t.Fatal("not recovered")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
func TestNativeAlarmLateAndMissingUnitsDoNotWrite(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
late := nativeAlarmRecord("3", "1")
late.Late = true
missing := nativeAlarmRecord("0", "0")
delete(missing.Fields, "gb32960.alarm.general_alarm_flag")
mock.ExpectBegin()
mock.ExpectCommit()
tx, _ := db.Begin()
if err := recordNativeAlarmsTx(t.Context(), tx, []AlertStreamRecord{late, missing}, &AlertEvaluationResult{}); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestNativeAlarmDescriptionDecodesSpecificFaultsAndReservedBits(t *testing.T) {
record := nativeAlarmRecord("0", `"0x00380800"`)
d, ok := DescribeNativeAlarm(record.Fields)
if !ok || !d.Active || d.Title != "绝缘报警" || len(d.Names) != 1 || len(d.ReservedBits) != 3 || d.ReservedBits[0] != 19 {
t.Fatalf("wrong description: %+v", d)
}
record = nativeAlarmRecord("0", `"0x00380000"`)
d, ok = DescribeNativeAlarm(record.Fields)
if !ok || d.Active || len(d.Names) != 0 {
t.Fatalf("reserved bits became faults: %+v", d)
}
record = nativeAlarmRecord("2", `"0x00020810"`)
record.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`"[\"0x12345678\"]"`)
d, ok = DescribeNativeAlarm(record.Fields)
if !ok || len(d.Names) != 4 || d.Names[0] != "SOC低报警" || d.Names[1] != "绝缘报警" || d.Names[2] != "驱动电机温度报警" || d.Names[3] != "驱动电机故障码 0x12345678(厂商定义)" {
t.Fatalf("lost concurrent faults: %+v", d)
}
record = nativeAlarmRecord("3", `"0x00000000"`)
d, ok = DescribeNativeAlarm(record.Fields)
if !ok || !d.Active || d.Title != "3级故障(车辆未上报具体故障项)" {
t.Fatalf("invented fault meaning: %+v", d)
}
for bit, name := range nativeAlarmBitNames {
record = nativeAlarmRecord("0", fmt.Sprintf("%d", uint32(1)<<bit))
d, ok = DescribeNativeAlarm(record.Fields)
if !ok || !d.Active || d.Title != name {
t.Fatalf("bit %d decoded as %+v", bit, d)
}
}
record = nativeAlarmRecord("0", `"0x00300000"`)
record.Fields["gb32960.header.version"] = json.RawMessage(`"V2025"`)
d, ok = DescribeNativeAlarm(record.Fields)
if !ok || !d.Active || len(d.Names) != 2 || len(d.ReservedBits) != 0 {
t.Fatalf("2025 extension must not be silently discarded: %+v", d)
}
record = nativeAlarmRecord("2", `"0x0007FFFF"`)
d, _ = DescribeNativeAlarm(record.Fields)
if len(d.Names) != 19 || len([]rune(d.Title)) > 80 {
t.Fatalf("title overflow or lost detail: %+v", d)
}
}
@@ -1158,7 +1158,7 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
var evidenceAvailable int
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm,
&hydrogen, &physicalRate, &balanced, &balancedRate, &pureElectric, &mixed, &socDelta, &chargeCount, &chargeEnergy, &refuelCount, &refuelAmount,
&evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &row.HydrogenAlgorithmVersion, &row.Source); err != nil {
&evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &row.HydrogenAlgorithmVersion, &row.Source, &row.Province, &row.City, &row.Region, &row.LocationTime, &row.LocationStatus); err != nil {
return Page[DailyMileageRow]{}, err
}
if hydrogen.Valid {
@@ -1237,7 +1237,8 @@ LIMIT 1`, vin, date)
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
result.PhysicalConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
result.ConsumptionKgPer100Km = nullableFloatPointer(balancedRate)
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
result.RefuelAmountKg = nullableFloatPointer(refuelAmount)
result.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy)
@@ -1267,7 +1268,7 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
result := MileageStatistics{
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的纯氢里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的纯氢里程计算)/ vehicle_open_daily_energy(质量通过的日修正用氢量)/ vehicle_realtime_location(最新里程表)",
}
summary := buildMileageStatisticsSummarySQL(query)
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
@@ -24,14 +24,14 @@ func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testi
"hydrogen_consumption_kg", "hydrogen_consumption_kg_per_100km", "hydrogen_soc_balanced_kg", "hydrogen_soc_balanced_kg_per_100km",
"pure_electric_mileage_km", "mixed_mileage_km", "battery_soc_delta_pct", "charge_count", "charge_energy_kwh",
"refuel_count", "refuel_amount_kg", "hydrogen_evidence_available", "hydrogen_quality_status", "hydrogen_quality_reason",
"hydrogen_algorithm_version", "protocol",
"hydrogen_algorithm_version", "protocol", "province", "city", "region", "location_time", "location_status",
}
mock.ExpectQuery("SELECT m.vin").
WithArgs(20, 0).
WillReturnRows(sqlmock.NewRows(columns).AddRow(
"LB9A32A29R0LS1423", "粤AGR9816", "2026-08-26", 23119.8, 23333.1, 213.3, 213.3,
4.491, 2.106, 4.544, 2.130, 0.0, 213.3, -4.0, 1, 12.5,
1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960",
1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960", "广东省", "广州市", "华南", "2026-08-26T23:59:00+08:00", "已解析",
))
page, err := (&ProductionStore{db: db}).DailyMileage(context.Background(), url.Values{"skipCount": {"1"}})
@@ -42,6 +42,9 @@ func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testi
t.Fatalf("items=%#v", page.Items)
}
row := page.Items[0]
if row.Province != "广东省" || row.City != "广州市" || row.Region != "华南" || row.LocationStatus != "已解析" {
t.Fatalf("missing persisted geography: %+v", row)
}
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 4.491 || row.HydrogenQualityStatus != "SUSPECT" || row.HydrogenQualityReason == "" ||
row.ChargeCount == nil || *row.ChargeCount != 1 || row.ChargeEnergyKWh == nil || *row.ChargeEnergyKWh != 12.5 ||
row.RefuelCount == nil || *row.RefuelCount != 1 || row.RefuelAmountKg == nil || *row.RefuelAmountKg != 8.976 {
@@ -693,7 +693,7 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
"GROUP BY m.vin, m.stat_date",
"MAX(COALESCE(m.daily_mileage_km",
"MAX(COALESCE(m.pure_hydrogen_mileage_km",
"MAX(h.consumption_kg)",
"MAX(h.soc_balanced_consumption_kg)",
"COUNT(DISTINCT d.vin)",
"SUM(d.daily_mileage_km)",
"SUM(d.pure_hydrogen_mileage_km)",
@@ -5591,10 +5591,18 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
if err != nil {
return Page[DailyMileageRow]{}, err
}
for index := range result.Items {
row := &result.Items[index]
row.HydrogenPhysicalConsumptionKg = row.HydrogenConsumptionKg
row.HydrogenConsumptionKg = row.HydrogenSOCBalancedKg
row.HydrogenConsumptionKgPer100Km = correctedDailyHydrogenRate(*row)
}
if !hydrogenConsumptionAllowed(ctx) {
for index := range result.Items {
result.Items[index].PureHydrogenMileageKm = 0
result.Items[index].HydrogenConsumptionKg = nil
result.Items[index].HydrogenPhysicalConsumptionKg = nil
result.Items[index].HydrogenConsumptionKgPer100Km = nil
result.Items[index].HydrogenSOCBalancedKg = nil
result.Items[index].HydrogenSOCBalancedKgPer100Km = nil
@@ -6702,3 +6710,16 @@ func boolToInt(value bool) int {
}
return 0
}
// correctedDailyHydrogenRate keeps physical consumption available for audit,
// but never uses it as a fallback for the displayed consumption rate.
func correctedDailyHydrogenRate(row DailyMileageRow) *float64 {
if row.HydrogenSOCBalancedKg == nil {
return nil
}
mileage := row.PureHydrogenMileageKm
if row.MixedMileageKm != nil && *row.MixedMileageKm > 0 {
mileage = *row.MixedMileageKm
}
return hydrogenRatePer100Km(*row.HydrogenSOCBalancedKg, mileage, 1)
}
@@ -629,7 +629,7 @@ func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T)
t.Fatal(err)
}
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil ||
daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable {
daily.Items[0].HydrogenPhysicalConsumptionKg != nil || daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable {
t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
}
summary, err := service.MileageStatistics(customer, query)