diff --git a/src/features/product/constants/product-search.constants.ts b/src/features/product/constants/product-search.constants.ts index efb4af7..6e43f6a 100644 --- a/src/features/product/constants/product-search.constants.ts +++ b/src/features/product/constants/product-search.constants.ts @@ -12,3 +12,9 @@ export const DEFAULT_PRODUCT_SEARCH_SORT: ProductSearchSort = 'POPULAR'; /** 검색 목록 기본/최대 페이지 크기(상품·매장 공통 정책). */ export const DEFAULT_SEARCH_PAGE_LIMIT = 20; export const MAX_SEARCH_PAGE_LIMIT = 50; + +/** 가격 분포 버킷 폭(원). 시안 슬라이더 눈금(2만~7만 이상)을 5,000원 단위로 나눈다(자체 판단). */ +export const FACET_PRICE_BUCKET_SIZE = 5000; + +/** 가격 분포 상한(원). 이 값 이상은 마지막 '이상' 버킷으로 묶는다(시안 '7만원 이상'). */ +export const FACET_PRICE_BUCKET_MAX = 70000; diff --git a/src/features/product/dto/inputs/search-product-facets.input.ts b/src/features/product/dto/inputs/search-product-facets.input.ts new file mode 100644 index 0000000..972339d --- /dev/null +++ b/src/features/product/dto/inputs/search-product-facets.input.ts @@ -0,0 +1,24 @@ +import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class SearchProductFacetsInput { + @IsString() + keyword!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + eventCategoryIds?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + styleCategoryIds?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + regionIds?: string[]; +} diff --git a/src/features/product/product-search.graphql b/src/features/product/product-search.graphql index 040d7b7..d5b4b94 100644 --- a/src/features/product/product-search.graphql +++ b/src/features/product/product-search.graphql @@ -63,3 +63,37 @@ type SearchProduct { """로그인 사용자의 찜 여부(비로그인 시 false).""" isWishlisted: Boolean! } + +extend type Query { + """가격대 필터 시트의 가격 분포 히스토그램(키워드+카테고리+지역 조건, 가격 조건 제외). 비로그인 접근 가능.""" + searchProductFacets(input: SearchProductFacetsInput!): SearchProductFacets! +} + +input SearchProductFacetsInput { + """검색어(정규화: trim·공백 축약, 빈값/200자 초과 400).""" + keyword: String! + """상황별(EVENT) 카테고리 ID 다중 선택(그룹 내 OR).""" + eventCategoryIds: [ID!] + """스타일별(STYLE) 카테고리 ID 다중 선택(그룹 내 OR, 상황별과는 AND).""" + styleCategoryIds: [ID!] + """2차 시군구 ID 다중 선택. 비우면 전국 대상.""" + regionIds: [ID!] +} + +"""가격 분포. 버킷은 5,000원 고정 폭으로 0원부터 70,000원까지, 마지막은 '70,000원 이상'(maxPrice null).""" +type SearchProductFacets { + buckets: [SearchPriceBucket!]! + """조건에 맞는 상품의 최저 표시가. 결과 없으면 null.""" + minPrice: Int + """조건에 맞는 상품의 최고 표시가. 결과 없으면 null.""" + maxPrice: Int + """조건에 맞는 상품 수(가격 조건 제외).""" + totalCount: Int! +} + +"""가격 버킷 [minPrice, maxPrice). maxPrice null = 상한 없음.""" +type SearchPriceBucket { + minPrice: Int! + maxPrice: Int + count: Int! +} diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index d433c0b..161186c 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -1099,6 +1099,16 @@ export class ProductRepository { }); } + /** 상품 검색 조건에 맞는 상품의 가격만(히스토그램용). 후보 조건과 단일 소스. */ + async findProductSearchPrices( + filter: ProductSearchFilter, + ): Promise<{ regular_price: number; sale_price: number | null }[]> { + return this.prisma.product.findMany({ + where: buildProductSearchWhere(filter), + select: { regular_price: true, sale_price: true }, + }); + } + /** 상품 검색 결과 수(검색 요약 탭 카운트). 후보 조건과 단일 소스. */ async countProductSearch(filter: ProductSearchFilter): Promise { return this.prisma.product.count({ diff --git a/src/features/product/resolvers/product-search-query.resolver.spec.ts b/src/features/product/resolvers/product-search-query.resolver.spec.ts index 6742b10..1e3e2f7 100644 --- a/src/features/product/resolvers/product-search-query.resolver.spec.ts +++ b/src/features/product/resolvers/product-search-query.resolver.spec.ts @@ -67,4 +67,16 @@ describe('ProductSearchQueryResolver (real DB)', () => { expect(result.items[0].isWishlisted).toBe(true); }); + + it('searchProductFacets: 가격 분포를 반환한다', async () => { + await createProduct(prisma, { + name: '리졸버 케이크', + regular_price: 12000, + }); + + const result = await resolver.searchProductFacets({ keyword: '리졸버' }); + + expect(result.totalCount).toBe(1); + expect(result.buckets.find((b) => b.minPrice === 10000)?.count).toBe(1); + }); }); diff --git a/src/features/product/resolvers/product-search-query.resolver.ts b/src/features/product/resolvers/product-search-query.resolver.ts index 950b9f3..d1fa6b9 100644 --- a/src/features/product/resolvers/product-search-query.resolver.ts +++ b/src/features/product/resolvers/product-search-query.resolver.ts @@ -1,9 +1,13 @@ import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; +import { SearchProductFacetsInput } from '@/features/product/dto/inputs/search-product-facets.input'; import { SearchProductsInput } from '@/features/product/dto/inputs/search-products.input'; import { ProductSearchService } from '@/features/product/services/product-search.service'; -import type { SearchProductConnection } from '@/features/product/types/product-search-output.type'; +import type { + SearchProductConnection, + SearchProductFacets, +} from '@/features/product/types/product-search-output.type'; import { CurrentUser, OptionalJwtAuthGuard, @@ -28,4 +32,11 @@ export class ProductSearchQueryResolver { const accountId = user ? parseAccountId(user) : undefined; return this.service.searchProducts(input, accountId); } + + @Query('searchProductFacets') + searchProductFacets( + @Args('input') input: SearchProductFacetsInput, + ): Promise { + return this.service.searchProductFacets(input); + } } diff --git a/src/features/product/services/product-search-mappers.helper.spec.ts b/src/features/product/services/product-search-mappers.helper.spec.ts new file mode 100644 index 0000000..8252517 --- /dev/null +++ b/src/features/product/services/product-search-mappers.helper.spec.ts @@ -0,0 +1,57 @@ +import { + buildPriceBuckets, + displayPrice, +} from '@/features/product/services/product-search-mappers.helper'; + +describe('product-search mappers', () => { + describe('displayPrice', () => { + it('할인가가 있으면 할인가, 없으면 정가', () => { + expect(displayPrice({ regular_price: 40000, sale_price: 30000 })).toBe( + 30000, + ); + expect(displayPrice({ regular_price: 40000, sale_price: null })).toBe( + 40000, + ); + }); + }); + + describe('buildPriceBuckets', () => { + it('0~70,000을 5,000원 폭 14개 + "이상" 버킷 1개로 만들고 빈 구간도 0으로 채운다', () => { + const buckets = buildPriceBuckets([]); + + expect(buckets).toHaveLength(15); + expect(buckets[0]).toEqual({ minPrice: 0, maxPrice: 5000, count: 0 }); + expect(buckets[13]).toEqual({ + minPrice: 65000, + maxPrice: 70000, + count: 0, + }); + expect(buckets[14]).toEqual({ + minPrice: 70000, + maxPrice: null, + count: 0, + }); + }); + + it('경계값은 상위 버킷에 속하고(5,000 → [5,000, 10,000)), 상한 이상은 마지막 버킷', () => { + const buckets = buildPriceBuckets([4999, 5000, 69999, 70000, 120000]); + + expect(buckets[0].count).toBe(1); + expect(buckets[1].count).toBe(1); + expect(buckets[13].count).toBe(1); + expect(buckets[14].count).toBe(2); + }); + + it('폭·상한을 바꿔도 마지막 버킷 상한이 max로 잘린다', () => { + const buckets = buildPriceBuckets([7000], 3000, 7000); + + expect(buckets.map((b) => [b.minPrice, b.maxPrice])).toEqual([ + [0, 3000], + [3000, 6000], + [6000, 7000], + [7000, null], + ]); + expect(buckets[3].count).toBe(1); + }); + }); +}); diff --git a/src/features/product/services/product-search-mappers.helper.ts b/src/features/product/services/product-search-mappers.helper.ts index 8dc59f3..59e4fae 100644 --- a/src/features/product/services/product-search-mappers.helper.ts +++ b/src/features/product/services/product-search-mappers.helper.ts @@ -1,10 +1,17 @@ import { roundRatingAverage } from '@/common/utils/rating'; +import { + FACET_PRICE_BUCKET_MAX, + FACET_PRICE_BUCKET_SIZE, +} from '@/features/product/constants/product-search.constants'; import type { ProductReviewStat, ProductSearchCandidateRow, } from '@/features/product/repositories/product.repository'; import { calcDiscountRate } from '@/features/product/services/product-storefront-mappers.helper'; -import type { SearchProduct } from '@/features/product/types/product-search-output.type'; +import type { + SearchPriceBucket, + SearchProduct, +} from '@/features/product/types/product-search-output.type'; import { buildRegionLabel } from '@/features/store'; /** 표시가(할인가 우선). 가격 필터·가격 정렬이 공유하는 단일 규칙. */ @@ -35,3 +42,25 @@ export function toSearchProduct( isWishlisted, }; } + +/** + * 표시가 목록을 고정 폭 버킷 [min, min+size)으로 센다. 마지막 버킷은 [max, ∞)로 + * maxPrice null. 값이 없는 구간도 count 0으로 모두 반환해 FE가 막대 자리를 고정할 수 있게 한다. + */ +export function buildPriceBuckets( + prices: number[], + size: number = FACET_PRICE_BUCKET_SIZE, + max: number = FACET_PRICE_BUCKET_MAX, +): SearchPriceBucket[] { + const bucketCount = Math.ceil(max / size); + const counts = new Array(bucketCount + 1).fill(0); + for (const price of prices) { + const index = price >= max ? bucketCount : Math.floor(price / size); + counts[index] += 1; + } + return counts.map((count, i) => ({ + minPrice: i === bucketCount ? max : i * size, + maxPrice: i === bucketCount ? null : Math.min((i + 1) * size, max), + count, + })); +} diff --git a/src/features/product/services/product-search.service.spec.ts b/src/features/product/services/product-search.service.spec.ts index a73f3ec..19cf482 100644 --- a/src/features/product/services/product-search.service.spec.ts +++ b/src/features/product/services/product-search.service.spec.ts @@ -424,4 +424,55 @@ describe('ProductSearchService (real DB)', () => { expect(await service.countProducts({ words: ['케이크'] })).toBe(2); }); }); + + describe('searchProductFacets', () => { + it('가격 조건을 제외한 조건으로 표시가 분포·최저/최고가·건수를 낸다', async () => { + const store = await createStore(prisma); + await makeCake(store, '케이크 3만', { regular_price: 30000 }); + await makeCake(store, '케이크 할인 4만', { + regular_price: 60000, + sale_price: 40000, + }); + await makeCake(store, '케이크 4.5만', { regular_price: 45000 }); + await makeCake(store, '케이크 8만', { regular_price: 80000 }); + await makeCake(store, '타르트', { regular_price: 1000 }); + + const facets = await service.searchProductFacets({ keyword: '케이크' }); + + expect(facets.totalCount).toBe(4); + expect(facets.minPrice).toBe(30000); + expect(facets.maxPrice).toBe(80000); + const countAt = (min: number) => + facets.buckets.find((b) => b.minPrice === min)?.count; + expect(countAt(30000)).toBe(1); + expect(countAt(40000)).toBe(1); + expect(countAt(45000)).toBe(1); + expect(countAt(70000)).toBe(1); + expect(countAt(0)).toBe(0); + }); + + it('카테고리·지역 조건을 반영하고 결과가 없으면 min/max null', async () => { + const store = await createStore(prisma); + const birthday = await makeCake(store, '케이크 생일', { + regular_price: 20000, + }); + const birthdayId = await categorize(birthday, 'EVENT', '생일'); + await makeCake(store, '케이크 기타', { regular_price: 50000 }); + + const filtered = await service.searchProductFacets({ + keyword: '케이크', + eventCategoryIds: [birthdayId.toString()], + }); + expect(filtered.totalCount).toBe(1); + expect(filtered.minPrice).toBe(20000); + + const empty = await service.searchProductFacets({ keyword: '없음' }); + expect(empty).toMatchObject({ + totalCount: 0, + minPrice: null, + maxPrice: null, + }); + expect(empty.buckets).toHaveLength(15); + }); + }); }); diff --git a/src/features/product/services/product-search.service.ts b/src/features/product/services/product-search.service.ts index b0e5b36..1a0ec6b 100644 --- a/src/features/product/services/product-search.service.ts +++ b/src/features/product/services/product-search.service.ts @@ -12,6 +12,7 @@ import { MAX_SEARCH_PAGE_LIMIT, type ProductSearchSort, } from '@/features/product/constants/product-search.constants'; +import type { SearchProductFacetsInput } from '@/features/product/dto/inputs/search-product-facets.input'; import type { SearchProductsInput } from '@/features/product/dto/inputs/search-products.input'; import { ProductRepository, @@ -19,10 +20,14 @@ import { type ProductSearchFilter, } from '@/features/product/repositories/product.repository'; import { + buildPriceBuckets, displayPrice, toSearchProduct, } from '@/features/product/services/product-search-mappers.helper'; -import type { SearchProductConnection } from '@/features/product/types/product-search-output.type'; +import type { + SearchProductConnection, + SearchProductFacets, +} from '@/features/product/types/product-search-output.type'; import { DEFAULT_GLOBAL_RATING_PRIOR, RANKING_RECENT_ORDER_DAYS, @@ -88,12 +93,52 @@ export class ProductSearchService { }; } + /** + * 가격대 시트 히스토그램. 가격 조건을 뺀 나머지 조건(키워드·카테고리·지역)으로 표시가를 + * 모아 5,000원 버킷으로 센다. 상품 수 소규모 전제의 메모리 집계(자체 판단 — 규모가 커지면 + * SQL FLOOR 그룹핑으로 전환). 'N개 상품보기' 카운트는 searchProducts.totalCount를 쓴다. + */ + async searchProductFacets( + input: SearchProductFacetsInput, + ): Promise { + const filter = this.toFilter({ + ...input, + minPrice: undefined, + maxPrice: undefined, + }); + const rows = await this.repo.findProductSearchPrices(filter); + const prices = rows.map(displayPrice); + // spread(Math.min(...prices))는 대략 12만 개 이상에서 인자 개수 한도로 터진다 — 순회로 방어 + let minPrice: number | null = null; + let maxPrice: number | null = null; + for (const price of prices) { + if (minPrice === null || price < minPrice) minPrice = price; + if (maxPrice === null || price > maxPrice) maxPrice = price; + } + return { + buckets: buildPriceBuckets(prices), + minPrice, + maxPrice, + totalCount: prices.length, + }; + } + /** 검색 요약 탭의 상품 건수(필터·정렬 없이 키워드+지역). */ countProducts(scope: ProductSearchScope): Promise { return this.repo.countProductSearch(scope); } - private toFilter(input: SearchProductsInput): ProductSearchFilter { + private toFilter( + input: Pick< + SearchProductsInput, + | 'keyword' + | 'eventCategoryIds' + | 'styleCategoryIds' + | 'minPrice' + | 'maxPrice' + | 'regionIds' + >, + ): ProductSearchFilter { const { words } = parseSearchKeyword(input.keyword); if ( input.minPrice !== undefined && diff --git a/src/features/product/types/product-search-output.type.ts b/src/features/product/types/product-search-output.type.ts index 5b66d75..e7c2bc8 100644 --- a/src/features/product/types/product-search-output.type.ts +++ b/src/features/product/types/product-search-output.type.ts @@ -23,3 +23,16 @@ export interface SearchProductConnection { totalCount: number; hasMore: boolean; } + +export interface SearchPriceBucket { + minPrice: number; + maxPrice: number | null; + count: number; +} + +export interface SearchProductFacets { + buckets: SearchPriceBucket[]; + minPrice: number | null; + maxPrice: number | null; + totalCount: number; +}