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

@ -5,9 +5,38 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// coerceNum converts a JSON value to a float64, parsing string-encoded numbers.
// The plugin sends several telemetry fields as strings (kills_per_hour, deaths,
// total_deaths, prismatic_taper_count via .ToString()); Python's pydantic
// coerced them, so Go must too or it writes null/0 (causing the live counters
// to flap 0<->value between the WS broadcast and the DB-derived /live poll).
func coerceNum(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int32:
return float64(x), true
case int64:
return float64(x), true
case string:
s := strings.TrimSpace(x)
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
return f, err == nil
}
return 0, false
}
// 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)
@ -323,45 +352,16 @@ func join(parts []string, sep string) string {
}
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
f, _ := coerceNum(v)
return f
}
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
f, _ := coerceNum(v)
return int(f)
}
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
f, _ := coerceNum(v)
return int64(f)
}