diff --git a/src/features/conversation/repositories/conversation.repository.spec.ts b/src/features/conversation/repositories/conversation.repository.spec.ts index 46ccb63..d668c46 100644 --- a/src/features/conversation/repositories/conversation.repository.spec.ts +++ b/src/features/conversation/repositories/conversation.repository.spec.ts @@ -147,5 +147,29 @@ describe('ConversationRepository (real DB)', () => { ); expect(updatedConv.store_id).toBe(store.id); }); + + it('기존 마커가 미래 시각이어도 새 메시지는 그보다 뒤 시각을 받는다(시계 컷오버 보정)', async () => { + const { conversation } = await setupConversation(); + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + // 앱 시계가 앞섰던 노드가 남긴 미래 마커 재현 + const futureMarker = new Date(Date.now() + 60 * 60 * 1000); + await prisma.storeConversation.update({ + where: { id: conversation.id }, + data: { last_read_at: futureMarker, last_message_at: futureMarker }, + }); + + const message = await repo.createSellerConversationMessage({ + conversationId: conversation.id, + sellerAccountId: seller.id, + bodyFormat: 'TEXT', + bodyText: '컷오버 이후 답장', + bodyHtml: null, + }); + + // created_at > last_read_at 이어야 안읽음 판정에서 누락되지 않는다 + expect(message.created_at.getTime()).toBeGreaterThan( + futureMarker.getTime(), + ); + }); }); }); diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 93c58a4..36028b2 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -376,11 +376,11 @@ export class ConversationRepository { return this.prisma.$transaction(async (tx) => { const conversation = await this.lockOrCreateConversation(tx, args); const conversationId = conversation.id; - // 메시지 시각은 대화 잠금 획득 "이후"에 채번한다 — 잠금 밖에서 미리 - // 받은 시각은 커밋 순서와 어긋나, 늦게 커밋된 과거 시각 메시지가 - // 읽음 마커(last_read_at)를 건너뛰는 레이스를 만든다(리뷰 반영). - // 잠금 순서 = 시각 순서 = 커밋 순서가 대화 단위로 보장된다(NTP 전제). - const now = new Date(); + // 메시지 시각은 대화 잠금 획득 "이후" DB 시계(NOW(3))로 채번한다 — + // 앱 호스트 시계는 다중 인스턴스에서 노드 간 오차로 잠금 순서와 + // 어긋날 수 있다(릴리즈 리뷰 반영). DB가 단일 시계 소스이므로 + // 잠금 순서 = 시각 순서 = 커밋 순서가 대화 단위로 보장된다. + const now = await this.fetchMonotonicNow(tx, conversationId); // 인사말 필요 여부는 실제 메시지 수로 판정한다 — "생성 여부" 플래그는 // 동시 첫 전송·실패 재시도에서 인사말 계약(항상 첫 메시지)을 깨뜨린다. @@ -467,6 +467,37 @@ export class ConversationRepository { }); } + /** + * 대화 단위 단조 시각 채번 — 인스턴스 간 단일 시계(DB NOW(3))를 쓰되, + * 해당 대화의 기존 last_message_at/last_read_at보다 1ms 이상 뒤로 보정한다. + * 앱 시계로 찍힌 과거 row(시계가 DB보다 앞섰던 노드)가 남아 있어도 새 + * 메시지가 마커보다 과거/동률 시각을 받아 안읽음 판정(created_at > + * last_read_at)에서 누락되지 않는다(릴리즈 리뷰 반영). 잠금 획득 후 호출 전제. + */ + private async fetchMonotonicNow( + tx: Prisma.TransactionClient, + conversationId: bigint, + ): Promise { + // FOR UPDATE 잠금 조회 — 일반 조회는 트랜잭션 초입 스냅샷을 읽어, + // 잠금 대기 중 커밋된 마커 갱신을 놓칠 수 있다(릴리즈 리뷰 반영). + // row는 이미 본 트랜잭션이 잠갔으므로 추가 대기는 없다. + const rows = await tx.$queryRaw<{ now: Date }[]>` + SELECT GREATEST( + NOW(3), + COALESCE(TIMESTAMPADD(MICROSECOND, 1000, last_message_at), NOW(3)), + COALESCE(TIMESTAMPADD(MICROSECOND, 1000, last_read_at), NOW(3)) + ) AS now + FROM store_conversation + WHERE id = ${conversationId} + FOR UPDATE`; + const now = rows[0]?.now; + if (!(now instanceof Date)) { + // row 부재/드라이버 매핑 실패의 비정상 경로 — 전송을 막지 않는다 + return new Date(); + } + return now; + } + /** * 트랜잭션 안에서 (account_id, store_id) 대화를 잠그거나 생성한다. * - 기존 대화: id FOR UPDATE 잠금(초기화 직렬화). 유니크 제약은 soft-delete @@ -543,10 +574,11 @@ export class ConversationRepository { bodyHtml: string | null; }) { return this.prisma.$transaction(async (tx) => { - // 구매자 전송·읽음 처리와 같은 대화 잠금 아래에서 시각을 채번해 - // 커밋 순서와 시각 순서를 대화 단위로 일치시킨다(읽음 마커 정합). + // 구매자 전송·읽음 처리와 같은 대화 잠금 아래에서 DB 시계로 시각을 + // 채번해 커밋 순서와 시각 순서를 대화 단위로 일치시킨다(읽음 마커 + // 정합 — 앱 호스트 시계는 다중 인스턴스 오차에 취약, 릴리즈 리뷰 반영). await tx.$queryRaw`SELECT id FROM store_conversation WHERE id = ${args.conversationId} FOR UPDATE`; - const now = new Date(); + const now = await this.fetchMonotonicNow(tx, args.conversationId); const message = await tx.storeConversationMessage.create({ data: {