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