# План миграции с 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 get primaryKey => {id}; @override List 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?, String> { const JsonMapConverter(); @override Map? fromSql(String? fromDb) { if (fromDb == null || fromDb.isEmpty) return null; return json.decode(fromDb) as Map; } @override String? toSql(Map? value) { if (value == null || value.isEmpty) return null; return json.encode(value); } } class JsonListConverter extends TypeConverter?, String> { const JsonListConverter(); @override List? 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? 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 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, String> { const IntListConverter(); @override List 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 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 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 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 _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 with _$UserDaoMixin { UserDao(super.db); // ==================== Users ==================== /// Получить пользователя по ID Future getUserById(int id) { return (select(users)..where((u) => u.id.equals(id))).getSingleOrNull(); } /// Получить пользователя с UserData Future 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 getUserByEmail(String email) { return (select(users)..where((u) => u.email.equals(email))).getSingleOrNull(); } /// Получить пользователя по externalUserId Future getUserByExternalId(String externalId) { return (select(users) ..where((u) => u.externalUserId.equals(externalId)) ).getSingleOrNull(); } /// Создать пользователя Future createUser(UsersCompanion user) { return into(users).insert(user); } /// Создать пользователя с UserData Future 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 updateUser(User user) { return update(users).replace(user); } /// Удалить пользователя (soft delete) Future softDeleteUser(int userId) { return (update(users)..where((u) => u.id.equals(userId))) .write(const UsersCompanion(isDeleted: Value(true))); } /// Получить всех пользователей (для админки) Future> 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 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> watchUsersWithActiveSubscription() { // TODO: implement with join to UserSubscriptions return (select(users) ..where((u) => u.isDeleted.equals(false)) ).watch(); } // ==================== UserData ==================== /// Получить UserData пользователя Future getUserData(int userId) { return (select(userDatas)..where((ud) => ud.userId.equals(userId))) .getSingleOrNull(); } /// Создать UserData Future createUserData(UserDatasCompanion userData) { return into(userDatas).insert(userData); } /// Обновить UserData Future updateUserData(UserData userData) { return update(userDatas).replace(userData); } /// Обновить время последнего визита Future updateLastOnline(int userId) async { await (update(userDatas)..where((ud) => ud.userId.equals(userId))) .write(UserDatasCompanion( lastTimeOnline: Value(DateTime.now()), updatedAt: Value(DateTime.now()), )); } /// Обновить баланс пользователя Future 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 getTokenByValue(String tokenValue) { return (select(tokens)..where((t) => t.token.equals(tokenValue))) .getSingleOrNull(); } /// Получить токен пользователя Future 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 createToken(TokensCompanion token) { return into(tokens).insert(token); } /// Удалить токен Future deleteToken(int tokenId) { return (delete(tokens)..where((t) => t.id.equals(tokenId))).go(); } /// Удалить истекшие токены Future deleteExpiredTokens() { return (delete(tokens) ..where((t) => t.expires.isSmallerThanValue(DateTime.now())) ).go(); } // ==================== User Packs ==================== /// Получить паки пользователя Future> 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 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 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 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 _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); configureDependencies(); await getIt().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 _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(database); // Настройка остальных зависимостей configureDependencies(); // Запуск API сервера print('🌐 Starting API server...'); await getIt().initV2(); // Запуск cron jobs print('⏰ Starting cron jobs...'); // ignore: unawaited_futures CronManager([ DeleteOldArchives(), CheckAdminsTask(), TestGeneratorTask(getIt.get()), getIt.get(), AddFreePacks(getIt.get()), Backup(backupDir), GeneratePromocodes(), DiscountCampaignTask(getIt.get()), UpdateOnlineUsersTask(getIt.get()), 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 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 fetchUser(int id) async { return await _db.userDao.getUserById(id); } // ... остальные методы обновить аналогично } ``` ### 4.3. Обновление PackManager **Файл:** `lib/packs/pack_manager.dart` **Было:** ```dart Future> listPacksPreviews( UserModel? userModel, Map? 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> listPacksPreviews( User? user, Map? 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 → TextColumn + JsonListConverter Map → TextColumn + JsonMapConverter ``` 3. **Отношения:** ```dart // Isar Links → Foreign Keys final pack = IsarLink() → 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(); } ``` **Стало (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 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 getUser(Id id) async { return await _isar.userModels.get(id); } // Стало Future 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> watchUsers() { return _isar.userModels.watchLazy().map((_) => _isar.userModels.where().findAll() ); } // Стало Stream> 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 getPack(Id id) async { return await _isar.cardPackModels.get(id); } Future> getAllPacks() async { return await _isar.cardPackModels .filter() .enabledEqualTo(true) .sortByOrder() .findAll(); } // Стало Future getPack(int id) async { return await _db.packDao.getPackById(id); } Future> getAllPacks() async { return await _db.packDao.getAllPacks( enabledOnly: true, orderByField: 'order', ); } ``` **Шаг 3. Обновить методы создания/обновления** ```dart // Было Future createPack(CardPackModel pack) async { return await _isar.writeTxn(() async { return await _isar.cardPackModels.put(pack); }); } // Стало Future createPack(CardPacksCompanion pack) async { return await _db.packDao.createPack(pack); } ``` **Шаг 4. Обновить сложные запросы** ```dart // Было Future> getUserPacks(Id userId) async { final user = await _isar.userModels.get(userId); if (user == null) return []; await user.packs.load(); return user.packs.toList(); } // Стало Future> getUserPacks(int userId) async { return await _db.userDao.getUserPacks(userId); } ``` **Шаг 5. Обновить транзакции** ```dart // Было Future addCardsToPack(Id packId, List 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 addCardsToPack(int packId, List 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 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 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> 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 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 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> 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` **Успешной миграции! 🚀**