diff --git a/fishing/apps/api/src/tournaments/leaderboard.service.spec.ts b/fishing/apps/api/src/tournaments/leaderboard.service.spec.ts index 230d301..70bb664 100644 --- a/fishing/apps/api/src/tournaments/leaderboard.service.spec.ts +++ b/fishing/apps/api/src/tournaments/leaderboard.service.spec.ts @@ -5,12 +5,14 @@ describe('TournamentsService 실시간 순위', () => { user: { findUnique: jest.fn() }, tournament: { findUnique: jest.fn() }, tournamentEntry: { findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + tournamentDayCode: { count: jest.fn() }, catch: { findMany: jest.fn() }, }; const service = new TournamentsService(prisma as never); beforeEach(() => { jest.clearAllMocks(); + prisma.tournamentDayCode.count.mockResolvedValue(0); prisma.tournament.findUnique.mockResolvedValue({ id: 'tour-1', organizerId: 'org-1', @@ -47,6 +49,54 @@ describe('TournamentsService 실시간 순위', () => { expect(rows).toEqual([ expect.objectContaining({ userId: 'user-1', bestLengthCm: 32.5, bestCatchId: 'catch-1', rank: 1 }), ]); + expect(prisma.catch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.not.objectContaining({ dayCodeStatus: 'accepted' }), + }), + ); + }); + + it('코드를 한 번이라도 등록한 대회는 수락된 제출만 집계한다', async () => { + prisma.tournamentDayCode.count.mockResolvedValue(2); + prisma.tournamentEntry.findMany.mockResolvedValue([ + { + id: 'entry-1', + userId: 'user-1', + disqualified: false, + paymentStatus: 'free', + user: { id: 'user-1', nickname: '하나', profileImage: null }, + }, + { + id: 'entry-2', + userId: 'user-2', + disqualified: false, + paymentStatus: 'free', + user: { id: 'user-2', nickname: '둘', profileImage: null }, + }, + ]); + prisma.catch.findMany.mockResolvedValue([ + { + id: 'accepted', + userId: 'user-2', + lengthCm: 30, + createdAt: new Date('2026-11-01T04:00:00.000Z'), + fishSpeciesId: 1, + claimedTournamentId: 'tour-1', + dayCodeStatus: 'accepted', + }, + ]); + + const rows = await service.getRanking('tour-1'); + + expect(rows.map((row) => row.bestCatchId)).toEqual(['accepted']); + expect(prisma.catch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + claimedTournamentId: 'tour-1', + dayCodeStatus: 'accepted', + }), + }), + ); }); it('실격은 참가 행을 지우지 않고 표시만 바꾼다', async () => { diff --git a/fishing/apps/api/src/tournaments/live-leaderboard.util.spec.ts b/fishing/apps/api/src/tournaments/live-leaderboard.util.spec.ts index aec7c90..3f621d7 100644 --- a/fishing/apps/api/src/tournaments/live-leaderboard.util.spec.ts +++ b/fishing/apps/api/src/tournaments/live-leaderboard.util.spec.ts @@ -53,6 +53,20 @@ describe('실시간 대회 순위', () => { ]); }); + it('코드를 쓰는 대회는 그 대회에서 수락된 기록만 남긴다', () => { + const rows = buildLiveLeaderboard( + { ...tournament, id: 'tour-1', requireAcceptedDayCode: true }, + [entry('a'), entry('b')], + [ + { ...fish('a', 40, '2026-11-01T01:00:00.000Z'), claimedTournamentId: 'tour-1', dayCodeStatus: 'pending' }, + { ...fish('b', 30, '2026-11-01T01:00:00.000Z'), claimedTournamentId: 'tour-1', dayCodeStatus: 'accepted' }, + { ...fish('a', 55, '2026-11-01T02:00:00.000Z'), claimedTournamentId: 'other', dayCodeStatus: 'accepted' }, + ], + ); + + expect(rows.map((row) => [row.userId, row.bestLengthCm])).toEqual([['b', 30]]); + }); + it('결과 CSV는 순위와 닉네임을 담는다', () => { const csv = leaderboardCsv([ { diff --git a/fishing/apps/api/src/tournaments/live-leaderboard.util.ts b/fishing/apps/api/src/tournaments/live-leaderboard.util.ts index f4af277..8945992 100644 --- a/fishing/apps/api/src/tournaments/live-leaderboard.util.ts +++ b/fishing/apps/api/src/tournaments/live-leaderboard.util.ts @@ -7,6 +7,8 @@ export type LeaderboardCatch = { status?: string; recordType?: string; deletedAt?: Date | null; + claimedTournamentId?: string | null; + dayCodeStatus?: string | null; }; export type LeaderboardEntry = { @@ -18,11 +20,13 @@ export type LeaderboardEntry = { }; export type LeaderboardTournament = { + id?: string; fishSpeciesId?: number | null; minLengthCm?: number | null; maxLengthCm?: number | null; startAt: Date; endAt: Date; + requireAcceptedDayCode?: boolean; }; export type LeaderboardRow = { @@ -51,6 +55,10 @@ export function buildLiveLeaderboard( if (catch_.deletedAt) continue; if (catch_.status && catch_.status !== 'approved') continue; if (catch_.recordType && catch_.recordType !== 'certified') continue; + if (tournament.requireAcceptedDayCode) { + if (!tournament.id || catch_.claimedTournamentId !== tournament.id) continue; + if (catch_.dayCodeStatus !== 'accepted') continue; + } if (catch_.createdAt < tournament.startAt || catch_.createdAt > tournament.endAt) continue; if (tournament.fishSpeciesId && catch_.fishSpeciesId !== tournament.fishSpeciesId) continue; if (tournament.minLengthCm != null && catch_.lengthCm < Number(tournament.minLengthCm)) continue; diff --git a/fishing/apps/api/src/tournaments/tournaments.service.ts b/fishing/apps/api/src/tournaments/tournaments.service.ts index 14373c6..61d1269 100644 --- a/fishing/apps/api/src/tournaments/tournaments.service.ts +++ b/fishing/apps/api/src/tournaments/tournaments.service.ts @@ -37,7 +37,7 @@ export class TournamentsService { const t = await this.prisma.tournament.findUnique({ where: { id }, include: { - _count: { select: { entries: true } }, + _count: { select: { entries: true, dayCodes: true } }, entries: { where: { rank: { not: null } }, include: { user: { select: { id: true, nickname: true, profileImage: true } } }, @@ -91,6 +91,8 @@ export class TournamentsService { }); if (!entries.length) return []; + const dayCodeCount = await this.prisma.tournamentDayCode.count({ where: { tournamentId } }); + const requireAcceptedDayCode = dayCodeCount > 0; const catches = await this.prisma.catch.findMany({ where: { deletedAt: null, @@ -98,17 +100,30 @@ export class TournamentsService { recordType: 'certified', userId: { in: entries.map((entry) => entry.userId) }, createdAt: { gte: tournament.startAt, lte: tournament.endAt }, + ...(requireAcceptedDayCode + ? { claimedTournamentId: tournamentId, dayCodeStatus: 'accepted' } + : {}), + }, + select: { + id: true, + userId: true, + lengthCm: true, + createdAt: true, + fishSpeciesId: true, + claimedTournamentId: true, + dayCodeStatus: true, }, - select: { id: true, userId: true, lengthCm: true, createdAt: true, fishSpeciesId: true }, }); return buildLiveLeaderboard( { + id: tournament.id, fishSpeciesId: tournament.fishSpeciesId, minLengthCm: tournament.minLengthCm == null ? null : Number(tournament.minLengthCm), maxLengthCm: tournament.maxLengthCm == null ? null : Number(tournament.maxLengthCm), startAt: tournament.startAt, endAt: tournament.endAt, + requireAcceptedDayCode, }, entries, catches.map((catch_) => ({ diff --git a/fishing/apps/web/src/app/tournament/[id]/page.tsx b/fishing/apps/web/src/app/tournament/[id]/page.tsx index 210971f..b763242 100644 --- a/fishing/apps/web/src/app/tournament/[id]/page.tsx +++ b/fishing/apps/web/src/app/tournament/[id]/page.tsx @@ -128,7 +128,11 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id:

현재 순위

-

대회 기간의 승인된 공식 인증만 반영합니다.

+

+ {(data._count?.dayCodes ?? 0) > 0 + ? '오늘의 코드를 운영자가 수락한 공식 인증만 순위에 들어갑니다.' + : '대회 기간의 승인된 공식 인증만 반영합니다.'} +

{rankingData && rankingData.length > 0 ? (
{rankingData.slice(0, 10).map((entry: {