-
Notifications
You must be signed in to change notification settings - Fork 0
chore: 릴리즈 — 검색 화면 API 4건 #255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b903900
f3840a7
2f04210
66aa80a
4651ef1
e50c067
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| -- AlterTable | ||
| ALTER TABLE `banner` MODIFY `placement` ENUM('HOME_MAIN', 'HOME_SUB', 'CATEGORY', 'STORE', 'SEARCH') NOT NULL DEFAULT 'HOME_MAIN'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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, | ||
| }, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n 'model[[:space:]]+SearchKeywordRankSnapshot|searchKeywordRankSnapshot' \
prisma/schema.prisma prisma/seed/search-events.ts
npx prisma generate
npx eslint prisma/seed/search-events.tsRepository: CaQuick/caquick-be Length of output: 1300 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- applicable repository guidance ---'
find /tmp/coderabbit-repo-knowledge/caquick-caquick-be-4fc8bd3a -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- seed file ---'
cat -n prisma/seed/search-events.ts | sed -n '1,110p'
printf '%s\n' '--- Prisma schema model ---'
sed -n '1240,1295p' prisma/schema.prisma
printf '%s\n' '--- Prisma binding and project configuration ---'
rg -n 'from[[:space:]]+["'\''][^"'\'']*prisma|PrismaClient|eslint|typescript|prisma' \
prisma/seed/search-events.ts package.json tsconfig*.json eslint.config.* .eslintrc* 2>/dev/null || trueRepository: CaQuick/caquick-be Length of output: 10092 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Prisma review convention ---'
cat /tmp/coderabbit-repo-knowledge/caquick-caquick-be-4fc8bd3a/conventions/prisma.md
printf '%s\n' '--- ESLint configuration around type-aware rules and file scope ---'
cat -n eslint.config.mjs | sed -n '1,270p'
printf '%s\n' '--- TypeScript and package scripts ---'
cat -n tsconfig.json | sed -n '1,180p'
cat -n package.json | sed -n '1,125p'Repository: CaQuick/caquick-be Length of output: 15395 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- available generated Prisma Client files ---'
if [ -d node_modules/@prisma/client ]; then
find node_modules/@prisma/client -maxdepth 2 -type f -print | sort | head -40
rg -n -m 5 'searchKeywordRankSnapshot|SearchKeywordRankSnapshot' \
node_modules/@prisma/client node_modules/.prisma 2>/dev/null || true
else
echo 'node_modules/@prisma/client is unavailable'
fi
printf '%s\n' '--- tracked generated-client or lockfile evidence ---'
git ls-files | rg '(^|/)(generated|node_modules)/|(^|/)(yarn.lock|package-lock.json|pnpm-lock.yaml)$' | head -80Repository: CaQuick/caquick-be Length of output: 2135 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Prisma Client type entry points ---'
sed -n '1,120p' node_modules/@prisma/client/index.d.ts
if [ -d node_modules/.prisma/client ]; then
find node_modules/.prisma/client -maxdepth 1 -type f -print | sort
rg -n -m 10 'searchKeywordRankSnapshot|SearchKeywordRankSnapshot|searchEvent' \
node_modules/.prisma/client node_modules/@prisma/client/index.d.ts
else
echo 'node_modules/.prisma/client is unavailable'
fi
printf '%s\n' '--- installed Prisma versions ---'
node -p "require('./node_modules/@prisma/client/package.json').version"
node -p "require('./node_modules/prisma/package.json').version"Repository: CaQuick/caquick-be Length of output: 584 🏁 Script executed: #!/bin/bash
set +e
printf '%s\n' '--- generated Prisma Client declarations ---'
wc -l node_modules/.prisma/client/index.d.ts node_modules/.prisma/client/default.d.ts
sed -n '1,100p' node_modules/.prisma/client/default.d.ts
rg -n -m 20 'class PrismaClient|UserDelegate|SearchEvent|SearchKeywordRankSnapshot|searchEvent|searchKeywordRankSnapshot|export.*PrismaClient' \
node_modules/.prisma/client/index.d.ts node_modules/.prisma/client/default.d.ts
printf '%s\n' '--- installed versions ---'
node -p "require('./node_modules/@prisma/client/package.json').version" || true
node -p "require('./node_modules/prisma/package.json').version" || trueRepository: CaQuick/caquick-be Length of output: 4525 생성된 Prisma Client 타입을 스키마와 동기화하세요.
🧰 Tools🪛 ESLint[error] 67-67: Unsafe call of an ( [error] 67-67: Unsafe member access .searchKeywordRankSnapshot on an ( 🤖 Prompt for AI AgentsSource: Linters/SAST tools
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. false positive: 리뷰 샌드박스에서 npx prisma generate가 실패해 생성 클라이언트가 any로 보이는 환경 아티팩트. 로컬/CI에서는 prisma generate 후 tsc·eslint 모두 통과(필수 체크 check=pass), searchKeywordRankSnapshot delegate 정상 생성됨. |
||
| 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, | ||
| })), | ||
| ], | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.