From a94b56ecb7c0e51d12333a8c5bbd573e0a0dd9ce Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 22:12:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D1=8F=D0=B2=D0=B8=D0=BB=20=D0=B8?= =?UTF-8?q?=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=B4?= =?UTF-8?q?=D0=B2=D0=B0=20=D0=BC=D0=B5=D1=81=D1=82=D0=B0,=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B5=20=D1=85=D0=BE=D1=80=D0=BE=D1=88=D0=BE=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=8A=D1=8F=D1=81=D0=BD=D1=8F=D1=8E=D1=82=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=BA=D1=83=D1=8E=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D1=83=D1=8E=20=D0=BD=D0=B5=D0=B4=D0=BE=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D1=83=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D0=BB=D0=B5=20=D0=B2=D0=B2=D0=B5=D1=80=D1=85.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/communications/api/viewsets.py | 46 +++++++++++---- .../communications/test_communications_api.py | 57 +++++++++++++++++++ frontend/src/app/messages/[chatId]/page.tsx | 2 +- 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index a73413ca..4ed16e03 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -106,6 +106,38 @@ def _build_message_search_snippet(message, query, max_length=140): return 'Сообщение без текста' +def _filter_messages_before_anchor(queryset, chat, before_id): + try: + anchor_id = int(before_id) + except (TypeError, ValueError): + return queryset + + anchor = chat.messages.only('id', 'created_at').filter(pk=anchor_id).first() + if not anchor: + return queryset.filter(id__lt=anchor_id) + + return queryset.filter( + Q(created_at__lt=anchor.created_at) + | Q(created_at=anchor.created_at, id__lt=anchor.id) + ) + + +def _filter_messages_after_anchor(queryset, chat, after_id): + try: + anchor_id = int(after_id) + except (TypeError, ValueError): + return queryset + + anchor = chat.messages.only('id', 'created_at').filter(pk=anchor_id).first() + if not anchor: + return queryset.filter(id__gt=anchor_id) + + return queryset.filter( + Q(created_at__gt=anchor.created_at) + | Q(created_at=anchor.created_at, id__gt=anchor.id) + ) + + class ChatViewSet(viewsets.ModelViewSet): """ ViewSet для управления чатами @@ -675,28 +707,22 @@ class ChatViewSet(viewsets.ModelViewSet): if after_id or after_ts: # Для загрузки новых сообщений - сортируем по возрастанию (от старых к # новым) - queryset = queryset.order_by('created_at') + queryset = queryset.order_by('created_at', 'id') else: # Для загрузки старых или начальной загрузки - по убыванию (от новых к # старым) - queryset = queryset.order_by('-created_at') + queryset = queryset.order_by('-created_at', '-id') # Фильтрация по ID (приоритет) или timestamp if before_id: - try: - queryset = queryset.filter(id__lt=int(before_id)) - except ValueError: - pass + queryset = _filter_messages_before_anchor(queryset, chat, before_id) elif before_ts: before_dt = _coerce_ts(before_ts) if before_dt: queryset = queryset.filter(created_at__lt=before_dt) if after_id: - try: - queryset = queryset.filter(id__gt=int(after_id)) - except ValueError: - pass + queryset = _filter_messages_after_anchor(queryset, chat, after_id) elif after_ts: after_dt = _coerce_ts(after_ts) if after_dt: diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index 8e033ddb..cf4dc03f 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -6,6 +6,7 @@ import pytest from communications.models import Chat, ChatMembership, ChatReadState, ChatUserSettings, Message from django.contrib.auth import get_user_model from django.urls import reverse +from django.utils import timezone from employees.models import Department from rest_framework import status from rest_framework.test import APIClient @@ -328,6 +329,62 @@ class TestChatViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.data["messages"]) <= 10 + def test_messages_pagination_before_id_respects_created_at_anchor( + self, auth_client, private_chat, user1 + ): + """before_id должен пагинировать по временной ленте, а не по голому id.""" + msg_old = Message.objects.create( + chat=private_chat, author=user1, content="Old by time" + ) + msg_middle = Message.objects.create( + chat=private_chat, author=user1, content="Middle by time" + ) + msg_new = Message.objects.create( + chat=private_chat, author=user1, content="New by time" + ) + + base = timezone.now() + Message.objects.filter(pk=msg_old.pk).update(created_at=base) + Message.objects.filter(pk=msg_middle.pk).update(created_at=base + timezone.timedelta(minutes=20)) + Message.objects.filter(pk=msg_new.pk).update(created_at=base + timezone.timedelta(minutes=10)) + + url = f"/api/v1/communications/chats/{private_chat.pk}/messages/" + response = auth_client.get(url, {"before_id": msg_middle.id, "limit": 10}) + + assert response.status_code == status.HTTP_200_OK + returned_ids = [item["id"] for item in response.data["messages"]] + + assert msg_old.id in returned_ids + assert msg_new.id in returned_ids + assert msg_middle.id not in returned_ids + + def test_messages_pagination_after_id_respects_created_at_anchor( + self, auth_client, private_chat, user1 + ): + """after_id должен догружать по временной ленте даже при несогласованных id и created_at.""" + msg_old = Message.objects.create( + chat=private_chat, author=user1, content="Old by time" + ) + msg_middle = Message.objects.create( + chat=private_chat, author=user1, content="Middle by time" + ) + msg_new = Message.objects.create( + chat=private_chat, author=user1, content="New by time" + ) + + base = timezone.now() + Message.objects.filter(pk=msg_old.pk).update(created_at=base) + Message.objects.filter(pk=msg_middle.pk).update(created_at=base + timezone.timedelta(minutes=20)) + Message.objects.filter(pk=msg_new.pk).update(created_at=base + timezone.timedelta(minutes=10)) + + url = f"/api/v1/communications/chats/{private_chat.pk}/messages/" + response = auth_client.get(url, {"after_id": msg_new.id, "limit": 10}) + + assert response.status_code == status.HTTP_200_OK + returned_ids = [item["id"] for item in response.data["messages"]] + + assert returned_ids == [msg_middle.id] + def test_chat_access_denied(self, user3, private_chat): """Попытка доступа к чату без прав""" client = APIClient() diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 274d1e0a..573b92da 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -419,7 +419,7 @@ function MessageDialogPageContent() { const viewport = c.containerRef.current; const onScroll = () => { - if (!cm.loadingOlder && !cm.messagesLoading && cm.hasMoreOlder && viewport.scrollTop <= 120) { + if (!cm.loadingOlder && !cm.messagesLoading && (cm.hasMoreOlder || cm.allowOneOlderProbe) && viewport.scrollTop <= 120) { cm.loadOlderMessages(); }