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>
144 lines
4.3 KiB
Go
144 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// invForwarder forwards plugin inventory events to the inventory service,
|
|
// porting main.py's _forward_to_inventory_service / _handle_inventory_delta.
|
|
// Only active in cutover (write) mode; nil in shadow/read-only mode, where the
|
|
// plugin firehose never carries inventory anyway.
|
|
//
|
|
// full_inventory -> POST {url}/process-inventory (full replace)
|
|
// inventory_delta add/update -> POST {url}/inventory/{char}/item
|
|
// inventory_delta remove -> DELETE {url}/inventory/{char}/item/{item_id}
|
|
//
|
|
// Deltas are fire-and-forget (never block the /ws/position read loop), serialized
|
|
// per-character (so a char's rapid deltas don't race the inventory DELETE+INSERT),
|
|
// and globally capped at 8 concurrent forwards.
|
|
type invForwarder struct {
|
|
url string
|
|
client *http.Client
|
|
sem chan struct{}
|
|
mu sync.Mutex
|
|
locks map[string]*sync.Mutex
|
|
log *slog.Logger
|
|
broadcast func(map[string]any)
|
|
}
|
|
|
|
func newInvForwarder(rawURL string, log *slog.Logger, broadcast func(map[string]any)) *invForwarder {
|
|
return &invForwarder{
|
|
url: strings.TrimRight(rawURL, "/"),
|
|
client: &http.Client{Timeout: 30 * time.Second},
|
|
sem: make(chan struct{}, 8),
|
|
locks: map[string]*sync.Mutex{},
|
|
log: log,
|
|
broadcast: broadcast,
|
|
}
|
|
}
|
|
|
|
func (f *invForwarder) charLock(name string) *sync.Mutex {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
l := f.locks[name]
|
|
if l == nil {
|
|
l = &sync.Mutex{}
|
|
f.locks[name] = l
|
|
}
|
|
return l
|
|
}
|
|
|
|
// forwardFullInventory POSTs a full inventory snapshot (full replace). Runs
|
|
// inline on the /ws/position handler — main.py awaits _store_inventory too.
|
|
func (f *invForwarder) forwardFullInventory(data map[string]any) {
|
|
char := toStr(data["character_name"])
|
|
body, _ := json.Marshal(map[string]any{
|
|
"character_name": char,
|
|
"timestamp": data["timestamp"],
|
|
"items": data["items"],
|
|
})
|
|
resp, err := f.client.Post(f.url+"/process-inventory", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
f.log.Error("full_inventory forward failed", "err", err, "char", char)
|
|
return
|
|
}
|
|
defer drain(resp)
|
|
if resp.StatusCode >= 400 {
|
|
f.log.Warn("inventory service error (full_inventory)", "status", resp.StatusCode, "char", char)
|
|
}
|
|
}
|
|
|
|
// handleInventoryDelta forwards a single add/update/remove. Fire-and-forget.
|
|
func (f *invForwarder) handleInventoryDelta(data map[string]any) {
|
|
go func() {
|
|
char := toStr(data["character_name"])
|
|
lock := f.charLock(char)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
f.sem <- struct{}{}
|
|
defer func() { <-f.sem }()
|
|
|
|
out := data
|
|
switch toStr(data["action"]) {
|
|
case "remove":
|
|
if itemID := data["item_id"]; itemID != nil {
|
|
req, _ := http.NewRequest(http.MethodDelete,
|
|
fmt.Sprintf("%s/inventory/%s/item/%v", f.url, url.PathEscape(char), itemID), nil)
|
|
if resp, err := f.client.Do(req); err != nil {
|
|
f.log.Warn("inventory delta remove failed", "err", err, "char", char)
|
|
} else {
|
|
if resp.StatusCode >= 400 {
|
|
f.log.Warn("inventory service error (delta remove)", "status", resp.StatusCode, "char", char)
|
|
}
|
|
drain(resp)
|
|
}
|
|
}
|
|
case "add", "update":
|
|
if item := data["item"]; item != nil {
|
|
b, _ := json.Marshal(item)
|
|
resp, err := f.client.Post(fmt.Sprintf("%s/inventory/%s/item", f.url, url.PathEscape(char)),
|
|
"application/json", bytes.NewReader(b))
|
|
if err != nil {
|
|
f.log.Warn("inventory delta add/update failed", "err", err, "char", char)
|
|
} else {
|
|
if resp.StatusCode < 400 {
|
|
// Re-broadcast the enriched item the service returns.
|
|
var r map[string]any
|
|
if json.NewDecoder(resp.Body).Decode(&r) == nil {
|
|
if enriched, ok := r["item"]; ok && enriched != nil {
|
|
out = map[string]any{
|
|
"type": "inventory_delta",
|
|
"action": toStr(data["action"]),
|
|
"character_name": char,
|
|
"item": enriched,
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
f.log.Warn("inventory service error (delta add/update)", "status", resp.StatusCode, "char", char)
|
|
}
|
|
drain(resp)
|
|
}
|
|
}
|
|
}
|
|
if f.broadcast != nil {
|
|
f.broadcast(out)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func drain(resp *http.Response) {
|
|
if resp != nil && resp.Body != nil {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
_ = resp.Body.Close()
|
|
}
|
|
}
|