mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
⚡ perf(db): порядок инбокса по индексу, страницы без N+1
Пагинация была правильной, но опиралась на то, что не ложится в индекс: список диалогов сортировался по агрегату max(messages.created_at) — GROUP BY по всей ленте на каждый запрос, — а превью строки и бейдж непрочитанных спрашивались по два запроса на диалог, тридцать раз на страницу, каждые четыре секунды. - Conversation.last_message_at: денормализованное поле вместо агрегата, держит сигнал на создание сообщения (одно место вместо семи), миграция заполняет существующие диалоги; - индексы: conv_inbox_order (организация → свежесть) и conv_message_window (диалог → время → id) — под окно инбокса и окно истории; conv_contact_recent под подзапросы списка контактов; триграммы для поиска по имени и телефону; - превью и бейдж считаются на страницу целиком (last_messages_for, pending_counts_for), а не построчно; - список сотрудников: своя сортировка групп в payload отменяла prefetch, а организация тянулась на каждую строку — обе связи взяты в выборке; - test_query_budget: число запросов сравнивается на разном объёме данных, так что вернуть N+1 незаметно больше нельзя; - журнал аудита переведён на общий usePagedResource — он был последним списком со своей копией отложенного поиска и защиты от гонок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed78617722
commit
f3cbf1658c
12 files changed
+435
-90
No files matched your search
@@ -1,8 +1,12 @@
|
||||
from django.apps import AppConfig␍
|
||||
␍
|
||||
␍
|
||||
class ConversationsConfig(AppConfig):␍
|
||||
default_auto_field = "django.db.models.BigAutoField"␍
|
||||
label = "conversations"␍
|
||||
name = "chatballs.conversations"␍
|
||||
verbose_name = "Conversations (contacts, dialogs, messages)"␍
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ConversationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
label = "conversations"
|
||||
name = "chatballs.conversations"
|
||||
verbose_name = "Conversations (contacts, dialogs, messages)"
|
||||
|
||||
def ready(self) -> None:
|
||||
# Свежесть диалога поддерживает сигнал: сообщения создаются в семи местах.
|
||||
from chatballs.conversations import signals # noqa: F401
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
"""Свежесть диалога — отдельным полем, порядок инбокса — по индексу.
|
||||
|
||||
Раньше список сортировался по агрегату max(messages.created_at): такой ключ не
|
||||
ложится ни в индекс, ни в курсор окна, поэтому каждый запрос инбокса собирал
|
||||
GROUP BY по всей ленте сообщений. Поле заполняется здесь и дальше держится
|
||||
сигналом (chatballs.conversations.signals).
|
||||
"""
|
||||
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.operations import TrigramExtension
|
||||
from django.db import migrations, models
|
||||
|
||||
CONTACT_TRGM_INDEXES = """
|
||||
CREATE INDEX IF NOT EXISTS contact_name_trgm
|
||||
ON conversations_contact USING gin ((UPPER(name)) gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS contact_phone_trgm
|
||||
ON conversations_contact USING gin ((UPPER(phone)) gin_trgm_ops);
|
||||
"""
|
||||
|
||||
DROP_CONTACT_TRGM_INDEXES = """
|
||||
DROP INDEX IF EXISTS contact_name_trgm;
|
||||
DROP INDEX IF EXISTS contact_phone_trgm;
|
||||
"""
|
||||
|
||||
BACKFILL = """
|
||||
UPDATE conversations_conversation AS c
|
||||
SET last_message_at = COALESCE(
|
||||
(SELECT MAX(m.created_at) FROM conversations_message AS m WHERE m.conversation_id = c.id),
|
||||
c.last_activity_at
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('channels', '0008_remove_channel_allow_checkout_actions_and_more'),
|
||||
('conversations', '0019_alter_message_kind'),
|
||||
('identity', '0032_remove_organization_tax_regime_and_more'),
|
||||
('integrations', '0007_integration_feature_flags'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Поиск по контактам идёт подстрокой — без триграмм это перебор таблицы.
|
||||
TrigramExtension(),
|
||||
migrations.AddField(
|
||||
model_name='conversation',
|
||||
name='last_message_at',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
migrations.RunSQL(sql=BACKFILL, reverse_sql=migrations.RunSQL.noop),
|
||||
# Функциональный индекс с opclass заводится сырым SQL: Django 5.2
|
||||
# рендерит его как «(UPPER(name) gin_trgm_ops)» — без скобок вокруг
|
||||
# самого выражения, и Postgres такой синтаксис отвергает.
|
||||
migrations.RunSQL(sql=CONTACT_TRGM_INDEXES, reverse_sql=DROP_CONTACT_TRGM_INDEXES),
|
||||
migrations.AddIndex(
|
||||
model_name='conversation',
|
||||
index=models.Index(
|
||||
fields=['organization', '-last_message_at', '-id'], name='conv_inbox_order'
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='conversation',
|
||||
index=models.Index(fields=['contact', '-last_activity_at'], name='conv_contact_recent'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='message',
|
||||
index=models.Index(
|
||||
fields=['conversation', 'created_at', 'id'], name='conv_message_window'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -2,6 +2,7 @@ from django.conf import settings
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVector
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
@@ -36,6 +37,11 @@ class Contact(models.Model):
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Триграммные индексы для поиска подстрокой (имя, телефон) заведены в
|
||||
# миграции 0020 сырым SQL: Django 5.2 рендерит функциональный индекс с
|
||||
# opclass без внутренних скобок — «(UPPER(name) gin_trgm_ops)», и Postgres
|
||||
# такой синтаксис не принимает.
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or f"contact:{self.id}"
|
||||
|
||||
@@ -192,10 +198,27 @@ class Conversation(models.Model):
|
||||
previous_conversation = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_activity_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
# Время последнего сообщения — порядок инбокса. Отдельно от last_activity_at,
|
||||
# который двигают и служебные действия (перехват, возврат AI): по нему список
|
||||
# сортировать нельзя. У диалога без сообщений — время создания, поэтому поле
|
||||
# непустое и по нему работает и индекс, и курсор окна (api.pagination).
|
||||
last_message_at = models.DateTimeField(default=timezone.now)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-last_activity_at"]
|
||||
indexes = [models.Index(fields=["channel", "lifecycle"])]
|
||||
indexes = [
|
||||
models.Index(fields=["channel", "lifecycle"]),
|
||||
# Порядок инбокса: организация → свежесть. Раньше сортировка шла по
|
||||
# агрегату max(messages.created_at) и в индекс не ложилась.
|
||||
models.Index(
|
||||
fields=["organization", "-last_message_at", "-id"], name="conv_inbox_order"
|
||||
),
|
||||
# Список контактов спрашивает по каждому контакту его свежий диалог
|
||||
# и число открытых — подзапросами по этой паре.
|
||||
models.Index(
|
||||
fields=["contact", "-last_activity_at"], name="conv_contact_recent"
|
||||
),
|
||||
]
|
||||
constraints = [
|
||||
# Диалог всегда принадлежит контакту.
|
||||
models.CheckConstraint(
|
||||
@@ -304,6 +327,10 @@ class Message(TenantRelationModel):
|
||||
SearchVector("text", config="russian"),
|
||||
name="conv_message_text_fts",
|
||||
),
|
||||
# Окно истории идёт ровно по этому ключу: диалог → время → id.
|
||||
models.Index(
|
||||
fields=["conversation", "created_at", "id"], name="conv_message_window"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -5,13 +5,11 @@ from django.db.models import (
|
||||
DateTimeField,
|
||||
F,
|
||||
IntegerField,
|
||||
Max,
|
||||
Q,
|
||||
QuerySet,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from chatballs.api.pagination import SortKey
|
||||
from chatballs.conversations.models import (
|
||||
@@ -36,21 +34,20 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]:
|
||||
)
|
||||
.prefetch_related("labels")
|
||||
# Инбокс сортируется по времени последнего сообщения (а не по служебной
|
||||
# активности вроде claim/takeover); fallback — last_activity_at для
|
||||
# диалогов без сообщений. Coalesce вместо nulls_last: ключ сортировки
|
||||
# обязан быть непустым, иначе курсор окна не сравнить (api.pagination).
|
||||
.annotate(sort_at=Coalesce(Max("messages__created_at"), F("last_activity_at")))
|
||||
.order_by("-sort_at", "-id")
|
||||
# активности вроде claim/takeover). Поле денормализовано и держится
|
||||
# сигналом: агрегат max(messages.created_at) не ложился ни в индекс, ни
|
||||
# в курсор окна — на каждый запрос выходил GROUP BY по всей ленте.
|
||||
.order_by("-last_message_at", "-id")
|
||||
)
|
||||
|
||||
|
||||
# Ключи сортировки инбокса. Порядок и правило сравнения курсора — одно и то же
|
||||
# знание, поэтому оно живёт здесь, а не разъезжается по view.
|
||||
ACTIVITY_KEYS = (SortKey("sort_at"), SortKey("id"))
|
||||
ACTIVITY_KEYS = (SortKey("last_message_at"), SortKey("id"))
|
||||
WAITING_KEYS = (
|
||||
SortKey("_waiting_rank", descending=False),
|
||||
SortKey("_wait_at", descending=False),
|
||||
SortKey("sort_at"),
|
||||
SortKey("last_message_at"),
|
||||
SortKey("id"),
|
||||
)
|
||||
# Заглушка ключа ожидания для диалогов, которые человека не ждут: ключ окна
|
||||
@@ -68,18 +65,18 @@ def order_conversations(
|
||||
дольше всех ждущий выше», остальные — по убыванию активности.
|
||||
"""
|
||||
if sort != "waiting":
|
||||
return queryset.order_by("-sort_at", "-id"), ACTIVITY_KEYS
|
||||
return queryset.order_by("-last_message_at", "-id"), ACTIVITY_KEYS
|
||||
waits = Q(lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED)
|
||||
ordered = queryset.annotate(
|
||||
_waiting_rank=Case(
|
||||
When(waits, then=Value(0)), default=Value(1), output_field=IntegerField()
|
||||
),
|
||||
_wait_at=Case(
|
||||
When(waits, then=F("sort_at")),
|
||||
When(waits, then=F("last_message_at")),
|
||||
default=Value(_NOT_WAITING_AT),
|
||||
output_field=DateTimeField(),
|
||||
),
|
||||
).order_by("_waiting_rank", "_wait_at", "-sort_at", "-id")
|
||||
).order_by("_waiting_rank", "_wait_at", "-last_message_at", "-id")
|
||||
return ordered, WAITING_KEYS
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.db.models import Count, Max, Q
|
||||
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
@@ -40,6 +42,54 @@ def message_payload(message: Message) -> dict[str, object]:
|
||||
return payload
|
||||
|
||||
|
||||
def last_messages_for(conversation_ids: list[int]) -> dict[int, Message]:
|
||||
"""Последняя реплика каждого диалога страницы — одним запросом.
|
||||
|
||||
Превью строки списка раньше спрашивалось на каждый диалог: тридцать строк
|
||||
инбокса стоили тридцати запросов, и обновление списка раз в четыре секунды
|
||||
множило их на число операторов.
|
||||
"""
|
||||
if not conversation_ids:
|
||||
return {}
|
||||
rows = (
|
||||
Message.objects.filter(conversation_id__in=conversation_ids)
|
||||
.exclude(author_type=MessageAuthor.SYSTEM)
|
||||
.select_related("author_user")
|
||||
.order_by("conversation_id", "-created_at", "-id")
|
||||
.distinct("conversation_id")
|
||||
)
|
||||
return {message.conversation_id: message for message in rows}
|
||||
|
||||
|
||||
def pending_counts_for(conversation_ids: list[int], read_map: dict[int, int]) -> dict[int, int]:
|
||||
"""Бейджи непрочитанных для страницы — двумя запросами вместо строки-на-строку.
|
||||
|
||||
Считается хвост клиентских сообщений: те, что пришли после последнего
|
||||
ответа AI или оператора и которых просматривающий ещё не открывал.
|
||||
"""
|
||||
if not conversation_ids:
|
||||
return {}
|
||||
answered = dict(
|
||||
Message.objects.filter(conversation_id__in=conversation_ids)
|
||||
.exclude(author_type=MessageAuthor.CONTACT)
|
||||
.values("conversation_id")
|
||||
.annotate(last_id=Max("id"))
|
||||
.values_list("conversation_id", "last_id")
|
||||
)
|
||||
tail = Q()
|
||||
for conversation_id in conversation_ids:
|
||||
threshold = max(read_map.get(conversation_id, 0), answered.get(conversation_id, 0))
|
||||
tail |= Q(conversation_id=conversation_id, id__gt=threshold)
|
||||
counts = (
|
||||
Message.objects.filter(author_type=MessageAuthor.CONTACT)
|
||||
.filter(tail)
|
||||
.values("conversation_id")
|
||||
.annotate(total=Count("id"))
|
||||
.values_list("conversation_id", "total")
|
||||
)
|
||||
return dict(counts)
|
||||
|
||||
|
||||
def _last_message(conversation: Conversation) -> Message | None:
|
||||
# Превью строки списка — последняя реплика клиента/AI/сотрудника; системные
|
||||
# события («AI передал диалог») в превью не показываются (дизайн-базлайн v2, B).
|
||||
@@ -123,6 +173,8 @@ def conversation_payload(
|
||||
detailed: bool = False,
|
||||
last_read_id: int = 0,
|
||||
viewer_id: int | None = None,
|
||||
last_message: Message | None = None,
|
||||
pending_count: int | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Карточка диалога.
|
||||
|
||||
@@ -130,7 +182,9 @@ def conversation_payload(
|
||||
собственным окном (`/messages/`), иначе открытие диалога с тысячей реплик
|
||||
тянуло бы их все, да ещё и на каждом обновлении карточки.
|
||||
"""
|
||||
last = None if detailed else _last_message(conversation)
|
||||
# Списку превью и бейдж считает страница целиком (last_messages_for,
|
||||
# pending_counts_for); поштучный расчёт остаётся для одиночных ответов.
|
||||
last = None if detailed else (last_message or _last_message(conversation))
|
||||
channel = conversation.channel
|
||||
payload = {
|
||||
"id": conversation.id,
|
||||
@@ -212,5 +266,7 @@ def conversation_payload(
|
||||
).exists()
|
||||
else:
|
||||
payload["lastMessage"] = message_payload(last) if last else None
|
||||
payload["pendingCount"] = _pending_count(conversation, last_read_id)
|
||||
payload["pendingCount"] = (
|
||||
pending_count if pending_count is not None else _pending_count(conversation, last_read_id)
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Сигналы домена диалогов.
|
||||
|
||||
Единственная задача — держать `Conversation.last_message_at` в согласии с
|
||||
лентой. Сообщения создаются в семи местах (приём из каналов, ответ оператора,
|
||||
голосовые, файлы, события звонков, демо-данные), поэтому обновление живёт в
|
||||
сигнале: иначе достаточно одного забытого места, чтобы диалог перестал
|
||||
подниматься в инбоксе.
|
||||
"""
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from chatballs.conversations.models import Conversation, Message
|
||||
|
||||
|
||||
@receiver(post_save, sender=Message, dispatch_uid="conversations.touch_last_message_at")
|
||||
def touch_last_message_at(sender, instance: Message, created: bool, **kwargs) -> None:
|
||||
"""Свежесть диалога двигается только вперёд.
|
||||
|
||||
Условие `__lt` защищает от задним числом импортированной переписки: старое
|
||||
сообщение не должно опускать диалог в инбоксе.
|
||||
"""
|
||||
if not created or instance.created_at is None:
|
||||
return
|
||||
Conversation.objects.filter(
|
||||
id=instance.conversation_id, last_message_at__lt=instance.created_at
|
||||
).update(last_message_at=instance.created_at)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Число запросов на страницу не зависит от размера набора.
|
||||
|
||||
Это и есть защита от возврата исходной беды: список или лента, которые
|
||||
разрастаются вместе с базой, начинаются с одного лишнего запроса на строку.
|
||||
Числа здесь не зафиксированы намеренно — сравниваются два прогона на разном
|
||||
объёме данных, поэтому тест не ломается от посторонних изменений и ловит ровно
|
||||
N+1.
|
||||
"""
|
||||
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import (
|
||||
Contact,
|
||||
Conversation,
|
||||
LifecycleState,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
)
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class QueryBudgetTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization, code="line", name="Линия"
|
||||
)
|
||||
self.connection_row = Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="bot",
|
||||
channel=self.channel,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def _conversation(self, name: str, messages: int = 1) -> Conversation:
|
||||
contact = Contact.objects.create(organization=self.organization, name=name)
|
||||
conversation = Conversation.objects.create(
|
||||
organization=self.organization,
|
||||
channel=self.channel,
|
||||
connection=self.connection_row,
|
||||
contact=contact,
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
)
|
||||
for index in range(messages):
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.CONTACT,
|
||||
text=f"{name} — реплика {index}",
|
||||
)
|
||||
return conversation
|
||||
|
||||
def _employee(self, index: int) -> None:
|
||||
user = HumanUser.objects.create_user(
|
||||
email=f"member{index:03d}@example.com",
|
||||
password="Password-123",
|
||||
full_name=f"Сотрудник {index:03d}",
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
user=user,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title="Оператор",
|
||||
)
|
||||
|
||||
def _queries(self, url: str) -> int:
|
||||
with CaptureQueriesContext(connection) as captured:
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return len(captured)
|
||||
|
||||
def _assert_flat(self, url: str, grow) -> None:
|
||||
"""Сколько бы строк ни добавилось, запросов должно остаться столько же."""
|
||||
# Первый запрос после входа тянет за собой сессию и контекст аренды —
|
||||
# прогреваем, иначе сравнивались бы разные вещи.
|
||||
self._queries(url)
|
||||
before = self._queries(url)
|
||||
grow()
|
||||
after = self._queries(url)
|
||||
self.assertEqual(after, before, f"{url}: запросов стало {after} вместо {before}")
|
||||
|
||||
def test_inbox_window_does_not_grow_with_dialogs(self) -> None:
|
||||
for index in range(3):
|
||||
self._conversation(f"Клиент {index:02d}")
|
||||
self._assert_flat(
|
||||
"/api/v1/conversations/?limit=30",
|
||||
lambda: [self._conversation(f"Ещё клиент {index:02d}") for index in range(12)],
|
||||
)
|
||||
|
||||
def test_history_window_does_not_grow_with_messages(self) -> None:
|
||||
conversation = self._conversation("Иван", messages=5)
|
||||
self._assert_flat(
|
||||
f"/api/v1/conversations/{conversation.id}/messages/?limit=50",
|
||||
lambda: [
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.CONTACT,
|
||||
text=f"Ещё реплика {index}",
|
||||
)
|
||||
for index in range(30)
|
||||
],
|
||||
)
|
||||
|
||||
def test_clients_page_does_not_grow_with_contacts(self) -> None:
|
||||
for index in range(3):
|
||||
self._conversation(f"Контакт {index:02d}")
|
||||
self._assert_flat(
|
||||
"/api/v1/conversations/clients/",
|
||||
lambda: [self._conversation(f"Новый контакт {index:02d}") for index in range(12)],
|
||||
)
|
||||
|
||||
def test_employees_page_does_not_grow_with_staff(self) -> None:
|
||||
for index in range(3):
|
||||
self._employee(index)
|
||||
self._assert_flat(
|
||||
"/api/v1/employees/",
|
||||
lambda: [self._employee(index) for index in range(10, 22)],
|
||||
)
|
||||
@@ -22,12 +22,12 @@ from chatballs.conversations.selectors import (
|
||||
order_conversations,
|
||||
visible_conversations_for,
|
||||
)
|
||||
from chatballs.conversations.serializers import conversation_payload, message_payload
|
||||
|
||||
# Окно инбокса и окно истории: размеры продуктовые, клиент может запросить
|
||||
# меньше, больше — только до потолка api.pagination.
|
||||
LIST_WINDOW_SIZE = 30
|
||||
MESSAGE_WINDOW_SIZE = 50
|
||||
from chatballs.conversations.serializers import (
|
||||
conversation_payload,
|
||||
last_messages_for,
|
||||
message_payload,
|
||||
pending_counts_for,
|
||||
)
|
||||
from chatballs.conversations.services import (
|
||||
ClaimError,
|
||||
claim_conversation,
|
||||
@@ -43,6 +43,11 @@ from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.identity.policy import ResourceScope, authorize, can_administer_access
|
||||
|
||||
# Окно инбокса и окно истории: размеры продуктовые, клиент может запросить
|
||||
# меньше, больше — только до потолка api.pagination.
|
||||
LIST_WINDOW_SIZE = 30
|
||||
MESSAGE_WINDOW_SIZE = 50
|
||||
|
||||
|
||||
class ConversationListView(ConversationViewBase):
|
||||
def get(self, request: Request) -> Response:
|
||||
@@ -113,6 +118,11 @@ class ConversationListView(ConversationViewBase):
|
||||
user=request.user, conversation__in=page.items
|
||||
).values_list("conversation_id", "last_read_message_id")
|
||||
)
|
||||
# Превью и бейдж — на всю страницу разом: построчно это давало по два
|
||||
# запроса на диалог при обновлении списка раз в четыре секунды.
|
||||
conversation_ids = [conversation.id for conversation in page.items]
|
||||
previews = last_messages_for(conversation_ids)
|
||||
pending = pending_counts_for(conversation_ids, read_map)
|
||||
return Response(
|
||||
window_payload(
|
||||
page,
|
||||
@@ -120,6 +130,8 @@ class ConversationListView(ConversationViewBase):
|
||||
c,
|
||||
last_read_id=read_map.get(c.id, 0),
|
||||
viewer_id=request.user.id,
|
||||
last_message=previews.get(c.id),
|
||||
pending_count=pending.get(c.id, 0),
|
||||
),
|
||||
# Счётчик над списком показывает весь охват с учётом фильтров,
|
||||
# а не число уже загруженных строк.
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.db.models import Prefetch, Q, QuerySet
|
||||
|
||||
from chatballs.identity.group_models import EmployeeGroupMember
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
|
||||
ROLE_ANY = "all"
|
||||
@@ -15,8 +16,17 @@ ROLE_ANY = "all"
|
||||
|
||||
def employees_for(organization_id: int, params) -> QuerySet[OrganizationMembership]:
|
||||
employees = (
|
||||
OrganizationMembership.objects.select_related("user")
|
||||
.prefetch_related("group_links__group")
|
||||
# organization нужен для адреса аватара, группы — с их порядком: обе
|
||||
# связи берутся здесь, иначе payload спрашивал бы их на каждую строку.
|
||||
OrganizationMembership.objects.select_related("user", "organization")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"group_links",
|
||||
queryset=EmployeeGroupMember.objects.select_related("group").order_by(
|
||||
"group__name"
|
||||
),
|
||||
)
|
||||
)
|
||||
.filter(organization_id=organization_id)
|
||||
)
|
||||
role = params.get("role")
|
||||
|
||||
@@ -11,9 +11,11 @@ def employee_payload(
|
||||
*,
|
||||
include_detail: bool = False,
|
||||
) -> dict[str, object]:
|
||||
# Порядок групп задаёт выборка (employees_for): своя сортировка здесь
|
||||
# отменяла бы prefetch и давала запрос на каждого сотрудника в списке.
|
||||
groups = [
|
||||
{"id": link.group_id, "name": link.group.name}
|
||||
for link in profile.group_links.select_related("group").order_by("group__name")
|
||||
for link in profile.group_links.all()
|
||||
]
|
||||
payload: dict[str, object] = {
|
||||
"id": profile.user_id,
|
||||
|
||||
@@ -1,61 +1,53 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { ApiError } from "../../api/client";
|
||||
import { useDebounced } from "../../shared/useDebounced";
|
||||
import { usePagedResource } from "../../shared/usePagedResource";
|
||||
import { loadAudit } from "./api";
|
||||
import { EMPTY_AUDIT_QUERY, type AuditPayload, type AuditQuery } from "./model";
|
||||
|
||||
// Журнал аудита: фильтры и страница живут на сервере — журнал растёт без
|
||||
// предела, и отдать его целиком нельзя. Поиск отложен, чтобы не слать запрос на
|
||||
// каждую букву; смена любого фильтра возвращает на первую страницу — иначе
|
||||
// человек остаётся на странице 7 отфильтрованного списка из двух событий.
|
||||
|
||||
const SEARCH_DELAY_MS = 300;
|
||||
// предела, и отдать его целиком нельзя. Страницей, отложенным поиском и
|
||||
// защитой от гонок занимается общий usePagedResource: журнал был единственным
|
||||
// списком со своей копией этой механики.
|
||||
|
||||
export function useAudit() {
|
||||
const [query, setQueryState] = useState<AuditQuery>(EMPTY_AUDIT_QUERY);
|
||||
const [payload, setPayload] = useState<AuditPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(EMPTY_AUDIT_QUERY.q);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedSearch(query.q), SEARCH_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [query.q]);
|
||||
|
||||
// Ответы медленного запроса не должны затирать более свежий: считаем поколения.
|
||||
const generation = useRef(0);
|
||||
|
||||
const effective: AuditQuery = { ...query, q: debouncedSearch };
|
||||
const { period, category, actor, result, page } = effective;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const current = ++generation.current;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await loadAudit({ q: debouncedSearch, period, category, actor, result, page });
|
||||
if (current === generation.current) setPayload(next);
|
||||
} catch (loadError) {
|
||||
if (current !== generation.current) return;
|
||||
setError(loadError instanceof ApiError ? loadError.message : "Не удалось загрузить журнал");
|
||||
} finally {
|
||||
if (current === generation.current) setLoading(false);
|
||||
}
|
||||
}, [debouncedSearch, period, category, actor, result, page]);
|
||||
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
const settledSearch = useDebounced(query.q);
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
q: settledSearch,
|
||||
period: query.period,
|
||||
category: query.category,
|
||||
actor: query.actor,
|
||||
result: query.result,
|
||||
}),
|
||||
[query.actor, query.category, query.period, query.result, settledSearch],
|
||||
);
|
||||
const load = useCallback(
|
||||
(page: number) => loadAudit({ ...filters, page }),
|
||||
[filters],
|
||||
);
|
||||
const journal = usePagedResource<AuditPayload>(load, filters, "Не удалось загрузить журнал");
|
||||
|
||||
const setQuery = useCallback((patch: Partial<AuditQuery>) => {
|
||||
setQueryState((prev) => ({
|
||||
...prev,
|
||||
...patch,
|
||||
// Страницу сбрасывает всё, кроме явной смены страницы.
|
||||
page: patch.page ?? 1,
|
||||
}));
|
||||
}, []);
|
||||
// Страницу двигает подвал списка, фильтры — состояние запроса; смена
|
||||
// фильтра сама возвращает на первую страницу (usePagedResource).
|
||||
if (patch.page !== undefined) {
|
||||
journal.setPage(patch.page);
|
||||
return;
|
||||
}
|
||||
setQueryState((prev) => ({ ...prev, ...patch }));
|
||||
}, [journal]);
|
||||
|
||||
const reset = useCallback(() => setQueryState(EMPTY_AUDIT_QUERY), []);
|
||||
|
||||
return { query, setQuery, reset, payload, loading, error, reload };
|
||||
return {
|
||||
query: { ...query, page: journal.page },
|
||||
setQuery,
|
||||
reset,
|
||||
payload: journal.payload,
|
||||
loading: journal.loading,
|
||||
error: journal.errorText,
|
||||
reload: journal.reload,
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,10 @@ export type PagedPayload<T> = {
|
||||
|
||||
const EMPTY: PagedPayload<never> = { items: [], page: 1, pageSize: 0, total: 0, pageCount: 1 };
|
||||
|
||||
export type PagedResource<T> = {
|
||||
export type PagedResource<T, P> = {
|
||||
/** Ответ целиком: у некоторых списков в нём есть и свои блоки — например
|
||||
* справочники фильтров журнала аудита. */
|
||||
payload: P | null;
|
||||
items: T[];
|
||||
page: number;
|
||||
pageCount: number;
|
||||
@@ -27,12 +30,14 @@ export type PagedResource<T> = {
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function usePagedResource<T>(
|
||||
load: (page: number) => Promise<PagedPayload<T>>,
|
||||
// Тип записи выводится из самого ответа: у части списков в нём есть и свои
|
||||
// блоки (например справочники фильтров журнала аудита), поэтому параметр — ответ.
|
||||
export function usePagedResource<P extends PagedPayload<unknown>>(
|
||||
load: (page: number) => Promise<P>,
|
||||
filters: unknown,
|
||||
errorMessage = "Не удалось загрузить список",
|
||||
): PagedResource<T> {
|
||||
const [payload, setPayload] = useState<PagedPayload<T>>(EMPTY as PagedPayload<T>);
|
||||
): PagedResource<P["items"][number], P> {
|
||||
const [payload, setPayload] = useState<P | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
// Загрузчик пересоздаётся на каждый рендер — держим его в ref, чтобы эффект
|
||||
@@ -76,14 +81,17 @@ export function usePagedResource<T>(
|
||||
void reload();
|
||||
}, [key, reload]);
|
||||
|
||||
// Пока ответа нет, список ведёт себя как пустая первая страница.
|
||||
const current = payload ?? (EMPTY as PagedPayload<P["items"][number]>);
|
||||
return {
|
||||
items: payload.items,
|
||||
payload,
|
||||
items: current.items,
|
||||
// Сервер возвращает существующую страницу: если записи удалили из-под
|
||||
// открытой страницы, он отдаст последнюю, и подвал покажет именно её.
|
||||
page: payload.page,
|
||||
pageCount: payload.pageCount,
|
||||
pageSize: payload.pageSize,
|
||||
total: payload.total,
|
||||
page: current.page,
|
||||
pageCount: current.pageCount,
|
||||
pageSize: current.pageSize,
|
||||
total: current.total,
|
||||
loading,
|
||||
errorText,
|
||||
setPage,
|
||||
|
||||
Reference in new issue
Block a user