583 lines
17 KiB
Markdown
583 lines
17 KiB
Markdown
# API изменения: Поле workplaceNumber
|
||
|
||
## Обзор
|
||
|
||
Добавлено новое поле `workplaceNumber` для отслеживания рабочего места (компьютера), за которым работает ученик. Это поле сохраняется при авторизации и возвращается во всех эндпоинтах статистики.
|
||
|
||
---
|
||
|
||
## 1. Авторизация пользователя
|
||
|
||
### `POST /challenge/auth`
|
||
|
||
Регистрация или авторизация пользователя с указанием рабочего места.
|
||
|
||
#### Изменения
|
||
- ✨ Добавлен опциональный параметр `workplaceNumber`
|
||
- При создании нового пользователя сохраняется `workplaceNumber`
|
||
- При повторной авторизации существующего пользователя с другим `workplaceNumber` - значение обновляется
|
||
- Поиск пользователя по-прежнему выполняется только по `nickname`
|
||
|
||
#### Request
|
||
|
||
```http
|
||
POST /challenge/auth
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"nickname": "student_ivan",
|
||
"workplaceNumber": "PC-15" // Опционально
|
||
}
|
||
```
|
||
|
||
#### Request Body Parameters
|
||
|
||
| Параметр | Тип | Обязательный | Описание |
|
||
|----------|-----|--------------|----------|
|
||
| `nickname` | `string` | ✅ Да | Никнейм пользователя (3-50 символов) |
|
||
| `workplaceNumber` | `string` | ❌ Нет | Номер рабочего места/компьютера (макс. 50 символов) |
|
||
|
||
#### Response
|
||
|
||
```json
|
||
{
|
||
"error": null,
|
||
"result": {
|
||
"ok": true,
|
||
"userId": "507f1f77bcf86cd799439011"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Примеры использования
|
||
|
||
**Первая авторизация с рабочим местом:**
|
||
```javascript
|
||
const response = await fetch('/challenge/auth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
nickname: 'student_ivan',
|
||
workplaceNumber: 'PC-15'
|
||
})
|
||
});
|
||
```
|
||
|
||
**Повторная авторизация с другого места:**
|
||
```javascript
|
||
// Если пользователь пересел за другой компьютер
|
||
const response = await fetch('/challenge/auth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
nickname: 'student_ivan',
|
||
workplaceNumber: 'PC-20' // Обновится в базе
|
||
})
|
||
});
|
||
```
|
||
|
||
**Авторизация без указания места:**
|
||
```javascript
|
||
// Работает как раньше, workplaceNumber необязателен
|
||
const response = await fetch('/challenge/auth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
nickname: 'student_ivan'
|
||
})
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Статистика по цепочке заданий
|
||
|
||
### `GET /challenge/chain/:chainId/submissions`
|
||
|
||
Получение всех попыток по цепочке с данными о рабочих местах участников.
|
||
|
||
**Требует права:** `teacher` или `challenge-author`
|
||
|
||
#### Изменения
|
||
- ✨ В объекте `user` внутри `submissions` добавлено поле `workplaceNumber`
|
||
- ✨ В массиве `participants` добавлено поле `workplaceNumber`
|
||
|
||
#### Request
|
||
|
||
```http
|
||
GET /challenge/chain/507f1f77bcf86cd799439011/submissions?limit=50&offset=0
|
||
```
|
||
|
||
#### Query Parameters
|
||
|
||
| Параметр | Тип | Описание |
|
||
|----------|-----|----------|
|
||
| `userId` | `string` | Фильтр по конкретному пользователю |
|
||
| `status` | `string` | Фильтр по статусу: `pending`, `in_progress`, `accepted`, `needs_revision` |
|
||
| `limit` | `number` | Количество записей (по умолчанию: 100) |
|
||
| `offset` | `number` | Смещение для пагинации (по умолчанию: 0) |
|
||
|
||
#### Response
|
||
|
||
```json
|
||
{
|
||
"error": null,
|
||
"result": {
|
||
"chain": {
|
||
"id": "507f1f77bcf86cd799439011",
|
||
"name": "Основы Python",
|
||
"tasks": [
|
||
{
|
||
"id": "507f1f77bcf86cd799439012",
|
||
"title": "Переменные и типы данных"
|
||
}
|
||
]
|
||
},
|
||
"participants": [
|
||
{
|
||
"userId": "507f1f77bcf86cd799439013",
|
||
"nickname": "student_ivan",
|
||
"workplaceNumber": "PC-15", // ✨ Новое поле
|
||
"completedTasks": 5,
|
||
"totalTasks": 10,
|
||
"progressPercent": 50
|
||
},
|
||
{
|
||
"userId": "507f1f77bcf86cd799439014",
|
||
"nickname": "student_maria",
|
||
"workplaceNumber": "PC-20", // ✨ Новое поле
|
||
"completedTasks": 8,
|
||
"totalTasks": 10,
|
||
"progressPercent": 80
|
||
}
|
||
],
|
||
"submissions": [
|
||
{
|
||
"id": "507f1f77bcf86cd799439015",
|
||
"user": {
|
||
"id": "507f1f77bcf86cd799439013",
|
||
"nickname": "student_ivan",
|
||
"workplaceNumber": "PC-15" // ✨ Новое поле
|
||
},
|
||
"task": {
|
||
"id": "507f1f77bcf86cd799439012",
|
||
"title": "Переменные и типы данных"
|
||
},
|
||
"status": "accepted",
|
||
"attemptNumber": 2,
|
||
"submittedAt": "2024-01-15T10:30:00.000Z",
|
||
"checkedAt": "2024-01-15T10:31:23.000Z",
|
||
"feedback": "Отличная работа!"
|
||
}
|
||
],
|
||
"pagination": {
|
||
"total": 150,
|
||
"limit": 50,
|
||
"offset": 0
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Пример использования
|
||
|
||
```javascript
|
||
const chainId = '507f1f77bcf86cd799439011';
|
||
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
|
||
headers: {
|
||
'Authorization': 'Bearer YOUR_TOKEN' // Требуется токен преподавателя
|
||
}
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
// Отобразить список участников с их местами
|
||
data.result.participants.forEach(participant => {
|
||
console.log(`${participant.nickname} (${participant.workplaceNumber}): ${participant.progressPercent}%`);
|
||
// Вывод: "student_ivan (PC-15): 50%"
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Расширенная статистика системы
|
||
|
||
### `GET /challenge/stats/v2`
|
||
|
||
Получение детальной статистики с данными о рабочих местах участников.
|
||
|
||
#### Изменения
|
||
- ✨ В массиве `activeParticipants` добавлено поле `workplaceNumber`
|
||
- ✨ В `chainsDetailed[].participantProgress[]` добавлено поле `workplaceNumber`
|
||
|
||
#### Request
|
||
|
||
```http
|
||
GET /challenge/stats/v2
|
||
```
|
||
|
||
или с фильтром по конкретной цепочке:
|
||
|
||
```http
|
||
GET /challenge/stats/v2?chainId=507f1f77bcf86cd799439011
|
||
```
|
||
|
||
#### Query Parameters
|
||
|
||
| Параметр | Тип | Описание |
|
||
|----------|-----|----------|
|
||
| `chainId` | `string` | Опционально: фильтр по конкретной цепочке |
|
||
|
||
#### Response (фрагмент)
|
||
|
||
```json
|
||
{
|
||
"error": null,
|
||
"result": {
|
||
"users": 25,
|
||
"tasks": 50,
|
||
"chains": 5,
|
||
"submissions": {
|
||
"total": 342,
|
||
"accepted": 150,
|
||
"rejected": 80,
|
||
"pending": 12,
|
||
"inProgress": 100
|
||
},
|
||
"averageCheckTimeMs": 2500,
|
||
"queue": {
|
||
"pending": 5,
|
||
"processing": 2,
|
||
"completed": 335
|
||
},
|
||
"tasksTable": [
|
||
{
|
||
"taskId": "507f1f77bcf86cd799439012",
|
||
"title": "Переменные и типы данных",
|
||
"totalAttempts": 45,
|
||
"uniqueUsers": 20,
|
||
"acceptedCount": 18,
|
||
"successRate": 90,
|
||
"averageAttemptsToSuccess": 2.1
|
||
}
|
||
],
|
||
"activeParticipants": [
|
||
{
|
||
"userId": "507f1f77bcf86cd799439013",
|
||
"nickname": "student_ivan",
|
||
"workplaceNumber": "PC-15", // ✨ Новое поле
|
||
"totalSubmissions": 25,
|
||
"completedTasks": 12,
|
||
"chainProgress": [
|
||
{
|
||
"chainId": "507f1f77bcf86cd799439011",
|
||
"chainName": "Основы Python",
|
||
"totalTasks": 10,
|
||
"completedTasks": 8,
|
||
"progressPercent": 80
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"chainsDetailed": [
|
||
{
|
||
"chainId": "507f1f77bcf86cd799439011",
|
||
"name": "Основы Python",
|
||
"totalTasks": 10,
|
||
"tasks": [
|
||
{
|
||
"taskId": "507f1f77bcf86cd799439012",
|
||
"title": "Переменные и типы данных",
|
||
"description": "Изучите основные типы данных..."
|
||
}
|
||
],
|
||
"participantProgress": [
|
||
{
|
||
"userId": "507f1f77bcf86cd799439013",
|
||
"nickname": "student_ivan",
|
||
"workplaceNumber": "PC-15", // ✨ Новое поле
|
||
"taskProgress": [
|
||
{
|
||
"taskId": "507f1f77bcf86cd799439012",
|
||
"taskTitle": "Переменные и типы данных",
|
||
"status": "completed"
|
||
}
|
||
],
|
||
"completedCount": 8,
|
||
"progressPercent": 80
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Пример использования
|
||
|
||
```javascript
|
||
const response = await fetch('/challenge/stats/v2');
|
||
const data = await response.json();
|
||
|
||
// Создать карту класса с прогрессом
|
||
const classMap = data.result.activeParticipants.map(participant => ({
|
||
workplace: participant.workplaceNumber || 'Не указано',
|
||
student: participant.nickname,
|
||
progress: participant.completedTasks,
|
||
chains: participant.chainProgress
|
||
}));
|
||
|
||
// Отсортировать по номеру места
|
||
classMap.sort((a, b) => {
|
||
const numA = parseInt(a.workplace.replace(/\D/g, '')) || 0;
|
||
const numB = parseInt(b.workplace.replace(/\D/g, '')) || 0;
|
||
return numA - numB;
|
||
});
|
||
|
||
// Визуализация карты класса
|
||
classMap.forEach(item => {
|
||
console.log(`[${item.workplace}] ${item.student}: ${item.progress} заданий`);
|
||
});
|
||
// Вывод:
|
||
// [PC-15] student_ivan: 12 заданий
|
||
// [PC-20] student_maria: 15 заданий
|
||
```
|
||
|
||
---
|
||
|
||
## Примеры интеграции на фронтенде
|
||
|
||
### Компонент авторизации (React)
|
||
|
||
```jsx
|
||
import { useState } from 'react';
|
||
|
||
function LoginForm() {
|
||
const [nickname, setNickname] = useState('');
|
||
const [workplaceNumber, setWorkplaceNumber] = useState('');
|
||
|
||
const handleLogin = async (e) => {
|
||
e.preventDefault();
|
||
|
||
const response = await fetch('/challenge/auth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
nickname,
|
||
workplaceNumber: workplaceNumber || undefined
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.result.ok) {
|
||
localStorage.setItem('userId', data.result.userId);
|
||
localStorage.setItem('nickname', nickname);
|
||
localStorage.setItem('workplaceNumber', workplaceNumber);
|
||
// Перенаправление на главную страницу
|
||
}
|
||
};
|
||
|
||
return (
|
||
<form onSubmit={handleLogin}>
|
||
<input
|
||
type="text"
|
||
placeholder="Никнейм"
|
||
value={nickname}
|
||
onChange={(e) => setNickname(e.target.value)}
|
||
required
|
||
/>
|
||
<input
|
||
type="text"
|
||
placeholder="Номер компьютера (опционально)"
|
||
value={workplaceNumber}
|
||
onChange={(e) => setWorkplaceNumber(e.target.value)}
|
||
/>
|
||
<button type="submit">Войти</button>
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Отображение карты класса (React)
|
||
|
||
```jsx
|
||
function ClassroomMap({ chainId }) {
|
||
const [participants, setParticipants] = useState([]);
|
||
|
||
useEffect(() => {
|
||
const fetchData = async () => {
|
||
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||
}
|
||
});
|
||
const data = await response.json();
|
||
setParticipants(data.result.participants);
|
||
};
|
||
|
||
fetchData();
|
||
}, [chainId]);
|
||
|
||
return (
|
||
<div className="classroom-map">
|
||
<h2>Карта класса</h2>
|
||
<div className="grid">
|
||
{participants.map(participant => (
|
||
<div
|
||
key={participant.userId}
|
||
className="student-card"
|
||
>
|
||
<div className="workplace-badge">
|
||
{participant.workplaceNumber || 'N/A'}
|
||
</div>
|
||
<div className="student-info">
|
||
<strong>{participant.nickname}</strong>
|
||
<div className="progress-bar">
|
||
<div
|
||
className="progress-fill"
|
||
style={{ width: `${participant.progressPercent}%` }}
|
||
/>
|
||
</div>
|
||
<small>
|
||
{participant.completedTasks} / {participant.totalTasks} заданий
|
||
</small>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### TypeScript интерфейсы
|
||
|
||
```typescript
|
||
// Типы для работы с новым API
|
||
|
||
interface AuthRequest {
|
||
nickname: string;
|
||
workplaceNumber?: string;
|
||
}
|
||
|
||
interface AuthResponse {
|
||
ok: boolean;
|
||
userId: string;
|
||
}
|
||
|
||
interface UserInfo {
|
||
id: string;
|
||
nickname: string;
|
||
workplaceNumber?: string; // ✨ Новое поле
|
||
}
|
||
|
||
interface Participant {
|
||
userId: string;
|
||
nickname: string;
|
||
workplaceNumber?: string; // ✨ Новое поле
|
||
completedTasks: number;
|
||
totalTasks: number;
|
||
progressPercent: number;
|
||
}
|
||
|
||
interface Submission {
|
||
id: string;
|
||
user: UserInfo; // Содержит workplaceNumber
|
||
task: {
|
||
id: string;
|
||
title: string;
|
||
};
|
||
status: 'pending' | 'in_progress' | 'accepted' | 'needs_revision';
|
||
attemptNumber: number;
|
||
submittedAt: string;
|
||
checkedAt?: string;
|
||
feedback?: string;
|
||
}
|
||
|
||
interface ChainSubmissionsResponse {
|
||
chain: {
|
||
id: string;
|
||
name: string;
|
||
tasks: Array<{ id: string; title: string }>;
|
||
};
|
||
participants: Participant[];
|
||
submissions: Submission[];
|
||
pagination: {
|
||
total: number;
|
||
limit: number;
|
||
offset: number;
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Миграция существующего кода
|
||
|
||
### До (без workplaceNumber)
|
||
|
||
```javascript
|
||
// Старый код авторизации
|
||
await fetch('/challenge/auth', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ nickname: 'student_ivan' })
|
||
});
|
||
|
||
// Старое отображение участников
|
||
participants.forEach(p => {
|
||
console.log(`${p.nickname}: ${p.progressPercent}%`);
|
||
});
|
||
```
|
||
|
||
### После (с workplaceNumber)
|
||
|
||
```javascript
|
||
// Новый код авторизации с местом
|
||
await fetch('/challenge/auth', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
nickname: 'student_ivan',
|
||
workplaceNumber: 'PC-15' // ✨ Добавлено
|
||
})
|
||
});
|
||
|
||
// Новое отображение участников
|
||
participants.forEach(p => {
|
||
const workplace = p.workplaceNumber ? `[${p.workplaceNumber}] ` : '';
|
||
console.log(`${workplace}${p.nickname}: ${p.progressPercent}%`);
|
||
// Вывод: "[PC-15] student_ivan: 50%"
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Обратная совместимость
|
||
|
||
✅ **Все изменения обратно совместимы:**
|
||
|
||
- Поле `workplaceNumber` опционально при авторизации
|
||
- Старый код без `workplaceNumber` продолжит работать
|
||
- Если `workplaceNumber` не указан, в ответах будет `undefined`
|
||
- Поиск пользователей по-прежнему работает только по `nickname`
|
||
|
||
---
|
||
|
||
## Рекомендации
|
||
|
||
1. **При авторизации**: Всегда передавайте `workplaceNumber`, если он известен (например, определяйте автоматически по IP или позволяйте ученику выбрать)
|
||
|
||
2. **В UI**: Отображайте номер места рядом с именем ученика для удобства преподавателя
|
||
|
||
3. **Сортировка**: При отображении списка учеников сортируйте по `workplaceNumber` для соответствия физическому расположению
|
||
|
||
4. **Валидация**: Проверяйте формат `workplaceNumber` на фронте (например, "PC-01", "Место 15")
|
||
|
||
5. **Обновление**: Если ученик пересел, просто авторизуйтесь с новым `workplaceNumber` - значение автоматически обновится
|
||
|
||
---
|
||
|
||
## Вопросы и поддержка
|
||
|
||
При возникновении вопросов обращайтесь к бэкенд-команде или создавайте issue в репозитории проекта.
|