功能:扩展开放平台氢耗溯源与合作站数据
This commit is contained in:
@@ -51,7 +51,7 @@ var gb32960PositionMappings = func() []MetricValueMapping {
|
||||
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.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: "动力系统总电压。"},
|
||||
|
||||
@@ -48,6 +48,7 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost)
|
||||
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
|
||||
h.mux.HandleFunc("POST /api/v2/statistics/mileage", h.handleMileageStatisticsPost)
|
||||
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/hydrogen-evidence", h.handleHydrogenDailyEvidence)
|
||||
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
|
||||
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
|
||||
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
||||
@@ -862,6 +863,11 @@ func (h *Handler) handleMileageStatisticsPost(w http.ResponseWriter, r *http.Req
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHydrogenDailyEvidence(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.HydrogenDailyEvidence(r.Context(), r.PathValue("vin"), r.URL.Query().Get("date"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func decodeMileageQuery(w http.ResponseWriter, r *http.Request) (MileageQuery, bool) {
|
||||
defer r.Body.Close()
|
||||
var query MileageQuery
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEvents(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
columns := []string{
|
||||
"vin", "plate", "date", "source_endpoint", "raw_consumption_kg", "battery_soc_delta_pct",
|
||||
"battery_discharge_kwh", "battery_equivalent_kg", "soc_balanced_consumption_kg",
|
||||
"mixed_mileage_km", "pure_electric_mileage_km", "consumption_kg_per_100km", "soc_balanced_kg_per_100km",
|
||||
"sample_count", "refuel_count", "charge_count", "valid_segment_count", "invalid_segment_count",
|
||||
"quality_status", "quality_reason", "algorithm_version", "parameter_json", "evidence_json", "calculated_at",
|
||||
}
|
||||
parameterJSON := `{"batteryCapacityKWh":21.04,"hydrogenEnergyKWhPerKg":16,"algorithmVersion":"PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2"}`
|
||||
evidenceJSON := `[{"index":1,"type":"MIXED","startEventId":"event-start","endEventId":"event-end","rawHydrogenConsumptionKg":3.1,"sampleCount":120,"qualityStatus":"OK","qualityReason":""}]`
|
||||
mock.ExpectQuery("SELECT h.vin").WithArgs("LTEST000000000001", "2026-08-26").WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
||||
"LTEST000000000001", "粤A12345", "2026-08-26", "factory-a", 3.1, -2.0, 0.421, 0.026, 3.126,
|
||||
56.2, 32.5, 5.516, 5.562, 120, 1, 1, 1, 0, "OK", "", "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2",
|
||||
[]byte(parameterJSON), []byte(evidenceJSON), "2026-08-27 00:05:00.000",
|
||||
))
|
||||
result, found, err := (&ProductionStore{db: db}).HydrogenDailyEvidence(context.Background(), "LTEST000000000001", "2026-08-26")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("found=%v err=%v", found, err)
|
||||
}
|
||||
if result.Parameters.BatteryCapacityKWh != 21.04 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" {
|
||||
t.Fatalf("result=%#v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1881,10 +1881,83 @@ type DailyMileageRow struct {
|
||||
PureHydrogenMileageKm float64 `json:"pureHydrogenMileageKm,omitempty"`
|
||||
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg,omitempty"`
|
||||
HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"`
|
||||
HydrogenSOCBalancedKg *float64 `json:"hydrogenSocBalancedKg,omitempty"`
|
||||
HydrogenSOCBalancedKgPer100Km *float64 `json:"hydrogenSocBalancedKgPer100Km,omitempty"`
|
||||
HydrogenEvidenceAvailable bool `json:"hydrogenEvidenceAvailable,omitempty"`
|
||||
HydrogenQualityStatus string `json:"hydrogenQualityStatus,omitempty"`
|
||||
HydrogenAlgorithmVersion string `json:"hydrogenAlgorithmVersion,omitempty"`
|
||||
Source string `json:"source"`
|
||||
AnomalySeverity string `json:"anomalySeverity,omitempty"`
|
||||
}
|
||||
|
||||
type HydrogenCalculationParameterEvidence struct {
|
||||
BatteryCapacityKWh float64 `json:"batteryCapacityKWh"`
|
||||
HydrogenEnergyKWhPerKg float64 `json:"hydrogenEnergyKWhPerKg"`
|
||||
PowerOnDelaySeconds int `json:"powerOnDelaySeconds"`
|
||||
PowerOffLeadSeconds int `json:"powerOffLeadSeconds"`
|
||||
RefuelRiseMpa float64 `json:"refuelRiseMpa"`
|
||||
RefuelSustainSeconds int `json:"refuelSustainSeconds"`
|
||||
PureElectricDropMpa float64 `json:"pureElectricDropMpa"`
|
||||
PureElectricWindowSeconds int `json:"pureElectricWindowSeconds"`
|
||||
AlgorithmVersion string `json:"algorithmVersion"`
|
||||
}
|
||||
|
||||
type HydrogenIntervalEvidenceRow struct {
|
||||
Index int `json:"index"`
|
||||
Type string `json:"type"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
StartEventID string `json:"startEventId"`
|
||||
EndEventID string `json:"endEventId"`
|
||||
Source string `json:"source"`
|
||||
StartPressureMpa float64 `json:"startPressureMpa"`
|
||||
EndPressureMpa float64 `json:"endPressureMpa"`
|
||||
StartTemperatureC float64 `json:"startTemperatureC"`
|
||||
EndTemperatureC float64 `json:"endTemperatureC"`
|
||||
StartMassKg float64 `json:"startMassKg"`
|
||||
EndMassKg float64 `json:"endMassKg"`
|
||||
RawHydrogenConsumptionKg float64 `json:"rawHydrogenConsumptionKg"`
|
||||
StartSOCPercent *float64 `json:"startSocPercent,omitempty"`
|
||||
EndSOCPercent *float64 `json:"endSocPercent,omitempty"`
|
||||
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
|
||||
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
|
||||
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
|
||||
StartMileageKm *float64 `json:"startMileageKm,omitempty"`
|
||||
EndMileageKm *float64 `json:"endMileageKm,omitempty"`
|
||||
MileageKm *float64 `json:"mileageKm,omitempty"`
|
||||
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
QualityStatus string `json:"qualityStatus"`
|
||||
QualityReason string `json:"qualityReason"`
|
||||
}
|
||||
|
||||
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"`
|
||||
ChargeCount int `json:"chargeCount"`
|
||||
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
|
||||
// mileage queries. Array fields keep large vehicle selections out of the URL
|
||||
// and avoid ambiguity when clients submit thousands of VINs.
|
||||
|
||||
@@ -575,6 +575,12 @@ WHERE ` + strings.Join(where, " AND ")
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||
`MAX(h.consumption_kg_per_100km) AS hydrogen_consumption_kg_per_100km, ` +
|
||||
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_soc_balanced_kg, ` +
|
||||
`MAX(h.soc_balanced_kg_per_100km) AS hydrogen_soc_balanced_kg_per_100km, ` +
|
||||
`MAX(CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END) AS hydrogen_evidence_available, ` +
|
||||
`COALESCE(MAX(h.quality_status), '') AS hydrogen_quality_status, ` +
|
||||
`COALESCE(MAX(h.algorithm_version), '') AS hydrogen_algorithm_version, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
|
||||
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
@@ -591,7 +597,9 @@ WHERE ` + strings.Join(where, " AND ")
|
||||
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
|
||||
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` +
|
||||
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, m.protocol ` +
|
||||
`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, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
|
||||
`COALESCE(h.quality_status, ''), COALESCE(h.algorithm_version, ''), m.protocol ` +
|
||||
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) ` + fromSQL,
|
||||
@@ -687,6 +695,7 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
||||
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
|
||||
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, ` +
|
||||
latestMileageExpression + ` AS latest_mileage_km ` +
|
||||
`FROM vehicle_daily_mileage m
|
||||
@@ -702,8 +711,9 @@ func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
||||
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
|
||||
`COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` +
|
||||
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
|
||||
}
|
||||
|
||||
@@ -711,8 +721,9 @@ func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
||||
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
|
||||
`COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` +
|
||||
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
|
||||
}
|
||||
|
||||
|
||||
@@ -1026,6 +1026,89 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P
|
||||
return Page[RawFrameRow]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
const latestRealtimeProjectionTimeSQL = "COALESCE(s.received_at, s.event_time, s.updated_at)"
|
||||
|
||||
func buildLatestRealtimeFramesSQL(query RawFrameQuery) SQLQuery {
|
||||
limit := query.Limit
|
||||
if limit <= 0 || limit > len(canonicalVehicleProtocols) {
|
||||
limit = len(canonicalVehicleProtocols)
|
||||
}
|
||||
where := []string{"s.vin = ?"}
|
||||
args := []any{strings.TrimSpace(query.VIN)}
|
||||
if protocol := strings.ToUpper(strings.TrimSpace(query.Protocol)); protocol != "" {
|
||||
where = append(where, "s.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
if value := strings.TrimSpace(query.DateFrom); value != "" {
|
||||
if parsed, ok := parseTrackRequestTime(value); ok {
|
||||
where = append(where, latestRealtimeProjectionTimeSQL+" >= ?")
|
||||
args = append(args, parsed)
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(query.DateTo); value != "" {
|
||||
if parsed, ok := parseTrackRequestTime(value); ok {
|
||||
where = append(where, latestRealtimeProjectionTimeSQL+" <= ?")
|
||||
args = append(args, parsed)
|
||||
}
|
||||
}
|
||||
args = append(args, limit)
|
||||
return SQLQuery{
|
||||
Text: `SELECT COALESCE(NULLIF(s.event_id, ''), CONCAT('snapshot:', s.protocol, ':', s.vin)), ` +
|
||||
`s.vin, COALESCE(s.plate, ''), s.protocol, ` +
|
||||
`COALESCE(DATE_FORMAT(s.event_time, '%Y-%m-%dT%H:%i:%s.%f+08:00'), ''), ` +
|
||||
`COALESCE(DATE_FORMAT(s.received_at, '%Y-%m-%dT%H:%i:%s.%f+08:00'), ''), ` +
|
||||
`COALESCE(s.peer, ''), COALESCE(s.parsed_json, '') ` +
|
||||
`FROM vehicle_realtime_snapshot s WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`ORDER BY ` + latestRealtimeProjectionTimeSQL + ` DESC, s.protocol ASC LIMIT ?`,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
|
||||
// LatestRealtimeFrames returns one durable latest projection per protocol.
|
||||
// Unlike Redis, these rows remain available after the online TTL expires.
|
||||
func (s *ProductionStore) LatestRealtimeFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||
built := buildLatestRealtimeFramesSQL(query)
|
||||
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
|
||||
if err != nil {
|
||||
return Page[RawFrameRow]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]RawFrameRow, 0, len(canonicalVehicleProtocols))
|
||||
for rows.Next() {
|
||||
var row RawFrameRow
|
||||
var parsedJSON string
|
||||
if err := rows.Scan(
|
||||
&row.ID,
|
||||
&row.VIN,
|
||||
&row.Plate,
|
||||
&row.Protocol,
|
||||
&row.DeviceTime,
|
||||
&row.ServerTime,
|
||||
&row.SourceEndpoint,
|
||||
&parsedJSON,
|
||||
); err != nil {
|
||||
return Page[RawFrameRow]{}, err
|
||||
}
|
||||
row.FrameType = "realtime_projection"
|
||||
row.ParseStatus = "OK"
|
||||
if query.IncludeFields || len(query.Fields) > 0 {
|
||||
row.ParsedFields = parsedFieldsFromString(parsedJSON)
|
||||
if len(query.Fields) > 0 {
|
||||
row.ParsedFields = filterParsedFieldsMap(row.ParsedFields, query.Fields)
|
||||
}
|
||||
}
|
||||
items = append(items, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Page[RawFrameRow]{}, err
|
||||
}
|
||||
limit := query.Limit
|
||||
if limit <= 0 || limit > len(canonicalVehicleProtocols) {
|
||||
limit = len(canonicalVehicleProtocols)
|
||||
}
|
||||
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
|
||||
built := buildMileageSummarySQL(query)
|
||||
var summary MileageSummary
|
||||
@@ -1054,15 +1137,31 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
||||
items := make([]DailyMileageRow, 0)
|
||||
for rows.Next() {
|
||||
var row DailyMileageRow
|
||||
var hydrogen sql.NullFloat64
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, &hydrogen, &row.Source); err != nil {
|
||||
var hydrogen, physicalRate, balanced, balancedRate sql.NullFloat64
|
||||
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, &evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenAlgorithmVersion, &row.Source); err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
if hydrogen.Valid {
|
||||
consumption := hydrogen.Float64
|
||||
row.HydrogenConsumptionKg = &consumption
|
||||
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.DailyMileageKm, 1)
|
||||
if physicalRate.Valid {
|
||||
value := physicalRate.Float64
|
||||
row.HydrogenConsumptionKgPer100Km = &value
|
||||
} else {
|
||||
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.PureHydrogenMileageKm, 1)
|
||||
}
|
||||
}
|
||||
if balanced.Valid {
|
||||
value := balanced.Float64
|
||||
row.HydrogenSOCBalancedKg = &value
|
||||
}
|
||||
if balancedRate.Valid {
|
||||
value := balancedRate.Float64
|
||||
row.HydrogenSOCBalancedKgPer100Km = &value
|
||||
}
|
||||
row.HydrogenEvidenceAvailable = evidenceAvailable == 1
|
||||
items = append(items, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -1075,11 +1174,66 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
||||
return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, bool, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT h.vin,COALESCE(NULLIF(b.plate,''),''),DATE_FORMAT(h.stat_date,'%Y-%m-%d'),
|
||||
h.source_endpoint,COALESCE(h.raw_consumption_kg,h.consumption_kg),h.battery_soc_delta_pct,
|
||||
h.battery_discharge_kwh,h.battery_equivalent_kg,h.soc_balanced_consumption_kg,
|
||||
h.mixed_mileage_km,h.pure_electric_mileage_km,h.consumption_kg_per_100km,h.soc_balanced_kg_per_100km,
|
||||
h.sample_count,h.refuel_count,h.charge_count,h.valid_segment_count,h.invalid_segment_count,
|
||||
h.quality_status,h.quality_reason,h.algorithm_version,h.parameter_json,h.evidence_json,
|
||||
DATE_FORMAT(h.calculated_at,'%Y-%m-%d %H:%i:%s.%f')
|
||||
FROM vehicle_open_daily_energy h
|
||||
LEFT JOIN vehicle_identity_binding b ON b.vin=h.vin
|
||||
WHERE h.vin=? AND h.stat_date=? AND h.energy_type='HYDROGEN'
|
||||
LIMIT 1`, vin, date)
|
||||
var result HydrogenDailyEvidence
|
||||
var socDelta, discharge, equivalent, balanced, physicalRate, balancedRate sql.NullFloat64
|
||||
var parameterJSON, evidenceJSON []byte
|
||||
if err := row.Scan(
|
||||
&result.VIN, &result.Plate, &result.Date, &result.Source, &result.RawConsumptionKg,
|
||||
&socDelta, &discharge, &equivalent, &balanced, &result.MixedMileageKm, &result.PureElectricMileageKm,
|
||||
&physicalRate, &balancedRate, &result.SampleCount, &result.RefuelCount, &result.ChargeCount,
|
||||
&result.ValidSegmentCount, &result.InvalidSegmentCount, &result.QualityStatus, &result.QualityReason,
|
||||
&result.AlgorithmVersion, ¶meterJSON, &evidenceJSON, &result.CalculatedAt,
|
||||
); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return HydrogenDailyEvidence{}, false, nil
|
||||
}
|
||||
return HydrogenDailyEvidence{}, false, err
|
||||
}
|
||||
result.BatterySOCDeltaPct = nullableFloatPointer(socDelta)
|
||||
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
|
||||
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
|
||||
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
|
||||
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
|
||||
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
|
||||
result.Intervals = []HydrogenIntervalEvidenceRow{}
|
||||
if len(parameterJSON) > 0 {
|
||||
if err := json.Unmarshal(parameterJSON, &result.Parameters); err != nil {
|
||||
return HydrogenDailyEvidence{}, false, err
|
||||
}
|
||||
}
|
||||
if len(evidenceJSON) > 0 {
|
||||
if err := json.Unmarshal(evidenceJSON, &result.Intervals); err != nil {
|
||||
return HydrogenDailyEvidence{}, false, err
|
||||
}
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func nullableFloatPointer(value sql.NullFloat64) *float64 {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
result := value.Float64
|
||||
return &result
|
||||
}
|
||||
|
||||
func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||
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(
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -141,7 +143,7 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenRatePer100KmUsesMatchedDailyMileage(t *testing.T) {
|
||||
func TestHydrogenRatePer100KmUsesMatchedPureHydrogenMileage(t *testing.T) {
|
||||
rate := hydrogenRatePer100Km(7.3, 193.3, 2)
|
||||
if rate == nil || *rate < 3.77 || *rate > 3.78 {
|
||||
t.Fatalf("hydrogen rate = %#v, want about 3.776 kg/100km", rate)
|
||||
@@ -200,6 +202,61 @@ func TestRawFramesReturnsEmptyPageWhenTDengineTableIsMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLatestRealtimeFramesSQLUsesDurableVINProtocolProjection(t *testing.T) {
|
||||
built := buildLatestRealtimeFramesSQL(RawFrameQuery{
|
||||
VIN: "LMRKH9AC0R1004086",
|
||||
Protocol: "yutong_mqtt",
|
||||
DateFrom: "2026-07-01T00:00:00+08:00",
|
||||
DateTo: "2026-08-25T18:00:00+08:00",
|
||||
Limit: 10,
|
||||
})
|
||||
for _, expected := range []string{
|
||||
"FROM vehicle_realtime_snapshot s",
|
||||
"s.vin = ?",
|
||||
"s.protocol = ?",
|
||||
latestRealtimeProjectionTimeSQL + " >= ?",
|
||||
latestRealtimeProjectionTimeSQL + " <= ?",
|
||||
"ORDER BY " + latestRealtimeProjectionTimeSQL + " DESC",
|
||||
} {
|
||||
if !strings.Contains(built.Text, expected) {
|
||||
t.Fatalf("latest projection SQL missing %q: %s", expected, built.Text)
|
||||
}
|
||||
}
|
||||
if len(built.Args) != 5 || built.Args[0] != "LMRKH9AC0R1004086" || built.Args[1] != "YUTONG_MQTT" || built.Args[4] != len(canonicalVehicleProtocols) {
|
||||
t.Fatalf("latest projection args = %#v", built.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestRealtimeFramesMapsPersistentProjectionToRawEvidence(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("(?s)FROM vehicle_realtime_snapshot s WHERE s\\.vin = \\?").
|
||||
WithArgs("LMRKH9AC0R1004086", len(canonicalVehicleProtocols)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"event_id", "vin", "plate", "protocol", "event_time", "received_at", "peer", "parsed_json"}).
|
||||
AddRow("event-1", "LMRKH9AC0R1004086", "沪A03561F", "YUTONG_MQTT", "2026-08-25 16:49:56.451000", "2026-08-25 16:49:56.485000", "mqtt://yutong/4", `{"yutong_mqtt.data.speed_kmh":12.5}`))
|
||||
store := &ProductionStore{db: db}
|
||||
page, err := store.LatestRealtimeFrames(t.Context(), RawFrameQuery{VIN: "LMRKH9AC0R1004086", IncludeFields: true, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Total != 1 {
|
||||
t.Fatalf("latest projection page = %+v", page)
|
||||
}
|
||||
row := page.Items[0]
|
||||
if row.ID != "event-1" || row.FrameType != "realtime_projection" || row.Protocol != "YUTONG_MQTT" || row.SourceEndpoint != "mqtt://yutong/4" {
|
||||
t.Fatalf("latest projection row = %+v", row)
|
||||
}
|
||||
if got := row.ParsedFields["yutong_mqtt.data.speed_kmh"]; got != 12.5 {
|
||||
t.Fatalf("projection parsed field = %#v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsHealthUsesRedisOnlineKeyProbe(t *testing.T) {
|
||||
db, err := sql.Open("ops_health_test", "")
|
||||
if err != nil {
|
||||
|
||||
@@ -697,9 +697,9 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
||||
"COUNT(DISTINCT d.vin)",
|
||||
"SUM(d.daily_mileage_km)",
|
||||
"SUM(d.pure_hydrogen_mileage_km)",
|
||||
"COUNT(d.hydrogen_consumption_kg)",
|
||||
"SUM(d.hydrogen_consumption_kg)",
|
||||
"CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km",
|
||||
"COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END)",
|
||||
"THEN d.hydrogen_consumption_kg ELSE 0 END",
|
||||
"THEN d.hydrogen_matched_mileage_km ELSE 0 END",
|
||||
} {
|
||||
if !strings.Contains(summary.Text, want) {
|
||||
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
|
||||
@@ -710,7 +710,7 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
||||
}
|
||||
trend := buildMileageStatisticsTrendSQL(query)
|
||||
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") ||
|
||||
!strings.Contains(trend.Text, "SUM(d.hydrogen_consumption_kg)") {
|
||||
!strings.Contains(trend.Text, "THEN d.hydrogen_consumption_kg ELSE 0 END") {
|
||||
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
|
||||
}
|
||||
ranking := buildMileageStatisticsRankingSQL(query)
|
||||
|
||||
@@ -43,6 +43,10 @@ type Store interface {
|
||||
OpsHealth(context.Context) (OpsHealth, error)
|
||||
}
|
||||
|
||||
type HydrogenDailyEvidenceStore interface {
|
||||
HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error)
|
||||
}
|
||||
|
||||
type VehicleOverviewBatchStore interface {
|
||||
VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error)
|
||||
}
|
||||
@@ -65,6 +69,14 @@ type VehicleSourceEvidenceStore interface {
|
||||
VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error)
|
||||
}
|
||||
|
||||
// RealtimeSnapshotFrameStore exposes the durable per-protocol realtime
|
||||
// projection. The projection is used as a time anchor for bounded TDengine
|
||||
// lookups, so an offline vehicle can still resolve its latest evidence without
|
||||
// scanning all retained RAW history.
|
||||
type RealtimeSnapshotFrameStore interface {
|
||||
LatestRealtimeFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
|
||||
}
|
||||
|
||||
type RawFrameQuery struct {
|
||||
Protocol string `json:"protocol"`
|
||||
VIN string `json:"vin"`
|
||||
@@ -1029,7 +1041,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
raw, err := s.RawFrames(ctx, rawQuery)
|
||||
raw, err := s.vehicleDetailRawPreview(ctx, rawQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
@@ -1094,6 +1106,142 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
}, nil
|
||||
}
|
||||
|
||||
const vehicleDetailRawProjectionLookback = 24 * time.Hour
|
||||
|
||||
type vehicleDetailRawPreviewResult struct {
|
||||
anchor RawFrameRow
|
||||
page Page[RawFrameRow]
|
||||
}
|
||||
|
||||
// vehicleDetailRawPreview preserves the latest-ever semantics without issuing
|
||||
// an unbounded TDengine query. MySQL supplies one persistent anchor per
|
||||
// protocol; TDengine then reads only the day preceding that protocol's actual
|
||||
// latest received time. If historical storage is unavailable, the projection
|
||||
// itself remains useful evidence and the rest of the vehicle detail can load.
|
||||
func (s *Service) vehicleDetailRawPreview(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||
snapshotStore, ok := s.store.(RealtimeSnapshotFrameStore)
|
||||
if !ok {
|
||||
return s.store.RawFrames(ctx, query)
|
||||
}
|
||||
anchors, err := snapshotStore.LatestRealtimeFrames(ctx, RawFrameQuery{
|
||||
VIN: query.VIN,
|
||||
Protocol: query.Protocol,
|
||||
DateFrom: query.DateFrom,
|
||||
DateTo: query.DateTo,
|
||||
Fields: query.Fields,
|
||||
IncludeFields: query.IncludeFields,
|
||||
Limit: len(canonicalVehicleProtocols),
|
||||
SkipCount: true,
|
||||
})
|
||||
if err != nil {
|
||||
return Page[RawFrameRow]{}, err
|
||||
}
|
||||
if len(anchors.Items) == 0 {
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: limit, Offset: 0}, nil
|
||||
}
|
||||
|
||||
results := make(chan vehicleDetailRawPreviewResult, len(anchors.Items))
|
||||
for _, anchor := range anchors.Items {
|
||||
anchor := anchor
|
||||
go func() {
|
||||
bounded, valid := boundedRawPreviewQuery(query, anchor)
|
||||
if !valid {
|
||||
results <- vehicleDetailRawPreviewResult{anchor: anchor}
|
||||
return
|
||||
}
|
||||
page, queryErr := s.store.RawFrames(ctx, bounded)
|
||||
if queryErr != nil {
|
||||
results <- vehicleDetailRawPreviewResult{anchor: anchor}
|
||||
return
|
||||
}
|
||||
results <- vehicleDetailRawPreviewResult{anchor: anchor, page: page}
|
||||
}()
|
||||
}
|
||||
|
||||
items := make([]RawFrameRow, 0, len(anchors.Items)*query.Limit)
|
||||
for range anchors.Items {
|
||||
result := <-results
|
||||
if len(result.page.Items) == 0 {
|
||||
items = append(items, result.anchor)
|
||||
continue
|
||||
}
|
||||
items = append(items, result.page.Items...)
|
||||
}
|
||||
items = newestRawFrames(items, query.Limit)
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil
|
||||
}
|
||||
|
||||
func boundedRawPreviewQuery(query RawFrameQuery, anchor RawFrameRow) (RawFrameQuery, bool) {
|
||||
anchorTime, ok := parseVehicleServiceTime(firstNonEmpty(anchor.ServerTime, anchor.DeviceTime))
|
||||
if !ok {
|
||||
return RawFrameQuery{}, false
|
||||
}
|
||||
start := anchorTime.Add(-vehicleDetailRawProjectionLookback)
|
||||
end := anchorTime.Add(time.Minute)
|
||||
if value := strings.TrimSpace(query.DateFrom); value != "" {
|
||||
if scopedStart, parsed := parseTrackRequestTime(value); parsed && scopedStart.After(start) {
|
||||
start = scopedStart
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(query.DateTo); value != "" {
|
||||
if scopedEnd, parsed := parseTrackRequestTime(value); parsed && scopedEnd.Before(end) {
|
||||
end = scopedEnd
|
||||
}
|
||||
}
|
||||
if end.Before(start) {
|
||||
return RawFrameQuery{}, false
|
||||
}
|
||||
bounded := query
|
||||
bounded.Protocol = anchor.Protocol
|
||||
bounded.DateFrom = start.Format(time.RFC3339Nano)
|
||||
bounded.DateTo = end.Format(time.RFC3339Nano)
|
||||
bounded.Offset = 0
|
||||
bounded.SkipCount = true
|
||||
return bounded, true
|
||||
}
|
||||
|
||||
func newestRawFrames(items []RawFrameRow, limit int) []RawFrameRow {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
deduplicated := make([]RawFrameRow, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.ToUpper(strings.TrimSpace(item.Protocol)) + "\x00" + strings.TrimSpace(item.ID)
|
||||
if key != "\x00" && seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
deduplicated = append(deduplicated, item)
|
||||
}
|
||||
sort.SliceStable(deduplicated, func(left, right int) bool {
|
||||
leftTime, leftOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[left].ServerTime, deduplicated[left].DeviceTime))
|
||||
rightTime, rightOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[right].ServerTime, deduplicated[right].DeviceTime))
|
||||
if leftOK && rightOK && !leftTime.Equal(rightTime) {
|
||||
return leftTime.After(rightTime)
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
if deduplicated[left].Protocol != deduplicated[right].Protocol {
|
||||
return deduplicated[left].Protocol < deduplicated[right].Protocol
|
||||
}
|
||||
return deduplicated[left].ID < deduplicated[right].ID
|
||||
})
|
||||
if len(deduplicated) > limit {
|
||||
deduplicated = deduplicated[:limit]
|
||||
}
|
||||
return deduplicated
|
||||
}
|
||||
|
||||
func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) {
|
||||
vin = strings.TrimSpace(vin)
|
||||
if vin == "" {
|
||||
@@ -1587,6 +1735,7 @@ func coverageStatus(sourceCount int, onlineSourceCount int) string {
|
||||
|
||||
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
|
||||
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{})
|
||||
_, hasDurableRawProjection := s.store.(RealtimeSnapshotFrameStore)
|
||||
for index := range statuses {
|
||||
protocol := statuses[index].Protocol
|
||||
if protocol == "" {
|
||||
@@ -1599,7 +1748,7 @@ func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, sta
|
||||
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
|
||||
}
|
||||
}
|
||||
if !statuses[index].HasRaw && scopeErr == nil {
|
||||
if !statuses[index].HasRaw && scopeErr == nil && !hasDurableRawProjection {
|
||||
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), Limit: 1, SkipCount: true})
|
||||
if err == nil && len(page.Items) > 0 {
|
||||
statuses[index].HasRaw = true
|
||||
@@ -1933,7 +2082,7 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates
|
||||
catalogResult := make(chan latestTelemetryCatalogResult, 1)
|
||||
for _, protocol := range canonicalVehicleProtocols {
|
||||
go func(protocol string) {
|
||||
page, queryErr := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true})
|
||||
page, queryErr := s.vehicleDetailRawPreview(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true})
|
||||
rawResult <- latestTelemetryRawResult{page: page, err: queryErr}
|
||||
}(protocol)
|
||||
}
|
||||
@@ -5409,6 +5558,40 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, error) {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
date = strings.TrimSpace(date)
|
||||
if len(vin) != 17 {
|
||||
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_VIN_INVALID", Message: "VIN格式不正确"}
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", date); err != nil {
|
||||
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_DATE_INVALID", Message: "统计日期格式不正确"}
|
||||
}
|
||||
if !hydrogenConsumptionAllowed(ctx) {
|
||||
return HydrogenDailyEvidence{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看氢耗计算证据"}
|
||||
}
|
||||
query := url.Values{"vins": {vin}, "dateFrom": {date}, "dateTo": {date}}
|
||||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||
if err != nil {
|
||||
return HydrogenDailyEvidence{}, err
|
||||
}
|
||||
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
|
||||
return HydrogenDailyEvidence{}, err
|
||||
}
|
||||
store, ok := s.store.(HydrogenDailyEvidenceStore)
|
||||
if !ok {
|
||||
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "当前数据源尚未提供氢耗计算证据"}
|
||||
}
|
||||
result, found, err := store.HydrogenDailyEvidence(ctx, vin, date)
|
||||
if err != nil {
|
||||
return HydrogenDailyEvidence{}, err
|
||||
}
|
||||
if !found {
|
||||
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "未找到该车辆当日氢耗计算证据"}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||
if err != nil {
|
||||
|
||||
@@ -543,6 +543,7 @@ func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing.
|
||||
|
||||
type countingStore struct {
|
||||
*MockStore
|
||||
rawMu sync.Mutex
|
||||
vehiclesCalls int
|
||||
vehicleRealtimeCalls int
|
||||
overviewBatchCalls int
|
||||
@@ -555,6 +556,17 @@ type countingStore struct {
|
||||
lastMileageStatisticsQuery url.Values
|
||||
}
|
||||
|
||||
type projectionCountingStore struct {
|
||||
*countingStore
|
||||
anchors Page[RawFrameRow]
|
||||
projectionQueries []RawFrameQuery
|
||||
}
|
||||
|
||||
func (s *projectionCountingStore) LatestRealtimeFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||
s.projectionQueries = append(s.projectionQueries, query)
|
||||
return s.anchors, nil
|
||||
}
|
||||
|
||||
type hydrogenMetricsStore struct{ *MockStore }
|
||||
|
||||
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
|
||||
@@ -572,15 +584,26 @@ func (s *hydrogenMetricsStore) MileageSummary(context.Context, url.Values) (Mile
|
||||
func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) {
|
||||
consumption, rate := 3.1, 5.5
|
||||
return MileageStatistics{
|
||||
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
|
||||
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1,
|
||||
PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||
Trend: []MileageTrendPoint{{
|
||||
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
|
||||
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1,
|
||||
HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *hydrogenMetricsStore) HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error) {
|
||||
return HydrogenDailyEvidence{
|
||||
VIN: "LB9A32A24R0LS1426",
|
||||
Date: "2026-07-13",
|
||||
AlgorithmVersion: "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2",
|
||||
Intervals: []HydrogenIntervalEvidenceRow{{
|
||||
Index: 1, StartEventID: "event-start", EndEventID: "event-end", QualityStatus: "OK",
|
||||
}},
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func newCountingStore() *countingStore {
|
||||
return &countingStore{MockStore: NewMockStore()}
|
||||
}
|
||||
@@ -622,6 +645,27 @@ func TestHydrogenConsumptionMetricsRemainAvailableToInternalAccounts(t *testing.
|
||||
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 {
|
||||
t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items)
|
||||
}
|
||||
evidence, err := service.HydrogenDailyEvidence(exportAdminContext(), "LB9A32A24R0LS1426", "2026-07-13")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if evidence.AlgorithmVersion == "" || len(evidence.Intervals) != 1 || evidence.Intervals[0].StartEventID != "event-start" {
|
||||
t.Fatalf("internal hydrogen evidence is incomplete: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenDailyEvidenceIsDeniedToCustomerAccounts(t *testing.T) {
|
||||
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
|
||||
validFrom := time.Date(2026, 7, 12, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
customer := WithPrincipal(context.Background(), Principal{
|
||||
Name: "业务客户", Role: "customer", UserType: "customer",
|
||||
VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
|
||||
})
|
||||
_, err := service.HydrogenDailyEvidence(customer, "LB9A32A24R0LS1426", "2026-07-13")
|
||||
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" {
|
||||
t.Fatalf("customer hydrogen evidence should be forbidden, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
||||
@@ -643,7 +687,9 @@ func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query
|
||||
}
|
||||
|
||||
func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||
s.rawMu.Lock()
|
||||
s.rawFrameQueries = append(s.rawFrameQueries, query)
|
||||
s.rawMu.Unlock()
|
||||
return s.MockStore.RawFrames(ctx, query)
|
||||
}
|
||||
|
||||
@@ -692,6 +738,66 @@ func TestVehicleDetailSkipsUnneededTDengineCounts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleDetailUsesPersistentProjectionAsBoundedHistoricalAnchor(t *testing.T) {
|
||||
base := newCountingStore()
|
||||
store := &projectionCountingStore{
|
||||
countingStore: base,
|
||||
anchors: Page[RawFrameRow]{Items: []RawFrameRow{
|
||||
{ID: "snapshot-gb", VIN: "LB9A32A24R0LS1426", Protocol: "GB32960", FrameType: "realtime_projection", DeviceTime: "2025-03-04T12:00:00+08:00", ServerTime: "2025-03-04T12:00:01+08:00", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 0}},
|
||||
{ID: "snapshot-jt", VIN: "LB9A32A24R0LS1426", Protocol: "JT808", FrameType: "realtime_projection", DeviceTime: "2025-02-03T10:00:00+08:00", ServerTime: "2025-02-03T10:00:01+08:00", ParsedFields: map[string]any{"jt808.location.speed_kmh": 0}},
|
||||
{ID: "snapshot-mqtt", VIN: "LB9A32A24R0LS1426", Protocol: "YUTONG_MQTT", FrameType: "realtime_projection", DeviceTime: "2025-01-01T08:00:00+08:00", ServerTime: "2025-01-01T08:00:01+08:00", ParsedFields: map[string]any{"yutong_mqtt.data.speed_kmh": 0}},
|
||||
}},
|
||||
}
|
||||
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(store.projectionQueries) != 1 || store.projectionQueries[0].VIN != "LB9A32A24R0LS1426" {
|
||||
t.Fatalf("projection queries = %+v", store.projectionQueries)
|
||||
}
|
||||
if len(store.rawFrameQueries) != 3 {
|
||||
t.Fatalf("bounded protocol RAW queries = %+v", store.rawFrameQueries)
|
||||
}
|
||||
for _, query := range store.rawFrameQueries {
|
||||
if query.Protocol == "" || query.DateFrom == "" || query.DateTo == "" || !query.SkipCount {
|
||||
t.Fatalf("RAW preview must be protocol-specific, time-bounded, and count-free: %+v", query)
|
||||
}
|
||||
if strings.HasPrefix(query.DateFrom, "2026-") || strings.HasPrefix(query.DateTo, "2026-") {
|
||||
t.Fatalf("offline projection should anchor its actual historical day, not today: %+v", query)
|
||||
}
|
||||
if !strings.Contains(query.DateFrom, "+08:00") || !strings.Contains(query.DateTo, "+08:00") {
|
||||
t.Fatalf("bounded RAW preview should preserve the platform timezone: %+v", query)
|
||||
}
|
||||
}
|
||||
if len(detail.Raw.Items) != 3 {
|
||||
t.Fatalf("bounded preview should preserve every latest protocol row: %+v", detail.Raw)
|
||||
}
|
||||
projectionFallbacks := 0
|
||||
for _, item := range detail.Raw.Items {
|
||||
if item.FrameType == "realtime_projection" {
|
||||
projectionFallbacks++
|
||||
}
|
||||
}
|
||||
if projectionFallbacks != 2 {
|
||||
t.Fatalf("missing projection fallbacks for protocols without RAW test rows: %+v", detail.Raw.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleDetailDoesNotFallBackToUnboundedRAWWhenProjectionIsEmpty(t *testing.T) {
|
||||
base := newCountingStore()
|
||||
store := &projectionCountingStore{countingStore: base, anchors: Page[RawFrameRow]{Items: []RawFrameRow{}}}
|
||||
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(store.rawFrameQueries) != 0 {
|
||||
t.Fatalf("empty durable projection must not trigger an unbounded RAW scan: %+v", store.rawFrameQueries)
|
||||
}
|
||||
if detail.Raw.Items == nil || len(detail.Raw.Items) != 0 || detail.Raw.Total != 0 {
|
||||
t.Fatalf("empty projection should return a stable empty preview: %+v", detail.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||
s.lastMileageStatisticsQuery = cloneValues(query)
|
||||
return s.MockStore.MileageStatistics(ctx, query)
|
||||
|
||||
Reference in New Issue
Block a user