diff --git a/package.json b/package.json index 6020387..9a07201 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "11.2.1", + "@nestjs/schedule": "^6.1.3", "@nestjs/serve-static": "^5.0.5", "@nestjs/swagger": "^11.4.7", "@prisma/client": "^6.19.3", diff --git a/prisma/migrations/20260830181501_add_search_keyword_rank_snapshot/migration.sql b/prisma/migrations/20260830181501_add_search_keyword_rank_snapshot/migration.sql new file mode 100644 index 0000000..bf06aed --- /dev/null +++ b/prisma/migrations/20260830181501_add_search_keyword_rank_snapshot/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE `search_keyword_rank_snapshot` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `ranked_at` DATETIME(3) NOT NULL, + `rank` SMALLINT UNSIGNED NOT NULL, + `keyword` VARCHAR(200) NOT NULL, + `search_count` INTEGER UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `idx_search_keyword_rank_snapshot_ranked_at`(`ranked_at`), + UNIQUE INDEX `uk_search_keyword_rank_snapshot`(`ranked_at`, `rank`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/migrations/20260830183130_add_banner_placement_search/migration.sql b/prisma/migrations/20260830183130_add_banner_placement_search/migration.sql new file mode 100644 index 0000000..0908ae4 --- /dev/null +++ b/prisma/migrations/20260830183130_add_banner_placement_search/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `banner` MODIFY `placement` ENUM('HOME_MAIN', 'HOME_SUB', 'CATEGORY', 'STORE', 'SEARCH') NOT NULL DEFAULT 'HOME_MAIN'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 767dad0..47f39fd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -90,6 +90,7 @@ enum BannerPlacement { HOME_SUB CATEGORY STORE + SEARCH // 검색 진입 화면 인기 검색어 아래 배너 슬롯(figma search 05) } enum BannerLinkType { @@ -1246,6 +1247,27 @@ model SearchEvent { @@map("search_event") } +/** + * 인기 검색어 시간별 스냅샷(검색 진입 화면 TOP10 + 순위 변동). + * 매시 정각 크론이 직전 24h SearchEvent를 keyword별 집계해 TOP20을 저장한다. + * 불변 로그 성격이라 soft-delete(deleted_at)를 두지 않는다(보관 무제한 — 사용자 확정). + */ +model SearchKeywordRankSnapshot { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + + // 정각(시 단위 절삭) 기준 시각. 같은 ranked_at은 1회만 생성(멱등) + ranked_at DateTime @db.DateTime(3) + rank Int @db.UnsignedSmallInt + keyword String @db.VarChar(200) + search_count Int @db.UnsignedInt + + created_at DateTime @default(now()) @db.DateTime(3) + + @@unique([ranked_at, rank], map: "uk_search_keyword_rank_snapshot") + @@index([ranked_at], map: "idx_search_keyword_rank_snapshot_ranked_at") + @@map("search_keyword_rank_snapshot") +} + /** * ========================= * 12) Banner diff --git a/prisma/seed.ts b/prisma/seed.ts index fdc08b9..471ecdd 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -11,6 +11,7 @@ */ import { PrismaClient } from '@prisma/client'; +import { seedBanners } from './seed/banners'; import { seedCategories } from './seed/categories'; import { seedCustomDrafts } from './seed/custom-drafts'; import { resetSeedScope } from './seed/idempotent'; @@ -19,6 +20,7 @@ import { seedOrders } from './seed/orders'; import { seedRecentViews } from './seed/recent-views'; import { seedRegions } from './seed/regions'; import { seedReviews } from './seed/reviews'; +import { seedSearchEvents } from './seed/search-events'; import { seedSearchHistory } from './seed/search-history'; import { seedStores } from './seed/stores'; import { seedUsers } from './seed/users'; @@ -67,6 +69,12 @@ async function main(): Promise { log('검색 히스토리 시드 중...'); await seedSearchHistory(prisma, { users }); + log('검색 이벤트 + 인기 검색어 스냅샷 시드 중...'); + await seedSearchEvents(prisma, { users }); + + log('배너 시드 중...'); + await seedBanners(prisma); + log('완료. 발급된 테스트 계정:'); for (const u of users) { const status = diff --git a/prisma/seed/banners.ts b/prisma/seed/banners.ts new file mode 100644 index 0000000..f7898de --- /dev/null +++ b/prisma/seed/banners.ts @@ -0,0 +1,20 @@ +/** + * 배너 시드(검색 진입 화면 SEARCH 지면 1건). + * title을 SEED_BANNER_TITLE_PREFIX로 시작시켜 resetSeedScope가 자기 영역만 정리한다. + * 링크는 NONE — 시드 매장/상품 FK에 묶이지 않아 재시드 순서와 무관하다. + */ +import type { PrismaClient } from '@prisma/client'; + +import { SEED_BANNER_TITLE_PREFIX } from './idempotent'; + +export async function seedBanners(prisma: PrismaClient): Promise { + await prisma.banner.create({ + data: { + placement: 'SEARCH', + title: `${SEED_BANNER_TITLE_PREFIX}검색 진입 배너`, + image_url: 'https://picsum.photos/seed/caquick-search/750/220', + link_type: 'NONE', + sort_order: 0, + }, + }); +} diff --git a/prisma/seed/idempotent.ts b/prisma/seed/idempotent.ts index ee052cb..8f321e8 100644 --- a/prisma/seed/idempotent.ts +++ b/prisma/seed/idempotent.ts @@ -4,6 +4,7 @@ * 시드는 다음 식별자들로만 자기 영역을 구분한다: * - 유저 이메일: SEED_USER_EMAIL_PREFIX (`seed-user-`) * - 매장 이름: SEED_STORE_NAME_PREFIX (`[SEED] `) + * - 배너 제목: SEED_BANNER_TITLE_PREFIX (`[SEED] `) * * 정리 시 위 prefix에 매칭되는 row와 그 종속 데이터(주문/리뷰/찜/...)를 * 삭제한 뒤 다시 삽입하므로, 수동으로 만든 다른 데이터는 보존된다. @@ -12,8 +13,14 @@ import type { PrismaClient } from '@prisma/client'; export const SEED_USER_EMAIL_PREFIX = 'seed-user-'; export const SEED_STORE_NAME_PREFIX = '[SEED] '; +export const SEED_BANNER_TITLE_PREFIX = '[SEED] '; export async function resetSeedScope(prisma: PrismaClient): Promise { + // 배너(링크 NONE, FK 없음) + await prisma.banner.deleteMany({ + where: { title: { startsWith: SEED_BANNER_TITLE_PREFIX } }, + }); + const seedUsers = await prisma.account.findMany({ where: { email: { startsWith: SEED_USER_EMAIL_PREFIX } }, select: { id: true }, @@ -116,6 +123,11 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { where: { account_id: { in: userIds } }, }); + // 검색 집계 이벤트(시드 유저 소유분만) — 스냅샷은 파생 캐시라 seedSearchEvents가 전량 재생성 + await prisma.searchEvent.deleteMany({ + where: { account_id: { in: userIds } }, + }); + // 카트 const userCarts = await prisma.cart.findMany({ where: { account_id: { in: userIds } }, diff --git a/prisma/seed/search-events.ts b/prisma/seed/search-events.ts new file mode 100644 index 0000000..c6ce944 --- /dev/null +++ b/prisma/seed/search-events.ts @@ -0,0 +1,86 @@ +/** + * 검색 집계 이벤트 + 인기 검색어 스냅샷 시드(검색 진입 화면 검증용). + * + * - 이벤트는 시드 유저 소유(account_id)로만 만들어 resetSeedScope가 유저 기준으로 정리한다. + * - 스냅샷은 시드가 쓰는 두 정각(직전·현재)만 지우고 재생성한다 — 다른 시각대의 + * 기존 스냅샷은 시드 데이터가 아니므로 보존한다(릴리즈 리뷰 반영). + * 순위 변동 UP/DOWN/SAME/NEW가 모두 보이도록 구성. + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededUser } from './users'; + +const HOUR_MS = 60 * 60 * 1000; + +const PREVIOUS_RANKING = [ + '과일 케이크', + '생일 케이크', + '크리스마스', + '3d', + '강아지 케이크', + '생화 케이크', + '떡 케이크', + '연인', + '미니 케이크', + '기념일 케이크', + '레터링 케이크', +] as const; + +/** 현재 정각 기준 순위(검색 횟수 desc). 직전 대비 UP/DOWN/SAME/NEW 혼합. */ +const CURRENT_RANKING: { keyword: string; count: number }[] = [ + { keyword: '생일 케이크', count: 12 }, // 2 → 1 UP + { keyword: '과일 케이크', count: 11 }, // 1 → 2 DOWN + { keyword: '크리스마스', count: 9 }, // SAME + { keyword: '3d', count: 8 }, // SAME + { keyword: '강아지 케이크', count: 7 }, // SAME + { keyword: '생화 케이크', count: 6 }, // SAME + { keyword: '떡 케이크', count: 5 }, // SAME + { keyword: '연인', count: 4 }, // SAME + { keyword: '미니 케이크', count: 3 }, // SAME + { keyword: '도넛', count: 2 }, // NEW + { keyword: '기념일 케이크', count: 1 }, // 10 → 11 DOWN +]; + +export async function seedSearchEvents( + prisma: PrismaClient, + ctx: { users: SeededUser[] }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedSearchEvents'); + + const now = Date.now(); + const rankedAt = new Date(Math.floor(now / HOUR_MS) * HOUR_MS); + const previousAt = new Date(rankedAt.getTime() - HOUR_MS); + + // 현재 정각 스냅샷 윈도우([rankedAt-24h, rankedAt)) 안에 count만큼 이벤트를 심는다 + await prisma.searchEvent.createMany({ + data: CURRENT_RANKING.flatMap(({ keyword, count }) => + Array.from({ length: count }, (_, i) => ({ + account_id: user1.id, + keyword, + context: 'GLOBAL' as const, + created_at: new Date(rankedAt.getTime() - (i + 1) * 10 * 60 * 1000), + })), + ), + }); + + await prisma.searchKeywordRankSnapshot.deleteMany({ + where: { ranked_at: { in: [previousAt, rankedAt] } }, + }); + await prisma.searchKeywordRankSnapshot.createMany({ + data: [ + ...PREVIOUS_RANKING.map((keyword, i) => ({ + ranked_at: previousAt, + rank: i + 1, + keyword, + search_count: PREVIOUS_RANKING.length - i, + })), + ...CURRENT_RANKING.map(({ keyword, count }, i) => ({ + ranked_at: rankedAt, + rank: i + 1, + keyword, + search_count: count, + })), + ], + }); +} diff --git a/src/app.module.ts b/src/app.module.ts index 8be329e..e800113 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { GraphQLModule } from '@nestjs/graphql'; +import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; import type { Request, Response } from 'express'; @@ -23,6 +24,7 @@ import s3Config from '@/config/s3.config'; import { AuthModule } from '@/features/auth/auth.module'; import { PickupModule } from '@/features/pickup'; import { RegionModule } from '@/features/region'; +import { SearchModule } from '@/features/search/search.module'; import { SellerModule } from '@/features/seller/seller.module'; import { StoreModule } from '@/features/store'; import { SystemModule } from '@/features/system/system.module'; @@ -56,6 +58,8 @@ import { PrismaModule } from '@/prisma'; AuthGlobalModule, GraphqlGlobalModule, StorageModule, + // 인기 검색어 스냅샷 크론(SearchModule) 활성화 + ScheduleModule.forRoot(), GraphQLModule.forRootAsync({ driver: ApolloDriver, inject: [ConfigService], @@ -90,6 +94,7 @@ import { PrismaModule } from '@/prisma'; AuthModule, PickupModule, RegionModule, + SearchModule, StoreModule, UserModule, SellerModule, diff --git a/src/common/utils/search-keyword.spec.ts b/src/common/utils/search-keyword.spec.ts new file mode 100644 index 0000000..1851e1e --- /dev/null +++ b/src/common/utils/search-keyword.spec.ts @@ -0,0 +1,77 @@ +import { BadRequestException } from '@nestjs/common'; + +import { + normalizeSearchKeyword, + parseSearchKeyword, + SEARCH_KEYWORD_MAX_LENGTH, + splitSearchWords, +} from '@/common/utils/search-keyword'; + +describe('search-keyword utils', () => { + describe('normalizeSearchKeyword', () => { + it('앞뒤 공백을 제거하고 연속 공백을 하나로 축약한다', () => { + expect(normalizeSearchKeyword(' 딸기 케이크\t\n')).toEqual({ + ok: true, + keyword: '딸기 케이크', + }); + }); + + it('1글자 검색어를 허용한다', () => { + expect(normalizeSearchKeyword('a')).toEqual({ ok: true, keyword: 'a' }); + }); + + it('공백만 있으면 EMPTY로 거절한다', () => { + expect(normalizeSearchKeyword(' ')).toEqual({ + ok: false, + reason: 'EMPTY', + }); + }); + + it('길이는 코드 포인트 기준이다(이모지 200개는 허용, 201개는 거절)', () => { + expect( + normalizeSearchKeyword('😀'.repeat(SEARCH_KEYWORD_MAX_LENGTH)).ok, + ).toBe(true); + expect( + normalizeSearchKeyword('😀'.repeat(SEARCH_KEYWORD_MAX_LENGTH + 1)), + ).toEqual({ ok: false, reason: 'TOO_LONG' }); + }); + + it('정규화 후 200자를 넘으면 TOO_LONG으로 거절한다', () => { + const raw = 'a'.repeat(SEARCH_KEYWORD_MAX_LENGTH + 1); + expect(normalizeSearchKeyword(raw)).toEqual({ + ok: false, + reason: 'TOO_LONG', + }); + expect( + normalizeSearchKeyword(` ${'a'.repeat(SEARCH_KEYWORD_MAX_LENGTH)} `) + .ok, + ).toBe(true); + }); + }); + + describe('splitSearchWords', () => { + it('공백 기준으로 나누고 중복 단어를 제거한다', () => { + expect(splitSearchWords('딸기 케이크 딸기')).toEqual(['딸기', '케이크']); + }); + + it('단어 하나면 그대로 반환한다', () => { + 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 new file mode 100644 index 0000000..ac478bf --- /dev/null +++ b/src/common/utils/search-keyword.ts @@ -0,0 +1,67 @@ +/** + * 검색어 정규화·단어 분리(DI-free 순수 함수). + * + * 검색 기록(SearchHistory/SearchEvent)·인기 검색어 집계·상품/매장 검색이 동일 규칙을 + * 공유해야 "같은 검색어"가 한 키로 모인다 — 정책 확정(검색 화면 문답): + * trim → 연속 공백 1개로 축약. 최소 길이 제한 없음(1글자 허용), 최대 200자 + * (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; + +export type SearchKeywordInvalidReason = 'EMPTY' | 'TOO_LONG'; + +export type NormalizeSearchKeywordResult = + | { ok: true; keyword: string } + | { ok: false; reason: SearchKeywordInvalidReason }; + +/** 앞뒤 공백 제거 + 연속 공백(탭·개행 포함) 1개로 축약. 빈 문자열/길이 초과는 실패로 알린다. */ +export function normalizeSearchKeyword( + raw: string, +): NormalizeSearchKeywordResult { + const keyword = raw.trim().replace(/\s+/g, ' '); + if (keyword.length === 0) return { ok: false, reason: 'EMPTY' }; + // MySQL VarChar(200)은 문자(코드 포인트) 수 기준 — UTF-16 단위(.length)로 세면 + // 서로게이트 쌍(이모지 등)이 2로 계산돼 저장 가능한 검색어를 거절한다(릴리즈 리뷰 반영) + if ([...keyword].length > SEARCH_KEYWORD_MAX_LENGTH) { + return { ok: false, reason: 'TOO_LONG' }; + } + return { ok: true, keyword }; +} + +/** + * 정규화된 검색어를 공백 기준 단어로 나눈다(중복 단어 제거). + * 상품/매장 검색은 각 단어를 AND로 결합한다 — '딸기 케이크'가 '딸기 생크림 케이크'에 매칭. + */ +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-best-seller.constants.ts b/src/features/product/constants/product-best-seller.constants.ts new file mode 100644 index 0000000..089557c --- /dev/null +++ b/src/features/product/constants/product-best-seller.constants.ts @@ -0,0 +1,8 @@ +/** realtimeBestCakes 기본 카드 수(figma 검색 진입 시안 리스트 기준). */ +export const DEFAULT_REALTIME_BEST_LIMIT = 10; + +/** realtimeBestCakes 최대 카드 수. */ +export const MAX_REALTIME_BEST_LIMIT = 20; + +/** '실시간' 판매 집계 윈도우(시간). 사용자 확정: 최근 24시간 주문 수량 합. */ +export const REALTIME_BEST_WINDOW_HOURS = 24; 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..6e43f6a --- /dev/null +++ b/src/features/product/constants/product-search.constants.ts @@ -0,0 +1,20 @@ +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; + +/** 가격 분포 버킷 폭(원). 시안 슬라이더 눈금(2만~7만 이상)을 5,000원 단위로 나눈다(자체 판단). */ +export const FACET_PRICE_BUCKET_SIZE = 5000; + +/** 가격 분포 상한(원). 이 값 이상은 마지막 '이상' 버킷으로 묶는다(시안 '7만원 이상'). */ +export const FACET_PRICE_BUCKET_MAX = 70000; diff --git a/src/features/product/dto/inputs/realtime-best-cakes.input.ts b/src/features/product/dto/inputs/realtime-best-cakes.input.ts new file mode 100644 index 0000000..f92d07f --- /dev/null +++ b/src/features/product/dto/inputs/realtime-best-cakes.input.ts @@ -0,0 +1,11 @@ +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +import { MAX_REALTIME_BEST_LIMIT } from '@/features/product/constants/product-best-seller.constants'; + +export class RealtimeBestCakesInput { + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_REALTIME_BEST_LIMIT) + limit?: number; +} diff --git a/src/features/product/dto/inputs/search-product-facets.input.ts b/src/features/product/dto/inputs/search-product-facets.input.ts new file mode 100644 index 0000000..972339d --- /dev/null +++ b/src/features/product/dto/inputs/search-product-facets.input.ts @@ -0,0 +1,24 @@ +import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class SearchProductFacetsInput { + @IsString() + keyword!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + eventCategoryIds?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + styleCategoryIds?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + regionIds?: string[]; +} diff --git a/src/features/product/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 d606cfa..32b3f4f 100644 --- a/src/features/product/index.ts +++ b/src/features/product/index.ts @@ -7,3 +7,15 @@ export { } from '@/features/product/repositories/product.repository'; // 할인율 산식(0~100). 상품 카드 표기 규칙 — 찜 목록(user feature)이 동일 정책을 공유한다. export { calcDiscountRate } from '@/features/product/services/product-storefront-mappers.helper'; +// 검색 진입 화면(search feature)이 소비하는 실시간 판매 Best·배너 매퍼·출력 타입. +// 랭킹·카드 표기 규칙은 product feature에 유지한다(홈 인기 케이크와 단일 소스). +export { RealtimeBestCakesInput } from '@/features/product/dto/inputs/realtime-best-cakes.input'; +export { ProductBestSellerService } from '@/features/product/services/product-best-seller.service'; +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..d5b4b94 --- /dev/null +++ b/src/features/product/product-search.graphql @@ -0,0 +1,99 @@ +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! +} + +extend type Query { + """가격대 필터 시트의 가격 분포 히스토그램(키워드+카테고리+지역 조건, 가격 조건 제외). 비로그인 접근 가능.""" + searchProductFacets(input: SearchProductFacetsInput!): SearchProductFacets! +} + +input SearchProductFacetsInput { + """검색어(정규화: trim·공백 축약, 빈값/200자 초과 400).""" + keyword: String! + """상황별(EVENT) 카테고리 ID 다중 선택(그룹 내 OR).""" + eventCategoryIds: [ID!] + """스타일별(STYLE) 카테고리 ID 다중 선택(그룹 내 OR, 상황별과는 AND).""" + styleCategoryIds: [ID!] + """2차 시군구 ID 다중 선택. 비우면 전국 대상.""" + regionIds: [ID!] +} + +"""가격 분포. 버킷은 5,000원 고정 폭으로 0원부터 70,000원까지, 마지막은 '70,000원 이상'(maxPrice null).""" +type SearchProductFacets { + buckets: [SearchPriceBucket!]! + """조건에 맞는 상품의 최저 표시가. 결과 없으면 null.""" + minPrice: Int + """조건에 맞는 상품의 최고 표시가. 결과 없으면 null.""" + maxPrice: Int + """조건에 맞는 상품 수(가격 조건 제외).""" + totalCount: Int! +} + +"""가격 버킷 [minPrice, maxPrice). maxPrice null = 상한 없음.""" +type SearchPriceBucket { + minPrice: Int! + maxPrice: Int + count: Int! +} diff --git a/src/features/product/product.module.ts b/src/features/product/product.module.ts index bd96bd1..d12e0e5 100644 --- a/src/features/product/product.module.ts +++ b/src/features/product/product.module.ts @@ -6,11 +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({ @@ -27,7 +30,11 @@ import { ProductStorefrontService } from '@/features/product/services/product-st ProductCategoryQueryResolver, ProductHomeService, ProductHomeQueryResolver, + ProductBestSellerService, + ProductSearchService, + ProductSearchQueryResolver, ], - exports: [ProductRepository], + // 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 ca25f85..161186c 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,75 @@ 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 findProductSearchPrices( + filter: ProductSearchFilter, + ): Promise<{ regular_price: number; sale_price: number | null }[]> { + return this.prisma.product.findMany({ + where: buildProductSearchWhere(filter), + select: { regular_price: true, sale_price: true }, + }); + } + + /** 상품 검색 결과 수(검색 요약 탭 카운트). 후보 조건과 단일 소스. */ + async countProductSearch(filter: ProductSearchFilter): Promise { + return this.prisma.product.count({ + 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[], @@ -1102,6 +1190,31 @@ export class ProductRepository { return new Map(rows.map((r) => [r.product_id, r._count._all])); } + /** + * 상품별 최근 판매 수량 합(OrderItem.quantity). 인기 점수와 동일한 유효 주문 상태· + * 주문 생성 시각(created_at) 기준. 실시간 판매 Best·판매순 정렬이 공유한다. + */ + async aggregateProductSoldQuantities( + productIds: bigint[], + since: Date, + ): Promise> { + if (productIds.length === 0) return new Map(); + const rows = await this.prisma.orderItem.groupBy({ + by: ['product_id'], + where: { + product_id: { in: productIds }, + order: { + status: { in: [...RANKING_VALID_ORDER_STATUSES] }, + created_at: { gte: since }, + // nested relation filter에는 soft-delete가 주입되지 않으므로 명시 + ...activeWhere, + }, + }, + _sum: { quantity: true }, + }); + return new Map(rows.map((r) => [r.product_id, r._sum.quantity ?? 0])); + } + /** * 전체 활성 리뷰 평균 평점(베이지안 prior). 리뷰가 없으면 null. * store feature의 globalReviewAverage와 동일 정의(전 도메인 공용 prior). @@ -1122,23 +1235,39 @@ export class ProductRepository { categoryId?: bigint; now: Date; }): Promise { + return this.findFirstBanner( + args.categoryId !== undefined + ? { + placement: 'CATEGORY', + link_category_id: args.categoryId, + // 랭킹과 동일하게 홈 칩은 EVENT 카테고리만 — 비EVENT id면 배너도 없음 + link_category: { + ...visibleWhere, + category_type: 'EVENT', + }, + } + : { placement: 'HOME_MAIN' }, + args.now, + ); + } + + /** 검색 진입 화면 배너 1건(placement=SEARCH). 노출 조건은 홈 배너와 동일. */ + async findSearchBanner(now: Date): Promise { + return this.findFirstBanner({ placement: 'SEARCH' }, now); + } + + /** 지면 조건 + 활성·노출 기간·링크 대상 활성까지 확인한 배너 1건(sort_order asc). */ + private findFirstBanner( + placementWhere: Prisma.BannerWhereInput, + now: Date, + ): Promise { return this.prisma.banner.findFirst({ where: { is_active: true, - ...(args.categoryId !== undefined - ? { - placement: 'CATEGORY', - link_category_id: args.categoryId, - // 랭킹과 동일하게 홈 칩은 EVENT 카테고리만 — 비EVENT id면 배너도 없음 - link_category: { - ...visibleWhere, - category_type: 'EVENT', - }, - } - : { placement: 'HOME_MAIN' }), - OR: [{ starts_at: null }, { starts_at: { lte: args.now } }], + ...placementWhere, + OR: [{ starts_at: null }, { starts_at: { lte: now } }], AND: [ - { OR: [{ ends_at: null }, { ends_at: { gt: args.now } }] }, + { OR: [{ ends_at: null }, { ends_at: { gt: now } }] }, { // 링크 대상이 내려간(비활성/삭제) 배너를 노출하면 클릭이 죽은 화면으로 // 떨어지므로 대상 활성까지 확인하고 다음 배너로 넘어간다 @@ -1321,3 +1450,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..1e3e2f7 --- /dev/null +++ b/src/features/product/resolvers/product-search-query.resolver.spec.ts @@ -0,0 +1,82 @@ +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); + }); + + it('searchProductFacets: 가격 분포를 반환한다', async () => { + await createProduct(prisma, { + name: '리졸버 케이크', + regular_price: 12000, + }); + + const result = await resolver.searchProductFacets({ keyword: '리졸버' }); + + expect(result.totalCount).toBe(1); + expect(result.buckets.find((b) => b.minPrice === 10000)?.count).toBe(1); + }); +}); diff --git a/src/features/product/resolvers/product-search-query.resolver.ts b/src/features/product/resolvers/product-search-query.resolver.ts new file mode 100644 index 0000000..d1fa6b9 --- /dev/null +++ b/src/features/product/resolvers/product-search-query.resolver.ts @@ -0,0 +1,42 @@ +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, + SearchProductFacets, +} 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); + } + + @Query('searchProductFacets') + searchProductFacets( + @Args('input') input: SearchProductFacetsInput, + ): Promise { + return this.service.searchProductFacets(input); + } +} diff --git a/src/features/product/services/product-best-seller.service.spec.ts b/src/features/product/services/product-best-seller.service.spec.ts new file mode 100644 index 0000000..0bbeddb --- /dev/null +++ b/src/features/product/services/product-best-seller.service.spec.ts @@ -0,0 +1,207 @@ +import type { OrderStatus, PrismaClient, Product, Store } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { ProductBestSellerService } from '@/features/product/services/product-best-seller.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createOrder, + createOrderItem, + createProduct, + createStore, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('ProductBestSellerService (real DB)', () => { + let service: ProductBestSellerService; + let prisma: PrismaClient; + let clock: ClockService; + + const NOW = new Date('2026-08-31T13:00:00.000Z'); + const HOUR_MS = 60 * 60 * 1000; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ProductBestSellerService, ProductRepository, ClockService], + }); + service = module.get(ProductBestSellerService); + clock = module.get(ClockService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + jest.spyOn(clock, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + /** 상품에 주문 1건(수량 quantity)을 만든다. 기본은 CONFIRMED, 현재 시각 1시간 전. */ + async function sell( + product: Product, + quantity: number, + opts: { status?: OrderStatus; hoursAgo?: number } = {}, + ): Promise { + const order = await createOrder(prisma, { + status: opts.status ?? 'CONFIRMED', + }); + await prisma.order.update({ + where: { id: order.id }, + data: { + created_at: new Date(NOW.getTime() - (opts.hoursAgo ?? 1) * HOUR_MS), + }, + }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + quantity, + }); + } + + async function makeCake( + store: Store, + name: string, + overrides: Parameters[1] = {}, + ): Promise { + return createProduct(prisma, { store_id: store.id, name, ...overrides }); + } + + describe('realtimeBestCakes', () => { + it('최근 24시간 판매 수량 합 desc로 정렬하고 rank를 매긴다', async () => { + const store = await createStore(prisma); + const a = await makeCake(store, '수량 5'); + const b = await makeCake(store, '수량 7'); + const c = await makeCake(store, '수량 2'); + await sell(a, 2); + await sell(a, 3); + await sell(b, 7); + await sell(c, 2); + + const result = await service.realtimeBestCakes(); + + expect(result.items.map((i) => [i.rank, i.name])).toEqual([ + [1, '수량 7'], + [2, '수량 5'], + [3, '수량 2'], + ]); + expect(result.rankedAt).toEqual(NOW); + }); + + it('판매가 0인 상품은 제외하고, 아무 판매도 없으면 빈 목록', async () => { + const store = await createStore(prisma); + await makeCake(store, '미판매'); + + const result = await service.realtimeBestCakes(); + + expect(result.items).toEqual([]); + expect(result.rankedAt).toEqual(NOW); + }); + + it('24시간 이전 주문·취소/접수 상태 주문·삭제된 주문은 집계하지 않는다', async () => { + const store = await createStore(prisma); + const stale = await makeCake(store, '오래된 판매'); + const canceled = await makeCake(store, '취소'); + const submitted = await makeCake(store, '접수만'); + const fresh = await makeCake(store, '유효'); + await sell(stale, 9, { hoursAgo: 25 }); + await sell(canceled, 9, { status: 'CANCELED' }); + await sell(submitted, 9, { status: 'SUBMITTED' }); + await sell(fresh, 1, { hoursAgo: 23 }); + const deletedOrder = await createOrder(prisma, { + status: 'CONFIRMED', + deleted_at: NOW, + }); + await createOrderItem(prisma, { + order_id: deletedOrder.id, + product_id: canceled.id, + quantity: 9, + }); + + const result = await service.realtimeBestCakes(); + + expect(result.items.map((i) => i.name)).toEqual(['유효']); + }); + + it('비활성 상품·비활성 매장 상품은 후보에서 제외한다', async () => { + const store = await createStore(prisma); + const inactiveStore = await createStore(prisma, { is_active: false }); + const inactive = await makeCake(store, '비활성', { is_active: false }); + const ofInactiveStore = await makeCake(inactiveStore, '비활성 매장'); + const active = await makeCake(store, '활성'); + await sell(inactive, 9); + await sell(ofInactiveStore, 9); + await sell(active, 1); + + const result = await service.realtimeBestCakes(); + + expect(result.items.map((i) => i.name)).toEqual(['활성']); + }); + + it('수량 동률은 인기 점수(찜 수)로 푼다', async () => { + const store = await createStore(prisma); + const plain = await makeCake(store, '찜 없음'); + const liked = await makeCake(store, '찜 있음'); + await sell(plain, 3); + await sell(liked, 3); + const account = await createAccount(prisma, { account_type: 'USER' }); + await prisma.wishlistItem.create({ + data: { account_id: account.id, product_id: liked.id }, + }); + + const result = await service.realtimeBestCakes(); + + expect(result.items.map((i) => i.name)).toEqual(['찜 있음', '찜 없음']); + }); + + it('limit만큼만 반환하며 상한 20을 넘지 않는다', async () => { + const store = await createStore(prisma); + for (let i = 0; i < 3; i += 1) { + await sell(await makeCake(store, `케이크 ${i}`), i + 1); + } + + const limited = await service.realtimeBestCakes({ limit: 2 }); + expect(limited.items).toHaveLength(2); + + const clamped = await service.realtimeBestCakes({ limit: 99 }); + expect(clamped.items).toHaveLength(3); + }); + + 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' }, + }); + await sell(cake, 1); + + const result = await service.realtimeBestCakes(); + + expect(result.items[0]).toMatchObject({ + id: cake.id.toString(), + storeId: store.id.toString(), + storeName: '청라 케이크', + regionLabel: '인천 청라동', + regularPrice: 40000, + salePrice: 30000, + discountRate: 25, + thumbnailUrl: 'https://img/1.png', + }); + }); + }); +}); diff --git a/src/features/product/services/product-best-seller.service.ts b/src/features/product/services/product-best-seller.service.ts new file mode 100644 index 0000000..7a10013 --- /dev/null +++ b/src/features/product/services/product-best-seller.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; + +import { ClockService } from '@/common/providers/clock.service'; +import { DAY_MS } from '@/common/utils/kst-time'; +import { + DEFAULT_REALTIME_BEST_LIMIT, + MAX_REALTIME_BEST_LIMIT, + REALTIME_BEST_WINDOW_HOURS, +} from '@/features/product/constants/product-best-seller.constants'; +import type { RealtimeBestCakesInput } from '@/features/product/dto/inputs/realtime-best-cakes.input'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { toPopularCake } from '@/features/product/services/product-home-mappers.helper'; +import type { RealtimeBestCakesResult } from '@/features/product/types/product-best-seller-output.type'; +import { + DEFAULT_GLOBAL_RATING_PRIOR, + RANKING_RECENT_ORDER_DAYS, + scoreAndSortByPopularity, +} from '@/features/store'; + +const HOUR_MS = 60 * 60 * 1000; + +@Injectable() +export class ProductBestSellerService { + constructor( + private readonly repo: ProductRepository, + private readonly clock: ClockService, + ) {} + + /** + * 검색 진입 화면 '실시간 판매 Best'. 최근 24시간 유효 주문(인기 점수와 동일 상태 집합)의 + * 수량 합 desc로 정렬하고, 동률은 인기 점수(→ 리뷰수 → id desc) 순으로 푼다(자체 판단). + * 판매가 0인 상품은 'Best'가 아니므로 제외 — 데이터가 적으면 빈 목록이 될 수 있다(사용자 확정). + * 호출 시점에 실시간 집계하며 rankedAt은 호출 시각이다(스냅샷 없음). + */ + async realtimeBestCakes( + input?: RealtimeBestCakesInput, + ): Promise { + const limit = Math.min( + input?.limit ?? DEFAULT_REALTIME_BEST_LIMIT, + MAX_REALTIME_BEST_LIMIT, + ); + const rankedAt = this.clock.now(); + + const candidates = await this.repo.findActiveCakesForRanking({}); + if (candidates.length === 0) return { items: [], rankedAt }; + + const productIds = candidates.map((c) => c.id); + const soldQuantities = await this.repo.aggregateProductSoldQuantities( + productIds, + new Date(rankedAt.getTime() - REALTIME_BEST_WINDOW_HOURS * HOUR_MS), + ); + const sold = candidates.filter((c) => (soldQuantities.get(c.id) ?? 0) > 0); + if (sold.length === 0) return { items: [], rankedAt }; + + // 동률 해소용 인기 점수 — 판매된 상품에 대해서만 집계한다 + const soldIds = sold.map((c) => c.id); + const since = new Date( + rankedAt.getTime() - RANKING_RECENT_ORDER_DAYS * DAY_MS, + ); + const [wishlistCounts, reviewStats, recentOrderCounts, globalAverage] = + await Promise.all([ + this.repo.aggregateProductWishlistCounts(soldIds), + this.repo.aggregateProductReviewStats(soldIds), + this.repo.aggregateProductRecentOrderCounts(soldIds, since), + this.repo.globalReviewAverage(), + ]); + const byPopularity = scoreAndSortByPopularity( + sold, + { wishlistCounts, reviewStats, recentOrderCounts }, + globalAverage ?? DEFAULT_GLOBAL_RATING_PRIOR, + ); + + // 인기순으로 이미 정렬된 배열을 안정 정렬(quantity desc)하면 동률 순서가 인기순으로 남는다 + const ranked = [...byPopularity].sort( + (a, b) => + (soldQuantities.get(b.candidate.id) ?? 0) - + (soldQuantities.get(a.candidate.id) ?? 0), + ); + + return { + items: ranked + .slice(0, limit) + .map((entry, idx) => toPopularCake(entry.candidate, idx + 1)), + rankedAt, + }; + } +} diff --git a/src/features/product/services/product-search-mappers.helper.spec.ts b/src/features/product/services/product-search-mappers.helper.spec.ts new file mode 100644 index 0000000..8252517 --- /dev/null +++ b/src/features/product/services/product-search-mappers.helper.spec.ts @@ -0,0 +1,57 @@ +import { + buildPriceBuckets, + displayPrice, +} from '@/features/product/services/product-search-mappers.helper'; + +describe('product-search mappers', () => { + describe('displayPrice', () => { + it('할인가가 있으면 할인가, 없으면 정가', () => { + expect(displayPrice({ regular_price: 40000, sale_price: 30000 })).toBe( + 30000, + ); + expect(displayPrice({ regular_price: 40000, sale_price: null })).toBe( + 40000, + ); + }); + }); + + describe('buildPriceBuckets', () => { + it('0~70,000을 5,000원 폭 14개 + "이상" 버킷 1개로 만들고 빈 구간도 0으로 채운다', () => { + const buckets = buildPriceBuckets([]); + + expect(buckets).toHaveLength(15); + expect(buckets[0]).toEqual({ minPrice: 0, maxPrice: 5000, count: 0 }); + expect(buckets[13]).toEqual({ + minPrice: 65000, + maxPrice: 70000, + count: 0, + }); + expect(buckets[14]).toEqual({ + minPrice: 70000, + maxPrice: null, + count: 0, + }); + }); + + it('경계값은 상위 버킷에 속하고(5,000 → [5,000, 10,000)), 상한 이상은 마지막 버킷', () => { + const buckets = buildPriceBuckets([4999, 5000, 69999, 70000, 120000]); + + expect(buckets[0].count).toBe(1); + expect(buckets[1].count).toBe(1); + expect(buckets[13].count).toBe(1); + expect(buckets[14].count).toBe(2); + }); + + it('폭·상한을 바꿔도 마지막 버킷 상한이 max로 잘린다', () => { + const buckets = buildPriceBuckets([7000], 3000, 7000); + + expect(buckets.map((b) => [b.minPrice, b.maxPrice])).toEqual([ + [0, 3000], + [3000, 6000], + [6000, 7000], + [7000, null], + ]); + expect(buckets[3].count).toBe(1); + }); + }); +}); diff --git a/src/features/product/services/product-search-mappers.helper.ts b/src/features/product/services/product-search-mappers.helper.ts new file mode 100644 index 0000000..59e4fae --- /dev/null +++ b/src/features/product/services/product-search-mappers.helper.ts @@ -0,0 +1,66 @@ +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 { + SearchPriceBucket, + 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, + }; +} + +/** + * 표시가 목록을 고정 폭 버킷 [min, min+size)으로 센다. 마지막 버킷은 [max, ∞)로 + * maxPrice null. 값이 없는 구간도 count 0으로 모두 반환해 FE가 막대 자리를 고정할 수 있게 한다. + */ +export function buildPriceBuckets( + prices: number[], + size: number = FACET_PRICE_BUCKET_SIZE, + max: number = FACET_PRICE_BUCKET_MAX, +): SearchPriceBucket[] { + const bucketCount = Math.ceil(max / size); + const counts = new Array(bucketCount + 1).fill(0); + for (const price of prices) { + const index = price >= max ? bucketCount : Math.floor(price / size); + counts[index] += 1; + } + return counts.map((count, i) => ({ + minPrice: i === bucketCount ? max : i * size, + maxPrice: i === bucketCount ? null : Math.min((i + 1) * size, max), + count, + })); +} diff --git a/src/features/product/services/product-search.service.spec.ts b/src/features/product/services/product-search.service.spec.ts new file mode 100644 index 0000000..19cf482 --- /dev/null +++ b/src/features/product/services/product-search.service.spec.ts @@ -0,0 +1,478 @@ +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); + }); + }); + + describe('searchProductFacets', () => { + it('가격 조건을 제외한 조건으로 표시가 분포·최저/최고가·건수를 낸다', async () => { + const store = await createStore(prisma); + await makeCake(store, '케이크 3만', { regular_price: 30000 }); + await makeCake(store, '케이크 할인 4만', { + regular_price: 60000, + sale_price: 40000, + }); + await makeCake(store, '케이크 4.5만', { regular_price: 45000 }); + await makeCake(store, '케이크 8만', { regular_price: 80000 }); + await makeCake(store, '타르트', { regular_price: 1000 }); + + const facets = await service.searchProductFacets({ keyword: '케이크' }); + + expect(facets.totalCount).toBe(4); + expect(facets.minPrice).toBe(30000); + expect(facets.maxPrice).toBe(80000); + const countAt = (min: number) => + facets.buckets.find((b) => b.minPrice === min)?.count; + expect(countAt(30000)).toBe(1); + expect(countAt(40000)).toBe(1); + expect(countAt(45000)).toBe(1); + expect(countAt(70000)).toBe(1); + expect(countAt(0)).toBe(0); + }); + + it('카테고리·지역 조건을 반영하고 결과가 없으면 min/max null', async () => { + const store = await createStore(prisma); + const birthday = await makeCake(store, '케이크 생일', { + regular_price: 20000, + }); + const birthdayId = await categorize(birthday, 'EVENT', '생일'); + await makeCake(store, '케이크 기타', { regular_price: 50000 }); + + const filtered = await service.searchProductFacets({ + keyword: '케이크', + eventCategoryIds: [birthdayId.toString()], + }); + expect(filtered.totalCount).toBe(1); + expect(filtered.minPrice).toBe(20000); + + const empty = await service.searchProductFacets({ keyword: '없음' }); + expect(empty).toMatchObject({ + totalCount: 0, + minPrice: null, + maxPrice: null, + }); + expect(empty.buckets).toHaveLength(15); + }); + }); +}); diff --git a/src/features/product/services/product-search.service.ts b/src/features/product/services/product-search.service.ts new file mode 100644 index 0000000..1a0ec6b --- /dev/null +++ b/src/features/product/services/product-search.service.ts @@ -0,0 +1,234 @@ +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 { 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, + SearchProductFacets, +} 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), + }; + } + + /** + * 가격대 시트 히스토그램. 가격 조건을 뺀 나머지 조건(키워드·카테고리·지역)으로 표시가를 + * 모아 5,000원 버킷으로 센다. 상품 수 소규모 전제의 메모리 집계(자체 판단 — 규모가 커지면 + * SQL FLOOR 그룹핑으로 전환). 'N개 상품보기' 카운트는 searchProducts.totalCount를 쓴다. + */ + async searchProductFacets( + input: SearchProductFacetsInput, + ): Promise { + const filter = this.toFilter({ + ...input, + minPrice: undefined, + maxPrice: undefined, + }); + const rows = await this.repo.findProductSearchPrices(filter); + const prices = rows.map(displayPrice); + // spread(Math.min(...prices))는 대략 12만 개 이상에서 인자 개수 한도로 터진다 — 순회로 방어 + let minPrice: number | null = null; + let maxPrice: number | null = null; + for (const price of prices) { + if (minPrice === null || price < minPrice) minPrice = price; + if (maxPrice === null || price > maxPrice) maxPrice = price; + } + return { + buckets: buildPriceBuckets(prices), + minPrice, + maxPrice, + totalCount: prices.length, + }; + } + + /** 검색 요약 탭의 상품 건수(필터·정렬 없이 키워드+지역). */ + countProducts(scope: ProductSearchScope): Promise { + return this.repo.countProductSearch(scope); + } + + private toFilter( + input: Pick< + SearchProductsInput, + | 'keyword' + | 'eventCategoryIds' + | 'styleCategoryIds' + | 'minPrice' + | 'maxPrice' + | 'regionIds' + >, + ): 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-best-seller-output.type.ts b/src/features/product/types/product-best-seller-output.type.ts new file mode 100644 index 0000000..c392cfc --- /dev/null +++ b/src/features/product/types/product-best-seller-output.type.ts @@ -0,0 +1,8 @@ +import type { PopularCake } from '@/features/product/types/product-home-output.type'; + +/** realtimeBestCakes 결과(SDL search-entry.graphql RealtimeBestCakesResult). */ +export interface RealtimeBestCakesResult { + items: PopularCake[]; + /** 집계 기준 시각(호출 시점). 화면의 'HH:mm 기준' 표기. */ + rankedAt: Date; +} 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..e7c2bc8 --- /dev/null +++ b/src/features/product/types/product-search-output.type.ts @@ -0,0 +1,38 @@ +/** + * 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; +} + +export interface SearchPriceBucket { + minPrice: number; + maxPrice: number | null; + count: number; +} + +export interface SearchProductFacets { + buckets: SearchPriceBucket[]; + minPrice: number | null; + maxPrice: number | null; + totalCount: number; +} diff --git a/src/features/search/constants/search.constants.ts b/src/features/search/constants/search.constants.ts new file mode 100644 index 0000000..8c3165e --- /dev/null +++ b/src/features/search/constants/search.constants.ts @@ -0,0 +1,16 @@ +/** 인기 검색어 기본 노출 개수(figma 검색 진입 시안 TOP10). */ +export const DEFAULT_POPULAR_KEYWORDS_LIMIT = 10; + +/** + * 스냅샷당 저장 순위 수. 노출은 10건이지만 직전 11~20위에서 올라온 키워드를 + * NEW가 아니라 UP으로 판정하기 위해 20건을 저장한다(사용자 확정). + */ +export const KEYWORD_RANK_SNAPSHOT_SIZE = 20; + +/** popularSearchKeywords limit 상한(= 스냅샷 저장 크기). */ +export const MAX_POPULAR_KEYWORDS_LIMIT = KEYWORD_RANK_SNAPSHOT_SIZE; + +/** 스냅샷 집계 윈도우(시간). 직전 24시간 SearchEvent를 keyword별로 센다. */ +export const KEYWORD_RANK_WINDOW_HOURS = 24; + +export const HOUR_MS = 60 * 60 * 1000; diff --git a/src/features/search/dto/inputs/popular-search-keywords.input.ts b/src/features/search/dto/inputs/popular-search-keywords.input.ts new file mode 100644 index 0000000..aa37e32 --- /dev/null +++ b/src/features/search/dto/inputs/popular-search-keywords.input.ts @@ -0,0 +1,11 @@ +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +import { MAX_POPULAR_KEYWORDS_LIMIT } from '@/features/search/constants/search.constants'; + +export class PopularSearchKeywordsInput { + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_POPULAR_KEYWORDS_LIMIT) + limit?: number; +} 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/repositories/search.repository.spec.ts b/src/features/search/repositories/search.repository.spec.ts new file mode 100644 index 0000000..197d500 --- /dev/null +++ b/src/features/search/repositories/search.repository.spec.ts @@ -0,0 +1,61 @@ +import type { PrismaClient } from '@prisma/client'; + +import { SearchRepository } from '@/features/search/repositories/search.repository'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** repository에서만 도달 가능한 계약(uk 충돌 흡수·빈 입력) 검증. */ +describe('SearchRepository (real DB)', () => { + let repo: SearchRepository; + let prisma: PrismaClient; + const rankedAt = new Date('2026-08-31T13:00:00.000Z'); + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [SearchRepository], + }); + repo = module.get(SearchRepository); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + describe('createSnapshot', () => { + it('빈 집계는 저장하지 않고 false', async () => { + expect(await repo.createSnapshot({ rankedAt, rows: [] })).toBe(false); + expect(await prisma.searchKeywordRankSnapshot.count()).toBe(0); + }); + + it('같은 ranked_at 경합(uk 충돌)은 false로 흡수하고 기존 스냅샷을 보존한다', async () => { + const rows = [{ keyword: '생일', count: 3 }]; + expect(await repo.createSnapshot({ rankedAt, rows })).toBe(true); + + expect( + await repo.createSnapshot({ + rankedAt, + rows: [{ keyword: '덮어쓰기', count: 9 }], + }), + ).toBe(false); + + const saved = await repo.listSnapshotRows(rankedAt); + expect(saved).toEqual([{ rank: 1, keyword: '생일', search_count: 3 }]); + }); + + it('uk 충돌이 아닌 DB 오류는 그대로 던진다', async () => { + await expect( + repo.createSnapshot({ + rankedAt, + rows: [{ keyword: 'x'.repeat(201), count: 1 }], + }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/src/features/search/repositories/search.repository.ts b/src/features/search/repositories/search.repository.ts new file mode 100644 index 0000000..e57e440 --- /dev/null +++ b/src/features/search/repositories/search.repository.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { KEYWORD_RANK_SNAPSHOT_SIZE } from '@/features/search/constants/search.constants'; +import { PrismaService } from '@/prisma/prisma.service'; + +export interface KeywordCountRow { + keyword: string; + count: number; +} + +export interface KeywordRankSnapshotRow { + rank: number; + keyword: string; + search_count: number; +} + +@Injectable() +export class SearchRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * 검색 실행 기록. 로그인 사용자는 최근 검색어(SearchHistory)도 함께 갱신한다 — + * uk(account_id, keyword) upsert라 soft-delete된 항목은 복원되고 last_used_at만 앞당겨진다. + * 두 쓰기는 한 트랜잭션으로 묶어 집계 이벤트만 남고 최근 검색어가 빠지는 상태를 막는다. + */ + async recordSearch(args: { + accountId: bigint | null; + keyword: string; + now: Date; + }): Promise { + const event = this.prisma.searchEvent.create({ + data: { + account_id: args.accountId, + keyword: args.keyword, + context: 'GLOBAL', + created_at: args.now, + }, + }); + if (args.accountId === null) { + await event; + return; + } + await this.prisma.$transaction([ + event, + this.prisma.searchHistory.upsert({ + where: { + account_id_keyword: { + account_id: args.accountId, + keyword: args.keyword, + }, + }, + create: { + account_id: args.accountId, + keyword: args.keyword, + last_used_at: args.now, + }, + update: { last_used_at: args.now, deleted_at: null }, + }), + ]); + } + + /** + * [since, until) 윈도우의 keyword별 검색 횟수 상위 N. 동률은 keyword asc로 고정해 + * 스냅샷 순위가 결정적으로 나오게 한다(직전 스냅샷 비교가 흔들리지 않도록). + */ + async countKeywordsInWindow(args: { + since: Date; + until: Date; + limit: number; + }): Promise { + const rows = await this.prisma.searchEvent.groupBy({ + by: ['keyword'], + where: { created_at: { gte: args.since, lt: args.until } }, + _count: { _all: true }, + orderBy: [{ _count: { keyword: 'desc' } }, { keyword: 'asc' }], + take: args.limit, + }); + return rows.map((r) => ({ keyword: r.keyword, count: r._count._all })); + } + + async snapshotExists(rankedAt: Date): Promise { + const count = await this.prisma.searchKeywordRankSnapshot.count({ + where: { ranked_at: rankedAt }, + }); + return count > 0; + } + + /** + * 스냅샷 저장. 같은 ranked_at이 이미 있으면(크론·부트스트랩 경합) uk 충돌을 + * "이미 생성됨"으로 흡수해 false를 반환한다. + */ + async createSnapshot(args: { + rankedAt: Date; + rows: KeywordCountRow[]; + }): Promise { + if (args.rows.length === 0) return false; + try { + await this.prisma.searchKeywordRankSnapshot.createMany({ + data: args.rows.slice(0, KEYWORD_RANK_SNAPSHOT_SIZE).map((row, i) => ({ + ranked_at: args.rankedAt, + rank: i + 1, + keyword: row.keyword, + search_count: row.count, + })), + }); + return true; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + return false; + } + throw err; + } + } + + /** 가장 최근 스냅샷 시각. `before` 지정 시 그보다 이전 것 중 최근(직전 스냅샷 탐색용). */ + async findLatestSnapshotAt(before?: Date): Promise { + const row = await this.prisma.searchKeywordRankSnapshot.findFirst({ + where: before ? { ranked_at: { lt: before } } : undefined, + orderBy: { ranked_at: 'desc' }, + select: { ranked_at: true }, + }); + return row?.ranked_at ?? null; + } + + async listSnapshotRows( + rankedAt: Date, + limit?: number, + ): Promise { + return this.prisma.searchKeywordRankSnapshot.findMany({ + where: { ranked_at: rankedAt }, + orderBy: { rank: 'asc' }, + take: limit, + select: { rank: true, keyword: true, search_count: true }, + }); + } +} diff --git a/src/features/search/resolvers/search-entry-mutation.resolver.ts b/src/features/search/resolvers/search-entry-mutation.resolver.ts new file mode 100644 index 0000000..2bb076f --- /dev/null +++ b/src/features/search/resolvers/search-entry-mutation.resolver.ts @@ -0,0 +1,28 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { SearchEntryService } from '@/features/search/services/search-entry.service'; +import { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** + * 검색 실행 기록 resolver. 비로그인도 호출 가능(집계 이벤트만), 로그인 시 최근 검색어까지 갱신. + */ +@Resolver('Mutation') +export class SearchEntryMutationResolver { + constructor(private readonly service: SearchEntryService) {} + + @Mutation('recordSearch') + @UseGuards(OptionalJwtAuthGuard) + recordSearch( + @Args('keyword') keyword: string, + @CurrentUser() user: JwtUser | undefined, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.service.recordSearch(keyword, accountId); + } +} diff --git a/src/features/search/resolvers/search-entry-query.resolver.spec.ts b/src/features/search/resolvers/search-entry-query.resolver.spec.ts new file mode 100644 index 0000000..f95d20b --- /dev/null +++ b/src/features/search/resolvers/search-entry-query.resolver.spec.ts @@ -0,0 +1,116 @@ +import type { PrismaClient } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { + ProductBestSellerService, + ProductRepository, +} 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 { SearchEntryService } from '@/features/search/services/search-entry.service'; +import { SearchKeywordRankService } from '@/features/search/services/search-keyword-rank.service'; +import type { JwtUser } from '@/global/auth'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createKeywordRankSnapshot, + createOrder, + createOrderItem, + createProduct, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/집계 세부 검증은 service.spec.ts에서 담당. + */ +describe('SearchEntry Resolvers (real DB)', () => { + let queryResolver: SearchEntryQueryResolver; + let mutationResolver: SearchEntryMutationResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SearchEntryQueryResolver, + SearchEntryMutationResolver, + SearchEntryService, + SearchKeywordRankService, + SearchRepository, + ProductBestSellerService, + ProductRepository, + ClockService, + ], + }); + queryResolver = module.get(SearchEntryQueryResolver); + mutationResolver = module.get(SearchEntryMutationResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('popularSearchKeywords: 최신 스냅샷을 반환한다', async () => { + const rankedAt = new Date('2026-08-31T13:00:00.000Z'); + await createKeywordRankSnapshot(prisma, { + ranked_at: rankedAt, + keywords: [{ keyword: '생일 케이크', count: 7 }], + }); + + const result = await queryResolver.popularSearchKeywords({ limit: 10 }); + + expect(result.rankedAt).toEqual(rankedAt); + expect(result.items).toEqual([ + { rank: 1, keyword: '생일 케이크', trend: 'NEW', searchCount: 7 }, + ]); + }); + + it('recordSearch: 로그인 사용자는 최근 검색어까지 기록한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const user = { accountId: account.id.toString() } as JwtUser; + + const result = await mutationResolver.recordSearch(' 레터링 ', user); + + expect(result).toBe(true); + expect(await prisma.searchEvent.count()).toBe(1); + expect( + await prisma.searchHistory.count({ + where: { account_id: account.id, keyword: '레터링' }, + }), + ).toBe(1); + }); + + it('recordSearch: 비로그인은 집계 이벤트만 남긴다', async () => { + await mutationResolver.recordSearch('도넛', undefined); + + expect(await prisma.searchEvent.count()).toBe(1); + expect(await prisma.searchHistory.count()).toBe(0); + }); + + it('realtimeBestCakes: 판매된 상품을 수량순으로 반환한다', async () => { + const product = await createProduct(prisma, { name: '베스트' }); + const order = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + quantity: 2, + }); + + const result = await queryResolver.realtimeBestCakes({ limit: 5 }); + + expect(result.items.map((i) => i.name)).toEqual(['베스트']); + expect(result.rankedAt).toBeInstanceOf(Date); + }); + + it('searchBanner: 등록된 SEARCH 배너가 없으면 null', async () => { + expect(await queryResolver.searchBanner()).toBeNull(); + }); +}); diff --git a/src/features/search/resolvers/search-entry-query.resolver.ts b/src/features/search/resolvers/search-entry-query.resolver.ts new file mode 100644 index 0000000..8a29654 --- /dev/null +++ b/src/features/search/resolvers/search-entry-query.resolver.ts @@ -0,0 +1,43 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { + type HomeBanner, + ProductBestSellerService, + RealtimeBestCakesInput, + type RealtimeBestCakesResult, +} from '@/features/product'; +import { PopularSearchKeywordsInput } from '@/features/search/dto/inputs/popular-search-keywords.input'; +import { SearchEntryService } from '@/features/search/services/search-entry.service'; +import { SearchKeywordRankService } from '@/features/search/services/search-keyword-rank.service'; +import type { PopularSearchKeywordsResult } from '@/features/search/types/search-entry-output.type'; + +/** + * 검색 진입 화면 조회 resolver. 개인화 필드가 없는 public query(인증 불필요). + */ +@Resolver('Query') +export class SearchEntryQueryResolver { + constructor( + private readonly rankService: SearchKeywordRankService, + private readonly entryService: SearchEntryService, + private readonly bestSellerService: ProductBestSellerService, + ) {} + + @Query('popularSearchKeywords') + popularSearchKeywords( + @Args('input', { nullable: true }) input?: PopularSearchKeywordsInput, + ): Promise { + return this.rankService.popularSearchKeywords(input); + } + + @Query('realtimeBestCakes') + realtimeBestCakes( + @Args('input', { nullable: true }) input?: RealtimeBestCakesInput, + ): Promise { + return this.bestSellerService.realtimeBestCakes(input); + } + + @Query('searchBanner') + searchBanner(): Promise { + return this.entryService.searchBanner(); + } +} 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-entry.graphql b/src/features/search/search-entry.graphql new file mode 100644 index 0000000..56cd8c6 --- /dev/null +++ b/src/features/search/search-entry.graphql @@ -0,0 +1,55 @@ +extend type Query { + """검색 진입 화면 '인기 검색어' TOP10(매시 정각 스냅샷 기준, 순위 변동 포함). 스냅샷이 없으면 빈 목록 + rankedAt null. 비로그인 접근 가능.""" + popularSearchKeywords(input: PopularSearchKeywordsInput): PopularSearchKeywordsResult! + + """검색 진입 화면 '실시간 판매 Best'(최근 24시간 유효 주문 수량 합 desc, 판매 0건 제외). 비로그인 접근 가능.""" + realtimeBestCakes(input: RealtimeBestCakesInput): RealtimeBestCakesResult! + + """검색 진입 화면 배너(placement=SEARCH) 1건. 등록된 배너가 없으면 null(FE placeholder 처리). 비로그인 접근 가능.""" + searchBanner: HomeBanner +} + +extend type Mutation { + """검색 실행 기록. 로그인 시 최근 검색어(SearchHistory) 갱신 + 인기 검색어 집계 이벤트 기록, 비로그인 시 집계 이벤트만. 검색 실행 시 1회 호출(탭 전환·페이지네이션에는 호출하지 않는다).""" + recordSearch(keyword: String!): Boolean! +} + +input PopularSearchKeywordsInput { + """노출 개수(최대 20 — 스냅샷 저장 상한).""" + limit: Int = 10 +} + +"""직전 스냅샷 대비 순위 변동.""" +enum SearchKeywordTrend { + UP + DOWN + SAME + NEW +} + +type PopularSearchKeywordsResult { + items: [PopularSearchKeyword!]! + """스냅샷 기준 시각(정각). 화면의 'YY.MM.DD HH:mm 기준' 표기. 스냅샷이 없으면 null.""" + rankedAt: DateTime +} + +type PopularSearchKeyword { + """순위(1부터).""" + rank: Int! + keyword: String! + trend: SearchKeywordTrend! + """집계 윈도우(직전 24시간) 내 검색 횟수.""" + searchCount: Int! +} + +input RealtimeBestCakesInput { + """카드 수(최대 20).""" + limit: Int = 10 +} + +"""실시간 판매 Best. 카드는 홈 인기 케이크와 동일 타입(지역·매장명·케이크명·가격·할인율).""" +type RealtimeBestCakesResult { + items: [PopularCake!]! + """집계 기준 시각(호출 시점). 화면의 'YY.MM.DD HH:mm 기준' 표기.""" + rankedAt: DateTime! +} 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 new file mode 100644 index 0000000..525ff36 --- /dev/null +++ b/src/features/search/search.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; + +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, StoreModule], + providers: [ + SearchRepository, + SearchEntryService, + SearchKeywordRankService, + SearchKeywordRankScheduler, + SearchEntryQueryResolver, + SearchEntryMutationResolver, + SearchResultService, + SearchResultQueryResolver, + ], +}) +export class SearchModule {} diff --git a/src/features/search/services/search-entry.service.spec.ts b/src/features/search/services/search-entry.service.spec.ts new file mode 100644 index 0000000..7ee5d05 --- /dev/null +++ b/src/features/search/services/search-entry.service.spec.ts @@ -0,0 +1,178 @@ +import { BadRequestException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { ProductRepository } from '@/features/product'; +import { SearchRepository } from '@/features/search/repositories/search.repository'; +import { SearchEntryService } from '@/features/search/services/search-entry.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createSearchHistory, + createStore, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('SearchEntryService (real DB)', () => { + let service: SearchEntryService; + let prisma: PrismaClient; + let clock: ClockService; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SearchEntryService, + SearchRepository, + ProductRepository, + ClockService, + ], + }); + service = module.get(SearchEntryService); + clock = module.get(ClockService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + jest.restoreAllMocks(); + }); + + async function searchEvents() { + return prisma.searchEvent.findMany({ orderBy: { id: 'asc' } }); + } + + async function activeHistories(accountId: bigint) { + return prisma.searchHistory.findMany({ + where: { account_id: accountId }, + orderBy: { id: 'asc' }, + }); + } + + describe('recordSearch', () => { + it('비로그인은 정규화된 검색어로 SearchEvent만 남긴다', async () => { + const result = await service.recordSearch(' 딸기 케이크 '); + + expect(result).toBe(true); + const events = await searchEvents(); + expect(events).toHaveLength(1); + expect(events[0].keyword).toBe('딸기 케이크'); + expect(events[0].account_id).toBeNull(); + expect(events[0].context).toBe('GLOBAL'); + expect(await prisma.searchHistory.count()).toBe(0); + }); + + it('로그인은 SearchEvent + SearchHistory를 함께 기록한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const now = new Date('2026-08-31T03:00:00.000Z'); + jest.spyOn(clock, 'now').mockReturnValue(now); + + await service.recordSearch('레터링', account.id); + + const events = await searchEvents(); + expect(events).toHaveLength(1); + expect(events[0].account_id).toBe(account.id); + const histories = await activeHistories(account.id); + expect(histories).toHaveLength(1); + expect(histories[0].keyword).toBe('레터링'); + expect(histories[0].last_used_at).toEqual(now); + }); + + it('같은 검색어 재검색은 SearchHistory를 1건으로 유지하고 last_used_at만 갱신한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createSearchHistory(prisma, { + account_id: account.id, + keyword: '레터링', + last_used_at: new Date('2026-01-01T00:00:00.000Z'), + }); + const now = new Date('2026-08-31T03:00:00.000Z'); + jest.spyOn(clock, 'now').mockReturnValue(now); + + await service.recordSearch('레터링', account.id); + + const histories = await activeHistories(account.id); + expect(histories).toHaveLength(1); + expect(histories[0].last_used_at).toEqual(now); + // 집계 이벤트는 검색 횟수만큼 쌓인다 + expect(await searchEvents()).toHaveLength(1); + }); + + it('soft-delete된 최근 검색어는 복원한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createSearchHistory(prisma, { + account_id: account.id, + keyword: '도넛', + deleted_at: new Date('2026-01-02T00:00:00.000Z'), + }); + + await service.recordSearch('도넛', account.id); + + const histories = await activeHistories(account.id); + expect(histories).toHaveLength(1); + expect(histories[0].deleted_at).toBeNull(); + }); + + it('공백만 있는 검색어는 400', async () => { + await expect(service.recordSearch(' ')).rejects.toThrow( + BadRequestException, + ); + expect(await searchEvents()).toHaveLength(0); + }); + + it('200자를 넘는 검색어는 400', async () => { + await expect(service.recordSearch('a'.repeat(201))).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('searchBanner', () => { + const now = new Date('2026-08-31T03:00:00.000Z'); + + async function makeBanner( + overrides: Partial[0]['data']>, + ) { + return prisma.banner.create({ + data: { + placement: 'SEARCH', + image_url: 'https://img/search.png', + link_type: 'NONE', + ...overrides, + }, + }); + } + + it('placement=SEARCH 활성 배너를 sort_order 순으로 1건 반환한다', async () => { + jest.spyOn(clock, 'now').mockReturnValue(now); + await makeBanner({ image_url: 'https://img/second.png', sort_order: 2 }); + await makeBanner({ image_url: 'https://img/first.png', sort_order: 1 }); + await makeBanner({ + placement: 'HOME_MAIN', + image_url: 'https://img/home.png', + }); + + const banner = await service.searchBanner(); + + expect(banner).toMatchObject({ + imageUrl: 'https://img/first.png', + linkType: 'NONE', + }); + }); + + it('노출 기간 밖·비활성·링크 대상이 내려간 배너는 건너뛴다', async () => { + jest.spyOn(clock, 'now').mockReturnValue(now); + await makeBanner({ starts_at: new Date('2026-09-01T00:00:00.000Z') }); + await makeBanner({ ends_at: new Date('2026-08-01T00:00:00.000Z') }); + await makeBanner({ is_active: false }); + const closedStore = await createStore(prisma, { is_active: false }); + await makeBanner({ link_type: 'STORE', link_store_id: closedStore.id }); + + expect(await service.searchBanner()).toBeNull(); + }); + }); +}); diff --git a/src/features/search/services/search-entry.service.ts b/src/features/search/services/search-entry.service.ts new file mode 100644 index 0000000..c0a84ba --- /dev/null +++ b/src/features/search/services/search-entry.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; + +import { ClockService } from '@/common/providers/clock.service'; +import { parseSearchKeyword } from '@/common/utils/search-keyword'; +import { + type HomeBanner, + ProductRepository, + toHomeBanner, +} from '@/features/product'; +import { SearchRepository } from '@/features/search/repositories/search.repository'; + +@Injectable() +export class SearchEntryService { + constructor( + private readonly repo: SearchRepository, + private readonly productRepo: ProductRepository, + private readonly clock: ClockService, + ) {} + + /** 검색 진입 배너(placement=SEARCH). 없으면 null — 홈 배너와 동일하게 fallback 없음. */ + async searchBanner(): Promise { + const row = await this.productRepo.findSearchBanner(this.clock.now()); + return row ? toHomeBanner(row) : null; + } + + /** + * 검색 실행 기록. 정규화(trim·공백 축약)된 검색어를 저장해 최근 검색어·인기 검색어가 + * 같은 키로 모이게 한다. 비로그인(accountId undefined)은 집계 이벤트만 남긴다. + */ + async recordSearch(rawKeyword: string, accountId?: bigint): Promise { + const { keyword } = parseSearchKeyword(rawKeyword); + await this.repo.recordSearch({ + accountId: accountId ?? null, + keyword, + now: this.clock.now(), + }); + return true; + } +} diff --git a/src/features/search/services/search-keyword-rank.scheduler.spec.ts b/src/features/search/services/search-keyword-rank.scheduler.spec.ts new file mode 100644 index 0000000..bc2098c --- /dev/null +++ b/src/features/search/services/search-keyword-rank.scheduler.spec.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; + +import { ClockService } from '@/common/providers/clock.service'; +import { SearchKeywordRankScheduler } from '@/features/search/services/search-keyword-rank.scheduler'; +import type { SearchKeywordRankService } from '@/features/search/services/search-keyword-rank.service'; + +describe('SearchKeywordRankScheduler', () => { + const now = new Date('2026-08-31T13:00:00.000Z'); + + function build(captureSnapshot: jest.Mock) { + const clock = new ClockService(); + jest.spyOn(clock, 'now').mockReturnValue(now); + const service = { captureSnapshot } as unknown as SearchKeywordRankService; + return new SearchKeywordRankScheduler(service, clock); + } + + beforeEach(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('부팅 시 현재 시각으로 스냅샷 생성을 시도한다', async () => { + const capture = jest.fn().mockResolvedValue(true); + const scheduler = build(capture); + + await scheduler.onApplicationBootstrap(); + + expect(capture).toHaveBeenCalledWith(now); + }); + + it('정각 크론도 동일하게 스냅샷 생성을 시도한다', async () => { + const capture = jest.fn().mockResolvedValue(false); + const scheduler = build(capture); + + await scheduler.handleHourly(); + + expect(capture).toHaveBeenCalledTimes(1); + }); + + it('스냅샷 생성 실패는 삼키고 로그만 남긴다', async () => { + const capture = jest.fn().mockRejectedValue(new Error('db down')); + const scheduler = build(capture); + + await expect(scheduler.handleHourly()).resolves.toBeUndefined(); + expect(Logger.prototype.error).toHaveBeenCalled(); + }); +}); diff --git a/src/features/search/services/search-keyword-rank.scheduler.ts b/src/features/search/services/search-keyword-rank.scheduler.ts new file mode 100644 index 0000000..4876dfb --- /dev/null +++ b/src/features/search/services/search-keyword-rank.scheduler.ts @@ -0,0 +1,43 @@ +import { + Injectable, + Logger, + type OnApplicationBootstrap, +} from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { ClockService } from '@/common/providers/clock.service'; +import { SearchKeywordRankService } from '@/features/search/services/search-keyword-rank.service'; + +/** + * 인기 검색어 스냅샷 크론(매시 정각, KST). 단일 서버 전제 — 다중 인스턴스가 되면 + * 분산 락이 필요하다(현재는 uk(ranked_at, rank) 충돌을 repo가 흡수해 중복 생성은 없다). + * 부팅 직후 현재 정각 스냅샷이 없으면 1회 즉시 만들어, 재시작 시 빈 화면을 줄인다. + * 스냅샷 실패는 앱 기동·다음 정각 실행을 막지 않도록 로그만 남긴다. + */ +@Injectable() +export class SearchKeywordRankScheduler implements OnApplicationBootstrap { + private readonly logger = new Logger(SearchKeywordRankScheduler.name); + + constructor( + private readonly rankService: SearchKeywordRankService, + private readonly clock: ClockService, + ) {} + + async onApplicationBootstrap(): Promise { + await this.captureSafely(); + } + + @Cron(CronExpression.EVERY_HOUR, { timeZone: 'Asia/Seoul' }) + async handleHourly(): Promise { + await this.captureSafely(); + } + + async captureSafely(): Promise { + try { + const created = await this.rankService.captureSnapshot(this.clock.now()); + if (created) this.logger.log('인기 검색어 스냅샷 생성'); + } catch (err) { + this.logger.error('인기 검색어 스냅샷 생성 실패', err); + } + } +} diff --git a/src/features/search/services/search-keyword-rank.service.spec.ts b/src/features/search/services/search-keyword-rank.service.spec.ts new file mode 100644 index 0000000..13c0e19 --- /dev/null +++ b/src/features/search/services/search-keyword-rank.service.spec.ts @@ -0,0 +1,228 @@ +import type { PrismaClient } from '@prisma/client'; + +import { SearchRepository } from '@/features/search/repositories/search.repository'; +import { + SearchKeywordRankService, + truncateToHour, +} from '@/features/search/services/search-keyword-rank.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createKeywordRankSnapshot, createSearchEvent } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('SearchKeywordRankService (real DB)', () => { + let service: SearchKeywordRankService; + let prisma: PrismaClient; + + const NOW = new Date('2026-08-31T13:37:12.000Z'); + const RANKED_AT = new Date('2026-08-31T13:00:00.000Z'); + const PREV_AT = new Date('2026-08-31T12:00:00.000Z'); + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [SearchKeywordRankService, SearchRepository], + }); + service = module.get(SearchKeywordRankService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function addEvents(keyword: string, count: number, at: Date) { + for (let i = 0; i < count; i++) { + await createSearchEvent(prisma, { keyword, created_at: at }); + } + } + + async function snapshotRows(rankedAt: Date) { + return prisma.searchKeywordRankSnapshot.findMany({ + where: { ranked_at: rankedAt }, + orderBy: { rank: 'asc' }, + }); + } + + describe('truncateToHour', () => { + it('분·초·밀리초를 버려 정각으로 만든다', () => { + expect(truncateToHour(NOW)).toEqual(RANKED_AT); + }); + }); + + describe('captureSnapshot', () => { + it('직전 24시간 이벤트를 keyword별로 세어 count desc → keyword asc로 저장한다', async () => { + const inWindow = new Date('2026-08-31T00:00:00.000Z'); + await addEvents('생일 케이크', 3, inWindow); + await addEvents('과일 케이크', 2, inWindow); + await addEvents('강아지 케이크', 2, inWindow); + // 윈도우 밖(24시간 이전) — 제외 + await addEvents('오래된', 5, new Date('2026-08-30T12:59:59.000Z')); + // 정각 이후(현재 시각 사이) — 다음 스냅샷 몫 + await addEvents('미래', 5, new Date('2026-08-31T13:10:00.000Z')); + + const created = await service.captureSnapshot(NOW); + + expect(created).toBe(true); + const rows = await snapshotRows(RANKED_AT); + expect(rows.map((r) => [r.rank, r.keyword, r.search_count])).toEqual([ + [1, '생일 케이크', 3], + [2, '강아지 케이크', 2], + [3, '과일 케이크', 2], + ]); + }); + + it('상위 20건까지만 저장한다', async () => { + const at = new Date('2026-08-31T10:00:00.000Z'); + for (let i = 0; i < 25; i++) { + await addEvents(`k${i.toString().padStart(2, '0')}`, 25 - i, at); + } + + await service.captureSnapshot(NOW); + + const rows = await snapshotRows(RANKED_AT); + expect(rows).toHaveLength(20); + expect(rows[19].keyword).toBe('k19'); + }); + + it('같은 정각 스냅샷이 이미 있으면 만들지 않는다(멱등)', async () => { + await addEvents('생일', 1, new Date('2026-08-31T10:00:00.000Z')); + expect(await service.captureSnapshot(NOW)).toBe(true); + + await addEvents('추가', 9, new Date('2026-08-31T11:00:00.000Z')); + expect( + await service.captureSnapshot(new Date('2026-08-31T13:59:00.000Z')), + ).toBe(false); + + const rows = await snapshotRows(RANKED_AT); + expect(rows.map((r) => r.keyword)).toEqual(['생일']); + }); + + it('윈도우에 이벤트가 없으면 스냅샷을 남기지 않는다', async () => { + expect(await service.captureSnapshot(NOW)).toBe(false); + expect(await prisma.searchKeywordRankSnapshot.count()).toBe(0); + }); + + it('soft-delete된 이벤트는 집계에서 제외한다', async () => { + const at = new Date('2026-08-31T10:00:00.000Z'); + await createSearchEvent(prisma, { + keyword: '삭제됨', + created_at: at, + deleted_at: at, + }); + await addEvents('유효', 1, at); + + await service.captureSnapshot(NOW); + + const rows = await snapshotRows(RANKED_AT); + expect(rows.map((r) => r.keyword)).toEqual(['유효']); + }); + }); + + describe('popularSearchKeywords', () => { + it('스냅샷이 없으면 빈 목록 + rankedAt null', async () => { + const result = await service.popularSearchKeywords(); + expect(result).toEqual({ items: [], rankedAt: null }); + }); + + it('최신 스냅샷을 직전 스냅샷과 비교해 UP/DOWN/SAME/NEW를 매긴다', async () => { + await createKeywordRankSnapshot(prisma, { + ranked_at: PREV_AT, + keywords: [ + { keyword: '과일' }, // 1 → 2: DOWN + { keyword: '생일' }, // 2 → 1: UP + { keyword: '크리스마스' }, // 3 → 3: SAME + { keyword: '사라짐' }, + ], + }); + await createKeywordRankSnapshot(prisma, { + ranked_at: RANKED_AT, + keywords: [ + { keyword: '생일', count: 30 }, + { keyword: '과일', count: 20 }, + { keyword: '크리스마스', count: 10 }, + { keyword: '연인', count: 5 }, // 직전에 없음: NEW + ], + }); + + const result = await service.popularSearchKeywords(); + + expect(result.rankedAt).toEqual(RANKED_AT); + expect( + result.items.map((i) => [i.rank, i.keyword, i.trend, i.searchCount]), + ).toEqual([ + [1, '생일', 'UP', 30], + [2, '과일', 'DOWN', 20], + [3, '크리스마스', 'SAME', 10], + [4, '연인', 'NEW', 5], + ]); + }); + + it('직전 스냅샷과 대소문자 표기가 달라도 같은 검색어로 비교한다(NEW 오판 방지)', async () => { + await createKeywordRankSnapshot(prisma, { + ranked_at: PREV_AT, + keywords: [{ keyword: '3D' }, { keyword: '케이크' }], + }); + await createKeywordRankSnapshot(prisma, { + ranked_at: RANKED_AT, + keywords: [{ keyword: '케이크' }, { keyword: '3d' }], + }); + + const result = await service.popularSearchKeywords(); + + expect(result.items.map((i) => [i.keyword, i.trend])).toEqual([ + ['케이크', 'UP'], + ['3d', 'DOWN'], + ]); + }); + + it('직전 스냅샷이 없으면 전부 NEW', async () => { + await createKeywordRankSnapshot(prisma, { + ranked_at: RANKED_AT, + keywords: [{ keyword: '생일' }, { keyword: '과일' }], + }); + + const result = await service.popularSearchKeywords(); + + expect(result.items.map((i) => i.trend)).toEqual(['NEW', 'NEW']); + }); + + it('직전 스냅샷은 정확히 1시간 전이 아니어도 가장 최근 이전 것을 쓴다', async () => { + await createKeywordRankSnapshot(prisma, { + ranked_at: new Date('2026-08-30T20:00:00.000Z'), + keywords: [{ keyword: '과일' }, { keyword: '생일' }], + }); + await createKeywordRankSnapshot(prisma, { + ranked_at: RANKED_AT, + keywords: [{ keyword: '생일' }], + }); + + const result = await service.popularSearchKeywords(); + + expect(result.items[0].trend).toBe('UP'); + }); + + it('limit만큼만 노출하고 변동 비교는 직전 스냅샷 전체(20건)와 한다', async () => { + await createKeywordRankSnapshot(prisma, { + ranked_at: PREV_AT, + keywords: Array.from({ length: 15 }, (_, i) => ({ + keyword: `k${i}`, + })), + }); + // k14(직전 15위)가 1위로: NEW가 아니라 UP + await createKeywordRankSnapshot(prisma, { + ranked_at: RANKED_AT, + keywords: [{ keyword: 'k14' }, { keyword: 'k0' }, { keyword: 'k1' }], + }); + + const result = await service.popularSearchKeywords({ limit: 2 }); + + expect(result.items).toHaveLength(2); + expect(result.items[0]).toMatchObject({ keyword: 'k14', trend: 'UP' }); + }); + }); +}); diff --git a/src/features/search/services/search-keyword-rank.service.ts b/src/features/search/services/search-keyword-rank.service.ts new file mode 100644 index 0000000..dcb8dcf --- /dev/null +++ b/src/features/search/services/search-keyword-rank.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; + +import { + DEFAULT_POPULAR_KEYWORDS_LIMIT, + HOUR_MS, + KEYWORD_RANK_SNAPSHOT_SIZE, + KEYWORD_RANK_WINDOW_HOURS, + MAX_POPULAR_KEYWORDS_LIMIT, +} from '@/features/search/constants/search.constants'; +import type { PopularSearchKeywordsInput } from '@/features/search/dto/inputs/popular-search-keywords.input'; +import { SearchRepository } from '@/features/search/repositories/search.repository'; +import type { + PopularSearchKeywordsResult, + SearchKeywordTrend, +} from '@/features/search/types/search-entry-output.type'; + +/** 시 단위 절삭(정각). 시간대 오프셋이 정수 시간이라 UTC/KST 어느 쪽 정각과도 일치한다. */ +export function truncateToHour(date: Date): Date { + return new Date(Math.floor(date.getTime() / HOUR_MS) * HOUR_MS); +} + +@Injectable() +export class SearchKeywordRankService { + constructor(private readonly repo: SearchRepository) {} + + /** + * 인기 검색어 스냅샷 생성. ranked_at = now의 정각, 윈도우 = [ranked_at - 24h, ranked_at). + * 같은 정각 스냅샷이 이미 있으면(크론·부트스트랩 중복 호출) 만들지 않는다(멱등). + * 이벤트가 하나도 없으면 스냅샷을 남기지 않아 직전 유효 스냅샷이 계속 노출된다. + * @returns 실제 생성 여부 + */ + async captureSnapshot(now: Date): Promise { + const rankedAt = truncateToHour(now); + if (await this.repo.snapshotExists(rankedAt)) return false; + + const rows = await this.repo.countKeywordsInWindow({ + since: new Date(rankedAt.getTime() - KEYWORD_RANK_WINDOW_HOURS * HOUR_MS), + until: rankedAt, + limit: KEYWORD_RANK_SNAPSHOT_SIZE, + }); + return this.repo.createSnapshot({ rankedAt, rows }); + } + + /** + * 최신 스냅샷 상위 limit + 직전 스냅샷 대비 변동. + * 직전 스냅샷은 "가장 최근의 이전 스냅샷"(정확히 1시간 전이 아닐 수 있음 — + * 서버 다운타임으로 빈 시간이 있어도 비교가 가능하도록. 자체 판단, 시안 외). + */ + async popularSearchKeywords( + input?: PopularSearchKeywordsInput, + ): Promise { + const limit = Math.min( + input?.limit ?? DEFAULT_POPULAR_KEYWORDS_LIMIT, + MAX_POPULAR_KEYWORDS_LIMIT, + ); + + const rankedAt = await this.repo.findLatestSnapshotAt(); + if (rankedAt === null) return { items: [], rankedAt: null }; + + const previousAt = await this.repo.findLatestSnapshotAt(rankedAt); + const [current, previous] = await Promise.all([ + this.repo.listSnapshotRows(rankedAt, limit), + previousAt === null + ? Promise.resolve([]) + : this.repo.listSnapshotRows(previousAt), + ]); + // GROUP BY는 collation(ci) 기준으로 묶여 스냅샷마다 대표 표기(대소문자)가 다를 수 + // 있다('3d' ↔ '3D'). JS Map은 대소문자를 구분하므로 소문자 키로 비교해 같은 + // 검색어가 NEW로 오판되지 않게 방어한다(릴리즈 리뷰 반영 — collation 완전 동치는 + // 아니지만 실사용 대표 케이스). + const previousRankByKeyword = new Map( + previous.map((row) => [row.keyword.toLowerCase(), row.rank]), + ); + + return { + items: current.map((row) => ({ + rank: row.rank, + keyword: row.keyword, + trend: resolveTrend( + row.rank, + previousRankByKeyword.get(row.keyword.toLowerCase()), + ), + searchCount: row.search_count, + })), + rankedAt, + }; + } +} + +function resolveTrend( + rank: number, + previousRank: number | undefined, +): SearchKeywordTrend { + if (previousRank === undefined) return 'NEW'; + if (previousRank > rank) return 'UP'; + if (previousRank < rank) return 'DOWN'; + return 'SAME'; +} 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-entry-output.type.ts b/src/features/search/types/search-entry-output.type.ts new file mode 100644 index 0000000..760c734 --- /dev/null +++ b/src/features/search/types/search-entry-output.type.ts @@ -0,0 +1,18 @@ +/** + * search-entry resolver 반환용 도메인 출력 타입. + * SDL(search-entry.graphql)의 타입과 필드 일치. + */ + +export type SearchKeywordTrend = 'UP' | 'DOWN' | 'SAME' | 'NEW'; + +export interface PopularSearchKeyword { + rank: number; + keyword: string; + trend: SearchKeywordTrend; + searchCount: number; +} + +export interface PopularSearchKeywordsResult { + items: PopularSearchKeyword[]; + rankedAt: Date | null; +} 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/seller/dto/inputs/seller-create-banner.input.ts b/src/features/seller/dto/inputs/seller-create-banner.input.ts index 398526d..bb75d3e 100644 --- a/src/features/seller/dto/inputs/seller-create-banner.input.ts +++ b/src/features/seller/dto/inputs/seller-create-banner.input.ts @@ -12,6 +12,7 @@ const BANNER_PLACEMENTS = [ 'HOME_SUB', 'CATEGORY', 'STORE', + 'SEARCH', ] as const; type SellerBannerPlacement = (typeof BANNER_PLACEMENTS)[number]; diff --git a/src/features/seller/dto/inputs/seller-update-banner.input.ts b/src/features/seller/dto/inputs/seller-update-banner.input.ts index f2ea0ed..739f744 100644 --- a/src/features/seller/dto/inputs/seller-update-banner.input.ts +++ b/src/features/seller/dto/inputs/seller-update-banner.input.ts @@ -12,6 +12,7 @@ const BANNER_PLACEMENTS = [ 'HOME_SUB', 'CATEGORY', 'STORE', + 'SEARCH', ] as const; type SellerBannerPlacement = (typeof BANNER_PLACEMENTS)[number]; diff --git a/src/features/seller/seller-content.graphql b/src/features/seller/seller-content.graphql index 3ade46b..db2e169 100644 --- a/src/features/seller/seller-content.graphql +++ b/src/features/seller/seller-content.graphql @@ -57,6 +57,8 @@ enum SellerBannerPlacement { HOME_SUB CATEGORY STORE + """검색 진입 화면 배너 슬롯""" + SEARCH } """SellerBannerLinkType 열거형""" diff --git a/src/features/seller/services/seller-banner.service.spec.ts b/src/features/seller/services/seller-banner.service.spec.ts index 50fc35b..cc1247e 100644 --- a/src/features/seller/services/seller-banner.service.spec.ts +++ b/src/features/seller/services/seller-banner.service.spec.ts @@ -98,6 +98,22 @@ describe('SellerBannerService (real DB)', () => { }); describe('sellerCreateBanner', () => { + it('placement=SEARCH(검색 진입 배너)로 생성할 수 있다', async () => { + const { account } = await setupSellerWithStore(prisma); + + const created = await service.sellerCreateBanner(account.id, { + placement: 'SEARCH', + imageUrl: 'https://i.example/search.png', + linkType: 'NONE', + }); + + expect(created.placement).toBe('SEARCH'); + const row = await prisma.banner.findUniqueOrThrow({ + where: { id: BigInt(created.id) }, + }); + expect(row.placement).toBe('SEARCH'); + }); + it('linkType=URL인데 linkUrl 없음 → BadRequestException', async () => { const { account } = await setupSellerWithStore(prisma); await expect( diff --git a/src/features/seller/services/seller-banner.service.ts b/src/features/seller/services/seller-banner.service.ts index 88864e5..d08ca56 100644 --- a/src/features/seller/services/seller-banner.service.ts +++ b/src/features/seller/services/seller-banner.service.ts @@ -443,6 +443,7 @@ export class SellerBannerService if (raw === 'HOME_SUB') return BannerPlacement.HOME_SUB; if (raw === 'CATEGORY') return BannerPlacement.CATEGORY; if (raw === 'STORE') return BannerPlacement.STORE; + if (raw === 'SEARCH') return BannerPlacement.SEARCH; throw new BadRequestException(INVALID_BANNER_PLACEMENT); } diff --git a/src/features/seller/services/seller-content-mappers.helper.ts b/src/features/seller/services/seller-content-mappers.helper.ts index ffeb0cc..ddb9ea6 100644 --- a/src/features/seller/services/seller-content-mappers.helper.ts +++ b/src/features/seller/services/seller-content-mappers.helper.ts @@ -25,7 +25,7 @@ export interface FaqTopicRow { export interface BannerRow { id: bigint; - placement: 'HOME_MAIN' | 'HOME_SUB' | 'CATEGORY' | 'STORE'; + placement: 'HOME_MAIN' | 'HOME_SUB' | 'CATEGORY' | 'STORE' | 'SEARCH'; title: string | null; image_url: string; link_type: 'NONE' | 'URL' | 'PRODUCT' | 'STORE' | 'CATEGORY'; diff --git a/src/features/seller/types/seller-output.type.ts b/src/features/seller/types/seller-output.type.ts index 6bd2734..90766b7 100644 --- a/src/features/seller/types/seller-output.type.ts +++ b/src/features/seller/types/seller-output.type.ts @@ -250,7 +250,7 @@ export interface SellerFaqTopicOutput { export interface SellerBannerOutput { id: string; - placement: 'HOME_MAIN' | 'HOME_SUB' | 'CATEGORY' | 'STORE'; + placement: 'HOME_MAIN' | 'HOME_SUB' | 'CATEGORY' | 'STORE' | 'SEARCH'; title: string | null; imageUrl: string; linkType: 'NONE' | 'URL' | 'PRODUCT' | 'STORE' | 'CATEGORY'; 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 6f0ea30..6cd5e12 100644 --- a/src/test/factories/index.ts +++ b/src/test/factories/index.ts @@ -7,9 +7,12 @@ export * from './product.factory'; export * from './recent-product-view.factory'; export * from './region.factory'; export * from './review.factory'; +export * from './search-event.factory'; export * from './search-history.factory'; +export * from './search-keyword-rank-snapshot.factory'; 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/search-event.factory.ts b/src/test/factories/search-event.factory.ts new file mode 100644 index 0000000..6ea1b35 --- /dev/null +++ b/src/test/factories/search-event.factory.ts @@ -0,0 +1,27 @@ +import type { PrismaClient, SearchEvent } from '@prisma/client'; + +import { nextSeq } from '@/test/factories/sequence'; + +export interface SearchEventOverrides { + account_id?: bigint | null; + keyword?: string; + created_at?: Date; + deleted_at?: Date | null; +} + +/** 검색 집계 이벤트. 기본은 비로그인(account_id null). */ +export async function createSearchEvent( + prisma: PrismaClient, + overrides: SearchEventOverrides = {}, +): Promise { + const seq = nextSeq(); + return prisma.searchEvent.create({ + data: { + account_id: overrides.account_id ?? null, + keyword: overrides.keyword ?? `keyword_${seq}`, + context: 'GLOBAL', + created_at: overrides.created_at ?? new Date(), + deleted_at: overrides.deleted_at ?? null, + }, + }); +} diff --git a/src/test/factories/search-keyword-rank-snapshot.factory.ts b/src/test/factories/search-keyword-rank-snapshot.factory.ts new file mode 100644 index 0000000..57e5557 --- /dev/null +++ b/src/test/factories/search-keyword-rank-snapshot.factory.ts @@ -0,0 +1,16 @@ +import type { PrismaClient } from '@prisma/client'; + +/** 한 정각(ranked_at)의 스냅샷 행들을 순서대로 rank 1..N으로 만든다. */ +export async function createKeywordRankSnapshot( + prisma: PrismaClient, + args: { ranked_at: Date; keywords: { keyword: string; count?: number }[] }, +): Promise { + await prisma.searchKeywordRankSnapshot.createMany({ + data: args.keywords.map((k, i) => ({ + ranked_at: args.ranked_at, + rank: i + 1, + keyword: k.keyword, + search_count: k.count ?? 1, + })), + }); +} 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, + }, + }); +} diff --git a/yarn.lock b/yarn.lock index 5076ff9..c609429 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4063,6 +4063,18 @@ __metadata: languageName: node linkType: hard +"@nestjs/schedule@npm:^6.1.3": + version: 6.1.3 + resolution: "@nestjs/schedule@npm:6.1.3" + dependencies: + cron: "npm:4.4.0" + peerDependencies: + "@nestjs/common": ^10.0.0 || ^11.0.0 + "@nestjs/core": ^10.0.0 || ^11.0.0 + checksum: 10c0/de6da8d0246f486f7fdefaf6991fea18718f7a2d2eb698cf40094bfaa21e028107e37a27352af4571370c14cd757ca7402b5398af544438cc046d28575dee827 + languageName: node + linkType: hard + "@nestjs/schematics@npm:^11.0.1": version: 11.0.9 resolution: "@nestjs/schematics@npm:11.0.9" @@ -5287,6 +5299,13 @@ __metadata: languageName: node linkType: hard +"@types/luxon@npm:~3.7.0": + version: 3.7.5 + resolution: "@types/luxon@npm:3.7.5" + checksum: 10c0/7b851e94cd0d47784682dd3bc412960519d2c888bcca1408b6c8484742805637ed1a94797ce4e44fc925c8b20b52803035880d0122e3658c17f98838b387c46b + languageName: node + linkType: hard + "@types/methods@npm:^1.1.4": version: 1.1.4 resolution: "@types/methods@npm:1.1.4" @@ -7347,6 +7366,7 @@ __metadata: "@nestjs/jwt": "npm:^11.0.2" "@nestjs/passport": "npm:^11.0.5" "@nestjs/platform-express": "npm:11.2.1" + "@nestjs/schedule": "npm:^6.1.3" "@nestjs/schematics": "npm:^11.1.0" "@nestjs/serve-static": "npm:^5.0.5" "@nestjs/swagger": "npm:^11.4.7" @@ -8209,6 +8229,16 @@ __metadata: languageName: node linkType: hard +"cron@npm:4.4.0": + version: 4.4.0 + resolution: "cron@npm:4.4.0" + dependencies: + "@types/luxon": "npm:~3.7.0" + luxon: "npm:~3.7.0" + checksum: 10c0/e3762fda4a9a2404ac2e54e1104a3c6c695747e14b2321bbc5531aa1192c75a319075695fcaf68b6927e0ffd877b5454f314c680f6586db1a78b2c25b76b8a32 + languageName: node + linkType: hard + "cross-env@npm:^10.1.0": version: 10.1.0 resolution: "cross-env@npm:10.1.0" @@ -12633,6 +12663,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:~3.7.0": + version: 3.7.2 + resolution: "luxon@npm:3.7.2" + checksum: 10c0/ed8f0f637826c08c343a29dd478b00628be93bba6f068417b1d8896b61cb61c6deacbe1df1e057dbd9298334044afa150f9aaabbeb3181418ac8520acfdc2ae2 + languageName: node + linkType: hard + "magic-string@npm:0.30.17": version: 0.30.17 resolution: "magic-string@npm:0.30.17"