Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions fishing/apps/api/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '무료 입문자 대회 — 민물 어종',
Expand All @@ -480,22 +480,34 @@ 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. 참가비 없음. 상은 후원',
},
];

for (const t of tournamentSamples) {
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,
},
});
}
}

Expand Down
63 changes: 63 additions & 0 deletions fishing/apps/api/src/tournaments/join.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
13 changes: 9 additions & 4 deletions fishing/apps/api/src/tournaments/tournaments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand All @@ -69,8 +72,8 @@ export class TournamentsService {
data: {
tournamentId,
userId,
paymentStatus: tournament.isFree ? 'free' : 'pending',
paymentAmount: tournament.entryFee,
paymentStatus: 'free',
paymentAmount: 0,
},
});
}
Expand Down Expand Up @@ -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 } });
}

// 어드민: 대회 상태 변경
Expand All @@ -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 } });
}

// 어드민: 대회 삭제
Expand Down
4 changes: 2 additions & 2 deletions fishing/apps/web/src/app/admin/tournament-entries/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default function AdminTournamentEntriesPage() {
<div className="admin-page">
<header className="admin-page-head">
<h1>대회 참가 관리</h1>
<p>대회별 참가자·결제 상태를 확인합니다.</p>
<p>대회별 참가 상태를 확인합니다. 결제는 받지 않습니다.</p>
<Link href="/admin/tournaments" className="admin-link">← 대회 관리로</Link>
</header>

Expand Down Expand Up @@ -64,7 +64,7 @@ export default function AdminTournamentEntriesPage() {
<th>대회</th>
<th>참가자</th>
<th>이메일</th>
<th>결제</th>
<th>참가</th>
<th>순위</th>
<th>참가일</th>
</tr>
Expand Down
26 changes: 6 additions & 20 deletions fishing/apps/web/src/app/admin/tournaments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -123,7 +123,7 @@ export default function AdminTournamentsPage() {
<strong>{t.title}</strong>
<p className="admin-muted">
{t.category === 'saltwater' ? '바다' : t.category === 'all' ? '전체' : '민물'}
{' · '}{t.isFree ? '무료' : `${t.entryFee.toLocaleString()}원`}
{' · '}{t.isFree ? '무료' : '유료 참가 불가'}
{' · '}참가 {t._count?.entries ?? 0}명
</p>
</div>
Expand Down Expand Up @@ -192,27 +192,13 @@ export default function AdminTournamentsPage() {
</select>
</label>
<label className="admin-field">
<span>참가 유형</span>
<select value={form.isFree.toString()} onChange={(e) => setForm((p) => ({ ...p, isFree: e.target.value === 'true' }))} className="admin-input">
<option value="true">무료</option>
<option value="false">유료</option>
</select>
<span>후원 상 금액 (원, 안내)</span>
<input name="prizeAmount" type="number" value={form.prizeAmount} onChange={handleChange} className="admin-input" />
</label>
</div>
{!form.isFree && (
<div className="admin-form-row">
<label className="admin-field">
<span>참가비 (원)</span>
<input name="entryFee" type="number" value={form.entryFee} onChange={handleChange} className="admin-input" />
</label>
<label className="admin-field">
<span>총 상금 (원)</span>
<input name="prizeAmount" type="number" value={form.prizeAmount} onChange={handleChange} className="admin-input" />
</label>
</div>
)}
<p className="admin-muted">참가비와 결제는 받지 않습니다. 상은 후원 안내만 적습니다.</p>
<label className="admin-field">
<span>상금 설명</span>
<span>후원 상 안내</span>
<input name="prize" value={form.prize} onChange={handleChange} placeholder="1위 30만원 / 2위 15만원" className="admin-input" />
</label>
<div className="admin-form-row">
Expand Down
22 changes: 11 additions & 11 deletions fishing/apps/web/src/app/tournament/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id:
<h1 className="tournament-detail-title">{data.title}</h1>
{data.prizeAmount > 0 && (
<div className="tournament-detail-prize">
총 상금 {data.prizeAmount.toLocaleString()}원
후원 상 {data.prizeAmount.toLocaleString()}원
</div>
)}
<div className="tournament-detail-meta">
Expand All @@ -121,7 +121,7 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id:

{data.prize && (
<div className="tournament-prize-block">
<h3>🏆 상금 구성</h3>
<h3>🏆 후원 상</h3>
<pre className="content-prose-sm" style={{ color: 'rgba(255,255,255,0.85)' }}>{data.prize}</pre>
</div>
)}
Expand Down Expand Up @@ -172,11 +172,11 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id:
<div className="tournament-join-fee-row">
<span>참가비</span>
<span className={`tournament-join-fee${isFree ? ' free' : ''}`}>
{isFree ? '무료' : `${data.entryFee.toLocaleString()}원`}
{isFree ? '무료' : '준비 중'}
</span>
</div>
{!isFree && (
<div className="tournament-escrow-note">🔒 에스크로 결제로 안전하게 보호됩니다</div>
<div className="tournament-escrow-note">유료 참가와 결제는 아직 열리지 않았습니다. 상은 후원으로만 안내합니다.</div>
)}

{myEntry ? (
Expand All @@ -191,16 +191,16 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id:
<button
type="button"
onClick={() => joinMutation.mutate()}
disabled={!isActive || joinMutation.isPending}
disabled={!isActive || !isFree || joinMutation.isPending}
className="site-btn-primary"
>
{joinMutation.isPending
? '처리 중...'
: isActive
? isFree
: !isFree
? '유료 참가는 아직 열리지 않았습니다'
: isActive
? '무료 참가하기'
: `${data.entryFee.toLocaleString()}원 결제 후 참가`
: '참가 불가'}
: '참가 불가'}
</button>
) : (
<Link href="/auth/login" className="site-btn-primary" style={{ display: 'block', textAlign: 'center', textDecoration: 'none' }}>
Expand All @@ -209,9 +209,9 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id:
)}

<p className="tournament-join-note">
* 유료 대회는 에스크로 결제 시스템을 통해 처리됩니다
* 참가비 결제와 에스크로는 아직 없습니다
<br />
* 결제 완료 후 취소/환불 불가
* 무료 대회만 참가할 수 있습니다
</p>
</div>
</aside>
Expand Down
4 changes: 2 additions & 2 deletions fishing/apps/web/src/app/tournament/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export default function TournamentPage() {
<div className="tournament-card-title">{t.title}</div>
{t.prizeAmount > 0 && (
<div className="tournament-card-prize">
상금 {t.prizeAmount.toLocaleString()}원
후원 상 {t.prizeAmount.toLocaleString()}원
</div>
)}
</div>
Expand All @@ -128,7 +128,7 @@ export default function TournamentPage() {
<div className="tournament-card-info">
<span>📅 {formatDate(t.startAt)} ~ {formatDate(t.endAt)}</span>
<span>👥 {t._count?.entries ?? 0}명 참가</span>
<span>{t.isFree ? '무료' : `참가비 ${t.entryFee.toLocaleString()}원`}</span>
<span>{t.isFree ? '무료' : '유료 참가 준비 중'}</span>
</div>
</div>
</div>
Expand Down
Loading