Initial commit: standalone quiz-testing system
Full-stack F# (Domain/Server/Client via Fable+Elmish+Feliz), PostgreSQL persistence via Dapper, Docker Compose deployment. Student quiz-taking flow with time-limit enforcement and focus-loss tracking, Teacher question bank and quiz builder with results analytics, Admin user management.
This commit is contained in:
501
docs/DESIGN.md
Normal file
501
docs/DESIGN.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# Дизайн системы
|
||||
|
||||
Дата: 2026-08-03. Статус: проектирование, реализация не начата (кроме уже существующего студенческого MVP).
|
||||
|
||||
## 1. Идея и границы системы
|
||||
|
||||
Standalone-система проверки знаний — аналог модуля «Тест» в Moodle, без остальной части LMS
|
||||
(без курсов как оргединицы, без контента/форумов). Три роли: **Admin**, **Teacher**, **Student**.
|
||||
|
||||
Ключевые решения, зафиксированные в разговоре с пользователем:
|
||||
|
||||
| Вопрос | Решение |
|
||||
|---|---|
|
||||
| Курсы | Не нужны. Убираем `Course`/`Enrollment` из домена. |
|
||||
| Банк вопросов | Приватный у каждого преподавателя (темы = топики, без общего пространства). |
|
||||
| «Итоговый» тест со случайными вопросами | Случайный набор вопросов формируется **заново при каждой попытке** (как в Moodle). |
|
||||
| Кому виден тест | Преподаватель явно назначает тест конкретным студентам (нет курсов/групп). |
|
||||
| Регистрация | Открытой регистрации нет — все аккаунты создаёт Admin вручную. |
|
||||
| Роль Admin | Admin = Teacher + управление пользователями (создание/удаление учителей и студентов). |
|
||||
| Персистентность | PostgreSQL (уже предусмотрено комментарием в `Store.fs`), доступ через Dapper. |
|
||||
| Аналитика | Нужна сразу: % правильных ответов и сложность по каждому вопросу, не только список попыток. |
|
||||
| Анти-списывание | В аналитике попытки нужно видеть число потерь фокуса окна теста. |
|
||||
| Лимит времени | Настраивается преподавателем (уже есть `TimeLimit`), но должен реально принуждаться, а не только отображаться. |
|
||||
| Что происходит по истечении времени | Авто-сдача текущих ответов на сервере, как в Moodle. |
|
||||
| Публикация теста | Отдельный флаг `IsPublished` — преподаватель готовит тест и назначения заранее, студенты не видят его, пока он явно не опубликован. |
|
||||
| Ручная коррекция автооценки | Не нужна для v1 — полагаемся только на автопроверку (`Grading.gradeResponse`). |
|
||||
|
||||
## 2. Роли и права
|
||||
|
||||
- **Student** — видит только тесты, на которые его явно назначили; проходит попытки; видит свои результаты.
|
||||
- **Teacher** — управляет **своими** темами, вопросами и тестами; назначает на тест студентов из общего
|
||||
справочника пользователей (справочник read-only для Teacher — юзеров создаёт только Admin);
|
||||
видит попытки и аналитику только по **своим** тестам.
|
||||
- **Admin** — всё, что может Teacher (свой банк вопросов и свои тесты — то есть Admin тоже может быть
|
||||
автором тестов), плюс CRUD пользователей (создание/деактивация/сброс пароля, назначение ролей).
|
||||
|
||||
Авторизация как сейчас: JWT с claim роли, выдаётся при `login`. Каждый серверный хендлер
|
||||
проверяет роль/владельца сам (единообразный `Result`-based шаблон ошибок, без ASP.NET `[Authorize(Roles=...)]`,
|
||||
чтобы не расходиться со стилем существующего `QuizApi.fs`).
|
||||
|
||||
## 3. Доменная модель (изменения к текущей)
|
||||
|
||||
Убираем: `Course`, `Enrollment`, `CourseId`, `EnrollmentRole` (полностью, не используются без курсов).
|
||||
|
||||
### 3.1 Пользователь
|
||||
|
||||
Текущий `User` (`Domain/Users.fs`) не несёт признака активности, хотя возможность деактивации
|
||||
уже решена (§2, Admin) и уже присутствует в схеме БД (§5) — это пробел, закрываем явно.
|
||||
`CreatedAt` — практическая необходимость для сортировки списков в UI (Admin увидит, когда
|
||||
заведён аккаунт; Teacher — когда создана тема/вопрос/тест).
|
||||
|
||||
```fsharp
|
||||
type User =
|
||||
{ Id: UserId
|
||||
Name: string
|
||||
Email: string
|
||||
PasswordHash: string
|
||||
Role: Role
|
||||
IsActive: bool // NEW — деактивированный пользователь не может login'иться
|
||||
CreatedAt: DateTimeOffset } // NEW
|
||||
```
|
||||
|
||||
`login` дополнительно проверяет `IsActive`; при `false` — тот же `Error "Неверный email или пароль"`,
|
||||
что и при неверном пароле (не раскрываем факт существования/деактивации аккаунта в тексте ошибки).
|
||||
|
||||
### 3.2 Темы и вопросы
|
||||
|
||||
`QuestionCategory` переименовывается в `Topic` и переподвешивается на преподавателя вместо курса:
|
||||
|
||||
```fsharp
|
||||
[<Struct>] type TopicId = TopicId of Guid
|
||||
|
||||
type Topic =
|
||||
{ Id: TopicId
|
||||
OwnerId: UserId // преподаватель-владелец
|
||||
Name: string
|
||||
CreatedAt: DateTimeOffset } // NEW
|
||||
|
||||
type Question =
|
||||
{ Id: QuestionId
|
||||
TopicId: TopicId
|
||||
Text: string
|
||||
Points: float // дефолтные баллы вопроса в банке
|
||||
Type: QuestionType // без изменений: SingleChoice/MultipleChoice/TrueFalse/ShortAnswer/Numeric
|
||||
IsArchived: bool // NEW — см. врезку про удаление ниже
|
||||
CreatedAt: DateTimeOffset } // NEW
|
||||
```
|
||||
|
||||
**Удаление при наличии истории.** Вопрос может быть частью `Quiz.Composition` (`FixedQuestions`)
|
||||
и/или уже фигурировать в чьих-то `Attempt.ResolvedQuestions`/`attempt_grades` — жёсткое удаление
|
||||
сломало бы ссылочную целостность и обнулило бы прошлые результаты. Поэтому `deleteQuestion`:
|
||||
если вопрос нигде не используется — удаляет по-настоящему; если используется хоть где-то —
|
||||
выставляет `IsArchived = true` вместо удаления. Архивные вопросы не показываются в списке для
|
||||
добавления в новый тест и не участвуют в пуле `RandomFromTopics` (§3.4), но остаются видны в
|
||||
уже прошедших попытках и в `getQuestionStats` (§3.5) — история не должна исчезать.
|
||||
Тема (`deleteTopic`) удаляется только если в ней **нет вопросов** (архивных в том числе — сначала
|
||||
перенести/удалить вопросы); отдельного `IsArchived` для темы не вводим, это не то, на что
|
||||
что-либо ссылается напрямую после удаления вопросов.
|
||||
|
||||
### 3.3 Состав теста: fixed vs random-from-topics
|
||||
|
||||
Главное новое понятие — тест может либо содержать явный список вопросов, либо описывать правило
|
||||
случайного набора по темам:
|
||||
|
||||
```fsharp
|
||||
type QuizQuestionRef =
|
||||
{ QuestionId: QuestionId
|
||||
Points: float // баллы именно в этом тесте, может отличаться от дефолта вопроса
|
||||
Order: int }
|
||||
|
||||
/// Правило "N случайных вопросов из темы X, каждый на Y баллов"
|
||||
type RandomTopicRule =
|
||||
{ TopicId: TopicId
|
||||
Count: int
|
||||
PointsPerQuestion: float }
|
||||
|
||||
type QuizComposition =
|
||||
| FixedQuestions of QuizQuestionRef list
|
||||
| RandomFromTopics of RandomTopicRule list
|
||||
|
||||
type Quiz =
|
||||
{ Id: QuizId
|
||||
OwnerId: UserId
|
||||
Title: string
|
||||
Description: string
|
||||
Composition: QuizComposition
|
||||
TimeLimit: TimeSpan option
|
||||
MaxAttempts: int option
|
||||
GradingMethod: GradingMethod
|
||||
ShuffleQuestions: bool
|
||||
ShuffleAnswers: bool
|
||||
OpenFrom: DateTimeOffset option
|
||||
OpenTo: DateTimeOffset option
|
||||
PassingScore: float option
|
||||
AssignedStudentIds: Set<UserId>
|
||||
IsPublished: bool // NEW — по умолчанию false, см. таблицу решений в §1
|
||||
IsArchived: bool // NEW — аналогично Question.IsArchived: мягкое удаление, если есть попытки
|
||||
CreatedAt: DateTimeOffset } // NEW
|
||||
```
|
||||
|
||||
`IsPublished` отделяет подготовку теста от его показа студентам: пока флаг не выставлен явно
|
||||
(`publishQuiz`, см. §4.2), тест не появляется в `getAvailableQuizzes` даже у уже назначенных
|
||||
студентов — независимо от `OpenFrom`/`OpenTo`. `deleteQuiz` — то же правило мягкого удаления,
|
||||
что и для `Question` (§3.2): если по тесту уже есть хоть одна попытка, `deleteQuiz` архивирует
|
||||
(`IsArchived = true`) вместо удаления, чтобы не потерять историю в `getQuizResults`/`getQuestionStats`.
|
||||
|
||||
`Quiz.totalPoints` считается без резолва вопросов (важно, чтобы студент видел максимум баллов
|
||||
ещё до начала попытки):
|
||||
- `FixedQuestions refs` → `sum refs.Points`
|
||||
- `RandomFromTopics rules` → `sum (rule.Count * rule.PointsPerQuestion)`
|
||||
|
||||
"Тест по теме" из требования пользователя — это `RandomFromTopics` с одним правилом
|
||||
(например, все/N вопросов из одной темы), а "итоговый" тест — `RandomFromTopics` с несколькими
|
||||
правилами (по одному на каждую выбранную тему). Отдельный домена не нужен — это два случая одного
|
||||
и того же режима.
|
||||
|
||||
### 3.4 Резолв случайного набора и попытка
|
||||
|
||||
Ключевая проблема: раз набор вопросов случаен, конкретная попытка должна **зафиксировать**, какие
|
||||
именно вопросы были показаны студенту — иначе нечего будет ни оценивать, ни показывать в ревью.
|
||||
Резолв случается **один раз при `startAttempt`** и сохраняется на самой попытке:
|
||||
|
||||
```fsharp
|
||||
type AttemptFinishReason =
|
||||
| ManualSubmit
|
||||
| TimedOut
|
||||
|
||||
type Attempt =
|
||||
{ Id: AttemptId
|
||||
QuizId: QuizId
|
||||
UserId: UserId
|
||||
AttemptNumber: int // NEW — 1, 2, 3... в рамках (QuizId, UserId), для UI и MaxAttempts
|
||||
StartedAt: DateTimeOffset
|
||||
SubmittedAt: DateTimeOffset option
|
||||
State: AttemptState
|
||||
FinishReason: AttemptFinishReason option // NEW — None пока InProgress; см. §3.6
|
||||
ResolvedQuestions: QuizQuestionRef list // NEW — конкретные вопросы именно этой попытки
|
||||
Responses: Map<QuestionId, StudentResponse>
|
||||
Grades: Map<QuestionId, QuestionGrade>
|
||||
Score: float option
|
||||
FocusLossCount: int } // NEW — см. §3.6
|
||||
```
|
||||
|
||||
`AttemptNumber` считается при `startAttempt` как `(существующие попытки этого студента по этому
|
||||
тесту).Length + 1` — избавляет UI/аналитику от пересчёта каждый раз. `Attempt.submit` принимает
|
||||
`AttemptFinishReason` параметром (`ManualSubmit` из ручного `finishAttempt`, `TimedOut` из
|
||||
принудительного завершения по лимиту времени, см. §3.6) — сигнатура меняется с
|
||||
`submit (now: DateTimeOffset) (attempt: Attempt)` на
|
||||
`submit (reason: AttemptFinishReason) (now: DateTimeOffset) (attempt: Attempt)`.
|
||||
|
||||
Чистая (без I/O) функция резолва в `Domain`, рандом и доступ к вопросам передаются снаружи:
|
||||
|
||||
```fsharp
|
||||
module Quiz =
|
||||
/// shuffle — инжектируемая функция перемешивания (использует ShuffleQuestions квиза сам вызывающий код).
|
||||
/// topicQuestions — вопросы каждой темы, уже загруженные вызывающим кодом из Store.
|
||||
let resolveComposition
|
||||
(shuffle: 'a list -> 'a list)
|
||||
(topicQuestions: Map<TopicId, Question list>)
|
||||
(quiz: Quiz)
|
||||
: Result<QuizQuestionRef list, string> =
|
||||
match quiz.Composition with
|
||||
| FixedQuestions refs -> Ok(if quiz.ShuffleQuestions then shuffle refs else refs)
|
||||
| RandomFromTopics rules ->
|
||||
rules
|
||||
|> List.fold (fun acc rule ->
|
||||
acc |> Result.bind (fun picked ->
|
||||
match Map.tryFind rule.TopicId topicQuestions with
|
||||
| Some pool when pool.Length >= rule.Count ->
|
||||
let chosen = pool |> shuffle |> List.truncate rule.Count
|
||||
let refs = chosen |> List.map (fun q -> { QuestionId = q.Id; Points = rule.PointsPerQuestion; Order = 0 })
|
||||
Ok(picked @ refs)
|
||||
| _ -> Error "В одной из тем недостаточно вопросов для случайного набора"))
|
||||
(Ok [])
|
||||
|> Result.map (fun refs ->
|
||||
let ordered = if quiz.ShuffleQuestions then shuffle refs else refs
|
||||
ordered |> List.mapi (fun i r -> { r with Order = i }))
|
||||
```
|
||||
|
||||
`Grading.gradeAttempt` переключается на `attempt.ResolvedQuestions` вместо `quiz.Questions` как
|
||||
единый источник правды для обоих режимов — это же упрощает `startAttempt`/`finishAttempt`, им больше
|
||||
не нужно различать fixed/random после резолва.
|
||||
|
||||
### 3.5 Аналитика по вопросам
|
||||
|
||||
Отдельная нормализованная таблица (см. §5) `attempt_grades` (attempt_id, question_id, points_awarded,
|
||||
max_points, is_correct) пишется при `finishAttempt` вместе с `Grades`. По ней считается:
|
||||
|
||||
```fsharp
|
||||
type QuestionStat =
|
||||
{ QuestionId: QuestionId
|
||||
QuestionText: string
|
||||
TimesAsked: int
|
||||
TimesCorrect: int
|
||||
PercentCorrect: float
|
||||
AvgPointsAwarded: float }
|
||||
```
|
||||
|
||||
Агрегация — обычный SQL `GROUP BY question_id`, без DU-хитростей.
|
||||
|
||||
### 3.6 Анти-списывание: потери фокуса окна и жёсткий лимит времени
|
||||
|
||||
**Потери фокуса.** Клиент слушает `document.visibilitychange` (переключение вкладки, сворачивание)
|
||||
и `window.blur` (переключение на другое окно/приложение поверх той же вкладки) с момента получения
|
||||
ответа от `startAttempt` — то есть с самого начала попытки, а не с первого отвеченного вопроса
|
||||
(студент может уйти листать шпаргалку ещё до того, как ответит хоть на один вопрос, и это тоже
|
||||
должно засчитаться). При переходе в состояние "не в фокусе" клиент дёргает новый метод API, который **инкрементит**
|
||||
счётчик на сервере (не принимает готовое число от клиента — так его нельзя подделать в свою пользу,
|
||||
разве что заспамить в меньшую сторону невозможно, а накрутить больше нет смысла жулику):
|
||||
|
||||
```fsharp
|
||||
// добавляется в IQuizApi (§4.1)
|
||||
reportFocusLoss: AttemptId -> Async<Result<unit, string>>
|
||||
```
|
||||
|
||||
Дребезг (несколько событий подряд при одном уходе) гасится на клиенте — считаем один "уход",
|
||||
пока пользователь не вернулся (`visibilitychange` обратно в `visible` сбрасывает флаг "уже считали").
|
||||
`FocusLossCount` попадает в `AttemptSummary`/`AttemptDetail` (§3.7), преподаватель видит число
|
||||
рядом с баллом и сам решает, похоже это на списывание или нет — автоматических санкций система
|
||||
не применяет.
|
||||
|
||||
**Жёсткий лимит времени.** Два уровня принуждения:
|
||||
|
||||
1. **Активная сессия.** `submitAnswer` и `finishAttempt` перед выполнением проверяют
|
||||
`Attempt.isExpired quiz now attempt` (функция уже есть в `Attempts.fs`, просто не вызывается).
|
||||
Если время вышло — сервер сам переводит попытку в `Graded` (те же шаги, что и ручной
|
||||
`finishAttempt`: `Attempt.submit TimedOut` → `Grading.gradeAttempt`, `FinishReason = Some TimedOut`)
|
||||
и возвращает `Error "Время вышло, тест завершён автоматически"`,
|
||||
а не проваливает исходное действие молча. Клиент по такому ответу показывает экран результата.
|
||||
2. **Заброшенная сессия.** Если студент закрыл вкладку и больше не прислал ни одного запроса,
|
||||
пункт 1 не сработает — некому вызвать `submitAnswer`. Поэтому на сервере нужен фоновый
|
||||
`IHostedService` ("expiry sweeper"), который каждые ~30 сек находит `InProgress`-попытки с
|
||||
`started_at + time_limit < now` в Postgres и точно так же принудительно завершает и оценивает их
|
||||
(`Attempt.submit TimedOut` → `Grading.gradeAttempt`).
|
||||
Это и есть источник истины по лимиту — клиентский таймер в UI (обратный отсчёт) только для
|
||||
удобства студента, не для принуждения.
|
||||
|
||||
Обычное ручное завершение (`finishAttempt` без истечения лимита) ставит `FinishReason = Some ManualSubmit`.
|
||||
Вопросы, на которые студент не успел ответить к моменту авто-сдачи, никак специально не
|
||||
обрабатываются — они и так остаются без записи в `attempt.Responses`, а `Grading.gradeAttempt`
|
||||
уже сегодня трактует отсутствующий ответ через `emptyResponseFor` как нулевой/неверный
|
||||
(см. `Grading.fs:59-64`). Отдельной логики "пропущенный вопрос" вводить не нужно.
|
||||
|
||||
### 3.7 Финальная оценка по нескольким попыткам и история
|
||||
|
||||
Пробел в более ранней версии этого документа: типы `AttemptSummary`/`AttemptDetail`/`StudentQuizResult`
|
||||
упоминались в §4 по имени, но нигде не были определены. Плюс — в домене уже есть
|
||||
`Grading.applyGradingMethod`, который сводит несколько попыток студента в одну итоговую оценку по
|
||||
`Quiz.GradingMethod` (`HighestAttempt`/`AverageAttempt`/`FirstAttempt`/`LastAttempt`), но раньше
|
||||
эта функция никуда не была подключена: ни в одном API-методе результат её работы не отдавался ни
|
||||
преподавателю, ни самому студенту. Закрываем оба пробела одним набором типов:
|
||||
|
||||
```fsharp
|
||||
type AttemptSummary =
|
||||
{ AttemptId: AttemptId
|
||||
StudentId: UserId
|
||||
StudentName: string
|
||||
AttemptNumber: int
|
||||
State: AttemptState
|
||||
StartedAt: DateTimeOffset
|
||||
SubmittedAt: DateTimeOffset option
|
||||
FinishReason: AttemptFinishReason option
|
||||
Score: float option
|
||||
MaxScore: float
|
||||
FocusLossCount: int }
|
||||
|
||||
type AttemptDetail =
|
||||
{ Summary: AttemptSummary
|
||||
Questions: QuestionView list // как показывались студенту, из ResolvedQuestions
|
||||
Responses: Map<QuestionId, StudentResponse>
|
||||
Grades: Map<QuestionId, QuestionGrade> }
|
||||
|
||||
/// Итог по тесту для одного студента, с учётом Quiz.GradingMethod.
|
||||
type StudentQuizResult =
|
||||
{ StudentId: UserId
|
||||
StudentName: string
|
||||
Attempts: AttemptSummary list
|
||||
FinalScore: float option // Grading.applyGradingMethod по всем Attempts
|
||||
MaxScore: float
|
||||
Passed: bool option }
|
||||
```
|
||||
|
||||
Два инварианта, которые эти типы предполагают:
|
||||
- **Оценка — снимок на момент `finishAttempt`/sweeper'а.** Если преподаватель потом отредактирует
|
||||
`Question` (текст, правильный ответ) или состав теста, уже выставленные `Grades`/`Score` задним
|
||||
числом не пересчитываются — иначе история результатов "плыла" бы вместе с правками банка вопросов.
|
||||
Это и есть причина, почему `Question`/`Quiz` архивируются, а не пересчитываются на лету (§3.2, §3.3).
|
||||
- **`ShuffleAnswers`** (порядок вариантов ответа внутри вопроса) не требует отдельного состояния —
|
||||
в отличие от `ShuffleQuestions`/`ResolvedQuestions`, порядок вариантов не влияет на то, что именно
|
||||
засчитывается (ответ кодируется через стабильный `OptionId`), поэтому просто перемешивается на
|
||||
сервере при формировании `QuestionView` в `startAttempt`, без сохранения куда-либо.
|
||||
|
||||
## 4. API (Fable.Remoting-style, вручную через fetch — см. комментарий в `Api.fs`)
|
||||
|
||||
Три интерфейса вместо одного, каждый — отдельный роут-неймспейс (`/api/<TypeName>/<Method>`),
|
||||
роль проверяется на сервере в каждом хендлере.
|
||||
|
||||
### 4.1 `IQuizApi` (Student) — правки существующего
|
||||
|
||||
- `login` — дополнительно проверяет `User.IsActive` (§3.1).
|
||||
- `getAvailableQuizzes` — теперь фильтрует по `IsPublished = true`, `AssignedStudentIds` (только
|
||||
тесты, куда назначен текущий студент), `not IsArchived` и по окну `OpenFrom`/`OpenTo`, как сейчас.
|
||||
- `startAttempt`, `submitAnswer`, `finishAttempt` — логика резолва встраивается в `startAttempt`
|
||||
(см. §3.4), наружу для клиента ничего не меняется; `submitAnswer`/`finishAttempt` дополнительно
|
||||
проверяют истечение времени (см. §3.6).
|
||||
- `reportFocusLoss: AttemptId -> Async<Result<unit, string>>` — новый метод, см. §3.6.
|
||||
- `getMyResults: QuizId -> Async<Result<StudentQuizResult, string>>` — новый метод: студент видит
|
||||
свою историю попыток по тесту и итоговую оценку по `Quiz.GradingMethod` (§3.7) — без него у
|
||||
студента с `MaxAttempts > 1` нет способа посмотреть, как считался финальный балл.
|
||||
|
||||
### 4.2 `ITeacherApi` (Teacher и Admin)
|
||||
|
||||
```fsharp
|
||||
type ITeacherApi =
|
||||
{ listTopics: unit -> Async<Topic list>
|
||||
createTopic: string -> Async<Result<Topic, string>>
|
||||
renameTopic: TopicId * string -> Async<Result<unit, string>>
|
||||
deleteTopic: TopicId -> Async<Result<unit, string>>
|
||||
|
||||
listQuestions: TopicId -> Async<Question list>
|
||||
createQuestion: CreateQuestionRequest -> Async<Result<Question, string>>
|
||||
updateQuestion: UpdateQuestionRequest -> Async<Result<unit, string>>
|
||||
deleteQuestion: QuestionId -> Async<Result<unit, string>>
|
||||
|
||||
listMyQuizzes: unit -> Async<QuizAdminSummary list>
|
||||
getQuiz: QuizId -> Async<Result<QuizDetail, string>>
|
||||
createQuiz: CreateQuizRequest -> Async<Result<QuizId, string>>
|
||||
updateQuiz: UpdateQuizRequest -> Async<Result<unit, string>>
|
||||
publishQuiz: QuizId -> Async<Result<unit, string>> // NEW — см. §3.3
|
||||
unpublishQuiz: QuizId -> Async<Result<unit, string>> // NEW — снять с публикации (уже стартовавших попыток не отменяет)
|
||||
deleteQuiz: QuizId -> Async<Result<unit, string>> // архивирует, если есть попытки — см. §3.3
|
||||
|
||||
listStudents: unit -> Async<StudentSummary list> // справочник для назначения, read-only
|
||||
assignStudents: QuizId * UserId list -> Async<Result<unit, string>>
|
||||
unassignStudent: QuizId * UserId -> Async<Result<unit, string>>
|
||||
|
||||
getQuizAttempts: QuizId -> Async<AttemptSummary list> // сырой список попыток, см. §3.7
|
||||
getAttemptDetail: AttemptId -> Async<Result<AttemptDetail, string>>
|
||||
getQuizResults: QuizId -> Async<StudentQuizResult list> // NEW — сводка по студентам, см. §3.7
|
||||
getQuestionStats: QuizId -> Async<QuestionStat list> }
|
||||
```
|
||||
|
||||
`getQuizAttempts` и `getQuizResults` отвечают на разные вопросы: первый — "кто, когда и как проходил
|
||||
тест" (нужен для анти-читерского ревью каждой отдельной попытки — `FocusLossCount`, `FinishReason`,
|
||||
длительность), второй — "какая у студента итоговая оценка по тесту с учётом `GradingMethod`"
|
||||
(журнал-ведомость). Оба используют типы из §3.7.
|
||||
|
||||
Владение проверяется всюду: Teacher видит/меняет только темы/вопросы/тесты со своим `OwnerId`
|
||||
(Admin — тоже, но плюс видит вообще всех через `IAdminApi`, не через `ITeacherApi`).
|
||||
|
||||
### 4.3 `IAdminApi` (только Admin)
|
||||
|
||||
```fsharp
|
||||
type IAdminApi =
|
||||
{ listUsers: unit -> Async<UserSummary list>
|
||||
createUser: CreateUserRequest -> Async<Result<UserSummary, string>> // задаёт Role: Teacher | Student | Admin
|
||||
updateUser: UpdateUserRequest -> Async<Result<unit, string>>
|
||||
deactivateUser: UserId -> Async<Result<unit, string>>
|
||||
resetPassword: UserId * string -> Async<Result<unit, string>> }
|
||||
```
|
||||
|
||||
## 5. Персистентность (PostgreSQL + Dapper)
|
||||
|
||||
DU-тяжёлые части (`QuestionType`, `QuizComposition`, `Responses`) храним как `jsonb` — реляционных
|
||||
join-таблиц под каждый вариант DU не оправдано на этом масштабе, а Dapper + `System.Text.Json` (с тем же
|
||||
подходом к конвертерам, что уже применён для Fable.Remoting.Json на сервере) сериализует их напрямую.
|
||||
|
||||
```sql
|
||||
users (
|
||||
id uuid pk, name text, email text unique, password_hash text, role text,
|
||||
is_active bool not null default true, created_at timestamptz not null default now()
|
||||
)
|
||||
|
||||
topics (id uuid pk, owner_id uuid references users, name text, created_at timestamptz not null default now())
|
||||
|
||||
questions (
|
||||
id uuid pk, topic_id uuid references topics, text text, points double precision, type_json jsonb,
|
||||
is_archived bool not null default false, created_at timestamptz not null default now()
|
||||
)
|
||||
|
||||
quizzes (
|
||||
id uuid pk, owner_id uuid references users, title text, description text,
|
||||
composition_json jsonb, time_limit_minutes int null, max_attempts int null,
|
||||
grading_method text, shuffle_questions bool, shuffle_answers bool,
|
||||
open_from timestamptz null, open_to timestamptz null, passing_score double precision null,
|
||||
is_published bool not null default false, is_archived bool not null default false,
|
||||
created_at timestamptz not null default now()
|
||||
)
|
||||
|
||||
quiz_assignments (quiz_id uuid references quizzes, student_id uuid references users, primary key (quiz_id, student_id))
|
||||
|
||||
attempts (
|
||||
id uuid pk, quiz_id uuid references quizzes, user_id uuid references users,
|
||||
attempt_number int not null, started_at timestamptz, submitted_at timestamptz null, state text,
|
||||
finish_reason text null, resolved_questions_json jsonb, responses_json jsonb, score double precision null,
|
||||
focus_loss_count int not null default 0
|
||||
)
|
||||
|
||||
attempt_grades (
|
||||
attempt_id uuid references attempts, question_id uuid references questions,
|
||||
points_awarded double precision, max_points double precision, is_correct bool,
|
||||
primary key (attempt_id, question_id)
|
||||
)
|
||||
```
|
||||
|
||||
`Store.fs` заменяется на модуль с Dapper-запросами за тем же member-интерфейсом (там уже есть
|
||||
комментарий это предвосхищающий) — сигнатуры методов остаются похожими, чтобы `QuizApi.fs` и новые
|
||||
`TeacherApi.fs`/`AdminApi.fs` менялись минимально.
|
||||
|
||||
Миграции — лёгкий инструмент поверх Dapper (например DbUp: пронумерованные `.sql`-файлы, применяются
|
||||
при старте сервера), без EF Core, чтобы не тащить лишний ORM-слой.
|
||||
|
||||
## 6. UX прохождения теста
|
||||
|
||||
Список вопросов остаётся одним непрерывно прокручиваемым блоком, как сейчас (`View.fs:226`) —
|
||||
без пагинации/пошагового визарда «один вопрос за раз». Студент должен иметь возможность свободно
|
||||
скроллить вверх-вниз по всем вопросам в любом порядке и с любой скоростью, без ограничений.
|
||||
|
||||
Кнопка «Завершить тест» физически выносится из области прокрутки. Сейчас (`View.fs:227-231`) она
|
||||
рендерится прямо под последним вопросом внутри того же `taking-quiz-page`-контейнера — при быстрой
|
||||
прокрутке длинного списка случайный клик в момент остановки скролла может преждевременно завершить
|
||||
попытку. Нужен отдельный зафиксированный блок (sticky-хедер сверху или боковая панель), где живут
|
||||
общие элементы управления попыткой — обратный отсчёт времени (§3.6) и кнопка «Завершить тест», — и
|
||||
который не участвует в скролле списка вопросов, чтобы моторика "долистать до конца" и "нажать
|
||||
завершить" были физически разными жестами.
|
||||
|
||||
## 7. Фазы реализации
|
||||
|
||||
1. **Домен**: `User.IsActive`/`CreatedAt`, переименование Category→Topic + `Topic.CreatedAt`,
|
||||
`Question.IsArchived`/`CreatedAt`, `QuizComposition`, `Quiz.IsPublished`/`IsArchived`/`CreatedAt`,
|
||||
`Attempt.ResolvedQuestions`/`AttemptNumber`/`FinishReason`/`FocusLossCount`, включение проверки
|
||||
`Attempt.isExpired` в поток завершения попытки, удаление Course/Enrollment, обновление
|
||||
`Grading`/`Attempt`/`Quiz` модулей + юнит-тесты (`tests/Domain.Tests`) на резолв случайного
|
||||
набора, на `totalPoints` для обоих режимов и на сведение попыток через `applyGradingMethod`.
|
||||
2. **PostgreSQL**: схема (§5), Dapper-репозиторий взамен `Store.fs`, миграции, конфиг строки подключения,
|
||||
фоновый `IHostedService`-sweeper для заброшенных просроченных попыток (§3.6).
|
||||
3. **Admin API + мини-UI**: CRUD пользователей (с учётом `IsActive`).
|
||||
4. **Teacher API + UI**: темы → вопросы (с архивированием вместо жёсткого удаления, §3.2) → тесты
|
||||
(fixed/random, включая настройку `TimeLimit`, `publishQuiz`/`unpublishQuiz`) → назначение студентов.
|
||||
5. **Результаты и аналитика**: список попыток по тесту (`getQuizAttempts`, с `FocusLossCount`,
|
||||
`FinishReason` и длительностью), сводка по студентам с учётом `GradingMethod` (`getQuizResults`),
|
||||
детальный просмотр попытки, `QuestionStat`.
|
||||
6. **Student UI**: уже работает end-to-end, донастройка — reflect only assigned+open quizzes,
|
||||
обратный отсчёт времени в UI, слушатели `visibilitychange`/`blur` → `reportFocusLoss`,
|
||||
вынос кнопки «Завершить тест» в отдельный зафиксированный блок (§6).
|
||||
7. **Деплой на RuVDS**: systemd-юнит для Kestrel, nginx как reverse proxy + TLS, прод-конфиг
|
||||
`Jwt:Secret`/`Client:Origin`/строка подключения к Postgres (сейчас в `appsettings.Development.json`
|
||||
захардкожен dev-секрет и dev-порт клиента — см. память проекта про CORS-баг 2026-08-03).
|
||||
|
||||
## 8. Открытые вопросы (не решены, всплывут по ходу реализации)
|
||||
|
||||
- Нужен ли предпросмотр/тестовый прогон теста преподавателем без сохранения попытки в статистику?
|
||||
- Что показывать студенту при просмотре своего результата — только баллы, или также его ответы
|
||||
с правильными (риск слива вопросов в банк для будущих попыток при `MaxAttempts > 1`)?
|
||||
- Лимит на минимальное число вопросов в теме, чтобы `RandomFromTopics` не падал в самый ответственный
|
||||
момент («недостаточно вопросов») — валидировать при создании теста или только при старте попытки?
|
||||
- Нужен ли визуальный порог/бейдж «подозрительно» при большом `FocusLossCount`, или преподаватель
|
||||
просто смотрит на число сам без автоматической оценки?
|
||||
- ~~С какого момента считать потери фокуса~~ — решено: с ответа `startAttempt`, см. §3.6.
|
||||
- Показывать ли преподавателю архивные (`IsArchived`) вопросы/тесты в общих списках приглушённым
|
||||
цветом с фильтром, или полностью прятать и доставать только через карточку конкретной попытки?
|
||||
187
docs/PLAN.md
Normal file
187
docs/PLAN.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# План реализации
|
||||
|
||||
Живой чек-лист по `docs/DESIGN.md`. Отмечайте пункты по мере реализации (`[ ]` → `[x]`); статусы
|
||||
ниже соответствуют фактическому состоянию кода на 2026-08-03. Фазы и нумерация разделов совпадают
|
||||
с `docs/DESIGN.md` §7 — там же обоснование каждого пункта, здесь только чек-лист.
|
||||
|
||||
## Базовое состояние на сегодня
|
||||
|
||||
- [x] Студенческий флоу целиком на **старой** доменной модели (`Course`/`CategoryId`/без
|
||||
`Composition`) работает end-to-end через in-memory `Store`: `login` → `getAvailableQuizzes` →
|
||||
`startAttempt` → `submitAnswer` → `finishAttempt` — проверено в браузере.
|
||||
- [x] CORS для dev настроен верно (`Client:Origin = http://localhost:5173`), баг с портом 5174 исправлен.
|
||||
- [x] `Grading.applyGradingMethod` уже реализован и покрыт тестами (`HighestAttempt`, пустой список) —
|
||||
но никуда не подключён (нет API-метода, который бы его вызывал).
|
||||
- [x] `Attempt.isExpired` уже реализован — но нигде не вызывается, лимит времени сейчас не принуждается.
|
||||
|
||||
Все пункты ниже — то, чего в коде пока нет.
|
||||
|
||||
## Архитектурный рефакторинг: организация кода по вертикальным срезам
|
||||
|
||||
Отдельный от фаз 1–7 вопрос — не про функциональность, а про то, как раскладывать код по файлам
|
||||
по мере роста Server/Client. Возник из обсуждения 2026-08-03: сейчас архитектура классическая
|
||||
слоистая (3 проекта = 3 слоя), а не по фиче.
|
||||
|
||||
**Текущее состояние.** `Domain` (чистые типы и бизнес-логика) → `Server` (`Store.fs` + `Auth.fs` +
|
||||
один `QuizApi.fs` на все хендлеры, роутинг через Fable.Remoting.Giraffe по единому интерфейсу
|
||||
`IQuizApi`) → `Client` (Elmish MVU: один общий `Model` в `Types.fs`, один общий `Msg`-DU, один
|
||||
`update` в `State.fs`, один `View.fs`). Срез идёт по техническому слою (типы / состояние /
|
||||
отображение / API), а не по фиче — ровно то, что архитектура вертикальных срезов (VSA) устраняет.
|
||||
|
||||
**Почему не полный переход на VSA.** Два места будут этому сопротивляться:
|
||||
1. Контракт `IQuizApi`/`ITeacherApi`/`IAdminApi` в `Domain.Contracts` (DESIGN.md §4) — это по сути
|
||||
RPC-интерфейс "один record на роль", а не роутинг по фиче. Fable.Remoting-style клиент
|
||||
(`Api.fs`) уже завязан на паттерн `/api/<TypeName>/<Method>` по этим record'ам.
|
||||
2. Elmish с одним `Model`/`Msg` на всё приложение — тоже принципиально центральный паттерн;
|
||||
срез по фиче потребовал бы отдельного рефакторинга на суб-модели (паттерн "Elmish page"),
|
||||
не связанного с серверным вопросом.
|
||||
|
||||
**Компромиссное решение.** Не ломать контракт и Elmish целиком, а **реализацию** каждого метода
|
||||
интерфейса выносить в свой файл/папку по фиче, а не копить всё в одном `TeacherApi.fs`/`View.fs`.
|
||||
Даёт большую часть пользы VSA (не лазить по всему файлу ради одной фичи, фича = один файл со всем
|
||||
необходимым) без переписывания транспорта и стейт-менеджмента.
|
||||
|
||||
**Почему сейчас удобный момент.** Из нового дизайна пока не реализовано практически ничего (см.
|
||||
«Базовое состояние» выше) — так что переход дешевле всего до того, как `TeacherApi.fs`/`View.fs`
|
||||
разрастутся под Teacher/Admin функциональность.
|
||||
|
||||
### Server: организация по фиче
|
||||
- [ ] Вместо одного `TeacherApi.fs`/`AdminApi.fs` — папка `Server/Features/<Role>/<UseCase>.fs`
|
||||
(например `Server/Features/Teacher/CreateQuiz.fs`, `.../PublishQuiz.fs`), каждый файл содержит
|
||||
маппинг запроса/ответа и логику ровно одного метода интерфейса.
|
||||
- [ ] Сборка `ITeacherApi`/`IAdminApi` в одном месте (аналог текущего `QuizApi.build`) остаётся
|
||||
тонкой композицией — record просто ссылается на функции из `Features/*`, сам не содержит логики.
|
||||
- [ ] Тот же принцип для уже существующего `QuizApi.fs` — постепенно разнести на
|
||||
`Server/Features/Student/*.fs`, но не отдельным рывком, а по мере следующих правок этого файла.
|
||||
|
||||
### Client: частичная декомпозиция Elmish
|
||||
- [ ] Общий `Model`/`Msg`/`update` в `Types.fs`/`State.fs` остаётся корнем (сессия, роутинг между
|
||||
страницами), но крупные разделы (кабинет Teacher, кабинет Admin) выносятся в под-модели по
|
||||
паттерну "Elmish page" — свои Model/Msg/update/view на страницу — вместо бесконечного
|
||||
расширения единых `Types.fs`/`State.fs`/`View.fs`.
|
||||
- [ ] Не переписывать существующий студенческий флоу (`Types.fs`/`State.fs`/`View.fs`) ради этого —
|
||||
он маленький и рабочий; применять паттерн к новым разделам (Teacher/Admin UI, фазы 3–4).
|
||||
|
||||
### Когда применять
|
||||
- [ ] Не блокирует фазы 1–2 (домен/БД) — там организация по типу файла (`Users.fs`/`Quizzes.fs`/...,
|
||||
`Store.fs`) уже естественна и менять её не нужно.
|
||||
- [ ] Применяется как соглашение **начиная с фазы 3** (Admin API) — новый код сразу пишется в
|
||||
`Features/`-структуре; уже написанный `QuizApi.fs` не рефакторится превентивно, только когда
|
||||
до него дойдёт очередная правка (рефакторинг не ради рефакторинга).
|
||||
|
||||
## Фаза 1 — Домен (DESIGN.md §3)
|
||||
|
||||
### 1.1 Пользователь (§3.1)
|
||||
- [ ] `User.IsActive`
|
||||
- [ ] `User.CreatedAt`
|
||||
- [ ] `login` проверяет `IsActive`, отказывает тем же текстом ошибки, что и неверный пароль
|
||||
|
||||
### 1.2 Темы и вопросы (§3.2)
|
||||
- [ ] `QuestionCategory` → `Topic`, `CategoryId` → `TopicId`
|
||||
- [ ] `Topic.OwnerId` (вместо `CourseId`), `Topic.CreatedAt`
|
||||
- [ ] `Question.TopicId` (вместо `CategoryId`)
|
||||
- [ ] `Question.IsArchived`, `Question.CreatedAt`
|
||||
- [ ] Правило мягкого удаления вопроса (архивировать вместо удаления, если используется) — сама
|
||||
флаг-логика в домене; серверная проверка "используется ли где-то" — фаза 4
|
||||
|
||||
### 1.3 Состав теста (§3.3)
|
||||
- [ ] `RandomTopicRule`
|
||||
- [ ] `QuizComposition` (`FixedQuestions` / `RandomFromTopics`)
|
||||
- [ ] `Quiz.OwnerId` (вместо `CourseId`)
|
||||
- [ ] `Quiz.AssignedStudentIds`
|
||||
- [ ] `Quiz.IsPublished`, `Quiz.IsArchived`, `Quiz.CreatedAt`
|
||||
- [ ] `Quiz.totalPoints` пересчитан под `Composition` (сумма по `FixedQuestions` или
|
||||
`Count * PointsPerQuestion` по `RandomFromTopics`)
|
||||
- [ ] Удалены `Course`, `Enrollment`, `CourseId`, `EnrollmentRole`
|
||||
|
||||
### 1.4 Резолв и попытка (§3.4, §3.6)
|
||||
- [ ] `AttemptFinishReason` (`ManualSubmit` / `TimedOut`)
|
||||
- [ ] `Attempt.AttemptNumber`
|
||||
- [ ] `Attempt.FinishReason`
|
||||
- [ ] `Attempt.ResolvedQuestions`
|
||||
- [ ] `Attempt.FocusLossCount`
|
||||
- [ ] `Quiz.resolveComposition` (чистая функция, shuffle и вопросы темы — параметрами)
|
||||
- [ ] `Attempt.submit` принимает `AttemptFinishReason`
|
||||
- [ ] `Grading.gradeAttempt` переключён на `attempt.ResolvedQuestions` вместо `quiz.Questions`
|
||||
- [ ] Проверка `Attempt.isExpired` встроена в поток `submitAnswer`/`finishAttempt` (авто-завершение
|
||||
с `FinishReason = TimedOut`)
|
||||
|
||||
### 1.5 Типы аналитики и валидация (§3.5, §3.7)
|
||||
- [ ] `QuestionStat`
|
||||
- [ ] `AttemptSummary`, `AttemptDetail`, `StudentQuizResult`
|
||||
- [ ] `Validation.fs`: `QuizValidation` переработан под `QuizComposition` (сейчас требует
|
||||
непустой `quiz.Questions`, нужно — непустой `FixedQuestions` **или** хотя бы одно правило
|
||||
с `Count > 0` в `RandomFromTopics`)
|
||||
|
||||
### 1.6 Юнит-тесты (`tests/Domain.Tests`)
|
||||
- [ ] `resolveComposition`: `FixedQuestions` (с шаффлом и без), `RandomFromTopics` (успешный набор,
|
||||
ошибка при нехватке вопросов в теме)
|
||||
- [ ] `totalPoints` для обоих режимов `Composition`
|
||||
- [ ] `applyGradingMethod`: добавить `AverageAttempt`/`FirstAttempt`/`LastAttempt` (сейчас покрыт
|
||||
только `HighestAttempt`)
|
||||
- [ ] Существующие `GradingTests.fs`/`ValidationTests.fs` обновлены под новую форму
|
||||
`Question`/`Quiz` (`TopicId` вместо `CategoryId`, `Composition` вместо `Questions`)
|
||||
|
||||
## Фаза 2 — PostgreSQL (§5)
|
||||
|
||||
- [ ] SQL-схема: `users`, `topics`, `questions`, `quizzes`, `quiz_assignments`, `attempts`, `attempt_grades`
|
||||
- [ ] Миграции (DbUp, пронумерованные `.sql`, применяются при старте сервера)
|
||||
- [ ] Dapper-репозиторий взамен `Store.fs` (тот же member-интерфейс)
|
||||
- [ ] Строка подключения к Postgres в конфиге (`appsettings.*.json`)
|
||||
- [ ] `IHostedService` — "expiry sweeper" для заброшенных просроченных попыток (§3.6)
|
||||
|
||||
## Фаза 3 — Admin API + UI (§2, §4.3)
|
||||
|
||||
- [ ] `IAdminApi.listUsers`
|
||||
- [ ] `IAdminApi.createUser` (с выбором `Role`)
|
||||
- [ ] `IAdminApi.updateUser`
|
||||
- [ ] `IAdminApi.deactivateUser`
|
||||
- [ ] `IAdminApi.resetPassword`
|
||||
- [ ] Проверка роли `Admin` на сервере для всех методов `IAdminApi`
|
||||
- [ ] UI: список пользователей, создание/деактивация/сброс пароля
|
||||
|
||||
## Фаза 4 — Teacher API + UI (§4.2)
|
||||
|
||||
- [ ] `ITeacherApi`: `listTopics`/`createTopic`/`renameTopic`/`deleteTopic`
|
||||
- [ ] `ITeacherApi`: `listQuestions`/`createQuestion`/`updateQuestion`/`deleteQuestion`
|
||||
(с архивированием вместо удаления, если вопрос используется — §3.2)
|
||||
- [ ] `ITeacherApi`: `listMyQuizzes`/`getQuiz`/`createQuiz`/`updateQuiz`
|
||||
- [ ] `ITeacherApi`: `publishQuiz`/`unpublishQuiz`
|
||||
- [ ] `ITeacherApi`: `deleteQuiz` (архивирование, если есть попытки — §3.3)
|
||||
- [ ] `ITeacherApi`: `listStudents`/`assignStudents`/`unassignStudent`
|
||||
- [ ] Проверка владения (`OwnerId`) на каждом хендлере, кроме `listStudents`
|
||||
- [ ] UI: темы → вопросы → конструктор теста (fixed-список / random-по-темам) → назначение студентов
|
||||
|
||||
## Фаза 5 — Результаты и аналитика (§3.5, §3.7, §4.2)
|
||||
|
||||
- [ ] Запись в `attempt_grades` при `finishAttempt`/авто-завершении
|
||||
- [ ] `ITeacherApi.getQuizAttempts`
|
||||
- [ ] `ITeacherApi.getAttemptDetail`
|
||||
- [ ] `ITeacherApi.getQuizResults` (сводка по студентам с учётом `GradingMethod`)
|
||||
- [ ] `ITeacherApi.getQuestionStats`
|
||||
- [ ] UI: список попыток (с `FocusLossCount`/`FinishReason`/длительностью), карточка попытки,
|
||||
сводная ведомость по студентам, аналитика по вопросам
|
||||
|
||||
## Фаза 6 — Student UI (§4.1, §6)
|
||||
|
||||
- [ ] `getAvailableQuizzes`: фильтр по `IsPublished`, `AssignedStudentIds`, `not IsArchived`, окну дат
|
||||
- [ ] `IQuizApi.getMyResults` + экран истории попыток студента
|
||||
- [ ] `IQuizApi.reportFocusLoss` + клиентские слушатели `visibilitychange`/`blur`
|
||||
- [ ] Обратный отсчёт времени в UI прохождения теста
|
||||
- [ ] Кнопка «Завершить тест» вынесена в отдельный зафиксированный блок, не участвующий в
|
||||
скролле списка вопросов (§6)
|
||||
|
||||
## Фаза 7 — Деплой на RuVDS (§1, §7)
|
||||
|
||||
- [ ] systemd-юнит для Kestrel
|
||||
- [ ] nginx как reverse proxy + TLS
|
||||
- [ ] Прод-конфиг: `Jwt:Secret`, `Client:Origin`, строка подключения к Postgres
|
||||
(сейчас в `appsettings.Development.json` — dev-значения, включая исправленный порт 5173)
|
||||
|
||||
## Открытые вопросы (DESIGN.md §8) — решить по ходу соответствующей фазы
|
||||
|
||||
- [ ] Нужен ли предпросмотр/тестовый прогон теста преподавателем без сохранения попытки в статистику? (фаза 4)
|
||||
- [ ] Показывать ли студенту его ответы с правильными при просмотре результата, или только баллы? (фаза 5/6)
|
||||
- [ ] Валидировать нехватку вопросов в теме для `RandomFromTopics` при создании теста или только при старте попытки? (фаза 1/4)
|
||||
- [ ] Нужен ли визуальный порог/бейдж «подозрительно» при большом `FocusLossCount`? (фаза 5)
|
||||
- [ ] Показывать ли преподавателю архивные вопросы/тесты в общих списках (приглушённо+фильтр) или полностью прятать? (фаза 4)
|
||||
118
docs/SETUP.md
Normal file
118
docs/SETUP.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Развёртывание проекта для разработки (Windows 11)
|
||||
|
||||
Два варианта: **нативная разработка** (быстрый цикл правки-проверки, рекомендуется для повседневной
|
||||
работы) и **Docker Compose** (весь стек одной командой, полезно для быстрой проверки/демо). Для
|
||||
обоих нужен Postgres — с этой сессии сервер больше не хранит данные в памяти.
|
||||
|
||||
## Предварительные требования
|
||||
|
||||
| Инструмент | Версия | Зачем |
|
||||
|---|---|---|
|
||||
| [Git](https://git-scm.com/) | любая современная | клонировать репозиторий |
|
||||
| [.NET SDK](https://dotnet.microsoft.com/download) | 9.0 или новее | сборка Domain/Server/Client (Fable компилирует F# в JS поверх .NET SDK) |
|
||||
| [Node.js](https://nodejs.org/) | 20 LTS или новее | Vite (сборка/дев-сервер клиента) |
|
||||
| [Docker Desktop](https://www.docker.com/products/docker-desktop/) | любая современная | Postgres (в обоих вариантах) и опционально весь стек |
|
||||
|
||||
Проверить, что всё установлено:
|
||||
|
||||
```powershell
|
||||
git --version
|
||||
dotnet --version
|
||||
node --version
|
||||
docker --version
|
||||
```
|
||||
|
||||
## 1. Клонировать репозиторий
|
||||
|
||||
```powershell
|
||||
git clone <URL репозитория>
|
||||
cd RuVdsTests
|
||||
```
|
||||
|
||||
## 2. Поставить зависимости
|
||||
|
||||
```powershell
|
||||
# Fable (F# → JS компилятор) — версия закреплена в .config/dotnet-tools.json
|
||||
dotnet tool restore
|
||||
|
||||
# npm-пакеты клиента (react, vite и т.д.)
|
||||
npm install
|
||||
```
|
||||
|
||||
## 3. Поднять Postgres
|
||||
|
||||
Серверу нужна база `quizsystem` с пользователем `quizsystem`/паролем `devpassword` на порту `5432`
|
||||
localhost — это то, что уже прописано по умолчанию в
|
||||
`src/Server/appsettings.Development.json`, менять ничего не нужно, если использовать эти же значения.
|
||||
|
||||
Самый быстрый способ — разовый контейнер:
|
||||
|
||||
```powershell
|
||||
docker run -d --name quizsystem-postgres -p 5432:5432 `
|
||||
-e POSTGRES_DB=quizsystem -e POSTGRES_USER=quizsystem -e POSTGRES_PASSWORD=devpassword `
|
||||
postgres:16-alpine
|
||||
```
|
||||
|
||||
Схема и демо-данные создаются автоматически при первом запуске сервера (миграции — через DbUp,
|
||||
сид — через `Seed.fs`, оба идемпотентны, безопасно перезапускать).
|
||||
|
||||
Если 5432 на хосте уже занят другим Postgres — либо остановите его, либо смените порт в команде выше
|
||||
и в `ConnectionStrings:Postgres` в `appsettings.Development.json` соответственно.
|
||||
|
||||
## 4. Запустить сервер и клиент
|
||||
|
||||
Два процесса, в двух отдельных терминалах:
|
||||
|
||||
```powershell
|
||||
# Терминал 1 — сервер (ASP.NET/Giraffe, порт 5144)
|
||||
dotnet run --project src/Server/Server.fsproj
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Терминал 2 — клиент (Fable watch + Vite dev-сервер, порт 5173)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Открыть **http://localhost:5173**. Демо-доступы (создаются сидом при первом запуске сервера):
|
||||
|
||||
| Роль | Email | Пароль |
|
||||
|---|---|---|
|
||||
| Преподаватель | `teacher@example.com` | `teacher123` |
|
||||
| Студент | `student@example.com` | `student123` |
|
||||
| Администратор | `admin@example.com` | `admin123` |
|
||||
|
||||
## Альтернатива: всё через Docker Compose
|
||||
|
||||
Вместо шагов 3–4 можно поднять весь стек (Postgres + Server + Client) одной командой — не нужен ни
|
||||
локальный .NET SDK, ни Node, только Docker.
|
||||
|
||||
```powershell
|
||||
cp .env.example .env
|
||||
# при желании отредактировать .env (JWT_SECRET/POSTGRES_PASSWORD)
|
||||
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Клиент — **http://localhost:8081**, сервер — **http://localhost:5144** (тот же порт, что и при
|
||||
нативном запуске: клиент обращается к серверу по захардкоженному `http://localhost:5144`, поэтому
|
||||
адрес совпадает независимо от способа запуска).
|
||||
|
||||
```powershell
|
||||
docker compose down # остановить, данные в volume сохраняются
|
||||
docker compose down -v # остановить и стереть все данные Postgres
|
||||
```
|
||||
|
||||
## Типичные проблемы
|
||||
|
||||
- **"Domain.dll используется другим процессом" при пересборке.** Где-то в фоне остался запущенный
|
||||
`dotnet run`/`dotnet watch` от прошлой сессии — найти и завершить процесс
|
||||
(`Get-Process dotnet | Stop-Process`, либо точечно по PID из текста ошибки) и пересобрать заново.
|
||||
- **Логин не проходит / CORS-ошибка в консоли браузера.** Обычно значит, что сервер или клиент не
|
||||
запущены, либо запущены не на портах 5144/5173 — `Client:Origin` в `appsettings.Development.json`
|
||||
жёстко указывает на `http://localhost:5173`.
|
||||
- **Сервер падает при старте с ошибкой подключения к Postgres.** Убедиться, что контейнер/сервис
|
||||
Postgres реально поднят и слушает порт 5432 (`docker ps`), и что `ConnectionStrings:Postgres`
|
||||
в `appsettings.Development.json` соответствует реальным логину/паролю/порту.
|
||||
- **`docker compose build` падает с сетевой ошибкой (не может достучаться до nuget.org/registry).**
|
||||
Обычно временная проблема DNS/VPN на хосте — попробовать пересобрать ещё раз
|
||||
(`docker compose build --no-cache`).
|
||||
Reference in New Issue
Block a user