feat(server): integrate UDP video service and normalize database schema

- Implement UDPVideoService to stream MJPEG video frames from ESP32s via UDP and send control commands.
- Normalize race database schema:
  - Remove redundant driver_ids array from races table (migration 011).
  - Move per-driver outcomes (total_time_ms, best_lap_ms, position) to race_drivers table (migration 012).
  - Add device_id column to lobby_drivers to link active physical devices (migration 013).
- Update WebSocket handler to pass lobbySvc, driversSvc, and videoSvc for driver identity tracking, syncing in-memory driver metadata, and forwarding WS controls to physical cars.
- Add CORS middleware to HTTP routes.
- Regenerate Swagger documentation.
This commit is contained in:
x0gp
2026-07-06 12:38:34 +04:00
parent 978d36c505
commit 49351b4928
19 changed files with 1406 additions and 132 deletions
+212 -62
View File
@@ -2,9 +2,9 @@ package races
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -24,23 +24,44 @@ type PodiumEntry struct {
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
}
// DriverResult is the per-driver outcome of a race. Stored in
// race_drivers. DriverID + TotalTimeMs are required; BestLapMs is
// the driver's fastest lap; Position is 1..N when the driver
// finished, or NULL when DNF/DSQ. The aggregate podium and winner
// fields are derived from this row set on read.
type DriverResult struct {
DriverID string
TotalTimeMs int64
BestLapMs int64
Position *int // nil for DNF
}
// FinishedRace is the on-disk shape of a completed race. Mirrors the
// lobby.RaceMeta plus outcome fields populated by the engine at finish.
// lobby.RaceMeta plus aggregate outcome fields. Per-driver results
// (including the podium) live in race_drivers and are read into
// Results on demand.
type FinishedRace struct {
ID string
Name string
TrackID string
MaxCars int
Laps int
TimeLimitS int
DriverIDs []string
Status string // "finished" | "cancelled"
CreatedMs int64
StartedMs int64
FinishedMs int64
DurationMs int64
TotalLaps int
TotalDrivers int
ID string
Name string
TrackID string
MaxCars int
Laps int
TimeLimitS int
DriverIDs []string
Status string // "finished" | "cancelled"
CreatedMs int64
StartedMs int64
FinishedMs int64
DurationMs int64
TotalLaps int
TotalDrivers int
// Results is the per-driver race_drivers data, hydrated on read.
// Includes the winner and the podium.
Results []DriverResult
// Derived fields (computed in scanFinished from Results). The
// public API exposes them so existing clients keep working.
WinnerDriverID string
WinnerName string
BestLapMs int64
@@ -126,8 +147,8 @@ func (s *PgStore) UpsertLive(ctx context.Context, r lobby.RaceMeta) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO races
(id, name, track_id, max_cars, laps, time_limit_s, status,
created_ms, started_ms, finished_ms, updated_ms, podium)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, '[]'::jsonb)
created_ms, started_ms, finished_ms, updated_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
track_id = EXCLUDED.track_id,
@@ -363,8 +384,8 @@ func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) err
_, err := s.pool.Exec(ctx, `
INSERT INTO lobby_drivers
(driver_id, name, nickname, avatar_url, clan_id, clan_tag,
status, current_race_id, connected_ms, last_seen_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
status, current_race_id, connected_ms, last_seen_ms, device_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (driver_id) DO UPDATE SET
name = EXCLUDED.name,
nickname = EXCLUDED.nickname,
@@ -373,9 +394,10 @@ func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) err
clan_tag = EXCLUDED.clan_tag,
status = EXCLUDED.status,
current_race_id = EXCLUDED.current_race_id,
last_seen_ms = EXCLUDED.last_seen_ms`,
last_seen_ms = EXCLUDED.last_seen_ms,
device_id = EXCLUDED.device_id`,
d.ID, d.Name, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag,
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs)
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs, d.DeviceID)
if err != nil {
return fmt.Errorf("upsert lobby driver %s: %w", d.ID, err)
}
@@ -392,7 +414,7 @@ func (s *PgStore) DeleteLobbyDriver(ctx context.Context, driverID string) error
func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, error) {
rows, err := s.pool.Query(ctx, `
SELECT driver_id, name, nickname, avatar_url, clan_id, clan_tag,
status, current_race_id, connected_ms, last_seen_ms
status, current_race_id, connected_ms, last_seen_ms, device_id
FROM lobby_drivers ORDER BY last_seen_ms DESC`)
if err != nil {
return nil, err
@@ -403,7 +425,7 @@ func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, err
var d lobby.DriverMeta
var status string
if err := rows.Scan(&d.ID, &d.Name, &d.Nickname, &d.AvatarURL, &d.ClanID, &d.ClanTag,
&status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs); err != nil {
&status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs, &d.DeviceID); err != nil {
return nil, err
}
d.Status = lobby.DriverStatus(status)
@@ -416,41 +438,66 @@ func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, err
// finished_races
// ---------------------------------------------------------------------------
// InsertFinished upserts a finished race row.
// InsertFinished upserts a finished race row and replaces its
// race_drivers entries in a single transaction. Per-driver outcome
// (total_time_ms, best_lap_ms, position) lives on race_drivers;
// the race row itself carries only metadata.
func (s *PgStore) InsertFinished(ctx context.Context, r FinishedRace) error {
podium := r.Podium
if podium == nil {
podium = []PodiumEntry{}
}
podiumJSON, err := json.Marshal(podium)
if err != nil {
return fmt.Errorf("marshal podium: %w", err)
}
_, err = s.pool.Exec(ctx, `
_, err := s.pool.Exec(ctx, `
INSERT INTO races
(id, name, track_id, max_cars, laps, time_limit_s,
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
podium)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
status = EXCLUDED.status,
finished_ms = EXCLUDED.finished_ms,
duration_ms = EXCLUDED.duration_ms,
total_laps = EXCLUDED.total_laps,
total_drivers = EXCLUDED.total_drivers,
winner_driver_id = EXCLUDED.winner_driver_id,
winner_name = EXCLUDED.winner_name,
best_lap_ms = EXCLUDED.best_lap_ms,
podium = EXCLUDED.podium`,
total_drivers = EXCLUDED.total_drivers`,
r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS,
r.DriverIDs, r.Status, r.CreatedMs, r.StartedMs, r.FinishedMs, r.DurationMs,
r.TotalLaps, r.TotalDrivers, r.WinnerDriverID, r.WinnerName, r.BestLapMs,
podiumJSON)
r.Status, r.CreatedMs, r.StartedMs, r.FinishedMs, r.DurationMs,
r.TotalLaps, r.TotalDrivers)
if err != nil {
return fmt.Errorf("insert finished race %s: %w", r.ID, err)
}
// Replace race_drivers with the provided list atomically.
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM race_drivers WHERE race_id = $1`, r.ID); err != nil {
return fmt.Errorf("clear race_drivers for %s: %w", r.ID, err)
}
// Build a map from driver_id -> DriverResult for lookup.
resByDriver := make(map[string]DriverResult, len(r.Results))
for _, dr := range r.Results {
resByDriver[dr.DriverID] = dr
}
for i, did := range r.DriverIDs {
dr, ok := resByDriver[did]
_ = ok // missing result: total_time_ms=0, best_lap_ms=0, position=NULL
var totalTime, bestLap int64
var pos *int
if ok {
totalTime = dr.TotalTimeMs
bestLap = dr.BestLapMs
pos = dr.Position
}
if _, err := tx.Exec(ctx, `
INSERT INTO race_drivers
(race_id, driver_id, slot, joined_ms,
total_time_ms, best_lap_ms, position)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
r.ID, did, i, r.CreatedMs, totalTime, bestLap, pos); err != nil {
return fmt.Errorf("insert race_drivers %s: %w", r.ID, err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit race_drivers: %w", err)
}
return nil
}
@@ -474,9 +521,8 @@ func (s *PgStore) ListFinished(ctx context.Context, trackID string, cur Cursor,
}
args = append(args, limit+1)
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s,
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
podium
status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers
FROM races
WHERE ` + joinAnd(conds) + `
ORDER BY finished_ms DESC, id DESC
@@ -486,36 +532,130 @@ func (s *PgStore) ListFinished(ctx context.Context, trackID string, cur Cursor,
return nil, fmt.Errorf("list finished: %w", err)
}
defer rows.Close()
return scanFinished(rows)
return s.scanFinished(ctx, rows)
}
func scanFinished(rows pgx.Rows) ([]FinishedRace, error) {
// CountRaces returns the total number of races matching the status filter and trackID.
func (s *PgStore) CountRaces(ctx context.Context, statuses []string, trackID string) (int, error) {
args := []any{}
conds := []string{"1=1"}
if len(statuses) > 0 {
placeholders := make([]string, len(statuses))
for i, st := range statuses {
args = append(args, st)
placeholders[i] = fmt.Sprintf("$%d", len(args))
}
conds = append(conds, fmt.Sprintf("status IN (%s)", strings.Join(placeholders, ",")))
}
if trackID != "" {
args = append(args, trackID)
conds = append(conds, fmt.Sprintf("track_id = $%d", len(args)))
}
q := `SELECT COUNT(*) FROM races WHERE ` + joinAnd(conds)
var count int
err := s.pool.QueryRow(ctx, q, args...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count races: %w", err)
}
return count, nil
}
// scanFinished reads the rows from the SELECT above, then hydrates
// DriverIDs and the per-driver Results from race_drivers. The
// Winner* and Podium fields are derived from Results.
func (s *PgStore) scanFinished(ctx context.Context, rows pgx.Rows) ([]FinishedRace, error) {
out := make([]FinishedRace, 0)
for rows.Next() {
var r FinishedRace
var podiumJSON []byte
if err := rows.Scan(
&r.ID, &r.Name, &r.TrackID,
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.DriverIDs, &r.Status,
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.Status,
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
&r.TotalLaps, &r.TotalDrivers, &r.WinnerDriverID, &r.WinnerName, &r.BestLapMs,
&podiumJSON,
&r.TotalLaps, &r.TotalDrivers,
); err != nil {
return nil, err
}
if len(podiumJSON) > 0 {
if err := json.Unmarshal(podiumJSON, &r.Podium); err != nil {
return nil, fmt.Errorf("unmarshal podium: %w", err)
}
}
if r.Podium == nil {
r.Podium = []PodiumEntry{}
if err := s.hydrateFinished(ctx, &r); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// hydrateFinished pulls race_drivers for the given race into Results
// and derives the Winner* and Podium fields.
func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
rows, err := s.pool.Query(ctx, `
SELECT driver_id, total_time_ms, best_lap_ms, position
FROM race_drivers
WHERE race_id = $1
ORDER BY slot ASC, joined_ms ASC`, r.ID)
if err != nil {
return fmt.Errorf("hydrate race_drivers %s: %w", r.ID, err)
}
defer rows.Close()
r.Results = make([]DriverResult, 0)
r.DriverIDs = make([]string, 0)
r.Podium = make([]PodiumEntry, 0)
for rows.Next() {
var dr DriverResult
var pos *int
if err := rows.Scan(&dr.DriverID, &dr.TotalTimeMs, &dr.BestLapMs, &pos); err != nil {
return err
}
dr.Position = pos
r.Results = append(r.Results, dr)
r.DriverIDs = append(r.DriverIDs, dr.DriverID)
}
if err := rows.Err(); err != nil {
return err
}
// Derive winner (lowest total_time_ms among rows with time > 0).
var bestTotal int64 = -1
for _, dr := range r.Results {
if dr.TotalTimeMs <= 0 {
continue
}
if bestTotal == -1 || dr.TotalTimeMs < bestTotal {
bestTotal = dr.TotalTimeMs
r.WinnerDriverID = dr.DriverID
r.WinnerName = dr.DriverID
}
if dr.BestLapMs > 0 && (r.BestLapMs == 0 || dr.BestLapMs < r.BestLapMs) {
r.BestLapMs = dr.BestLapMs
}
}
// Derive podium: top-3 drivers by total_time_ms (> 0) ASC.
type row struct {
id string
t int64
best int64
}
var sorted []row
for _, dr := range r.Results {
if dr.TotalTimeMs <= 0 {
continue
}
sorted = append(sorted, row{dr.DriverID, dr.TotalTimeMs, dr.BestLapMs})
}
for i := 1; i < len(sorted); i++ {
for j := i; j > 0 && sorted[j-1].t > sorted[j].t; j-- {
sorted[j-1], sorted[j] = sorted[j], sorted[j-1]
}
}
for i := 0; i < len(sorted) && i < 3; i++ {
r.Podium = append(r.Podium, PodiumEntry{
Position: i + 1,
DriverID: sorted[i].id,
Name: sorted[i].id,
TotalTimeMs: sorted[i].t,
})
}
return nil
}
func joinAnd(parts []string) string {
out := ""
for i, p := range parts {
@@ -624,6 +764,16 @@ func scanPlans(rows pgx.Rows) ([]RacePlan, error) {
return out, rows.Err()
}
// CountPlans returns the total number of race plans.
func (s *PgStore) CountPlans(ctx context.Context) (int, error) {
var count int
err := s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM race_plans").Scan(&count)
if err != nil {
return 0, fmt.Errorf("count plans: %w", err)
}
return count, nil
}
// DeletePlan removes a plan by id.
func (s *PgStore) DeletePlan(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM race_plans WHERE id = $1`, id)