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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/features/store/constants/store-sort.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const STORE_SORTS = ['POPULAR', 'LATEST', 'BEST_SELLING'] as const;
export type StoreSort = (typeof STORE_SORTS)[number];

export const DEFAULT_STORE_SORT: StoreSort = 'POPULAR';
9 changes: 9 additions & 0 deletions src/features/store/dto/inputs/search-stores.input.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
Expand All @@ -9,6 +10,10 @@ import {
} from 'class-validator';

import { MAX_SEARCH_PAGE_LIMIT } from '@/features/store/constants/store-search.constants';
import {
STORE_SORTS,
type StoreSort,
} from '@/features/store/constants/store-sort.constants';

export class SearchStoresInput {
@IsOptional()
Expand All @@ -21,6 +26,10 @@ export class SearchStoresInput {
@IsNotEmpty({ each: true })
regionIds?: string[];

@IsOptional()
@IsIn(STORE_SORTS)
sort?: StoreSort | null;

@IsOptional()
@IsInt()
@Min(0)
Expand Down
38 changes: 16 additions & 22 deletions src/features/store/repositories/store.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,22 @@ export interface StoreCandidateRow {
pickup_slot_interval_minutes: number;
min_lead_time_minutes: number;
max_days_ahead: number;
created_at: Date;
}

const STORE_CANDIDATE_SELECT = {
id: true,
store_name: true,
profile_image_url: 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,
created_at: true,
} satisfies Prisma.StoreSelect;

export type StoreSearchCandidateRow = StoreCandidateRow;

export interface StoreSearchFilter {
Expand Down Expand Up @@ -76,17 +90,7 @@ export class StoreRepository {
? { region_id: { in: regionIds } }
: {}),
},
select: {
id: true,
store_name: true,
profile_image_url: 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,
},
select: STORE_CANDIDATE_SELECT,
});
}

Expand All @@ -96,17 +100,7 @@ export class StoreRepository {
): Promise<StoreSearchCandidateRow[]> {
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,
},
select: STORE_CANDIDATE_SELECT,
orderBy: { id: 'asc' },
});
}
Expand Down
19 changes: 19 additions & 0 deletions src/features/store/resolvers/store-search-query.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,23 @@ describe('StoreSearchQueryResolver (real DB)', () => {

expect(result.totalCount).toBe(1);
});

it('searchStores: sort를 서비스로 전달한다', async () => {
const old = await createStore(prisma, { store_name: '리졸버 이전' });
await createStore(prisma, { store_name: '리졸버 최신' });
await prisma.store.update({
where: { id: old.id },
data: { created_at: new Date('2026-01-01T00:00:00Z') },
});

const result = await resolver.searchStores(
{ keyword: '리졸버', sort: 'LATEST' },
undefined,
);

expect(result.items.map((i) => i.storeName)).toEqual([
'리졸버 최신',
'리졸버 이전',
]);
});
});
103 changes: 103 additions & 0 deletions src/features/store/services/store-listing.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const DAY_MS = 24 * 60 * 60 * 1000;

describe('StoreListingService (real DB)', () => {
let service: StoreListingService;
let repo: StoreRepository;
let prisma: PrismaClient;

beforeAll(async () => {
Expand All @@ -35,6 +36,7 @@ describe('StoreListingService (real DB)', () => {
],
});
service = module.get(StoreListingService);
repo = module.get(StoreRepository);
prisma = p;
});

Expand Down Expand Up @@ -200,4 +202,105 @@ describe('StoreListingService (real DB)', () => {
expect(result.totalCount).toBe(0);
});
});

describe('sortStores', () => {
async function sold(storeId: bigint, quantity: number, daysAgo = 0) {
const order = await createOrder(prisma, { status: 'CONFIRMED' });
await createOrderItem(prisma, {
order_id: order.id,
store_id: storeId,
quantity,
});
if (daysAgo > 0) {
await prisma.order.update({
where: { id: order.id },
data: { created_at: new Date(Date.now() - daysAgo * DAY_MS) },
});
}
}

async function sortedNames(sort: Parameters<typeof service.sortStores>[1]) {
const candidates = await repo.findActiveStoresForRanking();
const { ordered } = await service.sortStores(
candidates,
sort,
new Date(),
);
return ordered.map((c) => c.store_name);
}

it('LATEST는 등록일 최신순, 같은 등록일은 id desc로 정렬한다', async () => {
const old = await createStore(prisma, { store_name: '오래된' });
const tieA = await createStore(prisma, { store_name: '동률A' });
const tieB = await createStore(prisma, { store_name: '동률B' });
await createStore(prisma, { store_name: '최신' });
const at = (daysAgo: number) => new Date(Date.now() - daysAgo * DAY_MS);
await prisma.store.update({
where: { id: old.id },
data: { created_at: at(10) },
});
for (const id of [tieA.id, tieB.id]) {
await prisma.store.update({
where: { id },
data: { created_at: at(5) },
});
}

expect(await sortedNames('LATEST')).toEqual([
'최신',
'동률B',
'동률A',
'오래된',
]);
});

it('BEST_SELLING은 최근 30일 판매 수량 합순, 판매 0건도 뒤에 남기고 동률은 id desc', async () => {
const few = await createStore(prisma, { store_name: '주문많고수량적음' });
const many = await createStore(prisma, { store_name: '수량많음' });
const stale = await createStore(prisma, { store_name: '오래된판매' });
await createStore(prisma, { store_name: '판매없음A' });
await createStore(prisma, { store_name: '판매없음B' });
await sold(few.id, 1);
await sold(few.id, 1);
await sold(many.id, 5);
await sold(stale.id, 10, 40);

expect(await sortedNames('BEST_SELLING')).toEqual([
'수량많음',
'주문많고수량적음',
'판매없음B',
'판매없음A',
'오래된판매',
]);
});

it('POPULAR는 인기 점수순이고 카드용 통계를 함께 돌려준다', async () => {
await createStore(prisma, { store_name: '보통' });
const hot = await createStore(prisma, { store_name: '인기' });
await reviewStore(hot.id, 5);

const candidates = await repo.findActiveStoresForRanking();
const result = await service.sortStores(
candidates,
'POPULAR',
new Date(),
);

expect(result.ordered.map((c) => c.store_name)).toEqual(['인기', '보통']);
expect(result.stats?.get(hot.id)).toMatchObject({
ratingAverage: 5,
reviewCount: 1,
});
});

it('LATEST·BEST_SELLING은 통계를 채우지 않는다(카드가 직접 집계)', async () => {
await createStore(prisma);
const candidates = await repo.findActiveStoresForRanking();

for (const sort of ['LATEST', 'BEST_SELLING'] as const) {
const result = await service.sortStores(candidates, sort, new Date());
expect(result.stats).toBeUndefined();
}
});
});
});
58 changes: 57 additions & 1 deletion src/features/store/services/store-listing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ import {
DEFAULT_POPULAR_STORES_LIMIT,
RANKING_RECENT_ORDER_DAYS,
} from '@/features/store/constants/store-ranking.constants';
import type { StoreSort } from '@/features/store/constants/store-sort.constants';
import type { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input';
import { StoreStatsRepository } from '@/features/store/repositories/store-stats.repository';
import {
StoreRepository,
type StoreCandidateRow,
} from '@/features/store/repositories/store.repository';
import { StoreCardService } from '@/features/store/services/store-card.service';
import {
StoreCardService,
type StoreCardStats,
} from '@/features/store/services/store-card.service';
import {
scoreAndSortByPopularity,
type ScoredCandidate,
Expand All @@ -25,6 +29,12 @@ import type { PopularStoreConnection } from '@/features/store/types/store-output

export type ScoredStore = ScoredCandidate<StoreCandidateRow>;

export interface SortedStores<T> {
ordered: T[];
/** 인기순만 채운다 — 점수에 쓴 리뷰 통계를 카드가 다시 집계하지 않도록. */
stats?: Map<bigint, StoreCardStats>;
}

@Injectable()
export class StoreListingService {
constructor(
Expand Down Expand Up @@ -72,6 +82,47 @@ export class StoreListingService {
);
}

/** 동률은 id desc로 고정한다(인기순은 리뷰 수 desc가 먼저). */
async sortStores<T extends StoreCandidateRow>(
candidates: T[],
sort: StoreSort,
at: Date,
): Promise<SortedStores<T>> {
switch (sort) {
case 'POPULAR': {
const scored = await this.scoreStores(candidates, at);
return {
ordered: scored.map((entry) => entry.candidate),
stats: new Map(
scored.map((entry) => [entry.candidate.id, entry.metrics]),
),
};
}
case 'BEST_SELLING': {
const sold = await this.stats.aggregateSoldQuantities(
'store_id',
candidates.map((c) => c.id),
new Date(at.getTime() - RANKING_RECENT_ORDER_DAYS * DAY_MS),
);
return {
ordered: [...candidates].sort(
(a, b) =>
(sold.get(b.id) ?? 0) - (sold.get(a.id) ?? 0) ||
compareIdDesc(a, b),
),
};
}
case 'LATEST':
return {
ordered: [...candidates].sort(
(a, b) =>
b.created_at.getTime() - a.created_at.getTime() ||
compareIdDesc(a, b),
),
};
}
}

async popularStores(
input?: PopularStoresInput,
accountId?: bigint,
Expand Down Expand Up @@ -110,3 +161,8 @@ export class StoreListingService {
};
}
}

function compareIdDesc(a: { id: bigint }, b: { id: bigint }): number {
if (a.id === b.id) return 0;
return b.id > a.id ? 1 : -1;
}
35 changes: 35 additions & 0 deletions src/features/store/services/store-search.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,41 @@ describe('StoreSearchService (real DB)', () => {
]);
});

it('sort를 지정하면 그 순서로 정렬하고 페이지는 정렬 뒤에 자른다', async () => {
const first = await createStore(prisma, { store_name: '케이크 먼저' });
await createStore(prisma, { store_name: '케이크 나중' });
await prisma.store.update({
where: { id: first.id },
data: { created_at: new Date('2026-01-01T00:00:00Z') },
});
await confirmOrders(first, 2);

expect(await names({ keyword: '케이크', sort: 'LATEST' })).toEqual([
'케이크 나중',
'케이크 먼저',
]);
expect(await names({ keyword: '케이크', sort: 'BEST_SELLING' })).toEqual([
'케이크 먼저',
'케이크 나중',
]);
expect(
await names({ keyword: '케이크', sort: 'LATEST', offset: 1, limit: 1 }),
).toEqual(['케이크 먼저']);
});

it('sort를 생략하거나 null로 넘기면 인기순이다', async () => {
const hot = await createStore(prisma, { store_name: '케이크 인기' });
await createStore(prisma, { store_name: '케이크 신규' });
await confirmOrders(hot, 2);

for (const sort of [undefined, null]) {
expect(await names({ keyword: '케이크', sort })).toEqual([
'케이크 인기',
'케이크 신규',
]);
}
});

it('offset/limit·totalCount·hasMore', async () => {
for (let i = 0; i < 3; i += 1) {
await createStore(prisma, { store_name: `케이크 ${i}` });
Expand Down
Loading
Loading