mnemo_cards/mnemo_cards_backend/DB_PLAN.md
2026-01-03 16:14:27 +03:00

2489 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# План миграции с Isar на PostgreSQL + Drift
## Содержание
1. [Обзор миграции](#обзор-миграции)
2. [Архитектурные решения](#архитектурные-решения)
3. [Этап 1: Подготовка инфраструктуры](#этап-1-подготовка-инфраструктуры)
4. [Этап 2: Создание Drift схем](#этап-2-создание-drift-схем)
5. [Этап 3: Создание DAOs](#этап-3-создание-daos)
6. [Этап 4: Рефакторинг кода](#этап-4-рефакторинг-кода)
7. [Этап 5: Миграция данных](#этап-5-миграция-данных)
8. [Этап 6: Тестирование](#этап-6-тестирование)
9. [Этап 7: Deployment](#этап-7-deployment)
10. [Чеклисты](#чеклисты)
---
## Обзор миграции
### Цель
Заменить embedded Isar БД на production-ready PostgreSQL с type-safe ORM Drift для:
- ✅ ACID транзакций (критично для платежей)
- ✅ Независимого деплоя backend и БД
- ✅ Горизонтального масштабирования
- ✅ Мощной аналитики
- ✅ Стандартных инструментов мониторинга
### Оценка трудозатрат
- **Подготовка:** 15-20 часов
- **Создание схем и DAOs:** 40-50 часов
- **Рефакторинг кода:** 80-100 часов
- **Миграция данных:** 20-30 часов
- **Тестирование:** 30-40 часов
- **Итого:** ~165-240 часов
### Архитектурные решения
#### ✅ Решено: PostgreSQL
- Реляционная структура данных (User → Packs → Cards)
- ACID транзакции для платежей и подписок
- Мощная аналитика (window functions, CTEs)
- JSONB для гибких полей
- Проверенная надежность
#### ✅ Решено: Drift ORM
- Type-safe queries (компиляция проверяет правильность запросов)
- Автогенерация кода
- Хорошая поддержка миграций
- Нативная интеграция с PostgreSQL
#### ✅ Решено: Держать Drift внутри mnemo_cards_backend
- НЕ выносить в отдельный пакет
- Telegram bot использует HTTP API (не прямой доступ к БД)
- Backend - единственный владелец БД
---
## Этап 1: Подготовка инфраструктуры
### 1.1. Настройка PostgreSQL для разработки
#### Вариант A: Docker Compose (рекомендуется)
**Создать файл:** `mnemo_cards_backend/docker-compose.yml`
```yaml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: mnemo_postgres
environment:
POSTGRES_DB: mnemo_cards_dev
POSTGRES_USER: mnemo_user
POSTGRES_PASSWORD: ${DB_PASSWORD:-dev_password_change_me}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.UTF-8"
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init.sql # опционально
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mnemo_user -d mnemo_cards_dev"]
interval: 10s
timeout: 5s
retries: 5
networks:
- mnemo_network
# Опционально: pgAdmin для визуального управления
pgadmin:
image: dpage/pgadmin4:latest
container_name: mnemo_pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: admin@mnemo.local
PGADMIN_DEFAULT_PASSWORD: admin
ports:
- "5050:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
networks:
- mnemo_network
depends_on:
- postgres
volumes:
postgres_data:
driver: local
pgadmin_data:
driver: local
networks:
mnemo_network:
driver: bridge
```
**Команды:**
```bash
# Запустить PostgreSQL
cd mnemo_cards_backend
docker-compose up -d postgres
# Проверить статус
docker-compose ps
# Логи
docker-compose logs -f postgres
# Подключиться к psql
docker-compose exec postgres psql -U mnemo_user -d mnemo_cards_dev
# Остановить
docker-compose down
# Удалить с данными (осторожно!)
docker-compose down -v
```
#### Вариант B: Managed PostgreSQL (для продакшена)
**Провайдеры:**
1. **Supabase** (бесплатный tier)
- URL: https://supabase.com
- Создать проект → получить connection string
- Плюсы: бесплатно до 500MB, auth из коробки, realtime
2. **Railway** (~$5/месяц)
- URL: https://railway.app
- New Project → PostgreSQL
- Плюсы: простой deployment, auto-backups
3. **Neon** (serverless PostgreSQL)
- URL: https://neon.tech
- Бесплатный tier до 3GB
- Плюсы: serverless, бесплатно, быстрый
4. **AWS RDS / Google Cloud SQL**
- Для больших нагрузок
- Дороже, но более гибкие
### 1.2. Обновление зависимостей
**Файл:** `mnemo_cards_backend/pubspec.yaml`
```yaml
name: mnemo_cards_backend
description: Mnemo backend
publish_to: 'none'
version: 1.0.0+5 # увеличить версию
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
mnemo_cards_common:
path: ../mnemo_cards_common
# УДАЛИТЬ Isar зависимости:
# isar: *isar_version
# ДОБАВИТЬ PostgreSQL + Drift:
drift: ^2.14.0
drift_postgres: ^1.1.0
postgres: ^3.0.4
# Остальные зависимости остаются:
image: ^4.1.7
http: ^1.1.0
async:
get_it: ^7.6.4
injectable: ^2.4.1
copy_with_extension_gen: ^5.0.4
shelf: ^1.4.1
shelf_enforces_ssl: ^1.2.1
shelf_router: ^1.1.4
shelf_open_api: ^1.1.0
shelf_swagger_ui: ^1.0.0+2
shelf_static: ^1.1.2
shelf_cors_headers: ^0.1.5
json_serializable:
json_annotation: ^4.8.1
dio: ^5.3.3
encrypt: ^5.0.3
basic_utils: ^5.7.0
crypto: ^3.0.3
googleapis: ^13.1.0
googleapis_auth:
uuid: ^3.0.7
yookassa_client: ^1.0.2
neat_periodic_task: ^2.0.1
jaguar_jwt: ^3.0.0
dev_dependencies:
build_runner: ^2.4.0
# УДАЛИТЬ:
# isar_generator: *isar_version
# ДОБАВИТЬ:
drift_dev: ^2.14.0
# Остальные остаются:
shelf_router_generator: ^1.1.0
shelf_open_api_generator:
injectable_generator:
archive: ^3.4.6
test: ^1.25.0
mockito: ^5.4.4
```
**Команды:**
```bash
cd mnemo_cards_backend
# Очистить старые зависимости
rm -rf .dart_tool/
rm pubspec.lock
# Установить новые
dart pub get
# Проверить, что всё установилось
dart pub deps
```
### 1.3. Создание .env файла
**Создать файл:** `mnemo_cards_backend/.env.example`
```bash
# PostgreSQL connection
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mnemo_cards_dev
DB_USER=mnemo_user
DB_PASSWORD=dev_password_change_me
DB_SSL_MODE=disable # для разработки, 'require' для продакшена
# Backend settings
PORT=3000
SERVER_ADDRESS=0.0.0.0
WORK_DIR=/root/mnemo_cards_backend
DEBUG=true
# Admin IDs (comma-separated)
ADMIN_IDS=1,2,3
# JWT secrets
JWT_SECRET=your-jwt-secret-key-here
JWT_REFRESH_SECRET=your-jwt-refresh-secret-key-here
# Backup (не нужно для PostgreSQL, но оставить для старых cron jobs)
BACKUP_DIR=/app/backups
```
**Создать файл:** `mnemo_cards_backend/.env` (добавить в .gitignore!)
```bash
# Скопировать из .env.example и заполнить реальными значениями
cp .env.example .env
# Отредактировать .env
```
**Обновить:** `.gitignore`
```gitignore
# ... существующие правила ...
# Environment variables
.env
.env.local
.env.production
# PostgreSQL data (если локально запускаете вне Docker)
postgres_data/
# Старые Isar файлы (можно удалить после миграции)
isar/
```
---
## Этап 2: Создание Drift схем
### 2.1. Структура файлов
```
mnemo_cards_backend/lib/database/
├── database.dart # Главный класс AppDatabase
├── database.g.dart # Сгенерированный код (автогенерация)
├── connection/
│ └── connection.dart # Фабрика подключений
├── tables/
│ ├── users.dart # Users, UserDatas
│ ├── auth.dart # Tokens, RefreshTokens, TelegramAuthCodes
│ ├── packs.dart # CardPacks, GameCards, VoiceModels
│ ├── relations.dart # UserPacks (many-to-many)
│ ├── subscriptions.dart # SubscriptionPlans, UserSubscriptions
│ ├── payments.dart # Payments
│ ├── tests.dart # Tests, TestQuestions, TestStatistics
│ ├── tasks.dart # Tasks, UserTasks, UserTaskProgresses, UserTaskResults
│ ├── promo_codes.dart # PromoCodesCampaigns, PromoCodes
│ ├── discounts.dart # DiscountCampaigns, Discounts
│ ├── statistics.dart # StudySessions
│ └── telegram.dart # ShareRequests
└── daos/
├── user_dao.dart # User CRUD operations
├── pack_dao.dart # CardPack CRUD
├── test_dao.dart # Test CRUD
├── payment_dao.dart # Payment CRUD
├── subscription_dao.dart # Subscription CRUD
├── task_dao.dart # Task CRUD
├── promo_code_dao.dart # PromoCode CRUD
├── discount_dao.dart # Discount CRUD
└── statistics_dao.dart # Statistics CRUD
```
### 2.2. Пример таблиц: Users
**Создать файл:** `lib/database/tables/users.dart`
```dart
import 'package:drift/drift.dart';
import 'dart:convert';
/// Таблица Users - основная информация о пользователях
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get email => text().nullable()();
TextColumn get externalUserId => text().unique()();
BoolColumn get admin => boolean().withDefault(const Constant(false))();
// Audit fields
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
@override
List<String> get customConstraints => [
'CONSTRAINT valid_email CHECK (email IS NULL OR email LIKE \'%@%\')',
];
}
/// Таблица UserDatas - расширенная информация о пользователе
class UserDatas extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get userId => integer().unique().references(Users, #id, onDelete: KeyAction.cascade)();
// Баланс и статистика
IntColumn get balance => integer().withDefault(const Constant(0))();
IntColumn get totalCards => integer().withDefault(const Constant(0))();
IntColumn get totalTests => integer().withDefault(const Constant(0))();
// Временные метки
DateTimeColumn get lastTimeOnline => dateTime().nullable()();
DateTimeColumn get registrationDate => dateTime().withDefault(currentDateAndTime)();
// Настройки пользователя (JSON)
TextColumn get settings => text()
.nullable()
.map(const JsonMapConverter())();
// Теги пользователя (JSON array)
TextColumn get tags => text()
.nullable()
.map(const JsonListConverter())();
// Audit
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
// Конвертеры для JSON полей
class JsonMapConverter extends TypeConverter<Map<String, dynamic>?, String> {
const JsonMapConverter();
@override
Map<String, dynamic>? fromSql(String? fromDb) {
if (fromDb == null || fromDb.isEmpty) return null;
return json.decode(fromDb) as Map<String, dynamic>;
}
@override
String? toSql(Map<String, dynamic>? value) {
if (value == null || value.isEmpty) return null;
return json.encode(value);
}
}
class JsonListConverter extends TypeConverter<List<String>?, String> {
const JsonListConverter();
@override
List<String>? fromSql(String? fromDb) {
if (fromDb == null || fromDb.isEmpty) return null;
final decoded = json.decode(fromDb);
return (decoded as List).map((e) => e.toString()).toList();
}
@override
String? toSql(List<String>? value) {
if (value == null || value.isEmpty) return null;
return json.encode(value);
}
}
```
### 2.3. Пример таблиц: Auth
**Создать файл:** `lib/database/tables/auth.dart`
```dart
import 'package:drift/drift.dart';
import 'users.dart';
/// Таблица Tokens - токены авторизации пользователей
class Tokens extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
TextColumn get token => text().unique()();
TextColumn get externalUserId => text()();
DateTimeColumn get created => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get expires => dateTime()();
@override
List<String> get customConstraints => [
'CONSTRAINT valid_expiry CHECK (expires > created)',
];
}
/// Таблица RefreshTokens - refresh токены для JWT
class RefreshTokens extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
TextColumn get token => text().unique()();
TextColumn get jti => text().unique()(); // JWT ID
BoolColumn get revoked => boolean().withDefault(const Constant(false))();
DateTimeColumn get revokedAt => dateTime().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get expiresAt => dateTime()();
// IP и User-Agent для безопасности
TextColumn get ipAddress => text().nullable()();
TextColumn get userAgent => text().nullable()();
}
/// Таблица TelegramAuthCodes - коды для авторизации через Telegram
class TelegramAuthCodes extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get code => text().unique()();
TextColumn get telegramUserId => text()();
TextColumn get telegramUsername => text().nullable()();
BoolColumn get used => boolean().withDefault(const Constant(false))();
DateTimeColumn get usedAt => dateTime().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get expiresAt => dateTime()();
}
```
### 2.4. Пример таблиц: CardPacks
**Создать файл:** `lib/database/tables/packs.dart`
```dart
import 'package:drift/drift.dart';
import 'dart:convert';
/// Таблица CardPacks - наборы карточек
class CardPacks extends Table {
IntColumn get id => integer().autoIncrement()();
// Основная информация
TextColumn get title => text()();
TextColumn get subtitle => text()();
TextColumn get description => text().nullable()();
// Визуальное оформление
TextColumn get color => text().nullable()();
TextColumn get cover => text().nullable()(); // URL обложки
// Параметры
IntColumn get size => integer()(); // количество карточек
TextColumn get version => text().nullable()();
IntColumn get order => integer().withDefault(const Constant(0))();
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
// Порядок карточек (JSON array of IDs)
TextColumn get cardsOrder => text()
.withDefault(const Constant('[]'))
.map(const IntListConverter())();
// Store IDs для покупок
TextColumn get googlePlayId => text().nullable()();
TextColumn get rustoreId => text().nullable()();
TextColumn get appStoreId => text().nullable()();
// Цена
TextColumn get price => text().nullable()();
TextColumn get currency => text().withDefault(const Constant('RUB'))();
// Audit
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
}
/// Таблица GameCards - карточки для изучения
class GameCards extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
// Основной контент
TextColumn get original => text()(); // слово на иностранном языке
TextColumn get translation => text()(); // перевод
TextColumn get mnemo => text().nullable()(); // мнемоническая подсказка
// Изображения
TextColumn get image => text().nullable()(); // основное изображение
TextColumn get imageBack => text().nullable()(); // изображение на обратной стороне
// Произношение
TextColumn get transcription => text().nullable()();
TextColumn get transcriptionMnemo => text().nullable()();
// Дополнительная информация
TextColumn get back => text().nullable()(); // дополнительный текст
// Audit
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
/// Таблица VoiceModels - голосовые файлы для карточек
class VoiceModels extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get cardId => integer().references(GameCards, #id, onDelete: KeyAction.cascade)();
TextColumn get voiceUrl => text()(); // URL аудиофайла
TextColumn get language => text()(); // язык озвучки
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
// Конвертер для списка int (порядок карточек)
class IntListConverter extends TypeConverter<List<int>, String> {
const IntListConverter();
@override
List<int> fromSql(String fromDb) {
if (fromDb.isEmpty || fromDb == '[]') return [];
final decoded = json.decode(fromDb);
return (decoded as List).map((e) => e as int).toList();
}
@override
String toSql(List<int> value) {
return json.encode(value);
}
}
```
### 2.5. Таблица связей: UserPacks (Many-to-Many)
**Создать файл:** `lib/database/tables/relations.dart`
```dart
import 'package:drift/drift.dart';
import 'users.dart';
import 'packs.dart';
/// Junction table для связи Users ↔ CardPacks (многие ко многим)
/// Хранит информацию о том, какие паки куплены пользователем
class UserPacks extends Table {
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
// Когда пользователь получил доступ к паку
DateTimeColumn get grantedAt => dateTime().withDefault(currentDateAndTime)();
// Как пользователь получил пак (purchase, promo, free, admin)
TextColumn get grantType => text().withDefault(const Constant('purchase'))();
@override
Set<Column> get primaryKey => {userId, packId};
}
/// Таблица PreviewCards - связь CardPacks с preview карточками
/// Хранит какие карточки показывать в превью пака
class PreviewCards extends Table {
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
IntColumn get cardId => integer().references(GameCards, #id, onDelete: KeyAction.cascade)();
IntColumn get order => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {packId, cardId};
}
```
### 2.6. Остальные таблицы
**Создать файлы:**
1. `lib/database/tables/subscriptions.dart` - SubscriptionPlans, UserSubscriptions
2. `lib/database/tables/payments.dart` - Payments
3. `lib/database/tables/tests.dart` - Tests, TestQuestions, TestStatistics
4. `lib/database/tables/tasks.dart` - Tasks, UserTasks, UserTaskProgresses, UserTaskResults
5. `lib/database/tables/promo_codes.dart` - PromoCodesCampaigns, PromoCodes
6. `lib/database/tables/discounts.dart` - DiscountCampaigns, Discounts
7. `lib/database/tables/statistics.dart` - StudySessions
8. `lib/database/tables/telegram.dart` - ShareRequests
**Примеры в каждом файле аналогичны приведенным выше.**
### 2.7. Главный файл базы данных
**Создать файл:** `lib/database/database.dart`
```dart
import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:postgres/postgres.dart' as pg;
import 'dart:io';
// Импорт всех таблиц
import 'tables/users.dart';
import 'tables/auth.dart';
import 'tables/packs.dart';
import 'tables/relations.dart';
import 'tables/subscriptions.dart';
import 'tables/payments.dart';
import 'tables/tests.dart';
import 'tables/tasks.dart';
import 'tables/promo_codes.dart';
import 'tables/discounts.dart';
import 'tables/statistics.dart';
import 'tables/telegram.dart';
// Импорт DAOs (будут созданы позже)
import 'daos/user_dao.dart';
import 'daos/pack_dao.dart';
import 'daos/test_dao.dart';
import 'daos/payment_dao.dart';
import 'daos/subscription_dao.dart';
import 'daos/task_dao.dart';
import 'daos/promo_code_dao.dart';
import 'daos/discount_dao.dart';
import 'daos/statistics_dao.dart';
// Сгенерированный код будет здесь
part 'database.g.dart';
@DriftDatabase(
tables: [
// User tables
Users,
UserDatas,
// Auth tables
Tokens,
RefreshTokens,
TelegramAuthCodes,
// Pack tables
CardPacks,
GameCards,
VoiceModels,
// Relations
UserPacks,
PreviewCards,
// Subscription tables
SubscriptionPlans,
UserSubscriptions,
// Payment tables
Payments,
// Test tables
Tests,
TestQuestions,
TestStatistics,
// Task tables
Tasks,
UserTasks,
UserTaskProgresses,
UserTaskResults,
// Promo code tables
PromoCodesCampaigns,
PromoCodes,
// Discount tables
DiscountCampaigns,
Discounts,
// Statistics tables
StudySessions,
// Telegram tables
ShareRequests,
],
daos: [
UserDao,
PackDao,
TestDao,
PaymentDao,
SubscriptionDao,
TaskDao,
PromoCodeDao,
DiscountDao,
StatisticsDao,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
/// Factory для подключения к PostgreSQL
static AppDatabase connect({
required String host,
required int port,
required String database,
required String username,
required String password,
bool useSsl = false,
}) {
final endpoint = pg.Endpoint(
host: host,
port: port,
database: database,
username: username,
password: password,
);
final connection = PgDatabase(
endpoint: endpoint,
settings: pg.ConnectionSettings(
sslMode: useSsl ? pg.SslMode.require : pg.SslMode.disable,
connectTimeout: const Duration(seconds: 10),
),
);
return AppDatabase(connection);
}
/// Factory для подключения из environment variables
static AppDatabase fromEnvironment() {
return connect(
host: Platform.environment['DB_HOST'] ?? 'localhost',
port: int.parse(Platform.environment['DB_PORT'] ?? '5432'),
database: Platform.environment['DB_NAME'] ?? 'mnemo_cards_dev',
username: Platform.environment['DB_USER'] ?? 'mnemo_user',
password: Platform.environment['DB_PASSWORD'] ?? '',
useSsl: Platform.environment['DB_SSL_MODE'] == 'require',
);
}
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async {
print('Creating database schema...');
await m.createAll();
print('Database schema created successfully');
// Создать индексы для оптимизации
await _createIndexes();
},
onUpgrade: (Migrator m, int from, int to) async {
print('Migrating database from version $from to $to');
// Миграции при обновлении схемы
// if (from < 2) {
// await m.addColumn(users, users.phoneNumber);
// }
},
beforeOpen: (details) async {
print('Opening database connection...');
// Проверка подключения
final result = await customSelect('SELECT 1 as test').getSingle();
print('Database connection successful: ${result.data}');
// Включить foreign key constraints
await customStatement('SET CONSTRAINTS ALL IMMEDIATE');
},
);
/// Создание индексов для оптимизации запросов
Future<void> _createIndexes() async {
print('Creating indexes...');
// Users indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_external_id ON users(external_user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_admin ON users(admin) WHERE admin = true');
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_not_deleted ON users(is_deleted) WHERE is_deleted = false');
// UserDatas indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_user_datas_last_online ON user_datas(last_time_online DESC)');
// Auth indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON tokens(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_tokens_expires ON tokens(expires) WHERE expires > NOW()');
await customStatement('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_jti ON refresh_tokens(jti)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_not_revoked ON refresh_tokens(revoked) WHERE revoked = false');
await customStatement('CREATE INDEX IF NOT EXISTS idx_telegram_codes_code ON telegram_auth_codes(code)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_telegram_codes_not_used ON telegram_auth_codes(used) WHERE used = false');
// CardPacks indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_packs_enabled ON card_packs(enabled) WHERE enabled = true');
await customStatement('CREATE INDEX IF NOT EXISTS idx_packs_order ON card_packs("order")');
// GameCards indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_cards_pack_id ON game_cards(pack_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_cards_original ON game_cards(original)');
// UserPacks indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_user_packs_user_id ON user_packs(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_user_packs_pack_id ON user_packs(pack_id)');
// Payments indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_payments_created ON payments(created_at DESC)');
// Subscriptions indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON user_subscriptions(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_subscriptions_active ON user_subscriptions(end_date) WHERE end_date > NOW()');
// Tests indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_tests_pack_id ON tests(pack_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_test_questions_test_id ON test_questions(test_id)');
// StudySessions indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON study_sessions(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_sessions_started ON study_sessions(started_at DESC)');
print('Indexes created successfully');
}
}
```
### 2.8. Генерация кода
После создания всех таблиц, нужно сгенерировать Drift код:
```bash
cd mnemo_cards_backend
# Сгенерировать код
dart run build_runner build --delete-conflicting-outputs
# Или в watch mode (автогенерация при изменениях)
dart run build_runner watch --delete-conflicting-outputs
```
Это создаст файл `lib/database/database.g.dart` с сгенерированным кодом.
---
## Этап 3: Создание DAOs
### 3.1. UserDao - CRUD для Users
**Создать файл:** `lib/database/daos/user_dao.dart`
```dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../tables/users.dart';
import '../tables/auth.dart';
import '../tables/packs.dart';
import '../tables/relations.dart';
part 'user_dao.g.dart';
@DriftAccessor(tables: [Users, UserDatas, Tokens, RefreshTokens, UserPacks])
class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
UserDao(super.db);
// ==================== Users ====================
/// Получить пользователя по ID
Future<User?> getUserById(int id) {
return (select(users)..where((u) => u.id.equals(id))).getSingleOrNull();
}
/// Получить пользователя с UserData
Future<UserWithData?> getUserWithDataById(int id) async {
final query = select(users).join([
leftOuterJoin(userDatas, userDatas.userId.equalsExp(users.id)),
])..where(users.id.equals(id));
final result = await query.getSingleOrNull();
if (result == null) return null;
return UserWithData(
user: result.readTable(users),
userData: result.readTableOrNull(userDatas),
);
}
/// Получить пользователя по email
Future<User?> getUserByEmail(String email) {
return (select(users)..where((u) => u.email.equals(email))).getSingleOrNull();
}
/// Получить пользователя по externalUserId
Future<User?> getUserByExternalId(String externalId) {
return (select(users)
..where((u) => u.externalUserId.equals(externalId))
).getSingleOrNull();
}
/// Создать пользователя
Future<int> createUser(UsersCompanion user) {
return into(users).insert(user);
}
/// Создать пользователя с UserData
Future<int> createUserWithData({
required UsersCompanion user,
required UserDatasCompanion userData,
}) async {
return await transaction(() async {
final userId = await into(users).insert(user);
await into(userDatas).insert(userData.copyWith(userId: Value(userId)));
return userId;
});
}
/// Обновить пользователя
Future<bool> updateUser(User user) {
return update(users).replace(user);
}
/// Удалить пользователя (soft delete)
Future<void> softDeleteUser(int userId) {
return (update(users)..where((u) => u.id.equals(userId)))
.write(const UsersCompanion(isDeleted: Value(true)));
}
/// Получить всех пользователей (для админки)
Future<List<User>> getAllUsers({
int? limit,
int? offset,
bool includeDeleted = false,
}) {
final query = select(users);
if (!includeDeleted) {
query.where((u) => u.isDeleted.equals(false));
}
query.orderBy([(u) => OrderingTerm.desc(u.createdAt)]);
if (limit != null) {
query.limit(limit, offset: offset);
}
return query.get();
}
/// Подсчитать пользователей
Future<int> countUsers({bool includeDeleted = false}) async {
final countExpr = users.id.count();
final query = selectOnly(users)..addColumns([countExpr]);
if (!includeDeleted) {
query.where(users.isDeleted.equals(false));
}
return await query.map((row) => row.read(countExpr)!).getSingle();
}
/// Получить пользователей с подпиской
Stream<List<User>> watchUsersWithActiveSubscription() {
// TODO: implement with join to UserSubscriptions
return (select(users)
..where((u) => u.isDeleted.equals(false))
).watch();
}
// ==================== UserData ====================
/// Получить UserData пользователя
Future<UserData?> getUserData(int userId) {
return (select(userDatas)..where((ud) => ud.userId.equals(userId)))
.getSingleOrNull();
}
/// Создать UserData
Future<int> createUserData(UserDatasCompanion userData) {
return into(userDatas).insert(userData);
}
/// Обновить UserData
Future<bool> updateUserData(UserData userData) {
return update(userDatas).replace(userData);
}
/// Обновить время последнего визита
Future<void> updateLastOnline(int userId) async {
await (update(userDatas)..where((ud) => ud.userId.equals(userId)))
.write(UserDatasCompanion(
lastTimeOnline: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
));
}
/// Обновить баланс пользователя
Future<void> updateBalance(int userId, int newBalance) async {
await (update(userDatas)..where((ud) => ud.userId.equals(userId)))
.write(UserDatasCompanion(
balance: Value(newBalance),
updatedAt: Value(DateTime.now()),
));
}
// ==================== Tokens ====================
/// Получить токен по значению
Future<Token?> getTokenByValue(String tokenValue) {
return (select(tokens)..where((t) => t.token.equals(tokenValue)))
.getSingleOrNull();
}
/// Получить токен пользователя
Future<Token?> getTokenByUserId(int userId) {
return (select(tokens)
..where((t) => t.userId.equals(userId))
..where((t) => t.expires.isBiggerThanValue(DateTime.now()))
..orderBy([(t) => OrderingTerm.desc(t.created)])
).getSingleOrNull();
}
/// Создать токен
Future<int> createToken(TokensCompanion token) {
return into(tokens).insert(token);
}
/// Удалить токен
Future<int> deleteToken(int tokenId) {
return (delete(tokens)..where((t) => t.id.equals(tokenId))).go();
}
/// Удалить истекшие токены
Future<int> deleteExpiredTokens() {
return (delete(tokens)
..where((t) => t.expires.isSmallerThanValue(DateTime.now()))
).go();
}
// ==================== User Packs ====================
/// Получить паки пользователя
Future<List<CardPack>> getUserPacks(int userId) async {
final query = select(cardPacks).join([
innerJoin(
userPacks,
userPacks.packId.equalsExp(cardPacks.id) &
userPacks.userId.equals(userId),
),
]);
return query.map((row) => row.readTable(cardPacks)).get();
}
/// Проверить, есть ли у пользователя доступ к паку
Future<bool> hasPackAccess(int userId, int packId) async {
final query = select(userPacks)
..where((up) => up.userId.equals(userId) & up.packId.equals(packId));
final result = await query.getSingleOrNull();
return result != null;
}
/// Дать пользователю доступ к паку
Future<void> grantPackAccess({
required int userId,
required int packId,
String grantType = 'purchase',
}) async {
await into(userPacks).insert(
UserPacksCompanion.insert(
userId: userId,
packId: packId,
grantType: Value(grantType),
),
mode: InsertMode.insertOrIgnore, // игнорировать если уже есть
);
}
/// Отозвать доступ к паку
Future<void> revokePackAccess(int userId, int packId) async {
await (delete(userPacks)
..where((up) => up.userId.equals(userId) & up.packId.equals(packId))
).go();
}
}
/// Вспомогательный класс для User с UserData
class UserWithData {
final User user;
final UserData? userData;
UserWithData({required this.user, this.userData});
}
```
### 3.2. Остальные DAOs
Создать аналогичные DAO файлы:
1. **PackDao** (`lib/database/daos/pack_dao.dart`) - CRUD для CardPacks, GameCards
2. **TestDao** (`lib/database/daos/test_dao.dart`) - CRUD для Tests, TestQuestions
3. **PaymentDao** (`lib/database/daos/payment_dao.dart`) - CRUD для Payments
4. **SubscriptionDao** (`lib/database/daos/subscription_dao.dart`) - CRUD для Subscriptions
5. **TaskDao** (`lib/database/daos/task_dao.dart`) - CRUD для Tasks
6. **PromoCodeDao** (`lib/database/daos/promo_code_dao.dart`) - CRUD для PromoCodes
7. **DiscountDao** (`lib/database/daos/discount_dao.dart`) - CRUD для Discounts
8. **StatisticsDao** (`lib/database/daos/statistics_dao.dart`) - CRUD для StudySessions
**Каждый DAO должен содержать:**
- Методы получения (get, getById, getAll)
- Методы создания (create, insert)
- Методы обновления (update)
- Методы удаления (delete, soft delete)
- Специфичные методы для сущности (например, getActiveSubscriptions)
### 3.3. Генерация кода для DAOs
```bash
# Сгенерировать код после создания DAOs
dart run build_runner build --delete-conflicting-outputs
```
---
## Этап 4: Рефакторинг кода
### 4.1. Обновление main.dart
**Файл:** `lib/main.dart`
**Было:**
```dart
import 'package:isar/isar.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
late Isar isar;
Future<Isar> _initIsar(String dir, bool debugMode) async {
var attempt = 0;
while (attempt < 5) {
print('Attempt $attempt to initialize Isar');
try {
return isar = await IsarConnector().connect(
dir: dir,
name: 'db',
inspector: debugMode,
);
} catch (e, s) {
print('Error initializing Isar: $e');
log('Error initializing Isar: $e', error: e, stackTrace: s);
}
await Future.delayed(Duration(seconds: attempt * 10));
attempt++;
}
throw Exception('Failed to initialize Isar');
}
void main() async {
final dir = Platform.environment['ISAR_DIR'] ?? 'isar';
final debugMode = Platform.environment['DEBUG'] == 'true' ||
Platform.environment['DEBUG'] == '1';
_initIsar(dir, debugMode).then((isar) async {
getIt.registerSingleton<Isar>(isar);
configureDependencies();
await getIt<MnemoShelf>().initV2();
CronManager([...]).init();
});
ProcessSignal.sigterm.watch().listen((signal) async {
try {
await isar.close();
} catch (e, s) {
log('Error closing Isar: $e', error: e, stackTrace: s);
}
exit(0);
});
}
```
**Стало:**
```dart
import 'dart:developer';
import 'dart:io';
import 'package:mnemo_cards_backend/api/mnemo_shelf.dart';
import 'package:mnemo_cards_backend/cron/add_free_packs.dart';
import 'package:mnemo_cards_backend/cron/backup.dart';
import 'package:mnemo_cards_backend/cron/cron_executor.dart';
import 'package:mnemo_cards_backend/cron/generate_promocodes.dart';
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
import 'package:mnemo_cards_backend/user/user_manager.dart';
import 'package:mnemo_cards_backend/tests/test_manager.dart';
// Импорт Drift database
import 'package:mnemo_cards_backend/database/database.dart';
import 'api/di/injector.dart';
import 'cron/check_admins.dart';
import 'cron/check_payment.dart';
import 'cron/delete_old_archives.dart';
import 'cron/discount_campaign_task.dart';
import 'cron/tasks_seeder.dart';
import 'cron/test_generator.dart';
import 'cron/update_online_users.dart';
import 'packs/free_packs_distributor.dart';
late AppDatabase database;
late final String WORK_DIR;
/// Инициализация подключения к PostgreSQL
Future<AppDatabase> _initDatabase() async {
var attempt = 0;
const maxAttempts = 5;
while (attempt < maxAttempts) {
print('Attempt ${attempt + 1}/$maxAttempts to connect to PostgreSQL...');
try {
// Создать подключение из environment variables
final db = AppDatabase.fromEnvironment();
// Проверить подключение
await db.customSelect('SELECT 1').getSingle();
print('✅ Successfully connected to PostgreSQL');
return database = db;
} catch (e, s) {
print('❌ Error connecting to PostgreSQL: $e');
log('Error connecting to PostgreSQL: $e', error: e, stackTrace: s);
attempt++;
if (attempt < maxAttempts) {
final delay = Duration(seconds: attempt * 5);
print('Retrying in ${delay.inSeconds} seconds...');
await Future.delayed(delay);
}
}
}
throw Exception('Failed to connect to PostgreSQL after $maxAttempts attempts');
}
void main() async {
print('🚀 Starting Mnemo Cards Backend...');
// Читаем environment variables
WORK_DIR = Platform.environment['WORK_DIR'] ?? '/root/mnemo_cards_backend';
final backupDir = Platform.environment['BACKUP_DIR'] ?? '../backups/';
final debugMode = Platform.environment['DEBUG'] == 'true' ||
Platform.environment['DEBUG'] == '1';
print('📂 Working directory: $WORK_DIR');
print('🐛 Debug mode: $debugMode');
// Инициализация PostgreSQL
try {
await _initDatabase();
// Регистрация в DI
getIt.registerSingleton<AppDatabase>(database);
// Настройка остальных зависимостей
configureDependencies();
// Запуск API сервера
print('🌐 Starting API server...');
await getIt<MnemoShelf>().initV2();
// Запуск cron jobs
print('⏰ Starting cron jobs...');
// ignore: unawaited_futures
CronManager([
DeleteOldArchives(),
CheckAdminsTask(),
TestGeneratorTask(getIt.get<TestManager>()),
getIt.get<CheckPaymentTask>(),
AddFreePacks(getIt.get<FreePacksDistributor>()),
Backup(backupDir),
GeneratePromocodes(),
DiscountCampaignTask(getIt.get<DiscountsManager>()),
UpdateOnlineUsersTask(getIt.get<UserManager>()),
TasksSeederTask(),
]).init();
print('✅ Backend started successfully!');
} catch (e, s) {
print('❌ Fatal error starting backend: $e');
log('Fatal error starting backend: $e', error: e, stackTrace: s);
exit(1);
}
// Обработка SIGTERM для graceful shutdown
ProcessSignal.sigterm.watch().listen((signal) async {
print('🛑 Received SIGTERM, shutting down gracefully...');
try {
// Закрыть подключение к БД
await database.close();
print('✅ Database connection closed');
} catch (e, s) {
print('❌ Error closing database: $e');
log('Error closing database: $e', error: e, stackTrace: s);
}
exit(0);
});
}
```
### 4.2. Обновление UserManager
**Файл:** `lib/user/user_manager.dart`
Заменить все прямые обращения к `isar` на вызовы через `UserDao`:
**Было:**
```dart
Future<UserModel?> fetchUser(Id id) async {
final user = await isar.userModels.get(id);
return user;
}
```
**Стало:**
```dart
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
@lazySingleton
class UserManager {
final AppDatabase _db;
final FreePacksDistributor _freePacksDistributor;
final SessionTracker _sessionTracker;
final StatisticsCalculator _statisticsCalculator;
final AchievementManager _achievementManager;
UserManager(
this._db,
this._freePacksDistributor,
this._sessionTracker,
this._statisticsCalculator,
this._achievementManager,
);
Future<User?> fetchUser(int id) async {
return await _db.userDao.getUserById(id);
}
// ... остальные методы обновить аналогично
}
```
### 4.3. Обновление PackManager
**Файл:** `lib/packs/pack_manager.dart`
**Было:**
```dart
Future<List<CardPackPreviewDto>> listPacksPreviews(
UserModel? userModel,
Map<String, String>? params,
) async {
final models = await isar.cardPackModels
.filter()
.optional(
userModel?.admin != true,
(q) => q.enabledEqualTo(true),
)
.findAll();
models.sort((p, n) => p.order.compareTo(n.order));
// ...
}
```
**Стало:**
```dart
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
@lazySingleton
class PackManager {
final AppDatabase _db;
final PackDtoConverter packDtoConverter;
PackManager(this._db, this.packDtoConverter);
Future<List<CardPackPreviewDto>> listPacksPreviews(
User? user,
Map<String, String>? params,
) async {
// Получить паки через DAO
final models = await _db.packDao.getAllPacks(
enabledOnly: user?.admin != true,
orderByField: 'order',
);
// ... остальная логика
}
}
```
### 4.4. Обновление API endpoints
Для каждого API файла обновить обращения к БД:
**Пример:** `lib/api/v2/users_api_v2.dart`
**Было:**
```dart
await backend_main.isar.writeTxn(() async {
final current = await backend_main.isar.userModels.get(user.id!);
if (current == null) {
throw StateError('User not found');
}
final updated = current.copyWith(
name: name ?? current.name,
email: email ?? current.email,
);
await backend_main.isar.userModels.put(updated);
});
```
**Стало:**
```dart
await _db.transaction(() async {
final current = await _db.userDao.getUserById(user.id!);
if (current == null) {
throw StateError('User not found');
}
await _db.userDao.updateUser(current.copyWith(
name: name ?? current.name,
email: email ?? current.email,
));
});
```
### 4.5. Приоритет рефакторинга
**Высокий приоритет (критичные для работы):**
1.`lib/main.dart` - инициализация БД
2.`lib/user/user_manager.dart` - аутентификация
3.`lib/api/v2/auth_api_v2.dart` - логин/регистрация
4.`lib/api/v2/jwt_service.dart` - токены
5.`lib/api/purchase/payment_manager.dart` - платежи
**Средний приоритет:**
6. `lib/packs/pack_manager.dart`
7. `lib/api/v2/packs_api_v2.dart`
8. `lib/api/v2/users_api_v2.dart`
9. `lib/tests/test_manager.dart`
10. `lib/api/subscription/subscription_manager.dart`
**Низкий приоритет:**
11-34. Admin API, cron jobs, статистика
### 4.6. Миграция моделей и менеджеров
#### 4.6.1. Маппинг Isar моделей → Drift таблицы
**Принципы миграции:**
1. **Isar @Collection****Drift Table**
- `@Collection()` класс превращается в класс, наследующий `Table`
- Поля модели становятся методами с типом `Column`
2. **Типы данных:**
```dart
// Isar → Drift
Id → IntColumn (autoIncrement)
int → IntColumn
String → TextColumn
bool → BoolColumn
DateTime → DateTimeColumn
double → RealColumn
List<String> → TextColumn + JsonListConverter
Map<String, dynamic> → TextColumn + JsonMapConverter
```
3. **Отношения:**
```dart
// Isar Links → Foreign Keys
final pack = IsarLink<CardPackModel>()
IntColumn get packId => integer().references(CardPacks, #id)
```
**Пример миграции модели:**
**Было (Isar):** `lib/models/user_model.dart`
```dart
import 'package:isar/isar.dart';
@Collection()
class UserModel {
Id? id;
@Index(unique: true)
late String externalUserId;
late String name;
String? email;
@Index()
bool admin = false;
DateTime? createdAt;
bool isDeleted = false;
// Связь с UserData
final userData = IsarLink<UserDataModel>();
}
```
**Стало (Drift):** `lib/database/tables/users.dart`
```dart
import 'package:drift/drift.dart';
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get email => text().nullable()();
TextColumn get externalUserId => text().unique()();
BoolColumn get admin => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
// UserData в отдельной таблице с Foreign Key
class UserDatas extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get userId => integer().unique()
.references(Users, #id, onDelete: KeyAction.cascade)();
IntColumn get balance => integer().withDefault(const Constant(0))();
DateTimeColumn get lastTimeOnline => dateTime().nullable()();
// ... остальные поля
}
```
#### 4.6.2. Рефакторинг менеджеров
**Шаблон рефакторинга для всех Manager классов:**
**1. Заменить зависимость от Isar на AppDatabase**
```dart
// Было
@lazySingleton
class UserManager {
final Isar _isar;
UserManager(this._isar);
}
// Стало
@lazySingleton
class UserManager {
final AppDatabase _db;
UserManager(this._db);
}
```
**2. Заменить прямые обращения к коллекциям на DAO методы**
```dart
// Было
Future<UserModel?> getUser(Id id) async {
return await _isar.userModels.get(id);
}
// Стало
Future<User?> getUser(int id) async {
return await _db.userDao.getUserById(id);
}
```
**3. Заменить транзакции**
```dart
// Было
await _isar.writeTxn(() async {
await _isar.userModels.put(user);
await _isar.userDataModels.put(userData);
});
// Стало
await _db.transaction(() async {
await _db.userDao.updateUser(user);
await _db.userDao.updateUserData(userData);
});
```
**4. Заменить фильтры и запросы**
```dart
// Было
final users = await _isar.userModels
.filter()
.adminEqualTo(true)
.isDeletedEqualTo(false)
.findAll();
// Стало
final users = await _db.userDao.getAllUsers(
includeDeleted: false,
adminOnly: true,
);
```
**5. Заменить стримы**
```dart
// Было
Stream<List<UserModel>> watchUsers() {
return _isar.userModels.watchLazy().map((_) =>
_isar.userModels.where().findAll()
);
}
// Стало
Stream<List<User>> watchUsers() {
return _db.userDao.watchAllUsers();
}
```
#### 4.6.3. Полный список менеджеров для миграции
**Таблица соответствия:**
| Менеджер | Файл | Зависимые модели | DAO |
|----------|------|------------------|-----|
| UserManager | `lib/user/user_manager.dart` | UserModel, UserDataModel | UserDao |
| PackManager | `lib/packs/pack_manager.dart` | CardPackModel, GameCardModel | PackDao |
| TestManager | `lib/tests/test_manager.dart` | TestModel, TestQuestionModel | TestDao |
| PaymentManager | `lib/api/purchase/payment_manager.dart` | PaymentModel | PaymentDao |
| SubscriptionManager | `lib/api/subscription/subscription_manager.dart` | SubscriptionPlanModel, UserSubscriptionModel | SubscriptionDao |
| TaskManager | `lib/tasks/task_manager.dart` | TaskModel, UserTaskModel | TaskDao |
| PromoCodeManager | `lib/promo_codes/promo_code_manager.dart` | PromoCodeModel | PromoCodeDao |
| DiscountsManager | `lib/discounts/discounts_manager.dart` | DiscountModel | DiscountDao |
| StatisticsCalculator | `lib/statistics/statistics_calculator.dart` | StudySessionModel | StatisticsDao |
| AuthManager | `lib/api/v2/auth_manager.dart` | TokenModel, RefreshTokenModel | UserDao (включает Tokens) |
#### 4.6.4. Пошаговая миграция менеджера
**Пример: PackManager**
**Шаг 1. Обновить зависимости**
```dart
// Было
import 'package:isar/isar.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@lazySingleton
class PackManager {
final Isar _isar;
final PackDtoConverter _packDtoConverter;
PackManager(this._isar, this._packDtoConverter);
```
```dart
// Стало
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/api/dto/pack_dto.dart';
@lazySingleton
class PackManager {
final AppDatabase _db;
final PackDtoConverter _packDtoConverter;
PackManager(this._db, this._packDtoConverter);
```
**Шаг 2. Обновить методы получения**
```dart
// Было
Future<CardPackModel?> getPack(Id id) async {
return await _isar.cardPackModels.get(id);
}
Future<List<CardPackModel>> getAllPacks() async {
return await _isar.cardPackModels
.filter()
.enabledEqualTo(true)
.sortByOrder()
.findAll();
}
// Стало
Future<CardPack?> getPack(int id) async {
return await _db.packDao.getPackById(id);
}
Future<List<CardPack>> getAllPacks() async {
return await _db.packDao.getAllPacks(
enabledOnly: true,
orderByField: 'order',
);
}
```
**Шаг 3. Обновить методы создания/обновления**
```dart
// Было
Future<Id> createPack(CardPackModel pack) async {
return await _isar.writeTxn(() async {
return await _isar.cardPackModels.put(pack);
});
}
// Стало
Future<int> createPack(CardPacksCompanion pack) async {
return await _db.packDao.createPack(pack);
}
```
**Шаг 4. Обновить сложные запросы**
```dart
// Было
Future<List<CardPackModel>> getUserPacks(Id userId) async {
final user = await _isar.userModels.get(userId);
if (user == null) return [];
await user.packs.load();
return user.packs.toList();
}
// Стало
Future<List<CardPack>> getUserPacks(int userId) async {
return await _db.userDao.getUserPacks(userId);
}
```
**Шаг 5. Обновить транзакции**
```dart
// Было
Future<void> addCardsToPack(Id packId, List<GameCardModel> cards) async {
await _isar.writeTxn(() async {
final pack = await _isar.cardPackModels.get(packId);
if (pack == null) throw StateError('Pack not found');
await _isar.gameCardModels.putAll(cards);
pack.size = cards.length;
await _isar.cardPackModels.put(pack);
});
}
// Стало
Future<void> addCardsToPack(int packId, List<GameCardsCompanion> cards) async {
await _db.transaction(() async {
final pack = await _db.packDao.getPackById(packId);
if (pack == null) throw StateError('Pack not found');
for (final card in cards) {
await _db.packDao.createCard(card);
}
await _db.packDao.updatePack(pack.copyWith(size: cards.length));
});
}
```
#### 4.6.5. Обновление DI (Dependency Injection)
**Файл:** `lib/api/di/injector.dart`
```dart
// Было
@module
abstract class AppModule {
@singleton
Isar get isar => backend_main.isar;
@lazySingleton
UserManager userManager(Isar isar) => UserManager(isar);
@lazySingleton
PackManager packManager(Isar isar, PackDtoConverter converter) =>
PackManager(isar, converter);
}
// Стало
@module
abstract class AppModule {
@singleton
AppDatabase get database => backend_main.database;
@lazySingleton
UserManager userManager(AppDatabase db) => UserManager(db);
@lazySingleton
PackManager packManager(AppDatabase db, PackDtoConverter converter) =>
PackManager(db, converter);
}
```
После изменений:
```bash
dart run build_runner build --delete-conflicting-outputs
```
#### 4.6.6. Checklist миграции для каждого менеджера
Для каждого менеджера проверить:
- [ ] Заменена зависимость `Isar` на `AppDatabase`
- [ ] Все методы `get()` заменены на `dao.getById()`
- [ ] Все методы `filter().findAll()` заменены на DAO методы
- [ ] Все `writeTxn()` заменены на `transaction()`
- [ ] Все `IsarLink` заменены на JOIN запросы через DAO
- [ ] Все типы `Id` заменены на `int`
- [ ] Все модели `*Model` заменены на Drift data классы
- [ ] Companion классы используются для insert/update
- [ ] Unit тесты обновлены
- [ ] DI обновлён в `injector.dart`
#### 4.6.7. Пример полной миграции менеджера
**Файл:** `lib/api/purchase/payment_manager.dart`
```dart
// ========== БЫЛО ==========
import 'package:isar/isar.dart';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@lazySingleton
class PaymentManager {
final Isar _isar;
final YookassaClient _yookassa;
PaymentManager(this._isar, this._yookassa);
Future<PaymentModel> createPayment({
required Id userId,
required Id packId,
required double amount,
}) async {
final payment = PaymentModel()
..userId = userId
..packId = packId
..amount = amount
..status = 'pending'
..createdAt = DateTime.now();
await _isar.writeTxn(() async {
await _isar.paymentModels.put(payment);
});
return payment;
}
Future<void> confirmPayment(Id paymentId) async {
await _isar.writeTxn(() async {
final payment = await _isar.paymentModels.get(paymentId);
if (payment == null) throw StateError('Payment not found');
payment.status = 'confirmed';
payment.confirmedAt = DateTime.now();
await _isar.paymentModels.put(payment);
// Дать доступ к паку
final user = await _isar.userModels.get(payment.userId);
final pack = await _isar.cardPackModels.get(payment.packId);
if (user != null && pack != null) {
await user.packs.load();
user.packs.add(pack);
await user.packs.save();
}
});
}
Future<List<PaymentModel>> getUserPayments(Id userId) async {
return await _isar.paymentModels
.filter()
.userIdEqualTo(userId)
.sortByCreatedAtDesc()
.findAll();
}
}
// ========== СТАЛО ==========
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/api/yookassa/yookassa_client.dart';
import 'package:drift/drift.dart' as drift;
@lazySingleton
class PaymentManager {
final AppDatabase _db;
final YookassaClient _yookassa;
PaymentManager(this._db, this._yookassa);
Future<Payment> createPayment({
required int userId,
required int packId,
required double amount,
}) async {
final companion = PaymentsCompanion.insert(
userId: userId,
packId: packId,
amount: amount.toString(),
status: 'pending',
);
final paymentId = await _db.paymentDao.createPayment(companion);
final payment = await _db.paymentDao.getPaymentById(paymentId);
if (payment == null) {
throw StateError('Failed to create payment');
}
return payment;
}
Future<void> confirmPayment(int paymentId) async {
await _db.transaction(() async {
final payment = await _db.paymentDao.getPaymentById(paymentId);
if (payment == null) throw StateError('Payment not found');
// Обновить статус платежа
await _db.paymentDao.updatePayment(
payment.copyWith(
status: 'confirmed',
confirmedAt: drift.Value(DateTime.now()),
),
);
// Дать доступ к паку
await _db.userDao.grantPackAccess(
userId: payment.userId,
packId: payment.packId,
grantType: 'purchase',
);
});
}
Future<List<Payment>> getUserPayments(int userId) async {
return await _db.paymentDao.getPaymentsByUserId(
userId,
orderByCreatedAt: true,
descending: true,
);
}
}
```
#### 4.6.8. Автоматизация проверки миграции
**Создать скрипт:** `scripts/check_migration_progress.dart`
```dart
import 'dart:io';
void main() {
final menedgersToMigrate = [
'lib/user/user_manager.dart',
'lib/packs/pack_manager.dart',
'lib/tests/test_manager.dart',
'lib/api/purchase/payment_manager.dart',
'lib/api/subscription/subscription_manager.dart',
'lib/tasks/task_manager.dart',
'lib/promo_codes/promo_code_manager.dart',
'lib/discounts/discounts_manager.dart',
'lib/statistics/statistics_calculator.dart',
];
print('🔍 Checking migration progress...\n');
int migrated = 0;
int notMigrated = 0;
for (final path in menedgersToMigrate) {
final file = File(path);
if (!file.existsSync()) {
print('❓ $path - file not found');
continue;
}
final content = file.readAsStringSync();
final hasIsar = content.contains('import \'package:isar/isar.dart\'');
final hasDrift = content.contains('AppDatabase');
if (hasDrift && !hasIsar) {
print('✅ $path - migrated');
migrated++;
} else if (hasIsar) {
print('❌ $path - NOT migrated (still uses Isar)');
notMigrated++;
} else {
print('⚠️ $path - unclear state');
}
}
print('\n📊 Summary:');
print('Migrated: $migrated / ${menedgersToMigrate.length}');
print('Not migrated: $notMigrated / ${menedgersToMigrate.length}');
print('Progress: ${(migrated / menedgersToMigrate.length * 100).toStringAsFixed(1)}%');
}
```
Запуск:
```bash
dart run scripts/check_migration_progress.dart
```
---
## Этап 5: Тестирование
### 5.1. Обновление unit тестов
**Пример:** `test/api/v2/users_api_v2_test.dart`
**Было:**
```dart
setUpAll(() async {
await Isar.initializeIsarCore(download: true);
testIsar = await Isar.open([...schemas], directory: testDir.path);
backend_main.isar = testIsar;
});
```
**Стало:**
```dart
late AppDatabase testDb;
setUpAll(() async {
// Создать тестовую БД (можно использовать in-memory или testcontainers)
testDb = AppDatabase.connect(
host: 'localhost',
port: 5433, // отдельный порт для тестов
database: 'mnemo_cards_test',
username: 'test_user',
password: 'test_pass',
);
// Создать схему
await testDb.migrator.createAll();
backend_main.database = testDb;
});
tearDown(() async {
// Очистить данные после каждого теста
await testDb.transaction(() async {
await testDb.delete(testDb.users).go();
await testDb.delete(testDb.cardPacks).go();
// ...
});
});
tearDownAll() async {
await testDb.close();
});
```
### 6.2. Mock DAOs для unit тестов
**Создать файл:** `test/mocks/mock_user_dao.dart`
```dart
import 'package:mockito/mockito.dart';
import 'package:mnemo_cards_backend/database/database.dart';
class MockUserDao extends Mock implements UserDao {}
class MockPackDao extends Mock implements PackDao {}
// ... остальные mock DAOs
```
### 6.3. Integration тесты
**Создать файл:** `test/integration/database_test.dart`
```dart
import 'package:test/test.dart';
import 'package:mnemo_cards_backend/database/database.dart';
void main() {
late AppDatabase db;
setUpAll(() async {
db = AppDatabase.connect(
host: 'localhost',
port: 5433,
database: 'mnemo_cards_test',
username: 'test_user',
password: 'test_pass',
);
await db.migrator.createAll();
});
tearDownAll(() async {
await db.close();
});
group('UserDao', () {
test('создание и получение пользователя', () async {
// Arrange
final user = UsersCompanion.insert(
name: 'Test User',
externalUserId: 'test123',
);
// Act
final userId = await db.userDao.createUser(user);
final retrieved = await db.userDao.getUserById(userId);
// Assert
expect(retrieved, isNotNull);
expect(retrieved!.name, equals('Test User'));
expect(retrieved.externalUserId, equals('test123'));
});
// ... остальные тесты
});
}
```
---
## Этап 6: Deployment
### 6.1. Обновление Dockerfile
**Файл:** `mnemo_cards_backend/Dockerfile`
```dockerfile
# syntax=docker/dockerfile:1.7
# --- Build stage ------------------------------------------------------------
FROM dart:stable-sdk AS build
WORKDIR /app
# Copy sources
COPY mnemo_cards_common /app/mnemo_cards_common
COPY mnemo_cards_backend /app/mnemo_cards_backend
# Get dependencies
WORKDIR /app/mnemo_cards_common
RUN dart pub get
WORKDIR /app/mnemo_cards_backend
RUN dart pub get
# Generate Drift code
RUN dart run build_runner build --delete-conflicting-outputs
# Compile to native executable
RUN dart compile exe lib/main.dart -o /app/server
# --- Runtime stage ----------------------------------------------------------
FROM debian:bookworm-slim AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
openssl \
curl \
wget \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# App binary
COPY --from=build /app/server /app/server
# Static/assets
COPY --from=build /app/mnemo_cards_backend/public /app/public
COPY --from=build /app/mnemo_cards_backend/data /app/data
# Environment variables
ENV PORT=3000 \
SERVER_ADDRESS=0.0.0.0 \
WORK_DIR=/app \
DEBUG=false \
ADMIN_IDS= \
DB_HOST=postgres \
DB_PORT=5432 \
DB_NAME=mnemo_cards \
DB_USER=mnemo_user \
DB_PASSWORD= \
DB_SSL_MODE=disable
EXPOSE 3000
# Healthcheck
HEALTHCHECK --interval=15s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f -sS --max-time 8 --connect-timeout 3 http://127.0.0.1:${PORT:-3000}/health > /dev/null 2>&1 || exit 1
CMD ["/app/server"]
```
### 7.2. docker-compose для продакшена
**Файл:** `docker-compose.prod.yml`
```yaml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: mnemo_postgres_prod
environment:
POSTGRES_DB: mnemo_cards
POSTGRES_USER: mnemo_user
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- postgres_prod_data:/var/lib/postgresql/data
networks:
- mnemo_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mnemo_user -d mnemo_cards"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
secrets:
- db_password
backend:
image: mnemo_backend:latest
container_name: mnemo_backend_prod
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: mnemo_cards
DB_USER: mnemo_user
DB_PASSWORD_FILE: /run/secrets/db_password
DB_SSL_MODE: require
PORT: 3000
DEBUG: false
ADMIN_IDS: ${ADMIN_IDS}
JWT_SECRET_FILE: /run/secrets/jwt_secret
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
networks:
- mnemo_network
restart: unless-stopped
secrets:
- db_password
- jwt_secret
# Опционально: PgBouncer для connection pooling
pgbouncer:
image: pgbouncer/pgbouncer:latest
environment:
DATABASES_HOST: postgres
DATABASES_PORT: 5432
DATABASES_DBNAME: mnemo_cards
DATABASES_USER: mnemo_user
PGBOUNCER_POOL_MODE: transaction
PGBOUNCER_MAX_CLIENT_CONN: 1000
PGBOUNCER_DEFAULT_POOL_SIZE: 25
ports:
- "6432:5432"
depends_on:
- postgres
networks:
- mnemo_network
volumes:
postgres_prod_data:
driver: local
networks:
mnemo_network:
driver: bridge
secrets:
db_password:
file: ./secrets/db_password.txt
jwt_secret:
file: ./secrets/jwt_secret.txt
```
### 7.3. План production миграции
**Zero-downtime migration plan:**
1. **Подготовка:**
```bash
# Создать backup Isar БД
cp -r isar/ isar_backup_$(date +%Y%m%d)/
# Развернуть PostgreSQL (managed или на сервере)
# Настроить connection string
```
2. **Миграция данных:**
```bash
# Включить maintenance mode (опционально)
# Создать read-only режим на время миграции
# Запустить миграцию
dart run scripts/migrate_isar_to_postgres.dart
# Верифицировать
dart run scripts/verify_migration.dart
```
3. **Deployment новой версии:**
```bash
# Билд нового образа
docker build -t mnemo_backend:2.0 .
# Обновить environment variables (убрать ISAR_DIR, добавить DB_*)
# Развернуть
docker service update --image mnemo_backend:2.0 mnemo_backend
# Или через docker-compose
docker-compose up -d backend
```
4. **Проверка:**
```bash
# Проверить health endpoint
curl http://localhost:3000/health
# Проверить логи
docker-compose logs -f backend
# Smoke tests (создать пользователя, получить паки, и т.д.)
```
5. **Rollback план (если что-то пошло не так):**
```bash
# Откатить Docker image
docker service update --image mnemo_backend:1.9 mnemo_backend
# Восстановить Isar БД из бэкапа
rm -rf isar/
cp -r isar_backup_YYYYMMDD/ isar/
# Проверить работоспособность
```
---
## Чеклисты
### Pre-migration Checklist
- [ ] PostgreSQL развернут (Docker/managed service)
- [ ] Connection string настроен и проверен
- [ ] Drift зависимости установлены (`dart pub get`)
- [ ] Все таблицы созданы
- [ ] Все DAOs созданы
- [ ] Drift code сгенерирован (`dart run build_runner build`)
- [ ] Backup Isar БД создан
- [ ] Скрипт миграции протестирован на копии данных
### Migration Checklist
- [ ] Maintenance mode включен (опционально)
- [ ] Скрипт миграции запущен
- [ ] Миграция завершилась без ошибок
- [ ] Verification script успешно прошел
- [ ] Количество записей в PostgreSQL совпадает с Isar
- [ ] Sample queries возвращают корректные данные
### Post-migration Checklist
- [ ] Backend рефакторинг завершен (все файлы обновлены)
- [ ] Unit тесты обновлены и проходят
- [ ] Integration тесты написаны и проходят
- [ ] Docker image собран
- [ ] Environment variables обновлены
- [ ] Backend развернут в production
- [ ] Health check проходит
- [ ] API endpoints работают корректно
- [ ] Smoke tests пройдены
- [ ] Логи не содержат критических ошибок
- [ ] Мониторинг настроен (PostgreSQL + Backend)
- [ ] Backup настроен (automated PostgreSQL backups)
- [ ] Documentation обновлена
### Rollback Checklist (если нужен откат)
- [ ] Docker image откачен на предыдущую версию
- [ ] Isar БД восстановлена из backup
- [ ] Environment variables восстановлены (ISAR_DIR и т.д.)
- [ ] Backend перезапущен
- [ ] Health check проходит
- [ ] API endpoints работают
- [ ] Логи проверены
---
## Дополнительные ресурсы
### Документация
- [Drift Documentation](https://drift.simonbinder.eu/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [postgres package](https://pub.dev/packages/postgres)
### Инструменты мониторинга
- **pgAdmin** - GUI для PostgreSQL
- **DBeaver** - универсальный клиент БД
- **pg_stat_statements** - статистика запросов
- **Metabase** - аналитика и dashboards
### Managed PostgreSQL провайдеры
- [Supabase](https://supabase.com) - бесплатный tier
- [Railway](https://railway.app) - ~$5/месяц
- [Neon](https://neon.tech) - serverless PostgreSQL
- [AWS RDS](https://aws.amazon.com/rds/postgresql/)
- [Google Cloud SQL](https://cloud.google.com/sql/postgresql)
---
## Контакты и поддержка
При возникновении проблем:
1. Проверить логи: `docker-compose logs -f backend postgres`
2. Проверить connectivity: `docker-compose exec backend ping postgres`
3. Проверить PostgreSQL: `docker-compose exec postgres psql -U mnemo_user -d mnemo_cards_dev`
**Успешной миграции! 🚀**