From 8e88ea3c1be4dbda8fdaf20fd56ff1f9cb2c37bb Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 6 Aug 2026 12:10:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20storeReviews=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=EC=88=9C=20=EC=A0=95=EB=A0=AC(sort=3DLIKES)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE 요청 반영. 페이지 내 클라이언트 정렬로는 전체 기준 좋아요순이 불가능해 productReviews와 동일 의미론의 sort(LATEST/LIKES)를 추가한다. - repository를 id 페이지 + hydrate 구조로 재편(product 미러) - 좋아요순은 soft-delete 좋아요 제외 집계 기준이라 raw 키셋 페이지네이션((likeCount, id) 커서) 사용 - ":" 커서 파싱에 안전 정수 검증 포함 - 기존 호출 영향 없음(sort 기본 LATEST, LATEST 커서는 기존 id 방식) --- .../constants/store-review-error-messages.ts | 4 + .../store/dto/inputs/store-reviews.input.ts | 8 ++ .../repositories/store-review.repository.ts | 72 +++++++++- .../store-review-query.resolver.spec.ts | 20 +++ .../services/store-review.service.spec.ts | 129 ++++++++++++++++++ .../store/services/store-review.service.ts | 126 ++++++++++++++--- src/features/store/store-reviews.graphql | 11 +- 7 files changed, 344 insertions(+), 26 deletions(-) create mode 100644 src/features/store/constants/store-review-error-messages.ts diff --git a/src/features/store/constants/store-review-error-messages.ts b/src/features/store/constants/store-review-error-messages.ts new file mode 100644 index 0000000..064c6bb --- /dev/null +++ b/src/features/store/constants/store-review-error-messages.ts @@ -0,0 +1,4 @@ +/** 매장 리뷰 조회 에러 메시지. */ +export const STORE_REVIEW_ERRORS = { + INVALID_LIKES_CURSOR: '좋아요순 커서 형식이 올바르지 않습니다.', +} as const; diff --git a/src/features/store/dto/inputs/store-reviews.input.ts b/src/features/store/dto/inputs/store-reviews.input.ts index 9a0c8e6..0ab67d5 100644 --- a/src/features/store/dto/inputs/store-reviews.input.ts +++ b/src/features/store/dto/inputs/store-reviews.input.ts @@ -1,5 +1,6 @@ import { IsBoolean, + IsIn, IsInt, IsNotEmpty, IsOptional, @@ -8,6 +9,9 @@ import { Min, } from 'class-validator'; +export const STORE_REVIEW_SORTS = ['LATEST', 'LIKES'] as const; +export type StoreReviewSort = (typeof STORE_REVIEW_SORTS)[number]; + export class StoreReviewsInput { @IsString() @IsNotEmpty() @@ -17,6 +21,10 @@ export class StoreReviewsInput { @IsBoolean() photoOnly?: boolean; + @IsOptional() + @IsIn(STORE_REVIEW_SORTS) + sort?: StoreReviewSort; + @IsOptional() @IsString() @IsNotEmpty() diff --git a/src/features/store/repositories/store-review.repository.ts b/src/features/store/repositories/store-review.repository.ts index e58d78d..2bdc023 100644 --- a/src/features/store/repositories/store-review.repository.ts +++ b/src/features/store/repositories/store-review.repository.ts @@ -43,14 +43,14 @@ export class StoreReviewRepository { }; } - /** 매장 공개 리뷰 목록(최신순, 커서 id desc). soft-delete 제외. */ - async listStoreReviews(args: { + /** 매장 리뷰 id 페이지(최신순, 커서 id desc). */ + async listStoreReviewIdsLatest(args: { storeId: bigint; photoOnly: boolean; limit: number; cursor?: bigint; - }): Promise { - return this.prisma.review.findMany({ + }): Promise { + const rows = await this.prisma.review.findMany({ where: { store_id: args.storeId, ...this.publicReviewWhere(args.photoOnly), @@ -58,6 +58,68 @@ export class StoreReviewRepository { // zero cursor가 페이지를 리셋하므로 undefined로만 분기한다. ...(args.cursor !== undefined ? { id: { lt: args.cursor } } : {}), }, + select: { id: true }, + orderBy: { id: 'desc' }, + take: args.limit + 1, + }); + return rows.map((row) => row.id); + } + + /** + * 매장 리뷰 id 페이지(좋아요순 desc, 동률이면 id desc). + * + * soft-delete된 좋아요를 제외한 집계 기준 정렬이 Prisma orderBy(_count)로는 + * 불가능하므로 raw 키셋 페이지네이션으로 조회한다. 커서는 이전 페이지 경계의 + * (likeCount, id) 값을 그대로 받아 이어간다 — 경계 리뷰의 좋아요 수가 요청 + * 사이에 변해도 페이지가 중복/누락되지 않는다. + */ + async listStoreReviewIdsByLikes(args: { + storeId: bigint; + photoOnly: boolean; + limit: number; + cursor?: { likeCount: number; id: bigint }; + }): Promise<{ id: bigint; likeCount: number }[]> { + const photoFilter = args.photoOnly + ? Prisma.sql`AND EXISTS ( + SELECT 1 FROM review_media m + WHERE m.review_id = r.id AND m.deleted_at IS NULL + )` + : Prisma.empty; + const cursorHaving = + args.cursor !== undefined + ? Prisma.sql`HAVING COUNT(l.id) < ${args.cursor.likeCount} + OR (COUNT(l.id) = ${args.cursor.likeCount} AND r.id < ${args.cursor.id})` + : Prisma.empty; + + const rows = await this.prisma.$queryRaw< + { id: bigint; like_count: bigint }[] + >(Prisma.sql` + SELECT r.id AS id, COUNT(l.id) AS like_count + FROM review r + JOIN store s + ON s.id = r.store_id AND s.is_active = 1 AND s.deleted_at IS NULL + LEFT JOIN review_like l + ON l.review_id = r.id AND l.deleted_at IS NULL + WHERE r.store_id = ${args.storeId} AND r.deleted_at IS NULL + ${photoFilter} + GROUP BY r.id + ${cursorHaving} + ORDER BY like_count DESC, r.id DESC + LIMIT ${args.limit + 1} + `); + return rows.map((row) => ({ + id: row.id, + likeCount: Number(row.like_count), + })); + } + + /** id 페이지의 리뷰 본문 row 일괄 조회(정렬은 service에서 id 순서로 복원). */ + async findStoreReviewRowsByIds( + reviewIds: bigint[], + ): Promise { + if (reviewIds.length === 0) return []; + return this.prisma.review.findMany({ + where: { id: { in: reviewIds }, deleted_at: null }, select: { id: true, rating: true, @@ -82,8 +144,6 @@ export class StoreReviewRepository { }, }, }, - orderBy: { id: 'desc' }, - take: args.limit + 1, }); } diff --git a/src/features/store/resolvers/store-review-query.resolver.spec.ts b/src/features/store/resolvers/store-review-query.resolver.spec.ts index 3db4787..64549b3 100644 --- a/src/features/store/resolvers/store-review-query.resolver.spec.ts +++ b/src/features/store/resolvers/store-review-query.resolver.spec.ts @@ -67,6 +67,26 @@ describe('Store Review Query Resolver (real DB)', () => { expect(result.items[0].isLiked).toBe(false); }); + it('storeReviews: sort=LIKES가 service까지 전달되어 좋아요순으로 반환한다', async () => { + const store = await createStore(prisma); + const unpopular = await makeReview(store.id); + const popular = await makeReview(store.id); + const liker = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewLike.create({ + data: { review_id: popular.id, account_id: liker.id }, + }); + + const result = await resolver.storeReviews( + { storeId: store.id.toString(), sort: 'LIKES' }, + undefined, + ); + + expect(result.items.map((r) => r.id)).toEqual([ + popular.id.toString(), + unpopular.id.toString(), + ]); + }); + it('storeReviews: photoOnly 필터가 service까지 전달된다', async () => { const store = await createStore(prisma); await makeReview(store.id); diff --git a/src/features/store/services/store-review.service.spec.ts b/src/features/store/services/store-review.service.spec.ts index d16c056..76392b1 100644 --- a/src/features/store/services/store-review.service.spec.ts +++ b/src/features/store/services/store-review.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import type { PrismaClient } from '@prisma/client'; import { StoreReviewRepository } from '@/features/store/repositories/store-review.repository'; @@ -56,6 +57,26 @@ describe('StoreReviewService (real DB)', () => { }); } + async function addLikes(reviewId: bigint, count: number) { + for (let i = 0; i < count; i += 1) { + const liker = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewLike.create({ + data: { review_id: reviewId, account_id: liker.id }, + }); + } + } + + async function addMedia(reviewId: bigint) { + await prisma.reviewMedia.create({ + data: { + review_id: reviewId, + media_type: 'IMAGE', + media_url: 'a.png', + sort_order: 0, + }, + }); + } + it('리뷰가 없으면 빈 목록과 totalCount/photoTotalCount 0', async () => { const store = await createStore(prisma); const result = await service.storeReviews({ storeId: store.id.toString() }); @@ -218,6 +239,114 @@ describe('StoreReviewService (real DB)', () => { expect(second.nextCursor).toBeNull(); }); + it('좋아요순 정렬: soft-delete 좋아요 제외 집계, 동률이면 최신순', async () => { + const store = await createStore(prisma); + const zeroLikes = await makeReview(store.id, {}); + const twoLikes = await makeReview(store.id, {}); + const threeLikes = await makeReview(store.id, {}); + await addLikes(twoLikes.id, 2); + await addLikes(threeLikes.id, 3); + // soft-delete된 좋아요는 집계에서 제외 → twoLikes는 2개 유지 + const canceledLiker = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewLike.create({ + data: { + review_id: twoLikes.id, + account_id: canceledLiker.id, + deleted_at: new Date(), + }, + }); + + const result = await service.storeReviews({ + storeId: store.id.toString(), + sort: 'LIKES', + }); + + expect(result.items.map((r) => r.id)).toEqual([ + threeLikes.id.toString(), + twoLikes.id.toString(), + zeroLikes.id.toString(), + ]); + expect(result.items.map((r) => r.likeCount)).toEqual([3, 2, 0]); + }); + + it('좋아요순 + photoOnly 조합: 사진 리뷰만 좋아요순으로 반환한다', async () => { + const store = await createStore(prisma); + const textOnlyPopular = await makeReview(store.id, {}); + await addLikes(textOnlyPopular.id, 5); + const photoFew = await makeReview(store.id, {}); + await addMedia(photoFew.id); + await addLikes(photoFew.id, 1); + const photoMany = await makeReview(store.id, {}); + await addMedia(photoMany.id); + await addLikes(photoMany.id, 3); + + const result = await service.storeReviews({ + storeId: store.id.toString(), + sort: 'LIKES', + photoOnly: true, + }); + + // 좋아요 5개인 텍스트 리뷰는 photoOnly에서 제외된다 + expect(result.items.map((r) => r.id)).toEqual([ + photoMany.id.toString(), + photoFew.id.toString(), + ]); + }); + + it('좋아요순 커서: (likeCount, id) 키셋으로 다음 페이지를 이어받는다', async () => { + const store = await createStore(prisma); + const reviewA = await makeReview(store.id, {}); + const reviewB = await makeReview(store.id, {}); + const reviewC = await makeReview(store.id, {}); + await addLikes(reviewA.id, 2); + await addLikes(reviewB.id, 2); + await addLikes(reviewC.id, 1); + + // 동률(2)은 id desc → B, A 순. limit=2로 첫 페이지 [B, A] + const page1 = await service.storeReviews({ + storeId: store.id.toString(), + sort: 'LIKES', + limit: 2, + }); + expect(page1.items.map((r) => r.id)).toEqual([ + reviewB.id.toString(), + reviewA.id.toString(), + ]); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).toBe(`2:${reviewA.id.toString()}`); + + const page2 = await service.storeReviews({ + storeId: store.id.toString(), + sort: 'LIKES', + limit: 2, + cursor: page1.nextCursor ?? undefined, + }); + expect(page2.items.map((r) => r.id)).toEqual([reviewC.id.toString()]); + expect(page2.hasMore).toBe(false); + expect(page2.nextCursor).toBeNull(); + }); + + it('좋아요순 커서 형식이 잘못되면 BAD_USER_INPUT', async () => { + const store = await createStore(prisma); + + await expect( + service.storeReviews({ + storeId: store.id.toString(), + sort: 'LIKES', + cursor: 'abc', + }), + ).rejects.toThrow(BadRequestException); + + // 자릿수 폭탄: 안전 정수 범위를 벗어난 likeCount는 형식 오류로 거부 + await expect( + service.storeReviews({ + storeId: store.id.toString(), + sort: 'LIKES', + cursor: `${'1'.repeat(400)}:1`, + }), + ).rejects.toThrow(BadRequestException); + }); + it('비활성/삭제 매장의 리뷰는 목록·카운트에서 제외한다', async () => { const store = await createStore(prisma, { is_active: false }); await makeReview(store.id, {}); diff --git a/src/features/store/services/store-review.service.ts b/src/features/store/services/store-review.service.ts index 35e5315..6bd3c65 100644 --- a/src/features/store/services/store-review.service.ts +++ b/src/features/store/services/store-review.service.ts @@ -1,19 +1,23 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { parseId } from '@/common/utils/id-parser'; +import { STORE_REVIEW_ERRORS } from '@/features/store/constants/store-review-error-messages'; import { DEFAULT_STORE_REVIEWS_LIMIT } from '@/features/store/constants/store-review.constants'; import type { StoreReviewsInput } from '@/features/store/dto/inputs/store-reviews.input'; import { StoreReviewRepository } from '@/features/store/repositories/store-review.repository'; import { toStoreReview } from '@/features/store/services/store-review-mappers.helper'; -import type { StoreReviewConnection } from '@/features/store/types/store-review-output.type'; +import type { + StoreReview, + StoreReviewConnection, +} from '@/features/store/types/store-review-output.type'; @Injectable() export class StoreReviewService { constructor(private readonly repo: StoreReviewRepository) {} /** - * 매장 공개 리뷰 목록(커서). soft-delete 제외, 최신순. 사진 필터 지원. - * 좋아요 수는 집계, isLiked는 로그인 사용자에 한해 채운다(비로그인 false). + * 매장 공개 리뷰 목록(커서). 사진 필터·정렬(최신/좋아요) 지원. + * id 페이지를 먼저 정한 뒤 본문·집계를 일괄 hydrate한다. */ async storeReviews( input: StoreReviewsInput, @@ -22,42 +26,126 @@ export class StoreReviewService { const storeId = parseId(input.storeId); const limit = input.limit ?? DEFAULT_STORE_REVIEWS_LIMIT; const photoOnly = input.photoOnly ?? false; + const sort = input.sort ?? 'LATEST'; // photoTotalCount는 필터와 무관하게 항상 사진 리뷰 총수(productReviews와 동일 의미) - const [rows, totalCount, photoTotalCount] = await Promise.all([ - this.repo.listStoreReviews({ + const [idPage, totalCount, photoTotalCount] = await Promise.all([ + this.fetchReviewIdPage({ storeId, photoOnly, + sort, limit, - cursor: input.cursor ? parseId(input.cursor) : undefined, + cursorRaw: input.cursor, }), this.repo.countStoreReviews({ storeId, photoOnly: false }), this.repo.countStoreReviews({ storeId, photoOnly: true }), ]); - const hasMore = rows.length > limit; - const page = hasMore ? rows.slice(0, limit) : rows; - const reviewIds = page.map((row) => row.id); + const items = await this.hydrateReviews(idPage.pageIds, accountId); - const [likeCounts, likedIds] = await Promise.all([ + return { + items, + totalCount, + photoTotalCount, + hasMore: idPage.hasMore, + nextCursor: idPage.nextCursor, + }; + } + + /** + * 정렬별 리뷰 id 페이지 + 다음 커서 계산. + * + * 좋아요순 커서는 ":" 불투명 토큰 — 경계 시점의 좋아요 수를 + * 담아, 이후 좋아요 수가 변해도 페이지가 중복/누락되지 않는다. + * 최신순 커서는 마지막 리뷰 id. 커서는 동일 sort 안에서만 유효하다. + */ + private async fetchReviewIdPage(args: { + storeId: bigint; + photoOnly: boolean; + sort: 'LATEST' | 'LIKES'; + limit: number; + cursorRaw?: string; + }): Promise<{ + pageIds: bigint[]; + hasMore: boolean; + nextCursor: string | null; + }> { + if (args.sort === 'LIKES') { + const rows = await this.repo.listStoreReviewIdsByLikes({ + storeId: args.storeId, + photoOnly: args.photoOnly, + limit: args.limit, + cursor: args.cursorRaw + ? this.parseLikesCursor(args.cursorRaw) + : undefined, + }); + const hasMore = rows.length > args.limit; + const page = hasMore ? rows.slice(0, args.limit) : rows; + const last = page[page.length - 1]; + return { + pageIds: page.map((row) => row.id), + hasMore, + nextCursor: hasMore ? `${last.likeCount}:${last.id.toString()}` : null, + }; + } + + const ids = await this.repo.listStoreReviewIdsLatest({ + storeId: args.storeId, + photoOnly: args.photoOnly, + limit: args.limit, + cursor: args.cursorRaw ? parseId(args.cursorRaw) : undefined, + }); + const hasMore = ids.length > args.limit; + const pageIds = hasMore ? ids.slice(0, args.limit) : ids; + return { + pageIds, + hasMore, + nextCursor: hasMore ? pageIds[pageIds.length - 1].toString() : null, + }; + } + + /** 좋아요순 커서 파싱. ":" 형식이 아니면 BAD_USER_INPUT. */ + private parseLikesCursor(raw: string): { likeCount: number; id: bigint } { + const match = /^(\d+):(\d+)$/.exec(raw); + if (!match) { + throw new BadRequestException(STORE_REVIEW_ERRORS.INVALID_LIKES_CURSOR); + } + const likeCount = Number(match[1]); + // 자릿수 폭탄(예: 309자리)은 Number 변환 시 Infinity가 되어 raw SQL에 + // 비유한 값이 흘러간다. 안전 정수 범위를 벗어나면 형식 오류로 거부한다. + if (!Number.isSafeInteger(likeCount)) { + throw new BadRequestException(STORE_REVIEW_ERRORS.INVALID_LIKES_CURSOR); + } + return { likeCount, id: BigInt(match[2]) }; + } + + /** id 페이지 순서를 유지하며 본문 + 집계(좋아요/isLiked)를 채운다. */ + private async hydrateReviews( + reviewIds: bigint[], + accountId?: bigint, + ): Promise { + if (reviewIds.length === 0) return []; + + const [rows, likeCounts, likedIds] = await Promise.all([ + this.repo.findStoreReviewRowsByIds(reviewIds), this.repo.aggregateLikeCounts(reviewIds), accountId !== undefined ? this.repo.findLikedReviewIds({ reviewIds, accountId }) : Promise.resolve(new Set()), ]); - return { - items: page.map((row) => + const rowById = new Map(rows.map((row) => [row.id.toString(), row])); + return reviewIds.flatMap((id) => { + const row = rowById.get(id.toString()); + // id 페이지 조회와 hydrate 사이에 삭제된 리뷰는 건너뛴다 + if (!row) return []; + return [ toStoreReview( row, likeCounts.get(row.id) ?? 0, likedIds.has(row.id.toString()), ), - ), - totalCount, - photoTotalCount, - hasMore, - nextCursor: hasMore ? page[page.length - 1].id.toString() : null, - }; + ]; + }); } } diff --git a/src/features/store/store-reviews.graphql b/src/features/store/store-reviews.graphql index ba68bc5..d7a332a 100644 --- a/src/features/store/store-reviews.graphql +++ b/src/features/store/store-reviews.graphql @@ -7,11 +7,20 @@ input StoreReviewsInput { storeId: ID! """true면 사진(미디어) 있는 리뷰만(사진후기 그리드).""" photoOnly: Boolean = false - """이전 페이지 마지막 리뷰 id(이후부터 조회).""" + sort: StoreReviewSort = LATEST + """이전 페이지의 nextCursor 값(불투명 토큰). 동일 sort에서만 유효.""" cursor: ID limit: Int = 20 } +"""매장 리뷰 정렬.""" +enum StoreReviewSort { + """최신순.""" + LATEST + """좋아요순(동률이면 최신순).""" + LIKES +} + """매장 리뷰 목록(커서 기반).""" type StoreReviewConnection { items: [StoreReview!]!