From 01be06f3cb88828cf9087ab9d8879f7c86b544fe Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Thu, 20 Aug 2026 20:00:34 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat=20:=20=EC=9D=B4=EB=8B=AC=EC=9D=98=20?= =?UTF-8?q?=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=ED=88=AC=ED=91=9C/?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=20DB=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EB=B0=8F=20API=20=EC=B6=94=EA=B0=80=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포인트로 아티스트에 투표하는 artist_votes, 월별 1~10위를 확정하는 monthly_artist_rankings, 그리고 두 테이블이 FK로 참조하는 artists 마스터 테이블 스키마를 추가한다. 투표 증감/삭제(PUT), 랭킹/투표자 조회, 매일 00:10 KST에 전월을 확정하는 finalize 배치(GitHub Actions + service role 전용 라우트)까지 백엔드 전체를 구성한다. --- .../workflows/finalize_artist_of_month.yml | 19 +++ apps/web/artist-vote-schema.sql | 98 +++++++++++++ .../src/app/api/artist-vote/_lib/rankVotes.ts | 56 ++++++++ .../src/app/api/artist-vote/_lib/starCount.ts | 16 +++ .../api/artist-vote/current-winner/route.ts | 39 +++++ .../src/app/api/artist-vote/finalize/route.ts | 66 +++++++++ .../app/api/artist-vote/my-current/route.ts | 38 +++++ .../rankings/[month]/[artist]/voters/route.ts | 62 ++++++++ .../src/app/api/artist-vote/rankings/route.ts | 85 +++++++++++ apps/web/src/app/api/artist-vote/route.ts | 136 ++++++++++++++++++ apps/web/src/lib/api/artistVote.ts | 40 ++++++ apps/web/src/lib/supabase/serviceRole.ts | 9 ++ apps/web/src/queries/artistVoteQuery.ts | 73 ++++++++++ apps/web/src/types/artistVote.ts | 30 ++++ apps/web/src/utils/kst.ts | 9 ++ turbo.json | 4 +- 16 files changed, 779 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/finalize_artist_of_month.yml create mode 100644 apps/web/artist-vote-schema.sql create mode 100644 apps/web/src/app/api/artist-vote/_lib/rankVotes.ts create mode 100644 apps/web/src/app/api/artist-vote/_lib/starCount.ts create mode 100644 apps/web/src/app/api/artist-vote/current-winner/route.ts create mode 100644 apps/web/src/app/api/artist-vote/finalize/route.ts create mode 100644 apps/web/src/app/api/artist-vote/my-current/route.ts create mode 100644 apps/web/src/app/api/artist-vote/rankings/[month]/[artist]/voters/route.ts create mode 100644 apps/web/src/app/api/artist-vote/rankings/route.ts create mode 100644 apps/web/src/app/api/artist-vote/route.ts create mode 100644 apps/web/src/lib/api/artistVote.ts create mode 100644 apps/web/src/lib/supabase/serviceRole.ts create mode 100644 apps/web/src/queries/artistVoteQuery.ts create mode 100644 apps/web/src/types/artistVote.ts diff --git a/.github/workflows/finalize_artist_of_month.yml b/.github/workflows/finalize_artist_of_month.yml new file mode 100644 index 00000000..1627b035 --- /dev/null +++ b/.github/workflows/finalize_artist_of_month.yml @@ -0,0 +1,19 @@ +name: Finalize Artist of the Month + +on: + schedule: + # 매일 00:10 KST(UTC 15:10) 실행. finalize API는 "지난달" 결과만 계산해 덮어쓰므로 + # 하루에 한 번 이상 돌아도 안전하다 — GitHub Actions cron 지연으로 자정 실행을 놓치는 상황에 대비한다. + - cron: "10 15 * * *" + workflow_dispatch: + +jobs: + finalize: + runs-on: ubuntu-latest + + steps: + - name: Call finalize API + run: | + curl --fail --silent --show-error \ + -X POST "https://www.singcode.kr/api/artist-vote/finalize" \ + -H "Authorization: Bearer ${{ secrets.ARTIST_VOTE_FINALIZE_SECRET }}" diff --git a/apps/web/artist-vote-schema.sql b/apps/web/artist-vote-schema.sql new file mode 100644 index 00000000..c3f75416 --- /dev/null +++ b/apps/web/artist-vote-schema.sql @@ -0,0 +1,98 @@ +-- ========================================================= +-- 아티스트 마스터 테이블 +-- artist_votes/monthly_artist_rankings가 이 테이블을 FK로 참조하므로 먼저 만든다. +-- packages/crawling의 backfillArtists.ts가 songs 테이블을 순회하며 채운다 +-- (packages/crawling/.env의 SUPABASE_KEY는 RLS를 우회하는 키라 별도 쓰기 정책 불필요). +-- name은 songs.artist 원문(alias 사전에 매칭되면 그 공식 표기)을 그대로 쓰고, +-- name_ko는 일본 아티스트의 한국어 표기, country_code는 song_tags 언어 태그로 추정한다. +-- +-- artist_votes/monthly_artist_rankings가 name을 FK로 참조하므로, 이 테이블이 +-- 비어있는 상태(백필 전)에서는 투표가 전부 실패한다 — backfill-artists를 먼저 돌려야 한다. +-- ========================================================= +create table public.artists ( + id uuid primary key default gen_random_uuid(), + name text not null unique, + name_ko text, + country_code text check (country_code is null or country_code in ('KR', 'JP', 'US')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index artists_name_ko_idx on public.artists (name_ko); + +alter table public.artists enable row level security; + +-- 아티스트 투표 검색 자동완성이 로그인 없이도 조회하므로 SELECT는 공개 +create policy "artists_select_all" + on public.artists for select + using (true); + +-- insert/update 정책 없음 — backfill 스크립트(service-role 상당 키)만 쓴다. + +-- ========================================================= +-- 이달의 아티스트: 아티스트 투표 +-- 유저 x 아티스트 x 월 단위로 1행. amount는 "현재 걸어둔 포인트"이며 +-- PUT /api/artist-vote 를 통해 자유롭게 증감/삭제된다. +-- artist는 artists.name을 FK로 참조해, artists 테이블에 없는 이름으로는 투표할 수 없다. +-- ========================================================= +create table public.artist_votes ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references public.users(id) on delete cascade, + artist text not null references public.artists(name) on delete restrict, + vote_month date not null, -- 매월 1일로 정규화 (예: 2026-08-01) + amount integer not null check (amount > 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (user_id, artist, vote_month) +); + +create index artist_votes_month_artist_idx on public.artist_votes (vote_month, artist); + +alter table public.artist_votes enable row level security; + +-- 역대 투표자 상세를 로그인 없이도 조회할 수 있어야 하므로 SELECT는 공개 +create policy "artist_votes_select_all" + on public.artist_votes for select + using (true); + +-- 본인 소유 행만 쓰기 가능 (API 라우트가 항상 인증된 user_id로만 씀) +create policy "artist_votes_insert_own" + on public.artist_votes for insert + with check (auth.uid() = user_id); + +create policy "artist_votes_update_own" + on public.artist_votes for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id); + +create policy "artist_votes_delete_own" + on public.artist_votes for delete + using (auth.uid() = user_id); + +-- ========================================================= +-- 이달의 아티스트: 월별 확정 랭킹 (1~10위) +-- 매월 1일 00:00(KST) GitHub Actions가 finalize API를 호출해 전월 결과를 기록한다. +-- artist_votes에서 집계하므로 artist는 항상 artists.name에 존재하는 값이다. +-- ========================================================= +create table public.monthly_artist_rankings ( + id uuid primary key default gen_random_uuid(), + vote_month date not null, + rank smallint not null check (rank between 1 and 10), + artist text not null references public.artists(name) on delete restrict, + total_votes integer not null, + top_voter_user_id uuid references public.users(id) on delete set null, + top_voter_amount integer, + decided_at timestamptz not null default now(), + unique (vote_month, rank), + unique (vote_month, artist) +); + +alter table public.monthly_artist_rankings enable row level security; + +-- 누구나 조회 가능 (역대 결과 공개 페이지) +create policy "monthly_artist_rankings_select_all" + on public.monthly_artist_rankings for select + using (true); + +-- insert/update/delete 정책은 의도적으로 만들지 않는다. +-- anon/authenticated 롤은 쓰기 불가하며, finalize 배치만 service role key로 RLS를 우회해 쓴다. diff --git a/apps/web/src/app/api/artist-vote/_lib/rankVotes.ts b/apps/web/src/app/api/artist-vote/_lib/rankVotes.ts new file mode 100644 index 00000000..a364efc4 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/_lib/rankVotes.ts @@ -0,0 +1,56 @@ +export interface VoteRow { + user_id: string; + artist: string; + amount: number; + created_at: string; +} + +export interface ArtistAggregate { + artist: string; + total: number; + topVoterUserId: string; + topVoterAmount: number; + firstCreatedAt: string; +} + +/** + * 아티스트별로 득표를 합산해 상위 10명을 반환한다. + * 동점이면 그 달에 먼저 득표를 모으기 시작한 아티스트가 우선, 그래도 같으면 이름순. + */ +export function rankTopArtists(votes: VoteRow[]): ArtistAggregate[] { + const aggregateMap = new Map(); + + for (const vote of votes) { + const existing = aggregateMap.get(vote.artist); + + if (!existing) { + aggregateMap.set(vote.artist, { + artist: vote.artist, + total: vote.amount, + topVoterUserId: vote.user_id, + topVoterAmount: vote.amount, + firstCreatedAt: vote.created_at, + }); + continue; + } + + existing.total += vote.amount; + if (vote.created_at < existing.firstCreatedAt) { + existing.firstCreatedAt = vote.created_at; + } + if (vote.amount > existing.topVoterAmount) { + existing.topVoterUserId = vote.user_id; + existing.topVoterAmount = vote.amount; + } + } + + return [...aggregateMap.values()] + .sort((a, b) => { + if (b.total !== a.total) return b.total - a.total; + if (a.firstCreatedAt !== b.firstCreatedAt) { + return a.firstCreatedAt < b.firstCreatedAt ? -1 : 1; + } + return a.artist.localeCompare(b.artist); + }) + .slice(0, 10); +} diff --git a/apps/web/src/app/api/artist-vote/_lib/starCount.ts b/apps/web/src/app/api/artist-vote/_lib/starCount.ts new file mode 100644 index 00000000..cb7fb503 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/_lib/starCount.ts @@ -0,0 +1,16 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + +/** 아티스트가 지금까지 1위(=이달의 아티스트)로 확정된 누적 횟수 */ +export async function getArtistStarCount( + supabase: SupabaseClient, + artist: string, +): Promise { + const { count, error } = await supabase + .from('monthly_artist_rankings') + .select('id', { count: 'exact', head: true }) + .eq('artist', artist) + .eq('rank', 1); + + if (error) throw error; + return count ?? 0; +} diff --git a/apps/web/src/app/api/artist-vote/current-winner/route.ts b/apps/web/src/app/api/artist-vote/current-winner/route.ts new file mode 100644 index 00000000..c162c42b --- /dev/null +++ b/apps/web/src/app/api/artist-vote/current-winner/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; + +import { getArtistStarCount } from '@/app/api/artist-vote/_lib/starCount'; +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { CurrentArtistOfMonth } from '@/types/artistVote'; + +// 곡 카드 배지용 — 가장 최근에 확정된 1위 아티스트 + 누적 선정(★) 횟수만 가볍게 반환한다. +export async function GET(): Promise>> { + try { + const supabase = await createClient(); + + const { data: latest, error: latestError } = await supabase + .from('monthly_artist_rankings') + .select('artist') + .eq('rank', 1) + .order('vote_month', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (latestError) throw latestError; + if (!latest) { + return NextResponse.json({ success: true, data: null }); + } + + const starCount = await getArtistStarCount(supabase, latest.artist); + + return NextResponse.json({ + success: true, + data: { artist: latest.artist, starCount }, + }); + } catch (error) { + console.error('Error in GET current artist of month:', error); + return NextResponse.json( + { success: false, error: 'Failed to get current artist of month' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/artist-vote/finalize/route.ts b/apps/web/src/app/api/artist-vote/finalize/route.ts new file mode 100644 index 00000000..e4cfeef8 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/finalize/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server'; + +import { VoteRow, rankTopArtists } from '@/app/api/artist-vote/_lib/rankVotes'; +import createServiceRoleClient from '@/lib/supabase/serviceRole'; +import { ApiResponse } from '@/types/apiRoute'; +import { getPrevMonthFirstDayKST } from '@/utils/kst'; + +// GitHub Actions가 매일 00:10(KST)에 호출한다. +// "지금이 이번 달"이라는 전제 하에 getPrevMonthFirstDayKST()로 "방금 끝난 달"을 계산해 +// 매번 같은 달을 덮어써 재실행에도 안전하다(idempotent). +export async function POST(request: Request): Promise>> { + const authHeader = request.headers.get('authorization'); + const secret = process.env.ARTIST_VOTE_FINALIZE_SECRET; + + if (!secret || authHeader !== `Bearer ${secret}`) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + } + + try { + const supabase = createServiceRoleClient(); + const targetMonth = getPrevMonthFirstDayKST(); + + const { data, error } = await supabase + .from('artist_votes') + .select('user_id, artist, amount, created_at') + .eq('vote_month', targetMonth) + .returns(); + + if (error) throw error; + + const votes = data ?? []; + if (votes.length === 0) { + return NextResponse.json({ success: true }); + } + + const ranked = rankTopArtists(votes); + + const { error: deleteError } = await supabase + .from('monthly_artist_rankings') + .delete() + .eq('vote_month', targetMonth); + + if (deleteError) throw deleteError; + + const rows = ranked.map((entry, index) => ({ + vote_month: targetMonth, + rank: index + 1, + artist: entry.artist, + total_votes: entry.total, + top_voter_user_id: entry.topVoterUserId, + top_voter_amount: entry.topVoterAmount, + })); + + const { error: insertError } = await supabase.from('monthly_artist_rankings').insert(rows); + + if (insertError) throw insertError; + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error in POST finalize artist vote:', error); + return NextResponse.json( + { success: false, error: 'Failed to finalize artist vote' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/artist-vote/my-current/route.ts b/apps/web/src/app/api/artist-vote/my-current/route.ts new file mode 100644 index 00000000..8b095142 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/my-current/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { MyArtistVote } from '@/types/artistVote'; +import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; +import { getCurrentMonthFirstDayKST } from '@/utils/kst'; + +export async function GET(): Promise>> { + try { + const supabase = await createClient(); + const userId = await getAuthenticatedUser(supabase); + const currentMonth = getCurrentMonthFirstDayKST(); + + const { data, error } = await supabase + .from('artist_votes') + .select('artist, amount') + .eq('user_id', userId) + .eq('vote_month', currentMonth) + .order('amount', { ascending: false }); + + if (error) throw error; + + return NextResponse.json({ success: true, data: data ?? [] }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { success: false, error: 'User not authenticated' }, + { status: 401 }, + ); + } + console.error('Error in GET my-current artist votes:', error); + return NextResponse.json( + { success: false, error: 'Failed to get my artist votes' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/artist-vote/rankings/[month]/[artist]/voters/route.ts b/apps/web/src/app/api/artist-vote/rankings/[month]/[artist]/voters/route.ts new file mode 100644 index 00000000..1b04549f --- /dev/null +++ b/apps/web/src/app/api/artist-vote/rankings/[month]/[artist]/voters/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { ArtistVoter } from '@/types/artistVote'; + +interface VoterRow { + amount: number; + users: { nickname: string } | null; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ month: string; artist: string }> }, +): Promise>> { + try { + const { month, artist: encodedArtist } = await params; + const artist = decodeURIComponent(encodedArtist); + + const supabase = await createClient(); + + // 확정된 랭킹에 포함된 (월, 아티스트) 조합만 공개한다. + // 이 검사가 없으면 아직 확정되지 않은 이번 달 실시간 득표 현황이 새어나간다. + const { data: rankingRow, error: rankingError } = await supabase + .from('monthly_artist_rankings') + .select('id') + .eq('vote_month', month) + .eq('artist', artist) + .maybeSingle(); + + if (rankingError) throw rankingError; + if (!rankingRow) { + return NextResponse.json( + { success: false, error: '확정되지 않은 아티스트입니다.' }, + { status: 404 }, + ); + } + + const { data, error } = await supabase + .from('artist_votes') + .select('amount, users(nickname)') + .eq('vote_month', month) + .eq('artist', artist) + .order('amount', { ascending: false }) + .returns(); + + if (error) throw error; + + const voters: ArtistVoter[] = (data ?? []).map(row => ({ + nickname: row.users?.nickname ?? '알 수 없음', + amount: row.amount, + })); + + return NextResponse.json({ success: true, data: voters }); + } catch (error) { + console.error('Error in GET artist voters:', error); + return NextResponse.json( + { success: false, error: 'Failed to get artist voters' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/artist-vote/rankings/route.ts b/apps/web/src/app/api/artist-vote/rankings/route.ts new file mode 100644 index 00000000..dfe2b6a2 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/rankings/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getArtistStarCount } from '@/app/api/artist-vote/_lib/starCount'; +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { ArtistRankingResponse } from '@/types/artistVote'; + +const MONTH_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +interface RankingRow { + rank: number; + artist: string; + total_votes: number; + top_voter_amount: number | null; + users: { nickname: string } | null; +} + +export async function GET( + request: NextRequest, +): Promise>> { + try { + const supabase = await createClient(); + const searchParams = request.nextUrl.searchParams; + + // 1) 확정된 월 목록 조회 (1위 행만 봐도 그 월이 확정됐는지 알 수 있다) + const { data: monthRows, error: monthError } = await supabase + .from('monthly_artist_rankings') + .select('vote_month') + .eq('rank', 1) + .order('vote_month', { ascending: false }); + + if (monthError) throw monthError; + + const availableMonths = [...new Set((monthRows ?? []).map(row => row.vote_month as string))]; + + if (availableMonths.length === 0) { + return NextResponse.json({ + success: true, + data: { month: '', availableMonths: [], items: [] }, + }); + } + + const monthParam = searchParams.get('month'); + const targetMonth = + monthParam && MONTH_PATTERN.test(monthParam) && availableMonths.includes(monthParam) + ? monthParam + : availableMonths[0]; + + // 2) 해당 월 1~10위 조회 + const { data, error } = await supabase + .from('monthly_artist_rankings') + .select('rank, artist, total_votes, top_voter_amount, users:top_voter_user_id(nickname)') + .eq('vote_month', targetMonth) + .order('rank', { ascending: true }) + .returns(); + + if (error) throw error; + + const rows = data ?? []; + const winnerArtist = rows.find(row => row.rank === 1)?.artist; + + // 3) 1위 아티스트의 누적 선정(★) 횟수 조회 + const starCount = winnerArtist ? await getArtistStarCount(supabase, winnerArtist) : 0; + + const items = rows.map(row => ({ + rank: row.rank, + artist: row.artist, + totalVotes: row.total_votes, + topVoterNickname: row.users?.nickname ?? null, + topVoterAmount: row.top_voter_amount, + starCount: row.rank === 1 ? starCount : 0, + })); + + return NextResponse.json({ + success: true, + data: { month: targetMonth, availableMonths, items }, + }); + } catch (error) { + console.error('Error in GET artist rankings:', error); + return NextResponse.json( + { success: false, error: 'Failed to get artist rankings' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/artist-vote/route.ts b/apps/web/src/app/api/artist-vote/route.ts new file mode 100644 index 00000000..6053db48 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; +import { getCurrentMonthFirstDayKST } from '@/utils/kst'; + +// artist_votes는 (user, artist, 이번 달) 당 1행이며 amount는 "현재 걸어둔 포인트"다. +// amount(절대값)를 보내면 기존 값과의 차액만큼 포인트를 차감/환불하고, +// amount=0이면 행을 삭제해 전액 환불한다. vote_month는 항상 서버가 계산한 +// "이번 달"만 사용해 지난 달 투표는 이 라우트로 절대 건드릴 수 없게 한다. +export async function PUT(request: Request): Promise>> { + try { + const supabase = await createClient(); + const userId = await getAuthenticatedUser(supabase); + + const { artist, amount } = await request.json(); + + if (typeof artist !== 'string' || !artist.trim()) { + return NextResponse.json({ success: false, error: 'artist is required' }, { status: 400 }); + } + if (typeof amount !== 'number' || !Number.isInteger(amount) || amount < 0) { + return NextResponse.json( + { success: false, error: 'amount는 0 이상의 정수여야 합니다.' }, + { status: 400 }, + ); + } + + const normalizedArtist = artist.trim(); + const currentMonth = getCurrentMonthFirstDayKST(); + + // artist_votes.artist는 artists.name을 FK로 참조한다. amount=0(삭제)이 아니라면 + // 여기서 미리 걸러야 포인트 차감 전에 실패하고, 사용자에게도 원인이 분명히 보인다. + if (amount > 0) { + const { data: artistRow, error: artistError } = await supabase + .from('artists') + .select('name') + .eq('name', normalizedArtist) + .maybeSingle(); + + if (artistError) throw artistError; + if (!artistRow) { + return NextResponse.json( + { success: false, error: '존재하지 않는 아티스트입니다.' }, + { status: 400 }, + ); + } + } + + const { data: existing, error: existingError } = await supabase + .from('artist_votes') + .select('amount') + .eq('user_id', userId) + .eq('artist', normalizedArtist) + .eq('vote_month', currentMonth) + .maybeSingle(); + + if (existingError) throw existingError; + + const currentAmount = existing?.amount ?? 0; + const diff = amount - currentAmount; + + if (diff === 0) { + return NextResponse.json({ success: true }); + } + + if (diff > 0) { + const { data: userData, error: userError } = await supabase + .from('users') + .select('point') + .eq('id', userId) + .single(); + + if (userError || !userData) throw userError ?? new Error('User not found'); + + if (userData.point < diff) { + return NextResponse.json( + { + success: false, + error: `포인트가 부족합니다. 필요: ${diff}P, 보유: ${userData.point}P`, + }, + { status: 400 }, + ); + } + } + + if (amount === 0) { + const { error: deleteError } = await supabase + .from('artist_votes') + .delete() + .match({ user_id: userId, artist: normalizedArtist, vote_month: currentMonth }); + + if (deleteError) throw deleteError; + } else { + const { error: upsertError } = await supabase.from('artist_votes').upsert( + { + user_id: userId, + artist: normalizedArtist, + vote_month: currentMonth, + amount, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id,artist,vote_month' }, + ); + + if (upsertError) throw upsertError; + } + + const description = + diff > 0 + ? `아티스트 투표: ${normalizedArtist} (+${diff}P)` + : `아티스트 투표 조정: ${normalizedArtist} (${diff}P)`; + + const { error: pointError } = await supabase.rpc('record_point_change', { + p_user_id: userId, + p_amount: -diff, + p_description: description, + }); + + if (pointError) throw pointError; + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { success: false, error: 'User not authenticated' }, + { status: 401 }, + ); + } + console.error('Error in PUT artist-vote:', error); + return NextResponse.json( + { success: false, error: 'Failed to update artist vote' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/lib/api/artistVote.ts b/apps/web/src/lib/api/artistVote.ts new file mode 100644 index 00000000..e7cf365f --- /dev/null +++ b/apps/web/src/lib/api/artistVote.ts @@ -0,0 +1,40 @@ +import { ApiResponse } from '@/types/apiRoute'; +import { + ArtistRankingResponse, + ArtistVoter, + CurrentArtistOfMonth, + MyArtistVote, +} from '@/types/artistVote'; + +import { instance } from './client'; + +export async function putArtistVote(body: { artist: string; amount: number }) { + const response = await instance.put>('/artist-vote', body); + return response.data; +} + +export async function getMyArtistVotes() { + const response = await instance.get>('/artist-vote/my-current'); + return response.data; +} + +export async function getArtistRankings(month?: string) { + const response = await instance.get>('/artist-vote/rankings', { + params: month ? { month } : undefined, + }); + return response.data; +} + +export async function getArtistVoters(month: string, artist: string) { + const response = await instance.get>( + `/artist-vote/rankings/${month}/${encodeURIComponent(artist)}/voters`, + ); + return response.data; +} + +export async function getCurrentArtistOfMonth() { + const response = await instance.get>( + '/artist-vote/current-winner', + ); + return response.data; +} diff --git a/apps/web/src/lib/supabase/serviceRole.ts b/apps/web/src/lib/supabase/serviceRole.ts new file mode 100644 index 00000000..8a82a325 --- /dev/null +++ b/apps/web/src/lib/supabase/serviceRole.ts @@ -0,0 +1,9 @@ +import { createClient } from '@supabase/supabase-js'; + +// RLS를 우회해야 하는 서버 전용 배치(예: 아티스트 투표 월간 집계)에서만 사용한다. +// 절대 클라이언트 번들에 노출되면 안 되므로 'use client' 컴포넌트에서 import 금지. +export default function createServiceRoleClient() { + return createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { + auth: { persistSession: false }, + }); +} diff --git a/apps/web/src/queries/artistVoteQuery.ts b/apps/web/src/queries/artistVoteQuery.ts new file mode 100644 index 00000000..bfe77925 --- /dev/null +++ b/apps/web/src/queries/artistVoteQuery.ts @@ -0,0 +1,73 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { + getArtistRankings, + getArtistVoters, + getCurrentArtistOfMonth, + getMyArtistVotes, + putArtistVote, +} from '@/lib/api/artistVote'; + +export const useMyArtistVotesQuery = (enabled: boolean) => { + return useQuery({ + queryKey: ['myArtistVotes'], + queryFn: async () => { + const response = await getMyArtistVotes(); + if (!response.success) return []; + return response.data ?? []; + }, + enabled, + }); +}; + +export const usePutArtistVoteMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: { artist: string; amount: number }) => putArtistVote(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['myArtistVotes'] }); + queryClient.invalidateQueries({ queryKey: ['userCheckIn'] }); + queryClient.invalidateQueries({ queryKey: ['pointLogs'] }); + }, + onError: error => { + console.error('error', error); + alert(error.message ?? '투표 반영에 실패했어요'); + }, + }); +}; + +export const useArtistRankingsQuery = (month?: string) => { + return useQuery({ + queryKey: ['artistRankings', month], + queryFn: async () => { + const response = await getArtistRankings(month); + if (!response.success) return null; + return response.data; + }, + placeholderData: keepPreviousData, + }); +}; + +export const useArtistVotersQuery = (month: string, artist: string, enabled: boolean) => { + return useQuery({ + queryKey: ['artistVoters', month, artist], + queryFn: async () => { + const response = await getArtistVoters(month, artist); + if (!response.success) return []; + return response.data ?? []; + }, + enabled, + }); +}; + +export const useCurrentArtistOfMonthQuery = () => { + return useQuery({ + queryKey: ['currentArtistOfMonth'], + queryFn: async () => { + const response = await getCurrentArtistOfMonth(); + if (!response.success) return null; + return response.data ?? null; + }, + staleTime: 1000 * 60 * 30, + }); +}; diff --git a/apps/web/src/types/artistVote.ts b/apps/web/src/types/artistVote.ts new file mode 100644 index 00000000..4e9e7f4d --- /dev/null +++ b/apps/web/src/types/artistVote.ts @@ -0,0 +1,30 @@ +export interface MyArtistVote { + artist: string; + amount: number; +} + +export interface ArtistRankingItem { + rank: number; + artist: string; + totalVotes: number; + topVoterNickname: string | null; + topVoterAmount: number | null; + /** 1위 누적 선정 횟수. rank가 1인 아이템에만 의미가 있다. */ + starCount: number; +} + +export interface ArtistRankingResponse { + month: string; + availableMonths: string[]; + items: ArtistRankingItem[]; +} + +export interface ArtistVoter { + nickname: string; + amount: number; +} + +export interface CurrentArtistOfMonth { + artist: string; + starCount: number; +} diff --git a/apps/web/src/utils/kst.ts b/apps/web/src/utils/kst.ts index 33166306..ba981d9c 100644 --- a/apps/web/src/utils/kst.ts +++ b/apps/web/src/utils/kst.ts @@ -14,6 +14,15 @@ export function getPrevMonthFirstDayKST(): string { return format(startOfMonth(subMonths(new Date(year, month - 1, 1), 1)), 'yyyy-MM-dd'); } +/** + * 이번 달 1일을 'YYYY-MM-DD' 문자열로 반환 (KST 기준, 서버 타임존 무관) + * 아티스트 투표는 이 달에만 수정 가능하므로 잠금 기준으로 사용한다. + */ +export function getCurrentMonthFirstDayKST(): string { + const [year, month] = getTodayKST().split('-').map(Number); + return format(startOfMonth(new Date(year, month - 1, 1)), 'yyyy-MM-dd'); +} + /** 내일 KST 날짜를 'YYYY-MM-DD' 문자열로 반환 */ export function getTomorrowKST(): string { return addDays(addHours(new Date(), 9), 1).toISOString().split('T')[0]; diff --git a/turbo.json b/turbo.json index 35967c45..5250c7e3 100644 --- a/turbo.json +++ b/turbo.json @@ -23,7 +23,9 @@ "TJ_CONCURRENCY", "TJ_DRY_RUN", "TJ_RESET_PROGRESS", - "DEAD_SONGS_APPLY" + "DEAD_SONGS_APPLY", + "ARTIST_VOTE_FINALIZE_SECRET", + "ARTIST_BACKFILL_SINCE_DAYS" ], "tasks": { "build": { From 95aec2b43f9d24f0807cf8a7885b02181cec0952 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Thu, 20 Aug 2026 20:00:46 +0900 Subject: [PATCH 02/21] =?UTF-8?q?feat=20:=20=EC=95=84=ED=8B=B0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=B0=B1=ED=95=84=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit songs 테이블을 순회하며 artistAlias로 표기를 정규화하고, song_tags 언어 태그로 country_code를, artist_ko/artistAlias로 name_ko를 추정해 artists 테이블을 채우는 backfillArtists.ts를 추가한다. ARTIST_BACKFILL_SINCE_DAYS로 최초(전체) 백필과 매달 증분 백필을 구분하며, 매달 1일 자동 실행되는 GitHub Actions 워크플로우를 함께 둔다. --- .github/workflows/backfill_artists.yml | 54 +++++++++ packages/crawling/package.json | 1 + packages/crawling/src/cron/backfillArtists.ts | 110 ++++++++++++++++++ packages/crawling/src/supabase/getDB.ts | 22 +++- packages/crawling/src/supabase/postDB.ts | 21 +++- packages/crawling/src/types.ts | 22 ++++ 6 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/backfill_artists.yml create mode 100644 packages/crawling/src/cron/backfillArtists.ts diff --git a/.github/workflows/backfill_artists.yml b/.github/workflows/backfill_artists.yml new file mode 100644 index 00000000..00bb13ac --- /dev/null +++ b/.github/workflows/backfill_artists.yml @@ -0,0 +1,54 @@ +name: Backfill Artists + +on: + schedule: + - cron: "0 1 1 * *" # 매달 1일 KST 오전 10:00 실행 (UTC+9 → UTC 01:00) — 최근 35일치만 증분 반영 + workflow_dispatch: + inputs: + since_days: + description: "최근 N일 이내 등록된 곡만 대상으로 함 (비워두면 전체 곡 대상 백필)" + required: false + +jobs: + run-npm-task: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + run_install: false + + - name: Install dependencies + working-directory: packages/crawling + run: pnpm install + + - name: Create .env file + working-directory: packages/crawling + run: | + echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env + echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env + + # 스케줄 실행은 지난 한 달치만 증분 반영, 수동 실행은 입력값을 그대로 쓴다(비워두면 전체 백필). + - name: Determine backfill window + id: window + run: | + if [ "${{ github.event_name }}" = "schedule" ]; then + echo "since_days=35" >> "$GITHUB_OUTPUT" + else + echo "since_days=${{ inputs.since_days }}" >> "$GITHUB_OUTPUT" + fi + + - name: run backfill script - backfillArtists.ts + working-directory: packages/crawling + env: + ARTIST_BACKFILL_SINCE_DAYS: ${{ steps.window.outputs.since_days }} + run: pnpm run backfill-artists diff --git a/packages/crawling/package.json b/packages/crawling/package.json index fa3a2228..564075ec 100644 --- a/packages/crawling/package.json +++ b/packages/crawling/package.json @@ -19,6 +19,7 @@ "remove-dead-songs": "tsx src/cron/removeDeadTjSongs.ts", "tj-chart": "tsx src/cron/crawlTjChart.ts", "tj-chart-backfill": "tsx src/cron/crawlTjChartBackfill.ts", + "backfill-artists": "tsx src/cron/backfillArtists.ts", "lint": "eslint .", "test": "vitest run", "format": "prettier --write \"**/*.{ts,tsx,md}\"" diff --git a/packages/crawling/src/cron/backfillArtists.ts b/packages/crawling/src/cron/backfillArtists.ts new file mode 100644 index 00000000..232ebbfa --- /dev/null +++ b/packages/crawling/src/cron/backfillArtists.ts @@ -0,0 +1,110 @@ +import { subDays } from 'date-fns'; + +import { artistAlias } from '@repo/constants'; + +import { getSongsForArtistBackfillDB } from '@/supabase/getDB'; +import { upsertArtistsDB } from '@/supabase/postDB'; +import { ArtistCountryCode, ArtistUpsert, LanguageTagId } from '@/types'; + +// artistAlias: { [원어 공식 표기]: [한국어 표기 별칭들] }. +// 공식 표기는 songs.artist 원문과 그대로 일치하고, 별칭은 한국어 검색용 힌트라 +// songs.artist 값으로 등장하는 일은 거의 없다 — 그래도 등장하면 공식 표기로 접어준다(방어적 정규화). +// "공식 표기 → 한국어 대표 표기(별칭 0번째)"는 translationJpn.ts와 동일하게 name_ko 1순위로 쓴다. +const aliasToOfficial = new Map(); +const officialToKo = new Map(); + +for (const [officialName, aliases] of Object.entries(artistAlias)) { + officialToKo.set(officialName, aliases[0]); + aliasToOfficial.set(officialName, officialName); + aliases.forEach(alias => aliasToOfficial.set(alias, officialName)); +} + +function resolveCanonicalName(rawArtist: string): string { + return aliasToOfficial.get(rawArtist) ?? rawArtist; +} + +// song_tags 언어 태그 중 가장 많이 등장한 태그로 국가를 추정한다. +// 102(팝송/서구권)는 US로, 103(글로벌)이거나 태그가 전혀 없으면 null로 둔다. +function pickCountryCode(tagCounts: Map): ArtistCountryCode | null { + let bestTag: number | null = null; + let bestCount = 0; + + for (const [tagId, count] of tagCounts) { + if (count > bestCount) { + bestTag = tagId; + bestCount = count; + } + } + + switch (bestTag) { + case LanguageTagId.Korean: + return 'KR'; + case LanguageTagId.Japanese: + return 'JP'; + case LanguageTagId.Pop: + return 'US'; + default: + return null; + } +} + +interface ArtistGroup { + tagCounts: Map; + artistKoCounts: Map; +} + +// 미설정(최초 백필)이면 전체 곡, 설정하면 그 일수 이내 등록된 곡만(월간 증분 갱신) 대상으로 한다. +const sinceDaysEnv = process.env.ARTIST_BACKFILL_SINCE_DAYS; +const sinceIso = sinceDaysEnv ? subDays(new Date(), Number(sinceDaysEnv)).toISOString() : undefined; + +console.log( + sinceIso + ? `최근 ${sinceDaysEnv}일 이내 등록된 곡만 대상으로 백필합니다.` + : '전체 곡을 대상으로 백필합니다.', +); + +const rows = await getSongsForArtistBackfillDB(sinceIso); +console.log('대상 곡 수:', rows.length); + +const groups = new Map(); + +for (const row of rows) { + if (!row.artist) continue; + + const canonicalName = resolveCanonicalName(row.artist); + const group = groups.get(canonicalName) ?? { tagCounts: new Map(), artistKoCounts: new Map() }; + + for (const { tag_id } of row.song_tags ?? []) { + group.tagCounts.set(tag_id, (group.tagCounts.get(tag_id) ?? 0) + 1); + } + + if (row.artist_ko) { + group.artistKoCounts.set(row.artist_ko, (group.artistKoCounts.get(row.artist_ko) ?? 0) + 1); + } + + groups.set(canonicalName, group); +} + +console.log('고유 아티스트 수:', groups.size); + +const upsertRows: ArtistUpsert[] = [...groups.entries()].map(([name, group]) => { + // name_ko 우선순위: 1) artistAlias 큐레이션 값 2) DB artist_ko 중 가장 흔한 값 + const aliasKo = officialToKo.get(name); + const mostCommonArtistKo = [...group.artistKoCounts.entries()].sort( + (a, b) => b[1] - a[1], + )[0]?.[0]; + + return { + name, + name_ko: aliasKo ?? mostCommonArtistKo ?? null, + country_code: pickCountryCode(group.tagCounts), + }; +}); + +const { upserted, failed } = await upsertArtistsDB(upsertRows); + +console.log(` + 아티스트 upsert 결과: + - 성공: ${upserted}건 + - 실패: ${failed}건 +`); diff --git a/packages/crawling/src/supabase/getDB.ts b/packages/crawling/src/supabase/getDB.ts index fcdc56a2..fbffe1f3 100644 --- a/packages/crawling/src/supabase/getDB.ts +++ b/packages/crawling/src/supabase/getDB.ts @@ -1,4 +1,4 @@ -import { TransSong } from '@/types'; +import { ArtistBackfillSongRow, TransSong } from '@/types'; import { containsJapanese } from '@/utils/parseString'; import { getClient } from './getClient'; @@ -152,6 +152,26 @@ export async function getArtistKoMapDB(): Promise> { return map; } +// 아티스트 백필용 조회. sinceIso가 있으면 그 시각 이후 등록된 곡만(월간 증분 갱신), +// 없으면 전체 곡을 대상으로 한다(최초 백필). song_tags는 언어 태그로 country_code를 추정하는 데 쓴다. +export async function getSongsForArtistBackfillDB( + sinceIso?: string, +): Promise { + const supabase = getClient(); + + let query = supabase.from('songs').select('artist, artist_ko, song_tags(tag_id)').limit(200000); + + if (sinceIso) { + query = query.gte('created_at', sinceIso); + } + + const { data, error } = await query; + + if (error) throw error; + + return data as ArtistBackfillSongRow[]; +} + export async function getSongTagSongIdsDB(): Promise> { const supabase = getClient(); diff --git a/packages/crawling/src/supabase/postDB.ts b/packages/crawling/src/supabase/postDB.ts index 953eb018..ce68c5ef 100644 --- a/packages/crawling/src/supabase/postDB.ts +++ b/packages/crawling/src/supabase/postDB.ts @@ -1,4 +1,4 @@ -import { LogData, Song, TjChartRankingInsert } from '@/types'; +import { ArtistUpsert, LogData, Song, TjChartRankingInsert } from '@/types'; import { getClient } from './getClient'; @@ -108,6 +108,25 @@ export async function postTjChartRankingsDB(rows: TjChartRankingInsert[]) { return true; } +export async function upsertArtistsDB(rows: ArtistUpsert[], chunkSize: number = 500) { + const supabase = getClient(); + + let upserted = 0; + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + + const { error } = await supabase.from('artists').upsert(chunk, { onConflict: 'name' }); + + if (error) { + console.error('upsertArtistsDB error:', error); + continue; + } + upserted += chunk.length; + } + + return { upserted, failed: rows.length - upserted }; +} + export async function postInvalidKYSongsDB(song: Song) { const supabase = getClient(); diff --git a/packages/crawling/src/types.ts b/packages/crawling/src/types.ts index 9b5fe7c3..6de76522 100644 --- a/packages/crawling/src/types.ts +++ b/packages/crawling/src/types.ts @@ -103,3 +103,25 @@ export interface TjChartRankingInsert { rank: number; song_id: string; } + +// 언어 태그 ID (apps/web/src/constants/languageTags.ts와 의도적으로 중복 정의) +export enum LanguageTagId { + Korean = 100, + Japanese = 101, + Pop = 102, + Global = 103, +} + +export type ArtistCountryCode = 'KR' | 'JP' | 'US'; + +export interface ArtistBackfillSongRow { + artist: string; + artist_ko: string | null; + song_tags: { tag_id: number }[]; +} + +export interface ArtistUpsert { + name: string; + name_ko: string | null; + country_code: ArtistCountryCode | null; +} From 9922a0e815333d4a0bd8e94bffa443566f3ac5c8 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Thu, 20 Aug 2026 20:00:54 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat=20:=20=EC=95=84=ED=8B=B0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=80=EC=83=89=20API=20=EC=B6=94=EA=B0=80=20(#3?= =?UTF-8?q?07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit artists 테이블을 name/name_ko로 검색하는 GET /api/artists/search와 클라이언트 훅을 추가한다. 이달의 아티스트 투표 모달의 아티스트 자동완성이 이 API를 쓴다. --- apps/web/src/app/api/artists/search/route.ts | 55 ++++++++++++++++++++ apps/web/src/lib/api/artists.ts | 11 ++++ apps/web/src/queries/artistsQuery.ts | 18 +++++++ apps/web/src/types/artist.ts | 7 +++ 4 files changed, 91 insertions(+) create mode 100644 apps/web/src/app/api/artists/search/route.ts create mode 100644 apps/web/src/lib/api/artists.ts create mode 100644 apps/web/src/queries/artistsQuery.ts create mode 100644 apps/web/src/types/artist.ts diff --git a/apps/web/src/app/api/artists/search/route.ts b/apps/web/src/app/api/artists/search/route.ts new file mode 100644 index 00000000..16a8b3f4 --- /dev/null +++ b/apps/web/src/app/api/artists/search/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { ArtistSearchResult } from '@/types/artist'; + +const RESULT_LIMIT = 10; + +export async function GET( + request: NextRequest, +): Promise>> { + try { + const query = request.nextUrl.searchParams.get('q')?.trim(); + if (!query) { + return NextResponse.json({ success: true, data: [] }); + } + + const supabase = await createClient(); + const pattern = `%${query}%`; + + // 원어 표기(name)와 한국어 표기(name_ko) 양쪽을 각각 조회해 합친다. + // ilike 값은 supabase-js가 바인딩 파라미터로 보내므로 or() 필터 문자열 조립보다 안전하다. + const [byName, byNameKo] = await Promise.all([ + supabase + .from('artists') + .select('name, name_ko, country_code') + .ilike('name', pattern) + .limit(RESULT_LIMIT), + supabase + .from('artists') + .select('name, name_ko, country_code') + .ilike('name_ko', pattern) + .limit(RESULT_LIMIT), + ]); + + if (byName.error) throw byName.error; + if (byNameKo.error) throw byNameKo.error; + + const merged = new Map(); + for (const row of [...(byName.data ?? []), ...(byNameKo.data ?? [])]) { + merged.set(row.name, row); + } + + return NextResponse.json({ + success: true, + data: [...merged.values()].slice(0, RESULT_LIMIT), + }); + } catch (error) { + console.error('Error in GET artists search:', error); + return NextResponse.json( + { success: false, error: 'Failed to search artists' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/lib/api/artists.ts b/apps/web/src/lib/api/artists.ts new file mode 100644 index 00000000..bdee158c --- /dev/null +++ b/apps/web/src/lib/api/artists.ts @@ -0,0 +1,11 @@ +import { ApiResponse } from '@/types/apiRoute'; +import { ArtistSearchResult } from '@/types/artist'; + +import { instance } from './client'; + +export async function getArtistSearch(query: string) { + const response = await instance.get>('/artists/search', { + params: { q: query }, + }); + return response.data; +} diff --git a/apps/web/src/queries/artistsQuery.ts b/apps/web/src/queries/artistsQuery.ts new file mode 100644 index 00000000..8029ba3f --- /dev/null +++ b/apps/web/src/queries/artistsQuery.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getArtistSearch } from '@/lib/api/artists'; + +export const useArtistSearchQuery = (query: string) => { + const trimmed = query.trim(); + + return useQuery({ + queryKey: ['artistSearch', trimmed], + queryFn: async () => { + const response = await getArtistSearch(trimmed); + if (!response.success) return []; + return response.data ?? []; + }, + enabled: trimmed.length > 0, + staleTime: 1000 * 60, + }); +}; diff --git a/apps/web/src/types/artist.ts b/apps/web/src/types/artist.ts new file mode 100644 index 00000000..47782b14 --- /dev/null +++ b/apps/web/src/types/artist.ts @@ -0,0 +1,7 @@ +export type ArtistCountryCode = 'KR' | 'JP' | 'US'; + +export interface ArtistSearchResult { + name: string; + name_ko: string | null; + country_code: ArtistCountryCode | null; +} From d448f1062a74219f77b48229dacc6bcd12ab4aa2 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Thu, 20 Aug 2026 20:01:06 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat=20:=20=EC=9D=B4=EB=8B=AC=EC=9D=98=20?= =?UTF-8?q?=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=ED=88=AC=ED=91=9C/?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=20UI=20=EC=B6=94=EA=B0=80=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 하단 내비게이션 '인기곡'을 '인기 차트'로 바꾸고 /popular를 노래방 인기곡/이달의 아티스트 탭으로 재구성한다. 이달의 아티스트 탭은 월 선택, 1~3위 포디움과 4~10위 리스트, 투표자 상세, 투표 모달(검색 + 내 투표 증감/삭제)로 구성된다. 월 선택 UI는 기존 인기차트와 공유하도록 MonthSelector로 분리했다. 곡 카드 공통 컴포넌트 SongSummary에는 이번 달 1위 아티스트 배지(★ 누적 선정 횟수 포함)를 추가한다. --- apps/web/src/Footer.tsx | 2 +- apps/web/src/app/popular/ArtistOfMonthTab.tsx | 96 ++++++++++++++++ .../src/app/popular/ArtistRankingBoard.tsx | 79 +++++++++++++ apps/web/src/app/popular/ArtistVoteModal.tsx | 106 +++++++++++++++++ apps/web/src/app/popular/ArtistVoteRow.tsx | 64 +++++++++++ .../src/app/popular/ArtistVotersDialog.tsx | 44 +++++++ apps/web/src/app/popular/ChartRankingList.tsx | 92 ++------------- apps/web/src/app/popular/MonthSelector.tsx | 108 ++++++++++++++++++ apps/web/src/app/popular/PopularTabs.tsx | 25 ++++ apps/web/src/app/popular/page.tsx | 10 +- apps/web/src/components/SongSummary.tsx | 12 ++ 11 files changed, 551 insertions(+), 87 deletions(-) create mode 100644 apps/web/src/app/popular/ArtistOfMonthTab.tsx create mode 100644 apps/web/src/app/popular/ArtistRankingBoard.tsx create mode 100644 apps/web/src/app/popular/ArtistVoteModal.tsx create mode 100644 apps/web/src/app/popular/ArtistVoteRow.tsx create mode 100644 apps/web/src/app/popular/ArtistVotersDialog.tsx create mode 100644 apps/web/src/app/popular/MonthSelector.tsx create mode 100644 apps/web/src/app/popular/PopularTabs.tsx diff --git a/apps/web/src/Footer.tsx b/apps/web/src/Footer.tsx index 76bd7f4f..09f77f88 100644 --- a/apps/web/src/Footer.tsx +++ b/apps/web/src/Footer.tsx @@ -20,7 +20,7 @@ const navigation: Navigation[] = [ { name: '부를 곡', href: '/tosing', key: 'TOSING' }, { name: '검색', href: '/', key: 'SEARCH' }, - { name: '인기곡', href: '/popular', key: 'POPULAR' }, + { name: '인기 차트', href: '/popular', key: 'POPULAR' }, { name: '정보', href: '/info', key: 'INFO' }, ]; diff --git a/apps/web/src/app/popular/ArtistOfMonthTab.tsx b/apps/web/src/app/popular/ArtistOfMonthTab.tsx new file mode 100644 index 00000000..e8c6c9da --- /dev/null +++ b/apps/web/src/app/popular/ArtistOfMonthTab.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { addMonths, format, parseISO, startOfMonth } from 'date-fns'; +import { Construction } from 'lucide-react'; +import { useState } from 'react'; + +import StaticLoading from '@/components/StaticLoading'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useArtistRankingsQuery } from '@/queries/artistVoteQuery'; +import { cn } from '@/utils/cn'; + +import ArtistRankingBoard from './ArtistRankingBoard'; +import ArtistVoteModal from './ArtistVoteModal'; +import ArtistVotersDialog from './ArtistVotersDialog'; +import MonthSelector from './MonthSelector'; + +const MONTH_FORMAT = 'yyyy-MM-dd'; +const shiftMonth = (month: string, delta: number) => + format(startOfMonth(addMonths(parseISO(month), delta)), MONTH_FORMAT); + +export default function ArtistOfMonthTab() { + const [monthOverride, setMonthOverride] = useState(undefined); + const [isVoteOpen, setIsVoteOpen] = useState(false); + const [votersArtist, setVotersArtist] = useState(null); + + const { data, isPending, isPlaceholderData } = useArtistRankingsQuery(monthOverride); + + if (isPending) { + return ; + } + + const availableMonths = data?.availableMonths ?? []; + const month = data?.month ?? ''; + const items = data?.items ?? []; + + const voteButton = ; + const voteModal = ( + + + setIsVoteOpen(false)} /> + + + ); + + if (availableMonths.length === 0) { + return ( +
+
{voteButton}
+
+ +

아직 확정된 결과가 없어요

+
+ {voteModal} +
+ ); + } + + // availableMonths는 최신순이므로 마지막 원소가 가장 오래된 월이다. + const oldestMonth = availableMonths.at(-1); + const canGoPrev = !!oldestMonth && month > oldestMonth; + const canGoNext = month < availableMonths[0]; + + return ( +
+ setMonthOverride(shiftMonth(month, -1))} + onNext={() => setMonthOverride(shiftMonth(month, 1))} + onChange={setMonthOverride} + /> + +
{voteButton}
+ + +
+ +
+
+ + {voteModal} + + {votersArtist && ( + setVotersArtist(null)} + /> + )} +
+ ); +} diff --git a/apps/web/src/app/popular/ArtistRankingBoard.tsx b/apps/web/src/app/popular/ArtistRankingBoard.tsx new file mode 100644 index 00000000..d7d0af71 --- /dev/null +++ b/apps/web/src/app/popular/ArtistRankingBoard.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { ArtistRankingItem } from '@/types/artistVote'; +import { cn } from '@/utils/cn'; + +const PODIUM_STYLE: Record = { + 1: 'border-amber-500 bg-amber-500/10', + 2: 'border-gray-300 bg-gray-300/10', + 3: 'border-amber-700 bg-amber-700/10', +}; + +const PODIUM_MEDAL: Record = { + 1: '🥇', + 2: '🥈', + 3: '🥉', +}; + +function PodiumCard({ item, onClick }: { item: ArtistRankingItem; onClick: () => void }) { + return ( + + ); +} + +interface ArtistRankingBoardProps { + items: ArtistRankingItem[]; + onSelectArtist: (artist: string) => void; +} + +export default function ArtistRankingBoard({ items, onSelectArtist }: ArtistRankingBoardProps) { + const top3 = items.filter(item => item.rank <= 3); + const rest = items.filter(item => item.rank > 3); + + return ( +
+
+ {top3.map(item => ( + onSelectArtist(item.artist)} /> + ))} +
+ +
+ {rest.map(item => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/app/popular/ArtistVoteModal.tsx b/apps/web/src/app/popular/ArtistVoteModal.tsx new file mode 100644 index 00000000..d6d99700 --- /dev/null +++ b/apps/web/src/app/popular/ArtistVoteModal.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useState } from 'react'; + +import SearchAutocomplete from '@/app/search/SearchAutocomplete'; +import { Button } from '@/components/ui/button'; +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { useMyArtistVotesQuery, usePutArtistVoteMutation } from '@/queries/artistVoteQuery'; +import { useArtistSearchQuery } from '@/queries/artistsQuery'; +import { useUserQuery } from '@/queries/userQuery'; + +import ArtistVoteRow from './ArtistVoteRow'; + +const STEP = 10; + +interface ArtistVoteModalProps { + handleClose: () => void; +} + +export default function ArtistVoteModal({ handleClose }: ArtistVoteModalProps) { + const [query, setQuery] = useState(''); + const [isFocusAuto, setIsFocusAuto] = useState(false); + + const { data: user } = useUserQuery(); + const point = user?.point ?? 0; + const isAuthenticated = !!user; + + const { data: myVotes = [] } = useMyArtistVotesQuery(isAuthenticated); + const { mutate: putVote, isPending } = usePutArtistVoteMutation(); + + const { data: searchResults = [] } = useArtistSearchQuery(query); + const autoCompleteList = searchResults.map(artist => ({ + label: artist.name_ko ? `${artist.name} (${artist.name_ko})` : artist.name, + value: artist.name, + })); + + const handleSelectArtist = (artist: string) => { + setQuery(''); + setIsFocusAuto(false); + + // 검색은 새 아티스트를 추가할 때만 쓴다. 이미 투표한 아티스트는 아래 목록에서 조정한다. + if (myVotes.some(vote => vote.artist === artist)) return; + if (point < STEP) return; + + putVote({ artist, amount: STEP }); + }; + + const handleAdjust = (artist: string, currentAmount: number, delta: number) => { + if (delta > 0 && point < delta) return; + putVote({ artist, amount: Math.max(0, currentAmount + delta) }); + }; + + return ( +
+ + 아티스트 투표 + + 보유 포인트로 이달의 아티스트에게 투표하세요. 월말까지 자유롭게 조정할 수 있어요. + + + +
+ 보유 포인트 {point}P +
+ +
+ setQuery(e.target.value)} + onFocus={() => setIsFocusAuto(true)} + onBlur={() => setIsFocusAuto(false)} + /> + {isFocusAuto && ( + + )} +
+ +
+ 이번 달 내 투표 + + {myVotes.length === 0 ? ( +

아직 투표한 아티스트가 없어요.

+ ) : ( + myVotes.map(vote => ( + = STEP} + disabled={isPending} + onAdjust={delta => handleAdjust(vote.artist, vote.amount, delta)} + onDelete={() => putVote({ artist: vote.artist, amount: 0 })} + /> + )) + )} +
+ + +
+ ); +} diff --git a/apps/web/src/app/popular/ArtistVoteRow.tsx b/apps/web/src/app/popular/ArtistVoteRow.tsx new file mode 100644 index 00000000..e02477d4 --- /dev/null +++ b/apps/web/src/app/popular/ArtistVoteRow.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { Minus, Plus, Trash2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; + +interface ArtistVoteRowProps { + artist: string; + amount: number; + step: number; + canIncrease: boolean; + disabled: boolean; + onAdjust: (delta: number) => void; + onDelete: () => void; +} + +export default function ArtistVoteRow({ + artist, + amount, + step, + canIncrease, + disabled, + onAdjust, + onDelete, +}: ArtistVoteRowProps) { + return ( +
+ {artist} +
+ + {amount}P + + +
+
+ ); +} diff --git a/apps/web/src/app/popular/ArtistVotersDialog.tsx b/apps/web/src/app/popular/ArtistVotersDialog.tsx new file mode 100644 index 00000000..109885db --- /dev/null +++ b/apps/web/src/app/popular/ArtistVotersDialog.tsx @@ -0,0 +1,44 @@ +'use client'; + +import StaticLoading from '@/components/StaticLoading'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useArtistVotersQuery } from '@/queries/artistVoteQuery'; + +interface ArtistVotersDialogProps { + month: string; + artist: string; + onClose: () => void; +} + +export default function ArtistVotersDialog({ month, artist, onClose }: ArtistVotersDialogProps) { + const { data: voters, isPending } = useArtistVotersQuery(month, artist, true); + + return ( + !open && onClose()}> + + + {artist} 투표자 + + + {isPending ? ( + + ) : ( + +
+ {(voters ?? []).map((voter, index) => ( +
+ {voter.nickname} + {voter.amount}P +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/popular/ChartRankingList.tsx b/apps/web/src/app/popular/ChartRankingList.tsx index 6ae350e8..aacb9788 100644 --- a/apps/web/src/app/popular/ChartRankingList.tsx +++ b/apps/web/src/app/popular/ChartRankingList.tsx @@ -1,37 +1,22 @@ 'use client'; import { addMonths, format, parseISO, startOfMonth } from 'date-fns'; -import { ChevronLeft, ChevronRight, Construction } from 'lucide-react'; +import { Construction } from 'lucide-react'; import { useState } from 'react'; import SongSummary from '@/components/SongSummary'; import StaticLoading from '@/components/StaticLoading'; -import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { useTjChartQuery } from '@/queries/tjChartQuery'; import { StrType } from '@/types/tjChart'; import { cn } from '@/utils/cn'; import { getPrevMonthFirstDayKST } from '@/utils/kst'; import ChartGenreFilter from './ChartGenreFilter'; +import MonthSelector from './MonthSelector'; const MONTH_FORMAT = 'yyyy-MM-dd'; -// 조회 월은 항상 'yyyy-MM-01' 문자열이라 파싱 없이 잘라 쓴다. -const getYear = (month: string) => month.slice(0, 4); -const getMonthNumber = (month: string) => month.slice(5, 7); - -// 년/월을 그냥 텍스트처럼 보이게 두고, 눌러야 드롭다운이 열린다는 건 셀렉트 기본 화살표로 알린다. -const SELECT_TRIGGER_CLASSES = - 'h-auto w-auto gap-1 border-none p-0 text-xl font-bold shadow-none focus-visible:ring-0'; - const getRankStyle = (rank: number) => { switch (rank) { case 1: @@ -73,73 +58,18 @@ export default function ChartRankingList() { ? availableMonths : [...availableMonths, month].sort().reverse(); - const years = [...new Set(selectableMonths.map(getYear))]; - // 연도 안에서는 1월 → 12월 순으로 훑는 게 자연스러워 오름차순으로 둔다. - const monthsInYear = selectableMonths.filter(item => getYear(item) === getYear(month)).sort(); - - // 연도를 바꿀 때 같은 달이 있으면 유지하고, 없으면 그 해에서 가장 최근 달로 이동한다. - const handleYearChange = (nextYear: string) => { - const candidates = selectableMonths.filter(item => getYear(item) === nextYear); - if (candidates.length === 0) return; - - const sameMonth = candidates.find(item => getMonthNumber(item) === getMonthNumber(month)); - setMonth(sameMonth ?? candidates[0]); - }; - return (
{/* 차트 제목은 페이지 h1로 빠졌고, 여기서는 조회 월 선택만 한 줄을 통째로 쓴다. */} -
- - -
- - - -
- - -
+ setMonth(shiftMonth(month, -1))} + onNext={() => setMonth(shiftMonth(month, 1))} + onChange={setMonth} + /> diff --git a/apps/web/src/app/popular/MonthSelector.tsx b/apps/web/src/app/popular/MonthSelector.tsx new file mode 100644 index 00000000..77e9208c --- /dev/null +++ b/apps/web/src/app/popular/MonthSelector.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +// 년/월을 그냥 텍스트처럼 보이게 두고, 눌러야 드롭다운이 열린다는 건 셀렉트 기본 화살표로 알린다. +const SELECT_TRIGGER_CLASSES = + 'h-auto w-auto gap-1 border-none p-0 text-xl font-bold shadow-none focus-visible:ring-0'; + +// 조회 월은 항상 'yyyy-MM-dd' 문자열이라 파싱 없이 잘라 쓴다. +const getYear = (month: string) => month.slice(0, 4); +const getMonthNumber = (month: string) => month.slice(5, 7); + +interface MonthSelectorProps { + month: string; + /** 이미 정렬/보정까지 끝낸 선택 가능 월 목록 (최신순). 화면마다 보정 규칙이 달라 호출부에서 계산해 넘긴다. */ + selectableMonths: string[]; + canGoPrev: boolean; + canGoNext: boolean; + onPrev: () => void; + onNext: () => void; + onChange: (month: string) => void; +} + +export default function MonthSelector({ + month, + selectableMonths, + canGoPrev, + canGoNext, + onPrev, + onNext, + onChange, +}: MonthSelectorProps) { + const years = [...new Set(selectableMonths.map(getYear))]; + // 연도 안에서는 1월 → 12월 순으로 훑는 게 자연스러워 오름차순으로 둔다. + const monthsInYear = selectableMonths.filter(item => getYear(item) === getYear(month)).sort(); + + // 연도를 바꿀 때 같은 달이 있으면 유지하고, 없으면 그 해에서 가장 최근 달로 이동한다. + const handleYearChange = (nextYear: string) => { + const candidates = selectableMonths.filter(item => getYear(item) === nextYear); + if (candidates.length === 0) return; + + const sameMonth = candidates.find(item => getMonthNumber(item) === getMonthNumber(month)); + onChange(sameMonth ?? candidates[0]); + }; + + return ( +
+ + +
+ + + +
+ + +
+ ); +} diff --git a/apps/web/src/app/popular/PopularTabs.tsx b/apps/web/src/app/popular/PopularTabs.tsx new file mode 100644 index 00000000..30844ead --- /dev/null +++ b/apps/web/src/app/popular/PopularTabs.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +import ArtistOfMonthTab from './ArtistOfMonthTab'; +import ChartRankingList from './ChartRankingList'; + +export default function PopularTabs() { + return ( + + + 노래방 인기곡 + 이달의 아티스트 + + + + + + + + + + + ); +} diff --git a/apps/web/src/app/popular/page.tsx b/apps/web/src/app/popular/page.tsx index 4d1e2b9f..a1a394e9 100644 --- a/apps/web/src/app/popular/page.tsx +++ b/apps/web/src/app/popular/page.tsx @@ -1,4 +1,4 @@ -import ChartRankingList from './ChartRankingList'; +import PopularTabs from './PopularTabs'; // 차트 기본 조회 월을 "현재 기준 전월"로 계산하므로 빌드 시점에 프리렌더되면 값이 낡는다. // 요청 시점에 렌더해 서버/클라이언트가 같은 월을 계산하도록 한다. @@ -6,11 +6,11 @@ export const dynamic = 'force-dynamic'; export default function PopularPage() { return ( -
- {/* TJ 공식 차트 기반 인기 순위 */} -

인기차트

+
+ {/* TJ 공식 차트 기반 인기 순위 / 포인트 투표로 뽑는 이달의 아티스트 */} +

인기 차트

- +
); } diff --git a/apps/web/src/components/SongSummary.tsx b/apps/web/src/components/SongSummary.tsx index d98dc004..a93df313 100644 --- a/apps/web/src/components/SongSummary.tsx +++ b/apps/web/src/components/SongSummary.tsx @@ -1,5 +1,8 @@ +'use client'; + import MarqueeText from '@/components/MarqueeText'; import SongBadges from '@/components/SongBadges'; +import { useCurrentArtistOfMonthQuery } from '@/queries/artistVoteQuery'; import { Song } from '@/types/song'; import { cn } from '@/utils/cn'; @@ -26,6 +29,9 @@ export default function SongSummary({ song, className }: SongSummaryProps) { const hasKoTitle = !!title_ko && title_ko !== title; const hasKoArtist = !!artist_ko && artist_ko !== artist; + const { data: artistOfMonth } = useCurrentArtistOfMonthQuery(); + const isArtistOfMonth = artistOfMonth?.artist === artist; + return (
{/* min-w-0가 없으면 MarqueeText가 부모를 밀어내 번호 영역이 잘린다 */} @@ -41,6 +47,12 @@ export default function SongSummary({ song, className }: SongSummaryProps) { {hasKoArtist && ( {artist_ko} )} + {isArtistOfMonth && ( + + 🏆 이달의 아티스트 + {artistOfMonth.starCount > 0 && ` ★${artistOfMonth.starCount}`} + + )}
{/* 번호는 자릿수가 달라도 세로로 맞아떨어지도록 고정폭 + tabular-nums */} From 4e19605ce76d9bd4148a1a6be5cdd53fac97fbef Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Thu, 20 Aug 2026 20:01:21 +0900 Subject: [PATCH 05/21] =?UTF-8?q?doc=20:=20=EC=9D=B4=EB=8B=AC=EC=9D=98=20?= =?UTF-8?q?=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=EC=9E=94=EC=97=AC=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20CLAUDE.md=EC=97=90=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL 실행, artists 최초 백필, 환경변수/시크릿 등록 등 코드 완성 후 남은 수동 작업을 진행 중인 작업 섹션에 정리한다. --- CLAUDE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4a88a5b8..631b96dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,19 @@ fix : SongCard css 수정 (#57) chore : 버전 2.3.0 (#61) ``` +## 진행 중인 작업 + +### #307 이달의 아티스트 (포인트 투표) + +코드는 완성됐으나 아래 수동 작업이 남아있어 실제로는 아직 동작하지 않는다. + +1. `apps/web/artist-vote-schema.sql`을 Supabase SQL Editor에서 실행 — `artists`(마스터), `artist_votes`, `monthly_artist_rankings` 테이블 + RLS 정책 생성. `artists`를 먼저 만들고 나머지 두 테이블이 `artist`를 그 `name`으로 FK 참조하는 순서라 반드시 이 파일 그대로 한 번에 실행해야 한다. +2. `packages/crawling`에서 `pnpm backfill-artists`를 환경변수 없이(전체 스캔) 최초 1회 실행해 `artists`를 채운다 — 비어있으면 `artist_votes.artist`의 FK 제약 때문에 투표 자체가 전부 실패한다. +3. 환경변수/시크릿 등록 + - Vercel(`apps/web` 프로덕션): `SUPABASE_SERVICE_ROLE_KEY`(turbo.json엔 이미 선언돼 있으나 실제 값 미설정), `ARTIST_VOTE_FINALIZE_SECRET`(새로 발급) + - GitHub Actions repo secret: `ARTIST_VOTE_FINALIZE_SECRET` (`finalize_artist_of_month.yml`에서 사용, Vercel과 같은 값이어야 함) +4. 위 설정 후 투표 → 월간 확정(`finalize_artist_of_month.yml`) → 곡 카드 배지 노출까지 전체 흐름을 수동 QA (테스트 스위트 없음) + ## Self-Maintenance 이 파일(CLAUDE.md)은 프로젝트의 규칙과 구조가 변경될 때 함께 업데이트한다. From 00ce7ed627d0b3ed8d900636edd0bbcb56d7c079 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Fri, 21 Aug 2026 18:47:23 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat=20:=20=EC=95=84=ED=8B=B0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=B1=ED=95=84=20=EC=A0=95=EA=B7=9C=ED=99=94=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?language=5Ftag=5Fid=20FK=20=EC=A0=81=EC=9A=A9=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit artists.country_code(임의 문자열)를 기존 tags 테이블을 참조하는 language_tag_id FK로 교체하고, 스키마를 정책 없이 테이블·RLS enable만 남기도록 정리한다. 백필 정규화는 애매하게 잘라 쓰는 대신 명확한 기준으로 무효 처리하는 쪽으로 단순화한다 — 쉼표, &/&, ×/・, feat, with, 앞뒤 단어가 있는 x-콜라보(예: "지코 X 아이유")가 하나라도 있으면 그 곡의 artist는 백필 대상에서 제외한다. 살아남은 값은 첫 괄호 앞부분만 남기되(내용 무관), 잘랐을 때 빈 문자열이 되면("(여자)아이들"처럼 이름 자체가 괄호로 시작하는 경우) 원문을 유지한다. songs.updated_at도 증분 스캔 조건에 포함해 기존 곡의 artist 오타 수정 같은 걸 놓치지 않게 한다. 백필 실행 로그를 파일로 남기고(packages/crawling/src/assets/ artistBackfillLog.txt) GitHub Actions에서도 아티팩트로 업로드한다. --- .github/workflows/backfill_artists.yml | 10 +++ apps/web/artist-vote-schema.sql | 69 ++---------------- apps/web/src/app/api/artists/search/route.ts | 4 +- apps/web/src/types/artist.ts | 6 +- .../crawling/src/assets/artistBackfillLog.txt | 20 ++++++ packages/crawling/src/cron/backfillArtists.ts | 71 ++++++++++++------- packages/crawling/src/supabase/getDB.ts | 8 ++- packages/crawling/src/types.ts | 4 +- .../src/utils/extractPrimaryArtist.ts | 36 ++++++++++ 9 files changed, 129 insertions(+), 99 deletions(-) create mode 100644 packages/crawling/src/assets/artistBackfillLog.txt create mode 100644 packages/crawling/src/utils/extractPrimaryArtist.ts diff --git a/.github/workflows/backfill_artists.yml b/.github/workflows/backfill_artists.yml index 00bb13ac..0f5dcce3 100644 --- a/.github/workflows/backfill_artists.yml +++ b/.github/workflows/backfill_artists.yml @@ -52,3 +52,13 @@ jobs: env: ARTIST_BACKFILL_SINCE_DAYS: ${{ steps.window.outputs.since_days }} run: pnpm run backfill-artists + + # 로그 파일은 러너 파일시스템에만 남고 잡이 끝나면 사라지므로, 실패해도(if: always()) 확인할 수 있게 올려둔다. + - name: Upload backfill log + if: always() + uses: actions/upload-artifact@v4 + with: + name: artist-backfill-log-${{ github.run_id }} + path: packages/crawling/src/assets/artistBackfillLog.txt + if-no-files-found: ignore + retention-days: 30 diff --git a/apps/web/artist-vote-schema.sql b/apps/web/artist-vote-schema.sql index c3f75416..2d57c59a 100644 --- a/apps/web/artist-vote-schema.sql +++ b/apps/web/artist-vote-schema.sql @@ -1,44 +1,21 @@ --- ========================================================= --- 아티스트 마스터 테이블 --- artist_votes/monthly_artist_rankings가 이 테이블을 FK로 참조하므로 먼저 만든다. --- packages/crawling의 backfillArtists.ts가 songs 테이블을 순회하며 채운다 --- (packages/crawling/.env의 SUPABASE_KEY는 RLS를 우회하는 키라 별도 쓰기 정책 불필요). --- name은 songs.artist 원문(alias 사전에 매칭되면 그 공식 표기)을 그대로 쓰고, --- name_ko는 일본 아티스트의 한국어 표기, country_code는 song_tags 언어 태그로 추정한다. --- --- artist_votes/monthly_artist_rankings가 name을 FK로 참조하므로, 이 테이블이 --- 비어있는 상태(백필 전)에서는 투표가 전부 실패한다 — backfill-artists를 먼저 돌려야 한다. --- ========================================================= create table public.artists ( id uuid primary key default gen_random_uuid(), name text not null unique, name_ko text, - country_code text check (country_code is null or country_code in ('KR', 'JP', 'US')), + language_tag_id smallint references public.tags(id), created_at timestamptz not null default now(), - updated_at timestamptz not null default now() + updated_at timestamptz not null default now(), + constraint artists_language_tag_id_range check ( + language_tag_id is null or language_tag_id between 100 and 103 + ) ); -create index artists_name_ko_idx on public.artists (name_ko); - alter table public.artists enable row level security; --- 아티스트 투표 검색 자동완성이 로그인 없이도 조회하므로 SELECT는 공개 -create policy "artists_select_all" - on public.artists for select - using (true); - --- insert/update 정책 없음 — backfill 스크립트(service-role 상당 키)만 쓴다. - --- ========================================================= --- 이달의 아티스트: 아티스트 투표 --- 유저 x 아티스트 x 월 단위로 1행. amount는 "현재 걸어둔 포인트"이며 --- PUT /api/artist-vote 를 통해 자유롭게 증감/삭제된다. --- artist는 artists.name을 FK로 참조해, artists 테이블에 없는 이름으로는 투표할 수 없다. --- ========================================================= create table public.artist_votes ( id uuid primary key default gen_random_uuid(), user_id uuid not null references public.users(id) on delete cascade, - artist text not null references public.artists(name) on delete restrict, + artist text not null references public.artists(name), vote_month date not null, -- 매월 1일로 정규화 (예: 2026-08-01) amount integer not null check (amount > 0), created_at timestamptz not null default now(), @@ -50,35 +27,11 @@ create index artist_votes_month_artist_idx on public.artist_votes (vote_month, a alter table public.artist_votes enable row level security; --- 역대 투표자 상세를 로그인 없이도 조회할 수 있어야 하므로 SELECT는 공개 -create policy "artist_votes_select_all" - on public.artist_votes for select - using (true); - --- 본인 소유 행만 쓰기 가능 (API 라우트가 항상 인증된 user_id로만 씀) -create policy "artist_votes_insert_own" - on public.artist_votes for insert - with check (auth.uid() = user_id); - -create policy "artist_votes_update_own" - on public.artist_votes for update - using (auth.uid() = user_id) - with check (auth.uid() = user_id); - -create policy "artist_votes_delete_own" - on public.artist_votes for delete - using (auth.uid() = user_id); - --- ========================================================= --- 이달의 아티스트: 월별 확정 랭킹 (1~10위) --- 매월 1일 00:00(KST) GitHub Actions가 finalize API를 호출해 전월 결과를 기록한다. --- artist_votes에서 집계하므로 artist는 항상 artists.name에 존재하는 값이다. --- ========================================================= create table public.monthly_artist_rankings ( id uuid primary key default gen_random_uuid(), vote_month date not null, rank smallint not null check (rank between 1 and 10), - artist text not null references public.artists(name) on delete restrict, + artist text not null references public.artists(name), total_votes integer not null, top_voter_user_id uuid references public.users(id) on delete set null, top_voter_amount integer, @@ -88,11 +41,3 @@ create table public.monthly_artist_rankings ( ); alter table public.monthly_artist_rankings enable row level security; - --- 누구나 조회 가능 (역대 결과 공개 페이지) -create policy "monthly_artist_rankings_select_all" - on public.monthly_artist_rankings for select - using (true); - --- insert/update/delete 정책은 의도적으로 만들지 않는다. --- anon/authenticated 롤은 쓰기 불가하며, finalize 배치만 service role key로 RLS를 우회해 쓴다. diff --git a/apps/web/src/app/api/artists/search/route.ts b/apps/web/src/app/api/artists/search/route.ts index 16a8b3f4..60f0b0fa 100644 --- a/apps/web/src/app/api/artists/search/route.ts +++ b/apps/web/src/app/api/artists/search/route.ts @@ -23,12 +23,12 @@ export async function GET( const [byName, byNameKo] = await Promise.all([ supabase .from('artists') - .select('name, name_ko, country_code') + .select('name, name_ko, language_tag_id') .ilike('name', pattern) .limit(RESULT_LIMIT), supabase .from('artists') - .select('name, name_ko, country_code') + .select('name, name_ko, language_tag_id') .ilike('name_ko', pattern) .limit(RESULT_LIMIT), ]); diff --git a/apps/web/src/types/artist.ts b/apps/web/src/types/artist.ts index 47782b14..11900295 100644 --- a/apps/web/src/types/artist.ts +++ b/apps/web/src/types/artist.ts @@ -1,7 +1,7 @@ -export type ArtistCountryCode = 'KR' | 'JP' | 'US'; - +// artists.language_tag_id는 tags(id)를 FK로 참조한다 — 값은 +// src/constants/languageTags.ts의 LANGUAGE_TAGS id(100~103) 중 하나다. export interface ArtistSearchResult { name: string; name_ko: string | null; - country_code: ArtistCountryCode | null; + language_tag_id: number | null; } diff --git a/packages/crawling/src/assets/artistBackfillLog.txt b/packages/crawling/src/assets/artistBackfillLog.txt new file mode 100644 index 00000000..ff645610 --- /dev/null +++ b/packages/crawling/src/assets/artistBackfillLog.txt @@ -0,0 +1,20 @@ + +===== 아티스트 백필 실행: 2026-08-21T07:53:09.595Z ===== +전체 곡을 대상으로 백필합니다. +대상 곡 수: 78160 +고유 아티스트 수: 24001 +아티스트 upsert 결과 — 성공: 24001건, 실패: 0건 + +===== 아티스트 백필 실행: 2026-08-21T09:21:17.710Z ===== +전체 곡을 대상으로 백필합니다. +대상 곡 수: 78160 +무효 처리(쉼표/feat/with/한국어-& 포함)로 제외한 곡 수: 10270 +고유 아티스트 수: 14418 +아티스트 upsert 결과 — 성공: 14418건, 실패: 0건 + +===== 아티스트 백필 실행: 2026-08-21T09:34:31.797Z ===== +전체 곡을 대상으로 백필합니다. +대상 곡 수: 78160 +무효 처리(쉼표/&/feat/with/x-콜라보 포함)로 제외한 곡 수: 10515 +고유 아티스트 수: 13902 +아티스트 upsert 결과 — 성공: 13902건, 실패: 0건 diff --git a/packages/crawling/src/cron/backfillArtists.ts b/packages/crawling/src/cron/backfillArtists.ts index 232ebbfa..4c420ff7 100644 --- a/packages/crawling/src/cron/backfillArtists.ts +++ b/packages/crawling/src/cron/backfillArtists.ts @@ -1,10 +1,29 @@ import { subDays } from 'date-fns'; +import fs from 'fs'; +import path from 'path'; import { artistAlias } from '@repo/constants'; import { getSongsForArtistBackfillDB } from '@/supabase/getDB'; import { upsertArtistsDB } from '@/supabase/postDB'; -import { ArtistCountryCode, ArtistUpsert, LanguageTagId } from '@/types'; +import { ArtistUpsert, LanguageTagId } from '@/types'; +import { extractPrimaryArtist, isInvalidArtist } from '@/utils/extractPrimaryArtist'; + +const LOG_FILE = path.join('src', 'assets', 'artistBackfillLog.txt'); + +function log(message: string) { + console.log(message); + fs.appendFileSync(LOG_FILE, message + '\n', 'utf-8'); +} + +// 태깅 파이프라인은 지금 이 4개만 언어 태그로 쓴다. artists.language_tag_id가 +// tags(id)를 FK로 참조하면서 100~103 range check도 걸려 있어, 그 외 tag_id는 걸러야 한다. +const VALID_LANGUAGE_TAG_IDS: readonly number[] = [ + LanguageTagId.Korean, + LanguageTagId.Japanese, + LanguageTagId.Pop, + LanguageTagId.Global, +]; // artistAlias: { [원어 공식 표기]: [한국어 표기 별칭들] }. // 공식 표기는 songs.artist 원문과 그대로 일치하고, 별칭은 한국어 검색용 힌트라 @@ -20,32 +39,27 @@ for (const [officialName, aliases] of Object.entries(artistAlias)) { } function resolveCanonicalName(rawArtist: string): string { - return aliasToOfficial.get(rawArtist) ?? rawArtist; + // 1) isInvalidArtist를 통과한(쉼표/&/feat/with/x-콜라보 없는) 원문에서 괄호만 제거 + // 2) 그 결과가 artistAlias에 등록된 원어 표기와 일치하면 공식 표기로 접어준다(방어적 정규화) + const primary = extractPrimaryArtist(rawArtist); + return aliasToOfficial.get(primary) ?? primary; } -// song_tags 언어 태그 중 가장 많이 등장한 태그로 국가를 추정한다. -// 102(팝송/서구권)는 US로, 103(글로벌)이거나 태그가 전혀 없으면 null로 둔다. -function pickCountryCode(tagCounts: Map): ArtistCountryCode | null { - let bestTag: number | null = null; +// song_tags 중 언어 태그(100~103)만 골라 가장 많이 등장한 것을 그 아티스트의 +// language_tag_id로 쓴다. tags(id) FK이므로 다른 카테고리 태그가 섞여 있어도 무시한다. +function pickLanguageTagId(tagCounts: Map): LanguageTagId | null { + let bestTag: LanguageTagId | null = null; let bestCount = 0; for (const [tagId, count] of tagCounts) { + if (!VALID_LANGUAGE_TAG_IDS.includes(tagId)) continue; if (count > bestCount) { bestTag = tagId; bestCount = count; } } - switch (bestTag) { - case LanguageTagId.Korean: - return 'KR'; - case LanguageTagId.Japanese: - return 'JP'; - case LanguageTagId.Pop: - return 'US'; - default: - return null; - } + return bestTag; } interface ArtistGroup { @@ -57,20 +71,28 @@ interface ArtistGroup { const sinceDaysEnv = process.env.ARTIST_BACKFILL_SINCE_DAYS; const sinceIso = sinceDaysEnv ? subDays(new Date(), Number(sinceDaysEnv)).toISOString() : undefined; -console.log( +log(`\n===== 아티스트 백필 실행: ${new Date().toISOString()} =====`); +log( sinceIso - ? `최근 ${sinceDaysEnv}일 이내 등록된 곡만 대상으로 백필합니다.` + ? `최근 ${sinceDaysEnv}일 이내 등록/수정된 곡만 대상으로 백필합니다.` : '전체 곡을 대상으로 백필합니다.', ); const rows = await getSongsForArtistBackfillDB(sinceIso); -console.log('대상 곡 수:', rows.length); +log(`대상 곡 수: ${rows.length}`); const groups = new Map(); +let invalidCount = 0; for (const row of rows) { if (!row.artist) continue; + // 쉼표/&·&/×·・/feat/with, 또는 " X " 콜라보 구분자가 있으면 다른 조건과 무관하게 백필 대상에서 제외한다. + if (isInvalidArtist(row.artist)) { + invalidCount++; + continue; + } + const canonicalName = resolveCanonicalName(row.artist); const group = groups.get(canonicalName) ?? { tagCounts: new Map(), artistKoCounts: new Map() }; @@ -85,7 +107,8 @@ for (const row of rows) { groups.set(canonicalName, group); } -console.log('고유 아티스트 수:', groups.size); +log(`무효 처리(쉼표/&·&/×·・/feat/with/x-콜라보 포함)로 제외한 곡 수: ${invalidCount}`); +log(`고유 아티스트 수: ${groups.size}`); const upsertRows: ArtistUpsert[] = [...groups.entries()].map(([name, group]) => { // name_ko 우선순위: 1) artistAlias 큐레이션 값 2) DB artist_ko 중 가장 흔한 값 @@ -97,14 +120,10 @@ const upsertRows: ArtistUpsert[] = [...groups.entries()].map(([name, group]) => return { name, name_ko: aliasKo ?? mostCommonArtistKo ?? null, - country_code: pickCountryCode(group.tagCounts), + language_tag_id: pickLanguageTagId(group.tagCounts), }; }); const { upserted, failed } = await upsertArtistsDB(upsertRows); -console.log(` - 아티스트 upsert 결과: - - 성공: ${upserted}건 - - 실패: ${failed}건 -`); +log(`아티스트 upsert 결과 — 성공: ${upserted}건, 실패: ${failed}건`); diff --git a/packages/crawling/src/supabase/getDB.ts b/packages/crawling/src/supabase/getDB.ts index fbffe1f3..00779e88 100644 --- a/packages/crawling/src/supabase/getDB.ts +++ b/packages/crawling/src/supabase/getDB.ts @@ -152,8 +152,9 @@ export async function getArtistKoMapDB(): Promise> { return map; } -// 아티스트 백필용 조회. sinceIso가 있으면 그 시각 이후 등록된 곡만(월간 증분 갱신), -// 없으면 전체 곡을 대상으로 한다(최초 백필). song_tags는 언어 태그로 country_code를 추정하는 데 쓴다. +// 아티스트 백필용 조회. sinceIso가 있으면 그 시각 이후 등록되었거나 수정된 곡만 +// (월간 증분 갱신 — updated_at도 봐야 기존 곡의 artist 오타 수정 같은 걸 놓치지 않는다), +// 없으면 전체 곡을 대상으로 한다(최초 백필). song_tags는 언어 태그로 language_tag_id를 추정하는 데 쓴다. export async function getSongsForArtistBackfillDB( sinceIso?: string, ): Promise { @@ -162,7 +163,8 @@ export async function getSongsForArtistBackfillDB( let query = supabase.from('songs').select('artist, artist_ko, song_tags(tag_id)').limit(200000); if (sinceIso) { - query = query.gte('created_at', sinceIso); + // sinceIso는 서버가 계산한 값이라 사용자 입력이 섞이지 않는다 — or() 필터 문자열 조립이 안전하다. + query = query.or(`created_at.gte.${sinceIso},updated_at.gte.${sinceIso}`); } const { data, error } = await query; diff --git a/packages/crawling/src/types.ts b/packages/crawling/src/types.ts index 6de76522..53fec356 100644 --- a/packages/crawling/src/types.ts +++ b/packages/crawling/src/types.ts @@ -112,8 +112,6 @@ export enum LanguageTagId { Global = 103, } -export type ArtistCountryCode = 'KR' | 'JP' | 'US'; - export interface ArtistBackfillSongRow { artist: string; artist_ko: string | null; @@ -123,5 +121,5 @@ export interface ArtistBackfillSongRow { export interface ArtistUpsert { name: string; name_ko: string | null; - country_code: ArtistCountryCode | null; + language_tag_id: LanguageTagId | null; } diff --git a/packages/crawling/src/utils/extractPrimaryArtist.ts b/packages/crawling/src/utils/extractPrimaryArtist.ts new file mode 100644 index 00000000..a47024f7 --- /dev/null +++ b/packages/crawling/src/utils/extractPrimaryArtist.ts @@ -0,0 +1,36 @@ +// 쉼표, &(반각/전각), ×, ・, feat, with가 하나라도 들어있으면 다른 조건과 무관하게 +// 이 곡의 artist 값 자체를 무효 처리한다(백필 대상에서 제외) — 애매하게 앞부분만 +// 잘라 쓰는 것보다 아예 안 쓰는 게 안전하다. +// ×(가운뎃점 아님, 곱하기 기호)와 ・(가운뎃점)는 일본어 콘텐츠의 협업 표기에 흔히 +// 쓰인다 — "絢香×コブクロ", "林原めぐみ・関智一"처럼. ・는 한 사람 이름 안에서 +// 성·이름을 구분할 때도 쓰이긴 하지만(예: オノ・ヨーコ), 실측 데이터엔 전부 +// 두 아티스트를 잇는 용도로만 나타나 무효 처리 대상에 포함했다. +const INVALID_ARTIST_PATTERN = /[,&&×・]|\bfeat\b|\bwith\b/i; +// "지코 X 아이유"처럼 앞뒤로 다른 단어가 있을 때만 x를 콜라보 구분자로 본다. +// 이 제약이 없으면 "X JAPAN"처럼 이름 자체가 X로 시작하는 실존 아티스트까지 걸린다. +const X_JOIN_PATTERN = /\S\s+[xX]\s+\S/; + +/** + * songs.artist 원문이 무효 처리 대상인지 판별한다: 쉼표/&/&/×/・/feat/with 포함, + * 또는 " X "/" x " 형태의 콜라보 구분자가 있으면 true. + */ +export function isInvalidArtist(raw: string): boolean { + return INVALID_ARTIST_PATTERN.test(raw) || X_JOIN_PATTERN.test(raw); +} + +/** + * songs.artist 원문에서 첫 괄호 앞부분만 남긴다(괄호 안 내용은 무엇이든 전부 버림 — + * 누락을 감수하는 대신 규칙을 단순하게 유지). isInvalidArtist를 먼저 걸러낸 뒤에만 + * 호출한다는 전제라 쉼표/&/feat/with/x-조인은 여기서 신경 쓰지 않는다. + * + * 자른 결과가 빈 문자열이 되면(예: "(여자)아이들"처럼 이름 자체가 괄호로 시작하는 + * 경우) 잘라내지 않고 원문을 그대로 돌려준다. + */ +export function extractPrimaryArtist(raw: string): string { + const s = raw.trim(); + + const firstParenIdx = s.indexOf('('); + if (firstParenIdx === -1 || firstParenIdx === 0) return s; + + return s.slice(0, firstParenIdx).trim() || s; +} From 52082d00332ac59f688b49c22e2a1b1c3393b67c Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 22 Aug 2026 01:24:53 +0900 Subject: [PATCH 07/21] =?UTF-8?q?fix=20:=20=EA=B2=80=EC=83=89=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=20=ED=83=9C=EA=B7=B8=20=ED=95=84=ED=84=B0=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 오작동하던 언어 태그 필터(LanguageTagFilter)를 검색 화면과 api/search에서 걷어낸다. UI 컴포넌트, 관련 훅(useSearchSong, useSaveSongModal)과 쿼리(searchSongQuery)의 languageTag 파라미터, api/search의 song_tags 조인 필터링 로직, 가이드 투어 스텝을 모두 제거한다. --- apps/web/src/app/api/search/route.ts | 73 ++--------------- apps/web/src/app/search/HomePage.tsx | 19 +---- apps/web/src/app/search/LanguageTagFilter.tsx | 82 ------------------- apps/web/src/app/search/SearchTour.tsx | 5 -- apps/web/src/constants/languageTags.ts | 8 -- apps/web/src/hooks/useSaveSongModal.ts | 8 +- apps/web/src/hooks/useSearchSong.ts | 15 +--- apps/web/src/lib/api/searchSong.ts | 3 +- apps/web/src/queries/searchSongQuery.ts | 55 ++++--------- apps/web/src/types/tag.ts | 4 - 10 files changed, 30 insertions(+), 242 deletions(-) delete mode 100644 apps/web/src/app/search/LanguageTagFilter.tsx delete mode 100644 apps/web/src/constants/languageTags.ts delete mode 100644 apps/web/src/types/tag.ts diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index c9876cfa..a8e8c72b 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { LANGUAGE_TAGS } from '@/constants/languageTags'; import createClient from '@/lib/supabase/server'; import { ApiResponse } from '@/types/apiRoute'; import { SearchSong, Song } from '@/types/song'; @@ -57,36 +56,20 @@ function buildColumnOrGroup(columns: string[], pattern: string): string { return columns.map(column => `${column}.ilike.${pattern}`).join(','); } -// languageTag가 있으면 song_tags(언어 태그) 조인 필터를 추가한다. -// select 절에 `song_tags!inner(tag_id)`가 함께 포함되어 있어야 한다. -function applyLanguageTagFilter T }>( - query: T, - languageTag?: number, -): T { - return languageTag ? query.eq('song_tags.tag_id', languageTag) : query; -} - // 정확 일치: 전체 구가 한 컬럼과 일치 (랭킹 상위). 띄어쓰기 차이는 무시한다. -function applyExactFilter(baseQuery: any, type: string, searchText: string, languageTag?: number) { +function applyExactFilter(baseQuery: any, type: string, searchText: string) { if (type === 'number') { - const query = baseQuery.or(`num_tj.eq.${searchText},num_ky.eq.${searchText}`); - return applyLanguageTagFilter(query, languageTag); + return baseQuery.or(`num_tj.eq.${searchText},num_ky.eq.${searchText}`); } const phrase = buildPhrasePattern(tokenizeQuery(searchText)); const columns = getSearchColumns(type); - const query = baseQuery.or(buildColumnOrGroup(columns, phrase)); - return applyLanguageTagFilter(query, languageTag); + return baseQuery.or(buildColumnOrGroup(columns, phrase)); } // 부분 일치: 입력한 모든 토큰이 (순서 무관) 포함돼야 매칭(AND) + 정확 일치 결과는 제외. -function applyPartialFilter( - baseQuery: any, - type: string, - searchText: string, - languageTag?: number, -) { +function applyPartialFilter(baseQuery: any, type: string, searchText: string) { const tokens = tokenizeQuery(searchText); const phrase = buildPhrasePattern(tokens); const columns = getSearchColumns(type); @@ -107,7 +90,7 @@ function applyPartialFilter( query = query.or(`${column}.not.ilike.${phrase},${column}.is.null`); } - return applyLanguageTagFilter(query, languageTag); + return query; } async function executeSearchQueries( @@ -118,7 +101,6 @@ async function executeSearchQueries( order: string, from: number, to: number, - languageTag?: number, ): Promise<{ data: DBSong[]; hasNext: boolean } | { error: string }> { const size = to - from + 1; @@ -128,7 +110,6 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause, { count: 'exact', head: true }), type, query, - languageTag, ); if (exactCountResult.error) return { error: exactCountResult.error.message }; const exactTotal = exactCountResult.count ?? 0; @@ -137,7 +118,6 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause), type, query, - languageTag, ); const { data, error } = await exactQuery.order(order).range(from, to); if (error) return { error: error.message }; @@ -158,13 +138,11 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause, { count: 'exact', head: true }), type, query, - languageTag, ); const partialCountQuery = applyPartialFilter( supabase.from('songs').select(selectClause, { count: 'exact', head: true }), type, query, - languageTag, ); const [exactCountResult, partialCountResult] = await Promise.all([ @@ -189,7 +167,6 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause), type, query, - languageTag, ); const { data, error } = await partialQuery.order(order).range(from, to); if (error) return { error: error.message }; @@ -202,7 +179,6 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause), type, query, - languageTag, ); const { data, error } = await partialQuery.order(order).range(partialFrom, partialTo); if (error) return { error: error.message }; @@ -214,7 +190,6 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause), type, query, - languageTag, ); const exactResult = await exactQuery.order(order).range(from, exactTo); if (exactResult.error) return { error: exactResult.error.message }; @@ -227,7 +202,6 @@ async function executeSearchQueries( supabase.from('songs').select(selectClause), type, query, - languageTag, ); const partialResult = await partialQuery.order(order).range(0, partialTo); if (partialResult.error) return { error: partialResult.error.message }; @@ -251,23 +225,6 @@ export async function GET(request: Request): Promise tag.id === parsedLanguageTag); - if (!isValidLanguageTag) { - return NextResponse.json( - { - success: false, - error: '유효하지 않은 languageTag 값입니다', - }, - { status: 400 }, - ); - } - languageTag = parsedLanguageTag; - } - const page = parseInt(searchParams.get('page') || '0', 10); const size = 20; const from = page * size; @@ -285,25 +242,11 @@ export async function GET(request: Request): Promise )}
- - {/* 번호 검색에는 언어 필터가 의미가 없다. - 값이 남아 있으면 번호가 맞는데도 결과가 걸러져 원인을 알기 어렵다. */} - {searchType !== 'number' && ( -
- -
- )}
{isTourDemo && ( diff --git a/apps/web/src/app/search/LanguageTagFilter.tsx b/apps/web/src/app/search/LanguageTagFilter.tsx deleted file mode 100644 index 6ddd6f9e..00000000 --- a/apps/web/src/app/search/LanguageTagFilter.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { JP, KR, US } from 'country-flag-icons/react/3x2'; - -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { LANGUAGE_TAGS } from '@/constants/languageTags'; -import { cn } from '@/utils/cn'; - -const ALL_VALUE = 'all'; - -// Tailwind는 클래스명을 정적으로 스캔하므로, 색상 클래스는 태그마다 완전한 문자열로 나열해야 한다. -// 태그별로 chart-1/2/4/5 네온 컬러를 고정 배정해 "노래방 네온 칩" 느낌을 낸다. -// 선택 시에는 해당 색으로 배경을 가득 채우고 글자색을 흰색으로 바꿔 명확히 구분한다. -// TabsTrigger 기본 스타일에 dark:data-[state=active]:bg-input/30, dark:data-[state=active]:text-foreground가 -// 이미 있어서, dark: 접두사가 없는 클래스는 variant 체인이 달라 tailwind-merge가 덮어쓰지 못한다. -// 다크모드용도 명시적으로 함께 지정해야 실제로 배경/글자색이 바뀐다. -const TAG_ACCENT_CLASSES: Record = { - 100: 'border-chart-1/40 text-chart-1/80 data-[state=active]:border-chart-1 data-[state=active]:bg-chart-1 data-[state=active]:text-white dark:data-[state=active]:border-chart-1 dark:data-[state=active]:bg-chart-1 dark:data-[state=active]:text-white data-[state=active]:shadow-[0_0_10px_var(--chart-1)]', - 101: 'border-chart-2/40 text-chart-2/80 data-[state=active]:border-chart-2 data-[state=active]:bg-chart-2 data-[state=active]:text-white dark:data-[state=active]:border-chart-2 dark:data-[state=active]:bg-chart-2 dark:data-[state=active]:text-white data-[state=active]:shadow-[0_0_10px_var(--chart-2)]', - 102: 'border-chart-4/40 text-chart-4/80 data-[state=active]:border-chart-4 data-[state=active]:bg-chart-4 data-[state=active]:text-white dark:data-[state=active]:border-chart-4 dark:data-[state=active]:bg-chart-4 dark:data-[state=active]:text-white data-[state=active]:shadow-[0_0_10px_var(--chart-4)]', - 103: 'border-chart-5/40 text-chart-5/80 data-[state=active]:border-chart-5 data-[state=active]:bg-chart-5 data-[state=active]:text-white dark:data-[state=active]:border-chart-5 dark:data-[state=active]:bg-chart-5 dark:data-[state=active]:text-white data-[state=active]:shadow-[0_0_10px_var(--chart-5)]', -}; - -// "전체"는 특정 언어를 대표하지 않으므로 네온 글로우 없이 중립 톤(전경색 가득 채움)만 유지한다. -const ALL_ACCENT_CLASSES = - 'border-border text-muted-foreground data-[state=active]:border-foreground data-[state=active]:bg-foreground data-[state=active]:text-background dark:data-[state=active]:border-foreground dark:data-[state=active]:bg-foreground dark:data-[state=active]:text-background data-[state=active]:shadow-none'; - -const CHIP_BASE_CLASSES = - 'flex-none rounded-full border bg-transparent px-2.5 py-1.5 font-medium shadow-none transition-all'; - -// 좁은 화면에서 칩이 줄바꿈되면 검색창 아래 높이가 들쭉날쭉해진다. -// 한 줄로 고정하고 넘치면 가로 스크롤한다. 스크롤바는 더 볼 게 남았다는 신호라 노출하되, -// 기본 스크롤바는 두꺼우므로 thin으로 얇게 하고 pb로 칩과 띄운다. -// (인기차트 장르 필터와 같은 방식) -const LIST_CLASSES = - 'h-auto w-full flex-nowrap justify-start gap-1 overflow-x-auto bg-transparent p-0 pb-1 ' + - '[scrollbar-width:thin] [scrollbar-color:var(--color-muted-foreground)_transparent] ' + - '[&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent ' + - '[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground'; - -const ALL_EMOJI = '🎵'; - -const FLAG_CLASSES = 'h-3 w-auto rounded-[2px]'; - -const TAG_ICONS: Record = { - 100: , - 101: , - 102: , - 103: '🌐', -}; - -interface LanguageTagFilterProps { - value: number | undefined; - onChange: (value: number | undefined) => void; -} - -export default function LanguageTagFilter({ value, onChange }: LanguageTagFilterProps) { - const handleValueChange = (next: string) => { - onChange(next === ALL_VALUE ? undefined : Number(next)); - }; - - return ( - - - - {ALL_EMOJI} 전체 - - {LANGUAGE_TAGS.map(tag => ( - - {TAG_ICONS[tag.id]} {tag.name} - - ))} - - - ); -} diff --git a/apps/web/src/app/search/SearchTour.tsx b/apps/web/src/app/search/SearchTour.tsx index 6cee0724..0cca9232 100644 --- a/apps/web/src/app/search/SearchTour.tsx +++ b/apps/web/src/app/search/SearchTour.tsx @@ -73,11 +73,6 @@ export default function SearchTour({ content: '전체·제목·가수·번호 중 원하는 방식으로 검색할 수 있어요.', skipBeacon: true, }, - { - target: '[data-tour="language-filter"]', - title: '국가로 필터링', - content: '한국·일본·팝송처럼 국가(언어)로 검색 범위를 좁혀볼 수 있어요.', - }, { target: '[data-tour="search-result-list"]', title: '검색 결과 살펴보기', diff --git a/apps/web/src/constants/languageTags.ts b/apps/web/src/constants/languageTags.ts deleted file mode 100644 index 7c2b2623..00000000 --- a/apps/web/src/constants/languageTags.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Tag } from '@/types/tag'; - -export const LANGUAGE_TAGS: Tag[] = [ - { id: 100, name: '한국' }, - { id: 101, name: '일본' }, - { id: 102, name: '팝송' }, - { id: 103, name: '글로벌' }, -]; diff --git a/apps/web/src/hooks/useSaveSongModal.ts b/apps/web/src/hooks/useSaveSongModal.ts index 60f7ddc2..07c268de 100644 --- a/apps/web/src/hooks/useSaveSongModal.ts +++ b/apps/web/src/hooks/useSaveSongModal.ts @@ -10,11 +10,7 @@ import { SearchSong, SearchType } from '@/types/song'; type SaveModalType = '' | 'POST' | 'PATCH'; -export default function useSaveSongModal( - query: string, - queryType: SearchType, - languageTag?: number, -) { +export default function useSaveSongModal(query: string, queryType: SearchType) { const { isAuthenticated } = useAuthStore(); const { setFooterAnimateKey } = useFooterAnimateStore(); @@ -44,7 +40,7 @@ export default function useSaveSongModal( } setFooterAnimateKey('INFO'); - postSong({ songId, folderName, query, searchType: queryType, languageTag }); + postSong({ songId, folderName, query, searchType: queryType }); }; const patchSaveSong = async (songId: string, folderId: string) => { diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index ea48b884..898210b0 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -22,19 +22,14 @@ export default function useSearchSong() { const [searchType, setSearchType] = useState('all'); const [query, setQuery] = useState(''); const [queryType, setQueryType] = useState('all'); - // languageTag: Tabs에서 선택 중인(draft) 값. queryLanguageTag: 검색 실행 시 커밋되어 실제 쿼리에 쓰이는 값. - const [languageTag, setLanguageTag] = useState(undefined); - const [queryLanguageTag, setQueryLanguageTag] = useState(undefined); const { mutate: toggleToSing, isPending: isToggleToSingPending } = useToggleToSingMutation( query, queryType, - queryLanguageTag, ); const { mutate: toggleLike, isPending: isToggleLikePending } = useToggleLikeMutation( query, queryType, - queryLanguageTag, ); const { @@ -44,7 +39,7 @@ export default function useSearchSong() { isFetchingNextPage, isLoading: isPendingSearch, isError, - } = useInfiniteSearchSongQuery(query, queryType, isAuthenticated, queryLanguageTag); + } = useInfiniteSearchSongQuery(query, queryType, isAuthenticated); const { mutate: postSearchLog } = usePostSearchLogMutation(); @@ -83,7 +78,6 @@ export default function useSearchSong() { setQuery(parsedSearch); setSearch(parsedSearch); setQueryType(typeOverride ?? searchType); - setQueryLanguageTag(languageTag); addToHistory(parsedSearch); postSearchLog(parsedSearch); } @@ -93,10 +87,6 @@ export default function useSearchSong() { setSearchType(value); }; - const handleLanguageTagChange = (value: number | undefined) => { - setLanguageTag(value); - }; - const handleToggleToSing = useCallback( async (song: Song, method: Method) => { if (!isAuthenticated) { @@ -156,8 +146,6 @@ export default function useSearchSong() { autoCompleteList, query, queryType, - languageTag, - queryLanguageTag, searchResults, fetchNextPage, @@ -167,7 +155,6 @@ export default function useSearchSong() { isError, handleSearchTypeChange, - handleLanguageTagChange, handleSearch, handleToggleToSing, handleToggleLike, diff --git a/apps/web/src/lib/api/searchSong.ts b/apps/web/src/lib/api/searchSong.ts index 8052e51d..40456a7d 100644 --- a/apps/web/src/lib/api/searchSong.ts +++ b/apps/web/src/lib/api/searchSong.ts @@ -8,10 +8,9 @@ export async function getInfiniteSearchSong( searchType: string, isAuthenticated: boolean, page?: number, - languageTag?: number, ) { const response = await instance.get>('/search', { - params: { q: search, type: searchType, authenticated: isAuthenticated, page, languageTag }, + params: { q: search, type: searchType, authenticated: isAuthenticated, page }, }); return response.data; diff --git a/apps/web/src/queries/searchSongQuery.ts b/apps/web/src/queries/searchSongQuery.ts index 3fba511c..8e8d186c 100644 --- a/apps/web/src/queries/searchSongQuery.ts +++ b/apps/web/src/queries/searchSongQuery.ts @@ -30,25 +30,17 @@ interface FolderProps { folderName: string; query: string; searchType: string; - languageTag?: number; } export const useInfiniteSearchSongQuery = ( search: string, searchType: string, isAuthenticated: boolean, - languageTag?: number, ) => { return useInfiniteQuery({ - queryKey: ['searchSong', search, searchType, languageTag], + queryKey: ['searchSong', search, searchType], queryFn: async ({ pageParam }) => { - const response = await getInfiniteSearchSong( - search, - searchType, - isAuthenticated, - pageParam, - languageTag, - ); + const response = await getInfiniteSearchSong(search, searchType, isAuthenticated, pageParam); if (!response.success) { throw new Error('Search API failed'); @@ -71,11 +63,7 @@ export const useInfiniteSearchSongQuery = ( }); }; -export const useToggleToSingMutation = ( - query: string, - searchType: string, - languageTag?: number, -) => { +export const useToggleToSingMutation = (query: string, searchType: string) => { const queryClient = useQueryClient(); return useMutation({ // 낙관적 업데이트 검증 코드 @@ -89,7 +77,7 @@ export const useToggleToSingMutation = ( } }, onMutate: async ({ songId, method }: SongProps) => { - const queryKey = ['searchSong', query, searchType, languageTag]; + const queryKey = ['searchSong', query, searchType]; await queryClient.cancelQueries({ queryKey }); const prev = queryClient.getQueryData(queryKey); const isToSing = method === 'POST'; @@ -106,15 +94,12 @@ export const useToggleToSingMutation = ( }; }); - return { prev, query, searchType, languageTag }; + return { prev, query, searchType }; }, onError: (error, variables, context) => { console.error('error', error); alert(error.message ?? 'POST 실패'); - queryClient.setQueryData( - ['searchSong', context?.query, context?.searchType, context?.languageTag], - context?.prev, - ); + queryClient.setQueryData(['searchSong', context?.query, context?.searchType], context?.prev); }, onSettled: () => { if (invalidateToSingTimeout) { @@ -122,7 +107,7 @@ export const useToggleToSingMutation = ( } invalidateToSingTimeout = setTimeout(() => { queryClient.invalidateQueries({ - queryKey: ['searchSong', query, searchType, languageTag], + queryKey: ['searchSong', query, searchType], }); queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); }, 1000); @@ -130,7 +115,7 @@ export const useToggleToSingMutation = ( }); }; -export const useToggleLikeMutation = (query: string, searchType: string, languageTag?: number) => { +export const useToggleLikeMutation = (query: string, searchType: string) => { const queryClient = useQueryClient(); return useMutation({ @@ -142,7 +127,7 @@ export const useToggleLikeMutation = (query: string, searchType: string, languag } }, onMutate: async ({ songId, method }: SongProps) => { - const queryKey = ['searchSong', query, searchType, languageTag]; + const queryKey = ['searchSong', query, searchType]; await queryClient.cancelQueries({ queryKey }); const prev = queryClient.getQueryData(queryKey); const isLike = method === 'POST'; @@ -158,15 +143,12 @@ export const useToggleLikeMutation = (query: string, searchType: string, languag }; }); - return { prev, query, searchType, languageTag }; + return { prev, query, searchType }; }, onError: (error, variables, context) => { console.error('error', error); alert(error.message ?? 'POST 실패'); - queryClient.setQueryData( - ['searchSong', context?.query, context?.searchType, context?.languageTag], - context?.prev, - ); + queryClient.setQueryData(['searchSong', context?.query, context?.searchType], context?.prev); }, onSettled: () => { if (invalidateLikeTimeout) { @@ -174,7 +156,7 @@ export const useToggleLikeMutation = (query: string, searchType: string, languag } invalidateLikeTimeout = setTimeout(() => { queryClient.invalidateQueries({ - queryKey: ['searchSong', query, searchType, languageTag], + queryKey: ['searchSong', query, searchType], }); queryClient.invalidateQueries({ queryKey: ['likeSong'] }); }, 1000); @@ -189,8 +171,8 @@ export const useSaveMutation = () => { mutationFn: ({ songId, folderName }: FolderProps) => { return postSaveSong({ songId, folderName }); }, - onMutate: async ({ songId, query, searchType, languageTag }: FolderProps) => { - const queryKey = ['searchSong', query, searchType, languageTag]; + onMutate: async ({ songId, query, searchType }: FolderProps) => { + const queryKey = ['searchSong', query, searchType]; await queryClient.cancelQueries({ queryKey }); const prev = queryClient.getQueryData(queryKey); @@ -206,19 +188,16 @@ export const useSaveMutation = () => { }; }); - return { prev, query, searchType, languageTag }; + return { prev, query, searchType }; }, onError: (error, variables, context) => { console.error('error', error); alert(error.message ?? 'POST 실패'); - queryClient.setQueryData( - ['searchSong', context?.query, context?.searchType, context?.languageTag], - context?.prev, - ); + queryClient.setQueryData(['searchSong', context?.query, context?.searchType], context?.prev); }, onSettled: (data, error, context) => { queryClient.invalidateQueries({ - queryKey: ['searchSong', context?.query, context?.searchType, context?.languageTag], + queryKey: ['searchSong', context?.query, context?.searchType], }); queryClient.invalidateQueries({ queryKey: ['saveSongFolder'] }); queryClient.invalidateQueries({ queryKey: ['saveSongFolderList'] }); diff --git a/apps/web/src/types/tag.ts b/apps/web/src/types/tag.ts deleted file mode 100644 index 21ec96d0..00000000 --- a/apps/web/src/types/tag.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Tag { - id: number; - name: string; -} From b539610ce612f3c9d1e3e17d2be99359d7b89b3d Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 22 Aug 2026 01:25:06 +0900 Subject: [PATCH 08/21] =?UTF-8?q?chore=20:=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=93=B1=EB=A1=9D/=EC=B0=B8=EC=A1=B0=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 언어 태그 필터 오작동을 계기로 태그 기능 자체를 끈다. artists 백필에서 language_tag_id 산출 로직과 스키마 컬럼 정의를 제거하고, tags/song_tags를 등록하거나 참조하던 taggingSongs.ts와 translationJpn.ts는 삭제 대신 전체 주석처리한다. 연결된 GitHub Actions 스케줄(tagging_song.yml, translation_jpn.yml)도 schedule 트리거만 주석처리해 자동 실행을 멈추고, workflow_dispatch로 필요하면 수동 실행은 가능하게 남긴다. tags/song_tags 테이블 자체는 DB에서 지우지 않는다. --- .github/workflows/tagging_song.yml | 5 +- .github/workflows/translation_jpn.yml | 5 +- apps/web/artist-vote-schema.sql | 6 +- apps/web/src/app/api/artists/search/route.ts | 4 +- apps/web/src/types/artist.ts | 3 - packages/crawling/src/cron/backfillArtists.ts | 36 +-- packages/crawling/src/cron/taggingSongs.ts | 127 +++++----- packages/crawling/src/cron/translationJpn.ts | 233 +++++++++--------- packages/crawling/src/supabase/getDB.ts | 4 +- packages/crawling/src/types.ts | 10 - 10 files changed, 196 insertions(+), 237 deletions(-) diff --git a/.github/workflows/tagging_song.yml b/.github/workflows/tagging_song.yml index bf7ec9e9..efb142ca 100644 --- a/.github/workflows/tagging_song.yml +++ b/.github/workflows/tagging_song.yml @@ -1,8 +1,9 @@ name: Tagging Songs on: - schedule: - - cron: "0 7 * * *" # 매일 한국 시간 16:00 실행 (UTC+9 → UTC 7:00) + # 태그 기능 비활성화로 스케줄 실행 중단. 필요하면 workflow_dispatch로 수동 실행. + # schedule: + # - cron: "0 7 * * *" # 매일 한국 시간 16:00 실행 (UTC+9 → UTC 7:00) workflow_dispatch: permissions: diff --git a/.github/workflows/translation_jpn.yml b/.github/workflows/translation_jpn.yml index 4a72e37a..e6109622 100644 --- a/.github/workflows/translation_jpn.yml +++ b/.github/workflows/translation_jpn.yml @@ -1,8 +1,9 @@ name: Translation J-POP Songs on: - schedule: - - cron: "0 10 * * *" # 매일 한국 시간 19:00 실행 (UTC+9 → UTC 10:00) + # 태그 기능 비활성화로 스케줄 실행 중단. 필요하면 workflow_dispatch로 수동 실행. + # schedule: + # - cron: "0 10 * * *" # 매일 한국 시간 19:00 실행 (UTC+9 → UTC 10:00) workflow_dispatch: permissions: diff --git a/apps/web/artist-vote-schema.sql b/apps/web/artist-vote-schema.sql index 2d57c59a..d19ba44f 100644 --- a/apps/web/artist-vote-schema.sql +++ b/apps/web/artist-vote-schema.sql @@ -2,12 +2,8 @@ create table public.artists ( id uuid primary key default gen_random_uuid(), name text not null unique, name_ko text, - language_tag_id smallint references public.tags(id), created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - constraint artists_language_tag_id_range check ( - language_tag_id is null or language_tag_id between 100 and 103 - ) + updated_at timestamptz not null default now() ); alter table public.artists enable row level security; diff --git a/apps/web/src/app/api/artists/search/route.ts b/apps/web/src/app/api/artists/search/route.ts index 60f0b0fa..342cf0c5 100644 --- a/apps/web/src/app/api/artists/search/route.ts +++ b/apps/web/src/app/api/artists/search/route.ts @@ -23,12 +23,12 @@ export async function GET( const [byName, byNameKo] = await Promise.all([ supabase .from('artists') - .select('name, name_ko, language_tag_id') + .select('name, name_ko') .ilike('name', pattern) .limit(RESULT_LIMIT), supabase .from('artists') - .select('name, name_ko, language_tag_id') + .select('name, name_ko') .ilike('name_ko', pattern) .limit(RESULT_LIMIT), ]); diff --git a/apps/web/src/types/artist.ts b/apps/web/src/types/artist.ts index 11900295..740b0e6c 100644 --- a/apps/web/src/types/artist.ts +++ b/apps/web/src/types/artist.ts @@ -1,7 +1,4 @@ -// artists.language_tag_id는 tags(id)를 FK로 참조한다 — 값은 -// src/constants/languageTags.ts의 LANGUAGE_TAGS id(100~103) 중 하나다. export interface ArtistSearchResult { name: string; name_ko: string | null; - language_tag_id: number | null; } diff --git a/packages/crawling/src/cron/backfillArtists.ts b/packages/crawling/src/cron/backfillArtists.ts index 4c420ff7..593f40fd 100644 --- a/packages/crawling/src/cron/backfillArtists.ts +++ b/packages/crawling/src/cron/backfillArtists.ts @@ -6,7 +6,7 @@ import { artistAlias } from '@repo/constants'; import { getSongsForArtistBackfillDB } from '@/supabase/getDB'; import { upsertArtistsDB } from '@/supabase/postDB'; -import { ArtistUpsert, LanguageTagId } from '@/types'; +import { ArtistUpsert } from '@/types'; import { extractPrimaryArtist, isInvalidArtist } from '@/utils/extractPrimaryArtist'; const LOG_FILE = path.join('src', 'assets', 'artistBackfillLog.txt'); @@ -16,15 +16,6 @@ function log(message: string) { fs.appendFileSync(LOG_FILE, message + '\n', 'utf-8'); } -// 태깅 파이프라인은 지금 이 4개만 언어 태그로 쓴다. artists.language_tag_id가 -// tags(id)를 FK로 참조하면서 100~103 range check도 걸려 있어, 그 외 tag_id는 걸러야 한다. -const VALID_LANGUAGE_TAG_IDS: readonly number[] = [ - LanguageTagId.Korean, - LanguageTagId.Japanese, - LanguageTagId.Pop, - LanguageTagId.Global, -]; - // artistAlias: { [원어 공식 표기]: [한국어 표기 별칭들] }. // 공식 표기는 songs.artist 원문과 그대로 일치하고, 별칭은 한국어 검색용 힌트라 // songs.artist 값으로 등장하는 일은 거의 없다 — 그래도 등장하면 공식 표기로 접어준다(방어적 정규화). @@ -45,25 +36,7 @@ function resolveCanonicalName(rawArtist: string): string { return aliasToOfficial.get(primary) ?? primary; } -// song_tags 중 언어 태그(100~103)만 골라 가장 많이 등장한 것을 그 아티스트의 -// language_tag_id로 쓴다. tags(id) FK이므로 다른 카테고리 태그가 섞여 있어도 무시한다. -function pickLanguageTagId(tagCounts: Map): LanguageTagId | null { - let bestTag: LanguageTagId | null = null; - let bestCount = 0; - - for (const [tagId, count] of tagCounts) { - if (!VALID_LANGUAGE_TAG_IDS.includes(tagId)) continue; - if (count > bestCount) { - bestTag = tagId; - bestCount = count; - } - } - - return bestTag; -} - interface ArtistGroup { - tagCounts: Map; artistKoCounts: Map; } @@ -94,11 +67,7 @@ for (const row of rows) { } const canonicalName = resolveCanonicalName(row.artist); - const group = groups.get(canonicalName) ?? { tagCounts: new Map(), artistKoCounts: new Map() }; - - for (const { tag_id } of row.song_tags ?? []) { - group.tagCounts.set(tag_id, (group.tagCounts.get(tag_id) ?? 0) + 1); - } + const group = groups.get(canonicalName) ?? { artistKoCounts: new Map() }; if (row.artist_ko) { group.artistKoCounts.set(row.artist_ko, (group.artistKoCounts.get(row.artist_ko) ?? 0) + 1); @@ -120,7 +89,6 @@ const upsertRows: ArtistUpsert[] = [...groups.entries()].map(([name, group]) => return { name, name_ko: aliasKo ?? mostCommonArtistKo ?? null, - language_tag_id: pickLanguageTagId(group.tagCounts), }; }); diff --git a/packages/crawling/src/cron/taggingSongs.ts b/packages/crawling/src/cron/taggingSongs.ts index c68e8c09..03c0f805 100644 --- a/packages/crawling/src/cron/taggingSongs.ts +++ b/packages/crawling/src/cron/taggingSongs.ts @@ -1,62 +1,65 @@ -import { getSongTagSongIdsDB, getSongsAllDB } from '@/supabase/getDB'; -import { postSongTagsDB } from '@/supabase/postDB'; -import { autoTagSong, getTagsForPrompt } from '@/utils/getSongTag'; - -const resultsLog = { - success: 0, - failed: 0, - skipped: 0, -}; - -// 1. 전체 곡 조회 + 이미 태그된 곡 ID + 태그 프롬프트 로드 -const [allSongs, taggedSongIds, tagsPrompt] = await Promise.all([ - getSongsAllDB(), - getSongTagSongIdsDB(), - getTagsForPrompt(), -]); - -console.log('전체 곡 수:', allSongs.length); -console.log('이미 태그된 곡 수:', taggedSongIds.size); - -let processedCount = 0; -for (const song of allSongs) { - if (processedCount >= 20000) break; - if (taggedSongIds.has(song.id)) { - resultsLog.skipped++; - continue; - } - - try { - const tagId = await autoTagSong(song.title, song.artist, tagsPrompt); - - if (tagId === null) { - resultsLog.failed++; - console.log(`[FAIL] ${song.title} - ${song.artist}: 태그 없음`); - continue; - } - - const success = await postSongTagsDB(song.id, [tagId]); - if (success) { - resultsLog.success++; - console.log(`[OK] ${song.title} - ${song.artist}: [${tagId}]`); - } else { - resultsLog.failed++; - } - } catch (error) { - resultsLog.failed++; - console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); - } - - processedCount++; - - // OpenAI rate limit 대비 딜레이 - await new Promise(resolve => setTimeout(resolve, 200)); -} - -// 3. 결과 출력 -console.log(` - 총 ${allSongs.length}곡 중: - - 스킵 (이미 태그됨): ${resultsLog.skipped}곡 - - 성공: ${resultsLog.success}곡 - - 실패: ${resultsLog.failed}곡 -`); +// 태그 기능 비활성화로 전체 주석처리. tags/song_tags 테이블 자체는 남아있지만 +// 이 스크립트로 새로 태그를 등록하지는 않는다. +// +// import { getSongTagSongIdsDB, getSongsAllDB } from '@/supabase/getDB'; +// import { postSongTagsDB } from '@/supabase/postDB'; +// import { autoTagSong, getTagsForPrompt } from '@/utils/getSongTag'; +// +// const resultsLog = { +// success: 0, +// failed: 0, +// skipped: 0, +// }; +// +// // 1. 전체 곡 조회 + 이미 태그된 곡 ID + 태그 프롬프트 로드 +// const [allSongs, taggedSongIds, tagsPrompt] = await Promise.all([ +// getSongsAllDB(), +// getSongTagSongIdsDB(), +// getTagsForPrompt(), +// ]); +// +// console.log('전체 곡 수:', allSongs.length); +// console.log('이미 태그된 곡 수:', taggedSongIds.size); +// +// let processedCount = 0; +// for (const song of allSongs) { +// if (processedCount >= 20000) break; +// if (taggedSongIds.has(song.id)) { +// resultsLog.skipped++; +// continue; +// } +// +// try { +// const tagId = await autoTagSong(song.title, song.artist, tagsPrompt); +// +// if (tagId === null) { +// resultsLog.failed++; +// console.log(`[FAIL] ${song.title} - ${song.artist}: 태그 없음`); +// continue; +// } +// +// const success = await postSongTagsDB(song.id, [tagId]); +// if (success) { +// resultsLog.success++; +// console.log(`[OK] ${song.title} - ${song.artist}: [${tagId}]`); +// } else { +// resultsLog.failed++; +// } +// } catch (error) { +// resultsLog.failed++; +// console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); +// } +// +// processedCount++; +// +// // OpenAI rate limit 대비 딜레이 +// await new Promise(resolve => setTimeout(resolve, 200)); +// } +// +// // 3. 결과 출력 +// console.log(` +// 총 ${allSongs.length}곡 중: +// - 스킵 (이미 태그됨): ${resultsLog.skipped}곡 +// - 성공: ${resultsLog.success}곡 +// - 실패: ${resultsLog.failed}곡 +// `); diff --git a/packages/crawling/src/cron/translationJpn.ts b/packages/crawling/src/cron/translationJpn.ts index 4acf47e5..feba8756 100644 --- a/packages/crawling/src/cron/translationJpn.ts +++ b/packages/crawling/src/cron/translationJpn.ts @@ -1,115 +1,118 @@ -import { artistAlias } from '@repo/constants'; - -import { getArtistKoMapDB, getJpopSongsForTranslationDB } from '@/supabase/getDB'; -import { updateSongKoTranslationDB } from '@/supabase/updateDB'; -import { translateJpnToKo } from '@/utils/translateJpnToKo'; - -const resultsLog = { - success: 0, - failed: 0, - skipped: 0, - usedAlias: 0, - usedDbArtist: 0, -}; - -// 히라가나, 카타카나, CJK 한자 범위로 일본어 포함 여부 판단 -const JAPANESE_REGEX = /[\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf]/; - -function containsJapanese(text: string): boolean { - return JAPANESE_REGEX.test(text); -} - -// artistAlias 로부터 artist 원어 → 한국어 대표 표기(별명 배열의 0번째) 맵 생성 -const artistAliasMap = new Map( - Object.entries(artistAlias).map(([artist, aliases]) => [artist, aliases[0]]), -); - -const songs = await getJpopSongsForTranslationDB(); - -console.log('J-POP 곡 수:', songs.length); - -// DB 에 이미 번역된 artist → artist_ko 맵 (동일 아티스트 번역 일관성 유지 목적) -// DB 는 아티스트당 단일 artist_ko 로 정규화되어 있으며, 번역 성공 시 런타임에도 동기화한다 -const dbArtistKoMap = await getArtistKoMapDB(); - -let processedCount = 0; -for (const song of songs) { - if (processedCount >= 10000) break; - - // 이미 번역된 곡 스킵 - if (song.title_ko && song.artist_ko) { - resultsLog.skipped++; - continue; - } - - // 일본어가 포함되지 않은 곡 스킵 - // 영어로만 이루어진 경우 번역하지 않음 - if (!containsJapanese(song.title) && !containsJapanese(song.artist)) { - resultsLog.skipped++; - continue; - } - - try { - const result = await translateJpnToKo(song.title, song.artist); - - if (!result) { - resultsLog.failed++; - console.log(`[FAIL] ${song.title} - ${song.artist}: 번역 실패`); - continue; - } - - // artist_ko 우선순위: - // 1) artistAlias (수동 큐레이션된 고정 값) - // 2) DB 에 이미 번역된 동일 아티스트의 artist_ko (번역 일관성 유지) - // 3) AI 번역 결과 - // title_ko 는 AI 번역 결과를 그대로 사용 - const aliasArtistKo = artistAliasMap.get(song.artist); - const dbArtistKo = dbArtistKoMap.get(song.artist); - const finalArtistKo = aliasArtistKo ?? dbArtistKo ?? result.artist_ko; - - const success = await updateSongKoTranslationDB(song.id, result.title_ko, finalArtistKo); - if (!success) { - resultsLog.failed++; - continue; - } - - let logPrefix: string; - if (aliasArtistKo) { - resultsLog.usedAlias++; - logPrefix = '[ALIAS]'; - } else if (dbArtistKo) { - resultsLog.usedDbArtist++; - logPrefix = '[DB]'; - } else { - resultsLog.success++; - logPrefix = '[OK]'; - } - - // DB 업데이트 성공 시 런타임 맵도 동기화 (first-seen 원칙 — 기존 값 덮어쓰지 않음) - if (!dbArtistKoMap.has(song.artist)) { - dbArtistKoMap.set(song.artist, finalArtistKo); - } - - console.log( - `${logPrefix} ${song.title} → ${result.title_ko} / ${song.artist} → ${finalArtistKo}`, - ); - } catch (error) { - resultsLog.failed++; - console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); - } - - processedCount++; - - // OpenAI rate limit 대비 딜레이 - await new Promise(resolve => setTimeout(resolve, 200)); -} - -// 결과 출력 -console.log(` - 총 ${songs.length}곡 중: - - 스킵 (이미 번역됨): ${resultsLog.skipped}곡 - - 성공 (AI 번역): ${resultsLog.success}곡 - - 성공 (artist_ko alias 적용): ${resultsLog.usedAlias}곡 - - 성공 (artist_ko DB 재사용): ${resultsLog.usedDbArtist}곡 - - 실패: ${resultsLog.failed}곡 -`); +// 태그 기능 비활성화로 전체 주석처리. 이 스크립트는 song_tags.tag_id=101(일본어)로 +// J-POP 곡을 찾는데, 태그 기능을 더 이상 쓰지 않으므로 함께 멈춘다. +// +// import { artistAlias } from '@repo/constants'; +// +// import { getArtistKoMapDB, getJpopSongsForTranslationDB } from '@/supabase/getDB'; +// import { updateSongKoTranslationDB } from '@/supabase/updateDB'; +// import { translateJpnToKo } from '@/utils/translateJpnToKo'; +// +// const resultsLog = { +// success: 0, +// failed: 0, +// skipped: 0, +// usedAlias: 0, +// usedDbArtist: 0, +// }; +// +// // 히라가나, 카타카나, CJK 한자 범위로 일본어 포함 여부 판단 +// const JAPANESE_REGEX = /[぀-ゟ゠-ヿ＀-゚一-龯㐀-䶿]/; +// +// function containsJapanese(text: string): boolean { +// return JAPANESE_REGEX.test(text); +// } +// +// // artistAlias 로부터 artist 원어 → 한국어 대표 표기(별명 배열의 0번째) 맵 생성 +// const artistAliasMap = new Map( +// Object.entries(artistAlias).map(([artist, aliases]) => [artist, aliases[0]]), +// ); +// +// const songs = await getJpopSongsForTranslationDB(); +// +// console.log('J-POP 곡 수:', songs.length); +// +// // DB 에 이미 번역된 artist → artist_ko 맵 (동일 아티스트 번역 일관성 유지 목적) +// // DB 는 아티스트당 단일 artist_ko 로 정규화되어 있으며, 번역 성공 시 런타임에도 동기화한다 +// const dbArtistKoMap = await getArtistKoMapDB(); +// +// let processedCount = 0; +// for (const song of songs) { +// if (processedCount >= 10000) break; +// +// // 이미 번역된 곡 스킵 +// if (song.title_ko && song.artist_ko) { +// resultsLog.skipped++; +// continue; +// } +// +// // 일본어가 포함되지 않은 곡 스킵 +// // 영어로만 이루어진 경우 번역하지 않음 +// if (!containsJapanese(song.title) && !containsJapanese(song.artist)) { +// resultsLog.skipped++; +// continue; +// } +// +// try { +// const result = await translateJpnToKo(song.title, song.artist); +// +// if (!result) { +// resultsLog.failed++; +// console.log(`[FAIL] ${song.title} - ${song.artist}: 번역 실패`); +// continue; +// } +// +// // artist_ko 우선순위: +// // 1) artistAlias (수동 큐레이션된 고정 값) +// // 2) DB 에 이미 번역된 동일 아티스트의 artist_ko (번역 일관성 유지) +// // 3) AI 번역 결과 +// // title_ko 는 AI 번역 결과를 그대로 사용 +// const aliasArtistKo = artistAliasMap.get(song.artist); +// const dbArtistKo = dbArtistKoMap.get(song.artist); +// const finalArtistKo = aliasArtistKo ?? dbArtistKo ?? result.artist_ko; +// +// const success = await updateSongKoTranslationDB(song.id, result.title_ko, finalArtistKo); +// if (!success) { +// resultsLog.failed++; +// continue; +// } +// +// let logPrefix: string; +// if (aliasArtistKo) { +// resultsLog.usedAlias++; +// logPrefix = '[ALIAS]'; +// } else if (dbArtistKo) { +// resultsLog.usedDbArtist++; +// logPrefix = '[DB]'; +// } else { +// resultsLog.success++; +// logPrefix = '[OK]'; +// } +// +// // DB 업데이트 성공 시 런타임 맵도 동기화 (first-seen 원칙 — 기존 값 덮어쓰지 않음) +// if (!dbArtistKoMap.has(song.artist)) { +// dbArtistKoMap.set(song.artist, finalArtistKo); +// } +// +// console.log( +// `${logPrefix} ${song.title} → ${result.title_ko} / ${song.artist} → ${finalArtistKo}`, +// ); +// } catch (error) { +// resultsLog.failed++; +// console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); +// } +// +// processedCount++; +// +// // OpenAI rate limit 대비 딜레이 +// await new Promise(resolve => setTimeout(resolve, 200)); +// } +// +// // 결과 출력 +// console.log(` +// 총 ${songs.length}곡 중: +// - 스킵 (이미 번역됨): ${resultsLog.skipped}곡 +// - 성공 (AI 번역): ${resultsLog.success}곡 +// - 성공 (artist_ko alias 적용): ${resultsLog.usedAlias}곡 +// - 성공 (artist_ko DB 재사용): ${resultsLog.usedDbArtist}곡 +// - 실패: ${resultsLog.failed}곡 +// `); diff --git a/packages/crawling/src/supabase/getDB.ts b/packages/crawling/src/supabase/getDB.ts index 00779e88..491d6208 100644 --- a/packages/crawling/src/supabase/getDB.ts +++ b/packages/crawling/src/supabase/getDB.ts @@ -154,13 +154,13 @@ export async function getArtistKoMapDB(): Promise> { // 아티스트 백필용 조회. sinceIso가 있으면 그 시각 이후 등록되었거나 수정된 곡만 // (월간 증분 갱신 — updated_at도 봐야 기존 곡의 artist 오타 수정 같은 걸 놓치지 않는다), -// 없으면 전체 곡을 대상으로 한다(최초 백필). song_tags는 언어 태그로 language_tag_id를 추정하는 데 쓴다. +// 없으면 전체 곡을 대상으로 한다(최초 백필). export async function getSongsForArtistBackfillDB( sinceIso?: string, ): Promise { const supabase = getClient(); - let query = supabase.from('songs').select('artist, artist_ko, song_tags(tag_id)').limit(200000); + let query = supabase.from('songs').select('artist, artist_ko').limit(200000); if (sinceIso) { // sinceIso는 서버가 계산한 값이라 사용자 입력이 섞이지 않는다 — or() 필터 문자열 조립이 안전하다. diff --git a/packages/crawling/src/types.ts b/packages/crawling/src/types.ts index 53fec356..0f216d43 100644 --- a/packages/crawling/src/types.ts +++ b/packages/crawling/src/types.ts @@ -104,22 +104,12 @@ export interface TjChartRankingInsert { song_id: string; } -// 언어 태그 ID (apps/web/src/constants/languageTags.ts와 의도적으로 중복 정의) -export enum LanguageTagId { - Korean = 100, - Japanese = 101, - Pop = 102, - Global = 103, -} - export interface ArtistBackfillSongRow { artist: string; artist_ko: string | null; - song_tags: { tag_id: number }[]; } export interface ArtistUpsert { name: string; name_ko: string | null; - language_tag_id: LanguageTagId | null; } From 68c9cad503a9bc4eae5a369b091eca76f61b06b3 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 22 Aug 2026 01:25:15 +0900 Subject: [PATCH 09/21] =?UTF-8?q?doc=20:=20=ED=83=9C=EA=B7=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=A0=9C=EA=B1=B0=20=EB=82=B4=EC=97=AD=20=EB=B0=8F?= =?UTF-8?q?=20=EB=82=A8=EC=9D=80=20=EC=9E=91=EC=97=85=20CLAUDE.md=EC=97=90?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 태그 기능 제거 배경을 참고용으로 남기고, 이달의 아티스트 잔여 작업 목록에 아티스트 투표/검색 기능 동작 검증 항목을 추가한다. --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 631b96dc..d57d793b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,11 @@ chore : 버전 2.3.0 (#61) - Vercel(`apps/web` 프로덕션): `SUPABASE_SERVICE_ROLE_KEY`(turbo.json엔 이미 선언돼 있으나 실제 값 미설정), `ARTIST_VOTE_FINALIZE_SECRET`(새로 발급) - GitHub Actions repo secret: `ARTIST_VOTE_FINALIZE_SECRET` (`finalize_artist_of_month.yml`에서 사용, Vercel과 같은 값이어야 함) 4. 위 설정 후 투표 → 월간 확정(`finalize_artist_of_month.yml`) → 곡 카드 배지 노출까지 전체 흐름을 수동 QA (테스트 스위트 없음) +5. 아티스트 투표/검색 기능 동작 검증 — dev 서버 기동 및 `/popular` 응답까지는 확인했으나, 공개 API(`/api/artists/search`, `/api/artist-vote/rankings`, `/api/artist-vote/current-winner`) curl 스모크 테스트와 `/popular` 페이지 UI(투표 모달·검색) 브라우저 확인이 아직 안 끝났다. 로그인이 필요한 실제 투표(포인트 차감→랭킹 반영) 흐름은 카카오 로그인 기반이라 자동 검증이 어려워 수동 QA가 필요하다. + +### 태그 기능 제거 (완료 — 참고용) + +검색 언어 태그 필터가 오작동해 관련 코드를 전부 걷어냈다: 프론트(`LanguageTagFilter.tsx` 등)와 `api/search`의 `song_tags` 조인, `artists.language_tag_id` 참조(추후 대시보드에서 컬럼 수동 삭제 예정)를 제거했고, `packages/crawling`의 `taggingSongs.ts`/`translationJpn.ts`는 삭제 대신 전체 주석처리했다. `tagging_song.yml`/`translation_jpn.yml`은 `schedule` 트리거만 비활성화(주석)했고 `workflow_dispatch`로 수동 실행은 여전히 가능하다. `tags`/`song_tags` 테이블 자체는 DB에 남아있지만 앱은 더 이상 참조하지 않는다. ## Self-Maintenance From 75fc2dfd26e7e3df1c76c6050135c11bf2e4d67b Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 19:29:14 +0900 Subject: [PATCH 10/21] =?UTF-8?q?refactor=20:=20=EC=95=84=ED=8B=B0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=99=94=20=EA=B7=9C=EC=B9=99=20@repo/constants=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- packages/constants/src/artistName.ts | 74 +++++++++++++++++++ packages/constants/src/index.ts | 7 +- .../src/utils/extractPrimaryArtist.ts | 39 +--------- 3 files changed, 83 insertions(+), 37 deletions(-) create mode 100644 packages/constants/src/artistName.ts diff --git a/packages/constants/src/artistName.ts b/packages/constants/src/artistName.ts new file mode 100644 index 00000000..fec11326 --- /dev/null +++ b/packages/constants/src/artistName.ts @@ -0,0 +1,74 @@ +import { artistAlias } from "./artistAlias"; + +// 쉼표, &(반각/전각), ×, ・, feat, with가 하나라도 들어있으면 다른 조건과 무관하게 +// 이 곡의 artist 값 자체를 무효 처리한다(백필 대상에서 제외) — 애매하게 앞부분만 +// 잘라 쓰는 것보다 아예 안 쓰는 게 안전하다. +// ×(가운뎃점 아님, 곱하기 기호)와 ・(가운뎃점)는 일본어 콘텐츠의 협업 표기에 흔히 +// 쓰인다 — "絢香×コブクロ", "林原めぐみ・関智一"처럼. ・는 한 사람 이름 안에서 +// 성·이름을 구분할 때도 쓰이긴 하지만(예: オノ・ヨーコ), 실측 데이터엔 전부 +// 두 아티스트를 잇는 용도로만 나타나 무효 처리 대상에 포함했다. +const INVALID_ARTIST_PATTERN = /[,&&×・]|\bfeat\b|\bwith\b/i; +// "지코 X 아이유"처럼 앞뒤로 다른 단어가 있을 때만 x를 콜라보 구분자로 본다. +// 이 제약이 없으면 "X JAPAN"처럼 이름 자체가 X로 시작하는 실존 아티스트까지 걸린다. +const X_JOIN_PATTERN = /\S\s+[xX]\s+\S/; + +// 콜라보 표기를 참여자 단위로 쪼갤 때 쓰는 구분자. +// INVALID_ARTIST_PATTERN과 같은 기호를 보지만 목적이 다르다 — 저쪽은 "버릴지"를 정하고 +// 이쪽은 "어디까지가 맨 앞 아티스트인지"를 정한다. feat/with는 앞의 공백까지 함께 잘라 +// "IU Feat.최백호"에서 뒤쪽 이름만 떨어져 나가게 한다. +const COLLAB_SEPARATOR_PATTERN = /[,&&×・]|\s+(?:feat|with)\b\.?|\s+[xX]\s+/i; + +// artistAlias: { [원어 공식 표기]: [한국어 표기 별칭들] }. +// 별칭이 songs.artist 값으로 등장하는 일은 드물지만, 등장하면 공식 표기로 접어준다. +const aliasToOfficial = new Map(); +for (const [officialName, aliases] of Object.entries(artistAlias)) { + aliasToOfficial.set(officialName, officialName); + aliases.forEach((alias) => aliasToOfficial.set(alias, officialName)); +} + +/** + * songs.artist 원문이 무효 처리 대상인지 판별한다: 쉼표/&/&/×/・/feat/with 포함, + * 또는 " X "/" x " 형태의 콜라보 구분자가 있으면 true. + */ +export function isInvalidArtist(raw: string): boolean { + return INVALID_ARTIST_PATTERN.test(raw) || X_JOIN_PATTERN.test(raw); +} + +/** + * songs.artist 원문에서 첫 괄호 앞부분만 남긴다(괄호 안 내용은 무엇이든 전부 버림 — + * 누락을 감수하는 대신 규칙을 단순하게 유지). isInvalidArtist를 먼저 걸러낸 뒤에만 + * 호출한다는 전제라 쉼표/&/feat/with/x-조인은 여기서 신경 쓰지 않는다. + * + * 자른 결과가 빈 문자열이 되면(예: "(여자)아이들"처럼 이름 자체가 괄호로 시작하는 + * 경우) 잘라내지 않고 원문을 그대로 돌려준다. + */ +export function extractPrimaryArtist(raw: string): string { + const s = raw.trim(); + + const firstParenIdx = s.indexOf("("); + if (firstParenIdx === -1 || firstParenIdx === 0) return s; + + return s.slice(0, firstParenIdx).trim() || s; +} + +/** + * songs.artist 원문에서 "맨 앞에 적힌 주 아티스트" 하나를 뽑아낸다. + * + * 백필(extractPrimaryArtist + isInvalidArtist)은 마스터 테이블에 올릴 이름을 고르는 쪽이라 + * 콜라보 표기를 통째로 버리지만, 화면 표시(예: 이달의 아티스트 배지)는 틀려도 비용이 작고 + * 반대로 안 붙는 누락이 훨씬 눈에 띈다. 그래서 버리는 대신 맨 앞 이름만 취한다. + * 참여자 전원을 인정하지 않는 이유는 피처링으로 한 소절 부른 곡까지 주인공처럼 보이면 + * 배지가 흔해져 값어치를 잃기 때문이다. + * + * 괄호를 먼저 떼는 순서가 중요하다. "IU(Feat.최백호)"는 구분자를 먼저 보면 "IU(" 가 남는다. + * + * "IU(Feat.최백호)" → "IU" / "IU,나윤권" → "IU" / "DAOKO X 米津玄師" → "DAOKO" + */ +export function getPrimaryArtistName(raw: string): string { + if (!raw) return ""; + + const primary = extractPrimaryArtist(raw); + const leading = primary.split(COLLAB_SEPARATOR_PATTERN)[0]?.trim() || primary; + + return aliasToOfficial.get(leading) ?? leading; +} diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 533baaa1..ef262111 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1 +1,6 @@ -export { artistAlias } from './artistAlias'; +export { artistAlias } from "./artistAlias"; +export { + extractPrimaryArtist, + getPrimaryArtistName, + isInvalidArtist, +} from "./artistName"; diff --git a/packages/crawling/src/utils/extractPrimaryArtist.ts b/packages/crawling/src/utils/extractPrimaryArtist.ts index a47024f7..8268aee5 100644 --- a/packages/crawling/src/utils/extractPrimaryArtist.ts +++ b/packages/crawling/src/utils/extractPrimaryArtist.ts @@ -1,36 +1,3 @@ -// 쉼표, &(반각/전각), ×, ・, feat, with가 하나라도 들어있으면 다른 조건과 무관하게 -// 이 곡의 artist 값 자체를 무효 처리한다(백필 대상에서 제외) — 애매하게 앞부분만 -// 잘라 쓰는 것보다 아예 안 쓰는 게 안전하다. -// ×(가운뎃점 아님, 곱하기 기호)와 ・(가운뎃점)는 일본어 콘텐츠의 협업 표기에 흔히 -// 쓰인다 — "絢香×コブクロ", "林原めぐみ・関智一"처럼. ・는 한 사람 이름 안에서 -// 성·이름을 구분할 때도 쓰이긴 하지만(예: オノ・ヨーコ), 실측 데이터엔 전부 -// 두 아티스트를 잇는 용도로만 나타나 무효 처리 대상에 포함했다. -const INVALID_ARTIST_PATTERN = /[,&&×・]|\bfeat\b|\bwith\b/i; -// "지코 X 아이유"처럼 앞뒤로 다른 단어가 있을 때만 x를 콜라보 구분자로 본다. -// 이 제약이 없으면 "X JAPAN"처럼 이름 자체가 X로 시작하는 실존 아티스트까지 걸린다. -const X_JOIN_PATTERN = /\S\s+[xX]\s+\S/; - -/** - * songs.artist 원문이 무효 처리 대상인지 판별한다: 쉼표/&/&/×/・/feat/with 포함, - * 또는 " X "/" x " 형태의 콜라보 구분자가 있으면 true. - */ -export function isInvalidArtist(raw: string): boolean { - return INVALID_ARTIST_PATTERN.test(raw) || X_JOIN_PATTERN.test(raw); -} - -/** - * songs.artist 원문에서 첫 괄호 앞부분만 남긴다(괄호 안 내용은 무엇이든 전부 버림 — - * 누락을 감수하는 대신 규칙을 단순하게 유지). isInvalidArtist를 먼저 걸러낸 뒤에만 - * 호출한다는 전제라 쉼표/&/feat/with/x-조인은 여기서 신경 쓰지 않는다. - * - * 자른 결과가 빈 문자열이 되면(예: "(여자)아이들"처럼 이름 자체가 괄호로 시작하는 - * 경우) 잘라내지 않고 원문을 그대로 돌려준다. - */ -export function extractPrimaryArtist(raw: string): string { - const s = raw.trim(); - - const firstParenIdx = s.indexOf('('); - if (firstParenIdx === -1 || firstParenIdx === 0) return s; - - return s.slice(0, firstParenIdx).trim() || s; -} +// 아티스트 이름 정규화 규칙은 웹(이달의 아티스트 배지 매칭)과 공유해야 해서 +// @repo/constants로 옮겼다. 여기서는 기존 import 경로를 유지하기 위해 재수출만 한다. +export { extractPrimaryArtist, getPrimaryArtistName, isInvalidArtist } from '@repo/constants'; From bec7151320326d09883b59ffb0d867d460ed9b44 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 19:29:14 +0900 Subject: [PATCH 11/21] =?UTF-8?q?chore=20:=20recharts=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=20=EB=B0=8F=20=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=B0=B1=ED=95=84=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=84=A0=EC=96=B8=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- apps/web/package.json | 1 + pnpm-lock.yaml | 301 +++++++++++++++++++++++++++++++++++++++++- turbo.json | 6 +- 3 files changed, 303 insertions(+), 5 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 9f048a3a..7d74e819 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -63,6 +63,7 @@ "react-dom": "^19.0.0", "react-intersection-observer": "^9.16.0", "react-joyride": "3.2.0", + "recharts": "^3.10.1", "serwist": "^9.5.7", "sonner": "^2.0.3", "tailwind-merge": "^3.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21c64c48..d3510a80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: react-joyride: specifier: 3.2.0 version: 3.2.0(@types/react@19.0.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + recharts: + specifier: ^3.10.1 + version: 3.10.1(@types/react@19.0.10)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1) serwist: specifier: ^9.5.7 version: 9.5.7(browserslist@4.28.1)(typescript@5.9.3) @@ -1218,8 +1221,8 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@maxim_mazurok/gapi.client.androidpublisher-v3@0.5.20260811': - resolution: {integrity: sha512-5LSWplF7CXqG2KNfgka2s8QWpQkpBwiPgxTjpar7jJcPJn9PrqYrCJSZhGZPt1NayVM2ERm73cUL/IdpDtNJnQ==} + '@maxim_mazurok/gapi.client.androidpublisher-v3@0.5.20260820': + resolution: {integrity: sha512-OXrVp1H+OubRfY+Omsv8CudkO9qjIReCGqM8RxeMbTVtYKEe3ddqBheUS4iEur1q3aLGgHcQphCF5FmUT0S9Zg==} '@maxim_mazurok/gapi.client.discovery-v1@0.6.20200806': resolution: {integrity: sha512-QN6aGmIbLXcJW0SEf8tz9UWCpQTBMy2u4xSuZ04YofzMGknhWwMeUecySLntCZYAEstHzDoSgwxCf5Gb8GqIRQ==} @@ -2049,6 +2052,17 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@resvg/resvg-js-android-arm-eabi@2.6.2': resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} engines: {node: '>= 10'} @@ -2286,6 +2300,12 @@ packages: typescript: optional: true + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@supabase/auth-js@2.89.0': resolution: {integrity: sha512-wiWZdz8WMad8LQdJMWYDZ2SJtZP5MwMqzQq3ehtW2ngiI3UTgbKiFrvMUUS3KADiVlk4LiGfODB2mrYx7w2f8w==} engines: {node: '>=20.0.0'} @@ -2484,6 +2504,33 @@ packages: '@types/color@3.0.7': resolution: {integrity: sha512-e/ecxz2EGJhUEaoeFa9Xx6ayIkBFQ5bwKgOWzmrEtP+ApYB5ou+eOB71DasFsn3qCKzIPrbi/NKdM8lI0tnGUQ==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2575,6 +2622,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/valid-url@1.0.7': resolution: {integrity: sha512-tgsWVG80dM5PVEBSbXUttPJTBCOo0IKbBh4R4z/SHsC5C81A3aaUH4fsbj+JYk7fopApU/Mao1c0EWTE592TSg==} @@ -3177,6 +3227,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -3219,6 +3313,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -3380,6 +3477,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.51.0: + resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -3481,6 +3581,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -3883,6 +3986,9 @@ packages: immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + immer@11.1.18: + resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3925,6 +4031,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -4944,6 +5054,18 @@ packages: react: 16.8 - 19 react-dom: 16.8 - 19 + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -4997,6 +5119,22 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + recharts@3.10.1: + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5022,6 +5160,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5397,6 +5538,9 @@ packages: timm@1.7.1: resolution: {integrity: sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} @@ -5687,6 +5831,9 @@ packages: resolution: {integrity: sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -6655,7 +6802,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@maxim_mazurok/gapi.client.androidpublisher-v3@0.5.20260811': + '@maxim_mazurok/gapi.client.androidpublisher-v3@0.5.20260820': dependencies: '@types/gapi.client': 1.0.8 '@types/gapi.client.discovery-v1': 0.0.4 @@ -7521,6 +7668,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.0.10)(react@19.2.3)(redux@5.0.1))(react@19.2.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.18 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.2.3 + react-redux: 9.3.0(@types/react@19.0.10)(react@19.2.3)(redux@5.0.1) + '@resvg/resvg-js-android-arm-eabi@2.6.2': optional: true @@ -7694,6 +7853,10 @@ snapshots: transitivePeerDependencies: - browserslist + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@supabase/auth-js@2.89.0': dependencies: tslib: 2.8.1 @@ -7901,13 +8064,37 @@ snapshots: dependencies: '@types/color-convert': 1.9.0 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/extract-zip@1.6.2': {} '@types/gapi.client.androidpublisher@3.0.2': dependencies: - '@maxim_mazurok/gapi.client.androidpublisher-v3': 0.5.20260811 + '@maxim_mazurok/gapi.client.androidpublisher-v3': 0.5.20260820 '@types/gapi.client.discovery-v1@0.0.4': dependencies: @@ -7992,6 +8179,8 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/valid-url@1.0.7': {} '@types/ws@8.18.1': @@ -8628,6 +8817,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-uri-to-buffer@6.0.2: {} data-view-buffer@1.0.2: @@ -8662,6 +8889,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -8892,6 +9121,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.51.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -9045,6 +9276,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -9537,6 +9770,8 @@ snapshots: immer@10.2.0: {} + immer@11.1.18: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -9632,6 +9867,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + ip-address@10.1.0: {} is-array-buffer@3.0.5: @@ -10657,6 +10894,15 @@ snapshots: transitivePeerDependencies: - '@types/react' + react-redux@9.3.0(@types/react@19.0.10)(react@19.2.3)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.0.10 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.0.10)(react@19.2.3): dependencies: react: 19.2.3 @@ -10716,6 +10962,32 @@ snapshots: readdirp@4.1.2: {} + recharts@3.10.1(@types/react@19.0.10)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.0.10)(react@19.2.3)(redux@5.0.1))(react@19.2.3) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.51.0 + eventemitter3: 5.0.4 + immer: 11.1.18 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-is: 16.13.1 + react-redux: 9.3.0(@types/react@19.0.10)(react@19.2.3)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.3) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -10751,6 +11023,8 @@ snapshots: requires-port@1.0.0: {} + reselect@5.2.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -11238,6 +11512,8 @@ snapshots: timm@1.7.1: {} + tiny-invariant@1.3.3: {} + tinycolor2@1.6.0: {} tinyexec@0.3.2: {} @@ -11544,6 +11820,23 @@ snapshots: dependencies: builtins: 5.1.0 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 diff --git a/turbo.json b/turbo.json index 5250c7e3..42b1cf0f 100644 --- a/turbo.json +++ b/turbo.json @@ -25,7 +25,11 @@ "TJ_RESET_PROGRESS", "DEAD_SONGS_APPLY", "ARTIST_VOTE_FINALIZE_SECRET", - "ARTIST_BACKFILL_SINCE_DAYS" + "ARTIST_BACKFILL_SINCE_DAYS", + "ARTIST_IMAGE_LIMIT", + "ARTIST_IMAGE_NAMES", + "ARTIST_IMAGE_PODIUM", + "ARTIST_IMAGE_DRY_RUN" ], "tasks": { "build": { From 10026b306db0109106e1e61fdc174b63bb1dab35 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 19:29:21 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat=20:=20=EC=9D=B4=EB=8B=AC=EC=9D=98=20?= =?UTF-8?q?=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=95=A1=EC=9E=90=C2=B7=EC=9A=B0=EC=8A=B9?= =?UTF-8?q?=20=EB=B3=84=C2=B7=EB=93=9D=ED=91=9C=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=ED=8E=B8=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- apps/web/artist-vote-schema.sql | 66 ++++- .../app/api/artist-vote/_lib/artistWins.ts | 46 ++++ .../src/app/api/artist-vote/_lib/rankVotes.ts | 7 +- .../src/app/api/artist-vote/_lib/starCount.ts | 16 -- .../api/artist-vote/current-winner/route.ts | 9 +- .../app/api/artist-vote/my-current/route.ts | 21 +- .../src/app/api/artist-vote/rankings/route.ts | 37 +-- apps/web/src/app/popular/ArtistName.tsx | 30 +++ apps/web/src/app/popular/ArtistOfMonthTab.tsx | 127 +++++---- apps/web/src/app/popular/ArtistPortrait.tsx | 119 ++++++++ .../src/app/popular/ArtistRankingBoard.tsx | 194 +++++++++---- .../src/app/popular/ArtistRankingChart.tsx | 217 +++++++++++++++ apps/web/src/app/popular/ArtistVoteModal.tsx | 106 -------- apps/web/src/app/popular/ArtistVotePanel.tsx | 254 ++++++++++++++++++ apps/web/src/app/popular/ArtistVoteRow.tsx | 62 ++++- .../src/app/popular/ArtistVotersDialog.tsx | 15 +- apps/web/src/app/popular/WinDetailDialog.tsx | 56 ++++ apps/web/src/app/popular/WinStars.tsx | 108 ++++++++ apps/web/src/globals.css | 61 ++++- apps/web/src/queries/artistVoteQuery.ts | 36 ++- apps/web/src/types/artistVote.ts | 28 +- apps/web/src/utils/formatVoteShare.ts | 9 + 22 files changed, 1328 insertions(+), 296 deletions(-) create mode 100644 apps/web/src/app/api/artist-vote/_lib/artistWins.ts delete mode 100644 apps/web/src/app/api/artist-vote/_lib/starCount.ts create mode 100644 apps/web/src/app/popular/ArtistName.tsx create mode 100644 apps/web/src/app/popular/ArtistPortrait.tsx create mode 100644 apps/web/src/app/popular/ArtistRankingChart.tsx delete mode 100644 apps/web/src/app/popular/ArtistVoteModal.tsx create mode 100644 apps/web/src/app/popular/ArtistVotePanel.tsx create mode 100644 apps/web/src/app/popular/WinDetailDialog.tsx create mode 100644 apps/web/src/app/popular/WinStars.tsx create mode 100644 apps/web/src/utils/formatVoteShare.ts diff --git a/apps/web/artist-vote-schema.sql b/apps/web/artist-vote-schema.sql index d19ba44f..d2745a53 100644 --- a/apps/web/artist-vote-schema.sql +++ b/apps/web/artist-vote-schema.sql @@ -1,14 +1,21 @@ -create table public.artists ( +-- 이달의 아티스트(포인트 투표) 스키마 + RLS 정책. +-- 여러 번 실행해도 안전하도록 작성했으니, 이미 테이블을 만든 환경에서도 이 파일을 그대로 다시 실행하면 된다. + +create table if not exists public.artists ( id uuid primary key default gen_random_uuid(), name text not null unique, name_ko text, + image_url text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); +-- 이미 만들어진 환경에도 컬럼을 채워 넣는다. 랭킹 1위 액자에 쓰는 아티스트 사진 URL이다. +alter table public.artists add column if not exists image_url text; + alter table public.artists enable row level security; -create table public.artist_votes ( +create table if not exists public.artist_votes ( id uuid primary key default gen_random_uuid(), user_id uuid not null references public.users(id) on delete cascade, artist text not null references public.artists(name), @@ -19,14 +26,14 @@ create table public.artist_votes ( unique (user_id, artist, vote_month) ); -create index artist_votes_month_artist_idx on public.artist_votes (vote_month, artist); +create index if not exists artist_votes_month_artist_idx on public.artist_votes (vote_month, artist); alter table public.artist_votes enable row level security; -create table public.monthly_artist_rankings ( +create table if not exists public.monthly_artist_rankings ( id uuid primary key default gen_random_uuid(), vote_month date not null, - rank smallint not null check (rank between 1 and 10), + rank smallint not null check (rank between 1 and 30), artist text not null references public.artists(name), total_votes integer not null, top_voter_user_id uuid references public.users(id) on delete set null, @@ -36,4 +43,53 @@ create table public.monthly_artist_rankings ( unique (vote_month, artist) ); +-- 최초 버전은 1~10위만 저장했다. 11~30위 구간을 화면에 쓰기 위해 제약을 넓힌다. +-- 제약 이름은 인라인 check에 postgres가 붙이는 기본 이름이다. +alter table public.monthly_artist_rankings + drop constraint if exists monthly_artist_rankings_rank_check; +alter table public.monthly_artist_rankings + add constraint monthly_artist_rankings_rank_check check (rank between 1 and 30); + alter table public.monthly_artist_rankings enable row level security; + +-- RLS 정책 -------------------------------------------------------------- +-- 앱의 API 라우트는 사용자 세션(anon 키)으로 Supabase에 접근하므로, 정책이 없으면 +-- RLS가 모든 행을 가려 검색·랭킹이 조용히 빈 배열로 돌아온다. + +-- artists: 아티스트 검색/자동완성에 필요해 누구나 읽는다. +-- 쓰기는 백필 스크립트(service_role)만 하므로 정책을 주지 않는다. +drop policy if exists "artists_select_public" on public.artists; +create policy "artists_select_public" on public.artists for select using (true); + +-- monthly_artist_rankings: 확정된 월간 결과는 공개 데이터다. +-- 쓰기는 finalize 라우트(service_role) 전용이라 정책이 없다. +drop policy if exists "monthly_artist_rankings_select_public" on public.monthly_artist_rankings; +create policy "monthly_artist_rankings_select_public" on public.monthly_artist_rankings + for select using (true); + +-- artist_votes: 본인 투표는 언제나 볼 수 있고, 남의 투표는 "이미 확정된 (월, 아티스트)"만 공개한다. +-- 이렇게 두지 않으면 아직 집계 전인 이번 달 실시간 득표 현황이 그대로 새어나간다. +drop policy if exists "artist_votes_select_own_or_finalized" on public.artist_votes; +create policy "artist_votes_select_own_or_finalized" on public.artist_votes + for select using ( + auth.uid() = user_id + or exists ( + select 1 + from public.monthly_artist_rankings r + where r.vote_month = artist_votes.vote_month + and r.artist = artist_votes.artist + ) + ); + +-- 투표 등록/수정/삭제는 본인 행만. upsert가 insert와 update 정책을 모두 타므로 둘 다 필요하다. +drop policy if exists "artist_votes_insert_own" on public.artist_votes; +create policy "artist_votes_insert_own" on public.artist_votes + for insert with check (auth.uid() = user_id); + +drop policy if exists "artist_votes_update_own" on public.artist_votes; +create policy "artist_votes_update_own" on public.artist_votes + for update using (auth.uid() = user_id) with check (auth.uid() = user_id); + +drop policy if exists "artist_votes_delete_own" on public.artist_votes; +create policy "artist_votes_delete_own" on public.artist_votes + for delete using (auth.uid() = user_id); diff --git a/apps/web/src/app/api/artist-vote/_lib/artistWins.ts b/apps/web/src/app/api/artist-vote/_lib/artistWins.ts new file mode 100644 index 00000000..239c6ef3 --- /dev/null +++ b/apps/web/src/app/api/artist-vote/_lib/artistWins.ts @@ -0,0 +1,46 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + +import { ArtistWin } from '@/types/artistVote'; + +interface WinRow { + artist: string; + vote_month: string; + total_votes: number; +} + +/** + * 여러 아티스트의 1위 이력을 한 번에 가져온다. + * 트로피 하나가 우승 한 번이라 개수만이 아니라 "언제, 몇 P로" 받았는지가 필요하다. + * 시상대(1~3위)마다 따로 조회하면 요청이 셋으로 늘어나므로 in 절로 묶는다. + * + * untilMonth로 잘라내는 게 핵심이다. 지난 달 결과를 볼 때는 그 시점에 실제로 갖고 있던 + * 우승만 보여야 한다 — 자르지 않으면 2026-03 화면에 그 뒤에 받은 우승까지 딸려 나와, + * 과거를 볼 때마다 개수가 오늘 기준으로 부풀려진다. + */ +export async function getArtistWins( + supabase: SupabaseClient, + artists: string[], + untilMonth: string, +): Promise> { + const wins = new Map(); + if (artists.length === 0) return wins; + + const { data, error } = await supabase + .from('monthly_artist_rankings') + .select('artist, vote_month, total_votes') + .eq('rank', 1) + .in('artist', artists) + // 조회 중인 달까지 포함한다. 그 달의 1위는 그 달 화면에서 이미 받은 우승이다. + .lte('vote_month', untilMonth) + .order('vote_month', { ascending: true }) + .returns(); + + if (error) throw error; + + for (const row of data ?? []) { + const list = wins.get(row.artist) ?? []; + list.push({ month: row.vote_month, totalVotes: row.total_votes }); + wins.set(row.artist, list); + } + return wins; +} diff --git a/apps/web/src/app/api/artist-vote/_lib/rankVotes.ts b/apps/web/src/app/api/artist-vote/_lib/rankVotes.ts index a364efc4..07776bc8 100644 --- a/apps/web/src/app/api/artist-vote/_lib/rankVotes.ts +++ b/apps/web/src/app/api/artist-vote/_lib/rankVotes.ts @@ -1,3 +1,6 @@ +/** 확정 저장하는 순위 개수. monthly_artist_rankings의 rank check 제약과 같아야 한다. */ +export const TOP_RANK_LIMIT = 30; + export interface VoteRow { user_id: string; artist: string; @@ -14,7 +17,7 @@ export interface ArtistAggregate { } /** - * 아티스트별로 득표를 합산해 상위 10명을 반환한다. + * 아티스트별로 득표를 합산해 상위 30명을 반환한다. * 동점이면 그 달에 먼저 득표를 모으기 시작한 아티스트가 우선, 그래도 같으면 이름순. */ export function rankTopArtists(votes: VoteRow[]): ArtistAggregate[] { @@ -52,5 +55,5 @@ export function rankTopArtists(votes: VoteRow[]): ArtistAggregate[] { } return a.artist.localeCompare(b.artist); }) - .slice(0, 10); + .slice(0, TOP_RANK_LIMIT); } diff --git a/apps/web/src/app/api/artist-vote/_lib/starCount.ts b/apps/web/src/app/api/artist-vote/_lib/starCount.ts deleted file mode 100644 index cb7fb503..00000000 --- a/apps/web/src/app/api/artist-vote/_lib/starCount.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { SupabaseClient } from '@supabase/supabase-js'; - -/** 아티스트가 지금까지 1위(=이달의 아티스트)로 확정된 누적 횟수 */ -export async function getArtistStarCount( - supabase: SupabaseClient, - artist: string, -): Promise { - const { count, error } = await supabase - .from('monthly_artist_rankings') - .select('id', { count: 'exact', head: true }) - .eq('artist', artist) - .eq('rank', 1); - - if (error) throw error; - return count ?? 0; -} diff --git a/apps/web/src/app/api/artist-vote/current-winner/route.ts b/apps/web/src/app/api/artist-vote/current-winner/route.ts index c162c42b..c469259a 100644 --- a/apps/web/src/app/api/artist-vote/current-winner/route.ts +++ b/apps/web/src/app/api/artist-vote/current-winner/route.ts @@ -1,18 +1,17 @@ import { NextResponse } from 'next/server'; -import { getArtistStarCount } from '@/app/api/artist-vote/_lib/starCount'; import createClient from '@/lib/supabase/server'; import { ApiResponse } from '@/types/apiRoute'; import { CurrentArtistOfMonth } from '@/types/artistVote'; -// 곡 카드 배지용 — 가장 최근에 확정된 1위 아티스트 + 누적 선정(★) 횟수만 가볍게 반환한다. +// 곡 카드 배지용 — 가장 최근에 확정된 1위 아티스트와 그 달만 가볍게 반환한다. export async function GET(): Promise>> { try { const supabase = await createClient(); const { data: latest, error: latestError } = await supabase .from('monthly_artist_rankings') - .select('artist') + .select('artist, vote_month') .eq('rank', 1) .order('vote_month', { ascending: false }) .limit(1) @@ -23,11 +22,9 @@ export async function GET(): Promise>> { try { const supabase = await createClient(); const userId = await getAuthenticatedUser(supabase); const currentMonth = getCurrentMonthFirstDayKST(); + // artist는 artists.name FK라 한국어 표기를 그대로 임베딩해 가져온다. + // 투표 목록에서도 곡 카드처럼 원어 표기 아래 한국어 표기를 보여주기 위함이다. const { data, error } = await supabase .from('artist_votes') - .select('artist, amount') + .select('artist, amount, artists(name_ko)') .eq('user_id', userId) .eq('vote_month', currentMonth) - .order('amount', { ascending: false }); + .order('amount', { ascending: false }) + .returns(); if (error) throw error; - return NextResponse.json({ success: true, data: data ?? [] }); + const votes: MyArtistVote[] = (data ?? []).map(row => ({ + artist: row.artist, + amount: row.amount, + artistKo: row.artists?.name_ko ?? null, + })); + + return NextResponse.json({ success: true, data: votes }); } catch (error) { if (error instanceof Error && error.cause === 'auth') { return NextResponse.json( diff --git a/apps/web/src/app/api/artist-vote/rankings/route.ts b/apps/web/src/app/api/artist-vote/rankings/route.ts index dfe2b6a2..c870fcd9 100644 --- a/apps/web/src/app/api/artist-vote/rankings/route.ts +++ b/apps/web/src/app/api/artist-vote/rankings/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getArtistStarCount } from '@/app/api/artist-vote/_lib/starCount'; +import { getArtistWins } from '@/app/api/artist-vote/_lib/artistWins'; import createClient from '@/lib/supabase/server'; import { ApiResponse } from '@/types/apiRoute'; import { ArtistRankingResponse } from '@/types/artistVote'; @@ -13,6 +13,7 @@ interface RankingRow { total_votes: number; top_voter_amount: number | null; users: { nickname: string } | null; + artists: { name_ko: string | null; image_url: string | null } | null; } export async function GET( @@ -33,23 +34,25 @@ export async function GET( const availableMonths = [...new Set((monthRows ?? []).map(row => row.vote_month as string))]; - if (availableMonths.length === 0) { + // 조회 월의 주인은 클라이언트다. 아직 확정되지 않은 월을 요청해도 그 월을 그대로 돌려주고 + // 결과만 비워 보낸다. 여기서 임의로 다른 월로 바꾸면 월 선택기 값과 화면 데이터가 어긋난다. + const monthParam = searchParams.get('month'); + const targetMonth = + monthParam && MONTH_PATTERN.test(monthParam) ? monthParam : (availableMonths[0] ?? ''); + + if (!availableMonths.includes(targetMonth)) { return NextResponse.json({ success: true, - data: { month: '', availableMonths: [], items: [] }, + data: { month: targetMonth, availableMonths, items: [] }, }); } - const monthParam = searchParams.get('month'); - const targetMonth = - monthParam && MONTH_PATTERN.test(monthParam) && availableMonths.includes(monthParam) - ? monthParam - : availableMonths[0]; - - // 2) 해당 월 1~10위 조회 + // 2) 해당 월 1~30위 조회 const { data, error } = await supabase .from('monthly_artist_rankings') - .select('rank, artist, total_votes, top_voter_amount, users:top_voter_user_id(nickname)') + .select( + 'rank, artist, total_votes, top_voter_amount, users:top_voter_user_id(nickname), artists(name_ko, image_url)', + ) .eq('vote_month', targetMonth) .order('rank', { ascending: true }) .returns(); @@ -57,18 +60,22 @@ export async function GET( if (error) throw error; const rows = data ?? []; - const winnerArtist = rows.find(row => row.rank === 1)?.artist; - // 3) 1위 아티스트의 누적 선정(★) 횟수 조회 - const starCount = winnerArtist ? await getArtistStarCount(supabase, winnerArtist) : 0; + // 3) 시상대(1~3위)의 1위 이력 조회 — 별(또는 트로피)을 그 개수만큼 새긴다. + // 그 달 1위가 아니어도 과거에 뽑힌 적이 있으면 표식이 남는다. + // 조회 중인 달까지로 자르므로, 지난 달을 보면 그 시점의 개수가 그대로 나온다. + const podiumArtists = rows.filter(row => row.rank <= 3).map(row => row.artist); + const winsByArtist = await getArtistWins(supabase, podiumArtists, targetMonth); const items = rows.map(row => ({ rank: row.rank, artist: row.artist, + artistKo: row.artists?.name_ko ?? null, + artistImage: row.artists?.image_url ?? null, totalVotes: row.total_votes, topVoterNickname: row.users?.nickname ?? null, topVoterAmount: row.top_voter_amount, - starCount: row.rank === 1 ? starCount : 0, + wins: winsByArtist.get(row.artist) ?? [], })); return NextResponse.json({ diff --git a/apps/web/src/app/popular/ArtistName.tsx b/apps/web/src/app/popular/ArtistName.tsx new file mode 100644 index 00000000..6a919282 --- /dev/null +++ b/apps/web/src/app/popular/ArtistName.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { cn } from '@/utils/cn'; + +interface ArtistNameProps { + name: string; + artistKo: string | null; + /** 바깥 래퍼(원어 표기 줄)에 얹을 스타일 */ + className?: string; +} + +/** + * 아티스트 원어 표기 + 그 아래 작은 한국어 표기. + * + * 곡 카드(SongSummary)가 제목·아티스트의 한국어 표기를 다루는 규칙을 그대로 따른다. + * 한국어 표기가 원어 표기와 같으면(예: name과 name_ko가 모두 'IVE') 같은 글자가 두 번 + * 보일 뿐이라 감춘다. 버튼 안에서도 쓰이므로 래퍼는 div가 아닌 span이다. + */ +export default function ArtistName({ name, artistKo, className }: ArtistNameProps) { + const hasKoName = !!artistKo && artistKo !== name; + + return ( + + {name} + {hasKoName && ( + {artistKo} + )} + + ); +} diff --git a/apps/web/src/app/popular/ArtistOfMonthTab.tsx b/apps/web/src/app/popular/ArtistOfMonthTab.tsx index e8c6c9da..9139493b 100644 --- a/apps/web/src/app/popular/ArtistOfMonthTab.tsx +++ b/apps/web/src/app/popular/ArtistOfMonthTab.tsx @@ -1,19 +1,19 @@ 'use client'; import { addMonths, format, parseISO, startOfMonth } from 'date-fns'; -import { Construction } from 'lucide-react'; +import { ChartPie, Construction, List } from 'lucide-react'; import { useState } from 'react'; import StaticLoading from '@/components/StaticLoading'; import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent } from '@/components/ui/dialog'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useArtistRankingsQuery } from '@/queries/artistVoteQuery'; import { cn } from '@/utils/cn'; +import { getCurrentMonthFirstDayKST, getPrevMonthFirstDayKST } from '@/utils/kst'; import ArtistRankingBoard from './ArtistRankingBoard'; -import ArtistVoteModal from './ArtistVoteModal'; -import ArtistVotersDialog from './ArtistVotersDialog'; +import ArtistRankingChart from './ArtistRankingChart'; +import ArtistVotePanel from './ArtistVotePanel'; import MonthSelector from './MonthSelector'; const MONTH_FORMAT = 'yyyy-MM-dd'; @@ -21,75 +21,96 @@ const shiftMonth = (month: string, delta: number) => format(startOfMonth(addMonths(parseISO(month), delta)), MONTH_FORMAT); export default function ArtistOfMonthTab() { - const [monthOverride, setMonthOverride] = useState(undefined); - const [isVoteOpen, setIsVoteOpen] = useState(false); - const [votersArtist, setVotersArtist] = useState(null); + // 순위는 월말에 확정되므로 인기곡 차트와 똑같이 전월을 기본으로 본다. + const [month, setMonth] = useState(getPrevMonthFirstDayKST); + // 같은 순위 데이터를 순위표로 볼지, 득표 비중 파이로 볼지 고르는 스위치 + const [view, setView] = useState<'list' | 'chart'>('list'); - const { data, isPending, isPlaceholderData } = useArtistRankingsQuery(monthOverride); + const currentMonth = getCurrentMonthFirstDayKST(); + // 이번 달은 아직 집계 전이라 순위 대신 내 투표를 편집하는 화면을 보여준다. + const isVotingMonth = month === currentMonth; + + const { data, isPending, isPlaceholderData } = useArtistRankingsQuery(month); if (isPending) { return ; } const availableMonths = data?.availableMonths ?? []; - const month = data?.month ?? ''; const items = data?.items ?? []; - const voteButton = ; - const voteModal = ( - - - setIsVoteOpen(false)} /> - - - ); - - if (availableMonths.length === 0) { - return ( -
-
{voteButton}
-
- -

아직 확정된 결과가 없어요

-
- {voteModal} -
- ); - } + // 확정 월 + 이번 달(투표) + 현재 조회 월을 합쳐 최신순으로 둔다. + // 아직 확정되지 않은 월을 보고 있어도 셀렉트 값이 비지 않게 하기 위함이다. + const selectableMonths = [...new Set([...availableMonths, currentMonth, month])].sort().reverse(); - // availableMonths는 최신순이므로 마지막 원소가 가장 오래된 월이다. - const oldestMonth = availableMonths.at(-1); + const oldestMonth = selectableMonths.at(-1); const canGoPrev = !!oldestMonth && month > oldestMonth; - const canGoNext = month < availableMonths[0]; + const canGoNext = month < currentMonth; return (
setMonthOverride(shiftMonth(month, -1))} - onNext={() => setMonthOverride(shiftMonth(month, 1))} - onChange={setMonthOverride} + onPrev={() => setMonth(shiftMonth(month, -1))} + onNext={() => setMonth(shiftMonth(month, 1))} + onChange={setMonth} /> -
{voteButton}
- - -
- -
-
- - {voteModal} - - {votersArtist && ( - setVotersArtist(null)} - /> + {isVotingMonth ? ( + + ) : ( + <> + {items.length > 0 && ( +
+ {/* 지난 달 결과를 보다가 바로 이번 달 투표로 넘어갈 수 있게 둔다. + 월 선택기의 다음 달 버튼으로도 갈 수 있지만 그건 눈에 잘 띄지 않는다. */} + + +
+ + +
+
+ )} + + +
+ {items.length === 0 ? ( +
+ +

아직 확정된 결과가 없어요

+
+ ) : view === 'chart' ? ( + + ) : ( + + )} +
+
+ )}
); diff --git a/apps/web/src/app/popular/ArtistPortrait.tsx b/apps/web/src/app/popular/ArtistPortrait.tsx new file mode 100644 index 00000000..85973c51 --- /dev/null +++ b/apps/web/src/app/popular/ArtistPortrait.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { cn } from '@/utils/cn'; + +/** + * 액자 톤. 1위만 금이고 2·3위는 황동 하나를 똑같이 나눠 쓴다. + * + * 금과 같은 난색 계열을 쓰되 밝기가 아니라 **채도**로 갈라놓은 게 핵심이다. + * 금보다 어둡기만 한 색(구리·청동)을 옆에 놓으면 다른 금속이 아니라 '바랜 금'으로 읽힌다. + * 채도를 떨어뜨리면 같은 계열 안에서도 금은 보석, 황동은 기물로 분리돼 보인다. + * + * 2·3위가 완전히 같은 톤이라 둘 사이의 위계는 색이 아니라 순위 라벨과 카드 순서만 진다. + * 은(회색)을 쓰지 않는 이유는 따로 있다 — 회색은 UI에서 이미 '비활성'을 뜻해서 + * 2위가 대접받는 게 아니라 꺼져 있는 것처럼 보인다. + */ +export type PortraitTone = 'gold' | 'brass' | 'plain'; + +/** + * 액자 몰딩의 결. 한 방향 그라데이션에 밝은 면과 어두운 면을 번갈아 넣어야 + * 납작한 색 띠가 아니라 빛을 받는 금속·나무 테로 보인다. + */ +const FRAME_SURFACE: Record = { + gold: 'linear-gradient(135deg, #f9e6a4 0%, #dcae3e 30%, #a8801f 55%, #f4dd93 78%, #c99b2c 100%)', + // 금과 같은 각도·같은 명암 구조에 채도만 덜어낸 결. 값을 만질 땐 금 쪽과 나란히 놓고 봐야 한다. + brass: 'linear-gradient(135deg, #e6d9bb 0%, #c0a97c 30%, #8a7550 55%, #dccfae 78%, #a38c64 100%)', + plain: 'linear-gradient(135deg, #c29260 0%, #8a6440 30%, #6b4c30 55%, #a67c52 100%)', +}; + +/** 사진이 없을 때 그림 자리에 깔리는 색 */ +const CANVAS_FALLBACK: Record = { + gold: 'bg-amber-500/15 text-amber-700 dark:text-amber-400', + brass: 'bg-[#a38c64]/15 text-[#8a7450] dark:text-[#d3bf95]', + plain: 'bg-muted text-muted-foreground', +}; + +/** + * 몰딩 위를 지나가는 하이라이트 띠. 금속 결 위에 이 한 겹을 더 깔고 위치만 흘린다. + * 각도(120deg)와 띠 폭은 React Bits의 ShinyText가 쓰는 값을 그대로 가져왔다. + */ +const FRAME_SHEEN = + 'linear-gradient(120deg, transparent 38%, rgb(255 255 255 / 0.75) 50%, transparent 62%)'; + +// 몰딩의 입체감: 바깥으로 드리우는 그림자 + 위쪽 하이라이트 + 아래쪽 음영. +const FRAME_SHADOW = + '0 3px 8px rgb(0 0 0 / 0.28), inset 0 1px 0 rgb(255 255 255 / 0.6), inset 0 -1px 0 rgb(0 0 0 / 0.35)'; +// 그림이 매트보다 안쪽으로 들어가 보이게 하는 그림자 +const CANVAS_SHADOW = 'inset 0 2px 6px rgb(0 0 0 / 0.35)'; + +interface ArtistPortraitProps { + name: string; + imageUrl: string | null; + /** 액자 바깥 한 변의 픽셀 크기 */ + size: number; + tone?: PortraitTone; + className?: string; +} + +/** + * 아티스트 사진 액자. 몰딩 → 매트(여백) → 그림 순으로 세 겹을 쌓아 실제 액자처럼 보이게 한다. + * 안쪽 여백을 %로 잡아 size를 바꿔도 세 겹의 비율이 그대로 유지된다. + * + * artists.image_url은 이제 막 생긴 컬럼이라 당분간 대부분 비어 있다. + * 빈 액자를 그리면 화면이 무너져 보여, 사진이 채워지기 전까지는 이니셜이 그 자리를 지킨다. + */ +export default function ArtistPortrait({ + name, + imageUrl, + size, + tone = 'plain', + className, +}: ArtistPortraitProps) { + return ( +
+ {/* 매트 — 몰딩과 그림 사이의 여백. 몰딩과 함께 얇아져야 테 전체가 얇아 보인다. + 몰딩만 줄이면 흰 여백이 남아 테두리 두께가 그대로인 것처럼 읽힌다. */} +
+
+ {imageUrl ? ( + // next/image가 아닌 이유: image_url에 어떤 도메인이 들어올지 정해지지 않았는데 + // next.config.ts에 images.remotePatterns가 없어 외부 URL이면 런타임에 깨진다. + // eslint-disable-next-line @next/next/no-img-element + {name} + ) : ( +
+ {name.slice(0, 1)} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/popular/ArtistRankingBoard.tsx b/apps/web/src/app/popular/ArtistRankingBoard.tsx index d7d0af71..ed65492a 100644 --- a/apps/web/src/app/popular/ArtistRankingBoard.tsx +++ b/apps/web/src/app/popular/ArtistRankingBoard.tsx @@ -2,78 +2,162 @@ import { ArtistRankingItem } from '@/types/artistVote'; import { cn } from '@/utils/cn'; +import { formatVoteShare } from '@/utils/formatVoteShare'; -const PODIUM_STYLE: Record = { - 1: 'border-amber-500 bg-amber-500/10', - 2: 'border-gray-300 bg-gray-300/10', - 3: 'border-amber-700 bg-amber-700/10', -}; +import ArtistName from './ArtistName'; +import ArtistPortrait, { PortraitTone } from './ArtistPortrait'; +import WinStars from './WinStars'; -const PODIUM_MEDAL: Record = { - 1: '🥇', - 2: '🥈', - 3: '🥉', -}; +/** + * 1~3위 액자 카드의 톤·크기. 1위만 한 단계 크게 둬서 위계를 남긴다. + * 2·3위는 은·동 대신 황동 하나를 그대로 나눠 쓴다(이유는 PortraitTone 주석 참고). + * 둘의 카드가 완전히 같으므로, 크기 차이를 1위와의 사이에만 두는 지금 배치를 유지해야 + * 2위와 3위가 순위 라벨과 순서로만 구분된다. + */ +const MEDAL = { + 1: { tone: 'gold', size: 96, card: 'border-amber-500 bg-amber-500/10' }, + 2: { tone: 'brass', size: 80, card: 'border-[#a38c64] bg-[#a38c64]/10' }, + 3: { tone: 'brass', size: 80, card: 'border-[#a38c64] bg-[#a38c64]/10' }, +} satisfies Record; + +/** + * 액자가 차지하는 자리의 폭. 액자 자체는 순위마다 작아져도 이 칸은 1위 크기로 고정한다. + * 칸이 액자를 따라 줄면 오른쪽 본문이 카드마다 다른 지점에서 시작해, 세 카드를 세로로 + * 쌓았을 때 이름 줄이 계단처럼 어긋난다. 1위 크기를 그대로 참조해 둘이 벌어지지 않게 한다. + */ +const PORTRAIT_SLOT = MEDAL[1].size; + +/** + * 4위 아래 막대의 농도. 같은 색 하나를 옅게 단계지어 구간을 구분한다. + * 서로 다른 색을 쓰면 순위가 아니라 종류가 다른 것처럼 읽힌다. + */ +const BAR_TONES = [ + { from: 4, to: 10, bar: 'bg-foreground/70' }, + { from: 11, to: 20, bar: 'bg-foreground/45' }, + { from: 21, to: 30, bar: 'bg-foreground/25' }, +]; + +const getBarTone = (rank: number) => + BAR_TONES.find(tone => rank >= tone.from && rank <= tone.to)?.bar ?? 'bg-foreground/25'; + +interface MedalCardProps { + item: ArtistRankingItem; + total: number; +} + +/** 1~3위 — 왼쪽 액자, 오른쪽 정보 */ +function MedalCard({ item, total }: MedalCardProps) { + const medal = MEDAL[item.rank as 1 | 2 | 3]; -function PodiumCard({ item, onClick }: { item: ArtistRankingItem; onClick: () => void }) { return ( - +
+ + ); +} + +interface BarRowProps { + item: ArtistRankingItem; + total: number; + /** 1위 득표. 막대 길이의 기준이라 전 구간이 같은 자로 재진다. */ + maxVotes: number; +} + +/** 4~30위 — 액자 없이 한 줄. 막대 길이는 득표량, 농도는 순위 구간을 뜻한다. */ +function BarRow({ item, total, maxVotes }: BarRowProps) { + const width = maxVotes > 0 ? (item.totalVotes / maxVotes) * 100 : 0; + + return ( +
+ + {item.rank} + + + + + + {/* 득표가 아주 적어도 막대가 사라지지 않도록 최소 폭을 남긴다 */} + + + + + + {item.totalVotes}P + {formatVoteShare(item.totalVotes, total)} + +
); } interface ArtistRankingBoardProps { items: ArtistRankingItem[]; - onSelectArtist: (artist: string) => void; } -export default function ArtistRankingBoard({ items, onSelectArtist }: ArtistRankingBoardProps) { - const top3 = items.filter(item => item.rank <= 3); - const rest = items.filter(item => item.rank > 3); +export default function ArtistRankingBoard({ items }: ArtistRankingBoardProps) { + const total = items.reduce((sum, item) => sum + item.totalVotes, 0); + const maxVotes = Math.max(...items.map(item => item.totalVotes), 1); + + const medalists = items.filter(item => item.rank <= 3); + const rest = items.filter(item => item.rank >= 4); return (
-
- {top3.map(item => ( - onSelectArtist(item.artist)} /> - ))} -
+ {medalists.map(item => ( + + ))} -
- {rest.map(item => ( - - ))} -
+ {rest.length > 0 && ( +
+ {rest.map(item => ( + + ))} +
+ )}
); } diff --git a/apps/web/src/app/popular/ArtistRankingChart.tsx b/apps/web/src/app/popular/ArtistRankingChart.tsx new file mode 100644 index 00000000..63a0b9de --- /dev/null +++ b/apps/web/src/app/popular/ArtistRankingChart.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; + +import { ArtistRankingItem } from '@/types/artistVote'; +import { formatVoteShare } from '@/utils/formatVoteShare'; + +// 1~10위를 각자 조각으로 두고, 11위 아래만 한 덩어리로 접는다. +// 조각이 열 개면 파이가 원래 잘하는 "한눈에 비중" 읽기는 포기하는 셈이라, +// 아래 범례가 순위·이름·득표를 다 적어 표 역할을 겸한다. 색은 조각을 세는 수단일 뿐이다. +const TOP_SLICE_COUNT = 10; + +// 검증을 통과한 카테고리 팔레트(globals.css). 순위가 아니라 슬롯 순서대로 배정한다. +const HUES = [ + 'var(--chart-1)', + 'var(--chart-2)', + 'var(--chart-3)', + 'var(--chart-4)', + 'var(--chart-5)', + 'var(--chart-6)', + 'var(--chart-7)', + 'var(--chart-8)', +]; + +/** + * 아홉·열 번째 조각. 카테고리 색은 여덟 칸이 한계라 새 색을 만들지 않는다 — + * 아홉 번째로 지어낸 색은 반드시 앞의 여덟 중 하나와 붙어 구분이 무너진다. + * 대신 앞선 색을 빗금으로 다시 써서, 구분을 색이 아니라 색+질감이 지게 한다. + * 각도는 45°와 그 거울인 135° 둘뿐이다. 각도를 더 쪼개면 그것도 결국 색과 같은 문제를 만난다. + */ +const HATCHES = [ + { id: 'artist-slice-hatch-a', hue: 'var(--chart-1)', angle: 45 }, + { id: 'artist-slice-hatch-b', hue: 'var(--chart-2)', angle: 135 }, +]; + +/** 빗금 위에 얹는 그늘. 같은 색의 어두운 결로 보이도록 검정을 옅게 깐다. */ +const HATCH_INK = 'rgb(0 0 0 / 0.38)'; + +// "그 외"는 카테고리가 아니라 나머지를 뭉친 덩어리라 색을 빼고 회색으로 둔다. +const REST_COLOR = 'var(--muted-foreground)'; + +interface Slice { + /** 조각 식별자. 이름이 겹쳐도 되도록 순위를 섞어 만든다. */ + key: string; + /** 툴팁·범례에 뜨는 이름 */ + name: string; + /** 범례 앞에 붙는 순위. "그 외" 묶음은 순위가 없다. */ + rank: number | null; + value: number; + /** 파이 조각의 fill (색 또는 빗금 패턴) */ + fill: string; + /** 범례 칩의 CSS background. 파이 조각과 같은 모양이어야 한다. */ + chip: string; +} + +/** 순위(0부터)에 맞는 칠. 여덟 칸을 넘어가면 빗금으로 넘어간다. */ +function getFill(index: number): Pick { + if (index < HUES.length) { + return { fill: HUES[index], chip: HUES[index] }; + } + + const hatch = HATCHES[index - HUES.length]; + return { + fill: `url(#${hatch.id})`, + // 파이는 SVG 패턴, 범례는 CSS 그라데이션이라 방식이 다르다. 결과가 같아야 해서 + // 줄 간격(3px/6px)과 그늘 농도를 양쪽에 같은 값으로 적어 둔다. + chip: `repeating-linear-gradient(${hatch.angle}deg, ${HATCH_INK} 0 3px, transparent 3px 6px), ${hatch.hue}`, + }; +} + +/** + * 빗금 패턴 정의. + * recharts가 차트 안에 끼워 넣은 자식을 그대로 통과시키는지에 기대지 않으려고 별도 svg에 둔다. + * 패턴은 id로 참조되므로 문서 어디에 있든 걸린다. + */ +function HatchDefs() { + return ( + + + {HATCHES.map(hatch => ( + + {/* fill 속성 대신 style인 이유: 프로젝트 색이 CSS 변수라 값이 확실히 + 풀리는 자리에 둔다. 표현 속성에서의 var()는 브라우저별로 이력이 있다. */} + + + + ))} + + + ); +} + +interface SliceLegendProps { + slices: Slice[]; + total: number; +} + +/** + * 조각이 열한 개라 원 안에 이름을 넣을 자리가 없다. 색 단독으로 구분하지 않도록 + * 범례가 순위·이름·수치를 다 적어 표 역할까지 겸한다. + * 조각은 12시부터 순위 순으로 도니, 범례 순서가 곧 조각 순서다. + */ +function SliceLegend({ slices, total }: SliceLegendProps) { + return ( +
    + {slices.map(slice => ( +
  • + + {/* "그 외" 줄도 자리는 비워 둔다 — 순위 칸이 사라지면 이름 줄이 혼자 밀린다. */} + + {slice.rank ?? ''} + + {slice.name} + + {slice.value}P · {formatVoteShare(slice.value, total)} + +
  • + ))} +
+ ); +} + +interface ArtistRankingChartProps { + items: ArtistRankingItem[]; +} + +export default function ArtistRankingChart({ items }: ArtistRankingChartProps) { + const total = items.reduce((sum, item) => sum + item.totalVotes, 0); + + if (items.length === 0 || total === 0) { + return

표시할 득표가 없어요.

; + } + + const top = items.slice(0, TOP_SLICE_COUNT); + const rest = items.slice(TOP_SLICE_COUNT); + const restTotal = rest.reduce((sum, item) => sum + item.totalVotes, 0); + + const slices: Slice[] = [ + ...top.map((item, index) => ({ + key: `${item.rank}-${item.artist}`, + name: item.artistKo && item.artistKo !== item.artist ? item.artistKo : item.artist, + rank: item.rank, + value: item.totalVotes, + ...getFill(index), + })), + ...(restTotal > 0 + ? [ + { + key: 'rest', + name: `그 외 ${rest.length}명`, + rank: null, + value: restTotal, + fill: REST_COLOR, + chip: REST_COLOR, + }, + ] + : []), + ]; + + return ( +
+ + +
+ + + + {slices.map(slice => ( + + ))} + + { + const amount = Number(value); + return [`${amount}P (${formatVoteShare(amount, total)})`, String(name)]; + }} + contentStyle={{ + background: 'var(--popover)', + border: '1px solid var(--border)', + borderRadius: 8, + fontSize: 12, + color: 'var(--popover-foreground)', + }} + itemStyle={{ color: 'var(--popover-foreground)' }} + /> + + +
+ + +
+ ); +} diff --git a/apps/web/src/app/popular/ArtistVoteModal.tsx b/apps/web/src/app/popular/ArtistVoteModal.tsx deleted file mode 100644 index d6d99700..00000000 --- a/apps/web/src/app/popular/ArtistVoteModal.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import SearchAutocomplete from '@/app/search/SearchAutocomplete'; -import { Button } from '@/components/ui/button'; -import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { useMyArtistVotesQuery, usePutArtistVoteMutation } from '@/queries/artistVoteQuery'; -import { useArtistSearchQuery } from '@/queries/artistsQuery'; -import { useUserQuery } from '@/queries/userQuery'; - -import ArtistVoteRow from './ArtistVoteRow'; - -const STEP = 10; - -interface ArtistVoteModalProps { - handleClose: () => void; -} - -export default function ArtistVoteModal({ handleClose }: ArtistVoteModalProps) { - const [query, setQuery] = useState(''); - const [isFocusAuto, setIsFocusAuto] = useState(false); - - const { data: user } = useUserQuery(); - const point = user?.point ?? 0; - const isAuthenticated = !!user; - - const { data: myVotes = [] } = useMyArtistVotesQuery(isAuthenticated); - const { mutate: putVote, isPending } = usePutArtistVoteMutation(); - - const { data: searchResults = [] } = useArtistSearchQuery(query); - const autoCompleteList = searchResults.map(artist => ({ - label: artist.name_ko ? `${artist.name} (${artist.name_ko})` : artist.name, - value: artist.name, - })); - - const handleSelectArtist = (artist: string) => { - setQuery(''); - setIsFocusAuto(false); - - // 검색은 새 아티스트를 추가할 때만 쓴다. 이미 투표한 아티스트는 아래 목록에서 조정한다. - if (myVotes.some(vote => vote.artist === artist)) return; - if (point < STEP) return; - - putVote({ artist, amount: STEP }); - }; - - const handleAdjust = (artist: string, currentAmount: number, delta: number) => { - if (delta > 0 && point < delta) return; - putVote({ artist, amount: Math.max(0, currentAmount + delta) }); - }; - - return ( -
- - 아티스트 투표 - - 보유 포인트로 이달의 아티스트에게 투표하세요. 월말까지 자유롭게 조정할 수 있어요. - - - -
- 보유 포인트 {point}P -
- -
- setQuery(e.target.value)} - onFocus={() => setIsFocusAuto(true)} - onBlur={() => setIsFocusAuto(false)} - /> - {isFocusAuto && ( - - )} -
- -
- 이번 달 내 투표 - - {myVotes.length === 0 ? ( -

아직 투표한 아티스트가 없어요.

- ) : ( - myVotes.map(vote => ( - = STEP} - disabled={isPending} - onAdjust={delta => handleAdjust(vote.artist, vote.amount, delta)} - onDelete={() => putVote({ artist: vote.artist, amount: 0 })} - /> - )) - )} -
- - -
- ); -} diff --git a/apps/web/src/app/popular/ArtistVotePanel.tsx b/apps/web/src/app/popular/ArtistVotePanel.tsx new file mode 100644 index 00000000..95c1972f --- /dev/null +++ b/apps/web/src/app/popular/ArtistVotePanel.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { useState } from 'react'; + +import SearchAutocomplete from '@/app/search/SearchAutocomplete'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useMyArtistVotesQuery, useSaveArtistVotesMutation } from '@/queries/artistVoteQuery'; +import { useArtistSearchQuery } from '@/queries/artistsQuery'; +import { useUserQuery } from '@/queries/userQuery'; +import useModalStore from '@/stores/useModalStore'; +import { ArtistSearchResult } from '@/types/artist'; + +import ArtistName from './ArtistName'; +import ArtistVoteRow from './ArtistVoteRow'; + +const STEP = 10; + +/** + * 이번 달(아직 확정되지 않은 달) 화면. 랭킹 대신 내가 이번 달에 투표한 아티스트를 편집한다. + * 검색으로 고른 아티스트는 0P로 목록에 담기기만 하고, 값을 고친 뒤 엔터(또는 저장 버튼)로 + * 확인 창을 거쳐야 실제 요청이 나간다. + */ +export default function ArtistVotePanel() { + const [query, setQuery] = useState(''); + const [isFocusAuto, setIsFocusAuto] = useState(false); + // 저장 전 편집값. 키는 아티스트명, 값은 화면에 보이는 투표 포인트. + const [draft, setDraft] = useState>({}); + // 검색으로 담았지만 아직 저장하지 않아 서버 목록에는 없는 아티스트. + // 저장 전에도 한국어 표기를 보여줘야 해서 이름만이 아니라 검색 결과를 통째로 들고 있는다. + const [addedArtists, setAddedArtists] = useState([]); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + + const openMessage = useModalStore(state => state.openMessage); + + const { data: user } = useUserQuery(); + const point = user?.point ?? 0; + const isAuthenticated = !!user; + + const { data: myVotes = [] } = useMyArtistVotesQuery(isAuthenticated); + const { mutate: saveVotes, isPending } = useSaveArtistVotesMutation(); + const { data: searchResults = [], isFetching: isSearching } = useArtistSearchQuery(query); + + const autoCompleteList = searchResults.map(artist => ({ + // 한국어 표기가 원어 표기와 같으면(예: 'IVE') 같은 글자를 두 번 보여줄 뿐이라 생략한다. + label: + artist.name_ko && artist.name_ko !== artist.name + ? `${artist.name} (${artist.name_ko})` + : artist.name, + value: artist.name, + })); + // 자동완성은 결과가 없으면 아무것도 그리지 않아 검색이 동작하는지조차 알기 어렵다. + // 검색어를 넣었는데 후보가 없으면 그 사실을 그대로 알려준다. + const isSearchEmpty = query.trim().length > 0 && !isSearching && autoCompleteList.length === 0; + + const savedAmounts = new Map(myVotes.map(vote => [vote.artist, vote.amount])); + const rows = [ + ...myVotes.map(vote => ({ + artist: vote.artist, + artistKo: vote.artistKo, + savedAmount: vote.amount, + })), + ...addedArtists + .filter(artist => !savedAmounts.has(artist.name)) + .map(artist => ({ artist: artist.name, artistKo: artist.name_ko, savedAmount: 0 })), + ].map(row => ({ ...row, amount: draft[row.artist] ?? row.savedAmount })); + + const changedRows = rows.filter(row => row.amount !== row.savedAmount); + // 늘린 만큼 차감되고 줄인 만큼 환불되므로, 차액 합계가 이번 저장에서 실제로 쓰는 포인트다. + const spending = changedRows.reduce((sum, row) => sum + (row.amount - row.savedAmount), 0); + const remainingPoint = point - spending; + const isOverPoint = remainingPoint < 0; + const canSave = !isPending && changedRows.length > 0 && !isOverPoint; + + // 자동완성 항목은 onMouseDown을 막아 입력창 포커스를 유지시킨다. 그래서 아티스트를 담은 뒤 + // 다시 타이핑해도 onFocus가 재발생하지 않아, 여기서 직접 열어주지 않으면 목록이 닫힌 채로 남는다. + const handleChangeQuery = (value: string) => { + setQuery(value); + setIsFocusAuto(true); + }; + + const handleSelectArtist = (name: string) => { + setQuery(''); + setIsFocusAuto(false); + + if (savedAmounts.has(name) || addedArtists.some(artist => artist.name === name)) return; + + const selected = searchResults.find(artist => artist.name === name); + setAddedArtists(prev => [...prev, selected ?? { name, name_ko: null }]); + }; + + const handleChange = (artist: string, amount: number) => { + setDraft(prev => ({ ...prev, [artist]: Math.max(0, amount) })); + }; + + const handleDelete = (artist: string) => { + // 이미 저장된 투표는 0P로 두고 저장할 때 환불받는다. 담기만 한 아티스트는 목록에서 뺀다. + if (savedAmounts.has(artist)) { + handleChange(artist, 0); + return; + } + setAddedArtists(prev => prev.filter(item => item.name !== artist)); + setDraft(prev => { + const next = { ...prev }; + delete next[artist]; + return next; + }); + }; + + const handleSubmit = () => { + if (!canSave) return; + setIsConfirmOpen(true); + }; + + const handleConfirm = () => { + setIsConfirmOpen(false); + saveVotes( + changedRows.map(({ artist, amount }) => ({ artist, amount })), + { + onSuccess: () => { + setDraft({}); + setAddedArtists([]); + }, + onError: error => { + openMessage({ title: '투표 실패', message: error.message, variant: 'error' }); + }, + }, + ); + }; + + if (!isAuthenticated) { + return ( +
+

로그인하고 투표해보세요

+

+ 보유 포인트로 이달의 아티스트를 뽑을 수 있어요. +

+
+ ); + } + + return ( +
+
+ 보유 포인트 {point}P + {changedRows.length > 0 && ( + + {' '} + → 저장 후 {remainingPoint}P + + )} +
+ +
+ handleChangeQuery(event.target.value)} + onFocus={() => setIsFocusAuto(true)} + onBlur={() => setIsFocusAuto(false)} + /> + {isFocusAuto && ( + + )} +
+ + {isSearchEmpty && ( +

+ ‘{query.trim()}’ 검색 결과가 없어요. +

+ )} + + +
+ {rows.length === 0 ? ( +

+ 검색해서 투표할 아티스트를 담아보세요. +

+ ) : ( + rows.map(row => ( + handleChange(row.artist, amount)} + onSubmit={handleSubmit} + onDelete={() => handleDelete(row.artist)} + /> + )) + )} +
+
+ +
+ {isOverPoint && ( +

+ 포인트가 {-remainingPoint}P 부족해요. 투표값을 줄여주세요. +

+ )} + +
+ + + + + 투표를 반영할까요? + + 저장하면 차액만큼 포인트가 차감되거나 환불돼요. 이번 달에는 언제든 다시 조정할 수 + 있어요. + + + +
+ {changedRows.map(row => ( +
+ + + {row.savedAmount}P → {row.amount}P + +
+ ))} +
+ +
+ {spending >= 0 ? '차감 포인트' : '환불 포인트'} + {Math.abs(spending)}P +
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/src/app/popular/ArtistVoteRow.tsx b/apps/web/src/app/popular/ArtistVoteRow.tsx index e02477d4..a37850a3 100644 --- a/apps/web/src/app/popular/ArtistVoteRow.tsx +++ b/apps/web/src/app/popular/ArtistVoteRow.tsx @@ -3,51 +3,91 @@ import { Minus, Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/utils/cn'; + +import ArtistName from './ArtistName'; interface ArtistVoteRowProps { artist: string; + artistKo: string | null; + /** 화면에 보이는(=편집 중인) 투표 포인트 */ amount: number; + /** 서버에 저장돼 있는 투표 포인트. 다르면 변경 표시를 띄운다. */ + savedAmount: number; step: number; - canIncrease: boolean; disabled: boolean; - onAdjust: (delta: number) => void; + onChange: (amount: number) => void; + /** 입력창에서 엔터를 눌렀을 때 (저장 확인) */ + onSubmit: () => void; onDelete: () => void; } export default function ArtistVoteRow({ artist, + artistKo, amount, + savedAmount, step, - canIncrease, disabled, - onAdjust, + onChange, + onSubmit, onDelete, }: ArtistVoteRowProps) { + const isChanged = amount !== savedAmount; + return ( -
- {artist} +
+
+ + {isChanged && ( + + {savedAmount}P → {amount}P (저장 전) + + )} +
+
- {amount}P + + {/* 숫자를 직접 고칠 수 있어야 해서 입력창으로 둔다. 엔터가 곧 저장 확인이다. */} + onChange(Number(event.target.value.replace(/\D/g, '') || 0))} + onKeyDown={event => { + if (event.key === 'Enter') onSubmit(); + }} + /> + + + ); +} + +interface WinStarsProps { + artist: string; + wins: ArtistWin[]; +} + +/** + * 역대 우승을 카드 위쪽에 별로 새긴다. 프로 스포츠가 엠블럼 위에 우승 별을 다는 방식이다. + * + * 별 한 줄은 "몇 번"만 남기고 "언제"는 감춘다 — 그건 별을 눌러 다이얼로그로 본다. + * 카드 한 장에 세로 공간을 거의 쓰지 않으면서 통산 우승을 한눈에 보여주는 게 목적이라, + * 우승마다 년·월을 지면에 적는 표현으로 되돌리면 이 장점이 사라진다. + */ +export default function WinStars({ artist, wins }: WinStarsProps) { + const [selected, setSelected] = useState(null); + + if (wins.length === 0) return null; + + // 오래된 순으로 들어오므로 뒤에서 자른다 — 접히는 건 언제나 옛 기록이고, 최근 우승은 남는다. + const shown = wins.slice(-STAR_LIMIT); + const folded = wins.length - shown.length; + + return ( +
+ {folded > 0 && ( + + +{folded} + + )} + + {shown.map(win => ( + setSelected(win)} /> + ))} + + setSelected(null)} + /> +
+ ); +} diff --git a/apps/web/src/globals.css b/apps/web/src/globals.css index 3ff8e797..2094500e 100644 --- a/apps/web/src/globals.css +++ b/apps/web/src/globals.css @@ -48,6 +48,9 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-chart-6: var(--chart-6); + --color-chart-7: var(--chart-7); + --color-chart-8: var(--chart-8); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); @@ -103,11 +106,17 @@ --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.75 0.2 145); - --chart-1: oklch(0.75 0.2 145); - --chart-2: oklch(0.65 0.2 350); - --chart-3: oklch(0.696 0.17 162.48); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.627 0.265 303.9); + /* 차트 카테고리 팔레트 8칸 — 색각 이상 분리도/명도 대역 검증을 통과한 조합이다. + 순서를 바꾸거나 중간 색을 갈아끼우면 인접 조각이 구분되지 않으니 순서대로만 쓴다. + 여덟 칸이 한계다. 아홉 번째 색을 새로 만들지 말고 질감(빗금)을 얹어 구분한다. */ + --chart-1: #2a78d6; + --chart-2: #eb6834; + --chart-3: #1baf7a; + --chart-4: #eda100; + --chart-5: #e87ba4; + --chart-6: #008300; + --chart-7: #4a3aa7; + --chart-8: #e34948; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.75 0.2 145); @@ -143,11 +152,15 @@ --border: oklch(0.3 0.01 260); --input: oklch(0.25 0.01 260); --ring: oklch(0.75 0.2 145); - --chart-1: oklch(0.75 0.2 145); - --chart-2: oklch(0.65 0.2 350); - --chart-3: oklch(0.696 0.17 162.48); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.627 0.265 303.9); + /* 다크 표면에 맞춰 다시 고른 같은 계열 8색 (밝기만 뒤집은 값이 아니다) */ + --chart-1: #3987e5; + --chart-2: #d95926; + --chart-3: #199e70; + --chart-4: #c98500; + --chart-5: #d55181; + --chart-6: #008300; + --chart-7: #9085e9; + --chart-8: #e66767; --sidebar: oklch(0.18 0.01 260); --sidebar-foreground: oklch(0.9 0 0); --sidebar-primary: oklch(0.75 0.2 145); @@ -213,9 +226,35 @@ button { animation: character-shake 0.3s ease-in-out 2; } +/* 액자 몰딩 광택 (React Bits의 ShinyText 기법을 테두리에 옮긴 것). + 배경을 두 겹으로 깔고 첫 겹(하이라이트 띠)의 위치만 흘린다. 금속 결 자체가 흐르면 + 테가 미끄러지는 것처럼 보이지만, 결은 고정하고 빛만 지나가면 광택으로 읽힌다. + background-position만 움직이므로 레이아웃 계산이 없다. */ +@keyframes frame-sheen { + from { + background-position: + 150% center, + center; + } + to { + background-position: + -50% center, + center; + } +} + +.animate-frame-sheen { + background-size: + 200% auto, + auto; + background-repeat: no-repeat; + animation: frame-sheen 4.5s linear infinite; +} + @media (prefers-reduced-motion: reduce) { .animate-character-bounce, - .animate-character-shake { + .animate-character-shake, + .animate-frame-sheen { animation: none; } } diff --git a/apps/web/src/queries/artistVoteQuery.ts b/apps/web/src/queries/artistVoteQuery.ts index bfe77925..731a7295 100644 --- a/apps/web/src/queries/artistVoteQuery.ts +++ b/apps/web/src/queries/artistVoteQuery.ts @@ -1,4 +1,5 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; import { getArtistRankings, @@ -7,6 +8,16 @@ import { getMyArtistVotes, putArtistVote, } from '@/lib/api/artistVote'; +import { ApiErrorResponse } from '@/types/apiRoute'; +import { ArtistVoteInput } from '@/types/artistVote'; + +// 투표 실패 사유(포인트 부족, 없는 아티스트 등)는 라우트가 error 필드로 내려주므로 그대로 꺼내 쓴다. +const getVoteErrorMessage = (error: unknown) => { + if (axios.isAxiosError(error)) { + return (error.response?.data as ApiErrorResponse | undefined)?.error ?? error.message; + } + return error instanceof Error ? error.message : '투표 반영에 실패했어요'; +}; export const useMyArtistVotesQuery = (enabled: boolean) => { return useQuery({ @@ -20,19 +31,30 @@ export const useMyArtistVotesQuery = (enabled: boolean) => { }); }; -export const usePutArtistVoteMutation = () => { +/** + * 편집한 투표 목록을 한 번에 저장한다. + * PUT 라우트가 아티스트 단위라 변경분을 순차로 보내고, 하나라도 실패하면 거기서 멈춘다. + * amount는 절대값이라 재시도해도 중복 차감되지 않는다. + */ +export const useSaveArtistVotesMutation = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (body: { artist: string; amount: number }) => putArtistVote(body), - onSuccess: () => { + mutationFn: async (votes: ArtistVoteInput[]) => { + for (const vote of votes) { + try { + const response = await putArtistVote(vote); + if (!response.success) throw new Error(response.error ?? '투표 반영에 실패했어요'); + } catch (error) { + throw new Error(getVoteErrorMessage(error)); + } + } + }, + // 중간에 실패해도 이미 반영된 건 남으므로, 성공/실패 관계없이 서버 값을 다시 읽어 화면을 맞춘다. + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['myArtistVotes'] }); queryClient.invalidateQueries({ queryKey: ['userCheckIn'] }); queryClient.invalidateQueries({ queryKey: ['pointLogs'] }); }, - onError: error => { - console.error('error', error); - alert(error.message ?? '투표 반영에 실패했어요'); - }, }); }; diff --git a/apps/web/src/types/artistVote.ts b/apps/web/src/types/artistVote.ts index 4e9e7f4d..0192cfe4 100644 --- a/apps/web/src/types/artistVote.ts +++ b/apps/web/src/types/artistVote.ts @@ -1,16 +1,35 @@ -export interface MyArtistVote { +/** 투표 저장 요청 단위. 서버로 보내는 값은 항상 이 두 개뿐이다. */ +export interface ArtistVoteInput { artist: string; amount: number; } +export interface MyArtistVote extends ArtistVoteInput { + /** 아티스트 한국어 표기. 없거나 원어 표기와 같으면 화면에 따로 그리지 않는다. */ + artistKo: string | null; +} + +/** 이달의 아티스트로 뽑힌 한 번의 기록. 트로피 하나에 대응한다. */ +export interface ArtistWin { + /** 'YYYY-MM-DD'(매월 1일) */ + month: string; + totalVotes: number; +} + export interface ArtistRankingItem { rank: number; artist: string; + artistKo: string | null; + /** 아티스트 사진 URL. 없으면 이니셜 액자로 대체한다. */ + artistImage: string | null; totalVotes: number; topVoterNickname: string | null; topVoterAmount: number | null; - /** 1위 누적 선정 횟수. rank가 1인 아이템에만 의미가 있다. */ - starCount: number; + /** + * 1위 기록(오래된 순). 시상대(1~3위)만 채워진다. + * 조회 중인 달까지로 잘라 담기므로, 지난 달을 보면 그 시점의 기록만 들어 있다. + */ + wins: ArtistWin[]; } export interface ArtistRankingResponse { @@ -26,5 +45,6 @@ export interface ArtistVoter { export interface CurrentArtistOfMonth { artist: string; - starCount: number; + /** 선정된 달. 'YYYY-MM-DD'(매월 1일)로 내려온다. */ + month: string; } diff --git a/apps/web/src/utils/formatVoteShare.ts b/apps/web/src/utils/formatVoteShare.ts new file mode 100644 index 00000000..e2ddd1f9 --- /dev/null +++ b/apps/web/src/utils/formatVoteShare.ts @@ -0,0 +1,9 @@ +/** + * 득표가 전체에서 차지하는 비중을 소수 첫째 자리까지 적는다. + * + * 순위표(ArtistRankingBoard)와 파이(ArtistRankingChart)는 같은 데이터를 다른 방식으로 + * 보여주는 한 쌍이라, 같은 득표에 대해 두 화면이 다른 숫자를 적으면 곧바로 오류로 읽힌다. + * 자릿수 규칙을 한 곳에 둬야 그 어긋남이 생기지 않는다. + */ +export const formatVoteShare = (votes: number, total: number) => + total > 0 ? `${((votes / total) * 100).toFixed(1)}%` : '0%'; From 2f5fd9393c59ccffe885aa24338cb8f968268ce8 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 19:29:54 +0900 Subject: [PATCH 13/21] =?UTF-8?q?fix=20:=20=EA=B3=A1=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=8B=AC=EC=9D=98=20=EC=95=84=ED=8B=B0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=B0=EC=A7=80=EA=B0=80=20=ED=94=BC=EC=B2=98?= =?UTF-8?q?=EB=A7=81=C2=B7=EB=93=80=EC=97=A3=20=EA=B3=A1=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B9=A0=EC=A7=80=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- apps/web/src/components/SongSummary.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/SongSummary.tsx b/apps/web/src/components/SongSummary.tsx index a93df313..beeaff30 100644 --- a/apps/web/src/components/SongSummary.tsx +++ b/apps/web/src/components/SongSummary.tsx @@ -1,5 +1,7 @@ 'use client'; +import { getPrimaryArtistName } from '@repo/constants'; + import MarqueeText from '@/components/MarqueeText'; import SongBadges from '@/components/SongBadges'; import { useCurrentArtistOfMonthQuery } from '@/queries/artistVoteQuery'; @@ -29,8 +31,11 @@ export default function SongSummary({ song, className }: SongSummaryProps) { const hasKoTitle = !!title_ko && title_ko !== title; const hasKoArtist = !!artist_ko && artist_ko !== artist; + // songs.artist는 "IU(Feat.최백호)"처럼 원문 그대로라, artists 마스터에 등록된 정규화된 + // 이름과 곧이곧대로 비교하면 우승 아티스트의 피처링·듀엣 곡에 배지가 빠진다. + // 맨 앞에 적힌 주 아티스트만 뽑아 비교한다. const { data: artistOfMonth } = useCurrentArtistOfMonthQuery(); - const isArtistOfMonth = artistOfMonth?.artist === artist; + const isArtistOfMonth = !!artistOfMonth && artistOfMonth.artist === getPrimaryArtistName(artist); return (
@@ -49,8 +54,9 @@ export default function SongSummary({ song, className }: SongSummaryProps) { )} {isArtistOfMonth && ( - 🏆 이달의 아티스트 - {artistOfMonth.starCount > 0 && ` ★${artistOfMonth.starCount}`} + {/* month는 'YYYY-MM-DD'(매월 1일)라 앞 7자리만 잘라 쓴다. + "이달"이 언제인지는 곡 카드만 보면 알 수 없어 선정된 달을 함께 적는다. */} + 🏆 {artistOfMonth.month.slice(0, 7)} 이달의 아티스트 )}
From 9afd419b5de1ed7fbed8472cc23dc8d481237851 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 19:29:55 +0900 Subject: [PATCH 14/21] =?UTF-8?q?fix=20:=20=EC=B5=9C=EC=8B=A0=EA=B3=A1=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B5=AC=EB=B6=84=EC=84=A0=C2=B7=EC=97=AC?= =?UTF-8?q?=EB=B0=B1=20=EC=9D=B8=EA=B8=B0=20=EC=B0=A8=ED=8A=B8=EC=99=80=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- apps/web/src/app/recent/RecentSongCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/recent/RecentSongCard.tsx b/apps/web/src/app/recent/RecentSongCard.tsx index b31a1175..e5a9658a 100644 --- a/apps/web/src/app/recent/RecentSongCard.tsx +++ b/apps/web/src/app/recent/RecentSongCard.tsx @@ -1,12 +1,12 @@ import SongSummary from '@/components/SongSummary'; -import { Separator } from '@/components/ui/separator'; import { Song } from '@/types/song'; export default function RecentSongCard({ song }: { song: Song }) { + // 간격과 구분선은 인기 차트(ChartRankingList)와 같은 규칙을 쓴다. + // 행 아래쪽 테두리로 선을 긋고 마지막 행에서만 지워, 리스트 끝에 선이 떠 있지 않게 한다. return ( -
- - +
+
); } From e8a10259f79c79ac771cd1d54108253f946b287a Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 19:29:55 +0900 Subject: [PATCH 15/21] =?UTF-8?q?feat=20:=20=EC=95=84=ED=8B=B0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AC=EC=A7=84=20=EB=B0=B1=ED=95=84=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9B=94=EA=B0=84=20=ED=99=95=EC=A0=95=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=20=EC=97=B0=EB=8F=99=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- .../workflows/finalize_artist_of_month.yml | 36 +++ packages/crawling/package.json | 1 + .../src/assets/artistImageBackfillLog.txt | 56 +++++ .../crawling/src/cron/backfillArtistImages.ts | 230 ++++++++++++++++++ packages/crawling/src/supabase/getDB.ts | 69 +++++- packages/crawling/src/supabase/postDB.ts | 20 ++ packages/crawling/src/types.ts | 6 + packages/crawling/src/utils/musicbrainz.ts | 104 ++++++++ .../crawling/src/utils/normalizeArtistName.ts | 11 + 9 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 packages/crawling/src/assets/artistImageBackfillLog.txt create mode 100644 packages/crawling/src/cron/backfillArtistImages.ts create mode 100644 packages/crawling/src/utils/musicbrainz.ts create mode 100644 packages/crawling/src/utils/normalizeArtistName.ts diff --git a/.github/workflows/finalize_artist_of_month.yml b/.github/workflows/finalize_artist_of_month.yml index 1627b035..4c3ed558 100644 --- a/.github/workflows/finalize_artist_of_month.yml +++ b/.github/workflows/finalize_artist_of_month.yml @@ -17,3 +17,39 @@ jobs: curl --fail --silent --show-error \ -X POST "https://www.singcode.kr/api/artist-vote/finalize" \ -H "Authorization: Bearer ${{ secrets.ARTIST_VOTE_FINALIZE_SECRET }}" + + # 확정된 시상대(1~3위)의 사진만 채운다. 사진이 쓰이는 자리가 거기뿐이라 + # 13,000명을 미리 채워둘 이유가 없다. + # 이 잡은 매일 돌지만 사진이 이미 있는 아티스트는 대상에서 빠지므로, + # 그 달 첫 성공 이후로는 대상이 0명이라 외부 API를 부르지 않는다. + - uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + run_install: false + + - name: Install dependencies + working-directory: packages/crawling + run: pnpm install + + - name: Create .env file + working-directory: packages/crawling + run: | + echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env + echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env + + # 사진은 있으면 좋은 것이지 확정의 조건이 아니다. + # 외부 API(MusicBrainz·Deezer)가 죽어 있어도 순위 확정 자체는 성공으로 남겨야 한다. + - name: Backfill podium artist images + working-directory: packages/crawling + continue-on-error: true + env: + ARTIST_IMAGE_PODIUM: "3" + run: pnpm run backfill-artist-images diff --git a/packages/crawling/package.json b/packages/crawling/package.json index 564075ec..a98db919 100644 --- a/packages/crawling/package.json +++ b/packages/crawling/package.json @@ -20,6 +20,7 @@ "tj-chart": "tsx src/cron/crawlTjChart.ts", "tj-chart-backfill": "tsx src/cron/crawlTjChartBackfill.ts", "backfill-artists": "tsx src/cron/backfillArtists.ts", + "backfill-artist-images": "tsx src/cron/backfillArtistImages.ts", "lint": "eslint .", "test": "vitest run", "format": "prettier --write \"**/*.{ts,tsx,md}\"" diff --git a/packages/crawling/src/assets/artistImageBackfillLog.txt b/packages/crawling/src/assets/artistImageBackfillLog.txt new file mode 100644 index 00000000..0febb2f3 --- /dev/null +++ b/packages/crawling/src/assets/artistImageBackfillLog.txt @@ -0,0 +1,56 @@ + +===== 아티스트 이미지 백필(Deezer) 실행: 2026-08-23T09:44:07.541Z ===== +대상 10명 (DRY RUN — DB에 쓰지 않음) +사진이 비어 있는 아티스트: 10명 +[없음] 건 — 사진 있는 동일 이름 없음 · 후보: GuNN | 건희 +[없음] 결 — 사진 있는 동일 이름 없음 · 후보: KYUL +[없음] 겸 — 사진 있는 동일 이름 없음 · 후보: GYE0M +[없음] 길 — 사진 있는 동일 이름 없음 · 후보: Gill +[없음] 넬 — 사진 있는 동일 이름 없음 · 후보: Nell +[없음] 뉴 — 사진 있는 동일 이름 없음 · 후보: NEW (THE BOYZ) | New | NU'EST | 뉴이스트 W | 뉴 크리에이션 워십 New Creation Worship | 뉴제너레이션워십 New Generation Worship +[없음] 던 — 사진 있는 동일 이름 없음 · 후보: Dawn | DAWN 던 +[없음] 딘 — 사진 있는 동일 이름 없음 · 후보: Dean | Dean +[없음] 란 — 사진 있는 동일 이름 없음 · 후보: Ran | Ran | Ran | Ran +[없음] 렌 — 사진 있는 동일 이름 없음 · 후보: LEN | REN | LEN | ReN | REN +결과 — 채움: 0명, 사진 못 찾음: 10명, 실패: 0명 + +===== 아티스트 이미지 백필(Deezer) 실행: 2026-08-23T09:45:14.973Z ===== +대상 지정 15명 (DRY RUN — DB에 쓰지 않음) +사진이 비어 있는 아티스트: 10명 +[없음] 싸이 — 사진 있는 동일 이름 없음 · 후보: 싸이 (PSY) | Psy | Psy | Simons | Ciipher | xikers (싸이커스) | Sizer +[찾음] 태연 → 태연 (팬 66) https://cdn-images.dzcdn.net/images/artist/0c3af2f07d2afe670691d72742bcbcf9/1000x1000-000000-80-0-0.jpg +[찾음] 폴킴 → 폴킴 (팬 17) https://cdn-images.dzcdn.net/images/artist/06b09294f5a59e641c69ce6928a68b4b/1000x1000-000000-80-0-0.jpg +[없음] 임영웅 — 사진 있는 동일 이름 없음 · 후보: Lim Young Woong | LIM YOUNG MIN | Lim Youngwoong | im young woong +[찾음] 잔나비 → 잔나비 (팬 21) https://cdn-images.dzcdn.net/images/artist/21a64bdc3fd7a2f3bb7f7b625520c3da/1000x1000-000000-80-0-0.jpg +[없음] 방탄소년단 — 사진 있는 동일 이름 없음 · 후보: BTS | Supreme Boi +[없음] 볼빨간사춘기 — 사진 있는 동일 이름 없음 · 후보: BOL4 +[찾음] IU → IU (팬 305837) https://cdn-images.dzcdn.net/images/artist/c619e4cef93d6fa98a38018d92106bb6/1000x1000-000000-80-0-0.jpg +[찾음] YOASOBI → YOASOBI (팬 126490) https://cdn-images.dzcdn.net/images/artist/2cbfaf626a591e162bfcc2b4b0214217/1000x1000-000000-80-0-0.jpg +[없음] 米津玄師 — 사진 있는 동일 이름 없음 · 후보: 결과 없음 +결과 — 채움: 0명, 사진 못 찾음: 5명, 실패: 0명 + +===== 아티스트 이미지 백필(Deezer) 실행: 2026-08-23T09:48:01.729Z ===== +대상 지정 5명 +사진이 비어 있는 아티스트: 5명 +[찾음] 태연 → 태연 (팬 66) https://cdn-images.dzcdn.net/images/artist/0c3af2f07d2afe670691d72742bcbcf9/1000x1000-000000-80-0-0.jpg +[찾음] 폴킴 → 폴킴 (팬 17) https://cdn-images.dzcdn.net/images/artist/06b09294f5a59e641c69ce6928a68b4b/1000x1000-000000-80-0-0.jpg +[찾음] 잔나비 → 잔나비 (팬 21) https://cdn-images.dzcdn.net/images/artist/21a64bdc3fd7a2f3bb7f7b625520c3da/1000x1000-000000-80-0-0.jpg +[찾음] IU → IU (팬 305837) https://cdn-images.dzcdn.net/images/artist/c619e4cef93d6fa98a38018d92106bb6/1000x1000-000000-80-0-0.jpg +[찾음] YOASOBI → YOASOBI (팬 126490) https://cdn-images.dzcdn.net/images/artist/2cbfaf626a591e162bfcc2b4b0214217/1000x1000-000000-80-0-0.jpg +결과 — 채움: 5명, 사진 못 찾음: 0명, 실패: 0명 + +===== 아티스트 이미지 백필(Deezer) 실행: 2026-08-23T09:57:32.741Z ===== +대상 지정 10명 (DRY RUN — DB에 쓰지 않음) +사진이 비어 있는 아티스트: 5명 +[찾음] 싸이 → Psy (팬 893608) https://cdn-images.dzcdn.net/images/artist/fff83cade2015634a283e3c955871162/1000x1000-000000-80-0-0.jpg +[찾음] 임영웅 → Lim Young Woong (팬 829) https://cdn-images.dzcdn.net/images/artist/243dd9e39fd2c303b8da825197ded197/1000x1000-000000-80-0-0.jpg +[찾음] 방탄소년단 → BTS (팬 3850357) https://cdn-images.dzcdn.net/images/artist/b5c64fa8216ca158e52b4d88bd9388ff/1000x1000-000000-80-0-0.jpg +[찾음] 볼빨간사춘기 → BOL4 (팬 16648) https://cdn-images.dzcdn.net/images/artist/b4b21c7205dc80813c88a742e82e9154/1000x1000-000000-80-0-0.jpg +[찾음] 米津玄師 → Kenshi Yonezu (팬 67445) https://cdn-images.dzcdn.net/images/artist/29288251d9b8a8bd9c8497fb7b4fbcec/1000x1000-000000-80-0-0.jpg +결과 — 채움: 0명, 사진 못 찾음: 0명, 실패: 0명 + +===== 아티스트 이미지 백필(Deezer) 실행: 2026-08-23T10:04:25.158Z ===== +대상 시상대 3명 (DRY RUN — DB에 쓰지 않음) +최근 확정 월 상위 3명: 없음 +사진이 비어 있는 아티스트: 0명 +결과 — 채움: 0명, 사진 못 찾음: 0명, 실패: 0명 diff --git a/packages/crawling/src/cron/backfillArtistImages.ts b/packages/crawling/src/cron/backfillArtistImages.ts new file mode 100644 index 00000000..de851d44 --- /dev/null +++ b/packages/crawling/src/cron/backfillArtistImages.ts @@ -0,0 +1,230 @@ +import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; + +import { artistAlias } from '@repo/constants'; + +import { + getArtistsByNamesDB, + getArtistsWithoutImageDB, + getLatestPodiumArtistNamesDB, +} from '@/supabase/getDB'; +import { updateArtistImageDB } from '@/supabase/postDB'; +import { ArtistImageTarget } from '@/types'; +import { MUSICBRAINZ_INTERVAL_MS, getArtistNameSet } from '@/utils/musicbrainz'; +import { normalizeArtistName as normalize } from '@/utils/normalizeArtistName'; + +/** + * artists.image_url을 Deezer 공개 API로 채운다. + * + * Deezer를 쓰는 이유는 키 발급이 없어서다. 저장하는 건 파일이 아니라 URL이라, + * 나중에 출처를 바꿔도 이 컬럼만 갈아끼우면 된다. + * + * 이름은 MusicBrainz로 한 번 번역해서 넘긴다. 우리 DB는 TJ 표기(한글·원어)를 쓰는데 + * Deezer의 정품 등록은 로마자라("방탄소년단" ↔ "BTS"), 표기 그대로 찾으면 사진이 있는데도 + * 못 찾는다. 실제로 이 단계가 없을 때 방탄소년단·임영웅·볼빨간사춘기·싸이가 전부 빈손이었다. + * + * 환경변수 + * - ARTIST_IMAGE_PODIUM: N을 주면 가장 최근 확정 월의 상위 N명만 처리한다(월간 확정 워크플로용) + * - ARTIST_IMAGE_NAMES: 쉼표로 구분한 이름 목록. 지정하면 그 아티스트만 처리한다 + * - ARTIST_IMAGE_LIMIT: 이번 실행에서 처리할 아티스트 수 (기본 10, NAMES 지정 시 무시) + * - ARTIST_IMAGE_DRY_RUN: '1'이면 DB에 쓰지 않고 무엇이 잡혔는지만 찍는다 + */ + +const LOG_FILE = path.join('src', 'assets', 'artistImageBackfillLog.txt'); + +function log(message: string) { + console.log(message); + fs.appendFileSync(LOG_FILE, message + '\n', 'utf-8'); +} + +const LIMIT = Number(process.env.ARTIST_IMAGE_LIMIT ?? 10); +const PODIUM_TOP = Number(process.env.ARTIST_IMAGE_PODIUM ?? 0); +const NAMES = (process.env.ARTIST_IMAGE_NAMES ?? '') + .split(',') + .map(name => name.trim()) + .filter(Boolean); +const DRY_RUN = process.env.ARTIST_IMAGE_DRY_RUN === '1'; + +// Deezer는 IP당 5초에 50회까지 받는다. 초당 3~4회면 그 절반도 안 쓰니 재시도 로직이 필요 없다. +const REQUEST_INTERVAL_MS = 300; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +interface DeezerArtist { + id: number; + name: string; + /** 1000x1000. 사진이 없으면 실루엣 기본 이미지 URL이 온다. */ + picture_xl: string; + picture_big: string; + nb_fan: number; +} + +/** + * 사진이 없는 아티스트에게 Deezer가 돌려주는 실루엣 URL 판별. + * 이 경우 이미지 md5 자리가 비어 경로에 슬래시가 두 번 겹친다(.../images/artist//1000x1000-...). + * 실루엣을 저장하면 액자에 회색 사람 모양이 걸리는데, 그건 이니셜 폴백보다 나쁘다. + */ +const isPlaceholderPicture = (url: string) => !url || url.includes('/images/artist//'); + +// artistAlias는 키가 고정된 객체 리터럴이라 임의의 이름으로 색인할 수 없다. Map으로 한 번 편다. +const aliasesByOfficialName = new Map(Object.entries(artistAlias)); + +/** + * 이 아티스트로 인정할 이름들. 공식 표기에 artistAlias의 한국어 별칭을 더한다. + * Deezer가 "아이유" 대신 "IU"를 돌려주는 식의 표기 차이를 여기서 흡수한다. + */ +function buildAcceptedNames(target: ArtistImageTarget): Set { + const accepted = new Set([normalize(target.name)]); + + if (target.name_ko) accepted.add(normalize(target.name_ko)); + for (const alias of aliasesByOfficialName.get(target.name) ?? []) accepted.add(normalize(alias)); + + return accepted; +} + +/** + * MusicBrainz에서 이 아티스트의 다른 표기를 받아 인정 이름 집합에 더하고, 대표 표기를 돌려준다. + * + * 실패해도(못 찾음·서버 오류) 원래 표기로는 계속 찾아보므로 여기서 멈추지 않는다 — + * 이름 사전은 검색 결과를 넓혀 주는 보조 수단이지 없으면 안 되는 재료가 아니다. + */ +async function expandWithMusicbrainz(name: string, accepted: Set) { + let canonicalName: string | undefined; + + try { + const nameSet = await getArtistNameSet(name); + if (nameSet) { + canonicalName = nameSet.canonicalName; + nameSet.names.forEach(alias => accepted.add(normalize(alias))); + } + } catch (error) { + log( + `[MB실패] ${name} — ${error instanceof Error ? error.message : String(error)} (표기 그대로 계속)`, + ); + } + + await sleep(MUSICBRAINZ_INTERVAL_MS); + + return canonicalName; +} + +async function searchDeezer(query: string): Promise { + const { data } = await axios.get<{ data?: DeezerArtist[] }>( + 'https://api.deezer.com/search/artist', + // 같은 아티스트가 중복 등록돼 있는 경우가 많아 넉넉히 받는다(아래 pickBestMatch 주석 참고). + { params: { q: query, limit: 10 }, timeout: 10000 }, + ); + return data.data ?? []; +} + +/** + * 이름이 정확히 일치하는 후보들 중 팬 수가 가장 많고 사진이 있는 하나를 고른다. + * + * "가장 비슷한 것"을 고르지 않는 게 첫 번째 원칙이다 — 검색 API는 무엇을 넣든 뭔가를 + * 돌려주므로, 느슨하게 받으면 1위 액자에 다른 사람 얼굴이 걸린다. 틀린 사진은 빈 액자보다 나쁘다. + * + * 팬 수로 다시 거르는 건 Deezer에 같은 아티스트가 여러 번 등록돼 있기 때문이다. + * 정품 등록(팬 수십만)과 누군가 업로드하며 생긴 중복 항목(팬 수십)이 같은 이름으로 공존하고, + * 중복 쪽은 사진이 비어 있는 일이 잦다. 이름만 보고 첫 번째를 집으면 실루엣을 집게 된다. + */ +function pickBestMatch(candidates: DeezerArtist[], accepted: Set) { + const matched = candidates + .filter(candidate => accepted.has(normalize(candidate.name))) + .sort((a, b) => b.nb_fan - a.nb_fan); + + return matched.find( + candidate => !isPlaceholderPicture(candidate.picture_xl || candidate.picture_big), + ); +} + +log(`\n===== 아티스트 이미지 백필(Deezer) 실행: ${new Date().toISOString()} =====`); +/** + * 처리 대상 결정. + * - PODIUM: 월간 확정 직후 시상대만. 이미 사진이 있는 아티스트는 걸러지므로, 같은 달에 + * 여러 번 돌아도(이 워크플로는 매일 돈다) 두 번째부터는 대상이 0명이라 외부 요청이 없다. + * - NAMES: 이름을 콕 집어 시험할 때. + * - 기본: 사진이 빈 아티스트를 이름순으로 LIMIT명. + */ +async function resolveTargets() { + if (PODIUM_TOP > 0) { + const names = await getLatestPodiumArtistNamesDB(PODIUM_TOP); + log(`최근 확정 월 상위 ${PODIUM_TOP}명: ${names.join(', ') || '없음'}`); + return names.length > 0 ? getArtistsByNamesDB(names) : []; + } + + if (NAMES.length > 0) return getArtistsByNamesDB(NAMES); + + return getArtistsWithoutImageDB(LIMIT); +} + +log( + `대상 ${PODIUM_TOP > 0 ? `시상대 ${PODIUM_TOP}명` : NAMES.length > 0 ? `지정 ${NAMES.length}명` : `${LIMIT}명`}${DRY_RUN ? ' (DRY RUN — DB에 쓰지 않음)' : ''}`, +); + +const targets = await resolveTargets(); +log(`사진이 비어 있는 아티스트: ${targets.length}명`); + +let filled = 0; +let noMatch = 0; +let failed = 0; + +for (const target of targets) { + await sleep(REQUEST_INTERVAL_MS); + + try { + const accepted = buildAcceptedNames(target); + + // 1) 이 아티스트의 다른 표기를 모아 인정 이름과 검색어를 넓힌다. + const canonicalName = await expandWithMusicbrainz(target.name, accepted); + + // 2) 대표 표기(보통 로마자) → 원어 표기 → 한국어 표기 순으로 찾는다. + // 대표 표기를 먼저 두는 건 그쪽에 정품 등록이 몰려 있어서다. 중복 등록에서 건지는 + // 사진보다 정품 쪽 사진이 낫다. + const queries = [ + ...(canonicalName && normalize(canonicalName) !== normalize(target.name) + ? [canonicalName] + : []), + target.name, + ...(target.name_ko && target.name_ko !== target.name ? [target.name_ko] : []), + ]; + + // 후보를 다 모아 두고 마지막에 한 번에 고른다. 검색어마다 끊어 고르면 먼저 돈 검색어의 + // 중복 등록이 뒤에 나올 정품 등록을 이겨버린다. + const seenCandidates: DeezerArtist[] = []; + + for (const query of queries) { + seenCandidates.push(...(await searchDeezer(query))); + await sleep(REQUEST_INTERVAL_MS); + } + + const matched = pickBestMatch(seenCandidates, accepted); + + if (!matched) { + // 놓친 후보 이름을 함께 남긴다. 로마자 표기(예: 米津玄師 → Kenshi Yonezu)로만 사진이 + // 있는 경우가 있어, 이 로그가 곧 artistAlias에 별칭을 더할지 판단하는 근거가 된다. + const missed = + [...new Set(seenCandidates.map(candidate => candidate.name))].join(' | ') || '결과 없음'; + noMatch++; + log(`[없음] ${target.name} — 사진 있는 동일 이름 없음 · 후보: ${missed}`); + continue; + } + + const picture = matched.picture_xl || matched.picture_big; + + log(`[찾음] ${target.name} → ${matched.name} (팬 ${matched.nb_fan}) ${picture}`); + + if (DRY_RUN) continue; + + if (await updateArtistImageDB(target.name, picture)) { + filled++; + } else { + failed++; + } + } catch (error) { + failed++; + log(`[실패] ${target.name} — ${error instanceof Error ? error.message : String(error)}`); + } +} + +log(`결과 — 채움: ${filled}명, 사진 못 찾음: ${noMatch}명, 실패: ${failed}명`); diff --git a/packages/crawling/src/supabase/getDB.ts b/packages/crawling/src/supabase/getDB.ts index 491d6208..79b63e08 100644 --- a/packages/crawling/src/supabase/getDB.ts +++ b/packages/crawling/src/supabase/getDB.ts @@ -1,4 +1,4 @@ -import { ArtistBackfillSongRow, TransSong } from '@/types'; +import { ArtistBackfillSongRow, ArtistImageTarget, TransSong } from '@/types'; import { containsJapanese } from '@/utils/parseString'; import { getClient } from './getClient'; @@ -209,3 +209,70 @@ export async function getSongsBadgeNullDB(limit: number = 1000, afterNumTj?: str return data; } + +/** + * 사진이 아직 비어 있는 아티스트를 이름순으로 가져온다. + * 이름순인 이유는 여러 번 나눠 돌려도 매번 같은 자리에서 이어지게 하기 위함이다. + */ +export async function getArtistsWithoutImageDB(limit: number): Promise { + const supabase = getClient(); + + const { data, error } = await supabase + .from('artists') + .select('name, name_ko') + .is('image_url', null) + .order('name', { ascending: true }) + .limit(limit); + + if (error) throw error; + + return data as ArtistImageTarget[]; +} + +/** + * 이름을 콕 집어 가져온다. 사진 백필을 특정 아티스트로 시험해 볼 때 쓴다. + * 이름순 조회는 한 글자 이름("건", "결"…)부터 걸려서 표본으로 삼기에 나쁘다. + */ +export async function getArtistsByNamesDB(names: string[]): Promise { + const supabase = getClient(); + + const { data, error } = await supabase + .from('artists') + .select('name, name_ko') + .in('name', names) + .is('image_url', null); + + if (error) throw error; + + return data as ArtistImageTarget[]; +} + +/** + * 가장 최근에 확정된 달의 상위 N명 이름을 가져온다. + * 사진이 실제로 쓰이는 자리가 시상대(1~3위)뿐이라, 월간 확정 직후 그 이름만 채우면 된다. + */ +export async function getLatestPodiumArtistNamesDB(topN: number): Promise { + const supabase = getClient(); + + const { data: latest, error: latestError } = await supabase + .from('monthly_artist_rankings') + .select('vote_month') + .order('vote_month', { ascending: false }) + .limit(1); + + if (latestError) throw latestError; + + const month = latest?.[0]?.vote_month as string | undefined; + if (!month) return []; + + const { data, error } = await supabase + .from('monthly_artist_rankings') + .select('artist') + .eq('vote_month', month) + .lte('rank', topN) + .order('rank', { ascending: true }); + + if (error) throw error; + + return (data ?? []).map(row => row.artist as string); +} diff --git a/packages/crawling/src/supabase/postDB.ts b/packages/crawling/src/supabase/postDB.ts index ce68c5ef..09040ba1 100644 --- a/packages/crawling/src/supabase/postDB.ts +++ b/packages/crawling/src/supabase/postDB.ts @@ -145,3 +145,23 @@ export async function postInvalidKYSongsDB(song: Song) { return error; } } + +/** + * 아티스트 사진 URL을 채운다. + * 이미 채워진 행은 건드리지 않는다 — 손으로 골라 넣은 사진을 자동 백필이 덮어쓰면 안 된다. + */ +export async function updateArtistImageDB(name: string, imageUrl: string) { + const supabase = getClient(); + + const { error } = await supabase + .from('artists') + .update({ image_url: imageUrl }) + .eq('name', name) + .is('image_url', null); + + if (error) { + console.error('updateArtistImageDB error:', name, error); + return false; + } + return true; +} diff --git a/packages/crawling/src/types.ts b/packages/crawling/src/types.ts index 0f216d43..c1c1badb 100644 --- a/packages/crawling/src/types.ts +++ b/packages/crawling/src/types.ts @@ -109,6 +109,12 @@ export interface ArtistBackfillSongRow { artist_ko: string | null; } +/** 사진을 아직 못 채운 아티스트. 이미지 백필의 대상이다. */ +export interface ArtistImageTarget { + name: string; + name_ko: string | null; +} + export interface ArtistUpsert { name: string; name_ko: string | null; diff --git a/packages/crawling/src/utils/musicbrainz.ts b/packages/crawling/src/utils/musicbrainz.ts new file mode 100644 index 00000000..f918626e --- /dev/null +++ b/packages/crawling/src/utils/musicbrainz.ts @@ -0,0 +1,104 @@ +import axios from 'axios'; + +import { normalizeArtistName } from './normalizeArtistName'; + +/** + * MusicBrainz를 "이름 사전"으로만 쓴다. 사진은 여기서 가져오지 않는다. + * + * 우리 DB의 아티스트 이름은 TJ 표기(한글·원어)인데, 음원 서비스들은 같은 아티스트를 + * 로마자로 등록해 둔다("방탄소년단" ↔ "BTS", "米津玄師" ↔ "Kenshi Yonezu"). + * 이 표기 차이 때문에 이름이 정확히 일치하는 항목을 못 찾아 사진 백필이 통째로 비었다. + * MusicBrainz는 그 대응표(별칭)를 공개해 두고 있어, 여기서 이름만 받아 오면 + * 사진은 원래 쓰던 곳에서 정확한 이름으로 찾을 수 있다. + */ + +const ENDPOINT = 'https://musicbrainz.org/ws/2'; +// MusicBrainz는 앱을 식별할 수 있는 User-Agent를 요구한다. 없으면 차단당한다. +const USER_AGENT = 'singcode-crawler/1.0 (https://singcode.kr)'; +// 익명 사용자에게 허용되는 건 초당 1회다. 여유를 조금 둔다. +const REQUEST_INTERVAL_MS = 1100; +const MAX_RETRY = 4; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +interface MbSearchArtist { + id: string; + name: string; + score: number; + aliases?: { name: string }[]; +} + +interface MbArtistDetail { + name: string; + aliases?: { name: string }[]; +} + +/** + * 서버가 붐빌 때 503을 자주 돌려준다. 재시도 간격을 늘려가며 몇 번 더 두드린다. + * 503이 아닌 에러(잘못된 질의 등)는 재시도해도 결과가 같으니 그대로 던진다. + */ +async function request(path: string, params: Record): Promise { + for (let attempt = 1; ; attempt++) { + try { + const { data } = await axios.get(`${ENDPOINT}${path}`, { + params, + headers: { 'User-Agent': USER_AGENT }, + timeout: 15000, + }); + return data; + } catch (error) { + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + if (status !== 503 || attempt >= MAX_RETRY) throw error; + await sleep(2000 * attempt); + } + } +} + +export interface ArtistNameSet { + /** MusicBrainz가 쓰는 대표 표기. 보통 로마자다. */ + canonicalName: string; + /** 대표 표기 + 모든 별칭 */ + names: string[]; +} + +/** + * 이름으로 MusicBrainz 아티스트를 찾아 그 아티스트의 모든 표기를 돌려준다. + * + * 질의에 alias 필드를 함께 넣는 게 핵심이다. artist 필드만 보면 대표 표기가 로마자인 + * 아티스트("방탄소년단" → 대표 표기 BTS)는 아예 검색되지 않는다. + * + * 받아들이는 조건은 하나다 — 그 아티스트가 **우리가 넘긴 이름을 표기 중 하나로 그대로 갖고 + * 있을 것**. 점수가 높다는 이유로 받으면 동명이인·유사명이 섞여 들어오고, 그 뒤 단계는 + * 잘못된 이름으로 정확히 일치하는 사진을 찾아 오므로 아무도 오류를 눈치채지 못한다. + */ +export async function getArtistNameSet(name: string): Promise { + const target = normalizeArtistName(name); + + const search = await request<{ artists?: MbSearchArtist[] }>('/artist', { + query: `artist:"${name}" OR alias:"${name}"`, + fmt: 'json', + limit: 5, + }); + + const hit = (search.artists ?? []).find( + artist => + normalizeArtistName(artist.name) === target || + (artist.aliases ?? []).some(alias => normalizeArtistName(alias.name) === target), + ); + + if (!hit) return null; + + await sleep(REQUEST_INTERVAL_MS); + + // 검색 응답의 aliases는 질의에 걸린 것만 담겨 오는 일이 있어, 상세로 한 번 더 받는다. + const detail = await request(`/artist/${hit.id}`, { + inc: 'aliases', + fmt: 'json', + }); + + const names = [detail.name, ...(detail.aliases ?? []).map(alias => alias.name)]; + + return { canonicalName: detail.name, names: [...new Set(names)] }; +} + +export const MUSICBRAINZ_INTERVAL_MS = REQUEST_INTERVAL_MS; diff --git a/packages/crawling/src/utils/normalizeArtistName.ts b/packages/crawling/src/utils/normalizeArtistName.ts new file mode 100644 index 00000000..1d079315 --- /dev/null +++ b/packages/crawling/src/utils/normalizeArtistName.ts @@ -0,0 +1,11 @@ +/** + * 아티스트 이름 비교용 정규화. + * NFKC로 전각·반각을 한 모양으로 접고, 대소문자와 공백·구두점을 지운다. + * "BIGBANG"과 "Big Bang", "IU"와 "IU"는 같은 이름으로 봐야 하지만, + * 여기서 더 느슨하게 풀면(예: 부분 일치) 엉뚱한 사람 사진이 조용히 들어온다. + */ +export const normalizeArtistName = (name: string) => + name + .normalize('NFKC') + .toLowerCase() + .replace(/[\s.,'"·・&&!?()[\]{}-]/g, ''); From 6a6bfaab545fa620264c7112a0a9a9aeb99fc9d9 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 19:30:03 +0900 Subject: [PATCH 16/21] =?UTF-8?q?chore=20:=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9A=94=EC=B2=AD=20=EB=82=B4=EC=97=AD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- apps/web/src/app/info/page.tsx | 4 ++-- apps/web/src/app/info/reports/page.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/info/page.tsx b/apps/web/src/app/info/page.tsx index 0cba9780..2f33b857 100644 --- a/apps/web/src/app/info/page.tsx +++ b/apps/web/src/app/info/page.tsx @@ -36,8 +36,8 @@ const menuItems = [ }, { id: 'reports', - title: '내 신고 내역', - description: '내가 신고한 곡 오류를 확인하고 삭제합니다', + title: '수정 요청 내역', + description: '내가 수정 요청한 곡을 확인하고 삭제합니다', icon: , }, ]; diff --git a/apps/web/src/app/info/reports/page.tsx b/apps/web/src/app/info/reports/page.tsx index 4d470825..1ab0394f 100644 --- a/apps/web/src/app/info/reports/page.tsx +++ b/apps/web/src/app/info/reports/page.tsx @@ -30,7 +30,7 @@ export default function MyReportsPage() { -

내 신고 내역

+

수정 요청 내역

From 6f0ef9ea4097335efff026d939d7b1f4ec8f58d2 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 19:30:03 +0900 Subject: [PATCH 17/21] =?UTF-8?q?chore=20:=20prettier=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- apps/web/src/app/api/artists/search/route.ts | 6 +----- apps/web/src/app/api/search/route.ts | 12 ++---------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/apps/web/src/app/api/artists/search/route.ts b/apps/web/src/app/api/artists/search/route.ts index 342cf0c5..4af01e71 100644 --- a/apps/web/src/app/api/artists/search/route.ts +++ b/apps/web/src/app/api/artists/search/route.ts @@ -21,11 +21,7 @@ export async function GET( // 원어 표기(name)와 한국어 표기(name_ko) 양쪽을 각각 조회해 합친다. // ilike 값은 supabase-js가 바인딩 파라미터로 보내므로 or() 필터 문자열 조립보다 안전하다. const [byName, byNameKo] = await Promise.all([ - supabase - .from('artists') - .select('name, name_ko') - .ilike('name', pattern) - .limit(RESULT_LIMIT), + supabase.from('artists').select('name, name_ko').ilike('name', pattern).limit(RESULT_LIMIT), supabase .from('artists') .select('name, name_ko') diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index a8e8c72b..a662a57e 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -114,11 +114,7 @@ async function executeSearchQueries( if (exactCountResult.error) return { error: exactCountResult.error.message }; const exactTotal = exactCountResult.count ?? 0; - const exactQuery = applyExactFilter( - supabase.from('songs').select(selectClause), - type, - query, - ); + const exactQuery = applyExactFilter(supabase.from('songs').select(selectClause), type, query); const { data, error } = await exactQuery.order(order).range(from, to); if (error) return { error: error.message }; @@ -186,11 +182,7 @@ async function executeSearchQueries( } else { // 현재 페이지가 정확 일치 영역에 포함 (경계에 걸릴 수도 있음) const exactTo = Math.min(to, exactTotal - 1); - const exactQuery = applyExactFilter( - supabase.from('songs').select(selectClause), - type, - query, - ); + const exactQuery = applyExactFilter(supabase.from('songs').select(selectClause), type, query); const exactResult = await exactQuery.order(order).range(from, exactTo); if (exactResult.error) return { error: exactResult.error.message }; exactData = (exactResult.data as DBSong[]) ?? []; From 6614209c398d4a1aa0ca47bda844eb922d2561d7 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 19:30:04 +0900 Subject: [PATCH 18/21] =?UTF-8?q?doc=20:=20=EC=9D=B4=EB=8B=AC=EC=9D=98=20?= =?UTF-8?q?=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=83=81=ED=99=A9=20=EB=B0=8F=20=ED=81=AC=EB=A1=A4=EB=A7=81=20?= =?UTF-8?q?=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EB=AC=B8=EC=84=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- CLAUDE.md | 67 ++++++++++++++++++++++++++----------- packages/crawling/CLAUDE.md | 19 ++++++----- 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d57d793b..2260cc1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,9 @@ packages/ query/ — Shared TanStack Query hooks for open-api (@repo/query) api/ — Internal API utilities (@repo/api), built with tsup ui/ — Shared UI components (@repo/ui) + constants/ — Shared domain constants & rules (@repo/constants). 아티스트 별칭(`artistAlias`)과 + 이름 정규화 규칙(`artistName.ts`)이 있다. 백필(`packages/crawling`)과 웹(이달의 + 아티스트 배지 매칭)이 같은 규칙을 써야 해 여기 둔다 — 한쪽만 고치면 규칙이 어긋난다. eslint-config/ — Shared ESLint config (@repo/eslint-config) format-config/ — Shared Prettier config (@repo/format-config) typescript-config/ — Shared tsconfig bases @@ -69,17 +72,17 @@ See [apps/web/CLAUDE.md](apps/web/CLAUDE.md) for full detail. Key points: /start → /spsc → /red → /green → /refactor → /verify → /commit ``` -| 커맨드 | 설명 | 필수 여부 | -| ----------- | --------------------------------------- | --------- | -| `/start` | GitHub Issue 생성 + 작업 브랜치 체크아웃 | 권장 | -| `/spsc` | 이슈 기반 작업 범위 정의 | 권장 | -| `/red` | 실패 테스트 먼저 작성 (TDD) | 생략 가능 | -| `/green` | 구현 코드 작성 | 필수 | -| `/refactor` | 코드 품질 개선 (동작 변경 X) | 생략 가능 | -| `/verify` | build, lint, format, test 전체 검증 | **필수** | -| `/commit` | 커밋 메시지 생성 및 커밋 | **필수** | -| `/pr` | PR 생성 및 Qodo AI 리뷰 요청 | 권장 | -| `/check-review` | Qodo 리뷰 코멘트 읽기 및 이슈 브리핑 | 권장 | +| 커맨드 | 설명 | 필수 여부 | +| --------------- | ---------------------------------------- | --------- | +| `/start` | GitHub Issue 생성 + 작업 브랜치 체크아웃 | 권장 | +| `/spsc` | 이슈 기반 작업 범위 정의 | 권장 | +| `/red` | 실패 테스트 먼저 작성 (TDD) | 생략 가능 | +| `/green` | 구현 코드 작성 | 필수 | +| `/refactor` | 코드 품질 개선 (동작 변경 X) | 생략 가능 | +| `/verify` | build, lint, format, test 전체 검증 | **필수** | +| `/commit` | 커밋 메시지 생성 및 커밋 | **필수** | +| `/pr` | PR 생성 및 Qodo AI 리뷰 요청 | 권장 | +| `/check-review` | Qodo 리뷰 코멘트 읽기 및 이슈 브리핑 | 권장 | ### 단축 사이클 @@ -94,6 +97,7 @@ Branch format: `/-` — flow: `feat/*` → `d Types: `feat`, `fix`, `hotfix`, `chore`, `refactor`, `doc` Branch examples: + ``` feat/42-addSearchFilter fix/57-songCardCss @@ -103,6 +107,7 @@ chore/61-versionBump Commit format: ` : (#issue-number)` (space before and after colon) Examples: + ``` feat : MarqueeText 자동 스크롤 텍스트 적용 (#42) fix : SongCard css 수정 (#57) @@ -113,15 +118,37 @@ chore : 버전 2.3.0 (#61) ### #307 이달의 아티스트 (포인트 투표) -코드는 완성됐으나 아래 수동 작업이 남아있어 실제로는 아직 동작하지 않는다. - -1. `apps/web/artist-vote-schema.sql`을 Supabase SQL Editor에서 실행 — `artists`(마스터), `artist_votes`, `monthly_artist_rankings` 테이블 + RLS 정책 생성. `artists`를 먼저 만들고 나머지 두 테이블이 `artist`를 그 `name`으로 FK 참조하는 순서라 반드시 이 파일 그대로 한 번에 실행해야 한다. -2. `packages/crawling`에서 `pnpm backfill-artists`를 환경변수 없이(전체 스캔) 최초 1회 실행해 `artists`를 채운다 — 비어있으면 `artist_votes.artist`의 FK 제약 때문에 투표 자체가 전부 실패한다. -3. 환경변수/시크릿 등록 - - Vercel(`apps/web` 프로덕션): `SUPABASE_SERVICE_ROLE_KEY`(turbo.json엔 이미 선언돼 있으나 실제 값 미설정), `ARTIST_VOTE_FINALIZE_SECRET`(새로 발급) - - GitHub Actions repo secret: `ARTIST_VOTE_FINALIZE_SECRET` (`finalize_artist_of_month.yml`에서 사용, Vercel과 같은 값이어야 함) -4. 위 설정 후 투표 → 월간 확정(`finalize_artist_of_month.yml`) → 곡 카드 배지 노출까지 전체 흐름을 수동 QA (테스트 스위트 없음) -5. 아티스트 투표/검색 기능 동작 검증 — dev 서버 기동 및 `/popular` 응답까지는 확인했으나, 공개 API(`/api/artists/search`, `/api/artist-vote/rankings`, `/api/artist-vote/current-winner`) curl 스모크 테스트와 `/popular` 페이지 UI(투표 모달·검색) 브라우저 확인이 아직 안 끝났다. 로그인이 필요한 실제 투표(포인트 차감→랭킹 반영) 흐름은 카카오 로그인 기반이라 자동 검증이 어려워 수동 QA가 필요하다. +코드는 완성됐다. 남은 건 배포 설정과 실데이터 QA다. + +**완료** + +- `apps/web/artist-vote-schema.sql` 실행 — 테이블 3종 + RLS 정책 + `artists.image_url`. + 최초 버전에는 정책이 하나도 없어서 앱(anon 키)에서 모든 조회가 조용히 빈 배열로 돌아왔다. + 파일 전체가 `if not exists` / `drop policy if exists`라 다시 실행해도 안전하다. +- `pnpm backfill-artists`로 `artists` 적재 (13,869명). +- 랭킹 화면: 1~3위 액자(1위 금 / 2·3위 황동) + 카드 위 우승 별(`WinStars`) + 득표 파이(1~10위). + 화면 확인용 목업은 제거했다. + +**남은 작업** + +1. 환경변수/시크릿 + - Vercel(`apps/web` 프로덕션): `SUPABASE_SERVICE_ROLE_KEY`, `ARTIST_VOTE_FINALIZE_SECRET` + - GitHub Actions repo secret: `ARTIST_VOTE_FINALIZE_SECRET`(Vercel과 같은 값), + `SUPABASE_URL`·`SUPABASE_KEY`(service_role) — 뒤 둘은 finalize 워크플로에 추가된 + 이미지 백필 단계가 쓴다. `continue-on-error`라 없어도 잡은 초록불이니 첫 실행 로그를 봐야 안다. +2. 실데이터 QA (테스트 스위트 없음) + - 아티스트 검색 자동완성 — RLS 정책이 실제로 붙었는지 확인하는 가장 빠른 길 + - `artists(name_ko, image_url)` 임베드 첫 조회에서 `PGRST200`이 나면 FK 힌트 문법을 명시해야 한다 + - 투표(포인트 차감) → 월간 확정 → 곡 카드 배지까지. 카카오 로그인이라 자동 검증이 어렵다 +3. 아티스트 사진 — `artists.image_url` + - 채우는 주체는 `finalize_artist_of_month.yml`의 마지막 단계다. + 확정된 그달 1~3위 중 사진이 없는 사람만 `pnpm backfill-artist-images`(`ARTIST_IMAGE_PODIUM=3`)로 채운다. + 사진이 쓰이는 자리가 시상대뿐이라 13,000명을 미리 채우지 않는다. + - 이름 매칭은 MusicBrainz(별칭 사전) → Deezer(사진) 순이다. 우리 DB는 TJ 표기(한글·원어)인데 + 음원 서비스 등록은 로마자라("방탄소년단" ↔ "BTS"), 이 다리가 없으면 사진이 있는데도 못 찾는다. + - 태연·잔나비·폴킴 3명은 이 다리를 붙이기 전에 채워져 팬 수 20~60짜리 중복 등록에서 온 사진이다. + 정품으로 바꾸려면 `update public.artists set image_url = null where name in (...)` 후 다시 돌려야 한다 + (`image_url is null`인 행만 대상이라 그냥 두면 갱신되지 않는다). ### 태그 기능 제거 (완료 — 참고용) diff --git a/packages/crawling/CLAUDE.md b/packages/crawling/CLAUDE.md index d48429cc..5af499e7 100644 --- a/packages/crawling/CLAUDE.md +++ b/packages/crawling/CLAUDE.md @@ -19,6 +19,8 @@ pnpm tj-badges # TJ 반주 버전 뱃지(MV/MR/LV/60) 수집 (badges가 pnpm remove-dead-songs # TJ에서 사라진 번호의 곡 정리 (기본 미리보기, DEAD_SONGS_APPLY=true로 실행) pnpm tj-chart # TJ 공식 차트(TOP100) 전월분 수집 pnpm tj-chart-backfill # TJ 공식 차트 과거 월 일괄 백필 (기간은 스크립트 상수로 지정) +pnpm backfill-artists # songs.artist에서 artists 마스터 테이블 백필 +pnpm backfill-artist-images # artists.image_url을 Deezer 공개 API로 채움 (키 불필요) pnpm tag-songs # AI 기반 곡 자동 태깅 pnpm trans-jpn # J-POP 곡 제목/아티스트 한국어 번역 pnpm test # vitest 실행 @@ -99,14 +101,15 @@ findKYByOpen.ts ### Supabase 테이블 -| 테이블 | 용도 | -| ------------------ | -------------------------------- | -| `songs` | 메인 곡 데이터 (TJ/KY 번호 포함) | -| `invalid_ky_songs` | KY 번호 수집 실패 목록 | -| `tags` | 태그 마스터 (id, name, category) | -| `song_tags` | 곡-태그 매핑 (song_id, tag_id) | -| `verify_ky_songs` | KY 번호 검증 완료 목록 | -| `chart_rankings` | TJ 공식 차트 월별/장르별 순위 | +| 테이블 | 용도 | +| ------------------ | ------------------------------------------ | +| `songs` | 메인 곡 데이터 (TJ/KY 번호 포함) | +| `invalid_ky_songs` | KY 번호 수집 실패 목록 | +| `tags` | 태그 마스터 (id, name, category) | +| `song_tags` | 곡-태그 매핑 (song_id, tag_id) | +| `verify_ky_songs` | KY 번호 검증 완료 목록 | +| `chart_rankings` | TJ 공식 차트 월별/장르별 순위 | +| `artists` | 아티스트 마스터 (name, name_ko, image_url) | ### AI 유틸 From f227db86fef201558e0f6810ec1b5f2e4af4e0e8 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 22:51:50 +0900 Subject: [PATCH 19/21] =?UTF-8?q?refactor=20:=20=EC=9D=B4=EB=8B=AC?= =?UTF-8?q?=EC=9D=98=20=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=ED=81=AC=EB=A1=A4=EB=A7=81=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=EB=A1=9C=20=EC=9D=B4=EA=B4=80=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=9C=EA=B1=B0=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- .../workflows/finalize_artist_of_month.yml | 22 +++---- .../src/app/api/artist-vote/finalize/route.ts | 66 ------------------- apps/web/src/lib/supabase/serviceRole.ts | 9 --- apps/web/src/utils/kst.ts | 27 ++------ packages/constants/package.json | 3 + packages/constants/src/index.ts | 11 ++-- packages/constants/src/kstMonth.ts | 28 ++++++++ .../constants/src/rankArtistVotes.ts | 7 ++ packages/crawling/package.json | 1 + .../src/cron/finalizeArtistOfMonth.ts | 62 +++++++++++++++++ packages/crawling/src/supabase/getDB.ts | 17 +++++ packages/crawling/src/supabase/postDB.ts | 35 ++++++++++ pnpm-lock.yaml | 4 ++ turbo.json | 2 - 14 files changed, 176 insertions(+), 118 deletions(-) delete mode 100644 apps/web/src/app/api/artist-vote/finalize/route.ts delete mode 100644 apps/web/src/lib/supabase/serviceRole.ts create mode 100644 packages/constants/src/kstMonth.ts rename apps/web/src/app/api/artist-vote/_lib/rankVotes.ts => packages/constants/src/rankArtistVotes.ts (85%) create mode 100644 packages/crawling/src/cron/finalizeArtistOfMonth.ts diff --git a/.github/workflows/finalize_artist_of_month.yml b/.github/workflows/finalize_artist_of_month.yml index 4c3ed558..d88b3bd7 100644 --- a/.github/workflows/finalize_artist_of_month.yml +++ b/.github/workflows/finalize_artist_of_month.yml @@ -2,7 +2,7 @@ name: Finalize Artist of the Month on: schedule: - # 매일 00:10 KST(UTC 15:10) 실행. finalize API는 "지난달" 결과만 계산해 덮어쓰므로 + # 매일 00:10 KST(UTC 15:10) 실행. 확정 스크립트는 "지난달" 결과만 계산해 덮어쓰므로 # 하루에 한 번 이상 돌아도 안전하다 — GitHub Actions cron 지연으로 자정 실행을 놓치는 상황에 대비한다. - cron: "10 15 * * *" workflow_dispatch: @@ -12,16 +12,6 @@ jobs: runs-on: ubuntu-latest steps: - - name: Call finalize API - run: | - curl --fail --silent --show-error \ - -X POST "https://www.singcode.kr/api/artist-vote/finalize" \ - -H "Authorization: Bearer ${{ secrets.ARTIST_VOTE_FINALIZE_SECRET }}" - - # 확정된 시상대(1~3위)의 사진만 채운다. 사진이 쓰이는 자리가 거기뿐이라 - # 13,000명을 미리 채워둘 이유가 없다. - # 이 잡은 매일 돌지만 사진이 이미 있는 아티스트는 대상에서 빠지므로, - # 그 달 첫 성공 이후로는 대상이 0명이라 외부 API를 부르지 않는다. - uses: actions/checkout@v4 - name: Use Node.js 20 @@ -45,7 +35,15 @@ jobs: echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env - # 사진은 있으면 좋은 것이지 확정의 조건이 아니다. + - name: Finalize artist of the month + working-directory: packages/crawling + run: pnpm run finalize-artist + + # 확정된 시상대(1~3위)의 사진만 채운다. 사진이 쓰이는 자리가 거기뿐이라 + # 13,000명을 미리 채워둘 이유가 없다. 이 잡은 매일 돌지만 사진이 이미 있는 아티스트는 + # 대상에서 빠지므로, 그 달 첫 성공 이후로는 대상이 0명이라 외부 API를 부르지 않는다. + # + # continue-on-error인 이유: 사진은 있으면 좋은 것이지 확정의 조건이 아니다. # 외부 API(MusicBrainz·Deezer)가 죽어 있어도 순위 확정 자체는 성공으로 남겨야 한다. - name: Backfill podium artist images working-directory: packages/crawling diff --git a/apps/web/src/app/api/artist-vote/finalize/route.ts b/apps/web/src/app/api/artist-vote/finalize/route.ts deleted file mode 100644 index e4cfeef8..00000000 --- a/apps/web/src/app/api/artist-vote/finalize/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { NextResponse } from 'next/server'; - -import { VoteRow, rankTopArtists } from '@/app/api/artist-vote/_lib/rankVotes'; -import createServiceRoleClient from '@/lib/supabase/serviceRole'; -import { ApiResponse } from '@/types/apiRoute'; -import { getPrevMonthFirstDayKST } from '@/utils/kst'; - -// GitHub Actions가 매일 00:10(KST)에 호출한다. -// "지금이 이번 달"이라는 전제 하에 getPrevMonthFirstDayKST()로 "방금 끝난 달"을 계산해 -// 매번 같은 달을 덮어써 재실행에도 안전하다(idempotent). -export async function POST(request: Request): Promise>> { - const authHeader = request.headers.get('authorization'); - const secret = process.env.ARTIST_VOTE_FINALIZE_SECRET; - - if (!secret || authHeader !== `Bearer ${secret}`) { - return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); - } - - try { - const supabase = createServiceRoleClient(); - const targetMonth = getPrevMonthFirstDayKST(); - - const { data, error } = await supabase - .from('artist_votes') - .select('user_id, artist, amount, created_at') - .eq('vote_month', targetMonth) - .returns(); - - if (error) throw error; - - const votes = data ?? []; - if (votes.length === 0) { - return NextResponse.json({ success: true }); - } - - const ranked = rankTopArtists(votes); - - const { error: deleteError } = await supabase - .from('monthly_artist_rankings') - .delete() - .eq('vote_month', targetMonth); - - if (deleteError) throw deleteError; - - const rows = ranked.map((entry, index) => ({ - vote_month: targetMonth, - rank: index + 1, - artist: entry.artist, - total_votes: entry.total, - top_voter_user_id: entry.topVoterUserId, - top_voter_amount: entry.topVoterAmount, - })); - - const { error: insertError } = await supabase.from('monthly_artist_rankings').insert(rows); - - if (insertError) throw insertError; - - return NextResponse.json({ success: true }); - } catch (error) { - console.error('Error in POST finalize artist vote:', error); - return NextResponse.json( - { success: false, error: 'Failed to finalize artist vote' }, - { status: 500 }, - ); - } -} diff --git a/apps/web/src/lib/supabase/serviceRole.ts b/apps/web/src/lib/supabase/serviceRole.ts deleted file mode 100644 index 8a82a325..00000000 --- a/apps/web/src/lib/supabase/serviceRole.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createClient } from '@supabase/supabase-js'; - -// RLS를 우회해야 하는 서버 전용 배치(예: 아티스트 투표 월간 집계)에서만 사용한다. -// 절대 클라이언트 번들에 노출되면 안 되므로 'use client' 컴포넌트에서 import 금지. -export default function createServiceRoleClient() { - return createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { - auth: { persistSession: false }, - }); -} diff --git a/apps/web/src/utils/kst.ts b/apps/web/src/utils/kst.ts index ba981d9c..db35d291 100644 --- a/apps/web/src/utils/kst.ts +++ b/apps/web/src/utils/kst.ts @@ -1,27 +1,8 @@ -import { addDays, addHours, format, startOfMonth, subMonths } from 'date-fns'; +import { addDays, addHours } from 'date-fns'; -/** 오늘 KST 날짜를 'YYYY-MM-DD' 문자열로 반환 (서버 타임존 무관) */ -export function getTodayKST(): string { - return addHours(new Date(), 9).toISOString().split('T')[0]; -} - -/** - * 이전 달 1일을 'YYYY-MM-DD' 문자열로 반환 (KST 기준, 서버 타임존 무관) - * 월 단위로 집계되는 차트의 기본 조회 월로 사용한다. - */ -export function getPrevMonthFirstDayKST(): string { - const [year, month] = getTodayKST().split('-').map(Number); - return format(startOfMonth(subMonths(new Date(year, month - 1, 1), 1)), 'yyyy-MM-dd'); -} - -/** - * 이번 달 1일을 'YYYY-MM-DD' 문자열로 반환 (KST 기준, 서버 타임존 무관) - * 아티스트 투표는 이 달에만 수정 가능하므로 잠금 기준으로 사용한다. - */ -export function getCurrentMonthFirstDayKST(): string { - const [year, month] = getTodayKST().split('-').map(Number); - return format(startOfMonth(new Date(year, month - 1, 1)), 'yyyy-MM-dd'); -} +// 월 계산은 확정 배치(packages/crawling)와 같은 규칙을 써야 해 @repo/constants로 옮겼다. +// 기존 import 경로를 유지하려고 여기서 재수출한다. +export { getCurrentMonthFirstDayKST, getPrevMonthFirstDayKST, getTodayKST } from '@repo/constants'; /** 내일 KST 날짜를 'YYYY-MM-DD' 문자열로 반환 */ export function getTomorrowKST(): string { diff --git a/packages/constants/package.json b/packages/constants/package.json index 19076769..58225f8d 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -14,5 +14,8 @@ "@types/node": "^22.13.10", "tsup": "^8.4.0", "typescript": "^5.8.2" + }, + "dependencies": { + "date-fns": "^4.1.0" } } diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index ef262111..635599fb 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1,6 +1,5 @@ -export { artistAlias } from "./artistAlias"; -export { - extractPrimaryArtist, - getPrimaryArtistName, - isInvalidArtist, -} from "./artistName"; +export { artistAlias } from './artistAlias'; +export { extractPrimaryArtist, getPrimaryArtistName, isInvalidArtist } from './artistName'; +export { getCurrentMonthFirstDayKST, getPrevMonthFirstDayKST, getTodayKST } from './kstMonth'; +export { TOP_RANK_LIMIT, rankTopArtists } from './rankArtistVotes'; +export type { ArtistAggregate, VoteRow } from './rankArtistVotes'; diff --git a/packages/constants/src/kstMonth.ts b/packages/constants/src/kstMonth.ts new file mode 100644 index 00000000..94df9809 --- /dev/null +++ b/packages/constants/src/kstMonth.ts @@ -0,0 +1,28 @@ +import { addHours, format, startOfMonth, subMonths } from 'date-fns'; + +/** + * KST 기준 월 계산. + * + * 웹(기본 조회 월 표시)과 백필/확정 스크립트(집계 대상 월)가 같은 답을 내야 한다. + * 한쪽만 고치면 "화면이 기본으로 보여주는 달"과 "실제로 확정된 달"이 어긋나, 사용자에게는 + * 결과가 통째로 사라진 것처럼 보인다. 그래서 규칙을 공유 패키지에 둔다. + * + * 서버 타임존이 무엇이든 같은 값을 내도록 UTC에 9시간을 더해 KST 벽시계를 만든 뒤 자른다. + */ + +/** 오늘 KST 날짜를 'YYYY-MM-DD' 문자열로 반환 */ +export function getTodayKST(): string { + return addHours(new Date(), 9).toISOString().split('T')[0]; +} + +/** 이전 달 1일 'YYYY-MM-DD' (KST 기준). 월 단위 집계의 대상 월이다. */ +export function getPrevMonthFirstDayKST(): string { + const [year, month] = getTodayKST().split('-').map(Number); + return format(startOfMonth(subMonths(new Date(year, month - 1, 1), 1)), 'yyyy-MM-dd'); +} + +/** 이번 달 1일 'YYYY-MM-DD' (KST 기준). 아티스트 투표는 이 달에만 수정할 수 있다. */ +export function getCurrentMonthFirstDayKST(): string { + const [year, month] = getTodayKST().split('-').map(Number); + return format(startOfMonth(new Date(year, month - 1, 1)), 'yyyy-MM-dd'); +} diff --git a/apps/web/src/app/api/artist-vote/_lib/rankVotes.ts b/packages/constants/src/rankArtistVotes.ts similarity index 85% rename from apps/web/src/app/api/artist-vote/_lib/rankVotes.ts rename to packages/constants/src/rankArtistVotes.ts index 07776bc8..ce6e6d27 100644 --- a/apps/web/src/app/api/artist-vote/_lib/rankVotes.ts +++ b/packages/constants/src/rankArtistVotes.ts @@ -1,3 +1,10 @@ +/** + * 아티스트 투표 집계 규칙. + * + * 확정 배치(packages/crawling)와 웹이 같은 규칙을 봐야 해서 공유 패키지에 둔다 — + * 순위 개수나 동점 처리가 한쪽만 바뀌면 화면에 적힌 순위와 저장된 순위가 어긋난다. + */ + /** 확정 저장하는 순위 개수. monthly_artist_rankings의 rank check 제약과 같아야 한다. */ export const TOP_RANK_LIMIT = 30; diff --git a/packages/crawling/package.json b/packages/crawling/package.json index a98db919..f0d54d1c 100644 --- a/packages/crawling/package.json +++ b/packages/crawling/package.json @@ -21,6 +21,7 @@ "tj-chart-backfill": "tsx src/cron/crawlTjChartBackfill.ts", "backfill-artists": "tsx src/cron/backfillArtists.ts", "backfill-artist-images": "tsx src/cron/backfillArtistImages.ts", + "finalize-artist": "tsx src/cron/finalizeArtistOfMonth.ts", "lint": "eslint .", "test": "vitest run", "format": "prettier --write \"**/*.{ts,tsx,md}\"" diff --git a/packages/crawling/src/cron/finalizeArtistOfMonth.ts b/packages/crawling/src/cron/finalizeArtistOfMonth.ts new file mode 100644 index 00000000..f15ce56c --- /dev/null +++ b/packages/crawling/src/cron/finalizeArtistOfMonth.ts @@ -0,0 +1,62 @@ +import fs from 'fs'; +import path from 'path'; + +import { getPrevMonthFirstDayKST, rankTopArtists } from '@repo/constants'; + +import { getArtistVotesByMonthDB } from '@/supabase/getDB'; +import { MonthlyRankingInsert, replaceMonthlyRankingsDB } from '@/supabase/postDB'; + +/** + * 이달의 아티스트 월간 확정. + * + * 원래는 웹앱의 API 라우트(`/api/artist-vote/finalize`)였다. 그 구조에서는 확정 트리거가 + * 인증 없는 공개 URL이라 공유 시크릿으로 잠가야 했고, 순위를 지우고 다시 쓰는 작업이 + * 외부에서 언제든 호출될 수 있었다. 여기로 옮기면서 그 문(門) 자체가 없어졌다 — + * 이제 확정은 이 워크플로 안에서만 일어나고, 시크릿도 필요 없다. + * + * "지금이 이번 달"이라는 전제로 지난달을 계산해 매번 같은 달을 덮어쓰므로, + * 하루에 여러 번 돌아도 결과가 같다(idempotent). 그래서 워크플로를 매일 돌려 + * cron 지연으로 1일 실행을 놓치는 상황에 대비할 수 있다. + */ + +const LOG_FILE = path.join('src', 'assets', 'artistFinalizeLog.txt'); + +function log(message: string) { + console.log(message); + fs.appendFileSync(LOG_FILE, message + '\n', 'utf-8'); +} + +const targetMonth = getPrevMonthFirstDayKST(); + +log(`\n===== 이달의 아티스트 확정 실행: ${new Date().toISOString()} =====`); +log(`대상 월: ${targetMonth}`); + +const votes = await getArtistVotesByMonthDB(targetMonth); +log(`투표 수: ${votes.length}건`); + +if (votes.length === 0) { + // 투표가 없으면 아무것도 하지 않는다. 여기서 기존 순위를 지우면, 투표 조회가 일시적으로 + // 비어 돌아온 날 이미 확정된 결과까지 날아간다. + log('투표가 없어 종료합니다 (기존 순위는 그대로 둔다).'); +} else { + const ranked = rankTopArtists(votes); + + const rows: MonthlyRankingInsert[] = ranked.map((entry, index) => ({ + vote_month: targetMonth, + rank: index + 1, + artist: entry.artist, + total_votes: entry.total, + top_voter_user_id: entry.topVoterUserId, + top_voter_amount: entry.topVoterAmount, + })); + + const saved = await replaceMonthlyRankingsDB(targetMonth, rows); + + log(`확정 완료 — ${saved}명 저장`); + log( + `시상대: ${rows + .slice(0, 3) + .map(row => `${row.rank}위 ${row.artist}(${row.total_votes}P)`) + .join(' · ')}`, + ); +} diff --git a/packages/crawling/src/supabase/getDB.ts b/packages/crawling/src/supabase/getDB.ts index 79b63e08..09977d95 100644 --- a/packages/crawling/src/supabase/getDB.ts +++ b/packages/crawling/src/supabase/getDB.ts @@ -1,3 +1,5 @@ +import { VoteRow } from '@repo/constants'; + import { ArtistBackfillSongRow, ArtistImageTarget, TransSong } from '@/types'; import { containsJapanese } from '@/utils/parseString'; @@ -276,3 +278,18 @@ export async function getLatestPodiumArtistNamesDB(topN: number): Promise row.artist as string); } + +/** 그달 투표 전체. 집계는 rankTopArtists(@repo/constants)가 한다. */ +export async function getArtistVotesByMonthDB(month: string): Promise { + const supabase = getClient(); + + const { data, error } = await supabase + .from('artist_votes') + .select('user_id, artist, amount, created_at') + .eq('vote_month', month) + .returns(); + + if (error) throw error; + + return data ?? []; +} diff --git a/packages/crawling/src/supabase/postDB.ts b/packages/crawling/src/supabase/postDB.ts index 09040ba1..53cfcf4d 100644 --- a/packages/crawling/src/supabase/postDB.ts +++ b/packages/crawling/src/supabase/postDB.ts @@ -165,3 +165,38 @@ export async function updateArtistImageDB(name: string, imageUrl: string) { } return true; } + +export interface MonthlyRankingInsert { + vote_month: string; + rank: number; + artist: string; + total_votes: number; + top_voter_user_id: string; + top_voter_amount: number; +} + +/** + * 그달 순위를 통째로 갈아끼운다. + * + * 지우고 넣는 사이에 트랜잭션이 없어 그 틈에 조회하면 결과가 비어 보인다. + * 이 스크립트가 하루 한 번, 단일 워크플로에서만 도는 전제라 그 틈을 감수한다 — + * 여러 곳에서 동시에 부를 수 있게 되면 그때는 RPC(단일 트랜잭션)로 바꿔야 한다. + */ +export async function replaceMonthlyRankingsDB(month: string, rows: MonthlyRankingInsert[]) { + const supabase = getClient(); + + const { error: deleteError } = await supabase + .from('monthly_artist_rankings') + .delete() + .eq('vote_month', month); + + if (deleteError) throw deleteError; + + if (rows.length === 0) return 0; + + const { error: insertError } = await supabase.from('monthly_artist_rankings').insert(rows); + + if (insertError) throw insertError; + + return rows.length; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3510a80..e55855ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,6 +246,10 @@ importers: version: 5.9.3 packages/constants: + dependencies: + date-fns: + specifier: ^4.1.0 + version: 4.1.0 devDependencies: '@types/node': specifier: ^22.13.10 diff --git a/turbo.json b/turbo.json index 42b1cf0f..8b4fadb7 100644 --- a/turbo.json +++ b/turbo.json @@ -7,7 +7,6 @@ "NEXT_PUBLIC_SUPABASE_ANON_KEY", "SUPABASE_URL", "SUPABASE_ANON_KEY", - "SUPABASE_SERVICE_ROLE_KEY", "ADMIN_USER_ID", "KAKAO_REST_API_KEY", "KAKAO_SECRET_KEY", @@ -24,7 +23,6 @@ "TJ_DRY_RUN", "TJ_RESET_PROGRESS", "DEAD_SONGS_APPLY", - "ARTIST_VOTE_FINALIZE_SECRET", "ARTIST_BACKFILL_SINCE_DAYS", "ARTIST_IMAGE_LIMIT", "ARTIST_IMAGE_NAMES", From 172f84c52f3603029376e6e4da7d4a0b8d56e31d Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 23 Aug 2026 22:51:51 +0900 Subject: [PATCH 20/21] =?UTF-8?q?doc=20:=20=ED=99=95=EC=A0=95=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=9D=B4=EA=B4=80=20=EB=B0=8F=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=B4=EC=A7=84=20=EB=B0=B0=ED=8F=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=A0=95=EB=A6=AC=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPpnfzdUzPYFQZrjzNL65k --- CLAUDE.md | 30 +++++++++++++++++++----------- packages/crawling/CLAUDE.md | 20 +++++++++++--------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2260cc1b..3f93438e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,9 +42,10 @@ packages/ query/ — Shared TanStack Query hooks for open-api (@repo/query) api/ — Internal API utilities (@repo/api), built with tsup ui/ — Shared UI components (@repo/ui) - constants/ — Shared domain constants & rules (@repo/constants). 아티스트 별칭(`artistAlias`)과 - 이름 정규화 규칙(`artistName.ts`)이 있다. 백필(`packages/crawling`)과 웹(이달의 - 아티스트 배지 매칭)이 같은 규칙을 써야 해 여기 둔다 — 한쪽만 고치면 규칙이 어긋난다. + constants/ — Shared domain constants & rules (@repo/constants). 아티스트 별칭(`artistAlias`), + 이름 정규화(`artistName.ts`), 투표 집계 규칙(`rankArtistVotes.ts`), KST 월 계산 + (`kstMonth.ts`)이 있다. 배치(`packages/crawling`)와 웹이 같은 규칙을 써야 해 + 여기 둔다 — 한쪽만 고치면 화면에 적힌 순위와 저장된 순위가 어긋난다. eslint-config/ — Shared ESLint config (@repo/eslint-config) format-config/ — Shared Prettier config (@repo/format-config) typescript-config/ — Shared tsconfig bases @@ -131,25 +132,32 @@ chore : 버전 2.3.0 (#61) **남은 작업** -1. 환경변수/시크릿 - - Vercel(`apps/web` 프로덕션): `SUPABASE_SERVICE_ROLE_KEY`, `ARTIST_VOTE_FINALIZE_SECRET` - - GitHub Actions repo secret: `ARTIST_VOTE_FINALIZE_SECRET`(Vercel과 같은 값), - `SUPABASE_URL`·`SUPABASE_KEY`(service_role) — 뒤 둘은 finalize 워크플로에 추가된 - 이미지 백필 단계가 쓴다. `continue-on-error`라 없어도 잡은 초록불이니 첫 실행 로그를 봐야 안다. +1. GitHub Actions repo secret 확인 — `SUPABASE_URL`, `SUPABASE_KEY`(service_role). + `finalize_artist_of_month.yml`이 확정과 사진 백필 양쪽에 쓴다. 다른 크롤링 워크플로가 + 이미 쓰는 값이라 등록돼 있을 가능성이 높지만, 이 워크플로에서는 처음 참조한다. + **Vercel 쪽에 추가로 넣을 환경변수는 없다.** 2. 실데이터 QA (테스트 스위트 없음) - 아티스트 검색 자동완성 — RLS 정책이 실제로 붙었는지 확인하는 가장 빠른 길 - `artists(name_ko, image_url)` 임베드 첫 조회에서 `PGRST200`이 나면 FK 힌트 문법을 명시해야 한다 - 투표(포인트 차감) → 월간 확정 → 곡 카드 배지까지. 카카오 로그인이라 자동 검증이 어렵다 3. 아티스트 사진 — `artists.image_url` - - 채우는 주체는 `finalize_artist_of_month.yml`의 마지막 단계다. - 확정된 그달 1~3위 중 사진이 없는 사람만 `pnpm backfill-artist-images`(`ARTIST_IMAGE_PODIUM=3`)로 채운다. - 사진이 쓰이는 자리가 시상대뿐이라 13,000명을 미리 채우지 않는다. + - `finalize_artist_of_month.yml`의 마지막 단계가 채운다. 확정된 그달 1~3위 중 사진이 없는 + 사람만 `ARTIST_IMAGE_PODIUM=3`으로 처리한다. 사진이 쓰이는 자리가 시상대뿐이라 + 13,000명을 미리 채우지 않는다. - 이름 매칭은 MusicBrainz(별칭 사전) → Deezer(사진) 순이다. 우리 DB는 TJ 표기(한글·원어)인데 음원 서비스 등록은 로마자라("방탄소년단" ↔ "BTS"), 이 다리가 없으면 사진이 있는데도 못 찾는다. - 태연·잔나비·폴킴 3명은 이 다리를 붙이기 전에 채워져 팬 수 20~60짜리 중복 등록에서 온 사진이다. 정품으로 바꾸려면 `update public.artists set image_url = null where name in (...)` 후 다시 돌려야 한다 (`image_url is null`인 행만 대상이라 그냥 두면 갱신되지 않는다). +**월간 확정이 도는 자리** + +`packages/crawling`의 `pnpm finalize-artist`다. 원래는 웹앱 API 라우트 +(`/api/artist-vote/finalize`)였는데, 인증 없는 공개 URL이 순위를 지우고 다시 쓰는 구조라 +공유 시크릿으로 잠가야 했다. 배치로 옮기면서 그 엔드포인트와 `ARTIST_VOTE_FINALIZE_SECRET`, +Vercel의 `SUPABASE_SERVICE_ROLE_KEY`가 모두 없어졌다. **확정 로직을 웹으로 되돌리지 말 것** — +공개 엔드포인트가 다시 생긴다. + ### 태그 기능 제거 (완료 — 참고용) 검색 언어 태그 필터가 오작동해 관련 코드를 전부 걷어냈다: 프론트(`LanguageTagFilter.tsx` 등)와 `api/search`의 `song_tags` 조인, `artists.language_tag_id` 참조(추후 대시보드에서 컬럼 수동 삭제 예정)를 제거했고, `packages/crawling`의 `taggingSongs.ts`/`translationJpn.ts`는 삭제 대신 전체 주석처리했다. `tagging_song.yml`/`translation_jpn.yml`은 `schedule` 트리거만 비활성화(주석)했고 `workflow_dispatch`로 수동 실행은 여전히 가능하다. `tags`/`song_tags` 테이블 자체는 DB에 남아있지만 앱은 더 이상 참조하지 않는다. diff --git a/packages/crawling/CLAUDE.md b/packages/crawling/CLAUDE.md index 5af499e7..c4d66800 100644 --- a/packages/crawling/CLAUDE.md +++ b/packages/crawling/CLAUDE.md @@ -21,6 +21,7 @@ pnpm tj-chart # TJ 공식 차트(TOP100) 전월분 수집 pnpm tj-chart-backfill # TJ 공식 차트 과거 월 일괄 백필 (기간은 스크립트 상수로 지정) pnpm backfill-artists # songs.artist에서 artists 마스터 테이블 백필 pnpm backfill-artist-images # artists.image_url을 Deezer 공개 API로 채움 (키 불필요) +pnpm finalize-artist # 이달의 아티스트 월간 확정 (지난달 투표 집계 → 순위 저장) pnpm tag-songs # AI 기반 곡 자동 태깅 pnpm trans-jpn # J-POP 곡 제목/아티스트 한국어 번역 pnpm test # vitest 실행 @@ -179,14 +180,15 @@ crawlTjBadges.ts (pnpm tj-badges) ### GitHub Actions 워크플로우 -| 워크플로우 파일 | 스케줄 (UTC) | 실행 스크립트 | -| ------------------------- | ----------------- | -------------------- | -| `crawl_recent_tj.yml` | 매일 14:00 | `pnpm recent-tj` | -| `crawl_tj_chart.yml` | 매달 1일 01:00 | `pnpm tj-chart` | -| `tagging_song.yml` | 매일 07:00 | `pnpm tag-songs` | -| `translation_jpn.yml` | 매일 10:00 | `pnpm trans-jpn` | -| `update_ky_youtube.yml` | 매일 14:00 | `pnpm ky-youtube` | -| `verify_ky_youtube.yml` | 매주 월요일 14:00 | `pnpm ky-verify` | -| `crawl_tj_all_number.yml` | 수동 전용 | `pnpm tj-all-number` | +| 워크플로우 파일 | 스케줄 (UTC) | 실행 스크립트 | +| ------------------------------ | ----------------- | ------------------------------------------------------ | +| `crawl_recent_tj.yml` | 매일 14:00 | `pnpm recent-tj` | +| `crawl_tj_chart.yml` | 매달 1일 01:00 | `pnpm tj-chart` | +| `tagging_song.yml` | 매일 07:00 | `pnpm tag-songs` | +| `translation_jpn.yml` | 매일 10:00 | `pnpm trans-jpn` | +| `update_ky_youtube.yml` | 매일 14:00 | `pnpm ky-youtube` | +| `verify_ky_youtube.yml` | 매주 월요일 14:00 | `pnpm ky-verify` | +| `crawl_tj_all_number.yml` | 수동 전용 | `pnpm tj-all-number` | +| `finalize_artist_of_month.yml` | 매일 15:10 | `pnpm finalize-artist` + `pnpm backfill-artist-images` | `crawl_tj_all_number.yml`을 제외한 모든 워크플로우는 스케줄 + `workflow_dispatch`(수동) 양쪽으로 실행 가능하다. `crawl_tj_all_number.yml`은 번호 구간을 `matrix`로 병렬 분할해 수동으로만 실행한다. From c9d89cc8fdc5da7a995bd460ce6494fd6c0ef824 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 25 Aug 2026 01:12:10 +0900 Subject: [PATCH 21/21] =?UTF-8?q?chore=20:=20=EC=9D=B4=EB=8B=AC=EC=9D=98?= =?UTF-8?q?=20=EC=95=84=ED=8B=B0=EC=8A=A4=ED=8A=B8=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=8B=A4=ED=96=89=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011BpPKpkMzHxHvgWgfye3DJ --- packages/crawling/src/assets/artistFinalizeLog.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/crawling/src/assets/artistFinalizeLog.txt diff --git a/packages/crawling/src/assets/artistFinalizeLog.txt b/packages/crawling/src/assets/artistFinalizeLog.txt new file mode 100644 index 00000000..6a69e04a --- /dev/null +++ b/packages/crawling/src/assets/artistFinalizeLog.txt @@ -0,0 +1,5 @@ + +===== 이달의 아티스트 확정 실행: 2026-08-23T14:42:46.248Z ===== +대상 월: 2026-07-01 +투표 수: 0건 +투표가 없어 종료합니다 (기존 순위는 그대로 둔다).