fix(chat): идемпотентность сообщений по clientMessageId — защита от дублей при повторной отправке

Первопричина дублей (3 одинаковых сообщения в задаче 1988): живой POST сорвался
→ сообщение попало в общую IndexedDB-очередь → background-sync разослал
TRIGGER_SYNC всем вкладкам → каждая вкладка реплейнула одну и ту же запись
(мьютекс _syncLock модульный, кросс-вкладочной координации нет). Гонка
воспроизведена тестом tests/offline-queue-multitab-race.test.tsx (1 запись →
2 POST для двух вкладок).

Вариант C — серверная идемпотентность:
- task_messages.client_message_id + partial unique index (миграция 0086);
- sendTaskMessage: повтор с тем же clientMessageId возвращает существующее
  сообщение БЕЗ insert и БЕЗ side-эффектов (уведомления/SSE/вебхуки не дублируются);
  гонка insert'ов ловится по 23505 → fallback на select;
- клиент TaskChat шлёт crypto.randomUUID() в каждом сообщении; при офлайн-постановке
  тело с ключом сохраняется, все реплеи идут с одним ключом;
- attachment-реплей: clientMessageId = id записи очереди (стабилен между реплеями);
- юнит-тесты tests/task-message-idempotency.test.ts (3: быстрый путь, гонка 23505,
  проброс прочих ошибок).
This commit is contained in:
2026-09-29 17:40:47 +03:00
parent f6e391d588
commit af4850bd6d
10 changed files with 286 additions and 2 deletions

View File

@@ -142,6 +142,7 @@ interface SendMessagePayload {
mentionedBotIds?: number[];
replyToMessageId?: number;
attachments?: ChatAttachment[];
clientMessageId?: string;
}
// Сообщение задачи, дополненное полем replyTo — контракт общего MessageBubble
@@ -1405,13 +1406,17 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
return;
}
// Отправляем сообщение без файлов как обычно
// Отправляем сообщение без файлов как обычно.
// clientMessageId — ключ идемпотентности: если POST сорвётся, сообщение
// попадёт в офлайн-очередь с ЭТИМ же телом, и все реплеи (в т.ч. из разных
// вкладок) пойдут на сервер с одним ключом → unique-дедуп на сервере.
sendMessageMutation.mutate({
message: text,
mentionedUserIds: finalMentionedIds,
mentionedBotIds,
replyToMessageId: replyingTo?.id,
attachments: undefined,
clientMessageId: crypto.randomUUID(),
});
};

View File

@@ -256,6 +256,12 @@ async function trySendAttachmentMessage(
attachments,
};
// Идемпотентность: id записи очереди стабилен между реплеями (в т.ч. из разных
// вкладок) — сервер дедупит повторы по client_message_id (миграция 0086).
if (item.type === 'task') {
payload.clientMessageId = (item as { clientMessageId?: string }).clientMessageId ?? item.id;
}
if (item.type === 'messenger' && (item.replyToId || item.replyToMessageId)) {
payload.replyToId = item.replyToId || item.replyToMessageId;
}