feat: Go backend production cutover — website layer, ingest forwarding, alerts, live fixes

Completes the Go backend so it can fully replace Python in production:

tracker-go website layer (serves the unchanged frontend):
- static file serving + SPA fallback + /icons (website.go)
- login/logout with itsdangerous cookie ISSUING (bcrypt, Python-interop) and the
  /me handler (auth.go issueSessionCookie + website.go)
- admin user CRUD (website_admin.go) and the issue-board write side (website_issues.go)
- request-scoped user context + requireAdmin (auth.go)

cutover ingest (gated off during the parallel run, required for a clean cutover):
- inventory forwarding: full_inventory -> /process-inventory, inventory_delta ->
  item POST/DELETE, per-character serialized, fire-and-forget (inventory_forward.go)
- death/idle Discord alerts via DISCORD_ACLOG_WEBHOOK (aclog.go)
- SKIP_SCHEMA_INIT so write mode against the prod DBs runs no DDL (tracker-go + inventory-go)

two bugs found live and fixed:
- coerceNum: the plugin sends kills_per_hour/deaths/total_deaths/prismatic_taper_count
  as STRINGS; pydantic coerced them, Go's number helpers wrote null/0 (reads.go/ingest.go)
- telemetry is broadcast TYPELESS so the browser ignores it and uses the /live poll;
  broadcasting it typed flapped the per-player counters 0<->value (ingest.go stripType)

docker-compose.cutover.yml: reversible override flipping the Go services to write
mode against the production DBs and repointing the Discord bot at the Go /ws/live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 19:46:40 +02:00
parent 776076b981
commit 5ade47dc64
13 changed files with 1074 additions and 66 deletions

View file

@ -39,6 +39,9 @@ type Ingestor struct {
vitalPeerState map[string]map[string]any
plugins *pluginRegistry // for share_* fan-out + plugin_connected status
invFwd *invForwarder // inventory forwarding (cutover only; nil in shadow/read)
aclog *aclogPoster // death/idle Discord alerts (cutover only; nil otherwise)
}
func newIngestor(pool *pgxpool.Pool, log *slog.Logger, broadcast func(map[string]any), plugins *pluginRegistry) *Ingestor {
@ -71,6 +74,17 @@ func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) {
switch {
case t == "telemetry" || (t == "" && hasTelemetryShape(data)):
i.handleTelemetry(ctx, data)
// Python broadcasts telemetry as a TYPELESS snapshot (snap.dict()); the
// browser intentionally ignores typeless messages (useLiveData drops
// `if (!msg.type) return`) and takes player data from the 5s /live poll
// instead. Broadcasting it WITH a type makes the UI overwrite the
// /live-derived telemetry (which has total_kills/total_rares/session_rares)
// with the raw plugin payload (which lacks them), flapping those counters
// 0<->value. Strip the type to match.
if i.broadcast != nil {
i.broadcast(stripType(data))
}
return
case t == "rare":
i.handleRare(ctx, data)
case t == "portal":
@ -91,6 +105,18 @@ func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) {
i.handleDungeonMap(data)
case t == "combat_stats":
i.handleCombatStats(ctx, data)
case t == "full_inventory":
// Forward the full snapshot to the inventory service; not browser-broadcast.
if i.invFwd != nil {
i.invFwd.forwardFullInventory(data)
}
return
case t == "inventory_delta":
// Fire-and-forget forward; the forwarder broadcasts the enriched delta.
if i.invFwd != nil {
i.invFwd.handleInventoryDelta(data)
}
return
case t == "share_subscribe":
i.handleShareSubscribe(data)
case t == "share_unsubscribe":
@ -108,6 +134,18 @@ func (i *Ingestor) dispatch(ctx context.Context, data map[string]any) {
}
}
// stripType returns a shallow copy of the message without its "type" key, so the
// browser treats it as a typeless snapshot (and ignores it, deferring to /live).
func stripType(data map[string]any) map[string]any {
cp := make(map[string]any, len(data))
for k, v := range data {
if k != "type" {
cp[k] = v
}
}
return cp
}
func hasTelemetryShape(d map[string]any) bool {
_, a := d["session_id"]
_, b := d["ew"]
@ -312,8 +350,21 @@ func (i *Ingestor) handleVitals(data map[string]any) {
if name == "" {
return
}
// Death detection (discord alert) is intentionally omitted in shadow mode —
// it would duplicate the production alert. The live overlay still updates.
// Death detection (main.py:3419): vitae crossing 0 -> >0. Only in cutover
// (i.aclog != nil); in shadow mode it stays off to avoid duplicating the
// production alert.
if i.aclog != nil {
i.mu.RLock()
prev := i.liveVitals[name]
i.mu.RUnlock()
var prevVitae float64
if prev != nil {
prevVitae = toFloat(prev["vitae"])
}
if newVitae := toFloat(data["vitae"]); prevVitae == 0 && newVitae > 0 {
i.aclog.maybeDeath(name, newVitae)
}
}
i.mu.Lock()
i.liveVitals[name] = data
i.mu.Unlock()
@ -428,25 +479,22 @@ func nstr(v any) any {
}
return nil
}
// nint/nfloat return a typed number or nil (for nullable columns), coercing
// string-encoded numbers the plugin sends (see coerceNum).
func nint(v any) any {
switch x := v.(type) {
case float64:
return int64(x)
case int:
return int64(x)
case int64:
return x
if f, ok := coerceNum(v); ok {
return int64(f)
}
return nil
}
func nfloat(v any) any {
if f, ok := v.(float64); ok {
if f, ok := coerceNum(v); ok {
return f
}
return nil
}
func toFloatOr(v any, def float64) float64 {
if f, ok := v.(float64); ok {
if f, ok := coerceNum(v); ok {
return f
}
return def