1734 lines
60 KiB
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)
|
|
}
|