feat(go-services): tracker-go — complete the Phase 1 read API

Adds the rest of the read-side endpoints to the Go tracker, all parity-checked
against the live Python service:

- DB reads: /stats/{c}, /portals, /spawns/heatmap, /server-health,
  /character-stats/{c} (stats_data JSONB merged to top level),
  /combat-stats[/{c}], /inventories, /inventory/{c}/search.
- 5-minute totals cache + /total-rares, /total-kills.
- Ingest-only state returned as Python's empty/default shapes (/quest-status,
  /vital-sharing/peers, /equipment-cantrip-state/{c}); /issues (flat file),
  /me (401 until cookie verification lands).
- Streaming reverse proxy to inventory-service (/inventory/{c},
  /inventory-characters, /search/*, /sets/list, /inv/{path...} incl. the SSE
  suitbuilder stream).
- compare/compare_endpoints.py: structural parity for all read endpoints +
  exact-match check for /character-stats and /combat-stats on OFFLINE chars
  (online chars legitimately differ — Python serves a richer live overlay that
  Phase-1 Go lacks until ingest).

Verified live: 14/14 endpoints structural-match, 8/8 rich offline chars
exact-match on /character-stats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 09:38:10 +02:00
parent 1af47520c0
commit c4e8190656
9 changed files with 908 additions and 10 deletions

View file

@ -2,6 +2,8 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
@ -52,6 +54,65 @@ func queryRowsAsMaps(ctx context.Context, pool *pgxpool.Pool, sql string, args .
return out, nil
}
// queryRowAsMap runs a query expected to return at most one row. It returns
// (nil, nil) when there are no rows, so callers can map that to a 404.
func queryRowAsMap(ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) (map[string]any, error) {
rows, err := pool.Query(ctx, sql, args...)
if err != nil {
return nil, err
}
m, err := pgx.CollectExactlyOneRow(rows, pgx.RowToMap)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return m, nil
}
// asJSONMap coerces a value that may be JSON bytes, a JSON string, or an
// already-decoded map into a map[string]any. Used for JSONB columns where pgx's
// decoding can vary. Returns nil if the value can't be interpreted as an object.
func asJSONMap(v any) map[string]any {
switch x := v.(type) {
case nil:
return nil
case map[string]any:
return x
case []byte:
var m map[string]any
if json.Unmarshal(x, &m) == nil {
return m
}
case string:
var m map[string]any
if json.Unmarshal([]byte(x), &m) == nil {
return m
}
}
return nil
}
// decodeJSONValue coerces a JSON/JSONB column value into its natural Go value
// (map, slice, scalar). Bytes/strings are unmarshaled; anything else is
// returned unchanged.
func decodeJSONValue(v any) any {
switch x := v.(type) {
case []byte:
var out any
if json.Unmarshal(x, &out) == nil {
return out
}
case string:
var out any
if json.Unmarshal([]byte(x), &out) == nil {
return out
}
}
return v
}
// pyISO formats a timestamp the way Python's datetime.isoformat() does for a
// UTC tz-aware value, so output matches FastAPI's jsonable_encoder:
// - no fractional part when microseconds are zero