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

1734 lines
60 KiB
Go

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)
}