This commit is contained in:
Kirill
2026-07-02 23:13:13 +07:00
parent e997a20fe6
commit d4a57c0b6a
34 changed files with 524 additions and 67 deletions
+35
View File
@@ -0,0 +1,35 @@
# `db/` — доступ к данным
- **`models.rs`** — табличные структуры (`#[derive(FromRow)]`: `User`, `Subscription`,
`VpnNode`, `PromoCode`) и производные DTO, которые уходят напрямую в JSON API
(`ProfileData`, `SubscriptionData`, `ReferralData`).
- **`repository.rs`** — трейт [`AppRepository`] (единая граница между `modules::*::service`
и Postgres) и его единственная реализация `PgRepository`. Сервисы модулей зависят
только от трейта (`Arc<dyn AppRepository>`), не от конкретной СУБД.
## Почему трейт, а не просто `PgPool` напрямую в сервисах
`AppRepository` — это явная граница: каждый метод — одна осмысленная доменная операция
(`consume_ticket_and_reward`, `apply_promo_code`, `buy_subscription`), а не сырой SQL,
размазанный по сервисам. Это также даёт возможность подменить реализацию в тестах без
БД, хотя на практике сейчас (см. `tests/db_integration_test.rs`) тесты гоняются через
`#[sqlx::test]` на реальном (эфемерном) Postgres, а не через мок трейта.
## Транзакционные инварианты
Там, где над данными возможна гонка при параллельных запросах, используется
`pool.begin()` + явная блокировка строки:
| Операция | Защита |
|---|---|
| `consume_ticket_and_reward` (билет Cyberhack) | `SELECT ... FOR UPDATE` по юзеру |
| `apply_promo_code` | `SELECT ... FOR UPDATE` по промокоду + `UNIQUE(promo_id, user_id)` |
| `reward_referrer` | `UPDATE ... RETURNING` атомарно проверяет и переводит статус реферала |
| Инвойсы | `invoices.external_id``UNIQUE`, поэтому один tx-хеш нельзя засчитать дважды |
## Модели, для которых важно помнить о NULL
`User.tg_id` и `User.seed_hash` — взаимоисключающе опциональны: обычный юзер имеет
`tg_id`, анонимный Ghost-юзер — `seed_hash`. Код, который трактует отсутствие `tg_id`
как «анонимный юзер» (например, запрет триала), находится в
[`billing::service::activate_trial`](../modules/billing/README.md).