968 lines
30 KiB
Markdown
968 lines
30 KiB
Markdown
|
|
# 📋 Детальный план улучшений базы данных
|
|||
|
|
|
|||
|
|
> **Дата:** 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)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
**Готовы начать реализацию?**
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|