Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/features/product/constants/product-search.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
24 changes: 24 additions & 0 deletions src/features/product/dto/inputs/search-product-facets.input.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
34 changes: 34 additions & 0 deletions src/features/product/product-search.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
}
10 changes: 10 additions & 0 deletions src/features/product/repositories/product.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
return this.prisma.product.count({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
13 changes: 12 additions & 1 deletion src/features/product/resolvers/product-search-query.resolver.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<SearchProductFacets> {
return this.service.searchProductFacets(input);
}
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
31 changes: 30 additions & 1 deletion src/features/product/services/product-search-mappers.helper.ts
Original file line number Diff line number Diff line change
@@ -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';

/** 표시가(할인가 우선). 가격 필터·가격 정렬이 공유하는 단일 규칙. */
Expand Down Expand Up @@ -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<number>(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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make bucket bounds match inclusive search filters

When a client uses a bucket's returned maxPrice in SearchProductsInput, the histogram and result count disagree at every boundary: this code defines the bucket as [minPrice, maxPrice) and assigns a 10,000원 product to the 10,000–15,000 bucket, while buildProductSearchWhere applies the search maximum with lte, so the same product is also returned for the 5,000–10,000 selection. Return bounds compatible with the inclusive filter (or change the filter/API contract consistently) so bucket counts match the products shown.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

미반영: 버킷은 가격 슬라이더 배경의 분포 시각화용이지 클릭 필터 계약이 아님(figma 03 — 슬라이더가 임의 min/max를 보내고, 필터바 프리셋 경계는 FE 정의). half-open [min, max)는 히스토그램 표준 규약이고 SDL 주석에 명시됨. 경계 1건의 시각적 오차는 수용, 버킷을 필터 프리셋으로 쓰는 요구가 생기면 그때 계약을 맞춘다.

count,
}));
}
51 changes: 51 additions & 0 deletions src/features/product/services/product-search.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
49 changes: 47 additions & 2 deletions src/features/product/services/product-search.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,22 @@ 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,
type ProductSearchCandidateRow,
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,
Expand Down Expand Up @@ -88,12 +93,52 @@ export class ProductSearchService {
};
}

/**
* 가격대 시트 히스토그램. 가격 조건을 뺀 나머지 조건(키워드·카테고리·지역)으로 표시가를
* 모아 5,000원 버킷으로 센다. 상품 수 소규모 전제의 메모리 집계(자체 판단 — 규모가 커지면
* SQL FLOOR 그룹핑으로 전환). 'N개 상품보기' 카운트는 searchProducts.totalCount를 쓴다.
*/
async searchProductFacets(
input: SearchProductFacetsInput,
): Promise<SearchProductFacets> {
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<number> {
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 &&
Expand Down
13 changes: 13 additions & 0 deletions src/features/product/types/product-search-output.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading