docs: приведение документации в соответствие с backend
- Auth: register без токенов до верификации (userId, message, verificationCode)
- Auth: login — 429 RATE_LIMIT_EXCEEDED при lockout, user с avatarUrl
- Auth: verify-email — { userId, code }, без Bearer
- Auth: reset-password — поле newPassword
- Profile: stats — byStack, totalTestsTaken, totalQuestions, correctAnswers, accuracy
- Tests: POST /tests возвращает полный список questions
- Tests: answer — полный snapshot отвеченного вопроса
- Tests: history — offset-пагинация (limit/offset), формат { tests, total }
- Admin: GET /admin/questions/pending, POST approve/reject, PATCH для редактирования
- DB: email_verification_codes, password_reset_tokens; обновлена question_cache_meta
- Security: CORS_ORIGINS из env, CSP/COEP отключены
- LLM: LLM_FALLBACK_MODEL, LLM_RETRY_DELAY_MS
- Onboarding: правило .env.example, JWT_SECRET >= 32 символов
This commit is contained in:
157
AGENT_TASK_DOCS_SYNC.md
Normal file
157
AGENT_TASK_DOCS_SYNC.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Задача: Синхронизация документации с реализацией
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
По итогам аудита документация должна быть приведена в соответствие с текущей реализацией backend. Данная задача — правки в samreshu_docs (submodule или основной репо с документацией).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. API: Базовый URL
|
||||||
|
|
||||||
|
Убедиться, что базовый URL `/api/v1` соответствует backend (после внесения префикса агентом backend). Если в docs есть примеры с другим базовым путём — исправить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Auth: Register
|
||||||
|
|
||||||
|
**Файл:** `samreshu_docs/api/contracts.md`
|
||||||
|
|
||||||
|
- Регистрация **не выдаёт токены** до подтверждения email.
|
||||||
|
- Response 201: `{ userId, message, verificationCode }` (verificationCode — для dev/тестов, в prod не отдаётся).
|
||||||
|
- Добавить ошибку `NICKNAME_TAKEN` (409), если в коде она есть.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Auth: Login
|
||||||
|
|
||||||
|
- Ошибка при блокировке (lockout): **429** `RATE_LIMIT_EXCEEDED` (не 403 ACCOUNT_LOCKED).
|
||||||
|
- Response 200 должен включать объект `user`: `{ id, email, nickname, avatarUrl, role, emailVerified }`.
|
||||||
|
- Set-Cookie: refreshToken (httpOnly, Secure, SameSite=Strict, Path=/api/v1/auth).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Auth: Verify-email
|
||||||
|
|
||||||
|
- **Request:** `{ userId, code }` (не только code).
|
||||||
|
- **Авторизация:** не требуется (Bearer не нужен).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Auth: Reset-password
|
||||||
|
|
||||||
|
- **Request:** поле `newPassword` (не `password`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Profile: GET /profile
|
||||||
|
|
||||||
|
- В ответе: `role`, `plan` (из subscriptions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Profile: GET /profile/:username (публичный профиль)
|
||||||
|
|
||||||
|
- Структура `stats`: `{ byStack, totalTestsTaken, totalQuestions, correctAnswers, accuracy }` (а не только testsCompleted, averageScore).
|
||||||
|
- Описать формат `byStack` и остальных полей.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tests: POST /tests (создание)
|
||||||
|
|
||||||
|
- Response: тест со списком **всех** вопросов в `questions` (не только текущий в `question`).
|
||||||
|
- Структура ответа: `{ id, stack, level, questionCount, status, startedAt, timeLimitSeconds, questions: [...] }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tests: POST /tests/:id/answer
|
||||||
|
|
||||||
|
- Response: полный snapshot отвеченного вопроса (формат из реализации).
|
||||||
|
- Указать, что структура может отличаться от минимальной "answered + progress + nextQuestion".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Tests: POST /tests/:id/finish
|
||||||
|
|
||||||
|
- `score` — количество правильных ответов (integer).
|
||||||
|
- В ответе: `score`, `totalQuestions`, `percentage` (процент считает фронтенд или он приходит с бэка).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Tests: GET /tests/history (или GET /tests для истории)
|
||||||
|
|
||||||
|
- **Пагинация:** offset-based: `limit`, `offset` (не cursor).
|
||||||
|
- Формат: `{ tests: [...], total }` (не `{ data, pagination }`).
|
||||||
|
- Указать параметры `limit`, `offset`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Admin
|
||||||
|
|
||||||
|
- Эндпоинт списка: **GET /admin/questions/pending** (не /queue).
|
||||||
|
- Отдельные эндпоинты: **POST /admin/questions/:id/approve**, **POST /admin/questions/:id/reject**.
|
||||||
|
- **PATCH /admin/questions/:id** — для редактирования контента (без смены статуса).
|
||||||
|
- Пагинация: limit/offset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Database: Токены верификации
|
||||||
|
|
||||||
|
**Файл:** `samreshu_docs/database/schema.md`
|
||||||
|
|
||||||
|
- Вместо общей таблицы `verification_tokens` описать:
|
||||||
|
- `email_verification_codes` (userId, code, expiresAt)
|
||||||
|
- `password_reset_tokens` (userId, tokenHash, expiresAt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Security: CORS
|
||||||
|
|
||||||
|
**Файл:** `samreshu_docs/principles/security.md`
|
||||||
|
|
||||||
|
- Origins задаются через переменную окружения **CORS_ORIGINS** (не хардкод localhost/prod).
|
||||||
|
- Методы: **GET, POST, PATCH, DELETE, PUT, OPTIONS** (OPTIONS нужен для preflight, PUT — для идемпотентных обновлений).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Security: Helmet (CSP, COEP)
|
||||||
|
|
||||||
|
- **CSP и COEP отключены** — бэкенд отдаёт только JSON API.
|
||||||
|
- Эти заголовки предназначены для HTML-страниц; для REST API они не нужны и могут мешать Swagger UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. LLM: Переменные окружения
|
||||||
|
|
||||||
|
**Файлы:** `samreshu_docs/llm/strategy.md`, `samreshu_docs/onboarding/setup.md`
|
||||||
|
|
||||||
|
Добавить в документацию:
|
||||||
|
- **LLM_FALLBACK_MODEL** — запасная модель при падении основной.
|
||||||
|
- **LLM_RETRY_DELAY_MS** — задержка между retry при ошибках API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. LLM: Логирование в question_cache_meta
|
||||||
|
|
||||||
|
**Файл:** `samreshu_docs/llm/strategy.md`
|
||||||
|
|
||||||
|
- Актуальная структура: `llm_model`, `prompt_hash`, `generation_time_ms`, `valid`, `retry_count`, `questions_generated`, `raw_response` (опционально).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Onboarding: .env.example
|
||||||
|
|
||||||
|
- Закрепить правило: `.env.example` обновляется при добавлении новых фич (новые переменные, rate limits, LLM и т.д.).
|
||||||
|
- Упомянуть требование JWT_SECRET >= 32 символа.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [ ] api/contracts.md: register, login, verify-email, reset-password, logout, refresh
|
||||||
|
- [ ] api/contracts.md: profile (role, plan, stats)
|
||||||
|
- [ ] api/contracts.md: tests (create, answer, finish, history)
|
||||||
|
- [ ] api/contracts.md: admin (pending, approve, reject, PATCH)
|
||||||
|
- [ ] database/schema.md: email_verification_codes, password_reset_tokens
|
||||||
|
- [ ] principles/security.md: CORS, Helmet
|
||||||
|
- [ ] llm/strategy.md: LLM_FALLBACK_MODEL, LLM_RETRY_DELAY_MS, question_cache_meta
|
||||||
|
- [ ] onboarding/setup.md: правило .env.example
|
||||||
198
api/contracts.md
198
api/contracts.md
@@ -35,34 +35,9 @@
|
|||||||
| 429 | `RATE_LIMIT_EXCEEDED` | Превышен лимит запросов |
|
| 429 | `RATE_LIMIT_EXCEEDED` | Превышен лимит запросов |
|
||||||
| 500 | `INTERNAL_ERROR` | Внутренняя ошибка сервера |
|
| 500 | `INTERNAL_ERROR` | Внутренняя ошибка сервера |
|
||||||
|
|
||||||
### Пагинация (cursor-based)
|
### Пагинация
|
||||||
|
|
||||||
Для списков используется cursor-based пагинация на основе UUID v7 (сортируемый по времени).
|
Для списков тестов и админ-очередей используется **offset-based** пагинация: `limit`, `offset`.
|
||||||
|
|
||||||
Запрос:
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /tests/history?limit=10&cursor=0192a8b0-...
|
|
||||||
```
|
|
||||||
|
|
||||||
| Параметр | Тип | Обязательный | Описание |
|
|
||||||
| ---------- | ----- | -------------- | ---------- |
|
|
||||||
| limit | integer | нет | Количество записей (default 10, max 50) |
|
|
||||||
| cursor | uuid | нет | ID последнего элемента предыдущей страницы |
|
|
||||||
|
|
||||||
Ответ всегда содержит:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [...],
|
|
||||||
"pagination": {
|
|
||||||
"nextCursor": "0192a8b0-...",
|
|
||||||
"hasMore": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`nextCursor = null` и `hasMore = false` если это последняя страница.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -70,7 +45,7 @@ GET /tests/history?limit=10&cursor=0192a8b0-...
|
|||||||
|
|
||||||
### POST /auth/register
|
### POST /auth/register
|
||||||
|
|
||||||
Регистрация нового пользователя. Отправляет письмо с кодом подтверждения email.
|
Регистрация нового пользователя. Отправляет письмо с кодом подтверждения email. **Токены не выдаются** до подтверждения email.
|
||||||
|
|
||||||
**Авторизация:** не требуется
|
**Авторизация:** не требуется
|
||||||
|
|
||||||
@@ -94,25 +69,20 @@ GET /tests/history?limit=10&cursor=0192a8b0-...
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"user": {
|
"userId": "0192a8b0-1234-7000-8000-000000000001",
|
||||||
"id": "0192a8b0-1234-7000-8000-000000000001",
|
"message": "Verification code sent to your email",
|
||||||
"email": "user@example.com",
|
"verificationCode": "123456"
|
||||||
"nickname": "john_doe",
|
|
||||||
"role": "free",
|
|
||||||
"emailVerified": false,
|
|
||||||
"createdAt": "2026-03-03T12:00:00.000Z"
|
|
||||||
},
|
|
||||||
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Set-Cookie: `refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=604800`
|
`verificationCode` — для dev/тестов; в production не отдаётся.
|
||||||
|
|
||||||
**Ошибки:**
|
**Ошибки:**
|
||||||
|
|
||||||
| HTTP | Код | Когда |
|
| HTTP | Код | Когда |
|
||||||
| ------ | ----- | ------- |
|
| ------ | ----- | ------- |
|
||||||
| 409 | `EMAIL_TAKEN` | Email уже зарегистрирован |
|
| 409 | `EMAIL_TAKEN` | Email уже зарегистрирован |
|
||||||
|
| 409 | `NICKNAME_TAKEN` | Никнейм уже занят |
|
||||||
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
||||||
| 429 | `RATE_LIMIT_EXCEEDED` | Более 3 регистраций с IP за час |
|
| 429 | `RATE_LIMIT_EXCEEDED` | Более 3 регистраций с IP за час |
|
||||||
|
|
||||||
@@ -146,6 +116,7 @@ Set-Cookie: `refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/
|
|||||||
"id": "0192a8b0-1234-7000-8000-000000000001",
|
"id": "0192a8b0-1234-7000-8000-000000000001",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
"nickname": "john_doe",
|
"nickname": "john_doe",
|
||||||
|
"avatarUrl": null,
|
||||||
"role": "free",
|
"role": "free",
|
||||||
"emailVerified": true
|
"emailVerified": true
|
||||||
},
|
},
|
||||||
@@ -162,8 +133,7 @@ Set-Cookie: `refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/
|
|||||||
| HTTP | Код | Когда |
|
| HTTP | Код | Когда |
|
||||||
| ------ | ----- | ------- |
|
| ------ | ----- | ------- |
|
||||||
| 401 | `INVALID_CREDENTIALS` | Неверный email или пароль |
|
| 401 | `INVALID_CREDENTIALS` | Неверный email или пароль |
|
||||||
| 403 | `ACCOUNT_LOCKED` | Прогрессивный lockout (brute force) |
|
| 429 | `RATE_LIMIT_EXCEEDED` | Прогрессивный lockout (brute force) или превышен лимит попыток входа |
|
||||||
| 429 | `RATE_LIMIT_EXCEEDED` | Превышен лимит попыток входа |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -218,16 +188,18 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
|
|
||||||
Подтверждение email по коду из письма.
|
Подтверждение email по коду из письма.
|
||||||
|
|
||||||
**Авторизация:** Bearer token
|
**Авторизация:** не требуется (Bearer не нужен)
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
| Поле | Тип | Обязательное |
|
| Поле | Тип | Обязательное |
|
||||||
| ------ | ----- | -------------- |
|
| ------ | ----- | -------------- |
|
||||||
|
| userId | uuid | да |
|
||||||
| code | string | да |
|
| code | string | да |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"userId": "0192a8b0-1234-7000-8000-000000000001",
|
||||||
"code": "123456"
|
"code": "123456"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -296,12 +268,12 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
| Поле | Тип | Обязательное |
|
| Поле | Тип | Обязательное |
|
||||||
| ------ | ----- | ------------- |
|
| ------ | ----- | ------------- |
|
||||||
| token | string | да |
|
| token | string | да |
|
||||||
| password | string | да |
|
| newPassword | string | да |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"token": "reset-token-from-email",
|
"token": "reset-token-from-email",
|
||||||
"password": "newSecurePass456"
|
"newPassword": "newSecurePass456"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -408,12 +380,25 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
"selfLevel": "jun",
|
"selfLevel": "jun",
|
||||||
"createdAt": "2026-03-03T12:00:00.000Z",
|
"createdAt": "2026-03-03T12:00:00.000Z",
|
||||||
"stats": {
|
"stats": {
|
||||||
"testsCompleted": 42,
|
"byStack": {
|
||||||
"averageScore": 78
|
"html": { "totalTestsTaken": 5, "totalQuestions": 50, "correctAnswers": 42, "accuracy": 84 },
|
||||||
|
"css": { "totalTestsTaken": 3, "totalQuestions": 30, "correctAnswers": 24, "accuracy": 80 }
|
||||||
|
},
|
||||||
|
"totalTestsTaken": 8,
|
||||||
|
"totalQuestions": 80,
|
||||||
|
"correctAnswers": 66,
|
||||||
|
"accuracy": 82.5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Структура stats:**
|
||||||
|
- `byStack` — объект: ключ — стек (html, css и т.д.), значение — статистика по стеку (`totalTestsTaken`, `totalQuestions`, `correctAnswers`, `accuracy`)
|
||||||
|
- `totalTestsTaken` — общее количество пройденных тестов
|
||||||
|
- `totalQuestions` — общее количество отвеченных вопросов
|
||||||
|
- `correctAnswers` — количество правильных ответов
|
||||||
|
- `accuracy` — процент правильных ответов (0–100)
|
||||||
|
|
||||||
Не возвращает email, role, план — только публичная информация.
|
Не возвращает email, role, план — только публичная информация.
|
||||||
|
|
||||||
**Ошибки:**
|
**Ошибки:**
|
||||||
@@ -457,10 +442,10 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
"level": "basic",
|
"level": "basic",
|
||||||
"questionCount": 10,
|
"questionCount": 10,
|
||||||
"status": "in_progress",
|
"status": "in_progress",
|
||||||
"currentQuestion": 1,
|
|
||||||
"startedAt": "2026-03-03T12:05:00.000Z",
|
"startedAt": "2026-03-03T12:05:00.000Z",
|
||||||
"timeLimitSeconds": null,
|
"timeLimitSeconds": null,
|
||||||
"question": {
|
"questions": [
|
||||||
|
{
|
||||||
"id": "0192a8b0-9abc-7000-8000-000000000003",
|
"id": "0192a8b0-9abc-7000-8000-000000000003",
|
||||||
"orderNumber": 1,
|
"orderNumber": 1,
|
||||||
"type": "single_choice",
|
"type": "single_choice",
|
||||||
@@ -471,11 +456,24 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
{ "key": "C", "text": "Links a stylesheet" },
|
{ "key": "C", "text": "Links a stylesheet" },
|
||||||
{ "key": "D", "text": "Defines a script" }
|
{ "key": "D", "text": "Defines a script" }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0192a8b0-9abc-7000-8000-000000000004",
|
||||||
|
"orderNumber": 2,
|
||||||
|
"type": "single_choice",
|
||||||
|
"questionText": "Which HTML element is used for the largest heading?",
|
||||||
|
"options": [
|
||||||
|
{ "key": "A", "text": "<h6>" },
|
||||||
|
{ "key": "B", "text": "<h1>" },
|
||||||
|
{ "key": "C", "text": "<head>" },
|
||||||
|
{ "key": "D", "text": "<header>" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
При создании теста вопросы копируются в `test_questions` (снепшот). Первый вопрос возвращается сразу.
|
При создании теста возвращается **полный список всех вопросов** в `questions`. Вопросы копируются в `test_questions` (снепшот).
|
||||||
|
|
||||||
**Ошибки:**
|
**Ошибки:**
|
||||||
|
|
||||||
@@ -533,7 +531,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
|
|
||||||
### POST /tests/:id/answer
|
### POST /tests/:id/answer
|
||||||
|
|
||||||
Ответ на текущий вопрос. Возвращает следующий вопрос.
|
Ответ на текущий вопрос.
|
||||||
|
|
||||||
**Авторизация:** Bearer token (только свой тест)
|
**Авторизация:** Bearer token (только свой тест)
|
||||||
|
|
||||||
@@ -541,7 +539,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
|
|
||||||
| Поле | Тип | Обязательное | Описание |
|
| Поле | Тип | Обязательное | Описание |
|
||||||
| ------ | ----- | -------------- | ---------- |
|
| ------ | ----- | -------------- | ---------- |
|
||||||
| questionId | uuid | да | ID вопроса из `question` |
|
| questionId | uuid | да | ID вопроса из `question` / `questions` |
|
||||||
| answer | string / string[] | да | Ключ ответа ("A") или массив (["A", "C"]) |
|
| answer | string / string[] | да | Ключ ответа ("A") или массив (["A", "C"]) |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -553,32 +551,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
|
|
||||||
```json
|
Возвращает полный **snapshot отвеченного вопроса** (формат из реализации). Структура может отличаться от минимальной «answered + progress + nextQuestion» — контракт определяется backend. Может включать детали ответа, правильный ответ, объяснение, прогресс и/или следующий вопрос.
|
||||||
{
|
|
||||||
"answered": {
|
|
||||||
"questionId": "0192a8b0-9abc-7000-8000-000000000003",
|
|
||||||
"isCorrect": true
|
|
||||||
},
|
|
||||||
"progress": {
|
|
||||||
"answeredCount": 1,
|
|
||||||
"totalCount": 10
|
|
||||||
},
|
|
||||||
"nextQuestion": {
|
|
||||||
"id": "0192a8b0-9abc-7000-8000-000000000004",
|
|
||||||
"orderNumber": 2,
|
|
||||||
"type": "single_choice",
|
|
||||||
"questionText": "Which HTML element is used for the largest heading?",
|
|
||||||
"options": [
|
|
||||||
{ "key": "A", "text": "<h6>" },
|
|
||||||
{ "key": "B", "text": "<h1>" },
|
|
||||||
{ "key": "C", "text": "<head>" },
|
|
||||||
{ "key": "D", "text": "<header>" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`nextQuestion = null` если это был последний вопрос (клиент должен вызвать `/finish`).
|
|
||||||
|
|
||||||
**Ошибки:**
|
**Ошибки:**
|
||||||
|
|
||||||
@@ -613,6 +586,8 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`score` — количество правильных ответов (integer). `percentage` приходит с backend (или считается на фронте).
|
||||||
|
|
||||||
**Ошибки:**
|
**Ошибки:**
|
||||||
|
|
||||||
| HTTP | Код | Когда |
|
| HTTP | Код | Когда |
|
||||||
@@ -687,7 +662,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
|
|
||||||
### GET /tests/history
|
### GET /tests/history
|
||||||
|
|
||||||
История тестов пользователя. Cursor-based пагинация, сортировка по дате (новые первые).
|
История тестов пользователя. Offset-based пагинация, сортировка по дате (новые первые).
|
||||||
|
|
||||||
**Авторизация:** Bearer token
|
**Авторизация:** Bearer token
|
||||||
|
|
||||||
@@ -696,7 +671,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
| Параметр | Тип | Обязательный | Описание |
|
| Параметр | Тип | Обязательный | Описание |
|
||||||
| ---------- | ----- | -------------- | --------- |
|
| ---------- | ----- | -------------- | --------- |
|
||||||
| limit | integer | нет | default 10, max 50 |
|
| limit | integer | нет | default 10, max 50 |
|
||||||
| cursor | uuid | нет | ID последнего теста предыдущей страницы |
|
| offset | integer | нет | Смещение (default 0) |
|
||||||
| stack | string | нет | Фильтр по стеку |
|
| stack | string | нет | Фильтр по стеку |
|
||||||
| status | string | нет | Фильтр: completed / abandoned |
|
| status | string | нет | Фильтр: completed / abandoned |
|
||||||
|
|
||||||
@@ -704,7 +679,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": [
|
"tests": [
|
||||||
{
|
{
|
||||||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||||||
"stack": "html",
|
"stack": "html",
|
||||||
@@ -717,10 +692,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
"finishedAt": "2026-03-03T12:10:42.000Z"
|
"finishedAt": "2026-03-03T12:10:42.000Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pagination": {
|
"total": 42
|
||||||
"nextCursor": "0192a8b0-5678-7000-8000-000000000002",
|
|
||||||
"hasMore": false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -728,9 +700,9 @@ Set-Cookie: новый `refreshToken` (ротация).
|
|||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
### GET /admin/questions/queue
|
### GET /admin/questions/pending
|
||||||
|
|
||||||
QA очередь вопросов для модерации. Cursor-based пагинация.
|
Список вопросов на модерацию (статус pending). Offset-based пагинация.
|
||||||
|
|
||||||
**Авторизация:** Bearer token (role: admin)
|
**Авторизация:** Bearer token (role: admin)
|
||||||
|
|
||||||
@@ -739,15 +711,14 @@ QA очередь вопросов для модерации. Cursor-based па
|
|||||||
| Параметр | Тип | Обязательный | Описание |
|
| Параметр | Тип | Обязательный | Описание |
|
||||||
| ---------- | ----- | -------------- | ---------- |
|
| ---------- | ----- | -------------- | ---------- |
|
||||||
| limit | integer | нет | default 20, max 50 |
|
| limit | integer | нет | default 20, max 50 |
|
||||||
| cursor | uuid | нет | |
|
| offset | integer | нет | Смещение (default 0) |
|
||||||
| status | string | нет | pending / approved / rejected (default: pending) |
|
|
||||||
| stack | string | нет | Фильтр по стеку |
|
| stack | string | нет | Фильтр по стеку |
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": [
|
"questions": [
|
||||||
{
|
{
|
||||||
"id": "0192a8b0-def0-7000-8000-000000000010",
|
"id": "0192a8b0-def0-7000-8000-000000000010",
|
||||||
"stack": "html",
|
"stack": "html",
|
||||||
@@ -769,10 +740,7 @@ QA очередь вопросов для модерации. Cursor-based па
|
|||||||
"reportsCount": 0
|
"reportsCount": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pagination": {
|
"total": 15
|
||||||
"nextCursor": "0192a8b0-def0-7000-8000-000000000010",
|
|
||||||
"hasMore": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -784,9 +752,37 @@ QA очередь вопросов для модерации. Cursor-based па
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### POST /admin/questions/:id/approve
|
||||||
|
|
||||||
|
Одобрение вопроса (смена статуса на approved).
|
||||||
|
|
||||||
|
**Авторизация:** Bearer token (role: admin)
|
||||||
|
|
||||||
|
**Request:** пустое тело
|
||||||
|
|
||||||
|
**Response 200:** полный объект вопроса с обновлённым статусом.
|
||||||
|
|
||||||
|
Действие записывается в `audit_logs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /admin/questions/:id/reject
|
||||||
|
|
||||||
|
Отклонение вопроса (смена статуса на rejected).
|
||||||
|
|
||||||
|
**Авторизация:** Bearer token (role: admin)
|
||||||
|
|
||||||
|
**Request:** пустое тело
|
||||||
|
|
||||||
|
**Response 200:** полный объект вопроса с обновлённым статусом.
|
||||||
|
|
||||||
|
Действие записывается в `audit_logs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### PATCH /admin/questions/:id
|
### PATCH /admin/questions/:id
|
||||||
|
|
||||||
Одобрение, отклонение или редактирование вопроса.
|
Редактирование контента вопроса **без смены статуса** (текст, варианты, правильный ответ, объяснение).
|
||||||
|
|
||||||
**Авторизация:** Bearer token (role: admin)
|
**Авторизация:** Bearer token (role: admin)
|
||||||
|
|
||||||
@@ -794,7 +790,6 @@ QA очередь вопросов для модерации. Cursor-based па
|
|||||||
|
|
||||||
| Поле | Тип | Обязательное | Описание |
|
| Поле | Тип | Обязательное | Описание |
|
||||||
| ------ | ----- | -------------- | ---------- |
|
| ------ | ----- | -------------- | ---------- |
|
||||||
| status | string | нет | approved / rejected |
|
|
||||||
| questionText | string | нет | Отредактированный текст |
|
| questionText | string | нет | Отредактированный текст |
|
||||||
| options | array | нет | Отредактированные варианты |
|
| options | array | нет | Отредактированные варианты |
|
||||||
| correctAnswer | string | нет | Исправленный ответ |
|
| correctAnswer | string | нет | Исправленный ответ |
|
||||||
@@ -802,24 +797,11 @@ QA очередь вопросов для модерации. Cursor-based па
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "approved"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Или с редактированием:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "approved",
|
|
||||||
"explanation": "The <meta charset> tag specifies the character encoding for the HTML document, typically UTF-8."
|
"explanation": "The <meta charset> tag specifies the character encoding for the HTML document, typically UTF-8."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:** полный объект вопроса с обновлёнными полями.
|
||||||
|
|
||||||
Полный объект вопроса (как в GET queue) с обновлёнными полями.
|
|
||||||
|
|
||||||
Действие записывается в `audit_logs`.
|
|
||||||
|
|
||||||
**Ошибки:**
|
**Ошибки:**
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
erDiagram
|
erDiagram
|
||||||
users ||--o{ subscriptions : has
|
users ||--o{ subscriptions : has
|
||||||
users ||--o{ sessions : has
|
users ||--o{ sessions : has
|
||||||
|
users ||--o{ email_verification_codes : has
|
||||||
|
users ||--o{ password_reset_tokens : has
|
||||||
users ||--o{ oauth_accounts : has
|
users ||--o{ oauth_accounts : has
|
||||||
users ||--o| totp_secrets : has
|
users ||--o| totp_secrets : has
|
||||||
users ||--o{ tests : takes
|
users ||--o{ tests : takes
|
||||||
@@ -74,6 +76,30 @@ erDiagram
|
|||||||
| expires_at | timestamptz | |
|
| expires_at | timestamptz | |
|
||||||
| created_at | timestamptz | |
|
| created_at | timestamptz | |
|
||||||
|
|
||||||
|
### email_verification_codes
|
||||||
|
|
||||||
|
Коды подтверждения email (для регистрации).
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|----------|
|
||||||
|
| id | uuid, PK | |
|
||||||
|
| user_id | uuid, FK → users | |
|
||||||
|
| code | varchar | Код из письма (обычно 6 цифр) |
|
||||||
|
| expires_at | timestamptz | Срок действия кода |
|
||||||
|
| created_at | timestamptz | |
|
||||||
|
|
||||||
|
### password_reset_tokens
|
||||||
|
|
||||||
|
Токены для сброса пароля.
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|----------|
|
||||||
|
| id | uuid, PK | |
|
||||||
|
| user_id | uuid, FK → users | |
|
||||||
|
| token_hash | varchar | Хеш токена из письма |
|
||||||
|
| expires_at | timestamptz | Срок действия |
|
||||||
|
| created_at | timestamptz | |
|
||||||
|
|
||||||
### oauth_accounts
|
### oauth_accounts
|
||||||
|
|
||||||
Привязанные OAuth-провайдеры (Phase 2).
|
Привязанные OAuth-провайдеры (Phase 2).
|
||||||
@@ -165,7 +191,11 @@ erDiagram
|
|||||||
| question_bank_id | uuid, FK → question_bank | |
|
| question_bank_id | uuid, FK → question_bank | |
|
||||||
| llm_model | varchar | Модель, сгенерировавшая вопрос |
|
| llm_model | varchar | Модель, сгенерировавшая вопрос |
|
||||||
| prompt_hash | varchar | Хеш промпта |
|
| prompt_hash | varchar | Хеш промпта |
|
||||||
| generation_time_ms | integer | Время генерации |
|
| generation_time_ms | integer | Время генерации в мс |
|
||||||
|
| valid | boolean | Прошёл ли валидацию с первого раза |
|
||||||
|
| retry_count | integer | Количество retry при ошибках |
|
||||||
|
| questions_generated | integer | Сколько вопросов сгенерировано |
|
||||||
|
| raw_response | text, nullable | Сырой ответ LLM (опционально, для отладки) |
|
||||||
| created_at | timestamptz | |
|
| created_at | timestamptz | |
|
||||||
|
|
||||||
### question_reports
|
### question_reports
|
||||||
|
|||||||
@@ -20,12 +20,17 @@ flowchart LR
|
|||||||
LLM_BASE_URL=http://localhost:11434/v1
|
LLM_BASE_URL=http://localhost:11434/v1
|
||||||
LLM_MODEL=qwen2.5:14b
|
LLM_MODEL=qwen2.5:14b
|
||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
|
LLM_FALLBACK_MODEL=qwen2.5:7b
|
||||||
LLM_TIMEOUT_MS=15000
|
LLM_TIMEOUT_MS=15000
|
||||||
LLM_MAX_RETRIES=1
|
LLM_MAX_RETRIES=1
|
||||||
|
LLM_RETRY_DELAY_MS=2000
|
||||||
LLM_TEMPERATURE=0.7
|
LLM_TEMPERATURE=0.7
|
||||||
LLM_MAX_TOKENS=2048
|
LLM_MAX_TOKENS=2048
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **LLM_FALLBACK_MODEL** — запасная модель при падении основной.
|
||||||
|
- **LLM_RETRY_DELAY_MS** — задержка между retry при ошибках API (мс).
|
||||||
|
|
||||||
Все провайдеры используют OpenAI-совместимый API (`/v1/chat/completions`). Замена провайдера — изменение `.env`, код не меняется.
|
Все провайдеры используют OpenAI-совместимый API (`/v1/chat/completions`). Замена провайдера — изменение `.env`, код не меняется.
|
||||||
|
|
||||||
### Стратегия провайдеров
|
### Стратегия провайдеров
|
||||||
@@ -466,14 +471,15 @@ Here are the questions:
|
|||||||
|
|
||||||
Каждый LLM-вызов записывается в `question_cache_meta`:
|
Каждый LLM-вызов записывается в `question_cache_meta`:
|
||||||
|
|
||||||
| Метрика | Что записываем |
|
| Поле | Тип | Описание |
|
||||||
| - | - |
|
| - | - | - |
|
||||||
| model | Модель (`qwen2.5:14b`, `gpt-4o-mini`, ...) |
|
| llm_model | varchar | Модель (`qwen2.5:14b`, `gpt-4o-mini`, ...) |
|
||||||
| generation_time_ms | Время генерации |
|
| prompt_hash | varchar | SHA-256 промпта (для дедупликации) |
|
||||||
| prompt_hash | SHA-256 промпта (для дедупликации) |
|
| generation_time_ms | integer | Время генерации в мс |
|
||||||
| valid | boolean — прошёл ли валидацию с первого раза |
|
| valid | boolean | Прошёл ли валидацию с первого раза |
|
||||||
| retry_count | Сколько retry потребовалось |
|
| retry_count | integer | Сколько retry потребовалось |
|
||||||
| questions_generated | Сколько вопросов вернул |
|
| questions_generated | integer | Сколько вопросов сгенерировано |
|
||||||
|
| raw_response | text, nullable | Сырой ответ LLM (опционально, для отладки) |
|
||||||
|
|
||||||
Периодически анализируем: `% валидных ответов по модели`. Если ниже 80% — менять промпт или модель.
|
Периодически анализируем: `% валидных ответов по модели`. Если ниже 80% — менять промпт или модель.
|
||||||
|
|
||||||
|
|||||||
@@ -69,17 +69,22 @@ DATABASE_URL=postgresql://samreshu:samreshu_dev@localhost:5432/samreshu
|
|||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
# Auth
|
# Auth (JWT_SECRET минимум 32 символа в production)
|
||||||
JWT_SECRET=dev-secret-change-in-production
|
JWT_SECRET=dev-secret-change-in-production
|
||||||
JWT_ACCESS_TTL=15m
|
JWT_ACCESS_TTL=15m
|
||||||
JWT_REFRESH_TTL=7d
|
JWT_REFRESH_TTL=7d
|
||||||
|
|
||||||
|
# CORS (через запятую: http://localhost:5173,https://samreshu.ru)
|
||||||
|
CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
# LLM
|
# LLM
|
||||||
LLM_BASE_URL=http://localhost:11434/v1
|
LLM_BASE_URL=http://localhost:11434/v1
|
||||||
LLM_MODEL=qwen2.5:14b
|
LLM_MODEL=qwen2.5:14b
|
||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
|
LLM_FALLBACK_MODEL=qwen2.5:7b
|
||||||
LLM_TIMEOUT_MS=15000
|
LLM_TIMEOUT_MS=15000
|
||||||
LLM_MAX_RETRIES=1
|
LLM_MAX_RETRIES=1
|
||||||
|
LLM_RETRY_DELAY_MS=2000
|
||||||
LLM_TEMPERATURE=0.7
|
LLM_TEMPERATURE=0.7
|
||||||
LLM_MAX_TOKENS=2048
|
LLM_MAX_TOKENS=2048
|
||||||
|
|
||||||
@@ -96,6 +101,8 @@ SENTRY_DSN=
|
|||||||
|
|
||||||
`.env` файл **не коммитится**. В репо лежит `.env.example` с теми же ключами и примерами значений.
|
`.env` файл **не коммитится**. В репо лежит `.env.example` с теми же ключами и примерами значений.
|
||||||
|
|
||||||
|
**Правило:** `.env.example` обновляется при добавлении новых фич — новые переменные (rate limits, LLM, CORS и т.д.) должны быть зафиксированы в шаблоне. **JWT_SECRET** должен быть не менее 32 символов (рекомендуется `openssl rand -base64 32`).
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -159,23 +159,23 @@ ${userAnswer}
|
|||||||
- `X-Frame-Options: DENY`
|
- `X-Frame-Options: DENY`
|
||||||
- `Strict-Transport-Security: max-age=31536000; includeSubDomains`
|
- `Strict-Transport-Security: max-age=31536000; includeSubDomains`
|
||||||
- `X-XSS-Protection: 0` (устаревший, отключаем — CSP заменяет)
|
- `X-XSS-Protection: 0` (устаревший, отключаем — CSP заменяет)
|
||||||
- `Content-Security-Policy` — ограничение источников скриптов, стилей, шрифтов
|
|
||||||
|
**CSP и COEP отключены** — бэкенд отдаёт только JSON API. Эти заголовки предназначены для HTML-страниц; для REST API они не нужны и могут мешать Swagger UI.
|
||||||
|
|
||||||
### CORS
|
### CORS
|
||||||
|
|
||||||
Плагин: **`@fastify/cors`** с whitelist origins:
|
Плагин: **`@fastify/cors`**. Origins задаются через переменную окружения **`CORS_ORIGINS`** (не хардкод localhost/prod):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
origin: [
|
origin: process.env.CORS_ORIGINS?.split(',') ?? ['http://localhost:5173'],
|
||||||
'http://localhost:5173', // dev (Vite)
|
|
||||||
'https://samreshu.ru', // prod
|
|
||||||
],
|
|
||||||
credentials: true, // для httpOnly cookies
|
credentials: true, // для httpOnly cookies
|
||||||
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
|
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS'],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Методы: **GET, POST, PATCH, DELETE, PUT, OPTIONS** (OPTIONS для preflight, PUT для идемпотентных обновлений).
|
||||||
|
|
||||||
### HTTPS
|
### HTTPS
|
||||||
|
|
||||||
SSL termination на nginx (reverse proxy), не в Node.js:
|
SSL termination на nginx (reverse proxy), не в Node.js:
|
||||||
|
|||||||
Reference in New Issue
Block a user