# `db/` — доступ к данным - **`models.rs`** — табличные структуры (`#[derive(FromRow)]`: `User`, `Subscription`, `VpnNode`, `PromoCode`, `RefreshSession`) и производные DTO, которые уходят напрямую в JSON API (`ProfileData`, `SubscriptionData`, `ReferralData`). - **`repository.rs`** — трейт [`AppRepository`] (единая граница между `modules::*::service` и Postgres) и его единственная реализация `PgRepository`. Сервисы модулей зависят только от трейта (`Arc`), не от конкретной СУБД. ## Почему трейт, а не просто `PgPool` напрямую в сервисах `AppRepository` — это явная граница: каждый метод — одна осмысленная доменная операция (`consume_ticket_and_reward`, `apply_promo_code`, `buy_subscription`), а не сырой SQL, размазанный по сервисам. Это также даёт возможность подменить реализацию в тестах без БД, хотя на практике сейчас (см. `tests/db_integration_test.rs`) тесты гоняются через `#[sqlx::test]` на реальном (эфемерном) Postgres, а не через мок трейта. ## Транзакционные инварианты Там, где над данными возможна гонка при параллельных запросах, используется `pool.begin()` + явная блокировка строки: | Операция | Защита | |---|---| | `start_hack_session` (билет Cyberhack) | `SELECT ... FOR UPDATE` по юзеру | | `consume_hack_session` (сдача результата) | `UPDATE ... WHERE used_at IS NULL RETURNING` — повторная сдача одной сессии невозможна | | `apply_promo_code` | `SELECT ... FOR UPDATE` по промокоду + `UNIQUE(promo_id, user_id)` | | `reward_referrer` | `UPDATE ... RETURNING` атомарно проверяет и переводит статус реферала | | Инвойсы | `invoices.external_id` — `UNIQUE`, поэтому один tx-хеш нельзя засчитать дважды | | `refresh_session` (ротация refresh-токена) | `UPDATE ... WHERE revoked_at IS NULL` — повторное использование отозванного токена отзывает все сессии юзера | ## Модели, для которых важно помнить о NULL `User.tg_id` и `User.seed_hash` — взаимоисключающе опциональны: обычный юзер имеет `tg_id`, анонимный Ghost-юзер — `seed_hash`. Код, который трактует отсутствие `tg_id` как «анонимный юзер» (например, запрет триала), находится в [`billing::service::activate_trial`](../modules/billing/README.md).