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:
@@ -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(),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user