feat: add realtime snapshot and location APIs
This commit is contained in:
@@ -46,6 +46,8 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/openapi.json", realtime.NewOpenAPIHandler())
|
||||||
|
mux.Handle("/swagger-ui/", realtime.NewSwaggerUIHandler())
|
||||||
mux.Handle("/api/realtime/vehicles/", realtime.NewHandler(repository))
|
mux.Handle("/api/realtime/vehicles/", realtime.NewHandler(repository))
|
||||||
closeStats := func() {}
|
closeStats := func() {}
|
||||||
var updater realtimeUpdater = repository
|
var updater realtimeUpdater = repository
|
||||||
@@ -73,13 +75,18 @@ func main() {
|
|||||||
logger.Info("realtime mysql snapshot enabled", "table", "vehicle_realtime_snapshot", "plate_binding_table", bindingTable)
|
logger.Info("realtime mysql snapshot enabled", "table", "vehicle_realtime_snapshot", "plate_binding_table", bindingTable)
|
||||||
}
|
}
|
||||||
mux.Handle("/api/stats/daily-metrics", stats.NewMetricHandler(stats.NewMetricRepository(db)))
|
mux.Handle("/api/stats/daily-metrics", stats.NewMetricHandler(stats.NewMetricRepository(db)))
|
||||||
|
mux.Handle("/api/realtime/snapshots", realtime.NewSnapshotQueryHandler(realtime.NewSnapshotQueryRepository(db)))
|
||||||
|
mux.Handle("/api/realtime/locations", realtime.NewLocationQueryHandler(realtime.NewLocationQueryRepository(db)))
|
||||||
logger.Info("stats mysql query enabled")
|
logger.Info("stats mysql query enabled")
|
||||||
} else {
|
} else {
|
||||||
mux.HandleFunc("/api/stats/daily-metrics", func(w http.ResponseWriter, _ *http.Request) {
|
mysqlUnavailable := func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "MYSQL_DSN is not configured"})
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": "MYSQL_DSN is not configured"})
|
||||||
})
|
}
|
||||||
|
mux.HandleFunc("/api/stats/daily-metrics", mysqlUnavailable)
|
||||||
|
mux.HandleFunc("/api/realtime/snapshots", mysqlUnavailable)
|
||||||
|
mux.HandleFunc("/api/realtime/locations", mysqlUnavailable)
|
||||||
logger.Warn("MYSQL_DSN is empty; stats query api disabled")
|
logger.Warn("MYSQL_DSN is empty; stats query api disabled")
|
||||||
}
|
}
|
||||||
defer closeStats()
|
defer closeStats()
|
||||||
|
|||||||
386
go/vehicle-gateway/internal/realtime/mysql_query.go
Normal file
386
go/vehicle-gateway/internal/realtime/mysql_query.go
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
package realtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mysqlQueryer interface {
|
||||||
|
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RealtimeTableQuery struct {
|
||||||
|
Protocol string
|
||||||
|
VIN string
|
||||||
|
Plate string
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapshotRow struct {
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
EventTime string `json:"event_time,omitempty"`
|
||||||
|
ReceivedAt string `json:"received_at,omitempty"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocationRow struct {
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
EventTime string `json:"event_time,omitempty"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
SpeedKMH *float64 `json:"speed_kmh,omitempty"`
|
||||||
|
TotalMileageKM *float64 `json:"total_mileage_km,omitempty"`
|
||||||
|
SOCPercent *float64 `json:"soc_percent,omitempty"`
|
||||||
|
AltitudeM *float64 `json:"altitude_m,omitempty"`
|
||||||
|
DirectionDeg *float64 `json:"direction_deg,omitempty"`
|
||||||
|
AlarmFlag *int64 `json:"alarm_flag,omitempty"`
|
||||||
|
StatusFlag *int64 `json:"status_flag,omitempty"`
|
||||||
|
ReceivedAt string `json:"received_at,omitempty"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapshotQueryRepository struct {
|
||||||
|
db mysqlQueryer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSnapshotQueryRepository(db mysqlQueryer) *SnapshotQueryRepository {
|
||||||
|
if db == nil {
|
||||||
|
panic("snapshot query db must not be nil")
|
||||||
|
}
|
||||||
|
return &SnapshotQueryRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SnapshotQueryRepository) Count(ctx context.Context, query RealtimeTableQuery) (int64, error) {
|
||||||
|
sqlText, args := buildRealtimeCountSQL("vehicle_realtime_snapshot", normalizeRealtimeTableQuery(query))
|
||||||
|
return queryCount(ctx, r.db, sqlText, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SnapshotQueryRepository) Query(ctx context.Context, query RealtimeTableQuery) ([]SnapshotRow, error) {
|
||||||
|
query = normalizeRealtimeTableQuery(query)
|
||||||
|
sqlText, args := buildRealtimeSelectSQL(
|
||||||
|
"vehicle_realtime_snapshot",
|
||||||
|
"protocol, vin, plate, event_time, received_at, event_id, created_at, updated_at",
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]SnapshotRow, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var row SnapshotRow
|
||||||
|
var eventTime, receivedAt, createdAt, updatedAt scanSQLDateTime
|
||||||
|
if err := rows.Scan(&row.Protocol, &row.VIN, &row.Plate, &eventTime, &receivedAt, &row.EventID, &createdAt, &updatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.EventTime = eventTime.String
|
||||||
|
row.ReceivedAt = receivedAt.String
|
||||||
|
row.CreatedAt = createdAt.String
|
||||||
|
row.UpdatedAt = updatedAt.String
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocationQueryRepository struct {
|
||||||
|
db mysqlQueryer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLocationQueryRepository(db mysqlQueryer) *LocationQueryRepository {
|
||||||
|
if db == nil {
|
||||||
|
panic("location query db must not be nil")
|
||||||
|
}
|
||||||
|
return &LocationQueryRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LocationQueryRepository) Count(ctx context.Context, query RealtimeTableQuery) (int64, error) {
|
||||||
|
sqlText, args := buildRealtimeCountSQL("vehicle_realtime_location", normalizeRealtimeTableQuery(query))
|
||||||
|
return queryCount(ctx, r.db, sqlText, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LocationQueryRepository) Query(ctx context.Context, query RealtimeTableQuery) ([]LocationRow, error) {
|
||||||
|
query = normalizeRealtimeTableQuery(query)
|
||||||
|
sqlText, args := buildRealtimeSelectSQL(
|
||||||
|
"vehicle_realtime_location",
|
||||||
|
"protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, created_at, updated_at",
|
||||||
|
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 eventTime, receivedAt, createdAt, updatedAt scanSQLDateTime
|
||||||
|
var speed, mileage, soc, altitude, direction sql.NullFloat64
|
||||||
|
var alarm, status sql.NullInt64
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.Protocol,
|
||||||
|
&row.VIN,
|
||||||
|
&row.Plate,
|
||||||
|
&eventTime,
|
||||||
|
&row.Latitude,
|
||||||
|
&row.Longitude,
|
||||||
|
&speed,
|
||||||
|
&mileage,
|
||||||
|
&soc,
|
||||||
|
&altitude,
|
||||||
|
&direction,
|
||||||
|
&alarm,
|
||||||
|
&status,
|
||||||
|
&receivedAt,
|
||||||
|
&row.EventID,
|
||||||
|
&createdAt,
|
||||||
|
&updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.EventTime = eventTime.String
|
||||||
|
row.SpeedKMH = nullableFloat(speed)
|
||||||
|
row.TotalMileageKM = nullableFloat(mileage)
|
||||||
|
row.SOCPercent = nullableFloat(soc)
|
||||||
|
row.AltitudeM = nullableFloat(altitude)
|
||||||
|
row.DirectionDeg = nullableFloat(direction)
|
||||||
|
row.AlarmFlag = nullableInt(alarm)
|
||||||
|
row.StatusFlag = nullableInt(status)
|
||||||
|
row.ReceivedAt = receivedAt.String
|
||||||
|
row.CreatedAt = createdAt.String
|
||||||
|
row.UpdatedAt = updatedAt.String
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapshotQueryHandler struct {
|
||||||
|
repository *SnapshotQueryRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSnapshotQueryHandler(repository *SnapshotQueryRepository) *SnapshotQueryHandler {
|
||||||
|
if repository == nil {
|
||||||
|
panic("snapshot query repository must not be nil")
|
||||||
|
}
|
||||||
|
return &SnapshotQueryHandler{repository: repository}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SnapshotQueryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Trim(r.URL.Path, "/") != "api/realtime/snapshots" {
|
||||||
|
writeError(w, http.StatusNotFound, "route not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query, err := parseRealtimeTableQuery(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := h.repository.Count(r.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := h.repository.Query(r.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writePage(w, rows, total, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocationQueryHandler struct {
|
||||||
|
repository *LocationQueryRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLocationQueryHandler(repository *LocationQueryRepository) *LocationQueryHandler {
|
||||||
|
if repository == nil {
|
||||||
|
panic("location query repository must not be nil")
|
||||||
|
}
|
||||||
|
return &LocationQueryHandler{repository: repository}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LocationQueryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Trim(r.URL.Path, "/") != "api/realtime/locations" {
|
||||||
|
writeError(w, http.StatusNotFound, "route not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query, err := parseRealtimeTableQuery(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := h.repository.Count(r.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := h.repository.Query(r.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writePage(w, rows, total, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRealtimeTableQuery(r *http.Request) (RealtimeTableQuery, error) {
|
||||||
|
values := r.URL.Query()
|
||||||
|
limit, err := parseRealtimeBoundedInt(values.Get("limit"), 50, 1, 1000, "limit")
|
||||||
|
if err != nil {
|
||||||
|
return RealtimeTableQuery{}, err
|
||||||
|
}
|
||||||
|
offset, err := parseRealtimeBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
|
||||||
|
if err != nil {
|
||||||
|
return RealtimeTableQuery{}, err
|
||||||
|
}
|
||||||
|
return normalizeRealtimeTableQuery(RealtimeTableQuery{
|
||||||
|
Protocol: values.Get("protocol"),
|
||||||
|
VIN: values.Get("vin"),
|
||||||
|
Plate: values.Get("plate"),
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRealtimeTableQuery(query RealtimeTableQuery) RealtimeTableQuery {
|
||||||
|
query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
|
||||||
|
query.VIN = strings.TrimSpace(query.VIN)
|
||||||
|
query.Plate = strings.TrimSpace(query.Plate)
|
||||||
|
if query.Limit <= 0 {
|
||||||
|
query.Limit = 50
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRealtimeCountSQL(table string, query RealtimeTableQuery) (string, []any) {
|
||||||
|
where, args := buildRealtimeWhere(query)
|
||||||
|
sqlText := "SELECT COUNT(*) FROM " + table
|
||||||
|
if len(where) > 0 {
|
||||||
|
sqlText += " WHERE " + strings.Join(where, " AND ")
|
||||||
|
}
|
||||||
|
return sqlText, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRealtimeSelectSQL(table string, columns string, query RealtimeTableQuery) (string, []any) {
|
||||||
|
where, args := buildRealtimeWhere(query)
|
||||||
|
sqlText := "SELECT " + columns + " FROM " + table
|
||||||
|
if len(where) > 0 {
|
||||||
|
sqlText += " WHERE " + strings.Join(where, " AND ")
|
||||||
|
}
|
||||||
|
sqlText += " ORDER BY updated_at DESC LIMIT ? OFFSET ?"
|
||||||
|
args = append(args, query.Limit, query.Offset)
|
||||||
|
return sqlText, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRealtimeWhere(query RealtimeTableQuery) ([]string, []any) {
|
||||||
|
var where []string
|
||||||
|
var 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.Plate != "" {
|
||||||
|
where = append(where, "plate = ?")
|
||||||
|
args = append(args, query.Plate)
|
||||||
|
}
|
||||||
|
return where, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryCount(ctx context.Context, db mysqlQueryer, 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 parseRealtimeBoundedInt(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 writePage(w http.ResponseWriter, items any, total int64, query RealtimeTableQuery) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": query.Limit,
|
||||||
|
"offset": query.Offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanSQLDateTime struct {
|
||||||
|
String string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scanSQLDateTime) Scan(value any) error {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
s.String = ""
|
||||||
|
case time.Time:
|
||||||
|
s.String = typed.Format("2006-01-02 15:04:05.000")
|
||||||
|
case []byte:
|
||||||
|
s.String = strings.TrimSpace(string(typed))
|
||||||
|
case string:
|
||||||
|
s.String = strings.TrimSpace(typed)
|
||||||
|
default:
|
||||||
|
s.String = strings.TrimSpace(fmt.Sprint(typed))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
101
go/vehicle-gateway/internal/realtime/mysql_query_test.go
Normal file
101
go/vehicle-gateway/internal/realtime/mysql_query_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package realtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSnapshotQueryHandlerReturnsRealtimeSnapshots(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock.New() error = %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_realtime_snapshot").
|
||||||
|
WithArgs("GB32960", "LB9A32A21R0LS1707").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
|
||||||
|
mock.ExpectQuery("SELECT protocol, vin, plate, event_time, received_at, event_id, created_at, updated_at FROM vehicle_realtime_snapshot").
|
||||||
|
WithArgs("GB32960", "LB9A32A21R0LS1707", 20, 0).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"protocol", "vin", "plate", "event_time", "received_at", "event_id", "created_at", "updated_at",
|
||||||
|
}).AddRow(
|
||||||
|
"GB32960", "LB9A32A21R0LS1707", "浙A12345", "2026-07-02 16:01:02.123", "2026-07-02 16:01:03.456", "evt-1", "2026-07-02 16:01:03", "2026-07-02 16:01:04",
|
||||||
|
))
|
||||||
|
|
||||||
|
handler := NewSnapshotQueryHandler(NewSnapshotQueryRepository(db))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/realtime/snapshots?protocol=gb32960&vin=LB9A32A21R0LS1707&limit=20", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, want := range []string{`"protocol":"GB32960"`, `"vin":"LB9A32A21R0LS1707"`, `"plate":"浙A12345"`, `"total":1`, `"limit":20`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("response missing %s: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocationQueryHandlerReturnsRealtimeLocations(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock.New() error = %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_realtime_location").
|
||||||
|
WithArgs("JT808", "粤B98765").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
|
||||||
|
mock.ExpectQuery("SELECT protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, created_at, updated_at FROM vehicle_realtime_location").
|
||||||
|
WithArgs("JT808", "粤B98765", 10, 10).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"protocol", "vin", "plate", "event_time", "latitude", "longitude", "speed_kmh", "total_mileage_km",
|
||||||
|
"soc_percent", "altitude_m", "direction_deg", "alarm_flag", "status_flag", "received_at", "event_id", "created_at", "updated_at",
|
||||||
|
}).AddRow(
|
||||||
|
"JT808", "LKLG7C4E3NA774736", "粤B98765", "2026-07-02 16:11:02.000", 30.123456, 120.654321, 54.3, 48798.9,
|
||||||
|
nil, 19.0, 88.0, int64(0), int64(3), "2026-07-02 16:11:03.000", "evt-2", "2026-07-02 16:11:03", "2026-07-02 16:11:04",
|
||||||
|
))
|
||||||
|
|
||||||
|
handler := NewLocationQueryHandler(NewLocationQueryRepository(db))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/realtime/locations?protocol=jt808&plate=粤B98765&limit=10&offset=10", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, want := range []string{`"protocol":"JT808"`, `"latitude":30.123456`, `"longitude":120.654321`, `"total_mileage_km":48798.9`, `"offset":10`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("response missing %s: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeQueryHandlerRejectsInvalidPagination(t *testing.T) {
|
||||||
|
handler := NewSnapshotQueryHandler(NewSnapshotQueryRepository(&sql.DB{}))
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/realtime/snapshots?limit=1001", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
175
go/vehicle-gateway/internal/realtime/openapi.go
Normal file
175
go/vehicle-gateway/internal/realtime/openapi.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package realtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewOpenAPIHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Trim(r.URL.Path, "/") != "openapi.json" {
|
||||||
|
writeError(w, http.StatusNotFound, "route not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(realtimeOpenAPISpec())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSwaggerUIHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(swaggerHTML))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func realtimeOpenAPISpec() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": map[string]any{
|
||||||
|
"title": "Lingniu Vehicle Realtime API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MySQL realtime query APIs for vehicle_realtime_snapshot and vehicle_realtime_location.",
|
||||||
|
},
|
||||||
|
"paths": map[string]any{
|
||||||
|
"/api/realtime/snapshots": map[string]any{
|
||||||
|
"get": map[string]any{
|
||||||
|
"summary": "查询车辆实时快照",
|
||||||
|
"description": "从 vehicle_realtime_snapshot 查询各协议最新在线快照,支持协议、VIN、车牌过滤和分页。",
|
||||||
|
"tags": []string{"Realtime Snapshot API"},
|
||||||
|
"x-table": "vehicle_realtime_snapshot",
|
||||||
|
"parameters": commonRealtimeParameters(),
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{
|
||||||
|
"description": "Snapshot page",
|
||||||
|
"content": map[string]any{
|
||||||
|
"application/json": map[string]any{
|
||||||
|
"schema": map[string]any{"$ref": "#/components/schemas/SnapshotPage"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/realtime/locations": map[string]any{
|
||||||
|
"get": map[string]any{
|
||||||
|
"summary": "查询车辆实时位置",
|
||||||
|
"description": "从 vehicle_realtime_location 查询各协议最新位置,支持协议、VIN、车牌过滤和分页。",
|
||||||
|
"tags": []string{"Realtime Location API"},
|
||||||
|
"x-table": "vehicle_realtime_location",
|
||||||
|
"parameters": commonRealtimeParameters(),
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{
|
||||||
|
"description": "Location page",
|
||||||
|
"content": map[string]any{
|
||||||
|
"application/json": map[string]any{
|
||||||
|
"schema": map[string]any{"$ref": "#/components/schemas/LocationPage"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"components": map[string]any{
|
||||||
|
"schemas": map[string]any{
|
||||||
|
"SnapshotPage": pageSchema("#/components/schemas/SnapshotRow"),
|
||||||
|
"LocationPage": pageSchema("#/components/schemas/LocationRow"),
|
||||||
|
"SnapshotRow": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"protocol": stringSchema("GB32960"),
|
||||||
|
"vin": stringSchema("LB9A32A21R0LS1707"),
|
||||||
|
"plate": stringSchema("浙A12345"),
|
||||||
|
"event_time": stringSchema("2026-07-02 16:01:02.123"),
|
||||||
|
"received_at": stringSchema("2026-07-02 16:01:03.456"),
|
||||||
|
"event_id": stringSchema("event id"),
|
||||||
|
"created_at": stringSchema("2026-07-02 16:01:03"),
|
||||||
|
"updated_at": stringSchema("2026-07-02 16:01:04"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"LocationRow": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"protocol": stringSchema("JT808"),
|
||||||
|
"vin": stringSchema("LKLG7C4E3NA774736"),
|
||||||
|
"plate": stringSchema("粤B98765"),
|
||||||
|
"event_time": stringSchema("2026-07-02 16:11:02.000"),
|
||||||
|
"latitude": numberSchema(30.123456),
|
||||||
|
"longitude": numberSchema(120.654321),
|
||||||
|
"speed_kmh": numberSchema(54.3),
|
||||||
|
"total_mileage_km": numberSchema(48798.9),
|
||||||
|
"soc_percent": numberSchema(81.5),
|
||||||
|
"altitude_m": numberSchema(19.0),
|
||||||
|
"direction_deg": numberSchema(88.0),
|
||||||
|
"alarm_flag": integerSchema(0),
|
||||||
|
"status_flag": integerSchema(3),
|
||||||
|
"received_at": stringSchema("2026-07-02 16:11:03.000"),
|
||||||
|
"event_id": stringSchema("event id"),
|
||||||
|
"created_at": stringSchema("2026-07-02 16:11:03"),
|
||||||
|
"updated_at": stringSchema("2026-07-02 16:11:04"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonRealtimeParameters() []map[string]any {
|
||||||
|
return []map[string]any{
|
||||||
|
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
|
||||||
|
{"name": "vin", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
|
||||||
|
{"name": "plate", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
|
||||||
|
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
|
||||||
|
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageSchema(itemRef string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"items": map[string]any{"type": "array", "items": map[string]any{"$ref": itemRef}},
|
||||||
|
"total": integerSchema(1),
|
||||||
|
"limit": integerSchema(50),
|
||||||
|
"offset": integerSchema(0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSchema(example string) map[string]any {
|
||||||
|
return map[string]any{"type": "string", "example": example}
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberSchema(example float64) map[string]any {
|
||||||
|
return map[string]any{"type": "number", "example": example}
|
||||||
|
}
|
||||||
|
|
||||||
|
func integerSchema(example int64) map[string]any {
|
||||||
|
return map[string]any{"type": "integer", "example": example}
|
||||||
|
}
|
||||||
|
|
||||||
|
const swaggerHTML = `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Lingniu Vehicle Realtime API</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
window.ui = SwaggerUIBundle({ url: "/openapi.json", dom_id: "#swagger-ui" });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
25
go/vehicle-gateway/internal/realtime/openapi_test.go
Normal file
25
go/vehicle-gateway/internal/realtime/openapi_test.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package realtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenAPIHandlerDocumentsRealtimeSnapshotAndLocation(t *testing.T) {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/openapi.json", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
NewOpenAPIHandler().ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, want := range []string{`"/api/realtime/snapshots"`, `"/api/realtime/locations"`, `"vehicle_realtime_snapshot"`, `"vehicle_realtime_location"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("openapi missing %s: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user