feat(platform): surface no-source quality issues

This commit is contained in:
lingniu
2026-07-04 04:34:32 +08:00
parent 40b55600c7
commit 669390e8bb
3 changed files with 115 additions and 26 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
) )
@@ -586,6 +587,66 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
} }
} }
func TestHandlerQualityIncludesNoSourceVehicles(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/quality/issues?limit=20", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data Page[QualityIssueRow] `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
}
found := false
for _, item := range body.Data.Items {
if item.IssueType == "NO_SOURCE" {
found = true
if item.VIN == "" || item.Plate == "" || item.Protocol != "VEHICLE_SERVICE" {
t.Fatalf("NO_SOURCE issue should identify bound vehicle service, got %+v", item)
}
}
}
if !found {
t.Fatalf("quality issues should include NO_SOURCE vehicles, got %+v", body.Data.Items)
}
}
func TestHandlerQualitySummaryIncludesNoSourceBucket(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/quality/summary?limit=20", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data QualitySummary `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
}
found := false
for _, item := range body.Data.IssueTypes {
if item.Name == "NO_SOURCE" {
found = true
}
}
if !found {
t.Fatalf("quality summary should include NO_SOURCE bucket, got %+v", body.Data.IssueTypes)
}
}
func TestBuildQualityIssueWhereUsesMatchingArgs(t *testing.T) {
fromSQL, args := buildQualityIssueWhere(url.Values{"vin": {"VIN001"}, "protocol": {"VEHICLE_SERVICE"}, "keyword": {"粤A"}})
if strings.Count(fromSQL, "?") != len(args) {
t.Fatalf("quality issue SQL placeholders should match args: placeholders=%d args=%d sql=%s args=%#v", strings.Count(fromSQL, "?"), len(args), fromSQL, args)
}
}
func TestHandlerOpsHealthIncludesVehicleServiceRuntime(t *testing.T) { func TestHandlerOpsHealthIncludesVehicleServiceRuntime(t *testing.T) {
handler := NewHandler(NewServiceWithRuntime(NewMockStore(), RuntimeInfo{RequestTimeoutMs: 1500})) handler := NewHandler(NewServiceWithRuntime(NewMockStore(), RuntimeInfo{RequestTimeoutMs: 1500}))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View File

@@ -556,6 +556,7 @@ func mockQualityRows() []QualityIssueRow {
return []QualityIssueRow{ return []QualityIssueRow{
{VIN: "unknown", Plate: "", Phone: "13300000001", SourceEndpoint: "127.0.0.1:808", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 13300000001 未命中 binding 表"}, {VIN: "unknown", Plate: "", Phone: "13300000001", SourceEndpoint: "127.0.0.1:808", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 13300000001 未命中 binding 表"},
{VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Phone: "", SourceEndpoint: "Hyundai", Protocol: "GB32960", IssueType: "LINK_GAP", Severity: "error", LastSeen: "2026-07-03 18:20:00", Detail: "Hyundai 平台近 60 分钟无转发"}, {VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Phone: "", SourceEndpoint: "Hyundai", Protocol: "GB32960", IssueType: "LINK_GAP", Severity: "error", LastSeen: "2026-07-03 18:20:00", Detail: "Hyundai 平台近 60 分钟无转发"},
{VIN: "LB9NO_SOURCE001", Plate: "粤A无源1", Phone: "13300000002", SourceEndpoint: "vehicle_identity_binding", Protocol: "VEHICLE_SERVICE", IssueType: "NO_SOURCE", Severity: "warning", LastSeen: "", Detail: "车辆已绑定,但暂无 32960、808 或 MQTT 数据来源"},
} }
} }

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
@@ -704,36 +703,74 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
} }
func buildQualityIssueWhere(query url.Values) (string, []any) { func buildQualityIssueWhere(query url.Values) (string, []any) {
where := []string{"(r.vin = '' OR r.vin = 'unknown' OR r.vin IS NULL)"} where := []string{"1 = 1"}
args := []any{} args := []any{}
if vin := strings.TrimSpace(query.Get("vin")); vin != "" { if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
where = append(where, "(r.vin = ? OR b.vin = ?)") where = append(where, "q.vin = ?")
args = append(args, vin, vin) args = append(args, vin)
} }
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "? = 'JT808'") where = append(where, "q.protocol = ?")
args = append(args, protocol) args = append(args, protocol)
} }
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" { if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
where = append(where, "(r.phone LIKE ? OR COALESCE(r.source_endpoint, '') LIKE ? OR COALESCE(b.plate, '') LIKE ?)") where = append(where, "(q.vin LIKE ? OR q.plate LIKE ? OR q.phone LIKE ? OR q.source_endpoint LIKE ?)")
like := "%" + keyword + "%" like := "%" + keyword + "%"
args = append(args, like, like, like) args = append(args, like, like, like, like)
} }
return `FROM jt808_registration r LEFT JOIN vehicle_identity_binding b ON b.phone = r.phone WHERE ` + strings.Join(where, " AND "), args sourceSQL := `SELECT COALESCE(NULLIF(b.vin, ''), 'unknown') AS vin, COALESCE(b.plate, '') AS plate, r.phone AS phone, ` +
`COALESCE(r.source_endpoint, '') AS source_endpoint, 'JT808' AS protocol, 'VIN_MISSING' AS issue_type, ` +
`'warning' AS severity, COALESCE(DATE_FORMAT(r.latest_seen_at, '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
`CONCAT('phone ', r.phone, ' 未命中 binding 表,来源 ', COALESCE(r.source_endpoint, '')) AS detail ` +
`FROM jt808_registration r LEFT JOIN vehicle_identity_binding b ON b.phone = r.phone ` +
`WHERE r.vin = '' OR r.vin = 'unknown' OR r.vin IS NULL ` +
`UNION ALL ` +
`SELECT b.vin AS vin, COALESCE(b.plate, '') AS plate, COALESCE(b.phone, '') AS phone, ` +
`'vehicle_identity_binding' AS source_endpoint, 'VEHICLE_SERVICE' AS protocol, 'NO_SOURCE' AS issue_type, ` +
`'warning' AS severity, '' AS last_seen, '车辆已绑定,但暂无 32960、808 或 MQTT 数据来源' AS detail ` +
`FROM vehicle_identity_binding b LEFT JOIN vehicle_realtime_snapshot s ON s.vin = b.vin ` +
`WHERE b.vin IS NOT NULL AND b.vin <> '' ` +
`GROUP BY b.vin, b.plate, b.phone HAVING COUNT(DISTINCT s.protocol) = 0`
return `FROM (` + sourceSQL + `) q WHERE ` + strings.Join(where, " AND "), args
} }
func (s *ProductionStore) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) { func (s *ProductionStore) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) {
fromSQL, args := buildQualityIssueWhere(query) fromSQL, args := buildQualityIssueWhere(query)
var summary QualitySummary var summary QualitySummary
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT COALESCE(NULLIF(b.vin, ''), r.phone)) AS issue_vehicle_count, COUNT(*) AS issue_record_count `+fromSQL, args...).Scan(&summary.IssueVehicleCount, &summary.IssueRecordCount); err != nil { if err := s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT COALESCE(NULLIF(q.vin, ''), q.phone)) AS issue_vehicle_count, COUNT(*) AS issue_record_count `+fromSQL, args...).Scan(&summary.IssueVehicleCount, &summary.IssueRecordCount); err != nil {
return QualitySummary{}, err return QualitySummary{}, err
} }
summary.WarningCount = summary.IssueRecordCount summary.WarningCount = summary.IssueRecordCount
summary.Protocols = []QualityBucketStat{{Name: "JT808", Count: summary.IssueRecordCount}} protocols, err := s.qualityBuckets(ctx, fromSQL, args, "q.protocol")
summary.IssueTypes = []QualityBucketStat{{Name: "VIN_MISSING", Count: summary.IssueRecordCount}} if err != nil {
return QualitySummary{}, err
}
issueTypes, err := s.qualityBuckets(ctx, fromSQL, args, "q.issue_type")
if err != nil {
return QualitySummary{}, err
}
summary.Protocols = protocols
summary.IssueTypes = issueTypes
return summary, nil return summary, nil
} }
func (s *ProductionStore) qualityBuckets(ctx context.Context, fromSQL string, args []any, field string) ([]QualityBucketStat, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+field+`, COUNT(*) `+fromSQL+` GROUP BY `+field+` ORDER BY COUNT(*) DESC, `+field+` ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []QualityBucketStat{}
for rows.Next() {
var item QualityBucketStat
if err := rows.Scan(&item.Name, &item.Count); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) { func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
limit, offset := buildLimitOffset(query) limit, offset := buildLimitOffset(query)
fromSQL, args := buildQualityIssueWhere(query) fromSQL, args := buildQualityIssueWhere(query)
@@ -743,29 +780,19 @@ func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) `+fromSQL, countArgs...).Scan(&total); err != nil { if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) `+fromSQL, countArgs...).Scan(&total); err != nil {
return Page[QualityIssueRow]{}, err return Page[QualityIssueRow]{}, err
} }
rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(b.vin, 'unknown') AS vin, COALESCE(b.plate, '') AS plate, r.phone, COALESCE(r.source_endpoint, ''), COALESCE(DATE_FORMAT(r.latest_seen_at, '%Y-%m-%d %H:%i:%s'), '') `+ rows, err := s.db.QueryContext(ctx, `SELECT q.vin, q.plate, q.phone, q.source_endpoint, q.protocol, q.issue_type, q.severity, q.last_seen, q.detail `+
fromSQL+` ORDER BY r.latest_seen_at DESC, r.phone ASC LIMIT ? OFFSET ?`, args...) fromSQL+` ORDER BY q.last_seen DESC, q.vin ASC, q.phone ASC LIMIT ? OFFSET ?`, args...)
if err != nil { if err != nil {
return Page[QualityIssueRow]{}, err return Page[QualityIssueRow]{}, err
} }
defer rows.Close() defer rows.Close()
items := make([]QualityIssueRow, 0) items := make([]QualityIssueRow, 0)
for rows.Next() { for rows.Next() {
var vin, plate, phone, source, lastSeen string var item QualityIssueRow
if err := rows.Scan(&vin, &plate, &phone, &source, &lastSeen); err != nil { if err := rows.Scan(&item.VIN, &item.Plate, &item.Phone, &item.SourceEndpoint, &item.Protocol, &item.IssueType, &item.Severity, &item.LastSeen, &item.Detail); err != nil {
return Page[QualityIssueRow]{}, err return Page[QualityIssueRow]{}, err
} }
items = append(items, QualityIssueRow{ items = append(items, item)
VIN: vin,
Plate: plate,
Phone: phone,
SourceEndpoint: source,
Protocol: "JT808",
IssueType: "VIN_MISSING",
Severity: "warning",
LastSeen: lastSeen,
Detail: fmt.Sprintf("phone %s 未命中 binding 表,来源 %s", phone, source),
})
} }
return Page[QualityIssueRow]{Items: items, Total: total, Limit: limit, Offset: offset}, rows.Err() return Page[QualityIssueRow]{Items: items, Total: total, Limit: limit, Offset: offset}, rows.Err()
} }