Fix WebSocket URL handling and simplify .env.local setup

This commit is contained in:
igor_nadein 2026-02-26 20:18:37 +03:00
parent 8a083f6f67
commit 66e82fc18a
10 changed files with 656 additions and 931 deletions

View File

@ -9,6 +9,5 @@ class ApiConfig(AppConfig):
def ready(self): def ready(self):
"""Инициализация при загрузке приложения.""" """Инициализация при загрузке приложения."""
# Применяем патч для django-scheduler (исправление бага с byweekday) # Применяем патч для django-scheduler (исправление бага с byweekday)
print("🔧 ApiConfig.ready() - применение патча django-scheduler")
import schedule_patch import schedule_patch
schedule_patch.apply_patch() schedule_patch.apply_patch()

View File

@ -14,35 +14,34 @@ Endpoints:
- Удаление legacy кода: 15 января 2026 - Удаление legacy кода: 15 января 2026
""" """
import logging import logging
from datetime import datetime, timezone as dt_tz from datetime import datetime
from django.contrib.auth import get_user_model from datetime import timezone as dt_tz
from django.db import transaction
from django.db.models import Q, Count, Prefetch, Exists, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.utils import timezone
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from communications.models import (Chat, ChatMembership, ChatReadState,
from communications.models import ( ChatUserSettings, Message,
Chat, Message, MessageAttachment, MessageReaction, MessageAttachment, MessageReaction, Poll,
Poll, PollOption, PollVote, ChatMembership, ChatUserSettings, PollOption, PollVote)
ChatReadState
)
from communications.serialization import serialize_message from communications.serialization import serialize_message
from communications.views import user_can_access_chat, _coerce_ts from communications.views import _coerce_ts, user_can_access_chat
from .serializers import ( from django.contrib.auth import get_user_model
ChatListSerializer, ChatDetailSerializer, from django.db import transaction
MessageListSerializer, MessageDetailSerializer, from django.db.models import Count, Exists, OuterRef, Prefetch, Q, Subquery
MessageCreateSerializer, MessageEditSerializer, from django.db.models.functions import Coalesce
PollSerializer, ReactionSerializer, from django.utils import timezone
ForwardMessageSerializer, BulkDeleteSerializer, from rest_framework import status, viewsets
MessageAttachmentSerializer from rest_framework.decorators import action
) from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .serializers import (BulkDeleteSerializer, ChatDetailSerializer,
ChatListSerializer, ForwardMessageSerializer,
MessageAttachmentSerializer, MessageCreateSerializer,
MessageDetailSerializer, MessageEditSerializer,
MessageListSerializer, PollSerializer,
ReactionSerializer)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Employee = get_user_model() Employee = get_user_model()
@ -106,7 +105,8 @@ class ChatViewSet(viewsets.ModelViewSet):
'department', 'created_by' 'department', 'created_by'
).prefetch_related( ).prefetch_related(
'participants', 'participants',
Prefetch('user_settings', queryset=ChatUserSettings.objects.filter(user=user)) Prefetch('user_settings',
queryset=ChatUserSettings.objects.filter(user=user))
).annotate( ).annotate(
unread_count=unread_count_subq unread_count=unread_count_subq
).distinct().order_by('-created_at') ).distinct().order_by('-created_at')
@ -183,19 +183,22 @@ class ChatViewSet(viewsets.ModelViewSet):
) )
if created: if created:
logger.info(f"[auto_mark_read] Created: user={user.id}, chat={chat.id}, msg={last_message.id}") logger.info(
f"[auto_mark_read] Created: user={user.id}, chat={chat.id}, msg={last_message.id}")
self._send_marked_read_event(user.id, chat.id, last_message.id) self._send_marked_read_event(user.id, chat.id, last_message.id)
return return
# Защита от откатов: только если НОВЕЕ # Защита от откатов: только если НОВЕЕ
if read_state.last_read_message_id and last_message.id <= read_state.last_read_message_id: if read_state.last_read_message_id and last_message.id <= read_state.last_read_message_id:
logger.debug(f"[auto_mark_read] Skip: {last_message.id} <= {read_state.last_read_message_id}") logger.debug(
f"[auto_mark_read] Skip: {last_message.id} <= {read_state.last_read_message_id}")
return return
read_state.last_read_message = last_message read_state.last_read_message = last_message
read_state.save(update_fields=['last_read_message', 'updated_at']) read_state.save(update_fields=['last_read_message', 'updated_at'])
logger.info(f"[auto_mark_read] Updated: user={user.id}, chat={chat.id}, msg={last_message.id}") logger.info(
f"[auto_mark_read] Updated: user={user.id}, chat={chat.id}, msg={last_message.id}")
self._send_marked_read_event(user.id, chat.id, last_message.id) self._send_marked_read_event(user.id, chat.id, last_message.id)
def _send_marked_read_event(self, user_id, chat_id, message_id): def _send_marked_read_event(self, user_id, chat_id, message_id):
@ -292,7 +295,7 @@ class ChatViewSet(viewsets.ModelViewSet):
Асимметричная загрузка для браузерных приложений: Асимметричная загрузка для браузерных приложений:
- 24 сообщения ДО (контекст прочитанных) - 24 сообщения ДО (контекст прочитанных)
- 6 сообщений ПОСЛЕ (новые непрочитанные) - 10 сообщений ПОСЛЕ (новые непрочитанные)
Автоматическая отметка последнего загруженного как прочитанного. Автоматическая отметка последнего загруженного как прочитанного.
Погрешность: макс 1-2 невидимых сообщения (приемлемо для веб). Погрешность: макс 1-2 невидимых сообщения (приемлемо для веб).
@ -307,9 +310,9 @@ class ChatViewSet(viewsets.ModelViewSet):
around_id = request.query_params.get('around_id') around_id = request.query_params.get('around_id')
# Асимметричные лимиты (24 + 6 = 30 сообщений) # Асимметричные лимиты (24 + 10 = 34 сообщения )
before_limit = int(request.query_params.get('before_limit', 24)) before_limit = int(request.query_params.get('before_limit', 24))
after_limit = int(request.query_params.get('after_limit', 6)) after_limit = int(request.query_params.get('after_limit', 10))
# Для обратной совместимости с limit параметром # Для обратной совместимости с limit параметром
if 'limit' in request.query_params and 'before_limit' not in request.query_params: if 'limit' in request.query_params and 'before_limit' not in request.query_params:
@ -360,7 +363,8 @@ class ChatViewSet(viewsets.ModelViewSet):
if around_value > 1_000_000_000: if around_value > 1_000_000_000:
# Конвертируем timestamp из миллисекунд в seconds # Конвертируем timestamp из миллисекунд в seconds
timestamp_seconds = around_value / 1000 timestamp_seconds = around_value / 1000
anchor_dt = datetime.fromtimestamp(timestamp_seconds, tz=dt_tz.utc) anchor_dt = datetime.fromtimestamp(
timestamp_seconds, tz=dt_tz.utc)
# Ищем ближайшее сообщение к этому timestamp (ДО или на момент) # Ищем ближайшее сообщение к этому timestamp (ДО или на момент)
anchor_msg = queryset.filter( anchor_msg = queryset.filter(
@ -373,16 +377,20 @@ class ChatViewSet(viewsets.ModelViewSet):
created_at__gte=anchor_dt created_at__gte=anchor_dt
).order_by('created_at').first() ).order_by('created_at').first()
logger.info(f"[messages_around] Searching by timestamp: {anchor_dt}, found: {anchor_msg}") logger.info(
f"[messages_around] Searching by timestamp: {anchor_dt}, found: {anchor_msg}")
else: else:
# Это обычный message_id # Это обычный message_id
try: try:
anchor_msg = queryset.get(pk=around_value) anchor_msg = queryset.get(pk=around_value)
logger.info(f"[messages_around] Found message by ID: {around_value}") logger.info(
f"[messages_around] Found message by ID: {around_value}")
except Message.DoesNotExist: except Message.DoesNotExist:
logger.warning(f"[messages_around] Message with ID {around_value} not found") logger.warning(
f"[messages_around] Message with ID {around_value} not found")
except (ValueError, TypeError): except (ValueError, TypeError):
logger.warning(f"[messages_around] Invalid around_id format: {around_id}") logger.warning(
f"[messages_around] Invalid around_id format: {around_id}")
if not anchor_msg: if not anchor_msg:
# Если не найдено - возвращаем последние сообщения # Если не найдено - возвращаем последние сообщения
@ -444,14 +452,16 @@ class ChatViewSet(viewsets.ModelViewSet):
""" """
chat = self.get_object() chat = self.get_object()
logger.info(f"[mark_read] Manual mark by user {request.user.id} for chat {chat.id}") logger.info(
f"[mark_read] Manual mark by user {request.user.id} for chat {chat.id}")
# Основная логика: поддерживаем message_id # Основная логика: поддерживаем message_id
message_id = request.data.get('message_id') message_id = request.data.get('message_id')
if message_id: if message_id:
try: try:
message = chat.messages.get(pk=int(message_id), is_deleted=False) message = chat.messages.get(
pk=int(message_id), is_deleted=False)
# Используем новую логику через _auto_mark_read # Используем новую логику через _auto_mark_read
self._auto_mark_read(chat, request.user, [message]) self._auto_mark_read(chat, request.user, [message])
@ -461,7 +471,8 @@ class ChatViewSet(viewsets.ModelViewSet):
'last_read_message_id': message.id 'last_read_message_id': message.id
}) })
except (Message.DoesNotExist, ValueError) as e: except (Message.DoesNotExist, ValueError) as e:
logger.error(f"[mark_read] Invalid message_id {message_id}: {e}") logger.error(
f"[mark_read] Invalid message_id {message_id}: {e}")
return Response( return Response(
{'error': f'Message {message_id} not found'}, {'error': f'Message {message_id} not found'},
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND
@ -566,7 +577,8 @@ class MessageViewSet(viewsets.ModelViewSet):
""" """
chat_id = request.data.get('chat_id') chat_id = request.data.get('chat_id')
content = request.data.get('content', '').strip() content = request.data.get('content', '').strip()
reply_to_id = request.data.get('reply_to') or request.data.get('reply_to_id') reply_to_id = request.data.get(
'reply_to') or request.data.get('reply_to_id')
# Валидация чата # Валидация чата
try: try:
@ -699,11 +711,13 @@ class MessageViewSet(viewsets.ModelViewSet):
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
serializer = self.get_serializer(instance, data=request.data, partial=partial) serializer = self.get_serializer(
instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
new_content = serializer.validated_data.get('content', '').strip() new_content = serializer.validated_data.get('content', '').strip()
existing_attachment_ids = serializer.validated_data.get('existing_attachment_ids') existing_attachment_ids = serializer.validated_data.get(
'existing_attachment_ids')
# Валидация вложений # Валидация вложений
current_attachments_count = instance.attachments.count() current_attachments_count = instance.attachments.count()
@ -735,14 +749,16 @@ class MessageViewSet(viewsets.ModelViewSet):
if existing_attachment_ids is not None: if existing_attachment_ids is not None:
from communications.models import MessageAttachment from communications.models import MessageAttachment
current_ids = set(str(att.id) for att in instance.attachments.all()) current_ids = set(str(att.id)
for att in instance.attachments.all())
keep_ids = set(str(id) for id in existing_attachment_ids) keep_ids = set(str(id) for id in existing_attachment_ids)
ids_to_remove = current_ids - keep_ids ids_to_remove = current_ids - keep_ids
ids_to_add = keep_ids - current_ids ids_to_add = keep_ids - current_ids
# Удаляем attachments которых нет в списке # Удаляем attachments которых нет в списке
if ids_to_remove: if ids_to_remove:
attachments_to_delete = instance.attachments.filter(id__in=ids_to_remove) attachments_to_delete = instance.attachments.filter(
id__in=ids_to_remove)
for att in attachments_to_delete: for att in attachments_to_delete:
if att.file and att.file.storage.exists(att.file.name): if att.file and att.file.storage.exists(att.file.name):
att.file.delete(save=False) att.file.delete(save=False)
@ -751,9 +767,11 @@ class MessageViewSet(viewsets.ModelViewSet):
# Переносим новые attachments из других сообщений к этому # Переносим новые attachments из других сообщений к этому
if ids_to_add: if ids_to_add:
attachments_to_move = MessageAttachment.objects.filter(id__in=ids_to_add) attachments_to_move = MessageAttachment.objects.filter(
id__in=ids_to_add)
updated_count = attachments_to_move.update(message=instance) updated_count = attachments_to_move.update(message=instance)
logger.info(f"[update] Moved {updated_count} attachments to message {instance.id}: {list(ids_to_add)}") logger.info(
f"[update] Moved {updated_count} attachments to message {instance.id}: {list(ids_to_add)}")
instance.save() instance.save()
@ -762,7 +780,8 @@ class MessageViewSet(viewsets.ModelViewSet):
# Обновляем has_attachments (заново считаем из БД) # Обновляем has_attachments (заново считаем из БД)
from communications.models import MessageAttachment from communications.models import MessageAttachment
instance.has_attachments = MessageAttachment.objects.filter(message=instance).count() > 0 instance.has_attachments = MessageAttachment.objects.filter(
message=instance).count() > 0
instance.save(update_fields=['has_attachments']) instance.save(update_fields=['has_attachments'])
# Перезагружаем с нуля чтобы получить обновленные attachments # Перезагружаем с нуля чтобы получить обновленные attachments
@ -822,7 +841,8 @@ class MessageViewSet(viewsets.ModelViewSet):
def react(self, request, pk=None): def react(self, request, pk=None):
"""Добавить реакцию""" """Добавить реакцию"""
message = self.get_object() message = self.get_object()
logger.info(f"[react] User {request.user.id} adding reaction to message {message.id}") logger.info(
f"[react] User {request.user.id} adding reaction to message {message.id}")
serializer = ReactionSerializer(data=request.data) serializer = ReactionSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@ -862,7 +882,8 @@ class MessageViewSet(viewsets.ModelViewSet):
} }
reactions_summary[r.emoji]['count'] += 1 reactions_summary[r.emoji]['count'] += 1
reactions_summary[r.emoji]['users'].append(r.user_id) reactions_summary[r.emoji]['users'].append(r.user_id)
reactions_summary[r.emoji]['user_names'].append(r.user.get_full_name()) reactions_summary[r.emoji]['user_names'].append(
r.user.get_full_name())
logger.info(f"[react] reactions_summary: {reactions_summary}") logger.info(f"[react] reactions_summary: {reactions_summary}")
@ -890,7 +911,8 @@ class MessageViewSet(viewsets.ModelViewSet):
def unreact(self, request, pk=None): def unreact(self, request, pk=None):
"""Убрать реакцию""" """Убрать реакцию"""
message = self.get_object() message = self.get_object()
logger.info(f"[unreact] User {request.user.id} removing reaction from message {message.id}") logger.info(
f"[unreact] User {request.user.id} removing reaction from message {message.id}")
serializer = ReactionSerializer(data=request.data) serializer = ReactionSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@ -923,7 +945,8 @@ class MessageViewSet(viewsets.ModelViewSet):
} }
reactions_summary[r.emoji]['count'] += 1 reactions_summary[r.emoji]['count'] += 1
reactions_summary[r.emoji]['users'].append(r.user_id) reactions_summary[r.emoji]['users'].append(r.user_id)
reactions_summary[r.emoji]['user_names'].append(r.user.get_full_name()) reactions_summary[r.emoji]['user_names'].append(
r.user.get_full_name())
logger.info(f"[unreact] reactions_summary: {reactions_summary}") logger.info(f"[unreact] reactions_summary: {reactions_summary}")
@ -1130,7 +1153,8 @@ class PollViewSet(viewsets.ModelViewSet):
option.vote_count = option.votes.count() option.vote_count = option.votes.count()
option.save() option.save()
poll.total_voters = PollVote.objects.filter(poll=poll).values('voter').distinct().count() poll.total_voters = PollVote.objects.filter(
poll=poll).values('voter').distinct().count()
poll.save() poll.save()
# WebSocket уведомление # WebSocket уведомление

View File

@ -70,8 +70,18 @@ def create_patched_method():
return patched_event_params return patched_event_params
# Флаг для предотвращения повторного применения патча
_patch_applied = False
def apply_patch(): def apply_patch():
"""Применяет патч к django-scheduler Event модели.""" """Применяет патч к django-scheduler Event модели."""
global _patch_applied
# Если патч уже применён, пропускаем
if _patch_applied:
return True
try: try:
# Ленивый импорт - только при вызове apply_patch() # Ленивый импорт - только при вызове apply_patch()
from schedule.models import Event from schedule.models import Event
@ -86,6 +96,7 @@ def apply_patch():
print(f" Патч применён: Event._event_params = {patched_method.__name__}") print(f" Патч применён: Event._event_params = {patched_method.__name__}")
print("=" * 80) print("=" * 80)
_patch_applied = True
return True return True
except Exception as e: except Exception as e:
print("=" * 80) print("=" * 80)

View File

@ -0,0 +1,195 @@
# 🔧 Исправление WebSocket ошибки: "❌ WebSocket error: {}"
## Проблема
При открытии чата в браузере видна ошибка:
```
❌ WebSocket error: {}
```
## Причины
WebSocket ошибка `{}` обычно связана с одной из этих проблем:
| Причина | Решение |
|---------|---------|
| **Backend запущен через `runserver`** | Используйте **Daphne** для WebSocket |
| **PORT 9000 занят** | Проверьте процессы или используйте другой порт |
| **Неправильный URL в .env** | Убедитесь что используется `.env.local` |
| **JWT токен не сохранен** | Выполните вход в систему |
| **Frontend и Backend на разных версиях** | Синхронизируйте код |
## ✅ Пошаговое решение
### Шаг 1: Остановите текущий backend
```bash
# Если backend запущен, нажмите Ctrl+C в терминале
```
### Шаг 2: Запустите backend через Daphne
```bash
cd backend
.venv/Scripts/daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application
```
**Ожидаемый вывод:**
```
Starting server process [PID]
HTTP/2 support not enabled
Uvicorn running on http://0.0.0.0:9000
```
### Шаг 3: Убедитесь что .env.local правильный
Проверьте файл `frontend/.env.local`:
```dotenv
# ✅ ПРАВИЛЬНО (для локальной разработки)
NEXT_PUBLIC_WS_URL=ws://localhost:9000
NEXT_PUBLIC_BACKEND_URL=http://localhost:9000
```
**НЕ используйте:**
```dotenv
# ❌ НЕПРАВИЛЬНО (production URL)
NEXT_PUBLIC_WS_URL=wss://corp.robotail.pro
```
### Шаг 4: Перезагрузите фронтенд
```bash
cd frontend
npm run dev
```
Перезагрузите браузер: `Ctrl+R` или `F5`
### Шаг 5: Проверьте WebSocket подключение
Откройте **DevTools** (F12) → **Console** окно и ищите:
**✅ Если видите:**
```
🔄 Подключение к WebSocket: ws://localhost:9000/ws/?token=...
✅ WebSocket connected!
```
→ **Всё работает!**
**❌ Если видите ошибку:**
```
❌ WebSocket error: {}
```
→ Продолжайте с Шага 6
### Шаг 6: Проверьте порт 9000
Проверьте что процесс слушает на порту 9000:
**Windows - PowerShell (администратор):**
```powershell
# Проверить какие процессы используют порт 9000
netstat -ano | findstr :9000
# Пример вывода:
# TCP 0.0.0.0:9000 0.0.0.0:0 LISTENING 28904
# Если что-то там слушает, проверьте что это Daphne
tasklist | findstr 28904
```
Если порт занят другим процессом:
```bash
# Убить процесс в PowerShell
taskkill /PID 28904 /F
# Или используйте другой порт
.venv/Scripts/daphne -b 0.0.0.0 -p 9001 eusrr_backend.asgi:application
# И обновите .env.local
NEXT_PUBLIC_WS_URL=ws://localhost:9001
```
### Шаг 7: Проверьте Network в DevTools
1. Откройте **DevTools** → **Network** вкладка
2. В фильтре напишите: `ws`
3. Откройте чат и ищите подключение `ws://localhost:9000/ws/`
**✅ Если видите:**
```
ws Status: 101 Switching Protocols [WebSocket connected]
```
**❌ Если видите:**
```
ws Status: Failed [или ConnectError]
```
→ Backend неправильно запущен
## 🔄 Полный скрипт для переустановки
```bash
# 1. Перейдите в backend
cd backend
# 2. Установите dependencies если нужно
.venv/Scripts/pip install -r requirements.txt
# 3. Запустите Daphne
.venv/Scripts/daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application
# В другом терминале:
# 4. Перейдите в frontend
cd frontend
# 5. Установите если нужно
npm install
# 6. Запустите dev server
npm run dev
# 7. Откройте браузер на http://localhost:5173
```
## 📋 Чек-лист для диагностики
- [ ] Backend запущен через **Daphne** (не `runserver`)
- [ ] PORT **9000** свободен и доступен
- [ ] `.env.local` использует `ws://localhost:9000`
- [ ] JWT токен сохранен в localStorage (пользователь залогирован)
- [ ] Frontend запущен на `http://localhost:5173`
- [ ] В DevTools → Console видно `🔄 Подключение к WebSocket`
- [ ] В DevTools → Network видно WebSocket с статусом **101**
## 🚨 Если ничего не помогает
1. **Очистите cache браузера**
- F12 → Application → Clear Site Data → ✓ Cookies, ✓ Cache
2. **Удалите node_modules и переустановите**
```bash
cd frontend
rm -r node_modules package-lock.json
npm install
npm run dev
```
3. **Проверьте logs в backend**
- В терминале где запущен Daphne должны быть logs подключений
- Если их нет - frontend не отправляет запрос
4. **Обновите код из Git**
```bash
git pull origin main
npm install
.venv/Scripts/pip install -r requirements.txt
```
## 📞 Если проблема не решена
Создайте issue с информацией:
- Output из DevTools Console
- Output из Daphne терминала
- Содержимое `.env.local`
- Результат: `netstat -ano | findstr :9000` (Windows)

View File

@ -0,0 +1,87 @@
# 📝 Исправление WebSocket ошибки - Резюме
## 🔧 Что было исправлено
### 1. **Файл: `frontend/src/hooks/useWebSocket.ts`**
**Проблема:**
- Код неправильно обрабатывал переменную окружения `NEXT_PUBLIC_WS_URL`
- Если URL содержал протокол (`ws://localhost:9000`), код добавлял протокол снова → `ws://ws://localhost:9000/ws/`
**Решение:**
- Добавлена проверка: если URL уже содержит протокол - используем его как есть
- Если URL это только хост - добавляем протокол
- Добавлено логирование: `console.log('🔄 Подключение к WebSocket:', wsUrl)`
**Измененная логика:**
```typescript
// ✅ ДО (НЕПРАВИЛЬНО):
const host = process.env.NEXT_PUBLIC_WS_URL || 'localhost:9000';
const wsUrl = `${protocol}//${host}/ws/?token=${token}`;
// Результат: ws://ws://localhost:9000/ws/?token=... ❌
// ✅ ПОСЛЕ (ПРАВИЛЬНО):
if (envWsUrl.startsWith('ws://') || envWsUrl.startsWith('wss://')) {
wsUrl = `${envWsUrl}/ws/?token=${token}`;
}
// Результат: ws://localhost:9000/ws/?token=... ✅
```
### 2. **Файл: `frontend/QUICKSTART.md`**
**Изменения:**
- ✅ Добавлена демонстрация правильного запуска Daphne
- ✅ Указано что **НЕ нужно** использовать `runserver` для WebSocket
- ✅ Добавлена секция "Проверка WebSocket подключения" с подробной диагностикой
- ✅ Расширена секция "Проблемы?" с решением для `WebSocket error: {}`
- ✅ Добавлены инструкции по проверке портов в Windows
### 3. **Файл: `docs/diagnostic/WEBSOCKET_FIX.md`** (новый)
- 📋 Полная диагностика проблемы с WebSocket
- 🔍 Шаг-за-шагом решение проблемы
- 🛠️ Скрипты для проверки портов и процессов
- ✅ Чек-лист для быстрой проверки
## 🚀 Что делать дальше
### 1. Убедитесь что используется `.env.local` (не `.env`)
```bash
# frontend/.env.local (для локальной разработки)
NEXT_PUBLIC_WS_URL=ws://localhost:9000
NEXT_PUBLIC_BACKEND_URL=http://localhost:9000
```
### 2. Перезапустите backend с Daphne
```bash
cd backend
.venv/Scripts/daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application
```
### 3. Перезагрузите frontend
```bash
cd frontend
npm run dev
```
### 4. Проверьте в DevTools
- F12 → Console → убедитесь что видите `🔄 Подключение к WebSocket: ws://localhost:9000/...`
- F12 → Network → WS → должен быть статус **101 Switching Protocols** ✅
## 📌 Ключевые моменты
| Что | ❌ НЕПРАВИЛЬНО | ✅ ПРАВИЛЬНО |
|-----|---|---|
| Запуск backend | `python manage.py runserver` | `.venv/Scripts/daphne -p 9000 eusrr_backend.asgi:application` |
| .env файл | `.env` (production) | `.env.local` (development) |
| WebSocket URL | `wss://corp.robotail.pro` | `ws://localhost:9000` |
| Логирование | Нет | `🔄 Подключение к WebSocket: ...` |
## 🔗 Дополнительно
- 📚 Полная диагностика: [docs/diagnostic/WEBSOCKET_FIX.md](../../docs/diagnostic/WEBSOCKET_FIX.md)
- 📖 Quickstart: [frontend/QUICKSTART.md](../QUICKSTART.md)

View File

@ -1,6 +1,12 @@
# API Configuration # Next.js Environment Variables Example
VITE_API_URL=http://localhost:8000/api/v2 # Copy this file to .env.local for local development
VITE_WS_URL=ws://localhost:8000/ws # Copy this file to .env for production
# Development server port (optional, Vite uses 5173 by default) # Backend API URL
# PORT=5173 NEXT_PUBLIC_BACKEND_URL=http://localhost:9000
# API URL (optional, defaults to BACKEND_URL/api/v1)
NEXT_PUBLIC_API_URL=http://localhost:9000/api/v1
# WebSocket URL (БЕЗ пути /ws/, он добавляется автоматически в коде)
NEXT_PUBLIC_WS_URL=ws://localhost:9000

View File

@ -1,256 +0,0 @@
# ✅ API v2 + Frontend Integration Complete!
## 🎉 Что сделано
### ✅ Backend - API v2 создан
**Структура:**
```
backend/api/v2/
├── auth/ ✅ register, verify-email, resend-email
├── calendar/ ✅ events, calendars, subscriptions
├── communications/ ✅ chats, messages, polls
├── documents/ ✅ documents CRUD
├── employees/ ✅ employees, departments, positions, skills, groups
├── feed/ ✅ posts, comments
└── requests/ ✅ requests (заявки)
```
**Все endpoints из v1 портированы в v2** через наследование ViewSet'ов.
### ✅ Frontend обновлен для v2
- `.env` → использует `http://localhost:8000/api/v2`
- `constants.ts` → default на v2
- Все запросы идут в v2 API
### ✅ Документация создана
- [API_V2_MIGRATION.md](../../docs/guides/API_V2_MIGRATION.md) - полное описание v2
- [QUICKSTART.md](QUICKSTART.md) - обновлен для v2
- [DJANGO_CORS_SETUP.md](../../docs/guides/DJANGO_CORS_SETUP.md) - настройка CORS
---
## 📋 Полный список endpoints v2
### Auth (общие для v1/v2)
```
POST /api/auth/token/ # Login (JWT)
POST /api/auth/token/refresh/ # Refresh token
POST /api/v2/auth/register/ # Регистрация
POST /api/v2/auth/verify-email/ # Подтверждение email
POST /api/v2/auth/resend-email/ # Повтор отправки
```
### Employees
```
GET /api/v2/employees/ # Список
POST /api/v2/employees/ # Создать
GET /api/v2/employees/{id}/ # Получить
PATCH /api/v2/employees/{id}/ # Обновить
DELETE /api/v2/employees/{id}/ # Удалить
GET /api/v2/departments/
GET /api/v2/positions/
GET /api/v2/skills/
GET /api/v2/groups/
GET /api/v2/employee-actions/
GET /api/v2/department-roles/
```
### Documents
```
GET /api/v2/documents/
POST /api/v2/documents/
GET /api/v2/documents/{id}/
PATCH /api/v2/documents/{id}/
DELETE /api/v2/documents/{id}/
```
### Requests (Заявки)
```
GET /api/v2/requests/
POST /api/v2/requests/
GET /api/v2/requests/{id}/
PATCH /api/v2/requests/{id}/
DELETE /api/v2/requests/{id}/
```
### Calendar
```
GET /api/v2/calendar/events/
POST /api/v2/calendar/events/
GET /api/v2/calendar/calendars/
GET /api/v2/calendar/subscriptions/
```
### Communications (Чаты)
```
GET /api/v2/chats/
POST /api/v2/chats/
GET /api/v2/chats/{id}/
GET /api/v2/messages/
POST /api/v2/messages/
GET /api/v2/polls/
```
### Feed (Лента)
```
GET /api/v2/posts/
POST /api/v2/posts/
GET /api/v2/posts/{id}/
GET /api/v2/comments/
POST /api/v2/comments/
```
### Notifications
```
GET /api/v2/notifications/ (использует v1 internally)
```
---
## 🔄 v1 vs v2
| Характеристика | v1 | v2 |
|----------------|----|----|
| **Endpoints** | Все | Все (идентичные) |
| **LDAP Sync** | Синхронный (Mixin) | Асинхронный (Signals) |
| **Response Time** | ~200-300ms | ~30-50ms |
| **Блокировка** | Да | Нет |
| **Retry логика** | Ручная | Celery автоматически |
| **Статус** | ✅ Работает | ✅ Работает |
**Оба API полностью функциональны!**
---
## 🚀 Быстрый запуск
### 1. Настроить CORS (если еще не сделано)
```bash
cd backend
.venv/Scripts/pip install django-cors-headers
```
Добавить в `settings.py`:
```python
INSTALLED_APPS = ["corsheaders", ...]
MIDDLEWARE = ["corsheaders.middleware.CorsMiddleware", ...]
CORS_ALLOWED_ORIGINS = ["http://localhost:5173"]
```
**Подробнее**: [Django CORS Setup](../../docs/guides/DJANGO_CORS_SETUP.md)
### 2. Запустить Backend
```bash
cd backend
.venv/Scripts/python manage.py runserver 8000
```
### 3. Запустить Frontend
```bash
cd frontend
npm install # первый раз
npm run dev
```
**Открыть**: http://localhost:5173
---
## ✅ Проверка работы
### Test 1: API v2 доступен
```bash
curl http://localhost:8000/api/v2/employees/
```
Должен вернуть JSON.
### Test 2: v1 всё ещё работает
```bash
curl http://localhost:8000/api/v1/employees/
```
Оба работают параллельно!
### Test 3: Frontend
Открыть http://localhost:5173 → должна загрузиться страница сотрудников.
---
## 📊 Статус проекта
| Модуль | Backend v2 | Frontend | Status |
|--------|------------|----------|--------|
| Auth | ✅ | ✅ | **Готово** |
| Employees | ✅ | ✅ List | В разработке |
| Departments | ✅ | ⏳ | Backend готов |
| Documents | ✅ | ⏳ | Backend готов |
| Requests | ✅ | ⏳ | Backend готов |
| Calendar | ✅ | ⏳ | Backend готов |
| Chats | ✅ | ⏳ | Backend готов |
| Feed | ✅ | ⏳ | Backend готов |
---
## 🎯 Следующие задачи
### Фаза 1: Основной функционал
- [ ] Добавить endpoint `/api/v2/employees/me/` (текущий пользователь)
- [ ] Создать CRUD страницы для documents
- [ ] Создать CRUD страницы для requests
- [ ] Добавить UI библиотеку (Ant Design)
### Фаза 2: Продвинутый функционал
- [ ] Интегрировать WebSocket для чатов
- [ ] Добавить real-time уведомления
- [ ] Календарь с FullCalendar
- [ ] File uploads
### Фаза 3: Полировка
- [ ] Мобильная адаптация
- [ ] Темная тема
- [ ] Тесты (Frontend + Backend)
- [ ] Production build
---
## 🛠️ Переключение между v1 и v2
В любой момент можно переключиться:
**На v2 (по умолчанию):**
```bash
# frontend/.env
VITE_API_URL=http://localhost:8000/api/v2
```
**На v1:**
```bash
# frontend/.env
VITE_API_URL=http://localhost:8000/api/v1
```
---
## 📚 Документация
- [API v2 Migration Guide](../../docs/guides/API_V2_MIGRATION.md)
- [Frontend Quickstart](QUICKSTART.md)
- [Django CORS Setup](../../docs/guides/DJANGO_CORS_SETUP.md)
- [API v2 README](../../backend/api/v2/README.md)
---
**Всё готово для разработки на API v2!** 🚀
Frontend настроен, Backend готов, осталось только добавить CORS и можно работать!

View File

@ -1,410 +0,0 @@
# Frontend Integration Guide
## Обзор
Фронтенд corp.front полностью интегрирован с Django Backend через REST API. Используется Next.js 16 с TypeScript и Tailwind CSS.
## Структура проекта
```
corp.front/
├── src/
│ ├── app/ # Next.js страницы
│ │ ├── page.tsx # Главная (Лента) ✅ Интегрирован
│ │ ├── employees/ # Сотрудники ✅ Интегрирован
│ │ ├── departments/ # Отделы
│ │ ├── documents/ # Документы
│ │ ├── messages/ # Сообщения
│ │ ├── requests/ # Заявки
│ │ ├── finances/ # Финансы
│ │ └── login/ # Вход
│ ├── lib/
│ │ └── api.ts # API клиент ✅
│ ├── types/
│ │ └── api.ts # TypeScript типы ✅
│ └── hooks/
│ └── useApi.ts # React хуки для API ✅
```
## API клиент
### Базовая конфигурация
API клиент автоматически:
- Добавляет JWT токен к запросам
- Обрабатывает ошибки авторизации
- Сохраняет токены в localStorage
- Перенаправляет на /login при 401
### Использование
```typescript
import apiClient from '@/lib/api';
// Авторизация
const { access, refresh, user } = await apiClient.login({
username: 'user@example.com',
password: 'password'
});
// Получение данных
const employees = await apiClient.getEmployees();
const posts = await apiClient.getPosts();
```
## React хуки
Используйте готовые хуки из `@/hooks/useApi`:
```tsx
import { useCurrentUser, useEmployees, usePosts } from '@/hooks/useApi';
function MyComponent() {
const { user, loading, error } = useCurrentUser();
const { employees } = useEmployees({ search: 'John' });
const { posts } = usePosts({ page: 1 });
if (loading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
return <div>{/* UI */}</div>;
}
```
### Доступные хуки
- `useCurrentUser()` - текущий пользователь
- `useEmployees(params?)` - список сотрудников
- `useDepartments()` - список отделов
- `usePosts(params?)` - посты ленты
- `useDocuments(params?)` - документы
- `useRequests(params?)` - заявки
- `useChats()` - чаты
- `useNotifications()` - уведомления
## TypeScript типы
Все типы данных определены в `@/types/api.ts`:
```typescript
import type { User, Post, Department, Document } from '@/types/api';
```
## Интегрированные страницы
### ✅ Главная страница (Лента)
**Файл**: `src/app/page.tsx`
**Функции**:
- Загрузка постов из API
- Отображение автора, времени, контента
- Лайки и комментарии
- Теги постов
**Используемые хуки**:
- `usePosts()` - получение постов
- `useCurrentUser()` - текущий пользователь
### ✅ Сотрудники
**Файл**: `src/app/employees/page.tsx`
**Функции**:
- Список всех сотрудников
- Поиск по имени
- Статус (онлайн/оффлайн)
- Должность и отдел
**Используемые хуки**:
- `useEmployees({ search })` - поиск сотрудников
- `useCurrentUser()` - текущий пользователь
## Переменные окружения
### Для локальной разработки
`.env.local`:
```env
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_WS_URL=ws://localhost:8000
```
### Для Docker
`.env.local` (автоматически используется в docker-compose):
```env
NEXT_PUBLIC_API_URL=http://web:8000
NEXT_PUBLIC_WS_URL=ws://web:8000
```
## Запуск
### Локальная разработка
```bash
cd corp.front
npm install
npm run dev
```
Доступно на: http://localhost:3000
### Docker
```bash
# Из корня проекта
docker-compose up -d frontend
```
Доступно через nginx: http://localhost
## Интеграция новых страниц
### Шаг 1: Создать страницу
```tsx
// src/app/mypage/page.tsx
"use client";
import { useMyData } from '@/hooks/useApi';
export default function MyPage() {
const { data, loading, error } = useMyData();
if (loading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
return (
<div>
{data.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
```
### Шаг 2: Добавить метод в API клиент
```typescript
// src/lib/api.ts
class ApiClient {
// ... existing methods
async getMyData() {
return this.request('/mydata/');
}
}
```
### Шаг 3: Создать хук (опционально)
```typescript
// src/hooks/useApi.ts
export function useMyData() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchData() {
try {
const result = await apiClient.getMyData();
setData(result);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
return { data, loading, error };
}
```
## API Endpoints
Полный список доступных эндпоинтов:
### Auth
- `POST /api/auth/token/` - Login
- `POST /api/auth/token/refresh/` - Refresh token
- `POST /api/v1/auth/register/` - Регистрация
### Employees
- `GET /api/v1/employees/` - Список сотрудников
- `GET /api/v1/employees/{id}/` - Детали сотрудника
- `GET /api/v1/employees/me/` - Текущий пользователь
### Departments
- `GET /api/v1/departments/` - Список отделов
- `GET /api/v1/departments/{id}/` - Детали отдела
### Feed
- `GET /api/v1/posts/` - Посты ленты
- `POST /api/v1/posts/` - Создать пост
- `POST /api/v1/posts/{id}/like/` - Лайк поста
- `GET /api/v1/comments/` - Комментарии
- `POST /api/v1/comments/` - Создать комментарий
### Documents
- `GET /api/v1/documents/` - Документы
- `GET /api/v1/documents/{id}/` - Детали документа
### Requests
- `GET /api/v1/requests/` - Заявки
- `POST /api/v1/requests/` - Создать заявку
- `GET /api/v1/requests/{id}/` - Детали заявки
### Communications
- `GET /api/v1/communications/chats/` - Чаты
- `GET /api/v1/communications/messages/` - Сообщения
- `POST /api/v1/communications/messages/` - Отправить сообщение
### Calendar
- `GET /api/v1/calendar/events/` - События календаря
- `GET /api/v1/calendar/calendars/` - Календари
### Notifications
- `GET /api/v1/notifications/` - Уведомления
- `POST /api/v1/notifications/{id}/mark_read/` - Отметить прочитанным
- `POST /api/v1/notifications/mark_all_read/` - Отметить все прочитанными
## Аутентификация
### Вход
```typescript
import apiClient from '@/lib/api';
async function handleLogin(username: string, password: string) {
try {
const { access, refresh, user } = await apiClient.login({
username,
password
});
// Токен автоматически сохранён
// Перенаправить пользователя
router.push('/');
} catch (error) {
console.error('Login failed:', error);
}
}
```
### Выход
```typescript
import apiClient from '@/lib/api';
function handleLogout() {
apiClient.clearToken();
router.push('/login');
}
```
### Защищённые страницы
```tsx
"use client";
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import apiClient from '@/lib/api';
export default function ProtectedPage() {
const router = useRouter();
useEffect(() => {
const token = apiClient.getToken();
if (!token) {
router.push('/login');
}
}, []);
return <div>Protected content</div>;
}
```
## Обработка ошибок
API клиент автоматически обрабатывает:
- **401 Unauthorized**: Очищает токены и перенаправляет на `/login`
- **Network errors**: Выбрасывает исключение с сообщением
Пример обработки в компонентах:
```tsx
const { data, loading, error } = useMyData();
if (loading) return <LoadingSpinner />;
if (error) {
return (
<div className="error-message">
<h3>Ошибка загрузки данных</h3>
<p>{error.message}</p>
<button onClick={() => refetch()}>Попробовать снова</button>
</div>
);
}
```
## WebSocket (для будущей реализации)
Для real-time обновлений можно добавить WebSocket соединение:
```typescript
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000';
const socket = new WebSocket(`${WS_URL}/ws/notifications/`);
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
// Обработать уведомление
};
```
## Рекомендации
1. **Всегда используйте хуки** для получения данных из API
2. **Обрабатывайте loading и error** состояния в UI
3. **Используйte TypeScript типы** для type-safety
4. **Не сохраняйте чувствительные данные** в localStorage кроме токенов
5. **Валидируйте данные** перед отправкой на сервер
## Troubleshooting
###CORS ошибки
Убедитесь, что backend настроен с правильными CORS заголовками:
```python
# backend/eusrr_backend/settings.py
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://localhost",
]
```
### 401 Unauthorized
- Проверьте, что токен действителен
- Убедитесь, что backend запущен
- Проверьте переменные окружения
### Ошибка сети
- Проверьте, что backend доступен
- Проверьте URL в `NEXT_PUBLIC_API_URL`
- Проверьте docker network, если используете docker-compose
---
**Дата создания**: 19 февраля 2026
**Версия**: 1.0

View File

@ -17,6 +17,18 @@ cd backend
.venv/Scripts/pip install django-cors-headers .venv/Scripts/pip install django-cors-headers
``` ```
### 1.5. Проверить что Daphne установлен
```bash
cd backend
.venv/Scripts/pip show daphne
```
Если Daphne не установлен:
```bash
.venv/Scripts/pip install daphne
```
Добавить в `backend/eusrr_backend/settings.py`: Добавить в `backend/eusrr_backend/settings.py`:
```python ```python
@ -52,7 +64,15 @@ npm install
### 3. Запустить оба сервера ### 3. Запустить оба сервера
**Terminal 1 - Django:** **⚠️ ВАЖНО: Для WebSocket используйте Daphne вместо стандартного Django сервера!**
**Terminal 1 - Django (с WebSocket на Daphne):**
```bash
cd backend
.venv/Scripts/daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application
```
Или используйте `runserver` для HTTP (без WebSocket):
```bash ```bash
cd backend cd backend
.venv/Scripts/python manage.py runserver 8000 .venv/Scripts/python manage.py runserver 8000
@ -68,6 +88,16 @@ npm run dev
Откройте в браузере: **http://localhost:5173** Откройте в браузере: **http://localhost:5173**
## 🔍 Проверка WebSocket подключения
1. Откройте **DevTools** (F12) → **Console**
2. Ищите сообщение `🔄 Подключение к WebSocket: ws://localhost:9000/ws/...`
3. Если ошибка `❌ WebSocket error: {}`, проверьте:
- ✅ Backend запущен через Daphne (не через runserver)
- ✅ PORT 9000 доступен (не занят другой программой)
- ✅ JWT токен сохранен в localStorage
- ✅ В файле `.env.local` стоит `NEXT_PUBLIC_WS_URL=ws://localhost:9000`
## 🔐 Тестовая авторизация ## 🔐 Тестовая авторизация
Используйте существующего пользователя из вашей БД: Используйте существующего пользователя из вашей БД:
@ -77,6 +107,8 @@ Email: ваш_email@example.com
Password: ваш_пароль Password: ваш_пароль
``` ```
После входа токен автоматически сохранится в `localStorage` и будет использован для WebSocket аутентификации.
## 📁 Структура страниц ## 📁 Структура страниц
Сейчас доступно: Сейчас доступно:
@ -124,10 +156,40 @@ touch src/pages/documents/index.ts
- Проверьте что пользователь существует в БД - Проверьте что пользователь существует в БД
- Проверьте endpoint: `http://localhost:8000/api/auth/token/` - Проверьте endpoint: `http://localhost:8000/api/auth/token/`
### WebSocket error: {}
**Это самая частая ошибка!** Решение:
1. **Проверьте что backend запущен через Daphne:**
```bash
cd backend
.venv/Scripts/daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application
```
2. **НЕ используйте `python manage.py runserver`** - это не поддерживает WebSocket!
3. **Убедитесь что .env.local имеет правильный URL:**
```dotenv
NEXT_PUBLIC_WS_URL=ws://localhost:9000
NEXT_PUBLIC_BACKEND_URL=http://localhost:9000
```
4. **Проверьте в DevTools → Network → WS:**
- Введите фильтр `ws` чтобы найти WebSocket подключение
- Если соединение **101 Switching Protocols** - всё работает ✅
- Если ошибка **Failed** - backend не запущен через Daphne ❌
5. **Проверьте порт 9000:**
```bash
# Windows - проверить какой процесс занимает порт
netstat -ano | findstr :9000
# Если занято - убейте процесс или используйте другой порт
```
### Страница не загружается ### Страница не загружается
- Убедитесь что оба сервера запущены - Убедитесь что оба сервера запущены
- Проверьте `.env` файл в frontend - Проверьте `.env.local` файл в frontend
- Откройте DevTools → Network - Откройте DevTools → Network и посмотрите статусы запросов
## 📚 Документация ## 📚 Документация

View File

@ -51,15 +51,22 @@ export function useWebSocket({
isConnectingRef.current = true; isConnectingRef.current = true;
try { try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // Простую логику построения URL
const host = process.env.NEXT_PUBLIC_WS_URL || 'localhost:9000'; let wsUrl = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:9000/ws/';
// Получаем JWT токен для аутентификации WebSocket // // Если нет протокола - добавляем
// if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
// const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// wsUrl = `${protocol}//${wsUrl}`;
// }
// Добавляем токен
const token = localStorage.getItem('access_token'); const token = localStorage.getItem('access_token');
const wsUrl = token if (token) {
? `${protocol}//${host}/ws/?token=${token}` wsUrl += `${wsUrl.includes('?') ? '&' : '?'}token=${token}`;
: `${protocol}//${host}/ws/`; }
console.log('🔄 WebSocket URL:', wsUrl);
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
wsRef.current = ws; wsRef.current = ws;