8881214213
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
228 lines
15 KiB
Markdown
228 lines
15 KiB
Markdown
# backend
|
||
|
||
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
|
||
It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history
|
||
and administration. Its only network consumers are the `gateway` and the platform
|
||
side-services; it is never exposed publicly.
|
||
|
||
The backend provides the foundation: configuration, the HTTP listener with the
|
||
`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose
|
||
migrations, OpenTelemetry wiring, an in-memory session cache, and the durable
|
||
accounts / identities / sessions data model. The session and account REST
|
||
endpoints live in the `gateway`; the backend ships the store/service layer they
|
||
call.
|
||
|
||
`internal/engine` is the in-process bridge to the `scrabble-solver`
|
||
library: a versioned dictionary registry, a deterministic tile bag, and a pure
|
||
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
|
||
detection) that emits dictionary-independent move records. It is a library only;
|
||
the game domain wires it into the process.
|
||
|
||
`internal/game` is the game domain over the engine. Active games are
|
||
event-sourced: a `games` row plus an append-only decoded move journal, with the
|
||
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
|
||
provides create, the play/pass/exchange/resign transitions, an unlimited
|
||
score/legality preview, the hint (per-game allowance plus a profile wallet), the
|
||
word-check tool with complaint capture, per-player game state, history and GCG
|
||
export, per-account statistics on finish, and a background turn-timeout sweeper
|
||
that auto-resigns overdue turns (honouring each player's daily away window). Like
|
||
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
|
||
|
||
The lobby and social fabric. `internal/lobby` holds an in-memory
|
||
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
|
||
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||
invitee accepts). `internal/social` owns the friend graph (request/accept),
|
||
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
||
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
||
including obfuscated forms) and stored with the sender's IP. `internal/account`
|
||
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a
|
||
development log mailer). The engine now also handles **multi-player drop-out**: in
|
||
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
||
(the tile disposition is a per-game setting), the game ending when one active seat
|
||
remains. As before this is a service/store layer — chat and nudges are persisted
|
||
but their live delivery, and all REST endpoints, live in the `gateway`; the
|
||
services are exposed via `Server` accessors for those handlers.
|
||
|
||
The robot opponent (`internal/robot`). A pool of durable accounts —
|
||
each a `kind='robot'` identity, provisioned at startup with chat and friend
|
||
requests blocked — backs human-like, per-language composed names. A background driver plays the
|
||
robot's moves through the public game API as an ordinary seated player (so only
|
||
`internal/engine` imports the solver): it decides once per game whether to play to
|
||
win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware
|
||
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
|
||
exposes `Poll` so a waiting player can collect the started game — it is the **stream-down
|
||
fallback**: while the client is streaming, the live match-found push (enriched with the recipient's
|
||
initial game state) drives it instead.
|
||
|
||
The backend opens to the edge. The route groups gain their first
|
||
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
||
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
||
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
||
state, lobby enqueue/poll, chat). The social/account/history operations under
|
||
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
|
||
`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a
|
||
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
||
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
|
||
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
|
||
route out-of-app push to the Telegram connector; the Telegram login
|
||
seeds a new account's language and display name from the launch fields, and the
|
||
`accounts.notifications_in_app_only` flag (default true).
|
||
`accounts.is_guest` marks an ephemeral guest — a durable row
|
||
with no identity, excluded from statistics. The server-rendered
|
||
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
||
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
|
||
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
|
||
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
|
||
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
|
||
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
|
||
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
||
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
|
||
holds the language tag of the bot a Telegram
|
||
user last signed in through, written on every login and returned by
|
||
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
||
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
||
|
||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
||
attached to the current account, and when the identity already has its own account
|
||
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
||
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
|
||
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
|
||
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
|
||
The current account is primary, except a guest initiator whose linked identity has a
|
||
durable owner — then the durable account wins and a fresh session is minted for it.
|
||
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
|
||
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
||
|
||
Rate-limit observability: the gateway posts its periodic rejection
|
||
summaries to `POST /api/v1/internal/ratelimit/report`; `internal/ratewatch` keeps a
|
||
bounded in-memory episode window for the console's **Throttled** page and applies the
|
||
conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD`
|
||
rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible
|
||
`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a
|
||
**Clear** action on the user card; never an automatic ban).
|
||
|
||
## Package layout
|
||
|
||
```
|
||
cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> robot pool+driver -> lobby+social -> server
|
||
cmd/jetgen/ # dev tool: regenerate go-jet code from a throwaway container
|
||
internal/config/ # env configuration (composes postgres + telemetry + game config)
|
||
internal/telemetry/ # OpenTelemetry providers + per-request timing middleware
|
||
internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
|
||
migrations/ # embedded *.sql (goose), schema `backend`
|
||
jet/ # generated go-jet models + table builders (committed)
|
||
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
|
||
internal/accountmerge/ # single-transaction merge of a secondary account into a primary
|
||
internal/link/ # link/merge orchestrator over account + accountmerge + session
|
||
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
|
||
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
|
||
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
||
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
|
||
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
|
||
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
|
||
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
|
||
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
|
||
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
|
||
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
|
||
```
|
||
|
||
## Configuration (environment)
|
||
|
||
| Variable | Default | Notes |
|
||
| --- | --- | --- |
|
||
| `BACKEND_HTTP_ADDR` | `:8080` | HTTP (REST) listen address. |
|
||
| `BACKEND_GRPC_ADDR` | `:9090` | gRPC listen address for the live-event push stream to the gateway. |
|
||
| `BACKEND_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error`. |
|
||
| `BACKEND_POSTGRES_DSN` | — | **Required.** pgx/libpq URL; must pin `search_path=backend`. |
|
||
| `BACKEND_POSTGRES_MAX_OPEN_CONNS` | `25` | Pool max open connections. |
|
||
| `BACKEND_POSTGRES_MAX_IDLE_CONNS` | `5` | Pool max idle connections. |
|
||
| `BACKEND_POSTGRES_CONN_MAX_LIFETIME` | `30m` | Max connection lifetime. |
|
||
| `BACKEND_POSTGRES_OPERATION_TIMEOUT` | `5s` | Connect attempt + `/readyz` ping bound. |
|
||
| `BACKEND_SERVICE_NAME` | `scrabble-backend` | OpenTelemetry `service.name`. |
|
||
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
|
||
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
|
||
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
|
||
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
|
||
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
|
||
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
|
||
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
|
||
| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. |
|
||
| `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. |
|
||
| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). |
|
||
| `BACKEND_SMTP_PORT` | `587` | Email relay port. |
|
||
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
||
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
||
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
||
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
||
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
|
||
| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. |
|
||
|
||
## Run
|
||
|
||
```sh
|
||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
||
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
|
||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.0.0/scrabble-dawg-v1.0.0.tar.gz | tar xz -C /tmp/dawg
|
||
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||
BACKEND_DICT_DIR=/tmp/dawg \
|
||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
||
go run ./cmd/backend
|
||
```
|
||
|
||
On boot the backend opens the pool, creates the `backend` schema if needed,
|
||
applies the embedded migrations, loads the dictionaries into the engine registry
|
||
(a hard dependency — a missing dictionary aborts the boot), warms the session
|
||
cache and starts the game turn-timeout sweeper. `GET /healthz` reports liveness;
|
||
`GET /readyz` reports 200 only when the database answers and the session cache is
|
||
warmed.
|
||
|
||
## Migrations & generated code
|
||
|
||
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
|
||
`NNNNN_name.sql`), embedded and applied at startup. The incremental history was
|
||
squashed into a single `00001_baseline.sql` before the first production deploy
|
||
(there was no production data); new schema changes append as `00002_*` onward.
|
||
After changing the schema,
|
||
regenerate the committed go-jet code (needs Docker):
|
||
|
||
```sh
|
||
go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp container
|
||
```
|
||
|
||
## Engine & dictionaries
|
||
|
||
`internal/engine` consumes `scrabble-solver` in-process as a **published, versioned
|
||
module** (`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `go.mod`). Set
|
||
`GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea (skipping
|
||
the public proxy/checksum DB); no sibling checkout or `go.work` replace is needed (for
|
||
local solver co-development you may add a temporary replace — see `go.work`).
|
||
`github.com/iliadenisov/dafsa` (the DAWG loader) is a direct dependency. The dictionaries
|
||
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
|
||
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
|
||
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
||
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
|
||
(a missing dictionary aborts the boot).
|
||
|
||
## Tests
|
||
|
||
```sh
|
||
go test -count=1 ./... # unit tests (no Docker)
|
||
go test -tags=integration -count=1 -p=1 ./... # Postgres-backed (needs Docker)
|
||
```
|
||
|
||
Integration tests are guarded by the `integration` build tag and run against a
|
||
throwaway `postgres:17-alpine` container; they fail loudly when Docker is absent
|
||
rather than skipping. The `internal/engine` tests load the DAWGs from
|
||
`BACKEND_DICT_DIR` (CI sets it to the extracted dictionary release artifact; locally it
|
||
defaults to a `scrabble-solver/dawg` sibling checkout) and fail loudly when that directory
|
||
is absent. `GOPRIVATE=gitea.iliadenisov.ru/*` is needed for go to fetch the pinned solver
|
||
module.
|