- Architecture: overview, 7 ADR, tech stack - Principles: code-style, git-workflow, security - API contracts: auth, profile, tests, admin endpoints - Database schema: tables, relationships, indexes - LLM strategy: prompts, fallback, validation, Qwen 2.5 14B - Onboarding: setup, Docker, .env template - Progress: roadmap, changelog - Agents: context, backend instructions Made-with: Cursor
846 lines
22 KiB
Markdown
846 lines
22 KiB
Markdown
# API контракты
|
||
|
||
## Общие соглашения
|
||
|
||
- Базовый URL: `/api/v1`
|
||
- Формат: JSON (`Content-Type: application/json`)
|
||
- Аутентификация: Bearer token в заголовке `Authorization: Bearer <accessToken>`
|
||
- Refresh token: httpOnly secure cookie `refreshToken` (устанавливается сервером при login/refresh)
|
||
- Даты: ISO 8601, UTC (`2026-03-03T12:00:00.000Z`)
|
||
- ID: UUID v7
|
||
|
||
### Формат ошибок
|
||
|
||
Все ошибки возвращаются в едином формате:
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "ERROR_CODE",
|
||
"message": "Human-readable description"
|
||
}
|
||
}
|
||
```
|
||
|
||
### Общие коды ошибок
|
||
|
||
| HTTP | Код | Описание |
|
||
| ------ | ----- | ---------- |
|
||
| 400 | `BAD_REQUEST` | Некорректный запрос |
|
||
| 401 | `UNAUTHORIZED` | Не авторизован или токен истёк |
|
||
| 403 | `FORBIDDEN` | Нет прав |
|
||
| 404 | `NOT_FOUND` | Ресурс не найден |
|
||
| 409 | `CONFLICT` | Конфликт (дубликат) |
|
||
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
||
| 429 | `RATE_LIMIT_EXCEEDED` | Превышен лимит запросов |
|
||
| 500 | `INTERNAL_ERROR` | Внутренняя ошибка сервера |
|
||
|
||
### Пагинация (cursor-based)
|
||
|
||
Для списков используется cursor-based пагинация на основе UUID v7 (сортируемый по времени).
|
||
|
||
Запрос:
|
||
|
||
```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` если это последняя страница.
|
||
|
||
---
|
||
|
||
## Auth
|
||
|
||
### POST /auth/register
|
||
|
||
Регистрация нового пользователя. Отправляет письмо с кодом подтверждения email.
|
||
|
||
**Авторизация:** не требуется
|
||
|
||
**Request:**
|
||
|
||
| Поле | Тип | Обязательное | Валидация |
|
||
| ------ | ----- | -------------- | ----------- |
|
||
| email | string | да | format: email, max 255 |
|
||
| password | string | да | min 8, max 128 |
|
||
| nickname | string | да | min 2, max 30, alphanumeric + underscore |
|
||
|
||
```json
|
||
{
|
||
"email": "user@example.com",
|
||
"password": "securePass123",
|
||
"nickname": "john_doe"
|
||
}
|
||
```
|
||
|
||
**Response 201:**
|
||
|
||
```json
|
||
{
|
||
"user": {
|
||
"id": "0192a8b0-1234-7000-8000-000000000001",
|
||
"email": "user@example.com",
|
||
"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`
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 409 | `EMAIL_TAKEN` | Email уже зарегистрирован |
|
||
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
||
| 429 | `RATE_LIMIT_EXCEEDED` | Более 3 регистраций с IP за час |
|
||
|
||
---
|
||
|
||
### POST /auth/login
|
||
|
||
Аутентификация по email и паролю.
|
||
|
||
**Авторизация:** не требуется
|
||
|
||
**Request:**
|
||
|
||
| Поле | Тип | Обязательное |
|
||
| ------ | ----- | -------------- |
|
||
| email | string | да |
|
||
| password | string | да |
|
||
|
||
```json
|
||
{
|
||
"email": "user@example.com",
|
||
"password": "securePass123"
|
||
}
|
||
```
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"user": {
|
||
"id": "0192a8b0-1234-7000-8000-000000000001",
|
||
"email": "user@example.com",
|
||
"nickname": "john_doe",
|
||
"role": "free",
|
||
"emailVerified": true
|
||
},
|
||
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
|
||
}
|
||
```
|
||
|
||
Set-Cookie: `refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=604800`
|
||
|
||
Создаётся запись в `sessions` с device info и IP.
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 401 | `INVALID_CREDENTIALS` | Неверный email или пароль |
|
||
| 403 | `ACCOUNT_LOCKED` | Прогрессивный lockout (brute force) |
|
||
| 429 | `RATE_LIMIT_EXCEEDED` | Превышен лимит попыток входа |
|
||
|
||
---
|
||
|
||
### POST /auth/logout
|
||
|
||
Завершение текущей сессии. Удаляет запись из `sessions`, очищает refresh cookie.
|
||
|
||
**Авторизация:** Bearer token
|
||
|
||
**Request:** пустое тело
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"message": "Logged out successfully"
|
||
}
|
||
```
|
||
|
||
Set-Cookie: `refreshToken=; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=0`
|
||
|
||
---
|
||
|
||
### POST /auth/refresh
|
||
|
||
Обновление access token по refresh token из cookie. Выполняет ротацию refresh token.
|
||
|
||
**Авторизация:** не требуется (refresh token в cookie)
|
||
|
||
**Request:** пустое тело (refresh token читается из cookie)
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
|
||
}
|
||
```
|
||
|
||
Set-Cookie: новый `refreshToken` (ротация).
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 401 | `INVALID_REFRESH_TOKEN` | Токен невалидный или истёк |
|
||
| 401 | `TOKEN_REUSE_DETECTED` | Повторное использование старого токена — все сессии пользователя инвалидируются |
|
||
|
||
---
|
||
|
||
### POST /auth/verify-email
|
||
|
||
Подтверждение email по коду из письма.
|
||
|
||
**Авторизация:** Bearer token
|
||
|
||
**Request:**
|
||
|
||
| Поле | Тип | Обязательное |
|
||
| ------ | ----- | -------------- |
|
||
| code | string | да |
|
||
|
||
```json
|
||
{
|
||
"code": "123456"
|
||
}
|
||
```
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"message": "Email verified successfully"
|
||
}
|
||
```
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 400 | `INVALID_CODE` | Неверный или истёкший код |
|
||
| 409 | `ALREADY_VERIFIED` | Email уже подтверждён |
|
||
|
||
---
|
||
|
||
### POST /auth/forgot-password
|
||
|
||
Запрос на сброс пароля. Отправляет письмо с кодом/ссылкой.
|
||
|
||
**Авторизация:** не требуется
|
||
|
||
**Request:**
|
||
|
||
| Поле | Тип | Обязательное |
|
||
| ------ | ----- | -------------- |
|
||
| email | string | да |
|
||
|
||
```json
|
||
{
|
||
"email": "user@example.com"
|
||
}
|
||
```
|
||
|
||
**Response 200:**
|
||
|
||
Всегда возвращает успех (даже если email не найден — чтобы не раскрывать наличие аккаунта):
|
||
|
||
```json
|
||
{
|
||
"message": "If this email is registered, a reset link has been sent"
|
||
}
|
||
```
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 429 | `RATE_LIMIT_EXCEEDED` | Более 3 запросов с IP за час |
|
||
|
||
---
|
||
|
||
### POST /auth/reset-password
|
||
|
||
Сброс пароля по токену из письма.
|
||
|
||
**Авторизация:** не требуется
|
||
|
||
**Request:**
|
||
|
||
| Поле | Тип | Обязательное |
|
||
| ------ | ----- | ------------- |
|
||
| token | string | да |
|
||
| password | string | да |
|
||
|
||
```json
|
||
{
|
||
"token": "reset-token-from-email",
|
||
"password": "newSecurePass456"
|
||
}
|
||
```
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"message": "Password reset successfully"
|
||
}
|
||
```
|
||
|
||
Все сессии пользователя инвалидируются после сброса пароля.
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 400 | `INVALID_RESET_TOKEN` | Токен невалидный или истёк |
|
||
| 422 | `VALIDATION_ERROR` | Пароль не соответствует требованиям |
|
||
|
||
---
|
||
|
||
## Profile
|
||
|
||
### GET /profile
|
||
|
||
Текущий профиль авторизованного пользователя.
|
||
|
||
**Авторизация:** Bearer token
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"id": "0192a8b0-1234-7000-8000-000000000001",
|
||
"email": "user@example.com",
|
||
"nickname": "john_doe",
|
||
"avatarUrl": null,
|
||
"country": "Russia",
|
||
"city": null,
|
||
"selfLevel": null,
|
||
"isPublic": true,
|
||
"role": "free",
|
||
"emailVerified": true,
|
||
"plan": "free",
|
||
"createdAt": "2026-03-03T12:00:00.000Z"
|
||
}
|
||
```
|
||
|
||
Поле `plan` берётся из `subscriptions` через subscription middleware.
|
||
|
||
---
|
||
|
||
### PATCH /profile
|
||
|
||
Обновление профиля.
|
||
|
||
**Авторизация:** Bearer token
|
||
|
||
**Request** (все поля опциональны):
|
||
|
||
| Поле | Тип | Валидация |
|
||
| ------ | ----- | ----------- |
|
||
| nickname | string | min 2, max 30 |
|
||
| country | string | max 100 |
|
||
| city | string | max 100 |
|
||
| selfLevel | string | enum: jun / mid / sen |
|
||
| isPublic | boolean | |
|
||
|
||
```json
|
||
{
|
||
"nickname": "jane_doe",
|
||
"country": "Russia",
|
||
"selfLevel": "jun"
|
||
}
|
||
```
|
||
|
||
**Response 200:**
|
||
|
||
Полный объект профиля (как в GET /profile) с обновлёнными полями.
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 409 | `NICKNAME_TAKEN` | Никнейм уже занят |
|
||
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
||
|
||
---
|
||
|
||
### GET /profile/:username
|
||
|
||
Публичный профиль пользователя.
|
||
|
||
**Авторизация:** не требуется
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"nickname": "john_doe",
|
||
"avatarUrl": null,
|
||
"country": "Russia",
|
||
"selfLevel": "jun",
|
||
"createdAt": "2026-03-03T12:00:00.000Z",
|
||
"stats": {
|
||
"testsCompleted": 42,
|
||
"averageScore": 78
|
||
}
|
||
}
|
||
```
|
||
|
||
Не возвращает email, role, план — только публичная информация.
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 404 | `USER_NOT_FOUND` | Пользователь не найден или профиль скрыт (`isPublic = false`) |
|
||
|
||
---
|
||
|
||
## Tests
|
||
|
||
### POST /tests
|
||
|
||
Создание нового теста. Генерирует вопросы через LLM или берёт из банка.
|
||
|
||
**Авторизация:** Bearer token
|
||
|
||
**Request:**
|
||
|
||
| Поле | Тип | Обязательное | Валидация |
|
||
| ------ | ----- | -------------- | ----------- |
|
||
| stack | string | да | enum: html / css (MVP 0) |
|
||
| level | string | да | enum: basic / beginner (MVP 0) |
|
||
| questionCount | integer | да | enum: 10 / 20 |
|
||
|
||
```json
|
||
{
|
||
"stack": "html",
|
||
"level": "basic",
|
||
"questionCount": 10
|
||
}
|
||
```
|
||
|
||
**Response 201:**
|
||
|
||
```json
|
||
{
|
||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||
"stack": "html",
|
||
"level": "basic",
|
||
"questionCount": 10,
|
||
"status": "in_progress",
|
||
"currentQuestion": 1,
|
||
"startedAt": "2026-03-03T12:05:00.000Z",
|
||
"timeLimitSeconds": null,
|
||
"question": {
|
||
"id": "0192a8b0-9abc-7000-8000-000000000003",
|
||
"orderNumber": 1,
|
||
"type": "single_choice",
|
||
"questionText": "What does the <meta charset=\"UTF-8\"> tag do?",
|
||
"options": [
|
||
{ "key": "A", "text": "Sets the character encoding" },
|
||
{ "key": "B", "text": "Sets the page title" },
|
||
{ "key": "C", "text": "Links a stylesheet" },
|
||
{ "key": "D", "text": "Defines a script" }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
При создании теста вопросы копируются в `test_questions` (снепшот). Первый вопрос возвращается сразу.
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 403 | `DAILY_LIMIT_REACHED` | Free: 5 тестов в день |
|
||
| 403 | `EMAIL_NOT_VERIFIED` | Email не подтверждён |
|
||
| 422 | `VALIDATION_ERROR` | Невалидный стек/уровень |
|
||
| 503 | `QUESTIONS_UNAVAILABLE` | LLM и банк вопросов недоступны |
|
||
|
||
---
|
||
|
||
### GET /tests/:id
|
||
|
||
Получение текущего состояния теста (для восстановления после перезагрузки страницы).
|
||
|
||
**Авторизация:** Bearer token (только свой тест)
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||
"stack": "html",
|
||
"level": "basic",
|
||
"questionCount": 10,
|
||
"status": "in_progress",
|
||
"currentQuestion": 3,
|
||
"answeredCount": 2,
|
||
"startedAt": "2026-03-03T12:05:00.000Z",
|
||
"timeLimitSeconds": null,
|
||
"question": {
|
||
"id": "0192a8b0-9abc-7000-8000-000000000005",
|
||
"orderNumber": 3,
|
||
"type": "true_false",
|
||
"questionText": "The <div> element is an inline element.",
|
||
"options": [
|
||
{ "key": "A", "text": "True" },
|
||
{ "key": "B", "text": "False" }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
Не возвращает правильные ответы и объяснения — только текущий вопрос.
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 403 | `FORBIDDEN` | Тест принадлежит другому пользователю |
|
||
| 404 | `NOT_FOUND` | Тест не найден |
|
||
|
||
---
|
||
|
||
### POST /tests/:id/answer
|
||
|
||
Ответ на текущий вопрос. Возвращает следующий вопрос.
|
||
|
||
**Авторизация:** Bearer token (только свой тест)
|
||
|
||
**Request:**
|
||
|
||
| Поле | Тип | Обязательное | Описание |
|
||
| ------ | ----- | -------------- | ---------- |
|
||
| questionId | uuid | да | ID вопроса из `question` |
|
||
| answer | string / string[] | да | Ключ ответа ("A") или массив (["A", "C"]) |
|
||
|
||
```json
|
||
{
|
||
"questionId": "0192a8b0-9abc-7000-8000-000000000003",
|
||
"answer": "A"
|
||
}
|
||
```
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"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`).
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 400 | `QUESTION_ALREADY_ANSWERED` | Вопрос уже отвечен |
|
||
| 400 | `WRONG_QUESTION` | questionId не соответствует текущему вопросу |
|
||
| 400 | `TEST_ALREADY_FINISHED` | Тест завершён |
|
||
| 422 | `VALIDATION_ERROR` | Невалидный ответ |
|
||
|
||
---
|
||
|
||
### POST /tests/:id/finish
|
||
|
||
Завершение теста. Подсчитывает результат, обновляет `user_stats`.
|
||
|
||
**Авторизация:** Bearer token (только свой тест)
|
||
|
||
**Request:** пустое тело
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||
"status": "completed",
|
||
"score": 8,
|
||
"totalQuestions": 10,
|
||
"percentage": 80,
|
||
"timeSpentSeconds": 342,
|
||
"finishedAt": "2026-03-03T12:10:42.000Z"
|
||
}
|
||
```
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 400 | `TEST_ALREADY_FINISHED` | Тест уже завершён |
|
||
| 400 | `NO_ANSWERS` | Ни один вопрос не отвечен |
|
||
|
||
---
|
||
|
||
### GET /tests/:id/results
|
||
|
||
Детальные результаты теста с разбором каждого вопроса.
|
||
|
||
**Авторизация:** Bearer token (только свой тест)
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||
"stack": "html",
|
||
"level": "basic",
|
||
"score": 8,
|
||
"totalQuestions": 10,
|
||
"percentage": 80,
|
||
"timeSpentSeconds": 342,
|
||
"startedAt": "2026-03-03T12:05:00.000Z",
|
||
"finishedAt": "2026-03-03T12:10:42.000Z",
|
||
"questions": [
|
||
{
|
||
"orderNumber": 1,
|
||
"type": "single_choice",
|
||
"questionText": "What does the <meta charset=\"UTF-8\"> tag do?",
|
||
"options": [
|
||
{ "key": "A", "text": "Sets the character encoding" },
|
||
{ "key": "B", "text": "Sets the page title" },
|
||
{ "key": "C", "text": "Links a stylesheet" },
|
||
{ "key": "D", "text": "Defines a script" }
|
||
],
|
||
"userAnswer": "A",
|
||
"correctAnswer": "A",
|
||
"isCorrect": true,
|
||
"explanation": "The <meta charset=\"UTF-8\"> tag specifies the character encoding for the HTML document."
|
||
},
|
||
{
|
||
"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>" }
|
||
],
|
||
"userAnswer": "C",
|
||
"correctAnswer": "B",
|
||
"isCorrect": false,
|
||
"explanation": "<h1> defines the largest heading. <head> is a container for metadata, not a heading element."
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 400 | `TEST_NOT_FINISHED` | Тест ещё не завершён |
|
||
| 404 | `NOT_FOUND` | Тест не найден |
|
||
|
||
---
|
||
|
||
### GET /tests/history
|
||
|
||
История тестов пользователя. Cursor-based пагинация, сортировка по дате (новые первые).
|
||
|
||
**Авторизация:** Bearer token
|
||
|
||
**Query параметры:**
|
||
|
||
| Параметр | Тип | Обязательный | Описание |
|
||
| ---------- | ----- | -------------- | --------- |
|
||
| limit | integer | нет | default 10, max 50 |
|
||
| cursor | uuid | нет | ID последнего теста предыдущей страницы |
|
||
| stack | string | нет | Фильтр по стеку |
|
||
| status | string | нет | Фильтр: completed / abandoned |
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"data": [
|
||
{
|
||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||
"stack": "html",
|
||
"level": "basic",
|
||
"questionCount": 10,
|
||
"score": 8,
|
||
"percentage": 80,
|
||
"status": "completed",
|
||
"startedAt": "2026-03-03T12:05:00.000Z",
|
||
"finishedAt": "2026-03-03T12:10:42.000Z"
|
||
}
|
||
],
|
||
"pagination": {
|
||
"nextCursor": "0192a8b0-5678-7000-8000-000000000002",
|
||
"hasMore": false
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Admin
|
||
|
||
### GET /admin/questions/queue
|
||
|
||
QA очередь вопросов для модерации. Cursor-based пагинация.
|
||
|
||
**Авторизация:** Bearer token (role: admin)
|
||
|
||
**Query параметры:**
|
||
|
||
| Параметр | Тип | Обязательный | Описание |
|
||
| ---------- | ----- | -------------- | ---------- |
|
||
| limit | integer | нет | default 20, max 50 |
|
||
| cursor | uuid | нет | |
|
||
| status | string | нет | pending / approved / rejected (default: pending) |
|
||
| stack | string | нет | Фильтр по стеку |
|
||
|
||
**Response 200:**
|
||
|
||
```json
|
||
{
|
||
"data": [
|
||
{
|
||
"id": "0192a8b0-def0-7000-8000-000000000010",
|
||
"stack": "html",
|
||
"level": "basic",
|
||
"type": "single_choice",
|
||
"questionText": "What does the <meta charset=\"UTF-8\"> tag do?",
|
||
"options": [
|
||
{ "key": "A", "text": "Sets the character encoding" },
|
||
{ "key": "B", "text": "Sets the page title" },
|
||
{ "key": "C", "text": "Links a stylesheet" },
|
||
{ "key": "D", "text": "Defines a script" }
|
||
],
|
||
"correctAnswer": "A",
|
||
"explanation": "The meta charset tag specifies the character encoding.",
|
||
"source": "llm_generated",
|
||
"status": "pending",
|
||
"usageCount": 0,
|
||
"createdAt": "2026-03-03T11:00:00.000Z",
|
||
"reportsCount": 0
|
||
}
|
||
],
|
||
"pagination": {
|
||
"nextCursor": "0192a8b0-def0-7000-8000-000000000010",
|
||
"hasMore": true
|
||
}
|
||
}
|
||
```
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 403 | `FORBIDDEN` | Пользователь не admin |
|
||
|
||
---
|
||
|
||
### PATCH /admin/questions/:id
|
||
|
||
Одобрение, отклонение или редактирование вопроса.
|
||
|
||
**Авторизация:** Bearer token (role: admin)
|
||
|
||
**Request:**
|
||
|
||
| Поле | Тип | Обязательное | Описание |
|
||
| ------ | ----- | -------------- | ---------- |
|
||
| status | string | нет | approved / rejected |
|
||
| questionText | string | нет | Отредактированный текст |
|
||
| options | array | нет | Отредактированные варианты |
|
||
| correctAnswer | string | нет | Исправленный ответ |
|
||
| explanation | string | нет | Исправленное объяснение |
|
||
|
||
```json
|
||
{
|
||
"status": "approved"
|
||
}
|
||
```
|
||
|
||
Или с редактированием:
|
||
|
||
```json
|
||
{
|
||
"status": "approved",
|
||
"explanation": "The <meta charset> tag specifies the character encoding for the HTML document, typically UTF-8."
|
||
}
|
||
```
|
||
|
||
**Response 200:**
|
||
|
||
Полный объект вопроса (как в GET queue) с обновлёнными полями.
|
||
|
||
Действие записывается в `audit_logs`.
|
||
|
||
**Ошибки:**
|
||
|
||
| HTTP | Код | Когда |
|
||
| ------ | ----- | ------- |
|
||
| 403 | `FORBIDDEN` | Пользователь не admin |
|
||
| 404 | `NOT_FOUND` | Вопрос не найден |
|
||
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
||
|
||
---
|
||
|
||
## Rate limits по endpoint
|
||
|
||
Подробности в [security.md](../principles/security.md). Краткая сводка:
|
||
|
||
| Группа | Лимит | Окно |
|
||
| -------- | ------- | ------ |
|
||
| `/auth/login` | Прогрессивный lockout | 15 мин / 1 час / 24 часа |
|
||
| `/auth/register` | 3 | 1 час |
|
||
| `/auth/forgot-password` | 3 | 1 час |
|
||
| Общий (авторизованный) | 100 | 1 мин |
|
||
| Общий (гость) | 30 | 1 мин |
|
||
| Тесты (Free) | 5 тестов | 1 день |
|