From ac38b096926906a313dfa8cbd23131122552ad47 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 31 Aug 2026 05:25:05 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=8B=9C=EB=93=9C?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=82=AD=EC=A0=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=C2=B7=EA=B2=80=EC=83=89=EC=96=B4=20=EA=B8=B8=EC=9D=B4?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20(#255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 릴리즈 리뷰 2건 반영. - seedSearchEvents: 스냅샷 전량 deleteMany가 시드와 무관한 기존 스냅샷까지 지움 → 시드가 쓰는 두 정각(직전·현재)만 삭제 후 재삽입(시드는 자기 영역만 정리한다는 idempotent 원칙 준수). - normalizeSearchKeyword: 길이 검사를 UTF-16 단위(.length)에서 코드 포인트 기준으로 — MySQL VarChar(200)은 문자 수 기준이라 서로게이트 쌍(이모지)이 2로 계산되면 저장 가능한 검색어를 거절함. 회귀 테스트(이모지 200/201개) 추가. --- prisma/seed/search-events.ts | 9 ++++++--- src/common/utils/search-keyword.spec.ts | 9 +++++++++ src/common/utils/search-keyword.ts | 4 +++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/prisma/seed/search-events.ts b/prisma/seed/search-events.ts index 4f32049..c6ce944 100644 --- a/prisma/seed/search-events.ts +++ b/prisma/seed/search-events.ts @@ -2,8 +2,9 @@ * 검색 집계 이벤트 + 인기 검색어 스냅샷 시드(검색 진입 화면 검증용). * * - 이벤트는 시드 유저 소유(account_id)로만 만들어 resetSeedScope가 유저 기준으로 정리한다. - * - 스냅샷은 이벤트에서 파생되는 캐시라 시드마다 전량 재생성한다(직전 정각 + 현재 정각 2개, - * 순위 변동 UP/DOWN/SAME/NEW가 모두 보이도록 구성). + * - 스냅샷은 시드가 쓰는 두 정각(직전·현재)만 지우고 재생성한다 — 다른 시각대의 + * 기존 스냅샷은 시드 데이터가 아니므로 보존한다(릴리즈 리뷰 반영). + * 순위 변동 UP/DOWN/SAME/NEW가 모두 보이도록 구성. */ import type { PrismaClient } from '@prisma/client'; @@ -63,7 +64,9 @@ export async function seedSearchEvents( ), }); - await prisma.searchKeywordRankSnapshot.deleteMany(); + await prisma.searchKeywordRankSnapshot.deleteMany({ + where: { ranked_at: { in: [previousAt, rankedAt] } }, + }); await prisma.searchKeywordRankSnapshot.createMany({ data: [ ...PREVIOUS_RANKING.map((keyword, i) => ({ diff --git a/src/common/utils/search-keyword.spec.ts b/src/common/utils/search-keyword.spec.ts index 29cbafc..1851e1e 100644 --- a/src/common/utils/search-keyword.spec.ts +++ b/src/common/utils/search-keyword.spec.ts @@ -27,6 +27,15 @@ describe('search-keyword utils', () => { }); }); + 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({ diff --git a/src/common/utils/search-keyword.ts b/src/common/utils/search-keyword.ts index d120669..ac478bf 100644 --- a/src/common/utils/search-keyword.ts +++ b/src/common/utils/search-keyword.ts @@ -30,7 +30,9 @@ export function normalizeSearchKeyword( ): NormalizeSearchKeywordResult { const keyword = raw.trim().replace(/\s+/g, ' '); if (keyword.length === 0) return { ok: false, reason: 'EMPTY' }; - if (keyword.length > SEARCH_KEYWORD_MAX_LENGTH) { + // MySQL VarChar(200)은 문자(코드 포인트) 수 기준 — UTF-16 단위(.length)로 세면 + // 서로게이트 쌍(이모지 등)이 2로 계산돼 저장 가능한 검색어를 거절한다(릴리즈 리뷰 반영) + if ([...keyword].length > SEARCH_KEYWORD_MAX_LENGTH) { return { ok: false, reason: 'TOO_LONG' }; } return { ok: true, keyword };