From f2fac5dade1d0da762e737471bad8d324be5742f Mon Sep 17 00:00:00 2001 From: rjsgud49 Date: Tue, 20 Oct 2026 07:20:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(tournaments):=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=88=9C=EC=9C=84=EC=99=80=20=EC=B0=B8=EA=B0=80?= =?UTF-8?q?=EC=9E=90=20=EC=8B=A4=EA=B2=A9,=20=EA=B2=B0=EA=B3=BC=20CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 저장된 최고 체장 대신 대회 기간의 승인된 공식 인증으로 순위를 계산하고, 실격은 참가 기록을 지우지 않는다. Co-authored-by: park gunhyung --- .../migration.sql | 3 + fishing/apps/api/prisma/schema.prisma | 2 + .../tournaments/dto/set-participant.dto.ts | 11 +++ .../tournaments/leaderboard.service.spec.ts | 64 +++++++++++++ .../tournaments/live-leaderboard.util.spec.ts | 69 ++++++++++++++ .../src/tournaments/live-leaderboard.util.ts | 94 +++++++++++++++++++ .../src/tournaments/tournaments.controller.ts | 38 +++++++- .../src/tournaments/tournaments.service.ts | 69 +++++++++++++- .../apps/web/src/app/tournament/[id]/page.tsx | 1 + .../web/src/app/tournament/organize/page.tsx | 66 +++++++++++-- 10 files changed, 404 insertions(+), 13 deletions(-) create mode 100644 fishing/apps/api/prisma/migrations/20261020150000_tournament_leaderboard/migration.sql create mode 100644 fishing/apps/api/src/tournaments/dto/set-participant.dto.ts create mode 100644 fishing/apps/api/src/tournaments/leaderboard.service.spec.ts create mode 100644 fishing/apps/api/src/tournaments/live-leaderboard.util.spec.ts create mode 100644 fishing/apps/api/src/tournaments/live-leaderboard.util.ts diff --git a/fishing/apps/api/prisma/migrations/20261020150000_tournament_leaderboard/migration.sql b/fishing/apps/api/prisma/migrations/20261020150000_tournament_leaderboard/migration.sql new file mode 100644 index 0000000..ac2cdad --- /dev/null +++ b/fishing/apps/api/prisma/migrations/20261020150000_tournament_leaderboard/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "tournament_entries" +ADD COLUMN "disqualified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "disqualify_note" VARCHAR(200); diff --git a/fishing/apps/api/prisma/schema.prisma b/fishing/apps/api/prisma/schema.prisma index 54fb1ee..482a522 100644 --- a/fishing/apps/api/prisma/schema.prisma +++ b/fishing/apps/api/prisma/schema.prisma @@ -360,6 +360,8 @@ model TournamentEntry { bestLengthCm Decimal? @map("best_length_cm") @db.Decimal(5, 1) bestCatchId String? @map("best_catch_id") rank Int? + disqualified Boolean @default(false) + disqualifyNote String? @map("disqualify_note") @db.VarChar(200) joinedAt DateTime @default(now()) @map("joined_at") tournament Tournament @relation(fields: [tournamentId], references: [id]) diff --git a/fishing/apps/api/src/tournaments/dto/set-participant.dto.ts b/fishing/apps/api/src/tournaments/dto/set-participant.dto.ts new file mode 100644 index 0000000..652b3ed --- /dev/null +++ b/fishing/apps/api/src/tournaments/dto/set-participant.dto.ts @@ -0,0 +1,11 @@ +import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class SetParticipantDto { + @IsBoolean() + disqualified: boolean; + + @IsOptional() + @IsString() + @MaxLength(200) + note?: string; +} diff --git a/fishing/apps/api/src/tournaments/leaderboard.service.spec.ts b/fishing/apps/api/src/tournaments/leaderboard.service.spec.ts new file mode 100644 index 0000000..230d301 --- /dev/null +++ b/fishing/apps/api/src/tournaments/leaderboard.service.spec.ts @@ -0,0 +1,64 @@ +import { TournamentsService } from './tournaments.service'; + +describe('TournamentsService 실시간 순위', () => { + const prisma = { + user: { findUnique: jest.fn() }, + tournament: { findUnique: jest.fn() }, + tournamentEntry: { findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + catch: { findMany: jest.fn() }, + }; + const service = new TournamentsService(prisma as never); + + beforeEach(() => { + jest.clearAllMocks(); + prisma.tournament.findUnique.mockResolvedValue({ + id: 'tour-1', + organizerId: 'org-1', + fishSpeciesId: null, + minLengthCm: null, + maxLengthCm: null, + startAt: new Date('2026-11-01T00:00:00.000Z'), + endAt: new Date('2026-11-02T00:00:00.000Z'), + }); + }); + + it('저장된 최고 체장 대신 승인된 인증 기록으로 순위를 만든다', async () => { + prisma.tournamentEntry.findMany.mockResolvedValue([ + { + id: 'entry-1', + userId: 'user-1', + disqualified: false, + paymentStatus: 'free', + bestLengthCm: 99, + user: { id: 'user-1', nickname: '하나', profileImage: null }, + }, + ]); + prisma.catch.findMany.mockResolvedValue([ + { + id: 'catch-1', + userId: 'user-1', + lengthCm: 32.5, + createdAt: new Date('2026-11-01T03:00:00.000Z'), + fishSpeciesId: 1, + }, + ]); + + const rows = await service.getRanking('tour-1'); + expect(rows).toEqual([ + expect.objectContaining({ userId: 'user-1', bestLengthCm: 32.5, bestCatchId: 'catch-1', rank: 1 }), + ]); + }); + + it('실격은 참가 행을 지우지 않고 표시만 바꾼다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' }); + prisma.tournamentEntry.findUnique.mockResolvedValue({ id: 'entry-1' }); + prisma.tournamentEntry.update.mockResolvedValue({ id: 'entry-1', disqualified: true }); + + await service.setDisqualified('org-1', 'tour-1', 'user-1', true, '사진 재사용'); + + expect(prisma.tournamentEntry.update).toHaveBeenCalledWith({ + where: { id: 'entry-1' }, + data: { disqualified: true, disqualifyNote: '사진 재사용' }, + }); + }); +}); diff --git a/fishing/apps/api/src/tournaments/live-leaderboard.util.spec.ts b/fishing/apps/api/src/tournaments/live-leaderboard.util.spec.ts new file mode 100644 index 0000000..aec7c90 --- /dev/null +++ b/fishing/apps/api/src/tournaments/live-leaderboard.util.spec.ts @@ -0,0 +1,69 @@ +import { buildLiveLeaderboard, leaderboardCsv } from './live-leaderboard.util'; + +const tournament = { + fishSpeciesId: 7, + minLengthCm: 20, + maxLengthCm: 60, + startAt: new Date('2026-11-01T00:00:00.000Z'), + endAt: new Date('2026-11-03T00:00:00.000Z'), +}; + +function entry(userId: string, extra: Partial<{ disqualified: boolean; paymentStatus: string }> = {}) { + return { + id: `entry-${userId}`, + userId, + disqualified: extra.disqualified ?? false, + paymentStatus: extra.paymentStatus ?? 'free', + user: { id: userId, nickname: userId }, + }; +} + +function fish(userId: string, lengthCm: number, at: string, extra: Partial<{ fishSpeciesId: number; status: string }> = {}) { + return { + id: `${userId}-${lengthCm}`, + userId, + lengthCm, + createdAt: new Date(at), + fishSpeciesId: extra.fishSpeciesId ?? 7, + status: extra.status ?? 'approved', + recordType: 'certified', + }; +} + +describe('실시간 대회 순위', () => { + it('기간·어종·체장에 맞는 가장 긴 기록으로 순위를 매긴다', () => { + const rows = buildLiveLeaderboard( + tournament, + [entry('a'), entry('b'), entry('c', { disqualified: true }), entry('d', { paymentStatus: 'pending' })], + [ + fish('a', 30, '2026-11-01T01:00:00.000Z'), + fish('a', 41, '2026-11-01T02:00:00.000Z'), + fish('b', 41, '2026-11-01T00:30:00.000Z'), + fish('a', 70, '2026-11-01T03:00:00.000Z'), + fish('a', 50, '2026-10-31T00:00:00.000Z'), + fish('b', 55, '2026-11-02T00:00:00.000Z', { fishSpeciesId: 8 }), + fish('c', 59, '2026-11-01T04:00:00.000Z'), + fish('d', 58, '2026-11-01T04:00:00.000Z'), + ], + ); + + expect(rows.map((row) => [row.userId, row.bestLengthCm, row.rank])).toEqual([ + ['b', 41, 1], + ['a', 41, 2], + ]); + }); + + it('결과 CSV는 순위와 닉네임을 담는다', () => { + const csv = leaderboardCsv([ + { + id: 'e1', + userId: 'a', + user: { id: 'a', nickname: '홍,길동' }, + bestLengthCm: 41, + bestCatchId: 'catch-1', + rank: 1, + }, + ]); + expect(csv).toBe('rank,nickname,length_cm,catch_id\n1,"홍,길동",41,catch-1'); + }); +}); diff --git a/fishing/apps/api/src/tournaments/live-leaderboard.util.ts b/fishing/apps/api/src/tournaments/live-leaderboard.util.ts new file mode 100644 index 0000000..f4af277 --- /dev/null +++ b/fishing/apps/api/src/tournaments/live-leaderboard.util.ts @@ -0,0 +1,94 @@ +export type LeaderboardCatch = { + id: string; + userId: string; + lengthCm: number; + createdAt: Date; + fishSpeciesId?: number | null; + status?: string; + recordType?: string; + deletedAt?: Date | null; +}; + +export type LeaderboardEntry = { + id: string; + userId: string; + disqualified: boolean; + paymentStatus: string; + user: { id: string; nickname: string; profileImage?: string | null }; +}; + +export type LeaderboardTournament = { + fishSpeciesId?: number | null; + minLengthCm?: number | null; + maxLengthCm?: number | null; + startAt: Date; + endAt: Date; +}; + +export type LeaderboardRow = { + id: string; + userId: string; + user: LeaderboardEntry['user']; + bestLengthCm: number; + bestCatchId: string; + rank: number; +}; + +export function buildLiveLeaderboard( + tournament: LeaderboardTournament, + entries: LeaderboardEntry[], + catches: LeaderboardCatch[], +): LeaderboardRow[] { + const eligible = new Map( + entries + .filter((entry) => !entry.disqualified && (entry.paymentStatus === 'free' || entry.paymentStatus === 'paid')) + .map((entry) => [entry.userId, entry]), + ); + const best = new Map(); + + for (const catch_ of catches) { + if (!eligible.has(catch_.userId) || !Number.isFinite(catch_.lengthCm)) continue; + if (catch_.deletedAt) continue; + if (catch_.status && catch_.status !== 'approved') continue; + if (catch_.recordType && catch_.recordType !== 'certified') 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; + if (tournament.maxLengthCm != null && catch_.lengthCm > Number(tournament.maxLengthCm)) continue; + + const current = best.get(catch_.userId); + const better = + !current || + catch_.lengthCm > current.lengthCm || + (catch_.lengthCm === current.lengthCm && catch_.createdAt < current.createdAt); + if (better) best.set(catch_.userId, catch_); + } + + return [...best.entries()] + .map(([userId, catch_]) => ({ entry: eligible.get(userId)!, catch_ })) + .sort((left, right) => { + if (right.catch_.lengthCm !== left.catch_.lengthCm) return right.catch_.lengthCm - left.catch_.lengthCm; + return left.catch_.createdAt.getTime() - right.catch_.createdAt.getTime(); + }) + .map((row, index) => ({ + id: row.entry.id, + userId: row.entry.userId, + user: row.entry.user, + bestLengthCm: row.catch_.lengthCm, + bestCatchId: row.catch_.id, + rank: index + 1, + })); +} + +export function leaderboardCsv(rows: LeaderboardRow[]): string { + const lines = ['rank,nickname,length_cm,catch_id']; + for (const row of rows) { + lines.push([row.rank, csvCell(row.user.nickname), row.bestLengthCm, row.bestCatchId].join(',')); + } + return lines.join('\n'); +} + +function csvCell(value: string): string { + if (/[",\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`; + return value; +} diff --git a/fishing/apps/api/src/tournaments/tournaments.controller.ts b/fishing/apps/api/src/tournaments/tournaments.controller.ts index 3b00eef..dae86ca 100644 --- a/fishing/apps/api/src/tournaments/tournaments.controller.ts +++ b/fishing/apps/api/src/tournaments/tournaments.controller.ts @@ -1,4 +1,5 @@ -import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards, UseInterceptors, UploadedFile } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards, UseInterceptors, UploadedFile, Res } from '@nestjs/common'; +import type { Response } from 'express'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger'; @@ -9,6 +10,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'; import { TournamentsService } from './tournaments.service'; import { SetDayCodeDto } from './dto/set-day-code.dto'; import { OrganizerTournamentDto } from './dto/organizer-tournament.dto'; +import { SetParticipantDto } from './dto/set-participant.dto'; import { createImageUploadInterceptor, saveUploadedFile } from '../common/upload/upload.config'; import { ImageMagicInterceptor } from '../common/upload/image-magic.interceptor'; @@ -100,6 +102,40 @@ export class TournamentsController { return { success: true, data: result }; } + @Get('organized/:id/participants') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '대회 참가자. 실격은 삭제하지 않음' }) + async listParticipants(@Param('id') id: string, @CurrentUser() user: any) { + const result = await this.tournamentsService.listParticipants(user.id, id); + return { success: true, data: result }; + } + + @Patch('organized/:id/participants/:userId') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '참가자 실격 또는 복귀' }) + async setParticipant( + @Param('id') id: string, + @Param('userId') userId: string, + @CurrentUser() user: any, + @Body() body: SetParticipantDto, + ) { + const result = await this.tournamentsService.setDisqualified(user.id, id, userId, body.disqualified, body.note); + return { success: true, data: result }; + } + + @Get('organized/:id/export') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '실시간 순위 CSV' }) + async exportLeaderboard(@Param('id') id: string, @CurrentUser() user: any, @Res({ passthrough: true }) res: Response) { + const csv = await this.tournamentsService.exportLeaderboard(user.id, id); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="tournament-${id}.csv"`); + return `\uFEFF${csv}`; + } + @Get(':id') @ApiOperation({ summary: '대회 상세' }) diff --git a/fishing/apps/api/src/tournaments/tournaments.service.ts b/fishing/apps/api/src/tournaments/tournaments.service.ts index 197a84a..e483c39 100644 --- a/fishing/apps/api/src/tournaments/tournaments.service.ts +++ b/fishing/apps/api/src/tournaments/tournaments.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { normalizeDayCode, seoulDateKey } from './day-code.util'; +import { buildLiveLeaderboard, leaderboardCsv } from './live-leaderboard.util'; export type DayCodeClaim = { claimedTournamentId: string | null; @@ -82,15 +83,39 @@ export class TournamentsService { async getRanking(tournamentId: string) { const tournament = await this.prisma.tournament.findUnique({ where: { id: tournamentId } }); - if (!tournament) throw new NotFoundException(); + if (!tournament) throw new NotFoundException('대회를 찾을 수 없습니다.'); const entries = await this.prisma.tournamentEntry.findMany({ - where: { tournamentId, paymentStatus: { in: ['free', 'paid'] } }, + where: { tournamentId }, include: { user: { select: { id: true, nickname: true, profileImage: true } } }, - orderBy: { bestLengthCm: 'desc' }, + }); + if (!entries.length) return []; + + const catches = await this.prisma.catch.findMany({ + where: { + deletedAt: null, + status: 'approved', + recordType: 'certified', + userId: { in: entries.map((entry) => entry.userId) }, + createdAt: { gte: tournament.startAt, lte: tournament.endAt }, + }, + select: { id: true, userId: true, lengthCm: true, createdAt: true, fishSpeciesId: true }, }); - return entries.map((e, i) => ({ ...e, rank: i + 1 })); + return buildLiveLeaderboard( + { + 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, + }, + entries, + catches.map((catch_) => ({ + ...catch_, + lengthCm: Number(catch_.lengthCm), + })), + ); } // 어드민: 대회 생성 @@ -190,6 +215,42 @@ export class TournamentsService { return { claimedTournamentId: tournamentId, claimedDayCode: code, dayCodeStatus: 'pending' }; } + async listParticipants(actorId: string, tournamentId: string) { + await this.assertManagesTournament(actorId, tournamentId); + return this.prisma.tournamentEntry.findMany({ + where: { tournamentId }, + include: { user: { select: { id: true, nickname: true, profileImage: true } } }, + orderBy: { joinedAt: 'asc' }, + }); + } + + async setDisqualified(actorId: string, tournamentId: string, userId: string, disqualified: boolean, note?: string) { + await this.assertManagesTournament(actorId, tournamentId); + const entry = await this.prisma.tournamentEntry.findUnique({ + where: { tournamentId_userId: { tournamentId, userId } }, + }); + if (!entry) throw new NotFoundException('참가자를 찾을 수 없습니다.'); + return this.prisma.tournamentEntry.update({ + where: { id: entry.id }, + data: { disqualified, disqualifyNote: note?.trim() || null }, + }); + } + + async exportLeaderboard(actorId: string, tournamentId: string) { + await this.assertManagesTournament(actorId, tournamentId); + return leaderboardCsv(await this.getRanking(tournamentId)); + } + + private async assertManagesTournament(actorId: string, tournamentId: string) { + const actor = await this.prisma.user.findUnique({ where: { id: actorId } }); + const tournament = await this.prisma.tournament.findUnique({ where: { id: tournamentId } }); + if (!tournament) throw new NotFoundException('대회를 찾을 수 없습니다.'); + const isAdmin = actor?.role === 'admin'; + const isOwner = actor?.role === 'organizer' && tournament.organizerId === actorId; + if (!isAdmin && !isOwner) throw new ForbiddenException('이 대회를 운영할 수 없습니다.'); + return tournament; + } + async listOrganized(userId: string) { await this.assertOrganizer(userId); return this.prisma.tournament.findMany({ diff --git a/fishing/apps/web/src/app/tournament/[id]/page.tsx b/fishing/apps/web/src/app/tournament/[id]/page.tsx index 149450e..210971f 100644 --- a/fishing/apps/web/src/app/tournament/[id]/page.tsx +++ b/fishing/apps/web/src/app/tournament/[id]/page.tsx @@ -128,6 +128,7 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id:

현재 순위

+

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

{rankingData && rankingData.length > 0 ? (
{rankingData.slice(0, 10).map((entry: { diff --git a/fishing/apps/web/src/app/tournament/organize/page.tsx b/fishing/apps/web/src/app/tournament/organize/page.tsx index e7f319f..ccf76d1 100644 --- a/fishing/apps/web/src/app/tournament/organize/page.tsx +++ b/fishing/apps/web/src/app/tournament/organize/page.tsx @@ -27,6 +27,7 @@ export default function OrganizeTournamentPage() { const queryClient = useQueryClient(); const [form, setForm] = useState(EMPTY); const [message, setMessage] = useState(''); + const [openId, setOpenId] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['organized-tournaments'], @@ -65,6 +66,28 @@ export default function OrganizeTournamentPage() { }, }); + const { data: participants } = useQuery({ + queryKey: ['tournament-participants', openId], + queryFn: async () => (await api.get(`/tournaments/organized/${openId}/participants`)).data.data, + enabled: !!openId, + }); + + const disqualifyMutation = useMutation({ + mutationFn: ({ tournamentId, userId, disqualified }: { tournamentId: string; userId: string; disqualified: boolean }) => + api.patch(`/tournaments/organized/${tournamentId}/participants/${userId}`, { disqualified }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tournament-participants', openId] }), + }); + + async function downloadCsv(id: string) { + const res = await api.get(`/tournaments/organized/${id}/export`, { responseType: 'blob' }); + const url = URL.createObjectURL(res.data); + const link = document.createElement('a'); + link.href = url; + link.download = `tournament-${id}.csv`; + link.click(); + URL.revokeObjectURL(url); + } + const set = (key: keyof typeof EMPTY) => (event: ChangeEvent) => { setForm((prev) => ({ ...prev, [key]: event.target.value })); }; @@ -116,14 +139,41 @@ export default function OrganizeTournamentPage() {

아직 없습니다.

) : ( data.map((tournament: { id: string; title: string; region?: string | null; sponsorName?: string | null; status: string }) => ( -

- {tournament.title} - - {' '}· {tournament.status} - {tournament.region ? ` · ${tournament.region}` : ''} - {tournament.sponsorName ? ` · ${tournament.sponsorName}` : ''} - -

+
+

+ {tournament.title} + + {' '}· {tournament.status} + {tournament.region ? ` · ${tournament.region}` : ''} + {tournament.sponsorName ? ` · ${tournament.sponsorName}` : ''} + +

+
+ + +
+ {openId === tournament.id && ( +
    + {(participants ?? []).map((entry: { id: string; userId: string; disqualified: boolean; user: { nickname: string } }) => ( +
  • + {entry.user.nickname} + {entry.disqualified ? ' · 실격' : ''} + +
  • + ))} +
+ )} +
)) )}