diff --git a/src/common/utils/search-keyword.spec.ts b/src/common/utils/search-keyword.spec.ts index 67429f5..29cbafc 100644 --- a/src/common/utils/search-keyword.spec.ts +++ b/src/common/utils/search-keyword.spec.ts @@ -1,5 +1,8 @@ +import { BadRequestException } from '@nestjs/common'; + import { normalizeSearchKeyword, + parseSearchKeyword, SEARCH_KEYWORD_MAX_LENGTH, splitSearchWords, } from '@/common/utils/search-keyword'; @@ -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, + ); + }); + }); }); diff --git a/src/common/utils/search-keyword.ts b/src/common/utils/search-keyword.ts index c531d1a..d120669 100644 --- a/src/common/utils/search-keyword.ts +++ b/src/common/utils/search-keyword.ts @@ -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; @@ -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) }; +} diff --git a/src/features/product/constants/product-search-error-messages.ts b/src/features/product/constants/product-search-error-messages.ts new file mode 100644 index 0000000..3ad0de5 --- /dev/null +++ b/src/features/product/constants/product-search-error-messages.ts @@ -0,0 +1,3 @@ +export const PRODUCT_SEARCH_ERROR_MESSAGES = { + INVALID_PRICE_RANGE: '최저가는 최고가보다 클 수 없습니다.', +} as const; diff --git a/src/features/product/constants/product-search.constants.ts b/src/features/product/constants/product-search.constants.ts new file mode 100644 index 0000000..efb4af7 --- /dev/null +++ b/src/features/product/constants/product-search.constants.ts @@ -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; diff --git a/src/features/product/dto/inputs/search-products.input.ts b/src/features/product/dto/inputs/search-products.input.ts new file mode 100644 index 0000000..ded29d9 --- /dev/null +++ b/src/features/product/dto/inputs/search-products.input.ts @@ -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; +} diff --git a/src/features/product/index.ts b/src/features/product/index.ts index ec4f824..32b3f4f 100644 --- a/src/features/product/index.ts +++ b/src/features/product/index.ts @@ -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'; diff --git a/src/features/product/product-search.graphql b/src/features/product/product-search.graphql new file mode 100644 index 0000000..040d7b7 --- /dev/null +++ b/src/features/product/product-search.graphql @@ -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! +} diff --git a/src/features/product/product.module.ts b/src/features/product/product.module.ts index 51b838c..d12e0e5 100644 --- a/src/features/product/product.module.ts +++ b/src/features/product/product.module.ts @@ -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({ @@ -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 {} diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index bcbdad8..d433c0b 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -41,6 +41,25 @@ export interface CakeCandidateRow { }; } +/** 상품 검색 후보 row(정렬·필터는 service 메모리 파이프라인). */ +export interface ProductSearchCandidateRow extends CakeCandidateRow { + created_at: Date; +} + +/** 상품 검색 조건(정규화된 단어 목록 + 필터). */ +export interface ProductSearchFilter { + /** 상품명 또는 태그명에 모두 포함돼야 하는 단어(AND). */ + words: string[]; + /** 상황별(EVENT) 카테고리 — 그룹 내 OR. */ + eventCategoryIds?: bigint[]; + /** 스타일별(STYLE) 카테고리 — 그룹 내 OR, 상황별과 AND. */ + styleCategoryIds?: bigint[]; + /** 표시가(sale ?? regular) 하한/상한. */ + minPrice?: number; + maxPrice?: number; + regionIds?: bigint[]; +} + /** 홈 배너 row. */ export interface HomeBannerRow { id: bigint; @@ -1044,6 +1063,65 @@ export class ProductRepository { }); } + /** + * 상품 검색 후보 전량(활성 상품 + 활성 매장). 정렬 기준이 다양하고 인기/판매순이 + * 메모리 점수화라 후보를 모두 로드한 뒤 service가 정렬·페이지를 자른다 + * (인기 매장과 동일 트레이드오프 — 상품 수가 커지면 정렬별 DB 페이지네이션으로 분리). + */ + async findProductSearchCandidates( + filter: ProductSearchFilter, + ): Promise { + return this.prisma.product.findMany({ + where: buildProductSearchWhere(filter), + select: { + id: true, + store_id: true, + name: true, + regular_price: true, + sale_price: true, + created_at: true, + images: { + where: activeWhere, + orderBy: { sort_order: 'asc' }, + take: 1, + select: { image_url: true }, + }, + store: { + select: { + store_name: true, + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + }, + }, + }, + orderBy: { id: 'asc' }, + }); + } + + /** 상품 검색 결과 수(검색 요약 탭 카운트). 후보 조건과 단일 소스. */ + async countProductSearch(filter: ProductSearchFilter): Promise { + return this.prisma.product.count({ + where: buildProductSearchWhere(filter), + }); + } + + /** 주어진 productIds 중 사용자가 찜한 product_id 집합(string). 단일 IN 쿼리(N+1 회피). */ + async findWishlistedProductIds(args: { + accountId: bigint; + productIds: bigint[]; + }): Promise> { + if (args.productIds.length === 0) return new Set(); + const rows = await this.prisma.wishlistItem.findMany({ + where: { + account_id: args.accountId, + product_id: { in: args.productIds }, + }, + select: { product_id: true }, + }); + return new Set(rows.map((r) => r.product_id.toString())); + } + /** 상품별 활성 찜 수. */ async aggregateProductWishlistCounts( productIds: bigint[], @@ -1362,3 +1440,68 @@ export class ProductRepository { })); } } + +/** + * 상품 검색 where. 단어별 (상품명 ∨ 태그명) contains를 AND로 묶고, 카테고리 그룹은 + * 그룹 내 OR·그룹 간 AND, 가격은 표시가(sale ?? regular) 기준(정책 확정). + * 카테고리 id의 타입(EVENT/STYLE)은 검증하지 않는다 — FE가 categories(type)에서 받은 + * id만 넘긴다는 전제(자체 판단). nested relation은 soft-delete 자동 주입이 없어 명시한다. + */ +export function buildProductSearchWhere( + filter: ProductSearchFilter, +): Prisma.ProductWhereInput { + const conditions: Prisma.ProductWhereInput[] = filter.words.map((word) => ({ + OR: [ + { name: { contains: word } }, + { + product_tags: { + some: { + ...activeWhere, + tag: { name: { contains: word }, ...activeWhere }, + }, + }, + }, + ], + })); + + for (const categoryIds of [ + filter.eventCategoryIds, + filter.styleCategoryIds, + ]) { + if (categoryIds && categoryIds.length > 0) { + conditions.push({ + product_categories: { + some: { + category_id: { in: categoryIds }, + ...activeWhere, + category: visibleWhere, + }, + }, + }); + } + } + + if (filter.minPrice !== undefined || filter.maxPrice !== undefined) { + const range = { + ...(filter.minPrice !== undefined ? { gte: filter.minPrice } : {}), + ...(filter.maxPrice !== undefined ? { lte: filter.maxPrice } : {}), + }; + conditions.push({ + OR: [ + { sale_price: { not: null, ...range } }, + { sale_price: null, regular_price: range }, + ], + }); + } + + return { + is_active: true, + store: { + ...visibleWhere, + ...(filter.regionIds && filter.regionIds.length > 0 + ? { region_id: { in: filter.regionIds } } + : {}), + }, + AND: conditions, + }; +} diff --git a/src/features/product/resolvers/product-search-query.resolver.spec.ts b/src/features/product/resolvers/product-search-query.resolver.spec.ts new file mode 100644 index 0000000..6742b10 --- /dev/null +++ b/src/features/product/resolvers/product-search-query.resolver.spec.ts @@ -0,0 +1,70 @@ +import type { PrismaClient } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { ProductSearchQueryResolver } from '@/features/product/resolvers/product-search-query.resolver'; +import { ProductSearchService } from '@/features/product/services/product-search.service'; +import type { JwtUser } from '@/global/auth'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createAccount, createProduct } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/집계 세부 검증은 service.spec.ts에서 담당. + */ +describe('ProductSearchQueryResolver (real DB)', () => { + let resolver: ProductSearchQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + ProductSearchQueryResolver, + ProductSearchService, + ProductRepository, + ClockService, + ], + }); + resolver = module.get(ProductSearchQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('searchProducts: 비로그인은 isWishlisted=false로 위임한다', async () => { + await createProduct(prisma, { name: '리졸버 케이크' }); + + const result = await resolver.searchProducts( + { keyword: '리졸버' }, + undefined, + ); + + expect(result.totalCount).toBe(1); + expect(result.items[0]).toMatchObject({ + name: '리졸버 케이크', + isWishlisted: false, + }); + }); + + it('searchProducts: 로그인 사용자의 찜 여부를 채운다', async () => { + const product = await createProduct(prisma, { name: '리졸버 케이크' }); + const account = await createAccount(prisma, { account_type: 'USER' }); + await prisma.wishlistItem.create({ + data: { account_id: account.id, product_id: product.id }, + }); + const user = { accountId: account.id.toString() } as JwtUser; + + const result = await resolver.searchProducts({ keyword: '리졸버' }, user); + + expect(result.items[0].isWishlisted).toBe(true); + }); +}); diff --git a/src/features/product/resolvers/product-search-query.resolver.ts b/src/features/product/resolvers/product-search-query.resolver.ts new file mode 100644 index 0000000..950b9f3 --- /dev/null +++ b/src/features/product/resolvers/product-search-query.resolver.ts @@ -0,0 +1,31 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +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 { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** + * 키워드 상품 검색 resolver. 비로그인도 접근 가능한 public query. + * 옵셔널 인증으로 로그인 시에만 isWishlisted를 채운다. + */ +@Resolver('Query') +export class ProductSearchQueryResolver { + constructor(private readonly service: ProductSearchService) {} + + @Query('searchProducts') + @UseGuards(OptionalJwtAuthGuard) + searchProducts( + @Args('input') input: SearchProductsInput, + @CurrentUser() user: JwtUser | undefined, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.service.searchProducts(input, accountId); + } +} diff --git a/src/features/product/services/product-search-mappers.helper.ts b/src/features/product/services/product-search-mappers.helper.ts new file mode 100644 index 0000000..8dc59f3 --- /dev/null +++ b/src/features/product/services/product-search-mappers.helper.ts @@ -0,0 +1,37 @@ +import { roundRatingAverage } from '@/common/utils/rating'; +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 { buildRegionLabel } from '@/features/store'; + +/** 표시가(할인가 우선). 가격 필터·가격 정렬이 공유하는 단일 규칙. */ +export function displayPrice(row: { + regular_price: number; + sale_price: number | null; +}): number { + return row.sale_price ?? row.regular_price; +} + +export function toSearchProduct( + row: ProductSearchCandidateRow, + stat: ProductReviewStat | undefined, + isWishlisted: boolean, +): SearchProduct { + return { + id: row.id.toString(), + storeId: row.store_id.toString(), + name: row.name, + thumbnailUrl: row.images[0]?.image_url ?? null, + storeName: row.store.store_name, + regionLabel: buildRegionLabel(row.store), + regularPrice: row.regular_price, + salePrice: row.sale_price, + discountRate: calcDiscountRate(row.regular_price, row.sale_price), + ratingAverage: roundRatingAverage(stat?.average ?? 0), + reviewCount: stat?.count ?? 0, + isWishlisted, + }; +} diff --git a/src/features/product/services/product-search.service.spec.ts b/src/features/product/services/product-search.service.spec.ts new file mode 100644 index 0000000..a73f3ec --- /dev/null +++ b/src/features/product/services/product-search.service.spec.ts @@ -0,0 +1,427 @@ +import { BadRequestException } from '@nestjs/common'; +import type { PrismaClient, Product, Store } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { ProductSearchService } from '@/features/product/services/product-search.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createCategory, + createOrder, + createOrderItem, + createProduct, + createRegion, + createReview, + createStore, + createTag, + createUserProfile, + linkProductCategory, + linkProductTag, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('ProductSearchService (real DB)', () => { + let service: ProductSearchService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ProductSearchService, ProductRepository, ClockService], + }); + service = module.get(ProductSearchService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function makeCake( + store: Store, + name: string, + overrides: Parameters[1] = {}, + ): Promise { + return createProduct(prisma, { store_id: store.id, name, ...overrides }); + } + + async function tagProduct(product: Product, tagName: string): Promise { + const tag = await createTag(prisma, { name: tagName }); + await linkProductTag(prisma, { productId: product.id, tagId: tag.id }); + } + + async function categorize( + product: Product, + type: 'EVENT' | 'STYLE', + name: string, + ): Promise { + const category = await createCategory(prisma, { + category_type: type, + name, + }); + await linkProductCategory(prisma, { + productId: product.id, + categoryId: category.id, + }); + return category.id; + } + + async function confirmOrders( + product: Product, + count: number, + quantity = 1, + ): Promise { + for (let i = 0; i < count; i += 1) { + const order = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + quantity, + }); + } + } + + async function names(input: Parameters[0]) { + const result = await service.searchProducts(input); + return result.items.map((i) => i.name); + } + + describe('searchProducts — 매칭', () => { + it('공백 분리 단어가 상품명·태그에 모두 포함돼야 매칭된다(AND, 순서 무관)', async () => { + const store = await createStore(prisma); + await makeCake(store, '딸기 생크림 케이크'); + const tagged = await makeCake(store, '생크림 케이크'); + await tagProduct(tagged, '딸기'); + await makeCake(store, '딸기 타르트'); + await makeCake(store, '초코 케이크'); + + expect(await names({ keyword: '딸기 케이크' })).toEqual( + expect.arrayContaining(['딸기 생크림 케이크', '생크림 케이크']), + ); + expect(await names({ keyword: '케이크 딸기' })).toHaveLength(2); + }); + + it('삭제된 태그·삭제된 태그 연결은 매칭에 쓰지 않는다', async () => { + const store = await createStore(prisma); + const deletedTag = await makeCake(store, '케이크 A'); + const tag = await createTag(prisma, { + name: '레터링', + deleted_at: new Date(), + }); + await linkProductTag(prisma, { productId: deletedTag.id, tagId: tag.id }); + const deletedLink = await makeCake(store, '케이크 B'); + const liveTag = await createTag(prisma, { name: '레터링2' }); + await linkProductTag(prisma, { + productId: deletedLink.id, + tagId: liveTag.id, + deleted_at: new Date(), + }); + + expect(await names({ keyword: '레터링' })).toEqual([]); + }); + + it('비활성 상품·비활성/삭제 매장 상품은 제외한다', async () => { + const store = await createStore(prisma); + const closed = await createStore(prisma, { is_active: false }); + const deleted = await createStore(prisma); + await prisma.store.update({ + where: { id: deleted.id }, + data: { deleted_at: new Date() }, + }); + await makeCake(store, '케이크 활성'); + await makeCake(store, '케이크 비활성', { is_active: false }); + await makeCake(closed, '케이크 휴업'); + await makeCake(deleted, '케이크 삭제매장'); + + expect(await names({ keyword: '케이크' })).toEqual(['케이크 활성']); + }); + + it('빈 검색어·길이 초과는 400', async () => { + await expect(service.searchProducts({ keyword: ' ' })).rejects.toThrow( + BadRequestException, + ); + await expect( + service.searchProducts({ keyword: 'a'.repeat(201) }), + ).rejects.toThrow(BadRequestException); + }); + + it('결과가 없으면 빈 커넥션', async () => { + expect(await service.searchProducts({ keyword: '없음' })).toEqual({ + items: [], + totalCount: 0, + hasMore: false, + }); + }); + }); + + describe('searchProducts — 필터', () => { + it('상황별은 그룹 내 OR, 스타일별과는 AND로 결합한다', async () => { + const store = await createStore(prisma); + const birthdayFlower = await makeCake(store, '케이크 생일 꽃'); + const birthdayId = await categorize(birthdayFlower, 'EVENT', '생일'); + const flowerId = await categorize(birthdayFlower, 'STYLE', '꽃장식'); + const loverFlower = await makeCake(store, '케이크 연인 꽃'); + const loverId = await categorize(loverFlower, 'EVENT', '연인'); + await linkProductCategory(prisma, { + productId: loverFlower.id, + categoryId: flowerId, + }); + const birthdayPlain = await makeCake(store, '케이크 생일 기본'); + await linkProductCategory(prisma, { + productId: birthdayPlain.id, + categoryId: birthdayId, + }); + + expect( + await names({ + keyword: '케이크', + eventCategoryIds: [birthdayId.toString(), loverId.toString()], + }), + ).toHaveLength(3); + expect( + await names({ + keyword: '케이크', + eventCategoryIds: [birthdayId.toString()], + styleCategoryIds: [flowerId.toString()], + }), + ).toEqual(['케이크 생일 꽃']); + }); + + it('삭제된 카테고리 연결·비활성 카테고리는 필터에 걸리지 않는다', async () => { + const store = await createStore(prisma); + const product = await makeCake(store, '케이크'); + const inactive = await createCategory(prisma, { is_active: false }); + await linkProductCategory(prisma, { + productId: product.id, + categoryId: inactive.id, + }); + const live = await createCategory(prisma); + await prisma.productCategory.create({ + data: { + product_id: product.id, + category_id: live.id, + deleted_at: new Date(), + }, + }); + + expect( + await names({ + keyword: '케이크', + eventCategoryIds: [inactive.id.toString(), live.id.toString()], + }), + ).toEqual([]); + }); + + it('가격 필터는 표시가(sale ?? regular) 기준이며 min/max 단독 지정을 허용한다', 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, '케이크 7만', { regular_price: 70000 }); + + expect( + await names({ keyword: '케이크', minPrice: 40000, maxPrice: 70000 }), + ).toEqual(expect.arrayContaining(['케이크 할인 4만', '케이크 7만'])); + expect(await names({ keyword: '케이크', maxPrice: 30000 })).toEqual([ + '케이크 3만', + ]); + expect(await names({ keyword: '케이크', minPrice: 70000 })).toEqual([ + '케이크 7만', + ]); + }); + + it('minPrice > maxPrice는 400', async () => { + await expect( + service.searchProducts({ + keyword: '케이크', + minPrice: 50000, + maxPrice: 10000, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('regionIds 지정 시 해당 지역 매장 상품만', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-s' }); + const inRegion = await createStore(prisma, { region_id: region.id }); + const outRegion = await createStore(prisma); + await makeCake(inRegion, '케이크 지역'); + await makeCake(outRegion, '케이크 타지역'); + + expect( + await names({ keyword: '케이크', regionIds: [region.id.toString()] }), + ).toEqual(['케이크 지역']); + }); + }); + + describe('searchProducts — 정렬', () => { + it('기본(POPULAR)은 인기 점수순', async () => { + const store = await createStore(prisma); + const hot = await makeCake(store, '케이크 인기'); + await makeCake(store, '케이크 보통'); + await confirmOrders(hot, 3); + + expect(await names({ keyword: '케이크' })).toEqual([ + '케이크 인기', + '케이크 보통', + ]); + }); + + it('LATEST는 등록 최신순', async () => { + const store = await createStore(prisma); + const old = await makeCake(store, '케이크 옛날'); + await prisma.product.update({ + where: { id: old.id }, + data: { created_at: new Date('2025-01-01T00:00:00.000Z') }, + }); + await makeCake(store, '케이크 최신'); + + expect(await names({ keyword: '케이크', sort: 'LATEST' })).toEqual([ + '케이크 최신', + '케이크 옛날', + ]); + }); + + it('BEST_SELLING은 최근 30일 판매 수량 합순(주문 건수가 아니라 수량)', async () => { + const store = await createStore(prisma); + const manyOrders = await makeCake(store, '케이크 주문 3건'); + const bigOrder = await makeCake(store, '케이크 주문 1건 수량 10'); + await makeCake(store, '케이크 미판매'); + await confirmOrders(manyOrders, 3); + await confirmOrders(bigOrder, 1, 10); + + expect(await names({ keyword: '케이크', sort: 'BEST_SELLING' })).toEqual([ + '케이크 주문 1건 수량 10', + '케이크 주문 3건', + '케이크 미판매', + ]); + }); + + it('PRICE_ASC/PRICE_DESC는 표시가 기준', async () => { + const store = await createStore(prisma); + await makeCake(store, '케이크 5만', { regular_price: 50000 }); + await makeCake(store, '케이크 할인 2만', { + regular_price: 80000, + sale_price: 20000, + }); + await makeCake(store, '케이크 3만', { regular_price: 30000 }); + + expect(await names({ keyword: '케이크', sort: 'PRICE_ASC' })).toEqual([ + '케이크 할인 2만', + '케이크 3만', + '케이크 5만', + ]); + expect(await names({ keyword: '케이크', sort: 'PRICE_DESC' })).toEqual([ + '케이크 5만', + '케이크 3만', + '케이크 할인 2만', + ]); + }); + + it('동률은 id desc로 안정 정렬한다', async () => { + const store = await createStore(prisma); + await makeCake(store, '케이크 먼저', { regular_price: 10000 }); + await makeCake(store, '케이크 나중', { regular_price: 10000 }); + + expect(await names({ keyword: '케이크', sort: 'PRICE_ASC' })).toEqual([ + '케이크 나중', + '케이크 먼저', + ]); + }); + }); + + describe('searchProducts — 페이지·카드', () => { + it('offset/limit으로 자르고 totalCount·hasMore를 계산한다', async () => { + const store = await createStore(prisma); + for (let i = 0; i < 5; i += 1) await makeCake(store, `케이크 ${i}`); + + const first = await service.searchProducts({ + keyword: '케이크', + sort: 'LATEST', + offset: 0, + limit: 2, + }); + const last = await service.searchProducts({ + keyword: '케이크', + sort: 'LATEST', + offset: 4, + limit: 2, + }); + + expect(first.totalCount).toBe(5); + expect(first.items).toHaveLength(2); + expect(first.hasMore).toBe(true); + expect(last.items).toHaveLength(1); + expect(last.hasMore).toBe(false); + }); + + it('카드에 매장·지역·가격·할인율·평점·리뷰수·찜 여부를 채운다', async () => { + const store = await createStore(prisma, { + store_name: '청라 케이크', + address_city: '인천', + address_neighborhood: '청라동', + }); + const cake = await makeCake(store, '케이크', { + regular_price: 40000, + sale_price: 30000, + }); + await prisma.productImage.create({ + data: { product_id: cake.id, image_url: 'https://img/1.png' }, + }); + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + const order = await createOrder(prisma, { + account_id: account.id, + status: 'PICKED_UP', + }); + const item = await createOrderItem(prisma, { + order_id: order.id, + product_id: cake.id, + }); + await createReview(prisma, { order_item_id: item.id, rating: 4.5 }); + await prisma.wishlistItem.create({ + data: { account_id: account.id, product_id: cake.id }, + }); + + const asUser = await service.searchProducts( + { keyword: '케이크' }, + account.id, + ); + const asGuest = await service.searchProducts({ keyword: '케이크' }); + + expect(asUser.items[0]).toEqual({ + id: cake.id.toString(), + storeId: store.id.toString(), + name: '케이크', + thumbnailUrl: 'https://img/1.png', + storeName: '청라 케이크', + regionLabel: '인천 청라동', + regularPrice: 40000, + salePrice: 30000, + discountRate: 25, + ratingAverage: 4.5, + reviewCount: 1, + isWishlisted: true, + }); + expect(asGuest.items[0].isWishlisted).toBe(false); + }); + }); + + describe('countProducts', () => { + it('목록과 동일 조건(키워드+지역)으로 센다', async () => { + const store = await createStore(prisma); + await makeCake(store, '케이크 1'); + await makeCake(store, '케이크 2'); + await makeCake(store, '타르트'); + + expect(await service.countProducts({ words: ['케이크'] })).toBe(2); + }); + }); +}); diff --git a/src/features/product/services/product-search.service.ts b/src/features/product/services/product-search.service.ts new file mode 100644 index 0000000..b0e5b36 --- /dev/null +++ b/src/features/product/services/product-search.service.ts @@ -0,0 +1,189 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { ClockService } from '@/common/providers/clock.service'; +import { parseId } from '@/common/utils/id-parser'; +import { DAY_MS } from '@/common/utils/kst-time'; +import { hasMoreByOffset } from '@/common/utils/pagination'; +import { parseSearchKeyword } from '@/common/utils/search-keyword'; +import { PRODUCT_SEARCH_ERROR_MESSAGES } from '@/features/product/constants/product-search-error-messages'; +import { + DEFAULT_PRODUCT_SEARCH_SORT, + DEFAULT_SEARCH_PAGE_LIMIT, + MAX_SEARCH_PAGE_LIMIT, + type ProductSearchSort, +} from '@/features/product/constants/product-search.constants'; +import type { SearchProductsInput } from '@/features/product/dto/inputs/search-products.input'; +import { + ProductRepository, + type ProductSearchCandidateRow, + type ProductSearchFilter, +} from '@/features/product/repositories/product.repository'; +import { + displayPrice, + toSearchProduct, +} from '@/features/product/services/product-search-mappers.helper'; +import type { SearchProductConnection } from '@/features/product/types/product-search-output.type'; +import { + DEFAULT_GLOBAL_RATING_PRIOR, + RANKING_RECENT_ORDER_DAYS, + scoreAndSortByPopularity, +} from '@/features/store'; + +/** 검색 요약(searchSummary)이 넘기는 공통 조건 — 정렬·가격·카테고리 없이 키워드+지역만. */ +export interface ProductSearchScope { + words: string[]; + regionIds?: bigint[]; +} + +@Injectable() +export class ProductSearchService { + constructor( + private readonly repo: ProductRepository, + private readonly clock: ClockService, + ) {} + + /** + * 키워드 상품 검색. 후보 전량을 로드해 정렬 후 offset 페이지를 자르고, 페이지 상품만 + * 평점·찜 여부를 채운다. 인기/판매순은 메모리 점수화라 DB 페이지네이션이 불가해 + * 정렬 5종을 같은 파이프라인으로 통일했다(자체 판단 — 인기 매장과 동일 트레이드오프). + */ + async searchProducts( + input: SearchProductsInput, + accountId?: bigint, + ): Promise { + const filter = this.toFilter(input); + const offset = input.offset ?? 0; + const limit = Math.min( + input.limit ?? DEFAULT_SEARCH_PAGE_LIMIT, + MAX_SEARCH_PAGE_LIMIT, + ); + const sort = input.sort ?? DEFAULT_PRODUCT_SEARCH_SORT; + + const candidates = await this.repo.findProductSearchCandidates(filter); + const totalCount = candidates.length; + if (totalCount === 0) return { items: [], totalCount: 0, hasMore: false }; + + const sorted = await this.sortCandidates(candidates, sort); + const page = sorted.slice(offset, offset + limit); + const pageIds = page.map((row) => row.id); + + const [reviewStats, wishlistedIds] = await Promise.all([ + this.repo.aggregateProductReviewStats(pageIds), + // 0n도 유효한 계정 id — undefined로만 비로그인을 분기한다 + accountId !== undefined + ? this.repo.findWishlistedProductIds({ accountId, productIds: pageIds }) + : Promise.resolve(new Set()), + ]); + + return { + items: page.map((row) => + toSearchProduct( + row, + reviewStats.get(row.id), + wishlistedIds.has(row.id.toString()), + ), + ), + totalCount, + hasMore: hasMoreByOffset(offset, limit, totalCount), + }; + } + + /** 검색 요약 탭의 상품 건수(필터·정렬 없이 키워드+지역). */ + countProducts(scope: ProductSearchScope): Promise { + return this.repo.countProductSearch(scope); + } + + private toFilter(input: SearchProductsInput): ProductSearchFilter { + const { words } = parseSearchKeyword(input.keyword); + if ( + input.minPrice !== undefined && + input.maxPrice !== undefined && + input.minPrice > input.maxPrice + ) { + throw new BadRequestException( + PRODUCT_SEARCH_ERROR_MESSAGES.INVALID_PRICE_RANGE, + ); + } + const ids = (raw?: string[]): bigint[] | undefined => + raw && raw.length > 0 ? raw.map((id) => parseId(id)) : undefined; + return { + words, + eventCategoryIds: ids(input.eventCategoryIds), + styleCategoryIds: ids(input.styleCategoryIds), + // GraphQL nullable 필드는 명시적 null도 오므로 null/undefined 모두 '미지정' + minPrice: input.minPrice ?? undefined, + maxPrice: input.maxPrice ?? undefined, + regionIds: ids(input.regionIds), + }; + } + + private async sortCandidates( + candidates: ProductSearchCandidateRow[], + sort: ProductSearchSort, + ): Promise { + switch (sort) { + case 'POPULAR': + return this.sortByPopularity(candidates); + case 'BEST_SELLING': + return this.sortByRecentSales(candidates); + case 'LATEST': + return [...candidates].sort( + (a, b) => + b.created_at.getTime() - a.created_at.getTime() || + compareIdDesc(a, b), + ); + case 'PRICE_ASC': + return [...candidates].sort( + (a, b) => displayPrice(a) - displayPrice(b) || compareIdDesc(a, b), + ); + case 'PRICE_DESC': + return [...candidates].sort( + (a, b) => displayPrice(b) - displayPrice(a) || compareIdDesc(a, b), + ); + } + } + + /** 인기 케이크·인기 매장과 동일 산식(최근 주문·찜·베이지안 평점). */ + private async sortByPopularity( + candidates: ProductSearchCandidateRow[], + ): Promise { + const ids = candidates.map((c) => c.id); + const since = new Date( + this.clock.now().getTime() - RANKING_RECENT_ORDER_DAYS * DAY_MS, + ); + const [wishlistCounts, reviewStats, recentOrderCounts, globalAverage] = + await Promise.all([ + this.repo.aggregateProductWishlistCounts(ids), + this.repo.aggregateProductReviewStats(ids), + this.repo.aggregateProductRecentOrderCounts(ids, since), + this.repo.globalReviewAverage(), + ]); + return scoreAndSortByPopularity( + candidates, + { wishlistCounts, reviewStats, recentOrderCounts }, + globalAverage ?? DEFAULT_GLOBAL_RATING_PRIOR, + ).map((entry) => entry.candidate); + } + + /** 판매순: 최근 30일 유효 주문 수량 합 desc → id desc. 판매 0건도 뒤에 남긴다(검색 결과 누락 방지). */ + private async sortByRecentSales( + candidates: ProductSearchCandidateRow[], + ): Promise { + const since = new Date( + this.clock.now().getTime() - RANKING_RECENT_ORDER_DAYS * DAY_MS, + ); + const sold = await this.repo.aggregateProductSoldQuantities( + candidates.map((c) => c.id), + since, + ); + return [...candidates].sort( + (a, b) => + (sold.get(b.id) ?? 0) - (sold.get(a.id) ?? 0) || compareIdDesc(a, b), + ); + } +} + +function compareIdDesc(a: { id: bigint }, b: { id: bigint }): number { + if (a.id === b.id) return 0; + return b.id > a.id ? 1 : -1; +} diff --git a/src/features/product/types/product-search-output.type.ts b/src/features/product/types/product-search-output.type.ts new file mode 100644 index 0000000..5b66d75 --- /dev/null +++ b/src/features/product/types/product-search-output.type.ts @@ -0,0 +1,25 @@ +/** + * product-search resolver 반환용 도메인 출력 타입. + * SDL(product-search.graphql)의 타입과 필드 일치. + */ + +export interface SearchProduct { + id: string; + storeId: string; + name: string; + thumbnailUrl: string | null; + storeName: string; + regionLabel: string | null; + regularPrice: number; + salePrice: number | null; + discountRate: number; + ratingAverage: number; + reviewCount: number; + isWishlisted: boolean; +} + +export interface SearchProductConnection { + items: SearchProduct[]; + totalCount: number; + hasMore: boolean; +} diff --git a/src/features/search/constants/search-error-messages.ts b/src/features/search/constants/search-error-messages.ts deleted file mode 100644 index 1a3fbdd..0000000 --- a/src/features/search/constants/search-error-messages.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const SEARCH_ERROR_MESSAGES = { - KEYWORD_EMPTY: '검색어를 입력해 주세요.', - KEYWORD_TOO_LONG: '검색어는 200자 이하여야 합니다.', -} as const; diff --git a/src/features/search/dto/inputs/search-summary.input.ts b/src/features/search/dto/inputs/search-summary.input.ts new file mode 100644 index 0000000..b51ab45 --- /dev/null +++ b/src/features/search/dto/inputs/search-summary.input.ts @@ -0,0 +1,12 @@ +import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class SearchSummaryInput { + @IsString() + keyword!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + regionIds?: string[]; +} diff --git a/src/features/search/resolvers/search-result-query.resolver.spec.ts b/src/features/search/resolvers/search-result-query.resolver.spec.ts new file mode 100644 index 0000000..ff7ce00 --- /dev/null +++ b/src/features/search/resolvers/search-result-query.resolver.spec.ts @@ -0,0 +1,62 @@ +import type { PrismaClient } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { ProductRepository, ProductSearchService } from '@/features/product'; +import { SearchResultQueryResolver } from '@/features/search/resolvers/search-result-query.resolver'; +import { SearchResultService } from '@/features/search/services/search-result.service'; +import { StoreSearchService } from '@/features/store'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createProduct, createStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/집계 세부 검증은 service.spec.ts에서 담당. + */ +describe('SearchResultQueryResolver (real DB)', () => { + let resolver: SearchResultQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SearchResultQueryResolver, + SearchResultService, + ProductSearchService, + ProductRepository, + StoreSearchService, + StoreListingService, + StoreRepository, + StoreWishlistRepository, + ClockService, + ], + }); + resolver = module.get(SearchResultQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('searchSummary: 상품/매장 건수를 반환한다', async () => { + const store = await createStore(prisma, { store_name: '크리스마스 매장' }); + await createProduct(prisma, { + store_id: store.id, + name: '크리스마스 케이크', + }); + + const result = await resolver.searchSummary({ keyword: '크리스마스' }); + + expect(result).toEqual({ productCount: 1, storeCount: 1 }); + }); +}); diff --git a/src/features/search/resolvers/search-result-query.resolver.ts b/src/features/search/resolvers/search-result-query.resolver.ts new file mode 100644 index 0000000..463b107 --- /dev/null +++ b/src/features/search/resolvers/search-result-query.resolver.ts @@ -0,0 +1,18 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { SearchSummaryInput } from '@/features/search/dto/inputs/search-summary.input'; +import { SearchResultService } from '@/features/search/services/search-result.service'; +import type { SearchSummary } from '@/features/search/types/search-result-output.type'; + +/** 검색 결과 요약 resolver. 개인화 필드가 없는 public query(인증 불필요). */ +@Resolver('Query') +export class SearchResultQueryResolver { + constructor(private readonly service: SearchResultService) {} + + @Query('searchSummary') + searchSummary( + @Args('input') input: SearchSummaryInput, + ): Promise { + return this.service.searchSummary(input); + } +} diff --git a/src/features/search/search-result.graphql b/src/features/search/search-result.graphql new file mode 100644 index 0000000..3c63188 --- /dev/null +++ b/src/features/search/search-result.graphql @@ -0,0 +1,16 @@ +extend type Query { + """검색 결과 '전체' 탭 카운트(상품/매장). 필터·정렬 없이 키워드(+지역)만 반영. '전체 N'은 FE가 합산. 비로그인 접근 가능.""" + searchSummary(input: SearchSummaryInput!): SearchSummary! +} + +input SearchSummaryInput { + """검색어(정규화: trim·공백 축약, 빈값/200자 초과 400).""" + keyword: String! + """2차 시군구 ID 다중 선택. 비우면 전국 대상.""" + regionIds: [ID!] +} + +type SearchSummary { + productCount: Int! + storeCount: Int! +} diff --git a/src/features/search/search.module.ts b/src/features/search/search.module.ts index d23a13a..525ff36 100644 --- a/src/features/search/search.module.ts +++ b/src/features/search/search.module.ts @@ -4,16 +4,19 @@ import { ProductModule } from '@/features/product'; import { SearchRepository } from '@/features/search/repositories/search.repository'; import { SearchEntryMutationResolver } from '@/features/search/resolvers/search-entry-mutation.resolver'; import { SearchEntryQueryResolver } from '@/features/search/resolvers/search-entry-query.resolver'; +import { SearchResultQueryResolver } from '@/features/search/resolvers/search-result-query.resolver'; import { SearchEntryService } from '@/features/search/services/search-entry.service'; import { SearchKeywordRankScheduler } from '@/features/search/services/search-keyword-rank.scheduler'; import { SearchKeywordRankService } from '@/features/search/services/search-keyword-rank.service'; +import { SearchResultService } from '@/features/search/services/search-result.service'; +import { StoreModule } from '@/features/store'; /** * 검색 도메인 모듈(검색 진입 화면·검색어 기록·인기 검색어 스냅샷). * 크론(@nestjs/schedule)은 AppModule의 ScheduleModule.forRoot()가 활성화한다. */ @Module({ - imports: [ProductModule], + imports: [ProductModule, StoreModule], providers: [ SearchRepository, SearchEntryService, @@ -21,6 +24,8 @@ import { SearchKeywordRankService } from '@/features/search/services/search-keyw SearchKeywordRankScheduler, SearchEntryQueryResolver, SearchEntryMutationResolver, + SearchResultService, + SearchResultQueryResolver, ], }) export class SearchModule {} diff --git a/src/features/search/services/search-entry.service.ts b/src/features/search/services/search-entry.service.ts index ed16914..c0a84ba 100644 --- a/src/features/search/services/search-entry.service.ts +++ b/src/features/search/services/search-entry.service.ts @@ -1,13 +1,12 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ClockService } from '@/common/providers/clock.service'; -import { normalizeSearchKeyword } from '@/common/utils/search-keyword'; +import { parseSearchKeyword } from '@/common/utils/search-keyword'; import { type HomeBanner, ProductRepository, toHomeBanner, } from '@/features/product'; -import { SEARCH_ERROR_MESSAGES } from '@/features/search/constants/search-error-messages'; import { SearchRepository } from '@/features/search/repositories/search.repository'; @Injectable() @@ -29,7 +28,7 @@ export class SearchEntryService { * 같은 키로 모이게 한다. 비로그인(accountId undefined)은 집계 이벤트만 남긴다. */ async recordSearch(rawKeyword: string, accountId?: bigint): Promise { - const keyword = this.requireKeyword(rawKeyword); + const { keyword } = parseSearchKeyword(rawKeyword); await this.repo.recordSearch({ accountId: accountId ?? null, keyword, @@ -37,14 +36,4 @@ export class SearchEntryService { }); return true; } - - private requireKeyword(raw: string): string { - const result = normalizeSearchKeyword(raw); - if (result.ok) return result.keyword; - throw new BadRequestException( - result.reason === 'EMPTY' - ? SEARCH_ERROR_MESSAGES.KEYWORD_EMPTY - : SEARCH_ERROR_MESSAGES.KEYWORD_TOO_LONG, - ); - } } diff --git a/src/features/search/services/search-result.service.spec.ts b/src/features/search/services/search-result.service.spec.ts new file mode 100644 index 0000000..5386d53 --- /dev/null +++ b/src/features/search/services/search-result.service.spec.ts @@ -0,0 +1,97 @@ +import { BadRequestException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { ProductRepository, ProductSearchService } from '@/features/product'; +import { SearchResultService } from '@/features/search/services/search-result.service'; +import { StoreSearchService } from '@/features/store'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createProduct, createRegion, createStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('SearchResultService (real DB)', () => { + let service: SearchResultService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SearchResultService, + ProductSearchService, + ProductRepository, + StoreSearchService, + StoreListingService, + StoreRepository, + StoreWishlistRepository, + ClockService, + ], + }); + service = module.get(SearchResultService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + describe('searchSummary', () => { + it('상품/매장 건수를 각각 센다(활성만)', async () => { + const store = await createStore(prisma, { + store_name: '크리스마스 하우스', + }); + await createStore(prisma, { + store_name: '크리스마스 공방', + is_active: false, + }); + await createProduct(prisma, { + store_id: store.id, + name: '크리스마스 케이크', + }); + await createProduct(prisma, { + store_id: store.id, + name: '미리 크리스마스', + }); + await createProduct(prisma, { store_id: store.id, name: '생일 케이크' }); + + expect(await service.searchSummary({ keyword: ' 크리스마스 ' })).toEqual({ + productCount: 2, + storeCount: 1, + }); + }); + + it('regionIds를 두 카운트에 모두 적용한다', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-sum' }); + const inRegion = await createStore(prisma, { + store_name: '케이크 지역', + region_id: region.id, + }); + const outRegion = await createStore(prisma, { + store_name: '케이크 타지역', + }); + await createProduct(prisma, { store_id: inRegion.id, name: '케이크' }); + await createProduct(prisma, { store_id: outRegion.id, name: '케이크' }); + + expect( + await service.searchSummary({ + keyword: '케이크', + regionIds: [region.id.toString()], + }), + ).toEqual({ productCount: 1, storeCount: 1 }); + }); + + it('빈 검색어는 400', async () => { + await expect(service.searchSummary({ keyword: '' })).rejects.toThrow( + BadRequestException, + ); + }); + }); +}); diff --git a/src/features/search/services/search-result.service.ts b/src/features/search/services/search-result.service.ts new file mode 100644 index 0000000..0ba1206 --- /dev/null +++ b/src/features/search/services/search-result.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { parseSearchKeyword } from '@/common/utils/search-keyword'; +import { ProductSearchService } from '@/features/product'; +import type { SearchSummaryInput } from '@/features/search/dto/inputs/search-summary.input'; +import type { SearchSummary } from '@/features/search/types/search-result-output.type'; +import { StoreSearchService } from '@/features/store'; + +@Injectable() +export class SearchResultService { + constructor( + private readonly productSearch: ProductSearchService, + private readonly storeSearch: StoreSearchService, + ) {} + + /** '전체' 탭 카운트. 각 도메인의 검색 조건(where 빌더)을 그대로 세어 목록과 어긋나지 않게 한다. */ + async searchSummary(input: SearchSummaryInput): Promise { + const { words } = parseSearchKeyword(input.keyword); + const regionIds = + input.regionIds && input.regionIds.length > 0 + ? input.regionIds.map((id) => parseId(id)) + : undefined; + + const [productCount, storeCount] = await Promise.all([ + this.productSearch.countProducts({ words, regionIds }), + this.storeSearch.countStores({ words, regionIds }), + ]); + return { productCount, storeCount }; + } +} diff --git a/src/features/search/types/search-result-output.type.ts b/src/features/search/types/search-result-output.type.ts new file mode 100644 index 0000000..e343492 --- /dev/null +++ b/src/features/search/types/search-result-output.type.ts @@ -0,0 +1,5 @@ +/** SDL(search-result.graphql) SearchSummary와 필드 일치. */ +export interface SearchSummary { + productCount: number; + storeCount: number; +} diff --git a/src/features/store/constants/store-search.constants.ts b/src/features/store/constants/store-search.constants.ts new file mode 100644 index 0000000..89db962 --- /dev/null +++ b/src/features/store/constants/store-search.constants.ts @@ -0,0 +1,3 @@ +/** 검색 목록 기본/최대 페이지 크기(상품 검색과 동일 정책). */ +export const DEFAULT_SEARCH_PAGE_LIMIT = 20; +export const MAX_SEARCH_PAGE_LIMIT = 50; diff --git a/src/features/store/dto/inputs/search-stores.input.ts b/src/features/store/dto/inputs/search-stores.input.ts new file mode 100644 index 0000000..840744b --- /dev/null +++ b/src/features/store/dto/inputs/search-stores.input.ts @@ -0,0 +1,33 @@ +import { + IsArray, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +import { MAX_SEARCH_PAGE_LIMIT } from '@/features/store/constants/store-search.constants'; + +export class SearchStoresInput { + @IsString() + keyword!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + regionIds?: string[]; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_SEARCH_PAGE_LIMIT) + limit?: number; +} diff --git a/src/features/store/index.ts b/src/features/store/index.ts index 9a39954..0becd2d 100644 --- a/src/features/store/index.ts +++ b/src/features/store/index.ts @@ -11,3 +11,8 @@ export { buildRegionLabel } from '@/features/store/services/store-mappers.helper // 판정 규칙은 store feature에 유지한다(달력·슬롯 조회와 단일 소스). export { StorePickupScheduleService } from '@/features/store/services/store-pickup-schedule.service'; export { scoreAndSortByPopularity } from '@/features/store/services/store-ranking.helper'; +// 검색 요약(search feature)의 매장 건수. 검색 조건은 store feature의 where 빌더가 단일 소스. +export { + StoreSearchService, + type StoreSearchScope, +} from '@/features/store/services/store-search.service'; diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index c378466..3568c65 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -18,6 +18,18 @@ export interface StoreCandidateRow { max_days_ahead: number; } +/** 매장 검색 후보 row(랭킹 후보 + 로고). */ +export interface StoreSearchCandidateRow extends StoreCandidateRow { + profile_image_url: string | null; +} + +/** 매장 검색 조건. */ +export interface StoreSearchFilter { + /** 매장명에 모두 포함돼야 하는 단어(AND). */ + words: string[]; + regionIds?: bigint[]; +} + /** 특정 요일의 매장 영업시간 row(오늘 픽업 슬롯 산출용). */ export interface StoreTodayBusinessHourRow { store_id: bigint; @@ -94,6 +106,35 @@ export class StoreRepository { }); } + /** + * 매장 검색 후보 전량(활성 매장). 인기순 점수화가 메모리라 후보를 모두 로드한다 + * (findActiveStoresForRanking과 동일 트레이드오프). + */ + async findStoreSearchCandidates( + filter: StoreSearchFilter, + ): Promise { + return this.prisma.store.findMany({ + where: buildStoreSearchWhere(filter), + select: { + id: true, + store_name: true, + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + pickup_slot_interval_minutes: true, + min_lead_time_minutes: true, + max_days_ahead: true, + profile_image_url: true, + }, + orderBy: { id: 'asc' }, + }); + } + + /** 매장 검색 결과 수(검색 요약 탭 카운트). 후보 조건과 단일 소스. */ + async countStoreSearch(filter: StoreSearchFilter): Promise { + return this.prisma.store.count({ where: buildStoreSearchWhere(filter) }); + } + /** 특정 요일(0=일~6=토)의 매장별 영업시간. */ async findBusinessHoursByWeekday( storeIds: bigint[], @@ -410,3 +451,16 @@ export class StoreRepository { return new Map(entries); } } + +/** 매장 검색 where. 단어별 매장명 contains AND + 활성 + 지역(정책 확정). */ +export function buildStoreSearchWhere( + filter: StoreSearchFilter, +): Prisma.StoreWhereInput { + return { + is_active: true, + ...(filter.regionIds && filter.regionIds.length > 0 + ? { region_id: { in: filter.regionIds } } + : {}), + AND: filter.words.map((word) => ({ store_name: { contains: word } })), + }; +} diff --git a/src/features/store/resolvers/store-search-query.resolver.spec.ts b/src/features/store/resolvers/store-search-query.resolver.spec.ts new file mode 100644 index 0000000..f7d2557 --- /dev/null +++ b/src/features/store/resolvers/store-search-query.resolver.spec.ts @@ -0,0 +1,79 @@ +import type { PrismaClient } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreSearchQueryResolver } from '@/features/store/resolvers/store-search-query.resolver'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StoreSearchService } from '@/features/store/services/store-search.service'; +import type { JwtUser } from '@/global/auth'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/집계 세부 검증은 service.spec.ts에서 담당. + */ +describe('StoreSearchQueryResolver (real DB)', () => { + let resolver: StoreSearchQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreSearchQueryResolver, + StoreSearchService, + StoreListingService, + StoreRepository, + StoreWishlistRepository, + ClockService, + ], + }); + resolver = module.get(StoreSearchQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('searchStores: 로그인 사용자의 찜 여부를 채운다', async () => { + const store = await createStore(prisma, { store_name: '리졸버 매장' }); + const account = await createAccount(prisma, { account_type: 'USER' }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + const user = { accountId: account.id.toString() } as JwtUser; + + const result = await resolver.searchStores({ keyword: '리졸버' }, user); + + expect(result.totalCount).toBe(1); + expect(result.items[0]).toMatchObject({ + storeName: '리졸버 매장', + isWishlisted: true, + }); + }); + + it('searchStores: 비로그인은 isWishlisted=false', async () => { + await createStore(prisma, { store_name: '리졸버 매장' }); + + const result = await resolver.searchStores( + { keyword: '리졸버' }, + undefined, + ); + + expect(result.items[0].isWishlisted).toBe(false); + }); +}); diff --git a/src/features/store/resolvers/store-search-query.resolver.ts b/src/features/store/resolvers/store-search-query.resolver.ts new file mode 100644 index 0000000..dd4bfb1 --- /dev/null +++ b/src/features/store/resolvers/store-search-query.resolver.ts @@ -0,0 +1,31 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { SearchStoresInput } from '@/features/store/dto/inputs/search-stores.input'; +import { StoreSearchService } from '@/features/store/services/store-search.service'; +import type { SearchStoreConnection } from '@/features/store/types/store-search-output.type'; +import { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** + * 키워드 매장 검색 resolver. 비로그인도 접근 가능한 public query. + * 옵셔널 인증으로 로그인 시에만 isWishlisted를 채운다. + */ +@Resolver('Query') +export class StoreSearchQueryResolver { + constructor(private readonly service: StoreSearchService) {} + + @Query('searchStores') + @UseGuards(OptionalJwtAuthGuard) + searchStores( + @Args('input') input: SearchStoresInput, + @CurrentUser() user: JwtUser | undefined, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.service.searchStores(input, accountId); + } +} diff --git a/src/features/store/services/store-listing.service.ts b/src/features/store/services/store-listing.service.ts index e723576..2382843 100644 --- a/src/features/store/services/store-listing.service.ts +++ b/src/features/store/services/store-listing.service.ts @@ -42,6 +42,17 @@ export class StoreListingService { rankedAt: Date, ): Promise { const candidates = await this.repo.findActiveStoresForRanking(regionIds); + return this.scoreStores(candidates, rankedAt); + } + + /** + * 주어진 후보 매장의 주문·찜·평점을 집계해 점수화·정렬한다. + * 키워드 매장 검색(후보를 검색어로 좁힌 뒤)도 같은 인기순을 쓴다. + */ + async scoreStores( + candidates: T[], + rankedAt: Date, + ): Promise[]> { if (candidates.length === 0) return []; const storeIds = candidates.map((c) => c.id); diff --git a/src/features/store/services/store-search.service.spec.ts b/src/features/store/services/store-search.service.spec.ts new file mode 100644 index 0000000..ed7e347 --- /dev/null +++ b/src/features/store/services/store-search.service.spec.ts @@ -0,0 +1,190 @@ +import { BadRequestException } from '@nestjs/common'; +import type { PrismaClient, Store } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StoreSearchService } from '@/features/store/services/store-search.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createOrder, + createOrderItem, + createProduct, + createRegion, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('StoreSearchService (real DB)', () => { + let service: StoreSearchService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreSearchService, + StoreListingService, + StoreRepository, + StoreWishlistRepository, + ClockService, + ], + }); + service = module.get(StoreSearchService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function confirmOrders(store: Store, count: number): Promise { + const product = await createProduct(prisma, { store_id: store.id }); + for (let i = 0; i < count; i += 1) { + const order = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + } + } + + async function names(input: Parameters[0]) { + const result = await service.searchStores(input); + return result.items.map((i) => i.storeName); + } + + describe('searchStores', () => { + it('공백 분리 단어가 매장명에 모두 포함돼야 매칭된다(AND)', async () => { + await createStore(prisma, { store_name: '매일이 크리스마스' }); + await createStore(prisma, { store_name: '크리스마스에 눈이 올까요' }); + await createStore(prisma, { store_name: '매일 베이커리' }); + + expect(await names({ keyword: '크리스마스 매일' })).toEqual([ + '매일이 크리스마스', + ]); + expect(await names({ keyword: '크리스마스' })).toHaveLength(2); + }); + + it('비활성·삭제 매장은 제외한다', async () => { + await createStore(prisma, { store_name: '케이크 활성' }); + await createStore(prisma, { + store_name: '케이크 휴업', + is_active: false, + }); + const deleted = await createStore(prisma, { store_name: '케이크 삭제' }); + await prisma.store.update({ + where: { id: deleted.id }, + data: { deleted_at: new Date() }, + }); + + expect(await names({ keyword: '케이크' })).toEqual(['케이크 활성']); + }); + + it('regionIds 지정 시 해당 지역 매장만', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-x' }); + await createStore(prisma, { + store_name: '케이크 지역', + region_id: region.id, + }); + await createStore(prisma, { store_name: '케이크 타지역' }); + + expect( + await names({ keyword: '케이크', regionIds: [region.id.toString()] }), + ).toEqual(['케이크 지역']); + }); + + it('인기 점수순(최근 주문 많은 매장 우선)으로 정렬한다', async () => { + const hot = await createStore(prisma, { store_name: '케이크 인기' }); + await createStore(prisma, { store_name: '케이크 보통' }); + await confirmOrders(hot, 3); + + expect(await names({ keyword: '케이크' })).toEqual([ + '케이크 인기', + '케이크 보통', + ]); + }); + + it('offset/limit·totalCount·hasMore', async () => { + for (let i = 0; i < 3; i += 1) { + await createStore(prisma, { store_name: `케이크 ${i}` }); + } + + const page = await service.searchStores({ + keyword: '케이크', + offset: 2, + limit: 2, + }); + + expect(page.totalCount).toBe(3); + expect(page.items).toHaveLength(1); + expect(page.hasMore).toBe(false); + }); + + it('카드에 로고·지역·대표 이미지(최대 4)·찜 여부를 채운다', async () => { + const store = await createStore(prisma, { + store_name: '케이크 하우스', + profile_image_url: 'https://img/logo.png', + address_city: '서울', + address_neighborhood: '용산구', + }); + for (let i = 0; i < 5; i += 1) { + const product = await createProduct(prisma, { store_id: store.id }); + await prisma.productImage.create({ + data: { product_id: product.id, image_url: `https://img/${i}.png` }, + }); + } + const account = await createAccount(prisma, { account_type: 'USER' }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const asUser = await service.searchStores( + { keyword: '케이크' }, + account.id, + ); + const asGuest = await service.searchStores({ keyword: '케이크' }); + + expect(asUser.items[0]).toMatchObject({ + id: store.id.toString(), + storeName: '케이크 하우스', + profileImageUrl: 'https://img/logo.png', + regionLabel: '서울 용산구', + ratingAverage: 0, + reviewCount: 0, + isWishlisted: true, + }); + expect(asUser.items[0].cakeImageUrls).toHaveLength(4); + expect(asGuest.items[0].isWishlisted).toBe(false); + }); + + it('빈 검색어는 400, 결과 없음은 빈 커넥션', async () => { + await expect(service.searchStores({ keyword: ' ' })).rejects.toThrow( + BadRequestException, + ); + expect(await service.searchStores({ keyword: '없음' })).toEqual({ + items: [], + totalCount: 0, + hasMore: false, + }); + }); + }); + + describe('countStores', () => { + it('목록과 동일 조건으로 센다', async () => { + await createStore(prisma, { store_name: '케이크 A' }); + await createStore(prisma, { store_name: '케이크 B', is_active: false }); + + expect(await service.countStores({ words: ['케이크'] })).toBe(1); + }); + }); +}); diff --git a/src/features/store/services/store-search.service.ts b/src/features/store/services/store-search.service.ts new file mode 100644 index 0000000..86f8e1d --- /dev/null +++ b/src/features/store/services/store-search.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; + +import { ClockService } from '@/common/providers/clock.service'; +import { parseId } from '@/common/utils/id-parser'; +import { hasMoreByOffset } from '@/common/utils/pagination'; +import { roundRatingAverage } from '@/common/utils/rating'; +import { parseSearchKeyword } from '@/common/utils/search-keyword'; +import { + DEFAULT_SEARCH_PAGE_LIMIT, + MAX_SEARCH_PAGE_LIMIT, +} from '@/features/store/constants/store-search.constants'; +import type { SearchStoresInput } from '@/features/store/dto/inputs/search-stores.input'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { + StoreRepository, + type StoreSearchFilter, +} from '@/features/store/repositories/store.repository'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { buildRegionLabel } from '@/features/store/services/store-mappers.helper'; +import type { SearchStoreConnection } from '@/features/store/types/store-search-output.type'; + +/** 검색 요약(searchSummary)이 넘기는 공통 조건. */ +export interface StoreSearchScope { + words: string[]; + regionIds?: bigint[]; +} + +@Injectable() +export class StoreSearchService { + constructor( + private readonly repo: StoreRepository, + private readonly wishlistRepo: StoreWishlistRepository, + private readonly listing: StoreListingService, + private readonly clock: ClockService, + ) {} + + /** + * 키워드 매장 검색. 후보를 매장명으로 좁힌 뒤 인기 매장과 동일 산식으로 정렬(정렬 옵션 없음 — + * 시안에 매장용 정렬 시트가 없어 인기순 고정, 사용자 확정), offset 페이지의 대표 이미지·찜을 채운다. + */ + async searchStores( + input: SearchStoresInput, + accountId?: bigint, + ): Promise { + const filter = this.toFilter(input); + const offset = input.offset ?? 0; + const limit = Math.min( + input.limit ?? DEFAULT_SEARCH_PAGE_LIMIT, + MAX_SEARCH_PAGE_LIMIT, + ); + + const candidates = await this.repo.findStoreSearchCandidates(filter); + const totalCount = candidates.length; + if (totalCount === 0) return { items: [], totalCount: 0, hasMore: false }; + + const scored = await this.listing.scoreStores(candidates, this.clock.now()); + const page = scored.slice(offset, offset + limit); + const pageStoreIds = page.map((entry) => entry.candidate.id); + + const [imagesByStore, wishlistedIds] = await Promise.all([ + this.repo.findStoreCakeImages(pageStoreIds), + // 0n도 유효한 계정 id — undefined로만 비로그인을 분기한다 + accountId !== undefined + ? this.wishlistRepo.findWishlistedStoreIds({ + accountId, + storeIds: pageStoreIds, + }) + : Promise.resolve(new Set()), + ]); + + return { + items: page.map(({ candidate, metrics }) => ({ + id: candidate.id.toString(), + storeName: candidate.store_name, + profileImageUrl: candidate.profile_image_url, + ratingAverage: roundRatingAverage(metrics.ratingAverage), + reviewCount: metrics.reviewCount, + regionLabel: buildRegionLabel(candidate), + cakeImageUrls: imagesByStore.get(candidate.id) ?? [], + isWishlisted: wishlistedIds.has(candidate.id.toString()), + })), + totalCount, + hasMore: hasMoreByOffset(offset, limit, totalCount), + }; + } + + /** 검색 요약 탭의 매장 건수. */ + countStores(scope: StoreSearchScope): Promise { + return this.repo.countStoreSearch(scope); + } + + private toFilter(input: SearchStoresInput): StoreSearchFilter { + const { words } = parseSearchKeyword(input.keyword); + return { + words, + regionIds: + input.regionIds && input.regionIds.length > 0 + ? input.regionIds.map((id) => parseId(id)) + : undefined, + }; + } +} diff --git a/src/features/store/store-search.graphql b/src/features/store/store-search.graphql new file mode 100644 index 0000000..f0bcf6a --- /dev/null +++ b/src/features/store/store-search.graphql @@ -0,0 +1,37 @@ +extend type Query { + """키워드 매장 검색(검색 결과 매장 탭). 매장명에 단어별 AND 부분일치, 인기순 고정. 비로그인 접근 가능.""" + searchStores(input: SearchStoresInput!): SearchStoreConnection! +} + +input SearchStoresInput { + """검색어(정규화: trim·공백 축약, 빈값/200자 초과 400).""" + keyword: String! + """2차 시군구 ID 다중 선택. 비우면 전국 대상.""" + regionIds: [ID!] + offset: Int = 0 + """조회 개수(최대 50).""" + limit: Int = 20 +} + +type SearchStoreConnection { + items: [SearchStore!]! + totalCount: Int! + hasMore: Boolean! +} + +"""검색 결과 매장 카드(로고·평점·지역·대표 케이크 이미지·찜).""" +type SearchStore { + id: ID! + storeName: String! + """매장 프로필(로고) 이미지. 미등록 시 null.""" + profileImageUrl: String + """평균 평점(0.0~5.0, 소수 첫째 자리). 리뷰 없으면 0.0.""" + ratingAverage: Float! + reviewCount: Int! + """매장 위치 표기(예: 서울 용산구).""" + regionLabel: String + """대표 케이크 이미지(활성 상품 대표 이미지, 최대 4장). 인기 매장 카드와 동일 소스.""" + cakeImageUrls: [String!]! + """로그인 사용자의 찜 여부(비로그인 시 false).""" + isWishlisted: Boolean! +} diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts index 606ecca..477c2c0 100644 --- a/src/features/store/store.module.ts +++ b/src/features/store/store.module.ts @@ -7,6 +7,7 @@ import { StoreDetailQueryResolver } from '@/features/store/resolvers/store-detai import { StorePickupScheduleQueryResolver } from '@/features/store/resolvers/store-pickup-schedule-query.resolver'; import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; import { StoreReviewQueryResolver } from '@/features/store/resolvers/store-review-query.resolver'; +import { StoreSearchQueryResolver } from '@/features/store/resolvers/store-search-query.resolver'; import { StoreTodayPickupQueryResolver } from '@/features/store/resolvers/store-today-pickup-query.resolver'; import { StoreWishlistMutationResolver } from '@/features/store/resolvers/store-wishlist-mutation.resolver'; import { StoreWishlistQueryResolver } from '@/features/store/resolvers/store-wishlist-query.resolver'; @@ -14,6 +15,7 @@ import { StoreDetailService } from '@/features/store/services/store-detail.servi import { StoreListingService } from '@/features/store/services/store-listing.service'; import { StorePickupScheduleService } from '@/features/store/services/store-pickup-schedule.service'; import { StoreReviewService } from '@/features/store/services/store-review.service'; +import { StoreSearchService } from '@/features/store/services/store-search.service'; import { StoreTodayPickupService } from '@/features/store/services/store-today-pickup.service'; import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; @@ -35,8 +37,11 @@ import { StoreWishlistService } from '@/features/store/services/store-wishlist.s StoreTodayPickupQueryResolver, StorePickupScheduleService, StorePickupScheduleQueryResolver, + StoreSearchService, + StoreSearchQueryResolver, ], - // StorePickupScheduleService는 주문 생성(order feature)의 픽업 일시 재검증이 소비한다 - exports: [StorePickupScheduleService], + // StorePickupScheduleService는 주문 생성(order feature)의 픽업 일시 재검증이, + // StoreSearchService는 검색 요약(search feature)의 매장 건수가 소비한다 + exports: [StorePickupScheduleService, StoreSearchService], }) export class StoreModule {} diff --git a/src/features/store/types/store-search-output.type.ts b/src/features/store/types/store-search-output.type.ts new file mode 100644 index 0000000..60fc6ac --- /dev/null +++ b/src/features/store/types/store-search-output.type.ts @@ -0,0 +1,21 @@ +/** + * store-search resolver 반환용 도메인 출력 타입. + * SDL(store-search.graphql)의 타입과 필드 일치. + */ + +export interface SearchStore { + id: string; + storeName: string; + profileImageUrl: string | null; + ratingAverage: number; + reviewCount: number; + regionLabel: string | null; + cakeImageUrls: string[]; + isWishlisted: boolean; +} + +export interface SearchStoreConnection { + items: SearchStore[]; + totalCount: number; + hasMore: boolean; +} diff --git a/src/test/factories/index.ts b/src/test/factories/index.ts index d96bd64..6cd5e12 100644 --- a/src/test/factories/index.ts +++ b/src/test/factories/index.ts @@ -14,4 +14,5 @@ export * from './seller.factory'; export * from './sequence'; export * from './store-wishlist.factory'; export * from './store.factory'; +export * from './tag.factory'; export * from './user-profile.factory'; diff --git a/src/test/factories/tag.factory.ts b/src/test/factories/tag.factory.ts new file mode 100644 index 0000000..11ce2ec --- /dev/null +++ b/src/test/factories/tag.factory.ts @@ -0,0 +1,30 @@ +import type { PrismaClient, Tag } from '@prisma/client'; + +import { nextSeq } from '@/test/factories/sequence'; + +export async function createTag( + prisma: PrismaClient, + overrides: { name?: string; deleted_at?: Date | null } = {}, +): Promise { + const seq = nextSeq(); + return prisma.tag.create({ + data: { + name: overrides.name ?? `tag_${seq}`, + deleted_at: overrides.deleted_at ?? null, + }, + }); +} + +/** 상품 ↔ 태그 연결. */ +export async function linkProductTag( + prisma: PrismaClient, + args: { productId: bigint; tagId: bigint; deleted_at?: Date | null }, +): Promise { + await prisma.productTag.create({ + data: { + product_id: args.productId, + tag_id: args.tagId, + deleted_at: args.deleted_at ?? null, + }, + }); +}