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