From b435de9a0215e937b7571948798d17f09af1b0a4 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 02:55:33 +0900 Subject: [PATCH 01/27] =?UTF-8?q?feat(user):=20=EC=95=8C=EB=A6=BC=EC=84=BC?= =?UTF-8?q?=ED=84=B0=20=EC=95=8C=EB=A6=BC=20=ED=83=AD=20=EB=8C=80=EC=9D=91?= =?UTF-8?q?=20=E2=80=94=20event=C2=B7=EC=97=B0=EA=B4=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=85=B8=EC=B6=9C,=203=EA=B0=9C=EC=9B=94=20?= =?UTF-8?q?=ED=95=84=ED=84=B0,=20=EC=BB=A4=EC=84=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit figma 알림센터(알림 탭) 화면 대응. 알림 항목이 이벤트 라벨(주문확정/제작완료/ 픽업완료/리뷰 좋아요)과 "[매장] '상품'…" 서브라인, 딥링크를 구성할 수 있도록 기존 범용 myNotifications API를 확장한다. 하단 안내 문구("최근 3개월 내의 알림만 확인할 수 있어요")에 맞춰 노출 범위도 서버가 강제한다. 변경점 - SDL: NotificationItem에 event·orderId·storeId·productId·reviewId· storeName·productName 추가, NotificationEvent enum 노출 - 페이지네이션: offset → 키셋 커서(":", created_at·id desc) 전환. FE 실사용 전이라 breaking 전환을 지금 수행(레포 컨벤션 정합) - 3개월 노출 필터: myNotifications 목록·totalCount와 viewerCounts.unreadNotificationCount에 created_at >= now-3개월 공통 적용 (삭제 아님 — 조회 필터만, 사용자 확정 정책) - 문구 상수를 figma 톤으로 갱신("주문이 확정되었어요." 등). 주문번호 prefix는 식별 필요 가능성에 대비해 유지(사용자 확정 — 표시 여부는 FE 판단) - 주문 상태 알림 생성 시 store_id·product_id 저장(신규 row부터). 과거 row는 조회 시 order.items 폴백으로 매장·상품 정보 보강, 상품명은 주문 시점 스냅샷 - 시드: 4종 이벤트 + 3개월 경과 알림으로 재구성 회귀 테스트 - service spec 12케이스(커서 연속 조회·타이브레이크, 잘못된 커서 거절, 3개월 필터, 직접 연결·주문 폴백 매핑, 배지 수 일치) - 매퍼 helper 순수 단위 4케이스, input spec 6케이스, resolver 통합 2케이스, order.repository spec에 연관 ID 저장 검증 추가 --- prisma/seed.ts | 2 +- prisma/seed/notifications.ts | 71 +++++-- .../constants/notification-messages.ts | 28 ++- .../notification-payloads.helper.spec.ts | 16 +- .../repositories/order.repository.spec.ts | 3 + .../order/repositories/order.repository.ts | 9 + .../user-notification-error-messages.ts | 5 + src/features/user/constants/user.constants.ts | 6 + .../dto/inputs/my-notifications.input.spec.ts | 28 ++- .../user/dto/inputs/my-notifications.input.ts | 25 ++- .../user/repositories/user.repository.ts | 101 +++++++--- .../user-notification.resolver.spec.ts | 2 +- .../user-notification-mappers.helper.spec.ts | 113 ++++++++++++ .../user-notification-mappers.helper.ts | 36 ++++ .../user-notification.service.spec.ts | 174 ++++++++++++++++-- .../services/user-notification.service.ts | 78 ++++++-- src/features/user/types/user-output.type.ts | 9 + src/features/user/user-common.graphql | 8 + src/features/user/user-notification.graphql | 24 ++- 19 files changed, 636 insertions(+), 102 deletions(-) create mode 100644 src/features/user/constants/user-notification-error-messages.ts create mode 100644 src/features/user/services/user-notification-mappers.helper.spec.ts create mode 100644 src/features/user/services/user-notification-mappers.helper.ts diff --git a/prisma/seed.ts b/prisma/seed.ts index 471ecdd3..5b541454 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -61,7 +61,7 @@ async function main(): Promise { await seedRecentViews(prisma, { users, stores }); log('알림 시드 중...'); - await seedNotifications(prisma, { users }); + await seedNotifications(prisma, { users, stores, orders }); log('커스텀 드래프트 시드 중...'); await seedCustomDrafts(prisma, { users, stores }); diff --git a/prisma/seed/notifications.ts b/prisma/seed/notifications.ts index 52d58fc9..fbee29b3 100644 --- a/prisma/seed/notifications.ts +++ b/prisma/seed/notifications.ts @@ -1,48 +1,95 @@ /** - * 시드 알림 (user1, 3건). 읽음 1 / 안읽음 2 → unreadNotificationCount=2. + * 시드 알림 (user1, 5건). + * - 3개월 내 4건(읽음 1 / 안읽음 3 → unreadNotificationCount=3): + * 주문확정·제작완료·픽업완료·리뷰 좋아요 — 알림센터 4종 이벤트 재현. + * - 3개월 경과 1건: myNotifications 3개월 노출 필터 검증용(목록·배지에서 제외). + * 문구는 notification feature 상수(figma notification-center 톤)와 동일하게 유지. */ import type { PrismaClient } from '@prisma/client'; +import type { SeededOrders } from './orders'; +import type { SeededStores } from './stores'; import type { SeededUser } from './users'; export async function seedNotifications( prisma: PrismaClient, - ctx: { users: SeededUser[] }, + ctx: { users: SeededUser[]; stores: SeededStores; orders: SeededOrders }, ): Promise { const user1 = ctx.users[0]; if (!user1) throw new Error('seedUsers must run before seedNotifications'); + const [p1, p2, p3] = ctx.stores.products; + if (!p1 || !p2 || !p3) { + throw new Error('seedStores must run before seedNotifications'); + } + + // 리뷰 좋아요 알림은 seedReviews가 만든 user1 리뷰(p1, storeA)에 연결한다. + const review = await prisma.review.findFirstOrThrow({ + where: { account_id: user1.id, product_id: p1.id }, + select: { id: true, store_id: true, product_id: true }, + }); const now = Date.now(); const hour = 60 * 60 * 1000; + const day = 24 * hour; await prisma.notification.createMany({ data: [ + { + account_id: user1.id, + type: 'REVIEW_LIKE', + event: 'REVIEW_LIKED', + title: '리뷰 좋아요', + body: '다른 사람이 내가 남긴 리뷰를 좋아했어요.', + review_id: review.id, + store_id: review.store_id, + product_id: review.product_id, + read_at: null, + created_at: new Date(now - 1 * hour), + }, { account_id: user1.id, type: 'ORDER_STATUS', event: 'ORDER_CONFIRMED', - title: '주문이 확정되었습니다', - body: 'SEED-O2-CONF 주문이 확정되었습니다.', + title: '주문확정', + body: 'SEED-O2-CONF 주문이 확정되었어요.', + order_id: ctx.orders.o2Confirmed, + store_id: p2.store_id, + product_id: p2.id, read_at: null, - created_at: new Date(now - 1 * hour), + created_at: new Date(now - 5 * hour), }, { account_id: user1.id, type: 'ORDER_STATUS', event: 'ORDER_MADE', - title: '주문이 제작 완료되었습니다', - body: 'SEED-O3-MADE 주문의 상품 제작이 완료되었습니다.', + title: '제작완료', + body: 'SEED-O3-MADE 주문하신 케이크 제작이 완료되었어요.', + order_id: ctx.orders.o3Made, + store_id: p3.store_id, + product_id: p3.id, read_at: null, - created_at: new Date(now - 24 * hour), + created_at: new Date(now - 1 * day), }, { + // 연관 ID 미저장 과거 주문 알림 재현 — 조회 시 order.items 폴백 검증용. account_id: user1.id, type: 'ORDER_STATUS', event: 'ORDER_PICKED_UP', - title: '주문이 픽업 처리되었습니다', - body: 'SEED-O4-PICKED-RE 주문이 픽업 완료 처리되었습니다.', - read_at: new Date(now - 9 * 24 * hour), - created_at: new Date(now - 10 * 24 * hour), + title: '픽업완료', + body: 'SEED-O4-PICKED-RE 케이크 픽업이 완료되었어요.', + order_id: ctx.orders.o4PickedUpReviewed, + read_at: new Date(now - 9 * day), + created_at: new Date(now - 10 * day), + }, + { + // 3개월(+7일) 경과 — 알림센터 목록·배지 어디에도 노출되지 않아야 한다. + account_id: user1.id, + type: 'ORDER_STATUS', + event: 'ORDER_PICKED_UP', + title: '픽업완료', + body: 'SEED-OLD 케이크 픽업이 완료되었어요.', + read_at: null, + created_at: new Date(now - 97 * day), }, ], }); diff --git a/src/features/notification/constants/notification-messages.ts b/src/features/notification/constants/notification-messages.ts index 1993ae10..f72348ef 100644 --- a/src/features/notification/constants/notification-messages.ts +++ b/src/features/notification/constants/notification-messages.ts @@ -1,24 +1,32 @@ import { OrderStatus } from '@prisma/client'; -/** 주문 상태별 알림 제목. 매핑이 없는 상태는 알림을 만들지 않는다. */ +/** + * 주문 상태별 알림 제목(알림센터 라벨). 매핑이 없는 상태는 알림을 만들지 않는다. + * 문구는 figma notification-center 알림 목록 화면 기준. + */ export const ORDER_STATUS_NOTIFICATION_TITLES: Partial< Record > = { - [OrderStatus.CONFIRMED]: '주문이 확정되었습니다', - [OrderStatus.MADE]: '주문이 제작 완료되었습니다', - [OrderStatus.PICKED_UP]: '주문이 픽업 처리되었습니다', + [OrderStatus.CONFIRMED]: '주문확정', + [OrderStatus.MADE]: '제작완료', + [OrderStatus.PICKED_UP]: '픽업완료', }; -/** 주문 상태별 알림 본문. 주문번호를 앞에 붙여 조립한다. */ +/** + * 주문 상태별 알림 본문. 주문번호를 앞에 붙여 조립한다. + * (figma 알림센터에는 주문번호가 노출되지 않지만, 식별 필요 가능성에 대비해 + * prefix는 유지한다 — 표시 여부는 FE 판단. 사용자 확정 정책) + */ export const ORDER_STATUS_NOTIFICATION_BODIES: Partial< Record > = { - [OrderStatus.CONFIRMED]: '주문이 확정되었습니다.', - [OrderStatus.MADE]: '주문의 상품 제작이 완료되었습니다.', - [OrderStatus.PICKED_UP]: '주문이 픽업 완료 처리되었습니다.', + [OrderStatus.CONFIRMED]: '주문이 확정되었어요.', + [OrderStatus.MADE]: '주문하신 케이크 제작이 완료되었어요.', + [OrderStatus.PICKED_UP]: '케이크 픽업이 완료되었어요.', }; +// 문구는 figma notification-center 알림 목록 화면 기준. export const REVIEW_LIKED_NOTIFICATION = { - title: '리뷰에 좋아요가 추가되었습니다', - body: '회원님의 리뷰를 다른 사용자가 좋아합니다.', + title: '리뷰 좋아요', + body: '다른 사람이 내가 남긴 리뷰를 좋아했어요.', } as const; diff --git a/src/features/notification/services/notification-payloads.helper.spec.ts b/src/features/notification/services/notification-payloads.helper.spec.ts index b3593bb9..0e61e7b8 100644 --- a/src/features/notification/services/notification-payloads.helper.spec.ts +++ b/src/features/notification/services/notification-payloads.helper.spec.ts @@ -17,8 +17,8 @@ describe('notification-payloads.helper', () => { ).toEqual({ type: NotificationType.ORDER_STATUS, event: NotificationEvent.ORDER_CONFIRMED, - title: '주문이 확정되었습니다', - body: 'ORD-1 주문이 확정되었습니다.', + title: '주문확정', + body: 'ORD-1 주문이 확정되었어요.', }); }); @@ -26,16 +26,16 @@ describe('notification-payloads.helper', () => { expect(buildOrderStatusNotification('ORD-2', OrderStatus.MADE)).toEqual({ type: NotificationType.ORDER_STATUS, event: NotificationEvent.ORDER_MADE, - title: '주문이 제작 완료되었습니다', - body: 'ORD-2 주문의 상품 제작이 완료되었습니다.', + title: '제작완료', + body: 'ORD-2 주문하신 케이크 제작이 완료되었어요.', }); expect( buildOrderStatusNotification('ORD-3', OrderStatus.PICKED_UP), ).toEqual({ type: NotificationType.ORDER_STATUS, event: NotificationEvent.ORDER_PICKED_UP, - title: '주문이 픽업 처리되었습니다', - body: 'ORD-3 주문이 픽업 완료 처리되었습니다.', + title: '픽업완료', + body: 'ORD-3 케이크 픽업이 완료되었어요.', }); }); @@ -54,8 +54,8 @@ describe('notification-payloads.helper', () => { expect(buildReviewLikedNotification()).toEqual({ type: NotificationType.REVIEW_LIKE, event: NotificationEvent.REVIEW_LIKED, - title: '리뷰에 좋아요가 추가되었습니다', - body: '회원님의 리뷰를 다른 사용자가 좋아합니다.', + title: '리뷰 좋아요', + body: '다른 사람이 내가 남긴 리뷰를 좋아했어요.', }); }); }); diff --git a/src/features/order/repositories/order.repository.spec.ts b/src/features/order/repositories/order.repository.spec.ts index 317c4bb1..2a9c6033 100644 --- a/src/features/order/repositories/order.repository.spec.ts +++ b/src/features/order/repositories/order.repository.spec.ts @@ -552,6 +552,9 @@ describe('OrderRepository (real DB)', () => { expect(notifications).toHaveLength(1); expect(notifications[0].event).toBe('ORDER_CONFIRMED'); expect(notifications[0].account_id).toBe(buyer.id); + // 알림센터 서브라인·딥링크용 연관 ID까지 저장한다 + expect(notifications[0].store_id).toBe(store.id); + expect(notifications[0].product_id).not.toBeNull(); const auditLogs = await prisma.auditLog.findMany({ where: { target_id: order.id, target_type: 'ORDER' }, diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index 80918c89..3d0e01b0 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -720,10 +720,19 @@ export class OrderRepository { args.toStatus, ); if (notification) { + // 알림센터 서브라인·딥링크용 연관 ID. 주문은 단일 상품 구조라 + // 첫 item으로 상품이 확정된다(다상품 확장 시 재검토). + const firstItem = await tx.orderItem.findFirst({ + where: { order_id: order.id }, + orderBy: { id: 'asc' }, + select: { product_id: true }, + }); await tx.notification.create({ data: { account_id: order.account_id, order_id: order.id, + store_id: args.storeId, + product_id: firstItem?.product_id ?? null, ...notification, }, }); diff --git a/src/features/user/constants/user-notification-error-messages.ts b/src/features/user/constants/user-notification-error-messages.ts new file mode 100644 index 00000000..c74e0812 --- /dev/null +++ b/src/features/user/constants/user-notification-error-messages.ts @@ -0,0 +1,5 @@ +export const USER_NOTIFICATION_ERRORS = { + NOTIFICATION_NOT_FOUND: 'Notification not found.', + // 커서는 ":" 불투명 토큰 — 형식이 다르면 클라이언트 버그다. + INVALID_CURSOR: 'Invalid notification cursor.', +} as const; diff --git a/src/features/user/constants/user.constants.ts b/src/features/user/constants/user.constants.ts index 60cedf63..72649883 100644 --- a/src/features/user/constants/user.constants.ts +++ b/src/features/user/constants/user.constants.ts @@ -24,3 +24,9 @@ export const MAX_PAGINATION_LIMIT = 50; // ── 리뷰 댓글 ── export const MAX_REVIEW_COMMENT_LENGTH = 500; + +// ── 알림 ── + +// figma notification-center: "최근 3개월 내의 알림만 확인할 수 있어요." +// 삭제가 아니라 조회 필터로만 강제한다(사용자 확정 정책). +export const NOTIFICATION_VISIBLE_MONTHS = 3; diff --git a/src/features/user/dto/inputs/my-notifications.input.spec.ts b/src/features/user/dto/inputs/my-notifications.input.spec.ts index 25cb2841..556a1775 100644 --- a/src/features/user/dto/inputs/my-notifications.input.spec.ts +++ b/src/features/user/dto/inputs/my-notifications.input.spec.ts @@ -10,25 +10,39 @@ function build(plain: object): MyNotificationsInput { } describe('MyNotificationsInput', () => { - it('unreadOnly true 허용', async () => { - const dto = build({ unreadOnly: true, offset: 0, limit: 20 }); + it('unreadOnly·cursor·limit 정상 조합 허용', async () => { + const dto = build({ unreadOnly: true, cursor: '123:45', limit: 20 }); expect(await validate(dto)).toHaveLength(0); }); - it('unreadOnly 누락 허용', async () => { - const dto = build({ offset: 0, limit: 20 }); + it('모든 필드 누락 허용(기본값은 서비스가 처리)', async () => { + const dto = build({}); expect(await validate(dto)).toHaveLength(0); }); it('unreadOnly 가 boolean 이 아니면 거절', async () => { - const dto = build({ unreadOnly: 'yes', offset: 0, limit: 20 }); + const dto = build({ unreadOnly: 'yes' }); const errors = await validate(dto); expect(errors).toHaveLength(1); expect(errors[0].property).toBe('unreadOnly'); }); - it('상속된 페이지네이션 검증도 적용 (limit > 50)', async () => { - const dto = build({ unreadOnly: false, offset: 0, limit: 51 }); + it('빈 문자열 커서 거절', async () => { + const dto = build({ cursor: '' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('cursor'); + }); + + it('limit > 50 거절', async () => { + const dto = build({ limit: 51 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('limit'); + }); + + it('limit 0 거절', async () => { + const dto = build({ limit: 0 }); const errors = await validate(dto); expect(errors).toHaveLength(1); expect(errors[0].property).toBe('limit'); diff --git a/src/features/user/dto/inputs/my-notifications.input.ts b/src/features/user/dto/inputs/my-notifications.input.ts index 83e510a8..f7d327c7 100644 --- a/src/features/user/dto/inputs/my-notifications.input.ts +++ b/src/features/user/dto/inputs/my-notifications.input.ts @@ -1,9 +1,28 @@ -import { IsBoolean, IsOptional } from 'class-validator'; +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; -import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; +import { MAX_PAGINATION_LIMIT } from '@/features/user/constants/user.constants'; -export class MyNotificationsInput extends UserPaginationInput { +export class MyNotificationsInput { @IsOptional() @IsBoolean() unreadOnly?: boolean; + + @IsOptional() + @IsString() + @IsNotEmpty() + cursor?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_PAGINATION_LIMIT) + limit?: number; } diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index a2a266ac..251e127c 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -3,7 +3,6 @@ import { AccountType, CustomDraftStatus, IdentityProvider, - NotificationType, Prisma, } from '@prisma/client'; @@ -11,6 +10,48 @@ import { buildWithdrawnProviderSubject } from '@/common/utils/withdrawn-identity import { buildReviewLikedNotification } from '@/features/notification'; import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; +/** + * 알림 목록 select. 서브라인·딥링크용 연관 정보를 함께 당긴다. + * - 직접 연결(store/product): 리뷰 좋아요 알림 등이 사용. + * - order.items 폴백: 연관 ID를 저장하지 않던 과거 주문 알림 보강용. + * 상품명은 주문 시점 스냅샷(product_name_snapshot)을 써 상품 삭제에도 안전하다. + * nested select라 soft-delete 자동 필터가 닿지 않지만, 삭제된 매장·상품이어도 + * 알림 표기용 이름은 그대로 보여주는 게 정책이다(이름만 노출, 이동은 FE 판단). + */ +const notificationListSelect = { + id: true, + type: true, + event: true, + title: true, + body: true, + read_at: true, + created_at: true, + store_id: true, + product_id: true, + order_id: true, + review_id: true, + store: { select: { store_name: true } }, + product: { select: { name: true } }, + order: { + select: { + items: { + select: { + store_id: true, + product_id: true, + product_name_snapshot: true, + store: { select: { store_name: true } }, + }, + orderBy: { id: 'asc' as const }, + take: 1, + }, + }, + }, +} satisfies Prisma.NotificationSelect; + +export type NotificationListRow = Prisma.NotificationGetPayload<{ + select: typeof notificationListSelect; +}>; + export interface UserAccountIdentity { provider: IdentityProvider; last_login_at: Date | null; @@ -244,17 +285,23 @@ export class UserRepository { } } - async getViewerCounts(accountId: bigint): Promise<{ + async getViewerCounts(args: { + accountId: bigint; + notificationSince: Date; + }): Promise<{ unreadNotificationCount: number; cartItemCount: number; wishlistCount: number; }> { + const { accountId, notificationSince } = args; const [unreadNotificationCount, cartItemCount, wishlistCount] = await this.prisma.$transaction([ + // 3개월 밖 미읽 알림까지 세면 목록(myNotifications)과 배지 수가 어긋난다 this.prisma.notification.count({ where: { account_id: accountId, read_at: null, + created_at: { gte: notificationSince }, }, }), this.prisma.cartItem.count({ @@ -273,38 +320,44 @@ export class UserRepository { async listNotifications(args: { accountId: bigint; unreadOnly: boolean; - offset: number; limit: number; + since: Date; + cursor?: { createdAt: Date; id: bigint }; }): Promise<{ - items: { - id: bigint; - type: NotificationType; - title: string; - body: string; - read_at: Date | null; - created_at: Date; - }[]; + items: NotificationListRow[]; totalCount: number; }> { - const where = { + const where: Prisma.NotificationWhereInput = { account_id: args.accountId, + created_at: { gte: args.since }, ...(args.unreadOnly ? { read_at: null } : {}), }; + // (created_at, id) desc 키셋. created_at이 같은 행이 있어도 id 타이브레이크로 + // 페이지 중복/누락이 없다. since 조건과 키가 겹쳐 AND 배열로 분리한다. + const pageWhere: Prisma.NotificationWhereInput = args.cursor + ? { + AND: [ + where, + { + OR: [ + { created_at: { lt: args.cursor.createdAt } }, + { + created_at: args.cursor.createdAt, + id: { lt: args.cursor.id }, + }, + ], + }, + ], + } + : where; + const [items, totalCount] = await this.prisma.$transaction([ this.prisma.notification.findMany({ - where, - orderBy: { created_at: 'desc' }, - skip: args.offset, - take: args.limit, - select: { - id: true, - type: true, - title: true, - body: true, - read_at: true, - created_at: true, - }, + where: pageWhere, + orderBy: [{ created_at: 'desc' }, { id: 'desc' }], + take: args.limit + 1, + select: notificationListSelect, }), this.prisma.notification.count({ where }), ]); diff --git a/src/features/user/resolvers/user-notification.resolver.spec.ts b/src/features/user/resolvers/user-notification.resolver.spec.ts index c015fa43..fb51720c 100644 --- a/src/features/user/resolvers/user-notification.resolver.spec.ts +++ b/src/features/user/resolvers/user-notification.resolver.spec.ts @@ -53,7 +53,7 @@ describe('User Notification Resolvers (real DB)', () => { const result = await queryResolver.myNotifications( { accountId: account.id.toString() }, - { unreadOnly: true, offset: 0, limit: 10 }, + { unreadOnly: true, limit: 10 }, ); expect(result.totalCount).toBe(1); diff --git a/src/features/user/services/user-notification-mappers.helper.spec.ts b/src/features/user/services/user-notification-mappers.helper.spec.ts new file mode 100644 index 00000000..f111f059 --- /dev/null +++ b/src/features/user/services/user-notification-mappers.helper.spec.ts @@ -0,0 +1,113 @@ +import type { NotificationListRow } from '@/features/user/repositories/user.repository'; +import { toNotificationItem } from '@/features/user/services/user-notification-mappers.helper'; + +function baseRow(overrides: Partial): NotificationListRow { + return { + id: BigInt(1), + type: 'SYSTEM', + event: null, + title: '제목', + body: '본문', + read_at: null, + created_at: new Date('2026-08-01T00:00:00Z'), + store_id: null, + product_id: null, + order_id: null, + review_id: null, + store: null, + product: null, + order: null, + ...overrides, + }; +} + +describe('toNotificationItem', () => { + it('연관 엔티티가 없으면 부가 필드를 모두 null로 매핑한다', () => { + const item = toNotificationItem(baseRow({})); + + expect(item).toMatchObject({ + id: '1', + event: null, + orderId: null, + storeId: null, + productId: null, + reviewId: null, + storeName: null, + productName: null, + }); + }); + + it('직접 연결(store/product/review)을 우선 사용한다', () => { + const item = toNotificationItem( + baseRow({ + event: 'REVIEW_LIKED', + store_id: BigInt(10), + product_id: BigInt(20), + review_id: BigInt(30), + store: { store_name: '달콤 케이크' }, + product: { name: '크리스마스 케이크' }, + // 직접 컬럼이 있으면 order 폴백은 쓰지 않는다 + order: { + items: [ + { + store_id: BigInt(99), + product_id: BigInt(98), + product_name_snapshot: '스냅샷', + store: { store_name: '다른 매장' }, + }, + ], + }, + }), + ); + + expect(item).toMatchObject({ + event: 'REVIEW_LIKED', + storeId: '10', + storeName: '달콤 케이크', + productId: '20', + productName: '크리스마스 케이크', + reviewId: '30', + }); + }); + + it('직접 컬럼이 없으면 order.items 폴백으로 채운다(상품명은 스냅샷)', () => { + const item = toNotificationItem( + baseRow({ + event: 'ORDER_PICKED_UP', + order_id: BigInt(5), + order: { + items: [ + { + store_id: BigInt(10), + product_id: BigInt(20), + product_name_snapshot: '주문 시점 상품명', + store: { store_name: '해즈 케이크' }, + }, + ], + }, + }), + ); + + expect(item).toMatchObject({ + orderId: '5', + storeId: '10', + storeName: '해즈 케이크', + productId: '20', + productName: '주문 시점 상품명', + }); + }); + + it('order.items가 비어 있어도 안전하게 null로 남긴다', () => { + const item = toNotificationItem( + baseRow({ order_id: BigInt(5), order: { items: [] } }), + ); + + expect(item).toMatchObject({ + orderId: '5', + storeId: null, + storeName: null, + productId: null, + productName: null, + }); + }); +}); diff --git a/src/features/user/services/user-notification-mappers.helper.ts b/src/features/user/services/user-notification-mappers.helper.ts new file mode 100644 index 00000000..156c32b0 --- /dev/null +++ b/src/features/user/services/user-notification-mappers.helper.ts @@ -0,0 +1,36 @@ +import type { NotificationListRow } from '@/features/user/repositories/user.repository'; +import type { NotificationItem } from '@/features/user/types/user-output.type'; + +/** + * 알림 row → 출력 매핑. DI-free 순수 함수. + * + * 연관 매장·상품은 직접 컬럼(store/product) 우선, 없으면 order.items 폴백 — + * 연관 ID를 저장하지 않던 과거 주문 알림도 서브라인·딥링크 정보를 채우기 위함. + * 주문 폴백의 상품명은 주문 시점 스냅샷이라 이후 상품명 변경·삭제와 무관하다. + */ +export function toNotificationItem(row: NotificationListRow): NotificationItem { + const orderItem = row.order?.items[0] ?? null; + + const storeId = row.store_id ?? orderItem?.store_id ?? null; + const storeName = + row.store?.store_name ?? orderItem?.store.store_name ?? null; + const productId = row.product_id ?? orderItem?.product_id ?? null; + const productName = + row.product?.name ?? orderItem?.product_name_snapshot ?? null; + + return { + id: row.id.toString(), + type: row.type, + event: row.event, + title: row.title, + body: row.body, + orderId: row.order_id?.toString() ?? null, + storeId: storeId?.toString() ?? null, + productId: productId?.toString() ?? null, + reviewId: row.review_id?.toString() ?? null, + storeName, + productName, + readAt: row.read_at, + createdAt: row.created_at, + }; +} diff --git a/src/features/user/services/user-notification.service.spec.ts b/src/features/user/services/user-notification.service.spec.ts index 37077ec1..69344479 100644 --- a/src/features/user/services/user-notification.service.spec.ts +++ b/src/features/user/services/user-notification.service.spec.ts @@ -1,4 +1,8 @@ -import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import type { PrismaClient } from '@prisma/client'; import { UserRepository } from '@/features/user/repositories/user.repository'; @@ -8,6 +12,11 @@ import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, createNotification, + createOrder, + createOrderItem, + createProduct, + createReview, + createStore, createUserProfile, } from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; @@ -39,6 +48,11 @@ describe('UserNotificationService (real DB)', () => { return account; } + // 3개월 노출 필터가 "지금" 기준이라 케이스 날짜도 상대 시각으로 만든다 + function daysAgo(days: number): Date { + return new Date(Date.now() - days * 24 * 60 * 60 * 1000); + } + // ─── viewerCounts ─── describe('viewerCounts', () => { it('미읽 알림 수 / 장바구니 / 위시리스트 수를 반환한다', async () => { @@ -59,6 +73,19 @@ describe('UserNotificationService (real DB)', () => { expect(result.wishlistCount).toBe(0); }); + it('3개월 지난 미읽 알림은 배지 수에서 제외한다(목록과 일치)', async () => { + const account = await setupUser(); + await createNotification(prisma, { account_id: account.id }); + await createNotification(prisma, { + account_id: account.id, + created_at: daysAgo(100), + }); + + const result = await service.viewerCounts(account.id); + + expect(result.unreadNotificationCount).toBe(1); + }); + it('계정이 없으면 UnauthorizedException을 던진다', async () => { await expect(service.viewerCounts(BigInt(999999))).rejects.toThrow( UnauthorizedException, @@ -75,26 +102,30 @@ describe('UserNotificationService (real DB)', () => { account_id: account.id, title: '오래된', body: '바디1', - created_at: new Date('2026-04-01'), + created_at: daysAgo(2), }); const newer = await createNotification(prisma, { account_id: account.id, title: '최근', body: '바디2', - created_at: new Date('2026-04-20'), + event: 'ORDER_CONFIRMED', + created_at: daysAgo(1), }); - const result = await service.myNotifications(account.id, { - offset: 0, - limit: 10, - }); + const result = await service.myNotifications(account.id, { limit: 10 }); expect(result.totalCount).toBe(2); expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); expect(result.items[0].id).toBe(newer.id.toString()); expect(result.items[1].id).toBe(older.id.toString()); expect(result.items[0].title).toBe('최근'); + expect(result.items[0].event).toBe('ORDER_CONFIRMED'); expect(result.items[0].readAt).toBeNull(); + // 연관 엔티티가 없는 알림은 부가 필드가 모두 null + expect(result.items[0].storeId).toBeNull(); + expect(result.items[0].storeName).toBeNull(); + expect(result.items[0].productName).toBeNull(); }); it('unreadOnly=true면 read_at이 null인 것만 반환한다', async () => { @@ -107,7 +138,6 @@ describe('UserNotificationService (real DB)', () => { const result = await service.myNotifications(account.id, { unreadOnly: true, - offset: 0, limit: 10, }); @@ -115,23 +145,133 @@ describe('UserNotificationService (real DB)', () => { expect(result.items[0].readAt).toBeNull(); }); - it('offset + limit < totalCount면 hasMore true', async () => { + it('커서로 다음 페이지를 이어간다 — 같은 created_at은 id로 타이브레이크', async () => { const account = await setupUser(); + const sameMoment = daysAgo(1); + const ids: bigint[] = []; for (let i = 0; i < 3; i++) { - await createNotification(prisma, { + const n = await createNotification(prisma, { account_id: account.id, - created_at: new Date(2026, 3, 20 - i), + created_at: sameMoment, }); + ids.push(n.id); } - - const result = await service.myNotifications(account.id, { - offset: 0, + const idDesc = [...ids].sort((a, b) => (a < b ? 1 : -1)); + + const page1 = await service.myNotifications(account.id, { limit: 2 }); + expect(page1.totalCount).toBe(3); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).not.toBeNull(); + expect(page1.items.map((i) => i.id)).toEqual([ + idDesc[0].toString(), + idDesc[1].toString(), + ]); + + const page2 = await service.myNotifications(account.id, { limit: 2, + cursor: page1.nextCursor!, }); + expect(page2.items.map((i) => i.id)).toEqual([idDesc[2].toString()]); + expect(page2.hasMore).toBe(false); + expect(page2.nextCursor).toBeNull(); + // totalCount는 커서와 무관하게 전체 기준을 유지한다 + expect(page2.totalCount).toBe(3); + }); + + it('형식이 잘못된 커서는 거절한다', async () => { + const account = await setupUser(); + + await expect( + service.myNotifications(account.id, { cursor: 'abc' }), + ).rejects.toThrow(BadRequestException); + // 자릿수 폭탄 — Number 변환 시 안전 정수 범위를 벗어나는 값 + await expect( + service.myNotifications(account.id, { cursor: `${'9'.repeat(30)}:1` }), + ).rejects.toThrow(BadRequestException); + }); - expect(result.totalCount).toBe(3); - expect(result.hasMore).toBe(true); - expect(result.items).toHaveLength(2); + it('3개월 지난 알림은 목록·totalCount에서 제외한다', async () => { + const account = await setupUser(); + const recent = await createNotification(prisma, { + account_id: account.id, + created_at: daysAgo(80), + }); + await createNotification(prisma, { + account_id: account.id, + created_at: daysAgo(100), + }); + + const result = await service.myNotifications(account.id); + + expect(result.totalCount).toBe(1); + expect(result.items.map((i) => i.id)).toEqual([recent.id.toString()]); + }); + + it('직접 연결된 매장·상품·리뷰 정보를 노출한다(리뷰 좋아요 형태)', async () => { + const account = await setupUser(); + const store = await createStore(prisma, { store_name: '달콤 케이크' }); + const product = await createProduct(prisma, { + store_id: store.id, + name: '크리스마스 케이크', + }); + const review = await createReview(prisma, { + order_item_id: ( + await createOrderItem(prisma, { product_id: product.id }) + ).id, + }); + + const notif = await createNotification(prisma, { + account_id: account.id, + type: 'REVIEW_LIKE', + event: 'REVIEW_LIKED', + store_id: store.id, + product_id: product.id, + review_id: review.id, + }); + + const result = await service.myNotifications(account.id); + + const item = result.items.find((i) => i.id === notif.id.toString()); + expect(item).toMatchObject({ + event: 'REVIEW_LIKED', + storeId: store.id.toString(), + storeName: '달콤 케이크', + productId: product.id.toString(), + productName: '크리스마스 케이크', + reviewId: review.id.toString(), + }); + }); + + it('연관 ID가 없는 과거 주문 알림은 order.items로 매장·상품을 보강한다', async () => { + const account = await setupUser(); + const store = await createStore(prisma, { store_name: '해즈 케이크' }); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { account_id: account.id }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + product_name_snapshot: '주문 시점 상품명', + }); + + const notif = await createNotification(prisma, { + account_id: account.id, + type: 'ORDER_STATUS', + event: 'ORDER_PICKED_UP', + order_id: order.id, + // store_id / product_id 미저장 — 과거 데이터 재현 + }); + + const result = await service.myNotifications(account.id); + + const item = result.items.find((i) => i.id === notif.id.toString()); + expect(item).toMatchObject({ + orderId: order.id.toString(), + storeId: store.id.toString(), + storeName: '해즈 케이크', + productId: product.id.toString(), + // 주문 폴백의 상품명은 스냅샷을 쓴다(상품 삭제·개명에도 안전) + productName: '주문 시점 상품명', + }); }); it('다른 계정의 알림은 섞여 나오지 않는다', async () => { diff --git a/src/features/user/services/user-notification.service.ts b/src/features/user/services/user-notification.service.ts index c7bec284..bee426ea 100644 --- a/src/features/user/services/user-notification.service.ts +++ b/src/features/user/services/user-notification.service.ts @@ -1,9 +1,19 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; -import { hasMoreByOffset } from '@/common/utils/pagination'; +import { sliceCursorPage } from '@/common/utils/pagination'; +import { USER_NOTIFICATION_ERRORS } from '@/features/user/constants/user-notification-error-messages'; +import { + DEFAULT_PAGINATION_LIMIT, + NOTIFICATION_VISIBLE_MONTHS, +} from '@/features/user/constants/user.constants'; import type { MyNotificationsInput } from '@/features/user/dto/inputs/my-notifications.input'; import { UserRepository } from '@/features/user/repositories/user.repository'; import { UserBaseService } from '@/features/user/services/user-base.service'; +import { toNotificationItem } from '@/features/user/services/user-notification-mappers.helper'; import type { NotificationConnection, ViewerCounts, @@ -17,7 +27,10 @@ export class UserNotificationService extends UserBaseService { async viewerCounts(accountId: bigint): Promise { await this.requireActiveUser(accountId); - return this.repo.getViewerCounts(accountId); + return this.repo.getViewerCounts({ + accountId, + notificationSince: this.notificationVisibleSince(), + }); } async myNotifications( @@ -26,25 +39,32 @@ export class UserNotificationService extends UserBaseService { ): Promise { await this.requireActiveUser(accountId); - const { offset, limit, unreadOnly } = this.normalizePaginationInput(input); + const limit = input?.limit ?? DEFAULT_PAGINATION_LIMIT; + const unreadOnly = Boolean(input?.unreadOnly); + const cursor = input?.cursor + ? this.parseNotificationCursor(input.cursor) + : undefined; + const result = await this.repo.listNotifications({ accountId, unreadOnly, - offset, limit, + since: this.notificationVisibleSince(), + cursor, }); + // (created_at, id) desc 정렬과 결합된 커서 — 정렬이 바뀌면 무효다. + const page = sliceCursorPage( + result.items, + limit, + (last) => `${last.created_at.getTime()}:${last.id.toString()}`, + ); + return { - items: result.items.map((item) => ({ - id: item.id.toString(), - type: item.type, - title: item.title, - body: item.body, - readAt: item.read_at, - createdAt: item.created_at, - })), + items: page.items.map(toNotificationItem), totalCount: result.totalCount, - hasMore: hasMoreByOffset(offset, limit, result.totalCount), + hasMore: page.hasMore, + nextCursor: page.nextCursor, }; } @@ -61,7 +81,9 @@ export class UserNotificationService extends UserBaseService { }); if (!updated) { - throw new NotFoundException('Notification not found.'); + throw new NotFoundException( + USER_NOTIFICATION_ERRORS.NOTIFICATION_NOT_FOUND, + ); } return true; @@ -72,4 +94,30 @@ export class UserNotificationService extends UserBaseService { await this.repo.markAllNotificationsRead({ accountId, now: new Date() }); return true; } + + /** + * "최근 3개월" 노출 하한. setMonth 롤오버(예: 5/31 → 3/1 아님, 3/3)로 + * 말일 경계가 며칠 어긋날 수 있으나 안내 문구 수준의 정밀도로 충분하다. + */ + private notificationVisibleSince(): Date { + const since = new Date(); + since.setMonth(since.getMonth() - NOTIFICATION_VISIBLE_MONTHS); + return since; + } + + /** 커서 파싱: ":". 형식·안전 정수 범위를 벗어나면 거부. */ + private parseNotificationCursor(raw: string): { + createdAt: Date; + id: bigint; + } { + const match = /^(\d+):(\d+)$/.exec(raw); + if (!match) { + throw new BadRequestException(USER_NOTIFICATION_ERRORS.INVALID_CURSOR); + } + const createdAtMs = Number(match[1]); + if (!Number.isSafeInteger(createdAtMs)) { + throw new BadRequestException(USER_NOTIFICATION_ERRORS.INVALID_CURSOR); + } + return { createdAt: new Date(createdAtMs), id: BigInt(match[2]) }; + } } diff --git a/src/features/user/types/user-output.type.ts b/src/features/user/types/user-output.type.ts index 08189ff0..2e218528 100644 --- a/src/features/user/types/user-output.type.ts +++ b/src/features/user/types/user-output.type.ts @@ -1,6 +1,7 @@ import type { AccountType, IdentityProvider, + NotificationEvent, NotificationType, } from '@prisma/client'; @@ -35,8 +36,15 @@ export interface ViewerCounts { export interface NotificationItem { id: string; type: NotificationType; + event: NotificationEvent | null; title: string; body: string; + orderId: string | null; + storeId: string | null; + productId: string | null; + reviewId: string | null; + storeName: string | null; + productName: string | null; readAt: Date | null; createdAt: Date; } @@ -45,6 +53,7 @@ export interface NotificationConnection { items: NotificationItem[]; totalCount: number; hasMore: boolean; + nextCursor: string | null; } export interface SearchHistoryItem { diff --git a/src/features/user/user-common.graphql b/src/features/user/user-common.graphql index 25cf8eaf..a6ddb25a 100644 --- a/src/features/user/user-common.graphql +++ b/src/features/user/user-common.graphql @@ -10,3 +10,11 @@ enum NotificationType { SYSTEM MARKETING } + +"""알림 이벤트. 알림센터의 아이콘·라벨 매핑 기준 (figma notification-center)""" +enum NotificationEvent { + REVIEW_LIKED + ORDER_CONFIRMED + ORDER_MADE + ORDER_PICKED_UP +} diff --git a/src/features/user/user-notification.graphql b/src/features/user/user-notification.graphql index 949b4caa..183e6a0f 100644 --- a/src/features/user/user-notification.graphql +++ b/src/features/user/user-notification.graphql @@ -26,20 +26,22 @@ type ViewerCounts { input MyNotificationsInput { """읽지 않은 알림만 조회""" unreadOnly: Boolean = false - """오프셋""" - offset: Int = 0 + """이전 응답의 nextCursor(불투명 토큰). 미지정 시 첫 페이지""" + cursor: String """조회 개수(최대 50)""" limit: Int = 20 } -"""알림 목록 응답""" +"""알림 목록 응답. 최근 3개월 내 알림만 노출한다.""" type NotificationConnection { """알림 아이템 목록""" items: [NotificationItem!]! - """전체 개수""" + """전체 개수(3개월 내 기준)""" totalCount: Int! """다음 페이지 존재 여부""" hasMore: Boolean! + """다음 페이지 커서. 없으면 null""" + nextCursor: String } """알림 아이템""" @@ -48,10 +50,24 @@ type NotificationItem { id: ID! """알림 타입""" type: NotificationType! + """알림 이벤트(아이콘·라벨 매핑용). 이벤트 없는 일반 알림은 null""" + event: NotificationEvent """제목""" title: String! """내용""" body: String! + """연관 주문 ID(딥링크용)""" + orderId: ID + """연관 매장 ID(딥링크용)""" + storeId: ID + """연관 상품 ID(딥링크용)""" + productId: ID + """연관 리뷰 ID(리뷰 보러가기 딥링크용)""" + reviewId: ID + """서브라인 조립용 매장명. 연관 매장이 없으면 null""" + storeName: String + """서브라인 조립용 상품명(주문 알림은 주문 시점 스냅샷). 없으면 null""" + productName: String """읽음 처리 시각""" readAt: DateTime """생성 시각""" From a48c9077b7e8d02490cf2f68bebaec8796d3e2b1 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 03:03:22 +0900 Subject: [PATCH 02/27] =?UTF-8?q?fix(user):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=95=8C=EB=A6=BC=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EB=AA=85=EC=9D=80=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=20(PR=20#267=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 Codex 지적 반영: 주문 상태 알림에 product_id를 저장하면서 현재 상품명이 주문 시점 스냅샷을 덮어쓰게 된 문제. 주문 연결 알림은 직접 연결(product)이 있어도 order.items의 product_name_snapshot을 우선해 SDL 계약(주문 시점 스냅샷)을 지킨다. 주문이 없는 알림(리뷰 좋아요)만 현재 상품명을 쓴다. - 매퍼 단위 spec에 "주문 연결 + 직접 연결 동시 존재 → 스냅샷 우선" 케이스 추가 --- .../user-notification-mappers.helper.spec.ts | 39 ++++++++++++++----- .../user-notification-mappers.helper.ts | 12 ++++-- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/features/user/services/user-notification-mappers.helper.spec.ts b/src/features/user/services/user-notification-mappers.helper.spec.ts index f111f059..e3fed256 100644 --- a/src/features/user/services/user-notification-mappers.helper.spec.ts +++ b/src/features/user/services/user-notification-mappers.helper.spec.ts @@ -37,7 +37,7 @@ describe('toNotificationItem', () => { }); }); - it('직접 연결(store/product/review)을 우선 사용한다', () => { + it('주문이 없는 알림(리뷰 좋아요)은 직접 연결의 현재 상품명을 쓴다', () => { const item = toNotificationItem( baseRow({ event: 'REVIEW_LIKED', @@ -46,14 +46,36 @@ describe('toNotificationItem', () => { review_id: BigInt(30), store: { store_name: '달콤 케이크' }, product: { name: '크리스마스 케이크' }, - // 직접 컬럼이 있으면 order 폴백은 쓰지 않는다 + }), + ); + + expect(item).toMatchObject({ + event: 'REVIEW_LIKED', + storeId: '10', + storeName: '달콤 케이크', + productId: '20', + productName: '크리스마스 케이크', + reviewId: '30', + }); + }); + + it('주문 연결 알림은 product 직접 연결이 있어도 상품명은 스냅샷을 우선한다', () => { + const item = toNotificationItem( + baseRow({ + event: 'ORDER_CONFIRMED', + order_id: BigInt(5), + store_id: BigInt(10), + product_id: BigInt(20), + store: { store_name: '달콤 케이크' }, + // 체크아웃 이후 개명된 현재 상품명 — 알림에는 노출되면 안 된다 + product: { name: '개명된 케이크' }, order: { items: [ { - store_id: BigInt(99), - product_id: BigInt(98), - product_name_snapshot: '스냅샷', - store: { store_name: '다른 매장' }, + store_id: BigInt(10), + product_id: BigInt(20), + product_name_snapshot: '주문 시점 상품명', + store: { store_name: '달콤 케이크' }, }, ], }, @@ -61,12 +83,11 @@ describe('toNotificationItem', () => { ); expect(item).toMatchObject({ - event: 'REVIEW_LIKED', + orderId: '5', storeId: '10', storeName: '달콤 케이크', productId: '20', - productName: '크리스마스 케이크', - reviewId: '30', + productName: '주문 시점 상품명', }); }); diff --git a/src/features/user/services/user-notification-mappers.helper.ts b/src/features/user/services/user-notification-mappers.helper.ts index 156c32b0..207b56ab 100644 --- a/src/features/user/services/user-notification-mappers.helper.ts +++ b/src/features/user/services/user-notification-mappers.helper.ts @@ -4,9 +4,9 @@ import type { NotificationItem } from '@/features/user/types/user-output.type'; /** * 알림 row → 출력 매핑. DI-free 순수 함수. * - * 연관 매장·상품은 직접 컬럼(store/product) 우선, 없으면 order.items 폴백 — + * 연관 ID·매장명은 직접 컬럼(store/product) 우선, 없으면 order.items 폴백 — * 연관 ID를 저장하지 않던 과거 주문 알림도 서브라인·딥링크 정보를 채우기 위함. - * 주문 폴백의 상품명은 주문 시점 스냅샷이라 이후 상품명 변경·삭제와 무관하다. + * 단 상품명은 주문 연결 알림이면 항상 주문 시점 스냅샷(이후 개명·삭제와 무관). */ export function toNotificationItem(row: NotificationListRow): NotificationItem { const orderItem = row.order?.items[0] ?? null; @@ -15,8 +15,12 @@ export function toNotificationItem(row: NotificationListRow): NotificationItem { const storeName = row.store?.store_name ?? orderItem?.store.store_name ?? null; const productId = row.product_id ?? orderItem?.product_id ?? null; - const productName = - row.product?.name ?? orderItem?.product_name_snapshot ?? null; + // 주문 연결 알림은 product_id가 저장돼 있어도 주문 시점 스냅샷을 우선한다 + // (SDL 계약) — 체크아웃 이후 상품 개명이 알림 문맥을 바꾸면 안 된다. + // 주문이 없는 알림(리뷰 좋아요 등)만 현재 상품명을 쓴다. + const productName = orderItem + ? orderItem.product_name_snapshot + : (row.product?.name ?? null); return { id: row.id.toString(), From 05bcf0138b5cf1a2fc14a49007af588fd9967a71 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 03:08:50 +0900 Subject: [PATCH 03/27] =?UTF-8?q?fix(user):=20Date=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=B0=96=20=EC=BB=A4=EC=84=9C=20timestamp=20=EA=B1=B0=EB=B6=80?= =?UTF-8?q?=20(PR=20#267=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 Codex 지적 반영: 안전 정수라도 Date 지원 범위(±8.64e15ms)를 넘는 커서 timestamp는 Invalid Date가 되어 Prisma 필터에서 내부 오류로 번진다. new Date 변환 후 getTime() NaN 검사로 형식 오류(BadRequest)로 선제 거부. - 커서 거절 spec에 "9000000000000000:1" 케이스 추가 --- .../user/services/user-notification.service.spec.ts | 4 ++++ src/features/user/services/user-notification.service.ts | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/features/user/services/user-notification.service.spec.ts b/src/features/user/services/user-notification.service.spec.ts index 69344479..f7c91191 100644 --- a/src/features/user/services/user-notification.service.spec.ts +++ b/src/features/user/services/user-notification.service.spec.ts @@ -188,6 +188,10 @@ describe('UserNotificationService (real DB)', () => { await expect( service.myNotifications(account.id, { cursor: `${'9'.repeat(30)}:1` }), ).rejects.toThrow(BadRequestException); + // 안전 정수지만 Date 지원 범위(±8.64e15ms)를 넘는 timestamp + await expect( + service.myNotifications(account.id, { cursor: '9000000000000000:1' }), + ).rejects.toThrow(BadRequestException); }); it('3개월 지난 알림은 목록·totalCount에서 제외한다', async () => { diff --git a/src/features/user/services/user-notification.service.ts b/src/features/user/services/user-notification.service.ts index bee426ea..e52c0580 100644 --- a/src/features/user/services/user-notification.service.ts +++ b/src/features/user/services/user-notification.service.ts @@ -118,6 +118,12 @@ export class UserNotificationService extends UserBaseService { if (!Number.isSafeInteger(createdAtMs)) { throw new BadRequestException(USER_NOTIFICATION_ERRORS.INVALID_CURSOR); } - return { createdAt: new Date(createdAtMs), id: BigInt(match[2]) }; + const createdAt = new Date(createdAtMs); + // 안전 정수여도 Date 지원 범위(±8.64e15ms) 밖이면 Invalid Date가 되어 + // Prisma 필터에서 내부 오류로 번진다 — 형식 오류로 선제 거부한다. + if (Number.isNaN(createdAt.getTime())) { + throw new BadRequestException(USER_NOTIFICATION_ERRORS.INVALID_CURSOR); + } + return { createdAt, id: BigInt(match[2]) }; } } From df00c03404fd13c4a7508ff48a90dfe3440cc47d Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 03:14:21 +0900 Subject: [PATCH 04/27] =?UTF-8?q?fix(user):=20UNSIGNED=20BIGINT=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=20=EB=84=98=EB=8A=94=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?id=20=EA=B1=B0=EB=B6=80=20(PR=20#267=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 지적 반영: 커서 id 세그먼트가 DB UNSIGNED BIGINT 상한(2^64-1)을 넘으면 Prisma 커넥터 범위 오류로 번진다. 파싱 시 상한 초과를 형식 오류(BadRequest)로 선제 거부한다. - MAX_UNSIGNED_BIGINT 상수 추가(user.constants), 커서 거절 spec 케이스 추가 --- src/features/user/constants/user.constants.ts | 3 +++ .../user/services/user-notification.service.spec.ts | 6 ++++++ src/features/user/services/user-notification.service.ts | 9 ++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/features/user/constants/user.constants.ts b/src/features/user/constants/user.constants.ts index 72649883..0862c0a8 100644 --- a/src/features/user/constants/user.constants.ts +++ b/src/features/user/constants/user.constants.ts @@ -30,3 +30,6 @@ export const MAX_REVIEW_COMMENT_LENGTH = 500; // figma notification-center: "최근 3개월 내의 알림만 확인할 수 있어요." // 삭제가 아니라 조회 필터로만 강제한다(사용자 확정 정책). export const NOTIFICATION_VISIBLE_MONTHS = 3; + +// DB UNSIGNED BIGINT 상한(2^64-1). 커서 등 외부 입력 id의 범위 방어에 쓴다. +export const MAX_UNSIGNED_BIGINT = 18446744073709551615n; diff --git a/src/features/user/services/user-notification.service.spec.ts b/src/features/user/services/user-notification.service.spec.ts index f7c91191..f3e433ab 100644 --- a/src/features/user/services/user-notification.service.spec.ts +++ b/src/features/user/services/user-notification.service.spec.ts @@ -192,6 +192,12 @@ describe('UserNotificationService (real DB)', () => { await expect( service.myNotifications(account.id, { cursor: '9000000000000000:1' }), ).rejects.toThrow(BadRequestException); + // UNSIGNED BIGINT 상한을 넘는 id + await expect( + service.myNotifications(account.id, { + cursor: `1700000000000:${'9'.repeat(30)}`, + }), + ).rejects.toThrow(BadRequestException); }); it('3개월 지난 알림은 목록·totalCount에서 제외한다', async () => { diff --git a/src/features/user/services/user-notification.service.ts b/src/features/user/services/user-notification.service.ts index e52c0580..6b32065c 100644 --- a/src/features/user/services/user-notification.service.ts +++ b/src/features/user/services/user-notification.service.ts @@ -8,6 +8,7 @@ import { sliceCursorPage } from '@/common/utils/pagination'; import { USER_NOTIFICATION_ERRORS } from '@/features/user/constants/user-notification-error-messages'; import { DEFAULT_PAGINATION_LIMIT, + MAX_UNSIGNED_BIGINT, NOTIFICATION_VISIBLE_MONTHS, } from '@/features/user/constants/user.constants'; import type { MyNotificationsInput } from '@/features/user/dto/inputs/my-notifications.input'; @@ -124,6 +125,12 @@ export class UserNotificationService extends UserBaseService { if (Number.isNaN(createdAt.getTime())) { throw new BadRequestException(USER_NOTIFICATION_ERRORS.INVALID_CURSOR); } - return { createdAt, id: BigInt(match[2]) }; + const id = BigInt(match[2]); + // id 컬럼은 UNSIGNED BIGINT — 그 최댓값을 넘는 값도 커넥터 범위 오류로 + // 번지기 전에 형식 오류로 거부한다. + if (id > MAX_UNSIGNED_BIGINT) { + throw new BadRequestException(USER_NOTIFICATION_ERRORS.INVALID_CURSOR); + } + return { createdAt, id }; } } From 46a25439208bbaca40b2d0f2e96015ae9e4d0358 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 03:20:36 +0900 Subject: [PATCH 05/27] =?UTF-8?q?feat(conversation):=20=EA=B5=AC=EB=A7=A4?= =?UTF-8?q?=EC=9E=90=20=EB=AC=B8=EC=9D=98=20=EC=B1=84=ED=8C=85=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=E2=80=94=20=EC=A7=84=EC=9E=85=20=EC=BB=A8=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=C2=B7=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=C2=B7FAQ=20=EC=9E=90=EB=8F=99=EC=9D=91=EB=8B=B5=C2=B7?= =?UTF-8?q?=EC=9D=B8=EC=82=AC=EB=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit figma 알림센터(문의 채팅) 화면 대응 2/4. 대화 모델·판매자 API만 있던 conversation feature에 구매자 측 진입·전송 경로를 신설한다. 변경점 - Prisma: store.greeting_message VARCHAR(500) 추가(마이그레이션 동반) — 문의 채팅 인사말 템플릿({nickname}/{storeName} 치환, null이면 기본 문구) - Query storeInquiryContext(storeId): 매장 프로필·요일별 상담시간(영업시간 rows, "HH:mm")·치환 완료 인사말·질문 칩(활성 StoreFaqTopic)·기존 대화 ID - Mutation sendConversationMessage: 텍스트 전송. 첫 전송 시 대화 생성 + 치환 완료 인사말을 STORE 메시지로 선저장(대화당 1회, 이력 보존) - Mutation sendConversationFaqMessage: 칩 탭 → 유저 질문(TEXT) + 매장 자동응답(FAQ answer_html, HTML) 트랜잭션 저장. 답변은 저장 시점 스냅샷 - 대화 upsert는 (account_id, store_id) 유니크와 동일 범위(삭제 포함)로 조회해 P2002를 예방하고, 동시 첫 전송 레이스는 P2002 복구로 방어 - 판매자: sellerUpdateStoreBasicInfo에 greetingMessage 확장(빈 문자열 → null 저장 = 기본 문구 복귀) + SellerStore에 노출. 별도 mutation 대신 기존 basic info 확장(자체 판단 — API 표면 최소화) - 활성 USER 판정은 user feature의 evaluateActiveUserAccount 정책 공유 - 시드: FAQ 칩 5종 + 대화 2건(칩 문답·판매자 답장 3건 안읽음 재현), resetSeedScope에 대화·FAQ 정리 범위 추가 회귀 테스트 - service spec 12케이스(기본/커스텀 인사말 치환, FAQ 정렬·비활성 제외, 첫 전송 인사말 선저장, 중복 대화 방지, soft-delete 대화 재사용, 본문 검증, 비활성 매장·FAQ 거절, 권한 3종) - 매퍼 helper 순수 단위 6케이스, resolver 통합 2케이스, input spec 6케이스, seller greeting 설정/초기화 케이스 --- .../migration.sql | 2 + prisma/schema.prisma | 2 + prisma/seed.ts | 4 + prisma/seed/conversations.ts | 151 ++++++++ prisma/seed/idempotent.ts | 33 ++ src/app.module.ts | 2 + .../constants/conversation-error-messages.ts | 9 + .../constants/conversation.constants.ts | 11 + .../conversation/conversation-inquiry.graphql | 95 +++++ .../conversation/conversation.module.ts | 10 +- ...end-conversation-faq-message.input.spec.ts | 27 ++ .../send-conversation-faq-message.input.ts | 11 + .../send-conversation-message.input.spec.ts | 27 ++ .../inputs/send-conversation-message.input.ts | 11 + .../repositories/conversation.repository.ts | 181 ++++++++- .../conversation-inquiry-mutation.resolver.ts | 37 ++ .../conversation-inquiry-query.resolver.ts | 26 ++ .../conversation-inquiry.resolver.spec.ts | 91 +++++ ...onversation-inquiry-mappers.helper.spec.ts | 75 ++++ .../conversation-inquiry-mappers.helper.ts | 69 ++++ .../conversation-inquiry.service.spec.ts | 344 ++++++++++++++++++ .../services/conversation-inquiry.service.ts | 192 ++++++++++ .../types/conversation-output.type.ts | 41 +++ .../seller/constants/seller.constants.ts | 2 + .../seller-update-store-basic-info.input.ts | 4 + src/features/seller/seller-store.graphql | 2 + .../services/seller-store-mappers.helper.ts | 2 + .../seller-store-profile.service.spec.ts | 23 ++ .../services/seller-store-profile.service.ts | 10 + .../seller/types/seller-output.type.ts | 1 + src/test/factories/store.factory.ts | 4 + 31 files changed, 1496 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260901175648_add_store_greeting_message/migration.sql create mode 100644 prisma/seed/conversations.ts create mode 100644 src/features/conversation/constants/conversation-error-messages.ts create mode 100644 src/features/conversation/constants/conversation.constants.ts create mode 100644 src/features/conversation/conversation-inquiry.graphql create mode 100644 src/features/conversation/dto/inputs/send-conversation-faq-message.input.spec.ts create mode 100644 src/features/conversation/dto/inputs/send-conversation-faq-message.input.ts create mode 100644 src/features/conversation/dto/inputs/send-conversation-message.input.spec.ts create mode 100644 src/features/conversation/dto/inputs/send-conversation-message.input.ts create mode 100644 src/features/conversation/resolvers/conversation-inquiry-mutation.resolver.ts create mode 100644 src/features/conversation/resolvers/conversation-inquiry-query.resolver.ts create mode 100644 src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts create mode 100644 src/features/conversation/services/conversation-inquiry-mappers.helper.spec.ts create mode 100644 src/features/conversation/services/conversation-inquiry-mappers.helper.ts create mode 100644 src/features/conversation/services/conversation-inquiry.service.spec.ts create mode 100644 src/features/conversation/services/conversation-inquiry.service.ts create mode 100644 src/features/conversation/types/conversation-output.type.ts diff --git a/prisma/migrations/20260901175648_add_store_greeting_message/migration.sql b/prisma/migrations/20260901175648_add_store_greeting_message/migration.sql new file mode 100644 index 00000000..c58250f6 --- /dev/null +++ b/prisma/migrations/20260901175648_add_store_greeting_message/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `store` ADD COLUMN `greeting_message` VARCHAR(500) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 47f39fde..491dde35 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -359,6 +359,8 @@ model Store { website_url String? @db.VarChar(2048) // 매장 프로필(로고) 이미지. 찜/매장별 보기 카드의 원형 프로필 표기용 (figma liked 02·04) profile_image_url String? @db.VarChar(2048) + // 문의 채팅 인사말 템플릿({nickname}/{storeName} 치환). null이면 서버 기본 문구 (figma notification-center) + greeting_message String? @db.VarChar(500) is_active Boolean @default(true) @db.TinyInt created_at DateTime @default(now()) @db.DateTime(3) diff --git a/prisma/seed.ts b/prisma/seed.ts index 5b541454..f614361b 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -13,6 +13,7 @@ import { PrismaClient } from '@prisma/client'; import { seedBanners } from './seed/banners'; import { seedCategories } from './seed/categories'; +import { seedConversations } from './seed/conversations'; import { seedCustomDrafts } from './seed/custom-drafts'; import { resetSeedScope } from './seed/idempotent'; import { seedNotifications } from './seed/notifications'; @@ -63,6 +64,9 @@ async function main(): Promise { log('알림 시드 중...'); await seedNotifications(prisma, { users, stores, orders }); + log('대화 + FAQ 시드 중...'); + await seedConversations(prisma, { users, stores }); + log('커스텀 드래프트 시드 중...'); await seedCustomDrafts(prisma, { users, stores }); diff --git a/prisma/seed/conversations.ts b/prisma/seed/conversations.ts new file mode 100644 index 00000000..c769e390 --- /dev/null +++ b/prisma/seed/conversations.ts @@ -0,0 +1,151 @@ +/** + * 시드 대화 + FAQ 칩 (figma notification-center 대화 탭·문의 채팅 재현). + * + * - storeA: 커스텀 인사말 + FAQ 5종(질문 칩) 등록. + * user1 대화 = 인사말 → 칩 질문("케이크 보관 방법") → HTML 자동응답. + * - storeB: 인사말 미설정(기본 문구 사용 경로 검증). + * user1 대화 = 인사말 → 유저 자유 텍스트 → 판매자 답장 3건(안읽음 배지 재현, + * last_read_at은 유저 메시지 시점까지만). + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export async function seedConversations( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedConversations'); + const [storeA, storeB] = ctx.stores.stores; + if (!storeA || !storeB) { + throw new Error('seedStores must run before seedConversations'); + } + + const now = Date.now(); + const hour = 60 * 60 * 1000; + const day = 24 * hour; + + // ── storeA: 커스텀 인사말 + FAQ 칩 ── + await prisma.store.update({ + where: { id: storeA.id }, + data: { + greeting_message: + '안녕하세요! {nickname} 고객님.\n{storeName} 입니다 😄\n무엇을 도와드릴까요?', + }, + }); + + const faqRows = [ + { title: '날짜 변경', answer_html: '

픽업 1일 전까지 채팅으로 요청해 주시면 일정 확인 후 변경해 드려요.

' }, + { + title: '케이크 보관 방법', + answer_html: + '

🎂 케이크 보관 방법

  • 냉장보관시 최대 3일
  • 생크림 케이크는 당일 드시는 걸 권장해요
', + }, + { title: '가게 위치 정보', answer_html: '

매장 상세의 찾아오는 길 안내를 확인해 주세요.

' }, + { title: '제일 많이 물어보는 질문', answer_html: '

레터링 문구는 주문 시 요청사항에 남겨 주시면 반영돼요.

' }, + { title: '예약 가능 일정', answer_html: '

캘린더에서 픽업 가능 날짜·시간대를 확인할 수 있어요.

' }, + ]; + const faqs = [] as { id: bigint; title: string; answer_html: string }[]; + for (const [i, row] of faqRows.entries()) { + faqs.push( + await prisma.storeFaqTopic.create({ + data: { store_id: storeA.id, sort_order: i + 1, ...row }, + }), + ); + } + + // ── user1 ↔ storeA: 칩 문답 대화 ── + const convA = await prisma.storeConversation.create({ + data: { + account_id: user1.id, + store_id: storeA.id, + last_message_at: new Date(now - 1 * day), + last_read_at: new Date(now - 1 * day), + }, + }); + const keepFaq = faqs[1]; + await prisma.storeConversationMessage.createMany({ + data: [ + { + conversation_id: convA.id, + sender_type: 'STORE', + body_format: 'TEXT', + body_text: + '안녕하세요! seedTester1 고객님.\n[SEED] 케이크샵 A 입니다 😄\n무엇을 도와드릴까요?', + created_at: new Date(now - 1 * day - 2 * 60 * 1000), + }, + { + conversation_id: convA.id, + sender_type: 'USER', + sender_account_id: user1.id, + body_format: 'TEXT', + body_text: keepFaq?.title ?? '케이크 보관 방법', + created_at: new Date(now - 1 * day - 60 * 1000), + }, + { + conversation_id: convA.id, + sender_type: 'STORE', + body_format: 'HTML', + body_html: keepFaq?.answer_html ?? '

보관 안내

', + created_at: new Date(now - 1 * day), + }, + ], + }); + + // ── user1 ↔ storeB: 자유 문의 + 판매자 답장 3건(안읽음) ── + const convB = await prisma.storeConversation.create({ + data: { + account_id: user1.id, + store_id: storeB.id, + last_message_at: new Date(now - 5 * hour), + // 유저가 마지막으로 읽은 시점 = 본인 메시지 직후 → 판매자 답장 3건 안읽음 + last_read_at: new Date(now - 8 * hour), + }, + }); + await prisma.storeConversationMessage.createMany({ + data: [ + { + conversation_id: convB.id, + sender_type: 'STORE', + body_format: 'TEXT', + body_text: + '안녕하세요! seedTester1 고객님.\n[SEED] 도넛샵 B 입니다 😄\n무엇을 도와드릴까요?', + created_at: new Date(now - 9 * hour), + }, + { + conversation_id: convB.id, + sender_type: 'USER', + sender_account_id: user1.id, + body_format: 'TEXT', + body_text: '주문한 도넛 픽업 시간을 30분 늦출 수 있을까요?', + created_at: new Date(now - 8 * hour), + }, + { + conversation_id: convB.id, + sender_type: 'STORE', + sender_account_id: storeB.seller_account_id, + body_format: 'TEXT', + body_text: '고객님이 말씀해 주신 문의사항에 대한 답변 드리겠습니다.', + created_at: new Date(now - 7 * hour), + }, + { + conversation_id: convB.id, + sender_type: 'STORE', + sender_account_id: storeB.seller_account_id, + body_format: 'TEXT', + body_text: '네, 30분 늦은 픽업 가능합니다.', + created_at: new Date(now - 6 * hour), + }, + { + conversation_id: convB.id, + sender_type: 'STORE', + sender_account_id: storeB.seller_account_id, + body_format: 'TEXT', + body_text: '방문 시 매장 카운터에서 주문번호를 말씀해 주세요.', + created_at: new Date(now - 5 * hour), + }, + ], + }); +} diff --git a/prisma/seed/idempotent.ts b/prisma/seed/idempotent.ts index 8f321e8d..725198ba 100644 --- a/prisma/seed/idempotent.ts +++ b/prisma/seed/idempotent.ts @@ -109,6 +109,21 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { }); // 알림 + // 대화(메시지 → 본체). 시드 유저 소유 대화만 정리한다 + const userConversations = await prisma.storeConversation.findMany({ + where: { account_id: { in: userIds } }, + select: { id: true }, + }); + const userConversationIds = userConversations.map((c) => c.id); + if (userConversationIds.length > 0) { + await prisma.storeConversationMessage.deleteMany({ + where: { conversation_id: { in: userConversationIds } }, + }); + await prisma.storeConversation.deleteMany({ + where: { id: { in: userConversationIds } }, + }); + } + await prisma.notification.deleteMany({ where: { account_id: { in: userIds } }, }); @@ -244,6 +259,24 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { await prisma.product.deleteMany({ where: { id: { in: productIds } } }); } + // 시드 매장을 참조하는 대화(다른 유저 소유 포함)와 FAQ — store FK 선정리 + const storeConversations = await prisma.storeConversation.findMany({ + where: { store_id: { in: storeIds } }, + select: { id: true }, + }); + const storeConversationIds = storeConversations.map((c) => c.id); + if (storeConversationIds.length > 0) { + await prisma.storeConversationMessage.deleteMany({ + where: { conversation_id: { in: storeConversationIds } }, + }); + await prisma.storeConversation.deleteMany({ + where: { id: { in: storeConversationIds } }, + }); + } + await prisma.storeFaqTopic.deleteMany({ + where: { store_id: { in: storeIds } }, + }); + await prisma.storeBusinessHour.deleteMany({ where: { store_id: { in: storeIds } }, }); diff --git a/src/app.module.ts b/src/app.module.ts index e8001134..b17dcddc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import docsConfig from '@/config/docs.config'; import oidcConfig from '@/config/oidc.config'; import s3Config from '@/config/s3.config'; import { AuthModule } from '@/features/auth/auth.module'; +import { ConversationModule } from '@/features/conversation'; import { PickupModule } from '@/features/pickup'; import { RegionModule } from '@/features/region'; import { SearchModule } from '@/features/search/search.module'; @@ -92,6 +93,7 @@ import { PrismaModule } from '@/prisma'; }), SystemModule, AuthModule, + ConversationModule, PickupModule, RegionModule, SearchModule, diff --git a/src/features/conversation/constants/conversation-error-messages.ts b/src/features/conversation/constants/conversation-error-messages.ts new file mode 100644 index 00000000..320da054 --- /dev/null +++ b/src/features/conversation/constants/conversation-error-messages.ts @@ -0,0 +1,9 @@ +export const CONVERSATION_ERRORS = { + STORE_NOT_FOUND: 'Store not found.', + FAQ_TOPIC_NOT_FOUND: 'FAQ topic not found.', + // 활성 USER 판정 실패 메시지 — user feature와 동일 의미론(판정은 정책 헬퍼 공유) + ACCOUNT_NOT_FOUND: 'Account not found.', + ACCOUNT_DELETED: 'Account is deleted.', + NOT_USER: 'Only USER account is allowed.', + PROFILE_INACTIVE: 'User profile not found.', +} as const; diff --git a/src/features/conversation/constants/conversation.constants.ts b/src/features/conversation/constants/conversation.constants.ts new file mode 100644 index 00000000..e0cb4d19 --- /dev/null +++ b/src/features/conversation/constants/conversation.constants.ts @@ -0,0 +1,11 @@ +// 인사말 템플릿 placeholder. 매장 커스텀 인사말과 기본 문구가 공유한다. +export const GREETING_NICKNAME_PLACEHOLDER = '{nickname}'; +export const GREETING_STORE_NAME_PLACEHOLDER = '{storeName}'; + +// figma notification-center 문의 채팅 인사말 기준(자체 판단: placeholder 형식). +// 매장이 greeting_message를 설정하지 않았을 때 사용한다. +export const DEFAULT_GREETING_TEMPLATE = + '안녕하세요! {nickname} 고객님.\n{storeName} 입니다 😄\n무엇을 도와드릴까요?'; + +// 구매자 텍스트 메시지 상한. 판매자 측 MAX_CONVERSATION_BODY_TEXT_LENGTH와 동일 정책. +export const MAX_INQUIRY_BODY_TEXT_LENGTH = 2000; diff --git a/src/features/conversation/conversation-inquiry.graphql b/src/features/conversation/conversation-inquiry.graphql new file mode 100644 index 00000000..59812aa0 --- /dev/null +++ b/src/features/conversation/conversation-inquiry.graphql @@ -0,0 +1,95 @@ +extend type Query { + """ + 문의 채팅 진입 컨텍스트(구매자). 매장 프로필·상담시간·치환 완료 인사말·질문 칩을 + 한 번에 내려준다. 아직 대화가 없어도 조회 가능하며 그 경우 conversationId는 null. + """ + storeInquiryContext(storeId: ID!): StoreInquiryContext! +} + +extend type Mutation { + """ + 구매자 텍스트 메시지 전송. 해당 매장과의 대화가 없으면 이 시점에 생성하고, + 치환 완료된 인사말을 매장 메시지로 먼저 저장한 뒤 유저 메시지를 저장한다. + """ + sendConversationMessage(input: SendConversationMessageInput!): ConversationMessagesPayload! + """ + 질문 칩(FAQ) 전송. 유저 메시지(칩 제목)와 매장 자동응답(FAQ 답변 HTML)을 + 한 트랜잭션으로 저장한다. 첫 전송이면 대화 생성 + 인사말 저장도 함께 수행. + """ + sendConversationFaqMessage(input: SendConversationFaqMessageInput!): ConversationMessagesPayload! +} + +"""대화 메시지 발신자 유형""" +enum ConversationSenderType { + USER + STORE + SYSTEM +} + +"""대화 메시지 본문 형식""" +enum ConversationBodyFormat { + TEXT + HTML +} + +"""문의 채팅 진입 컨텍스트""" +type StoreInquiryContext { + storeId: ID! + storeName: String! + """매장 프로필(로고) 이미지 URL. 미등록 시 null""" + profileImageUrl: String + """요일별 상담시간(영업시간과 동일 정책). FE가 "월/화/수… 10:00~18:00" 형식으로 조합""" + businessHours: [InquiryBusinessHour!]! + """닉네임·매장명 치환이 끝난 인사말""" + greetingMessage: String! + """질문 칩 목록(활성 FAQ, 노출 순서대로)""" + faqTopics: [InquiryFaqTopic!]! + """기존 대화가 있으면 그 ID, 없으면 null""" + conversationId: ID +} + +"""요일별 상담시간""" +type InquiryBusinessHour { + """0=일 ~ 6=토""" + dayOfWeek: Int! + isClosed: Boolean! + """오픈 시각 "HH:mm". 휴무면 null""" + openTime: String + """마감 시각 "HH:mm". 휴무면 null""" + closeTime: String +} + +"""질문 칩(매장 FAQ)""" +type InquiryFaqTopic { + id: ID! + title: String! +} + +"""대화 메시지""" +type ConversationMessage { + id: ID! + conversationId: ID! + senderType: ConversationSenderType! + bodyFormat: ConversationBodyFormat! + bodyText: String + bodyHtml: String + createdAt: DateTime! +} + +"""메시지 전송 결과. 이번 호출로 저장된 메시지들을 생성 순으로 담는다(첫 전송이면 인사말 포함)""" +type ConversationMessagesPayload { + conversationId: ID! + messages: [ConversationMessage!]! +} + +"""구매자 텍스트 메시지 전송 입력""" +input SendConversationMessageInput { + storeId: ID! + bodyText: String! +} + +"""질문 칩(FAQ) 전송 입력""" +input SendConversationFaqMessageInput { + storeId: ID! + faqTopicId: ID! +} diff --git a/src/features/conversation/conversation.module.ts b/src/features/conversation/conversation.module.ts index bcfeac45..9087ca2e 100644 --- a/src/features/conversation/conversation.module.ts +++ b/src/features/conversation/conversation.module.ts @@ -1,9 +1,17 @@ import { Module } from '@nestjs/common'; import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationInquiryMutationResolver } from '@/features/conversation/resolvers/conversation-inquiry-mutation.resolver'; +import { ConversationInquiryQueryResolver } from '@/features/conversation/resolvers/conversation-inquiry-query.resolver'; +import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; @Module({ - providers: [ConversationRepository], + providers: [ + ConversationRepository, + ConversationInquiryService, + ConversationInquiryQueryResolver, + ConversationInquiryMutationResolver, + ], exports: [ConversationRepository], }) export class ConversationModule {} diff --git a/src/features/conversation/dto/inputs/send-conversation-faq-message.input.spec.ts b/src/features/conversation/dto/inputs/send-conversation-faq-message.input.spec.ts new file mode 100644 index 00000000..fb47397a --- /dev/null +++ b/src/features/conversation/dto/inputs/send-conversation-faq-message.input.spec.ts @@ -0,0 +1,27 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SendConversationFaqMessageInput } from '@/features/conversation/dto/inputs/send-conversation-faq-message.input'; + +function build(plain: object): SendConversationFaqMessageInput { + return plainToInstance(SendConversationFaqMessageInput, plain); +} + +describe('SendConversationFaqMessageInput', () => { + it('storeId·faqTopicId 정상 조합 허용', async () => { + const dto = build({ storeId: '1', faqTopicId: '2' }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('faqTopicId 누락 거절', async () => { + const errors = await validate(build({ storeId: '1' })); + expect(errors.map((e) => e.property)).toEqual(['faqTopicId']); + }); + + it('storeId 빈 문자열 거절', async () => { + const errors = await validate(build({ storeId: '', faqTopicId: '2' })); + expect(errors.map((e) => e.property)).toEqual(['storeId']); + }); +}); diff --git a/src/features/conversation/dto/inputs/send-conversation-faq-message.input.ts b/src/features/conversation/dto/inputs/send-conversation-faq-message.input.ts new file mode 100644 index 00000000..0471471e --- /dev/null +++ b/src/features/conversation/dto/inputs/send-conversation-faq-message.input.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class SendConversationFaqMessageInput { + @IsString() + @IsNotEmpty() + storeId!: string; + + @IsString() + @IsNotEmpty() + faqTopicId!: string; +} diff --git a/src/features/conversation/dto/inputs/send-conversation-message.input.spec.ts b/src/features/conversation/dto/inputs/send-conversation-message.input.spec.ts new file mode 100644 index 00000000..7942adf1 --- /dev/null +++ b/src/features/conversation/dto/inputs/send-conversation-message.input.spec.ts @@ -0,0 +1,27 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SendConversationMessageInput } from '@/features/conversation/dto/inputs/send-conversation-message.input'; + +function build(plain: object): SendConversationMessageInput { + return plainToInstance(SendConversationMessageInput, plain); +} + +describe('SendConversationMessageInput', () => { + it('storeId·bodyText 정상 조합 허용', async () => { + const dto = build({ storeId: '1', bodyText: '문의합니다' }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('storeId 누락 거절', async () => { + const errors = await validate(build({ bodyText: '문의합니다' })); + expect(errors.map((e) => e.property)).toEqual(['storeId']); + }); + + it('bodyText 빈 문자열 거절', async () => { + const errors = await validate(build({ storeId: '1', bodyText: '' })); + expect(errors.map((e) => e.property)).toEqual(['bodyText']); + }); +}); diff --git a/src/features/conversation/dto/inputs/send-conversation-message.input.ts b/src/features/conversation/dto/inputs/send-conversation-message.input.ts new file mode 100644 index 00000000..b2feb5a0 --- /dev/null +++ b/src/features/conversation/dto/inputs/send-conversation-message.input.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class SendConversationMessageInput { + @IsString() + @IsNotEmpty() + storeId!: string; + + @IsString() + @IsNotEmpty() + bodyText!: string; +} diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index bfbbdd31..025e7b20 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -1,7 +1,20 @@ import { Injectable } from '@nestjs/common'; -import { ConversationBodyFormat, ConversationSenderType } from '@prisma/client'; +import { + ConversationBodyFormat, + ConversationSenderType, + Prisma, +} from '@prisma/client'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; + +/** 구매자 메시지 전송 시 한 트랜잭션으로 저장할 메시지 명세. */ +export interface ConversationMessageEntry { + senderType: ConversationSenderType; + senderAccountId: bigint | null; + bodyFormat: ConversationBodyFormat; + bodyText: string | null; + bodyHtml: string | null; +} @Injectable() export class ConversationRepository { @@ -49,6 +62,170 @@ export class ConversationRepository { }); } + /** 활성 USER 계정 판정용 부분집합 조회(user feature 정책 헬퍼와 계약 공유). */ + async findUserAccountForInquiry(accountId: bigint) { + return this.prisma.account.findFirst({ + where: { id: accountId }, + select: { + id: true, + account_type: true, + deleted_at: true, + user_profile: { select: { nickname: true, deleted_at: true } }, + }, + }); + } + + /** 문의 가능 매장(활성·미삭제) + 요일별 영업시간. 없으면 null. */ + async findInquiryStore(storeId: bigint) { + return this.prisma.store.findFirst({ + where: { id: storeId, ...visibleWhere }, + select: { + id: true, + store_name: true, + profile_image_url: true, + greeting_message: true, + business_hours: { + where: activeWhere, + orderBy: { day_of_week: 'asc' }, + select: { + day_of_week: true, + is_closed: true, + open_time: true, + close_time: true, + }, + }, + }, + }); + } + + /** 질문 칩 노출용 활성 FAQ 목록(노출 순서). */ + async listActiveFaqTopics(storeId: bigint) { + return this.prisma.storeFaqTopic.findMany({ + where: { store_id: storeId, is_active: true }, + orderBy: [{ sort_order: 'asc' }, { id: 'asc' }], + select: { id: true, title: true }, + }); + } + + /** 칩 전송 대상 FAQ 단건(활성만). */ + async findActiveFaqTopic(args: { storeId: bigint; faqTopicId: bigint }) { + return this.prisma.storeFaqTopic.findFirst({ + where: { + id: args.faqTopicId, + store_id: args.storeId, + is_active: true, + }, + select: { id: true, title: true, answer_html: true }, + }); + } + + async findConversationByAccountAndStore(args: { + accountId: bigint; + storeId: bigint; + }) { + return this.prisma.storeConversation.findFirst({ + where: { account_id: args.accountId, store_id: args.storeId }, + }); + } + + /** + * 구매자 메시지 저장. 대화가 없으면 이 트랜잭션에서 생성하고, 그 경우에만 + * 인사말(STORE 발신)을 유저 메시지보다 먼저 저장한다 — 인사말은 대화당 1회. + * + * 동시 첫 전송 레이스: findFirst 이후 create가 (account_id, store_id) 유니크에 + * 걸릴 수 있다 → P2002면 기존 대화를 다시 찾아 인사말 없이 이어간다. + */ + async createBuyerMessages(args: { + accountId: bigint; + storeId: bigint; + greetingBodyText: string; + entries: ConversationMessageEntry[]; + now: Date; + }) { + return this.prisma.$transaction(async (tx) => { + // (account_id, store_id) 유니크는 soft-delete된 row도 잡는다 — 조회를 + // 활성만으로 좁히면 삭제 row 존재 시 create가 항상 P2002로 터지므로, + // deleted_at 필터를 명시 해제(undefined)해 유니크 제약과 같은 범위로 찾는다. + let conversation = await tx.storeConversation.findFirst({ + where: { + account_id: args.accountId, + store_id: args.storeId, + deleted_at: undefined, + }, + }); + + let withGreeting = false; + if (!conversation) { + try { + conversation = await tx.storeConversation.create({ + data: { + account_id: args.accountId, + store_id: args.storeId, + created_at: args.now, + }, + }); + withGreeting = true; + } catch (e) { + if ( + e instanceof Prisma.PrismaClientKnownRequestError && + e.code === 'P2002' + ) { + conversation = await tx.storeConversation.findFirstOrThrow({ + where: { + account_id: args.accountId, + store_id: args.storeId, + deleted_at: undefined, + }, + }); + } else { + throw e; + } + } + } + + const toCreate: ConversationMessageEntry[] = [ + ...(withGreeting + ? [ + { + senderType: ConversationSenderType.STORE, + senderAccountId: null, + bodyFormat: ConversationBodyFormat.TEXT, + bodyText: args.greetingBodyText, + bodyHtml: null, + }, + ] + : []), + ...args.entries, + ]; + + // createMany는 생성 row를 돌려주지 않아 순서 보존 개별 create로 저장한다 + // (한 호출당 최대 3건이라 비용 문제 없음) + const messages = []; + for (const entry of toCreate) { + messages.push( + await tx.storeConversationMessage.create({ + data: { + conversation_id: conversation.id, + sender_type: entry.senderType, + sender_account_id: entry.senderAccountId, + body_format: entry.bodyFormat, + body_text: entry.bodyText, + body_html: entry.bodyHtml, + created_at: args.now, + }, + }), + ); + } + + await tx.storeConversation.update({ + where: { id: conversation.id }, + data: { last_message_at: args.now, updated_at: args.now }, + }); + + return { conversationId: conversation.id, messages }; + }); + } + async createSellerConversationMessage(args: { conversationId: bigint; sellerAccountId: bigint; diff --git a/src/features/conversation/resolvers/conversation-inquiry-mutation.resolver.ts b/src/features/conversation/resolvers/conversation-inquiry-mutation.resolver.ts new file mode 100644 index 00000000..311c3552 --- /dev/null +++ b/src/features/conversation/resolvers/conversation-inquiry-mutation.resolver.ts @@ -0,0 +1,37 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { SendConversationFaqMessageInput } from '@/features/conversation/dto/inputs/send-conversation-faq-message.input'; +import { SendConversationMessageInput } from '@/features/conversation/dto/inputs/send-conversation-message.input'; +import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; +import type { ConversationMessagesPayload } from '@/features/conversation/types/conversation-output.type'; +import { + CurrentUser, + JwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Mutation') +@UseGuards(JwtAuthGuard) +export class ConversationInquiryMutationResolver { + constructor(private readonly inquiryService: ConversationInquiryService) {} + + @Mutation('sendConversationMessage') + sendConversationMessage( + @CurrentUser() user: JwtUser, + @Args('input') input: SendConversationMessageInput, + ): Promise { + const accountId = parseAccountId(user); + return this.inquiryService.sendConversationMessage(accountId, input); + } + + @Mutation('sendConversationFaqMessage') + sendConversationFaqMessage( + @CurrentUser() user: JwtUser, + @Args('input') input: SendConversationFaqMessageInput, + ): Promise { + const accountId = parseAccountId(user); + return this.inquiryService.sendConversationFaqMessage(accountId, input); + } +} diff --git a/src/features/conversation/resolvers/conversation-inquiry-query.resolver.ts b/src/features/conversation/resolvers/conversation-inquiry-query.resolver.ts new file mode 100644 index 00000000..e321bc1d --- /dev/null +++ b/src/features/conversation/resolvers/conversation-inquiry-query.resolver.ts @@ -0,0 +1,26 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; +import type { StoreInquiryContextOutput } from '@/features/conversation/types/conversation-output.type'; +import { + CurrentUser, + JwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Query') +@UseGuards(JwtAuthGuard) +export class ConversationInquiryQueryResolver { + constructor(private readonly inquiryService: ConversationInquiryService) {} + + @Query('storeInquiryContext') + storeInquiryContext( + @CurrentUser() user: JwtUser, + @Args('storeId') storeId: string, + ): Promise { + const accountId = parseAccountId(user); + return this.inquiryService.storeInquiryContext(accountId, storeId); + } +} diff --git a/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts b/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts new file mode 100644 index 00000000..5663ba64 --- /dev/null +++ b/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts @@ -0,0 +1,91 @@ +// 전체 경로(리졸버→서비스→레포→DB) 통합 검증만 담당. 분기·예외 세부는 service.spec.ts에서 담당 +import type { PrismaClient } from '@prisma/client'; + +import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationInquiryMutationResolver } from '@/features/conversation/resolvers/conversation-inquiry-mutation.resolver'; +import { ConversationInquiryQueryResolver } from '@/features/conversation/resolvers/conversation-inquiry-query.resolver'; +import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('Conversation Inquiry Resolvers (real DB)', () => { + let queryResolver: ConversationInquiryQueryResolver; + let mutationResolver: ConversationInquiryMutationResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + ConversationInquiryQueryResolver, + ConversationInquiryMutationResolver, + ConversationInquiryService, + ConversationRepository, + ], + }); + queryResolver = module.get(ConversationInquiryQueryResolver); + mutationResolver = module.get(ConversationInquiryMutationResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('Query.storeInquiryContext → Mutation.sendConversationMessage 전체 경로', async () => { + const buyer = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: buyer.id, nickname: '현진' }); + const store = await createStore(prisma, { store_name: '해즈 케이크' }); + const jwtUser = { accountId: buyer.id.toString() }; + + const context = await queryResolver.storeInquiryContext( + jwtUser, + store.id.toString(), + ); + expect(context.storeName).toBe('해즈 케이크'); + expect(context.conversationId).toBeNull(); + + const sent = await mutationResolver.sendConversationMessage(jwtUser, { + storeId: store.id.toString(), + bodyText: '픽업 문의드립니다', + }); + expect(sent.messages.map((m) => m.senderType)).toEqual(['STORE', 'USER']); + + const after = await queryResolver.storeInquiryContext( + jwtUser, + store.id.toString(), + ); + expect(after.conversationId).toBe(sent.conversationId); + }); + + it('Mutation.sendConversationFaqMessage가 질문+자동응답을 저장한다', async () => { + const buyer = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: buyer.id }); + const store = await createStore(prisma); + const faq = await prisma.storeFaqTopic.create({ + data: { + store_id: store.id, + title: '예약 가능 일정', + answer_html: '

캘린더를 확인해 주세요.

', + }, + }); + + const result = await mutationResolver.sendConversationFaqMessage( + { accountId: buyer.id.toString() }, + { storeId: store.id.toString(), faqTopicId: faq.id.toString() }, + ); + + expect(result.messages).toHaveLength(3); + expect(result.messages[2].bodyHtml).toBe('

캘린더를 확인해 주세요.

'); + }); +}); diff --git a/src/features/conversation/services/conversation-inquiry-mappers.helper.spec.ts b/src/features/conversation/services/conversation-inquiry-mappers.helper.spec.ts new file mode 100644 index 00000000..bcf431f0 --- /dev/null +++ b/src/features/conversation/services/conversation-inquiry-mappers.helper.spec.ts @@ -0,0 +1,75 @@ +import { + formatTimeOfDay, + renderGreeting, + toInquiryBusinessHour, +} from '@/features/conversation/services/conversation-inquiry-mappers.helper'; + +describe('conversation-inquiry-mappers.helper', () => { + describe('renderGreeting', () => { + it('템플릿이 없으면 기본 문구에 닉네임·매장명을 치환한다', () => { + const result = renderGreeting(null, { + nickname: '김현진', + storeName: '해즈 케이크', + }); + + expect(result).toContain('김현진 고객님'); + expect(result).toContain('해즈 케이크'); + expect(result).not.toContain('{nickname}'); + expect(result).not.toContain('{storeName}'); + }); + + it('커스텀 템플릿의 placeholder를 전부 치환한다(다회 등장 포함)', () => { + const result = renderGreeting( + '{nickname}님! {storeName}입니다. {nickname}님 환영해요.', + { nickname: '현진', storeName: '달콤' }, + ); + + expect(result).toBe('현진님! 달콤입니다. 현진님 환영해요.'); + }); + + it('placeholder가 없는 템플릿은 그대로 반환한다', () => { + expect( + renderGreeting('반갑습니다.', { nickname: 'a', storeName: 'b' }), + ).toBe('반갑습니다.'); + }); + }); + + describe('formatTimeOfDay', () => { + it('Time 컬럼 Date를 UTC 기준 "HH:mm"으로 만든다', () => { + expect(formatTimeOfDay(new Date('1970-01-01T09:05:00Z'))).toBe('09:05'); + expect(formatTimeOfDay(null)).toBeNull(); + }); + }); + + describe('toInquiryBusinessHour', () => { + it('영업일은 시각을 채우고, 휴무일은 시각을 null로 만든다', () => { + expect( + toInquiryBusinessHour({ + day_of_week: 1, + is_closed: false, + open_time: new Date('1970-01-01T10:00:00Z'), + close_time: new Date('1970-01-01T18:00:00Z'), + }), + ).toEqual({ + dayOfWeek: 1, + isClosed: false, + openTime: '10:00', + closeTime: '18:00', + }); + + expect( + toInquiryBusinessHour({ + day_of_week: 0, + is_closed: true, + open_time: new Date('1970-01-01T10:00:00Z'), + close_time: null, + }), + ).toEqual({ + dayOfWeek: 0, + isClosed: true, + openTime: null, + closeTime: null, + }); + }); + }); +}); diff --git a/src/features/conversation/services/conversation-inquiry-mappers.helper.ts b/src/features/conversation/services/conversation-inquiry-mappers.helper.ts new file mode 100644 index 00000000..868a68ae --- /dev/null +++ b/src/features/conversation/services/conversation-inquiry-mappers.helper.ts @@ -0,0 +1,69 @@ +import { + DEFAULT_GREETING_TEMPLATE, + GREETING_NICKNAME_PLACEHOLDER, + GREETING_STORE_NAME_PLACEHOLDER, +} from '@/features/conversation/constants/conversation.constants'; +import type { + ConversationMessageOutput, + InquiryBusinessHourOutput, +} from '@/features/conversation/types/conversation-output.type'; + +/** DI-free 순수 함수만 둔다 — 인사말 치환·시각 포맷·메시지 매핑. */ + +/** + * 인사말 렌더링. 매장 커스텀 템플릿이 없으면 기본 문구를 쓴다. + * placeholder는 등장 위치·횟수 제한 없이 전부 치환한다. + */ +export function renderGreeting( + template: string | null, + args: { nickname: string; storeName: string }, +): string { + return (template ?? DEFAULT_GREETING_TEMPLATE) + .replaceAll(GREETING_NICKNAME_PLACEHOLDER, args.nickname) + .replaceAll(GREETING_STORE_NAME_PLACEHOLDER, args.storeName); +} + +/** + * Prisma Time(@db.Time) → "HH:mm". Time 컬럼은 UTC 기준 Date로 돌아오므로 + * UTC 게터를 써야 저장값 그대로 나온다(business-hours-formatter와 동일 규칙). + */ +export function formatTimeOfDay(date: Date | null): string | null { + if (!date) return null; + const h = date.getUTCHours().toString().padStart(2, '0'); + const m = date.getUTCMinutes().toString().padStart(2, '0'); + return `${h}:${m}`; +} + +export function toInquiryBusinessHour(row: { + day_of_week: number; + is_closed: boolean; + open_time: Date | null; + close_time: Date | null; +}): InquiryBusinessHourOutput { + return { + dayOfWeek: row.day_of_week, + isClosed: row.is_closed, + openTime: row.is_closed ? null : formatTimeOfDay(row.open_time), + closeTime: row.is_closed ? null : formatTimeOfDay(row.close_time), + }; +} + +export function toConversationMessageOutput(row: { + id: bigint; + conversation_id: bigint; + sender_type: 'USER' | 'STORE' | 'SYSTEM'; + body_format: 'TEXT' | 'HTML'; + body_text: string | null; + body_html: string | null; + created_at: Date; +}): ConversationMessageOutput { + return { + id: row.id.toString(), + conversationId: row.conversation_id.toString(), + senderType: row.sender_type, + bodyFormat: row.body_format, + bodyText: row.body_text, + bodyHtml: row.body_html, + createdAt: row.created_at, + }; +} diff --git a/src/features/conversation/services/conversation-inquiry.service.spec.ts b/src/features/conversation/services/conversation-inquiry.service.spec.ts new file mode 100644 index 00000000..8148cbb2 --- /dev/null +++ b/src/features/conversation/services/conversation-inquiry.service.spec.ts @@ -0,0 +1,344 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('ConversationInquiryService (real DB)', () => { + let service: ConversationInquiryService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ConversationInquiryService, ConversationRepository], + }); + service = module.get(ConversationInquiryService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function setupBuyer(nickname = '김현진') { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id, nickname }); + return account; + } + + async function createFaq( + storeId: bigint, + overrides: { + title?: string; + answer_html?: string; + sort_order?: number; + is_active?: boolean; + } = {}, + ) { + return prisma.storeFaqTopic.create({ + data: { + store_id: storeId, + title: overrides.title ?? '케이크 보관 방법', + answer_html: overrides.answer_html ?? '

냉장보관시 최대 3일

', + sort_order: overrides.sort_order ?? 0, + is_active: overrides.is_active ?? true, + }, + }); + } + + async function messagesOf(conversationId: bigint) { + return prisma.storeConversationMessage.findMany({ + where: { conversation_id: conversationId }, + orderBy: { id: 'asc' }, + }); + } + + // ─── storeInquiryContext ─── + describe('storeInquiryContext', () => { + it('매장 정보·기본 인사말·FAQ 칩·요일별 상담시간을 반환한다', async () => { + const buyer = await setupBuyer('김현진'); + const store = await createStore(prisma, { store_name: '해즈 케이크' }); + await prisma.storeBusinessHour.createMany({ + data: [ + { + store_id: store.id, + day_of_week: 1, + is_closed: false, + open_time: new Date('1970-01-01T10:00:00Z'), + close_time: new Date('1970-01-01T18:00:00Z'), + }, + { store_id: store.id, day_of_week: 0, is_closed: true }, + ], + }); + await createFaq(store.id, { title: '날짜 변경', sort_order: 2 }); + await createFaq(store.id, { title: '케이크 보관 방법', sort_order: 1 }); + await createFaq(store.id, { title: '비활성 칩', is_active: false }); + + const result = await service.storeInquiryContext( + buyer.id, + store.id.toString(), + ); + + expect(result.storeName).toBe('해즈 케이크'); + // 매장 인사말 미설정 → 기본 문구에 닉네임·매장명 치환 + expect(result.greetingMessage).toContain('김현진 고객님'); + expect(result.greetingMessage).toContain('해즈 케이크'); + expect(result.greetingMessage).not.toContain('{nickname}'); + // 활성 FAQ만 sort_order 순으로 + expect(result.faqTopics.map((t) => t.title)).toEqual([ + '케이크 보관 방법', + '날짜 변경', + ]); + // 요일 오름차순 + 휴무일은 시각 null + expect(result.businessHours).toEqual([ + { dayOfWeek: 0, isClosed: true, openTime: null, closeTime: null }, + { + dayOfWeek: 1, + isClosed: false, + openTime: '10:00', + closeTime: '18:00', + }, + ]); + expect(result.conversationId).toBeNull(); + }); + + it('커스텀 인사말 템플릿을 치환해 반환하고, 기존 대화 ID를 채운다', async () => { + const buyer = await setupBuyer('현진'); + const store = await createStore(prisma, { + store_name: '달콤 케이크', + greeting_message: '{storeName}에 오신 {nickname}님 환영!', + }); + const conv = await prisma.storeConversation.create({ + data: { account_id: buyer.id, store_id: store.id }, + }); + + const result = await service.storeInquiryContext( + buyer.id, + store.id.toString(), + ); + + expect(result.greetingMessage).toBe('달콤 케이크에 오신 현진님 환영!'); + expect(result.conversationId).toBe(conv.id.toString()); + }); + + it('비활성/삭제 매장은 NotFoundException', async () => { + const buyer = await setupBuyer(); + const inactive = await createStore(prisma, { is_active: false }); + const deleted = await createStore(prisma, { deleted_at: new Date() }); + + await expect( + service.storeInquiryContext(buyer.id, inactive.id.toString()), + ).rejects.toThrow(NotFoundException); + await expect( + service.storeInquiryContext(buyer.id, deleted.id.toString()), + ).rejects.toThrow(NotFoundException); + }); + + it('없는 계정은 Unauthorized, SELLER 계정은 Forbidden', async () => { + const store = await createStore(prisma); + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + + await expect( + service.storeInquiryContext(BigInt(999999), store.id.toString()), + ).rejects.toThrow(UnauthorizedException); + await expect( + service.storeInquiryContext(seller.id, store.id.toString()), + ).rejects.toThrow(ForbiddenException); + }); + }); + + // ─── sendConversationMessage ─── + describe('sendConversationMessage', () => { + it('첫 전송이면 대화를 생성하고 인사말(STORE) → 유저 메시지 순으로 저장한다', async () => { + const buyer = await setupBuyer('김현진'); + const store = await createStore(prisma, { store_name: '해즈 케이크' }); + + const result = await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: ' 픽업 시간 변경 가능한가요? ', + }); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0].senderType).toBe('STORE'); + expect(result.messages[0].bodyText).toContain('김현진 고객님'); + expect(result.messages[1].senderType).toBe('USER'); + // 앞뒤 공백은 정리해 저장한다 + expect(result.messages[1].bodyText).toBe('픽업 시간 변경 가능한가요?'); + + const conversation = await prisma.storeConversation.findFirstOrThrow({ + where: { account_id: buyer.id, store_id: store.id }, + }); + expect(result.conversationId).toBe(conversation.id.toString()); + expect(conversation.last_message_at).not.toBeNull(); + expect(await messagesOf(conversation.id)).toHaveLength(2); + }); + + it('대화가 이미 있으면 인사말 없이 유저 메시지 1건만 저장한다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '첫 메시지', + }); + + const second = await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '두 번째 메시지', + }); + + expect(second.messages).toHaveLength(1); + expect(second.messages[0].senderType).toBe('USER'); + + const conversations = await prisma.storeConversation.findMany({ + where: { account_id: buyer.id, store_id: store.id }, + }); + // 계정당 매장당 대화 1개 유지(중복 생성 없음) + expect(conversations).toHaveLength(1); + expect(await messagesOf(conversations[0].id)).toHaveLength(3); + }); + + it('soft-delete된 대화가 있으면 유니크 충돌 없이 그 대화를 재사용한다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const softDeleted = await prisma.storeConversation.create({ + data: { + account_id: buyer.id, + store_id: store.id, + deleted_at: new Date(), + }, + }); + + const result = await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '다시 문의드립니다', + }); + + // 유니크 제약 범위(삭제 포함)와 동일하게 기존 row를 찾아 이어간다 + expect(result.conversationId).toBe(softDeleted.id.toString()); + // 기존 대화 재사용이므로 인사말은 다시 저장하지 않는다 + expect(result.messages).toHaveLength(1); + expect(result.messages[0].senderType).toBe('USER'); + }); + + it('공백뿐인 본문·2000자 초과 본문은 거절한다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + + await expect( + service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: ' ', + }), + ).rejects.toThrow(BadRequestException); + await expect( + service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: 'a'.repeat(2001), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('비활성 매장에는 전송할 수 없다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma, { is_active: false }); + + await expect( + service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '안녕하세요', + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ─── sendConversationFaqMessage ─── + describe('sendConversationFaqMessage', () => { + it('첫 전송이면 인사말 → 유저 질문(TEXT) → 자동응답(HTML) 3건을 저장한다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const faq = await createFaq(store.id, { + title: '케이크 보관 방법', + answer_html: '

냉장보관시 최대 3일

', + }); + + const result = await service.sendConversationFaqMessage(buyer.id, { + storeId: store.id.toString(), + faqTopicId: faq.id.toString(), + }); + + expect(result.messages).toHaveLength(3); + expect(result.messages[0].senderType).toBe('STORE'); + expect(result.messages[0].bodyFormat).toBe('TEXT'); + expect(result.messages[1]).toMatchObject({ + senderType: 'USER', + bodyFormat: 'TEXT', + bodyText: '케이크 보관 방법', + }); + expect(result.messages[2]).toMatchObject({ + senderType: 'STORE', + bodyFormat: 'HTML', + bodyHtml: '

냉장보관시 최대 3일

', + }); + }); + + it('기존 대화가 있으면 질문+자동응답 2건만 저장한다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const faq = await createFaq(store.id); + await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '먼저 보낸 메시지', + }); + + const result = await service.sendConversationFaqMessage(buyer.id, { + storeId: store.id.toString(), + faqTopicId: faq.id.toString(), + }); + + expect(result.messages).toHaveLength(2); + expect(result.messages.map((m) => m.senderType)).toEqual([ + 'USER', + 'STORE', + ]); + }); + + it('비활성 FAQ·다른 매장 FAQ는 NotFoundException', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const otherStore = await createStore(prisma); + const inactiveFaq = await createFaq(store.id, { is_active: false }); + const othersFaq = await createFaq(otherStore.id); + + await expect( + service.sendConversationFaqMessage(buyer.id, { + storeId: store.id.toString(), + faqTopicId: inactiveFaq.id.toString(), + }), + ).rejects.toThrow(NotFoundException); + await expect( + service.sendConversationFaqMessage(buyer.id, { + storeId: store.id.toString(), + faqTopicId: othersFaq.id.toString(), + }), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/features/conversation/services/conversation-inquiry.service.ts b/src/features/conversation/services/conversation-inquiry.service.ts new file mode 100644 index 00000000..daa2aa07 --- /dev/null +++ b/src/features/conversation/services/conversation-inquiry.service.ts @@ -0,0 +1,192 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConversationBodyFormat, ConversationSenderType } from '@prisma/client'; + +import { parseId } from '@/common/utils/id-parser'; +import { cleanRequiredText } from '@/common/utils/text-cleaner'; +import { CONVERSATION_ERRORS } from '@/features/conversation/constants/conversation-error-messages'; +import { MAX_INQUIRY_BODY_TEXT_LENGTH } from '@/features/conversation/constants/conversation.constants'; +import type { SendConversationFaqMessageInput } from '@/features/conversation/dto/inputs/send-conversation-faq-message.input'; +import type { SendConversationMessageInput } from '@/features/conversation/dto/inputs/send-conversation-message.input'; +import { + ConversationRepository, + type ConversationMessageEntry, +} from '@/features/conversation/repositories/conversation.repository'; +import { + renderGreeting, + toConversationMessageOutput, + toInquiryBusinessHour, +} from '@/features/conversation/services/conversation-inquiry-mappers.helper'; +import type { + ConversationMessagesPayload, + StoreInquiryContextOutput, +} from '@/features/conversation/types/conversation-output.type'; +import { evaluateActiveUserAccount } from '@/features/user'; + +@Injectable() +export class ConversationInquiryService { + constructor(private readonly repo: ConversationRepository) {} + + async storeInquiryContext( + accountId: bigint, + storeIdRaw: string, + ): Promise { + const { nickname } = await this.requireActiveUser(accountId); + const storeId = parseId(storeIdRaw); + + const store = await this.requireInquiryStore(storeId); + const [faqTopics, conversation] = await Promise.all([ + this.repo.listActiveFaqTopics(storeId), + this.repo.findConversationByAccountAndStore({ accountId, storeId }), + ]); + + return { + storeId: store.id.toString(), + storeName: store.store_name, + profileImageUrl: store.profile_image_url, + businessHours: store.business_hours.map(toInquiryBusinessHour), + greetingMessage: renderGreeting(store.greeting_message, { + nickname, + storeName: store.store_name, + }), + faqTopics: faqTopics.map((t) => ({ + id: t.id.toString(), + title: t.title, + })), + conversationId: conversation?.id.toString() ?? null, + }; + } + + async sendConversationMessage( + accountId: bigint, + input: SendConversationMessageInput, + ): Promise { + const { nickname } = await this.requireActiveUser(accountId); + const storeId = parseId(input.storeId); + const store = await this.requireInquiryStore(storeId); + + const bodyText = cleanRequiredText( + input.bodyText, + MAX_INQUIRY_BODY_TEXT_LENGTH, + ); + + return this.saveBuyerMessages({ + accountId, + storeId, + nickname, + storeName: store.store_name, + greetingTemplate: store.greeting_message, + entries: [ + { + senderType: ConversationSenderType.USER, + senderAccountId: accountId, + bodyFormat: ConversationBodyFormat.TEXT, + bodyText, + bodyHtml: null, + }, + ], + }); + } + + async sendConversationFaqMessage( + accountId: bigint, + input: SendConversationFaqMessageInput, + ): Promise { + const { nickname } = await this.requireActiveUser(accountId); + const storeId = parseId(input.storeId); + const store = await this.requireInquiryStore(storeId); + + const topic = await this.repo.findActiveFaqTopic({ + storeId, + faqTopicId: parseId(input.faqTopicId), + }); + if (!topic) { + throw new NotFoundException(CONVERSATION_ERRORS.FAQ_TOPIC_NOT_FOUND); + } + + // 칩 탭 = 유저 질문(칩 제목) + 매장 자동응답(FAQ 답변 스냅샷) 한 쌍 저장. + // 이후 FAQ가 수정돼도 저장된 대화 이력은 당시 답변을 유지한다. + return this.saveBuyerMessages({ + accountId, + storeId, + nickname, + storeName: store.store_name, + greetingTemplate: store.greeting_message, + entries: [ + { + senderType: ConversationSenderType.USER, + senderAccountId: accountId, + bodyFormat: ConversationBodyFormat.TEXT, + bodyText: topic.title, + bodyHtml: null, + }, + { + senderType: ConversationSenderType.STORE, + senderAccountId: null, + bodyFormat: ConversationBodyFormat.HTML, + bodyText: null, + bodyHtml: topic.answer_html, + }, + ], + }); + } + + private async saveBuyerMessages(args: { + accountId: bigint; + storeId: bigint; + nickname: string; + storeName: string; + greetingTemplate: string | null; + entries: ConversationMessageEntry[]; + }): Promise { + const result = await this.repo.createBuyerMessages({ + accountId: args.accountId, + storeId: args.storeId, + // 첫 전송으로 대화가 생성될 때만 repository가 사용한다(치환 완료본 저장) + greetingBodyText: renderGreeting(args.greetingTemplate, { + nickname: args.nickname, + storeName: args.storeName, + }), + entries: args.entries, + now: new Date(), + }); + + return { + conversationId: result.conversationId.toString(), + messages: result.messages.map(toConversationMessageOutput), + }; + } + + /** user feature의 활성 USER 판정 정책을 공유한다(메시지 매핑만 도메인별). */ + private async requireActiveUser( + accountId: bigint, + ): Promise<{ nickname: string }> { + const account = await this.repo.findUserAccountForInquiry(accountId); + switch (evaluateActiveUserAccount(account)) { + case 'ACCOUNT_NOT_FOUND': + throw new UnauthorizedException(CONVERSATION_ERRORS.ACCOUNT_NOT_FOUND); + case 'ACCOUNT_DELETED': + throw new UnauthorizedException(CONVERSATION_ERRORS.ACCOUNT_DELETED); + case 'NOT_USER': + throw new ForbiddenException(CONVERSATION_ERRORS.NOT_USER); + case 'PROFILE_INACTIVE': + throw new UnauthorizedException(CONVERSATION_ERRORS.PROFILE_INACTIVE); + case null: + break; + } + // evaluate 통과 시 user_profile 존재가 보장된다 + return { nickname: account!.user_profile!.nickname }; + } + + private async requireInquiryStore(storeId: bigint) { + const store = await this.repo.findInquiryStore(storeId); + if (!store) { + throw new NotFoundException(CONVERSATION_ERRORS.STORE_NOT_FOUND); + } + return store; + } +} diff --git a/src/features/conversation/types/conversation-output.type.ts b/src/features/conversation/types/conversation-output.type.ts new file mode 100644 index 00000000..69461ea9 --- /dev/null +++ b/src/features/conversation/types/conversation-output.type.ts @@ -0,0 +1,41 @@ +import type { + ConversationBodyFormat, + ConversationSenderType, +} from '@prisma/client'; + +export interface InquiryBusinessHourOutput { + dayOfWeek: number; + isClosed: boolean; + openTime: string | null; + closeTime: string | null; +} + +export interface InquiryFaqTopicOutput { + id: string; + title: string; +} + +export interface StoreInquiryContextOutput { + storeId: string; + storeName: string; + profileImageUrl: string | null; + businessHours: InquiryBusinessHourOutput[]; + greetingMessage: string; + faqTopics: InquiryFaqTopicOutput[]; + conversationId: string | null; +} + +export interface ConversationMessageOutput { + id: string; + conversationId: string; + senderType: ConversationSenderType; + bodyFormat: ConversationBodyFormat; + bodyText: string | null; + bodyHtml: string | null; + createdAt: Date; +} + +export interface ConversationMessagesPayload { + conversationId: string; + messages: ConversationMessageOutput[]; +} diff --git a/src/features/seller/constants/seller.constants.ts b/src/features/seller/constants/seller.constants.ts index d866f009..94d017ba 100644 --- a/src/features/seller/constants/seller.constants.ts +++ b/src/features/seller/constants/seller.constants.ts @@ -60,4 +60,6 @@ export const MAX_BANNER_TITLE_LENGTH = 200; // ── 대화 ── export const MAX_CONVERSATION_BODY_TEXT_LENGTH = 2000; +// 문의 채팅 인사말 템플릿(store.greeting_message VARCHAR(500)과 동일 상한) +export const MAX_GREETING_MESSAGE_LENGTH = 500; export const MAX_CONVERSATION_BODY_HTML_LENGTH = 100000; diff --git a/src/features/seller/dto/inputs/seller-update-store-basic-info.input.ts b/src/features/seller/dto/inputs/seller-update-store-basic-info.input.ts index 3b75fb7a..94f9e932 100644 --- a/src/features/seller/dto/inputs/seller-update-store-basic-info.input.ts +++ b/src/features/seller/dto/inputs/seller-update-store-basic-info.input.ts @@ -51,4 +51,8 @@ export class SellerUpdateStoreBasicInfoInput { @IsOptional() @IsString() profileImageUrl?: string; + + @IsOptional() + @IsString() + greetingMessage?: string; } diff --git a/src/features/seller/seller-store.graphql b/src/features/seller/seller-store.graphql index ac4e3d79..b199822e 100644 --- a/src/features/seller/seller-store.graphql +++ b/src/features/seller/seller-store.graphql @@ -122,6 +122,8 @@ input SellerUpdateStoreBasicInfoInput { businessHoursText: String """매장 프로필(로고) 이미지 URL. null 전달 시 제거.""" profileImageUrl: String + """문의 채팅 인사말 템플릿. 빈 문자열이면 기본 문구로 되돌린다(null 저장).""" + greetingMessage: String } """SellerUpsertStoreBusinessHourInput 입력 타입""" diff --git a/src/features/seller/services/seller-store-mappers.helper.ts b/src/features/seller/services/seller-store-mappers.helper.ts index f2943a80..0cdad419 100644 --- a/src/features/seller/services/seller-store-mappers.helper.ts +++ b/src/features/seller/services/seller-store-mappers.helper.ts @@ -28,6 +28,7 @@ export interface StoreRow { website_url: string | null; business_hours_text: string | null; profile_image_url: string | null; + greeting_message: string | null; pickup_slot_interval_minutes: number; min_lead_time_minutes: number; max_days_ahead: number; @@ -78,6 +79,7 @@ export function toStoreOutput(row: StoreRow): SellerStoreOutput { websiteUrl: row.website_url, businessHoursText: row.business_hours_text, profileImageUrl: row.profile_image_url, + greetingMessage: row.greeting_message, pickupSlotIntervalMinutes: row.pickup_slot_interval_minutes, minLeadTimeMinutes: row.min_lead_time_minutes, maxDaysAhead: row.max_days_ahead, diff --git a/src/features/seller/services/seller-store-profile.service.spec.ts b/src/features/seller/services/seller-store-profile.service.spec.ts index 5e81990d..b4da4199 100644 --- a/src/features/seller/services/seller-store-profile.service.spec.ts +++ b/src/features/seller/services/seller-store-profile.service.spec.ts @@ -188,5 +188,28 @@ describe('SellerStoreProfileService (real DB)', () => { } as unknown as SellerUpdateStoreBasicInfoInput); expect(removed.profileImageUrl).toBeNull(); }); + + it('greetingMessage를 설정하고, 빈 문자열이면 null로 되돌린다(기본 인사말 사용)', async () => { + const { account, store } = await setupSellerWithStore(prisma); + + const set = await service.sellerUpdateStoreBasicInfo(account.id, { + greetingMessage: '{nickname}님 반가워요! {storeName}입니다.', + }); + expect(set.greetingMessage).toBe( + '{nickname}님 반가워요! {storeName}입니다.', + ); + const dbStore = await prisma.store.findUniqueOrThrow({ + where: { id: store.id }, + }); + expect(dbStore.greeting_message).toBe( + '{nickname}님 반가워요! {storeName}입니다.', + ); + + // 빈 문자열 → null 저장(문의 채팅은 서버 기본 문구로 동작) + const cleared = await service.sellerUpdateStoreBasicInfo(account.id, { + greetingMessage: ' ', + }); + expect(cleared.greetingMessage).toBeNull(); + }); }); }); diff --git a/src/features/seller/services/seller-store-profile.service.ts b/src/features/seller/services/seller-store-profile.service.ts index 3651b55e..33909be6 100644 --- a/src/features/seller/services/seller-store-profile.service.ts +++ b/src/features/seller/services/seller-store-profile.service.ts @@ -12,6 +12,7 @@ import { import { STORE_NOT_FOUND } from '@/features/seller/constants/seller-error-messages'; import { MAX_ADDRESS_CITY_LENGTH, + MAX_GREETING_MESSAGE_LENGTH, MAX_ADDRESS_DISTRICT_LENGTH, MAX_ADDRESS_FULL_LENGTH, MAX_ADDRESS_NEIGHBORHOOD_LENGTH, @@ -163,6 +164,15 @@ export class SellerStoreProfileService ), } : {}), + // 빈 문자열은 null 저장 → 문의 채팅에서 서버 기본 인사말로 되돌아간다 + ...(input.greetingMessage !== undefined + ? { + greeting_message: cleanNullableText( + input.greetingMessage, + MAX_GREETING_MESSAGE_LENGTH, + ), + } + : {}), }; } } diff --git a/src/features/seller/types/seller-output.type.ts b/src/features/seller/types/seller-output.type.ts index 90766b75..7d35618a 100644 --- a/src/features/seller/types/seller-output.type.ts +++ b/src/features/seller/types/seller-output.type.ts @@ -13,6 +13,7 @@ export interface SellerStoreOutput { websiteUrl: string | null; businessHoursText: string | null; profileImageUrl: string | null; + greetingMessage: string | null; pickupSlotIntervalMinutes: number; minLeadTimeMinutes: number; maxDaysAhead: number; diff --git a/src/test/factories/store.factory.ts b/src/test/factories/store.factory.ts index 9f5363b3..da1b7e29 100644 --- a/src/test/factories/store.factory.ts +++ b/src/test/factories/store.factory.ts @@ -18,6 +18,8 @@ export interface StoreOverrides { map_provider?: 'NAVER' | 'KAKAO' | 'NONE'; business_hours_text?: string | null; profile_image_url?: string | null; + greeting_message?: string | null; + deleted_at?: Date | null; access_guide_text?: string | null; regular_closure_text?: string | null; pickup_slot_interval_minutes?: number; @@ -51,6 +53,8 @@ export async function createStore( map_provider: overrides.map_provider ?? 'NONE', business_hours_text: overrides.business_hours_text ?? null, profile_image_url: overrides.profile_image_url ?? null, + greeting_message: overrides.greeting_message ?? null, + deleted_at: overrides.deleted_at ?? null, access_guide_text: overrides.access_guide_text ?? null, regular_closure_text: overrides.regular_closure_text ?? null, pickup_slot_interval_minutes: From 897a8a8a4042004666003cf2934e5560926da9d4 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 03:30:35 +0900 Subject: [PATCH 06/27] =?UTF-8?q?fix(conversation):=20=EB=8C=80=ED=99=94?= =?UTF-8?q?=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EB=B3=B5=EA=B5=AC=C2=B7?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=8A=A4=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=C2=B7?= =?UTF-8?q?SellerStore=20=EC=9D=B8=EC=82=AC=EB=A7=90=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=20(PR=20#268=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 Codex 지적 3건 반영. - P1: soft-delete된 대화 재사용 시 deleted_at을 복구한다 — 삭제 상태로 메시지만 쌓이면 구매자·판매자 어느 조회에도 잡히지 않아 유실돼 보인다. 메시지 저장 트랜잭션의 대화 갱신에서 deleted_at: null 명시(평상시 no-op) - P2: 대화 확보(getOrCreateConversation)를 메시지 트랜잭션 밖으로 분리 — REPEATABLE READ 스냅샷 안의 P2002 복구 재조회는 경쟁 트랜잭션의 커밋 row를 못 볼 수 있다. 새 문장(새 스냅샷)에서 재조회해 승자 row를 얻는다. 대화 생성 후 메시지 트랜잭션이 실패해도 빈 대화는 목록에 노출되지 않음 - P2: SellerStore GraphQL 타입에 greetingMessage 필드 누락 보완(설정값 조회 불가 문제). 입력·매퍼·출력 타입은 기존 커밋에 이미 반영돼 있었음 - 재사용 spec에 deleted_at 복구 검증 추가 --- .../repositories/conversation.repository.ts | 136 ++++++++++-------- .../conversation-inquiry.service.spec.ts | 5 + src/features/seller/seller-store.graphql | 2 + 3 files changed, 84 insertions(+), 59 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 025e7b20..2dda7078 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -129,11 +129,8 @@ export class ConversationRepository { } /** - * 구매자 메시지 저장. 대화가 없으면 이 트랜잭션에서 생성하고, 그 경우에만 - * 인사말(STORE 발신)을 유저 메시지보다 먼저 저장한다 — 인사말은 대화당 1회. - * - * 동시 첫 전송 레이스: findFirst 이후 create가 (account_id, store_id) 유니크에 - * 걸릴 수 있다 → P2002면 기존 대화를 다시 찾아 인사말 없이 이어간다. + * 구매자 메시지 저장. 대화가 없으면 먼저 생성하고, 새로 생성한 경우에만 + * 인사말(STORE 발신)을 유저 메시지보다 앞서 저장한다 — 인사말은 대화당 1회. */ async createBuyerMessages(args: { accountId: bigint; @@ -142,62 +139,29 @@ export class ConversationRepository { entries: ConversationMessageEntry[]; now: Date; }) { - return this.prisma.$transaction(async (tx) => { - // (account_id, store_id) 유니크는 soft-delete된 row도 잡는다 — 조회를 - // 활성만으로 좁히면 삭제 row 존재 시 create가 항상 P2002로 터지므로, - // deleted_at 필터를 명시 해제(undefined)해 유니크 제약과 같은 범위로 찾는다. - let conversation = await tx.storeConversation.findFirst({ - where: { - account_id: args.accountId, - store_id: args.storeId, - deleted_at: undefined, - }, - }); + // 대화 확보는 메시지 트랜잭션 밖에서 수행한다 — REPEATABLE READ 스냅샷 + // 안에서 P2002 복구 재조회를 하면 경쟁 트랜잭션이 커밋한 row가 보이지 + // 않을 수 있다(리뷰 반영). 메시지 삽입 전에 대화 row가 확정되면 충분하고, + // 이후 메시지 트랜잭션이 실패해도 빈 대화 row는 목록에 노출되지 않는다 + // (last_message_at null). + const { conversation, created } = await this.getOrCreateConversation(args); - let withGreeting = false; - if (!conversation) { - try { - conversation = await tx.storeConversation.create({ - data: { - account_id: args.accountId, - store_id: args.storeId, - created_at: args.now, + const toCreate: ConversationMessageEntry[] = [ + ...(created + ? [ + { + senderType: ConversationSenderType.STORE, + senderAccountId: null, + bodyFormat: ConversationBodyFormat.TEXT, + bodyText: args.greetingBodyText, + bodyHtml: null, }, - }); - withGreeting = true; - } catch (e) { - if ( - e instanceof Prisma.PrismaClientKnownRequestError && - e.code === 'P2002' - ) { - conversation = await tx.storeConversation.findFirstOrThrow({ - where: { - account_id: args.accountId, - store_id: args.storeId, - deleted_at: undefined, - }, - }); - } else { - throw e; - } - } - } - - const toCreate: ConversationMessageEntry[] = [ - ...(withGreeting - ? [ - { - senderType: ConversationSenderType.STORE, - senderAccountId: null, - bodyFormat: ConversationBodyFormat.TEXT, - bodyText: args.greetingBodyText, - bodyHtml: null, - }, - ] - : []), - ...args.entries, - ]; + ] + : []), + ...args.entries, + ]; + return this.prisma.$transaction(async (tx) => { // createMany는 생성 row를 돌려주지 않아 순서 보존 개별 create로 저장한다 // (한 호출당 최대 3건이라 비용 문제 없음) const messages = []; @@ -219,13 +183,67 @@ export class ConversationRepository { await tx.storeConversation.update({ where: { id: conversation.id }, - data: { last_message_at: args.now, updated_at: args.now }, + data: { + last_message_at: args.now, + updated_at: args.now, + // soft-delete된 대화를 재사용한 경우 복구한다 — 삭제 상태로 두면 + // 구매자·판매자 어느 조회에도 잡히지 않아 메시지가 유실돼 보인다 + // (리뷰 반영). 평상시엔 이미 null이라 no-op. + deleted_at: null, + }, }); return { conversationId: conversation.id, messages }; }); } + /** + * (account_id, store_id) 대화 확보. 유니크 제약은 soft-delete row도 잡으므로 + * 조회 범위를 제약과 동일하게(삭제 포함, deleted_at 필터 명시 해제) 맞춘다. + * 동시 첫 전송 레이스는 P2002 후 새 문장(새 스냅샷) 재조회로 승자 row를 얻는다. + */ + private async getOrCreateConversation(args: { + accountId: bigint; + storeId: bigint; + now: Date; + }) { + const existing = await this.prisma.storeConversation.findFirst({ + where: { + account_id: args.accountId, + store_id: args.storeId, + deleted_at: undefined, + }, + }); + if (existing) return { conversation: existing, created: false }; + + try { + const conversation = await this.prisma.storeConversation.create({ + data: { + account_id: args.accountId, + store_id: args.storeId, + created_at: args.now, + }, + }); + return { conversation, created: true }; + } catch (e) { + if ( + e instanceof Prisma.PrismaClientKnownRequestError && + e.code === 'P2002' + ) { + const conversation = + await this.prisma.storeConversation.findFirstOrThrow({ + where: { + account_id: args.accountId, + store_id: args.storeId, + deleted_at: undefined, + }, + }); + return { conversation, created: false }; + } + throw e; + } + } + async createSellerConversationMessage(args: { conversationId: bigint; sellerAccountId: bigint; diff --git a/src/features/conversation/services/conversation-inquiry.service.spec.ts b/src/features/conversation/services/conversation-inquiry.service.spec.ts index 8148cbb2..68fc078b 100644 --- a/src/features/conversation/services/conversation-inquiry.service.spec.ts +++ b/src/features/conversation/services/conversation-inquiry.service.spec.ts @@ -236,6 +236,11 @@ describe('ConversationInquiryService (real DB)', () => { // 기존 대화 재사용이므로 인사말은 다시 저장하지 않는다 expect(result.messages).toHaveLength(1); expect(result.messages[0].senderType).toBe('USER'); + // 재사용 시 복구 — 삭제 상태로 두면 어느 조회에도 잡히지 않는다 + const restored = await prisma.storeConversation.findUniqueOrThrow({ + where: { id: softDeleted.id }, + }); + expect(restored.deleted_at).toBeNull(); }); it('공백뿐인 본문·2000자 초과 본문은 거절한다', async () => { diff --git a/src/features/seller/seller-store.graphql b/src/features/seller/seller-store.graphql index b199822e..d2f4c3b0 100644 --- a/src/features/seller/seller-store.graphql +++ b/src/features/seller/seller-store.graphql @@ -58,6 +58,8 @@ type SellerStore { businessHoursText: String """매장 프로필(로고) 이미지 URL. 미등록 시 null.""" profileImageUrl: String + """문의 채팅 인사말 템플릿({nickname}/{storeName} 치환). null이면 기본 문구.""" + greetingMessage: String pickupSlotIntervalMinutes: Int! minLeadTimeMinutes: Int! maxDaysAhead: Int! From 590ba92e97ed8ef47c2a2b0f90add92cbf889364 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 03:37:36 +0900 Subject: [PATCH 07/27] =?UTF-8?q?fix(conversation):=20=EC=9D=B8=EC=82=AC?= =?UTF-8?q?=EB=A7=90=20=EC=B4=88=EA=B8=B0=ED=99=94=EB=A5=BC=20row=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20+=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20(PR=20#268=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 지적 반영: "대화를 새로 생성했는가" 플래그 기준 인사말 저장은 동시 첫 전송(후발 요청이 인사말보다 먼저 유저 메시지 삽입)과 생성 후 메시지 트랜잭션 실패 재시도(빈 대화를 초기화 완료로 오인 → 인사말 영구 누락)에서 "인사말이 항상 첫 메시지" 계약을 깨뜨린다. - 메시지 트랜잭션 안에서 대화 row를 SELECT ... FOR UPDATE로 잠근 뒤 실제 메시지 수 0건일 때만 인사말을 삽입 — 동시 전송은 잠금으로 직렬화, 실패 재시도도 count 기준이라 인사말이 복구된다 - 빈 대화(soft-delete 재사용 포함)에는 인사말부터 저장되도록 spec 갱신 --- .../repositories/conversation.repository.ts | 47 +++++++++++-------- .../conversation-inquiry.service.spec.ts | 9 ++-- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 2dda7078..ca84322c 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -144,24 +144,33 @@ export class ConversationRepository { // 않을 수 있다(리뷰 반영). 메시지 삽입 전에 대화 row가 확정되면 충분하고, // 이후 메시지 트랜잭션이 실패해도 빈 대화 row는 목록에 노출되지 않는다 // (last_message_at null). - const { conversation, created } = await this.getOrCreateConversation(args); - - const toCreate: ConversationMessageEntry[] = [ - ...(created - ? [ - { - senderType: ConversationSenderType.STORE, - senderAccountId: null, - bodyFormat: ConversationBodyFormat.TEXT, - bodyText: args.greetingBodyText, - bodyHtml: null, - }, - ] - : []), - ...args.entries, - ]; + const { conversation } = await this.getOrCreateConversation(args); return this.prisma.$transaction(async (tx) => { + // 첫 메시지 초기화(인사말) 직렬화 — 대화 row를 잠근 뒤 실제 메시지 + // 수로 인사말 필요 여부를 판정한다(리뷰 반영). "생성 여부" 플래그는 + // 동시 첫 전송·생성 후 실패 재시도에서 인사말 계약(항상 첫 메시지)을 + // 깨뜨린다. raw SQL이라 soft-delete 필터는 무관(id 지정 잠금). + await tx.$queryRaw`SELECT id FROM store_conversation WHERE id = ${conversation.id} FOR UPDATE`; + const messageCount = await tx.storeConversationMessage.count({ + where: { conversation_id: conversation.id }, + }); + + const toCreate: ConversationMessageEntry[] = [ + ...(messageCount === 0 + ? [ + { + senderType: ConversationSenderType.STORE, + senderAccountId: null, + bodyFormat: ConversationBodyFormat.TEXT, + bodyText: args.greetingBodyText, + bodyHtml: null, + }, + ] + : []), + ...args.entries, + ]; + // createMany는 생성 row를 돌려주지 않아 순서 보존 개별 create로 저장한다 // (한 호출당 최대 3건이라 비용 문제 없음) const messages = []; @@ -214,7 +223,7 @@ export class ConversationRepository { deleted_at: undefined, }, }); - if (existing) return { conversation: existing, created: false }; + if (existing) return { conversation: existing }; try { const conversation = await this.prisma.storeConversation.create({ @@ -224,7 +233,7 @@ export class ConversationRepository { created_at: args.now, }, }); - return { conversation, created: true }; + return { conversation }; } catch (e) { if ( e instanceof Prisma.PrismaClientKnownRequestError && @@ -238,7 +247,7 @@ export class ConversationRepository { deleted_at: undefined, }, }); - return { conversation, created: false }; + return { conversation }; } throw e; } diff --git a/src/features/conversation/services/conversation-inquiry.service.spec.ts b/src/features/conversation/services/conversation-inquiry.service.spec.ts index 68fc078b..b04d8daa 100644 --- a/src/features/conversation/services/conversation-inquiry.service.spec.ts +++ b/src/features/conversation/services/conversation-inquiry.service.spec.ts @@ -233,9 +233,12 @@ describe('ConversationInquiryService (real DB)', () => { // 유니크 제약 범위(삭제 포함)와 동일하게 기존 row를 찾아 이어간다 expect(result.conversationId).toBe(softDeleted.id.toString()); - // 기존 대화 재사용이므로 인사말은 다시 저장하지 않는다 - expect(result.messages).toHaveLength(1); - expect(result.messages[0].senderType).toBe('USER'); + // 인사말 여부는 "메시지 0건" 기준 — 빈 대화 재사용이면 인사말부터 저장한다 + expect(result.messages).toHaveLength(2); + expect(result.messages.map((m) => m.senderType)).toEqual([ + 'STORE', + 'USER', + ]); // 재사용 시 복구 — 삭제 상태로 두면 어느 조회에도 잡히지 않는다 const restored = await prisma.storeConversation.findUniqueOrThrow({ where: { id: softDeleted.id }, From fbadab58fb6236b2eea4e361fcceb3eca14a7a84 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 03:44:54 +0900 Subject: [PATCH 08/27] =?UTF-8?q?fix(conversation):=20=EB=8C=80=ED=99=94?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EC=9D=84=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EA=B3=BC=20=EC=9B=90?= =?UTF-8?q?=EC=9E=90=ED=99=94=20(PR=20#268=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 지적 반영: 대화 row가 메시지 트랜잭션보다 먼저 커밋되면 sellerConversations(빈 대화 미필터)에 노출돼 판매자가 인사말보다 먼저 답장할 수 있고, 메시지 저장 실패 시 유령 대화가 영구히 남는다. - 대화 생성/잠금을 메시지 트랜잭션 안 lockOrCreateConversation으로 통합: 기존 대화는 id FOR UPDATE 잠금, 부재 시 본 트랜잭션에서 생성 - 동시 첫 전송의 P2002 복구는 FOR UPDATE 잠금 조회(locking read)로 수행 — REPEATABLE READ 스냅샷을 우회해 승자 커밋 row를 읽으므로, 앞서 지적된 스냅샷 문제 없이 원자성을 되찾는다 - 실패 시 전체 롤백이라 유령 대화·인사말 누락 재시도 문제 모두 해소 --- .../repositories/conversation.repository.ts | 85 ++++++++++--------- 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index ca84322c..d0914546 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -129,8 +129,13 @@ export class ConversationRepository { } /** - * 구매자 메시지 저장. 대화가 없으면 먼저 생성하고, 새로 생성한 경우에만 - * 인사말(STORE 발신)을 유저 메시지보다 앞서 저장한다 — 인사말은 대화당 1회. + * 구매자 메시지 저장. 대화가 없으면 같은 트랜잭션에서 생성하고, 인사말은 + * "대화의 첫 메시지"일 때만(메시지 0건) 유저 메시지보다 앞서 저장한다. + * + * 대화 생성과 메시지 저장을 한 트랜잭션으로 묶는다 — 대화 row가 먼저 + * 커밋되면 판매자 목록에 빈 대화가 노출되고, 이후 메시지 저장이 실패하면 + * 유령 대화가 남는다(리뷰 반영). 실패 시 전체가 롤백되므로 재시도에서 + * 인사말 계약도 유지된다. */ async createBuyerMessages(args: { accountId: bigint; @@ -139,21 +144,15 @@ export class ConversationRepository { entries: ConversationMessageEntry[]; now: Date; }) { - // 대화 확보는 메시지 트랜잭션 밖에서 수행한다 — REPEATABLE READ 스냅샷 - // 안에서 P2002 복구 재조회를 하면 경쟁 트랜잭션이 커밋한 row가 보이지 - // 않을 수 있다(리뷰 반영). 메시지 삽입 전에 대화 row가 확정되면 충분하고, - // 이후 메시지 트랜잭션이 실패해도 빈 대화 row는 목록에 노출되지 않는다 - // (last_message_at null). - const { conversation } = await this.getOrCreateConversation(args); - return this.prisma.$transaction(async (tx) => { - // 첫 메시지 초기화(인사말) 직렬화 — 대화 row를 잠근 뒤 실제 메시지 - // 수로 인사말 필요 여부를 판정한다(리뷰 반영). "생성 여부" 플래그는 - // 동시 첫 전송·생성 후 실패 재시도에서 인사말 계약(항상 첫 메시지)을 - // 깨뜨린다. raw SQL이라 soft-delete 필터는 무관(id 지정 잠금). - await tx.$queryRaw`SELECT id FROM store_conversation WHERE id = ${conversation.id} FOR UPDATE`; + const conversationId = await this.lockOrCreateConversation(tx, args); + + // 인사말 필요 여부는 실제 메시지 수로 판정한다 — "생성 여부" 플래그는 + // 동시 첫 전송·실패 재시도에서 인사말 계약(항상 첫 메시지)을 깨뜨린다. + // 위에서 row를 잠갔거나(기존 대화) 본 트랜잭션이 만들었으므로(신규) + // count 판정은 직렬화된다. const messageCount = await tx.storeConversationMessage.count({ - where: { conversation_id: conversation.id }, + where: { conversation_id: conversationId }, }); const toCreate: ConversationMessageEntry[] = [ @@ -178,7 +177,7 @@ export class ConversationRepository { messages.push( await tx.storeConversationMessage.create({ data: { - conversation_id: conversation.id, + conversation_id: conversationId, sender_type: entry.senderType, sender_account_id: entry.senderAccountId, body_format: entry.bodyFormat, @@ -191,7 +190,7 @@ export class ConversationRepository { } await tx.storeConversation.update({ - where: { id: conversation.id }, + where: { id: conversationId }, data: { last_message_at: args.now, updated_at: args.now, @@ -202,52 +201,62 @@ export class ConversationRepository { }, }); - return { conversationId: conversation.id, messages }; + return { conversationId, messages }; }); } /** - * (account_id, store_id) 대화 확보. 유니크 제약은 soft-delete row도 잡으므로 - * 조회 범위를 제약과 동일하게(삭제 포함, deleted_at 필터 명시 해제) 맞춘다. - * 동시 첫 전송 레이스는 P2002 후 새 문장(새 스냅샷) 재조회로 승자 row를 얻는다. + * 트랜잭션 안에서 (account_id, store_id) 대화를 잠그거나 생성한다. + * - 기존 대화: id FOR UPDATE 잠금(초기화 직렬화). 유니크 제약은 soft-delete + * row도 잡으므로 조회 범위를 제약과 동일하게(deleted_at 필터 해제) 맞춘다. + * - 부재: 본 트랜잭션에서 생성. 동시 첫 전송의 패자는 승자 커밋 후 P2002를 + * 받는데, REPEATABLE READ 스냅샷의 일반 재조회는 승자 row를 못 볼 수 있어 + * FOR UPDATE 잠금 조회(locking read — MVCC 스냅샷 우회, 최신 커밋을 읽음)로 + * 복구한다. */ - private async getOrCreateConversation(args: { - accountId: bigint; - storeId: bigint; - now: Date; - }) { + private async lockOrCreateConversation( + tx: Prisma.TransactionClient, + args: { accountId: bigint; storeId: bigint; now: Date }, + ): Promise { + const lockExisting = async (): Promise => { + const rows = await tx.$queryRaw<{ id: bigint }[]>` + SELECT id FROM store_conversation + WHERE account_id = ${args.accountId} AND store_id = ${args.storeId} + FOR UPDATE`; + return rows[0]?.id ?? null; + }; + const existing = await this.prisma.storeConversation.findFirst({ where: { account_id: args.accountId, store_id: args.storeId, deleted_at: undefined, }, + select: { id: true }, }); - if (existing) return { conversation: existing }; + if (existing) { + const locked = await lockExisting(); + if (locked !== null) return locked; + // 조회와 잠금 사이 hard delete는 운영상 없는 경로 — 생성 재시도로 폴백 + } try { - const conversation = await this.prisma.storeConversation.create({ + const created = await tx.storeConversation.create({ data: { account_id: args.accountId, store_id: args.storeId, created_at: args.now, }, + select: { id: true }, }); - return { conversation }; + return created.id; } catch (e) { if ( e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002' ) { - const conversation = - await this.prisma.storeConversation.findFirstOrThrow({ - where: { - account_id: args.accountId, - store_id: args.storeId, - deleted_at: undefined, - }, - }); - return { conversation }; + const locked = await lockExisting(); + if (locked !== null) return locked; } throw e; } From 0224c0d2252cd0572447d3f82b756d858e9deb99 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 03:59:11 +0900 Subject: [PATCH 09/27] =?UTF-8?q?fix(conversation):=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EB=82=B4=20=EC=82=AC=EC=A0=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20tx=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20(PR=20#268=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 지적 반영: $transaction 콜백 안에서 루트 클라이언트 조회는 풀 커넥션을 추가로 점유해, 동시 전송이 풀을 소진하면 상호 대기(타임아웃)가 난다(connection_limit=1이면 즉시 재현). 사전 조회를 tx 경유로 변경 — tx 스냅샷이 경쟁 커밋을 못 봐도 create → P2002 → FOR UPDATE 잠금 조회 경로가 복구하므로 의미는 동일하다. --- .../conversation/repositories/conversation.repository.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index d0914546..a080b450 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -226,7 +226,11 @@ export class ConversationRepository { return rows[0]?.id ?? null; }; - const existing = await this.prisma.storeConversation.findFirst({ + // 사전 조회도 tx 경유 — 트랜잭션 안에서 루트 클라이언트를 쓰면 풀 + // 커넥션을 2개 점유해 동시 전송이 풀을 소진하면 상호 대기가 난다(리뷰 + // 반영). tx 스냅샷이 경쟁 커밋을 못 봐도 create → P2002 → 잠금 조회 + // 경로가 복구하므로 안전하다. + const existing = await tx.storeConversation.findFirst({ where: { account_id: args.accountId, store_id: args.storeId, From 0d87e118e4923ea7ff73fbe79a3fbcf2e07c0012 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 03:28:37 +0900 Subject: [PATCH 10/27] =?UTF-8?q?feat(conversation):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=84=BC=ED=84=B0=20=EB=8C=80=ED=99=94=20=ED=83=AD=20=E2=80=94?= =?UTF-8?q?=20=EB=8C=80=ED=99=94=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=C2=B7?= =?UTF-8?q?=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit figma 알림센터(대화 탭) 화면 대응 3/4. 변경점 - Query myConversations: 마지막 메시지 최신순 키셋 커서 목록 — 매장 프로필·마지막 메시지 미리보기(HTML은 태그 제거 plain text)·안읽은 수신 메시지 수(unreadCount, "(3)" 표기용). 메시지 없는 대화는 제외 - Query conversationMessages: 본인 대화 검증 후 최신순(id desc) 키셋 커서 메시지 목록. 조회 시 last_read_at 자동 갱신(별도 mutation 없는 읽음 처리, 사용자 확정 정책 — 의도적 쓰기 부수효과로 주석 명시) - ":" 커서 파싱·조립을 common/utils/keyset-cursor로 공용화 (형식·안전 정수·Date 범위·UNSIGNED BIGINT 상한 방어 일원화) — user 알림 커서도 동일 유틸로 리팩토링 - ConversationBaseService로 활성 USER 판정 공유(inquiry/center 공통) 회귀 테스트 - center service 6케이스(정렬·미리보기·unreadCount·본인 메시지 제외·커서· 빈 대화 제외·읽음 부수효과·소유권), 커서 유틸 단위 5케이스, 미리보기 매퍼 4케이스, resolver 통합 1케이스, input spec 6케이스 --- src/common/utils/keyset-cursor.spec.ts | 50 +++ src/common/utils/keyset-cursor.ts | 47 +++ .../constants/conversation-error-messages.ts | 3 + .../constants/conversation.constants.ts | 5 + .../conversation/conversation-center.graphql | 60 ++++ .../conversation/conversation.module.ts | 4 + .../conversation-messages.input.spec.ts | 26 ++ .../dto/inputs/conversation-messages.input.ts | 23 ++ .../dto/inputs/my-conversations.input.spec.ts | 26 ++ .../dto/inputs/my-conversations.input.ts | 23 ++ .../repositories/conversation.repository.ts | 102 +++++++ .../conversation-center-query.resolver.ts | 45 +++ .../conversation-center.resolver.spec.ts | 74 +++++ .../services/conversation-base.service.ts | 30 ++ ...conversation-center-mappers.helper.spec.ts | 36 +++ .../conversation-center-mappers.helper.ts | 32 ++ .../conversation-center.service.spec.ts | 288 ++++++++++++++++++ .../services/conversation-center.service.ts | 123 ++++++++ .../services/conversation-inquiry.service.ts | 36 +-- .../types/conversation-output.type.ts | 24 ++ src/features/user/constants/user.constants.ts | 3 - .../services/user-notification.service.ts | 45 +-- 22 files changed, 1040 insertions(+), 65 deletions(-) create mode 100644 src/common/utils/keyset-cursor.spec.ts create mode 100644 src/common/utils/keyset-cursor.ts create mode 100644 src/features/conversation/conversation-center.graphql create mode 100644 src/features/conversation/dto/inputs/conversation-messages.input.spec.ts create mode 100644 src/features/conversation/dto/inputs/conversation-messages.input.ts create mode 100644 src/features/conversation/dto/inputs/my-conversations.input.spec.ts create mode 100644 src/features/conversation/dto/inputs/my-conversations.input.ts create mode 100644 src/features/conversation/resolvers/conversation-center-query.resolver.ts create mode 100644 src/features/conversation/resolvers/conversation-center.resolver.spec.ts create mode 100644 src/features/conversation/services/conversation-base.service.ts create mode 100644 src/features/conversation/services/conversation-center-mappers.helper.spec.ts create mode 100644 src/features/conversation/services/conversation-center-mappers.helper.ts create mode 100644 src/features/conversation/services/conversation-center.service.spec.ts create mode 100644 src/features/conversation/services/conversation-center.service.ts diff --git a/src/common/utils/keyset-cursor.spec.ts b/src/common/utils/keyset-cursor.spec.ts new file mode 100644 index 00000000..195379d0 --- /dev/null +++ b/src/common/utils/keyset-cursor.spec.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; + +import { + buildTimestampIdCursor, + parseTimestampIdCursor, +} from '@/common/utils/keyset-cursor'; + +describe('keyset-cursor', () => { + const ERR = 'Invalid cursor.'; + + it('build → parse 왕복이 값을 보존한다', () => { + const ts = new Date('2026-08-01T12:34:56.789Z'); + const raw = buildTimestampIdCursor(ts, BigInt(42)); + + expect(parseTimestampIdCursor(raw, ERR)).toEqual({ + timestamp: ts, + id: BigInt(42), + }); + }); + + it('형식이 다르면 거부한다', () => { + for (const raw of ['abc', '123', '1:2:3', '-1:2', '1:-2', '']) { + expect(() => parseTimestampIdCursor(raw, ERR)).toThrow( + BadRequestException, + ); + } + }); + + it('안전 정수 밖 timestamp(자릿수 폭탄)를 거부한다', () => { + expect(() => parseTimestampIdCursor(`${'9'.repeat(30)}:1`, ERR)).toThrow( + BadRequestException, + ); + }); + + it('Date 지원 범위 밖 timestamp를 거부한다', () => { + expect(() => parseTimestampIdCursor('9000000000000000:1', ERR)).toThrow( + BadRequestException, + ); + }); + + it('UNSIGNED BIGINT 상한을 넘는 id를 거부한다', () => { + expect(() => + parseTimestampIdCursor(`1700000000000:${'9'.repeat(30)}`, ERR), + ).toThrow(BadRequestException); + // 상한 자체는 허용 + expect( + parseTimestampIdCursor('1700000000000:18446744073709551615', ERR).id, + ).toBe(18446744073709551615n); + }); +}); diff --git a/src/common/utils/keyset-cursor.ts b/src/common/utils/keyset-cursor.ts new file mode 100644 index 00000000..256122d6 --- /dev/null +++ b/src/common/utils/keyset-cursor.ts @@ -0,0 +1,47 @@ +import { BadRequestException } from '@nestjs/common'; + +/** + * ":" 형식 키셋 커서 파싱 공용 유틸. + * 커서 토큰은 (시각, id) desc 정렬과 결합돼 있어 정렬이 바뀌면 무효다. + * + * 방어(전부 형식 오류로 거부): + * - 형식 불일치, 안전 정수 밖 timestamp(자릿수 폭탄 → Infinity) + * - Date 지원 범위(±8.64e15ms) 밖 timestamp → Invalid Date로 Prisma 내부 오류 + * - DB UNSIGNED BIGINT 상한을 넘는 id → 커넥터 범위 오류 + */ + +// DB UNSIGNED BIGINT 상한(2^64-1). 외부 입력 id의 범위 방어에 쓴다. +export const MAX_UNSIGNED_BIGINT = 18446744073709551615n; + +export interface TimestampIdCursor { + timestamp: Date; + id: bigint; +} + +export function parseTimestampIdCursor( + raw: string, + errorMessage: string, +): TimestampIdCursor { + const match = /^(\d+):(\d+)$/.exec(raw); + if (!match) { + throw new BadRequestException(errorMessage); + } + const timestampMs = Number(match[1]); + if (!Number.isSafeInteger(timestampMs)) { + throw new BadRequestException(errorMessage); + } + const timestamp = new Date(timestampMs); + if (Number.isNaN(timestamp.getTime())) { + throw new BadRequestException(errorMessage); + } + const id = BigInt(match[2]); + if (id > MAX_UNSIGNED_BIGINT) { + throw new BadRequestException(errorMessage); + } + return { timestamp, id }; +} + +/** (시각, id) desc 페이지의 다음 커서 문자열. */ +export function buildTimestampIdCursor(timestamp: Date, id: bigint): string { + return `${timestamp.getTime()}:${id.toString()}`; +} diff --git a/src/features/conversation/constants/conversation-error-messages.ts b/src/features/conversation/constants/conversation-error-messages.ts index 320da054..6a7a368d 100644 --- a/src/features/conversation/constants/conversation-error-messages.ts +++ b/src/features/conversation/constants/conversation-error-messages.ts @@ -1,6 +1,9 @@ export const CONVERSATION_ERRORS = { STORE_NOT_FOUND: 'Store not found.', FAQ_TOPIC_NOT_FOUND: 'FAQ topic not found.', + CONVERSATION_NOT_FOUND: 'Conversation not found.', + // 커서는 ":" 불투명 토큰 — 형식이 다르면 클라이언트 버그다. + INVALID_CURSOR: 'Invalid conversation cursor.', // 활성 USER 판정 실패 메시지 — user feature와 동일 의미론(판정은 정책 헬퍼 공유) ACCOUNT_NOT_FOUND: 'Account not found.', ACCOUNT_DELETED: 'Account is deleted.', diff --git a/src/features/conversation/constants/conversation.constants.ts b/src/features/conversation/constants/conversation.constants.ts index e0cb4d19..2c396100 100644 --- a/src/features/conversation/constants/conversation.constants.ts +++ b/src/features/conversation/constants/conversation.constants.ts @@ -9,3 +9,8 @@ export const DEFAULT_GREETING_TEMPLATE = // 구매자 텍스트 메시지 상한. 판매자 측 MAX_CONVERSATION_BODY_TEXT_LENGTH와 동일 정책. export const MAX_INQUIRY_BODY_TEXT_LENGTH = 2000; + +// 대화 목록/채팅 상세 페이지네이션 기본값(상한 50은 DTO가 검증) +export const DEFAULT_CONVERSATION_LIST_LIMIT = 20; +export const DEFAULT_CONVERSATION_MESSAGES_LIMIT = 30; +export const MAX_CONVERSATION_PAGE_LIMIT = 50; diff --git a/src/features/conversation/conversation-center.graphql b/src/features/conversation/conversation-center.graphql new file mode 100644 index 00000000..01827ab3 --- /dev/null +++ b/src/features/conversation/conversation-center.graphql @@ -0,0 +1,60 @@ +extend type Query { + """알림센터 대화 탭 목록(구매자). 마지막 메시지 최신순.""" + myConversations(input: MyConversationsInput): MyConversationConnection! + """ + 채팅 상세 메시지 목록(최신순 키셋 커서). 구매자 본인 대화만 조회 가능하며, + 조회 시 last_read_at을 현재 시각으로 갱신한다(안읽음 배지 해소 부수효과). + """ + conversationMessages(conversationId: ID!, input: ConversationMessagesInput): ConversationMessageConnection! +} + +"""대화 목록 조회 입력""" +input MyConversationsInput { + """이전 응답의 nextCursor(불투명 토큰). 미지정 시 첫 페이지""" + cursor: String + """조회 개수(최대 50)""" + limit: Int = 20 +} + +"""대화 목록 응답""" +type MyConversationConnection { + items: [MyConversationItem!]! + """전체 대화 수""" + totalCount: Int! + hasMore: Boolean! + """다음 페이지 커서. 없으면 null""" + nextCursor: String +} + +"""대화 목록 아이템""" +type MyConversationItem { + """대화 ID""" + id: ID! + storeId: ID! + storeName: String! + """매장 프로필(로고) 이미지 URL. 미등록 시 null""" + storeProfileImageUrl: String + """마지막 메시지 미리보기(HTML 메시지는 태그 제거 plain text)""" + lastMessagePreview: String + lastMessageAt: DateTime! + """읽지 않은 수신 메시지 수(매장명 옆 "(3)" 표기용)""" + unreadCount: Int! +} + +"""채팅 상세 메시지 조회 입력""" +input ConversationMessagesInput { + """이전 응답의 nextCursor(마지막 메시지 ID). 미지정 시 최신 페이지""" + cursor: String + """조회 개수(최대 50)""" + limit: Int = 30 +} + +"""채팅 상세 메시지 응답(최신순)""" +type ConversationMessageConnection { + items: [ConversationMessage!]! + """대화 전체 메시지 수""" + totalCount: Int! + hasMore: Boolean! + """다음(더 과거) 페이지 커서. 없으면 null""" + nextCursor: String +} diff --git a/src/features/conversation/conversation.module.ts b/src/features/conversation/conversation.module.ts index 9087ca2e..a07dcf76 100644 --- a/src/features/conversation/conversation.module.ts +++ b/src/features/conversation/conversation.module.ts @@ -1,16 +1,20 @@ import { Module } from '@nestjs/common'; import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationCenterQueryResolver } from '@/features/conversation/resolvers/conversation-center-query.resolver'; import { ConversationInquiryMutationResolver } from '@/features/conversation/resolvers/conversation-inquiry-mutation.resolver'; import { ConversationInquiryQueryResolver } from '@/features/conversation/resolvers/conversation-inquiry-query.resolver'; +import { ConversationCenterService } from '@/features/conversation/services/conversation-center.service'; import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; @Module({ providers: [ ConversationRepository, ConversationInquiryService, + ConversationCenterService, ConversationInquiryQueryResolver, ConversationInquiryMutationResolver, + ConversationCenterQueryResolver, ], exports: [ConversationRepository], }) diff --git a/src/features/conversation/dto/inputs/conversation-messages.input.spec.ts b/src/features/conversation/dto/inputs/conversation-messages.input.spec.ts new file mode 100644 index 00000000..58cee9d7 --- /dev/null +++ b/src/features/conversation/dto/inputs/conversation-messages.input.spec.ts @@ -0,0 +1,26 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { ConversationMessagesInput } from '@/features/conversation/dto/inputs/conversation-messages.input'; + +function build(plain: object): ConversationMessagesInput { + return plainToInstance(ConversationMessagesInput, plain); +} + +describe('ConversationMessagesInput', () => { + it('cursor·limit 정상 조합 허용', async () => { + expect(await validate(build({ cursor: '10', limit: 30 }))).toHaveLength(0); + }); + + it('limit 0 거절', async () => { + const errors = await validate(build({ limit: 0 })); + expect(errors.map((e) => e.property)).toEqual(['limit']); + }); + + it('limit 상한(50) 초과 거절', async () => { + const errors = await validate(build({ limit: 51 })); + expect(errors.map((e) => e.property)).toEqual(['limit']); + }); +}); diff --git a/src/features/conversation/dto/inputs/conversation-messages.input.ts b/src/features/conversation/dto/inputs/conversation-messages.input.ts new file mode 100644 index 00000000..70ec2057 --- /dev/null +++ b/src/features/conversation/dto/inputs/conversation-messages.input.ts @@ -0,0 +1,23 @@ +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +import { MAX_CONVERSATION_PAGE_LIMIT } from '@/features/conversation/constants/conversation.constants'; + +export class ConversationMessagesInput { + @IsOptional() + @IsString() + @IsNotEmpty() + cursor?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_CONVERSATION_PAGE_LIMIT) + limit?: number; +} diff --git a/src/features/conversation/dto/inputs/my-conversations.input.spec.ts b/src/features/conversation/dto/inputs/my-conversations.input.spec.ts new file mode 100644 index 00000000..37b8981a --- /dev/null +++ b/src/features/conversation/dto/inputs/my-conversations.input.spec.ts @@ -0,0 +1,26 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { MyConversationsInput } from '@/features/conversation/dto/inputs/my-conversations.input'; + +function build(plain: object): MyConversationsInput { + return plainToInstance(MyConversationsInput, plain); +} + +describe('MyConversationsInput', () => { + it('모든 필드 누락 허용(기본값은 서비스가 처리)', async () => { + expect(await validate(build({}))).toHaveLength(0); + }); + + it('빈 문자열 커서 거절', async () => { + const errors = await validate(build({ cursor: '' })); + expect(errors.map((e) => e.property)).toEqual(['cursor']); + }); + + it('limit 상한(50) 초과 거절', async () => { + const errors = await validate(build({ limit: 51 })); + expect(errors.map((e) => e.property)).toEqual(['limit']); + }); +}); diff --git a/src/features/conversation/dto/inputs/my-conversations.input.ts b/src/features/conversation/dto/inputs/my-conversations.input.ts new file mode 100644 index 00000000..2fa65366 --- /dev/null +++ b/src/features/conversation/dto/inputs/my-conversations.input.ts @@ -0,0 +1,23 @@ +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +import { MAX_CONVERSATION_PAGE_LIMIT } from '@/features/conversation/constants/conversation.constants'; + +export class MyConversationsInput { + @IsOptional() + @IsString() + @IsNotEmpty() + cursor?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_CONVERSATION_PAGE_LIMIT) + limit?: number; +} diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index a080b450..16766431 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -128,6 +128,108 @@ export class ConversationRepository { }); } + /** + * 구매자 대화 목록 페이지. (last_message_at, id) desc 키셋. + * 대화는 첫 메시지 전송 시에만 생성되지만, 방어적으로 메시지 없는 + * 대화(last_message_at null)는 목록에서 제외한다 — 커서 정렬 키가 없다. + */ + async listConversationsByAccount(args: { + accountId: bigint; + limit: number; + cursor?: { lastMessageAt: Date; id: bigint }; + }) { + const where: Prisma.StoreConversationWhereInput = { + account_id: args.accountId, + last_message_at: { not: null }, + }; + return this.prisma.storeConversation.findMany({ + where: args.cursor + ? { + AND: [ + where, + { + OR: [ + { last_message_at: { lt: args.cursor.lastMessageAt } }, + { + last_message_at: args.cursor.lastMessageAt, + id: { lt: args.cursor.id }, + }, + ], + }, + ], + } + : where, + orderBy: [{ last_message_at: 'desc' }, { id: 'desc' }], + take: args.limit + 1, + include: { + store: { select: { store_name: true, profile_image_url: true } }, + }, + }); + } + + async countConversationsByAccount(accountId: bigint): Promise { + return this.prisma.storeConversation.count({ + where: { account_id: accountId, last_message_at: { not: null } }, + }); + } + + /** + * 목록 아이템 부가 정보 — 대화별 마지막 메시지와 안읽은 수신 메시지 수. + * 안읽음 = last_read_at 이후 도착한, 내가 보낸 것이 아닌 메시지. + * 페이지 크기(≤50) 만큼의 소규모 병렬 조회라 per-row 쿼리로 충분하다. + */ + async getConversationListExtras( + rows: { id: bigint; last_read_at: Date | null }[], + ) { + return Promise.all( + rows.map(async (row) => { + const [lastMessage, unreadCount] = await Promise.all([ + this.prisma.storeConversationMessage.findFirst({ + where: { conversation_id: row.id }, + orderBy: { id: 'desc' }, + select: { body_format: true, body_text: true, body_html: true }, + }), + this.prisma.storeConversationMessage.count({ + where: { + conversation_id: row.id, + sender_type: { not: ConversationSenderType.USER }, + ...(row.last_read_at + ? { created_at: { gt: row.last_read_at } } + : {}), + }, + }), + ]); + return { conversationId: row.id, lastMessage, unreadCount }; + }), + ); + } + + async findConversationByIdAndAccount(args: { + conversationId: bigint; + accountId: bigint; + }) { + return this.prisma.storeConversation.findFirst({ + where: { id: args.conversationId, account_id: args.accountId }, + }); + } + + async countConversationMessages(conversationId: bigint): Promise { + return this.prisma.storeConversationMessage.count({ + where: { conversation_id: conversationId }, + }); + } + + /** 구매자 읽음 처리 — last_read_at 갱신(메시지 조회의 부수효과로 호출된다). */ + async markConversationRead(args: { + conversationId: bigint; + now: Date; + }): Promise { + await this.prisma.storeConversation.update({ + where: { id: args.conversationId }, + data: { last_read_at: args.now }, + }); + } + /** * 구매자 메시지 저장. 대화가 없으면 같은 트랜잭션에서 생성하고, 인사말은 * "대화의 첫 메시지"일 때만(메시지 0건) 유저 메시지보다 앞서 저장한다. diff --git a/src/features/conversation/resolvers/conversation-center-query.resolver.ts b/src/features/conversation/resolvers/conversation-center-query.resolver.ts new file mode 100644 index 00000000..4820925f --- /dev/null +++ b/src/features/conversation/resolvers/conversation-center-query.resolver.ts @@ -0,0 +1,45 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { ConversationMessagesInput } from '@/features/conversation/dto/inputs/conversation-messages.input'; +import { MyConversationsInput } from '@/features/conversation/dto/inputs/my-conversations.input'; +import { ConversationCenterService } from '@/features/conversation/services/conversation-center.service'; +import type { + ConversationMessageConnection, + MyConversationConnection, +} from '@/features/conversation/types/conversation-output.type'; +import { + CurrentUser, + JwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Query') +@UseGuards(JwtAuthGuard) +export class ConversationCenterQueryResolver { + constructor(private readonly centerService: ConversationCenterService) {} + + @Query('myConversations') + myConversations( + @CurrentUser() user: JwtUser, + @Args('input', { nullable: true }) input?: MyConversationsInput, + ): Promise { + const accountId = parseAccountId(user); + return this.centerService.myConversations(accountId, input); + } + + @Query('conversationMessages') + conversationMessages( + @CurrentUser() user: JwtUser, + @Args('conversationId') conversationId: string, + @Args('input', { nullable: true }) input?: ConversationMessagesInput, + ): Promise { + const accountId = parseAccountId(user); + return this.centerService.conversationMessages( + accountId, + conversationId, + input, + ); + } +} diff --git a/src/features/conversation/resolvers/conversation-center.resolver.spec.ts b/src/features/conversation/resolvers/conversation-center.resolver.spec.ts new file mode 100644 index 00000000..825dd52c --- /dev/null +++ b/src/features/conversation/resolvers/conversation-center.resolver.spec.ts @@ -0,0 +1,74 @@ +// 전체 경로(리졸버→서비스→레포→DB) 통합 검증만 담당. 분기/집계 세부 검증은 service.spec.ts에서 담당 +import type { PrismaClient } from '@prisma/client'; + +import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationCenterQueryResolver } from '@/features/conversation/resolvers/conversation-center-query.resolver'; +import { ConversationInquiryMutationResolver } from '@/features/conversation/resolvers/conversation-inquiry-mutation.resolver'; +import { ConversationCenterService } from '@/features/conversation/services/conversation-center.service'; +import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('Conversation Center Resolvers (real DB)', () => { + let centerResolver: ConversationCenterQueryResolver; + let inquiryMutationResolver: ConversationInquiryMutationResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + ConversationCenterQueryResolver, + ConversationInquiryMutationResolver, + ConversationCenterService, + ConversationInquiryService, + ConversationRepository, + ], + }); + centerResolver = module.get(ConversationCenterQueryResolver); + inquiryMutationResolver = module.get(ConversationInquiryMutationResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('전송 → 목록 → 상세 조회 전체 경로가 안읽음 수까지 일관된다', async () => { + const buyer = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: buyer.id }); + const store = await createStore(prisma, { store_name: '해즈 케이크' }); + const jwtUser = { accountId: buyer.id.toString() }; + + const sent = await inquiryMutationResolver.sendConversationMessage( + jwtUser, + { storeId: store.id.toString(), bodyText: '픽업 문의드립니다' }, + ); + + const list = await centerResolver.myConversations(jwtUser); + expect(list.totalCount).toBe(1); + expect(list.items[0]).toMatchObject({ + id: sent.conversationId, + storeName: '해즈 케이크', + lastMessagePreview: '픽업 문의드립니다', + }); + + const messages = await centerResolver.conversationMessages( + jwtUser, + sent.conversationId, + ); + // 인사말 + 유저 메시지 (최신순) + expect(messages.totalCount).toBe(2); + expect(messages.items.map((m) => m.senderType)).toEqual(['USER', 'STORE']); + }); +}); diff --git a/src/features/conversation/services/conversation-base.service.ts b/src/features/conversation/services/conversation-base.service.ts new file mode 100644 index 00000000..17ca2bd6 --- /dev/null +++ b/src/features/conversation/services/conversation-base.service.ts @@ -0,0 +1,30 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; + +import { CONVERSATION_ERRORS } from '@/features/conversation/constants/conversation-error-messages'; +import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { evaluateActiveUserAccount } from '@/features/user'; + +/** 구매자 대화 서비스 공통 — 활성 USER 판정(user feature 정책 헬퍼 공유). */ +export abstract class ConversationBaseService { + protected constructor(protected readonly repo: ConversationRepository) {} + + protected async requireActiveUser( + accountId: bigint, + ): Promise<{ nickname: string }> { + const account = await this.repo.findUserAccountForInquiry(accountId); + switch (evaluateActiveUserAccount(account)) { + case 'ACCOUNT_NOT_FOUND': + throw new UnauthorizedException(CONVERSATION_ERRORS.ACCOUNT_NOT_FOUND); + case 'ACCOUNT_DELETED': + throw new UnauthorizedException(CONVERSATION_ERRORS.ACCOUNT_DELETED); + case 'NOT_USER': + throw new ForbiddenException(CONVERSATION_ERRORS.NOT_USER); + case 'PROFILE_INACTIVE': + throw new UnauthorizedException(CONVERSATION_ERRORS.PROFILE_INACTIVE); + case null: + break; + } + // evaluate 통과 시 user_profile 존재가 보장된다 + return { nickname: account!.user_profile!.nickname }; + } +} diff --git a/src/features/conversation/services/conversation-center-mappers.helper.spec.ts b/src/features/conversation/services/conversation-center-mappers.helper.spec.ts new file mode 100644 index 00000000..526c3a73 --- /dev/null +++ b/src/features/conversation/services/conversation-center-mappers.helper.spec.ts @@ -0,0 +1,36 @@ +import { + stripHtmlToPreview, + toLastMessagePreview, +} from '@/features/conversation/services/conversation-center-mappers.helper'; + +describe('conversation-center-mappers.helper', () => { + describe('stripHtmlToPreview', () => { + it('태그를 제거하고 공백을 정리한다', () => { + expect( + stripHtmlToPreview('

🎂 케이크 보관 방법

  • 냉장 3일
'), + ).toBe('🎂 케이크 보관 방법 냉장 3일'); + }); + + it('기본 HTML 엔티티를 복원한다', () => { + expect(stripHtmlToPreview('A & B <3>')).toBe('A & B <3>'); + }); + }); + + describe('toLastMessagePreview', () => { + it('TEXT 메시지는 원문, HTML 메시지는 태그 제거 텍스트를 반환한다', () => { + expect( + toLastMessagePreview({ body_format: 'TEXT', body_text: '안녕하세요', body_html: null }), + ).toBe('안녕하세요'); + expect( + toLastMessagePreview({ body_format: 'HTML', body_text: null, body_html: '

답변

' }), + ).toBe('답변'); + }); + + it('메시지가 없거나 본문이 비면 null', () => { + expect(toLastMessagePreview(null)).toBeNull(); + expect( + toLastMessagePreview({ body_format: 'HTML', body_text: null, body_html: null }), + ).toBeNull(); + }); + }); +}); diff --git a/src/features/conversation/services/conversation-center-mappers.helper.ts b/src/features/conversation/services/conversation-center-mappers.helper.ts new file mode 100644 index 00000000..6c2ea16e --- /dev/null +++ b/src/features/conversation/services/conversation-center-mappers.helper.ts @@ -0,0 +1,32 @@ +/** DI-free 순수 함수만 둔다 — 대화 목록 미리보기 텍스트 가공. */ + +/** + * HTML 본문 → 목록 미리보기 plain text. + * 표시용 한 줄 미리보기가 목적이라 완전한 HTML 파싱 대신 태그 제거 + + * 공백 정리로 충분하다(저장 원문은 그대로 유지). + */ +export function stripHtmlToPreview(html: string): string { + return html + .replace(/<[^>]*>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\s+/g, ' ') + .trim(); +} + +/** 마지막 메시지 row → 미리보기 텍스트. TEXT는 원문, HTML은 태그 제거. */ +export function toLastMessagePreview( + message: { + body_format: 'TEXT' | 'HTML'; + body_text: string | null; + body_html: string | null; + } | null, +): string | null { + if (!message) return null; + if (message.body_format === 'HTML') { + return message.body_html ? stripHtmlToPreview(message.body_html) : null; + } + return message.body_text; +} diff --git a/src/features/conversation/services/conversation-center.service.spec.ts b/src/features/conversation/services/conversation-center.service.spec.ts new file mode 100644 index 00000000..7efd65eb --- /dev/null +++ b/src/features/conversation/services/conversation-center.service.spec.ts @@ -0,0 +1,288 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationCenterService } from '@/features/conversation/services/conversation-center.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('ConversationCenterService (real DB)', () => { + let service: ConversationCenterService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ConversationCenterService, ConversationRepository], + }); + service = module.get(ConversationCenterService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function setupBuyer() { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + return account; + } + + function hoursAgo(hours: number): Date { + return new Date(Date.now() - hours * 60 * 60 * 1000); + } + + async function makeConversation(args: { + accountId: bigint; + storeId: bigint; + lastMessageAt?: Date | null; + lastReadAt?: Date | null; + }) { + return prisma.storeConversation.create({ + data: { + account_id: args.accountId, + store_id: args.storeId, + last_message_at: + args.lastMessageAt === undefined ? new Date() : args.lastMessageAt, + last_read_at: args.lastReadAt ?? null, + }, + }); + } + + async function addMessage(args: { + conversationId: bigint; + senderType?: 'USER' | 'STORE' | 'SYSTEM'; + bodyFormat?: 'TEXT' | 'HTML'; + bodyText?: string | null; + bodyHtml?: string | null; + createdAt?: Date; + senderAccountId?: bigint; + }) { + return prisma.storeConversationMessage.create({ + data: { + conversation_id: args.conversationId, + sender_type: args.senderType ?? 'STORE', + sender_account_id: args.senderAccountId ?? null, + body_format: args.bodyFormat ?? 'TEXT', + body_text: + args.bodyText === undefined ? '메시지' : args.bodyText, + body_html: args.bodyHtml ?? null, + created_at: args.createdAt ?? new Date(), + }, + }); + } + + // ─── myConversations ─── + describe('myConversations', () => { + it('마지막 메시지 최신순으로 매장 정보·미리보기·안읽음 수를 반환한다', async () => { + const buyer = await setupBuyer(); + const storeA = await createStore(prisma, { + store_name: '해즈 케이크', + profile_image_url: 'https://cdn.example.com/hs.png', + }); + const storeB = await createStore(prisma, { store_name: '달콤 케이크' }); + + // storeA: 최근 대화, 안읽은 STORE 메시지 3건 + const convA = await makeConversation({ + accountId: buyer.id, + storeId: storeA.id, + lastMessageAt: hoursAgo(1), + lastReadAt: hoursAgo(5), + }); + await addMessage({ + conversationId: convA.id, + senderType: 'USER', + bodyText: '문의합니다', + createdAt: hoursAgo(6), + senderAccountId: buyer.id, + }); + for (let i = 0; i < 3; i++) { + await addMessage({ + conversationId: convA.id, + bodyText: `답변 ${i + 1}`, + createdAt: hoursAgo(4 - i), + }); + } + + // storeB: 오래된 대화, 전부 읽음. 마지막 메시지는 HTML + const convB = await makeConversation({ + accountId: buyer.id, + storeId: storeB.id, + lastMessageAt: hoursAgo(24), + lastReadAt: hoursAgo(23), + }); + await addMessage({ + conversationId: convB.id, + bodyFormat: 'HTML', + bodyText: null, + bodyHtml: '

🎂 케이크 보관 방법

냉장 3일

', + createdAt: hoursAgo(24), + }); + + const result = await service.myConversations(buyer.id); + + expect(result.totalCount).toBe(2); + expect(result.items.map((i) => i.storeName)).toEqual([ + '해즈 케이크', + '달콤 케이크', + ]); + expect(result.items[0]).toMatchObject({ + id: convA.id.toString(), + storeId: storeA.id.toString(), + storeProfileImageUrl: 'https://cdn.example.com/hs.png', + lastMessagePreview: '답변 3', + unreadCount: 3, + }); + // HTML 마지막 메시지는 태그를 제거한 미리보기 + expect(result.items[1]).toMatchObject({ + lastMessagePreview: '🎂 케이크 보관 방법 냉장 3일', + unreadCount: 0, + }); + }); + + it('내가 보낸 메시지는 안읽음 수에 세지 않는다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const conv = await makeConversation({ + accountId: buyer.id, + storeId: store.id, + lastReadAt: null, + }); + await addMessage({ + conversationId: conv.id, + senderType: 'USER', + senderAccountId: buyer.id, + }); + await addMessage({ conversationId: conv.id, senderType: 'STORE' }); + + const result = await service.myConversations(buyer.id); + + // last_read_at이 null이면 수신 메시지 전부가 안읽음 + expect(result.items[0].unreadCount).toBe(1); + }); + + it('커서로 다음 페이지를 이어가고, 메시지 없는 대화는 제외한다', async () => { + const buyer = await setupBuyer(); + const convIds: string[] = []; + for (let i = 0; i < 3; i++) { + const store = await createStore(prisma); + const conv = await makeConversation({ + accountId: buyer.id, + storeId: store.id, + lastMessageAt: hoursAgo(i + 1), + }); + convIds.push(conv.id.toString()); + } + // 메시지 없는(빈) 대화 — 목록 비노출 + const emptyStore = await createStore(prisma); + await makeConversation({ + accountId: buyer.id, + storeId: emptyStore.id, + lastMessageAt: null, + }); + + const page1 = await service.myConversations(buyer.id, { limit: 2 }); + expect(page1.totalCount).toBe(3); + expect(page1.hasMore).toBe(true); + expect(page1.items.map((i) => i.id)).toEqual([convIds[0], convIds[1]]); + + const page2 = await service.myConversations(buyer.id, { + limit: 2, + cursor: page1.nextCursor!, + }); + expect(page2.items.map((i) => i.id)).toEqual([convIds[2]]); + expect(page2.hasMore).toBe(false); + expect(page2.nextCursor).toBeNull(); + }); + + it('형식이 잘못된 커서는 거절하고, 다른 계정 대화는 노출하지 않는다', async () => { + const buyer = await setupBuyer(); + const other = await setupBuyer(); + const store = await createStore(prisma); + await makeConversation({ accountId: other.id, storeId: store.id }); + + await expect( + service.myConversations(buyer.id, { cursor: 'abc' }), + ).rejects.toThrow(BadRequestException); + + const result = await service.myConversations(buyer.id); + expect(result.totalCount).toBe(0); + expect(result.items).toHaveLength(0); + }); + }); + + // ─── conversationMessages ─── + describe('conversationMessages', () => { + it('메시지를 최신순 키셋 커서로 반환하고, 조회 시 last_read_at을 갱신한다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const conv = await makeConversation({ + accountId: buyer.id, + storeId: store.id, + lastReadAt: null, + }); + const msgIds: string[] = []; + for (let i = 0; i < 3; i++) { + const m = await addMessage({ + conversationId: conv.id, + bodyText: `메시지 ${i + 1}`, + }); + msgIds.push(m.id.toString()); + } + + const page1 = await service.conversationMessages( + buyer.id, + conv.id.toString(), + { limit: 2 }, + ); + expect(page1.totalCount).toBe(3); + expect(page1.hasMore).toBe(true); + // 최신(id desc)부터 + expect(page1.items.map((m) => m.id)).toEqual([msgIds[2], msgIds[1]]); + + const page2 = await service.conversationMessages( + buyer.id, + conv.id.toString(), + { limit: 2, cursor: page1.nextCursor! }, + ); + expect(page2.items.map((m) => m.id)).toEqual([msgIds[0]]); + expect(page2.hasMore).toBe(false); + + // 조회 부수효과로 읽음 처리 → 목록 안읽음 수 0 + const saved = await prisma.storeConversation.findUniqueOrThrow({ + where: { id: conv.id }, + }); + expect(saved.last_read_at).not.toBeNull(); + const list = await service.myConversations(buyer.id); + expect(list.items[0].unreadCount).toBe(0); + }); + + it('남의 대화·없는 대화는 NotFoundException', async () => { + const buyer = await setupBuyer(); + const other = await setupBuyer(); + const store = await createStore(prisma); + const othersConv = await makeConversation({ + accountId: other.id, + storeId: store.id, + }); + + await expect( + service.conversationMessages(buyer.id, othersConv.id.toString()), + ).rejects.toThrow(NotFoundException); + await expect( + service.conversationMessages(buyer.id, '999999'), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/features/conversation/services/conversation-center.service.ts b/src/features/conversation/services/conversation-center.service.ts new file mode 100644 index 00000000..f88c255b --- /dev/null +++ b/src/features/conversation/services/conversation-center.service.ts @@ -0,0 +1,123 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { + buildTimestampIdCursor, + parseTimestampIdCursor, +} from '@/common/utils/keyset-cursor'; +import { sliceCursorPage } from '@/common/utils/pagination'; +import { CONVERSATION_ERRORS } from '@/features/conversation/constants/conversation-error-messages'; +import { + DEFAULT_CONVERSATION_LIST_LIMIT, + DEFAULT_CONVERSATION_MESSAGES_LIMIT, +} from '@/features/conversation/constants/conversation.constants'; +import type { ConversationMessagesInput } from '@/features/conversation/dto/inputs/conversation-messages.input'; +import type { MyConversationsInput } from '@/features/conversation/dto/inputs/my-conversations.input'; +import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationBaseService } from '@/features/conversation/services/conversation-base.service'; +import { toLastMessagePreview } from '@/features/conversation/services/conversation-center-mappers.helper'; +import { toConversationMessageOutput } from '@/features/conversation/services/conversation-inquiry-mappers.helper'; +import type { + ConversationMessageConnection, + MyConversationConnection, +} from '@/features/conversation/types/conversation-output.type'; + +@Injectable() +export class ConversationCenterService extends ConversationBaseService { + constructor(repo: ConversationRepository) { + super(repo); + } + + async myConversations( + accountId: bigint, + input?: MyConversationsInput, + ): Promise { + await this.requireActiveUser(accountId); + + const limit = input?.limit ?? DEFAULT_CONVERSATION_LIST_LIMIT; + const cursor = input?.cursor + ? parseTimestampIdCursor(input.cursor, CONVERSATION_ERRORS.INVALID_CURSOR) + : undefined; + + const [rows, totalCount] = await Promise.all([ + this.repo.listConversationsByAccount({ + accountId, + limit, + cursor: cursor + ? { lastMessageAt: cursor.timestamp, id: cursor.id } + : undefined, + }), + this.repo.countConversationsByAccount(accountId), + ]); + + // last_message_at desc 정렬과 결합된 커서 — 새 메시지 도착으로 대화가 + // 위로 떠오르면 다음 페이지에 다시 나타날 수 있다(목록 새로고침 전제). + const page = sliceCursorPage(rows, limit, (last) => + // listConversationsByAccount가 last_message_at null을 제외하므로 항상 존재 + buildTimestampIdCursor(last.last_message_at!, last.id), + ); + + const extras = await this.repo.getConversationListExtras( + page.items.map((row) => ({ id: row.id, last_read_at: row.last_read_at })), + ); + const extraById = new Map( + extras.map((e) => [e.conversationId.toString(), e]), + ); + + return { + items: page.items.map((row) => { + const extra = extraById.get(row.id.toString()); + return { + id: row.id.toString(), + storeId: row.store_id.toString(), + storeName: row.store.store_name, + storeProfileImageUrl: row.store.profile_image_url, + lastMessagePreview: toLastMessagePreview(extra?.lastMessage ?? null), + lastMessageAt: row.last_message_at!, + unreadCount: extra?.unreadCount ?? 0, + }; + }), + totalCount, + hasMore: page.hasMore, + nextCursor: page.nextCursor, + }; + } + + async conversationMessages( + accountId: bigint, + conversationIdRaw: string, + input?: ConversationMessagesInput, + ): Promise { + await this.requireActiveUser(accountId); + const conversationId = parseId(conversationIdRaw); + + const conversation = await this.repo.findConversationByIdAndAccount({ + conversationId, + accountId, + }); + if (!conversation) { + throw new NotFoundException(CONVERSATION_ERRORS.CONVERSATION_NOT_FOUND); + } + + const limit = input?.limit ?? DEFAULT_CONVERSATION_MESSAGES_LIMIT; + const cursor = input?.cursor ? parseId(input.cursor) : undefined; + + const [rows, totalCount] = await Promise.all([ + this.repo.listConversationMessages({ conversationId, limit, cursor }), + this.repo.countConversationMessages(conversationId), + ]); + + // 채팅 상세 진입/조회 = 읽음으로 간주 — 별도 mutation 없이 여기서 + // last_read_at을 갱신한다(조회의 의도적 쓰기 부수효과, 사용자 확정 정책). + await this.repo.markConversationRead({ conversationId, now: new Date() }); + + const page = sliceCursorPage(rows, limit, (last) => last.id.toString()); + + return { + items: page.items.map(toConversationMessageOutput), + totalCount, + hasMore: page.hasMore, + nextCursor: page.nextCursor, + }; + } +} diff --git a/src/features/conversation/services/conversation-inquiry.service.ts b/src/features/conversation/services/conversation-inquiry.service.ts index daa2aa07..04a04fcf 100644 --- a/src/features/conversation/services/conversation-inquiry.service.ts +++ b/src/features/conversation/services/conversation-inquiry.service.ts @@ -1,9 +1,4 @@ -import { - ForbiddenException, - Injectable, - NotFoundException, - UnauthorizedException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { ConversationBodyFormat, ConversationSenderType } from '@prisma/client'; import { parseId } from '@/common/utils/id-parser'; @@ -16,6 +11,7 @@ import { ConversationRepository, type ConversationMessageEntry, } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationBaseService } from '@/features/conversation/services/conversation-base.service'; import { renderGreeting, toConversationMessageOutput, @@ -25,11 +21,12 @@ import type { ConversationMessagesPayload, StoreInquiryContextOutput, } from '@/features/conversation/types/conversation-output.type'; -import { evaluateActiveUserAccount } from '@/features/user'; @Injectable() -export class ConversationInquiryService { - constructor(private readonly repo: ConversationRepository) {} +export class ConversationInquiryService extends ConversationBaseService { + constructor(repo: ConversationRepository) { + super(repo); + } async storeInquiryContext( accountId: bigint, @@ -161,27 +158,6 @@ export class ConversationInquiryService { }; } - /** user feature의 활성 USER 판정 정책을 공유한다(메시지 매핑만 도메인별). */ - private async requireActiveUser( - accountId: bigint, - ): Promise<{ nickname: string }> { - const account = await this.repo.findUserAccountForInquiry(accountId); - switch (evaluateActiveUserAccount(account)) { - case 'ACCOUNT_NOT_FOUND': - throw new UnauthorizedException(CONVERSATION_ERRORS.ACCOUNT_NOT_FOUND); - case 'ACCOUNT_DELETED': - throw new UnauthorizedException(CONVERSATION_ERRORS.ACCOUNT_DELETED); - case 'NOT_USER': - throw new ForbiddenException(CONVERSATION_ERRORS.NOT_USER); - case 'PROFILE_INACTIVE': - throw new UnauthorizedException(CONVERSATION_ERRORS.PROFILE_INACTIVE); - case null: - break; - } - // evaluate 통과 시 user_profile 존재가 보장된다 - return { nickname: account!.user_profile!.nickname }; - } - private async requireInquiryStore(storeId: bigint) { const store = await this.repo.findInquiryStore(storeId); if (!store) { diff --git a/src/features/conversation/types/conversation-output.type.ts b/src/features/conversation/types/conversation-output.type.ts index 69461ea9..aa04943e 100644 --- a/src/features/conversation/types/conversation-output.type.ts +++ b/src/features/conversation/types/conversation-output.type.ts @@ -39,3 +39,27 @@ export interface ConversationMessagesPayload { conversationId: string; messages: ConversationMessageOutput[]; } + +export interface MyConversationItemOutput { + id: string; + storeId: string; + storeName: string; + storeProfileImageUrl: string | null; + lastMessagePreview: string | null; + lastMessageAt: Date; + unreadCount: number; +} + +export interface MyConversationConnection { + items: MyConversationItemOutput[]; + totalCount: number; + hasMore: boolean; + nextCursor: string | null; +} + +export interface ConversationMessageConnection { + items: ConversationMessageOutput[]; + totalCount: number; + hasMore: boolean; + nextCursor: string | null; +} diff --git a/src/features/user/constants/user.constants.ts b/src/features/user/constants/user.constants.ts index 0862c0a8..72649883 100644 --- a/src/features/user/constants/user.constants.ts +++ b/src/features/user/constants/user.constants.ts @@ -30,6 +30,3 @@ export const MAX_REVIEW_COMMENT_LENGTH = 500; // figma notification-center: "최근 3개월 내의 알림만 확인할 수 있어요." // 삭제가 아니라 조회 필터로만 강제한다(사용자 확정 정책). export const NOTIFICATION_VISIBLE_MONTHS = 3; - -// DB UNSIGNED BIGINT 상한(2^64-1). 커서 등 외부 입력 id의 범위 방어에 쓴다. -export const MAX_UNSIGNED_BIGINT = 18446744073709551615n; diff --git a/src/features/user/services/user-notification.service.ts b/src/features/user/services/user-notification.service.ts index 6b32065c..f17c9c3f 100644 --- a/src/features/user/services/user-notification.service.ts +++ b/src/features/user/services/user-notification.service.ts @@ -1,14 +1,13 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + buildTimestampIdCursor, + parseTimestampIdCursor, +} from '@/common/utils/keyset-cursor'; import { sliceCursorPage } from '@/common/utils/pagination'; import { USER_NOTIFICATION_ERRORS } from '@/features/user/constants/user-notification-error-messages'; import { DEFAULT_PAGINATION_LIMIT, - MAX_UNSIGNED_BIGINT, NOTIFICATION_VISIBLE_MONTHS, } from '@/features/user/constants/user.constants'; import type { MyNotificationsInput } from '@/features/user/dto/inputs/my-notifications.input'; @@ -55,10 +54,8 @@ export class UserNotificationService extends UserBaseService { }); // (created_at, id) desc 정렬과 결합된 커서 — 정렬이 바뀌면 무효다. - const page = sliceCursorPage( - result.items, - limit, - (last) => `${last.created_at.getTime()}:${last.id.toString()}`, + const page = sliceCursorPage(result.items, limit, (last) => + buildTimestampIdCursor(last.created_at, last.id), ); return { @@ -106,31 +103,15 @@ export class UserNotificationService extends UserBaseService { return since; } - /** 커서 파싱: ":". 형식·안전 정수 범위를 벗어나면 거부. */ + /** 커서 파싱: ":". 형식·범위 방어는 공용 유틸이 담당. */ private parseNotificationCursor(raw: string): { createdAt: Date; id: bigint; } { - const match = /^(\d+):(\d+)$/.exec(raw); - if (!match) { - throw new BadRequestException(USER_NOTIFICATION_ERRORS.INVALID_CURSOR); - } - const createdAtMs = Number(match[1]); - if (!Number.isSafeInteger(createdAtMs)) { - throw new BadRequestException(USER_NOTIFICATION_ERRORS.INVALID_CURSOR); - } - const createdAt = new Date(createdAtMs); - // 안전 정수여도 Date 지원 범위(±8.64e15ms) 밖이면 Invalid Date가 되어 - // Prisma 필터에서 내부 오류로 번진다 — 형식 오류로 선제 거부한다. - if (Number.isNaN(createdAt.getTime())) { - throw new BadRequestException(USER_NOTIFICATION_ERRORS.INVALID_CURSOR); - } - const id = BigInt(match[2]); - // id 컬럼은 UNSIGNED BIGINT — 그 최댓값을 넘는 값도 커넥터 범위 오류로 - // 번지기 전에 형식 오류로 거부한다. - if (id > MAX_UNSIGNED_BIGINT) { - throw new BadRequestException(USER_NOTIFICATION_ERRORS.INVALID_CURSOR); - } - return { createdAt, id }; + const cursor = parseTimestampIdCursor( + raw, + USER_NOTIFICATION_ERRORS.INVALID_CURSOR, + ); + return { createdAt: cursor.timestamp, id: cursor.id }; } } From ac6b487ad52a33a64b6426749744171dc5965720 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 04:36:23 +0900 Subject: [PATCH 11/27] =?UTF-8?q?fix(conversation):=20=EC=9D=BD=EC=9D=8C?= =?UTF-8?q?=20=EB=A7=88=EC=BB=A4=20=EA=B3=A0=EC=88=98=EC=9C=84=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=C2=B7=EC=BB=A4=EC=84=9C=20=EC=83=81=ED=95=9C=C2=B7?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=B0=B0=EC=B9=98=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?(PR=20#269=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 Codex 지적 3건 반영. - P1: last_read_at을 벽시계가 아니라 "실제 내려준 최신 메시지 created_at" 까지만 전진 — 조회 후 커밋된(응답에 없는) 메시지가 영구 읽음 처리되는 레이스 방지. 과거 페이지 조회로 마커가 후퇴하지 않도록 단조 증가 조건. ms 동률 메시지는 다음 조회에 함께 내려가므로 허용(주석 명시) - P2: 메시지 커서에 UNSIGNED BIGINT 상한 검증(parseIdCursor 공용 유틸 추가) — parseId는 음수만 걸러 커넥터 범위 오류로 번지던 문제 - P2: 목록 부가 정보(마지막 메시지·안읽음 수)를 per-row 2N 쿼리에서 고정 3쿼리(최신 id 집계 → 본문 일괄 → 안읽음 OR-분기 groupBy)로 배치 - 회귀 spec: 마커 후퇴 방지·커서 상한 거절·parseIdCursor 단위 케이스 추가 --- src/common/utils/keyset-cursor.spec.ts | 16 ++++ src/common/utils/keyset-cursor.ts | 16 ++++ .../repositories/conversation.repository.ts | 90 ++++++++++++++----- ...conversation-center-mappers.helper.spec.ts | 26 ++++-- .../conversation-center.service.spec.ts | 55 +++++++++++- .../services/conversation-center.service.ts | 18 +++- 6 files changed, 188 insertions(+), 33 deletions(-) diff --git a/src/common/utils/keyset-cursor.spec.ts b/src/common/utils/keyset-cursor.spec.ts index 195379d0..2decc0b6 100644 --- a/src/common/utils/keyset-cursor.spec.ts +++ b/src/common/utils/keyset-cursor.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { buildTimestampIdCursor, + parseIdCursor, parseTimestampIdCursor, } from '@/common/utils/keyset-cursor'; @@ -47,4 +48,19 @@ describe('keyset-cursor', () => { parseTimestampIdCursor('1700000000000:18446744073709551615', ERR).id, ).toBe(18446744073709551615n); }); + + describe('parseIdCursor', () => { + it('정상 id는 bigint로 파싱하고 상한 자체는 허용한다', () => { + expect(parseIdCursor('42', ERR)).toBe(42n); + expect(parseIdCursor('18446744073709551615', ERR)).toBe( + 18446744073709551615n, + ); + }); + + it('형식 불일치·UNSIGNED BIGINT 상한 초과를 거부한다', () => { + for (const raw of ['abc', '-1', '', '1.5', '9'.repeat(30)]) { + expect(() => parseIdCursor(raw, ERR)).toThrow(BadRequestException); + } + }); + }); }); diff --git a/src/common/utils/keyset-cursor.ts b/src/common/utils/keyset-cursor.ts index 256122d6..42a813a6 100644 --- a/src/common/utils/keyset-cursor.ts +++ b/src/common/utils/keyset-cursor.ts @@ -45,3 +45,19 @@ export function parseTimestampIdCursor( export function buildTimestampIdCursor(timestamp: Date, id: bigint): string { return `${timestamp.getTime()}:${id.toString()}`; } + +/** + * 숫자 id 단독 커서 파싱. parseId와 달리 DB UNSIGNED BIGINT 상한까지 + * 검증한다 — 상한 초과 값이 커넥터 범위 오류로 번지는 것을 형식 오류로 + * 선제 거부한다. + */ +export function parseIdCursor(raw: string, errorMessage: string): bigint { + if (!/^\d+$/.test(raw)) { + throw new BadRequestException(errorMessage); + } + const id = BigInt(raw); + if (id > MAX_UNSIGNED_BIGINT) { + throw new BadRequestException(errorMessage); + } + return id; +} diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 16766431..429f0325 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -176,32 +176,65 @@ export class ConversationRepository { /** * 목록 아이템 부가 정보 — 대화별 마지막 메시지와 안읽은 수신 메시지 수. * 안읽음 = last_read_at 이후 도착한, 내가 보낸 것이 아닌 메시지. - * 페이지 크기(≤50) 만큼의 소규모 병렬 조회라 per-row 쿼리로 충분하다. + * per-row 쿼리는 페이지 50건 기준 100쿼리로 풀을 압박한다(리뷰 반영) — + * 최신 메시지 id 집계 → 본문 일괄 조회 → 안읽음 OR-분기 groupBy의 + * 고정 3쿼리로 배치한다. */ async getConversationListExtras( rows: { id: bigint; last_read_at: Date | null }[], ) { - return Promise.all( - rows.map(async (row) => { - const [lastMessage, unreadCount] = await Promise.all([ - this.prisma.storeConversationMessage.findFirst({ - where: { conversation_id: row.id }, - orderBy: { id: 'desc' }, - select: { body_format: true, body_text: true, body_html: true }, - }), - this.prisma.storeConversationMessage.count({ - where: { - conversation_id: row.id, - sender_type: { not: ConversationSenderType.USER }, - ...(row.last_read_at - ? { created_at: { gt: row.last_read_at } } - : {}), + if (rows.length === 0) return []; + const ids = rows.map((row) => row.id); + + const latestIdRows = await this.prisma.storeConversationMessage.groupBy({ + by: ['conversation_id'], + where: { conversation_id: { in: ids } }, + _max: { id: true }, + }); + const latestIds = latestIdRows + .map((row) => row._max.id) + .filter((id): id is bigint => id !== null); + + const [latestMessages, unreadGroups] = await Promise.all([ + latestIds.length > 0 + ? this.prisma.storeConversationMessage.findMany({ + where: { id: { in: latestIds } }, + select: { + conversation_id: true, + body_format: true, + body_text: true, + body_html: true, }, - }), - ]); - return { conversationId: row.id, lastMessage, unreadCount }; + }) + : Promise.resolve([]), + this.prisma.storeConversationMessage.groupBy({ + by: ['conversation_id'], + where: { + sender_type: { not: ConversationSenderType.USER }, + // 대화별 last_read_at이 달라 조건을 OR 분기로 배치한다(페이지 ≤50) + OR: rows.map((row) => ({ + conversation_id: row.id, + ...(row.last_read_at + ? { created_at: { gt: row.last_read_at } } + : {}), + })), + }, + _count: { _all: true }, }), + ]); + + const lastMessageById = new Map( + latestMessages.map((m) => [m.conversation_id.toString(), m]), + ); + const unreadById = new Map( + unreadGroups.map((g) => [g.conversation_id.toString(), g._count._all]), ); + + return rows.map((row) => ({ + conversationId: row.id, + lastMessage: lastMessageById.get(row.id.toString()) ?? null, + unreadCount: unreadById.get(row.id.toString()) ?? 0, + })); } async findConversationByIdAndAccount(args: { @@ -219,14 +252,23 @@ export class ConversationRepository { }); } - /** 구매자 읽음 처리 — last_read_at 갱신(메시지 조회의 부수효과로 호출된다). */ + /** + * 구매자 읽음 처리 — 메시지 조회의 부수효과로 호출된다. + * 마커는 벽시계가 아니라 "실제로 내려준 최신 메시지의 created_at"까지만 + * 전진시킨다(리뷰 반영) — 조회 후 커밋된 메시지가 응답에 없는데도 읽음 + * 처리되는 레이스 방지. 과거 페이지 조회로 마커가 후퇴하지 않도록 + * 단조 증가 조건을 건다. + */ async markConversationRead(args: { conversationId: bigint; - now: Date; + readAt: Date; }): Promise { - await this.prisma.storeConversation.update({ - where: { id: args.conversationId }, - data: { last_read_at: args.now }, + await this.prisma.storeConversation.updateMany({ + where: { + id: args.conversationId, + OR: [{ last_read_at: null }, { last_read_at: { lt: args.readAt } }], + }, + data: { last_read_at: args.readAt }, }); } diff --git a/src/features/conversation/services/conversation-center-mappers.helper.spec.ts b/src/features/conversation/services/conversation-center-mappers.helper.spec.ts index 526c3a73..ea83c537 100644 --- a/src/features/conversation/services/conversation-center-mappers.helper.spec.ts +++ b/src/features/conversation/services/conversation-center-mappers.helper.spec.ts @@ -7,29 +7,45 @@ describe('conversation-center-mappers.helper', () => { describe('stripHtmlToPreview', () => { it('태그를 제거하고 공백을 정리한다', () => { expect( - stripHtmlToPreview('

🎂 케이크 보관 방법

  • 냉장 3일
'), + stripHtmlToPreview( + '

🎂 케이크 보관 방법

  • 냉장 3일
', + ), ).toBe('🎂 케이크 보관 방법 냉장 3일'); }); it('기본 HTML 엔티티를 복원한다', () => { - expect(stripHtmlToPreview('A & B <3>')).toBe('A & B <3>'); + expect(stripHtmlToPreview('A & B <3>')).toBe( + 'A & B <3>', + ); }); }); describe('toLastMessagePreview', () => { it('TEXT 메시지는 원문, HTML 메시지는 태그 제거 텍스트를 반환한다', () => { expect( - toLastMessagePreview({ body_format: 'TEXT', body_text: '안녕하세요', body_html: null }), + toLastMessagePreview({ + body_format: 'TEXT', + body_text: '안녕하세요', + body_html: null, + }), ).toBe('안녕하세요'); expect( - toLastMessagePreview({ body_format: 'HTML', body_text: null, body_html: '

답변

' }), + toLastMessagePreview({ + body_format: 'HTML', + body_text: null, + body_html: '

답변

', + }), ).toBe('답변'); }); it('메시지가 없거나 본문이 비면 null', () => { expect(toLastMessagePreview(null)).toBeNull(); expect( - toLastMessagePreview({ body_format: 'HTML', body_text: null, body_html: null }), + toLastMessagePreview({ + body_format: 'HTML', + body_text: null, + body_html: null, + }), ).toBeNull(); }); }); diff --git a/src/features/conversation/services/conversation-center.service.spec.ts b/src/features/conversation/services/conversation-center.service.spec.ts index 7efd65eb..681fcee6 100644 --- a/src/features/conversation/services/conversation-center.service.spec.ts +++ b/src/features/conversation/services/conversation-center.service.spec.ts @@ -75,8 +75,7 @@ describe('ConversationCenterService (real DB)', () => { sender_type: args.senderType ?? 'STORE', sender_account_id: args.senderAccountId ?? null, body_format: args.bodyFormat ?? 'TEXT', - body_text: - args.bodyText === undefined ? '메시지' : args.bodyText, + body_text: args.bodyText === undefined ? '메시지' : args.bodyText, body_html: args.bodyHtml ?? null, created_at: args.createdAt ?? new Date(), }, @@ -268,6 +267,58 @@ describe('ConversationCenterService (real DB)', () => { expect(list.items[0].unreadCount).toBe(0); }); + it('과거 페이지 조회로는 읽음 마커가 후퇴하지 않는다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const conv = await makeConversation({ + accountId: buyer.id, + storeId: store.id, + lastReadAt: null, + }); + await addMessage({ conversationId: conv.id, createdAt: hoursAgo(2) }); + await addMessage({ conversationId: conv.id, createdAt: hoursAgo(1) }); + + // 첫 페이지(최신) → 마커 = 최신 메시지 시각 + const page1 = await service.conversationMessages( + buyer.id, + conv.id.toString(), + { limit: 1 }, + ); + const afterFirst = await prisma.storeConversation.findUniqueOrThrow({ + where: { id: conv.id }, + }); + expect(afterFirst.last_read_at?.getTime()).toBe( + new Date(page1.items[0].createdAt).getTime(), + ); + + // 과거 페이지 조회 — 더 오래된 메시지 시각으로 후퇴하면 안 된다 + await service.conversationMessages(buyer.id, conv.id.toString(), { + limit: 1, + cursor: page1.nextCursor!, + }); + const afterSecond = await prisma.storeConversation.findUniqueOrThrow({ + where: { id: conv.id }, + }); + expect(afterSecond.last_read_at?.getTime()).toBe( + afterFirst.last_read_at?.getTime(), + ); + }); + + it('UNSIGNED BIGINT 상한을 넘는 메시지 커서는 거절한다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + const conv = await makeConversation({ + accountId: buyer.id, + storeId: store.id, + }); + + await expect( + service.conversationMessages(buyer.id, conv.id.toString(), { + cursor: '9'.repeat(30), + }), + ).rejects.toThrow(BadRequestException); + }); + it('남의 대화·없는 대화는 NotFoundException', async () => { const buyer = await setupBuyer(); const other = await setupBuyer(); diff --git a/src/features/conversation/services/conversation-center.service.ts b/src/features/conversation/services/conversation-center.service.ts index f88c255b..d4041219 100644 --- a/src/features/conversation/services/conversation-center.service.ts +++ b/src/features/conversation/services/conversation-center.service.ts @@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { parseId } from '@/common/utils/id-parser'; import { buildTimestampIdCursor, + parseIdCursor, parseTimestampIdCursor, } from '@/common/utils/keyset-cursor'; import { sliceCursorPage } from '@/common/utils/pagination'; @@ -100,7 +101,11 @@ export class ConversationCenterService extends ConversationBaseService { } const limit = input?.limit ?? DEFAULT_CONVERSATION_MESSAGES_LIMIT; - const cursor = input?.cursor ? parseId(input.cursor) : undefined; + // parseId는 음수만 거르므로 UNSIGNED BIGINT 상한 초과가 커넥터 오류로 + // 번진다 — 상한까지 검증하는 커서 전용 파서를 쓴다(리뷰 반영) + const cursor = input?.cursor + ? parseIdCursor(input.cursor, CONVERSATION_ERRORS.INVALID_CURSOR) + : undefined; const [rows, totalCount] = await Promise.all([ this.repo.listConversationMessages({ conversationId, limit, cursor }), @@ -109,7 +114,16 @@ export class ConversationCenterService extends ConversationBaseService { // 채팅 상세 진입/조회 = 읽음으로 간주 — 별도 mutation 없이 여기서 // last_read_at을 갱신한다(조회의 의도적 쓰기 부수효과, 사용자 확정 정책). - await this.repo.markConversationRead({ conversationId, now: new Date() }); + // 마커는 벽시계가 아니라 실제 내려준 최신 메시지 시각까지만 전진 — + // 조회 직후 커밋된(응답에 없는) 메시지가 읽음 처리되는 레이스 방지. + // created_at 밀리초 동률 메시지는 다음 조회에서 함께 내려가므로 허용. + const newestFetched = rows[0]; + if (newestFetched) { + await this.repo.markConversationRead({ + conversationId, + readAt: newestFetched.created_at, + }); + } const page = sliceCursorPage(rows, limit, (last) => last.id.toString()); From e0d5d6472ddaf1399b2c6bb9262ddad32fa9d308 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 04:46:45 +0900 Subject: [PATCH 12/27] =?UTF-8?q?fix(conversation):=20=EC=9D=BD=EC=9D=8C?= =?UTF-8?q?=20=EB=A7=88=EC=BB=A4=EB=A5=BC=20=EC=BB=A4=EB=B0=8B=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=EC=99=80=20=EC=A0=95=EB=A0=AC=20+=20parseId=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=20=EA=B2=80=EC=A6=9D=20(PR=20#269=20?= =?UTF-8?q?=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 Codex 지적 2건 반영. - P1: 시각 채번이 잠금 밖이라, 먼저 채번되고 늦게 커밋된 메시지를 마커가 건너뛰는 레이스가 남아 있었다. 세 경로를 대화 row 잠금으로 정렬: · 전송(구매자/판매자): 잠금 획득 "이후"에 created_at 채번 → 대화 단위로 잠금 순서 = 시각 순서 = 커밋 순서(NTP 전제) · 읽기: listBuyerMessagesAndMarkRead 한 트랜잭션에서 같은 잠금을 잡아 미커밋 전송을 기다린 뒤 조회·마커 전진(단조 증가 조건 유지) now 파라미터는 repository 내부 채번으로 대체(전송 경로 시그니처 정리) - P2: 공용 parseId에 UNSIGNED BIGINT 상한 검증 추가 — conversationId 등 클라이언트 ID 입력 전반에서 커넥터 범위 오류가 형식 오류로 바뀐다 (id-parser spec 케이스 추가) --- src/common/utils/id-parser.spec.ts | 6 ++ src/common/utils/id-parser.ts | 6 +- .../conversation.repository.spec.ts | 7 +- .../repositories/conversation.repository.ts | 90 ++++++++++++------- .../services/conversation-center.service.ts | 25 ++---- .../services/conversation-inquiry.service.ts | 1 - .../services/seller-conversation.service.ts | 1 - 7 files changed, 82 insertions(+), 54 deletions(-) diff --git a/src/common/utils/id-parser.spec.ts b/src/common/utils/id-parser.spec.ts index 33b4b997..7399bb8b 100644 --- a/src/common/utils/id-parser.spec.ts +++ b/src/common/utils/id-parser.spec.ts @@ -24,6 +24,12 @@ describe('id-parser', () => { expect(() => parseId(' ')).toThrow(BadRequestException); }); + it('UNSIGNED BIGINT 상한(2^64-1)을 넘으면 BadRequestException을 던진다', () => { + expect(parseId('18446744073709551615')).toBe(18446744073709551615n); + expect(() => parseId('18446744073709551616')).toThrow(BadRequestException); + expect(() => parseId('9'.repeat(30))).toThrow(BadRequestException); + }); + it('음수이면 BadRequestException을 던진다', () => { expect(() => parseId('-1')).toThrow(BadRequestException); }); diff --git a/src/common/utils/id-parser.ts b/src/common/utils/id-parser.ts index 97acebf4..767de22e 100644 --- a/src/common/utils/id-parser.ts +++ b/src/common/utils/id-parser.ts @@ -1,5 +1,7 @@ import { BadRequestException } from '@nestjs/common'; +import { MAX_UNSIGNED_BIGINT } from '@/common/utils/keyset-cursor'; + export function parseId(raw: string): bigint { const trimmed = raw.trim(); if (trimmed === '') { @@ -11,7 +13,9 @@ export function parseId(raw: string): bigint { } catch { throw new BadRequestException('Invalid id.'); } - if (id < 0n) { + // DB UNSIGNED BIGINT 범위 밖 값은 커넥터 범위 오류(내부 오류)로 번진다 — + // 클라이언트 입력 단계에서 형식 오류로 거부한다. + if (id < 0n || id > MAX_UNSIGNED_BIGINT) { throw new BadRequestException('Invalid id.'); } return id; diff --git a/src/features/conversation/repositories/conversation.repository.spec.ts b/src/features/conversation/repositories/conversation.repository.spec.ts index 199c760d..46ccb63e 100644 --- a/src/features/conversation/repositories/conversation.repository.spec.ts +++ b/src/features/conversation/repositories/conversation.repository.spec.ts @@ -125,7 +125,6 @@ describe('ConversationRepository (real DB)', () => { it('메시지 생성 시 conversation.last_message_at/updated_at을 트랜잭션 안에서 갱신한다', async () => { const { store, conversation } = await setupConversation(); const seller = await createAccount(prisma, { account_type: 'SELLER' }); - const now = new Date('2026-04-22T12:00:00Z'); const message = await repo.createSellerConversationMessage({ conversationId: conversation.id, @@ -133,7 +132,6 @@ describe('ConversationRepository (real DB)', () => { bodyFormat: 'TEXT', bodyText: '판매자 응답', bodyHtml: null, - now, }); expect(message.sender_type).toBe('STORE'); @@ -143,8 +141,9 @@ describe('ConversationRepository (real DB)', () => { const updatedConv = await prisma.storeConversation.findUniqueOrThrow({ where: { id: conversation.id }, }); - expect(updatedConv.last_message_at?.toISOString()).toBe( - now.toISOString(), + // 시각은 대화 잠금 아래에서 repository가 채번한다 — 메시지와 동일해야 함 + expect(updatedConv.last_message_at?.getTime()).toBe( + message.created_at.getTime(), ); expect(updatedConv.store_id).toBe(store.id); }); diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 429f0325..f0a37eab 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -246,29 +246,52 @@ export class ConversationRepository { }); } - async countConversationMessages(conversationId: bigint): Promise { - return this.prisma.storeConversationMessage.count({ - where: { conversation_id: conversationId }, - }); - } - /** - * 구매자 읽음 처리 — 메시지 조회의 부수효과로 호출된다. - * 마커는 벽시계가 아니라 "실제로 내려준 최신 메시지의 created_at"까지만 - * 전진시킨다(리뷰 반영) — 조회 후 커밋된 메시지가 응답에 없는데도 읽음 - * 처리되는 레이스 방지. 과거 페이지 조회로 마커가 후퇴하지 않도록 - * 단조 증가 조건을 건다. + * 구매자 채팅 상세 조회 + 읽음 마커 전진(한 트랜잭션). + * + * 전송 경로와 같은 대화 row 잠금을 잡는다 — 미커밋 전송이 있으면 커밋을 + * 기다린 뒤 조회하므로, "아직 안 보이는 메시지"를 건너뛰고 마커가 + * 전진하는 레이스가 없다(리뷰 반영). 마커는 실제 내려준 최신 메시지의 + * created_at까지만, 과거 페이지 조회로 후퇴하지 않게 단조 증가 조건으로 + * 갱신한다. */ - async markConversationRead(args: { + async listBuyerMessagesAndMarkRead(args: { conversationId: bigint; - readAt: Date; - }): Promise { - await this.prisma.storeConversation.updateMany({ - where: { - id: args.conversationId, - OR: [{ last_read_at: null }, { last_read_at: { lt: args.readAt } }], - }, - data: { last_read_at: args.readAt }, + limit: number; + cursor?: bigint; + }) { + return this.prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT id FROM store_conversation WHERE id = ${args.conversationId} FOR UPDATE`; + + const [rows, totalCount] = await Promise.all([ + tx.storeConversationMessage.findMany({ + where: { + conversation_id: args.conversationId, + ...(args.cursor ? { id: { lt: args.cursor } } : {}), + }, + orderBy: { id: 'desc' }, + take: args.limit + 1, + }), + tx.storeConversationMessage.count({ + where: { conversation_id: args.conversationId }, + }), + ]); + + const newest = rows[0]; + if (newest) { + await tx.storeConversation.updateMany({ + where: { + id: args.conversationId, + OR: [ + { last_read_at: null }, + { last_read_at: { lt: newest.created_at } }, + ], + }, + data: { last_read_at: newest.created_at }, + }); + } + + return { rows, totalCount }; }); } @@ -286,10 +309,14 @@ export class ConversationRepository { storeId: bigint; greetingBodyText: string; entries: ConversationMessageEntry[]; - now: Date; }) { return this.prisma.$transaction(async (tx) => { const conversationId = await this.lockOrCreateConversation(tx, args); + // 메시지 시각은 대화 잠금 획득 "이후"에 채번한다 — 잠금 밖에서 미리 + // 받은 시각은 커밋 순서와 어긋나, 늦게 커밋된 과거 시각 메시지가 + // 읽음 마커(last_read_at)를 건너뛰는 레이스를 만든다(리뷰 반영). + // 잠금 순서 = 시각 순서 = 커밋 순서가 대화 단위로 보장된다(NTP 전제). + const now = new Date(); // 인사말 필요 여부는 실제 메시지 수로 판정한다 — "생성 여부" 플래그는 // 동시 첫 전송·실패 재시도에서 인사말 계약(항상 첫 메시지)을 깨뜨린다. @@ -327,7 +354,7 @@ export class ConversationRepository { body_format: entry.bodyFormat, body_text: entry.bodyText, body_html: entry.bodyHtml, - created_at: args.now, + created_at: now, }, }), ); @@ -336,8 +363,8 @@ export class ConversationRepository { await tx.storeConversation.update({ where: { id: conversationId }, data: { - last_message_at: args.now, - updated_at: args.now, + last_message_at: now, + updated_at: now, // soft-delete된 대화를 재사용한 경우 복구한다 — 삭제 상태로 두면 // 구매자·판매자 어느 조회에도 잡히지 않아 메시지가 유실돼 보인다 // (리뷰 반영). 평상시엔 이미 null이라 no-op. @@ -360,7 +387,7 @@ export class ConversationRepository { */ private async lockOrCreateConversation( tx: Prisma.TransactionClient, - args: { accountId: bigint; storeId: bigint; now: Date }, + args: { accountId: bigint; storeId: bigint }, ): Promise { const lockExisting = async (): Promise => { const rows = await tx.$queryRaw<{ id: bigint }[]>` @@ -393,7 +420,6 @@ export class ConversationRepository { data: { account_id: args.accountId, store_id: args.storeId, - created_at: args.now, }, select: { id: true }, }); @@ -416,9 +442,13 @@ export class ConversationRepository { bodyFormat: ConversationBodyFormat; bodyText: string | null; bodyHtml: string | null; - now: Date; }) { return this.prisma.$transaction(async (tx) => { + // 구매자 전송·읽음 처리와 같은 대화 잠금 아래에서 시각을 채번해 + // 커밋 순서와 시각 순서를 대화 단위로 일치시킨다(읽음 마커 정합). + await tx.$queryRaw`SELECT id FROM store_conversation WHERE id = ${args.conversationId} FOR UPDATE`; + const now = new Date(); + const message = await tx.storeConversationMessage.create({ data: { conversation_id: args.conversationId, @@ -427,15 +457,15 @@ export class ConversationRepository { body_format: args.bodyFormat, body_text: args.bodyText, body_html: args.bodyHtml, - created_at: args.now, + created_at: now, }, }); await tx.storeConversation.update({ where: { id: args.conversationId }, data: { - last_message_at: args.now, - updated_at: args.now, + last_message_at: now, + updated_at: now, }, }); diff --git a/src/features/conversation/services/conversation-center.service.ts b/src/features/conversation/services/conversation-center.service.ts index d4041219..7f8996b5 100644 --- a/src/features/conversation/services/conversation-center.service.ts +++ b/src/features/conversation/services/conversation-center.service.ts @@ -107,23 +107,14 @@ export class ConversationCenterService extends ConversationBaseService { ? parseIdCursor(input.cursor, CONVERSATION_ERRORS.INVALID_CURSOR) : undefined; - const [rows, totalCount] = await Promise.all([ - this.repo.listConversationMessages({ conversationId, limit, cursor }), - this.repo.countConversationMessages(conversationId), - ]); - - // 채팅 상세 진입/조회 = 읽음으로 간주 — 별도 mutation 없이 여기서 - // last_read_at을 갱신한다(조회의 의도적 쓰기 부수효과, 사용자 확정 정책). - // 마커는 벽시계가 아니라 실제 내려준 최신 메시지 시각까지만 전진 — - // 조회 직후 커밋된(응답에 없는) 메시지가 읽음 처리되는 레이스 방지. - // created_at 밀리초 동률 메시지는 다음 조회에서 함께 내려가므로 허용. - const newestFetched = rows[0]; - if (newestFetched) { - await this.repo.markConversationRead({ - conversationId, - readAt: newestFetched.created_at, - }); - } + // 채팅 상세 진입/조회 = 읽음으로 간주 — 별도 mutation 없이 조회 + // 트랜잭션이 last_read_at을 갱신한다(의도적 쓰기 부수효과, 사용자 확정 + // 정책). 전송 경로와 같은 잠금·마커 정합은 repository가 담당한다. + const { rows, totalCount } = await this.repo.listBuyerMessagesAndMarkRead({ + conversationId, + limit, + cursor, + }); const page = sliceCursorPage(rows, limit, (last) => last.id.toString()); diff --git a/src/features/conversation/services/conversation-inquiry.service.ts b/src/features/conversation/services/conversation-inquiry.service.ts index 04a04fcf..a64a9f0d 100644 --- a/src/features/conversation/services/conversation-inquiry.service.ts +++ b/src/features/conversation/services/conversation-inquiry.service.ts @@ -149,7 +149,6 @@ export class ConversationInquiryService extends ConversationBaseService { storeName: args.storeName, }), entries: args.entries, - now: new Date(), }); return { diff --git a/src/features/seller/services/seller-conversation.service.ts b/src/features/seller/services/seller-conversation.service.ts index 5250b13c..e0401dec 100644 --- a/src/features/seller/services/seller-conversation.service.ts +++ b/src/features/seller/services/seller-conversation.service.ts @@ -143,7 +143,6 @@ export class SellerConversationService extends SellerBaseService { bodyFormat, bodyText, bodyHtml, - now: new Date(), }); await this.auditLogs.createAuditLog({ From c641b39f63f7486f7776df547e69a8f52ddb9d08 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 04:55:38 +0900 Subject: [PATCH 13/27] =?UTF-8?q?fix(conversation):=20=EA=B5=AC=EB=A7=A4?= =?UTF-8?q?=EC=9E=90=20mutation=EC=9D=B4=20=EC=A0=84=EB=8B=AC=ED=95=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=9D=91=EB=8B=B5=EC=9D=80=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EC=B2=98=EB=A6=AC=20(PR=20#269=20=EB=A6=AC?= =?UTF-8?q?=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 Codex 지적 반영: 인사말·FAQ 자동응답은 mutation 응답으로 구매자 화면에 즉시 표시되는데 last_read_at이 그대로라 목록 미읽음 배지에 잡혔다. 전송 트랜잭션의 대화 갱신에서 last_read_at을 이번 배치 시각까지 전진 — 이후 도착하는 판매자 메시지만 미읽음으로 남는다. - 전송 후 last_read_at == last_message_at 검증 spec 추가 --- .../conversation/repositories/conversation.repository.ts | 5 +++++ .../services/conversation-inquiry.service.spec.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index f0a37eab..3398574f 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -365,6 +365,11 @@ export class ConversationRepository { data: { last_message_at: now, updated_at: now, + // 이번 mutation 응답으로 인사말·FAQ 자동응답까지 구매자에게 즉시 + // 표시되므로, 여기까지를 읽음으로 전진시킨다 — 방금 받은 자동응답이 + // 목록 미읽음 배지로 잡히는 불일치 방지(리뷰 반영). 항상 최신 + // 시각이라 단조 증가 조건이 필요 없다. + last_read_at: now, // soft-delete된 대화를 재사용한 경우 복구한다 — 삭제 상태로 두면 // 구매자·판매자 어느 조회에도 잡히지 않아 메시지가 유실돼 보인다 // (리뷰 반영). 평상시엔 이미 null이라 no-op. diff --git a/src/features/conversation/services/conversation-inquiry.service.spec.ts b/src/features/conversation/services/conversation-inquiry.service.spec.ts index b04d8daa..8eefa4ae 100644 --- a/src/features/conversation/services/conversation-inquiry.service.spec.ts +++ b/src/features/conversation/services/conversation-inquiry.service.spec.ts @@ -188,6 +188,10 @@ describe('ConversationInquiryService (real DB)', () => { }); expect(result.conversationId).toBe(conversation.id.toString()); expect(conversation.last_message_at).not.toBeNull(); + // 응답으로 인사말까지 즉시 표시되므로 여기까지 읽음 처리돼야 한다 + expect(conversation.last_read_at?.getTime()).toBe( + conversation.last_message_at?.getTime(), + ); expect(await messagesOf(conversation.id)).toHaveLength(2); }); From 07714f0f8b4bee314ea1394bb745b9396e5d40a7 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 05:03:59 +0900 Subject: [PATCH 14/27] =?UTF-8?q?fix(conversation):=20=EB=AF=B8=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EB=B0=B1=EB=A1=9C=EA=B7=B8=EA=B0=80=20=EC=9E=88?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EC=A0=84=EC=86=A1=20=EC=8B=9C=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EB=A7=88=EC=BB=A4=20=EC=9C=A0=EC=A7=80=20(PR=20#26?= =?UTF-8?q?9=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 Codex 지적 반영: 직전 수정이 기존 대화의 미읽음 판매자 답장까지 전송 시점에 읽음 처리해 버렸다(단일 워터마크 특성). 전송 트랜잭션에서 "이번 전송 이전" 미읽음 수신 메시지를 세어 0건일 때만 마커를 전진 — 백로그가 있으면 유지해 안 본 답장이 사라지지 않는다(방금 받은 자동응답이 잠시 미읽음에 포함되는 쪽을 감수, 채팅 상세 진입 시 함께 해소). - 백로그 보존 spec 추가 --- .../repositories/conversation.repository.ts | 24 ++++++++++--- .../conversation-inquiry.service.spec.ts | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 3398574f..da45b8d1 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -326,6 +326,21 @@ export class ConversationRepository { where: { conversation_id: conversationId }, }); + // 이번 전송 "이전"의 미읽음 수신 메시지 — 읽음 마커 전진 가능 여부 판정용. + const current = await tx.storeConversation.findFirst({ + where: { id: conversationId, deleted_at: undefined }, + select: { last_read_at: true }, + }); + const pendingUnread = await tx.storeConversationMessage.count({ + where: { + conversation_id: conversationId, + sender_type: { not: ConversationSenderType.USER }, + ...(current?.last_read_at + ? { created_at: { gt: current.last_read_at } } + : {}), + }, + }); + const toCreate: ConversationMessageEntry[] = [ ...(messageCount === 0 ? [ @@ -366,10 +381,11 @@ export class ConversationRepository { last_message_at: now, updated_at: now, // 이번 mutation 응답으로 인사말·FAQ 자동응답까지 구매자에게 즉시 - // 표시되므로, 여기까지를 읽음으로 전진시킨다 — 방금 받은 자동응답이 - // 목록 미읽음 배지로 잡히는 불일치 방지(리뷰 반영). 항상 최신 - // 시각이라 단조 증가 조건이 필요 없다. - last_read_at: now, + // 표시되므로 여기까지 읽음으로 전진시키되, 이전에 쌓인 미읽음 + // 답장이 있으면 전진하지 않는다 — 단일 워터마크라 함께 읽음 + // 처리돼 버리기 때문(리뷰 반영). 그 경우 방금 받은 자동응답도 + // 미읽음에 포함되지만, 채팅 상세를 열면 함께 해소된다. + ...(pendingUnread === 0 ? { last_read_at: now } : {}), // soft-delete된 대화를 재사용한 경우 복구한다 — 삭제 상태로 두면 // 구매자·판매자 어느 조회에도 잡히지 않아 메시지가 유실돼 보인다 // (리뷰 반영). 평상시엔 이미 null이라 no-op. diff --git a/src/features/conversation/services/conversation-inquiry.service.spec.ts b/src/features/conversation/services/conversation-inquiry.service.spec.ts index 8eefa4ae..75d0c94e 100644 --- a/src/features/conversation/services/conversation-inquiry.service.spec.ts +++ b/src/features/conversation/services/conversation-inquiry.service.spec.ts @@ -219,6 +219,40 @@ describe('ConversationInquiryService (real DB)', () => { expect(await messagesOf(conversations[0].id)).toHaveLength(3); }); + it('미읽음 판매자 답장이 있는 대화에 전송해도 읽음 마커를 전진시키지 않는다', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma); + await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '첫 문의', + }); + const conversation = await prisma.storeConversation.findFirstOrThrow({ + where: { account_id: buyer.id, store_id: store.id }, + }); + // 판매자 답장(미읽음) 도착 재현 + await prisma.storeConversationMessage.create({ + data: { + conversation_id: conversation.id, + sender_type: 'STORE', + body_format: 'TEXT', + body_text: '아직 안 읽은 답장', + created_at: new Date(Date.now() + 1000), + }, + }); + const markerBefore = conversation.last_read_at; + + await service.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '추가 문의', + }); + + // 백로그가 있으면 마커 유지 — 안 본 답장이 읽음 처리되면 안 된다 + const after = await prisma.storeConversation.findUniqueOrThrow({ + where: { id: conversation.id }, + }); + expect(after.last_read_at?.getTime()).toBe(markerBefore?.getTime()); + }); + it('soft-delete된 대화가 있으면 유니크 충돌 없이 그 대화를 재사용한다', async () => { const buyer = await setupBuyer(); const store = await createStore(prisma); From 490dba99c2fafeb259f5c6f0050f4a52489c4af1 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 05:10:07 +0900 Subject: [PATCH 15/27] =?UTF-8?q?fix(common):=20=EC=BB=A4=EC=84=9C=20times?= =?UTF-8?q?tamp=EC=97=90=20MySQL=20DATETIME=20=EC=83=81=ED=95=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20(PR=20#269=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 지적 반영: JS Date는 ±275760년까지 허용해 연도 10000 같은 값이 검증을 통과한 뒤 MySQL DATETIME(3) 변환에서 커넥터 오류로 번진다. parseTimestampIdCursor에 9999-12-31 23:59:59.999 UTC 상한 추가. --- src/common/utils/keyset-cursor.spec.ts | 11 +++++++++++ src/common/utils/keyset-cursor.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/common/utils/keyset-cursor.spec.ts b/src/common/utils/keyset-cursor.spec.ts index 2decc0b6..2f66c9fe 100644 --- a/src/common/utils/keyset-cursor.spec.ts +++ b/src/common/utils/keyset-cursor.spec.ts @@ -39,6 +39,17 @@ describe('keyset-cursor', () => { ); }); + it('MySQL DATETIME 상한(9999년)을 넘는 timestamp를 거부한다', () => { + // 연도 10000 — JS Date로는 유효하지만 MySQL DATETIME 범위 밖 + expect(() => parseTimestampIdCursor('253402300800000:1', ERR)).toThrow( + BadRequestException, + ); + // 상한 자체는 허용 + expect( + parseTimestampIdCursor('253402300799999:1', ERR).timestamp.getTime(), + ).toBe(253402300799999); + }); + it('UNSIGNED BIGINT 상한을 넘는 id를 거부한다', () => { expect(() => parseTimestampIdCursor(`1700000000000:${'9'.repeat(30)}`, ERR), diff --git a/src/common/utils/keyset-cursor.ts b/src/common/utils/keyset-cursor.ts index 42a813a6..b6b15739 100644 --- a/src/common/utils/keyset-cursor.ts +++ b/src/common/utils/keyset-cursor.ts @@ -13,6 +13,10 @@ import { BadRequestException } from '@nestjs/common'; // DB UNSIGNED BIGINT 상한(2^64-1). 외부 입력 id의 범위 방어에 쓴다. export const MAX_UNSIGNED_BIGINT = 18446744073709551615n; +// MySQL DATETIME 상한(9999-12-31 23:59:59.999 UTC). JS Date는 ±275760년까지 +// 허용해 그 사이 값이 커넥터 변환 오류로 번진다 — 커서 timestamp 상한. +export const MAX_MYSQL_DATETIME_MS = Date.UTC(9999, 11, 31, 23, 59, 59, 999); + export interface TimestampIdCursor { timestamp: Date; id: bigint; @@ -31,7 +35,10 @@ export function parseTimestampIdCursor( throw new BadRequestException(errorMessage); } const timestamp = new Date(timestampMs); - if (Number.isNaN(timestamp.getTime())) { + if ( + Number.isNaN(timestamp.getTime()) || + timestampMs > MAX_MYSQL_DATETIME_MS + ) { throw new BadRequestException(errorMessage); } const id = BigInt(match[2]); From 628d942e3edcbce392c240afb55c5b42be35fcfd Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 05:18:41 +0900 Subject: [PATCH 16/27] =?UTF-8?q?fix(conversation):=20=EB=AF=B8=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=ED=8C=90=EC=A0=95=EC=9D=84=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A1=9C=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EC=9A=B0=ED=9A=8C=20(PR=20#269=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 지적 반영: 트랜잭션 초입의 일반 조회가 만든 REPEATABLE READ 스냅샷 때문에, 잠금 대기 중 커밋된 판매자 답장을 pendingUnread가 못 보고 마커가 그 답장을 지나칠 수 있었다. - 인사말 판정(메시지 수)·미읽음 판정을 FOR SHARE 잠금 조회로 전환 — 잠금 조회는 최신 커밋을 읽는다. raw라 deleted_at IS NULL 수동 명시 - last_read_at은 lockOrCreateConversation의 FOR UPDATE 결과에서 수령 (동일 이유로 스냅샷이 아닌 최신 값) --- .../repositories/conversation.repository.ts | 65 ++++++++++++------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index da45b8d1..71a1c9ae 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -311,7 +311,8 @@ export class ConversationRepository { entries: ConversationMessageEntry[]; }) { return this.prisma.$transaction(async (tx) => { - const conversationId = await this.lockOrCreateConversation(tx, args); + const conversation = await this.lockOrCreateConversation(tx, args); + const conversationId = conversation.id; // 메시지 시각은 대화 잠금 획득 "이후"에 채번한다 — 잠금 밖에서 미리 // 받은 시각은 커밋 순서와 어긋나, 늦게 커밋된 과거 시각 메시지가 // 읽음 마커(last_read_at)를 건너뛰는 레이스를 만든다(리뷰 반영). @@ -320,26 +321,32 @@ export class ConversationRepository { // 인사말 필요 여부는 실제 메시지 수로 판정한다 — "생성 여부" 플래그는 // 동시 첫 전송·실패 재시도에서 인사말 계약(항상 첫 메시지)을 깨뜨린다. - // 위에서 row를 잠갔거나(기존 대화) 본 트랜잭션이 만들었으므로(신규) - // count 판정은 직렬화된다. - const messageCount = await tx.storeConversationMessage.count({ - where: { conversation_id: conversationId }, - }); + // 잠금 조회(FOR SHARE)로 최신 커밋 기준으로 센다(스냅샷 우회). + const messageCountRows = await tx.$queryRaw<{ c: bigint }[]>` + SELECT COUNT(*) AS c FROM store_conversation_message + WHERE conversation_id = ${conversationId} AND deleted_at IS NULL + FOR SHARE`; + const messageCount = Number(messageCountRows[0]?.c ?? 0n); // 이번 전송 "이전"의 미읽음 수신 메시지 — 읽음 마커 전진 가능 여부 판정용. - const current = await tx.storeConversation.findFirst({ - where: { id: conversationId, deleted_at: undefined }, - select: { last_read_at: true }, - }); - const pendingUnread = await tx.storeConversationMessage.count({ - where: { - conversation_id: conversationId, - sender_type: { not: ConversationSenderType.USER }, - ...(current?.last_read_at - ? { created_at: { gt: current.last_read_at } } - : {}), - }, - }); + // 잠금 조회(FOR SHARE)로 최신 커밋을 읽는다 — 트랜잭션 초입의 일반 + // 조회가 만든 REPEATABLE READ 스냅샷은 잠금 대기 중 커밋된 판매자 + // 답장을 못 본다(리뷰 반영). raw라 soft-delete 필터를 수동 명시. + const unreadRows = conversation.lastReadAt + ? await tx.$queryRaw<{ c: bigint }[]>` + SELECT COUNT(*) AS c FROM store_conversation_message + WHERE conversation_id = ${conversationId} + AND sender_type <> 'USER' + AND deleted_at IS NULL + AND created_at > ${conversation.lastReadAt} + FOR SHARE` + : await tx.$queryRaw<{ c: bigint }[]>` + SELECT COUNT(*) AS c FROM store_conversation_message + WHERE conversation_id = ${conversationId} + AND sender_type <> 'USER' + AND deleted_at IS NULL + FOR SHARE`; + const pendingUnread = Number(unreadRows[0]?.c ?? 0n); const toCreate: ConversationMessageEntry[] = [ ...(messageCount === 0 @@ -409,13 +416,21 @@ export class ConversationRepository { private async lockOrCreateConversation( tx: Prisma.TransactionClient, args: { accountId: bigint; storeId: bigint }, - ): Promise { - const lockExisting = async (): Promise => { - const rows = await tx.$queryRaw<{ id: bigint }[]>` - SELECT id FROM store_conversation + ): Promise<{ id: bigint; lastReadAt: Date | null }> { + // 잠금 조회가 돌려준 last_read_at을 그대로 쓴다 — 잠금 대기 중 커밋된 + // 변경까지 반영된 최신 값이다(일반 조회의 스냅샷과 달리). + const lockExisting = async (): Promise<{ + id: bigint; + lastReadAt: Date | null; + } | null> => { + const rows = await tx.$queryRaw< + { id: bigint; last_read_at: Date | null }[] + >` + SELECT id, last_read_at FROM store_conversation WHERE account_id = ${args.accountId} AND store_id = ${args.storeId} FOR UPDATE`; - return rows[0]?.id ?? null; + const row = rows[0]; + return row ? { id: row.id, lastReadAt: row.last_read_at } : null; }; // 사전 조회도 tx 경유 — 트랜잭션 안에서 루트 클라이언트를 쓰면 풀 @@ -444,7 +459,7 @@ export class ConversationRepository { }, select: { id: true }, }); - return created.id; + return { id: created.id, lastReadAt: null }; } catch (e) { if ( e instanceof Prisma.PrismaClientKnownRequestError && From d01ea4dfbdb67c834e904f27e7b1227c513cdec8 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 04:30:48 +0900 Subject: [PATCH 17/27] =?UTF-8?q?feat(conversation):=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20subscription=20=E2=80=94=20graphql-ws=20+=20Redis?= =?UTF-8?q?=20PubSub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit figma 알림센터 화면 대응 4/4. 채팅 신규 메시지와 대화 목록/배지 갱신을 실시간 구독으로 제공한다(구매자·판매자 양측, 사용자 확정 정책). 변경점 - 인프라: graphql-ws 전송 + Redis PubSub(graphql-redis-subscriptions, ioredis). 전역 PubSubModule(PUB_SUB 토큰 — cross-cutting 포트라 토큰 주입, spec은 in-memory PubSub 대체). REDIS_URL 미설정 시 redis://localhost:6379 폴백(개발 단계 로컬 DX 우선, README·compose 갱신) - ws 인증: connectionParams.authorization을 upgrade 요청 헤더로 이식하는 buildGraphqlContext로 기존 JwtAuthGuard/passport 경로 재사용(가드 이원화 방지) - Subscription 3종: conversationMessageAdded(대화 소유 구매자/해당 매장 판매자만, 존재 여부 비노출), myConversationUpdated(구매자 목록·배지), sellerConversationUpdated(판매자 목록) - 발행 지점: 구매자 텍스트/FAQ 전송·판매자 답장 서비스에서 저장 후 발행 (트랜잭션 밖 부수효과 — 실패해도 전송은 성공, 구독자는 재조회 폴백). 이벤트 payload는 Redis JSON 왕복을 고려해 날짜를 ISO 문자열로 나른다 - 토픽 조립은 ConversationEventsService 단일 소스(발행자·구독자 공유) 회귀 테스트 - events service 실 Redis(testcontainers) 왕복 2케이스(토픽 격리·payload 보존), subscription service 실DB 4케이스(권한 3종 + 발행 경로 통합 수신), 이벤트 매퍼 2케이스, ws 컨텍스트 헬퍼 3케이스 - 로컬 스모크: 인증 ws 구독 → HTTP mutation → Redis 경유 이벤트 수신 확인 --- README.md | 1 + docker-compose.yml | 12 ++ package.json | 4 + src/app.module.ts | 30 +-- src/config/redis.config.ts | 20 ++ .../conversation-subscription.graphql | 32 ++++ .../conversation/conversation.module.ts | 8 +- src/features/conversation/index.ts | 3 + .../repositories/conversation.repository.ts | 29 +++ .../conversation-center.resolver.spec.ts | 5 + .../conversation-inquiry.resolver.spec.ts | 5 + .../conversation-subscription.resolver.ts | 54 ++++++ ...conversation-events-mappers.helper.spec.ts | 47 +++++ .../conversation-events-mappers.helper.ts | 33 ++++ .../conversation-events.service.spec.ts | 116 +++++++++++ .../services/conversation-events.service.ts | 70 +++++++ .../conversation-inquiry.service.spec.ts | 11 +- .../services/conversation-inquiry.service.ts | 63 +++++- .../conversation-subscription.service.spec.ts | 180 ++++++++++++++++++ .../conversation-subscription.service.ts | 60 ++++++ .../types/conversation-output.type.ts | 30 +++ src/features/core/root.graphql | 5 + .../seller-conversation.resolver.spec.ts | 5 + .../seller-conversation.service.spec.ts | 5 + .../services/seller-conversation.service.ts | 74 ++++++- .../graphql/graphql-context.helper.spec.ts | 39 ++++ src/global/graphql/graphql-context.helper.ts | 34 ++++ src/global/pubsub/index.ts | 2 + src/global/pubsub/pubsub.constants.ts | 5 + src/global/pubsub/pubsub.module.ts | 43 +++++ yarn.lock | 88 ++++++++- 31 files changed, 1094 insertions(+), 19 deletions(-) create mode 100644 src/config/redis.config.ts create mode 100644 src/features/conversation/conversation-subscription.graphql create mode 100644 src/features/conversation/resolvers/conversation-subscription.resolver.ts create mode 100644 src/features/conversation/services/conversation-events-mappers.helper.spec.ts create mode 100644 src/features/conversation/services/conversation-events-mappers.helper.ts create mode 100644 src/features/conversation/services/conversation-events.service.spec.ts create mode 100644 src/features/conversation/services/conversation-events.service.ts create mode 100644 src/features/conversation/services/conversation-subscription.service.spec.ts create mode 100644 src/features/conversation/services/conversation-subscription.service.ts create mode 100644 src/global/graphql/graphql-context.helper.spec.ts create mode 100644 src/global/graphql/graphql-context.helper.ts create mode 100644 src/global/pubsub/index.ts create mode 100644 src/global/pubsub/pubsub.constants.ts create mode 100644 src/global/pubsub/pubsub.module.ts diff --git a/README.md b/README.md index 399441a3..9a6545e0 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ yarn start:dev | --- | --- | | **서버** | `NODE_ENV`, `PORT`, `BACKEND_BASE_URL`, `FRONTEND_BASE_URL` | | **DB** | `DATABASE_URL` | +| **Redis (선택)** | `REDIS_URL` — GraphQL subscription PubSub. 미설정 시 `redis://localhost:6379`(로컬 docker-compose) | | **JWT / Auth** | `JWT_ACCESS_SECRET`, `JWT_ACCESS_EXPIRES_SECONDS`, `AUTH_REFRESH_EXPIRES_DAYS`, `AUTH_COOKIE_DOMAIN`, `AUTH_COOKIE_SECURE` | | **OIDC (Google)** | `OIDC_GOOGLE_CLIENT_ID`, `OIDC_GOOGLE_CLIENT_SECRET`, `OIDC_GOOGLE_ISSUER_URL` | | **OIDC (Kakao)** | `OIDC_KAKAO_CLIENT_ID`, `OIDC_KAKAO_CLIENT_SECRET`, `OIDC_KAKAO_ISSUER_URL` | diff --git a/docker-compose.yml b/docker-compose.yml index b62f95bf..12910912 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,5 +25,17 @@ services: timeout: 5s retries: 5 + redis: + image: redis:7-alpine + container_name: caquick-redis + # GraphQL subscription PubSub 백엔드. 영속화 불필요(휘발성 이벤트 브로커). + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + volumes: caquick-mysql-data: diff --git a/package.json b/package.json index 9a07201d..595237ee 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,10 @@ "cookie-parser": "^1.4.7", "express": "^5.2.1", "graphql": "^16.14.0", + "graphql-redis-subscriptions": "^2.7.0", + "graphql-subscriptions": "^3.0.0", + "graphql-ws": "^6.2.1", + "ioredis": "^5.3.2", "logform": "^2.7.0", "openid-client": "5.7.1", "passport": "^0.7.0", diff --git a/src/app.module.ts b/src/app.module.ts index b17dcddc..617e0121 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,13 +13,13 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { GraphQLModule } from '@nestjs/graphql'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; -import type { Request, Response } from 'express'; import { CommonModule } from '@/common/common.module'; import authConfig from '@/config/auth.config'; import databaseConfig from '@/config/database.config'; import docsConfig from '@/config/docs.config'; import oidcConfig from '@/config/oidc.config'; +import redisConfig from '@/config/redis.config'; import s3Config from '@/config/s3.config'; import { AuthModule } from '@/features/auth/auth.module'; import { ConversationModule } from '@/features/conversation'; @@ -31,9 +31,11 @@ import { StoreModule } from '@/features/store'; import { SystemModule } from '@/features/system/system.module'; import { UserModule } from '@/features/user/user.module'; import { AuthGlobalModule } from '@/global/auth/auth-global.module'; +import { buildGraphqlContext } from '@/global/graphql/graphql-context.helper'; import { GraphqlGlobalModule } from '@/global/graphql/graphql.module'; import { LoggerModule } from '@/global/logger/logger.module'; import { DocsAccessMiddleware } from '@/global/middlewares/docs-access.middleware'; +import { PubSubModule } from '@/global/pubsub'; import { RequestContextMiddleware, RequestContextModule, @@ -46,7 +48,14 @@ import { PrismaModule } from '@/prisma'; ConfigModule.forRoot({ isGlobal: true, cache: true, - load: [authConfig, databaseConfig, docsConfig, oidcConfig, s3Config], + load: [ + authConfig, + databaseConfig, + docsConfig, + oidcConfig, + redisConfig, + s3Config, + ], }), ServeStaticModule.forRoot({ rootPath: join(process.cwd(), 'public'), @@ -58,6 +67,7 @@ import { PrismaModule } from '@/prisma'; LoggerModule, AuthGlobalModule, GraphqlGlobalModule, + PubSubModule, StorageModule, // 인기 검색어 스냅샷 크론(SearchModule) 활성화 ScheduleModule.forRoot(), @@ -73,21 +83,17 @@ import { PrismaModule } from '@/prisma'; : join(process.cwd(), 'src/features/**/*.graphql'), ], playground: false, + // 실시간 subscription(graphql-ws). 인증은 connectionParams → + // buildGraphqlContext가 HTTP 헤더로 이식해 기존 JWT 가드를 재사용한다. + subscriptions: { + 'graphql-ws': true, + }, plugins: [ isProd ? ApolloServerPluginLandingPageDisabled() : ApolloServerPluginLandingPageLocalDefault({ embed: true }), ], - context: ({ - req, - res, - }: { - req: Request; - res?: Response; - }): { - req: Request; - res?: Response; - } => ({ req, res }), + context: buildGraphqlContext, }; }, }), diff --git a/src/config/redis.config.ts b/src/config/redis.config.ts new file mode 100644 index 00000000..47ede9df --- /dev/null +++ b/src/config/redis.config.ts @@ -0,0 +1,20 @@ +import { registerAs } from '@nestjs/config'; + +/** + * Redis 설정 타입 (GraphQL subscription PubSub용) + */ +export interface RedisConfig { + url: string; +} + +/** + * Redis 설정. + * DATABASE_URL과 달리 미설정 시 로컬 docker-compose 기본값으로 폴백한다 — + * 현재 배포 인프라가 꺼져 있고 FE도 로컬 백엔드로 테스트하는 개발 단계라, + * 필수 강제보다 로컬 DX(compose up 후 바로 동작)를 우선한다. + */ +export default registerAs('redis', (): RedisConfig => { + return { + url: process.env.REDIS_URL ?? 'redis://localhost:6379', + }; +}); diff --git a/src/features/conversation/conversation-subscription.graphql b/src/features/conversation/conversation-subscription.graphql new file mode 100644 index 00000000..43806d39 --- /dev/null +++ b/src/features/conversation/conversation-subscription.graphql @@ -0,0 +1,32 @@ +extend type Subscription { + """ + 대화방 신규 메시지 실시간 구독. 구매자(대화 소유자)와 판매자(해당 매장 + 소유자)만 구독할 수 있다. 인증은 graphql-ws connectionParams.authorization. + """ + conversationMessageAdded(conversationId: ID!): ConversationMessage! + """구매자 대화 목록/배지 갱신 이벤트(새 메시지 도착 시).""" + myConversationUpdated: ConversationListUpdate! + """판매자 대화 목록 갱신 이벤트(고객 메시지 도착 시).""" + sellerConversationUpdated: SellerConversationListUpdate! +} + +"""구매자 대화 목록 갱신 이벤트""" +type ConversationListUpdate { + conversationId: ID! + storeId: ID! + storeName: String! + """마지막 메시지 미리보기(HTML은 태그 제거)""" + lastMessagePreview: String + lastMessageAt: DateTime! + """이벤트 시점의 안읽은 수신 메시지 수""" + unreadCount: Int! +} + +"""판매자 대화 목록 갱신 이벤트""" +type SellerConversationListUpdate { + conversationId: ID! + accountId: ID! + """마지막 메시지 미리보기(HTML은 태그 제거)""" + lastMessagePreview: String + lastMessageAt: DateTime! +} diff --git a/src/features/conversation/conversation.module.ts b/src/features/conversation/conversation.module.ts index a07dcf76..493424e9 100644 --- a/src/features/conversation/conversation.module.ts +++ b/src/features/conversation/conversation.module.ts @@ -4,18 +4,24 @@ import { ConversationRepository } from '@/features/conversation/repositories/con import { ConversationCenterQueryResolver } from '@/features/conversation/resolvers/conversation-center-query.resolver'; import { ConversationInquiryMutationResolver } from '@/features/conversation/resolvers/conversation-inquiry-mutation.resolver'; import { ConversationInquiryQueryResolver } from '@/features/conversation/resolvers/conversation-inquiry-query.resolver'; +import { ConversationSubscriptionResolver } from '@/features/conversation/resolvers/conversation-subscription.resolver'; import { ConversationCenterService } from '@/features/conversation/services/conversation-center.service'; +import { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; +import { ConversationSubscriptionService } from '@/features/conversation/services/conversation-subscription.service'; @Module({ providers: [ ConversationRepository, + ConversationEventsService, ConversationInquiryService, ConversationCenterService, + ConversationSubscriptionService, ConversationInquiryQueryResolver, ConversationInquiryMutationResolver, ConversationCenterQueryResolver, + ConversationSubscriptionResolver, ], - exports: [ConversationRepository], + exports: [ConversationRepository, ConversationEventsService], }) export class ConversationModule {} diff --git a/src/features/conversation/index.ts b/src/features/conversation/index.ts index 85b8af14..ad05881b 100644 --- a/src/features/conversation/index.ts +++ b/src/features/conversation/index.ts @@ -1,3 +1,6 @@ // cross-feature 공개 API. 단일 구현 repo라 토큰/인터페이스 없이 구체 클래스로 주입(의도적). export { ConversationModule } from '@/features/conversation/conversation.module'; export { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +// subscription 이벤트 발행/구독 어댑터 — 판매자 답장(seller feature)도 같은 토픽을 쓴다. +export { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; +export { toEventPreview } from '@/features/conversation/services/conversation-events-mappers.helper'; diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 71a1c9ae..f4e5f650 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -295,6 +295,35 @@ export class ConversationRepository { }); } + /** 이벤트 payload용 매장명 단건 조회. */ + async findStoreNameById(storeId: bigint) { + return this.prisma.store.findFirst({ + where: { id: storeId, deleted_at: undefined }, + select: { store_name: true }, + }); + } + + /** subscription 구독 권한 판정용 — 대화 소유 구매자/해당 매장 판매자 확인. */ + async findConversationAccess(conversationId: bigint) { + return this.prisma.storeConversation.findFirst({ + where: { id: conversationId }, + select: { + id: true, + account_id: true, + store_id: true, + store: { select: { seller_account_id: true } }, + }, + }); + } + + /** 판매자 구독 대상 매장(활성) 조회. */ + async findStoreBySellerAccount(sellerAccountId: bigint) { + return this.prisma.store.findFirst({ + where: { seller_account_id: sellerAccountId, ...activeWhere }, + select: { id: true }, + }); + } + /** * 구매자 메시지 저장. 대화가 없으면 같은 트랜잭션에서 생성하고, 인사말은 * "대화의 첫 메시지"일 때만(메시지 0건) 유저 메시지보다 앞서 저장한다. diff --git a/src/features/conversation/resolvers/conversation-center.resolver.spec.ts b/src/features/conversation/resolvers/conversation-center.resolver.spec.ts index 825dd52c..765d571f 100644 --- a/src/features/conversation/resolvers/conversation-center.resolver.spec.ts +++ b/src/features/conversation/resolvers/conversation-center.resolver.spec.ts @@ -1,11 +1,14 @@ // 전체 경로(리졸버→서비스→레포→DB) 통합 검증만 담당. 분기/집계 세부 검증은 service.spec.ts에서 담당 import type { PrismaClient } from '@prisma/client'; +import { PubSub } from 'graphql-subscriptions'; import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; import { ConversationCenterQueryResolver } from '@/features/conversation/resolvers/conversation-center-query.resolver'; import { ConversationInquiryMutationResolver } from '@/features/conversation/resolvers/conversation-inquiry-mutation.resolver'; import { ConversationCenterService } from '@/features/conversation/services/conversation-center.service'; +import { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; +import { PUB_SUB } from '@/global/pubsub'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { @@ -28,6 +31,8 @@ describe('Conversation Center Resolvers (real DB)', () => { ConversationCenterService, ConversationInquiryService, ConversationRepository, + ConversationEventsService, + { provide: PUB_SUB, useValue: new PubSub() }, ], }); centerResolver = module.get(ConversationCenterQueryResolver); diff --git a/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts b/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts index 5663ba64..a992999d 100644 --- a/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts +++ b/src/features/conversation/resolvers/conversation-inquiry.resolver.spec.ts @@ -1,10 +1,13 @@ // 전체 경로(리졸버→서비스→레포→DB) 통합 검증만 담당. 분기·예외 세부는 service.spec.ts에서 담당 import type { PrismaClient } from '@prisma/client'; +import { PubSub } from 'graphql-subscriptions'; import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; import { ConversationInquiryMutationResolver } from '@/features/conversation/resolvers/conversation-inquiry-mutation.resolver'; import { ConversationInquiryQueryResolver } from '@/features/conversation/resolvers/conversation-inquiry-query.resolver'; +import { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; +import { PUB_SUB } from '@/global/pubsub'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { @@ -26,6 +29,8 @@ describe('Conversation Inquiry Resolvers (real DB)', () => { ConversationInquiryMutationResolver, ConversationInquiryService, ConversationRepository, + ConversationEventsService, + { provide: PUB_SUB, useValue: new PubSub() }, ], }); queryResolver = module.get(ConversationInquiryQueryResolver); diff --git a/src/features/conversation/resolvers/conversation-subscription.resolver.ts b/src/features/conversation/resolvers/conversation-subscription.resolver.ts new file mode 100644 index 00000000..d8a49a81 --- /dev/null +++ b/src/features/conversation/resolvers/conversation-subscription.resolver.ts @@ -0,0 +1,54 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Resolver, Subscription } from '@nestjs/graphql'; + +import { ConversationSubscriptionService } from '@/features/conversation/services/conversation-subscription.service'; +import { + CurrentUser, + JwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** + * 이벤트 payload는 발행 시 이미 GraphQL 출력 형태로 조립돼 있어 + * resolve는 payload를 그대로 통과시킨다. + */ +const passthrough = { resolve: (payload: unknown): unknown => payload }; + +@Resolver('Subscription') +@UseGuards(JwtAuthGuard) +export class ConversationSubscriptionResolver { + constructor( + private readonly subscriptionService: ConversationSubscriptionService, + ) {} + + @Subscription('conversationMessageAdded', passthrough) + conversationMessageAdded( + @CurrentUser() user: JwtUser, + @Args('conversationId') conversationId: string, + ): Promise> { + const accountId = parseAccountId(user); + return this.subscriptionService.subscribeConversationMessages( + accountId, + conversationId, + ); + } + + @Subscription('myConversationUpdated', passthrough) + myConversationUpdated( + @CurrentUser() user: JwtUser, + ): Promise> { + const accountId = parseAccountId(user); + return this.subscriptionService.subscribeMyConversationUpdates(accountId); + } + + @Subscription('sellerConversationUpdated', passthrough) + sellerConversationUpdated( + @CurrentUser() user: JwtUser, + ): Promise> { + const accountId = parseAccountId(user); + return this.subscriptionService.subscribeSellerConversationUpdates( + accountId, + ); + } +} diff --git a/src/features/conversation/services/conversation-events-mappers.helper.spec.ts b/src/features/conversation/services/conversation-events-mappers.helper.spec.ts new file mode 100644 index 00000000..ef9fec7d --- /dev/null +++ b/src/features/conversation/services/conversation-events-mappers.helper.spec.ts @@ -0,0 +1,47 @@ +import { + toConversationMessageEvent, + toEventPreview, +} from '@/features/conversation/services/conversation-events-mappers.helper'; +import type { ConversationMessageOutput } from '@/features/conversation/types/conversation-output.type'; + +function message( + overrides: Partial = {}, +): ConversationMessageOutput { + return { + id: '1', + conversationId: '2', + senderType: 'USER', + bodyFormat: 'TEXT', + bodyText: '안녕하세요', + bodyHtml: null, + createdAt: new Date('2026-08-01T12:00:00Z'), + ...overrides, + }; +} + +describe('conversation-events-mappers.helper', () => { + it('메시지 출력의 날짜를 ISO 문자열로 바꿔 이벤트 payload를 만든다', () => { + expect(toConversationMessageEvent(message())).toEqual({ + id: '1', + conversationId: '2', + senderType: 'USER', + bodyFormat: 'TEXT', + bodyText: '안녕하세요', + bodyHtml: null, + createdAt: '2026-08-01T12:00:00.000Z', + }); + }); + + it('미리보기는 TEXT 원문 / HTML 태그 제거 텍스트를 쓴다', () => { + expect(toEventPreview(message())).toBe('안녕하세요'); + expect( + toEventPreview( + message({ + bodyFormat: 'HTML', + bodyText: null, + bodyHtml: '

자동 응답

', + }), + ), + ).toBe('자동 응답'); + }); +}); diff --git a/src/features/conversation/services/conversation-events-mappers.helper.ts b/src/features/conversation/services/conversation-events-mappers.helper.ts new file mode 100644 index 00000000..ad16bbc3 --- /dev/null +++ b/src/features/conversation/services/conversation-events-mappers.helper.ts @@ -0,0 +1,33 @@ +import { toLastMessagePreview } from '@/features/conversation/services/conversation-center-mappers.helper'; +import type { + ConversationMessageEvent, + ConversationMessageOutput, +} from '@/features/conversation/types/conversation-output.type'; + +/** DI-free 순수 함수만 둔다 — subscription 이벤트 payload 변환. */ + +/** 메시지 출력 → 이벤트 payload(날짜는 ISO 문자열). */ +export function toConversationMessageEvent( + message: ConversationMessageOutput, +): ConversationMessageEvent { + return { + id: message.id, + conversationId: message.conversationId, + senderType: message.senderType, + bodyFormat: message.bodyFormat, + bodyText: message.bodyText, + bodyHtml: message.bodyHtml, + createdAt: message.createdAt.toISOString(), + }; +} + +/** 메시지 출력 → 목록 이벤트용 미리보기 텍스트. */ +export function toEventPreview( + message: ConversationMessageOutput, +): string | null { + return toLastMessagePreview({ + body_format: message.bodyFormat, + body_text: message.bodyText, + body_html: message.bodyHtml, + }); +} diff --git a/src/features/conversation/services/conversation-events.service.spec.ts b/src/features/conversation/services/conversation-events.service.spec.ts new file mode 100644 index 00000000..3a1709a0 --- /dev/null +++ b/src/features/conversation/services/conversation-events.service.spec.ts @@ -0,0 +1,116 @@ +/** + * 실 Redis(testcontainers) 기반 발행/구독 왕복 검증 — JSON 직렬화를 거친 + * payload가 구독자에게 그대로 도착하는지까지 확인한다(DB 불필요). + */ +import { RedisPubSub } from 'graphql-redis-subscriptions'; +import Redis from 'ioredis'; +import { GenericContainer, type StartedTestContainer } from 'testcontainers'; + +import { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; +import type { ConversationMessageOutput } from '@/features/conversation/types/conversation-output.type'; + +jest.setTimeout(180_000); + +describe('ConversationEventsService (real Redis)', () => { + let container: StartedTestContainer; + let pubSub: RedisPubSub; + let service: ConversationEventsService; + + beforeAll(async () => { + container = await new GenericContainer('redis:7-alpine') + .withExposedPorts(6379) + .start(); + const url = `redis://${container.getHost()}:${container.getMappedPort(6379)}`; + pubSub = new RedisPubSub({ + publisher: new Redis(url), + subscriber: new Redis(url), + }); + service = new ConversationEventsService(pubSub); + }); + + afterAll(async () => { + await pubSub.close(); + await container.stop(); + }); + + async function nextEvent(iterator: AsyncIterator) { + return (await iterator.next()).value; + } + + /** + * asyncIterableIterator는 첫 next() 호출 시점에 Redis SUBSCRIBE를 보낸다 — + * 발행 전에 next()를 먼저 걸고 SUBSCRIBE 완료를 잠깐 기다려야 이벤트를 + * 놓치지 않는다(구독 등록 전 발행분은 유실되는 게 Pub/Sub 의미론). + */ + async function startListening(iterator: AsyncIterator) { + const pending = nextEvent(iterator); + await new Promise((resolve) => setTimeout(resolve, 300)); + // Promise를 그대로 반환하면 호출부의 await가 이벤트 도착까지 평탄화해 + // 기다려 버린다 — 객체로 감싸 구독 시작만 보장하고 대기는 호출부 몫으로. + return { pending }; + } + + it('대화방 메시지 발행이 해당 대화 구독자에게만 도착한다', async () => { + const target = service.messageAddedIterator('10'); + const other = service.messageAddedIterator('99'); + const { pending: pendingTarget } = await startListening(target); + const otherReceived = jest.fn(); + const { pending: pendingOther } = await startListening(other); + void pendingOther.then(otherReceived); + + const message: ConversationMessageOutput = { + id: '1', + conversationId: '10', + senderType: 'USER', + bodyFormat: 'TEXT', + bodyText: '픽업 문의', + bodyHtml: null, + createdAt: new Date('2026-08-01T12:00:00Z'), + }; + await service.publishMessagesAdded([message]); + + // Redis JSON 왕복 후에도 이벤트 payload가 보존된다(날짜는 ISO 문자열) + await expect(pendingTarget).resolves.toEqual({ + id: '1', + conversationId: '10', + senderType: 'USER', + bodyFormat: 'TEXT', + bodyText: '픽업 문의', + bodyHtml: null, + createdAt: '2026-08-01T12:00:00.000Z', + }); + expect(otherReceived).not.toHaveBeenCalled(); + await other.return?.(); + }); + + it('구매자/판매자 목록 갱신 이벤트가 각 토픽으로 도착한다', async () => { + const buyer = service.buyerListIterator('7'); + const seller = service.sellerListIterator('3'); + const { pending: pendingBuyer } = await startListening(buyer); + const { pending: pendingSeller } = await startListening(seller); + + await service.publishBuyerListUpdate('7', { + conversationId: '10', + storeId: '3', + storeName: '해즈 케이크', + lastMessagePreview: '답변 드리겠습니다', + lastMessageAt: '2026-08-01T12:00:00.000Z', + unreadCount: 2, + }); + await service.publishSellerListUpdate('3', { + conversationId: '10', + accountId: '7', + lastMessagePreview: '픽업 문의', + lastMessageAt: '2026-08-01T12:00:00.000Z', + }); + + await expect(pendingBuyer).resolves.toMatchObject({ + storeName: '해즈 케이크', + unreadCount: 2, + }); + await expect(pendingSeller).resolves.toMatchObject({ + accountId: '7', + lastMessagePreview: '픽업 문의', + }); + }); +}); diff --git a/src/features/conversation/services/conversation-events.service.ts b/src/features/conversation/services/conversation-events.service.ts new file mode 100644 index 00000000..0b401721 --- /dev/null +++ b/src/features/conversation/services/conversation-events.service.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { PubSubEngine } from 'graphql-subscriptions'; + +import { toConversationMessageEvent } from '@/features/conversation/services/conversation-events-mappers.helper'; +import type { + ConversationListUpdateEvent, + ConversationMessageOutput, + SellerConversationListUpdateEvent, +} from '@/features/conversation/types/conversation-output.type'; +import { PUB_SUB } from '@/global/pubsub'; + +/** + * 대화 subscription 이벤트 발행/구독 어댑터. + * 토픽 문자열은 여기서만 조립한다 — 발행자(구매자 전송·FAQ 자동응답·판매자 + * 답장)와 구독 리졸버가 같은 토픽을 보게 하는 단일 소스. + */ +@Injectable() +export class ConversationEventsService { + constructor(@Inject(PUB_SUB) private readonly pubSub: PubSubEngine) {} + + private messageTopic(conversationId: string): string { + return `conversation.message.${conversationId}`; + } + + private buyerTopic(accountId: string): string { + return `conversation.buyer.${accountId}`; + } + + private sellerTopic(storeId: string): string { + return `conversation.seller.${storeId}`; + } + + /** 저장된 메시지들을 대화방 토픽에 순서대로 발행한다. */ + async publishMessagesAdded( + messages: ConversationMessageOutput[], + ): Promise { + for (const message of messages) { + await this.pubSub.publish( + this.messageTopic(message.conversationId), + toConversationMessageEvent(message), + ); + } + } + + async publishBuyerListUpdate( + accountId: string, + event: ConversationListUpdateEvent, + ): Promise { + await this.pubSub.publish(this.buyerTopic(accountId), event); + } + + async publishSellerListUpdate( + storeId: string, + event: SellerConversationListUpdateEvent, + ): Promise { + await this.pubSub.publish(this.sellerTopic(storeId), event); + } + + messageAddedIterator(conversationId: string): AsyncIterator { + return this.pubSub.asyncIterableIterator(this.messageTopic(conversationId)); + } + + buyerListIterator(accountId: string): AsyncIterator { + return this.pubSub.asyncIterableIterator(this.buyerTopic(accountId)); + } + + sellerListIterator(storeId: string): AsyncIterator { + return this.pubSub.asyncIterableIterator(this.sellerTopic(storeId)); + } +} diff --git a/src/features/conversation/services/conversation-inquiry.service.spec.ts b/src/features/conversation/services/conversation-inquiry.service.spec.ts index 75d0c94e..3db802fd 100644 --- a/src/features/conversation/services/conversation-inquiry.service.spec.ts +++ b/src/features/conversation/services/conversation-inquiry.service.spec.ts @@ -5,9 +5,12 @@ import { UnauthorizedException, } from '@nestjs/common'; import type { PrismaClient } from '@prisma/client'; +import { PubSub } from 'graphql-subscriptions'; import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; +import { PUB_SUB } from '@/global/pubsub'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { @@ -23,7 +26,13 @@ describe('ConversationInquiryService (real DB)', () => { beforeAll(async () => { const { module, prisma: p } = await createTestingModuleWithRealDb({ - providers: [ConversationInquiryService, ConversationRepository], + providers: [ + ConversationInquiryService, + ConversationRepository, + ConversationEventsService, + // 발행 경로 실검증은 events service spec(실 Redis) 담당 — 여기선 in-memory + { provide: PUB_SUB, useValue: new PubSub() }, + ], }); service = module.get(ConversationInquiryService); prisma = p; diff --git a/src/features/conversation/services/conversation-inquiry.service.ts b/src/features/conversation/services/conversation-inquiry.service.ts index a64a9f0d..1f735fc2 100644 --- a/src/features/conversation/services/conversation-inquiry.service.ts +++ b/src/features/conversation/services/conversation-inquiry.service.ts @@ -12,6 +12,8 @@ import { type ConversationMessageEntry, } from '@/features/conversation/repositories/conversation.repository'; import { ConversationBaseService } from '@/features/conversation/services/conversation-base.service'; +import { toEventPreview } from '@/features/conversation/services/conversation-events-mappers.helper'; +import { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; import { renderGreeting, toConversationMessageOutput, @@ -24,7 +26,10 @@ import type { @Injectable() export class ConversationInquiryService extends ConversationBaseService { - constructor(repo: ConversationRepository) { + constructor( + repo: ConversationRepository, + private readonly events: ConversationEventsService, + ) { super(repo); } @@ -151,12 +156,66 @@ export class ConversationInquiryService extends ConversationBaseService { entries: args.entries, }); + const messages = result.messages.map(toConversationMessageOutput); + await this.publishBuyerSendEvents({ + accountId: args.accountId, + storeId: args.storeId, + storeName: args.storeName, + conversationId: result.conversationId, + messages, + }); + return { conversationId: result.conversationId.toString(), - messages: result.messages.map(toConversationMessageOutput), + messages, }; } + /** + * 실시간 이벤트 발행 — 대화방 메시지 + 양측 목록/배지 갱신. + * 저장 트랜잭션 밖의 부수효과라 실패해도 전송 자체는 성공으로 남는다 + * (구독자는 폴백 재조회 가능). + */ + private async publishBuyerSendEvents(args: { + accountId: bigint; + storeId: bigint; + storeName: string; + conversationId: bigint; + messages: ConversationMessagesPayload['messages']; + }): Promise { + const lastMessage = args.messages[args.messages.length - 1]; + if (!lastMessage) return; + // 시각은 repository가 잠금 아래 채번한 저장 시각을 그대로 쓴다 + const lastMessageAtIso = lastMessage.createdAt.toISOString(); + + const conversation = await this.repo.findConversationByAccountAndStore({ + accountId: args.accountId, + storeId: args.storeId, + }); + const [extras] = conversation + ? await this.repo.getConversationListExtras([ + { id: conversation.id, last_read_at: conversation.last_read_at }, + ]) + : [undefined]; + + const preview = toEventPreview(lastMessage); + await this.events.publishMessagesAdded(args.messages); + await this.events.publishBuyerListUpdate(args.accountId.toString(), { + conversationId: args.conversationId.toString(), + storeId: args.storeId.toString(), + storeName: args.storeName, + lastMessagePreview: preview, + lastMessageAt: lastMessageAtIso, + unreadCount: extras?.unreadCount ?? 0, + }); + await this.events.publishSellerListUpdate(args.storeId.toString(), { + conversationId: args.conversationId.toString(), + accountId: args.accountId.toString(), + lastMessagePreview: preview, + lastMessageAt: lastMessageAtIso, + }); + } + private async requireInquiryStore(storeId: bigint) { const store = await this.repo.findInquiryStore(storeId); if (!store) { diff --git a/src/features/conversation/services/conversation-subscription.service.spec.ts b/src/features/conversation/services/conversation-subscription.service.spec.ts new file mode 100644 index 00000000..8d9c7c65 --- /dev/null +++ b/src/features/conversation/services/conversation-subscription.service.spec.ts @@ -0,0 +1,180 @@ +import { + ForbiddenException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; +import { PubSub } from 'graphql-subscriptions'; + +import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; +import { ConversationInquiryService } from '@/features/conversation/services/conversation-inquiry.service'; +import { ConversationSubscriptionService } from '@/features/conversation/services/conversation-subscription.service'; +import { PUB_SUB } from '@/global/pubsub'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('ConversationSubscriptionService (real DB)', () => { + let service: ConversationSubscriptionService; + let inquiryService: ConversationInquiryService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + ConversationSubscriptionService, + ConversationInquiryService, + ConversationEventsService, + ConversationRepository, + // 발행-구독 왕복은 실 Redis spec(events service) 담당 — 여기선 in-memory + { provide: PUB_SUB, useValue: new PubSub() }, + ], + }); + service = module.get(ConversationSubscriptionService); + inquiryService = module.get(ConversationInquiryService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function setupBuyer() { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + return account; + } + + describe('subscribeConversationMessages', () => { + it('대화 소유 구매자와 해당 매장 판매자는 구독할 수 있고, 제3자는 NotFound', async () => { + const buyer = await setupBuyer(); + const stranger = await setupBuyer(); + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + const store = await createStore(prisma, { + seller_account_id: seller.id, + }); + const conversation = await prisma.storeConversation.create({ + data: { account_id: buyer.id, store_id: store.id }, + }); + const id = conversation.id.toString(); + + await expect( + service.subscribeConversationMessages(buyer.id, id), + ).resolves.toBeDefined(); + await expect( + service.subscribeConversationMessages(seller.id, id), + ).resolves.toBeDefined(); + await expect( + service.subscribeConversationMessages(stranger.id, id), + ).rejects.toThrow(NotFoundException); + await expect( + service.subscribeConversationMessages(buyer.id, '999999'), + ).rejects.toThrow(NotFoundException); + }); + + it('구독 중이면 구매자 전송 이벤트를 실제로 수신한다(발행 경로 통합)', async () => { + const buyer = await setupBuyer(); + const store = await createStore(prisma, { store_name: '해즈 케이크' }); + + // 첫 전송으로 대화 생성 → 그 대화를 구독 → 두 번째 전송 수신 확인 + const first = await inquiryService.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '첫 문의', + }); + const iterator = await service.subscribeConversationMessages( + buyer.id, + first.conversationId, + ); + const pending = iterator.next(); + + await inquiryService.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '추가 문의', + }); + + const { value } = await pending; + expect(value).toMatchObject({ + conversationId: first.conversationId, + senderType: 'USER', + bodyText: '추가 문의', + }); + await iterator.return?.(); + }); + }); + + describe('subscribeMyConversationUpdates', () => { + it('활성 USER만 구독 가능하고, 전송 시 목록 갱신 이벤트를 수신한다', async () => { + const buyer = await setupBuyer(); + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + const store = await createStore(prisma, { store_name: '달콤 케이크' }); + + await expect( + service.subscribeMyConversationUpdates(BigInt(999999)), + ).rejects.toThrow(UnauthorizedException); + await expect( + service.subscribeMyConversationUpdates(seller.id), + ).rejects.toThrow(ForbiddenException); + + const iterator = await service.subscribeMyConversationUpdates(buyer.id); + const pending = iterator.next(); + + await inquiryService.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '문의합니다', + }); + + const { value } = await pending; + expect(value).toMatchObject({ + storeId: store.id.toString(), + storeName: '달콤 케이크', + lastMessagePreview: '문의합니다', + // 인사말은 mutation 응답으로 즉시 표시돼 읽음 처리된다 + unreadCount: 0, + }); + await iterator.return?.(); + }); + }); + + describe('subscribeSellerConversationUpdates', () => { + it('매장 보유 판매자만 구독 가능하고, 고객 전송 이벤트를 수신한다', async () => { + const buyer = await setupBuyer(); + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + const store = await createStore(prisma, { + seller_account_id: seller.id, + }); + + // 매장 없는 계정은 구독 불가 + await expect( + service.subscribeSellerConversationUpdates(buyer.id), + ).rejects.toThrow(NotFoundException); + + const iterator = await service.subscribeSellerConversationUpdates( + seller.id, + ); + const pending = iterator.next(); + + await inquiryService.sendConversationMessage(buyer.id, { + storeId: store.id.toString(), + bodyText: '픽업 시간 문의', + }); + + const { value } = await pending; + expect(value).toMatchObject({ + accountId: buyer.id.toString(), + lastMessagePreview: '픽업 시간 문의', + }); + await iterator.return?.(); + }); + }); +}); diff --git a/src/features/conversation/services/conversation-subscription.service.ts b/src/features/conversation/services/conversation-subscription.service.ts new file mode 100644 index 00000000..cfa61147 --- /dev/null +++ b/src/features/conversation/services/conversation-subscription.service.ts @@ -0,0 +1,60 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { CONVERSATION_ERRORS } from '@/features/conversation/constants/conversation-error-messages'; +import { ConversationRepository } from '@/features/conversation/repositories/conversation.repository'; +import { ConversationBaseService } from '@/features/conversation/services/conversation-base.service'; +import { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; + +/** + * subscription 구독 진입점 — 구독 권한 검증 후 토픽 iterator를 돌려준다. + * 이벤트 발행은 각 전송 서비스(구매자 전송·판매자 답장)가 담당한다. + */ +@Injectable() +export class ConversationSubscriptionService extends ConversationBaseService { + constructor( + repo: ConversationRepository, + private readonly events: ConversationEventsService, + ) { + super(repo); + } + + /** 대화방 메시지 구독 — 대화 소유 구매자 또는 해당 매장 판매자만. */ + async subscribeConversationMessages( + accountId: bigint, + conversationIdRaw: string, + ): Promise> { + const conversationId = parseId(conversationIdRaw); + const conversation = await this.repo.findConversationAccess(conversationId); + + // 존재하지 않는 대화와 권한 없는 대화를 구분하지 않는다(존재 여부 노출 방지) + const allowed = + conversation && + (conversation.account_id === accountId || + conversation.store.seller_account_id === accountId); + if (!allowed) { + throw new NotFoundException(CONVERSATION_ERRORS.CONVERSATION_NOT_FOUND); + } + + return this.events.messageAddedIterator(conversationId.toString()); + } + + /** 구매자 대화 목록/배지 갱신 구독. */ + async subscribeMyConversationUpdates( + accountId: bigint, + ): Promise> { + await this.requireActiveUser(accountId); + return this.events.buyerListIterator(accountId.toString()); + } + + /** 판매자 대화 목록 갱신 구독. */ + async subscribeSellerConversationUpdates( + accountId: bigint, + ): Promise> { + const store = await this.repo.findStoreBySellerAccount(accountId); + if (!store) { + throw new NotFoundException(CONVERSATION_ERRORS.STORE_NOT_FOUND); + } + return this.events.sellerListIterator(store.id.toString()); + } +} diff --git a/src/features/conversation/types/conversation-output.type.ts b/src/features/conversation/types/conversation-output.type.ts index aa04943e..8581eb54 100644 --- a/src/features/conversation/types/conversation-output.type.ts +++ b/src/features/conversation/types/conversation-output.type.ts @@ -63,3 +63,33 @@ export interface ConversationMessageConnection { hasMore: boolean; nextCursor: string | null; } + +/** + * subscription 이벤트 payload — Redis JSON 직렬화를 거치므로 날짜는 ISO + * 문자열로 나른다(DateTime 스칼라가 문자열도 직렬화 가능). + */ +export interface ConversationMessageEvent { + id: string; + conversationId: string; + senderType: ConversationSenderType; + bodyFormat: ConversationBodyFormat; + bodyText: string | null; + bodyHtml: string | null; + createdAt: string; +} + +export interface ConversationListUpdateEvent { + conversationId: string; + storeId: string; + storeName: string; + lastMessagePreview: string | null; + lastMessageAt: string; + unreadCount: number; +} + +export interface SellerConversationListUpdateEvent { + conversationId: string; + accountId: string; + lastMessagePreview: string | null; + lastMessageAt: string; +} diff --git a/src/features/core/root.graphql b/src/features/core/root.graphql index c74beee4..f956249f 100644 --- a/src/features/core/root.graphql +++ b/src/features/core/root.graphql @@ -12,3 +12,8 @@ type Mutation { """스키마 루트 유지를 위한 no-op 필드""" _noop: Boolean } + +type Subscription { + """스키마 루트 유지를 위한 no-op 필드""" + _subscriptionNoop: Boolean +} diff --git a/src/features/seller/resolvers/seller-conversation.resolver.spec.ts b/src/features/seller/resolvers/seller-conversation.resolver.spec.ts index 4eac2235..559631d2 100644 --- a/src/features/seller/resolvers/seller-conversation.resolver.spec.ts +++ b/src/features/seller/resolvers/seller-conversation.resolver.spec.ts @@ -1,13 +1,16 @@ import { NotFoundException } from '@nestjs/common'; import type { PrismaClient } from '@prisma/client'; +import { PubSub } from 'graphql-subscriptions'; import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; import { ConversationRepository } from '@/features/conversation'; +import { ConversationEventsService } from '@/features/conversation'; import { SellerRepository } from '@/features/seller/repositories/seller.repository'; import { SellerConversationMutationResolver } from '@/features/seller/resolvers/seller-conversation-mutation.resolver'; import { SellerConversationQueryResolver } from '@/features/seller/resolvers/seller-conversation-query.resolver'; import { SellerConversationService } from '@/features/seller/services/seller-conversation.service'; +import { PUB_SUB } from '@/global/pubsub'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, setupSellerWithStore } from '@/test/factories'; @@ -26,6 +29,8 @@ describe('Seller Conversation Resolvers (real DB)', () => { SellerConversationService, SellerRepository, ConversationRepository, + ConversationEventsService, + { provide: PUB_SUB, useValue: new PubSub() }, { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository, diff --git a/src/features/seller/services/seller-conversation.service.spec.ts b/src/features/seller/services/seller-conversation.service.spec.ts index 8ae22cdd..98ea43ba 100644 --- a/src/features/seller/services/seller-conversation.service.spec.ts +++ b/src/features/seller/services/seller-conversation.service.spec.ts @@ -1,11 +1,14 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import type { PrismaClient } from '@prisma/client'; +import { PubSub } from 'graphql-subscriptions'; import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; import { ConversationRepository } from '@/features/conversation'; +import { ConversationEventsService } from '@/features/conversation'; import { SellerRepository } from '@/features/seller/repositories/seller.repository'; import { SellerConversationService } from '@/features/seller/services/seller-conversation.service'; +import { PUB_SUB } from '@/global/pubsub'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, setupSellerWithStore } from '@/test/factories'; @@ -21,6 +24,8 @@ describe('SellerConversationService (real DB)', () => { SellerConversationService, SellerRepository, ConversationRepository, + ConversationEventsService, + { provide: PUB_SUB, useValue: new PubSub() }, { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository, diff --git a/src/features/seller/services/seller-conversation.service.ts b/src/features/seller/services/seller-conversation.service.ts index e0401dec..892cbb34 100644 --- a/src/features/seller/services/seller-conversation.service.ts +++ b/src/features/seller/services/seller-conversation.service.ts @@ -16,7 +16,11 @@ import { AUDIT_LOG_REPOSITORY, type IAuditLogRepository, } from '@/features/audit-log'; -import { ConversationRepository } from '@/features/conversation'; +import { + ConversationEventsService, + ConversationRepository, + toEventPreview, +} from '@/features/conversation'; import { BODY_HTML_REQUIRED, BODY_TEXT_REQUIRED, @@ -48,6 +52,7 @@ export class SellerConversationService extends SellerBaseService { @Inject(AUDIT_LOG_REPOSITORY) auditLogs: IAuditLogRepository, private readonly conversationRepository: ConversationRepository, + private readonly conversationEvents: ConversationEventsService, ) { super(repo, auditLogs); } @@ -156,7 +161,72 @@ export class SellerConversationService extends SellerBaseService { }, }); - return this.toConversationMessageOutput(row); + const output = this.toConversationMessageOutput(row); + await this.publishSellerReplyEvents({ + conversation, + storeId: ctx.storeId, + message: output, + }); + + return output; + } + + /** + * 실시간 이벤트 발행 — 대화방 메시지 + 구매자·판매자 목록 갱신. + * 저장 트랜잭션 밖의 부수효과라 실패해도 답장 자체는 성공으로 남는다. + */ + private async publishSellerReplyEvents(args: { + conversation: { + id: bigint; + account_id: bigint; + last_read_at: Date | null; + }; + storeId: bigint; + message: SellerConversationMessageOutput; + }): Promise { + const message = { + id: args.message.id, + conversationId: args.message.conversationId, + senderType: args.message.senderType, + bodyFormat: args.message.bodyFormat, + bodyText: args.message.bodyText, + bodyHtml: args.message.bodyHtml, + createdAt: args.message.createdAt, + }; + const preview = toEventPreview(message); + const lastMessageAtIso = args.message.createdAt.toISOString(); + + const [store, [extras]] = await Promise.all([ + this.conversationRepository.findStoreNameById(args.storeId), + this.conversationRepository.getConversationListExtras([ + { + id: args.conversation.id, + last_read_at: args.conversation.last_read_at, + }, + ]), + ]); + + await this.conversationEvents.publishMessagesAdded([message]); + await this.conversationEvents.publishBuyerListUpdate( + args.conversation.account_id.toString(), + { + conversationId: args.conversation.id.toString(), + storeId: args.storeId.toString(), + storeName: store?.store_name ?? '', + lastMessagePreview: preview, + lastMessageAt: lastMessageAtIso, + unreadCount: extras?.unreadCount ?? 0, + }, + ); + await this.conversationEvents.publishSellerListUpdate( + args.storeId.toString(), + { + conversationId: args.conversation.id.toString(), + accountId: args.conversation.account_id.toString(), + lastMessagePreview: preview, + lastMessageAt: lastMessageAtIso, + }, + ); } private toConversationBodyFormat(raw: string): ConversationBodyFormat { diff --git a/src/global/graphql/graphql-context.helper.spec.ts b/src/global/graphql/graphql-context.helper.spec.ts new file mode 100644 index 00000000..8a69e3af --- /dev/null +++ b/src/global/graphql/graphql-context.helper.spec.ts @@ -0,0 +1,39 @@ +import type { IncomingMessage } from 'node:http'; + +import type { Request, Response } from 'express'; + +import { buildGraphqlContext } from '@/global/graphql/graphql-context.helper'; + +describe('buildGraphqlContext', () => { + it('HTTP 요청은 req/res를 그대로 전달한다', () => { + const req = { headers: {} } as Request; + const res = {} as Response; + + expect(buildGraphqlContext({ req, res })).toEqual({ req, res }); + }); + + it('ws 연결은 connectionParams.authorization을 헤더로 이식한다', () => { + const request = { headers: { host: 'localhost' } } as IncomingMessage; + + const ctx = buildGraphqlContext({ + extra: { request }, + connectionParams: { authorization: 'Bearer token-123' }, + }); + + expect(ctx.req).toBe(request); + expect(request.headers.authorization).toBe('Bearer token-123'); + expect(request.headers.host).toBe('localhost'); + }); + + it('authorization이 없거나 문자열이 아니면 헤더를 건드리지 않는다', () => { + const request = { headers: {} } as IncomingMessage; + + buildGraphqlContext({ + extra: { request }, + connectionParams: { authorization: 123 }, + }); + buildGraphqlContext({ extra: { request } }); + + expect(request.headers.authorization).toBeUndefined(); + }); +}); diff --git a/src/global/graphql/graphql-context.helper.ts b/src/global/graphql/graphql-context.helper.ts new file mode 100644 index 00000000..bb39112e --- /dev/null +++ b/src/global/graphql/graphql-context.helper.ts @@ -0,0 +1,34 @@ +import type { IncomingMessage } from 'node:http'; + +import type { Request, Response } from 'express'; + +/** + * HTTP·WebSocket(graphql-ws) 공용 GraphQL context 조립. + * + * ws 연결의 인증 토큰은 connectionParams.authorization으로 들어오므로, + * upgrade 요청 객체에 HTTP 헤더 형태로 이식해 기존 JwtAuthGuard/passport + * 경로(req.headers.authorization)를 그대로 태운다 — 가드 이원화 방지. + */ +export interface GraphqlContextArgs { + req?: Request; + res?: Response; + extra?: { request?: IncomingMessage }; + connectionParams?: Record; +} + +export interface GraphqlContext { + req: Request | IncomingMessage | undefined; + res?: Response; +} + +export function buildGraphqlContext(ctx: GraphqlContextArgs): GraphqlContext { + const wsRequest = ctx?.extra?.request; + if (wsRequest) { + const authorization = ctx.connectionParams?.authorization; + if (typeof authorization === 'string' && authorization.length > 0) { + wsRequest.headers = { ...wsRequest.headers, authorization }; + } + return { req: wsRequest }; + } + return { req: ctx?.req, res: ctx?.res }; +} diff --git a/src/global/pubsub/index.ts b/src/global/pubsub/index.ts new file mode 100644 index 00000000..1f881398 --- /dev/null +++ b/src/global/pubsub/index.ts @@ -0,0 +1,2 @@ +export { PubSubModule } from '@/global/pubsub/pubsub.module'; +export { PUB_SUB } from '@/global/pubsub/pubsub.constants'; diff --git a/src/global/pubsub/pubsub.constants.ts b/src/global/pubsub/pubsub.constants.ts new file mode 100644 index 00000000..4630172f --- /dev/null +++ b/src/global/pubsub/pubsub.constants.ts @@ -0,0 +1,5 @@ +/** + * PubSub 주입 토큰. cross-cutting 포트(외부 부수효과 어댑터)라 토큰 주입을 + * 사용한다 — 프로덕션은 RedisPubSub, spec은 in-memory PubSub로 대체된다. + */ +export const PUB_SUB = Symbol('PUB_SUB'); diff --git a/src/global/pubsub/pubsub.module.ts b/src/global/pubsub/pubsub.module.ts new file mode 100644 index 00000000..d0b7e60e --- /dev/null +++ b/src/global/pubsub/pubsub.module.ts @@ -0,0 +1,43 @@ +import { Global, Module, type OnModuleDestroy, Inject } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { RedisPubSub } from 'graphql-redis-subscriptions'; +import Redis, { type RedisOptions } from 'ioredis'; + +import type { RedisConfig } from '@/config/redis.config'; +import { PUB_SUB } from '@/global/pubsub/pubsub.constants'; + +/** + * GraphQL subscription용 Redis PubSub 전역 모듈. + * 수평 확장(다중 인스턴스) 시에도 이벤트가 모든 인스턴스에 전파되도록 + * 처음부터 Redis 백엔드를 쓴다(사용자 확정). publisher/subscriber는 + * Redis 프로토콜 제약(SUBSCRIBE 중 일반 명령 불가) 때문에 커넥션을 분리한다. + */ +@Global() +@Module({ + providers: [ + { + provide: PUB_SUB, + inject: [ConfigService], + useFactory: (config: ConfigService): RedisPubSub => { + const redisConfig = config.getOrThrow('redis'); + const options: RedisOptions = { + // Redis 미기동 시 부팅을 막지 않고 재시도만 한다(로컬 DX) + retryStrategy: (times: number) => Math.min(times * 500, 5000), + maxRetriesPerRequest: null, + }; + return new RedisPubSub({ + publisher: new Redis(redisConfig.url, options), + subscriber: new Redis(redisConfig.url, options), + }); + }, + }, + ], + exports: [PUB_SUB], +}) +export class PubSubModule implements OnModuleDestroy { + constructor(@Inject(PUB_SUB) private readonly pubSub: RedisPubSub) {} + + async onModuleDestroy(): Promise { + await this.pubSub.close(); + } +} diff --git a/yarn.lock b/yarn.lock index c6094299..abb88dd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3398,6 +3398,13 @@ __metadata: languageName: node linkType: hard +"@ioredis/commands@npm:1.10.0": + version: 1.10.0 + resolution: "@ioredis/commands@npm:1.10.0" + checksum: 10c0/baf91e62d0e64ef2b5f7ca4413dc2456fe250e87483beac4a1c8ef1fe5ad0d2fcdeb9b89d4556d8ef6c7455c64a964359d729601fdb06b2f4c76c35dd59afa99 + languageName: node + linkType: hard + "@isaacs/balanced-match@npm:^4.0.1": version: 4.0.1 resolution: "@isaacs/balanced-match@npm:4.0.1" @@ -7394,7 +7401,11 @@ __metadata: express: "npm:^5.2.1" globals: "npm:^17.11.0" graphql: "npm:^16.14.0" + graphql-redis-subscriptions: "npm:^2.7.0" + graphql-subscriptions: "npm:^3.0.0" + graphql-ws: "npm:^6.2.1" husky: "npm:^9.1.7" + ioredis: "npm:^5.3.2" jest: "npm:^30" knip: "npm:^6.32.2" lint-staged: "npm:^17.3.0" @@ -7722,6 +7733,13 @@ __metadata: languageName: node linkType: hard +"cluster-key-slot@npm:1.1.1": + version: 1.1.1 + resolution: "cluster-key-slot@npm:1.1.1" + checksum: 10c0/079b1ae86b20e2d53308a877b08de5e830722a45c07810569d0dab4955bed569da33ac9f79998289d014adf02cca7223a0647cb0ee6548a12ab3c4f9beac1377 + languageName: node + linkType: hard + "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -8440,6 +8458,13 @@ __metadata: languageName: node linkType: hard +"denque@npm:2.1.0": + version: 2.1.0 + resolution: "denque@npm:2.1.0" + checksum: 10c0/f9ef81aa0af9c6c614a727cb3bd13c5d7db2af1abf9e6352045b86e85873e629690f6222f4edd49d10e4ccf8f078bbeec0794fafaf61b659c0589d0c511ec363 + languageName: node + linkType: hard + "depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -10377,6 +10402,20 @@ __metadata: languageName: node linkType: hard +"graphql-redis-subscriptions@npm:^2.7.0": + version: 2.7.0 + resolution: "graphql-redis-subscriptions@npm:2.7.0" + dependencies: + ioredis: "npm:^5.3.2" + peerDependencies: + graphql-subscriptions: ^1.0.0 || ^2.0.0 || ^3.0.0 + dependenciesMeta: + ioredis: + optional: true + checksum: 10c0/f98e9a16aa60d5470f6916f5a85b0b91898e3ec341a70ae3ddac878aa5b415dae9081ba872afdab5873cef3933fde1c3f1ee690ffa6d5b6d164a9165aed5cad1 + languageName: node + linkType: hard + "graphql-scalars@npm:^1.15.0": version: 1.25.0 resolution: "graphql-scalars@npm:1.25.0" @@ -10388,6 +10427,15 @@ __metadata: languageName: node linkType: hard +"graphql-subscriptions@npm:^3.0.0": + version: 3.0.0 + resolution: "graphql-subscriptions@npm:3.0.0" + peerDependencies: + graphql: ^15.7.2 || ^16.0.0 + checksum: 10c0/10445c0d376773d15c887237ac460226b03e0b9c76789846f9133e3cfba6275b529f946634cd5a4e1f1f6c728b960714c8efc6c504af20ab05a3a1219ac9cc09 + languageName: node + linkType: hard + "graphql-tag@npm:2.12.6, graphql-tag@npm:^2.11.0": version: 2.12.6 resolution: "graphql-tag@npm:2.12.6" @@ -10399,7 +10447,7 @@ __metadata: languageName: node linkType: hard -"graphql-ws@npm:6.2.1": +"graphql-ws@npm:6.2.1, graphql-ws@npm:^6.2.1": version: 6.2.1 resolution: "graphql-ws@npm:6.2.1" peerDependencies: @@ -11096,6 +11144,21 @@ __metadata: languageName: node linkType: hard +"ioredis@npm:^5.3.2": + version: 5.11.1 + resolution: "ioredis@npm:5.11.1" + dependencies: + "@ioredis/commands": "npm:1.10.0" + cluster-key-slot: "npm:1.1.1" + debug: "npm:4.4.3" + denque: "npm:2.1.0" + redis-errors: "npm:1.2.0" + redis-parser: "npm:3.0.0" + standard-as-callback: "npm:2.1.0" + checksum: 10c0/a8b27043cf2c045dfc93f40a32ce24cf9f8b57799a37f4234c4b925c365ccf131629590f94a512f546fda2ba8ed034009c94c4933ecd44c50bc166636d929fd6 + languageName: node + linkType: hard + "ip-address@npm:^10.0.1": version: 10.1.0 resolution: "ip-address@npm:10.1.0" @@ -14619,6 +14682,22 @@ __metadata: languageName: node linkType: hard +"redis-errors@npm:1.2.0, redis-errors@npm:^1.0.0": + version: 1.2.0 + resolution: "redis-errors@npm:1.2.0" + checksum: 10c0/5b316736e9f532d91a35bff631335137a4f974927bb2fb42bf8c2f18879173a211787db8ac4c3fde8f75ed6233eb0888e55d52510b5620e30d69d7d719c8b8a7 + languageName: node + linkType: hard + +"redis-parser@npm:3.0.0": + version: 3.0.0 + resolution: "redis-parser@npm:3.0.0" + dependencies: + redis-errors: "npm:^1.0.0" + checksum: 10c0/ee16ac4c7b2a60b1f42a2cdaee22b005bd4453eb2d0588b8a4939718997ae269da717434da5d570fe0b05030466eeb3f902a58cf2e8e1ca058bf6c9c596f632f + languageName: node + linkType: hard + "reflect-metadata@npm:^0.2.2": version: 0.2.2 resolution: "reflect-metadata@npm:0.2.2" @@ -15524,6 +15603,13 @@ __metadata: languageName: node linkType: hard +"standard-as-callback@npm:2.1.0": + version: 2.1.0 + resolution: "standard-as-callback@npm:2.1.0" + checksum: 10c0/012677236e3d3fdc5689d29e64ea8a599331c4babe86956bf92fc5e127d53f85411c5536ee0079c52c43beb0026b5ce7aa1d834dd35dd026e82a15d1bcaead1f + languageName: node + linkType: hard + "statuses@npm:>= 1.5.0 < 2, statuses@npm:~1.5.0": version: 1.5.0 resolution: "statuses@npm:1.5.0" From 17884c9b324067c550ca499064ae73d7b2efc36c Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 05:34:59 +0900 Subject: [PATCH 18/27] =?UTF-8?q?fix(conversation):=20Redis=20=EC=9E=A5?= =?UTF-8?q?=EC=95=A0=20=EA=B2=A9=EB=A6=AC=C2=B7=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B5=9C=EC=8B=A0=20=EC=83=81=ED=83=9C=20=EB=B0=9C=ED=96=89?= =?UTF-8?q?=20(PR=20#270=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 Codex 지적 2건 반영. - P1: maxRetriesPerRequest null이면 Redis 장애 시 커밋 완료된 mutation이 발행 대기로 매달려 클라이언트 재시도 → 중복 전송 위험. 재시도를 2회로 제한(enableOfflineQueue false)하고, 발행 실패는 events service에서 경고 로그 후 삼킨다(구독자는 재조회 폴백) — 발행 실패 spec 추가 - P2: 동시 전송에서 잠금 해제 후 발행 순서가 커밋 순서와 어긋나면 늦은 목록 이벤트가 과거 미리보기/시각으로 화면을 되돌릴 수 있다. 목록 이벤트를 "발행 시점의 최신 커밋 상태"(대화 재조회 + 최신 메시지)로 조립해 회귀를 차단 — 메시지 스트림 이벤트는 id를 실어 구독자 정렬 --- src/features/conversation/index.ts | 1 + .../conversation-events.service.spec.ts | 32 +++++++++++++++++++ .../services/conversation-events.service.ts | 27 +++++++++++++--- .../services/conversation-inquiry.service.ts | 15 +++++++-- .../services/seller-conversation.service.ts | 28 +++++++++++----- src/global/pubsub/pubsub.module.ts | 6 +++- 6 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/features/conversation/index.ts b/src/features/conversation/index.ts index ad05881b..4db65102 100644 --- a/src/features/conversation/index.ts +++ b/src/features/conversation/index.ts @@ -4,3 +4,4 @@ export { ConversationRepository } from '@/features/conversation/repositories/con // subscription 이벤트 발행/구독 어댑터 — 판매자 답장(seller feature)도 같은 토픽을 쓴다. export { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; export { toEventPreview } from '@/features/conversation/services/conversation-events-mappers.helper'; +export { toLastMessagePreview } from '@/features/conversation/services/conversation-center-mappers.helper'; diff --git a/src/features/conversation/services/conversation-events.service.spec.ts b/src/features/conversation/services/conversation-events.service.spec.ts index 3a1709a0..f416e682 100644 --- a/src/features/conversation/services/conversation-events.service.spec.ts +++ b/src/features/conversation/services/conversation-events.service.spec.ts @@ -3,6 +3,7 @@ * payload가 구독자에게 그대로 도착하는지까지 확인한다(DB 불필요). */ import { RedisPubSub } from 'graphql-redis-subscriptions'; +import type { PubSubEngine } from 'graphql-subscriptions'; import Redis from 'ioredis'; import { GenericContainer, type StartedTestContainer } from 'testcontainers'; @@ -113,4 +114,35 @@ describe('ConversationEventsService (real Redis)', () => { lastMessagePreview: '픽업 문의', }); }); + + it('발행 실패는 삼킨다 — 커밋된 전송을 Redis 장애가 실패로 만들지 않는다', async () => { + const failing = { + publish: jest.fn().mockRejectedValue(new Error('redis down')), + } as unknown as PubSubEngine; + const failingService = new ConversationEventsService(failing); + + await expect( + failingService.publishBuyerListUpdate('1', { + conversationId: '1', + storeId: '2', + storeName: '매장', + lastMessagePreview: null, + lastMessageAt: '2026-08-01T12:00:00.000Z', + unreadCount: 0, + }), + ).resolves.toBeUndefined(); + await expect( + failingService.publishMessagesAdded([ + { + id: '1', + conversationId: '1', + senderType: 'USER', + bodyFormat: 'TEXT', + bodyText: '문의', + bodyHtml: null, + createdAt: new Date(), + }, + ]), + ).resolves.toBeUndefined(); + }); }); diff --git a/src/features/conversation/services/conversation-events.service.ts b/src/features/conversation/services/conversation-events.service.ts index 0b401721..5b760acd 100644 --- a/src/features/conversation/services/conversation-events.service.ts +++ b/src/features/conversation/services/conversation-events.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import type { PubSubEngine } from 'graphql-subscriptions'; import { toConversationMessageEvent } from '@/features/conversation/services/conversation-events-mappers.helper'; @@ -16,8 +16,27 @@ import { PUB_SUB } from '@/global/pubsub'; */ @Injectable() export class ConversationEventsService { + private readonly logger = new Logger(ConversationEventsService.name); + constructor(@Inject(PUB_SUB) private readonly pubSub: PubSubEngine) {} + /** + * 발행은 DB 커밋 이후의 부수효과 — Redis 장애가 이미 성공한 전송을 + * 실패로 둔갑시키면 클라이언트 재시도로 중복 전송이 난다(리뷰 반영). + * 실패는 경고 로그만 남기고 삼킨다(구독자는 재조회 폴백). + */ + private async safePublish(topic: string, payload: unknown): Promise { + try { + await this.pubSub.publish(topic, payload); + } catch (e) { + this.logger.warn( + `subscription publish 실패 (topic=${topic}): ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + } + private messageTopic(conversationId: string): string { return `conversation.message.${conversationId}`; } @@ -35,7 +54,7 @@ export class ConversationEventsService { messages: ConversationMessageOutput[], ): Promise { for (const message of messages) { - await this.pubSub.publish( + await this.safePublish( this.messageTopic(message.conversationId), toConversationMessageEvent(message), ); @@ -46,14 +65,14 @@ export class ConversationEventsService { accountId: string, event: ConversationListUpdateEvent, ): Promise { - await this.pubSub.publish(this.buyerTopic(accountId), event); + await this.safePublish(this.buyerTopic(accountId), event); } async publishSellerListUpdate( storeId: string, event: SellerConversationListUpdateEvent, ): Promise { - await this.pubSub.publish(this.sellerTopic(storeId), event); + await this.safePublish(this.sellerTopic(storeId), event); } messageAddedIterator(conversationId: string): AsyncIterator { diff --git a/src/features/conversation/services/conversation-inquiry.service.ts b/src/features/conversation/services/conversation-inquiry.service.ts index 1f735fc2..4095581c 100644 --- a/src/features/conversation/services/conversation-inquiry.service.ts +++ b/src/features/conversation/services/conversation-inquiry.service.ts @@ -12,6 +12,7 @@ import { type ConversationMessageEntry, } from '@/features/conversation/repositories/conversation.repository'; import { ConversationBaseService } from '@/features/conversation/services/conversation-base.service'; +import { toLastMessagePreview } from '@/features/conversation/services/conversation-center-mappers.helper'; import { toEventPreview } from '@/features/conversation/services/conversation-events-mappers.helper'; import { ConversationEventsService } from '@/features/conversation/services/conversation-events.service'; import { @@ -185,9 +186,11 @@ export class ConversationInquiryService extends ConversationBaseService { }): Promise { const lastMessage = args.messages[args.messages.length - 1]; if (!lastMessage) return; - // 시각은 repository가 잠금 아래 채번한 저장 시각을 그대로 쓴다 - const lastMessageAtIso = lastMessage.createdAt.toISOString(); + // 목록 이벤트는 "발행 시점의 최신 커밋 상태"를 다시 읽어 조립한다 — + // 동시 전송에서 잠금 해제 후 발행 순서가 커밋 순서와 어긋나도, 늦게 + // 발행된 이벤트가 과거 미리보기/시각으로 화면을 되돌리지 않는다 + // (리뷰 반영). 메시지 스트림 이벤트는 id를 실어 구독자가 정렬한다. const conversation = await this.repo.findConversationByAccountAndStore({ accountId: args.accountId, storeId: args.storeId, @@ -198,7 +201,13 @@ export class ConversationInquiryService extends ConversationBaseService { ]) : [undefined]; - const preview = toEventPreview(lastMessage); + const preview = extras + ? toLastMessagePreview(extras.lastMessage) + : toEventPreview(lastMessage); + const lastMessageAtIso = ( + conversation?.last_message_at ?? lastMessage.createdAt + ).toISOString(); + await this.events.publishMessagesAdded(args.messages); await this.events.publishBuyerListUpdate(args.accountId.toString(), { conversationId: args.conversationId.toString(), diff --git a/src/features/seller/services/seller-conversation.service.ts b/src/features/seller/services/seller-conversation.service.ts index 892cbb34..5328c3e4 100644 --- a/src/features/seller/services/seller-conversation.service.ts +++ b/src/features/seller/services/seller-conversation.service.ts @@ -20,6 +20,7 @@ import { ConversationEventsService, ConversationRepository, toEventPreview, + toLastMessagePreview, } from '@/features/conversation'; import { BODY_HTML_REQUIRED, @@ -193,18 +194,29 @@ export class SellerConversationService extends SellerBaseService { bodyHtml: args.message.bodyHtml, createdAt: args.message.createdAt, }; - const preview = toEventPreview(message); - const lastMessageAtIso = args.message.createdAt.toISOString(); - - const [store, [extras]] = await Promise.all([ + // 목록 이벤트는 "발행 시점의 최신 커밋 상태"를 다시 읽어 조립한다 — + // 동시 전송에서 발행 순서가 커밋 순서와 어긋나도 늦은 이벤트가 과거 + // 상태로 화면을 되돌리지 않는다(리뷰 반영). 메시지 스트림은 id 정렬. + const [store, fresh] = await Promise.all([ this.conversationRepository.findStoreNameById(args.storeId), - this.conversationRepository.getConversationListExtras([ + this.conversationRepository.findConversationByIdAndStore({ + conversationId: args.conversation.id, + storeId: args.storeId, + }), + ]); + const [extras] = + await this.conversationRepository.getConversationListExtras([ { id: args.conversation.id, - last_read_at: args.conversation.last_read_at, + last_read_at: fresh?.last_read_at ?? args.conversation.last_read_at, }, - ]), - ]); + ]); + const preview = extras + ? toLastMessagePreview(extras.lastMessage) + : toEventPreview(message); + const lastMessageAtIso = ( + fresh?.last_message_at ?? args.message.createdAt + ).toISOString(); await this.conversationEvents.publishMessagesAdded([message]); await this.conversationEvents.publishBuyerListUpdate( diff --git a/src/global/pubsub/pubsub.module.ts b/src/global/pubsub/pubsub.module.ts index d0b7e60e..bf27a4ef 100644 --- a/src/global/pubsub/pubsub.module.ts +++ b/src/global/pubsub/pubsub.module.ts @@ -23,7 +23,11 @@ import { PUB_SUB } from '@/global/pubsub/pubsub.constants'; const options: RedisOptions = { // Redis 미기동 시 부팅을 막지 않고 재시도만 한다(로컬 DX) retryStrategy: (times: number) => Math.min(times * 500, 5000), - maxRetriesPerRequest: null, + // 발행은 DB 커밋 이후의 부수효과 — 무한 재시도(null)로 두면 Redis + // 장애 시 mutation 응답이 매달린다(리뷰 반영). 짧게 실패시키고 + // 실패 처리는 발행부(try/catch)가 담당한다. + maxRetriesPerRequest: 2, + enableOfflineQueue: false, }; return new RedisPubSub({ publisher: new Redis(redisConfig.url, options), From b3d3e42e87717fa94a47b759aa7a76ac7e2a9b54 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 05:43:08 +0900 Subject: [PATCH 19/27] =?UTF-8?q?fix(conversation):=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=8A=A4=EB=83=85=EC=83=B7?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A8=EC=9D=BC=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9C=BC=EB=A1=9C=20(PR=20#270=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 지적 반영: 발행 시점 재조회가 대화 row·최신 메시지·안읽음 수의 독립 조회로 쪼개져 있어, 경쟁 커밋이 사이에 끼면 "남의 미리보기 + 내 시각" 혼합 상태가 이벤트로 나갈 수 있었다. 대화·매장명·최신 메시지· 안읽음 수를 한 트랜잭션(단일 REPEATABLE READ 스냅샷)에서 읽는 getConversationEventSnapshot으로 통합 — 구매자·판매자 발행 경로 공용. findStoreNameById는 스냅샷에 흡수돼 제거. --- .../repositories/conversation.repository.ts | 44 ++++++++++++++++--- .../services/conversation-inquiry.service.ts | 31 ++++++------- .../services/seller-conversation.service.ts | 35 ++++++--------- 3 files changed, 65 insertions(+), 45 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index f4e5f650..93c58a45 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -295,11 +295,45 @@ export class ConversationRepository { }); } - /** 이벤트 payload용 매장명 단건 조회. */ - async findStoreNameById(storeId: bigint) { - return this.prisma.store.findFirst({ - where: { id: storeId, deleted_at: undefined }, - select: { store_name: true }, + /** + * 목록 갱신 이벤트용 대화 스냅샷 — 한 트랜잭션(단일 REPEATABLE READ + * 스냅샷)에서 대화·매장명·최신 메시지·안읽음 수를 함께 읽는다. 독립 + * 조회로 쪼개면 경쟁 커밋이 끼어들어 "B의 미리보기 + A의 시각" 같은 + * 혼합 상태가 이벤트로 나갈 수 있다(리뷰 반영). + */ + async getConversationEventSnapshot(conversationId: bigint) { + return this.prisma.$transaction(async (tx) => { + const conversation = await tx.storeConversation.findFirst({ + where: { id: conversationId, deleted_at: undefined }, + select: { + id: true, + account_id: true, + store_id: true, + last_message_at: true, + last_read_at: true, + store: { select: { store_name: true } }, + }, + }); + if (!conversation) return null; + + const [lastMessage, unreadCount] = await Promise.all([ + tx.storeConversationMessage.findFirst({ + where: { conversation_id: conversationId }, + orderBy: { id: 'desc' }, + select: { body_format: true, body_text: true, body_html: true }, + }), + tx.storeConversationMessage.count({ + where: { + conversation_id: conversationId, + sender_type: { not: ConversationSenderType.USER }, + ...(conversation.last_read_at + ? { created_at: { gt: conversation.last_read_at } } + : {}), + }, + }), + ]); + + return { conversation, lastMessage, unreadCount }; }); } diff --git a/src/features/conversation/services/conversation-inquiry.service.ts b/src/features/conversation/services/conversation-inquiry.service.ts index 4095581c..eb4e5822 100644 --- a/src/features/conversation/services/conversation-inquiry.service.ts +++ b/src/features/conversation/services/conversation-inquiry.service.ts @@ -187,26 +187,21 @@ export class ConversationInquiryService extends ConversationBaseService { const lastMessage = args.messages[args.messages.length - 1]; if (!lastMessage) return; - // 목록 이벤트는 "발행 시점의 최신 커밋 상태"를 다시 읽어 조립한다 — - // 동시 전송에서 잠금 해제 후 발행 순서가 커밋 순서와 어긋나도, 늦게 - // 발행된 이벤트가 과거 미리보기/시각으로 화면을 되돌리지 않는다 - // (리뷰 반영). 메시지 스트림 이벤트는 id를 실어 구독자가 정렬한다. - const conversation = await this.repo.findConversationByAccountAndStore({ - accountId: args.accountId, - storeId: args.storeId, - }); - const [extras] = conversation - ? await this.repo.getConversationListExtras([ - { id: conversation.id, last_read_at: conversation.last_read_at }, - ]) - : [undefined]; - - const preview = extras - ? toLastMessagePreview(extras.lastMessage) + // 목록 이벤트는 "발행 시점의 최신 커밋 상태"를 단일 트랜잭션 스냅샷 + // 으로 다시 읽어 조립한다 — 독립 조회로 쪼개면 경쟁 커밋이 끼어들어 + // 혼합 상태(남의 미리보기 + 내 시각)가 나갈 수 있다(리뷰 반영). + // 메시지 스트림 이벤트는 id를 실어 구독자가 정렬한다. + const snapshot = await this.repo.getConversationEventSnapshot( + args.conversationId, + ); + + const preview = snapshot + ? toLastMessagePreview(snapshot.lastMessage) : toEventPreview(lastMessage); const lastMessageAtIso = ( - conversation?.last_message_at ?? lastMessage.createdAt + snapshot?.conversation.last_message_at ?? lastMessage.createdAt ).toISOString(); + const unreadCount = snapshot?.unreadCount ?? 0; await this.events.publishMessagesAdded(args.messages); await this.events.publishBuyerListUpdate(args.accountId.toString(), { @@ -215,7 +210,7 @@ export class ConversationInquiryService extends ConversationBaseService { storeName: args.storeName, lastMessagePreview: preview, lastMessageAt: lastMessageAtIso, - unreadCount: extras?.unreadCount ?? 0, + unreadCount, }); await this.events.publishSellerListUpdate(args.storeId.toString(), { conversationId: args.conversationId.toString(), diff --git a/src/features/seller/services/seller-conversation.service.ts b/src/features/seller/services/seller-conversation.service.ts index 5328c3e4..5e0c7137 100644 --- a/src/features/seller/services/seller-conversation.service.ts +++ b/src/features/seller/services/seller-conversation.service.ts @@ -194,29 +194,20 @@ export class SellerConversationService extends SellerBaseService { bodyHtml: args.message.bodyHtml, createdAt: args.message.createdAt, }; - // 목록 이벤트는 "발행 시점의 최신 커밋 상태"를 다시 읽어 조립한다 — - // 동시 전송에서 발행 순서가 커밋 순서와 어긋나도 늦은 이벤트가 과거 - // 상태로 화면을 되돌리지 않는다(리뷰 반영). 메시지 스트림은 id 정렬. - const [store, fresh] = await Promise.all([ - this.conversationRepository.findStoreNameById(args.storeId), - this.conversationRepository.findConversationByIdAndStore({ - conversationId: args.conversation.id, - storeId: args.storeId, - }), - ]); - const [extras] = - await this.conversationRepository.getConversationListExtras([ - { - id: args.conversation.id, - last_read_at: fresh?.last_read_at ?? args.conversation.last_read_at, - }, - ]); - const preview = extras - ? toLastMessagePreview(extras.lastMessage) + // 목록 이벤트는 발행 시점의 최신 커밋 상태를 단일 트랜잭션 스냅샷으로 + // 조립한다 — 독립 조회로 쪼개면 경쟁 커밋이 끼어들어 혼합 상태가 나갈 + // 수 있다(리뷰 반영). 메시지 스트림은 id 정렬. + const snapshot = + await this.conversationRepository.getConversationEventSnapshot( + args.conversation.id, + ); + const preview = snapshot + ? toLastMessagePreview(snapshot.lastMessage) : toEventPreview(message); const lastMessageAtIso = ( - fresh?.last_message_at ?? args.message.createdAt + snapshot?.conversation.last_message_at ?? args.message.createdAt ).toISOString(); + const storeName = snapshot?.conversation.store.store_name ?? ''; await this.conversationEvents.publishMessagesAdded([message]); await this.conversationEvents.publishBuyerListUpdate( @@ -224,10 +215,10 @@ export class SellerConversationService extends SellerBaseService { { conversationId: args.conversation.id.toString(), storeId: args.storeId.toString(), - storeName: store?.store_name ?? '', + storeName, lastMessagePreview: preview, lastMessageAt: lastMessageAtIso, - unreadCount: extras?.unreadCount ?? 0, + unreadCount: snapshot?.unreadCount ?? 0, }, ); await this.conversationEvents.publishSellerListUpdate( From f2c5e13f5db4b2a8aecaf95bac0cf275b026395c Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 05:51:13 +0900 Subject: [PATCH 20/27] =?UTF-8?q?fix(conversation):=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=20=ED=9B=84=20=EB=B0=9C=ED=96=89=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20+=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EA=B3=84=EC=95=BD=20=EB=AA=85=EC=8B=9C=20?= =?UTF-8?q?(PR=20#270=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 Codex 지적 2건 대응. - P1(반영): 스냅샷 조회를 포함한 커밋 후 발행 경로 전체를 try/catch로 격리(구매자·판매자) — DB 순단이 이미 저장된 전송을 실패로 둔갑시켜 재시도 중복을 만들지 않도록. 실패는 경고 로그만 - P2(부분 반영): 완전한 커밋 순서 발행은 outbox 패턴이 필요해 범위와 비례하지 않음. 이벤트에 이미 실린 lastMessageAt(목록)·id(메시지)로 구독자가 stale 이벤트를 폐기하는 계약을 SDL에 명시 --- .../conversation-subscription.graphql | 8 ++++-- .../services/conversation-inquiry.service.ts | 25 ++++++++++++++++++- .../services/seller-conversation.service.ts | 25 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/features/conversation/conversation-subscription.graphql b/src/features/conversation/conversation-subscription.graphql index 43806d39..3b671b4b 100644 --- a/src/features/conversation/conversation-subscription.graphql +++ b/src/features/conversation/conversation-subscription.graphql @@ -10,7 +10,11 @@ extend type Subscription { sellerConversationUpdated: SellerConversationListUpdate! } -"""구매자 대화 목록 갱신 이벤트""" +""" +구매자 대화 목록 갱신 이벤트. +이벤트 간 도착 순서는 보장되지 않는다 — 구독자는 lastMessageAt을 비교해 +이미 표시 중인 상태보다 오래된 이벤트를 폐기해야 한다(메시지 스트림은 id 기준). +""" type ConversationListUpdate { conversationId: ID! storeId: ID! @@ -22,7 +26,7 @@ type ConversationListUpdate { unreadCount: Int! } -"""판매자 대화 목록 갱신 이벤트""" +"""판매자 대화 목록 갱신 이벤트. 도착 순서 비보장 — lastMessageAt 기준 폐기 규칙 동일.""" type SellerConversationListUpdate { conversationId: ID! accountId: ID! diff --git a/src/features/conversation/services/conversation-inquiry.service.ts b/src/features/conversation/services/conversation-inquiry.service.ts index eb4e5822..7c78ec84 100644 --- a/src/features/conversation/services/conversation-inquiry.service.ts +++ b/src/features/conversation/services/conversation-inquiry.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConversationBodyFormat, ConversationSenderType } from '@prisma/client'; import { parseId } from '@/common/utils/id-parser'; @@ -27,6 +27,8 @@ import type { @Injectable() export class ConversationInquiryService extends ConversationBaseService { + private readonly logger = new Logger(ConversationInquiryService.name); + constructor( repo: ConversationRepository, private readonly events: ConversationEventsService, @@ -183,6 +185,27 @@ export class ConversationInquiryService extends ConversationBaseService { storeName: string; conversationId: bigint; messages: ConversationMessagesPayload['messages']; + }): Promise { + // 커밋 이후의 부수효과 전체(스냅샷 조회 포함)를 격리한다 — 여기서 나는 + // 예외가 mutation을 실패로 둔갑시키면 클라이언트 재시도로 중복 전송이 + // 난다(리뷰 반영). 실패는 경고 로그만 남긴다. + try { + await this.doPublishBuyerSendEvents(args); + } catch (e) { + this.logger.warn( + `대화 이벤트 발행 실패 (conversationId=${args.conversationId}): ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + } + + private async doPublishBuyerSendEvents(args: { + accountId: bigint; + storeId: bigint; + storeName: string; + conversationId: bigint; + messages: ConversationMessagesPayload['messages']; }): Promise { const lastMessage = args.messages[args.messages.length - 1]; if (!lastMessage) return; diff --git a/src/features/seller/services/seller-conversation.service.ts b/src/features/seller/services/seller-conversation.service.ts index 5e0c7137..aceacb3d 100644 --- a/src/features/seller/services/seller-conversation.service.ts +++ b/src/features/seller/services/seller-conversation.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { @@ -48,6 +49,8 @@ import type { @Injectable() export class SellerConversationService extends SellerBaseService { + private readonly logger = new Logger(SellerConversationService.name); + constructor( repo: SellerRepository, @Inject(AUDIT_LOG_REPOSITORY) @@ -184,6 +187,28 @@ export class SellerConversationService extends SellerBaseService { }; storeId: bigint; message: SellerConversationMessageOutput; + }): Promise { + // 커밋 이후의 부수효과 전체(스냅샷 조회 포함)를 격리한다 — 예외가 + // 이미 저장된 답장을 실패로 둔갑시키면 재시도 중복이 난다(리뷰 반영). + try { + await this.doPublishSellerReplyEvents(args); + } catch (e) { + this.logger.warn( + `대화 이벤트 발행 실패 (conversationId=${args.conversation.id}): ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + } + + private async doPublishSellerReplyEvents(args: { + conversation: { + id: bigint; + account_id: bigint; + last_read_at: Date | null; + }; + storeId: bigint; + message: SellerConversationMessageOutput; }): Promise { const message = { id: args.message.id, From e028c612390905e4b9184d81526e4d5523383414 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 2 Sep 2026 06:01:52 +0900 Subject: [PATCH 21/27] =?UTF-8?q?fix(conversation):=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EC=97=90=20lastReadAt=20?= =?UTF-8?q?=EC=8B=A4=EC=96=B4=20=ED=8F=90=EA=B8=B0=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20+=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EB=A7=A4=EC=9E=A5=EB=AA=85=20(PR=20#270=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 지적 2건 반영. - P2: 읽음 처리는 last_message_at을 바꾸지 않아, 읽음 이후 도착한 지연 이벤트의 stale unreadCount를 lastMessageAt 비교로 걸러낼 수 없었다. 이벤트에 스냅샷 시점 lastReadAt을 추가하고 구독자 폐기 규칙을 (lastMessageAt, lastReadAt) 사전식 비교로 확장(SDL 계약 갱신) — 읽음은 lastReadAt을 전진시키므로 stale 배지 부활이 걸러진다 - P2: 구매자 이벤트 매장명도 스냅샷 값 우선 — 최초 조회 후 개명 시 최신 상태에 옛 이름이 섞여 나가던 문제 --- .../conversation/conversation-subscription.graphql | 8 ++++++-- .../services/conversation-events.service.spec.ts | 2 ++ .../conversation/services/conversation-inquiry.service.ts | 8 +++++++- .../conversation/types/conversation-output.type.ts | 1 + .../seller/services/seller-conversation.service.ts | 3 +++ 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/features/conversation/conversation-subscription.graphql b/src/features/conversation/conversation-subscription.graphql index 3b671b4b..ebd22cd3 100644 --- a/src/features/conversation/conversation-subscription.graphql +++ b/src/features/conversation/conversation-subscription.graphql @@ -12,8 +12,10 @@ extend type Subscription { """ 구매자 대화 목록 갱신 이벤트. -이벤트 간 도착 순서는 보장되지 않는다 — 구독자는 lastMessageAt을 비교해 -이미 표시 중인 상태보다 오래된 이벤트를 폐기해야 한다(메시지 스트림은 id 기준). +이벤트 간 도착 순서는 보장되지 않는다 — 구독자는 (lastMessageAt, lastReadAt)을 +사전식으로 비교해 이미 표시 중인 상태보다 오래된 이벤트를 폐기해야 한다. +읽음 처리는 lastReadAt만 전진시키므로, 읽음 이후 도착한 지연 이벤트도 이 비교로 +걸러진다(메시지 스트림은 id 기준). """ type ConversationListUpdate { conversationId: ID! @@ -22,6 +24,8 @@ type ConversationListUpdate { """마지막 메시지 미리보기(HTML은 태그 제거)""" lastMessagePreview: String lastMessageAt: DateTime! + """이벤트 스냅샷 시점의 구매자 마지막 읽음 시각(폐기 규칙 비교용)""" + lastReadAt: DateTime """이벤트 시점의 안읽은 수신 메시지 수""" unreadCount: Int! } diff --git a/src/features/conversation/services/conversation-events.service.spec.ts b/src/features/conversation/services/conversation-events.service.spec.ts index f416e682..9a6cec35 100644 --- a/src/features/conversation/services/conversation-events.service.spec.ts +++ b/src/features/conversation/services/conversation-events.service.spec.ts @@ -96,6 +96,7 @@ describe('ConversationEventsService (real Redis)', () => { storeName: '해즈 케이크', lastMessagePreview: '답변 드리겠습니다', lastMessageAt: '2026-08-01T12:00:00.000Z', + lastReadAt: null, unreadCount: 2, }); await service.publishSellerListUpdate('3', { @@ -128,6 +129,7 @@ describe('ConversationEventsService (real Redis)', () => { storeName: '매장', lastMessagePreview: null, lastMessageAt: '2026-08-01T12:00:00.000Z', + lastReadAt: null, unreadCount: 0, }), ).resolves.toBeUndefined(); diff --git a/src/features/conversation/services/conversation-inquiry.service.ts b/src/features/conversation/services/conversation-inquiry.service.ts index 7c78ec84..b6ebbded 100644 --- a/src/features/conversation/services/conversation-inquiry.service.ts +++ b/src/features/conversation/services/conversation-inquiry.service.ts @@ -224,15 +224,21 @@ export class ConversationInquiryService extends ConversationBaseService { const lastMessageAtIso = ( snapshot?.conversation.last_message_at ?? lastMessage.createdAt ).toISOString(); + const lastReadAtIso = + snapshot?.conversation.last_read_at?.toISOString() ?? null; const unreadCount = snapshot?.unreadCount ?? 0; + // 매장명도 스냅샷 값을 우선한다 — 최초 조회 후 개명되면 최신 메시지 + // 상태에 옛 이름이 실려 나갈 수 있다(리뷰 반영) + const storeName = snapshot?.conversation.store.store_name ?? args.storeName; await this.events.publishMessagesAdded(args.messages); await this.events.publishBuyerListUpdate(args.accountId.toString(), { conversationId: args.conversationId.toString(), storeId: args.storeId.toString(), - storeName: args.storeName, + storeName, lastMessagePreview: preview, lastMessageAt: lastMessageAtIso, + lastReadAt: lastReadAtIso, unreadCount, }); await this.events.publishSellerListUpdate(args.storeId.toString(), { diff --git a/src/features/conversation/types/conversation-output.type.ts b/src/features/conversation/types/conversation-output.type.ts index 8581eb54..b1965fed 100644 --- a/src/features/conversation/types/conversation-output.type.ts +++ b/src/features/conversation/types/conversation-output.type.ts @@ -84,6 +84,7 @@ export interface ConversationListUpdateEvent { storeName: string; lastMessagePreview: string | null; lastMessageAt: string; + lastReadAt: string | null; unreadCount: number; } diff --git a/src/features/seller/services/seller-conversation.service.ts b/src/features/seller/services/seller-conversation.service.ts index aceacb3d..4d46a9ed 100644 --- a/src/features/seller/services/seller-conversation.service.ts +++ b/src/features/seller/services/seller-conversation.service.ts @@ -233,6 +233,8 @@ export class SellerConversationService extends SellerBaseService { snapshot?.conversation.last_message_at ?? args.message.createdAt ).toISOString(); const storeName = snapshot?.conversation.store.store_name ?? ''; + const lastReadAtIso = + snapshot?.conversation.last_read_at?.toISOString() ?? null; await this.conversationEvents.publishMessagesAdded([message]); await this.conversationEvents.publishBuyerListUpdate( @@ -243,6 +245,7 @@ export class SellerConversationService extends SellerBaseService { storeName, lastMessagePreview: preview, lastMessageAt: lastMessageAtIso, + lastReadAt: lastReadAtIso, unreadCount: snapshot?.unreadCount ?? 0, }, ); From 4826dac0d8f6f7516703d03852971f653ba39432 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 03:02:42 +0900 Subject: [PATCH 22/27] =?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(=EB=8C=80=ED=99=94?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=8B=9C=EA=B0=81=EC=9D=84=20?= =?UTF-8?q?DB=20=EC=8B=9C=EA=B3=84=EB=A1=9C=20=EC=B1=84=EB=B2=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 릴리즈 PR #272 Codex P1 반영: 앱 호스트 시계로 채번한 created_at은 다중 인스턴스 배포에서 노드 간 시계 오차로 잠금 순서와 어긋날 수 있어, 시계가 늦은 노드의 답장이 last_read_at보다 과거 시각을 받아 안읽음 배지에서 영구 누락될 수 있다. - 구매자 전송·판매자 답장 모두 대화 잠금 획득 후 DB 시계(SELECT NOW(3))로 시각을 채번 — DB가 단일 시계 소스라 인스턴스 수와 무관하게 잠금 순서 = 시각 순서 = 커밋 순서 유지 - 읽음 마커는 이미 메시지 created_at 파생이라 추가 변경 없음 --- .../repositories/conversation.repository.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 93c58a45..8e134482 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.fetchDbNow(tx); // 인사말 필요 여부는 실제 메시지 수로 판정한다 — "생성 여부" 플래그는 // 동시 첫 전송·실패 재시도에서 인사말 계약(항상 첫 메시지)을 깨뜨린다. @@ -467,6 +467,17 @@ export class ConversationRepository { }); } + /** DB 시계(NOW(3)) 조회 — 인스턴스 간 단일 시계 소스. 잠금 획득 후 호출 전제. */ + private async fetchDbNow(tx: Prisma.TransactionClient): Promise { + const rows = await tx.$queryRaw<{ now: Date }[]>`SELECT NOW(3) AS now`; + const now = rows[0]?.now; + if (!(now instanceof Date)) { + // 드라이버가 Date 매핑에 실패하는 비정상 경로 — 전송을 막지 않는다 + return new Date(); + } + return now; + } + /** * 트랜잭션 안에서 (account_id, store_id) 대화를 잠그거나 생성한다. * - 기존 대화: id FOR UPDATE 잠금(초기화 직렬화). 유니크 제약은 soft-delete @@ -543,10 +554,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.fetchDbNow(tx); const message = await tx.storeConversationMessage.create({ data: { From a4a8ad26605a9f22f505363b121f6b151b773a7e Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 03:09:28 +0900 Subject: [PATCH 23/27] =?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=8B=9C=EA=B3=84?= =?UTF-8?q?=20=EC=BB=B7=EC=98=A4=EB=B2=84=20=EB=8C=80=EB=B9=84=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=20=EB=8B=A8=EC=9C=84=20=EB=8B=A8=EC=A1=B0=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=EB=B3=B4=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #273 Codex P1 반영: 배포 전 앱 시계가 DB보다 앞섰던 노드가 남긴 미래 last_read_at/last_message_at이 있으면, 컷오버 직후 NOW(3) 채번이 그보다 과거/동률이 되어 새 답장이 안읽음 판정(created_at > last_read_at) 에서 영구 누락될 수 있다. - 채번을 GREATEST(NOW(3), last_message_at+1ms, last_read_at+1ms)로 보정 — 잠금 아래라 대화 단위 단조성이 race 없이 보장되고, ms 동률 배제까지 해소 - 미래 마커 재현 회귀 spec 추가 --- .../conversation.repository.spec.ts | 24 ++++++++++++++++ .../repositories/conversation.repository.ts | 28 +++++++++++++++---- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.spec.ts b/src/features/conversation/repositories/conversation.repository.spec.ts index 46ccb63e..d668c464 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 8e134482..9f2a5259 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -380,7 +380,7 @@ export class ConversationRepository { // 앱 호스트 시계는 다중 인스턴스에서 노드 간 오차로 잠금 순서와 // 어긋날 수 있다(릴리즈 리뷰 반영). DB가 단일 시계 소스이므로 // 잠금 순서 = 시각 순서 = 커밋 순서가 대화 단위로 보장된다. - const now = await this.fetchDbNow(tx); + const now = await this.fetchMonotonicNow(tx, conversationId); // 인사말 필요 여부는 실제 메시지 수로 판정한다 — "생성 여부" 플래그는 // 동시 첫 전송·실패 재시도에서 인사말 계약(항상 첫 메시지)을 깨뜨린다. @@ -467,12 +467,28 @@ export class ConversationRepository { }); } - /** DB 시계(NOW(3)) 조회 — 인스턴스 간 단일 시계 소스. 잠금 획득 후 호출 전제. */ - private async fetchDbNow(tx: Prisma.TransactionClient): Promise { - const rows = await tx.$queryRaw<{ now: Date }[]>`SELECT NOW(3) AS now`; + /** + * 대화 단위 단조 시각 채번 — 인스턴스 간 단일 시계(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 { + 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}`; const now = rows[0]?.now; if (!(now instanceof Date)) { - // 드라이버가 Date 매핑에 실패하는 비정상 경로 — 전송을 막지 않는다 + // row 부재/드라이버 매핑 실패의 비정상 경로 — 전송을 막지 않는다 return new Date(); } return now; @@ -558,7 +574,7 @@ export class ConversationRepository { // 채번해 커밋 순서와 시각 순서를 대화 단위로 일치시킨다(읽음 마커 // 정합 — 앱 호스트 시계는 다중 인스턴스 오차에 취약, 릴리즈 리뷰 반영). await tx.$queryRaw`SELECT id FROM store_conversation WHERE id = ${args.conversationId} FOR UPDATE`; - const now = await this.fetchDbNow(tx); + const now = await this.fetchMonotonicNow(tx, args.conversationId); const message = await tx.storeConversationMessage.create({ data: { From a94857ace1acfba01da9b8a9c6880d24860d4f87 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 03:15:03 +0900 Subject: [PATCH 24/27] =?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(=EB=8B=A8=EC=A1=B0?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=81=20=EC=B1=84=EB=B2=88=EC=9D=84=20=EC=9E=A0?= =?UTF-8?q?=EA=B8=88=20=EC=A1=B0=ED=9A=8C=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #273 Codex P2 반영: GREATEST 채번 SELECT가 일반(비잠금) 조회라 트랜잭션 초입 스냅샷을 읽어, 잠금 대기 중 커밋된 마커 갱신을 놓칠 수 있었다. FOR UPDATE 잠금 조회로 전환 — 최신 커밋 값을 읽고, row는 이미 본 트랜잭션이 잠근 상태라 추가 대기 없음. --- .../conversation/repositories/conversation.repository.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 9f2a5259..36028b23 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -478,6 +478,9 @@ export class ConversationRepository { tx: Prisma.TransactionClient, conversationId: bigint, ): Promise { + // FOR UPDATE 잠금 조회 — 일반 조회는 트랜잭션 초입 스냅샷을 읽어, + // 잠금 대기 중 커밋된 마커 갱신을 놓칠 수 있다(릴리즈 리뷰 반영). + // row는 이미 본 트랜잭션이 잠갔으므로 추가 대기는 없다. const rows = await tx.$queryRaw<{ now: Date }[]>` SELECT GREATEST( NOW(3), @@ -485,7 +488,8 @@ export class ConversationRepository { COALESCE(TIMESTAMPADD(MICROSECOND, 1000, last_read_at), NOW(3)) ) AS now FROM store_conversation - WHERE id = ${conversationId}`; + WHERE id = ${conversationId} + FOR UPDATE`; const now = rows[0]?.now; if (!(now instanceof Date)) { // row 부재/드라이버 매핑 실패의 비정상 경로 — 전송을 막지 않는다 From 5fbc2c169bbfbd5d804609b867ad7890f39684dc Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 03:57:10 +0900 Subject: [PATCH 25/27] =?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(3=EA=B0=9C=EC=9B=94?= =?UTF-8?q?=20=ED=95=98=ED=95=9C=20=EC=9B=94=EB=A7=90=20=EB=A1=A4=EC=98=A4?= =?UTF-8?q?=EB=B2=84=20=ED=81=B4=EB=9E=A8=ED=94=84,=20=EC=8B=9C=EB=93=9C?= =?UTF-8?q?=20=ED=8F=AC=EB=A7=B7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 릴리즈 PR #272 CodeRabbit 지적 반영 2건. - 알림 3개월 노출 하한: setMonth 롤오버(5/31 → 3/3)로 하한이 늦어져 알림이 일찍 숨던 문제 — 롤오버 감지 시 대상 월 말일로 클램프 - prisma/seed/conversations.ts Prettier 포맷(lint 범위 밖 파일) --- prisma/seed/conversations.ts | 23 +++++++++++++++---- .../services/user-notification.service.ts | 10 ++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/prisma/seed/conversations.ts b/prisma/seed/conversations.ts index c769e390..a7f4006c 100644 --- a/prisma/seed/conversations.ts +++ b/prisma/seed/conversations.ts @@ -37,15 +37,30 @@ export async function seedConversations( }); const faqRows = [ - { title: '날짜 변경', answer_html: '

픽업 1일 전까지 채팅으로 요청해 주시면 일정 확인 후 변경해 드려요.

' }, + { + title: '날짜 변경', + answer_html: + '

픽업 1일 전까지 채팅으로 요청해 주시면 일정 확인 후 변경해 드려요.

', + }, { title: '케이크 보관 방법', answer_html: '

🎂 케이크 보관 방법

  • 냉장보관시 최대 3일
  • 생크림 케이크는 당일 드시는 걸 권장해요
', }, - { title: '가게 위치 정보', answer_html: '

매장 상세의 찾아오는 길 안내를 확인해 주세요.

' }, - { title: '제일 많이 물어보는 질문', answer_html: '

레터링 문구는 주문 시 요청사항에 남겨 주시면 반영돼요.

' }, - { title: '예약 가능 일정', answer_html: '

캘린더에서 픽업 가능 날짜·시간대를 확인할 수 있어요.

' }, + { + title: '가게 위치 정보', + answer_html: '

매장 상세의 찾아오는 길 안내를 확인해 주세요.

', + }, + { + title: '제일 많이 물어보는 질문', + answer_html: + '

레터링 문구는 주문 시 요청사항에 남겨 주시면 반영돼요.

', + }, + { + title: '예약 가능 일정', + answer_html: + '

캘린더에서 픽업 가능 날짜·시간대를 확인할 수 있어요.

', + }, ]; const faqs = [] as { id: bigint; title: string; answer_html: string }[]; for (const [i, row] of faqRows.entries()) { diff --git a/src/features/user/services/user-notification.service.ts b/src/features/user/services/user-notification.service.ts index f17c9c3f..94693825 100644 --- a/src/features/user/services/user-notification.service.ts +++ b/src/features/user/services/user-notification.service.ts @@ -94,12 +94,18 @@ export class UserNotificationService extends UserBaseService { } /** - * "최근 3개월" 노출 하한. setMonth 롤오버(예: 5/31 → 3/1 아님, 3/3)로 - * 말일 경계가 며칠 어긋날 수 있으나 안내 문구 수준의 정밀도로 충분하다. + * "최근 3개월" 노출 하한. setMonth는 대상 월에 없는 날짜를 다음 달로 + * 롤오버시키므로(예: 5/31 → 3/3) 하한이 며칠 늦어져 알림이 일찍 숨는다 — + * 롤오버가 감지되면 대상 월의 말일로 클램프한다(릴리즈 리뷰 반영). */ private notificationVisibleSince(): Date { const since = new Date(); + const dayOfMonth = since.getDate(); since.setMonth(since.getMonth() - NOTIFICATION_VISIBLE_MONTHS); + if (since.getDate() !== dayOfMonth) { + // 롤오버 발생 — setDate(0)은 이전 달(=대상 월)의 말일로 되돌린다 + since.setDate(0); + } return since; } From 1b6d2eba3732758ce2af42c72475b81d2ef59e37 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 04:13:28 +0900 Subject: [PATCH 26/27] =?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(=EB=8C=80=ED=99=94?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=C2=B7?= =?UTF-8?q?=EB=B6=80=EA=B0=80=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EC=8A=A4=EB=83=85=EC=83=B7=EC=9C=BC=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 릴리즈 PR #272 Codex P2 반영: myConversations의 페이지 조회·건수·부가 정보(마지막 메시지 미리보기·안읽음 수)가 독립 조회로 쪼개져 있어, 사이에 커밋된 메시지가 미리보기에만 반영되고 정렬 기준·커서(lastMessageAt)는 과거 값으로 남는 혼합 상태가 나갈 수 있었다. - getConversationPageWithExtras: 세 조회를 한 트랜잭션(단일 REPEATABLE READ 스냅샷)으로 통합, 부가 정보는 페이지 항목(limit)만 조회 - 기존 세 메서드는 tx 스코프 private으로 전환(외부 사용처 없음) --- .../repositories/conversation.repository.ts | 58 +++++++++++++++---- .../services/conversation-center.service.ts | 16 +++-- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 36028b23..a28085f9 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -129,20 +129,52 @@ export class ConversationRepository { } /** - * 구매자 대화 목록 페이지. (last_message_at, id) desc 키셋. - * 대화는 첫 메시지 전송 시에만 생성되지만, 방어적으로 메시지 없는 - * 대화(last_message_at null)는 목록에서 제외한다 — 커서 정렬 키가 없다. + * 구매자 대화 목록 페이지 + 부가 정보(마지막 메시지·안읽음 수)를 한 + * 트랜잭션(단일 REPEATABLE READ 스냅샷)으로 읽는다 — 조회를 쪼개면 + * 사이에 커밋된 메시지가 미리보기/안읽음 수에만 반영되고 정렬 기준· + * 커서(lastMessageAt)는 과거 값으로 남는 혼합 상태가 나갈 수 있다 + * (릴리즈 리뷰 반영). */ - async listConversationsByAccount(args: { + async getConversationPageWithExtras(args: { accountId: bigint; limit: number; cursor?: { lastMessageAt: Date; id: bigint }; }) { + return this.prisma.$transaction(async (tx) => { + const [rows, totalCount] = await Promise.all([ + this.listConversationsByAccount(tx, args), + this.countConversationsByAccount(tx, args.accountId), + ]); + // 초과분(limit+1)은 hasMore 판정용 — 부가 정보는 페이지 항목만 조회 + const extras = await this.getConversationListExtras( + tx, + rows.slice(0, args.limit).map((row) => ({ + id: row.id, + last_read_at: row.last_read_at, + })), + ); + return { rows, totalCount, extras }; + }); + } + + /** + * 구매자 대화 목록 페이지. (last_message_at, id) desc 키셋. + * 대화는 첫 메시지 전송 시에만 생성되지만, 방어적으로 메시지 없는 + * 대화(last_message_at null)는 목록에서 제외한다 — 커서 정렬 키가 없다. + */ + private async listConversationsByAccount( + tx: Prisma.TransactionClient, + args: { + accountId: bigint; + limit: number; + cursor?: { lastMessageAt: Date; id: bigint }; + }, + ) { const where: Prisma.StoreConversationWhereInput = { account_id: args.accountId, last_message_at: { not: null }, }; - return this.prisma.storeConversation.findMany({ + return tx.storeConversation.findMany({ where: args.cursor ? { AND: [ @@ -167,8 +199,11 @@ export class ConversationRepository { }); } - async countConversationsByAccount(accountId: bigint): Promise { - return this.prisma.storeConversation.count({ + private async countConversationsByAccount( + tx: Prisma.TransactionClient, + accountId: bigint, + ): Promise { + return tx.storeConversation.count({ where: { account_id: accountId, last_message_at: { not: null } }, }); } @@ -180,13 +215,14 @@ export class ConversationRepository { * 최신 메시지 id 집계 → 본문 일괄 조회 → 안읽음 OR-분기 groupBy의 * 고정 3쿼리로 배치한다. */ - async getConversationListExtras( + private async getConversationListExtras( + tx: Prisma.TransactionClient, rows: { id: bigint; last_read_at: Date | null }[], ) { if (rows.length === 0) return []; const ids = rows.map((row) => row.id); - const latestIdRows = await this.prisma.storeConversationMessage.groupBy({ + const latestIdRows = await tx.storeConversationMessage.groupBy({ by: ['conversation_id'], where: { conversation_id: { in: ids } }, _max: { id: true }, @@ -197,7 +233,7 @@ export class ConversationRepository { const [latestMessages, unreadGroups] = await Promise.all([ latestIds.length > 0 - ? this.prisma.storeConversationMessage.findMany({ + ? tx.storeConversationMessage.findMany({ where: { id: { in: latestIds } }, select: { conversation_id: true, @@ -207,7 +243,7 @@ export class ConversationRepository { }, }) : Promise.resolve([]), - this.prisma.storeConversationMessage.groupBy({ + tx.storeConversationMessage.groupBy({ by: ['conversation_id'], where: { sender_type: { not: ConversationSenderType.USER }, diff --git a/src/features/conversation/services/conversation-center.service.ts b/src/features/conversation/services/conversation-center.service.ts index 7f8996b5..b5d14968 100644 --- a/src/features/conversation/services/conversation-center.service.ts +++ b/src/features/conversation/services/conversation-center.service.ts @@ -40,27 +40,25 @@ export class ConversationCenterService extends ConversationBaseService { ? parseTimestampIdCursor(input.cursor, CONVERSATION_ERRORS.INVALID_CURSOR) : undefined; - const [rows, totalCount] = await Promise.all([ - this.repo.listConversationsByAccount({ + // 페이지·건수·부가 정보는 repository가 한 트랜잭션(단일 스냅샷)으로 + // 읽는다 — 조회 사이에 커밋된 메시지로 미리보기와 정렬 기준·커서가 + // 어긋나는 혼합 상태 방지(릴리즈 리뷰 반영). + const { rows, totalCount, extras } = + await this.repo.getConversationPageWithExtras({ accountId, limit, cursor: cursor ? { lastMessageAt: cursor.timestamp, id: cursor.id } : undefined, - }), - this.repo.countConversationsByAccount(accountId), - ]); + }); // last_message_at desc 정렬과 결합된 커서 — 새 메시지 도착으로 대화가 // 위로 떠오르면 다음 페이지에 다시 나타날 수 있다(목록 새로고침 전제). const page = sliceCursorPage(rows, limit, (last) => - // listConversationsByAccount가 last_message_at null을 제외하므로 항상 존재 + // 목록 조회가 last_message_at null을 제외하므로 항상 존재 buildTimestampIdCursor(last.last_message_at!, last.id), ); - const extras = await this.repo.getConversationListExtras( - page.items.map((row) => ({ id: row.id, last_read_at: row.last_read_at })), - ); const extraById = new Map( extras.map((e) => [e.conversationId.toString(), e]), ); From 6db8b6f71f9a3c62e34d01f72d5384646e5cd3a0 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 04:18:44 +0900 Subject: [PATCH 27/27] =?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(=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=8A=A4=EB=83=85=EC=83=B7=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EA=B2=A9=EB=A6=AC=20=EC=88=98=EC=A4=80=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #276 Codex P2 반영: 단일 스냅샷 보장이 서버/세션 기본 격리 수준에 의존하지 않도록 REPEATABLE READ를 명시(READ COMMITTED 환경에서는 문장마다 새 스냅샷이라 혼합 상태 레이스가 재발). --- .../repositories/conversation.repository.ts | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index a28085f9..cff8eadd 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -140,21 +140,26 @@ export class ConversationRepository { limit: number; cursor?: { lastMessageAt: Date; id: bigint }; }) { - return this.prisma.$transaction(async (tx) => { - const [rows, totalCount] = await Promise.all([ - this.listConversationsByAccount(tx, args), - this.countConversationsByAccount(tx, args.accountId), - ]); - // 초과분(limit+1)은 hasMore 판정용 — 부가 정보는 페이지 항목만 조회 - const extras = await this.getConversationListExtras( - tx, - rows.slice(0, args.limit).map((row) => ({ - id: row.id, - last_read_at: row.last_read_at, - })), - ); - return { rows, totalCount, extras }; - }); + return this.prisma.$transaction( + async (tx) => { + const [rows, totalCount] = await Promise.all([ + this.listConversationsByAccount(tx, args), + this.countConversationsByAccount(tx, args.accountId), + ]); + // 초과분(limit+1)은 hasMore 판정용 — 부가 정보는 페이지 항목만 조회 + const extras = await this.getConversationListExtras( + tx, + rows.slice(0, args.limit).map((row) => ({ + id: row.id, + last_read_at: row.last_read_at, + })), + ); + return { rows, totalCount, extras }; + }, + // 단일 스냅샷 보장은 REPEATABLE READ 전제 — 서버/세션 기본값이 + // READ COMMITTED면 문장마다 새 스냅샷이라 명시로 고정한다(리뷰 반영) + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); } /**