From 9149f58bc3df7497150f7237df7570f9f685e565 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 02:27:15 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(user):=20=EC=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=B9=B4=EB=93=9C=20=ED=99=95=EC=9E=A5(=EB=A7=A4?= =?UTF-8?q?=EC=9E=A5=EB=B3=84=20=ED=95=84=ED=84=B0=C2=B7=ED=8F=89=EC=A0=90?= =?UTF-8?q?=C2=B7=ED=95=A0=EC=9D=B8=EC=9C=A8)=EA=B3=BC=20=EB=A7=A4?= =?UTF-8?q?=EC=9E=A5=EB=B3=84=20=EA=B7=B8=EB=A3=B9(myWishlistStoreGroups)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 찜 화면 시안(상품 찜 목록·매장별 보기) 기준. 상품 카드에 지역 뱃지·상품 평점(리뷰 수)·할인율을 표시하고, '매장별 보기'는 찜 상품을 매장 단위로 묶어 프로필·찜 상품 수를 보여준다. 스펙 문서가 없어 정책은 사용자 문답으로 확정했다. 정책 결정(시안 외, 사용자 확정): - 특정 매장의 찜 상품 화면은 별도 쿼리 대신 MyWishlistInput.storeId 옵션 필터로 처리. - 매장 그룹 정렬은 찜 상품 수 desc, 동점 시 최근 찜 순. - 상품 평점은 리뷰 없으면 0.0/0건, 소수 첫째 자리 반올림(매장 평점 표기와 동일). - 할인율은 서버 제공(discountRate 0~100) — PopularCake 등 기존 카드 컨벤션과 통일. - 가시성(비활성·삭제 상품/매장 제외)은 기존 visibleWishlistWhere를 그룹핑에도 공유해 상품 찜 목록 totalCount와 그룹 카운트 합이 일치한다. 변경점: - SDL: WishlistItemSummary에 storeId·discountRate·regionLabel·ratingAverage·reviewCount 추가, MyWishlistInput에 storeId 추가, myWishlistStoreGroups 쿼리·타입 신설 - UserRepository: visibleWishlistWhere에 storeId 필터, findWishlistItems select 확장, findVisibleWishlistItemsForGrouping 신설 (WishlistItem에 store_id가 없어 groupBy 불가 → 최소 필드 조회 후 service 그룹핑, 찜은 사용자당 소규모 전제) - UserWishlistService: 카드 매핑 확장(aggregateProductReviewStats·calcDiscountRate· buildRegionLabel 재사용), myWishlistStoreGroups 구현 - product 배럴에 calcDiscountRate 공개(기존 aggregateProductReviewStats는 그대로 재사용) 회귀 테스트 11건: - service 8: 카드 필드 매핑 / 평점 0건 / storeId 필터 / 그룹 집계·정렬 / 동점 최근 찜 순 / 가시성 일치(그룹 합 == 목록 totalCount) / 그룹 페이지네이션 / 빈 목록 - resolver 통합 1: myWishlistStoreGroups 전체 경로 - input spec 2: storeId 검증 + 상속 페이지네이션 Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- src/features/product/index.ts | 2 + .../inputs/my-wishlist-store-groups.input.ts | 3 + .../user/dto/inputs/my-wishlist.input.spec.ts | 33 +++ .../user/dto/inputs/my-wishlist.input.ts | 8 +- .../user/repositories/user.repository.ts | 63 +++++- .../resolvers/user-wishlist-query.resolver.ts | 17 +- .../resolvers/user-wishlist.resolver.spec.ts | 20 ++ .../services/user-wishlist.service.spec.ts | 192 ++++++++++++++++++ .../user/services/user-wishlist.service.ts | 111 +++++++++- .../user/types/user-wishlist-output.type.ts | 18 ++ src/features/user/user-wishlist.graphql | 34 ++++ 11 files changed, 485 insertions(+), 16 deletions(-) create mode 100644 src/features/user/dto/inputs/my-wishlist-store-groups.input.ts create mode 100644 src/features/user/dto/inputs/my-wishlist.input.spec.ts diff --git a/src/features/product/index.ts b/src/features/product/index.ts index 890de72..d606cfa 100644 --- a/src/features/product/index.ts +++ b/src/features/product/index.ts @@ -5,3 +5,5 @@ export { // 주문 생성(order feature)의 옵션 검증·가격 스냅샷 입력 타입 type ProductDetailRow, } from '@/features/product/repositories/product.repository'; +// 할인율 산식(0~100). 상품 카드 표기 규칙 — 찜 목록(user feature)이 동일 정책을 공유한다. +export { calcDiscountRate } from '@/features/product/services/product-storefront-mappers.helper'; diff --git a/src/features/user/dto/inputs/my-wishlist-store-groups.input.ts b/src/features/user/dto/inputs/my-wishlist-store-groups.input.ts new file mode 100644 index 0000000..52aec2e --- /dev/null +++ b/src/features/user/dto/inputs/my-wishlist-store-groups.input.ts @@ -0,0 +1,3 @@ +import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; + +export class MyWishlistStoreGroupsInput extends UserPaginationInput {} diff --git a/src/features/user/dto/inputs/my-wishlist.input.spec.ts b/src/features/user/dto/inputs/my-wishlist.input.spec.ts new file mode 100644 index 0000000..12ac0f8 --- /dev/null +++ b/src/features/user/dto/inputs/my-wishlist.input.spec.ts @@ -0,0 +1,33 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { MyWishlistInput } from '@/features/user/dto/inputs/my-wishlist.input'; + +function build(plain: object): MyWishlistInput { + return plainToInstance(MyWishlistInput, plain); +} + +describe('MyWishlistInput', () => { + it('빈 입력 통과 (모두 optional)', async () => { + expect(await validate(build({}))).toHaveLength(0); + }); + + it('storeId 문자열 + offset/limit 통과', async () => { + const errors = await validate( + build({ storeId: '1', offset: 0, limit: 20 }), + ); + expect(errors).toHaveLength(0); + }); + + it('storeId 가 문자열이 아니면 거절', async () => { + const errors = await validate(build({ storeId: 1 })); + expect(errors[0].property).toBe('storeId'); + }); + + it('offset 음수 거절 (UserPaginationInput 상속)', async () => { + const errors = await validate(build({ offset: -1 })); + expect(errors[0].property).toBe('offset'); + }); +}); diff --git a/src/features/user/dto/inputs/my-wishlist.input.ts b/src/features/user/dto/inputs/my-wishlist.input.ts index 7b91c36..033046e 100644 --- a/src/features/user/dto/inputs/my-wishlist.input.ts +++ b/src/features/user/dto/inputs/my-wishlist.input.ts @@ -1,3 +1,9 @@ +import { IsOptional, IsString } from 'class-validator'; + import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; -export class MyWishlistInput extends UserPaginationInput {} +export class MyWishlistInput extends UserPaginationInput { + @IsOptional() + @IsString() + storeId?: string; +} diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 7d79e51..3e1f560 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -52,13 +52,15 @@ export class UserRepository { * count 와 list 가 같은 가시성 기준을 공유하도록 하여 * 마이페이지 카운트 카드와 실제 목록 길이 불일치를 방지한다. */ - private visibleWishlistWhere(accountId: bigint) { + private visibleWishlistWhere(accountId: bigint, storeId?: bigint) { return { account_id: accountId, deleted_at: null, product: { deleted_at: null, is_active: true, + // 매장별 보기 → 매장 선택 화면의 매장 필터 + ...(storeId !== undefined ? { store_id: storeId } : {}), store: { deleted_at: null, is_active: true }, }, } as const; @@ -510,21 +512,28 @@ export class UserRepository { accountId: bigint; offset: number; limit: number; + storeId?: bigint; }): Promise<{ items: { product_id: bigint; created_at: Date; product: { + store_id: bigint; name: string; regular_price: number; sale_price: number | null; images: { image_url: string }[]; - store: { store_name: string }; + store: { + store_name: string; + address_city: string | null; + address_neighborhood: string | null; + region: { name: string } | null; + }; }; }[]; totalCount: number; }> { - const where = this.visibleWishlistWhere(args.accountId); + const where = this.visibleWishlistWhere(args.accountId, args.storeId); const [rows, totalCount] = await this.prisma.$transaction([ this.prisma.wishlistItem.findMany({ @@ -538,10 +547,18 @@ export class UserRepository { created_at: true, product: { select: { + store_id: true, name: true, regular_price: true, sale_price: true, - store: { select: { store_name: true } }, + store: { + select: { + store_name: true, + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + }, + }, images: { where: { deleted_at: null }, orderBy: { sort_order: 'asc' }, @@ -558,6 +575,44 @@ export class UserRepository { return { items: rows, totalCount }; } + /** + * 매장별 그룹핑용 가시 찜 목록 전체 조회. + * WishlistItem에는 store_id가 없어(product 경유) Prisma groupBy로 매장 단위 집계가 + * 불가능하다 → 최소 필드만 가져와 service에서 그룹핑한다(찜은 사용자당 소규모 전제). + * 가시성 조건은 findWishlistItems/wishlistCount와 동일(visibleWishlistWhere)해야 + * 상품 찜 목록 totalCount와 그룹 카운트 합이 일치한다. + */ + async findVisibleWishlistItemsForGrouping(accountId: bigint): Promise< + { + created_at: Date; + product: { + store: { + id: bigint; + store_name: string; + profile_image_url: string | null; + }; + }; + }[] + > { + return this.prisma.wishlistItem.findMany({ + where: this.visibleWishlistWhere(accountId), + select: { + created_at: true, + product: { + select: { + store: { + select: { + id: true, + store_name: true, + profile_image_url: true, + }, + }, + }, + }, + }, + }); + } + async countMyReviews(accountId: bigint): Promise { return this.prisma.review.count({ where: { account_id: accountId }, diff --git a/src/features/user/resolvers/user-wishlist-query.resolver.ts b/src/features/user/resolvers/user-wishlist-query.resolver.ts index 0385598..128f17f 100644 --- a/src/features/user/resolvers/user-wishlist-query.resolver.ts +++ b/src/features/user/resolvers/user-wishlist-query.resolver.ts @@ -1,9 +1,13 @@ import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; +import { MyWishlistStoreGroupsInput } from '@/features/user/dto/inputs/my-wishlist-store-groups.input'; import { MyWishlistInput } from '@/features/user/dto/inputs/my-wishlist.input'; import { UserWishlistService } from '@/features/user/services/user-wishlist.service'; -import type { MyWishlistConnection } from '@/features/user/types/user-wishlist-output.type'; +import type { + MyWishlistConnection, + MyWishlistStoreGroupsConnection, +} from '@/features/user/types/user-wishlist-output.type'; import { CurrentUser, JwtAuthGuard, @@ -23,4 +27,15 @@ export class UserWishlistQueryResolver { ): Promise { return this.wishlistService.myWishlist(parseAccountId(user), input); } + + @Query('myWishlistStoreGroups') + myWishlistStoreGroups( + @CurrentUser() user: JwtUser, + @Args('input') input?: MyWishlistStoreGroupsInput, + ): Promise { + return this.wishlistService.myWishlistStoreGroups( + parseAccountId(user), + input, + ); + } } diff --git a/src/features/user/resolvers/user-wishlist.resolver.spec.ts b/src/features/user/resolvers/user-wishlist.resolver.spec.ts index ae2a04b..01de650 100644 --- a/src/features/user/resolvers/user-wishlist.resolver.spec.ts +++ b/src/features/user/resolvers/user-wishlist.resolver.spec.ts @@ -85,4 +85,24 @@ describe('User Wishlist Resolver (real DB)', () => { expect(list2.totalCount).toBe(0); expect(list2.items).toEqual([]); }); + + it('myWishlistStoreGroups → 매장 그룹을 반환한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + const store = await createStore(prisma, { store_name: '해즈케이크' }); + const product = await createProduct(prisma, { store_id: store.id }); + await mutationResolver.addToWishlist( + { accountId: account.id.toString() }, + product.id.toString(), + ); + + const groups = await queryResolver.myWishlistStoreGroups({ + accountId: account.id.toString(), + }); + + expect(groups.totalCount).toBe(1); + expect(groups.items[0].storeId).toBe(store.id.toString()); + expect(groups.items[0].storeName).toBe('해즈케이크'); + expect(groups.items[0].wishlistedProductCount).toBe(1); + }); }); diff --git a/src/features/user/services/user-wishlist.service.spec.ts b/src/features/user/services/user-wishlist.service.spec.ts index b949127..5f94fa9 100644 --- a/src/features/user/services/user-wishlist.service.spec.ts +++ b/src/features/user/services/user-wishlist.service.spec.ts @@ -8,7 +8,9 @@ import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, + createOrderItem, createProduct, + createReview, createStore, createUserProfile, } from '@/test/factories'; @@ -293,5 +295,195 @@ describe('UserWishlistService (real DB)', () => { }); // offset/limit 범위 검증은 DTO (MyWishlistInput → UserPaginationInput) 로 이전됨. + + it('카드 필드(storeId/regionLabel/discountRate/평점)를 매핑한다', async () => { + const account = await setupUser(); + const store = await createStore(prisma, { + store_name: '해즈케이크', + address_city: '서울', + address_neighborhood: '대치동', + }); + const product = await createProduct(prisma, { + store_id: store.id, + regular_price: 40000, + sale_price: 26000, + }); + const oi1 = await createOrderItem(prisma, { product_id: product.id }); + const oi2 = await createOrderItem(prisma, { product_id: product.id }); + await createReview(prisma, { order_item_id: oi1.id, rating: 4.5 }); + await createReview(prisma, { order_item_id: oi2.id, rating: 5 }); + await service.addToWishlist(account.id, product.id.toString()); + + const result = await service.myWishlist(account.id); + + const item = result.items[0]; + expect(item.storeId).toBe(store.id.toString()); + expect(item.regionLabel).toBe('서울 대치동'); + // (40000 - 26000) / 40000 = 35% + expect(item.discountRate).toBe(35); + // (4.5 + 5.0) / 2 = 4.75 → 4.8 + expect(item.ratingAverage).toBe(4.8); + expect(item.reviewCount).toBe(2); + }); + + it('리뷰 없는 상품의 평점은 0.0/0건이다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { + store_id: store.id, + sale_price: null, + }); + await service.addToWishlist(account.id, product.id.toString()); + + const result = await service.myWishlist(account.id); + + expect(result.items[0].ratingAverage).toBe(0); + expect(result.items[0].reviewCount).toBe(0); + expect(result.items[0].discountRate).toBe(0); + }); + + it('storeId 필터로 해당 매장 찜 상품만 반환한다', async () => { + const account = await setupUser(); + const storeA = await createStore(prisma); + const storeB = await createStore(prisma); + const pA = await createProduct(prisma, { store_id: storeA.id }); + const pB = await createProduct(prisma, { store_id: storeB.id }); + await service.addToWishlist(account.id, pA.id.toString()); + await service.addToWishlist(account.id, pB.id.toString()); + + const result = await service.myWishlist(account.id, { + storeId: storeA.id.toString(), + }); + + expect(result.totalCount).toBe(1); + expect(result.items[0].productId).toBe(pA.id.toString()); + expect(result.items[0].storeId).toBe(storeA.id.toString()); + }); + }); + + // ─── myWishlistStoreGroups ─── + describe('myWishlistStoreGroups', () => { + it('매장별 찜 상품 수를 집계하고 찜 수 desc로 정렬한다', async () => { + const account = await setupUser(); + const storeA = await createStore(prisma, { + store_name: '해즈 케이크', + profile_image_url: 'https://cdn.example.com/haz.png', + }); + const storeB = await createStore(prisma, { store_name: '달콤 케이크' }); + // A에 2개, B에 1개 찜 — A는 먼저 찜해도 개수 우선으로 앞에 온다 + for (let i = 0; i < 2; i++) { + const p = await createProduct(prisma, { store_id: storeA.id }); + await service.addToWishlist(account.id, p.id.toString()); + } + const pB = await createProduct(prisma, { store_id: storeB.id }); + await service.addToWishlist(account.id, pB.id.toString()); + + const result = await service.myWishlistStoreGroups(account.id); + + expect(result.totalCount).toBe(2); + expect(result.items).toEqual([ + { + storeId: storeA.id.toString(), + storeName: '해즈 케이크', + profileImageUrl: 'https://cdn.example.com/haz.png', + wishlistedProductCount: 2, + }, + { + storeId: storeB.id.toString(), + storeName: '달콤 케이크', + profileImageUrl: null, + wishlistedProductCount: 1, + }, + ]); + }); + + it('찜 수 동점이면 최근 찜한 매장이 먼저 온다', async () => { + const account = await setupUser(); + const storeA = await createStore(prisma); + const storeB = await createStore(prisma); + const pA = await createProduct(prisma, { store_id: storeA.id }); + const pB = await createProduct(prisma, { store_id: storeB.id }); + await service.addToWishlist(account.id, pA.id.toString()); + await new Promise((r) => setTimeout(r, 10)); + await service.addToWishlist(account.id, pB.id.toString()); + + const result = await service.myWishlistStoreGroups(account.id); + + expect(result.items.map((i) => i.storeId)).toEqual([ + storeB.id.toString(), + storeA.id.toString(), + ]); + }); + + it('가시성은 myWishlist와 일치한다(비활성 상품·매장/soft-delete 찜 제외)', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const visible = await createProduct(prisma, { store_id: store.id }); + const inactivated = await createProduct(prisma, { store_id: store.id }); + const removed = await createProduct(prisma, { store_id: store.id }); + const inactiveStore = await createStore(prisma); + const orphan = await createProduct(prisma, { + store_id: inactiveStore.id, + }); + await service.addToWishlist(account.id, visible.id.toString()); + await service.addToWishlist(account.id, inactivated.id.toString()); + await service.addToWishlist(account.id, removed.id.toString()); + await service.addToWishlist(account.id, orphan.id.toString()); + await prisma.product.update({ + where: { id: inactivated.id }, + data: { is_active: false }, + }); + await service.removeFromWishlist(account.id, removed.id.toString()); + await prisma.store.update({ + where: { id: inactiveStore.id }, + data: { is_active: false }, + }); + + const groups = await service.myWishlistStoreGroups(account.id); + const list = await service.myWishlist(account.id); + + expect(groups.totalCount).toBe(1); + expect(groups.items[0].wishlistedProductCount).toBe(1); + // 그룹 카운트 합 == 상품 찜 목록 totalCount (화면 01·02 카운트 일관성) + const groupSum = groups.items.reduce( + (sum, g) => sum + g.wishlistedProductCount, + 0, + ); + expect(groupSum).toBe(list.totalCount); + }); + + it('offset/limit 페이지네이션과 hasMore를 계산한다', async () => { + const account = await setupUser(); + for (let i = 0; i < 3; i++) { + const store = await createStore(prisma); + const p = await createProduct(prisma, { store_id: store.id }); + await service.addToWishlist(account.id, p.id.toString()); + } + + const page1 = await service.myWishlistStoreGroups(account.id, { + offset: 0, + limit: 2, + }); + const page2 = await service.myWishlistStoreGroups(account.id, { + offset: 2, + limit: 2, + }); + + expect(page1.items).toHaveLength(2); + expect(page1.totalCount).toBe(3); + expect(page1.hasMore).toBe(true); + expect(page2.items).toHaveLength(1); + expect(page2.hasMore).toBe(false); + }); + + it('찜이 없으면 빈 목록을 반환한다', async () => { + const account = await setupUser(); + + const result = await service.myWishlistStoreGroups(account.id); + + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + expect(result.hasMore).toBe(false); + }); }); }); diff --git a/src/features/user/services/user-wishlist.service.ts b/src/features/user/services/user-wishlist.service.ts index 3a8a9e3..fd2c32a 100644 --- a/src/features/user/services/user-wishlist.service.ts +++ b/src/features/user/services/user-wishlist.service.ts @@ -1,13 +1,18 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { parseId } from '@/common/utils/id-parser'; -import { ProductRepository } from '@/features/product'; +import { calcDiscountRate, ProductRepository } from '@/features/product'; +import { buildRegionLabel } from '@/features/store'; import { USER_WISHLIST_ERRORS } from '@/features/user/constants/user-wishlist-error-messages'; import { DEFAULT_PAGINATION_LIMIT } from '@/features/user/constants/user.constants'; +import type { MyWishlistStoreGroupsInput } from '@/features/user/dto/inputs/my-wishlist-store-groups.input'; import type { MyWishlistInput } from '@/features/user/dto/inputs/my-wishlist.input'; import { UserRepository } from '@/features/user/repositories/user.repository'; import { UserBaseService } from '@/features/user/services/user-base.service'; -import type { MyWishlistConnection } from '@/features/user/types/user-wishlist-output.type'; +import type { + MyWishlistConnection, + MyWishlistStoreGroupsConnection, +} from '@/features/user/types/user-wishlist-output.type'; @Injectable() export class UserWishlistService extends UserBaseService { @@ -61,22 +66,108 @@ export class UserWishlistService extends UserBaseService { const offset = input?.offset ?? 0; const limit = input?.limit ?? DEFAULT_PAGINATION_LIMIT; + // "0"도 유효 후보로 취급해 truthy 체크가 아닌 null/undefined 체크로 거른다(parseId가 검증). + const storeId = input?.storeId != null ? parseId(input.storeId) : undefined; const { items, totalCount } = await this.repo.findWishlistItems({ accountId, offset, limit, + storeId, }); + // 상품 평점은 페이지 상품들만 단일 groupBy로 집계(N+1 회피) + const reviewStats = + await this.productRepository.aggregateProductReviewStats( + items.map((row) => row.product_id), + ); + + return { + items: items.map((row) => { + const stat = reviewStats.get(row.product_id); + return { + productId: row.product_id.toString(), + storeId: row.product.store_id.toString(), + productName: row.product.name, + representativeImageUrl: row.product.images[0]?.image_url ?? null, + salePrice: row.product.sale_price, + regularPrice: row.product.regular_price, + discountRate: calcDiscountRate( + row.product.regular_price, + row.product.sale_price, + ), + storeName: row.product.store.store_name, + regionLabel: buildRegionLabel(row.product.store), + // 소수 첫째 자리까지(예: 4.666 → 4.7). 매장 평점 표기와 동일 정책. + ratingAverage: Math.round((stat?.average ?? 0) * 10) / 10, + reviewCount: stat?.count ?? 0, + addedAt: row.created_at, + }; + }), + totalCount, + hasMore: offset + limit < totalCount, + }; + } + + /** + * 찜 상품의 매장별 그룹 목록 (찜 상품 수 desc → 최근 찜 desc → storeId desc). + * 가시성은 myWishlist와 동일 조건 → totalCount 합이 상품 찜 목록과 일치한다. + */ + async myWishlistStoreGroups( + accountId: bigint, + input?: MyWishlistStoreGroupsInput, + ): Promise { + await this.requireActiveUser(accountId); + + const offset = input?.offset ?? 0; + const limit = input?.limit ?? DEFAULT_PAGINATION_LIMIT; + + const rows = await this.repo.findVisibleWishlistItemsForGrouping(accountId); + + const groups = new Map< + bigint, + { + storeId: bigint; + storeName: string; + profileImageUrl: string | null; + count: number; + lastAddedAt: Date; + } + >(); + for (const row of rows) { + const store = row.product.store; + const existing = groups.get(store.id); + if (existing) { + existing.count += 1; + if (row.created_at > existing.lastAddedAt) { + existing.lastAddedAt = row.created_at; + } + } else { + groups.set(store.id, { + storeId: store.id, + storeName: store.store_name, + profileImageUrl: store.profile_image_url, + count: 1, + lastAddedAt: row.created_at, + }); + } + } + + const sorted = [...groups.values()].sort((a, b) => { + if (b.count !== a.count) return b.count - a.count; + const timeDiff = b.lastAddedAt.getTime() - a.lastAddedAt.getTime(); + if (timeDiff !== 0) return timeDiff; + // 같은 밀리초 찜까지 동률이면 storeId desc로 안정적 순서 보장 + return b.storeId > a.storeId ? 1 : -1; + }); + + const totalCount = sorted.length; return { - items: items.map((row) => ({ - productId: row.product_id.toString(), - productName: row.product.name, - representativeImageUrl: row.product.images[0]?.image_url ?? null, - salePrice: row.product.sale_price, - regularPrice: row.product.regular_price, - storeName: row.product.store.store_name, - addedAt: row.created_at, + items: sorted.slice(offset, offset + limit).map((group) => ({ + storeId: group.storeId.toString(), + storeName: group.storeName, + profileImageUrl: group.profileImageUrl, + wishlistedProductCount: group.count, })), totalCount, hasMore: offset + limit < totalCount, diff --git a/src/features/user/types/user-wishlist-output.type.ts b/src/features/user/types/user-wishlist-output.type.ts index 3801dad..55e92a8 100644 --- a/src/features/user/types/user-wishlist-output.type.ts +++ b/src/features/user/types/user-wishlist-output.type.ts @@ -1,10 +1,15 @@ export interface WishlistItemSummary { productId: string; + storeId: string; productName: string; representativeImageUrl: string | null; salePrice: number | null; regularPrice: number; + discountRate: number; storeName: string; + regionLabel: string | null; + ratingAverage: number; + reviewCount: number; addedAt: Date; } @@ -13,3 +18,16 @@ export interface MyWishlistConnection { totalCount: number; hasMore: boolean; } + +export interface WishlistStoreGroup { + storeId: string; + storeName: string; + profileImageUrl: string | null; + wishlistedProductCount: number; +} + +export interface MyWishlistStoreGroupsConnection { + items: WishlistStoreGroup[]; + totalCount: number; + hasMore: boolean; +} diff --git a/src/features/user/user-wishlist.graphql b/src/features/user/user-wishlist.graphql index 17f8650..ab1c601 100644 --- a/src/features/user/user-wishlist.graphql +++ b/src/features/user/user-wishlist.graphql @@ -1,6 +1,8 @@ extend type Query { """내 찜 목록""" myWishlist(input: MyWishlistInput): MyWishlistConnection! + """찜 상품의 매장별 그룹 목록 (찜 상품 수 desc, 동점 시 최근 찜 순). 로그인 필요.""" + myWishlistStoreGroups(input: MyWishlistStoreGroupsInput): MyWishlistStoreGroupsConnection! } extend type Mutation { @@ -13,6 +15,8 @@ extend type Mutation { input MyWishlistInput { offset: Int = 0 limit: Int = 20 + """특정 매장의 찜 상품만 조회(매장별 보기 → 매장 선택 화면).""" + storeId: ID } type MyWishlistConnection { @@ -23,10 +27,40 @@ type MyWishlistConnection { type WishlistItemSummary { productId: ID! + """소속 매장 ID(상세 이동용).""" + storeId: ID! productName: String! representativeImageUrl: String salePrice: Int regularPrice: Int! + """할인율(0~100). salePrice 없으면 0.""" + discountRate: Int! storeName: String! + """매장 위치 표기(예: 서울 대치동).""" + regionLabel: String + """상품 평균 평점(0.0~5.0, 소수 첫째 자리). 리뷰 없으면 0.0.""" + ratingAverage: Float! + reviewCount: Int! addedAt: DateTime! } + +input MyWishlistStoreGroupsInput { + offset: Int = 0 + limit: Int = 20 +} + +type MyWishlistStoreGroupsConnection { + items: [WishlistStoreGroup!]! + totalCount: Int! + hasMore: Boolean! +} + +"""매장별 보기 행(매장 + 찜 상품 수)""" +type WishlistStoreGroup { + storeId: ID! + storeName: String! + """매장 프로필(로고) 이미지. 미등록 시 null.""" + profileImageUrl: String + """이 매장에서 찜한 상품 수.""" + wishlistedProductCount: Int! +} From aaf5d9ae6f7bef9cd1c971779e023aa74daeaa3f Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 02:31:47 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(user):=20=EC=83=81=ED=92=88=20=EC=9E=AC?= =?UTF-8?q?=EC=B0=9C=20=EC=8B=9C=20created=5Fat=20=EA=B0=B1=EC=8B=A0=20(?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=20=EC=8B=9C=EC=97=90=EB=A7=8C,=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B6=94=EA=B0=80=EB=8A=94=20=EB=B3=B4=EC=A1=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #213 Codex 리뷰(매장 찜 동일 이슈 2건)를 상품 찜에도 반영. - soft-delete 복원 시 created_at이 최초 찜 시점으로 남아 '찜 최신순' 정렬과 addedAt 표기가 재찜을 반영하지 못하는 문제 수정. - 단, active 찜에 대한 중복 요청(더블 탭·재시도)은 created_at을 건드리지 않도록 복원(updateMany, deleted_at != null)과 신규 생성(create + P2002 멱등 처리)을 분리. 회귀 테스트 2건: 재찜 상품 최상단 정렬 / 중복 추가 시 created_at 불변. Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- .../user/repositories/user.repository.ts | 35 ++++++++++++---- .../services/user-wishlist.service.spec.ts | 42 ++++++++++++++++++- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 3e1f560..91d98a2 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -444,26 +444,43 @@ export class UserRepository { } /** - * 찜 추가 (멱등). 이미 있으면 그대로, soft-delete된 경우 deleted_at=null로 복원. + * 찜 추가 (멱등). 없으면 생성, soft-delete된 경우 복원. + * 복원(재찜) 시에만 created_at을 재찜 시점으로 갱신한다 — 목록 '찜 최신순' 정렬과 + * addedAt 표기가 재찜을 반영하되, 이미 active인 찜에 대한 중복 요청(더블 탭·재시도)은 + * created_at을 건드리지 않아 멱등 계약을 지킨다(매장 찜 upsertStoreWishlist와 동일 정책). */ async upsertWishlistItem(args: { accountId: bigint; productId: bigint; now: Date; }): Promise { - await this.prisma.wishlistItem.upsert({ + const restored = await this.prisma.wishlistItem.updateMany({ where: { - account_id_product_id: { - account_id: args.accountId, - product_id: args.productId, - }, - }, - create: { account_id: args.accountId, product_id: args.productId, + deleted_at: { not: null }, }, - update: { deleted_at: null, updated_at: args.now }, + data: { deleted_at: null, created_at: args.now, updated_at: args.now }, }); + if (restored.count > 0) return; + + try { + await this.prisma.wishlistItem.create({ + data: { + account_id: args.accountId, + product_id: args.productId, + }, + }); + } catch (error) { + // active 찜이 이미 존재(unique 충돌) — 멱등이므로 무시 + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return; + } + throw error; + } } /** diff --git a/src/features/user/services/user-wishlist.service.spec.ts b/src/features/user/services/user-wishlist.service.spec.ts index 5f94fa9..5cddb15 100644 --- a/src/features/user/services/user-wishlist.service.spec.ts +++ b/src/features/user/services/user-wishlist.service.spec.ts @@ -68,18 +68,37 @@ describe('UserWishlistService (real DB)', () => { expect(row?.deleted_at).toBeNull(); }); - it('이미 active 상태로 있으면 멱등 (true 반환, 추가 row 없음)', async () => { + it('이미 active 상태로 있으면 멱등 (1건 유지, created_at 불변)', async () => { const account = await setupUser(); const store = await createStore(prisma); const product = await createProduct(prisma, { store_id: store.id }); await service.addToWishlist(account.id, product.id.toString()); + const before = await prisma.wishlistItem.findUniqueOrThrow({ + where: { + account_id_product_id: { + account_id: account.id, + product_id: product.id, + }, + }, + }); + await new Promise((r) => setTimeout(r, 10)); await service.addToWishlist(account.id, product.id.toString()); const count = await prisma.wishlistItem.count({ where: { account_id: account.id, product_id: product.id }, }); expect(count).toBe(1); + const after = await prisma.wishlistItem.findUniqueOrThrow({ + where: { + account_id_product_id: { + account_id: account.id, + product_id: product.id, + }, + }, + }); + // 더블 탭·재시도가 찜 시각(목록 정렬 기준)을 밀지 않는다 + expect(after.created_at.getTime()).toBe(before.created_at.getTime()); }); it('soft-delete된 row가 있으면 deleted_at=null로 복원된다', async () => { @@ -296,6 +315,27 @@ describe('UserWishlistService (real DB)', () => { // offset/limit 범위 검증은 DTO (MyWishlistInput → UserPaginationInput) 로 이전됨. + it('재찜(복원)한 상품은 목록 최상단으로 온다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const first = await createProduct(prisma, { store_id: store.id }); + const second = await createProduct(prisma, { store_id: store.id }); + await service.addToWishlist(account.id, first.id.toString()); + await new Promise((r) => setTimeout(r, 10)); + await service.addToWishlist(account.id, second.id.toString()); + // first를 해제 후 재찜 → 재찜 시점 기준으로 second보다 앞서야 한다 + await service.removeFromWishlist(account.id, first.id.toString()); + await new Promise((r) => setTimeout(r, 10)); + await service.addToWishlist(account.id, first.id.toString()); + + const result = await service.myWishlist(account.id); + + expect(result.items.map((i) => i.productId)).toEqual([ + first.id.toString(), + second.id.toString(), + ]); + }); + it('카드 필드(storeId/regionLabel/discountRate/평점)를 매핑한다', async () => { const account = await setupUser(); const store = await createStore(prisma, {