From 77dce44f1540120488d48cb17eeadad6c66ba786 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 10 Sep 2026 07:03:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=C2=B7=EB=B0=9C=EC=8B=A0=EC=9E=90=C2=B7=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=ED=95=84=EB=93=9C=20=EA=B3=84=EC=95=BD=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MyOrderItemDetail.representativeImageUrl — "주문 당시"라 적었으나 user-order.service.ts:114가 현재 상품의 첫 이미지를 읽고 OrderItem에는 이미지 스냅샷 컬럼이 없다. 같은 품목의 productName·가격은 실제 스냅샷이라 나란히 있는 이미지도 같은 성격으로 적어 버렸다 - SellerConversationMessage.senderAccountId — "SYSTEM이면 null"이라 적었으나 인사말·FAQ 자동응답도 senderType STORE + senderAccountId null로 저장된다. 이 설명대로면 클라이언트가 인사말을 잘못 분류한다 - bodyText / bodyHtml — "하나만 채워진다"고 단정했으나 서버는 bodyFormat이 가리키는 쪽만 필수로 검사하고 반대쪽을 함께 넘기면 그대로 저장한다. 문서를 고쳤다 — 반대쪽을 지우면 기존 데이터와 계약이 달라지고, 표시 기준은 bodyFormat이라 실질 문제도 없다 지적은 seller SDL만이었으나 구매자 conversation-inquiry.graphql에도 같은 단정이 3자리 있어 함께 고쳤다(개념 단위 정합). 전체 1,874건 통과. --- .../conversation/conversation-inquiry.graphql | 6 +++--- src/features/seller/seller-conversation.graphql | 17 ++++++++++++----- src/features/user/user-order.graphql | 5 ++++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/features/conversation/conversation-inquiry.graphql b/src/features/conversation/conversation-inquiry.graphql index b3cf37f..04bb5e5 100644 --- a/src/features/conversation/conversation-inquiry.graphql +++ b/src/features/conversation/conversation-inquiry.graphql @@ -64,11 +64,11 @@ type ConversationMessage { conversationId: ID! """발신 주체. 말풍선 좌우 배치 기준이다.""" senderType: ConversationSenderType! - """본문 형식. 이 값에 따라 bodyText·bodyHtml 중 하나가 채워진다.""" + """본문 형식. 읽어야 할 본문 필드를 가리킨다.""" bodyFormat: ConversationBodyFormat! - """평문 본문. bodyFormat이 TEXT일 때 채워진다.""" + """평문 본문. bodyFormat이 TEXT면 이 값을 쓴다.""" bodyText: String - """서식 본문. bodyFormat이 HTML일 때 채워진다.""" + """서식 본문. bodyFormat이 HTML이면 이 값을 쓴다.""" bodyHtml: String """발송 시각. 메시지 스트림 정렬은 id 기준이다.""" createdAt: DateTime! diff --git a/src/features/seller/seller-conversation.graphql b/src/features/seller/seller-conversation.graphql index b43c51f..e5d4892 100644 --- a/src/features/seller/seller-conversation.graphql +++ b/src/features/seller/seller-conversation.graphql @@ -48,19 +48,26 @@ type SellerConversationConnection { nextCursor: String } -"""대화 메시지 1건. bodyFormat에 따라 bodyText 또는 bodyHtml 하나만 채워진다.""" +""" +대화 메시지 1건. bodyFormat이 읽어야 할 본문 필드를 가리킨다. +발송 시 다른 형식의 본문을 함께 넘기면 그대로 저장되므로, 두 필드가 모두 채워져 +있을 수 있다 — 표시할 본문은 bodyFormat으로 고른다. +""" type SellerConversationMessage { id: ID! conversationId: ID! """발신 주체. 말풍선 좌우 배치 기준이다.""" senderType: ConversationSenderType! - """보낸 계정 ID. senderType이 SYSTEM이면 null이다.""" + """ + 보낸 계정 ID. 사람이 보낸 메시지에만 채워진다. + 시스템 메시지는 물론, 인사말·FAQ 자동응답처럼 서버가 만든 STORE 메시지도 null이다. + """ senderAccountId: ID """본문 형식. 이 값에 따라 bodyText·bodyHtml 중 하나가 채워진다.""" bodyFormat: ConversationBodyFormat! - """평문 본문. bodyFormat이 TEXT일 때 채워진다.""" + """평문 본문. bodyFormat이 TEXT면 이 값을 쓴다.""" bodyText: String - """서식 본문. bodyFormat이 HTML일 때 채워진다.""" + """서식 본문. bodyFormat이 HTML이면 이 값을 쓴다.""" bodyHtml: String """발송 시각.""" createdAt: DateTime! @@ -84,7 +91,7 @@ type SellerConversationMessageConnection { """ input SellerSendConversationMessageInput { conversationId: ID! - """본문 형식. 이 값에 따라 아래 둘 중 어느 필드가 필수인지 정해진다.""" + """본문 형식. 이 값에 해당하는 본문 필드가 필수다(다른 쪽을 함께 넘기면 그대로 저장된다).""" bodyFormat: ConversationBodyFormat! """평문 본문(최대 2000자). bodyFormat이 TEXT면 필수.""" bodyText: String diff --git a/src/features/user/user-order.graphql b/src/features/user/user-order.graphql index 0abb77b..438b678 100644 --- a/src/features/user/user-order.graphql +++ b/src/features/user/user-order.graphql @@ -111,7 +111,10 @@ type MyOrderItemDetail { productId: ID! """상품명(주문 당시).""" productName: String! - """대표 이미지 URL(주문 당시). 이미지가 없으면 null.""" + """ + 대표 상품 이미지 URL. 주문 당시 스냅샷이 아니라 현재 상품의 첫 이미지를 읽는다 — + 판매자가 이미지를 바꾸거나 지우면 값이 달라지거나 null이 된다. + """ representativeImageUrl: String """주문 수량.""" quantity: Int! From df4e67c12301c5733cd3506dda12614c94e5854c Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 10 Sep 2026 07:06:11 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=ED=82=A4=EC=85=8B=20=EC=BB=A4?= =?UTF-8?q?=EC=84=9C=20=EB=8F=99=EB=A5=A0=20=EB=B6=84=EA=B8=B0=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 두 테스트가 서로 다른 updated_at만 써서 보조 키 분기(updated_at 동률 → id < cursor.id)를 한 번도 타지 않았다. 그 조건을 지우거나 뒤집어도 통과했다. 같은 updated_at으로 대화 둘을 만들고 큰 id를 커서로 넘겨 작은 id만 반환되는지 확인한다. 역검증: 보조 키 비교를 lt→gt로 뒤집으면 이 테스트만 실패한다. 키셋 커서를 고치면서 키셋의 핵심인 동률 처리를 테스트하지 않은 자리였다. --- .../conversation.repository.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/features/conversation/repositories/conversation.repository.spec.ts b/src/features/conversation/repositories/conversation.repository.spec.ts index 31d78bb..65b5eb4 100644 --- a/src/features/conversation/repositories/conversation.repository.spec.ts +++ b/src/features/conversation/repositories/conversation.repository.spec.ts @@ -73,6 +73,33 @@ describe('ConversationRepository (real DB)', () => { expect(rows.map((r) => r.id)).toEqual([older.id]); }); + it('updated_at이 같으면 id 내림차순으로 이어서 끊는다', async () => { + // 커서의 보조 키 분기(updated_at 동률 → id < cursor.id)를 타는 케이스. + // 서로 다른 updated_at만 쓰면 이 분기가 한 번도 실행되지 않는다. + const store = await createStore(prisma); + const sameTime = new Date('2026-09-05T00:00:00Z'); + const make = async () => { + const customer = await createAccount(prisma, { account_type: 'USER' }); + return prisma.storeConversation.create({ + data: { + account_id: customer.id, + store_id: store.id, + updated_at: sameTime, + }, + }); + }; + const first = await make(); + const second = await make(); + expect(second.id > first.id).toBe(true); + + const rows = await repo.listConversationsByStore({ + storeId: store.id, + limit: 10, + cursor: { updatedAt: sameTime, id: second.id }, + }); + expect(rows.map((r) => r.id)).toEqual([first.id]); + }); + it('id가 더 큰 오래된 대화도 커서 페이지에서 빠지지 않는다', async () => { // 정렬은 updated_at desc인데 커서가 id 단독이면 `id < cursor`가 정렬과 // 무관한 행을 잘라내, id가 큰 오래된 대화가 목록에서 영영 빠졌다. From e3257521116266465869604b8ec06a067a31a5c7 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sat, 12 Sep 2026 18:41:52 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=B3=B8=EB=AC=B8=C2=B7=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=ED=95=84=EB=93=9C=20=EB=B0=B0=ED=83=80=EC=84=B1=20?= =?UTF-8?q?=EB=8B=A8=EC=A0=95=20=EC=9E=94=EC=97=AC=EB=B6=84=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95=20(Codex=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 타입 설명은 고쳤는데 SellerConversationMessage.bodyFormat 필드 설명이 옛 문구로 남아 같은 타입 안에서 introspection 결과가 모순됐다. 입력 쪽만 보고 출력 쪽을 놓쳤다. 고치기 전에 bodyFormat·bodyText·bodyHtml이 나오는 자리를 전부 나열해 대조했고, 그 과정에서 같은 유형을 하나 더 찾았다 — seller-content.graphql의 배너 linkType이 '링크 필드 중 하나만 채워진다'로 남아 있었다. 판매자 조회는 저장된 값을 그대로 반환하므로 SellerBanner 타입 설명에서 이미 고친 것과 같은 결함이다. 구매자 product-home.graphql의 같은 문구 2자리는 매퍼가 나머지를 null로 비우므로 정확하다 — 그대로 둔다. --- src/features/seller/seller-content.graphql | 2 +- src/features/seller/seller-conversation.graphql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/seller/seller-content.graphql b/src/features/seller/seller-content.graphql index 8d9bc23..025a5e3 100644 --- a/src/features/seller/seller-content.graphql +++ b/src/features/seller/seller-content.graphql @@ -100,7 +100,7 @@ type SellerBanner { title: String """배너 이미지 URL.""" imageUrl: String! - """이동 대상 타입. 이 값에 따라 아래 링크 필드 중 하나만 채워진다.""" + """이동 대상 타입. 읽어야 할 링크 필드를 가리킨다(판매자 조회는 다른 필드 값도 그대로 내려준다).""" linkType: BannerLinkType! """이동할 URL. linkType이 URL일 때 쓰는 값.""" linkUrl: String diff --git a/src/features/seller/seller-conversation.graphql b/src/features/seller/seller-conversation.graphql index e5d4892..c887bc1 100644 --- a/src/features/seller/seller-conversation.graphql +++ b/src/features/seller/seller-conversation.graphql @@ -63,7 +63,7 @@ type SellerConversationMessage { 시스템 메시지는 물론, 인사말·FAQ 자동응답처럼 서버가 만든 STORE 메시지도 null이다. """ senderAccountId: ID - """본문 형식. 이 값에 따라 bodyText·bodyHtml 중 하나가 채워진다.""" + """본문 형식. 읽어야 할 본문 필드를 가리킨다(다른 쪽에도 값이 남아 있을 수 있다).""" bodyFormat: ConversationBodyFormat! """평문 본문. bodyFormat이 TEXT면 이 값을 쓴다.""" bodyText: String