Files
scrabble-game/backend/internal/config/config.go
T
Ilia Denisov e3c2e80a0a
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
feat(payments): report income to «Мой налог»
The direct rail runs on НПД, where the provider neither files with the tax
service nor issues a receipt — so nobody was doing it. This registers each
rouble purchase, annuls its receipt on a refund, and hands the buyer the
receipt by email.

Two properties of the (unofficial) lknpd API shape the design. Registering an
income takes no idempotency key, so an error does not mean nothing happened:
the service name is frozen before the call and carries a marker from the tail
of the order id, and after a failure the taxpayer's income list is searched
for that exact name. Found means filed; not found halts the queue for a human,
because declaring an income twice is as wrong as not declaring it. And faults
are classified rather than logged: a token is renewed silently, a throttle
backs off, an outage retries, but three unfixable rejections take the rail out
of service — a changed format must not become thousands of requests overnight.

The console button and the worker share one RunBatch. Automatic mode is armed
from the console, not from configuration, so the operator can watch a run go
through by hand first. A daily watchdog runs whether or not it is armed, since
the case it exists for is the export being off. An idle queue issues no call at
all — not even an authentication.

No payment path changed: the purchase letter rides the existing payment-event
outbox on its own cursor, the receipt and annulment letters ride the export
row. Decisions D53-D60.
2026-07-28 15:40:36 +02:00

381 lines
15 KiB
Go

// Package config loads and validates the backend's runtime configuration from
// the process environment.
package config
import (
"fmt"
"net/url"
"os"
"strconv"
"time"
"scrabble/backend/internal/account"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/mynalog"
"scrabble/backend/internal/mynalogsync"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry"
"scrabble/backend/internal/yookassa"
)
// Config holds the backend's runtime configuration.
type Config struct {
// HTTPAddr is the listen address of the HTTP listener (host:port).
HTTPAddr string
// GRPCAddr is the listen address of the gRPC push listener (host:port) that
// streams live events to the gateway.
GRPCAddr string
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
LogLevel string
// Postgres configures the primary database pool.
Postgres postgres.Config
// Telemetry configures the OpenTelemetry providers.
Telemetry telemetry.Config
// Game configures the game subsystem (dictionaries, sweeper, live-game cache).
Game game.Config
// Lobby configures matchmaking robot substitution (wait window, reaper cadence).
Lobby lobby.Config
// Robot configures the robot opponent driver (scan cadence).
Robot robot.Config
// RateWatch tunes the conservative high-rate auto-flag applied to the
// gateway's rate-limiter rejection reports.
RateWatch ratewatch.Config
// SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent).
SMTP account.SMTPConfig
// PublicBaseURL is the canonical public origin (scheme + host, e.g.
// https://erudit-game.ru) used to build absolute links in outgoing email — the
// confirm deeplink and the footer landing link. It is deliberately not derived
// from a request Host header, which would let an attacker inject a phishing link
// into the email. Required whenever an SMTP relay is configured.
PublicBaseURL string
// ConnectorAddr is the gRPC address of the Telegram platform connector
// side-service, used by the admin console to send operator broadcasts. Empty
// disables broadcasts (the admin broadcast actions report "not configured").
ConnectorAddr string
// GuestReapInterval is the cadence of the abandoned-guest reaper sweep.
GuestReapInterval time.Duration
// GuestRetention is the account age past which an unused guest (no game seat)
// is eligible for deletion by the reaper.
GuestRetention time.Duration
// ExportSignKey signs the finished-game export download URLs. Empty leaves
// the export-URL endpoints disabled (503 on mint, 404 on download).
ExportSignKey string
// RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string
// YooKassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
// (D42). An empty set leaves the direct order and notification endpoints unregistered and falls
// the direct rail back to Robokassa.
YooKassa yookassa.Shops
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
// Unset (0) sends no receipt at all — the state a merchant outside 54-ФЗ runs in, which is the
// default; setting a rate turns fiscal receipts back on without a code change.
YooKassaVatCode int
// Robokassa configures the retired direct-rail payment provider — one merchant shop per channel
// (D42). It is dormant: no deployment sets its credentials, so the set is empty and the rail
// resolves to YooKassa. Kept wired so restoring Robokassa is a credentials change, not a code
// change — see backend/internal/robokassa/README.md.
Robokassa robokassa.Shops
// MyNalog configures the professional-income tax export. An empty Key leaves the whole rail
// dormant: without somewhere safe to keep the cabinet's refresh token there is no way to stay
// signed in, so the console section and both workers stay unregistered.
MyNalog mynalogsync.Config
}
// Defaults applied when the corresponding environment variable is unset.
const (
defaultHTTPAddr = ":8080"
defaultGRPCAddr = ":9090"
defaultLogLevel = "info"
defaultGuestReapInterval = time.Hour
defaultGuestRetention = 30 * 24 * time.Hour
// defaultMyNalogTZ is the taxpayer's time zone. It decides which tax month a near-midnight
// payment is filed under, so it follows the taxpayer's registration, not the server's clock.
defaultMyNalogTZ = "Europe/Moscow"
)
// Load reads the configuration from the environment, applies defaults for unset
// variables, and validates the result.
func Load() (Config, error) {
pg := postgres.DefaultConfig()
pg.DSN = os.Getenv("BACKEND_POSTGRES_DSN")
var err error
if pg.MaxOpenConns, err = envInt("BACKEND_POSTGRES_MAX_OPEN_CONNS", pg.MaxOpenConns); err != nil {
return Config{}, err
}
if pg.MaxIdleConns, err = envInt("BACKEND_POSTGRES_MAX_IDLE_CONNS", pg.MaxIdleConns); err != nil {
return Config{}, err
}
if pg.ConnMaxLifetime, err = envDuration("BACKEND_POSTGRES_CONN_MAX_LIFETIME", pg.ConnMaxLifetime); err != nil {
return Config{}, err
}
if pg.OperationTimeout, err = envDuration("BACKEND_POSTGRES_OPERATION_TIMEOUT", pg.OperationTimeout); err != nil {
return Config{}, err
}
tel := telemetry.DefaultConfig()
tel.ServiceName = envOr("BACKEND_SERVICE_NAME", tel.ServiceName)
tel.TracesExporter = envOr("BACKEND_OTEL_TRACES_EXPORTER", tel.TracesExporter)
tel.MetricsExporter = envOr("BACKEND_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
gm := game.DefaultConfig()
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir)
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
return Config{}, err
}
if gm.CacheTTL, err = envDuration("BACKEND_GAME_CACHE_TTL", gm.CacheTTL); err != nil {
return Config{}, err
}
lb := lobby.DefaultConfig()
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
return Config{}, err
}
if lb.RobotWaitJitter, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT_JITTER", lb.RobotWaitJitter); err != nil {
return Config{}, err
}
if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
return Config{}, err
}
rb := robot.DefaultConfig()
if rb.DriveInterval, err = envDuration("BACKEND_ROBOT_DRIVE_INTERVAL", rb.DriveInterval); err != nil {
return Config{}, err
}
rw := ratewatch.DefaultConfig()
if rw.FlagThreshold, err = envInt("BACKEND_HIGHRATE_FLAG_THRESHOLD", rw.FlagThreshold); err != nil {
return Config{}, err
}
if rw.FlagWindow, err = envDuration("BACKEND_HIGHRATE_FLAG_WINDOW", rw.FlagWindow); err != nil {
return Config{}, err
}
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
if err != nil {
return Config{}, err
}
guestRetention, err := envDuration("BACKEND_GUEST_RETENTION", defaultGuestRetention)
if err != nil {
return Config{}, err
}
smtp := account.SMTPConfig{
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
TLS: os.Getenv("BACKEND_SMTP_TLS"),
AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"),
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
}
// YooKassa direct rail: one merchant shop per channel (D42). A shop missing either credential is
// dropped, so the rail stays dormant until a channel is fully configured.
ykShops := yookassa.Shops{}
for channel, prefix := range map[string]string{
yookassa.ChannelWeb: "BACKEND_YOOKASSA_WEB",
yookassa.ChannelAndroid: "BACKEND_YOOKASSA_ANDROID",
} {
if shop := yookassaShop(prefix); shop.Configured() {
ykShops[channel] = shop
}
}
vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeOff)
if err != nil {
return Config{}, err
}
// Robokassa direct rail: retired but kept wired (see backend/internal/robokassa/README.md). No
// deployment sets these, so the set is empty and the direct rail resolves to YooKassa; restoring
// the credentials revives it without a code change. The legacy single-shop vars seed the web
// channel; the per-channel vars add the rest.
shops := robokassa.Shops{}
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
if web.MerchantLogin == "" {
web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel
}
if web.MerchantLogin != "" {
shops[robokassa.ChannelWeb] = web
}
if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" {
shops[robokassa.ChannelAndroid] = android
}
// Professional-income tax export. The key seals the cabinet's refresh token at rest; without it
// the rail is dormant, which is the state every deployment starts in.
mn := mynalogsync.Config{BaseURL: os.Getenv("BACKEND_MYNALOG_BASE_URL")}
if raw := os.Getenv("BACKEND_MYNALOG_KEY"); raw != "" {
if mn.Key, err = mynalog.ParseKey(raw); err != nil {
return Config{}, fmt.Errorf("config: BACKEND_MYNALOG_KEY: %w", err)
}
}
// The offset a receipt carries decides which tax period an income falls into, so this is the
// taxpayer's own zone, not the server's.
if mn.Location, err = time.LoadLocation(envOr("BACKEND_MYNALOG_TZ", defaultMyNalogTZ)); err != nil {
return Config{}, fmt.Errorf("config: BACKEND_MYNALOG_TZ: %w", err)
}
c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
Postgres: pg,
Telemetry: tel,
Game: gm,
Lobby: lb,
Robot: rb,
RateWatch: rw,
SMTP: smtp,
PublicBaseURL: os.Getenv("BACKEND_PUBLIC_BASE_URL"),
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
GuestReapInterval: guestReapInterval,
GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
YooKassa: ykShops,
YooKassaVatCode: vatCode,
Robokassa: shops,
MyNalog: mn,
}
if err := c.validate(); err != nil {
return Config{}, err
}
return c, nil
}
// validate reports whether the configuration values are acceptable.
func (c Config) validate() error {
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("config: invalid BACKEND_LOG_LEVEL %q", c.LogLevel)
}
if c.HTTPAddr == "" {
return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty")
}
if c.GRPCAddr == "" {
return fmt.Errorf("config: BACKEND_GRPC_ADDR must not be empty")
}
if err := c.Postgres.Validate(); err != nil {
return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err)
}
if err := c.Telemetry.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if err := c.Game.Validate(); err != nil {
return fmt.Errorf("config: %w (set BACKEND_DICT_DIR)", err)
}
if err := c.Lobby.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if err := c.Robot.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if err := c.RateWatch.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if c.GuestReapInterval <= 0 {
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
}
if c.GuestRetention <= 0 {
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
}
if c.SMTP.Host != "" {
if c.PublicBaseURL == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when BACKEND_SMTP_HOST is configured")
}
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
for channel, shop := range c.Robokassa {
if shop.Password1 == "" || shop.Password2 == "" {
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
}
}
if !yookassa.ValidVatCode(c.YooKassaVatCode) {
return fmt.Errorf("config: BACKEND_YOOKASSA_VAT_CODE %d is not a 54-ФЗ VAT rate code (1..%d); leave it unset to send no receipts", c.YooKassaVatCode, yookassa.VatCodeMax)
}
if c.YooKassa.Configured() {
// The YooKassa rail sends the customer to a hosted payment page and needs an absolute return
// URL to bring them back, which only the public base URL can supply.
if c.PublicBaseURL == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when a YooKassa shop is configured")
}
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
return nil
}
// envOr returns the value of the environment variable named key, or fallback
// when the variable is unset or empty.
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// envInt parses the environment variable named key as an int, returning
// fallback when it is unset and an error when it is set but malformed.
func envInt(key string, fallback int) (int, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return n, nil
}
// envDuration parses the environment variable named key as a Go duration,
// returning fallback when it is unset and an error when it is set but malformed.
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return d, nil
}
// yookassaShop reads a YooKassa shop's credentials from the environment under prefix (e.g.
// "BACKEND_YOOKASSA_WEB" → _SHOP_ID / _SECRET_KEY / _TEST). _TEST marks a test shop, which lets the
// intake refuse to credit a live payment against test credentials and vice versa. A shop missing
// either credential yields a Config the caller drops.
func yookassaShop(prefix string) yookassa.Config {
return yookassa.Config{
ShopID: os.Getenv(prefix + "_SHOP_ID"),
SecretKey: os.Getenv(prefix + "_SECRET_KEY"),
IsTest: os.Getenv(prefix+"_TEST") == "1",
}
}
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
// MerchantLogin yields a zero Config the caller drops.
func robokassaShop(prefix string) robokassa.Config {
return robokassa.Config{
MerchantLogin: os.Getenv(prefix + "_MERCHANT_LOGIN"),
Password1: os.Getenv(prefix + "_PASSWORD1"),
Password2: os.Getenv(prefix + "_PASSWORD2"),
IsTest: os.Getenv(prefix+"_TEST") == "1",
}
}