2489 lines
74 KiB
Markdown
2489 lines
74 KiB
Markdown
# План миграции с 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`
|
||
|
||
**Успешной миграции! 🚀**
|