mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
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:
+212
-62
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user