feat(platform): query tdengine history data

This commit is contained in:
lingniu
2026-07-03 21:22:21 +08:00
parent 2de569f104
commit f9b8182949
11 changed files with 306 additions and 55 deletions

View File

@@ -67,6 +67,10 @@ func (m *MockStore) RealtimeLocations(_ context.Context, query url.Values) (Page
}
func (m *MockStore) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
return m.HistoryLocationsFromTDengine(ctx, query)
}
func (m *MockStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
realtime, _ := m.RealtimeLocations(ctx, query)
rows := make([]HistoryLocationRow, 0, len(realtime.Items))
for _, row := range realtime.Items {

View File

@@ -6,24 +6,26 @@ import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"time"
)
type ProductionStore struct {
db *sql.DB
database string
db *sql.DB
tdengine *sql.DB
tdDatabase string
}
func NewProductionStore(db *sql.DB, tdengineDatabase string) *ProductionStore {
func NewProductionStore(db *sql.DB, tdengine *sql.DB, tdengineDatabase string) *ProductionStore {
if db == nil {
panic("production db must not be nil")
}
return &ProductionStore{db: db, database: tdengineDatabase}
return &ProductionStore{db: db, tdengine: tdengine, tdDatabase: tdengineDatabase}
}
func OpenMySQL(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", dsn)
func OpenSQL(ctx context.Context, driver, dsn string) (*sql.DB, error) {
db, err := sql.Open(driver, dsn)
if err != nil {
return nil, err
}
@@ -139,10 +141,86 @@ func (s *ProductionStore) HistoryLocations(ctx context.Context, query url.Values
return Page[HistoryLocationRow]{Items: items, Total: realtime.Total, Limit: realtime.Limit, Offset: realtime.Offset}, nil
}
func (s *ProductionStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
if s.tdengine == nil {
return s.HistoryLocations(ctx, query)
}
limit, offset := buildLimitOffset(query)
tdQuery := map[string]string{
"protocol": query.Get("protocol"),
"vin": query.Get("vin"),
"limit": strconv.Itoa(limit),
"offset": strconv.Itoa(offset),
}
if value := strings.TrimSpace(query.Get("dateFrom")); value != "" {
tdQuery["dateFrom"] = value
}
if value := strings.TrimSpace(query.Get("dateTo")); value != "" {
tdQuery["dateTo"] = value
}
built := buildHistoryLocationSQL(s.tdDatabase, tdQuery)
rows, err := s.tdengine.QueryContext(ctx, built.Text, built.Args...)
if err != nil {
return Page[HistoryLocationRow]{}, err
}
defer rows.Close()
items := make([]HistoryLocationRow, 0)
for rows.Next() {
var row HistoryLocationRow
var ts, receivedAt string
var longitude, latitude, speed, mileage sql.NullFloat64
if err := rows.Scan(&ts, &row.VIN, &row.Protocol, &longitude, &latitude, &speed, &mileage, &receivedAt); err != nil {
return Page[HistoryLocationRow]{}, err
}
row.Longitude = nullFloat64(longitude)
row.Latitude = nullFloat64(latitude)
row.SpeedKmh = nullFloat64(speed)
row.TotalMileageKm = nullFloat64(mileage)
row.DeviceTime = ts
row.ServerTime = firstNonEmpty(receivedAt, ts)
items = append(items, row)
}
if err := rows.Err(); err != nil {
return Page[HistoryLocationRow]{}, err
}
return Page[HistoryLocationRow]{Items: items, Total: len(items), Limit: limit, Offset: offset}, nil
}
func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
_ = ctx
_ = buildRawFrameSQL(s.database, query)
return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: query.Limit, Offset: query.Offset}, nil
if s.tdengine == nil {
return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: query.Limit, Offset: query.Offset}, nil
}
built := buildRawFrameSQL(s.tdDatabase, query)
rows, err := s.tdengine.QueryContext(ctx, built.Text, built.Args...)
if err != nil {
return Page[RawFrameRow]{}, err
}
defer rows.Close()
items := make([]RawFrameRow, 0)
for rows.Next() {
var row RawFrameRow
var ts, frameID, eventTime, receivedAt, parsedFields, parseStatus, parseError, sourceEndpoint, protocol, vehicleKey, vin, phone string
var rawSizeBytes int
if err := rows.Scan(&ts, &frameID, &eventTime, &receivedAt, &rawSizeBytes, &parsedFields, &parseStatus, &parseError, &sourceEndpoint, &protocol, &vehicleKey, &vin, &phone); err != nil {
return Page[RawFrameRow]{}, err
}
row.ID = frameID
row.VIN = vin
row.Protocol = protocol
row.FrameType = vehicleKey
row.DeviceTime = firstNonEmpty(eventTime, ts)
row.ServerTime = firstNonEmpty(receivedAt, ts)
row.RawSizeBytes = rawSizeBytes
row.ParsedFields = parsedFieldsFromString(parsedFields)
if len(query.Fields) > 0 {
row.ParsedFields = filterParsedFieldsMap(row.ParsedFields, query.Fields)
}
items = append(items, row)
}
if err := rows.Err(); err != nil {
return Page[RawFrameRow]{}, err
}
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
}
func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
@@ -206,11 +284,11 @@ func (s *ProductionStore) OpsHealth(ctx context.Context) (OpsHealth, error) {
{Name: "MySQL realtime", Status: mysqlStatus, Detail: mysqlDetail},
{Name: "vehicle_realtime_snapshot", Status: "ok", Detail: "读取实时快照表"},
{Name: "vehicle_realtime_location", Status: "ok", Detail: "读取实时位置表"},
{Name: "TDengine raw_frames", Status: "warning", Detail: "中台 RAW 查询接口待接 TDengine 驱动"},
{Name: "TDengine raw_frames", Status: tdengineStatus(s.tdengine), Detail: tdengineDetail(s.tdengine)},
},
KafkaLag: 0,
RedisOnlineKeys: redisKeys,
TDengineWritable: false,
TDengineWritable: s.tdengine != nil,
MySQLWritable: mysqlStatus == "ok",
}, nil
}
@@ -242,3 +320,50 @@ func parsedFieldsFromString(value string) map[string]any {
}
return fields
}
func filterParsedFieldsMap(fields map[string]any, names []string) map[string]any {
if len(names) == 0 || len(fields) == 0 {
return fields
}
out := make(map[string]any, len(names))
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if value, ok := fields[name]; ok {
out[name] = value
}
}
return out
}
func nullFloat64(value sql.NullFloat64) float64 {
if value.Valid {
return value.Float64
}
return 0
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func tdengineStatus(db *sql.DB) string {
if db == nil {
return "warning"
}
return "ok"
}
func tdengineDetail(db *sql.DB) string {
if db == nil {
return "TDengine 未配置或连接失败,历史查询降级"
}
return "读取 TDengine raw_frames / vehicle_locations"
}

View File

@@ -36,10 +36,10 @@ func TestBuildRawFrameSQL(t *testing.T) {
VIN: "VIN001",
Limit: 1,
})
if !strings.Contains(built.Text, "lingniu_vehicle_ts.raw_frames") || !strings.Contains(built.Text, "parsed_fields") {
if !strings.Contains(built.Text, "lingniu_vehicle_ts.raw_gb32960_") || !strings.Contains(built.Text, "parsed_fields") {
t.Fatalf("SQL = %s", built.Text)
}
if len(built.Args) != 4 || built.Args[0] != "GB32960" || built.Args[1] != "VIN001" || built.Args[2] != 1 {
if len(built.Args) != 0 || !strings.Contains(built.Text, "protocol = 'GB32960'") || !strings.Contains(built.Text, "vin = 'VIN001'") || !strings.Contains(built.Text, "LIMIT 1 OFFSET 0") {
t.Fatalf("args = %#v", built.Args)
}
}

View File

@@ -10,6 +10,7 @@ type Store interface {
Vehicles(context.Context, url.Values) (Page[VehicleRow], error)
RealtimeLocations(context.Context, url.Values) (Page[RealtimeLocationRow], error)
HistoryLocations(context.Context, url.Values) (Page[HistoryLocationRow], error)
HistoryLocationsFromTDengine(context.Context, url.Values) (Page[HistoryLocationRow], error)
RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error)
QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error)
@@ -48,7 +49,7 @@ func (s *Service) RealtimeLocations(ctx context.Context, query url.Values) (Page
}
func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
return s.store.HistoryLocations(ctx, query)
return s.store.HistoryLocationsFromTDengine(ctx, query)
}
func (s *Service) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {

View File

@@ -1,59 +1,78 @@
package platform
import "strings"
import (
"crypto/sha1"
"encoding/hex"
"strconv"
"strings"
"time"
)
func buildRawFrameSQL(database string, query RawFrameQuery) SQLQuery {
table := qualifyTDengine(database, "raw_frames")
where := []string{"1 = 1"}
table := rawFrameTable(database, query)
where := rawFrameWhere(query)
parsedFieldsSelect := "'' AS parsed_fields"
if query.IncludeFields || len(query.Fields) > 0 {
parsedFieldsSelect = "parsed_json AS parsed_fields"
}
args := []any{}
if query.Protocol != "" {
where = append(where, "protocol = ?")
args = append(args, query.Protocol)
}
if query.VIN != "" {
where = append(where, "vin = ?")
args = append(args, query.VIN)
}
if query.DateFrom != "" {
where = append(where, "ts >= ?")
args = append(args, query.DateFrom)
}
if query.DateTo != "" {
where = append(where, "ts <= ?")
args = append(args, query.DateTo)
}
limit := query.Limit
if limit <= 0 {
limit = 100
}
args = append(args, limit, query.Offset)
return SQLQuery{
Text: `SELECT ts, frame_id, event_time, received_at, raw_size_bytes, parsed_fields, parse_status, ` +
`parse_error, source_endpoint, protocol, vehicle_key, vin, phone FROM ` + table +
` WHERE ` + strings.Join(where, " AND ") + ` ORDER BY ts DESC LIMIT ? OFFSET ?`,
Args: args,
offset := query.Offset
if offset < 0 {
offset = 0
}
text := `SELECT ts, frame_id, event_time, received_at, raw_size_bytes, ` + parsedFieldsSelect +
`, parse_status, parse_error, source_endpoint, protocol, vehicle_key, vin, phone FROM ` + table
if len(where) > 0 {
text += ` WHERE ` + strings.Join(where, " AND ")
}
text += ` ORDER BY ts DESC LIMIT ` + strconv.Itoa(limit) + ` OFFSET ` + strconv.Itoa(offset)
return SQLQuery{Text: text, Args: args}
}
func rawFrameWhere(query RawFrameQuery) []string {
where := make([]string, 0, 4)
if query.Protocol != "" {
where = append(where, "protocol = '"+quoteTDengine(strings.ToUpper(strings.TrimSpace(query.Protocol)))+"'")
}
if query.VIN != "" {
where = append(where, "vin = '"+quoteTDengine(strings.TrimSpace(query.VIN))+"'")
}
if query.DateFrom != "" {
where = append(where, "ts >= '"+quoteTDengine(normalizeTDengineTime(query.DateFrom))+"'")
}
if query.DateTo != "" {
where = append(where, "ts <= '"+quoteTDengine(normalizeTDengineTime(query.DateTo))+"'")
}
return where
}
func buildHistoryLocationSQL(database string, query map[string]string) SQLQuery {
table := qualifyTDengine(database, "vehicle_locations")
where := []string{"1 = 1"}
table := locationTable(database, query)
where := make([]string, 0, 4)
args := []any{}
if protocol := strings.TrimSpace(query["protocol"]); protocol != "" {
where = append(where, "protocol = ?")
args = append(args, protocol)
where = append(where, "protocol = '"+quoteTDengine(strings.ToUpper(protocol))+"'")
}
if vin := strings.TrimSpace(query["vin"]); vin != "" {
where = append(where, "vin = ?")
args = append(args, vin)
where = append(where, "vin = '"+quoteTDengine(vin)+"'")
}
if dateFrom := strings.TrimSpace(query["dateFrom"]); dateFrom != "" {
where = append(where, "ts >= '"+quoteTDengine(normalizeTDengineTime(dateFrom))+"'")
}
if dateTo := strings.TrimSpace(query["dateTo"]); dateTo != "" {
where = append(where, "ts <= '"+quoteTDengine(normalizeTDengineTime(dateTo))+"'")
}
limit, offset := parseLimitOffset(query["limit"], query["offset"])
args = append(args, limit, offset)
return SQLQuery{
Text: `SELECT ts, vin, protocol, longitude, latitude, speed_kmh, total_mileage_km, event_time, received_at FROM ` +
table + ` WHERE ` + strings.Join(where, " AND ") + ` ORDER BY ts DESC LIMIT ? OFFSET ?`,
Args: args,
text := `SELECT ts, vin, protocol, longitude, latitude, speed_kmh, total_mileage_km, received_at FROM ` + table
if len(where) > 0 {
text += ` WHERE ` + strings.Join(where, " AND ")
}
text += ` ORDER BY ts DESC LIMIT ` + strconv.Itoa(limit) + ` OFFSET ` + strconv.Itoa(offset)
return SQLQuery{Text: text, Args: args}
}
func qualifyTDengine(database, table string) string {
@@ -62,3 +81,50 @@ func qualifyTDengine(database, table string) string {
}
return database + "." + table
}
func rawFrameTable(database string, query RawFrameQuery) string {
protocol := strings.ToUpper(strings.TrimSpace(query.Protocol))
vin := strings.TrimSpace(query.VIN)
if protocol == "" || vin == "" {
return qualifyTDengine(database, "raw_frames")
}
if protocol == "JT808" {
return qualifyTDengine(database, "raw_frames")
}
return qualifyTDengine(database, "raw_"+strings.ToLower(protocol)+"_"+hash16(vin))
}
func locationTable(database string, query map[string]string) string {
protocol := strings.ToUpper(strings.TrimSpace(query["protocol"]))
vin := strings.TrimSpace(query["vin"])
if protocol == "" || vin == "" {
return qualifyTDengine(database, "vehicle_locations")
}
return qualifyTDengine(database, "loc_"+strings.ToLower(protocol)+"_"+hash16(vin))
}
func hash16(value string) string {
sum := sha1.Sum([]byte(value))
return hex.EncodeToString(sum[:8])
}
func quoteTDengine(value string) string {
return strings.ReplaceAll(value, "'", "''")
}
func normalizeTDengineTime(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", "2006-01-02"} {
if parsed, err := time.ParseInLocation(layout, value, shanghai); err == nil {
return parsed.UTC().Format("2006-01-02 15:04:05")
}
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed.UTC().Format("2006-01-02 15:04:05")
}
return value
}