feat(platform): filter vehicle coverage

This commit is contained in:
lingniu
2026-07-03 21:59:03 +08:00
parent 110041a9db
commit 2d064e3c53
5 changed files with 101 additions and 7 deletions

View File

@@ -36,7 +36,7 @@ func TestHandlerVehicles(t *testing.T) {
func TestHandlerVehicleCoverage(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage?limit=10", nil)
req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage?limit=10&coverage=multi&online=online&bindingStatus=bound", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())

View File

@@ -87,7 +87,9 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
}
items := make([]VehicleCoverageRow, 0, len(byVIN))
for _, row := range byVIN {
items = append(items, *row)
if keepCoverageRow(*row, query) {
items = append(items, *row)
}
}
sortVehicleCoverage(items)
return page(items, query), nil
@@ -237,6 +239,37 @@ func sortVehicleCoverage(rows []VehicleCoverageRow) {
})
}
func keepCoverageRow(row VehicleCoverageRow, query url.Values) bool {
switch strings.TrimSpace(query.Get("coverage")) {
case "single":
if row.SourceCount != 1 {
return false
}
case "multi":
if row.SourceCount <= 1 {
return false
}
}
switch strings.TrimSpace(query.Get("online")) {
case "online":
if !row.Online {
return false
}
case "offline":
if row.Online {
return false
}
}
switch strings.TrimSpace(query.Get("bindingStatus")) {
case "bound":
return row.BindingStatus == "bound"
case "unbound":
return row.BindingStatus == "unbound"
default:
return true
}
}
func parsePositive(raw string, fallback int) int {
value, err := strconv.Atoi(raw)
if err != nil || value < 0 {

View File

@@ -45,6 +45,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
offset := parsePositive(query.Get("offset"), 0)
args := []any{}
where := []string{"s.vin IS NOT NULL", "s.vin <> ''"}
having := []string{}
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
where = append(where, "(s.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
like := "%" + keyword + "%"
@@ -54,21 +55,44 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
where = append(where, "s.protocol = ?")
args = append(args, protocol)
}
switch strings.TrimSpace(query.Get("coverage")) {
case "single":
having = append(having, "COUNT(DISTINCT s.protocol) = 1")
case "multi":
having = append(having, "COUNT(DISTINCT s.protocol) > 1")
}
switch strings.TrimSpace(query.Get("online")) {
case "online":
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
case "offline":
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
}
switch strings.TrimSpace(query.Get("bindingStatus")) {
case "bound":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
case "unbound":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
}
args = append(args, limit, offset)
havingSQL := ""
if len(having) > 0 {
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
}
return SQLQuery{
Text: `SELECT s.vin, ` +
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
`COUNT(DISTINCT s.protocol) AS source_count, ` +
`SUM(CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END) AS online_source_count, ` +
`CASE WHEN SUM(CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END AS online, ` +
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
`CASE WHEN COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
`COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` +
`FROM vehicle_realtime_snapshot s ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
`WHERE ` + strings.Join(where, " AND ") + ` ` +
`GROUP BY s.vin, b.plate, b.phone, b.oem, b.vin ` +
havingSQL +
`ORDER BY MAX(s.updated_at) DESC LIMIT ? OFFSET ?`,
Args: args,
}

View File

@@ -20,13 +20,16 @@ func TestBuildVehicleListSQL(t *testing.T) {
}
func TestBuildVehicleCoverageSQL(t *testing.T) {
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "limit": {"8"}, "offset": {"16"}}
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
built := buildVehicleCoverageSQL(query)
for _, want := range []string{"GROUP BY s.vin", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count"} {
for _, want := range []string{"GROUP BY s.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("SQL missing %q: %s", want, built.Text)
}
}
if strings.Contains(built.Text, "1ORDER BY") || strings.Contains(built.Text, "0ORDER BY") {
t.Fatalf("SQL should keep whitespace before ORDER BY: %s", built.Text)
}
if len(built.Args) != 9 || built.Args[0] != "%粤A%" || built.Args[6] != "GB32960" || built.Args[7] != 8 || built.Args[8] != 16 {
t.Fatalf("args = %#v", built.Args)
}