# 📋 Детальный план улучшений базы данных > **Дата:** 14 декабря 2025 > **БД будет пересоздана с нуля** - SQL миграции не нужны --- ## 🎯 Итоговые решения ### ✅ Что делаем: 1. **WordStatistics** - вместо SessionCards (агрегация в реальном времени) 2. **AchievementDefinitions** + нормализация UserAchievements 3. Удаление 5 JSON полей из UserDatas 4. Удаление deprecated полей из Payments 5. Удаление packId из GameCards 6. Добавление метаданных в CardPacks 7. Исправление UserSubscriptions 8. Добавление soft delete везде 9. Создание AuditLog ### ❌ Что НЕ делаем (отложено): - SessionCards (слишком много записей) - Отзывы на паки - A/B тесты - User-Generated Content - Новые индексы (потом по мониторингу) --- ## 📝 Детальные шаги реализации ## Этап 1: Создание новых таблиц (Drift schemas) ### Шаг 1.1: Создать таблицу WordStatistics **Файл:** `lib/database/tables/word_statistics.dart` ```dart import 'package:drift/drift.dart'; import 'package:drift_postgres/drift_postgres.dart'; import 'users.dart'; import 'packs.dart'; /// Статистика изучения слов (агрегируется в реальном времени) /// Вместо миллионов SessionCards - одна запись на user×card class WordStatistics extends Table { TextColumn get id => text() .withDefault(const CustomExpression('gen_random_uuid()::text'))(); TextColumn get userId => text() .references(Users, #id, onDelete: KeyAction.cascade)(); TextColumn get cardId => text() .references(GameCards, #id, onDelete: KeyAction.cascade)(); // === Агрегированная статистика === IntColumn get totalReviews => integer().withDefault(const Constant(0))(); IntColumn get correctAnswers => integer().withDefault(const Constant(0))(); IntColumn get incorrectAnswers => integer().withDefault(const Constant(0))(); // Мастерство (correctAnswers / totalReviews) RealColumn get mastery => real().withDefault(const Constant(0.0))(); // Текущая и максимальная серия правильных ответов подряд IntColumn get currentStreak => integer().withDefault(const Constant(0))(); IntColumn get longestStreak => integer().withDefault(const Constant(0))(); // === Spaced Repetition (SM-2 алгоритм) === Column get lastReviewed => customType(PgTypes.timestampWithTimezone) .nullable()(); Column get nextReview => customType(PgTypes.timestampWithTimezone) .nullable()(); // SM-2 параметры IntColumn get repetitions => integer().withDefault(const Constant(0))(); RealColumn get easinessFactor => real().withDefault(const Constant(2.5))(); IntColumn get intervalDays => integer().withDefault(const Constant(1))(); // === Последняя попытка (для UI) === IntColumn get lastAttempts => integer().withDefault(const Constant(1))(); IntColumn get lastTimeSpentMs => integer().withDefault(const Constant(0))(); BoolColumn get lastWasCorrect => boolean().nullable()(); // === Audit === Column get createdAt => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); Column get updatedAt => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); BoolColumn get isDeleted => boolean() .withDefault(const Constant(false)) .customConstraint('')(); Column get deletedAt => customType(PgTypes.timestampWithTimezone) .nullable()(); @override Set get primaryKey => {id}; @override List get customConstraints => [ 'UNIQUE(user_id, card_id)', // Одна статистика на пользователя×карточку ]; } ``` --- ### Шаг 1.2: Создать таблицу AchievementDefinitions **Файл:** Обновить `lib/database/tables/achievements.dart` ```dart import 'package:drift/drift.dart'; import 'package:drift_postgres/drift_postgres.dart'; import 'users.dart'; /// Справочник всех возможных достижений в системе class AchievementDefinitions extends Table { TextColumn get id => text() .withDefault(const CustomExpression('gen_random_uuid()::text'))(); // === Идентификация === TextColumn get code => text().unique()(); // FIRST_PACK, STREAK_7, TESTS_100 // === UI информация === TextColumn get title => text()(); TextColumn get description => text()(); TextColumn get iconUrl => text().nullable()(); // === Условия получения (JSON) === // Примеры: // {"type": "purchase_pack", "count": 1} // {"type": "streak", "days": 7} // {"type": "complete_tests", "count": 100, "min_score": 90} TextColumn get requirement => text()(); // === Награды (JSON, опционально) === // {"coins": 100, "packs": ["pack-id"], "premium_days": 7} TextColumn get rewards => text().nullable()(); // === Геймификация === IntColumn get points => integer().withDefault(const Constant(0))(); TextColumn get rarity => text().withDefault(const Constant('common'))(); // common, rare, epic, legendary IntColumn get sortOrder => integer().withDefault(const Constant(0))(); // === Категория (для фильтрации) === TextColumn get category => text().nullable()(); // learning, social, streak, purchase // === Audit === Column get createdAt => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); Column get updatedAt => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); BoolColumn get isActive => boolean() .withDefault(const Constant(true)) .customConstraint('')(); BoolColumn get isDeleted => boolean() .withDefault(const Constant(false)) .customConstraint('')(); @override Set get primaryKey => {id}; } /// Таблица UserAchievements - достижения пользователей (ОБНОВЛЕННАЯ) class UserAchievements extends Table { TextColumn get id => text() .withDefault(const CustomExpression('gen_random_uuid()::text'))(); TextColumn get userId => text() .references(Users, #id, onDelete: KeyAction.cascade)(); TextColumn get achievementId => text() .references(AchievementDefinitions, #id, onDelete: KeyAction.cascade)(); // === Когда получено === Column get unlockedAt => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); // === Прогресс (если достижение имеет промежуточные этапы) === IntColumn get progress => integer().withDefault(const Constant(0))(); IntColumn get progressMax => integer().withDefault(const Constant(100))(); @override Set get primaryKey => {id}; @override List get customConstraints => [ 'UNIQUE(user_id, achievement_id)', // Каждое достижение получается один раз ]; } ``` --- ### Шаг 1.3: Обновить таблицу UserDatas **Файл:** `lib/database/tables/users.dart` **УДАЛИТЬ эти поля:** ```dart // ❌ УДАЛИТЬ: TextColumn get words => text()... TextColumn get achievements => text()... TextColumn get packProgress => text()... TextColumn get studyDates => text()... TextColumn get categoryMinutes => text()... ``` **Итоговая UserDatas:** ```dart class UserDatas extends Table { TextColumn get id => text() .withDefault(const CustomExpression('gen_random_uuid()::text'))(); TextColumn get userId => text() .unique() .references(Users, #id, onDelete: KeyAction.cascade)(); // === Простые счетчики (оставляем) === IntColumn get totalStudyTimeMinutes => integer().withDefault(const Constant(0))(); IntColumn get currentStreak => integer().withDefault(const Constant(0))(); IntColumn get longestStreak => integer().withDefault(const Constant(0))(); IntColumn get totalCards => integer().withDefault(const Constant(0))(); IntColumn get totalTests => integer().withDefault(const Constant(0))(); // === Временные метки === Column get lastTimeOnline => customType(PgTypes.timestampWithTimezone) .nullable()(); Column get registrationDate => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); TextColumn get lastTestSessionToken => text().nullable()(); // === Небольшой JSON массив (оставляем) === TextColumn get tags => text() .withDefault(const Constant('[]')) .map(const StringListConverter())(); // === Audit === Column get createdAt => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); Column get updatedAt => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); @override Set get primaryKey => {id}; } ``` --- ### Шаг 1.4: Обновить таблицу Payments **Файл:** `lib/database/tables/payments.dart` **УДАЛИТЬ deprecated поля:** ```dart // ❌ УДАЛИТЬ: TextColumn get packs => text()... BoolColumn get subscription => boolean()... ``` **Добавить soft delete:** ```dart // ✅ ДОБАВИТЬ: BoolColumn get isDeleted => boolean() .withDefault(const Constant(false)) .customConstraint('')(); Column get deletedAt => customType(PgTypes.timestampWithTimezone) .nullable()(); ``` --- ### Шаг 1.5: Обновить таблицу GameCards **Файл:** `lib/database/tables/packs.dart` **УДАЛИТЬ поле packId:** ```dart // ❌ УДАЛИТЬ из GameCards: TextColumn get packId => text() .references(CardPacks, #id, onDelete: KeyAction.cascade)(); ``` **Теперь связь Pack ↔ Card только через CardPackCards!** --- ### Шаг 1.6: Обновить таблицу CardPacks **Файл:** `lib/database/tables/packs.dart` **ДОБАВИТЬ метаданные:** ```dart class CardPacks extends Table { // ... существующие поля ... // === ДОБАВИТЬ новые поля === // Категория и язык TextColumn get category => text().nullable()(); // "Еда", "Путешествия", "Бизнес" TextColumn get language => text().withDefault(const Constant('en'))(); // en, es, fr, de TextColumn get difficulty => text().nullable()(); // beginner, intermediate, advanced // Метаинформация IntColumn get estimatedMinutes => integer().nullable()(); // Время на прохождение TextColumn get authorId => text().nullable()(); // Для UGC в будущем // Метрики популярности IntColumn get purchaseCount => integer().withDefault(const Constant(0))(); IntColumn get viewCount => integer().withDefault(const Constant(0))(); RealColumn get avgRating => real().nullable()(); // Для отзывов в будущем // ... остальные поля как есть ... } ``` --- ### Шаг 1.7: Обновить таблицу UserSubscriptions **Файл:** `lib/database/tables/subscriptions.dart` **Изменения:** ```dart class UserSubscriptions extends Table { TextColumn get id => text() .withDefault(const CustomExpression('gen_random_uuid()::text'))(); // ❌ УБРАТЬ unique() - пользователь может иметь историю подписок TextColumn get userId => text() .references(Users, #id, onDelete: KeyAction.cascade)(); // ✅ ДОБАВИТЬ связь с планом TextColumn get planId => text() .nullable() .references(SubscriptionPlans, #id)(); Column get start => customType(PgTypes.timestampWithTimezone)(); Column get finish => customType(PgTypes.timestampWithTimezone)(); // ✅ ДОБАВИТЬ статус TextColumn get status => text() .withDefault(const Constant('active'))(); // active, expired, cancelled, paused // ✅ ДОБАВИТЬ информацию о подписке BoolColumn get autoRenew => boolean().withDefault(const Constant(false))(); TextColumn get paymentId => text().nullable()(); // Связь с Payments Column get cancelledAt => customType(PgTypes.timestampWithTimezone) .nullable()(); TextColumn get cancellationReason => text().nullable()(); // Функции подписки (JSON array) - оставляем TextColumn get features => text() .withDefault(const Constant('[]')) .map(const JsonListConverter())(); // Audit Column get createdAt => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); Column get updatedAt => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); @override Set get primaryKey => {id}; } ``` --- ### Шаг 1.8: Добавить soft delete во все таблицы **Затронутые файлы:** - `lib/database/tables/auth.dart` (Tokens, RefreshTokens, TelegramAuthCodes) - `lib/database/tables/statistics.dart` (StudySessions) - `lib/database/tables/tests.dart` (Tests, TestQuestions) - `lib/database/tables/promo_codes.dart` (PromoCodesCampaigns, PromoCodes) - `lib/database/tables/discounts.dart` (DiscountCampaigns, Discounts) - `lib/database/tables/tasks.dart` (Tasks, UserTasks) **Добавить в каждую таблицу:** ```dart BoolColumn get isDeleted => boolean() .withDefault(const Constant(false)) .customConstraint('')(); Column get deletedAt => customType(PgTypes.timestampWithTimezone) .nullable()(); ``` --- ### Шаг 1.9: Создать таблицу AuditLog **Файл:** Новый `lib/database/tables/audit.dart` ```dart import 'package:drift/drift.dart'; import 'package:drift_postgres/drift_postgres.dart'; /// Таблица AuditLog - журнал всех изменений критичных данных class AuditLogs extends Table { TextColumn get id => text() .withDefault(const CustomExpression('gen_random_uuid()::text'))(); // === Что изменено === TextColumn get tableName => text()(); TextColumn get recordId => text()(); TextColumn get action => text()(); // INSERT, UPDATE, DELETE // === Кто изменил === TextColumn get userId => text().nullable()(); // === Данные до и после (JSON as TEXT) === TextColumn get oldData => text().nullable()(); TextColumn get newData => text().nullable()(); // === Дополнительная информация === TextColumn get ipAddress => text().nullable()(); TextColumn get userAgent => text().nullable()(); // === Когда === Column get createdAt => customType(PgTypes.timestampWithTimezone) .withDefault(now())(); @override Set get primaryKey => {id}; } ``` --- ## Этап 2: Обновить database.dart **Файл:** `lib/database/database.dart` **Добавить импорты:** ```dart import 'tables/word_statistics.dart'; import 'tables/audit.dart'; // achievements.dart уже импортирован, но обновлен import 'daos/word_statistics_dao.dart'; import 'daos/audit_dao.dart'; ``` **Обновить @DriftDatabase:** ```dart @DriftDatabase( tables: [ // ... существующие таблицы ... // ✅ ДОБАВИТЬ новые: WordStatistics, AuditLogs, AchievementDefinitions, // UserAchievements уже есть, но обновлена ], daos: [ // ... существующие DAO ... // ✅ ДОБАВИТЬ новые: WordStatisticsDao, AuditDao, ], ) class AppDatabase extends _$AppDatabase { // ... @override int get schemaVersion => 2; // ✅ Увеличить версию схемы } ``` --- ## Этап 3: Создание DAO ### Шаг 3.1: Создать WordStatisticsDao **Файл:** `lib/database/daos/word_statistics_dao.dart` ```dart import 'package:drift/drift.dart'; import 'package:drift_postgres/drift_postgres.dart'; import '../database.dart'; import '../tables/word_statistics.dart'; import 'dart:math' as math; part 'word_statistics_dao.g.dart'; @DriftAccessor(tables: [WordStatistics]) class WordStatisticsDao extends DatabaseAccessor with _$WordStatisticsDaoMixin { WordStatisticsDao(super.db); /// Записать результат повторения карточки (основной метод) /// Обновляет статистику и вычисляет nextReview по SM-2 алгоритму Future recordReview({ required String userId, required String cardId, required bool wasCorrect, int attempts = 1, int timeSpentMs = 0, }) async { await transaction(() async { // Получить или создать статистику var stats = await getOrCreate(userId: userId, cardId: cardId); // Обновить счетчики final totalReviews = stats.totalReviews + 1; final correctAnswers = stats.correctAnswers + (wasCorrect ? 1 : 0); final incorrectAnswers = stats.incorrectAnswers + (wasCorrect ? 0 : 1); final mastery = correctAnswers / totalReviews; // Обновить streak final currentStreak = wasCorrect ? stats.currentStreak + 1 : 0; final longestStreak = math.max(stats.longestStreak, currentStreak); // Вычислить nextReview по SM-2 алгоритму final sm2Result = _calculateSM2( quality: wasCorrect ? (attempts == 1 ? 5 : 4) : 2, easinessFactor: stats.easinessFactor, interval: stats.intervalDays, repetitions: stats.repetitions, ); // Обновить запись await (update(wordStatistics)..where((w) => w.id.equals(stats.id))) .write(WordStatisticsCompanion( totalReviews: Value(totalReviews), correctAnswers: Value(correctAnswers), incorrectAnswers: Value(incorrectAnswers), mastery: Value(mastery), currentStreak: Value(currentStreak), longestStreak: Value(longestStreak), lastReviewed: Value(PgDateTime(DateTime.now())), nextReview: Value(PgDateTime(sm2Result.nextReview)), repetitions: Value(sm2Result.repetitions), easinessFactor: Value(sm2Result.easinessFactor), intervalDays: Value(sm2Result.interval), lastAttempts: Value(attempts), lastTimeSpentMs: Value(timeSpentMs), lastWasCorrect: Value(wasCorrect), updatedAt: Value(PgDateTime(DateTime.now())), )); }); } /// Получить статистику по карточке (или создать если нет) Future getOrCreate({ required String userId, required String cardId, }) async { var existing = await (select(wordStatistics) ..where((w) => w.userId.equals(userId) & w.cardId.equals(cardId)) ).getSingleOrNull(); if (existing != null) return existing; // Создать новую запись await into(wordStatistics).insert( WordStatisticsCompanion.insert( userId: userId, cardId: cardId, ), ); return await (select(wordStatistics) ..where((w) => w.userId.equals(userId) & w.cardId.equals(cardId)) ).getSingle(); } /// Получить все слова пользователя Future> getUserWordStats(String userId) { return (select(wordStatistics) ..where((w) => w.userId.equals(userId) & w.isDeleted.equals(false)) ..orderBy([(w) => OrderingTerm.desc(w.mastery)]) ).get(); } /// Получить слова, которые пора повторить Future> getWordsForReview(String userId) { final now = PgDateTime(DateTime.now()); return (select(wordStatistics) ..where((w) => w.userId.equals(userId) & w.isDeleted.equals(false) & w.nextReview.isSmallerOrEqualValue(now) ) ..orderBy([(w) => OrderingTerm.asc(w.nextReview)]) ..limit(20) ).get(); } /// SM-2 алгоритм (Spaced Repetition) _SM2Result _calculateSM2({ required int quality, // 0-5 (5 = perfect, 0 = complete blackout) required double easinessFactor, required int interval, required int repetitions, }) { var ef = easinessFactor; var reps = repetitions; var inter = interval; // Обновить easiness factor ef = ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)); if (ef < 1.3) ef = 1.3; // Если ответ был плохим (quality < 3), сбросить if (quality < 3) { reps = 0; inter = 1; } else { reps += 1; if (reps == 1) { inter = 1; } else if (reps == 2) { inter = 6; } else { inter = (inter * ef).round(); } } final nextReview = DateTime.now().add(Duration(days: inter)); return _SM2Result( easinessFactor: ef, interval: inter, repetitions: reps, nextReview: nextReview, ); } } class _SM2Result { final double easinessFactor; final int interval; final int repetitions; final DateTime nextReview; _SM2Result({ required this.easinessFactor, required this.interval, required this.repetitions, required this.nextReview, }); } ``` --- ### Шаг 3.2: Создать AuditDao **Файл:** `lib/database/daos/audit_dao.dart` ```dart import 'package:drift/drift.dart'; import 'package:drift_postgres/drift_postgres.dart'; import 'dart:convert'; import '../database.dart'; import '../tables/audit.dart'; part 'audit_dao.g.dart'; @DriftAccessor(tables: [AuditLogs]) class AuditDao extends DatabaseAccessor with _$AuditDaoMixin { AuditDao(super.db); /// Записать изменение в audit log Future log({ required String tableName, required String recordId, required String action, // INSERT, UPDATE, DELETE String? userId, Map? oldData, Map? newData, String? ipAddress, String? userAgent, }) async { await into(auditLogs).insert( AuditLogsCompanion.insert( tableName: tableName, recordId: recordId, action: action, userId: Value(userId), oldData: Value(oldData != null ? jsonEncode(oldData) : null), newData: Value(newData != null ? jsonEncode(newData) : null), ipAddress: Value(ipAddress), userAgent: Value(userAgent), ), ); } /// Получить историю изменений записи Future> getLogsByRecord({ required String tableName, required String recordId, int? limit, }) { final query = select(auditLogs) ..where((a) => a.tableName.equals(tableName) & a.recordId.equals(recordId) ) ..orderBy([(a) => OrderingTerm.desc(a.createdAt)]); if (limit != null) { query.limit(limit); } return query.get(); } /// Получить все действия пользователя Future> getUserActions(String userId, {int? limit}) { final query = select(auditLogs) ..where((a) => a.userId.equals(userId)) ..orderBy([(a) => OrderingTerm.desc(a.createdAt)]); if (limit != null) { query.limit(limit); } return query.get(); } } ``` --- ### Шаг 3.3: Обновить существующие DAO (добавить soft delete) Добавить в каждый DAO метод для soft delete: ```dart // Пример для UserDao, PackDao, TestDao, etc Future softDelete(String id) async { await (update(tableName)..where((t) => t.id.equals(id))) .write(TableCompanion( isDeleted: const Value(true), deletedAt: Value(PgDateTime(DateTime.now())), updatedAt: Value(PgDateTime(DateTime.now())), )); } // Обновить методы выборки - фильтровать isDeleted Future> getAll({bool includeDeleted = false}) { final query = select(tableName); if (!includeDeleted) { query.where((t) => t.isDeleted.equals(false)); } return query.get(); } ``` --- ## Этап 4: Регенерация кода ```bash cd mnemo_cards_backend dart run build_runner build --delete-conflicting-outputs ``` --- ## Этап 5: Обновление бизнес-логики ### Шаг 5.1: Обновить UserManager **Файл:** `lib/user/user_manager.dart` **Изменения:** - Убрать обращения к `userData.words`, `userData.achievements`, etc - Добавить методы расчета packProgress, studyDates, categoryMinutes на лету ```dart /// Получить прогресс по пакам (рассчитывается на лету) Future> getPackProgress(String userId) async { final userPacks = await db.userDao.getUserPacks(userId); final result = []; for (final pack in userPacks) { // Получить все карточки пака final cards = await db.packDao.getPackCards(pack.id); // Получить статистику по карточкам final stats = await db.wordStatisticsDao.getUserWordStats(userId); final packStats = stats.where((s) => cards.any((c) => c.id == s.cardId) ).toList(); // Рассчитать прогресс final learnedCount = packStats.where((s) => s.mastery > 0.7).length; final progress = cards.isEmpty ? 0.0 : learnedCount / cards.length; result.add(PackProgress( packId: pack.id, totalCards: cards.length, learnedCards: learnedCount, progress: progress, )); } return result; } /// Получить даты обучения (рассчитывается из StudySessions) Future> getStudyDates(String userId) async { final sessions = await db.statisticsDao.getUserSessions(userId); return sessions.map((s) => s.startTime.dateTime).toSet().toList() ..sort((a, b) => b.compareTo(a)); } /// Получить минуты по категориям (рассчитывается из StudySessions + Pack.category) Future> getCategoryMinutes(String userId) async { final sessions = await db.statisticsDao.getUserSessions(userId); final result = {}; for (final session in sessions) { if (session.packId == null) continue; final pack = await db.packDao.getPackById(session.packId!); if (pack == null) continue; final category = pack.category ?? 'uncategorized'; final minutes = session.endTime != null ? session.endTime!.dateTime.difference(session.startTime.dateTime).inMinutes : 0; result[category] = (result[category] ?? 0) + minutes; } return result; } ``` --- ### Шаг 5.2: Интеграция AuditLog Добавить логирование критичных операций: ```dart // В PaymentManager при создании платежа await db.auditDao.log( tableName: 'payments', recordId: payment.id, action: 'INSERT', userId: userId, newData: payment.toJson(), ipAddress: request.headers['x-forwarded-for'], userAgent: request.headers['user-agent'], ); // В SubscriptionManager при отмене подписки await db.auditDao.log( tableName: 'user_subscriptions', recordId: subscriptionId, action: 'UPDATE', userId: userId, oldData: {'status': 'active'}, newData: {'status': 'cancelled'}, ); ``` --- ### Шаг 5.3: Обновить API endpoints Обновить все API, которые используют: - `userData.words` → использовать `WordStatisticsDao` - `userData.achievements` → использовать `UserAchievements + AchievementDefinitions` - `card.packId` → использовать `CardPackCards` --- ## Этап 6: Тестирование ### Шаг 6.1: Unit тесты для новых DAO **Файл:** `test/database/word_statistics_dao_test.dart` ```dart import 'package:test/test.dart'; import 'package:mnemo_cards_backend/database/database.dart'; void main() { late AppDatabase db; setUp(() async { db = AppDatabase.connect(/* test credentials */); await db.migrator.createAll(); }); tearDown(() async { await db.close(); }); test('recordReview создает и обновляет статистику', () async { const userId = 'user-1'; const cardId = 'card-1'; // Первое повторение await db.wordStatisticsDao.recordReview( userId: userId, cardId: cardId, wasCorrect: true, ); var stats = await db.wordStatisticsDao.getOrCreate( userId: userId, cardId: cardId, ); expect(stats.totalReviews, equals(1)); expect(stats.correctAnswers, equals(1)); expect(stats.mastery, equals(1.0)); // Второе повторение await db.wordStatisticsDao.recordReview( userId: userId, cardId: cardId, wasCorrect: false, ); stats = await db.wordStatisticsDao.getOrCreate( userId: userId, cardId: cardId, ); expect(stats.totalReviews, equals(2)); expect(stats.correctAnswers, equals(1)); expect(stats.incorrectAnswers, equals(1)); expect(stats.mastery, equals(0.5)); }); } ``` --- ## Проверка успешности - [ ] `dart run build_runner build` проходит без ошибок - [ ] Все unit тесты проходят - [ ] Backend запускается - [ ] БД создается с правильной схемой - [ ] API endpoints работают - [ ] WordStatistics записывает данные при изучении - [ ] Spaced Repetition работает (nextReview вычисляется) - [ ] packProgress, studyDates, categoryMinutes рассчитываются корректно - [ ] AuditLog записывает критичные операции - [ ] Soft delete работает для всех таблиц - [ ] CardPacks имеет новые поля (category, language, etc) - [ ] UserSubscriptions имеет историю (не unique userId) --- **Готовы начать реализацию?**