Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/history/query.go

1026 lines
28 KiB
Go

package history
import (
"compress/gzip"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
type Queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}
type RawFrameQuery struct {
Protocol string
VehicleKey string
VIN string
Phone string
DeviceID string
MessageID string
OrderBy string
IncludeFields bool
IncludePayload bool
IncludeTotal bool
ParsedFields []string
DateFrom string
DateTo string
Limit int
Offset int
}
type RawFrameRow struct {
TS string `json:"ts"`
FrameID string `json:"frame_id"`
EventID string `json:"event_id"`
MessageID int64 `json:"message_id"`
MessageIDHex string `json:"message_id_hex"`
EventTime string `json:"event_time"`
ReceivedAt string `json:"received_at"`
RawSizeBytes int64 `json:"raw_size_bytes"`
RawHex string `json:"raw_hex,omitempty"`
RawText string `json:"raw_text,omitempty"`
ParsedFields json.RawMessage `json:"parsed_fields,omitempty"`
ParseStatus string `json:"parse_status"`
ParseError string `json:"parse_error,omitempty"`
SourceEndpoint string `json:"source_endpoint"`
Protocol string `json:"protocol"`
VehicleKey string `json:"vehicle_key"`
VIN string `json:"vin"`
Phone string `json:"phone,omitempty"`
DeviceID string `json:"device_id,omitempty"`
}
type LocationQuery struct {
Protocol string
VIN string
IncludeTotal bool
DateFrom string
DateTo string
Limit int
Offset int
}
type LocationRow struct {
TS string `json:"ts"`
EventID string `json:"event_id"`
ReceivedAt string `json:"received_at"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
AltitudeM *float64 `json:"altitude_m,omitempty"`
SpeedKMH *float64 `json:"speed_kmh,omitempty"`
SOCPercent *float64 `json:"soc_percent,omitempty"`
DirectionDeg *int64 `json:"direction_deg,omitempty"`
AlarmFlag *int64 `json:"alarm_flag,omitempty"`
StatusFlag *int64 `json:"status_flag,omitempty"`
TotalMileageKM *float64 `json:"total_mileage_km,omitempty"`
Protocol string `json:"protocol"`
VIN string `json:"vin"`
}
type rawFrameQueryRequest struct {
Protocol string `json:"protocol"`
VehicleKey string `json:"vehicleKey"`
VehicleKeyAlt string `json:"vehicle_key"`
VIN string `json:"vin"`
Phone string `json:"phone"`
DeviceID string `json:"deviceId"`
DeviceIDAlt string `json:"device_id"`
MessageID string `json:"messageId"`
MessageIDAlt string `json:"message_id"`
OrderBy string `json:"orderBy"`
OrderByAlt string `json:"order_by"`
IncludeFields bool `json:"includeFields"`
IncludePayload bool `json:"includePayload"`
IncludeTotal bool `json:"includeTotal"`
Fields []string `json:"fields"`
ParsedFields []string `json:"parsedFields"`
DateFrom string `json:"dateFrom"`
DateFromAlt string `json:"date_from"`
DateTo string `json:"dateTo"`
DateToAlt string `json:"date_to"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type RawFrameRepository struct {
db Queryer
database string
}
func NewRawFrameRepository(db Queryer, database string) *RawFrameRepository {
if db == nil {
panic("raw frame query db must not be nil")
}
database = strings.TrimSpace(database)
if database != "" && !safeIdentifier(database) {
database = ""
}
return &RawFrameRepository{db: db, database: database}
}
type LocationRepository struct {
db Queryer
database string
}
func NewLocationRepository(db Queryer, database string) *LocationRepository {
if db == nil {
panic("location query db must not be nil")
}
database = strings.TrimSpace(database)
if database != "" && !safeIdentifier(database) {
database = ""
}
return &LocationRepository{db: db, database: database}
}
func (r *RawFrameRepository) Query(ctx context.Context, query RawFrameQuery) ([]RawFrameRow, error) {
query = normalizeRawFrameQuery(query)
sqlText, args := buildRawFrameSQL(r.tableName(query), query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]RawFrameRow, 0)
for rows.Next() {
var row RawFrameRow
var ts scanDateTime
var eventTime scanDateTime
var receivedAt scanDateTime
var parsedFields string
if err := rows.Scan(
&ts,
&row.FrameID,
&row.EventID,
&row.MessageID,
&eventTime,
&receivedAt,
&row.RawSizeBytes,
&row.RawHex,
&row.RawText,
&parsedFields,
&row.ParseStatus,
&row.ParseError,
&row.SourceEndpoint,
&row.Protocol,
&row.VehicleKey,
&row.VIN,
&row.Phone,
&row.DeviceID,
); err != nil {
return nil, err
}
row.TS = ts.String
row.EventTime = eventTime.String
row.ReceivedAt = receivedAt.String
row.MessageIDHex = fmt.Sprintf("0x%04X", row.MessageID)
row.ParsedFields = rawJSONMessage(parsedFields)
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := r.hydratePayloadChunks(ctx, out); err != nil {
return nil, err
}
filterParsedFields(out, query.ParsedFields)
return out, nil
}
func (r *RawFrameRepository) Count(ctx context.Context, query RawFrameQuery) (int64, error) {
query = normalizeRawFrameQuery(query)
sqlText, args := buildRawFrameCountSQL(r.tableName(query), query)
return countRows(ctx, r.db, sqlText, args...)
}
func (r *LocationRepository) Query(ctx context.Context, query LocationQuery) ([]LocationRow, error) {
query = normalizeLocationQuery(query)
sqlText, args := buildLocationSQL(r.tableName(), query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]LocationRow, 0)
for rows.Next() {
var row LocationRow
var ts scanDateTime
var receivedAt scanDateTime
var altitude sql.NullFloat64
var speed sql.NullFloat64
var soc sql.NullFloat64
var direction sql.NullInt64
var alarm sql.NullInt64
var status sql.NullInt64
var mileage sql.NullFloat64
if err := rows.Scan(
&ts,
&row.EventID,
&receivedAt,
&row.Longitude,
&row.Latitude,
&altitude,
&speed,
&soc,
&direction,
&alarm,
&status,
&mileage,
&row.Protocol,
&row.VIN,
); err != nil {
return nil, err
}
row.TS = ts.String
row.ReceivedAt = receivedAt.String
row.AltitudeM = nullableFloat(altitude)
row.SpeedKMH = nullableFloat(speed)
row.SOCPercent = nullableFloat(soc)
row.DirectionDeg = nullableInt(direction)
row.AlarmFlag = nullableInt(alarm)
row.StatusFlag = nullableInt(status)
row.TotalMileageKM = nullableFloat(mileage)
out = append(out, row)
}
return out, rows.Err()
}
func (r *LocationRepository) Count(ctx context.Context, query LocationQuery) (int64, error) {
query = normalizeLocationQuery(query)
sqlText, args := buildLocationCountSQL(r.tableName(), query)
return countRows(ctx, r.db, sqlText, args...)
}
func countRows(ctx context.Context, db Queryer, sqlText string, args ...any) (int64, error) {
rows, err := 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 *RawFrameRepository) tableName(query ...RawFrameQuery) string {
if len(query) > 0 {
if child := r.rawChildTableName(query[0]); child != "" {
return child
}
}
if r.database == "" {
return "raw_frames"
}
return r.database + ".raw_frames"
}
func (r *RawFrameRepository) rawChildTableName(query RawFrameQuery) string {
protocol := strings.ToUpper(strings.TrimSpace(query.Protocol))
if protocol == "" {
return ""
}
vehicleKey := strings.TrimSpace(query.VehicleKey)
if vehicleKey == "" {
vehicleKey = strings.TrimSpace(query.VIN)
}
if vehicleKey == "" {
return ""
}
table := "raw_" + strings.ToLower(protocol) + "_" + hash16(vehicleKey)
if r.database != "" {
return r.database + "." + table
}
return table
}
func (r *RawFrameRepository) chunkTableName() string {
if r.database == "" {
return "raw_frame_payload_chunks"
}
return r.database + ".raw_frame_payload_chunks"
}
func (r *LocationRepository) tableName() string {
if r.database == "" {
return "vehicle_locations"
}
return r.database + ".vehicle_locations"
}
func normalizeRawFrameQuery(query RawFrameQuery) RawFrameQuery {
query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
query.VehicleKey = strings.TrimSpace(query.VehicleKey)
query.VIN = strings.TrimSpace(query.VIN)
query.Phone = strings.TrimSpace(query.Phone)
query.DeviceID = strings.TrimSpace(query.DeviceID)
query.MessageID = strings.TrimSpace(query.MessageID)
query.ParsedFields = normalizeParsedFieldNames(query.ParsedFields)
if len(query.ParsedFields) > 0 {
query.IncludeFields = true
}
query.OrderBy = normalizeRawFrameOrderBy(query.OrderBy)
query.DateFrom = normalizeDateTimeLiteral(query.DateFrom)
query.DateTo = normalizeDateTimeLiteral(query.DateTo)
if query.Limit <= 0 {
query.Limit = 20
}
return query
}
func normalizeLocationQuery(query LocationQuery) LocationQuery {
query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
query.VIN = strings.TrimSpace(query.VIN)
query.DateFrom = normalizeDateTimeLiteral(query.DateFrom)
query.DateTo = normalizeDateTimeLiteral(query.DateTo)
if query.Limit <= 0 {
query.Limit = 20
}
return query
}
func buildRawFrameSQL(table string, query RawFrameQuery) (string, []any) {
where := rawFrameWhere(query)
rawHexSelect := "'' AS raw_hex"
rawTextSelect := "'' AS raw_text"
parsedFieldsSelect := "'' AS parsed_fields"
if query.IncludePayload {
rawHexSelect = "raw_hex"
rawTextSelect = "raw_text"
}
if query.IncludeFields {
parsedFieldsSelect = "parsed_json AS parsed_fields"
}
sqlText := `SELECT ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes, ` + rawHexSelect + `, ` + rawTextSelect + `, ` + parsedFieldsSelect + `, parse_status, parse_error, source_endpoint, protocol, vehicle_key, vin, phone, device_id FROM ` + table
if len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
sqlText += " ORDER BY " + rawFrameOrderColumn(query.OrderBy) + " DESC LIMIT " + strconv.Itoa(query.Limit) + " OFFSET " + strconv.Itoa(query.Offset)
return sqlText, nil
}
func buildRawFrameCountSQL(table string, query RawFrameQuery) (string, []any) {
sqlText := `SELECT COUNT(*) FROM ` + table
if where := rawFrameWhere(query); len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
return sqlText, nil
}
type payloadChunkManifest struct {
Chunked bool `json:"chunked"`
PayloadKind string `json:"payload_kind"`
EventID string `json:"event_id"`
ChunkCount int `json:"chunk_count"`
}
func (r *RawFrameRepository) hydratePayloadChunks(ctx context.Context, rows []RawFrameRow) error {
type target struct {
rowIndex int
kind string
eventID string
count int
}
targets := make([]target, 0)
eventSeen := map[string]struct{}{}
for index := range rows {
for _, candidate := range []struct {
kind string
value string
}{
{kind: "raw_hex", value: rows[index].RawHex},
{kind: "raw_text", value: rows[index].RawText},
{kind: "parsed_fields", value: string(rows[index].ParsedFields)},
} {
manifest, ok := parsePayloadChunkManifest(candidate.value)
if !ok || !payloadKindMatches(candidate.kind, manifest.PayloadKind) || manifest.ChunkCount <= 0 {
continue
}
eventID := strings.TrimSpace(manifest.EventID)
if eventID == "" {
eventID = rows[index].EventID
}
targets = append(targets, target{
rowIndex: index,
kind: candidate.kind,
eventID: eventID,
count: manifest.ChunkCount,
})
eventSeen[eventID] = struct{}{}
}
}
if len(targets) == 0 {
return nil
}
eventIDs := make([]string, 0, len(eventSeen))
for eventID := range eventSeen {
eventIDs = append(eventIDs, eventID)
}
sqlText := `SELECT event_id, payload_kind, chunk_index, chunk_text FROM ` + r.chunkTableName() +
" WHERE event_id IN (" + quotedList(eventIDs) + ") ORDER BY event_id, payload_kind, chunk_index"
chunkRows, err := r.db.QueryContext(ctx, sqlText)
if err != nil {
return err
}
defer chunkRows.Close()
chunks := map[string]map[string][]string{}
for chunkRows.Next() {
var eventID string
var kind string
var index int
var text string
if err := chunkRows.Scan(&eventID, &kind, &index, &text); err != nil {
return err
}
if chunks[eventID] == nil {
chunks[eventID] = map[string][]string{}
}
current := chunks[eventID][kind]
if index >= len(current) {
next := make([]string, index+1)
copy(next, current)
current = next
}
current[index] = text
chunks[eventID][kind] = current
}
if err := chunkRows.Err(); err != nil {
return err
}
for _, target := range targets {
parts := chunks[target.eventID][target.kind]
if len(parts) != target.count {
continue
}
hydrated := strings.Join(parts, "")
switch target.kind {
case "raw_hex":
rows[target.rowIndex].RawHex = hydrated
case "raw_text":
rows[target.rowIndex].RawText = hydrated
case "parsed_fields":
rows[target.rowIndex].ParsedFields = rawJSONMessage(hydrated)
}
}
return nil
}
func rawJSONMessage(value string) json.RawMessage {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
if json.Valid([]byte(value)) {
return json.RawMessage(value)
}
return json.RawMessage(jsonString(value))
}
func filterParsedFields(rows []RawFrameRow, fieldNames []string) {
fieldNames = normalizeParsedFieldNames(fieldNames)
if len(fieldNames) == 0 {
return
}
for index := range rows {
if len(rows[index].ParsedFields) == 0 {
continue
}
var fields map[string]any
if err := json.Unmarshal(rows[index].ParsedFields, &fields); err != nil {
continue
}
filtered := make(map[string]any, len(fieldNames))
for _, name := range fieldNames {
if value, ok := fields[name]; ok {
filtered[name] = value
}
}
if len(filtered) == 0 {
rows[index].ParsedFields = json.RawMessage(`{}`)
continue
}
rows[index].ParsedFields = rawJSONMessage(jsonString(filtered))
}
}
func normalizeParsedFieldNames(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
for _, part := range strings.Split(value, ",") {
name := strings.TrimSpace(part)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
}
return out
}
func payloadKindMatches(candidate string, manifest string) bool {
if candidate == manifest {
return true
}
return candidate == "parsed_fields" && manifest == "parsed_json"
}
func parsePayloadChunkManifest(value string) (payloadChunkManifest, bool) {
var manifest payloadChunkManifest
if !strings.Contains(value, `"chunked"`) {
return manifest, false
}
if err := json.Unmarshal([]byte(value), &manifest); err != nil {
return manifest, false
}
return manifest, manifest.Chunked
}
func quotedList(values []string) string {
out := make([]string, 0, len(values))
for _, value := range values {
out = append(out, "'"+quote(value)+"'")
}
return strings.Join(out, ", ")
}
func buildLocationSQL(table string, query LocationQuery) (string, []any) {
where := locationWhere(query)
sqlText := `SELECT ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh, soc_percent, direction_deg, alarm_flag, status_flag, total_mileage_km, protocol, vin FROM ` + table
if len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
sqlText += " ORDER BY ts DESC LIMIT " + strconv.Itoa(query.Limit) + " OFFSET " + strconv.Itoa(query.Offset)
return sqlText, nil
}
func buildLocationCountSQL(table string, query LocationQuery) (string, []any) {
sqlText := `SELECT COUNT(*) FROM ` + table
if where := locationWhere(query); len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
return sqlText, nil
}
func rawFrameWhere(query RawFrameQuery) []string {
var where []string
add := func(clause string) {
where = append(where, clause)
}
if query.Protocol != "" {
add("protocol = '" + quote(query.Protocol) + "'")
}
if query.VehicleKey != "" {
add("vehicle_key = '" + quote(query.VehicleKey) + "'")
}
if query.VIN != "" {
add("vin = '" + quote(query.VIN) + "'")
}
if query.Phone != "" {
add("phone = '" + quote(query.Phone) + "'")
}
if query.DeviceID != "" {
add("device_id = '" + quote(query.DeviceID) + "'")
}
if query.MessageID != "" {
if parsed, ok := parseMessageID(query.MessageID); ok {
add("message_id = " + strconv.FormatInt(parsed, 10))
}
}
if query.DateFrom != "" {
add("ts >= '" + quote(normalizeDateTimeLiteral(query.DateFrom)) + "'")
}
if query.DateTo != "" {
add("ts <= '" + quote(normalizeDateTimeLiteral(query.DateTo)) + "'")
}
return where
}
func normalizeRawFrameOrderBy(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "receivedat", "received_at":
return "receivedAt"
default:
return "ts"
}
}
func rawFrameOrderColumn(value string) string {
if normalizeRawFrameOrderBy(value) == "receivedAt" {
return "received_at"
}
return "ts"
}
func locationWhere(query LocationQuery) []string {
var where []string
add := func(clause string) {
where = append(where, clause)
}
if query.Protocol != "" {
add("protocol = '" + quote(query.Protocol) + "'")
}
if query.VIN != "" {
add("vin = '" + quote(query.VIN) + "'")
}
if query.DateFrom != "" {
add("ts >= '" + quote(normalizeDateTimeLiteral(query.DateFrom)) + "'")
}
if query.DateTo != "" {
add("ts <= '" + quote(normalizeDateTimeLiteral(query.DateTo)) + "'")
}
return where
}
type RawFrameHandler struct {
repository *RawFrameRepository
}
type LocationHandler struct {
repository *LocationRepository
}
func NewRawFrameHandler(repository *RawFrameRepository) *RawFrameHandler {
if repository == nil {
panic("raw frame repository must not be nil")
}
return &RawFrameHandler{repository: repository}
}
func NewLocationHandler(repository *LocationRepository) *LocationHandler {
if repository == nil {
panic("location repository must not be nil")
}
return &LocationHandler{repository: repository}
}
func (h *RawFrameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
writeHistoryError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
path := strings.Trim(r.URL.Path, "/")
if path != "api/history/raw-frames" && path != "api/history/raw-frames/query" {
writeHistoryError(w, http.StatusNotFound, "route not found")
return
}
query, err := rawFrameQueryFromRequest(r)
if err != nil {
writeHistoryError(w, http.StatusBadRequest, err.Error())
return
}
var total int64
if query.IncludeTotal {
total, err = h.repository.Count(r.Context(), query)
if err != nil {
writeHistoryError(w, http.StatusInternalServerError, err.Error())
return
}
}
rows, err := h.repository.Query(r.Context(), query)
if err != nil {
writeHistoryError(w, http.StatusInternalServerError, err.Error())
return
}
if !query.IncludeTotal {
total = int64(len(rows))
}
writeHistoryJSON(w, r, map[string]any{
"items": rows,
"total": total,
"limit": query.Limit,
"offset": query.Offset,
})
}
func (h *LocationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeHistoryError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if strings.Trim(r.URL.Path, "/") != "api/history/locations" {
writeHistoryError(w, http.StatusNotFound, "route not found")
return
}
query, err := parseLocationQuery(r)
if err != nil {
writeHistoryError(w, http.StatusBadRequest, err.Error())
return
}
var total int64
if query.IncludeTotal {
total, err = h.repository.Count(r.Context(), query)
if err != nil {
writeHistoryError(w, http.StatusInternalServerError, err.Error())
return
}
}
rows, err := h.repository.Query(r.Context(), query)
if err != nil {
writeHistoryError(w, http.StatusInternalServerError, err.Error())
return
}
if !query.IncludeTotal {
total = int64(len(rows))
}
writeHistoryJSON(w, r, map[string]any{
"items": rows,
"total": total,
"limit": query.Limit,
"offset": query.Offset,
})
}
func writeHistoryJSON(w http.ResponseWriter, r *http.Request, value any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Add("Vary", "Accept-Encoding")
if strings.Contains(strings.ToLower(r.Header.Get("Accept-Encoding")), "gzip") {
w.Header().Set("Content-Encoding", "gzip")
gzipWriter := gzip.NewWriter(w)
defer gzipWriter.Close()
_ = json.NewEncoder(gzipWriter).Encode(value)
return
}
_ = json.NewEncoder(w).Encode(value)
}
func rawFrameQueryFromRequest(r *http.Request) (RawFrameQuery, error) {
if r.Method == http.MethodPost {
return parseRawFrameQueryBody(r)
}
return parseRawFrameQuery(r)
}
func parseRawFrameQuery(r *http.Request) (RawFrameQuery, error) {
values := r.URL.Query()
limit, err := parseBoundedInt(values.Get("limit"), 20, 1, 500, "limit")
if err != nil {
return RawFrameQuery{}, err
}
offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
if err != nil {
return RawFrameQuery{}, err
}
query := RawFrameQuery{
Protocol: values.Get("protocol"),
VehicleKey: values.Get("vehicleKey"),
VIN: values.Get("vin"),
Phone: values.Get("phone"),
DeviceID: values.Get("deviceId"),
MessageID: values.Get("messageId"),
OrderBy: values.Get("orderBy"),
IncludeFields: strings.EqualFold(strings.TrimSpace(values.Get("includeFields")), "true"),
IncludePayload: strings.EqualFold(strings.TrimSpace(values.Get("includePayload")), "true"),
IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
ParsedFields: append(values["fields"], values["parsedFields"]...),
DateFrom: values.Get("dateFrom"),
DateTo: values.Get("dateTo"),
Limit: limit,
Offset: offset,
}
if !validDateTime(query.DateFrom) || !validDateTime(query.DateTo) {
return RawFrameQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD or YYYY-MM-DD HH:mm:ss")
}
if strings.TrimSpace(query.MessageID) != "" {
if _, ok := parseMessageID(query.MessageID); !ok {
return RawFrameQuery{}, errors.New("messageId must be decimal or hex like 0x0200")
}
}
return normalizeRawFrameQuery(query), nil
}
func parseRawFrameQueryBody(r *http.Request) (RawFrameQuery, error) {
defer r.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
decoder.DisallowUnknownFields()
var body rawFrameQueryRequest
if err := decoder.Decode(&body); err != nil {
return RawFrameQuery{}, fmt.Errorf("invalid JSON body: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return RawFrameQuery{}, errors.New("invalid JSON body")
}
if body.VehicleKey == "" {
body.VehicleKey = body.VehicleKeyAlt
}
if body.DeviceID == "" {
body.DeviceID = body.DeviceIDAlt
}
if body.MessageID == "" {
body.MessageID = body.MessageIDAlt
}
if body.OrderBy == "" {
body.OrderBy = body.OrderByAlt
}
if body.DateFrom == "" {
body.DateFrom = body.DateFromAlt
}
if body.DateTo == "" {
body.DateTo = body.DateToAlt
}
limit, err := boundedInt(body.Limit, 20, 1, 500, "limit")
if err != nil {
return RawFrameQuery{}, err
}
offset, err := boundedInt(body.Offset, 0, 0, 1_000_000, "offset")
if err != nil {
return RawFrameQuery{}, err
}
query := RawFrameQuery{
Protocol: body.Protocol,
VehicleKey: body.VehicleKey,
VIN: body.VIN,
Phone: body.Phone,
DeviceID: body.DeviceID,
MessageID: body.MessageID,
OrderBy: body.OrderBy,
IncludeFields: body.IncludeFields,
IncludePayload: body.IncludePayload,
IncludeTotal: body.IncludeTotal,
ParsedFields: append(body.Fields, body.ParsedFields...),
DateFrom: body.DateFrom,
DateTo: body.DateTo,
Limit: limit,
Offset: offset,
}
if !validDateTime(query.DateFrom) || !validDateTime(query.DateTo) {
return RawFrameQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD or YYYY-MM-DD HH:mm:ss")
}
if strings.TrimSpace(query.MessageID) != "" {
if _, ok := parseMessageID(query.MessageID); !ok {
return RawFrameQuery{}, errors.New("messageId must be decimal or hex like 0x0200")
}
}
return normalizeRawFrameQuery(query), nil
}
func parseLocationQuery(r *http.Request) (LocationQuery, error) {
values := r.URL.Query()
limit, err := parseBoundedInt(values.Get("limit"), 20, 1, 500, "limit")
if err != nil {
return LocationQuery{}, err
}
offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
if err != nil {
return LocationQuery{}, err
}
query := LocationQuery{
Protocol: values.Get("protocol"),
VIN: values.Get("vin"),
IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
DateFrom: values.Get("dateFrom"),
DateTo: values.Get("dateTo"),
Limit: limit,
Offset: offset,
}
if !validDateTime(query.DateFrom) || !validDateTime(query.DateTo) {
return LocationQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD or YYYY-MM-DD HH:mm:ss")
}
return normalizeLocationQuery(query), nil
}
func boundedInt(value int, fallback int, min int, max int, name string) (int, error) {
if value == 0 {
return fallback, nil
}
if value < min || value > max {
return 0, fmt.Errorf("%s must be between %d and %d", name, min, max)
}
return value, nil
}
func parseBoundedInt(raw string, fallback int, min int, max int, name string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value < min || value > max {
return 0, fmt.Errorf("%s must be between %d and %d", name, min, max)
}
return value, nil
}
func parseMessageID(value string) (int64, bool) {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
return 0, false
}
if strings.HasPrefix(value, "0x") {
parsed, err := strconv.ParseInt(strings.TrimPrefix(value, "0x"), 16, 32)
return parsed, err == nil
}
parsed, err := strconv.ParseInt(value, 10, 32)
return parsed, err == nil
}
func validDateTime(value string) bool {
value = normalizeDateTimeLiteral(value)
if value == "" {
return true
}
for _, layout := range []string{"2006-01-02", "2006-01-02 15:04:05", time.RFC3339} {
if _, err := time.Parse(layout, value); err == nil {
return true
}
}
return false
}
func normalizeDateTimeLiteral(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
shanghai := time.FixedZone("Asia/Shanghai", 8*3600)
for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02 15:04:05"} {
if parsed, err := time.ParseInLocation(layout, value, shanghai); err == nil {
return parsed.In(shanghai).Format("2006-01-02 15:04:05")
}
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed.In(shanghai).Format("2006-01-02 15:04:05")
}
return value
}
func safeIdentifier(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
continue
}
return false
}
return true
}
type scanDateTime struct {
String string
}
func (s *scanDateTime) Scan(value any) error {
s.String = formatSQLTime(value, "2006-01-02 15:04:05")
return nil
}
func formatSQLTime(value any, layout string) string {
switch typed := value.(type) {
case nil:
return ""
case time.Time:
return typed.Format(layout)
case []byte:
return strings.TrimSpace(string(typed))
case string:
return strings.TrimSpace(typed)
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func nullableFloat(value sql.NullFloat64) *float64 {
if !value.Valid {
return nil
}
return &value.Float64
}
func nullableInt(value sql.NullInt64) *int64 {
if !value.Valid {
return nil
}
return &value.Int64
}
func writeHistoryError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": message})
}