From 1e3b32b1b469bfc15bcf926e7690f351e5f38a12 Mon Sep 17 00:00:00 2001 From: rjsgud49 Date: Wed, 21 Oct 2026 01:46:28 +0000 Subject: [PATCH] =?UTF-8?q?fix(tournaments):=20=EC=9C=A0=EB=A3=8C=20?= =?UTF-8?q?=EC=B0=B8=EA=B0=80=EC=99=80=20=EC=97=86=EB=8A=94=20=EC=97=90?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=9C=20=EC=95=88=EB=82=B4=EB=A5=BC=20?= =?UTF-8?q?=EB=A7=89=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 참가비 결제와 에스크로는 아직 없으므로, 유료 대회 참가와 생성을 거절하고 화면에서는 후원 상만 안내한다. Co-authored-by: park gun hyung --- fishing/apps/api/prisma/seed.ts | 28 ++++++--- .../api/src/tournaments/join.service.spec.ts | 63 +++++++++++++++++++ .../src/tournaments/tournaments.service.ts | 13 ++-- .../src/app/admin/tournament-entries/page.tsx | 4 +- .../web/src/app/admin/tournaments/page.tsx | 26 ++------ .../apps/web/src/app/tournament/[id]/page.tsx | 22 +++---- fishing/apps/web/src/app/tournament/page.tsx | 4 +- 7 files changed, 113 insertions(+), 47 deletions(-) create mode 100644 fishing/apps/api/src/tournaments/join.service.spec.ts diff --git a/fishing/apps/api/prisma/seed.ts b/fishing/apps/api/prisma/seed.ts index 2c2489e..1c8250c 100644 --- a/fishing/apps/api/prisma/seed.ts +++ b/fishing/apps/api/prisma/seed.ts @@ -450,15 +450,15 @@ async function main() { description: '전국 낚시인들의 배스 실력을 겨루는 월간 대회입니다. AI 측정 S등급 기록만 인정됩니다.', category: 'freshwater', fishSpeciesId: 1, - isFree: false, - entryFee: 5000, - prize: '1위 30만원 / 2위 15만원 / 3위 5만원', + isFree: true, + entryFee: 0, + prize: '후원 상품. 1위 30만원 상당 / 2위 15만원 상당 / 3위 5만원 상당', prizeAmount: 500000, maxEntries: 200, startAt: new Date(now.getFullYear(), now.getMonth(), 1), endAt: new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59), status: 'active', - rules: '1. AI 측정 S등급 기록만 인정\n2. 동일 어종 최고 기록 1개만 집계\n3. 참가비는 에스크로 결제 (환불 불가)', + rules: '1. AI 측정 S등급 기록만 인정\n2. 동일 어종 최고 기록 1개만 집계\n3. 참가비 없음. 상은 후원', }, { title: '무료 입문자 대회 — 민물 어종', @@ -480,15 +480,15 @@ async function main() { description: '방어, 참돔, 광어 등 바다 대형 어종 대회. 선상낚시 기록만 인정.', category: 'saltwater', fishSpeciesId: null, - isFree: false, - entryFee: 10000, - prize: '1위 50만원 / 2위 20만원 / 3위 10만원', + isFree: true, + entryFee: 0, + prize: '후원 상품. 1위 50만원 상당 / 2위 20만원 상당 / 3위 10만원 상당', prizeAmount: 800000, maxEntries: 150, startAt: new Date(now.getFullYear(), now.getMonth() + 1, 1), endAt: new Date(now.getFullYear(), now.getMonth() + 2, 31, 23, 59, 59), status: 'upcoming', - rules: '1. 바다 어종만 참가 가능\n2. AI 측정 S등급 기록만 인정\n3. 에스크로 결제 필수', + rules: '1. 바다 어종만 참가 가능\n2. AI 측정 S등급 기록만 인정\n3. 참가비 없음. 상은 후원', }, ]; @@ -496,6 +496,18 @@ async function main() { const existing = await prisma.tournament.findFirst({ where: { title: t.title } }); if (!existing) { await prisma.tournament.create({ data: t as any }); + continue; + } + if ((existing.rules ?? '').includes('에스크로') || existing.entryFee > 0 || !existing.isFree) { + await prisma.tournament.update({ + where: { id: existing.id }, + data: { + isFree: true, + entryFee: 0, + prize: t.prize, + rules: t.rules, + }, + }); } } diff --git a/fishing/apps/api/src/tournaments/join.service.spec.ts b/fishing/apps/api/src/tournaments/join.service.spec.ts new file mode 100644 index 0000000..5abedda --- /dev/null +++ b/fishing/apps/api/src/tournaments/join.service.spec.ts @@ -0,0 +1,63 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { TournamentsService } from './tournaments.service'; + +describe('TournamentsService 무료 참가', () => { + const prisma = { + user: { findUnique: jest.fn() }, + tournament: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() }, + tournamentEntry: { findUnique: jest.fn(), create: jest.fn() }, + }; + const service = new TournamentsService(prisma as never); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('진행 중인 무료 대회에 참가하면 결제 금액은 0이다', async () => { + prisma.tournament.findUnique.mockResolvedValue({ + id: 'tour-1', + status: 'active', + isFree: true, + entryFee: 0, + maxEntries: null, + }); + prisma.tournamentEntry.findUnique.mockResolvedValue(null); + prisma.tournamentEntry.create.mockImplementation(async ({ data }) => data); + + await expect(service.join('tour-1', 'user-1')).resolves.toEqual({ + tournamentId: 'tour-1', + userId: 'user-1', + paymentStatus: 'free', + paymentAmount: 0, + }); + }); + + it('참가비가 있는 대회는 참가를 받지 않는다', async () => { + prisma.tournament.findUnique.mockResolvedValue({ + id: 'tour-1', + status: 'active', + isFree: false, + entryFee: 5000, + }); + + await expect(service.join('tour-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.tournamentEntry.create).not.toHaveBeenCalled(); + }); + + it('관리자도 참가비가 있는 대회를 만들지 못한다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'admin-1', role: 'admin' }); + + await expect( + service.create('admin-1', { title: '유료', isFree: false, entryFee: 5000 }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.tournament.create).not.toHaveBeenCalled(); + }); + + it('일반 회원은 관리자 대회 생성을 하지 못한다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'user' }); + + await expect(service.create('user-1', { title: '무료', isFree: true, entryFee: 0 })).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); +}); diff --git a/fishing/apps/api/src/tournaments/tournaments.service.ts b/fishing/apps/api/src/tournaments/tournaments.service.ts index 4c40a52..c896fca 100644 --- a/fishing/apps/api/src/tournaments/tournaments.service.ts +++ b/fishing/apps/api/src/tournaments/tournaments.service.ts @@ -54,6 +54,9 @@ export class TournamentsService { const tournament = await this.prisma.tournament.findUnique({ where: { id: tournamentId } }); if (!tournament) throw new NotFoundException('대회를 찾을 수 없습니다.'); if (tournament.status !== 'active') throw new BadRequestException('참가 가능한 대회가 아닙니다.'); + if (!tournament.isFree || tournament.entryFee > 0) { + throw new BadRequestException('유료 참가는 아직 열리지 않았습니다.'); + } const existing = await this.prisma.tournamentEntry.findUnique({ where: { tournamentId_userId: { tournamentId, userId } }, @@ -69,8 +72,8 @@ export class TournamentsService { data: { tournamentId, userId, - paymentStatus: tournament.isFree ? 'free' : 'pending', - paymentAmount: tournament.entryFee, + paymentStatus: 'free', + paymentAmount: 0, }, }); } @@ -137,8 +140,9 @@ export class TournamentsService { async create(adminId: string, dto: any) { const admin = await this.prisma.user.findUnique({ where: { id: adminId } }); if (admin?.role !== 'admin') throw new ForbiddenException('관리자만 대회를 생성할 수 있습니다.'); + this.assertFreeEntry(dto); - return this.prisma.tournament.create({ data: dto }); + return this.prisma.tournament.create({ data: { ...dto, isFree: true, entryFee: 0 } }); } // 어드민: 대회 상태 변경 @@ -153,8 +157,9 @@ export class TournamentsService { async update(adminId: string, id: string, dto: any) { const admin = await this.prisma.user.findUnique({ where: { id: adminId } }); if (admin?.role !== 'admin') throw new ForbiddenException(); + this.assertFreeEntry(dto); - return this.prisma.tournament.update({ where: { id }, data: dto }); + return this.prisma.tournament.update({ where: { id }, data: { ...dto, isFree: true, entryFee: 0 } }); } // 어드민: 대회 삭제 diff --git a/fishing/apps/web/src/app/admin/tournament-entries/page.tsx b/fishing/apps/web/src/app/admin/tournament-entries/page.tsx index 5fd8160..15da5b3 100644 --- a/fishing/apps/web/src/app/admin/tournament-entries/page.tsx +++ b/fishing/apps/web/src/app/admin/tournament-entries/page.tsx @@ -28,7 +28,7 @@ export default function AdminTournamentEntriesPage() {

대회 참가 관리

-

대회별 참가자·결제 상태를 확인합니다.

+

대회별 참가 상태를 확인합니다. 결제는 받지 않습니다.

← 대회 관리로
@@ -64,7 +64,7 @@ export default function AdminTournamentEntriesPage() { 대회 참가자 이메일 - 결제 + 참가 순위 참가일 diff --git a/fishing/apps/web/src/app/admin/tournaments/page.tsx b/fishing/apps/web/src/app/admin/tournaments/page.tsx index 3733494..379d18b 100644 --- a/fishing/apps/web/src/app/admin/tournaments/page.tsx +++ b/fishing/apps/web/src/app/admin/tournaments/page.tsx @@ -79,7 +79,7 @@ export default function AdminTournamentsPage() { const buildFormData = () => { const fd = new FormData(); - Object.entries(form).forEach(([key, value]) => fd.append(key, String(value))); + Object.entries({ ...form, isFree: true, entryFee: 0 }).forEach(([key, value]) => fd.append(key, String(value))); if (bannerFile) fd.append('banner', bannerFile); return fd; }; @@ -123,7 +123,7 @@ export default function AdminTournamentsPage() { {t.title}

{t.category === 'saltwater' ? '바다' : t.category === 'all' ? '전체' : '민물'} - {' · '}{t.isFree ? '무료' : `${t.entryFee.toLocaleString()}원`} + {' · '}{t.isFree ? '무료' : '유료 참가 불가'} {' · '}참가 {t._count?.entries ?? 0}명

@@ -192,27 +192,13 @@ export default function AdminTournamentsPage() { - {!form.isFree && ( -
- - -
- )} +

참가비와 결제는 받지 않습니다. 상은 후원 안내만 적습니다.

diff --git a/fishing/apps/web/src/app/tournament/[id]/page.tsx b/fishing/apps/web/src/app/tournament/[id]/page.tsx index b763242..5bd64c6 100644 --- a/fishing/apps/web/src/app/tournament/[id]/page.tsx +++ b/fishing/apps/web/src/app/tournament/[id]/page.tsx @@ -95,7 +95,7 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id:

{data.title}

{data.prizeAmount > 0 && (
- 총 상금 {data.prizeAmount.toLocaleString()}원 + 후원 상 {data.prizeAmount.toLocaleString()}원
)}
@@ -121,7 +121,7 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id: {data.prize && (
-

🏆 상금 구성

+

🏆 후원 상

{data.prize}
)} @@ -172,11 +172,11 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id:
참가비 - {isFree ? '무료' : `${data.entryFee.toLocaleString()}원`} + {isFree ? '무료' : '준비 중'}
{!isFree && ( -
🔒 에스크로 결제로 안전하게 보호됩니다
+
유료 참가와 결제는 아직 열리지 않았습니다. 상은 후원으로만 안내합니다.
)} {myEntry ? ( @@ -191,16 +191,16 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id: ) : ( @@ -209,9 +209,9 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id: )}

- * 유료 대회는 에스크로 결제 시스템을 통해 처리됩니다 + * 참가비 결제와 에스크로는 아직 없습니다
- * 결제 완료 후 취소/환불 불가 + * 무료 대회만 참가할 수 있습니다

diff --git a/fishing/apps/web/src/app/tournament/page.tsx b/fishing/apps/web/src/app/tournament/page.tsx index fa1b41d..3b54d26 100644 --- a/fishing/apps/web/src/app/tournament/page.tsx +++ b/fishing/apps/web/src/app/tournament/page.tsx @@ -115,7 +115,7 @@ export default function TournamentPage() {
{t.title}
{t.prizeAmount > 0 && (
- 상금 {t.prizeAmount.toLocaleString()}원 + 후원 상 {t.prizeAmount.toLocaleString()}원
)}
@@ -128,7 +128,7 @@ export default function TournamentPage() {
📅 {formatDate(t.startAt)} ~ {formatDate(t.endAt)} 👥 {t._count?.entries ?? 0}명 참가 - {t.isFree ? '무료' : `참가비 ${t.entryFee.toLocaleString()}원`} + {t.isFree ? '무료' : '유료 참가 준비 중'}