mnemo_cards/mnemo_cards_backend/DATABASE_IMPROVEMENT_PLAN_DETAILED.md

970 lines
30 KiB
Markdown
Raw Permalink Normal View History

2026-01-03 13:14:27 +00:00
# 📋 Детальный план улучшений базы данных
> **Дата:** 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<PgDateTime> get lastReviewed => customType(PgTypes.timestampWithTimezone)
.nullable()();
Column<PgDateTime> 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<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};
@override
List<String> 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<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
Column<PgDateTime> 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<Column> 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<PgDateTime> 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<Column> get primaryKey => {id};
@override
List<String> 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<PgDateTime> get lastTimeOnline => customType(PgTypes.timestampWithTimezone)
.nullable()();
Column<PgDateTime> 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<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
@override
Set<Column> 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<PgDateTime> 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<PgDateTime> get start => customType(PgTypes.timestampWithTimezone)();
Column<PgDateTime> 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<PgDateTime> 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<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
@override
Set<Column> 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<PgDateTime> 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<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
@override
Set<Column> 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<AppDatabase>
with _$WordStatisticsDaoMixin {
WordStatisticsDao(super.db);
/// Записать результат повторения карточки (основной метод)
/// Обновляет статистику и вычисляет nextReview по SM-2 алгоритму
Future<void> 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<WordStatistic> 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<List<WordStatistic>> getUserWordStats(String userId) {
return (select(wordStatistics)
..where((w) => w.userId.equals(userId) & w.isDeleted.equals(false))
..orderBy([(w) => OrderingTerm.desc(w.mastery)])
).get();
}
/// Получить слова, которые пора повторить
Future<List<WordStatistic>> 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<AppDatabase> with _$AuditDaoMixin {
AuditDao(super.db);
/// Записать изменение в audit log
Future<void> log({
required String tableName,
required String recordId,
required String action, // INSERT, UPDATE, DELETE
String? userId,
Map<String, dynamic>? oldData,
Map<String, dynamic>? 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<List<AuditLog>> 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<List<AuditLog>> 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<void> 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<List<TableData>> 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<List<PackProgress>> getPackProgress(String userId) async {
final userPacks = await db.userDao.getUserPacks(userId);
final result = <PackProgress>[];
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<List<DateTime>> 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<Map<String, int>> getCategoryMinutes(String userId) async {
final sessions = await db.statisticsDao.getUserSessions(userId);
final result = <String, int>{};
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)
---
**Готовы начать реализацию?**
2026-01-08 13:02:47 +00:00
2026-01-24 12:31:47 +00:00