Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/data_source_query.go

1734 lines
60 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package stats
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
type DataSourceQuery struct {
Protocol string
SourceIP string
SourceCode string
SourceKind string
SourceCodeMissing *bool
Enabled *bool
IncludeTotal bool
Limit int
Offset int
}
type DataSourceDiagnosticsQuery struct {
Protocol string
SourceIP string
SourceKind string
SourceCodeMissing *bool
MappingIssueOnly bool
Enabled *bool
IncludeTotal bool
Limit int
Offset int
}
type JT808IdentityGapQuery struct {
SourceIP string
SourceCode string
RecentSeconds int64
IncludeTotal bool
Limit int
Offset int
}
type JT808MappingGapQuery struct {
SourceIP string
SourceCode string
RecentSeconds int64
IncludeTotal bool
Limit int
Offset int
}
type DataSourceRow struct {
ID int64 `json:"id"`
Protocol string `json:"protocol"`
SourceIP string `json:"source_ip"`
LatestSourceEndpoint string `json:"latest_source_endpoint,omitempty"`
PlatformName string `json:"platform_name,omitempty"`
SourceCode string `json:"source_code,omitempty"`
SourceKind string `json:"source_kind"`
TrustPriority int `json:"trust_priority"`
Enabled bool `json:"enabled"`
FirstSeenAt string `json:"first_seen_at,omitempty"`
LatestSeenAt string `json:"latest_seen_at,omitempty"`
Remark string `json:"remark,omitempty"`
UpdatedAt string `json:"updated_at"`
}
type DataSourceUpdate struct {
PlatformName *string `json:"platform_name"`
SourceCode *string `json:"source_code"`
SourceKind *string `json:"source_kind"`
TrustPriority *int `json:"trust_priority"`
Enabled *bool `json:"enabled"`
Remark *string `json:"remark"`
}
type DataSourceDiagnosticRow struct {
ID int64 `json:"id"`
Protocol string `json:"protocol"`
SourceIP string `json:"source_ip"`
LatestSourceEndpoint string `json:"latest_source_endpoint,omitempty"`
PlatformName string `json:"platform_name,omitempty"`
SourceCode string `json:"source_code,omitempty"`
SourceKind string `json:"source_kind"`
FirstSeenAt string `json:"first_seen_at,omitempty"`
LatestSeenAt string `json:"latest_seen_at,omitempty"`
ActiveSpanSeconds int64 `json:"active_span_seconds"`
LatestSeenAgeSeconds int64 `json:"latest_seen_age_seconds"`
RegistrationRows int64 `json:"registration_rows"`
PhoneCount int64 `json:"phone_count"`
UnknownVINRows int64 `json:"unknown_vin_rows"`
IdentifierMatchedPhones int64 `json:"identifier_matched_phones"`
UnmappedPhoneCount int64 `json:"unmapped_phone_count"`
IdentifierMatchRatio float64 `json:"identifier_match_ratio"`
ConfiguredSourceCodeMatchedPhones int64 `json:"configured_source_code_matched_phones"`
ConfiguredSourceCodePlatformName string `json:"configured_source_code_platform_name,omitempty"`
ConfiguredSourceCodeConflict bool `json:"configured_source_code_conflict"`
SourcePlatformNameMismatch bool `json:"source_platform_name_mismatch"`
MatchedSourceCodeCount int64 `json:"matched_source_code_count"`
CandidateSourceCode string `json:"candidate_source_code,omitempty"`
CandidatePlatformName string `json:"candidate_platform_name,omitempty"`
MatchedSourceCodes []string `json:"matched_source_codes,omitempty"`
MatchedPlatformNames []string `json:"matched_platform_names,omitempty"`
SamplePhones []string `json:"sample_phones,omitempty"`
Reason string `json:"reason"`
RecommendedOperatorAction string `json:"recommended_operator_action"`
SuggestedSourceKind string `json:"suggested_source_kind"`
SuggestionConfidence string `json:"suggestion_confidence"`
SuggestionReason string `json:"suggestion_reason"`
}
type JT808IdentityGapRow struct {
Phone string `json:"phone"`
DeviceID string `json:"device_id,omitempty"`
Plate string `json:"plate,omitempty"`
VIN string `json:"vin,omitempty"`
SourceIP string `json:"source_ip,omitempty"`
SourceEndpoint string `json:"source_endpoint,omitempty"`
SourceCode string `json:"source_code,omitempty"`
PlatformName string `json:"platform_name,omitempty"`
SourceKind string `json:"source_kind,omitempty"`
FirstRegisteredAt string `json:"first_registered_at,omitempty"`
LatestRegisteredAt string `json:"latest_registered_at,omitempty"`
LatestAuthenticatedAt string `json:"latest_authenticated_at,omitempty"`
LatestSeenAt string `json:"latest_seen_at,omitempty"`
LatestSeenAgeSeconds int64 `json:"latest_seen_age_seconds"`
Reason string `json:"reason"`
RecommendedOperatorAction string `json:"recommended_operator_action"`
RawFrameQueryPath string `json:"raw_frame_query_path,omitempty"`
DataSourceQueryPath string `json:"data_source_query_path,omitempty"`
}
type JT808MappingGapRow struct {
Phone string `json:"phone"`
DeviceID string `json:"device_id,omitempty"`
Plate string `json:"plate,omitempty"`
VIN string `json:"vin,omitempty"`
SourceIP string `json:"source_ip,omitempty"`
SourceEndpoint string `json:"source_endpoint,omitempty"`
SourceCode string `json:"source_code,omitempty"`
PlatformName string `json:"platform_name,omitempty"`
SourceKind string `json:"source_kind,omitempty"`
IdentifierVIN string `json:"identifier_vin,omitempty"`
IdentifierPlate string `json:"identifier_plate,omitempty"`
MatchedSourceCodes []string `json:"matched_source_codes,omitempty"`
MatchedPlatformNames []string `json:"matched_platform_names,omitempty"`
LatestSeenAt string `json:"latest_seen_at,omitempty"`
LatestSeenAgeSeconds int64 `json:"latest_seen_age_seconds"`
Reason string `json:"reason"`
RecommendedAction string `json:"recommended_action"`
SuggestedSourceCode string `json:"suggested_source_code,omitempty"`
SuggestedPlatformName string `json:"suggested_platform_name,omitempty"`
SuggestedIdentifierType string `json:"suggested_identifier_type,omitempty"`
SuggestedIdentifierValue string `json:"suggested_identifier_value,omitempty"`
SuggestedVIN string `json:"suggested_vin,omitempty"`
SuggestedPlate string `json:"suggested_plate,omitempty"`
RawFrameQueryPath string `json:"raw_frame_query_path,omitempty"`
DataSourceQueryPath string `json:"data_source_query_path,omitempty"`
VehicleIdentifierExample string `json:"vehicle_identifier_example,omitempty"`
}
type dataSourceDB interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
type DataSourceRepository struct {
db dataSourceDB
}
func NewDataSourceRepository(db dataSourceDB) *DataSourceRepository {
if db == nil {
panic("data source db must not be nil")
}
return &DataSourceRepository{db: db}
}
func (r *DataSourceRepository) Query(ctx context.Context, query DataSourceQuery) ([]DataSourceRow, error) {
query = normalizeDataSourceQuery(query)
sqlText, args := buildDataSourceSQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]DataSourceRow, 0)
for rows.Next() {
var row DataSourceRow
var endpoint, platform, sourceCode, sourceKind, remark sql.NullString
var firstSeen, latestSeen, updatedAt scanDateTime
var enabled int
if err := rows.Scan(
&row.ID,
&row.Protocol,
&row.SourceIP,
&endpoint,
&platform,
&sourceCode,
&sourceKind,
&row.TrustPriority,
&enabled,
&firstSeen,
&latestSeen,
&remark,
&updatedAt,
); err != nil {
return nil, err
}
row.LatestSourceEndpoint = endpoint.String
row.PlatformName = platform.String
row.SourceCode = sourceCode.String
row.SourceKind = normalizeSourceKindForRead(sourceKind.String)
row.Enabled = enabled != 0
row.FirstSeenAt = firstSeen.String
row.LatestSeenAt = latestSeen.String
row.Remark = remark.String
row.UpdatedAt = updatedAt.String
out = append(out, row)
}
return out, rows.Err()
}
func (r *DataSourceRepository) Count(ctx context.Context, query DataSourceQuery) (int64, error) {
query = normalizeDataSourceQuery(query)
sqlText, args := buildDataSourceCountSQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return 0, err
}
defer rows.Close()
var total int64
if rows.Next() {
if err := rows.Scan(&total); err != nil {
return 0, err
}
}
return total, rows.Err()
}
func (r *DataSourceRepository) QueryDiagnostics(ctx context.Context, query DataSourceDiagnosticsQuery) ([]DataSourceDiagnosticRow, error) {
query = normalizeDataSourceDiagnosticsQuery(query)
sqlText, args := buildDataSourceDiagnosticsSQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]DataSourceDiagnosticRow, 0)
for rows.Next() {
var row DataSourceDiagnosticRow
var endpoint, platform, sourceCode, sourceKind sql.NullString
var firstSeen, latestSeen scanDateTime
var configuredSourceCodePlatformName, candidateSourceCode, candidatePlatformName, matchedSourceCodes, matchedPlatformNames, samplePhones sql.NullString
if err := rows.Scan(
&row.ID,
&row.Protocol,
&row.SourceIP,
&endpoint,
&platform,
&sourceCode,
&sourceKind,
&firstSeen,
&latestSeen,
&row.ActiveSpanSeconds,
&row.LatestSeenAgeSeconds,
&row.RegistrationRows,
&row.PhoneCount,
&row.UnknownVINRows,
&row.IdentifierMatchedPhones,
&row.UnmappedPhoneCount,
&row.IdentifierMatchRatio,
&row.ConfiguredSourceCodeMatchedPhones,
&configuredSourceCodePlatformName,
&row.MatchedSourceCodeCount,
&candidateSourceCode,
&candidatePlatformName,
&matchedSourceCodes,
&matchedPlatformNames,
&samplePhones,
); err != nil {
return nil, err
}
row.LatestSourceEndpoint = endpoint.String
row.PlatformName = platform.String
row.SourceCode = sourceCode.String
row.SourceKind = normalizeSourceKindForRead(sourceKind.String)
row.FirstSeenAt = firstSeen.String
row.LatestSeenAt = latestSeen.String
row.CandidateSourceCode = candidateSourceCode.String
row.CandidatePlatformName = candidatePlatformName.String
row.MatchedSourceCodes = splitCommaList(matchedSourceCodes.String)
row.MatchedPlatformNames = splitCommaList(matchedPlatformNames.String)
row.SamplePhones = splitCommaList(samplePhones.String)
row.ConfiguredSourceCodeConflict = configuredSourceCodeConflict(row)
row.ConfiguredSourceCodePlatformName = configuredSourceCodePlatformName.String
row.SourcePlatformNameMismatch = sourcePlatformNameMismatch(row)
row.Reason, row.RecommendedOperatorAction = diagnoseDataSource(row)
row.SuggestedSourceKind, row.SuggestionConfidence, row.SuggestionReason = suggestSourceKind(row)
out = append(out, row)
}
return out, rows.Err()
}
func (r *DataSourceRepository) CountDiagnostics(ctx context.Context, query DataSourceDiagnosticsQuery) (int64, error) {
query = normalizeDataSourceDiagnosticsQuery(query)
sqlText, args := buildDataSourceDiagnosticsCountSQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return 0, err
}
defer rows.Close()
var total int64
if rows.Next() {
if err := rows.Scan(&total); err != nil {
return 0, err
}
}
return total, rows.Err()
}
func (r *DataSourceRepository) QueryJT808IdentityGaps(ctx context.Context, query JT808IdentityGapQuery) ([]JT808IdentityGapRow, error) {
query = normalizeJT808IdentityGapQuery(query)
sqlText, args := buildJT808IdentityGapSQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]JT808IdentityGapRow, 0)
for rows.Next() {
var row JT808IdentityGapRow
var deviceID, plate, vin, sourceIP, endpoint, sourceCode, platform, sourceKind sql.NullString
var firstRegistered, latestRegistered, latestAuthenticated, latestSeen scanDateTime
if err := rows.Scan(
&row.Phone,
&deviceID,
&plate,
&vin,
&sourceIP,
&endpoint,
&sourceCode,
&platform,
&sourceKind,
&firstRegistered,
&latestRegistered,
&latestAuthenticated,
&latestSeen,
&row.LatestSeenAgeSeconds,
); err != nil {
return nil, err
}
row.DeviceID = deviceID.String
row.Plate = plate.String
row.VIN = vin.String
row.SourceIP = sourceIP.String
row.SourceEndpoint = endpoint.String
row.SourceCode = sourceCode.String
row.PlatformName = platform.String
row.SourceKind = normalizeSourceKindForRead(sourceKind.String)
row.FirstRegisteredAt = firstRegistered.String
row.LatestRegisteredAt = latestRegistered.String
row.LatestAuthenticatedAt = latestAuthenticated.String
row.LatestSeenAt = latestSeen.String
row.Reason, row.RecommendedOperatorAction = diagnoseJT808IdentityGap(row)
row.RawFrameQueryPath = jt808IdentityGapRawFrameQueryPath(row)
row.DataSourceQueryPath = jt808IdentityGapDataSourceQueryPath(row)
out = append(out, row)
}
return out, rows.Err()
}
func (r *DataSourceRepository) CountJT808IdentityGaps(ctx context.Context, query JT808IdentityGapQuery) (int64, error) {
query = normalizeJT808IdentityGapQuery(query)
sqlText, args := buildJT808IdentityGapCountSQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return 0, err
}
defer rows.Close()
var total int64
if rows.Next() {
if err := rows.Scan(&total); err != nil {
return 0, err
}
}
return total, rows.Err()
}
func (r *DataSourceRepository) QueryJT808MappingGaps(ctx context.Context, query JT808MappingGapQuery) ([]JT808MappingGapRow, error) {
query = normalizeJT808MappingGapQuery(query)
sqlText, args := buildJT808MappingGapSQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]JT808MappingGapRow, 0)
for rows.Next() {
var row JT808MappingGapRow
var deviceID, plate, vin, sourceIP, endpoint, sourceCode, platform, sourceKind sql.NullString
var identifierVIN, identifierPlate, matchedSourceCodes, matchedPlatformNames sql.NullString
var latestSeen scanDateTime
if err := rows.Scan(
&row.Phone,
&deviceID,
&plate,
&vin,
&sourceIP,
&endpoint,
&sourceCode,
&platform,
&sourceKind,
&identifierVIN,
&identifierPlate,
&matchedSourceCodes,
&matchedPlatformNames,
&latestSeen,
&row.LatestSeenAgeSeconds,
); err != nil {
return nil, err
}
row.DeviceID = deviceID.String
row.Plate = plate.String
row.VIN = vin.String
row.SourceIP = sourceIP.String
row.SourceEndpoint = endpoint.String
row.SourceCode = sourceCode.String
row.PlatformName = platform.String
row.SourceKind = normalizeSourceKindForRead(sourceKind.String)
row.IdentifierVIN = identifierVIN.String
row.IdentifierPlate = identifierPlate.String
row.MatchedSourceCodes = splitCommaList(matchedSourceCodes.String)
row.MatchedPlatformNames = splitCommaList(matchedPlatformNames.String)
row.LatestSeenAt = latestSeen.String
annotateJT808MappingGap(&row)
out = append(out, row)
}
return out, rows.Err()
}
func (r *DataSourceRepository) CountJT808MappingGaps(ctx context.Context, query JT808MappingGapQuery) (int64, error) {
query = normalizeJT808MappingGapQuery(query)
sqlText, args := buildJT808MappingGapCountSQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return 0, err
}
defer rows.Close()
var total int64
if rows.Next() {
if err := rows.Scan(&total); err != nil {
return 0, err
}
}
return total, rows.Err()
}
func (r *DataSourceRepository) Update(ctx context.Context, id int64, update DataSourceUpdate) (bool, error) {
if id <= 0 {
return false, errors.New("id must be positive")
}
if err := validateDataSourceUpdate(update); err != nil {
return false, err
}
var sets []string
var args []any
if update.PlatformName != nil {
sets = append(sets, "platform_name = ?")
args = append(args, nullableTrimmedString(*update.PlatformName))
}
if update.SourceCode != nil {
sets = append(sets, "source_code = ?")
args = append(args, nullableTrimmedString(*update.SourceCode))
}
if update.SourceKind != nil {
sets = append(sets, "source_kind = ?")
args = append(args, normalizeSourceKindForWrite(*update.SourceKind))
}
if update.TrustPriority != nil {
sets = append(sets, "trust_priority = ?")
args = append(args, *update.TrustPriority)
}
if update.Enabled != nil {
sets = append(sets, "enabled = ?")
if *update.Enabled {
args = append(args, 1)
} else {
args = append(args, 0)
}
}
if update.Remark != nil {
sets = append(sets, "remark = ?")
args = append(args, nullableTrimmedString(*update.Remark))
}
if len(sets) == 0 {
return false, errors.New("no mutable fields provided")
}
exists, err := r.exists(ctx, id)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
args = append(args, id)
_, err = r.db.ExecContext(ctx, "UPDATE vehicle_data_source SET "+strings.Join(sets, ", ")+", updated_at = CURRENT_TIMESTAMP WHERE id = ?", args...)
return err == nil, err
}
func (r *DataSourceRepository) exists(ctx context.Context, id int64) (bool, error) {
rows, err := r.db.QueryContext(ctx, "SELECT 1 FROM vehicle_data_source WHERE id = ? LIMIT 1", id)
if err != nil {
return false, err
}
defer rows.Close()
return rows.Next(), rows.Err()
}
func normalizeDataSourceQuery(query DataSourceQuery) DataSourceQuery {
query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
query.SourceIP = strings.TrimSpace(query.SourceIP)
query.SourceCode = strings.TrimSpace(query.SourceCode)
query.SourceKind = normalizeSourceKindForQuery(query.SourceKind)
if query.Limit <= 0 {
query.Limit = 50
}
return query
}
func normalizeDataSourceDiagnosticsQuery(query DataSourceDiagnosticsQuery) DataSourceDiagnosticsQuery {
query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
if query.Protocol == "" {
query.Protocol = "JT808"
}
query.SourceIP = strings.TrimSpace(query.SourceIP)
query.SourceKind = normalizeSourceKindForQuery(query.SourceKind)
if query.Enabled == nil {
enabled := true
query.Enabled = &enabled
}
if query.Limit <= 0 {
query.Limit = 50
}
return query
}
func normalizeJT808IdentityGapQuery(query JT808IdentityGapQuery) JT808IdentityGapQuery {
query.SourceIP = strings.TrimSpace(query.SourceIP)
query.SourceCode = strings.TrimSpace(query.SourceCode)
if query.RecentSeconds < 0 {
query.RecentSeconds = 0
}
if query.Limit <= 0 {
query.Limit = 50
}
return query
}
func normalizeJT808MappingGapQuery(query JT808MappingGapQuery) JT808MappingGapQuery {
query.SourceIP = strings.TrimSpace(query.SourceIP)
query.SourceCode = strings.TrimSpace(query.SourceCode)
if query.RecentSeconds < 0 {
query.RecentSeconds = 0
}
if query.Limit <= 0 {
query.Limit = 50
}
return query
}
func buildDataSourceSQL(query DataSourceQuery) (string, []any) {
where, args := buildDataSourceWhere(query)
sqlText := `SELECT id, protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind,
trust_priority, enabled, first_seen_at, latest_seen_at, remark, updated_at
FROM vehicle_data_source`
if len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
sqlText += " ORDER BY latest_seen_at DESC, id ASC LIMIT ? OFFSET ?"
args = append(args, query.Limit, query.Offset)
return sqlText, args
}
func buildDataSourceCountSQL(query DataSourceQuery) (string, []any) {
where, args := buildDataSourceWhere(query)
sqlText := `SELECT COUNT(*) FROM vehicle_data_source`
if len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
return sqlText, args
}
func buildDataSourceDiagnosticsSQL(query DataSourceDiagnosticsQuery) (string, []any) {
where, args := buildDataSourceDiagnosticsWhere(query)
sqlText := `SELECT
ds.id,
ds.protocol,
ds.source_ip,
ds.latest_source_endpoint,
ds.platform_name,
ds.source_code,
ds.source_kind,
ds.first_seen_at,
ds.latest_seen_at,
COALESCE(TIMESTAMPDIFF(SECOND, ds.first_seen_at, ds.latest_seen_at), 0) AS active_span_seconds,
COALESCE(TIMESTAMPDIFF(SECOND, ds.latest_seen_at, CURRENT_TIMESTAMP), 0) AS latest_seen_age_seconds,
COUNT(r.phone) AS registration_rows,
COUNT(DISTINCT r.phone) AS phone_count,
SUM(CASE WHEN r.phone IS NOT NULL AND (r.vin IS NULL OR TRIM(r.vin) = '' OR LOWER(TRIM(r.vin)) = 'unknown') THEN 1 ELSE 0 END) AS unknown_vin_rows,
COUNT(DISTINCT CASE WHEN vi.identifier_value IS NOT NULL THEN r.phone END) AS identifier_matched_phones,
COUNT(DISTINCT CASE WHEN r.phone IS NOT NULL AND vi.identifier_value IS NULL THEN r.phone END) AS unmapped_phone_count,
COALESCE(COUNT(DISTINCT CASE WHEN vi.identifier_value IS NOT NULL THEN r.phone END) / NULLIF(COUNT(DISTINCT r.phone), 0), 0) AS identifier_match_ratio,
COUNT(DISTINCT CASE
WHEN ds.source_code IS NOT NULL AND TRIM(ds.source_code) <> ''
AND vi.source_code IS NOT NULL AND TRIM(vi.source_code) <> ''
AND TRIM(vi.source_code) = TRIM(ds.source_code)
THEN r.phone
END) AS configured_source_code_matched_phones,
MAX(configured_catalog.platform_name) AS configured_source_code_platform_name,
COUNT(DISTINCT NULLIF(TRIM(vi.source_code), '')) AS matched_source_code_count,
MIN(NULLIF(TRIM(vi.source_code), '')) AS candidate_source_code,
MIN(COALESCE(NULLIF(TRIM(vi.oem), ''), NULLIF(TRIM(vi.source_code), ''))) AS candidate_platform_name,
GROUP_CONCAT(DISTINCT NULLIF(TRIM(vi.source_code), '') ORDER BY NULLIF(TRIM(vi.source_code), '') SEPARATOR ',') AS matched_source_codes,
GROUP_CONCAT(DISTINCT COALESCE(NULLIF(TRIM(vi.oem), ''), NULLIF(TRIM(vi.source_code), '')) ORDER BY COALESCE(NULLIF(TRIM(vi.oem), ''), NULLIF(TRIM(vi.source_code), '')) SEPARATOR ',') AS matched_platform_names,
SUBSTRING_INDEX(GROUP_CONCAT(DISTINCT r.phone ORDER BY r.latest_seen_at DESC SEPARATOR ','), ',', 5) AS sample_phones
FROM vehicle_data_source ds
LEFT JOIN jt808_registration r
ON ds.protocol = 'JT808'
AND r.source_ip = ds.source_ip
LEFT JOIN vehicle_identifier vi
ON vi.protocol = 'JT808'
AND vi.identifier_type = 'JT808_PHONE'
AND vi.identifier_value = r.phone
AND vi.enabled = 1
LEFT JOIN (
SELECT
source_code,
MIN(COALESCE(NULLIF(TRIM(oem), ''), NULLIF(TRIM(source_code), ''))) AS platform_name
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND enabled = 1
AND source_code IS NOT NULL
AND TRIM(source_code) <> ''
GROUP BY source_code
HAVING COUNT(DISTINCT COALESCE(NULLIF(TRIM(oem), ''), NULLIF(TRIM(source_code), ''))) = 1
) configured_catalog
ON configured_catalog.source_code = ds.source_code`
if len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
sqlText += ` GROUP BY ds.id, ds.protocol, ds.source_ip, ds.latest_source_endpoint, ds.platform_name, ds.source_code, ds.source_kind, ds.first_seen_at, ds.latest_seen_at
`
if having := buildDataSourceDiagnosticsHaving(query); len(having) > 0 {
sqlText += "HAVING " + strings.Join(having, " AND ") + "\n"
}
sqlText += `
ORDER BY ds.latest_seen_at DESC, ds.id ASC LIMIT ? OFFSET ?`
args = append(args, query.Limit, query.Offset)
return sqlText, args
}
func buildDataSourceDiagnosticsCountSQL(query DataSourceDiagnosticsQuery) (string, []any) {
where, args := buildDataSourceDiagnosticsWhere(query)
having := buildDataSourceDiagnosticsHaving(query)
if len(having) > 0 {
sqlText := `SELECT COUNT(*)
FROM (
SELECT ds.id,
MAX(ds.platform_name) AS platform_name,
ds.source_code AS source_code,
COUNT(DISTINCT r.phone) AS phone_count,
COUNT(DISTINCT CASE WHEN vi.identifier_value IS NOT NULL THEN r.phone END) AS identifier_matched_phones,
COUNT(DISTINCT CASE WHEN r.phone IS NOT NULL AND vi.identifier_value IS NULL THEN r.phone END) AS unmapped_phone_count,
COALESCE(COUNT(DISTINCT CASE WHEN vi.identifier_value IS NOT NULL THEN r.phone END) / NULLIF(COUNT(DISTINCT r.phone), 0), 0) AS identifier_match_ratio,
COUNT(DISTINCT NULLIF(TRIM(vi.source_code), '')) AS matched_source_code_count,
COUNT(DISTINCT CASE
WHEN ds.source_code IS NOT NULL AND TRIM(ds.source_code) <> ''
AND vi.source_code IS NOT NULL AND TRIM(vi.source_code) <> ''
AND TRIM(vi.source_code) = TRIM(ds.source_code)
THEN r.phone
END) AS configured_source_code_matched_phones,
MAX(configured_catalog.platform_name) AS configured_source_code_platform_name
FROM vehicle_data_source ds
LEFT JOIN jt808_registration r
ON ds.protocol = 'JT808'
AND r.source_ip = ds.source_ip
LEFT JOIN vehicle_identifier vi
ON vi.protocol = 'JT808'
AND vi.identifier_type = 'JT808_PHONE'
AND vi.identifier_value = r.phone
AND vi.enabled = 1
LEFT JOIN (
SELECT
source_code,
MIN(COALESCE(NULLIF(TRIM(oem), ''), NULLIF(TRIM(source_code), ''))) AS platform_name
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND enabled = 1
AND source_code IS NOT NULL
AND TRIM(source_code) <> ''
GROUP BY source_code
HAVING COUNT(DISTINCT COALESCE(NULLIF(TRIM(oem), ''), NULLIF(TRIM(source_code), ''))) = 1
) configured_catalog
ON configured_catalog.source_code = ds.source_code`
if len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
sqlText += ` GROUP BY ds.id, ds.source_code
HAVING ` + strings.Join(having, " AND ") + `
) diagnostics`
return sqlText, args
}
sqlText := `SELECT COUNT(*) FROM vehicle_data_source ds`
if len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
return sqlText, args
}
func buildJT808IdentityGapSQL(query JT808IdentityGapQuery) (string, []any) {
fromSQL, args := buildJT808IdentityGapFromSQL(query)
sqlText := `SELECT
r.phone,
r.device_id,
r.plate,
r.vin,
r.source_ip,
r.source_endpoint,
ds.source_code,
ds.platform_name,
ds.source_kind,
r.first_registered_at,
r.latest_registered_at,
r.latest_authenticated_at,
r.latest_seen_at,
COALESCE(TIMESTAMPDIFF(SECOND, r.latest_seen_at, CURRENT_TIMESTAMP), 0) AS latest_seen_age_seconds
` + fromSQL + `
ORDER BY r.latest_seen_at DESC, r.phone ASC
LIMIT ? OFFSET ?`
args = append(args, query.Limit, query.Offset)
return sqlText, args
}
func buildJT808IdentityGapCountSQL(query JT808IdentityGapQuery) (string, []any) {
fromSQL, args := buildJT808IdentityGapFromSQL(query)
return "SELECT COUNT(*) " + fromSQL, args
}
func buildJT808MappingGapSQL(query JT808MappingGapQuery) (string, []any) {
fromSQL, args := buildJT808MappingGapFromSQL(query)
sqlText := `SELECT
r.phone,
r.device_id,
r.plate,
r.vin,
r.source_ip,
r.source_endpoint,
ds.source_code,
ds.platform_name,
ds.source_kind,
source_vi.vin AS identifier_vin,
source_vi.plate AS identifier_plate,
all_vi.matched_source_codes,
all_vi.matched_platform_names,
r.latest_seen_at,
COALESCE(TIMESTAMPDIFF(SECOND, r.latest_seen_at, CURRENT_TIMESTAMP), 0) AS latest_seen_age_seconds
` + fromSQL + `
ORDER BY r.latest_seen_at DESC, r.phone ASC
LIMIT ? OFFSET ?`
args = append(args, query.Limit, query.Offset)
return sqlText, args
}
func buildJT808MappingGapCountSQL(query JT808MappingGapQuery) (string, []any) {
fromSQL, args := buildJT808MappingGapFromSQL(query)
return "SELECT COUNT(*) " + fromSQL, args
}
func buildJT808IdentityGapFromSQL(query JT808IdentityGapQuery) (string, []any) {
where, args := buildJT808IdentityGapWhere(query)
sqlText := `FROM jt808_registration r
LEFT JOIN vehicle_data_source ds
ON ds.protocol = 'JT808' AND ds.source_ip = r.source_ip
LEFT JOIN (
SELECT identifier_value, COUNT(*) AS match_count
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND identifier_type = 'JT808_PHONE'
AND enabled = 1
AND vin IS NOT NULL AND TRIM(vin) <> ''
GROUP BY identifier_value
) phone_vi ON phone_vi.identifier_value = r.phone
LEFT JOIN (
SELECT identifier_value, COUNT(*) AS match_count
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND identifier_type = 'PLATE'
AND enabled = 1
AND vin IS NOT NULL AND TRIM(vin) <> ''
GROUP BY identifier_value
) plate_vi ON plate_vi.identifier_value = r.plate
LEFT JOIN vehicle_identity_binding phone_binding
ON phone_binding.phone = r.phone
AND phone_binding.vin IS NOT NULL AND TRIM(phone_binding.vin) <> ''
LEFT JOIN vehicle_identity_binding plate_binding
ON plate_binding.plate = r.plate
AND plate_binding.vin IS NOT NULL AND TRIM(plate_binding.vin) <> ''`
if len(where) > 0 {
sqlText += "\nWHERE " + strings.Join(where, " AND ")
}
return sqlText, args
}
func buildJT808MappingGapFromSQL(query JT808MappingGapQuery) (string, []any) {
where, args := buildJT808MappingGapWhere(query)
sqlText := `FROM jt808_registration r
JOIN vehicle_data_source ds
ON ds.protocol = 'JT808'
AND ds.source_ip = r.source_ip
AND ds.enabled = 1
AND ds.source_code IS NOT NULL
AND TRIM(ds.source_code) <> ''
LEFT JOIN vehicle_identifier source_vi
ON source_vi.protocol = 'JT808'
AND source_vi.source_code = ds.source_code
AND source_vi.identifier_type = 'JT808_PHONE'
AND source_vi.identifier_value = r.phone
AND source_vi.enabled = 1
LEFT JOIN (
SELECT
identifier_value,
GROUP_CONCAT(DISTINCT NULLIF(TRIM(source_code), '') ORDER BY NULLIF(TRIM(source_code), '') SEPARATOR ',') AS matched_source_codes,
GROUP_CONCAT(DISTINCT COALESCE(NULLIF(TRIM(oem), ''), NULLIF(TRIM(source_code), '')) ORDER BY COALESCE(NULLIF(TRIM(oem), ''), NULLIF(TRIM(source_code), '')) SEPARATOR ',') AS matched_platform_names
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND identifier_type = 'JT808_PHONE'
AND enabled = 1
GROUP BY identifier_value
) all_vi
ON all_vi.identifier_value = r.phone`
if len(where) > 0 {
sqlText += "\nWHERE " + strings.Join(where, " AND ")
}
return sqlText, args
}
func buildJT808IdentityGapWhere(query JT808IdentityGapQuery) ([]string, []any) {
where := []string{
"r.phone IS NOT NULL AND TRIM(r.phone) <> ''",
"(r.vin IS NULL OR TRIM(r.vin) = '' OR LOWER(TRIM(r.vin)) = 'unknown')",
"COALESCE(phone_vi.match_count, 0) = 0",
"COALESCE(plate_vi.match_count, 0) = 0",
"phone_binding.vin IS NULL",
"plate_binding.vin IS NULL",
}
var args []any
if query.SourceIP != "" {
where = append(where, "r.source_ip = ?")
args = append(args, query.SourceIP)
}
if query.SourceCode != "" {
where = append(where, "ds.source_code = ?")
args = append(args, query.SourceCode)
}
if query.RecentSeconds > 0 {
where = append(where, "r.latest_seen_at >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? SECOND)")
args = append(args, query.RecentSeconds)
}
return where, args
}
func buildJT808MappingGapWhere(query JT808MappingGapQuery) ([]string, []any) {
where := []string{
"r.phone IS NOT NULL AND TRIM(r.phone) <> ''",
"(source_vi.identifier_value IS NULL OR source_vi.vin IS NULL OR TRIM(source_vi.vin) = '')",
}
var args []any
if query.SourceIP != "" {
where = append(where, "r.source_ip = ?")
args = append(args, query.SourceIP)
}
if query.SourceCode != "" {
where = append(where, "ds.source_code = ?")
args = append(args, query.SourceCode)
}
if query.RecentSeconds > 0 {
where = append(where, "r.latest_seen_at >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? SECOND)")
args = append(args, query.RecentSeconds)
}
return where, args
}
func buildDataSourceWhere(query DataSourceQuery) ([]string, []any) {
var where []string
var args []any
add := func(clause string, value any) {
where = append(where, clause)
args = append(args, value)
}
if query.Protocol != "" {
add("protocol = ?", query.Protocol)
}
if query.SourceIP != "" {
add("source_ip = ?", query.SourceIP)
}
if query.SourceCode != "" {
add("source_code = ?", query.SourceCode)
}
if query.SourceKind != "" {
add("source_kind = ?", query.SourceKind)
}
if query.SourceCodeMissing != nil {
if *query.SourceCodeMissing {
where = append(where, "(source_code IS NULL OR TRIM(source_code) = '')")
} else {
where = append(where, "(source_code IS NOT NULL AND TRIM(source_code) <> '')")
}
}
if query.Enabled != nil {
if *query.Enabled {
add("enabled = ?", 1)
} else {
add("enabled = ?", 0)
}
}
return where, args
}
func buildDataSourceDiagnosticsWhere(query DataSourceDiagnosticsQuery) ([]string, []any) {
var where []string
var args []any
add := func(clause string, value any) {
where = append(where, clause)
args = append(args, value)
}
if query.Protocol != "" {
add("ds.protocol = ?", query.Protocol)
}
if query.SourceIP != "" {
add("ds.source_ip = ?", query.SourceIP)
}
if query.SourceKind != "" {
add("ds.source_kind = ?", query.SourceKind)
}
if query.SourceCodeMissing != nil {
if *query.SourceCodeMissing {
where = append(where, "(ds.source_code IS NULL OR TRIM(ds.source_code) = '')")
} else {
where = append(where, "(ds.source_code IS NOT NULL AND TRIM(ds.source_code) <> '')")
}
}
if query.Enabled != nil {
if *query.Enabled {
add("ds.enabled = ?", 1)
} else {
add("ds.enabled = ?", 0)
}
}
return where, args
}
func buildDataSourceDiagnosticsHaving(query DataSourceDiagnosticsQuery) []string {
if !query.MappingIssueOnly {
return nil
}
return []string{`(
(
platform_name IS NOT NULL AND TRIM(platform_name) <> ''
AND configured_source_code_platform_name IS NOT NULL AND TRIM(configured_source_code_platform_name) <> ''
AND TRIM(platform_name) <> TRIM(configured_source_code_platform_name)
)
OR (
source_code IS NOT NULL AND TRIM(source_code) <> ''
AND matched_source_code_count > 0
AND configured_source_code_matched_phones = 0
)
OR (
source_code IS NOT NULL AND TRIM(source_code) <> ''
AND phone_count >= 10
AND unmapped_phone_count > 0
AND identifier_match_ratio < 0.8
)
)`}
}
type DataSourceHandler struct {
repository *DataSourceRepository
}
func NewDataSourceHandler(repository *DataSourceRepository) *DataSourceHandler {
if repository == nil {
panic("data source repository must not be nil")
}
return &DataSourceHandler{repository: repository}
}
func (h *DataSourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.Trim(r.URL.Path, "/")
switch {
case r.Method == http.MethodGet && path == "api/stats/data-sources/diagnostics":
h.handleDiagnostics(w, r)
case r.Method == http.MethodGet && path == "api/stats/data-sources/kind-suggestions":
h.handleKindSuggestions(w, r)
case r.Method == http.MethodGet && path == "api/stats/data-sources/jt808-identity-gaps":
h.handleJT808IdentityGaps(w, r)
case r.Method == http.MethodGet && path == "api/stats/data-sources/jt808-mapping-gaps":
h.handleJT808MappingGaps(w, r)
case r.Method == http.MethodGet && path == "api/stats/data-sources":
h.handleQuery(w, r)
case r.Method == http.MethodPatch && strings.HasPrefix(path, "api/stats/data-sources/"):
h.handlePatch(w, r)
default:
writeMetricError(w, http.StatusNotFound, "route not found")
}
}
func (h *DataSourceHandler) handleKindSuggestions(w http.ResponseWriter, r *http.Request) {
query, err := parseDataSourceKindSuggestionsQuery(r)
if err != nil {
writeMetricError(w, http.StatusBadRequest, err.Error())
return
}
var total int64
if query.IncludeTotal {
total, err = h.repository.CountDiagnostics(r.Context(), query)
if err != nil {
writeMetricError(w, http.StatusInternalServerError, err.Error())
return
}
}
rows, err := h.repository.QueryDiagnostics(r.Context(), query)
if err != nil {
writeMetricError(w, http.StatusInternalServerError, err.Error())
return
}
if !query.IncludeTotal {
total = int64(len(rows))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": rows,
"total": total,
"limit": query.Limit,
"offset": query.Offset,
})
}
func (h *DataSourceHandler) handleDiagnostics(w http.ResponseWriter, r *http.Request) {
query, err := parseDataSourceDiagnosticsQuery(r)
if err != nil {
writeMetricError(w, http.StatusBadRequest, err.Error())
return
}
var total int64
if query.IncludeTotal {
total, err = h.repository.CountDiagnostics(r.Context(), query)
if err != nil {
writeMetricError(w, http.StatusInternalServerError, err.Error())
return
}
}
rows, err := h.repository.QueryDiagnostics(r.Context(), query)
if err != nil {
writeMetricError(w, http.StatusInternalServerError, err.Error())
return
}
if !query.IncludeTotal {
total = int64(len(rows))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": rows,
"total": total,
"limit": query.Limit,
"offset": query.Offset,
})
}
func (h *DataSourceHandler) handleJT808IdentityGaps(w http.ResponseWriter, r *http.Request) {
query, err := parseJT808IdentityGapQuery(r)
if err != nil {
writeMetricError(w, http.StatusBadRequest, err.Error())
return
}
var total int64
if query.IncludeTotal {
total, err = h.repository.CountJT808IdentityGaps(r.Context(), query)
if err != nil {
writeMetricError(w, http.StatusInternalServerError, err.Error())
return
}
}
rows, err := h.repository.QueryJT808IdentityGaps(r.Context(), query)
if err != nil {
writeMetricError(w, http.StatusInternalServerError, err.Error())
return
}
if !query.IncludeTotal {
total = int64(len(rows))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": rows,
"total": total,
"limit": query.Limit,
"offset": query.Offset,
"recentSeconds": query.RecentSeconds,
})
}
func (h *DataSourceHandler) handleJT808MappingGaps(w http.ResponseWriter, r *http.Request) {
query, err := parseJT808MappingGapQuery(r)
if err != nil {
writeMetricError(w, http.StatusBadRequest, err.Error())
return
}
var total int64
if query.IncludeTotal {
total, err = h.repository.CountJT808MappingGaps(r.Context(), query)
if err != nil {
writeMetricError(w, http.StatusInternalServerError, err.Error())
return
}
}
rows, err := h.repository.QueryJT808MappingGaps(r.Context(), query)
if err != nil {
writeMetricError(w, http.StatusInternalServerError, err.Error())
return
}
if !query.IncludeTotal {
total = int64(len(rows))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": rows,
"total": total,
"limit": query.Limit,
"offset": query.Offset,
"recentSeconds": query.RecentSeconds,
})
}
func (h *DataSourceHandler) handleQuery(w http.ResponseWriter, r *http.Request) {
query, err := parseDataSourceQuery(r)
if err != nil {
writeMetricError(w, http.StatusBadRequest, err.Error())
return
}
var total int64
if query.IncludeTotal {
total, err = h.repository.Count(r.Context(), query)
if err != nil {
writeMetricError(w, http.StatusInternalServerError, err.Error())
return
}
}
rows, err := h.repository.Query(r.Context(), query)
if err != nil {
writeMetricError(w, http.StatusInternalServerError, err.Error())
return
}
if !query.IncludeTotal {
total = int64(len(rows))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": rows,
"total": total,
"limit": query.Limit,
"offset": query.Offset,
})
}
func (h *DataSourceHandler) handlePatch(w http.ResponseWriter, r *http.Request) {
id, err := parseDataSourceID(strings.TrimPrefix(strings.Trim(r.URL.Path, "/"), "api/stats/data-sources/"))
if err != nil {
writeMetricError(w, http.StatusBadRequest, err.Error())
return
}
defer r.Body.Close()
var update DataSourceUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
writeMetricError(w, http.StatusBadRequest, "invalid json body")
return
}
updated, err := h.repository.Update(r.Context(), id, update)
if err != nil {
writeMetricError(w, http.StatusBadRequest, err.Error())
return
}
if !updated {
writeMetricError(w, http.StatusNotFound, "data source not found")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"updated": true, "id": id})
}
func parseDataSourceQuery(r *http.Request) (DataSourceQuery, error) {
values := r.URL.Query()
limit, err := parseBoundedInt(values.Get("limit"), 50, 1, 1000, "limit")
if err != nil {
return DataSourceQuery{}, err
}
offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
if err != nil {
return DataSourceQuery{}, err
}
enabled, err := parseOptionalBool(values.Get("enabled"))
if err != nil {
return DataSourceQuery{}, err
}
sourceCodeMissing, err := parseOptionalBool(values.Get("sourceCodeMissing"))
if err != nil {
return DataSourceQuery{}, errors.New("sourceCodeMissing must be true or false")
}
if strings.TrimSpace(values.Get("sourceCode")) != "" && sourceCodeMissing != nil {
return DataSourceQuery{}, errors.New("sourceCode and sourceCodeMissing cannot be used together")
}
if strings.TrimSpace(values.Get("sourceKind")) != "" {
if _, err := parseSourceKind(values.Get("sourceKind")); err != nil {
return DataSourceQuery{}, err
}
}
return normalizeDataSourceQuery(DataSourceQuery{
Protocol: values.Get("protocol"),
SourceIP: values.Get("sourceIP"),
SourceCode: values.Get("sourceCode"),
SourceKind: values.Get("sourceKind"),
SourceCodeMissing: sourceCodeMissing,
Enabled: enabled,
IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
Limit: limit,
Offset: offset,
}), nil
}
func parseDataSourceDiagnosticsQuery(r *http.Request) (DataSourceDiagnosticsQuery, error) {
values := r.URL.Query()
limit, err := parseBoundedInt(values.Get("limit"), 50, 1, 1000, "limit")
if err != nil {
return DataSourceDiagnosticsQuery{}, err
}
offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
if err != nil {
return DataSourceDiagnosticsQuery{}, err
}
sourceCodeMissing, err := parseOptionalBool(values.Get("sourceCodeMissing"))
if err != nil {
return DataSourceDiagnosticsQuery{}, errors.New("sourceCodeMissing must be true or false")
}
mappingIssueOnly, err := parseOptionalBool(values.Get("mappingIssueOnly"))
if err != nil {
return DataSourceDiagnosticsQuery{}, errors.New("mappingIssueOnly must be true or false")
}
enabled, err := parseOptionalBool(values.Get("enabled"))
if err != nil {
return DataSourceDiagnosticsQuery{}, err
}
if sourceCodeMissing == nil {
missing := true
sourceCodeMissing = &missing
}
query := normalizeDataSourceDiagnosticsQuery(DataSourceDiagnosticsQuery{
Protocol: values.Get("protocol"),
SourceIP: values.Get("sourceIP"),
SourceCodeMissing: sourceCodeMissing,
MappingIssueOnly: mappingIssueOnly != nil && *mappingIssueOnly,
Enabled: enabled,
IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
Limit: limit,
Offset: offset,
})
return query, nil
}
func parseDataSourceKindSuggestionsQuery(r *http.Request) (DataSourceDiagnosticsQuery, error) {
values := r.URL.Query()
limit, err := parseBoundedInt(values.Get("limit"), 50, 1, 1000, "limit")
if err != nil {
return DataSourceDiagnosticsQuery{}, err
}
offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
if err != nil {
return DataSourceDiagnosticsQuery{}, err
}
sourceKind := strings.TrimSpace(values.Get("sourceKind"))
if sourceKind == "" {
sourceKind = "UNKNOWN"
}
if _, err := parseSourceKind(sourceKind); err != nil {
return DataSourceDiagnosticsQuery{}, err
}
sourceCodeMissing, err := parseOptionalBool(values.Get("sourceCodeMissing"))
if err != nil {
return DataSourceDiagnosticsQuery{}, errors.New("sourceCodeMissing must be true or false")
}
enabled, err := parseOptionalBool(values.Get("enabled"))
if err != nil {
return DataSourceDiagnosticsQuery{}, err
}
query := normalizeDataSourceDiagnosticsQuery(DataSourceDiagnosticsQuery{
Protocol: values.Get("protocol"),
SourceIP: values.Get("sourceIP"),
SourceKind: sourceKind,
SourceCodeMissing: sourceCodeMissing,
Enabled: enabled,
IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
Limit: limit,
Offset: offset,
})
return query, nil
}
func parseJT808IdentityGapQuery(r *http.Request) (JT808IdentityGapQuery, error) {
values := r.URL.Query()
limit, err := parseBoundedInt(values.Get("limit"), 50, 1, 1000, "limit")
if err != nil {
return JT808IdentityGapQuery{}, err
}
offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
if err != nil {
return JT808IdentityGapQuery{}, err
}
recentSeconds, err := parseBoundedInt(values.Get("recentSeconds"), 86400, 0, 30*86400, "recentSeconds")
if err != nil {
return JT808IdentityGapQuery{}, err
}
sourceCode := strings.TrimSpace(values.Get("sourceCode"))
if len(sourceCode) > 64 || !sourceCodePattern.MatchString(sourceCode) {
return JT808IdentityGapQuery{}, errors.New("sourceCode may contain only letters, digits, underscore, dot and dash")
}
return normalizeJT808IdentityGapQuery(JT808IdentityGapQuery{
SourceIP: values.Get("sourceIP"),
SourceCode: sourceCode,
RecentSeconds: int64(recentSeconds),
IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
Limit: limit,
Offset: offset,
}), nil
}
func parseJT808MappingGapQuery(r *http.Request) (JT808MappingGapQuery, error) {
values := r.URL.Query()
limit, err := parseBoundedInt(values.Get("limit"), 50, 1, 1000, "limit")
if err != nil {
return JT808MappingGapQuery{}, err
}
offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
if err != nil {
return JT808MappingGapQuery{}, err
}
recentSeconds, err := parseBoundedInt(values.Get("recentSeconds"), 86400, 0, 30*86400, "recentSeconds")
if err != nil {
return JT808MappingGapQuery{}, err
}
sourceCode := strings.TrimSpace(values.Get("sourceCode"))
if len(sourceCode) > 64 || !sourceCodePattern.MatchString(sourceCode) {
return JT808MappingGapQuery{}, errors.New("sourceCode may contain only letters, digits, underscore, dot and dash")
}
return normalizeJT808MappingGapQuery(JT808MappingGapQuery{
SourceIP: values.Get("sourceIP"),
SourceCode: sourceCode,
RecentSeconds: int64(recentSeconds),
IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
Limit: limit,
Offset: offset,
}), nil
}
func parseOptionalBool(value string) (*bool, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
switch strings.ToLower(value) {
case "true", "1", "yes":
enabled := true
return &enabled, nil
case "false", "0", "no":
enabled := false
return &enabled, nil
default:
return nil, errors.New("enabled must be true or false")
}
}
func parseDataSourceID(value string) (int64, error) {
value = strings.Trim(strings.TrimSpace(value), "/")
if value == "" || strings.Contains(value, "/") {
return 0, errors.New("data source id is required")
}
id, err := strconv.ParseInt(value, 10, 64)
if err != nil || id <= 0 {
return 0, errors.New("data source id must be positive")
}
return id, nil
}
var sourceCodePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]*$`)
func validateDataSourceUpdate(update DataSourceUpdate) error {
if update.PlatformName != nil && len([]rune(strings.TrimSpace(*update.PlatformName))) > 128 {
return errors.New("platform_name length exceeds 128")
}
if update.SourceCode != nil {
sourceCode := strings.TrimSpace(*update.SourceCode)
if len(sourceCode) > 64 {
return errors.New("source_code length exceeds 64")
}
if !sourceCodePattern.MatchString(sourceCode) {
return errors.New("source_code may only contain letters, numbers, dot, underscore or dash")
}
}
if update.SourceKind != nil {
if _, err := parseSourceKind(*update.SourceKind); err != nil {
return err
}
}
if update.TrustPriority != nil && (*update.TrustPriority < 0 || *update.TrustPriority > 100000) {
return errors.New("trust_priority must be between 0 and 100000")
}
if update.Remark != nil && len([]rune(strings.TrimSpace(*update.Remark))) > 512 {
return errors.New("remark length exceeds 512")
}
return nil
}
func parseSourceKind(value string) (string, error) {
kind := normalizeSourceKindForRead(value)
switch kind {
case "UNKNOWN", "PLATFORM", "DIRECT":
return kind, nil
default:
return "", errors.New("source_kind must be UNKNOWN, PLATFORM or DIRECT")
}
}
func normalizeSourceKindForRead(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
return "UNKNOWN"
}
return value
}
func normalizeSourceKindForQuery(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func normalizeSourceKindForWrite(value string) string {
kind, err := parseSourceKind(value)
if err != nil {
return "UNKNOWN"
}
return kind
}
func nullableTrimmedString(value string) any {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return value
}
func splitCommaList(value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func diagnoseDataSource(row DataSourceDiagnosticRow) (string, string) {
if strings.ToUpper(strings.TrimSpace(row.Protocol)) != "JT808" {
if strings.TrimSpace(row.SourceCode) == "" {
return "source_code_missing", "补充来源平台名称和 source_code用于多源统计选举、断链告警和大屏展示"
}
return "source_configured", "该来源已具备基础平台标识;如参与统计选举,请确认 source_kind、trust_priority 和 enabled"
}
switch {
case row.RegistrationRows == 0:
return "no_registration", "等待该来源产生 JT808 注册/鉴权/位置记录,或核对 source_endpoint 是否被正确写入 jt808_registration"
case lowIdentifierCoverage(row):
return "low_identifier_coverage", "该来源已配置平台,但大量注册手机号未维护到 vehicle_identifier会影响 VIN 解析、在线统计和里程来源选举"
case row.SourcePlatformNameMismatch:
return "source_platform_name_mismatch", "当前 source_code 在 vehicle_identifier 中对应的平台名与 vehicle_data_source.platform_name 不一致;先确认并修正来源配置,再导入手机号映射"
case row.IdentifierMatchedPhones == 0:
return "no_identifier_match", "补充该来源手机号到 vehicle_identifier或先导入对应平台的 808 车牌/手机号映射文件"
case row.ConfiguredSourceCodeConflict:
return "source_code_conflict", "已配置的 source_code 与该来源手机号绑定推断出的 source_code 不一致,需确认来源平台或修正 vehicle_identifier"
case strings.TrimSpace(row.SourceCode) != "":
return "source_configured", "该来源已具备基础平台标识;继续维护缺失手机号可提升 VIN 解析和统计覆盖率"
case row.MatchedSourceCodeCount > 1:
return "ambiguous_source_code", "同一来源 IP 匹配到多个 source_code需人工确认平台并在 vehicle_data_source 固定 source_code"
case row.CandidateSourceCode != "":
return "candidate_available", "可用候选 source_code执行 identity-import -sync-data-sources -apply 或在来源管理中确认"
default:
return "no_source_code", "已匹配到标识但没有 source_code补齐 vehicle_identifier.source_code 后重新同步"
}
}
func diagnoseJT808IdentityGap(row JT808IdentityGapRow) (string, string) {
if strings.TrimSpace(row.Plate) != "" {
return "missing_phone_and_plate_binding", "将该 phone 或 plate 维护到 vehicle_identifier并确认 VIN、source_code、oem若仍使用旧表也同步 vehicle_identity_binding"
}
return "missing_phone_binding", "将该 phone 维护到 vehicle_identifier并确认 VIN、source_code、oem如终端会补发注册帧可等待 plate 后再复核"
}
func jt808IdentityGapRawFrameQueryPath(row JT808IdentityGapRow) string {
if strings.TrimSpace(row.Phone) == "" {
return ""
}
dateFrom, dateTo := jt808IdentityGapDateWindow(row.LatestSeenAt)
values := url.Values{}
values.Set("protocol", "JT808")
values.Set("phone", row.Phone)
values.Set("dateFrom", dateFrom)
values.Set("dateTo", dateTo)
values.Set("orderBy", "eventTime")
values.Set("includeFields", "true")
values.Set("limit", "20")
return "/api/history/raw-frames?" + values.Encode()
}
func jt808IdentityGapDataSourceQueryPath(row JT808IdentityGapRow) string {
if strings.TrimSpace(row.SourceIP) == "" {
return ""
}
values := url.Values{}
values.Set("protocol", "JT808")
values.Set("sourceIP", row.SourceIP)
values.Set("includeTotal", "true")
values.Set("limit", "20")
return "/api/stats/data-sources?" + values.Encode()
}
func annotateJT808MappingGap(row *JT808MappingGapRow) {
if row == nil {
return
}
row.SuggestedSourceCode = strings.TrimSpace(row.SourceCode)
row.SuggestedPlatformName = strings.TrimSpace(firstNonEmpty(row.PlatformName, row.SourceCode))
row.SuggestedIdentifierType = "JT808_PHONE"
row.SuggestedIdentifierValue = strings.TrimSpace(row.Phone)
row.SuggestedVIN = suggestedMappingVIN(*row)
row.SuggestedPlate = strings.TrimSpace(firstNonEmpty(row.Plate, row.IdentifierPlate))
row.Reason, row.RecommendedAction = diagnoseJT808MappingGap(*row)
row.RawFrameQueryPath = jt808MappingGapRawFrameQueryPath(*row)
row.DataSourceQueryPath = jt808MappingGapDataSourceQueryPath(*row)
row.VehicleIdentifierExample = vehicleIdentifierExample(*row)
}
func diagnoseJT808MappingGap(row JT808MappingGapRow) (string, string) {
if strings.TrimSpace(row.SourceCode) == "" {
return "source_code_missing", "先维护 vehicle_data_source.source_code再按来源平台导入 phone 到 VIN 的 vehicle_identifier 映射"
}
if strings.TrimSpace(row.IdentifierVIN) == "" && strings.TrimSpace(row.VIN) != "" && !strings.EqualFold(strings.TrimSpace(row.VIN), "unknown") {
return "missing_source_phone_identifier", "按当前来源 source_code 维护 JT808_PHONE 到 VIN 的映射;如平台名与 source_code 不一致,先修正来源配置"
}
if len(row.MatchedSourceCodes) > 0 {
return "source_identifier_mismatch", "该 phone 存在其他 source_code 映射,但当前来源缺少映射;确认平台归属后补当前 source_code 或修正来源配置"
}
return "missing_source_phone_identifier", "补充该 phone 在当前来源 source_code 下的 vehicle_identifier 映射"
}
func suggestedMappingVIN(row JT808MappingGapRow) string {
vin := strings.TrimSpace(row.VIN)
if vin != "" && !strings.EqualFold(vin, "unknown") {
return vin
}
return strings.TrimSpace(row.IdentifierVIN)
}
func jt808MappingGapRawFrameQueryPath(row JT808MappingGapRow) string {
if strings.TrimSpace(row.Phone) == "" {
return ""
}
dateFrom, dateTo := jt808IdentityGapDateWindow(row.LatestSeenAt)
values := url.Values{}
values.Set("protocol", "JT808")
values.Set("phone", row.Phone)
values.Set("dateFrom", dateFrom)
values.Set("dateTo", dateTo)
values.Set("orderBy", "eventTime")
values.Set("includeFields", "true")
values.Set("limit", "20")
return "/api/history/raw-frames?" + values.Encode()
}
func jt808MappingGapDataSourceQueryPath(row JT808MappingGapRow) string {
if strings.TrimSpace(row.SourceIP) == "" {
return ""
}
values := url.Values{}
values.Set("protocol", "JT808")
values.Set("sourceIP", row.SourceIP)
values.Set("includeTotal", "true")
values.Set("limit", "20")
return "/api/stats/data-sources?" + values.Encode()
}
func vehicleIdentifierExample(row JT808MappingGapRow) string {
sourceCode := strings.TrimSpace(row.SuggestedSourceCode)
phone := strings.TrimSpace(row.SuggestedIdentifierValue)
vin := strings.TrimSpace(row.SuggestedVIN)
if sourceCode == "" || phone == "" || vin == "" {
return ""
}
plate := strings.ReplaceAll(strings.TrimSpace(row.SuggestedPlate), "'", "''")
oem := strings.ReplaceAll(strings.TrimSpace(row.SuggestedPlatformName), "'", "''")
sourceCode = strings.ReplaceAll(sourceCode, "'", "''")
phone = strings.ReplaceAll(phone, "'", "''")
vin = strings.ReplaceAll(vin, "'", "''")
return fmt.Sprintf("protocol=JT808, source_code=%s, identifier_type=JT808_PHONE, identifier_value=%s, vin=%s, plate=%s, oem=%s", sourceCode, phone, vin, plate, oem)
}
func jt808IdentityGapDateWindow(value string) (string, string) {
value = strings.TrimSpace(value)
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
seenAt, err := time.ParseInLocation(layout, value, time.FixedZone("Asia/Shanghai", 8*3600))
if err == nil {
start := time.Date(seenAt.Year(), seenAt.Month(), seenAt.Day(), 0, 0, 0, 0, seenAt.Location())
return start.Format("2006-01-02 15:04:05"), start.AddDate(0, 0, 1).Format("2006-01-02 15:04:05")
}
}
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600))
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return start.Format("2006-01-02 15:04:05"), start.AddDate(0, 0, 1).Format("2006-01-02 15:04:05")
}
func suggestSourceKind(row DataSourceDiagnosticRow) (kind string, confidence string, reason string) {
current := normalizeSourceKindForRead(row.SourceKind)
if current != "UNKNOWN" {
return current, "HIGH", "already_classified"
}
if strings.ToUpper(strings.TrimSpace(row.Protocol)) != "JT808" &&
(strings.TrimSpace(row.SourceCode) != "" || strings.TrimSpace(row.PlatformName) != "") {
return "PLATFORM", "MEDIUM", "non_jt808_configured_source"
}
if row.Reason == "ambiguous_source_code" || row.MatchedSourceCodeCount > 1 {
return "UNKNOWN", "HIGH", "ambiguous_source_code_requires_manual_review"
}
if row.Reason == "no_identifier_match" {
return "UNKNOWN", "MEDIUM", "missing_vehicle_identifier_mapping"
}
if row.CandidateSourceCode != "" && row.MatchedSourceCodeCount == 1 {
if row.PhoneCount >= 5 || row.ActiveSpanSeconds >= 6*3600 {
return "PLATFORM", "HIGH", "single_source_code_with_many_phones_or_long_activity"
}
if row.PhoneCount <= 1 && row.ActiveSpanSeconds < 2*3600 && row.LatestSeenAgeSeconds >= 3600 {
return "DIRECT", "MEDIUM", "single_phone_short_lived_stale_source"
}
return "PLATFORM", "MEDIUM", "single_source_code_candidate"
}
if row.Reason == "no_registration" {
if strings.TrimSpace(row.SourceCode) != "" || strings.TrimSpace(row.PlatformName) != "" {
return "UNKNOWN", "MEDIUM", "manual_source_without_registration_evidence"
}
if row.LatestSeenAgeSeconds >= 24*3600 {
return "DIRECT", "LOW", "stale_unclassified_source_without_registration"
}
}
return "UNKNOWN", "LOW", "insufficient_evidence"
}
func configuredSourceCodeConflict(row DataSourceDiagnosticRow) bool {
sourceCode := strings.TrimSpace(row.SourceCode)
if sourceCode == "" || row.MatchedSourceCodeCount == 0 {
return false
}
for _, matched := range row.MatchedSourceCodes {
if strings.EqualFold(strings.TrimSpace(matched), sourceCode) {
return false
}
}
return true
}
func sourcePlatformNameMismatch(row DataSourceDiagnosticRow) bool {
platformName := strings.TrimSpace(row.PlatformName)
configuredPlatformName := strings.TrimSpace(row.ConfiguredSourceCodePlatformName)
if platformName == "" || configuredPlatformName == "" {
return false
}
return platformName != configuredPlatformName
}
func lowIdentifierCoverage(row DataSourceDiagnosticRow) bool {
if strings.ToUpper(strings.TrimSpace(row.Protocol)) != "JT808" {
return false
}
if strings.TrimSpace(row.SourceCode) == "" || row.PhoneCount < 10 {
return false
}
return row.UnmappedPhoneCount > 0 && row.IdentifierMatchRatio < 0.8
}
func (q DataSourceQuery) String() string {
enabled := "any"
if q.Enabled != nil {
enabled = fmt.Sprint(*q.Enabled)
}
sourceCodeMissing := "any"
if q.SourceCodeMissing != nil {
sourceCodeMissing = fmt.Sprint(*q.SourceCodeMissing)
}
return fmt.Sprintf("protocol=%s sourceIP=%s sourceCode=%s sourceKind=%s sourceCodeMissing=%s enabled=%s limit=%d offset=%d", q.Protocol, q.SourceIP, q.SourceCode, q.SourceKind, sourceCodeMissing, enabled, q.Limit, q.Offset)
}