From 954d235065c7ed7980476270b1c4f17b79d9bb2b Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 03:16:07 +0900 Subject: [PATCH] =?UTF-8?q?fix(store):=20=EC=B0=9C=ED=95=9C=20=EB=A7=A4?= =?UTF-8?q?=EC=9E=A5=20=EC=B9=B4=EB=93=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EC=83=81=ED=92=88=20=EB=8C=80=ED=91=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20(#216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #216 반영. myWishlistedStores 카드의 imageUrls가 매장 대표 이미지(StoreImage)를 쓰고 있어 인기 매장 카드(PopularStore.cakeImageUrls, 상품 대표 이미지)와 소스가 달랐다 — 같은 매장이 화면마다 다른 이미지로 보이고, 매장 대표 이미지 미등록 시 카드가 비는 문제. 변경점: - StoreRepository.findStoreCakeImages에 limit 파라미터 추가(기본값 기존 4장 유지) - StoreWishlistService: StoreImage 대신 findStoreCakeImages(3장) 재사용, 평점 집계와 Promise.all 병렬화 - StoreWishlistRepository: 목록 select에서 store_images 제거 - SDL imageUrls 설명 갱신 회귀 테스트 2건 교체: 최신 상품 3장 우선·이미지 없는 매장 빈 배열 / 삭제 이미지·비활성 상품 제외. Closes #216 Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- .../repositories/store-wishlist.repository.ts | 8 -- .../store/repositories/store.repository.ts | 3 +- .../services/store-wishlist.service.spec.ts | 74 +++++++++++++++---- .../store/services/store-wishlist.service.ts | 21 ++++-- src/features/store/store-wishlist.graphql | 2 +- 5 files changed, 79 insertions(+), 29 deletions(-) diff --git a/src/features/store/repositories/store-wishlist.repository.ts b/src/features/store/repositories/store-wishlist.repository.ts index 07cb816..ce871ec 100644 --- a/src/features/store/repositories/store-wishlist.repository.ts +++ b/src/features/store/repositories/store-wishlist.repository.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { WISHLISTED_STORE_IMAGE_LIMIT } from '@/features/store/constants/store-wishlist.constants'; import { PrismaService } from '@/prisma'; /** 찜한 매장 목록 조회 결과 row. myWishlistedStores 매퍼 입력. */ @@ -14,7 +13,6 @@ export interface WishlistedStoreRow { address_city: string | null; address_neighborhood: string | null; region: { name: string } | null; - store_images: { image_url: string }[]; }; } @@ -129,12 +127,6 @@ export class StoreWishlistRepository { address_city: true, address_neighborhood: true, region: { select: { name: true } }, - store_images: { - where: { deleted_at: null }, - orderBy: { sort_order: 'asc' }, - take: WISHLISTED_STORE_IMAGE_LIMIT, - select: { image_url: true }, - }, }, }, }, diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index 3833b72..47cc6d3 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -380,6 +380,7 @@ export class StoreRepository { /** 페이지 매장들의 대표 케이크 이미지(매장당 최대 N장, 활성 상품 1장씩). */ async findStoreCakeImages( storeIds: bigint[], + limit: number = POPULAR_STORE_CAKE_IMAGE_LIMIT, ): Promise> { if (storeIds.length === 0) return new Map(); @@ -396,7 +397,7 @@ export class StoreRepository { images: { some: { deleted_at: null } }, }, orderBy: { id: 'desc' }, - take: POPULAR_STORE_CAKE_IMAGE_LIMIT, + take: limit, select: { images: { where: { deleted_at: null }, diff --git a/src/features/store/services/store-wishlist.service.spec.ts b/src/features/store/services/store-wishlist.service.spec.ts index 7cf59c0..c970dbc 100644 --- a/src/features/store/services/store-wishlist.service.spec.ts +++ b/src/features/store/services/store-wishlist.service.spec.ts @@ -13,6 +13,7 @@ import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, createOrderItem, + createProduct, createReview, createStore, createStoreWishlist, @@ -142,13 +143,14 @@ describe('StoreWishlistService (real DB)', () => { }); describe('myWishlistedStores', () => { - async function addImages(storeId: bigint, urls: string[]): Promise { - await prisma.storeImage.createMany({ - data: urls.map((url, index) => ({ - store_id: storeId, - image_url: url, - sort_order: index, - })), + /** 상품 1개 + 대표 이미지 1장 생성(카드 이미지 소스). */ + async function addProductWithImage( + storeId: bigint, + imageUrl: string, + ): Promise { + const product = await createProduct(prisma, { store_id: storeId }); + await prisma.productImage.create({ + data: { product_id: product.id, image_url: imageUrl, sort_order: 0 }, }); } @@ -186,13 +188,59 @@ describe('StoreWishlistService (real DB)', () => { expect(haz.addedAt).toBeInstanceOf(Date); }); - it('대표 이미지는 sort_order asc 최대 3장, 삭제된 이미지는 제외한다', async () => { + it('카드 이미지는 상품 대표 이미지 최대 3장(최신 상품 우선), 이미지 없는 매장은 빈 배열이다', async () => { const account = await createAccount(prisma, { account_type: 'USER' }); const store = await createStore(prisma); - await addImages(store.id, ['u0', 'u1', 'u2', 'u3']); - await prisma.storeImage.updateMany({ - where: { store_id: store.id, image_url: 'u1' }, - data: { deleted_at: new Date() }, + const empty = await createStore(prisma); + // 상품 4개 → 최신(id desc) 3개의 대표 이미지만 포함 + await addProductWithImage(store.id, 'u0'); + await addProductWithImage(store.id, 'u1'); + await addProductWithImage(store.id, 'u2'); + await addProductWithImage(store.id, 'u3'); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: empty.id, + }); + + const result = await service.myWishlistedStores(account.id); + + const withImages = result.items.find( + (i) => i.storeId === store.id.toString(), + ); + const withoutImages = result.items.find( + (i) => i.storeId === empty.id.toString(), + ); + expect(withImages?.imageUrls).toEqual(['u3', 'u2', 'u1']); + expect(withoutImages?.imageUrls).toEqual([]); + }); + + it('삭제된 이미지·비활성 상품은 카드 이미지에서 제외한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await addProductWithImage(store.id, 'kept'); + // 이미지가 soft-delete된 상품 → 이미지 보유 상품이 아니므로 제외 + const deletedImageProduct = await createProduct(prisma, { + store_id: store.id, + }); + await prisma.productImage.create({ + data: { + product_id: deletedImageProduct.id, + image_url: 'deleted', + sort_order: 0, + deleted_at: new Date(), + }, + }); + // 비활성 상품 → 제외 + const inactive = await createProduct(prisma, { + store_id: store.id, + is_active: false, + }); + await prisma.productImage.create({ + data: { product_id: inactive.id, image_url: 'inactive', sort_order: 0 }, }); await createStoreWishlist(prisma, { account_id: account.id, @@ -201,7 +249,7 @@ describe('StoreWishlistService (real DB)', () => { const result = await service.myWishlistedStores(account.id); - expect(result.items[0].imageUrls).toEqual(['u0', 'u2', 'u3']); + expect(result.items[0].imageUrls).toEqual(['kept']); }); it('평점은 소수 첫째 자리 반올림, 리뷰 없으면 0.0/0건이다', async () => { diff --git a/src/features/store/services/store-wishlist.service.ts b/src/features/store/services/store-wishlist.service.ts index d03e5b0..e079c36 100644 --- a/src/features/store/services/store-wishlist.service.ts +++ b/src/features/store/services/store-wishlist.service.ts @@ -6,7 +6,10 @@ import { import { parseId } from '@/common/utils/id-parser'; import { STORE_WISHLIST_ERRORS } from '@/features/store/constants/store-wishlist-error-messages'; -import { DEFAULT_WISHLISTED_STORES_LIMIT } from '@/features/store/constants/store-wishlist.constants'; +import { + DEFAULT_WISHLISTED_STORES_LIMIT, + WISHLISTED_STORE_IMAGE_LIMIT, +} from '@/features/store/constants/store-wishlist.constants'; import type { MyWishlistedStoresInput } from '@/features/store/dto/inputs/my-wishlisted-stores.input'; import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; @@ -60,10 +63,16 @@ export class StoreWishlistService { limit, }); - // 평점은 페이지 매장들만 단일 groupBy로 집계(N+1 회피). 랭킹과 동일 소스. - const reviewStats = await this.storeRepo.aggregateReviewStats( - items.map((row) => row.store.id), - ); + const storeIds = items.map((row) => row.store.id); + // 평점·이미지는 페이지 매장들만 집계(N+1 회피). + // 카드 이미지는 인기 매장 카드(PopularStore)와 동일하게 상품 대표 이미지를 쓴다(#216). + const [reviewStats, cakeImages] = await Promise.all([ + this.storeRepo.aggregateReviewStats(storeIds), + this.storeRepo.findStoreCakeImages( + storeIds, + WISHLISTED_STORE_IMAGE_LIMIT, + ), + ]); return { items: items.map((row) => { @@ -76,7 +85,7 @@ export class StoreWishlistService { ratingAverage: Math.round((stat?.average ?? 0) * 10) / 10, reviewCount: stat?.count ?? 0, regionLabel: buildRegionLabel(row.store), - imageUrls: row.store.store_images.map((image) => image.image_url), + imageUrls: cakeImages.get(row.store.id) ?? [], addedAt: row.created_at, }; }), diff --git a/src/features/store/store-wishlist.graphql b/src/features/store/store-wishlist.graphql index cf63411..4fe11b1 100644 --- a/src/features/store/store-wishlist.graphql +++ b/src/features/store/store-wishlist.graphql @@ -32,7 +32,7 @@ type WishlistedStoreSummary { reviewCount: Int! """매장 위치 표기(예: 인천 청라동).""" regionLabel: String - """매장 대표 이미지(최대 3장, sort_order asc).""" + """대표 케이크 이미지(활성 상품 대표 이미지, 최대 3장). 인기 매장 카드와 동일 소스.""" imageUrls: [String!]! """찜한 시각.""" addedAt: DateTime!