feat(platform): surface no-source quality issues
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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 数据来源"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user