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
19 changes: 19 additions & 0 deletions src/common/utils/search-keyword.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { BadRequestException } from '@nestjs/common';

import {
normalizeSearchKeyword,
parseSearchKeyword,
SEARCH_KEYWORD_MAX_LENGTH,
splitSearchWords,
} from '@/common/utils/search-keyword';
Expand Down Expand Up @@ -46,4 +49,20 @@ describe('search-keyword utils', () => {
expect(splitSearchWords('레터링')).toEqual(['레터링']);
});
});

describe('parseSearchKeyword', () => {
it('정규화된 검색어와 단어 목록을 함께 돌려준다', () => {
expect(parseSearchKeyword(' 딸기 케이크 ')).toEqual({
keyword: '딸기 케이크',
words: ['딸기', '케이크'],
});
});

it('빈 검색어·길이 초과는 400', () => {
expect(() => parseSearchKeyword(' ')).toThrow(BadRequestException);
expect(() => parseSearchKeyword('a'.repeat(201))).toThrow(
BadRequestException,
);
});
});
});
28 changes: 28 additions & 0 deletions src/common/utils/search-keyword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
* (SearchHistory/SearchEvent.keyword 컬럼 길이). 대소문자는 MySQL collation(ci)에 맡긴다.
*/

import { BadRequestException } from '@nestjs/common';

/** 검색어 검증 실패 메시지. 검색 기록·상품/매장 검색이 공유한다. */
export const SEARCH_KEYWORD_ERROR_MESSAGES = {
KEYWORD_EMPTY: '검색어를 입력해 주세요.',
KEYWORD_TOO_LONG: '검색어는 200자 이하여야 합니다.',
} as const;

/** 정규화된 검색어 최대 길이(keyword 컬럼 VarChar(200)). */
export const SEARCH_KEYWORD_MAX_LENGTH = 200;

Expand Down Expand Up @@ -35,3 +43,23 @@ export function normalizeSearchKeyword(
export function splitSearchWords(normalizedKeyword: string): string[] {
return [...new Set(normalizedKeyword.split(' ').filter((w) => w !== ''))];
}

export interface ParsedSearchKeyword {
/** 정규화된 검색어(기록·집계 키). */
keyword: string;
/** AND 매칭용 단어 목록. */
words: string[];
}

/** 경계(service)에서 쓰는 정규화 + 검증. 실패는 400(id-parser와 동일한 방어 방식). */
export function parseSearchKeyword(raw: string): ParsedSearchKeyword {
const result = normalizeSearchKeyword(raw);
if (!result.ok) {
throw new BadRequestException(
result.reason === 'EMPTY'
? SEARCH_KEYWORD_ERROR_MESSAGES.KEYWORD_EMPTY
: SEARCH_KEYWORD_ERROR_MESSAGES.KEYWORD_TOO_LONG,
);
}
return { keyword: result.keyword, words: splitSearchWords(result.keyword) };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const PRODUCT_SEARCH_ERROR_MESSAGES = {
INVALID_PRICE_RANGE: '최저가는 최고가보다 클 수 없습니다.',
} as const;
14 changes: 14 additions & 0 deletions src/features/product/constants/product-search.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const PRODUCT_SEARCH_SORTS = [
'POPULAR',
'LATEST',
'BEST_SELLING',
'PRICE_ASC',
'PRICE_DESC',
] as const;
export type ProductSearchSort = (typeof PRODUCT_SEARCH_SORTS)[number];

export const DEFAULT_PRODUCT_SEARCH_SORT: ProductSearchSort = 'POPULAR';

/** 검색 목록 기본/최대 페이지 크기(상품·매장 공통 정책). */
export const DEFAULT_SEARCH_PAGE_LIMIT = 20;
export const MAX_SEARCH_PAGE_LIMIT = 50;
64 changes: 64 additions & 0 deletions src/features/product/dto/inputs/search-products.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';

import {
MAX_SEARCH_PAGE_LIMIT,
PRODUCT_SEARCH_SORTS,
type ProductSearchSort,
} from '@/features/product/constants/product-search.constants';

export class SearchProductsInput {
@IsString()
keyword!: string;

@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
eventCategoryIds?: string[];

@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
styleCategoryIds?: string[];

@IsOptional()
@IsInt()
@Min(0)
minPrice?: number;

@IsOptional()
@IsInt()
@Min(0)
maxPrice?: number;

@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
regionIds?: string[];

@IsOptional()
@IsIn(PRODUCT_SEARCH_SORTS)
sort?: ProductSearchSort;

@IsOptional()
@IsInt()
@Min(0)
offset?: number;

@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_SEARCH_PAGE_LIMIT)
limit?: number;
}
5 changes: 5 additions & 0 deletions src/features/product/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ export { ProductBestSellerService } from '@/features/product/services/product-be
export { toHomeBanner } from '@/features/product/services/product-home-mappers.helper';
export type { RealtimeBestCakesResult } from '@/features/product/types/product-best-seller-output.type';
export type { HomeBanner } from '@/features/product/types/product-home-output.type';
// 검색 요약(search feature)의 상품 건수. 검색 조건은 product feature의 where 빌더가 단일 소스.
export {
ProductSearchService,
type ProductSearchScope,
} from '@/features/product/services/product-search.service';
65 changes: 65 additions & 0 deletions src/features/product/product-search.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
extend type Query {
"""키워드 상품 검색(검색 결과 상품 탭). 상품명·태그명에 단어별 AND 부분일치. 비로그인 접근 가능."""
searchProducts(input: SearchProductsInput!): SearchProductConnection!
}

"""상품 검색 정렬. 시안 '추천순' 라벨은 POPULAR(기본)로 해석한다."""
enum ProductSearchSort {
"""인기순(최근 주문·찜·베이지안 평점 — 인기 케이크와 동일 산식)."""
POPULAR
"""등록순(최신 등록 우선)."""
LATEST
"""판매순(최근 30일 유효 주문 수량 합)."""
BEST_SELLING
"""낮은 가격순(표시가 = salePrice ?? regularPrice)."""
PRICE_ASC
"""높은 가격순."""
PRICE_DESC
}

input SearchProductsInput {
"""검색어(정규화: trim·공백 축약, 빈값/200자 초과 400)."""
keyword: String!
"""상황별(EVENT) 카테고리 ID 다중 선택(그룹 내 OR). 비우면 '전체'."""
eventCategoryIds: [ID!]
"""스타일별(STYLE) 카테고리 ID 다중 선택(그룹 내 OR, 상황별과는 AND). 비우면 '전체'."""
styleCategoryIds: [ID!]
"""최저가(원, 표시가 기준). 비우면 하한 없음."""
minPrice: Int
"""최고가(원, 표시가 기준). 비우면 상한 없음('7만원 이상'). minPrice보다 작으면 400."""
maxPrice: Int
"""2차 시군구 ID 다중 선택. 비우면 전국 대상."""
regionIds: [ID!]
sort: ProductSearchSort = POPULAR
offset: Int = 0
"""조회 개수(최대 50)."""
limit: Int = 20
}

type SearchProductConnection {
items: [SearchProduct!]!
totalCount: Int!
hasMore: Boolean!
}

"""검색 결과 상품 카드. 가격 뒤 '~' 표기는 FE 장식(옵션 가격 가변 안내)."""
type SearchProduct {
id: ID!
"""소속 매장 ID(상세 URL 구성용: /store/{storeId}/products/{id})."""
storeId: ID!
name: String!
"""대표 이미지(sort_order 최소). 없으면 null."""
thumbnailUrl: String
storeName: String!
"""매장 위치 표기(예: 인천 청라동)."""
regionLabel: String
regularPrice: Int!
salePrice: Int
"""할인율(0~100). salePrice 없으면 0."""
discountRate: Int!
"""상품 평균 평점(0.0~5.0, 소수 첫째 자리). 리뷰 없으면 0.0."""
ratingAverage: Float!
reviewCount: Int!
"""로그인 사용자의 찜 여부(비로그인 시 false)."""
isWishlisted: Boolean!
}
8 changes: 6 additions & 2 deletions src/features/product/product.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { ProductCategoryQueryResolver } from '@/features/product/resolvers/produ
import { ProductDetailQueryResolver } from '@/features/product/resolvers/product-detail-query.resolver';
import { ProductHomeQueryResolver } from '@/features/product/resolvers/product-home-query.resolver';
import { ProductReviewQueryResolver } from '@/features/product/resolvers/product-review-query.resolver';
import { ProductSearchQueryResolver } from '@/features/product/resolvers/product-search-query.resolver';
import { ProductStorefrontQueryResolver } from '@/features/product/resolvers/product-storefront-query.resolver';
import { ProductBestSellerService } from '@/features/product/services/product-best-seller.service';
import { ProductCategoryService } from '@/features/product/services/product-category.service';
import { ProductDetailService } from '@/features/product/services/product-detail.service';
import { ProductHomeService } from '@/features/product/services/product-home.service';
import { ProductReviewService } from '@/features/product/services/product-review.service';
import { ProductSearchService } from '@/features/product/services/product-search.service';
import { ProductStorefrontService } from '@/features/product/services/product-storefront.service';

@Module({
Expand All @@ -29,8 +31,10 @@ import { ProductStorefrontService } from '@/features/product/services/product-st
ProductHomeService,
ProductHomeQueryResolver,
ProductBestSellerService,
ProductSearchService,
ProductSearchQueryResolver,
],
// ProductBestSellerService는 검색 진입 화면(search feature)의 실시간 판매 Best가 소비한다
exports: [ProductRepository, ProductBestSellerService],
// ProductBestSellerService·ProductSearchService는 검색 화면(search feature) 소비한다
exports: [ProductRepository, ProductBestSellerService, ProductSearchService],
})
export class ProductModule {}
Loading
Loading