diff --git a/src/app.module.ts b/src/app.module.ts index 11135f3..d670e5d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +23,7 @@ import s3Config from '@/config/s3.config'; import { AuthModule } from '@/features/auth/auth.module'; import { RegionModule } from '@/features/region'; import { SellerModule } from '@/features/seller/seller.module'; +import { StoreModule } from '@/features/store'; import { SystemModule } from '@/features/system/system.module'; import { UserModule } from '@/features/user/user.module'; import { AuthGlobalModule } from '@/global/auth/auth-global.module'; @@ -87,6 +88,7 @@ import { PrismaModule } from '@/prisma'; SystemModule, AuthModule, RegionModule, + StoreModule, UserModule, SellerModule, ], diff --git a/src/features/store/constants/store-ranking.constants.ts b/src/features/store/constants/store-ranking.constants.ts new file mode 100644 index 0000000..66b739d --- /dev/null +++ b/src/features/store/constants/store-ranking.constants.ts @@ -0,0 +1,33 @@ +/** + * 인기 매장 랭킹 점수 파라미터. + * + * 점수 = w_order·ln(1+최근주문수) + w_wishlist·ln(1+찜수) + w_rating·베이지안평점 + * 비즈니스 KPI가 확정되면 이 상수를 교체한다(추천 기본값으로 운영). + */ +export const RANKING_WEIGHTS = { + order: 1.0, + wishlist: 0.5, + rating: 0.4, +} as const; + +/** 베이지안 평점 신뢰 임계 리뷰수(prior 가중). 리뷰가 적은 신규 매장 콜드스타트 보정. */ +export const RANKING_BAYESIAN_M = 5; + +/** 최근 주문 집계 기간(일). */ +export const RANKING_RECENT_ORDER_DAYS = 30; + +/** 인기 점수 가중에 포함되는 유효 주문 상태. */ +export const RANKING_VALID_ORDER_STATUSES = [ + 'CONFIRMED', + 'MADE', + 'PICKED_UP', +] as const; + +/** 전체 리뷰가 전무할 때 베이지안 prior로 사용할 기본 평점. */ +export const DEFAULT_GLOBAL_RATING_PRIOR = 4.0; + +/** popularStores 기본 페이지 크기. */ +export const DEFAULT_POPULAR_STORES_LIMIT = 20; + +/** 매장 카드에 노출할 대표 케이크 이미지 최대 장수. */ +export const POPULAR_STORE_CAKE_IMAGE_LIMIT = 4; diff --git a/src/features/store/dto/inputs/popular-stores.input.spec.ts b/src/features/store/dto/inputs/popular-stores.input.spec.ts new file mode 100644 index 0000000..1205bbd --- /dev/null +++ b/src/features/store/dto/inputs/popular-stores.input.spec.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; + +function build(plain: object): PopularStoresInput { + return plainToInstance(PopularStoresInput, plain); +} + +describe('PopularStoresInput', () => { + it('빈 입력 통과 (모두 optional)', async () => { + expect(await validate(build({}))).toHaveLength(0); + }); + + it('regionIds 문자열 배열 + offset/limit 통과', async () => { + const errors = await validate( + build({ regionIds: ['1', '2'], offset: 0, limit: 20 }), + ); + expect(errors).toHaveLength(0); + }); + + it('regionIds 가 배열이 아니면 거절', async () => { + const errors = await validate(build({ regionIds: '1' })); + expect(errors[0].property).toBe('regionIds'); + }); + + it('regionIds 원소가 문자열이 아니면 거절', async () => { + const errors = await validate(build({ regionIds: [1, 2] })); + expect(errors[0].property).toBe('regionIds'); + }); + + it('offset 음수 거절', async () => { + const errors = await validate(build({ offset: -1 })); + expect(errors[0].property).toBe('offset'); + }); + + it('limit 하한(0)·상한(51) 거절', async () => { + expect((await validate(build({ limit: 0 })))[0].property).toBe('limit'); + expect((await validate(build({ limit: 51 })))[0].property).toBe('limit'); + }); +}); diff --git a/src/features/store/dto/inputs/popular-stores.input.ts b/src/features/store/dto/inputs/popular-stores.input.ts new file mode 100644 index 0000000..b8cdc7d --- /dev/null +++ b/src/features/store/dto/inputs/popular-stores.input.ts @@ -0,0 +1,26 @@ +import { + IsArray, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class PopularStoresInput { + @IsOptional() + @IsArray() + @IsString({ each: true }) + regionIds?: string[]; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + limit?: number; +} diff --git a/src/features/store/index.ts b/src/features/store/index.ts new file mode 100644 index 0000000..ce2acac --- /dev/null +++ b/src/features/store/index.ts @@ -0,0 +1,3 @@ +// cross-feature 공개 API. 단일 구현 repo라 토큰/인터페이스 없이 구체 클래스로 주입(의도적). +export { StoreModule } from '@/features/store/store.module'; +export { StoreRepository } from '@/features/store/repositories/store.repository'; diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts new file mode 100644 index 0000000..7c26baf --- /dev/null +++ b/src/features/store/repositories/store.repository.ts @@ -0,0 +1,154 @@ +import { Injectable } from '@nestjs/common'; + +import { + POPULAR_STORE_CAKE_IMAGE_LIMIT, + RANKING_VALID_ORDER_STATUSES, +} from '@/features/store/constants/store-ranking.constants'; +import { PrismaService } from '@/prisma'; + +export interface StoreCandidateRow { + id: bigint; + store_name: string; + address_city: string | null; + address_neighborhood: string | null; + region: { name: string } | null; +} + +export interface StoreReviewStat { + average: number; + count: number; +} + +@Injectable() +export class StoreRepository { + constructor(private readonly prisma: PrismaService) {} + + /** 인기 매장 랭킹 후보. 활성 매장만, 지역 필터(2차 시군구 다중) 적용. */ + async findActiveStoresForRanking( + regionIds?: bigint[], + ): Promise { + return this.prisma.store.findMany({ + where: { + is_active: true, + deleted_at: null, + ...(regionIds && regionIds.length > 0 + ? { region_id: { in: regionIds } } + : {}), + }, + select: { + id: true, + store_name: true, + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + }, + }); + } + + /** 매장별 활성 찜 수. */ + async aggregateWishlistCounts( + storeIds: bigint[], + ): Promise> { + if (storeIds.length === 0) return new Map(); + const rows = await this.prisma.storeWishlistItem.groupBy({ + by: ['store_id'], + where: { store_id: { in: storeIds }, deleted_at: null }, + _count: { _all: true }, + }); + return new Map(rows.map((r) => [r.store_id, r._count._all])); + } + + /** 매장별 평균 평점·리뷰 수. */ + async aggregateReviewStats( + storeIds: bigint[], + ): Promise> { + if (storeIds.length === 0) return new Map(); + const rows = await this.prisma.review.groupBy({ + by: ['store_id'], + where: { store_id: { in: storeIds }, deleted_at: null }, + _avg: { rating: true }, + _count: { _all: true }, + }); + return new Map( + rows.map((r) => [ + r.store_id, + { + average: r._avg.rating !== null ? Number(r._avg.rating) : 0, + count: r._count._all, + }, + ]), + ); + } + + /** 매장별 최근 N일 유효 주문(아이템) 수. */ + async aggregateRecentOrderCounts( + storeIds: bigint[], + since: Date, + ): Promise> { + if (storeIds.length === 0) return new Map(); + const rows = await this.prisma.orderItem.groupBy({ + by: ['store_id'], + where: { + store_id: { in: storeIds }, + deleted_at: null, + order: { + status: { in: [...RANKING_VALID_ORDER_STATUSES] }, + created_at: { gte: since }, + // soft-delete extension은 nested relation filter에 deleted_at을 주입하지 + // 않으므로(=root read만 보정), 삭제된 주문이 랭킹을 부풀리지 않도록 명시한다. + deleted_at: null, + }, + }, + _count: { _all: true }, + }); + return new Map(rows.map((r) => [r.store_id, r._count._all])); + } + + /** 전체 활성 리뷰 평균 평점(베이지안 prior). 리뷰가 없으면 null. */ + async globalReviewAverage(): Promise { + const agg = await this.prisma.review.aggregate({ + where: { deleted_at: null }, + _avg: { rating: true }, + }); + return agg._avg.rating !== null ? Number(agg._avg.rating) : null; + } + + /** 페이지 매장들의 대표 케이크 이미지(매장당 최대 N장, 활성 상품 1장씩). */ + async findStoreCakeImages( + storeIds: bigint[], + ): Promise> { + if (storeIds.length === 0) return new Map(); + + // 매장당 이미지 보유 활성 상품을 최대 N개만 조회한다. 전체 상품을 materialize한 + // 뒤 JS에서 자르면 상품이 많은 매장에서 불필요한 row 스캔이 발생하므로, + // 쿼리 단계에서 take로 제한한다(페이지 크기만큼의 병렬 조회). + const entries = await Promise.all( + storeIds.map(async (storeId) => { + const products = await this.prisma.product.findMany({ + where: { + store_id: storeId, + is_active: true, + deleted_at: null, + images: { some: { deleted_at: null } }, + }, + orderBy: { id: 'desc' }, + take: POPULAR_STORE_CAKE_IMAGE_LIMIT, + select: { + images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + take: 1, + select: { image_url: true }, + }, + }, + }); + const urls = products + .map((product) => product.images[0]?.image_url) + .filter((url): url is string => Boolean(url)); + return [storeId, urls] as const; + }), + ); + + return new Map(entries); + } +} diff --git a/src/features/store/resolvers/store-query.resolver.spec.ts b/src/features/store/resolvers/store-query.resolver.spec.ts new file mode 100644 index 0000000..d9eea0f --- /dev/null +++ b/src/features/store/resolvers/store-query.resolver.spec.ts @@ -0,0 +1,58 @@ +import type { PrismaClient } from '@prisma/client'; + +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; +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 { createRegion, createStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/집계 세부 검증은 service.spec.ts에서 담당. + */ +describe('Store Query Resolver (real DB)', () => { + let resolver: StoreQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [StoreQueryResolver, StoreListingService, StoreRepository], + }); + resolver = module.get(StoreQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('popularStores: 서비스에 위임해 커넥션을 반환한다', async () => { + await createStore(prisma, { store_name: '리졸버매장' }); + + const result = await resolver.popularStores(); + + expect(result.totalCount).toBe(1); + expect(result.items[0].storeName).toBe('리졸버매장'); + expect(result.rankedAt).toBeInstanceOf(Date); + }); + + it('popularStores: regionIds 필터를 위임한다', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-res' }); + const target = await createStore(prisma, { region_id: region.id }); + await createStore(prisma); // region 없는 매장 + + const result = await resolver.popularStores({ + regionIds: [region.id.toString()], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(target.id.toString()); + }); +}); diff --git a/src/features/store/resolvers/store-query.resolver.ts b/src/features/store/resolvers/store-query.resolver.ts new file mode 100644 index 0000000..5956804 --- /dev/null +++ b/src/features/store/resolvers/store-query.resolver.ts @@ -0,0 +1,20 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import type { PopularStoreConnection } from '@/features/store/types/store-output.type'; + +/** + * 매장 조회 resolver. 인기 매장 리스트는 비로그인도 접근 가능한 public query. + */ +@Resolver('Query') +export class StoreQueryResolver { + constructor(private readonly storeListingService: StoreListingService) {} + + @Query('popularStores') + popularStores( + @Args('input', { nullable: true }) input?: PopularStoresInput, + ): Promise { + return this.storeListingService.popularStores(input); + } +} diff --git a/src/features/store/services/store-listing.service.spec.ts b/src/features/store/services/store-listing.service.spec.ts new file mode 100644 index 0000000..ca7c4c3 --- /dev/null +++ b/src/features/store/services/store-listing.service.spec.ts @@ -0,0 +1,210 @@ +import type { PrismaClient } from '@prisma/client'; + +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 { + createOrder, + createOrderItem, + createProduct, + createRegion, + createReview, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe('StoreListingService (real DB)', () => { + let service: StoreListingService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [StoreListingService, StoreRepository], + }); + service = module.get(StoreListingService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function reviewStore(storeId: bigint, rating: number): Promise { + const orderItem = await createOrderItem(prisma, { store_id: storeId }); + await createReview(prisma, { order_item_id: orderItem.id, rating }); + } + + describe('popularStores', () => { + it('매장이 없으면 빈 커넥션과 rankedAt을 반환한다', async () => { + const result = await service.popularStores(); + + expect(result.totalCount).toBe(0); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.rankedAt).toBeInstanceOf(Date); + }); + + it('인기순(찜 많은 매장이 상위)으로 정렬하고 rank를 부여한다', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-rank' }); + const hot = await createStore(prisma, { + store_name: '핫', + region_id: region.id, + }); + const cold = await createStore(prisma, { + store_name: '콜드', + region_id: region.id, + }); + for (let i = 0; i < 3; i++) { + await createStoreWishlist(prisma, { store_id: hot.id }); + } + + const result = await service.popularStores(); + + expect(result.items.map((s) => s.storeName)).toEqual(['핫', '콜드']); + expect(result.items[0]).toMatchObject({ + id: hot.id.toString(), + rank: 1, + }); + expect(result.items[1].rank).toBe(2); + expect(cold.id.toString()).toBe(result.items[1].id); + }); + + it('지역 필터(regionIds)에 해당하는 시군구 매장만 반환한다', async () => { + const r1 = await createRegion(prisma, { level: 2, slug: 'sgg-a' }); + const r2 = await createRegion(prisma, { level: 2, slug: 'sgg-b' }); + const target = await createStore(prisma, { region_id: r1.id }); + await createStore(prisma, { region_id: r2.id }); + + const result = await service.popularStores({ + regionIds: [r1.id.toString()], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(target.id.toString()); + }); + + it('평균 평점(소수 첫째)과 리뷰 수를 집계한다', async () => { + const store = await createStore(prisma); + await reviewStore(store.id, 4); + await reviewStore(store.id, 5); + + const result = await service.popularStores(); + const item = result.items.find((s) => s.id === store.id.toString()); + + expect(item?.ratingAverage).toBe(4.5); + expect(item?.reviewCount).toBe(2); + }); + + it('최근 30일 유효 주문만 점수에 반영한다(오래된 주문 매장은 하위)', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-ord' }); + const recent = await createStore(prisma, { + store_name: '최근주문', + region_id: region.id, + }); + const stale = await createStore(prisma, { + store_name: '오래된주문', + region_id: region.id, + }); + + const recentOrder = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: recentOrder.id, + store_id: recent.id, + }); + + const staleOrder = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: staleOrder.id, + store_id: stale.id, + }); + await prisma.order.update({ + where: { id: staleOrder.id }, + data: { created_at: new Date(Date.now() - 40 * DAY_MS) }, + }); + + const result = await service.popularStores(); + + expect(result.items[0].storeName).toBe('최근주문'); + }); + + it('soft-delete된 주문은 점수에 반영하지 않는다', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-sd' }); + const valid = await createStore(prisma, { + store_name: '유효주문', + region_id: region.id, + }); + const removed = await createStore(prisma, { + store_name: '삭제주문', + region_id: region.id, + }); + + const validOrder = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: validOrder.id, + store_id: valid.id, + }); + + const removedOrder = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: removedOrder.id, + store_id: removed.id, + }); + await prisma.order.update({ + where: { id: removedOrder.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.popularStores(); + + expect(result.items[0].storeName).toBe('유효주문'); + }); + + it('대표 케이크 이미지를 매장당 최대 4장 노출한다', async () => { + const store = await createStore(prisma); + for (let i = 0; i < 5; i++) { + const product = await createProduct(prisma, { store_id: store.id }); + await prisma.productImage.create({ + data: { + product_id: product.id, + image_url: `https://img/${product.id}.png`, + sort_order: 0, + }, + }); + } + + const result = await service.popularStores(); + const item = result.items.find((s) => s.id === store.id.toString()); + + expect(item?.cakeImageUrls).toHaveLength(4); + }); + + it('offset/limit 페이지네이션과 hasMore를 처리한다', async () => { + for (let i = 0; i < 3; i++) { + await createStore(prisma, { store_name: `매장${i}` }); + } + + const result = await service.popularStores({ offset: 0, limit: 2 }); + + expect(result.items).toHaveLength(2); + expect(result.totalCount).toBe(3); + expect(result.hasMore).toBe(true); + }); + + it('비활성 매장은 제외된다', async () => { + await createStore(prisma, { is_active: false }); + + const result = await service.popularStores(); + + expect(result.totalCount).toBe(0); + }); + }); +}); diff --git a/src/features/store/services/store-listing.service.ts b/src/features/store/services/store-listing.service.ts new file mode 100644 index 0000000..a5f0f58 --- /dev/null +++ b/src/features/store/services/store-listing.service.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { + DEFAULT_GLOBAL_RATING_PRIOR, + DEFAULT_POPULAR_STORES_LIMIT, + RANKING_RECENT_ORDER_DAYS, +} from '@/features/store/constants/store-ranking.constants'; +import type { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { toPopularStore } from '@/features/store/services/store-mappers.helper'; +import { + popularityScore, + type StoreMetrics, +} from '@/features/store/services/store-ranking.helper'; +import type { PopularStoreConnection } from '@/features/store/types/store-output.type'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +@Injectable() +export class StoreListingService { + constructor(private readonly repo: StoreRepository) {} + + /** + * 인기 매장 리스트. 후보 매장의 주문·찜·평점을 실시간 집계해 점수화·정렬한 뒤 + * 페이지를 잘라 대표 이미지를 채운다. + * + * 실시간 집계는 매장 규모가 커지면 캐시/배치(스냅샷)로 최적화할 여지가 있다. + */ + async popularStores( + input?: PopularStoresInput, + ): Promise { + const offset = input?.offset ?? 0; + const limit = input?.limit ?? DEFAULT_POPULAR_STORES_LIMIT; + const regionIds = input?.regionIds?.map((id) => parseId(id)); + + const rankedAt = new Date(); + const candidates = await this.repo.findActiveStoresForRanking(regionIds); + if (candidates.length === 0) { + return { items: [], totalCount: 0, hasMore: false, rankedAt }; + } + + const storeIds = candidates.map((c) => c.id); + const since = new Date( + rankedAt.getTime() - RANKING_RECENT_ORDER_DAYS * DAY_MS, + ); + + const [wishlistCounts, reviewStats, orderCounts, globalAverage] = + await Promise.all([ + this.repo.aggregateWishlistCounts(storeIds), + this.repo.aggregateReviewStats(storeIds), + this.repo.aggregateRecentOrderCounts(storeIds, since), + this.repo.globalReviewAverage(), + ]); + const prior = globalAverage ?? DEFAULT_GLOBAL_RATING_PRIOR; + + const scored = candidates.map((candidate) => { + const review = reviewStats.get(candidate.id); + const metrics: StoreMetrics = { + recentOrderCount: orderCounts.get(candidate.id) ?? 0, + wishlistCount: wishlistCounts.get(candidate.id) ?? 0, + ratingAverage: review?.average ?? 0, + reviewCount: review?.count ?? 0, + }; + return { candidate, metrics, score: popularityScore(metrics, prior) }; + }); + + // 점수 desc → 리뷰수 desc → id desc (안정적 동점 처리) + scored.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (b.metrics.reviewCount !== a.metrics.reviewCount) { + return b.metrics.reviewCount - a.metrics.reviewCount; + } + return b.candidate.id > a.candidate.id ? 1 : -1; + }); + + const totalCount = scored.length; + const page = scored.slice(offset, offset + limit); + const imagesByStore = await this.repo.findStoreCakeImages( + page.map((s) => s.candidate.id), + ); + + const items = page.map((entry, idx) => + toPopularStore( + entry.candidate, + entry.metrics, + offset + idx + 1, + imagesByStore.get(entry.candidate.id) ?? [], + ), + ); + + return { + items, + totalCount, + hasMore: offset + limit < totalCount, + rankedAt, + }; + } +} diff --git a/src/features/store/services/store-mappers.helper.spec.ts b/src/features/store/services/store-mappers.helper.spec.ts new file mode 100644 index 0000000..cc14247 --- /dev/null +++ b/src/features/store/services/store-mappers.helper.spec.ts @@ -0,0 +1,69 @@ +import type { StoreCandidateRow } from '@/features/store/repositories/store.repository'; +import { + buildRegionLabel, + toPopularStore, +} from '@/features/store/services/store-mappers.helper'; + +function row(overrides: Partial): StoreCandidateRow { + return { + id: 1n, + store_name: '매장', + address_city: null, + address_neighborhood: null, + region: null, + ...overrides, + }; +} + +describe('store-mappers.helper', () => { + describe('buildRegionLabel', () => { + it('시/동이 있으면 조합해서 표기한다', () => { + expect( + buildRegionLabel( + row({ address_city: '서울특별시', address_neighborhood: '역삼동' }), + ), + ).toBe('서울특별시 역삼동'); + }); + + it('일부만 있으면 있는 값만 표기한다', () => { + expect(buildRegionLabel(row({ address_neighborhood: '역삼동' }))).toBe( + '역삼동', + ); + }); + + it('주소가 없으면 2차 지역명으로 대체한다', () => { + expect(buildRegionLabel(row({ region: { name: '강남구' } }))).toBe( + '강남구', + ); + }); + + it('주소도 지역도 없으면 null', () => { + expect(buildRegionLabel(row({}))).toBeNull(); + }); + }); + + describe('toPopularStore', () => { + it('평점을 소수 첫째 자리로 반올림하고 rank·이미지를 매핑한다', () => { + const result = toPopularStore( + row({ id: 7n, store_name: '케이크하우스' }), + { + recentOrderCount: 3, + wishlistCount: 2, + ratingAverage: 4.666, + reviewCount: 9, + }, + 1, + ['a.png', 'b.png'], + ); + + expect(result).toMatchObject({ + id: '7', + rank: 1, + storeName: '케이크하우스', + ratingAverage: 4.7, + reviewCount: 9, + cakeImageUrls: ['a.png', 'b.png'], + }); + }); + }); +}); diff --git a/src/features/store/services/store-mappers.helper.ts b/src/features/store/services/store-mappers.helper.ts new file mode 100644 index 0000000..bc5d3d6 --- /dev/null +++ b/src/features/store/services/store-mappers.helper.ts @@ -0,0 +1,30 @@ +import type { StoreCandidateRow } from '@/features/store/repositories/store.repository'; +import type { StoreMetrics } from '@/features/store/services/store-ranking.helper'; +import type { PopularStore } from '@/features/store/types/store-output.type'; + +/** 매장 위치 표기. 시/동 조합 우선, 없으면 2차 지역명. (표기 규칙 확정 전 기본형) */ +export function buildRegionLabel(row: StoreCandidateRow): string | null { + const parts = [row.address_city, row.address_neighborhood].filter( + (p): p is string => Boolean(p), + ); + if (parts.length > 0) return parts.join(' '); + return row.region?.name ?? null; +} + +export function toPopularStore( + row: StoreCandidateRow, + metrics: StoreMetrics, + rank: number, + cakeImageUrls: string[], +): PopularStore { + return { + id: row.id.toString(), + rank, + storeName: row.store_name, + // 소수 첫째 자리까지(예: 4.666 → 4.7) + ratingAverage: Math.round(metrics.ratingAverage * 10) / 10, + reviewCount: metrics.reviewCount, + regionLabel: buildRegionLabel(row), + cakeImageUrls, + }; +} diff --git a/src/features/store/services/store-ranking.helper.spec.ts b/src/features/store/services/store-ranking.helper.spec.ts new file mode 100644 index 0000000..f7f6cc7 --- /dev/null +++ b/src/features/store/services/store-ranking.helper.spec.ts @@ -0,0 +1,59 @@ +import { RANKING_BAYESIAN_M } from '@/features/store/constants/store-ranking.constants'; +import { + bayesianRating, + popularityScore, +} from '@/features/store/services/store-ranking.helper'; + +describe('store-ranking.helper', () => { + describe('bayesianRating', () => { + it('리뷰가 없으면 globalAverage를 그대로 반환한다', () => { + expect(bayesianRating(5, 0, 4.0)).toBe(4.0); + }); + + it('count = m이면 실제 평균과 prior의 중간값', () => { + // (5*5 + 5*3) / (5+5) = 4.0 + expect(bayesianRating(5, RANKING_BAYESIAN_M, 3.0)).toBeCloseTo(4.0); + }); + + it('리뷰가 많을수록 실제 평균에 수렴한다', () => { + const few = bayesianRating(5, 1, 3.0); + const many = bayesianRating(5, 200, 3.0); + expect(many).toBeGreaterThan(few); + expect(many).toBeLessThanOrEqual(5); + }); + }); + + describe('popularityScore', () => { + it('주문·찜·평점이 높을수록 점수가 높다', () => { + const low = popularityScore( + { + recentOrderCount: 0, + wishlistCount: 0, + ratingAverage: 0, + reviewCount: 0, + }, + 4.0, + ); + const high = popularityScore( + { + recentOrderCount: 100, + wishlistCount: 50, + ratingAverage: 5, + reviewCount: 50, + }, + 4.0, + ); + expect(high).toBeGreaterThan(low); + }); + + it('주문 수 기여는 ln으로 체감한다(동일 증가량의 한계효용 감소)', () => { + const base = { wishlistCount: 0, ratingAverage: 0, reviewCount: 0 }; + const at0 = popularityScore({ ...base, recentOrderCount: 0 }, 0); + const at10 = popularityScore({ ...base, recentOrderCount: 10 }, 0); + const at100 = popularityScore({ ...base, recentOrderCount: 100 }, 0); + const at110 = popularityScore({ ...base, recentOrderCount: 110 }, 0); + // 같은 +10 증가라도 낮은 구간(0→10)의 상승폭이 높은 구간(100→110)보다 크다 + expect(at10 - at0).toBeGreaterThan(at110 - at100); + }); + }); +}); diff --git a/src/features/store/services/store-ranking.helper.ts b/src/features/store/services/store-ranking.helper.ts new file mode 100644 index 0000000..453a894 --- /dev/null +++ b/src/features/store/services/store-ranking.helper.ts @@ -0,0 +1,44 @@ +import { + RANKING_BAYESIAN_M, + RANKING_WEIGHTS, +} from '@/features/store/constants/store-ranking.constants'; + +export interface StoreMetrics { + recentOrderCount: number; + wishlistCount: number; + ratingAverage: number; + reviewCount: number; +} + +/** + * 베이지안 평점: 리뷰 수가 적을수록 전체 평균(globalAvg)으로 수축시켜 + * 신규/소량 리뷰 매장이 과대평가되는 것을 막는다. + */ +export function bayesianRating( + average: number, + count: number, + globalAverage: number, + m: number = RANKING_BAYESIAN_M, +): number { + if (count <= 0) return globalAverage; + return (count / (count + m)) * average + (m / (count + m)) * globalAverage; +} + +/** + * 인기 점수. 주문/찜은 ln 으로 롱테일을 완화하고, 평점은 베이지안 보정 후 가중 합산. + */ +export function popularityScore( + metrics: StoreMetrics, + globalAverage: number, +): number { + const bayes = bayesianRating( + metrics.ratingAverage, + metrics.reviewCount, + globalAverage, + ); + return ( + RANKING_WEIGHTS.order * Math.log1p(metrics.recentOrderCount) + + RANKING_WEIGHTS.wishlist * Math.log1p(metrics.wishlistCount) + + RANKING_WEIGHTS.rating * bayes + ); +} diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts new file mode 100644 index 0000000..d407612 --- /dev/null +++ b/src/features/store/store.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; + +@Module({ + providers: [StoreRepository, StoreListingService, StoreQueryResolver], + exports: [StoreRepository], +}) +export class StoreModule {} diff --git a/src/features/store/store.types.graphql b/src/features/store/store.types.graphql new file mode 100644 index 0000000..54badc9 --- /dev/null +++ b/src/features/store/store.types.graphql @@ -0,0 +1,33 @@ +extend type Query { + """인기 매장 리스트 (지역 필터 + 인기순). 비로그인 접근 가능.""" + popularStores(input: PopularStoresInput): PopularStoreConnection! +} + +input PopularStoresInput { + """2차 시군구 ID 다중 선택. 비우면 전국 대상.""" + regionIds: [ID!] + offset: Int = 0 + limit: Int = 20 +} + +type PopularStoreConnection { + items: [PopularStore!]! + totalCount: Int! + hasMore: Boolean! + """랭킹 산출 기준 시각(서버 조회 시점). 화면의 'N시 기준' 표기에 사용.""" + rankedAt: DateTime! +} + +"""인기 매장 카드""" +type PopularStore { + id: ID! + rank: Int! + storeName: String! + """평균 평점(0.0~5.0, 소수 첫째 자리).""" + ratingAverage: Float! + reviewCount: Int! + """매장 위치 표기(예: 서울특별시 역삼동).""" + regionLabel: String + """대표 케이크 이미지(최대 4장).""" + cakeImageUrls: [String!]! +} diff --git a/src/features/store/types/store-output.type.ts b/src/features/store/types/store-output.type.ts new file mode 100644 index 0000000..83447ee --- /dev/null +++ b/src/features/store/types/store-output.type.ts @@ -0,0 +1,21 @@ +/** + * store resolver 반환용 도메인 출력 타입. + * SDL(store.types.graphql)의 PopularStore / PopularStoreConnection 와 필드 일치. + */ + +export interface PopularStore { + id: string; + rank: number; + storeName: string; + ratingAverage: number; + reviewCount: number; + regionLabel: string | null; + cakeImageUrls: string[]; +} + +export interface PopularStoreConnection { + items: PopularStore[]; + totalCount: number; + hasMore: boolean; + rankedAt: Date; +} diff --git a/src/test/factories/index.ts b/src/test/factories/index.ts index d110f1f..2d46c19 100644 --- a/src/test/factories/index.ts +++ b/src/test/factories/index.ts @@ -9,5 +9,6 @@ export * from './review.factory'; export * from './search-history.factory'; export * from './seller.factory'; export * from './sequence'; +export * from './store-wishlist.factory'; export * from './store.factory'; export * from './user-profile.factory'; diff --git a/src/test/factories/store-wishlist.factory.ts b/src/test/factories/store-wishlist.factory.ts new file mode 100644 index 0000000..e27e63f --- /dev/null +++ b/src/test/factories/store-wishlist.factory.ts @@ -0,0 +1,28 @@ +import type { PrismaClient, StoreWishlistItem } from '@prisma/client'; + +import { createAccount } from '@/test/factories/account.factory'; +import { createStore } from '@/test/factories/store.factory'; + +export interface StoreWishlistOverrides { + account_id?: bigint; + store_id?: bigint; + deleted_at?: Date | null; +} + +export async function createStoreWishlist( + prisma: PrismaClient, + overrides: StoreWishlistOverrides = {}, +): Promise { + const accountId = + overrides.account_id ?? + (await createAccount(prisma, { account_type: 'USER' })).id; + const storeId = overrides.store_id ?? (await createStore(prisma)).id; + + return prisma.storeWishlistItem.create({ + data: { + account_id: accountId, + store_id: storeId, + deleted_at: overrides.deleted_at ?? null, + }, + }); +} diff --git a/src/test/factories/store.factory.ts b/src/test/factories/store.factory.ts index fc41028..bf6574d 100644 --- a/src/test/factories/store.factory.ts +++ b/src/test/factories/store.factory.ts @@ -11,6 +11,7 @@ export interface StoreOverrides { address_city?: string | null; address_district?: string | null; address_neighborhood?: string | null; + region_id?: bigint | null; is_active?: boolean; } @@ -33,6 +34,7 @@ export async function createStore( address_city: overrides.address_city ?? '서울시', address_district: overrides.address_district ?? '테스트구', address_neighborhood: overrides.address_neighborhood ?? '테스트동', + region_id: overrides.region_id ?? null, is_active: overrides.is_active ?? true, }, });