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:
@@ -117,6 +117,7 @@ export function registerChatMessageRoutes(router: Router): void {
|
||||
attachments: req.body.attachments,
|
||||
bodyAuthorId: req.body.authorId,
|
||||
isBotToken: req.isBotToken === true,
|
||||
clientMessageId: req.body.clientMessageId ?? null,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
|
||||
@@ -35,6 +35,14 @@ export interface SendTaskMessageParams {
|
||||
bodyAuthorId?: number | null; // authorId из тела запроса (null — признак бот/системного сообщения)
|
||||
isBotToken?: boolean; // запрос аутентифицирован bot-service JWT
|
||||
visibleToUserIds?: number[] | null; // приватность: только перечисленные пользователи видят сообщение (NULL — все участники чата)
|
||||
clientMessageId?: string | null; // ключ идемпотентности (uuid от клиента) — защита от дублей при повторной отправке
|
||||
}
|
||||
|
||||
// PG unique violation (23505): drizzle оборачивает оригинальную ошибку, поэтому
|
||||
// проверяем и саму ошибку, и cause.
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
const e = err as { code?: string; cause?: { code?: string } } | null;
|
||||
return e?.code === '23505' || e?.cause?.code === '23505';
|
||||
}
|
||||
|
||||
// Создаёт сообщение задачи со всеми side-эффектами:
|
||||
@@ -57,6 +65,19 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
|
||||
}
|
||||
const taskId = task.id;
|
||||
|
||||
// Идемпотентность: повтор с тем же clientMessageId — вернуть уже созданное
|
||||
// сообщение БЕЗ повторного insert и БЕЗ side-эффектов (уведомления, SSE,
|
||||
// вебхуки, embedding уже отработали при первой записи). Защита от дублей
|
||||
// при multi-tab replay офлайн-очереди и ретраях после сетевых сбоев.
|
||||
const clientMessageId = params.clientMessageId ?? null;
|
||||
if (clientMessageId) {
|
||||
const existingId = await storage.getTaskMessageIdByClientMessageId(taskId, clientMessageId);
|
||||
if (existingId != null) {
|
||||
const existing = await storage.getTaskMessage(existingId, organizationId);
|
||||
if (existing) return existing as TaskMessage;
|
||||
}
|
||||
}
|
||||
|
||||
let replyToMessage = null;
|
||||
if (params.replyToMessageId) {
|
||||
const existingMessages = await storage.getTaskMessages(taskId, organizationId);
|
||||
@@ -109,9 +130,25 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
|
||||
visibleToUserIds: params.visibleToUserIds && params.visibleToUserIds.length > 0
|
||||
? [...new Set(params.visibleToUserIds)]
|
||||
: null,
|
||||
clientMessageId,
|
||||
};
|
||||
|
||||
const createdMessage = await storage.createTaskMessage(messageData, organizationId);
|
||||
let createdMessage: TaskMessage;
|
||||
try {
|
||||
createdMessage = await storage.createTaskMessage(messageData, organizationId);
|
||||
} catch (err) {
|
||||
// Гонка: параллельный запрос с тем же clientMessageId уже вставил сообщение
|
||||
// (unique index task_messages_client_message_id_unique). Возвращаем
|
||||
// существующее БЕЗ повторных side-эффектов.
|
||||
if (clientMessageId && isUniqueViolation(err)) {
|
||||
const existingId = await storage.getTaskMessageIdByClientMessageId(taskId, clientMessageId);
|
||||
if (existingId != null) {
|
||||
const existing = await storage.getTaskMessage(existingId, organizationId);
|
||||
if (existing) return existing as TaskMessage;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Link file uploads to this task
|
||||
if (createdMessage.attachments && createdMessage.attachments.length > 0) {
|
||||
|
||||
@@ -227,6 +227,7 @@ export interface IStorage {
|
||||
getTaskMessage(messageId: number, organizationId: number): Promise<TaskMessageWithAuthor | undefined>;
|
||||
getTaskMessagesByIds(messageIds: number[], organizationId: number): Promise<TaskMessage[]>;
|
||||
getTaskMessagesByTaskIds(taskIds: number[], organizationId: number): Promise<TaskMessage[]>;
|
||||
getTaskMessageIdByClientMessageId(taskId: number, clientMessageId: string): Promise<number | null>;
|
||||
createTaskMessage(insertMessage: any, organizationId: number): Promise<any>;
|
||||
updateTaskMessage(id: number, organizationId: number, updates: Partial<TaskMessage>): Promise<TaskMessage>;
|
||||
deleteTaskMessage(id: number, organizationId: number): Promise<void>;
|
||||
|
||||
@@ -410,6 +410,19 @@ export class TasksStorage extends TasksCoreStorage {
|
||||
};
|
||||
}
|
||||
|
||||
/** Поиск id сообщения по клиентскому ключу идемпотентности (uuid от клиента) */
|
||||
async getTaskMessageIdByClientMessageId(taskId: number, clientMessageId: string): Promise<number | null> {
|
||||
const rows = await db
|
||||
.select({ id: taskMessages.id })
|
||||
.from(taskMessages)
|
||||
.where(and(
|
||||
eq(taskMessages.taskId, taskId),
|
||||
eq(taskMessages.clientMessageId, clientMessageId)
|
||||
))
|
||||
.limit(1);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** Батч-загрузка сообщений по списку id одним IN-запросом (сырые строки, tenant-фильтр) */
|
||||
async getTaskMessagesByIds(messageIds: number[], organizationId: number): Promise<TaskMessage[]> {
|
||||
if (messageIds.length === 0) return [];
|
||||
|
||||
Reference in New Issue
Block a user