package realtime import ( "context" "encoding/json" "errors" "fmt" "sort" "strconv" "strings" "time" "github.com/redis/go-redis/v9" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) type Repository struct { client *redis.Client cfg Config } func NewRepository(client *redis.Client, cfg Config) *Repository { if client == nil { panic("redis client must not be nil") } return &Repository{client: client, cfg: cfg} } func (r *Repository) FastUpdate(ctx context.Context, env envelope.FrameEnvelope) error { if !envelope.IsRealtimeTelemetryFrame(env) { return nil } vin := strings.TrimSpace(env.VIN) if vin == "" { return nil } vehicleKey := strings.TrimSpace(env.VehicleKey()) if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") { return nil } return r.setFastProjection(ctx, vehicleKey, vin, env) } func (r *Repository) FastUpdateBatch(ctx context.Context, envs []envelope.FrameEnvelope) error { if len(envs) == 0 { return nil } pipe := r.client.Pipeline() queued := 0 for _, env := range envs { if !envelope.IsRealtimeTelemetryFrame(env) { continue } vin := strings.TrimSpace(env.VIN) if vin == "" { continue } vehicleKey := strings.TrimSpace(env.VehicleKey()) if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") { continue } if err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env); err != nil { return err } queued++ } if queued == 0 { return nil } _, err := pipe.Exec(ctx) return err } func (r *Repository) Update(ctx context.Context, env envelope.FrameEnvelope) error { if !envelope.IsRealtimeTelemetryFrame(env) { return nil } vin := strings.TrimSpace(env.VIN) if vin == "" { return nil } vehicleKey := strings.TrimSpace(env.VehicleKey()) if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") { return nil } nowMS := time.Now().UnixMilli() eventMS := env.EventTimeMS if eventMS <= 0 { eventMS = env.ReceivedAtMS } protocolSnapshot := Snapshot{ VehicleKey: vehicleKey, VIN: vin, Protocol: env.Protocol, EventID: env.StableEventID(), EventTimeMS: eventMS, ReceivedAtMS: env.ReceivedAtMS, Fields: cloneFields(env.Fields), FieldTimesMS: fieldTimes(env.Fields, eventMS), Parsed: cloneMap(env.Parsed), UpdatedAtMS: nowMS, } existingProtocol, err := r.GetProtocol(ctx, vehicleKey, env.Protocol) if err != nil && !errors.Is(err, redis.Nil) { return err } existingParsed, err := r.GetRealtimeRaw(ctx, vehicleKey, env.Protocol) if err != nil && !errors.Is(err, redis.Nil) { return err } if existingProtocol.VehicleKey != "" { protocolSnapshot = existingProtocol if protocolSnapshot.VIN == "" && vin != "" { protocolSnapshot.VIN = vin } mergeFields(&protocolSnapshot, env.Fields, eventMS) if eventMS >= protocolSnapshot.EventTimeMS { protocolSnapshot.EventTimeMS = eventMS protocolSnapshot.EventID = env.StableEventID() protocolSnapshot.ReceivedAtMS = env.ReceivedAtMS } protocolSnapshot.UpdatedAtMS = nowMS } protocolSnapshot.Parsed = mergeParsedForProtocol(env.Protocol, existingParsed, env.Parsed) if err := r.setJSON(ctx, protocolKey(vehicleKey, env.Protocol), protocolSnapshot.Lightweight(), r.cfg.ttl()); err != nil { return err } if err := r.setJSON(ctx, realtimeRawKey(vehicleKey, env.Protocol), protocolSnapshot.Parsed, r.cfg.ttl()); err != nil { return err } if err := r.setKV(ctx, vin, env, protocolSnapshot.Parsed); err != nil { return err } protocols, err := r.addProtocol(ctx, vehicleKey, env.Protocol) if err != nil { return err } merged, err := r.GetMerged(ctx, vehicleKey) if err != nil && !errors.Is(err, redis.Nil) { return err } if merged.VehicleKey == "" { merged = Snapshot{ VehicleKey: vehicleKey, VIN: vin, Fields: map[string]any{}, FieldTimesMS: map[string]int64{}, } } else if merged.VIN == "" && vin != "" { merged.VIN = vin } mergeFields(&merged, env.Fields, eventMS) if eventMS >= merged.EventTimeMS { merged.EventTimeMS = eventMS merged.EventID = env.StableEventID() merged.ReceivedAtMS = env.ReceivedAtMS } merged.Protocols = protocols merged.UpdatedAtMS = nowMS if err := r.setJSON(ctx, mergedKey(vehicleKey), merged, r.cfg.ttl()); err != nil { return err } online := OnlineStatus{ VehicleKey: vehicleKey, VIN: vin, Protocol: env.Protocol, Online: true, LastSeenMS: env.ReceivedAtMS, Protocols: protocols, TTLSeconds: int64(r.cfg.ttl().Seconds()), } if err := r.setOnlineStatus(ctx, online); err != nil { return err } return nil } func (r *Repository) GetMerged(ctx context.Context, vehicleKey string) (Snapshot, error) { return r.getSnapshot(ctx, mergedKey(vehicleKey)) } func (r *Repository) GetProtocol(ctx context.Context, vehicleKey string, protocol envelope.Protocol) (Snapshot, error) { return r.getSnapshot(ctx, protocolKey(vehicleKey, protocol)) } func (r *Repository) GetRealtimeRaw(ctx context.Context, vehicleKey string, protocol envelope.Protocol) (map[string]any, error) { payload, err := r.client.Get(ctx, realtimeRawKey(vehicleKey, protocol)).Bytes() if err != nil { return nil, err } var out map[string]any return out, json.Unmarshal(payload, &out) } func (r *Repository) IsOnline(ctx context.Context, vehicleKey string) (OnlineStatus, error) { vehicleKey = strings.TrimSpace(vehicleKey) best := OnlineStatus{VehicleKey: vehicleKey, VIN: vehicleKey, Online: false} for _, protocol := range knownRealtimeProtocols() { key := onlineKey(protocol, vehicleKey) payload, err := r.client.Get(ctx, key).Bytes() if err != nil { if errors.Is(err, redis.Nil) { continue } return OnlineStatus{}, err } var status OnlineStatus if err := json.Unmarshal(payload, &status); err != nil { return OnlineStatus{}, err } ttl := r.client.TTL(ctx, key).Val() status.Online = ttl > 0 status.TTLSeconds = int64(ttl.Seconds()) if status.Protocol == "" { status.Protocol = protocol } if status.LastSeenMS >= best.LastSeenMS { best = status } } return best, nil } func (r *Repository) ListOnline(ctx context.Context, query OnlineListQuery) ([]OnlineStatus, int64, error) { query = normalizeOnlineListQuery(query) total, err := r.onlineTotal(ctx, query.Protocol) if err != nil { return nil, 0, err } items := make([]OnlineStatus, 0, query.Limit) start := int64(query.Offset) if query.Protocol != "" { start = 0 } skipped := 0 batchSize := int64(query.Limit * 5) if batchSize < 100 { batchSize = 100 } for len(items) < query.Limit { stop := start + batchSize - 1 members, err := r.client.ZRevRangeWithScores(ctx, "vehicle:last_seen", start, stop).Result() if err != nil { return nil, 0, err } if len(members) == 0 { break } for _, member := range members { protocol, vin, ok := splitOnlineMember(fmt.Sprint(member.Member)) if !ok { continue } if query.Protocol != "" && protocol != query.Protocol { continue } if query.Protocol != "" && skipped < query.Offset { skipped++ continue } status, err := r.onlineStatusFor(ctx, protocol, vin, int64(member.Score)) if err != nil { return nil, 0, err } items = append(items, status) if len(items) >= query.Limit { break } } if int64(len(members)) < batchSize { break } start += batchSize } return items, total, nil } func (r *Repository) PipelineSummary(ctx context.Context) (PipelineSummary, error) { out := PipelineSummary{ Protocols: make([]ProtocolPipelineSummary, 0, len(knownRealtimeProtocols())), UpdatedMS: time.Now().UnixMilli(), } for _, protocol := range knownRealtimeProtocols() { indexed, err := r.client.SCard(ctx, realtimeIndexKey(protocol)).Result() if err != nil { return PipelineSummary{}, err } onlineCount, latestSeen, err := r.protocolOnlineStats(ctx, protocol) if err != nil { return PipelineSummary{}, err } out.Protocols = append(out.Protocols, ProtocolPipelineSummary{ Protocol: protocol, IndexedCount: indexed, OnlineCount: onlineCount, LatestSeenMS: latestSeen, }) } return out, nil } func (r *Repository) onlineTotal(ctx context.Context, protocol envelope.Protocol) (int64, error) { if protocol != "" { return r.client.SCard(ctx, realtimeIndexKey(protocol)).Result() } return r.client.ZCard(ctx, "vehicle:last_seen").Result() } func (r *Repository) protocolOnlineStats(ctx context.Context, protocol envelope.Protocol) (int64, int64, error) { members, err := r.client.SMembers(ctx, realtimeIndexKey(protocol)).Result() if err != nil { return 0, 0, err } var onlineCount int64 var latestSeen int64 for _, vin := range members { status, err := r.onlineStatusFor(ctx, protocol, vin, 0) if err != nil { return 0, 0, err } if status.Online { onlineCount++ } if status.LastSeenMS > latestSeen { latestSeen = status.LastSeenMS } } return onlineCount, latestSeen, nil } func (r *Repository) onlineStatusFor(ctx context.Context, protocol envelope.Protocol, vin string, scoreMS int64) (OnlineStatus, error) { key := onlineKey(protocol, vin) stateKey := onlineStateKey(protocol, vin) state, err := r.client.HGetAll(ctx, stateKey).Result() if err != nil { return OnlineStatus{}, err } ttl := r.client.TTL(ctx, key).Val() status := OnlineStatus{ VehicleKey: firstNonEmptyString(state["vehicle_key"], vin), VIN: firstNonEmptyString(state["vin"], vin), Protocol: protocol, Online: ttl > 0, LastSeenMS: firstPositiveInt64(parseInt64(state["last_seen_ms"]), scoreMS), OfflineAfterMS: parseInt64(state["offline_after_ms"]), SourceEndpoint: state["source_endpoint"], Protocols: []envelope.Protocol{protocol}, TTLSeconds: int64(ttl.Seconds()), } if status.TTLSeconds < 0 { status.TTLSeconds = 0 } if status.OfflineAfterMS <= 0 && status.LastSeenMS > 0 { status.OfflineAfterMS = status.LastSeenMS + r.cfg.ttl().Milliseconds() } return status, nil } func (r *Repository) getSnapshot(ctx context.Context, key string) (Snapshot, error) { var snapshot Snapshot payload, err := r.client.Get(ctx, key).Bytes() if err != nil { return snapshot, err } return snapshot, json.Unmarshal(payload, &snapshot) } func (r *Repository) setJSON(ctx context.Context, key string, value any, ttl time.Duration) error { payload, err := json.Marshal(value) if err != nil { return err } return r.client.Set(ctx, key, payload, ttl).Err() } func (r *Repository) setKV(ctx context.Context, vin string, env envelope.FrameEnvelope, parsed map[string]any) error { if len(env.ParsedFields) == 0 && len(parsed) > 0 { env.Parsed = parsed } values, types := realtimeKVMapsForEnvelope(env) if len(values) == 0 { return nil } meta := map[string]any{ "event_time_ms": strconv.FormatInt(eventTimeOrReceivedMS(env), 10), "received_at_ms": strconv.FormatInt(env.ReceivedAtMS, 10), "event_id": env.StableEventID(), "protocol": string(env.Protocol), "vin": vin, "source_endpoint": env.SourceEndpoint, "field_mapping": realtimeFieldMappingVersion, } pipe := r.client.Pipeline() pipe.HSet(ctx, realtimeKVValuesKey(env.Protocol, vin), values) pipe.HSet(ctx, realtimeKVTypesKey(env.Protocol, vin), types) pipe.HSet(ctx, realtimeKVMetaKey(env.Protocol, vin), meta) _, err := pipe.Exec(ctx) return err } func (r *Repository) setFastProjection(ctx context.Context, vehicleKey string, vin string, env envelope.FrameEnvelope) error { pipe := r.client.Pipeline() if err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env); err != nil { return err } _, err := pipe.Exec(ctx) return err } func (r *Repository) queueFastProjection(ctx context.Context, pipe redis.Pipeliner, vehicleKey string, vin string, env envelope.FrameEnvelope) error { values, types := realtimeKVMapsForEnvelope(env) eventTimeMS := eventTimeOrReceivedMS(env) offlineAfterMS := env.ReceivedAtMS + r.cfg.ttl().Milliseconds() online := OnlineStatus{ VehicleKey: vehicleKey, VIN: vin, Protocol: env.Protocol, Online: true, LastSeenMS: env.ReceivedAtMS, OfflineAfterMS: offlineAfterMS, SourceEndpoint: env.SourceEndpoint, Protocols: []envelope.Protocol{env.Protocol}, TTLSeconds: int64(r.cfg.ttl().Seconds()), } payload, err := json.Marshal(online) if err != nil { return err } meta := map[string]any{ "event_time_ms": strconv.FormatInt(eventTimeMS, 10), "received_at_ms": strconv.FormatInt(env.ReceivedAtMS, 10), "event_id": env.StableEventID(), "protocol": string(env.Protocol), "vin": vin, "source_endpoint": env.SourceEndpoint, "field_mapping": realtimeFieldMappingVersion, } state := map[string]any{ "vehicle_key": vehicleKey, "vin": vin, "protocol": string(env.Protocol), "online": strconv.FormatBool(true), "last_seen_ms": strconv.FormatInt(env.ReceivedAtMS, 10), "offline_after_ms": strconv.FormatInt(offlineAfterMS, 10), "ttl_seconds": strconv.FormatInt(int64(r.cfg.ttl().Seconds()), 10), "source_endpoint": env.SourceEndpoint, } if len(values) > 0 { pipe.HSet(ctx, realtimeKVValuesKey(env.Protocol, vin), values) pipe.HSet(ctx, realtimeKVTypesKey(env.Protocol, vin), types) pipe.HSet(ctx, realtimeKVMetaKey(env.Protocol, vin), meta) } pipe.Set(ctx, onlineKey(env.Protocol, vin), payload, r.cfg.ttl()) pipe.HSet(ctx, onlineStateKey(env.Protocol, vin), state) pipe.ZAdd(ctx, "vehicle:last_seen", redis.Z{Score: float64(env.ReceivedAtMS), Member: onlineMember(env.Protocol, vin)}) pipe.SAdd(ctx, realtimeIndexKey(env.Protocol), vin) return nil } func realtimeKVMapsForEnvelope(env envelope.FrameEnvelope) (map[string]any, map[string]any) { if fields, fieldTypes, ok := ParsedFieldsForEnvelope(env); ok { values := make(map[string]any, len(fields)) types := make(map[string]any, len(fields)) for field, value := range fields { if strings.TrimSpace(field) == "" { continue } stringValue, valueType, ok := stringifyKVValue(value) if !ok { continue } values[field] = stringValue if typed := strings.TrimSpace(fieldTypes[field]); typed != "" { types[field] = typed } else { types[field] = valueType } } return values, types } return nil, nil } func realtimeKVMaps(rows []RealtimeKVField) (map[string]any, map[string]any) { values := make(map[string]any, len(rows)) types := make(map[string]any, len(rows)) for _, row := range rows { field := realtimeKVFieldPath(row.Domain, row.Field) if field == "" { continue } values[field] = row.Value types[field] = row.ValueType } return values, types } func (r *Repository) setOnline(ctx context.Context, vehicleKey string, vin string, protocol envelope.Protocol, lastSeenMS int64, sourceEndpoint string) error { if protocol == "" { return nil } online := OnlineStatus{ VehicleKey: vehicleKey, VIN: vin, Protocol: protocol, Online: true, LastSeenMS: lastSeenMS, OfflineAfterMS: lastSeenMS + r.cfg.ttl().Milliseconds(), SourceEndpoint: sourceEndpoint, Protocols: []envelope.Protocol{protocol}, TTLSeconds: int64(r.cfg.ttl().Seconds()), } return r.setOnlineStatus(ctx, online) } func (r *Repository) setOnlineStatus(ctx context.Context, online OnlineStatus) error { protocol := online.Protocol if protocol == "" && len(online.Protocols) > 0 { protocol = online.Protocols[0] } if protocol == "" { return nil } if online.OfflineAfterMS <= 0 { online.OfflineAfterMS = online.LastSeenMS + r.cfg.ttl().Milliseconds() } member := onlineMember(protocol, online.VIN) state := map[string]any{ "vehicle_key": online.VehicleKey, "vin": online.VIN, "protocol": string(protocol), "online": strconv.FormatBool(true), "last_seen_ms": strconv.FormatInt(online.LastSeenMS, 10), "offline_after_ms": strconv.FormatInt(online.OfflineAfterMS, 10), "ttl_seconds": strconv.FormatInt(int64(r.cfg.ttl().Seconds()), 10), "source_endpoint": online.SourceEndpoint, } pipe := r.client.Pipeline() payload, err := json.Marshal(online) if err != nil { return err } pipe.Set(ctx, onlineKey(protocol, online.VIN), payload, r.cfg.ttl()) pipe.HSet(ctx, onlineStateKey(protocol, online.VIN), state) pipe.ZAdd(ctx, "vehicle:last_seen", redis.Z{Score: float64(online.LastSeenMS), Member: member}) pipe.SAdd(ctx, realtimeIndexKey(protocol), online.VIN) _, err = pipe.Exec(ctx) return err } func (r *Repository) addProtocol(ctx context.Context, vehicleKey string, protocol envelope.Protocol) ([]envelope.Protocol, error) { key := protocolsKey(vehicleKey) if err := r.client.SAdd(ctx, key, string(protocol)).Err(); err != nil { return nil, err } _ = r.client.Expire(ctx, key, r.cfg.ttl()).Err() values, err := r.client.SMembers(ctx, key).Result() if err != nil { return nil, err } sort.Strings(values) out := make([]envelope.Protocol, 0, len(values)) for _, value := range values { out = append(out, envelope.Protocol(value)) } return out, nil } func mergeFields(snapshot *Snapshot, fields map[string]any, eventMS int64) { if snapshot.Fields == nil { snapshot.Fields = map[string]any{} } if snapshot.FieldTimesMS == nil { snapshot.FieldTimesMS = map[string]int64{} } for key, value := range fields { if key == envelope.FieldTotalMileageKM && !positiveNumber(value) { continue } if eventMS >= snapshot.FieldTimesMS[key] { snapshot.Fields[key] = value snapshot.FieldTimesMS[key] = eventMS } } } func positiveNumber(value any) bool { switch typed := value.(type) { case float64: return typed > 0 case float32: return typed > 0 case int: return typed > 0 case int64: return typed > 0 case uint16: return typed > 0 case uint32: return typed > 0 case string: parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) return err == nil && parsed > 0 default: return false } } func cloneFields(fields map[string]any) map[string]any { if len(fields) == 0 { return map[string]any{} } out := make(map[string]any, len(fields)) for key, value := range fields { out[key] = value } return out } func cloneMap(values map[string]any) map[string]any { if len(values) == 0 { return map[string]any{} } out := make(map[string]any, len(values)) for key, value := range values { out[key] = cloneAny(value) } return out } func cloneAny(value any) any { switch typed := value.(type) { case map[string]any: return cloneMap(typed) case []any: out := make([]any, len(typed)) for i, item := range typed { out[i] = cloneAny(item) } return out case []map[string]any: out := make([]any, len(typed)) for i, item := range typed { out[i] = cloneMap(item) } return out default: return value } } func mergeParsed(existing map[string]any, incoming map[string]any) map[string]any { out := cloneMap(existing) for key, value := range incoming { out[key] = mergeAny(out[key], value) } return out } func mergeParsedForProtocol(protocol envelope.Protocol, existing map[string]any, incoming map[string]any) map[string]any { out := mergeParsed(existing, incoming) for _, key := range replaceParsedKeys(protocol, incoming) { out[key] = cloneAny(incoming[key]) } if protocol == envelope.ProtocolGB32960 { normalizeGB32960RealtimeParsed(out) } return out } func replaceParsedKeys(protocol envelope.Protocol, incoming map[string]any) []string { keys := make([]string, 0, 2) if _, ok := incoming["header"]; ok { keys = append(keys, "header") } if protocol == envelope.ProtocolJT808 { if _, ok := incoming["location"]; ok { keys = append(keys, "location") } } return keys } func mergeAny(existing any, incoming any) any { switch incomingTyped := incoming.(type) { case map[string]any: existingMap, _ := existing.(map[string]any) return mergeParsed(existingMap, incomingTyped) case []any: if merged, ok := mergeMapSlice(existing, incomingTyped); ok { return merged } return cloneAny(incomingTyped) case []map[string]any: items := make([]any, len(incomingTyped)) for i, item := range incomingTyped { items[i] = item } if merged, ok := mergeMapSlice(existing, items); ok { return merged } return cloneAny(incomingTyped) default: return cloneAny(incoming) } } func mergeMapSlice(existing any, incoming []any) ([]any, bool) { existingItems, ok := asAnySlice(existing) if !ok { return nil, false } merged := make([]any, 0, len(existingItems)+len(incoming)) index := map[string]int{} add := func(item any) { itemMap, ok := item.(map[string]any) if !ok { merged = append(merged, cloneAny(item)) return } key := mergeSliceKey(itemMap) if key == "" { merged = append(merged, cloneMap(itemMap)) return } if pos, exists := index[key]; exists { merged[pos] = mergeAny(merged[pos], itemMap) return } index[key] = len(merged) merged = append(merged, cloneMap(itemMap)) } for _, item := range existingItems { add(item) } for _, item := range incoming { add(item) } return merged, true } func asAnySlice(value any) ([]any, bool) { switch typed := value.(type) { case []any: return typed, true case []map[string]any: out := make([]any, len(typed)) for i, item := range typed { out[i] = item } return out, true default: return nil, false } } func mergeSliceKey(item map[string]any) string { for _, key := range []string{"name", "type", "id", "serial_no"} { if value, ok := item[key]; ok { text := strings.TrimSpace(strconvAny(value)) if text != "" { return key + ":" + text } } } return "" } func strconvAny(value any) string { switch typed := value.(type) { case string: return typed default: return fmt.Sprint(typed) } } func normalizeGB32960RealtimeParsed(parsed map[string]any) { units, ok := asAnySlice(parsed["data_units"]) if !ok { return } for _, unit := range units { unitMap, ok := unit.(map[string]any) if !ok { continue } value, ok := unitMap["value"].(map[string]any) if !ok { continue } switch strings.TrimSpace(strconvAny(unitMap["name"])) { case "gd_fc_stack": collapseGDFCStackValue(value) case "gd_fc_auxiliary": collapseGDFCAuxiliaryValue(value) } } } func collapseGDFCStackValue(value map[string]any) { summaries, ok := asAnySlice(value["summaries"]) if !ok || len(summaries) == 0 { return } collapsed := map[string]any{} for _, item := range summaries { summary, ok := item.(map[string]any) if !ok { continue } for key, fieldValue := range summary { if isGDFCStackFragmentField(key) { continue } collapsed[key] = cloneAny(fieldValue) } } if len(collapsed) == 0 { delete(value, "summaries") return } value["stack_count"] = 1 value["summaries"] = []any{collapsed} } func isGDFCStackFragmentField(key string) bool { switch key { case "frame_cell_start", "frame_cell_count", "frame_max_cell_voltage_v", "frame_min_cell_voltage_v": return true default: return false } } func collapseGDFCAuxiliaryValue(value map[string]any) { subsystems, ok := asAnySlice(value["subsystems"]) if !ok || len(subsystems) == 0 { return } latest := subsystems[len(subsystems)-1] subsystem, ok := latest.(map[string]any) if !ok { return } value["subsystem_count"] = 1 value["subsystems"] = []any{cloneMap(subsystem)} } func fieldTimes(fields map[string]any, eventMS int64) map[string]int64 { out := make(map[string]int64, len(fields)) for key := range fields { out[key] = eventMS } return out } func mergedKey(vehicleKey string) string { return "vehicle:latest:" + strings.TrimSpace(vehicleKey) } func protocolKey(vehicleKey string, protocol envelope.Protocol) string { return "vehicle:latest:" + strings.TrimSpace(vehicleKey) + ":" + string(protocol) } func realtimeRawKey(vehicleKey string, protocol envelope.Protocol) string { return "vehicle:realtime-raw:" + string(protocol) + ":" + strings.TrimSpace(vehicleKey) } func realtimeKVValuesKey(protocol envelope.Protocol, vin string) string { return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":values" } func realtimeKVTypesKey(protocol envelope.Protocol, vin string) string { return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":types" } func realtimeKVMetaKey(protocol envelope.Protocol, vin string) string { return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":meta" } func realtimeKVFieldPath(domain string, field string) string { domain = strings.TrimSpace(domain) field = strings.TrimSpace(field) switch { case domain == "": return field case field == "": return domain default: return domain + "." + field } } func eventTimeOrReceivedMS(env envelope.FrameEnvelope) int64 { if env.EventTimeMS > 0 { return env.EventTimeMS } return env.ReceivedAtMS } func onlineKey(protocol envelope.Protocol, vin string) string { return "vehicle:online:" + string(protocol) + ":" + strings.TrimSpace(vin) } func onlineStateKey(protocol envelope.Protocol, vin string) string { return "vehicle:online-state:" + string(protocol) + ":" + strings.TrimSpace(vin) } func onlineMember(protocol envelope.Protocol, vin string) string { return string(protocol) + ":" + strings.TrimSpace(vin) } func realtimeIndexKey(protocol envelope.Protocol) string { return "vehicle:rt-index:" + string(protocol) } func knownRealtimeProtocols() []envelope.Protocol { return []envelope.Protocol{envelope.ProtocolGB32960, envelope.ProtocolJT808, envelope.ProtocolYutongMQTT} } func protocolsKey(vehicleKey string) string { return "vehicle:protocols:" + strings.TrimSpace(vehicleKey) } func normalizeOnlineListQuery(query OnlineListQuery) OnlineListQuery { query.Protocol = envelope.Protocol(strings.ToUpper(strings.TrimSpace(string(query.Protocol)))) if query.Limit <= 0 { query.Limit = 100 } if query.Limit > 1000 { query.Limit = 1000 } if query.Offset < 0 { query.Offset = 0 } return query } func splitOnlineMember(member string) (envelope.Protocol, string, bool) { protocolText, vin, ok := strings.Cut(strings.TrimSpace(member), ":") if !ok || strings.TrimSpace(protocolText) == "" || strings.TrimSpace(vin) == "" { return "", "", false } return envelope.Protocol(protocolText), strings.TrimSpace(vin), true } func parseInt64(value string) int64 { parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) return parsed } func firstPositiveInt64(values ...int64) int64 { for _, value := range values { if value > 0 { return value } } return 0 } func firstNonEmptyString(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" }