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:
parent
776076b981
commit
5ade47dc64
13 changed files with 1074 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue