Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 13s

Server-rendered admin console in the backend at /_gm (internal/adminconsole),
fronted on the gateway's public listener by Basic-Auth + a verbatim reverse proxy
(mounted on the edge mux below the h2c wrap). A same-origin check guards its POSTs;
no operator identity is tracked. This supersedes the Stage 6 gateway-fronts-
/api/v1/admin model: GATEWAY_ADMIN_ADDR and the backend /api/v1/admin ping are
dropped and gateway/internal/admin is repurposed to the verbatim proxy.

- Complaints: migration 00008 (+ jetgen) adds disposition/resolution_note/
  resolved_at/applied_in_version + the deferred status CHECK; resolution feeds a
  query-derived pending dictionary-change pipeline (marked applied after a reload).
- Dictionary hot-reload: per-version subdir BACKEND_DICT_DIR/<version>/ via the new
  Registry.LoadAvailable; engine.OpenWithVersions restores resident versions on
  restart. Partially addresses TODO-2.
- Broadcasts: a backend Telegram-connector client (internal/connector,
  BACKEND_CONNECTOR_ADDR) for SendToUser / SendToGameChannel (discharges the Stage 9
  forward-note).
- Admin reads: account.ListAccounts/CountAccounts/Identities and
  game.ListGames/CountGames/GameByID/ListComplaints/GetComplaint/CountComplaints/
  ResolveComplaint/DictionaryChanges/MarkChangesApplied.
- Tests: adminconsole render, engine reload, same-origin guard, gateway verbatim
  proxy + h2c console mount, inttest complaint pipeline + list/count + /_gm console.
- Docs: PLAN (Stage 10 done + refinements + TODO-2), ARCHITECTURE §1/§5/§6/§12/§13,
  FUNCTIONAL (+_ru), TESTING, backend/gateway READMEs.
This commit is contained in:
Ilia Denisov
2026-06-04 09:24:59 +02:00
parent 4c4beace85
commit aafdd46a4b
49 changed files with 2548 additions and 200 deletions
+102
View File
@@ -420,6 +420,68 @@ func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UU
})
}
// ListComplaints returns word-check complaints for the admin review queue,
// newest first. status filters by lifecycle state ("" = all); limit is clamped
// to a sane page size and offset is floored at zero.
func (svc *Service) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
return svc.store.ListComplaints(ctx, status, clampPageSize(limit), max(0, offset))
}
// GetComplaint loads a single complaint for the admin detail view.
func (svc *Service) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
return svc.store.GetComplaint(ctx, id)
}
// CountComplaints returns the number of complaints, optionally restricted to a
// status, for the admin queue pager and the dashboard counts.
func (svc *Service) CountComplaints(ctx context.Context, status string) (int, error) {
return svc.store.CountComplaints(ctx, status)
}
// ResolveComplaint closes a complaint with an operator disposition (reject /
// accept_add / accept_remove) and an optional note. An accepted complaint then
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
// change is marked applied. It returns ErrInvalidConfig for an unknown
// disposition and ErrNotFound when no complaint matches.
func (svc *Service) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string) (Complaint, error) {
if !validDisposition(disposition) {
return Complaint{}, fmt.Errorf("%w: complaint disposition %q", ErrInvalidConfig, disposition)
}
return svc.store.ResolveComplaint(ctx, id, disposition, note, svc.clock())
}
// DictionaryChanges returns the pending wordlist edits implied by resolved,
// accepted complaints not yet marked applied — the input to the offline DAWG
// rebuild.
func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, error) {
rows, err := svc.store.ListDictionaryChanges(ctx)
if err != nil {
return nil, err
}
out := make([]DictionaryChange, 0, len(rows))
for _, c := range rows {
ch := DictionaryChange{
ComplaintID: c.ID,
Variant: c.Variant,
Word: c.Word,
Add: c.Disposition == DispositionAcceptAdd,
Note: c.Note,
}
if c.ResolvedAt != nil {
ch.ResolvedAt = *c.ResolvedAt
}
out = append(out, ch)
}
return out, nil
}
// MarkChangesApplied records that every pending accepted change for variant has
// been folded into the dictionary version that was just hot-reloaded, removing
// them from DictionaryChanges. It returns the number of changes marked.
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
}
// Hint reveals the top-scoring legal play for the requesting player on their
// turn, spending one hint from their per-game allowance and then their profile
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
@@ -583,6 +645,23 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
return svc.store.ListGamesForAccount(ctx, accountID)
}
// GameByID returns a game with its seats for the admin console detail view.
func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) {
return svc.store.GetGame(ctx, id)
}
// ListGames returns games for the admin list, newest-updated first, paginated,
// optionally filtered by status.
func (svc *Service) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
return svc.store.ListGames(ctx, status, clampPageSize(limit), max(0, offset))
}
// CountGames returns the game count, optionally filtered by status, for the admin
// list pager and dashboard.
func (svc *Service) CountGames(ctx context.Context, status string) (int, error) {
return svc.store.CountGames(ctx, status)
}
// History returns a game's full, dictionary-independent move journal.
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
g, err := svc.store.GetGame(ctx, gameID)
@@ -770,6 +849,29 @@ func normalizeWord(word string) string {
return strings.ToLower(strings.TrimSpace(word))
}
// validDisposition reports whether d is an accepted complaint disposition.
func validDisposition(d string) bool {
switch d {
case DispositionReject, DispositionAcceptAdd, DispositionAcceptRemove:
return true
default:
return false
}
}
// clampPageSize bounds an admin list page size to [1, 200], defaulting an unset
// (non-positive) request to 50.
func clampPageSize(limit int) int {
switch {
case limit <= 0:
return 50
case limit > 200:
return 200
default:
return limit
}
}
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
// system source fails.
func randomSeed() int64 {