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:
parent
1af47520c0
commit
c4e8190656
9 changed files with 908 additions and 10 deletions
110
go-services/compare/compare_endpoints.py
Normal file
110
go-services/compare/compare_endpoints.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Structural + value parity check for the tracker-go read API vs the Python service.
|
||||||
|
|
||||||
|
Run on the server (loopback access to both, plus `docker exec dereth-db` for the
|
||||||
|
offline-character exact-match check):
|
||||||
|
python3 compare_endpoints.py
|
||||||
|
|
||||||
|
Most live endpoints can't be value-equal byte-for-byte (the firehose updates
|
||||||
|
between fetches), so we assert:
|
||||||
|
* status code + top-level key-set parity for every read endpoint, and
|
||||||
|
* EXACT equality of /character-stats and /combat-stats for *offline*
|
||||||
|
characters (where Python also falls back to the DB, like Go). For online
|
||||||
|
characters Python serves a richer live in-memory overlay that Phase-1 Go
|
||||||
|
intentionally lacks (no ingest yet) — that difference is expected.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
PY = "http://127.0.0.1:8765"
|
||||||
|
GO = "http://127.0.0.1:8770"
|
||||||
|
|
||||||
|
|
||||||
|
def get(base, path):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(base + path, timeout=12) as r:
|
||||||
|
return r.status, json.load(r)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, None
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return "ERR:" + str(e)[:40], None
|
||||||
|
|
||||||
|
|
||||||
|
def topkeys(d):
|
||||||
|
if isinstance(d, dict):
|
||||||
|
return sorted(d.keys())
|
||||||
|
if isinstance(d, list):
|
||||||
|
return ["[list]"]
|
||||||
|
return [type(d).__name__]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
failures = 0
|
||||||
|
_, live = get(PY, "/live")
|
||||||
|
ch = live["players"][0]["character_name"] if live and live.get("players") else "Nobody"
|
||||||
|
chq = urllib.parse.quote(ch)
|
||||||
|
print(f"sample online character: {ch}\n")
|
||||||
|
|
||||||
|
endpoints = [
|
||||||
|
"/total-rares", "/total-kills", "/server-health", "/portals",
|
||||||
|
"/spawns/heatmap?hours=2", "/combat-stats", "/inventories",
|
||||||
|
"/quest-status", "/vital-sharing/peers",
|
||||||
|
f"/stats/{chq}", f"/combat-stats/{chq}",
|
||||||
|
f"/inventory/{chq}/search", "/sets/list", "/inventory-characters",
|
||||||
|
]
|
||||||
|
print(f"{'endpoint':<36} {'py':>5} {'go':>5} keys")
|
||||||
|
for ep in endpoints:
|
||||||
|
ps, pj = get(PY, ep)
|
||||||
|
gs, gj = get(GO, ep)
|
||||||
|
pk, gk = topkeys(pj), topkeys(gj)
|
||||||
|
ok = ps == gs and pk == gk
|
||||||
|
if not ok:
|
||||||
|
failures += 1
|
||||||
|
print(f"{ep:<36} {str(ps):>5} {str(gs):>5} {'OK' if ok else 'MISMATCH py=%s go=%s' % (pk, gk)}")
|
||||||
|
|
||||||
|
# Online-overlay endpoints: only structural note (expected to differ for online chars).
|
||||||
|
for ep in (f"/character-stats/{chq}", f"/equipment-cantrip-state/{chq}"):
|
||||||
|
ps, _ = get(PY, ep)
|
||||||
|
gs, _ = get(GO, ep)
|
||||||
|
print(f"{ep:<36} {str(ps):>5} {str(gs):>5} (online live-overlay; exact match only for offline chars)")
|
||||||
|
|
||||||
|
# Offline-character exact-match check.
|
||||||
|
print("\n-- offline-character exact match (/character-stats, /combat-stats) --")
|
||||||
|
try:
|
||||||
|
online = {p["character_name"] for p in live["players"]}
|
||||||
|
names = subprocess.check_output(
|
||||||
|
["docker", "exec", "dereth-db", "psql", "-U", "postgres", "-d", "dereth",
|
||||||
|
"-tA", "-c", "SELECT character_name FROM character_stats"], text=True)
|
||||||
|
off = [n for n in names.split("\n") if n.strip() and n not in online]
|
||||||
|
tested = matched = 0
|
||||||
|
for ch in off:
|
||||||
|
q = urllib.parse.quote(ch)
|
||||||
|
_, py = get(PY, f"/character-stats/{q}")
|
||||||
|
_, go = get(GO, f"/character-stats/{q}")
|
||||||
|
if not (isinstance(py, dict) and len(py.keys()) >= 18):
|
||||||
|
continue
|
||||||
|
tested += 1
|
||||||
|
same = py == go
|
||||||
|
matched += same
|
||||||
|
if not same:
|
||||||
|
failures += 1
|
||||||
|
print(f" MISMATCH {ch}: keydiff={set(py) ^ set(go)}")
|
||||||
|
if tested >= 8:
|
||||||
|
break
|
||||||
|
print(f" {matched}/{tested} rich offline chars exact-match")
|
||||||
|
if tested == 0:
|
||||||
|
print(" (no offline rich characters available to test)")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" (skipped DB-backed offline check: {e})")
|
||||||
|
|
||||||
|
print("\n" + ("RESULT: read-API parity OK" if failures == 0
|
||||||
|
else f"RESULT: {failures} mismatch(es)"))
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -29,6 +29,7 @@ services:
|
||||||
PORT: "8770"
|
PORT: "8770"
|
||||||
# Read-only use of the same dereth TimescaleDB the Python tracker writes.
|
# Read-only use of the same dereth TimescaleDB the Python tracker writes.
|
||||||
DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/dereth"
|
DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/dereth"
|
||||||
|
INVENTORY_SERVICE_URL: "http://inventory-service:8000"
|
||||||
LOG_LEVEL: "INFO"
|
LOG_LEVEL: "INFO"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
|
||||||
87
go-services/tracker-go/charstats.go
Normal file
87
go-services/tracker-go/charstats.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /character-stats/{name} — latest full stats. Phase 1 reads the DB
|
||||||
|
// (character_stats is authoritative); the live_character_stats overlay is an
|
||||||
|
// ingest-only freshness layer we don't have yet. (main.py:4137)
|
||||||
|
func (s *Server) handleCharacterStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
ctx, cancel := reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
row, err := queryRowAsMap(ctx, s.pool, `SELECT * FROM character_stats WHERE character_name = $1`, name)
|
||||||
|
if err != nil {
|
||||||
|
s.dbErr(w, "character-stats", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": "No stats available for this character"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Merge stats_data JSONB up to the top level, matching the frontend contract.
|
||||||
|
sd := asJSONMap(row["stats_data"])
|
||||||
|
delete(row, "stats_data")
|
||||||
|
formatTimes([]map[string]any{row}, "timestamp")
|
||||||
|
for k, v := range sd {
|
||||||
|
row[k] = v
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /combat-stats/{character_name} — lifetime combat blob. Phase 1: DB only,
|
||||||
|
// so session is always null. (main.py:1819)
|
||||||
|
func (s *Server) handleCombatStatsOne(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cn := r.PathValue("character_name")
|
||||||
|
ctx, cancel := reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
row, err := queryRowAsMap(ctx, s.pool, `SELECT stats_data FROM combat_stats WHERE character_name = $1`, cn)
|
||||||
|
if err != nil {
|
||||||
|
s.dbErr(w, "combat-stats/one", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"character_name": cn, "session": nil, "lifetime": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"character_name": cn,
|
||||||
|
"session": nil,
|
||||||
|
"lifetime": decodeJSONValue(row["stats_data"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /combat-stats — all characters' lifetime combat blobs. Phase 1: DB only. (main.py:1850)
|
||||||
|
func (s *Server) handleCombatStatsAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rows, err := queryRowsAsMaps(ctx, s.pool, `SELECT character_name, stats_data FROM combat_stats`)
|
||||||
|
if err != nil {
|
||||||
|
s.dbErr(w, "combat-stats/all", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
results = append(results, map[string]any{
|
||||||
|
"character_name": row["character_name"],
|
||||||
|
"session": nil,
|
||||||
|
"lifetime": decodeJSONValue(row["stats_data"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return toStr(results[i]["character_name"]) < toStr(results[j]["character_name"])
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"stats": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStr(v any) string {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
@ -32,9 +33,12 @@ var buildVersion = "dev"
|
||||||
|
|
||||||
// Server holds the shared dependencies for HTTP handlers.
|
// Server holds the shared dependencies for HTTP handlers.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
cache *liveCache
|
cache *liveCache
|
||||||
log *slog.Logger
|
totals *totalsCache
|
||||||
|
invProxy *httputil.ReverseProxy
|
||||||
|
staticDir string
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -47,12 +51,23 @@ func main() {
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
srv := &Server{cache: newLiveCache(), log: logger}
|
srv := &Server{
|
||||||
|
cache: newLiveCache(),
|
||||||
|
totals: newTotalsCache(),
|
||||||
|
staticDir: cfg.StaticDir,
|
||||||
|
log: logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory-service reverse proxy (independent of the DB).
|
||||||
|
if err := srv.initInvProxy(cfg.InventoryURL); err != nil {
|
||||||
|
logger.Error("inventory proxy init failed", "err", err, "target", cfg.InventoryURL)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to the dereth DB (read-only). If DATABASE_URL is unset we still
|
// Connect to the dereth DB (read-only). If DATABASE_URL is unset we still
|
||||||
// serve health/version (Phase-0 mode) so the container is observable.
|
// serve health/version (Phase-0 mode) so the container is observable.
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
logger.Warn("DATABASE_URL unset — running without DB; /live and /trails will be empty")
|
logger.Warn("DATABASE_URL unset — running without DB; DB-backed endpoints will be empty")
|
||||||
} else {
|
} else {
|
||||||
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
pool, err := newPool(connectCtx, cfg.DatabaseURL)
|
pool, err := newPool(connectCtx, cfg.DatabaseURL)
|
||||||
|
|
@ -64,7 +79,8 @@ func main() {
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
srv.pool = pool
|
srv.pool = pool
|
||||||
go srv.runCacheLoop(ctx)
|
go srv.runCacheLoop(ctx)
|
||||||
logger.Info("db connected; live cache loop started", "interval", cacheInterval.String())
|
go srv.runTotalsLoop(ctx)
|
||||||
|
logger.Info("db connected; cache loops started", "live_interval", cacheInterval.String(), "totals_interval", totalsInterval.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
@ -97,14 +113,18 @@ func main() {
|
||||||
// config holds runtime configuration sourced from environment variables,
|
// config holds runtime configuration sourced from environment variables,
|
||||||
// matching the Python service's env var names where they overlap.
|
// matching the Python service's env var names where they overlap.
|
||||||
type config struct {
|
type config struct {
|
||||||
Addr string // listen address, e.g. ":8770"
|
Addr string // listen address, e.g. ":8770"
|
||||||
DatabaseURL string // dereth TimescaleDB DSN (read-only use)
|
DatabaseURL string // dereth TimescaleDB DSN (read-only use)
|
||||||
|
InventoryURL string // inventory-service base URL
|
||||||
|
StaticDir string // directory for static assets / openissues.json
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() config {
|
func loadConfig() config {
|
||||||
return config{
|
return config{
|
||||||
Addr: ":" + envOr("PORT", "8770"),
|
Addr: ":" + envOr("PORT", "8770"),
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
|
InventoryURL: envOr("INVENTORY_SERVICE_URL", "http://inventory-service:8000"),
|
||||||
|
StaticDir: envOr("STATIC_DIR", "static"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,11 +139,39 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /health", s.handleHealth)
|
mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
// Mirrors Python's GET /api-version (hyphenated so nginx never strips it).
|
// Mirrors Python's GET /api-version (hyphenated so nginx never strips it).
|
||||||
mux.HandleFunc("GET /api-version", s.handleVersion)
|
mux.HandleFunc("GET /api-version", s.handleVersion)
|
||||||
|
|
||||||
// Phase 1 read-side: the 5s caches.
|
// Phase 1 read-side: the 5s caches.
|
||||||
mux.HandleFunc("GET /live", s.handleLive)
|
mux.HandleFunc("GET /live", s.handleLive)
|
||||||
mux.HandleFunc("GET /live/", s.handleLive)
|
mux.HandleFunc("GET /live/", s.handleLive)
|
||||||
mux.HandleFunc("GET /trails", s.handleTrails)
|
mux.HandleFunc("GET /trails", s.handleTrails)
|
||||||
mux.HandleFunc("GET /trails/", s.handleTrails)
|
mux.HandleFunc("GET /trails/", s.handleTrails)
|
||||||
|
|
||||||
|
// Totals (5-minute caches).
|
||||||
|
mux.HandleFunc("GET /total-rares", s.handleTotalRares)
|
||||||
|
mux.HandleFunc("GET /total-rares/", s.handleTotalRares)
|
||||||
|
mux.HandleFunc("GET /total-kills", s.handleTotalKills)
|
||||||
|
mux.HandleFunc("GET /total-kills/", s.handleTotalKills)
|
||||||
|
|
||||||
|
// Per-character & aggregate DB reads.
|
||||||
|
mux.HandleFunc("GET /stats/{character_name}", s.handleStats)
|
||||||
|
mux.HandleFunc("GET /portals", s.handlePortals)
|
||||||
|
mux.HandleFunc("GET /spawns/heatmap", s.handleSpawnHeatmap)
|
||||||
|
mux.HandleFunc("GET /server-health", s.handleServerHealth)
|
||||||
|
mux.HandleFunc("GET /character-stats/{name}", s.handleCharacterStats)
|
||||||
|
mux.HandleFunc("GET /combat-stats", s.handleCombatStatsAll)
|
||||||
|
mux.HandleFunc("GET /combat-stats/{character_name}", s.handleCombatStatsOne)
|
||||||
|
mux.HandleFunc("GET /inventories", s.handleInventories)
|
||||||
|
mux.HandleFunc("GET /inventory/{character_name}/search", s.handleInventorySearch)
|
||||||
|
|
||||||
|
// Ingest-only state (empty/default in Phase 1).
|
||||||
|
mux.HandleFunc("GET /quest-status", s.handleQuestStatus)
|
||||||
|
mux.HandleFunc("GET /vital-sharing/peers", s.handleVitalSharingPeers)
|
||||||
|
mux.HandleFunc("GET /equipment-cantrip-state/{name}", s.handleEquipmentCantrip)
|
||||||
|
mux.HandleFunc("GET /issues", s.handleIssues)
|
||||||
|
mux.HandleFunc("GET /me", s.handleMe)
|
||||||
|
|
||||||
|
// Inventory-service reverse proxies.
|
||||||
|
s.registerProxyRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
70
go-services/tracker-go/memstate.go
Normal file
70
go-services/tracker-go/memstate.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These endpoints are backed by ingest-only in-memory state in the Python
|
||||||
|
// service (populated from /ws/position events). Phase 1 has no ingest, so they
|
||||||
|
// return the same empty/default shapes the Python service emits when no data is
|
||||||
|
// present — preserving the API contract for the frontend.
|
||||||
|
|
||||||
|
// GET /quest-status (main.py:1940)
|
||||||
|
func (s *Server) handleQuestStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"quest_data": map[string]any{},
|
||||||
|
"tracked_quests": []string{
|
||||||
|
"Stipend Collection Timer",
|
||||||
|
"Blank Augmentation Gem Pickup Timer",
|
||||||
|
"Insatiable Eater Jaw",
|
||||||
|
},
|
||||||
|
"player_count": 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /vital-sharing/peers (main.py:1800)
|
||||||
|
func (s *Server) handleVitalSharingPeers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"peers": []any{},
|
||||||
|
"subscriber_count": 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /equipment-cantrip-state/{name} (main.py:4167)
|
||||||
|
func (s *Server) handleEquipmentCantrip(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"type": "equipment_cantrip_state",
|
||||||
|
"character_name": name,
|
||||||
|
"items": []any{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /issues — flat-file issue board. (main.py:1709)
|
||||||
|
func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) {
|
||||||
|
issues := s.loadIssues()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"issues": issues})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) loadIssues() []any {
|
||||||
|
empty := []any{}
|
||||||
|
b, err := os.ReadFile(filepath.Join(s.staticDir, "openissues.json"))
|
||||||
|
if err != nil {
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
var v []any
|
||||||
|
if json.Unmarshal(b, &v) != nil {
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /me — current user. Phase 1 has no session-cookie verification yet, so
|
||||||
|
// (like the Python service for an unauthenticated request) this is 401. The
|
||||||
|
// loopback internal-trust path carries no user identity. (main.py:1455)
|
||||||
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Not authenticated"})
|
||||||
|
}
|
||||||
74
go-services/tracker-go/proxy.go
Normal file
74
go-services/tracker-go/proxy.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initInvProxy builds a streaming reverse proxy to the inventory-service.
|
||||||
|
// FlushInterval=-1 flushes writes immediately so SSE endpoints (the suitbuilder
|
||||||
|
// search stream) work. Connection errors map to 503, mirroring the Python
|
||||||
|
// service's "Inventory service unavailable".
|
||||||
|
func (s *Server) initInvProxy(target string) error {
|
||||||
|
u, err := url.Parse(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rp := httputil.NewSingleHostReverseProxy(u)
|
||||||
|
rp.FlushInterval = -1
|
||||||
|
rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
s.log.Error("inventory proxy error", "err", err, "path", r.URL.Path)
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "Inventory service unavailable"})
|
||||||
|
}
|
||||||
|
s.invProxy = rp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyInv returns a handler that rewrites the request path (via rewrite) and
|
||||||
|
// forwards it to the inventory-service, preserving method, query, headers, and
|
||||||
|
// body. The original /inv/* prefix etc. is mapped to the upstream path.
|
||||||
|
func (s *Server) proxyInv(rewrite func(r *http.Request) string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.invProxy == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "Inventory service unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.URL.Path = rewrite(r)
|
||||||
|
r.URL.RawPath = "" // force re-encode from the (decoded) Path
|
||||||
|
s.invProxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) registerProxyRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /inventory/{character_name}", s.proxyInv(func(r *http.Request) string {
|
||||||
|
return "/inventory/" + r.PathValue("character_name")
|
||||||
|
}))
|
||||||
|
mux.HandleFunc("GET /inventory-characters", s.proxyInv(func(r *http.Request) string {
|
||||||
|
return "/characters/list"
|
||||||
|
}))
|
||||||
|
mux.HandleFunc("GET /search/items", s.proxyInv(func(r *http.Request) string {
|
||||||
|
return "/search/items"
|
||||||
|
}))
|
||||||
|
mux.HandleFunc("GET /search/equipped/{character_name}", s.proxyInv(func(r *http.Request) string {
|
||||||
|
return "/search/equipped/" + r.PathValue("character_name")
|
||||||
|
}))
|
||||||
|
mux.HandleFunc("GET /search/upgrades/{character_name}/{slot}", s.proxyInv(func(r *http.Request) string {
|
||||||
|
return "/search/upgrades/" + r.PathValue("character_name") + "/" + r.PathValue("slot")
|
||||||
|
}))
|
||||||
|
mux.HandleFunc("GET /sets/list", s.proxyInv(func(r *http.Request) string {
|
||||||
|
return "/sets/list"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// /inv/test is a static liveness probe in the Python service.
|
||||||
|
mux.HandleFunc("GET /inv/test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"message": "Inventory proxy route is working"})
|
||||||
|
})
|
||||||
|
// Generic catch-all proxy: /inv/{path...} -> {SVC}/{path}. Covers GET and
|
||||||
|
// POST (incl. the SSE suitbuilder search). Registered for both methods.
|
||||||
|
invAll := s.proxyInv(func(r *http.Request) string {
|
||||||
|
return "/" + r.PathValue("path")
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /inv/{path...}", invAll)
|
||||||
|
mux.HandleFunc("POST /inv/{path...}", invAll)
|
||||||
|
}
|
||||||
367
go-services/tracker-go/reads.go
Normal file
367
go-services/tracker-go/reads.go
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reqCtx returns a child of the request context with a query timeout.
|
||||||
|
func reqCtx(r *http.Request) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dbErr(w http.ResponseWriter, where string, err error) {
|
||||||
|
s.log.Error("db query failed", "where", where, "err", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Internal server error"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /stats/{character_name} — latest telemetry snapshot + lifetime totals. (main.py:3927)
|
||||||
|
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cn := r.PathValue("character_name")
|
||||||
|
ctx, cancel := reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT * FROM telemetry_events
|
||||||
|
WHERE character_name = $1
|
||||||
|
ORDER BY timestamp DESC LIMIT 1
|
||||||
|
)
|
||||||
|
SELECT l.*,
|
||||||
|
COALESCE(cs.total_kills, 0) AS total_kills,
|
||||||
|
COALESCE(rs.total_rares, 0) AS total_rares
|
||||||
|
FROM latest l
|
||||||
|
LEFT JOIN char_stats cs ON l.character_name = cs.character_name
|
||||||
|
LEFT JOIN rare_stats rs ON l.character_name = rs.character_name`
|
||||||
|
|
||||||
|
row, err := queryRowAsMap(ctx, s.pool, sql, cn)
|
||||||
|
if err != nil {
|
||||||
|
s.dbErr(w, "stats", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "Character not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalKills := row["total_kills"]
|
||||||
|
totalRares := row["total_rares"]
|
||||||
|
delete(row, "total_kills")
|
||||||
|
delete(row, "total_rares")
|
||||||
|
formatTimes([]map[string]any{row}, "timestamp", "received_at")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"character_name": cn,
|
||||||
|
"latest_snapshot": row,
|
||||||
|
"total_kills": totalKills,
|
||||||
|
"total_rares": totalRares,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /portals — all active portals (cleanup job handles 1h expiry). (main.py:1959)
|
||||||
|
func (s *Server) handlePortals(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rows, err := queryRowsAsMaps(ctx, s.pool,
|
||||||
|
`SELECT portal_name, ns, ew, z, discovered_at, discovered_by FROM portals ORDER BY discovered_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
s.dbErr(w, "portals", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
portals := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
da := ""
|
||||||
|
if t, ok := row["discovered_at"].(time.Time); ok {
|
||||||
|
da = pyISO(t)
|
||||||
|
}
|
||||||
|
portals = append(portals, map[string]any{
|
||||||
|
"portal_name": row["portal_name"],
|
||||||
|
"coordinates": map[string]any{"ns": row["ns"], "ew": row["ew"], "z": row["z"]},
|
||||||
|
"discovered_at": da,
|
||||||
|
"discovered_by": row["discovered_by"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"portals": portals, "portal_count": len(portals)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /spawns/heatmap?hours=&limit= — aggregated spawn density. (main.py:2037)
|
||||||
|
func (s *Server) handleSpawnHeatmap(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hours := clampInt(queryInt(r, "hours", 24), 1, 168)
|
||||||
|
limit := clampInt(queryInt(r, "limit", 10000), 100, 50000)
|
||||||
|
ctx, cancel := reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||||
|
rows, err := queryRowsAsMaps(ctx, s.pool,
|
||||||
|
`SELECT ew, ns, COUNT(*) AS spawn_count FROM spawn_events
|
||||||
|
WHERE timestamp >= $1 GROUP BY ew, ns ORDER BY spawn_count DESC LIMIT $2`,
|
||||||
|
cutoff, limit)
|
||||||
|
if err != nil {
|
||||||
|
s.dbErr(w, "spawns/heatmap", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
points := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
points = append(points, map[string]any{
|
||||||
|
"ew": toFloat(row["ew"]),
|
||||||
|
"ns": toFloat(row["ns"]),
|
||||||
|
"intensity": toInt(row["spawn_count"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"spawn_points": points,
|
||||||
|
"total_points": len(points),
|
||||||
|
"timestamp": pyISO(time.Now().UTC()),
|
||||||
|
"hours_window": hours,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /server-health — current Coldeve status + computed uptime. (main.py:1881)
|
||||||
|
func (s *Server) handleServerHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
row, err := queryRowAsMap(ctx, s.pool, `SELECT * FROM server_status WHERE server_name = $1`, "Coldeve")
|
||||||
|
if err != nil {
|
||||||
|
s.dbErr(w, "server-health", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "unknown"
|
||||||
|
var latency, playerCount, lastRestart, lastCheck any
|
||||||
|
var uptimeSeconds int64
|
||||||
|
if row != nil {
|
||||||
|
if v, ok := row["current_status"].(string); ok && v != "" {
|
||||||
|
status = v
|
||||||
|
}
|
||||||
|
latency = row["last_latency_ms"]
|
||||||
|
playerCount = row["last_player_count"]
|
||||||
|
uptimeSeconds = toInt64(row["total_uptime_seconds"])
|
||||||
|
if t, ok := row["last_restart"].(time.Time); ok {
|
||||||
|
lastRestart = pyISO(t)
|
||||||
|
}
|
||||||
|
if t, ok := row["last_check"].(time.Time); ok {
|
||||||
|
lastCheck = pyISO(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
days := uptimeSeconds / 86400
|
||||||
|
hours := (uptimeSeconds % 86400) / 3600
|
||||||
|
minutes := (uptimeSeconds % 3600) / 60
|
||||||
|
uptime := fmt.Sprintf("%dh %dm", hours, minutes)
|
||||||
|
if days > 0 {
|
||||||
|
uptime = fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"server_name": "Coldeve",
|
||||||
|
"status": status,
|
||||||
|
"latency_ms": latency,
|
||||||
|
"player_count": playerCount,
|
||||||
|
"uptime": uptime,
|
||||||
|
"uptime_seconds": uptimeSeconds,
|
||||||
|
"last_restart": lastRestart,
|
||||||
|
"last_check": lastCheck,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /inventories — characters with stored inventories. (main.py:2212)
|
||||||
|
func (s *Server) handleInventories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rows, err := queryRowsAsMaps(ctx, s.pool,
|
||||||
|
`SELECT character_name, COUNT(*) AS item_count, MAX(timestamp) AS last_updated
|
||||||
|
FROM character_inventories GROUP BY character_name ORDER BY last_updated DESC`)
|
||||||
|
if err != nil {
|
||||||
|
s.dbErr(w, "inventories", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formatTimes(rows, "last_updated")
|
||||||
|
chars := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
chars = append(chars, map[string]any{
|
||||||
|
"character_name": row["character_name"],
|
||||||
|
"item_count": row["item_count"],
|
||||||
|
"last_updated": row["last_updated"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"characters": chars, "total_characters": len(chars)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /inventory/{character_name}/search — filtered local inventory rows. (main.py:2135)
|
||||||
|
func (s *Server) handleInventorySearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cn := r.PathValue("character_name")
|
||||||
|
name := optStr(r, "name")
|
||||||
|
objectClass := optInt(r, "object_class")
|
||||||
|
minValue := optInt(r, "min_value")
|
||||||
|
maxValue := optInt(r, "max_value")
|
||||||
|
minBurden := optInt(r, "min_burden")
|
||||||
|
maxBurden := optInt(r, "max_burden")
|
||||||
|
|
||||||
|
conds := []string{"character_name = $1"}
|
||||||
|
args := []any{cn}
|
||||||
|
add := func(tmpl string, val any) {
|
||||||
|
args = append(args, val)
|
||||||
|
conds = append(conds, fmt.Sprintf(tmpl, len(args)))
|
||||||
|
}
|
||||||
|
if name != nil && *name != "" {
|
||||||
|
add("name ILIKE $%d", "%"+*name+"%")
|
||||||
|
}
|
||||||
|
if objectClass != nil {
|
||||||
|
add("object_class = $%d", *objectClass)
|
||||||
|
}
|
||||||
|
if minValue != nil {
|
||||||
|
add("value >= $%d", *minValue)
|
||||||
|
}
|
||||||
|
if maxValue != nil {
|
||||||
|
add("value <= $%d", *maxValue)
|
||||||
|
}
|
||||||
|
if minBurden != nil {
|
||||||
|
add("burden >= $%d", *minBurden)
|
||||||
|
}
|
||||||
|
if maxBurden != nil {
|
||||||
|
add("burden <= $%d", *maxBurden)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql := `SELECT name, icon, object_class, value, burden, has_id_data, item_data, timestamp
|
||||||
|
FROM character_inventories WHERE ` + join(conds, " AND ") + ` ORDER BY value DESC, name`
|
||||||
|
|
||||||
|
ctx, cancel := reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
rows, err := queryRowsAsMaps(ctx, s.pool, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
s.dbErr(w, "inventory-search", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formatTimes(rows, "timestamp")
|
||||||
|
for _, row := range rows {
|
||||||
|
if v, ok := row["item_data"]; ok {
|
||||||
|
row["item_data"] = decodeJSONValue(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"character_name": cn,
|
||||||
|
"item_count": len(rows),
|
||||||
|
"search_criteria": map[string]any{
|
||||||
|
"name": derefStr(name),
|
||||||
|
"object_class": derefInt(objectClass),
|
||||||
|
"min_value": derefInt(minValue),
|
||||||
|
"max_value": derefInt(maxValue),
|
||||||
|
"min_burden": derefInt(minBurden),
|
||||||
|
"max_burden": derefInt(maxBurden),
|
||||||
|
},
|
||||||
|
"items": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- small param/number helpers ----
|
||||||
|
|
||||||
|
func queryInt(r *http.Request, key string, def int) int {
|
||||||
|
if v := r.URL.Query().Get(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func optInt(r *http.Request, key string) *int {
|
||||||
|
v := r.URL.Query().Get(key)
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &n
|
||||||
|
}
|
||||||
|
|
||||||
|
func optStr(r *http.Request, key string) *string {
|
||||||
|
vs := r.URL.Query()
|
||||||
|
if !vs.Has(key) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := vs.Get(key)
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefStr(p *string) any {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefInt(p *int) any {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampInt(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func join(parts []string, sep string) string {
|
||||||
|
out := ""
|
||||||
|
for i, p := range parts {
|
||||||
|
if i > 0 {
|
||||||
|
out += sep
|
||||||
|
}
|
||||||
|
out += p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFloat(v any) float64 {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return x
|
||||||
|
case float32:
|
||||||
|
return float64(x)
|
||||||
|
case int64:
|
||||||
|
return float64(x)
|
||||||
|
case int32:
|
||||||
|
return float64(x)
|
||||||
|
case int:
|
||||||
|
return float64(x)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInt(v any) int {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case int64:
|
||||||
|
return int(x)
|
||||||
|
case int32:
|
||||||
|
return int(x)
|
||||||
|
case int:
|
||||||
|
return x
|
||||||
|
case float64:
|
||||||
|
return int(x)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInt64(v any) int64 {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case int64:
|
||||||
|
return x
|
||||||
|
case int32:
|
||||||
|
return int64(x)
|
||||||
|
case int:
|
||||||
|
return int64(x)
|
||||||
|
case float64:
|
||||||
|
return int64(x)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -52,6 +54,65 @@ func queryRowsAsMaps(ctx context.Context, pool *pgxpool.Pool, sql string, args .
|
||||||
return out, nil
|
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
|
// pyISO formats a timestamp the way Python's datetime.isoformat() does for a
|
||||||
// UTC tz-aware value, so output matches FastAPI's jsonable_encoder:
|
// UTC tz-aware value, so output matches FastAPI's jsonable_encoder:
|
||||||
// - no fractional part when microseconds are zero
|
// - no fractional part when microseconds are zero
|
||||||
|
|
|
||||||
80
go-services/tracker-go/totals.go
Normal file
80
go-services/tracker-go/totals.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalsInterval = 300 * time.Second // _refresh_total_rares_cache cadence
|
||||||
|
|
||||||
|
// totalsCache holds the pre-marshaled bodies for /total-rares and /total-kills,
|
||||||
|
// refreshed every totalsInterval, mirroring main.py:924.
|
||||||
|
type totalsCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
raresJSON []byte
|
||||||
|
killsJSON []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTotalsCache() *totalsCache {
|
||||||
|
return &totalsCache{
|
||||||
|
raresJSON: []byte(`{"all_time":0,"today":0,"last_updated":null}`),
|
||||||
|
killsJSON: []byte(`{"total":0,"last_updated":null}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *totalsCache) getRares() []byte { c.mu.RLock(); defer c.mu.RUnlock(); return c.raresJSON }
|
||||||
|
func (c *totalsCache) getKills() []byte { c.mu.RLock(); defer c.mu.RUnlock(); return c.killsJSON }
|
||||||
|
|
||||||
|
func (c *totalsCache) set(rares, kills []byte) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.raresJSON = rares
|
||||||
|
c.killsJSON = kills
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) refreshTotals(ctx context.Context) error {
|
||||||
|
qctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var allTime, today, totalKills int64
|
||||||
|
// Each query degrades to 0 on error, mirroring the Python try/except blocks.
|
||||||
|
_ = s.pool.QueryRow(qctx, "SELECT COALESCE(SUM(total_rares), 0) FROM rare_stats").Scan(&allTime)
|
||||||
|
_ = s.pool.QueryRow(qctx, "SELECT COUNT(*) FROM rare_events WHERE timestamp >= CURRENT_DATE").Scan(&today)
|
||||||
|
_ = s.pool.QueryRow(qctx, "SELECT COALESCE(SUM(total_kills), 0) FROM char_stats").Scan(&totalKills)
|
||||||
|
|
||||||
|
lastUpdated := pyISO(time.Now().UTC())
|
||||||
|
raresJSON, err := json.Marshal(map[string]any{"all_time": allTime, "today": today, "last_updated": lastUpdated})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
killsJSON, err := json.Marshal(map[string]any{"total": totalKills, "last_updated": lastUpdated})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.totals.set(raresJSON, killsJSON)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runTotalsLoop(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
if err := s.refreshTotals(ctx); err != nil {
|
||||||
|
s.log.Error("totals cache refresh failed", "err", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(totalsInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTotalRares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeRawJSON(w, s.totals.getRares())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTotalKills(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeRawJSON(w, s.totals.getKills())
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue